Skip to content

Latest commit

 

History

266 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Caspian — The Native Python Web Framework for the Reactive Web

Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without a JavaScript backend. You write file-system routes, plain HTML templates, and async def Python — Caspian wires the browser to your server.

  • FastAPI engine — async-native, with the Starlette/FastAPI middleware ecosystem underneath
  • PulsePoint — a shipped browser runtime with a React-style hook API and plain HTML templates (no JSX, no build step required)
  • "Zero-API" RPC — call Python functions from the browser with pp.rpc(); no controllers, no fetch boilerplate
  • File-system routing with nested layouts, dynamic segments, and route groups (Next.js App Router mental model)
  • Python components — reusable @component functions rendered as HTML-first x-* tags
  • Prisma ORM with a generated, typed Python client
  • Session auth with RBAC and OAuth providers, plus fail-closed security defaults
  • Optional: Tailwind CSS, TypeScript tooling, MCP server, WebSockets — each gated by one config flag

The full manual ships inside every project at node_modules/caspian-utils/dist/docs/ (start at index.md). This README is the tour; that folder is the reference.


Quick Start

Requires Python 3.14+ and Node.js with npm/npx (used for the CLI, Prisma, Tailwind, and the dev stack).

npx create-caspian-app@latest

The wizard asks for a project name, feature toggles (backend-only, Tailwind, Prisma, MCP, TypeScript), and a starter kit (basic, fullstack, api, realtime, or a custom Git source). Then:

npm run dev

npm run dev runs a BrowserSync proxy plus asset watchers, not a Vite dev server that owns the page. The proxy does not always land on its default port — check settings/bs-config.json for the active URL.


What "Reactive Python" looks like

A route is a folder with one file: index.py. Markup lives inline in html(r"""..."""), next to the server logic.

# src/app/todos/index.py
from casp.component_decorator import html
from casp.layout import Metadata
from casp.rpc import rpc
from casp.validate import Rule, Validate

from src.lib.prisma import prisma

metadata = Metadata(title="Todos", description="A tiny Caspian todo list.")


async def page():
    return html(r"""
<section>
  <form onsubmit="{addTodo(event)}">
    <input name="title" required />
    <button type="submit" disabled="{isSaving}">Add</button>
  </form>

  <ul>
    <template pp-for="(todo, index) in todos">
      <li key="{todo.id}" class="border-b p-2">
        {index + 1}. {todo.title}
        <button onclick="{removeTodo(todo.id)}">Remove</button>
      </li>
    </template>
  </ul>

  <p hidden="{todos.length > 0}">Nothing here yet.</p>

  <script>
    const [todos, setTodos] = pp.state([]);
    const [isSaving, setIsSaving] = pp.state(false);

    pp.effect(() => {
      pp.rpc("list_todos").then(setTodos);
    }, []);

    async function addTodo(event) {
      event.preventDefault();
      setIsSaving(true);
      try {
        const data = Object.fromEntries(
          new FormData(event.currentTarget).entries(),
        );
        setTodos([await pp.rpc("create_todo", data), ...todos]);
        event.currentTarget.reset();
      } finally {
        setIsSaving(false);
      }
    }

    async function removeTodo(id) {
      await pp.rpc("delete_todo", { id });
      setTodos(todos.filter((todo) => todo.id !== id));
    }
  </script>
</section>
""")


@rpc()
async def list_todos():
    todos = await prisma.todo.find_many(order_by={"id": "desc"})
    return [todo.to_dict() for todo in todos]


@rpc()
async def create_todo(title: str):
    checked = Validate.with_rules(title, [Rule.REQUIRED, Rule.min(3)])
    if checked is not True:
        raise ValueError("Title must be at least 3 characters.")
    todo = await prisma.todo.create(data={"title": title.strip(), "completed": False})
    return todo.to_dict()


@rpc(require_auth=True)
async def delete_todo(id: int):
    await prisma.todo.delete(where={"id": int(id)})
    return {"deleted": True}

That is the whole loop — no API routes, no client, no serializer. pp.rpc("create_todo", data) posts to the current route; Caspian resolves the decorated function, filters the payload against its signature, runs it, and returns JSON.


Core concepts

1. Routing

The directory structure under src/app is the URL structure.

src/app/index.py                   ->  /
src/app/blog/posts/index.py        ->  /blog/posts
src/app/users/[id]/index.py        ->  /users/123        (dynamic segment)
src/app/docs/[...slug]/index.py    ->  /docs/a/b/c       (catch-all)
src/app/(auth)/login/index.py      ->  /login            (route group, no URL segment)
src/app/dashboard/layout.py        ->  wraps every /dashboard/* page

Path params arrive as one positional dict (async def page(params: dict)); query params inject by name; request injects when declared. page() returns html(r"""...""", **context), or a (page_html, layout_props) tuple whose keys become {{ layout.* }} in a parent layout.

Special files. Only index.py is required — the rest are optional, and each owns a behavior you should not hand-build.

File Export Owns
index.py page() The page, plus metadata, route-owned @rpc(), redirects, first-render data
layout.py layout() A subtree shell containing <slot />, plus optional props and metadata
loading.py loading() Loading UI shown while navigating between routes
not_found.py page() Global 404 page
error.py page(error_message, error_trace) Global 500 page

loading.py is worth knowing because it is easy to reinvent: loading() is synchronous and takes no parameters, its URL scope comes from its folder (the closest ancestor wins), and its markup is injected as raw HTML — Jinja {{ }} interpolates, but <x-*> tags, { } bindings, and <script> do nothing inside it. Mark the pane it replaces with pp-loading-content="true" in the layout. Most subtrees have none, and a route with no loader simply fades; add one only when a section wants it, and never replace it with a spinner component or a navigation-event listener.


2. Templates are plain HTML — not JSX

PulsePoint borrows React's hook API and its component decomposition model. It does not borrow JSX. This is the single most common source of broken pages.

<!-- ❌ Silently corrupts the page -->
<div class="{cls}"></div>
{isOpen &&
<div>Panel</div>
} {items.map(item => (
<li>{item.name}</li>
))}
<button className="btn" onClick="{save}">Save</button>

<!-- ✅ The PulsePoint equivalents -->
<div class="{cls}"></div>
<div hidden="{!isOpen}">Panel</div>
<template pp-for="item in items"><li key="{item.id}">{item.name}</li></template>
<button class="btn" onclick="{save()}">Save</button>

An unquoted class={...} is invalid HTML: the parser splits it on spaces into junk attributes, the component root never compiles, and the route serves a blank page with no console error.

Sanity check: delete every {} from the template. What remains must still be valid HTML.

The directive list is closed. There is no pp-if, pp-show, pp-else, pp-model, or pp-key.

Syntax Where Purpose
{expression} Text nodes and quoted attribute values Interpolation
onclick, oninput, onsubmit, any on* Any element Event binding
pp-for="item in items" / "(item, index) in items" <template> only List rendering
key="{expr}" The repeated element Diffing identity
pp-ref="name" / pp-ref="{expr}" Native elements and x-* tags Imperative element access
defaultvalue / defaultchecked Form controls Uncontrolled seed (lowercase)
pp-style="{cssText}" Any element Dynamic inline style (string)
pp-spread="{...obj}" Any element Spread object into attributes
<token.provider value="{v}"> (lowercase) Anywhere Context provider
pp-spa="false" An <a> Opt one link out of SPA navigation
pp-reset-scroll, pp-scroll-key A scroll container Scroll restoration control
pp-loading-content="true" The pane swapped during navigation Where loading.py markup lands

A form control is controlled (value="{state}" + oninput) or uncontrolled (defaultvalue="{expr}") for its lifetime, never both. Never hand-write runtime-managed attributes (pp-component, pp-owner, pp-ref-forward, pp-loading-url, data-pp-*, …) — the pipeline injects them.

Root shape. Default to one top-level element with the owned <script> inside it. Beyond that: a component with sibling top-level nodes becomes a fragment (the <>…</> equivalent) that adds no element to the DOM — but a fragment has no root, so it cannot receive props; give it a single native root when it takes any. A page or layout with sibling top-level nodes gets a layout-neutral display: contents boundary host instead, so skip the meaningless wrapper <div> when the sections really are siblings.


3. PulsePoint hooks

Component <script> blocks are plain JavaScript, evaluated in component scope. Only top-level declarations reach the template. Props are read from pp.props — there is no injected props variable.

  • State & derivationpp.state, pp.reducer, pp.memo, pp.callback, pp.deferredValue, pp.optimistic
  • Lifecyclepp.effect, pp.layoutEffect (cleanups must be synchronous; always pass a dependency array)
  • DOM & identitypp.ref, pp.id (for id/for/aria-* — never index-derived ids), pp.portal, pp.imperativeHandle
  • Cross-treepp.createContext + a lowercase <themecontext.provider value="{theme}"> tag + pp.context(token) in descendants
  • Resilience & external datapp.errorBoundary ([error, reset]), pp.syncExternalStore (subscribe must be pp.callback(..., [])-stable), pp.transition

Utilities: pp.mount, pp.redirect, pp.rpc, pp.socket, and pp.enablePerf / disablePerf / getPerfStats / resetPerfStats.

React APIs with no equivalent: forwardRef, memo() as a wrapper, lazy, Suspense, useInsertionEffect, useActionState, useFormStatus, free-function startTransition. pp.transition() reports an accurate isPending but does not time-slice — rendering is synchronous.

Inside an on* attribute the runtime injects event plus the aliases e, $event, target, currentTarget, and el.

Performance ownership: pp.state means "a render is required". Timers, request generations, cursors, and RPC-only query text belong in pp.ref, whose mutation never renders. Debouncing a setter limits frequency, not render cost.


4. Components

A component is a @component function whose markup is authored inline and rendered as a kebab-cased x-* tag.

from casp.component_decorator import component, html
from casp.html_attrs import get_attributes, merge_classes


@component
def UserCard(user=None, **props):
    attributes = get_attributes({
        "class": merge_classes("card", props.pop("class", "")),
        "user-name": user["name"],
    }, props)

    return html(r"""
<div {{ attributes }}>
  <h3>{{ user.name }}</h3>
  <button onclick="{setLikes(likes + 1)}">Likes: {likes}</button>
  <script>
    const [likes, setLikes] = pp.state({{ user.likes | json }});
  </script>
</div>
""", attributes=attributes, user=user)

html(r"""...""") is the one markup form — always a raw triple-quoted literal. A non-raw string rewrites backslashes, so a regex or \n in the component script means one thing in the source and another at render. Never build markup as an f-string: it inverts the brace dialects ({x} becomes server interpolation), skips autoescaping while still marking the output trusted, and skips the <x-*> scope stash.

Three brace dialects coexist inside that literal: {{ value }} is server-side Jinja (autoescaped), {{ value | json }} safely serializes a server value into a <script>, {# … #} is a Jinja comment, and { value } is left untouched for PulsePoint in the browser.

Composition is Python-import-driven — the import is the registration:

from src.components.Container import Container
from src.components.ui.Button import Button
from src.lib.ppicons import ArrowRight, Search   # -> <x-arrow-right />, <x-search />

Container<x-container />, CommandDialog<x-command-dialog />. If one file exports several components, import them from that file. Slot content resolves in the scope where it was authored, so the module writing an x-* tag must import it.

Every prop the template reads must be re-emitted on the root. This is the most common silent failure:

  1. Attributes on the x-* tag reach Python as raw string kwargs, kebab-case converted to camelCase — open="{permOpen}" arrives as the literal string "{permOpen}".
  2. The component must deliberately re-emit them onto its single rendered root via get_attributes({...}, props) + {{ attributes }}.
  3. PulsePoint derives pp.props from the rendered root's attributes, evaluating brace expressions in the parent's scope.

A prop accepted in Python but not re-emitted is silently undefined — no server error, no console warning. Forwarding does not preserve types either: a brace expression keeps its real type, a literal (volume="0") arrives as a string, a valueless attribute arrives as true, None/False/"" are omitted entirely, and JS reserved words such as class are dropped from pp.props.

When tailwindcss: true, merge_classes(...) emits a frontend-ready {twMerge(...)} expression that the browser's twMerge(...) resolves. Pass it straight through — never wrap or re-merge it.


5. Data: pp.rpc() and @rpc()

await pp.rpc(name, data?, optionsOrAbort?)

Posts to the current route and resolves the @rpc() function of that name in its index.py. Serialization switches to FormData automatically when a File is present, the CSRF token is injected as X-CSRF-Token, and server redirects are honored through pp.redirect(). Passing true as the third argument means { abortPrevious: true }.

Options: abortPrevious, url, csrfUrl, credentials, onStream, onStreamError, onStreamComplete, onUploadProgress, onUploadComplete.

Payload safety: RPC keys are filtered against the function signature, so a parameter is client-settable only when declared. Declaring **kwargs opts into the whole payload — do that deliberately, and always derive identity and ownership server-side from the session.

Uploads — pass the File through and read progress:

await pp.rpc(
  "upload_asset",
  { file },
  {
    onUploadProgress: ({ percent }) => setPercent(percent),
    onUploadComplete: () => setPercent(100),
  },
);

onUploadProgress receives { loaded, total, percent }; total and percent are null when the length is not computable.

Streaming (the path for AI/LLM token output) — a generator @rpc() becomes a text/event-stream response:

@rpc()
async def ask_question(topic: str):
    async for chunk in llm.stream(topic):
        yield chunk
pp.rpc(
  "ask_question",
  { topic },
  {
    onStream: (chunk) => setAnswer((current) => current + chunk),
    onStreamComplete: () => setIsStreaming(false),
  },
);

Do not reinvent one-way streaming with raw fetch/ReadableStream, EventSource, or a WebSocket.


6. Validation

from casp.validate import Rule, Validate

email = Validate.email("  User@Example.com ")     # -> "User@Example.com"
count = Validate.int("42")                        # -> 42
bad   = Validate.url("not-a-url")                 # -> None

checked = Validate.with_rules(password, [Rule.REQUIRED, Rule.min(8)])
if checked is not True:
    return {"error": checked}

Validate covers strings and identifiers (string, email, url, ip, uuid, ulid, cuid, cuid2, nanoid), numbers (int, big_int, float, decimal), dates (date, date_time), boolean, and structured values (json, enum, enum_class). Validate.string() trims and HTML-escapes by default.

Browser-side checks are UX only. Server-side validation at the RPC/route boundary is authoritative.


7. Authentication

Session-based, configured centrally in src/lib/auth/auth_config.py and wired in main.py:

from casp.auth import Auth, GithubProvider, GoogleProvider, configure_auth
from src.lib.auth.auth_config import build_auth_settings

configure_auth(build_auth_settings())
Auth.set_providers(GithubProvider(), GoogleProvider())

The auth instance exposes sign_in(data, token_validity=None, redirect_to=False), sign_out(redirect_to=None), is_authenticated(), get_payload(), refresh_session(), and check_role(user, allowed_roles). Guards are @rpc(require_auth=True) for actions and @require_auth() / @guest_only() for pages, with public-vs-private route policy declared centrally.

OAuth is already wired. Auth.set_providers(...) registers /api/auth/signin/{google,github} and /api/auth/callback/{google,github}. Link a button and set the credentials in .env — do not hand-roll the flow.

Redirect ownership is centralized. Do not re-implement next= handling or post-login routing in a sign-in page; auth_config.py owns protected-route redirects, auth-route redirects, and default_signin_redirect.


8. Database (Prisma)

Enabled by "prisma": true. Define one prisma/schema.prisma; the typed Python client is generated into src/lib/prisma/.

from src.lib.prisma import prisma

users = await prisma.user.find_many(
    where={"active": True},
    include={"userRole": True},
    order_by={"createdAt": "desc"},
)

Two generators, one schema. After any schema change, run exactly two commands in order — sync the database, then regenerate the Python ORM:

npx prisma migrate dev
npx ppy generate

npx prisma generate builds the Node client used by prisma/seed.ts and writes zero Python — it is never a substitute for npx ppy generate. Run it before npx prisma db seed, and note that db seed may clear or overwrite tables; confirm the datasource first.

src/lib/prisma/** and settings/prisma-schema.json are generated. Never hand-edit them, and never add a second data layer alongside the ORM.


9. Optional features

Every optional capability is gated by one flag in caspian.config.json. That file is the single source of truth — a doc or example mentioning a feature does not mean it is enabled in your project. To turn one on after scaffold, set the flag and run npx casp update project.

Flag Enables
backendOnly API/service mode with no frontend assets
tailwindcss Tailwind v4 + PostCSS pipeline, merge_classes / twMerge contract
typescript TypeScript frontend tooling and the Vite build path
prisma Prisma schema, migrations, and the generated Python ORM
mcp A FastMCP server mounted into the same app (/mcp)
websocket Named sockets — @socket() in Python, pp.socket() in the browser

Named sockets

Use RPC for ordinary reads, writes, uploads, and one-way streams. Reach for a socket only when both sides may speak at any time: chat, collaboration, presence, multiplayer state. A named socket is the socket counterpart of @rpc()/pp.rpc() — one decorated Python function, one browser call.

from src.lib.websocket.sockets import Socket, socket


@socket()
async def echo(label: str, socket: Socket):
    while (text := await socket.recv()) is not None:
        if not await socket.send(f"{label}: {text}"):
            break  # The browser is gone.
const sock = pp.ref(null);

pp.effect(() => {
  sock.current = pp.socket(
    "echo",
    { label: "you" },
    {
      onMessage: (value) => append(value),
      onError: (error) => setStatus(error.message),
    },
  );
  return () => sock.current.close();
}, []);

Open the socket inside pp.effect(..., []), keep the handle in pp.ref(...), close it in the cleanup. The handle exposes send(value), close(code?, reason?), and readyState; handlers are onOpen, onMessage(value), onError(error), onClose({ code, reason, wasClean }).

The wire, in short:

  • Every socket connects to one endpoint, wired once in main.py. The function is named in a query parameter, so socket names are application-wide and a duplicate is refused at registration.
  • Arguments travel as the connection's first frame — one JSON object, exactly the payload pp.rpc would have posted — not in the URL, which every proxy logs. Keys are filtered against the handler signature, like RPC.
  • There is no status line inside an open connection, so failure is a frame: {"error": "..."}, then a close — routed to onError, never onMessage. A handler that returns ends the conversation, and await socket.send(...) returning False means the browser is gone: a signal to stop, not an error to report.
  • Broadcast: socket.sender() returns a detached SocketSender safe to hold in shared state; a room is a SocketPool of senders that prunes departed connections as it broadcasts. Keep authenticated and guest traffic in separate pools.
  • Auth is declared per socket: @socket() is public, @socket(require_auth=True) needs a session, @socket(allowed_roles=[...]) adds RBAC.

A socket in a route's index.py registers when that route first renders; one shared by several routes belongs in src/lib/**. A hand-written @app.websocket(...) route stays the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols) — and then the app owns every security check itself.

MCP

When mcp: true, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint sits outside the routing tree, so AuthMiddleware does not cover it — MCP_AUTH_TOKEN is its credential. With no token it stays open in development and returns 503 in production.


Security defaults

Caspian ships fail-closed. Worth knowing before changing any of it:

  • APP_ENV resolves fail-closed. Only an explicit development value (dev, development, local, staging, test, testing) enables relaxations. Unset or misspelled counts as production.
  • Server-interpolated values never carry live PulsePoint syntax. Jinja encodes { / } as entities on every non-Markup value, so stored user data cannot execute as a template expression. Markup is the trust boundary — | safe, get_attributes(...), merge_classes(...), and the json filter legitimately keep their braces.
  • Authenticated renders are never cached. The page cache keys on the URI alone, so an eligibility check gates both read and write; a route's Cache(...) cannot override it.
  • RPC payload keys are filtered against the function signature.
  • /uploads serves user content in attachment mode; only real image types render inline. First-party /css, /js, and /assets stay inline.
  • Sockets authorize themselves. The HTTP middleware stack early-returns on scope["type"] == "websocket", so AuthMiddleware never sees a handshake — HTTP route privacy does not extend to a socket.
  • CSRF protection, strict Origin validation, HttpOnly cookies, security headers, and page rate limiting are on by default.

Security-relevant environment variables: MCP_AUTH_TOKEN, RATE_LIMIT_PAGES (default 200/minute), CONTENT_SECURITY_POLICY (replaces the default policy wholesale), MAX_WEBSOCKET_CONNECTIONS, MAX_WEBSOCKET_MESSAGE_BYTES, MAX_WEBSOCKET_MESSAGES_PER_WINDOW, WEBSOCKET_RATE_WINDOW_SECONDS, WEBSOCKET_IDLE_TIMEOUT_SECONDS, and WEBSOCKET_ALLOWED_ORIGINS (required in production — the same-origin fallback is derived from the client-supplied Host header and is development-only).


Project structure

my-app/
├── main.py                      # FastAPI entry point, middleware stack, socket + MCP mounts
├── caspian.config.json          # Feature flags — the single source of truth
├── prisma/schema.prisma
├── src/
│   ├── app/                     # File-system routes
│   │   ├── layout.py            # Root layout
│   │   ├── index.py             # Home page (markup + logic in one file)
│   │   ├── globals.css
│   │   ├── not_found.py         # Global 404
│   │   ├── error.py             # Global 500
│   │   ├── dashboard/loading.py # Optional: /dashboard navigation loading UI
│   │   └── users/[id]/index.py  # /users/:id
│   ├── components/              # Reusable UI (@component)
│   └── lib/                     # Non-UI code
│       ├── auth/auth_config.py
│       ├── prisma/              # Generated Python ORM — do not edit
│       ├── websocket/           # Named sockets: @socket(), Socket, SocketPool
│       └── mcp/                 # FastMCP server (when mcp: true)
├── public/                      # Static assets, incl. the PulsePoint runtime and uploads
└── settings/                    # Dev stack config and generated indexes

Placement rules

  • Route-owned logic (first-render query, route @rpc() actions, redirects, validation) stays in that route's index.py. Move it to src/lib/** only when genuinely shared.
  • Reusable UI goes in src/components/; helpers, services, adapters go in src/lib/.
  • Compose pages from components. A route's template should read as a short assembly of x-* chunks (topbar, sidebar, sections, forms, footer), not a wall of markup. Plan the breakdown before writing the route.
  • Generated, never hand-edited: src/lib/prisma/**, settings/prisma-schema.json, settings/files-list.json, settings/component-map.json, public/css/styles.css.

CLI reference

npx create-caspian-app my-app
Flag Description
-y Non-interactive; skip all prompts
--backend-only API/service project, no frontend assets
--tailwindcss Enable Tailwind CSS
--typescript Enable TypeScript frontend tooling
--prisma Enable Prisma ORM
--mcp Enable MCP server scaffolding
--starter-kit=<kit> basic, fullstack, api, realtime, custom
--starter-kit-source=<url> Git repository for --starter-kit=custom
--list-starter-kits Print the built-in starter catalog

In -y mode every feature defaults to false, and starter-kit presets can still be overridden by explicit flags. websocket has no create flag — enable it in caspian.config.json after scaffold, then run npx casp update project (which also accepts --tag beta / --version 1.2.3 and -y). Use excludeFiles in caspian.config.json to protect files you have customized (commonly ./src/lib/auth/auth_config.py) from being overwritten on update.

Project scripts

Command What it does
npm run dev Full local stack: BrowserSync proxy, Tailwind watch, asset watch
npm run build Build Tailwind and regenerate the route/component index
npm run static Export every static route to static/ (SSG)
npm run static:serve Preview the exported folder on an auto-selected free loopback port

These are opt-in workflows — don't run them as a validation step just because source files changed.


Static export (SSG)

npm run static boots the app and writes static/<route>/index.html plus copied public assets — the equivalent of Next.js output: export, running npm run build first so the export walks a fresh route index. Policy is warn & skip: dynamic routes are pre-rendered only when their index.py exports static_paths (the getStaticPaths equivalent); auth-gated, non-200, and non-HTML routes are reported and skipped.

npm run static:serve serves only static/, binds loopback 127.0.0.1 (network exposure is opt-in via HOST=0.0.0.0), and walks upward from port 8000 until it finds a free one — read the port it prints. pp.rpc(), auth, WebSockets, streaming, and per-request server data are all inert in a static export.


Ecosystem

Two CLIs install ready-made Python components into src/lib/, where they behave like any other x-* tag:

npx ppicons add Rocket          # 1,500+ Lucide-based icons
npx maddex add button card dialog   # shadcn-style UI kit
from src.lib.ppicons import Rocket        # -> <x-rocket class="size-6" />
from src.lib.maddex.Button import Button  # -> <x-button variant="outline">Continue</x-button>

Recommended VS Code setup


Learn more

The full documentation ships inside every Caspian project at node_modules/caspian-utils/dist/docs/ — start with index.md, which routes you to the right feature guide.


License

MIT

About

Caspian — The Native Python Web Framework for the Reactive Web

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors