From 097ecbb68a91daf0b0a58ea1c861313aace94758 Mon Sep 17 00:00:00 2001 From: Tan Nguyen Date: Tue, 4 Aug 2026 22:54:52 +0700 Subject: [PATCH 1/7] feat(deno-adapter): deno adapter and deno desktop --- .github/workflows/e2e-tests.yml | 15 + .github/workflows/publish.yml | 6 + .gitignore | 5 +- AGENTS.md | 2 +- docs/database.md | 11 +- docs/queries.md | 2 +- examples/deno-desktop-counter/README.md | 22 + examples/deno-desktop-counter/deno.json | 27 + examples/deno-desktop-counter/deno.lock | 632 ++++++++++++++++++ examples/deno-desktop-counter/package.json | 28 + examples/deno-desktop-counter/src/App.ts | 7 + .../src/bootstrap/middlewares.ts | 3 + .../src/client/entry-client.ts | 5 + .../src/desktop/bindings.ts | 14 + examples/deno-desktop-counter/src/index.ts | 17 + .../deno-desktop-counter/src/pages/index.ts | 44 ++ examples/deno-desktop-counter/src/root.ts | 3 + examples/deno-desktop-counter/src/style.css | 4 + examples/deno-desktop-counter/tsconfig.json | 11 + examples/deno-desktop-counter/vite.config.ts | 40 ++ package.json | 4 +- .../core/src/shared/CossackDurableObject.ts | 17 +- packages/core/src/shared/component-types.ts | 2 + packages/core/src/shared/runtime.ts | 109 +++ .../core/src/shared/transport-connections.ts | 5 +- packages/core/tests/cossack.client.test.ts | 2 +- .../tests/in-memory-websocket-runtime.test.ts | 52 ++ packages/core/tests/websocket-url.test.ts | 29 + packages/cossack/src/commands/adapter.js | 6 +- packages/cossack/src/commands/add.js | 6 +- packages/cossack/src/commands/create.js | 10 +- packages/cossack/src/dispatch.js | 2 +- packages/database/README.md | 10 +- packages/database/docs/comparison.md | 2 +- packages/database/docs/installation.md | 9 +- packages/database/docs/raw-queries.md | 2 +- packages/database/docs/runtimes.md | 14 +- packages/database/package.json | 8 +- packages/database/scripts/audit-bundles.mjs | 9 +- packages/database/src/runtime/cloudflare.ts | 59 +- packages/database/src/runtime/deno.ts | 32 + packages/database/src/runtime/node.ts | 38 +- packages/database/src/runtime/turso.ts | 87 +++ packages/database/src/sql/client.ts | 6 +- packages/database/test/turso.test.ts | 24 + packages/deno-adapter/package.json | 34 + packages/deno-adapter/src/desktop-client.ts | 60 ++ packages/deno-adapter/src/desktop.ts | 63 ++ packages/deno-adapter/src/index.ts | 174 +++++ packages/deno-adapter/tests/adapter.test.ts | 50 ++ .../deno-adapter/tests/deno-smoke.test.ts | 51 ++ packages/deno-adapter/tests/desktop.test.ts | 60 ++ .../deno-adapter/tsconfig.declarations.json | 14 + packages/deno-adapter/tsconfig.json | 9 + packages/deno-adapter/vite.config.ts | 19 + packages/deno-adapter/vitest.config.ts | 9 + packages/framework/package.json | 4 + packages/framework/src/public.ts | 1 + packages/framework/src/router.ts | 75 ++- packages/framework/src/runtime-adapter.ts | 36 + packages/framework/src/runtime-websocket.ts | 38 ++ .../framework/tests/runtime-adapter.test.ts | 22 + .../framework/tests/runtime-websocket.test.ts | 26 + packages/node-adapter/src/index.ts | 46 +- packages/node-adapter/src/runtime.ts | 81 +-- packages/scaffold/package.json | 3 +- packages/scaffold/src/index.d.ts | 10 +- packages/scaffold/src/index.js | 255 ++++++- packages/scaffold/src/registry.js | 32 +- packages/scaffold/template/vite.config.ts | 2 +- packages/scaffold/tests/adapter.test.js | 32 +- packages/scaffold/tests/scaffold.test.js | 51 +- packages/studio/README.md | 2 +- .../app/src/components/studio/PragmasTab.ts | 2 +- packages/studio/app/src/pages/index.ts | 4 +- packages/studio/e2e/studio.spec.ts | 4 +- packages/studio/package.json | 3 +- packages/studio/src/index.ts | 4 +- packages/studio/src/lib/provider.ts | 7 +- packages/studio/src/lib/schema-types.ts | 2 +- packages/studio/src/lib/schema.ts | 2 +- packages/studio/src/lib/service.ts | 6 +- packages/studio/tests/database.test.ts | 8 +- packages/studio/tests/dialects.test.ts | 4 +- pnpm-lock.yaml | 400 +++++------ scripts/test-local-scaffold.mjs | 32 + .../references/database.md | 2 +- tsconfig.json | 1 + 88 files changed, 2679 insertions(+), 503 deletions(-) create mode 100644 examples/deno-desktop-counter/README.md create mode 100644 examples/deno-desktop-counter/deno.json create mode 100644 examples/deno-desktop-counter/deno.lock create mode 100644 examples/deno-desktop-counter/package.json create mode 100644 examples/deno-desktop-counter/src/App.ts create mode 100644 examples/deno-desktop-counter/src/bootstrap/middlewares.ts create mode 100644 examples/deno-desktop-counter/src/client/entry-client.ts create mode 100644 examples/deno-desktop-counter/src/desktop/bindings.ts create mode 100644 examples/deno-desktop-counter/src/index.ts create mode 100644 examples/deno-desktop-counter/src/pages/index.ts create mode 100644 examples/deno-desktop-counter/src/root.ts create mode 100644 examples/deno-desktop-counter/src/style.css create mode 100644 examples/deno-desktop-counter/tsconfig.json create mode 100644 examples/deno-desktop-counter/vite.config.ts create mode 100644 packages/core/tests/in-memory-websocket-runtime.test.ts create mode 100644 packages/core/tests/websocket-url.test.ts create mode 100644 packages/database/src/runtime/turso.ts create mode 100644 packages/database/test/turso.test.ts create mode 100644 packages/deno-adapter/package.json create mode 100644 packages/deno-adapter/src/desktop-client.ts create mode 100644 packages/deno-adapter/src/desktop.ts create mode 100644 packages/deno-adapter/src/index.ts create mode 100644 packages/deno-adapter/tests/adapter.test.ts create mode 100644 packages/deno-adapter/tests/deno-smoke.test.ts create mode 100644 packages/deno-adapter/tests/desktop.test.ts create mode 100644 packages/deno-adapter/tsconfig.declarations.json create mode 100644 packages/deno-adapter/tsconfig.json create mode 100644 packages/deno-adapter/vite.config.ts create mode 100644 packages/deno-adapter/vitest.config.ts create mode 100644 packages/framework/src/runtime-adapter.ts create mode 100644 packages/framework/src/runtime-websocket.ts create mode 100644 packages/framework/tests/runtime-adapter.test.ts create mode 100644 packages/framework/tests/runtime-websocket.test.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4f017691..ebd4b9e1 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -23,12 +23,27 @@ jobs: node-version: '22' cache: 'pnpm' + - name: Setup Deno 2.9 + uses: denoland/setup-deno@v2 + with: + deno-version: v2.9.x + - name: Install dependencies run: pnpm install - name: Build workspace libraries run: pnpm run build + - name: Check and build Deno adapter example + run: | + deno check packages/deno-adapter/src/index.ts + deno test -A packages/deno-adapter/tests/deno-smoke.test.ts + pnpm --filter @cossackframework/example-deno-desktop-counter build + + - name: Package Deno Desktop example for Linux + working-directory: examples/deno-desktop-counter + run: deno task desktop:build + - name: Test generated app with local package tarballs run: pnpm run test:scaffold:local diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8cd47229..2df10d40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,6 +37,7 @@ jobs: run: | pnpm --filter @cossackframework/core test -- --run pnpm --filter @cossackframework/renderer test -- --run + pnpm --filter @cossackframework/deno-adapter test pnpm --filter @cossackframework/ui test -- --run pnpm --filter @cossackframework/database test pnpm --filter @cossackframework/database audit:bundle @@ -79,6 +80,11 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish @cossackframework/deno-adapter + run: pnpm publish --filter @cossackframework/deno-adapter --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish @cossackframework/database run: pnpm publish --filter @cossackframework/database --access public --no-git-checks env: diff --git a/.gitignore b/.gitignore index 42807e38..f7a87f58 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ packages/*/pnpm-lock.yaml **/dist **/build +# Packaged Deno Desktop example (executable, runtime library, and markers) +/examples/deno-desktop-counter/Cossack Counter/ + # TypeScript incremental build cache **/*.tsbuildinfo @@ -47,4 +50,4 @@ reasonix.toml plan-*.md reviews.md request-update.md -todo.md \ No newline at end of file +todo.md diff --git a/AGENTS.md b/AGENTS.md index bb81f73e..c5682699 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ The project is a `pnpm` workspace. All packages are located in the `packages` di ## 4. Development Workflow -1. **Build Dependencies**: Build `core`, `renderer`, `node-adapter`, `database`, and `auth` first. +1. **Build Dependencies**: Build packages first `pnpm build`. 2. **Run Application**: Use `pnpm run dev`. ## Security: Code Stripping diff --git a/docs/database.md b/docs/database.md index 3ee84d97..ca1e49c5 100644 --- a/docs/database.md +++ b/docs/database.md @@ -29,9 +29,9 @@ src/ The model barrel imports `reflect-metadata` once and exports deterministic entity registration. Node recipes create one caller-owned ORM singleton. -Workers recipes create an ORM per request from D1, libSQL/Turso, or Hyperdrive -bindings; `ormMiddleware` closes factory-created instances after downstream -work completes. +Deno and Workers recipes create an ORM per request from Turso, D1, or +Hyperdrive bindings; `ormMiddleware` closes factory-created instances after +downstream work completes. ## Models @@ -78,8 +78,9 @@ context through application services. | Runtime | Providers | |---|---| -| Node | SQLite (`node:sqlite`), libSQL/Turso, PostgreSQL, MySQL | -| Workers | D1, Workers-safe libSQL/Turso, Hyperdrive PostgreSQL, Hyperdrive MySQL | +| Node | SQLite (`node:sqlite`), Turso, PostgreSQL, MySQL | +| Deno | Turso Database (embedded SQLite), remote Turso, PostgreSQL, MySQL | +| Workers | D1, remote Turso, Hyperdrive PostgreSQL, Hyperdrive MySQL | Hyperdrive recipes enable `nodejs_compat` and install only the selected PostgreSQL or MySQL driver. Other Workers recipes use `nodejs_als`. diff --git a/docs/queries.md b/docs/queries.md index 36ac61c2..901a4c9e 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -78,7 +78,7 @@ Studio still binds parameters and uses safe row locators for edits. ## Transactions -Node SQLite, libSQL, PostgreSQL, and MySQL support interactive transactions: +Node SQLite, PostgreSQL, and MySQL support interactive transactions: ```ts await sql.transaction(async () => { diff --git a/examples/deno-desktop-counter/README.md b/examples/deno-desktop-counter/README.md new file mode 100644 index 00000000..511744c7 --- /dev/null +++ b/examples/deno-desktop-counter/README.md @@ -0,0 +1,22 @@ +# Deno desktop counter + +The same Cossack page runs in a normal browser and in a Deno Desktop window. +The browser counter is in-memory. Desktop calls the typed, allowlisted Deno +bindings and persists the value in Deno-side `localStorage`. + +Requires Deno 2.9 or newer. + +```sh +pnpm install +deno task dev # browser development +deno task build +deno task start # production Deno HTTP server +deno task desktop:dev # desktop HMR +deno task desktop:build # web/SSR production build, then host package +``` + +To verify persistence, increment the counter in the desktop window, close the +application, relaunch the packaged app, and confirm that the previous count is +restored. Change `desktop.backend` in `deno.json` from `webview` to `cef` when +you need a bundled Chromium engine. The `raw` backend is intentionally not +supported because Cossack renders HTML. diff --git a/examples/deno-desktop-counter/deno.json b/examples/deno-desktop-counter/deno.json new file mode 100644 index 00000000..932ef845 --- /dev/null +++ b/examples/deno-desktop-counter/deno.json @@ -0,0 +1,27 @@ +{ + "nodeModulesDir": "auto", + "imports": { + "@cossackframework/core": "../../packages/core/dist/index.js", + "@cossackframework/deno-adapter": "../../packages/deno-adapter/dist/index.js", + "@cossackframework/deno-adapter/desktop": "../../packages/deno-adapter/dist/desktop.js", + "@cossackframework/deno-adapter/desktop/client": "../../packages/deno-adapter/dist/desktop-client.js", + "@cossackframework/framework/router": "../../packages/framework/dist/esm/router.js", + "@cossackframework/framework/vite-plugin": "../../packages/framework/dist/esm/vite-plugin.js", + "@cossackframework/framework/vite-security-plugin": "../../packages/framework/dist/esm/vite-security-plugin.js", + "@cossackframework/renderer": "../../packages/renderer/dist/index.js", + "@cossackframework/ui": "../../packages/ui/dist/index.js", + "hono": "npm:hono@^4.12.31", + "vite": "npm:vite@^8.1.4" + }, + "tasks": { + "dev": "vite dev", + "build": "vite build && vite build --ssr src/index.ts --outDir dist/server", + "start": "deno run --allow-env --allow-net --allow-read dist/server/index.js", + "desktop:dev": "deno desktop --hmr .", + "desktop:build": "deno task build && deno desktop ." + }, + "desktop": { + "app": { "name": "Cossack Counter", "identifier": "dev.cossack.counter" }, + "backend": "webview" + } +} diff --git a/examples/deno-desktop-counter/deno.lock b/examples/deno-desktop-counter/deno.lock new file mode 100644 index 00000000..faa54375 --- /dev/null +++ b/examples/deno-desktop-counter/deno.lock @@ -0,0 +1,632 @@ +{ + "version": "5", + "specifiers": { + "npm:@tailwindcss/vite@^4.1.0": "4.3.3_vite@8.2.0__@types+node@22.20.1_@types+node@22.20.1", + "npm:@types/deno@^2.3.0": "2.7.0", + "npm:@types/node@^22.19.0": "22.20.1", + "npm:hono@^4.12.31": "4.12.34", + "npm:tailwindcss@^4.1.0": "4.3.3", + "npm:typescript@^7.0.2": "7.0.2", + "npm:vite@^8.1.4": "8.2.0_@types+node@22.20.1" + }, + "npm": { + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@oxc-project/types@0.142.0": { + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==" + }, + "@rolldown/binding-android-arm64@1.2.2": { + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-arm64@1.2.2": { + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-x64@1.2.2": { + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rolldown/binding-freebsd-x64@1.2.2": { + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-arm-gnueabihf@1.2.2": { + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rolldown/binding-linux-arm64-gnu@1.2.2": { + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-arm64-musl@1.2.2": { + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-ppc64-gnu@1.2.2": { + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rolldown/binding-linux-s390x-gnu@1.2.2": { + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rolldown/binding-linux-x64-gnu@1.2.2": { + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-x64-musl@1.2.2": { + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-openharmony-arm64@1.2.2": { + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rolldown/binding-win32-arm64-msvc@1.2.2": { + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rolldown/binding-win32-x64-msvc@1.2.2": { + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rolldown/pluginutils@1.0.1": { + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + "@tailwindcss/node@4.3.3": { + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dependencies": [ + "@jridgewell/remapping", + "enhanced-resolve", + "jiti", + "lightningcss@1.32.0", + "magic-string", + "source-map-js", + "tailwindcss" + ] + }, + "@tailwindcss/oxide-android-arm64@4.3.3": { + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-arm64@4.3.3": { + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-x64@4.3.3": { + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-freebsd-x64@4.3.3": { + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3": { + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@tailwindcss/oxide-linux-arm64-gnu@4.3.3": { + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-arm64-musl@4.3.3": { + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-x64-gnu@4.3.3": { + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-x64-musl@4.3.3": { + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-wasm32-wasi@4.3.3": { + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "cpu": ["wasm32"] + }, + "@tailwindcss/oxide-win32-arm64-msvc@4.3.3": { + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-win32-x64-msvc@4.3.3": { + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide@4.3.3": { + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "optionalDependencies": [ + "@tailwindcss/oxide-android-arm64", + "@tailwindcss/oxide-darwin-arm64", + "@tailwindcss/oxide-darwin-x64", + "@tailwindcss/oxide-freebsd-x64", + "@tailwindcss/oxide-linux-arm-gnueabihf", + "@tailwindcss/oxide-linux-arm64-gnu", + "@tailwindcss/oxide-linux-arm64-musl", + "@tailwindcss/oxide-linux-x64-gnu", + "@tailwindcss/oxide-linux-x64-musl", + "@tailwindcss/oxide-wasm32-wasi", + "@tailwindcss/oxide-win32-arm64-msvc", + "@tailwindcss/oxide-win32-x64-msvc" + ] + }, + "@tailwindcss/vite@4.3.3_vite@8.2.0__@types+node@22.20.1_@types+node@22.20.1": { + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dependencies": [ + "@tailwindcss/node", + "@tailwindcss/oxide", + "tailwindcss", + "vite" + ] + }, + "@types/deno@2.7.0": { + "integrity": "sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==" + }, + "@types/node@22.20.1": { + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dependencies": [ + "undici-types" + ] + }, + "@typescript/typescript-aix-ppc64@7.0.2": { + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@typescript/typescript-darwin-arm64@7.0.2": { + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@typescript/typescript-darwin-x64@7.0.2": { + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@typescript/typescript-freebsd-arm64@7.0.2": { + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-freebsd-x64@7.0.2": { + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-linux-arm64@7.0.2": { + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@typescript/typescript-linux-arm@7.0.2": { + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@typescript/typescript-linux-loong64@7.0.2": { + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@typescript/typescript-linux-mips64el@7.0.2": { + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@typescript/typescript-linux-ppc64@7.0.2": { + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@typescript/typescript-linux-riscv64@7.0.2": { + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@typescript/typescript-linux-s390x@7.0.2": { + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@typescript/typescript-linux-x64@7.0.2": { + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@typescript/typescript-netbsd-arm64@7.0.2": { + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-netbsd-x64@7.0.2": { + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-openbsd-arm64@7.0.2": { + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-openbsd-x64@7.0.2": { + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-sunos-x64@7.0.2": { + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@typescript/typescript-win32-arm64@7.0.2": { + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@typescript/typescript-win32-x64@7.0.2": { + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "os": ["win32"], + "cpu": ["x64"] + }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "enhanced-resolve@5.24.5": { + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dependencies": [ + "graceful-fs", + "tapable" + ] + }, + "fdir@6.5.0_picomatch@4.0.5": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "graceful-fs@4.2.11": { + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "hono@4.12.34": { + "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==" + }, + "jiti@2.7.0": { + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "bin": true + }, + "lightningcss-android-arm64@1.32.0": { + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "lightningcss-android-arm64@1.33.0": { + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-arm64@1.32.0": { + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-arm64@1.33.0": { + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-x64@1.32.0": { + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-darwin-x64@1.33.0": { + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.32.0": { + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.33.0": { + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-linux-arm-gnueabihf@1.32.0": { + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm-gnueabihf@1.33.0": { + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm64-gnu@1.32.0": { + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-gnu@1.33.0": { + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.32.0": { + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.33.0": { + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-x64-gnu@1.32.0": { + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-gnu@1.33.0": { + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.32.0": { + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.33.0": { + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-win32-arm64-msvc@1.32.0": { + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-arm64-msvc@1.33.0": { + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-x64-msvc@1.32.0": { + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss-win32-x64-msvc@1.33.0": { + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss@1.32.0": { + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-android-arm64@1.32.0", + "lightningcss-darwin-arm64@1.32.0", + "lightningcss-darwin-x64@1.32.0", + "lightningcss-freebsd-x64@1.32.0", + "lightningcss-linux-arm-gnueabihf@1.32.0", + "lightningcss-linux-arm64-gnu@1.32.0", + "lightningcss-linux-arm64-musl@1.32.0", + "lightningcss-linux-x64-gnu@1.32.0", + "lightningcss-linux-x64-musl@1.32.0", + "lightningcss-win32-arm64-msvc@1.32.0", + "lightningcss-win32-x64-msvc@1.32.0" + ] + }, + "lightningcss@1.33.0": { + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-android-arm64@1.33.0", + "lightningcss-darwin-arm64@1.33.0", + "lightningcss-darwin-x64@1.33.0", + "lightningcss-freebsd-x64@1.33.0", + "lightningcss-linux-arm-gnueabihf@1.33.0", + "lightningcss-linux-arm64-gnu@1.33.0", + "lightningcss-linux-arm64-musl@1.33.0", + "lightningcss-linux-x64-gnu@1.33.0", + "lightningcss-linux-x64-musl@1.33.0", + "lightningcss-win32-arm64-msvc@1.33.0", + "lightningcss-win32-x64-msvc@1.33.0" + ] + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "nanoid@3.3.17": { + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "bin": true + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.5": { + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" + }, + "postcss@8.5.25": { + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "rolldown@1.2.2": { + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dependencies": [ + "@oxc-project/types", + "@rolldown/pluginutils" + ], + "optionalDependencies": [ + "@rolldown/binding-android-arm64", + "@rolldown/binding-darwin-arm64", + "@rolldown/binding-darwin-x64", + "@rolldown/binding-freebsd-x64", + "@rolldown/binding-linux-arm-gnueabihf", + "@rolldown/binding-linux-arm64-gnu", + "@rolldown/binding-linux-arm64-musl", + "@rolldown/binding-linux-ppc64-gnu", + "@rolldown/binding-linux-s390x-gnu", + "@rolldown/binding-linux-x64-gnu", + "@rolldown/binding-linux-x64-musl", + "@rolldown/binding-openharmony-arm64", + "@rolldown/binding-win32-arm64-msvc", + "@rolldown/binding-win32-x64-msvc" + ], + "bin": true + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "tailwindcss@4.3.3": { + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" + }, + "tapable@2.3.3": { + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==" + }, + "tinyglobby@0.2.17": { + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "typescript@7.0.2": { + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "optionalDependencies": [ + "@typescript/typescript-aix-ppc64", + "@typescript/typescript-darwin-arm64", + "@typescript/typescript-darwin-x64", + "@typescript/typescript-freebsd-arm64", + "@typescript/typescript-freebsd-x64", + "@typescript/typescript-linux-arm", + "@typescript/typescript-linux-arm64", + "@typescript/typescript-linux-loong64", + "@typescript/typescript-linux-mips64el", + "@typescript/typescript-linux-ppc64", + "@typescript/typescript-linux-riscv64", + "@typescript/typescript-linux-s390x", + "@typescript/typescript-linux-x64", + "@typescript/typescript-netbsd-arm64", + "@typescript/typescript-netbsd-x64", + "@typescript/typescript-openbsd-arm64", + "@typescript/typescript-openbsd-x64", + "@typescript/typescript-sunos-x64", + "@typescript/typescript-win32-arm64", + "@typescript/typescript-win32-x64" + ], + "bin": true + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "vite@8.2.0_@types+node@22.20.1": { + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dependencies": [ + "@types/node", + "lightningcss@1.33.0", + "picomatch", + "postcss", + "rolldown", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + } + }, + "workspace": { + "dependencies": [ + "npm:vite@^8.1.4" + ], + "packageJson": { + "dependencies": [ + "npm:@tailwindcss/vite@^4.1.0", + "npm:@types/deno@^2.3.0", + "npm:@types/node@^22.19.0", + "npm:hono@^4.12.31", + "npm:tailwindcss@^4.1.0", + "npm:typescript@^7.0.2", + "npm:vite@^8.1.4" + ] + } + } +} diff --git a/examples/deno-desktop-counter/package.json b/examples/deno-desktop-counter/package.json new file mode 100644 index 00000000..d0f3356d --- /dev/null +++ b/examples/deno-desktop-counter/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cossackframework/example-deno-desktop-counter", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && vite build --ssr src/index.ts --outDir dist/server", + "start": "deno run --allow-env --allow-net --allow-read dist/server/index.js", + "desktop:dev": "deno desktop --hmr .", + "desktop:build": "deno task build && deno desktop ." + }, + "dependencies": { + "@cossackframework/core": "workspace:*", + "@cossackframework/deno-adapter": "workspace:*", + "@cossackframework/framework": "workspace:*", + "@cossackframework/renderer": "workspace:*", + "@cossackframework/ui": "workspace:*", + "@tailwindcss/vite": "^4.1.0", + "hono": "^4.12.31", + "tailwindcss": "^4.1.0", + "vite": "^8.1.4" + }, + "devDependencies": { + "@types/deno": "^2.3.0", + "@types/node": "^22.19.0", + "typescript": "^7.0.2" + } +} diff --git a/examples/deno-desktop-counter/src/App.ts b/examples/deno-desktop-counter/src/App.ts new file mode 100644 index 00000000..4ac64106 --- /dev/null +++ b/examples/deno-desktop-counter/src/App.ts @@ -0,0 +1,7 @@ +import { Cossack, Page } from '@cossackframework/core'; +import { html } from '@cossackframework/renderer'; + +@Page({ transport: 'http' }) +export class App extends Cossack { + render() { return html`
${this.children}
`; } +} diff --git a/examples/deno-desktop-counter/src/bootstrap/middlewares.ts b/examples/deno-desktop-counter/src/bootstrap/middlewares.ts new file mode 100644 index 00000000..e0b120b9 --- /dev/null +++ b/examples/deno-desktop-counter/src/bootstrap/middlewares.ts @@ -0,0 +1,3 @@ +import type { MiddlewareHandler } from 'hono'; +const middlewares: MiddlewareHandler[] = []; +export default middlewares; diff --git a/examples/deno-desktop-counter/src/client/entry-client.ts b/examples/deno-desktop-counter/src/client/entry-client.ts new file mode 100644 index 00000000..1900d8fc --- /dev/null +++ b/examples/deno-desktop-counter/src/client/entry-client.ts @@ -0,0 +1,5 @@ +import '../style.css'; +import { createClientApp } from '@cossackframework/framework/client/app'; +import { App } from '../App'; + +createClientApp({ container: '#root', AppComponent: App }); diff --git a/examples/deno-desktop-counter/src/desktop/bindings.ts b/examples/deno-desktop-counter/src/desktop/bindings.ts new file mode 100644 index 00000000..142c5c35 --- /dev/null +++ b/examples/deno-desktop-counter/src/desktop/bindings.ts @@ -0,0 +1,14 @@ +import { defineDesktopBindings } from '@cossackframework/deno-adapter/desktop'; + +const STORAGE_KEY = 'cossack.desktop.counter'; + +export const desktopBindings = defineDesktopBindings({ + loadCount(): number { + const value = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '0', 10); + return Number.isFinite(value) ? value : 0; + }, + saveCount(count: number): void { + if (!Number.isSafeInteger(count)) throw new TypeError('count must be a safe integer'); + localStorage.setItem(STORAGE_KEY, String(count)); + }, +}); diff --git a/examples/deno-desktop-counter/src/index.ts b/examples/deno-desktop-counter/src/index.ts new file mode 100644 index 00000000..ff1d54ac --- /dev/null +++ b/examples/deno-desktop-counter/src/index.ts @@ -0,0 +1,17 @@ +import './desktop/bindings'; +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { createApp } from '@cossackframework/framework/router'; +import { App } from './App'; +import { template } from './root'; + +export const env: Record = Deno.env.toObject(); +export const runtime = createDenoAdapter({ env }); +export const app = createApp({ AppComponent: App, htmlTemplate: template, runtimeAdapter: runtime }); + +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(app, request, requestEnv), +}; +if (import.meta.main && typeof (Deno as any).BrowserWindow !== 'function') { + runtime.serve(app); +} diff --git a/examples/deno-desktop-counter/src/pages/index.ts b/examples/deno-desktop-counter/src/pages/index.ts new file mode 100644 index 00000000..2d0a8a54 --- /dev/null +++ b/examples/deno-desktop-counter/src/pages/index.ts @@ -0,0 +1,44 @@ +import { Client, ClientState, Cossack, Page } from '@cossackframework/core'; +import { component, html } from '@cossackframework/renderer'; +import { Button } from '@cossackframework/ui'; +import { createDesktopClient } from '@cossackframework/deno-adapter/desktop/client'; +import type { desktopBindings } from '../desktop/bindings'; + +const desktop = createDesktopClient(); + +@Page({ transport: 'http' }) +export default class CounterPage extends Cossack { + @ClientState() count = 0; + + @Client() + async clientInit() { + if (desktop.available) this.count = await desktop.invoke('loadCount'); + } + + @Client() + async increment() { + this.count += 1; + if (desktop.available) await desktop.invoke('saveCount', this.count); + } + + @Client() + async decrement() { + this.count -= 1; + if (desktop.available) await desktop.invoke('saveCount', this.count); + } + + render() { + return html` +
+
+

${desktop.available ? 'Deno Desktop · persistent' : 'Web · in memory'}

+

Cossack counter

+
+ ${this.count} +
+ ${component(Button, { variant: 'outline', '@click': this.decrement }, '−')} + ${component(Button, { '@click': this.increment }, '+')} +
+
`; + } +} diff --git a/examples/deno-desktop-counter/src/root.ts b/examples/deno-desktop-counter/src/root.ts new file mode 100644 index 00000000..9f652924 --- /dev/null +++ b/examples/deno-desktop-counter/src/root.ts @@ -0,0 +1,3 @@ +export const template = ` +{{ cossackScripts }} +{{ cossackBody }}`; diff --git a/examples/deno-desktop-counter/src/style.css b/examples/deno-desktop-counter/src/style.css new file mode 100644 index 00000000..434095b6 --- /dev/null +++ b/examples/deno-desktop-counter/src/style.css @@ -0,0 +1,4 @@ +@import "tailwindcss"; +@import "@cossackframework/ui/theme/base.css"; +@import "@cossackframework/ui/theme/theme.css"; +@source "../../../packages/ui/dist"; diff --git a/examples/deno-desktop-counter/tsconfig.json b/examples/deno-desktop-counter/tsconfig.json new file mode 100644 index 00000000..a3b26942 --- /dev/null +++ b/examples/deno-desktop-counter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "node", "@types/deno"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/deno-desktop-counter/vite.config.ts b/examples/deno-desktop-counter/vite.config.ts new file mode 100644 index 00000000..ccbcceae --- /dev/null +++ b/examples/deno-desktop-counter/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; +import path from 'node:path'; +import tailwindcss from '@tailwindcss/vite'; +import { cossackConfig, cossackLang, cossackMiddlewares, cossackPages } from '@cossackframework/framework/vite-plugin'; +import { cossackSecurityPlugin } from '@cossackframework/framework/vite-security-plugin'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + cossackSecurityPlugin({ devWarning: true }), + cossackPages(), cossackLang(), cossackMiddlewares(), cossackConfig(), + ], + build: { minify: true }, + resolve: { + dedupe: [ + '@cossackframework/core', '@cossackframework/renderer', + '@cossackframework/framework', '@cossackframework/ui', 'hono', + ], + alias: { + '@': path.resolve(import.meta.dirname, './src'), + '~': path.resolve(import.meta.dirname, './dist/client'), + }, + }, + environments: { + client: { + build: { + outDir: 'dist/client', target: 'esnext', manifest: true, + rolldownOptions: { + input: 'src/client/entry-client.ts', + output: { + entryFileNames: 'assets/[name].[hash].js', + chunkFileNames: 'assets/[name].[hash].js', + assetFileNames: 'assets/[name].[hash][extname]', format: 'esm', + }, + }, + }, + }, + ssr: { resolve: { noExternal: ['@cossackframework/ui', 'hono'] } }, + }, +}); diff --git a/package.json b/package.json index 1e630599..7c3e2384 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "packageManager": "pnpm@11.19.0", "scripts": { "dev": "pnpm --filter @cossackframework/framework run dev", - "build": "pnpm --filter @cossackframework/core --filter @cossackframework/renderer --filter @cossackframework/node-adapter --filter @cossackframework/ui --filter @cossackframework/database run build && pnpm --filter @cossackframework/framework run build:types && pnpm --filter @cossackframework/studio run build", - "test:unit": "pnpm --filter @cossackframework/core --filter @cossackframework/renderer --filter @cossackframework/database --filter @cossackframework/studio --filter @cossackframework/scaffold --filter cossack run test", + "build": "pnpm --filter @cossackframework/core --filter @cossackframework/renderer run build && pnpm --filter @cossackframework/framework run build:types && pnpm --filter @cossackframework/node-adapter --filter @cossackframework/deno-adapter --filter @cossackframework/ui --filter @cossackframework/database run build && pnpm --filter @cossackframework/studio run build", + "test:unit": "pnpm --filter @cossackframework/core --filter @cossackframework/renderer --filter @cossackframework/deno-adapter --filter @cossackframework/database --filter @cossackframework/studio --filter @cossackframework/scaffold --filter cossack run test", "audit:bundle": "pnpm --filter @cossackframework/database run audit:bundle", "test:scaffold:local": "node scripts/test-local-scaffold.mjs", "test:production-bundles": "node scripts/test-local-scaffold.mjs", diff --git a/packages/core/src/shared/CossackDurableObject.ts b/packages/core/src/shared/CossackDurableObject.ts index ff1ffad6..e106b152 100644 --- a/packages/core/src/shared/CossackDurableObject.ts +++ b/packages/core/src/shared/CossackDurableObject.ts @@ -106,11 +106,18 @@ export abstract class CossackDurableObject { return new Response('Headers X-Component-Path and X-Provider-Name are required', { status: 400 }); } - const params: Record = {}; - url.searchParams.forEach((value, key) => { - params[key] = value; - }); - const page = params.pathname; + let params: Record; + try { + const parsed: unknown = JSON.parse(url.searchParams.get('params') || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string')) { + throw new TypeError('params must be a string record'); + } + params = parsed as Record; + } catch { + return new Response('Invalid WebSocket route params', { status: 400 }); + } + const page = url.searchParams.get('pathname'); if (!page) { return new Response('pathname query parameter is required for WebSocket connection', { status: 400 }); diff --git a/packages/core/src/shared/component-types.ts b/packages/core/src/shared/component-types.ts index 6a4bc0dc..27fff8dd 100644 --- a/packages/core/src/shared/component-types.ts +++ b/packages/core/src/shared/component-types.ts @@ -71,6 +71,8 @@ export interface SerializedComponentState { scopeKey?: string; /** Resolved compiler-generated server$ invocations. */ serverResources?: Record; + /** Runtime-specific, browser-safe metadata contributed by an adapter. */ + runtime?: Record; } export interface CossackOptions { diff --git a/packages/core/src/shared/runtime.ts b/packages/core/src/shared/runtime.ts index fc0418da..056c80a3 100644 --- a/packages/core/src/shared/runtime.ts +++ b/packages/core/src/shared/runtime.ts @@ -5,3 +5,112 @@ export interface CossackServerRuntime { sendClientAction(client: unknown, action: string, payload: any[]): void; persistState(): Promise; } + +/** Minimal socket surface shared by process runtimes such as Node and Deno. */ +export interface InMemoryWebSocketClient { + readonly readyState: number; + send(data: string): void; +} + +export interface InMemoryWebSocketRuntimeOptions { + getUser?: (client: Client) => unknown; + isOpen?: (client: Client) => boolean; + onError?: (error: unknown) => void; +} + +/** + * Runtime-neutral, process-local WebSocket engine. + * + * Platform adapters own upgrades and socket event wiring; this class owns the + * Cossack protocol, nested-component dispatch, per-client authentication and + * fan-out semantics. It deliberately does not persist state. + */ +export class InMemoryWebSocketRuntime< + Client extends InMemoryWebSocketClient = InMemoryWebSocketClient, +> implements CossackServerRuntime { + protected readonly clients = new Set(); + protected readonly component: any; + private readonly users = new WeakMap(); + private readonly options: InMemoryWebSocketRuntimeOptions; + + constructor(component: any, options: InMemoryWebSocketRuntimeOptions = {}) { + this.component = component; + this.options = options; + this.component._runtime = this; + } + + addClient(client: Client, user?: unknown): void { + this.clients.add(client); + if (typeof client === 'object' && client !== null) this.users.set(client, user); + } + + removeClient(client: Client): void { + this.clients.delete(client); + if (typeof client === 'object' && client !== null) this.users.delete(client); + } + + get clientCount(): number { + return this.clients.size; + } + + getInitialState(): unknown { + return this.component.getInitialState(); + } + + async onClientMessage(client: unknown, message: string): Promise { + const socket = client as Client; + if (message === 'ping') { + if (this.isOpen(socket)) socket.send('pong'); + return; + } + + let data: any; + try { + data = JSON.parse(message); + } catch (error) { + this.options.onError?.(error); + return; + } + if (!data || typeof data !== 'object' || data.type !== 'action' || + typeof data.action !== 'string' || !Array.isArray(data.payload)) return; + + let target = this.component; + if (typeof data.target === 'string' && data.target !== target._id) { + target = target.activeComponents?.get?.(data.target); + if (!target) return; + } + + const user = this.options.getUser?.(socket) ?? + (typeof socket === 'object' && socket !== null ? this.users.get(socket) : undefined); + await target.executeAction(data.action, data.payload, user, socket); + } + + broadcastState(partialState: Record): void { + this.broadcast({ type: 'state-update', state: partialState }); + } + + broadcastEvent(eventName: string, payload: any[]): void { + this.broadcast({ type: 'event', eventName, payload }); + } + + sendClientAction(client: unknown, action: string, payload: any[]): void { + const socket = client as Client; + if (this.isOpen(socket)) socket.send(JSON.stringify({ type: 'client-action', action, payload })); + } + + async persistState(): Promise { + // Process runtimes are intentionally ephemeral. Applications persist + // durable data through the database package. + } + + protected isOpen(client: Client): boolean { + return this.options.isOpen?.(client) ?? client.readyState === 1; + } + + private broadcast(message: Record): void { + const encoded = JSON.stringify(message); + for (const client of this.clients) { + if (this.isOpen(client)) client.send(encoded); + } + } +} diff --git a/packages/core/src/shared/transport-connections.ts b/packages/core/src/shared/transport-connections.ts index 01b165ad..e67e430f 100644 --- a/packages/core/src/shared/transport-connections.ts +++ b/packages/core/src/shared/transport-connections.ts @@ -49,11 +49,12 @@ export function connectWebSocket(component: any): void { const params = new URLSearchParams({ routePath, pathname: pathname || '', - ...(initialState?.metadata?.params || {}), + params: JSON.stringify(initialState?.metadata?.params || {}), }).toString(); const wsUrl = `/ws/${providerName}/${target}?${params}`; - const fullWsUrl = `ws://${window.location.host}${wsUrl}`; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const fullWsUrl = `${protocol}//${window.location.host}${wsUrl}`; const ws = new WebSocket(fullWsUrl); component.websockets.set(providerName, ws); diff --git a/packages/core/tests/cossack.client.test.ts b/packages/core/tests/cossack.client.test.ts index 71eb754d..2c0e8d22 100644 --- a/packages/core/tests/cossack.client.test.ts +++ b/packages/core/tests/cossack.client.test.ts @@ -136,7 +136,7 @@ describe('Cossack Core: Client-Side', () => { expect(global.WebSocket).toHaveBeenCalledTimes(1); // Expected URL based on new logic: /ws/{provider}/{target}?routePath=...&pathname=...¶ms... // pathname and routePath come from mockInitialState - expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost/ws/page/durable-object-id-123?routePath=%2Ftest&pathname=%2Ftest&name=cossack'); + expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost/ws/page/durable-object-id-123?routePath=%2Ftest&pathname=%2Ftest¶ms=%7B%22name%22%3A%22cossack%22%7D'); }); it('should proxy server methods to send WebSocket messages', async () => { diff --git a/packages/core/tests/in-memory-websocket-runtime.test.ts b/packages/core/tests/in-memory-websocket-runtime.test.ts new file mode 100644 index 00000000..f785ef51 --- /dev/null +++ b/packages/core/tests/in-memory-websocket-runtime.test.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; +import { InMemoryWebSocketRuntime } from '../src/shared/runtime'; + +class Socket { + readyState = 1; + sent: string[] = []; + send(message: string) { this.sent.push(message); } +} + +describe('InMemoryWebSocketRuntime', () => { + it('isolates malformed messages and handles ping', async () => { + const component = { _id: 'root', activeComponents: new Map(), executeAction: vi.fn(), getInitialState: () => ({}) }; + const runtime = new InMemoryWebSocketRuntime(component); + const client = new Socket(); + runtime.addClient(client, { id: 'one' }); + await expect(runtime.onClientMessage(client, '{bad')).resolves.toBeUndefined(); + await runtime.onClientMessage(client, 'ping'); + expect(client.sent).toEqual(['pong']); + expect(component.executeAction).not.toHaveBeenCalled(); + }); + + it('dispatches nested targets with the authenticated client user', async () => { + const nested = { executeAction: vi.fn() }; + const component = { + _id: 'root', activeComponents: new Map([['child', nested]]), + executeAction: vi.fn(), getInitialState: () => ({ public: {} }), + }; + const runtime = new InMemoryWebSocketRuntime(component); + const client = new Socket(); + const user = { id: 'trusted' }; + runtime.addClient(client, user); + await runtime.onClientMessage(client, JSON.stringify({ + type: 'action', target: 'child', action: 'increment', payload: [2], + })); + expect(nested.executeAction).toHaveBeenCalledWith('increment', [2], user, client); + expect(component.executeAction).not.toHaveBeenCalled(); + }); + + it('broadcasts only to open clients and removes clients', () => { + const component = { getInitialState: () => ({}), activeComponents: new Map() }; + const runtime = new InMemoryWebSocketRuntime(component); + const open = new Socket(); + const closed = new Socket(); closed.readyState = 3; + runtime.addClient(open); runtime.addClient(closed); + runtime.broadcastState({ count: 1 }); + expect(open.sent).toEqual([JSON.stringify({ type: 'state-update', state: { count: 1 } })]); + expect(closed.sent).toEqual([]); + runtime.removeClient(open); + expect(runtime.clientCount).toBe(1); + }); +}); diff --git a/packages/core/tests/websocket-url.test.ts b/packages/core/tests/websocket-url.test.ts new file mode 100644 index 00000000..91e312fe --- /dev/null +++ b/packages/core/tests/websocket-url.test.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { connectWebSocket } from '../src/shared/transport-connections'; + +describe('connectWebSocket URL protocol', () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each([['https:', 'wss:'], ['http:', 'ws:']])('maps %s pages to %s sockets', (pageProtocol, socketProtocol) => { + const urls: string[] = []; + class FakeWebSocket { + static OPEN = 1; + readyState = 0; + onmessage: unknown; onclose: unknown; onerror: unknown; + constructor(url: string) { urls.push(url); } + send() {} close() {} + } + vi.stubGlobal('window', { location: { protocol: pageProtocol, host: 'example.test' } }); + vi.stubGlobal('WebSocket', FakeWebSocket); + const component = { + getInitialStateFromWindow: () => ({ + providerTargets: { page: 'scope' }, routePath: '/', metadata: { pathname: '/' }, + }), + websockets: new Map(), + }; + connectWebSocket(component); + expect(urls[0]).toBe(`${socketProtocol}//example.test/ws/page/scope?routePath=%2F&pathname=%2F¶ms=%7B%7D`); + for (const socket of component.websockets.values()) (socket as any).onclose?.(); + }); +}); diff --git a/packages/cossack/src/commands/adapter.js b/packages/cossack/src/commands/adapter.js index 50baf17b..f71408b9 100644 --- a/packages/cossack/src/commands/adapter.js +++ b/packages/cossack/src/commands/adapter.js @@ -72,9 +72,9 @@ export async function adapterCommand(args, ctx) { 'Review `DB_PATH` in `.env`, then run `cossack migration up`.', ); } else { - const environment = result.targetAdapter === 'node' ? '.env' : '.dev.vars'; + const environment = result.targetAdapter === 'cloudflare' ? '.dev.vars' : '.env'; console.log( - `Configure \`TURSO_URL\` and \`TURSO_TOKEN\` in \`${environment}\`, ` + + `Configure \`TURSO_DATABASE_URL\` and \`TURSO_AUTH_TOKEN\` in \`${environment}\`, ` + 'then run `cossack migration up`.', ); } @@ -106,7 +106,7 @@ export async function detectInstallCommand(root) { } export function adapterHelp() { - return `cossack adapter + return `cossack adapter Switch a schema-v3 scaffolded project to one runtime adapter. The complete recorded recipe is re-rendered; application features and unrelated edits are diff --git a/packages/cossack/src/commands/add.js b/packages/cossack/src/commands/add.js index 2961cde6..86389df8 100644 --- a/packages/cossack/src/commands/add.js +++ b/packages/cossack/src/commands/add.js @@ -33,6 +33,7 @@ export async function addCommand(args, ctx) { authMethods: ctx.flags['auth-methods'], oauth: ctx.flags.oauth, theme: flagString(ctx.flags.theme), + desktopBackend: flagString(ctx.flags['desktop-backend']), features: feature === 'dashboard' && ctx.flags.features !== undefined ? parseList(ctx.flags.features) : undefined, @@ -99,15 +100,16 @@ export async function addCommand(args, ctx) { } export function addHelp() { - return `cossack add [component] + return `cossack add [component] Options: component Eject a UI component for customization --database - --runtime + --runtime --auth-methods --oauth --theme --features Dashboard modules + --desktop-backend Desktop rendering backend --yes Apply without confirmation`; } diff --git a/packages/cossack/src/commands/create.js b/packages/cossack/src/commands/create.js index 2c5327fd..8dc62495 100644 --- a/packages/cossack/src/commands/create.js +++ b/packages/cossack/src/commands/create.js @@ -18,8 +18,8 @@ export async function createCommand(args, ctx) { } const adapter = flagString(ctx.flags.adapter); - if (adapter && adapter !== 'cloudflare' && adapter !== 'node') { - console.error(`Invalid --adapter "${adapter}". Use cloudflare or node.`); + if (adapter && adapter !== 'cloudflare' && adapter !== 'node' && adapter !== 'deno') { + console.error(`Invalid --adapter "${adapter}". Use cloudflare, node, or deno.`); return 1; } const requestedPackageManager = flagString(ctx.flags['package-manager']); @@ -49,6 +49,7 @@ export async function createCommand(args, ctx) { oauth: ctx.flags.oauth, theme: flagString(ctx.flags.theme), dashboardModules: ctx.flags['dashboard-features'], + desktopBackend: flagString(ctx.flags['desktop-backend']), yes, interactive: !yes, force: ctx.force, @@ -84,14 +85,15 @@ export function createHelp() { Scaffold a new Cossack project. Options: - --adapter + --adapter --package-manager npm|pnpm|yarn|bun|deno --preset - --features + --features --database --auth-methods --oauth --theme --dashboard-features + --desktop-backend Desktop rendering backend (default: webview) --yes Accept defaults and write without confirmation.`; } diff --git a/packages/cossack/src/dispatch.js b/packages/cossack/src/dispatch.js index f21542be..ccfbec8f 100644 --- a/packages/cossack/src/dispatch.js +++ b/packages/cossack/src/dispatch.js @@ -123,7 +123,7 @@ Commands: delete (d) Delete a generated file/folder. add Add a feature (ui, database, studio, auth, dashboard, markdown, examples). remove Remove a feature and its dependents. - adapter Switch the active runtime adapter. + adapter Switch the active runtime adapter. lang Manage localization catalogs under src/lang/. Subcommands: publish, add . migration (migrate) Run ORM migrations. Subcommands: generate, snapshot, diff --git a/packages/database/README.md b/packages/database/README.md index 408f0313..f774d53d 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -12,8 +12,9 @@ pnpm add @cossackframework/database reflect-metadata ``` Install only the optional driver used by the application (`pg`, `mysql2`, -`@libsql/client`, or `better-sqlite3`). Node 22's built-in `node:sqlite`, Bun SQL, -and Cloudflare D1 need no third-party database driver. +`@tursodatabase/database` for embedded/Desktop, `@tursodatabase/serverless` +for remote Turso, or `better-sqlite3`). Node 22's built-in `node:sqlite`, Bun +SQL, and Cloudflare D1 need no third-party database driver. Use TypeScript legacy decorators: @@ -116,7 +117,7 @@ builds object/bulk insert tuples. `sql.unsafe()` is the only API that injects literal SQL. `new SQL({ adapter })` creates a standalone Bun-compatible tagged client. In Node, -`new SQL("postgres://…")`, `new SQL("mysql://…")`, `new SQL("libsql://…")`, and +`new SQL("postgres://…")`, `new SQL("mysql://…")`, `new SQL("https://….turso.io")`, and SQLite paths select an adapter lazily. Workers intentionally require a binding or explicit adapter rather than environment URL guessing. @@ -141,7 +142,8 @@ are never replayed across an in-memory object graph. | Runtime entry | Adapters | | --- | --- | -| `@cossackframework/database/node` | `nodeSQLite`, `betterSQLite`, `postgres`, `mysql`, `libsql` | +| `@cossackframework/database/node` | `nodeSQLite`, `betterSQLite`, `postgres`, `mysql`, `turso` | +| `@cossackframework/database/deno` | `deno`, `denoSQLite`, `postgres`, `mysql`, `turso` | | `@cossackframework/database/bun` | `bun` over the documented Bun SQL core API | | `@cossackframework/database/cloudflare` | `d1`, `hyperdrivePostgres`, `hyperdriveMySQL` | | `@cossackframework/database/deno` | `deno` with an injected remote or SQLite driver | diff --git a/packages/database/docs/comparison.md b/packages/database/docs/comparison.md index d7250bbf..e06fecf2 100644 --- a/packages/database/docs/comparison.md +++ b/packages/database/docs/comparison.md @@ -37,7 +37,7 @@ Last reviewed: July 2026. | Model generation from a database | Decorated classes through `schema pull` | TypeScript schema through `pull` | Not built in | External code generator | Prisma schema through `db pull` | | Runtime schema push/synchronization | No, by design | `push` available | `synchronize`/`schema:sync` available | No | `db push` available | | First-party seeding support | Ordered `SeederRunner` with transaction policy | `drizzle-seed` data generator | Application or community tooling | Application code | CLI seed command runs an application script | -| SQL database coverage | SQLite, PostgreSQL, MySQL; D1 and libSQL adapters | Broad PostgreSQL, MySQL, SQLite, SingleStore, MSSQL, and CockroachDB ecosystem | Broad SQL driver set | Official PostgreSQL, MySQL, MSSQL, SQLite, and PGlite dialects | PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, and CockroachDB | +| SQL database coverage | SQLite, PostgreSQL, MySQL; D1 and current Turso adapters | Broad PostgreSQL, MySQL, SQLite, SingleStore, MSSQL, and CockroachDB ecosystem | Broad SQL driver set | Official PostgreSQL, MySQL, MSSQL, SQLite, and PGlite dialects | PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, and CockroachDB | | MongoDB/NoSQL | No | No | MongoDB support | No | MongoDB support | | Edge/serverless focus | First-class Bun, D1, Hyperdrive, and injected Deno entry points | Strong, with many serverless drivers | Driver-dependent | Runtime-neutral; dialect/driver-dependent | Supported through driver adapters and supported deployments | | Multiple connections | Multiple isolated `ORM` instances | Multiple database instances | Multiple `DataSource` instances | Multiple `Kysely` instances | Separate schemas/generated clients | diff --git a/packages/database/docs/installation.md b/packages/database/docs/installation.md index c35bf936..5df920a3 100644 --- a/packages/database/docs/installation.md +++ b/packages/database/docs/installation.md @@ -63,10 +63,10 @@ for the runtime where the application executes: | Runtime | Import | Available adapters | | --- | --- | --- | -| Node.js | `@cossackframework/database/node` | `nodeSQLite`, `betterSQLite`, `postgres`, `mysql`, `libsql` | +| Node.js | `@cossackframework/database/node` | `nodeSQLite`, `betterSQLite`, `postgres`, `mysql`, `turso` | +| Deno | `@cossackframework/database/deno` | `deno`, `denoSQLite`, `postgres`, `mysql`, `turso` | | Bun | `@cossackframework/database/bun` | `bun` for SQLite, PostgreSQL, or MySQL | -| Cloudflare Workers | `@cossackframework/database/cloudflare` | `d1`, `hyperdrivePostgres`, `hyperdriveMySQL` | -| Deno | `@cossackframework/database/deno` | `deno` with an injected driver | +| Cloudflare Workers | `@cossackframework/database/cloudflare` | `d1`, `hyperdrivePostgres`, `hyperdriveMySQL`, `turso` | Some Node and Workers adapters require an optional peer driver: @@ -74,7 +74,8 @@ Some Node and Workers adapters require an optional peer driver: # Install only what the application uses. pnpm add pg pnpm add mysql2 -pnpm add @libsql/client +pnpm add @tursodatabase/database # embedded SQLite / Desktop +pnpm add @tursodatabase/serverless # remote Turso / edge runtimes pnpm add better-sqlite3 ``` diff --git a/packages/database/docs/raw-queries.md b/packages/database/docs/raw-queries.md index ca329ac0..a739263d 100644 --- a/packages/database/docs/raw-queries.md +++ b/packages/database/docs/raw-queries.md @@ -171,7 +171,7 @@ try { } ``` -In Node.js, URL or path inference supports PostgreSQL, MySQL, libSQL, and +In Node.js, URL or path inference supports PostgreSQL, MySQL, Turso HTTPS URLs, and SQLite. Bun delegates to native Bun SQL. Workers and Deno require an explicit adapter rather than environment guessing: diff --git a/packages/database/docs/runtimes.md b/packages/database/docs/runtimes.md index b9cb6ece..280d245e 100644 --- a/packages/database/docs/runtimes.md +++ b/packages/database/docs/runtimes.md @@ -10,7 +10,8 @@ eagerly include Node built-ins, native SQLite packages, or unused drivers. | Runtime entry | Adapters | | --- | --- | -| `@cossackframework/database/node` | Node SQLite, better-sqlite3, PostgreSQL, MySQL, libSQL | +| `@cossackframework/database/node` | Node SQLite, better-sqlite3, PostgreSQL, MySQL, Turso | +| `@cossackframework/database/deno` | Turso embedded SQLite, PostgreSQL, MySQL, remote Turso, or an injected driver | | `@cossackframework/database/bun` | Native Bun SQL for SQLite, PostgreSQL, MySQL | | `@cossackframework/database/cloudflare` | D1 and Hyperdrive PostgreSQL/MySQL | | `@cossackframework/database/deno` | Injected SQLite, PostgreSQL, MySQL-compatible drivers | @@ -78,16 +79,19 @@ const adapter = await mysql({ }); ``` -libSQL or Turso: +Turso (choose the client matching the connection): ```sh -pnpm add @libsql/client +pnpm add @tursodatabase/database # embedded SQLite / Desktop +pnpm add @tursodatabase/serverless # remote Turso / Deno Deploy ``` ```ts -import { libsql } from "@cossackframework/database/node"; +import { turso } from "@cossackframework/database/node"; -const adapter = await libsql({ +const embedded = await turso({ path: "./app.turso" }); + +const adapter = await turso({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN!, }); diff --git a/packages/database/package.json b/packages/database/package.json index 3ca66bff..08dd4ffc 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -85,7 +85,8 @@ "tsx": "^4.23.0" }, "peerDependencies": { - "@libsql/client": ">=0.14", + "@tursodatabase/database": "^0.7.2", + "@tursodatabase/serverless": "^1.4.0", "better-sqlite3": ">=11", "hono": "^4.12.0", "mysql2": ">=3.11", @@ -93,7 +94,10 @@ "reflect-metadata": ">=0.2" }, "peerDependenciesMeta": { - "@libsql/client": { + "@tursodatabase/database": { + "optional": true + }, + "@tursodatabase/serverless": { "optional": true }, "better-sqlite3": { diff --git a/packages/database/scripts/audit-bundles.mjs b/packages/database/scripts/audit-bundles.mjs index 65318824..b3f921ff 100644 --- a/packages/database/scripts/audit-bundles.mjs +++ b/packages/database/scripts/audit-bundles.mjs @@ -7,7 +7,8 @@ const forbidden = [ "node:sqlite", "node:async_hooks", "better-sqlite3", - "@libsql/client", + "@tursodatabase/database", + "@tursodatabase/serverless", "mysql2", "\"pg\"", "'pg'", @@ -35,14 +36,14 @@ for (const value of [ "node:sqlite", "node:async_hooks", "better-sqlite3", - "@libsql/client/node", + "@tursodatabase/database", ]) { if (cloudflare.includes(value)) { throw new Error(`Cloudflare entry contains forbidden runtime dependency ${value}.`); } } -if (!cloudflare.includes("@libsql/client/web")) { - throw new Error("Cloudflare libSQL adapter must import @libsql/client/web."); +if (!cloudflare.includes("@tursodatabase/serverless")) { + throw new Error("Cloudflare Turso adapter must import @tursodatabase/serverless."); } console.log( diff --git a/packages/database/src/runtime/cloudflare.ts b/packages/database/src/runtime/cloudflare.ts index 66f60610..2781b998 100644 --- a/packages/database/src/runtime/cloudflare.ts +++ b/packages/database/src/runtime/cloudflare.ts @@ -1,7 +1,6 @@ import type { Adapter, BatchStatement, - DatabaseValue, Driver, DriverCapabilities, QueryOperation, @@ -268,22 +267,23 @@ export async function hyperdriveMySQL(binding: HyperdriveBinding): Promise; - close(): void; +interface TursoServerlessConnection { + prepare(text: string): Promise<{ + all(...parameters: readonly unknown[]): Promise; + run(...parameters: readonly unknown[]): Promise; + }> | { + all(...parameters: readonly unknown[]): Promise; + run(...parameters: readonly unknown[]): Promise; + }; + close?(): void | Promise; } -class CloudflareLibSQLDriver implements Driver { +class CloudflareTursoDriver implements Driver { readonly dialect = "sqlite" as const; readonly capabilities = capabilities({ transactions: false, @@ -292,22 +292,25 @@ class CloudflareLibSQLDriver implements Driver { parameterLimit: 999, }); - constructor(private readonly client: LibSQLClient) {} + constructor(private readonly connection: TursoServerlessConnection) {} async execute>( query: CompiledQuery, operation: QueryOperation = "raw", ): Promise> { const start = performance.now(); - const result = await this.client.execute({ - sql: query.text, - args: query.parameters as readonly DatabaseValue[], - }); + const statement = await this.connection.prepare(query.text); + const readsRows = operation === "select" || /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)/i.test(query.text) || + /\bRETURNING\b/i.test(query.text); + const result = await (readsRows + ? statement.all(...query.parameters) + : statement.run(...query.parameters)) as any; + const rows = Array.isArray(result) ? result : (result?.rows ?? []); return { - rows: result.rows as readonly Row[], + rows: rows as readonly Row[], meta: meta("sqlite", operation, start, { - rowsAffected: result.rowsAffected, - ...(result.lastInsertRowid === undefined + rowsAffected: Number(result?.rowsAffected ?? result?.changes ?? 0), + ...(result?.lastInsertRowid === undefined ? {} : { lastInsertId: result.lastInsertRowid }), }), @@ -320,19 +323,19 @@ class CloudflareLibSQLDriver implements Driver { } async close(): Promise { - this.client.close(); + await this.connection.close?.(); } } -/** Workers-safe libSQL/Turso adapter. Imports the web client only. */ -export async function libsql( - options: string | CloudflareLibSQLOptions, +/** Workers-safe remote Turso adapter using the current fetch-only client. */ +export async function turso( + options: string | CloudflareTursoOptions, ): Promise { - const imported = await import("@libsql/client/web"); - const client = imported.createClient( + const imported = await import("@tursodatabase/serverless"); + const connection = imported.connect( typeof options === "string" ? { url: options } : options, - ) as unknown as LibSQLClient; - return { driver: new CloudflareLibSQLDriver(client) }; + ) as TursoServerlessConnection; + return { driver: new CloudflareTursoDriver(connection) }; } -export { D1Driver, CloudflareLibSQLDriver }; +export { D1Driver, CloudflareTursoDriver }; diff --git a/packages/database/src/runtime/deno.ts b/packages/database/src/runtime/deno.ts index f9add7ea..c6df7100 100644 --- a/packages/database/src/runtime/deno.ts +++ b/packages/database/src/runtime/deno.ts @@ -7,6 +7,12 @@ import type { } from "../adapter/types.js"; import type { CompiledQuery } from "../sql/fragment.js"; import { capabilities, meta } from "./helpers.js"; +import { + postgres as nodePostgres, + mysql as nodeMySQL, +} from "./node.js"; +import { turso, type TursoEmbeddedOptions } from "./turso.js"; +export { turso, type TursoOptions, type TursoEmbeddedOptions, type TursoRemoteOptions } from "./turso.js"; export interface InjectedDenoDriver { readonly dialect: "sqlite" | "postgres" | "mysql"; @@ -71,4 +77,30 @@ export function deno( }; } +/** Local SQLite-compatible Turso Database engine for Deno and desktop. */ +export interface DenoSQLiteOptions { + readonly filename?: string; + readonly encryption?: TursoEmbeddedOptions["encryption"]; +} +export function denoSQLite(options: DenoSQLiteOptions = {}): Promise { + return turso({ + path: options.filename ?? ":memory:", + ...(options.encryption ? { encryption: options.encryption } : {}), + }); +} + +/** PostgreSQL adapter with per-request AsyncLocalStorage scoping. */ +export function postgres( + options: string | Readonly>, +): Promise { + return nodePostgres(options); +} + +/** MySQL adapter with per-request AsyncLocalStorage scoping. */ +export function mysql( + options: string | Readonly>, +): Promise { + return nodeMySQL(options); +} + export { DenoDriver }; diff --git a/packages/database/src/runtime/node.ts b/packages/database/src/runtime/node.ts index 6fcf89df..4c8e692a 100644 --- a/packages/database/src/runtime/node.ts +++ b/packages/database/src/runtime/node.ts @@ -47,7 +47,7 @@ class SQLiteDriver implements Driver { const statement = this.database.prepare(query.text); if ( operation === "select" || - /^\s*(SELECT|WITH|PRAGMA)/i.test(query.text) || + /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)/i.test(query.text) || /\bRETURNING\b/i.test(query.text) ) { const rows = statement.all(...query.parameters) as Row[]; @@ -284,39 +284,5 @@ export async function mysql( return adapter(new MySQLDriver(pool, pool)); } -export async function libsql( - options: string | Readonly>, -): Promise { - const moduleName = "@libsql/client"; - const imported = await import(/* @vite-ignore */ moduleName); - const client = imported.createClient(typeof options === "string" ? { url: options } : options) as { - execute(input: { sql: string; args: readonly unknown[] }): Promise<{ - rows: unknown[]; - rowsAffected: number; - lastInsertRowid?: string | number | bigint; - }>; - close(): void; - }; - const driver: Driver = { - dialect: "sqlite", - capabilities: capabilities({ parameterLimit: 999 }), - async execute>( - query: CompiledQuery, - operation: QueryOperation = "raw", - ): Promise> { - const start = performance.now(); - const result = await client.execute({ sql: query.text, args: query.parameters }); - return { - rows: result.rows as Row[], - meta: meta("sqlite", operation, start, { - rowsAffected: result.rowsAffected, - ...(result.lastInsertRowid === undefined ? {} : { lastInsertId: result.lastInsertRowid }), - }), - }; - }, - async close() { client.close(); }, - }; - return adapter(driver); -} - export { SQLiteDriver, PostgresDriver, MySQLDriver }; +export { turso, type TursoOptions, type TursoEmbeddedOptions, type TursoRemoteOptions } from "./turso.js"; diff --git a/packages/database/src/runtime/turso.ts b/packages/database/src/runtime/turso.ts new file mode 100644 index 00000000..50d4ff5c --- /dev/null +++ b/packages/database/src/runtime/turso.ts @@ -0,0 +1,87 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Adapter, Driver, QueryOperation, QueryResult } from "../adapter/types.js"; +import type { CompiledQuery } from "../sql/fragment.js"; +import { createAsyncLocalScope } from "../adapter/scope.js"; +import { capabilities, meta } from "./helpers.js"; + +export interface TursoEmbeddedOptions { + readonly path: string; + readonly encryption?: { + readonly cipher: "aes128gcm" | "aes256gcm" | "aegis256" | "aegis256x2" | + "aegis128l" | "aegis128x2" | "aegis128x4"; + readonly hexkey: string; + }; +} + +export interface TursoRemoteOptions { + readonly url: string; + readonly authToken?: string; +} + +export type TursoOptions = TursoEmbeddedOptions | TursoRemoteOptions; + +interface TursoStatement { + all(...parameters: readonly unknown[]): Promise | unknown; + run(...parameters: readonly unknown[]): Promise | unknown; +} + +interface TursoConnection { + prepare(text: string): Promise | TursoStatement; + close?(): Promise | void; +} + +class TursoDriver implements Driver { + readonly dialect = "sqlite" as const; + readonly capabilities = capabilities({ parameterLimit: 999 }); + + constructor(private readonly connection: TursoConnection) {} + + async execute>( + query: CompiledQuery, + operation: QueryOperation = "raw", + ): Promise> { + const start = performance.now(); + const statement = await this.connection.prepare(query.text); + const readsRows = operation === "select" || /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)/i.test(query.text) || + /\bRETURNING\b/i.test(query.text); + const result = await (readsRows + ? statement.all(...query.parameters) + : statement.run(...query.parameters)) as any; + const rows = Array.isArray(result) ? result : (result?.rows ?? []); + return { + rows: rows as Row[], + meta: meta("sqlite", operation, start, { + rowsAffected: Number(result?.rowsAffected ?? result?.changes ?? 0), + ...((result?.lastInsertRowid ?? result?.lastInsertId) === undefined + ? {} + : { lastInsertId: result.lastInsertRowid ?? result.lastInsertId }), + }), + }; + } + + async close(): Promise { + await this.connection.close?.(); + } +} + +export async function turso(options: TursoOptions): Promise { + let connection: TursoConnection; + if ("url" in options) { + const imported = await import("@tursodatabase/serverless"); + connection = imported.connect({ + url: options.url, + ...(options.authToken === undefined ? {} : { authToken: options.authToken }), + }); + } else { + const imported = await import("@tursodatabase/database"); + connection = await imported.connect(options.path, { + ...(options.encryption ? { encryption: options.encryption } : {}), + }); + } + return { + driver: new TursoDriver(connection), + scope: createAsyncLocalScope(new AsyncLocalStorage()), + }; +} + +export { TursoDriver }; diff --git a/packages/database/src/sql/client.ts b/packages/database/src/sql/client.ts index 367e70b7..4a8f322f 100644 --- a/packages/database/src/sql/client.ts +++ b/packages/database/src/sql/client.ts @@ -84,7 +84,7 @@ async function resolveAutoAdapter( }; if (runtime.WebSocketPair && !runtime.Bun && !runtime.Deno) { throw new ConfigurationError( - "Workers do not guess database URLs. Pass an explicit D1/libSQL/Hyperdrive adapter from @cossackframework/database/cloudflare.", + "Workers do not guess database URLs. Pass an explicit D1/Turso/Hyperdrive adapter from @cossackframework/database/cloudflare.", ); } if (runtime.Bun) { @@ -99,7 +99,9 @@ async function resolveAutoAdapter( const module = await import("../runtime/node.js"); if (url.startsWith("postgres:") || url.startsWith("postgresql:")) return module.postgres(options); if (url.startsWith("mysql:")) return module.mysql(options); - if (url.startsWith("libsql:") || url.startsWith("https:")) return module.libsql(options); + if (url.startsWith("https:")) { + return module.turso(typeof options === "string" ? { url: options } : options as any); + } return module.nodeSQLite({ filename: url.startsWith("sqlite:") ? url.slice("sqlite:".length) : (url || ":memory:"), }); diff --git a/packages/database/test/turso.test.ts b/packages/database/test/turso.test.ts new file mode 100644 index 00000000..a7c1798c --- /dev/null +++ b/packages/database/test/turso.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { turso } from '../src/runtime/deno'; + +describe('current Turso adapter', () => { + it('uses @tursodatabase/database for embedded SQL with request scoping', async () => { + const adapter = await turso({ path: ':memory:' }); + try { + expect(adapter.scope).toBeDefined(); + await adapter.driver.execute({ + text: 'CREATE TABLE counters (id INTEGER PRIMARY KEY, value INTEGER NOT NULL)', + parameters: [], + }); + await adapter.driver.execute({ + text: 'INSERT INTO counters (id, value) VALUES (?, ?)', parameters: [1, 4], + }); + const result = await adapter.driver.execute<{ value: number }>({ + text: 'SELECT value FROM counters WHERE id = ?', parameters: [1], + }, 'select'); + expect(result.rows).toEqual([{ value: 4 }]); + } finally { + await adapter.driver.close(); + } + }); +}); diff --git a/packages/deno-adapter/package.json b/packages/deno-adapter/package.json new file mode 100644 index 00000000..8be12325 --- /dev/null +++ b/packages/deno-adapter/package.json @@ -0,0 +1,34 @@ +{ + "name": "@cossackframework/deno-adapter", + "version": "0.8.1", + "type": "module", + "description": "Deno, Deno Deploy, and Deno Desktop adapter for Cossack", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./desktop": { "types": "./dist/desktop.d.ts", "import": "./dist/desktop.js" }, + "./desktop/client": { "types": "./dist/desktop-client.d.ts", "import": "./dist/desktop-client.js" } + }, + "files": ["dist"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "vite build && tsc -p tsconfig.declarations.json", + "test": "vitest --run" + }, + "dependencies": { + "@cossackframework/core": "workspace:*" + }, + "peerDependencies": { + "@cossackframework/framework": "workspace:*", + "hono": "^4.12.0" + }, + "devDependencies": { + "@cossackframework/framework": "workspace:*", + "hono": "^4.12.31", + "typescript": "^7.0.2", + "vite": "^8.1.4", + "vitest": "^4.1.10" + } +} diff --git a/packages/deno-adapter/src/desktop-client.ts b/packages/deno-adapter/src/desktop-client.ts new file mode 100644 index 00000000..47b8cc71 --- /dev/null +++ b/packages/deno-adapter/src/desktop-client.ts @@ -0,0 +1,60 @@ +import type { DesktopBindingRegistry } from './desktop.js'; + +type AwaitedReturn = Fn extends (...args: any[]) => infer Result ? Awaited : never; +type BindingArgs = Fn extends (...args: infer Args) => any ? Args : never; + +interface NativeBindings { + __cossackDesktopInvoke(token: string, name: string, args: unknown[]): Promise; +} + +export class DesktopUnavailableError extends Error { + constructor() { + super('Desktop bindings are unavailable in this web build.'); + this.name = 'DesktopUnavailableError'; + } +} + +export interface DesktopClient { + readonly available: boolean; + invoke>( + name: Name, + ...args: BindingArgs + ): Promise>; +} + +function runtimeDesktopMetadata(): { available?: boolean; capabilityToken?: string } | undefined { + return (globalThis as any).window?.__INITIAL_STATE__?.runtime?.desktop; +} + +function normalizeNativeError(error: unknown): Error { + if (error instanceof Error) return error; + if (error && typeof error === 'object') { + const value = error as { name?: unknown; message?: unknown; stack?: unknown }; + const normalized = new Error(typeof value.message === 'string' ? value.message : 'Desktop binding failed'); + normalized.name = typeof value.name === 'string' ? value.name : 'Error'; + if (typeof value.stack === 'string') normalized.stack = value.stack; + return normalized; + } + return new Error(String(error)); +} + +export function createDesktopClient(): DesktopClient { + const metadata = runtimeDesktopMetadata(); + const nativeBindings = (globalThis as any).bindings as NativeBindings | undefined; + const available = metadata?.available === true && typeof metadata.capabilityToken === 'string' && + typeof nativeBindings?.__cossackDesktopInvoke === 'function'; + + return { + available, + async invoke(name, ...args) { + if (!available) throw new DesktopUnavailableError(); + try { + return await nativeBindings!.__cossackDesktopInvoke( + metadata!.capabilityToken!, String(name), args, + ) as AwaitedReturn; + } catch (error) { + throw normalizeNativeError(error); + } + }, + }; +} diff --git a/packages/deno-adapter/src/desktop.ts b/packages/deno-adapter/src/desktop.ts new file mode 100644 index 00000000..26f814c3 --- /dev/null +++ b/packages/deno-adapter/src/desktop.ts @@ -0,0 +1,63 @@ +export type DesktopValue = + | undefined | null | boolean | number | string | Uint8Array + | DesktopValue[] | { [key: string]: DesktopValue }; + +export type DesktopBinding = (...args: any[]) => DesktopValue | void | Promise; +export type DesktopBindingRegistry = Record; + +export interface DesktopWindow { + bind(name: string, handler: (...args: any[]) => unknown): void; + unbind?(name: string): void; +} + +const DISPATCH_BINDING = '__cossackDesktopInvoke'; +const capabilityToken = crypto.randomUUID(); +let activeRegistry: DesktopBindingRegistry | undefined; +let mainWindow: DesktopWindow | undefined; + +function denoGlobal(): any { + return (globalThis as any).Deno; +} + +export function isDesktopRuntime(): boolean { + const deno = denoGlobal(); + return typeof deno?.BrowserWindow === 'function' + || typeof deno?.desktopVersion === 'string'; +} + +/** Define the allowlisted desktop surface and attach it to the startup window. */ +export function defineDesktopBindings(registry: Registry): Registry { + activeRegistry = Object.freeze({ ...registry }); + if (isDesktopRuntime()) { + const BrowserWindow = denoGlobal()?.BrowserWindow; + if (typeof BrowserWindow === 'function') { + const window = mainWindow ??= new BrowserWindow() as DesktopWindow; + attachDesktopBindings(window, activeRegistry); + } + } + return registry; +} + +/** Attach the current allowlist to an explicitly-created additional window. */ +export function attachDesktopBindings( + window: DesktopWindow, + registry: Registry, +): void { + const allowlist = Object.freeze({ ...registry }); + window.unbind?.(DISPATCH_BINDING); + window.bind(DISPATCH_BINDING, async (token: unknown, name: unknown, args: unknown) => { + if (token !== capabilityToken) throw new Error('Desktop capability token rejected'); + if (typeof name !== 'string' || !Object.prototype.hasOwnProperty.call(allowlist, name)) { + throw new Error(`Desktop binding '${String(name)}' is not registered`); + } + if (!Array.isArray(args)) throw new TypeError('Desktop binding arguments must be an array'); + return await allowlist[name]!(...args); + }); +} + +/** @internal Metadata injected into SSR state by the Deno runtime adapter. */ +export function getDesktopClientMetadata(): Record { + return activeRegistry && isDesktopRuntime() + ? { desktop: { available: true, capabilityToken } } + : { desktop: { available: false } }; +} diff --git a/packages/deno-adapter/src/index.ts b/packages/deno-adapter/src/index.ts new file mode 100644 index 00000000..d1875ee7 --- /dev/null +++ b/packages/deno-adapter/src/index.ts @@ -0,0 +1,174 @@ +import { Hono, type Context } from 'hono'; +import { serveStatic, upgradeWebSocket } from 'hono/deno'; +import { InMemoryWebSocketRuntime } from '@cossackframework/core'; +import type { CossackRuntimeAdapter, RuntimeWebSocketUpgrade } from '@cossackframework/framework/runtime-adapter'; +import { getDesktopClientMetadata } from './desktop.js'; + +export interface DenoAdapterOptions { + env?: Record; + assetsRoot?: string; + hostname?: string; + port?: number; + maxInstances?: number; + idleTimeoutMs?: number; +} + +interface DenoSocket { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +interface RuntimeEntry { + runtime: InMemoryWebSocketRuntime; + lastActive: number; +} + +export interface DenoApplication { + fetch(request: Request, env?: Record): Response | Promise; +} + +export interface DenoServer { + shutdown?(): Promise; + finished?: Promise; +} + +export interface CossackDenoAdapter extends CossackRuntimeAdapter { + fetch(app: DenoApplication, request: Request, env?: Record): Promise; + serve(app: DenoApplication): DenoServer; + readonly instanceCount: number; +} + +function denoGlobal(): any { + return (globalThis as any).Deno; +} + +export function createDenoAdapter(options: DenoAdapterOptions = {}): CossackDenoAdapter { + const instances = new Map(); + const fetchHandlers = new WeakMap, + ) => Promise>(); + const maxInstances = options.maxInstances ?? 512; + const idleTimeoutMs = options.idleTimeoutMs ?? 15 * 60_000; + + const getFetchHandler = (app: DenoApplication) => { + const cached = fetchHandlers.get(app); + if (cached) return cached; + + const root = options.assetsRoot ?? './dist/client'; + const assetsApp = new Hono(); + assetsApp.use('*', serveStatic({ root })); + const assets = { fetch: (request: Request) => assetsApp.fetch(request) }; + + const outer = new Hono(); + outer.use('*', serveStatic({ + root, + // Vite may emit an index.html, but Cossack owns document routing and SSR. + rewriteRequestPath: (pathname) => pathname === '/' ? '/__cossack_ssr__' : pathname, + })); + outer.all('*', (context) => app.fetch(context.req.raw, context.env as Record)); + + const handler = async (request: Request, requestEnv: Record = {}) => { + const env = { ...(options.env ?? {}), ...requestEnv, ASSETS: assets }; + return outer.fetch(request, env); + }; + fetchHandlers.set(app, handler); + return handler; + }; + + const prune = (reserveSlot = false) => { + const now = Date.now(); + for (const [key, entry] of instances) { + if (entry.runtime.clientCount === 0 && now - entry.lastActive >= idleTimeoutMs) instances.delete(key); + } + const targetSize = Math.max(0, maxInstances - (reserveSlot ? 1 : 0)); + if (instances.size <= targetSize) return; + const idle = [...instances.entries()] + .filter(([, entry]) => entry.runtime.clientCount === 0) + .sort((a, b) => a[1].lastActive - b[1].lastActive); + for (const [key] of idle) { + if (instances.size <= targetSize) break; + instances.delete(key); + } + }; + + const handleWebSocketUpgrade = async (context: Context, upgrade: RuntimeWebSocketUpgrade): Promise => { + let entryPromise: Promise | undefined; + const getEntry = async () => { + const key = `${upgrade.componentId}:${upgrade.provider}:${upgrade.target}`; + let entry = instances.get(key); + if (entry) { + entry.lastActive = Date.now(); + return entry; + } + prune(true); + if (instances.size >= maxInstances) { + throw new Error('Deno WebSocket instance limit reached'); + } + const component = await upgrade.createComponent(); + entry = { + runtime: new InMemoryWebSocketRuntime(component, { + onError: (error) => console.error('[Cossack] Ignoring malformed WebSocket message:', error), + }), + lastActive: Date.now(), + }; + instances.set(key, entry); + return entry; + }; + + const handler = upgradeWebSocket(() => ({ + async onOpen(_event, socket) { + const client = socket as unknown as DenoSocket; + try { + entryPromise ??= getEntry(); + const entry = await entryPromise; + entry.runtime.addClient(client, upgrade.user); + const state = entry.runtime.getInitialState(); + client.send(JSON.stringify({ type: 'state-update', state })); + } catch (error) { + console.error('[Cossack] Deno WebSocket upgrade failed:', error); + client.close(1013, 'Runtime unavailable'); + } + }, + async onMessage(event, socket) { + entryPromise ??= getEntry(); + const entry = await entryPromise; + entry.lastActive = Date.now(); + await entry.runtime.onClientMessage(socket as unknown as DenoSocket, String(event.data)); + }, + async onClose(_event, socket) { + if (!entryPromise) return; + const entry = await entryPromise.catch(() => undefined); + if (entry) { + entry.runtime.removeClient(socket as unknown as DenoSocket); + entry.lastActive = Date.now(); + } + prune(); + }, + })); + const response = await handler(context as any, async () => {}); + return response as Response; + }; + + return { + name: 'deno', + get instanceCount() { return instances.size; }, + getClientMetadata: () => getDesktopClientMetadata(), + handleWebSocketUpgrade, + fetch(app, request, env) { + return getFetchHandler(app)(request, env); + }, + serve(app) { + const deno = denoGlobal(); + if (!deno?.serve) throw new Error('createDenoAdapter().serve() requires Deno 2.9 or newer.'); + const serveOptions = { + ...(options.hostname ? { hostname: options.hostname } : {}), + ...(options.port !== undefined ? { port: options.port } : {}), + }; + return deno.serve(serveOptions, (request: Request) => getFetchHandler(app)(request)); + }, + }; +} + +export type { CossackRuntimeAdapter, RuntimeWebSocketUpgrade } from '@cossackframework/framework/runtime-adapter'; diff --git a/packages/deno-adapter/tests/adapter.test.ts b/packages/deno-adapter/tests/adapter.test.ts new file mode 100644 index 00000000..833c8795 --- /dev/null +++ b/packages/deno-adapter/tests/adapter.test.ts @@ -0,0 +1,50 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { DenoApplication } from '../src/index'; + +let createDenoAdapter: typeof import('../src/index').createDenoAdapter; + +beforeAll(async () => { + class NotFound extends Error {} + (globalThis as any).Deno = { + errors: { NotFound }, + lstatSync: () => { throw new NotFound(); }, + open: async () => { throw new NotFound(); }, + }; + ({ createDenoAdapter } = await import('../src/index')); + delete (globalThis as any).Deno; +}); + +describe('Deno adapter fetch handler', () => { + it('merges configured and request env and injects an ASSETS binding', async () => { + const adapter = createDenoAdapter({ env: { SHARED: 'configured', CONFIGURED: true } }); + const app: DenoApplication = { + async fetch(_request, env = {}) { + const assets = env.ASSETS as { fetch(request: Request): Promise }; + const missing = await assets.fetch(new Request('http://localhost/not-an-asset')); + return Response.json({ + shared: env.SHARED, + configured: env.CONFIGURED, + requestOnly: env.REQUEST_ONLY, + assetStatus: missing.status, + }); + }, + }; + + const response = await adapter.fetch(app, new Request('http://localhost/data'), { + SHARED: 'request', + REQUEST_ONLY: 42, + }); + + expect(await response.json()).toEqual({ + shared: 'request', + configured: true, + requestOnly: 42, + assetStatus: 404, + }); + }); + + it('requires Deno 2.9+ to start a local server', () => { + expect(() => createDenoAdapter().serve({ fetch: () => new Response() })) + .toThrow('requires Deno 2.9 or newer'); + }); +}); diff --git a/packages/deno-adapter/tests/deno-smoke.test.ts b/packages/deno-adapter/tests/deno-smoke.test.ts new file mode 100644 index 00000000..a6077219 --- /dev/null +++ b/packages/deno-adapter/tests/deno-smoke.test.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono'; +import { createDenoAdapter } from '../src/index.ts'; + +Deno.test('serves HTTP and upgrades a real WebSocket', async () => { + let action: { name: string; payload: unknown[]; user: unknown } | undefined; + let resolveAction!: () => void; + const actionReceived = new Promise((resolve) => { resolveAction = resolve; }); + const component = { + _id: 'root', + activeComponents: new Map(), + getInitialState: () => ({ public: { count: 0 } }), + executeAction: async (name: string, payload: unknown[], user: unknown) => { + action = { name, payload, user }; + resolveAction(); + }, + }; + + const adapter = createDenoAdapter({ port: 0, assetsRoot: './missing' }); + const app = new Hono(); + app.get('/health', (c) => c.text('ok')); + app.get('/ws', (c) => adapter.handleWebSocketUpgrade!(c, { + target: 'scope', provider: 'page', componentId: 'counter', pathname: '/', + user: { id: 'smoke-user' }, env: {}, createComponent: async () => component as any, + })); + + const server = adapter.serve(app) as any; + const port = server.addr.port; + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (await response.text() !== 'ok') throw new Error('HTTP smoke response mismatch'); + + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('WebSocket smoke connection failed')); + socket.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (message.type !== 'state-update') return; + socket.send(JSON.stringify({ type: 'action', action: 'increment', payload: [2] })); + resolve(); + }; + }); + await actionReceived; + if (action?.name !== 'increment' || action.payload[0] !== 2 || + (action.user as any)?.id !== 'smoke-user') { + throw new Error('WebSocket action dispatch mismatch'); + } + socket.close(); + } finally { + await server.shutdown(); + } +}); diff --git a/packages/deno-adapter/tests/desktop.test.ts b/packages/deno-adapter/tests/desktop.test.ts new file mode 100644 index 00000000..9b84e505 --- /dev/null +++ b/packages/deno-adapter/tests/desktop.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { attachDesktopBindings, defineDesktopBindings, getDesktopClientMetadata, isDesktopRuntime } from '../src/desktop'; +import { createDesktopClient, DesktopUnavailableError } from '../src/desktop-client'; + +class FakeWindow { + handlers = new Map unknown>(); + bind(name: string, handler: (...args: any[]) => unknown) { this.handlers.set(name, handler); } + unbind(name: string) { this.handlers.delete(name); } +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete (globalThis as any).bindings; +}); + +describe('desktop bindings', () => { + it('registers an allowlist per window and rejects invalid capabilities', async () => { + const registry = defineDesktopBindings({ + add: (a: number, b: number) => a + b, + bytes: (value: Uint8Array) => value, + }); + const window = new FakeWindow(); + attachDesktopBindings(window, registry); + const dispatch = window.handlers.get('__cossackDesktopInvoke')!; + await expect(dispatch('wrong', 'add', [1, 2])).rejects.toThrow('token rejected'); + vi.stubGlobal('Deno', { desktopVersion: '2.9.0' }); + const metadata = getDesktopClientMetadata() as any; + await expect(dispatch(metadata.desktop.capabilityToken, 'add', [1, 2])).resolves.toBe(3); + const bytes = new Uint8Array([1, 2]); + await expect(dispatch(metadata.desktop.capabilityToken, 'bytes', [bytes])).resolves.toBe(bytes); + await expect(dispatch(metadata.desktop.capabilityToken, 'missing', [])).rejects.toThrow('not registered'); + }); + + it('is unavailable in a normal browser and normalizes native errors', async () => { + vi.stubGlobal('window', { __INITIAL_STATE__: {} }); + const unavailable = createDesktopClient<{ fail(): never }>(); + expect(unavailable.available).toBe(false); + await expect(unavailable.invoke('fail')).rejects.toBeInstanceOf(DesktopUnavailableError); + + (globalThis as any).window.__INITIAL_STATE__ = { + runtime: { desktop: { available: true, capabilityToken: 'token' } }, + }; + (globalThis as any).bindings = { + __cossackDesktopInvoke: async () => { throw { name: 'NativeFailure', message: 'boom' }; }, + }; + const available = createDesktopClient<{ fail(): never }>(); + expect(available.available).toBe(true); + await expect(available.invoke('fail')).rejects.toMatchObject({ name: 'NativeFailure', message: 'boom' }); + }); + + it('detects Deno Desktop from its window API or a configured desktop version', () => { + expect(isDesktopRuntime()).toBe(false); + vi.stubGlobal('Deno', { BrowserWindow: class {} }); + expect(isDesktopRuntime()).toBe(true); + vi.stubGlobal('Deno', {}); + expect(isDesktopRuntime()).toBe(false); + vi.stubGlobal('Deno', { desktopVersion: '2.9.0' }); + expect(isDesktopRuntime()).toBe(true); + }); +}); diff --git a/packages/deno-adapter/tsconfig.declarations.json b/packages/deno-adapter/tsconfig.declarations.json new file mode 100644 index 00000000..6fdb2024 --- /dev/null +++ b/packages/deno-adapter/tsconfig.declarations.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "composite": false, + "incremental": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["tests"] +} diff --git a/packages/deno-adapter/tsconfig.json b/packages/deno-adapter/tsconfig.json new file mode 100644 index 00000000..fe7e1e7a --- /dev/null +++ b/packages/deno-adapter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "moduleResolution": "Bundler", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"] +} diff --git a/packages/deno-adapter/vite.config.ts b/packages/deno-adapter/vite.config.ts new file mode 100644 index 00000000..01955fae --- /dev/null +++ b/packages/deno-adapter/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import path from 'node:path'; + +export default defineConfig({ + build: { + lib: { + entry: { + index: path.resolve(__dirname, 'src/index.ts'), + desktop: path.resolve(__dirname, 'src/desktop.ts'), + 'desktop-client': path.resolve(__dirname, 'src/desktop-client.ts'), + }, + formats: ['es'], + }, + outDir: 'dist', + rolldownOptions: { + external: ['@cossackframework/core', '@cossackframework/framework/runtime-adapter', 'hono', 'hono/deno'], + }, + }, +}); diff --git a/packages/deno-adapter/vitest.config.ts b/packages/deno-adapter/vitest.config.ts new file mode 100644 index 00000000..0dd40b78 --- /dev/null +++ b/packages/deno-adapter/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + exclude: ['tests/deno-smoke.test.ts'], + }, +}); diff --git a/packages/framework/package.json b/packages/framework/package.json index 731f7ee2..63b6e7c1 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -54,6 +54,10 @@ "types": "./dist/esm/router.d.ts", "import": "./dist/esm/router.js" }, + "./runtime-adapter": { + "types": "./dist/esm/runtime-adapter.d.ts", + "import": "./dist/esm/runtime-adapter.js" + }, "./root": { "types": "./dist/esm/root.d.ts", "import": "./dist/esm/root.js" diff --git a/packages/framework/src/public.ts b/packages/framework/src/public.ts index f1f94093..a27971ff 100644 --- a/packages/framework/src/public.ts +++ b/packages/framework/src/public.ts @@ -5,5 +5,6 @@ * applications create and export their own runtime handler from src/index.ts. */ export * from './router.js'; +export * from './runtime-adapter.js'; export { AppDurableObject } from './DurableObject.js'; export { CacheDurableObject } from './cache.js'; diff --git a/packages/framework/src/router.ts b/packages/framework/src/router.ts index eb129948..233f20d4 100644 --- a/packages/framework/src/router.ts +++ b/packages/framework/src/router.ts @@ -7,6 +7,7 @@ import { createLayoutServiceScope, createRootServiceScope, getServiceState, + isOriginAllowed, isRpcCallableAction, sanitizeClientState, sanitizeServiceState, @@ -56,6 +57,8 @@ import { createRequestContextMiddleware } from './middlewares/request-context.js import { createCorsMiddleware } from './middlewares/cors.js'; import { getLocale, getLocaleCatalog, getDefaultLocale } from '@cossackframework/core'; import { runWithConfig, buildConfig, type EnvFunction } from './config.js'; +import { assertRuntimeTransportSupport, type CossackRuntimeAdapter } from './runtime-adapter.js'; +import { decodeRuntimeRouteParams, withRuntimeRouteParams } from './runtime-websocket.js'; // Side-effect: register the i18n helpers (`__`, `setLocale`, ...) on // `globalThis` so bare `__('key')` calls in `render()` resolve during SSR. @@ -322,6 +325,8 @@ export interface CreateAppOptions { i18n?: { autoDetectBrowser?: boolean; }; + /** Optional process runtime integration (for example the Deno adapter). */ + runtimeAdapter?: CossackRuntimeAdapter; } @Page({ transport: 'http' }) @@ -415,7 +420,8 @@ export function createApp(options: CreateAppOptions = {}) { if (pageOptions?.transport === 'durable-object' && pageOptions?.scope) { doIdName = scopeKey; } - if (pageOptions?.transport === 'durable-object' && pageOptions?.stateful === true) { + assertRuntimeTransportSupport(options.runtimeAdapter, pageOptions); + if (!options.runtimeAdapter && pageOptions?.transport === 'durable-object' && pageOptions?.stateful === true) { try { const doBinding = c.env.COSSACK_OBJECT; const id = doBinding.idFromName(doIdName); @@ -534,7 +540,13 @@ export function createApp(options: CreateAppOptions = {}) { // For durable-object transport, add the DO ID to providerTargets // Also add routePath to metadata for client WebSocket connections - if (pageOptions?.transport === 'durable-object') { + if (pageOptions?.transport === 'durable-object' && options.runtimeAdapter) { + pageInitialState.providerTargets = { + ...(pageInitialState.providerTargets || {}), + page: scopeKey, + }; + if (pageInitialState.metadata) pageInitialState.metadata.routePath = filePathToRoutePath(path); + } else if (pageOptions?.transport === 'durable-object') { const doBinding = c.env.COSSACK_OBJECT; // Use scoped ID (from scope function) or URL-based ID (default) const doId = doBinding.idFromName(doIdName); @@ -568,6 +580,12 @@ export function createApp(options: CreateAppOptions = {}) { // fallback if different) so `__()` works on the client immediately. // Other locales are dynamic-imported on demand by `setLocale()`. __cossackLang: buildLocaleHydrationData(), + ...(options.runtimeAdapter ? { + runtime: { + adapter: options.runtimeAdapter.name, + ...(await options.runtimeAdapter.getClientMetadata?.()), + }, + } : {}), }; c.header('Content-Type', 'text/html'); @@ -609,7 +627,58 @@ export function createApp(options: CreateAppOptions = {}) { }; // Transport routes - app.get('/ws/:provider/:id', handleWebSocketProxy(routerContext)); + if (options.runtimeAdapter?.handleWebSocketUpgrade) { + app.get('/ws/:provider/:id', async (c) => { + if (!isOriginAllowed(c.req.header('origin'), c.req.url, options.allowedOrigins)) { + return c.text('Origin not allowed', 403); + } + const { provider, id: target } = c.req.param(); + const routePath = c.req.query('routePath') || c.req.query('componentPath'); + if (!routePath) return c.text('routePath or componentPath query parameter is required', 400); + const componentPath = routePathToFilePathMap.get(routePath) || routePath; + const componentModule = pages[componentPath] || layouts[componentPath]; + if (!componentModule) return c.text('Component not found', 404); + const ComponentClass = Object.values(componentModule as object)[0] as new () => Cossack; + const pageOptions = Reflect.getMetadata('page:options', ComponentClass) as PageOptions | undefined; + try { + assertRuntimeTransportSupport(options.runtimeAdapter, pageOptions); + } catch (error) { + return c.text(error instanceof Error ? error.message : String(error), 400); + } + // Default scopes are recomputed from the authenticated user. Custom + // scope functions receive the same query values emitted during SSR. + let routeParams: Record; + try { + routeParams = decodeRuntimeRouteParams(c.req.query('params')); + } catch { + return c.text('Invalid WebSocket route params', 400); + } + const expectedTarget = await resolveSseScopeKey( + withRuntimeRouteParams(c, routeParams), + pageOptions, + ); + if (target !== expectedTarget) return c.text('Invalid WebSocket scope', 403); + + const pathname = c.req.query('pathname') || '/'; + const user = c.get('user'); + return options.runtimeAdapter!.handleWebSocketUpgrade!(c, { + target, + provider, + componentId: componentPath, + pathname, + user, + env: c.env as unknown as Record, + createComponent: async () => { + const instance = createInstance(ComponentClass) as Cossack; + await instance.bootstrap({ context: c, user, env: c.env, page: pathname, providerName: provider }); + instance._render(); + return instance; + }, + }); + }); + } else { + app.get('/ws/:provider/:id', handleWebSocketProxy(routerContext)); + } app.get('/sse/:componentRouteId', handleSseEndpoint(routerContext)); app.post('/upload', handleUpload(routerContext)); diff --git a/packages/framework/src/runtime-adapter.ts b/packages/framework/src/runtime-adapter.ts new file mode 100644 index 00000000..231be1fc --- /dev/null +++ b/packages/framework/src/runtime-adapter.ts @@ -0,0 +1,36 @@ +import type { Context } from 'hono'; +import type { Cossack } from '@cossackframework/core'; +import type { PageOptions } from '@cossackframework/core'; + +export interface RuntimeWebSocketUpgrade { + /** Process-local instance key computed by the framework during SSR. */ + target: string; + provider: string; + componentId: string; + pathname: string; + user?: unknown; + env: Record; + createComponent(): Promise; +} + +/** Runtime extension point. Routing, auth, origin checks and scope remain framework-owned. */ +export interface CossackRuntimeAdapter { + readonly name: string; + getClientMetadata?(): Record | Promise>; + handleWebSocketUpgrade?( + context: Context, + upgrade: RuntimeWebSocketUpgrade, + ): Response | Promise; +} + +export function assertRuntimeTransportSupport( + adapter: CossackRuntimeAdapter | undefined, + pageOptions: PageOptions | undefined, +): void { + if (adapter && pageOptions?.transport === 'durable-object' && pageOptions.stateful === true) { + throw new Error( + `[Cossack] ${adapter.name} WebSockets are process-local and do not support stateful: true. ` + + 'Persist durable state through a database or deploy with Cloudflare Durable Objects.', + ); + } +} diff --git a/packages/framework/src/runtime-websocket.ts b/packages/framework/src/runtime-websocket.ts new file mode 100644 index 00000000..50ad8516 --- /dev/null +++ b/packages/framework/src/runtime-websocket.ts @@ -0,0 +1,38 @@ +import type { Context } from 'hono'; + +export function decodeRuntimeRouteParams(encoded: string | undefined): Record { + if (encoded === undefined) return {}; + const parsed: unknown = JSON.parse(encoded); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new TypeError('WebSocket route params must be a JSON object'); + } + const params: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') throw new TypeError('WebSocket route params must contain strings'); + params[key] = value; + } + return params; +} + +/** Give a custom page scope the original page params while retaining the live request context. */ +export function withRuntimeRouteParams( + context: Context, + params: Record, +): Context { + const request = new Proxy(context.req, { + get(target, property, receiver) { + if (property === 'param') { + return (key?: string) => key === undefined ? { ...params } : params[key]; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return new Proxy(context, { + get(target, property, receiver) { + if (property === 'req') return request; + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} diff --git a/packages/framework/tests/runtime-adapter.test.ts b/packages/framework/tests/runtime-adapter.test.ts new file mode 100644 index 00000000..6a5c4cea --- /dev/null +++ b/packages/framework/tests/runtime-adapter.test.ts @@ -0,0 +1,22 @@ +import 'reflect-metadata'; +import { describe, expect, it } from 'vitest'; +import { assertRuntimeTransportSupport, type CossackRuntimeAdapter } from '../src/runtime-adapter'; + +describe('runtime adapter contract', () => { + const deno = { name: 'deno' } satisfies CossackRuntimeAdapter; + + it('accepts process-local WebSockets and rejects durable state', () => { + expect(() => assertRuntimeTransportSupport(deno, { + transport: 'durable-object', stateful: false, + })).not.toThrow(); + expect(() => assertRuntimeTransportSupport(deno, { + transport: 'durable-object', stateful: true, + })).toThrow('process-local'); + }); + + it('does not alter Cloudflare behavior without an adapter', () => { + expect(() => assertRuntimeTransportSupport(undefined, { + transport: 'durable-object', stateful: true, + })).not.toThrow(); + }); +}); diff --git a/packages/framework/tests/runtime-websocket.test.ts b/packages/framework/tests/runtime-websocket.test.ts new file mode 100644 index 00000000..583ec370 --- /dev/null +++ b/packages/framework/tests/runtime-websocket.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { decodeRuntimeRouteParams, withRuntimeRouteParams } from '../src/runtime-websocket'; + +describe('runtime WebSocket route params', () => { + it('decodes string-only route params and rejects malformed frames', () => { + expect(decodeRuntimeRouteParams('{"team":"cossack"}')).toEqual({ team: 'cossack' }); + expect(() => decodeRuntimeRouteParams('{')).toThrow(); + expect(() => decodeRuntimeRouteParams('["cossack"]')).toThrow('JSON object'); + expect(() => decodeRuntimeRouteParams('{"team":1}')).toThrow('contain strings'); + }); + + it('exposes original page params without replacing auth or query access', () => { + const get = vi.fn(() => ({ id: 'user-1' })); + const query = vi.fn((key?: string) => key === 'filter' ? 'active' : {}); + const context = { + get, + req: { param: () => ({ provider: 'page', id: 'forged' }), query }, + } as any; + const scoped = withRuntimeRouteParams(context, { team: 'cossack' }); + + expect(scoped.req.param()).toEqual({ team: 'cossack' }); + expect(scoped.req.param('team')).toBe('cossack'); + expect(scoped.req.query('filter')).toBe('active'); + expect(scoped.get('user')).toEqual({ id: 'user-1' }); + }); +}); diff --git a/packages/node-adapter/src/index.ts b/packages/node-adapter/src/index.ts index 40823d25..49cf9443 100644 --- a/packages/node-adapter/src/index.ts +++ b/packages/node-adapter/src/index.ts @@ -43,17 +43,28 @@ export interface CossackNodeAdapterOptions { * `this.env.EMAIL.send(...)` call works on both runtimes. */ env?: Record; + /** Maximum number of process-local component instances. Defaults to 512. */ + maxInstances?: number; + /** Evict disconnected instances after this idle period. Defaults to 15 minutes. */ + idleTimeoutMs?: number; +} + +interface NodeRuntimeEntry { + runtime: NodeWebSocketRuntime; + lastActive: number; } export class CossackNodeAdapter { private wss: WebSocketServer; // Map of target ID -> Runtime instance - private instances: Map = new Map(); + private instances: Map = new Map(); private componentRegistry: Map Cossack>; private allowedOrigins?: string[]; private authenticate?: (request: IncomingMessage) => Promise | unknown; private defaultUser: unknown; private env?: Record; + private maxInstances: number; + private idleTimeoutMs: number; constructor(options: CossackNodeAdapterOptions) { this.wss = new WebSocketServer({ noServer: true }); @@ -62,6 +73,8 @@ export class CossackNodeAdapter { this.authenticate = options.authenticate; this.defaultUser = options.defaultUser ?? { id: 'anonymous' }; this.env = options.env; + this.maxInstances = options.maxInstances ?? 512; + this.idleTimeoutMs = options.idleTimeoutMs ?? 15 * 60_000; options.server.on('upgrade', (request: IncomingMessage, socket: any, head: any) => { const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname; @@ -95,9 +108,16 @@ export class CossackNodeAdapter { return; } - let runtime = this.instances.get(target); + const instanceKey = `${componentId}:${provider ?? 'page'}:${target}`; + this.pruneInstances(true); + let entry = this.instances.get(instanceKey); + let runtime = entry?.runtime; if (!runtime) { + if (this.instances.size >= this.maxInstances) { + ws.close(1013, 'Runtime instance limit reached'); + return; + } const ComponentClass = this.componentRegistry.get(componentId); if (!ComponentClass) { ws.close(1008, 'Component not found'); @@ -140,8 +160,10 @@ export class CossackNodeAdapter { // init() and get() are now automatically called during bootstrap runtime = new NodeWebSocketRuntime(componentInstance); - this.instances.set(target, runtime); + entry = { runtime, lastActive: Date.now() }; + this.instances.set(instanceKey, entry); } + entry!.lastActive = Date.now(); // Resolve the connecting user via the authenticate hook (cookies, // JWT, etc.) so per-user state/authorization works. Without a hook, @@ -163,4 +185,22 @@ export class CossackNodeAdapter { ws.send(JSON.stringify({ type: 'state-update', state: initialState })); }); } + + private pruneInstances(reserveSlot = false): void { + const now = Date.now(); + for (const [key, entry] of this.instances) { + if (entry.runtime.clientCount === 0 && now - entry.lastActive >= this.idleTimeoutMs) { + this.instances.delete(key); + } + } + const targetSize = Math.max(0, this.maxInstances - (reserveSlot ? 1 : 0)); + if (this.instances.size <= targetSize) return; + const idle = [...this.instances.entries()] + .filter(([, entry]) => entry.runtime.clientCount === 0) + .sort((a, b) => a[1].lastActive - b[1].lastActive); + for (const [key] of idle) { + if (this.instances.size <= targetSize) break; + this.instances.delete(key); + } + } } diff --git a/packages/node-adapter/src/runtime.ts b/packages/node-adapter/src/runtime.ts index 4f8e2725..e078b607 100644 --- a/packages/node-adapter/src/runtime.ts +++ b/packages/node-adapter/src/runtime.ts @@ -1,80 +1,23 @@ import { WebSocket } from 'ws'; -import type { Cossack, CossackServerRuntime } from '@cossackframework/core'; - -export class NodeWebSocketRuntime implements CossackServerRuntime { - private component: Cossack; - private clients: Set = new Set(); +import { InMemoryWebSocketRuntime, type Cossack } from '@cossackframework/core'; +export class NodeWebSocketRuntime extends InMemoryWebSocketRuntime { constructor(component: Cossack) { - this.component = component; - (this.component as any)._runtime = this; + super(component, { + isOpen: (client) => client.readyState === WebSocket.OPEN, + onError: (error) => console.error('[Cossack] Ignoring malformed WebSocket message:', error), + }); } - public addClient(ws: WebSocket, user?: any) { - this.clients.add(ws); - - // Attach user to the websocket object for later retrieval if needed, - // similar to how Cloudflare's WebSocket attachment works, - // though strictly typing this would require extending WebSocket. - (ws as any).user = user; - + public override addClient(ws: WebSocket, user?: unknown) { + super.addClient(ws, user); ws.on('close', () => { - this.clients.delete(ws); + this.removeClient(ws); }); - ws.on('message', (data) => { - this.onClientMessage(ws, data.toString()).catch((e) => { - console.error('[Cossack] Error handling Node WebSocket message:', e); - }); + this.onClientMessage(ws, data.toString()).catch((error) => { + console.error('[Cossack] Error handling Node WebSocket message:', error); + }); }); } - - async onClientMessage(client: unknown, message: string): Promise { - const ws = client as WebSocket; - let data: any; - try { - data = JSON.parse(message); - } catch (e) { - console.error('[Cossack] Ignoring malformed WebSocket message:', e); - return; - } - if (!data || typeof data !== 'object' || data.type !== 'action' || - typeof data.action !== 'string' || !Array.isArray(data.payload)) { - return; - } - - const user = (ws as any).user; - await this.component.executeAction(data.action, data.payload, user, client); - } - - broadcastState(partialState: Record): void { - const message = JSON.stringify({ type: 'state-update', state: partialState }); - for (const client of this.clients) { - if (client.readyState === WebSocket.OPEN) { - client.send(message); - } - } - } - - broadcastEvent(eventName: string, payload: any[]): void { - const message = JSON.stringify({ type: 'event', eventName, payload }); - for (const client of this.clients) { - if (client.readyState === WebSocket.OPEN) { - client.send(message); - } - } - } - - sendClientAction(client: unknown, action: string, payload: any[]): void { - const ws = client as WebSocket; - const message = JSON.stringify({ type: 'client-action', action, payload }); - if (ws.readyState === WebSocket.OPEN) { - ws.send(message); - } - } - - async persistState(): Promise { - // TODO: Implement pluggable persistence for Node.js (e.g., Redis, file system, DB) - // For now, state is just in-memory in the component instance. - } } diff --git a/packages/scaffold/package.json b/packages/scaffold/package.json index db48b74d..558ed405 100644 --- a/packages/scaffold/package.json +++ b/packages/scaffold/package.json @@ -27,7 +27,8 @@ "scaffold": { "dependencyVersions": { "@cloudflare/vite-plugin": "^1.48.0", - "@libsql/client": "^0.15.15", + "@tursodatabase/database": "^0.7.2", + "@tursodatabase/serverless": "^1.4.0", "@cossackframework/solar-icons": "^0.7.1", "@cossackframework/studio": "^0.8.1", "@hono/node-server": "^1.13.0", diff --git a/packages/scaffold/src/index.d.ts b/packages/scaffold/src/index.d.ts index ba556cac..73778c76 100644 --- a/packages/scaffold/src/index.d.ts +++ b/packages/scaffold/src/index.d.ts @@ -1,11 +1,12 @@ -export type Adapter = 'cloudflare' | 'node'; +export type Adapter = 'cloudflare' | 'node' | 'deno'; export type Preset = 'minimal' | 'database' | 'auth' | 'full-stack'; -export type Feature = 'ui' | 'database' | 'studio' | 'auth' | 'dashboard' | 'markdown' | 'examples'; +export type Feature = 'ui' | 'database' | 'studio' | 'auth' | 'dashboard' | 'markdown' | 'examples' | 'desktop'; export type AuthMethod = 'credentials' | 'oauth'; -export type DatabaseProvider = 'd1' | 'sqlite' | 'turso'; +export type DatabaseProvider = 'd1' | 'sqlite' | 'turso' | 'postgres' | 'mysql' | 'hyperdrive-postgres' | 'hyperdrive-mysql'; export type OAuthProvider = 'github' | 'google' | 'gitlab' | 'facebook' | 'microsoft'; export type UITheme = 'default' | 'neutral' | 'zinc' | 'stone' | 'gray' | 'slate' | 'blue' | 'green' | 'red'; export type DashboardModule = 'users' | 'sessions' | 'settings' | 'roles'; +export type DesktopBackend = 'webview' | 'cef'; export interface ScaffoldRecipe { adapter: Adapter; @@ -18,6 +19,7 @@ export interface ScaffoldRecipe { authMethods: AuthMethod[]; oauth: OAuthProvider[]; theme: UITheme; + desktopBackend?: DesktopBackend; }; } @@ -32,6 +34,7 @@ export interface CreateAppOptions { theme?: UITheme; dashboardModules?: DashboardModule[] | string; dashboardFeatures?: DashboardModule[] | string; + desktopBackend?: DesktopBackend; interactive?: boolean; confirm?: boolean; yes?: boolean; @@ -59,6 +62,7 @@ export declare const AUTH_METHODS: readonly AuthMethod[]; export declare const OAUTH_PROVIDERS: readonly OAuthProvider[]; export declare const UI_THEMES: readonly UITheme[]; export declare const DASHBOARD_MODULES: readonly DashboardModule[]; +export declare const DESKTOP_BACKENDS: readonly DesktopBackend[]; export declare const FEATURE_REGISTRY: Record; export declare const PRESET_REGISTRY: Record; export declare const DATABASE_PROVIDERS: Record; diff --git a/packages/scaffold/src/index.js b/packages/scaffold/src/index.js index 5fb00a23..9576487c 100644 --- a/packages/scaffold/src/index.js +++ b/packages/scaffold/src/index.js @@ -11,6 +11,7 @@ import { OAUTH_PROVIDERS, UI_THEMES, DASHBOARD_MODULES, + DESKTOP_BACKENDS, FEATURE_REGISTRY, PRESET_REGISTRY, DATABASE_PROVIDERS, @@ -28,6 +29,7 @@ export { OAUTH_PROVIDERS, UI_THEMES, DASHBOARD_MODULES, + DESKTOP_BACKENDS, FEATURE_REGISTRY, PRESET_REGISTRY, DATABASE_PROVIDERS, @@ -64,6 +66,7 @@ const PNPM_MANAGED_BUILDS = new Set([ const ADAPTER_PATHS = new Set([ '.env.example', '.dev.vars.example', + 'deno.json', 'package.json', 'scripts/dev.js', 'orm.config.ts', @@ -96,8 +99,8 @@ const TRANSFERRED_ENV_NAMES = new Set([ 'SMTP_USER', 'SMTP_PASS', 'OAUTH_SECRET', - 'TURSO_URL', - 'TURSO_TOKEN', + 'TURSO_DATABASE_URL', + 'TURSO_AUTH_TOKEN', ...OAUTH_PROVIDERS.flatMap((provider) => { const prefix = provider.toUpperCase(); return [`${prefix}_CLIENT_ID`, `${prefix}_CLIENT_SECRET`]; @@ -298,6 +301,7 @@ const EXAMPLE_PATHS = new Set([ 'src/pages/(public)/index.ts', 'src/pages/(public)/layout.ts', ]); +const DESKTOP_PATHS = new Set(['src/desktop/index.ts']); function capabilityFor(rel, recipe) { if (BASE_PATHS.has(rel) || rel.startsWith('public/') || rel === 'tsconfig.json') return 'base'; @@ -314,6 +318,7 @@ function capabilityFor(rel, recipe) { if (paths.includes(rel)) return recipe.dashboardModules.includes(module) ? `dashboard:${module}` : null; } if (EXAMPLE_PATHS.has(rel)) return recipe.resolvedFeatures.includes('examples') ? 'examples' : null; + if (DESKTOP_PATHS.has(rel)) return recipe.resolvedFeatures.includes('desktop') ? 'desktop' : null; return null; } @@ -433,7 +438,13 @@ function packageJson(recipe, projectName) { dependencies['@cossackframework/database'] = `^${templateVersion}`; dependencies['reflect-metadata'] = '^0.2.2'; if (recipe.config.database === 'turso') { - dependencies['@libsql/client'] = dependencyVersion('@libsql/client'); + if (recipe.adapter === 'deno' && recipe.resolvedFeatures.includes('desktop')) { + dependencies['@tursodatabase/database'] = dependencyVersion('@tursodatabase/database'); + } else { + dependencies['@tursodatabase/serverless'] = dependencyVersion('@tursodatabase/serverless'); + } + } else if (recipe.adapter === 'deno' && recipe.config.database === 'sqlite') { + dependencies['@tursodatabase/database'] = dependencyVersion('@tursodatabase/database'); } else if ( recipe.config.database === 'postgres' || recipe.config.database === 'hyperdrive-postgres' @@ -451,6 +462,8 @@ function packageJson(recipe, projectName) { dependencies['@cossackframework/node-adapter'] = `^${templateVersion}`; dependencies['@hono/node-server'] = dependencyVersion('@hono/node-server'); dependencies.ws = dependencyVersion('ws'); + } else if (recipe.adapter === 'deno') { + dependencies['@cossackframework/deno-adapter'] = `^${templateVersion}`; } const devDependencies = { '@types/node': dependencyVersion('@types/node'), @@ -461,6 +474,7 @@ function packageJson(recipe, projectName) { vite: dependencyVersion('vite'), vitest: dependencyVersion('vitest'), }; + if (recipe.adapter === 'deno') devDependencies['@types/deno'] = '^2.3.0'; if (recipe.resolvedFeatures.includes('studio')) { devDependencies['@cossackframework/studio'] = `^${templateVersion}`; } @@ -486,7 +500,7 @@ function packageJson(recipe, projectName) { devDependencies['@cloudflare/vite-plugin'] = dependencyVersion('@cloudflare/vite-plugin'); devDependencies.wrangler = dependencyVersion('wrangler'); - } else { + } else if (recipe.adapter === 'node') { devDependencies['@types/ws'] = dependencyVersion('@types/ws'); if ( recipe.resolvedFeatures.includes('database') && @@ -502,7 +516,18 @@ function packageJson(recipe, projectName) { build: 'vite build && vite build --ssr src/index.ts --outDir dist/server', start: 'node --env-file-if-exists=.env dist/server/index.js', } - : { + : recipe.adapter === 'deno' + ? { + dev: 'vite dev', + build: 'vite build && vite build --ssr src/index.ts --outDir dist/server', + start: 'deno run --allow-env --allow-net --allow-read dist/server/index.js', + deploy: 'deno task build && deno deploy', + ...(recipe.resolvedFeatures.includes('desktop') ? { + 'desktop:dev': 'deno desktop --hmr .', + 'desktop:build': 'deno task build && deno desktop .', + } : {}), + } + : { dev: 'vite dev', build: 'vite build', 'build:ssg': 'vite build && cossack ssg', @@ -511,7 +536,9 @@ function packageJson(recipe, projectName) { if (recipe.resolvedFeatures.includes('database')) { scripts.migrate = recipe.adapter === 'node' ? 'node --env-file-if-exists=.env ./node_modules/cossack/bin/cossack.js migration up' - : 'cossack migration up'; + : recipe.adapter === 'deno' + ? 'deno run -A npm:cossack migration up' + : 'cossack migration up'; scripts['schema:check'] = 'cossack schema check'; } if (recipe.resolvedFeatures.includes('studio')) { @@ -539,18 +566,61 @@ function pnpmWorkspace(recipe) { function ormFactory(recipe) { const provider = recipe.config.database; + if (recipe.adapter === 'deno') { + const adapterImport = provider === 'sqlite' + ? 'denoSQLite' + : provider === 'turso' + ? 'turso' + : provider; + const adapterExpression = provider === 'sqlite' + ? "denoSQLite({ filename: env.DB_PATH ?? './database.sqlite' })" + : provider === 'turso' + ? recipe.resolvedFeatures.includes('desktop') + ? "turso({ path: env.DB_PATH ?? './database.turso' })" + : `turso({ + url: required(env.TURSO_DATABASE_URL, 'TURSO_DATABASE_URL'), + authToken: env.TURSO_AUTH_TOKEN, + })` + : provider === 'postgres' + ? "postgres(required(env.DATABASE_URL, 'DATABASE_URL'))" + : "mysql(required(env.DATABASE_URL, 'DATABASE_URL'))"; + return `import { createORM, type ORM } from '@cossackframework/database'; +import { ${adapterImport} } from '@cossackframework/database/deno'; +import { models } from '../models'; + +export type ORMEnvironment = Record; + +function required(value: string | undefined, name: string): string { + if (!value) throw new Error(\`\${name} is required\`); + return value; +} + +export function createToolingAdapter(env: ORMEnvironment = Deno.env.toObject()) { + return ${adapterExpression}; +} + +export function createRequestORM(env: ORMEnvironment) { + return createToolingAdapter(env).then((adapter) => createORM({ adapter, entities: models })); +} + +let singleton: Promise | undefined; +export function getORM(env: ORMEnvironment = Deno.env.toObject()): Promise { + return singleton ??= createRequestORM(env); +} +`; + } if (recipe.adapter === 'node') { const adapterImport = provider === 'sqlite' ? 'nodeSQLite' : provider === 'turso' - ? 'libsql' + ? 'turso' : provider; const adapterExpression = provider === 'sqlite' ? "nodeSQLite({ filename: env.DB_PATH ?? './database.sqlite' })" : provider === 'turso' - ? `libsql({ - url: required(env.TURSO_URL, 'TURSO_URL'), - authToken: env.TURSO_TOKEN, + ? `turso({ + url: required(env.TURSO_DATABASE_URL, 'TURSO_DATABASE_URL'), + authToken: env.TURSO_AUTH_TOKEN, })` : provider === 'postgres' ? "postgres(required(env.DATABASE_URL, 'DATABASE_URL'))" @@ -582,16 +652,16 @@ export function getORM(env: ORMEnvironment = process.env): Promise { const runtimeImport = provider === 'd1' ? 'd1' : provider === 'turso' - ? 'libsql' + ? 'turso' : provider === 'hyperdrive-postgres' ? 'hyperdrivePostgres' : 'hyperdriveMySQL'; const adapterExpression = provider === 'd1' ? 'd1(env.DB)' : provider === 'turso' - ? `libsql({ - url: required(env.TURSO_URL, 'TURSO_URL'), - authToken: env.TURSO_TOKEN, + ? `turso({ + url: required(env.TURSO_DATABASE_URL, 'TURSO_DATABASE_URL'), + authToken: env.TURSO_AUTH_TOKEN, })` : provider === 'hyperdrive-postgres' ? 'hyperdrivePostgres(env.HYPERDRIVE)' @@ -599,7 +669,7 @@ export function getORM(env: ORMEnvironment = process.env): Promise { const envShape = provider === 'd1' ? 'DB: D1Database;' : provider === 'turso' - ? 'TURSO_URL?: string;\n TURSO_TOKEN?: string;' + ? 'TURSO_DATABASE_URL?: string;\n TURSO_AUTH_TOKEN?: string;' : 'HYPERDRIVE: Hyperdrive;'; return `import { createORM } from '@cossackframework/database'; import type { Adapter } from '@cossackframework/database'; @@ -650,7 +720,7 @@ export async function createToolingAdapter(): Promise { } function ormConfiguration(recipe) { - const toolingImport = recipe.adapter === 'node' + const toolingImport = recipe.adapter === 'node' || recipe.adapter === 'deno' ? "import { createToolingAdapter } from './src/orm/factory';" : "import { createToolingAdapter } from './src/orm/tooling';"; return `import { defineConfig } from '@cossackframework/database'; @@ -673,7 +743,10 @@ function ormMiddlewareModule(recipe) { const runtime = recipe.adapter === 'node' ? `const orm = await getORM(); export const ormRequestMiddleware = ormMiddleware(orm);` - : `export const ormRequestMiddleware = ormMiddleware((context) => + : recipe.adapter === 'deno' + ? `export const ormRequestMiddleware = ormMiddleware((context) => + createRequestORM(context.env as ORMEnvironment));` + : `export const ormRequestMiddleware = ormMiddleware((context) => createRequestORM(context.env as ORMEnvironment));`; const factoryImport = recipe.adapter === 'node' ? "import { getORM } from '../orm/factory';" @@ -779,6 +852,73 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { `; } +function denoEntry(providers = [], desktop = false) { + const oauthImport = providers.length + ? "import { oauth, handleOAuthUser } from './auth';\n" + : ''; + const desktopImport = desktop ? "import './desktop/index';\n" : ''; + const routes = oauthRouteBlock(providers, 'frameworkApp'); + return `import { createApp } from '@cossackframework/framework/router'; +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { App } from './App'; +import { template } from './root'; +${oauthImport}${desktopImport} +export const env: Record = Deno.env.toObject(); +export const runtime = createDenoAdapter({ env }); +export const frameworkApp = createApp({ + AppComponent: App, + htmlTemplate: template, + runtimeAdapter: runtime, +}); +${routes} +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(frameworkApp, request, requestEnv), +}; + +if (import.meta.main && typeof (Deno as any).BrowserWindow !== 'function') { + runtime.serve(frameworkApp); +} +`; +} + +function denoConfiguration(recipe, projectName) { + const tasks = { + dev: 'vite dev', + build: 'vite build && vite build --ssr src/index.ts --outDir dist/server', + start: 'deno run --allow-env --allow-net --allow-read dist/server/index.js', + deploy: 'deno task build && deno deploy', + ...(recipe.resolvedFeatures.includes('desktop') ? { + 'desktop:dev': 'deno desktop --hmr .', + 'desktop:build': 'deno task build && deno desktop .', + } : {}), + }; + return JSON.stringify({ + nodeModulesDir: 'auto', + imports: { + hono: `npm:hono@${dependencyVersion('hono')}`, + vite: `npm:vite@${dependencyVersion('vite')}`, + }, + tasks, + ...(recipe.resolvedFeatures.includes('desktop') ? { + desktop: { + app: { name: projectName }, + backend: recipe.config.desktopBackend ?? 'webview', + }, + } : {}), + }, null, 2) + '\n'; +} + +function desktopEntry() { + return `import { defineDesktopBindings } from '@cossackframework/deno-adapter/desktop'; + +export const desktopBindings = defineDesktopBindings({ + // Add allowlisted, machine-local capabilities here. Handlers must validate + // every path, identifier, and domain value supplied by the webview. +}); +`; +} + function descriptor(module) { const definitions = { users: { @@ -1123,28 +1263,33 @@ function mergeEnvironmentContent(existing, values) { } function nodeEnvironmentValues(recipe, projectName, example = false) { + const port = recipe.adapter === 'deno' ? '8000' : '3000'; const values = [ ['APP_NAME', projectName], ['APP_ENV', 'development'], ['APP_DEBUG', 'true'], - ['APP_URL', 'http://localhost:3000'], + ['APP_URL', `http://localhost:${port}`], ['APP_LOCALE', 'en'], ['APP_FALLBACK_LOCALE', 'en'], ['APP_SECRET', example ? 'replace-with-a-random-32-byte-secret' : (recipe.config.appSecret ?? generateAuthSecret())], - ['PORT', '3000'], + ['PORT', port], ['CACHE_DRIVER', 'memory'], ['CORS_ENABLED', 'true'], - ['CORS_ORIGINS', 'http://localhost:3000'], + ['CORS_ORIGINS', `http://localhost:${port}`], ]; if (recipe.resolvedFeatures.includes('database')) { values.push(['DB_CONNECTION', recipe.config.database]); if (recipe.config.database === 'sqlite') { values.push(['DB_PATH', './database.sqlite']); } else if (recipe.config.database === 'turso') { - values.push(['TURSO_URL', example ? 'libsql://your-database.turso.io' : '']); - values.push(['TURSO_TOKEN', example ? 'your-turso-token' : '']); + if (recipe.adapter === 'deno' && recipe.resolvedFeatures.includes('desktop')) { + values.push(['DB_PATH', './database.turso']); + } else { + values.push(['TURSO_DATABASE_URL', example ? 'https://your-database.turso.io' : '']); + values.push(['TURSO_AUTH_TOKEN', example ? 'your-turso-token' : '']); + } } else { values.push(['DATABASE_URL', example ? `${recipe.config.database}://user:password@localhost:5432/database` @@ -1181,8 +1326,8 @@ function cloudflareEnvironmentValues(recipe, example = false) { const values = []; if (recipe.resolvedFeatures.includes('database') && recipe.config.database === 'turso') { values.push( - ['TURSO_URL', example ? 'libsql://your-database.turso.io' : ''], - ['TURSO_TOKEN', example ? 'your-turso-token' : ''], + ['TURSO_DATABASE_URL', example ? 'https://your-database.turso.io' : ''], + ['TURSO_AUTH_TOKEN', example ? 'your-turso-token' : ''], ); } if (recipe.resolvedFeatures.includes('auth') && @@ -1279,13 +1424,20 @@ export async function renderRecipe(recipe, options = {}) { recipe.config.authMethods, )); } - if (recipe.adapter === 'node' && ['wrangler.jsonc', 'worker-configuration.d.ts'].includes(rel)) continue; + if ((recipe.adapter === 'node' || recipe.adapter === 'deno') && + ['wrangler.jsonc', 'worker-configuration.d.ts'].includes(rel)) continue; if (recipe.adapter === 'node' && rel === 'src/index.ts') { content = text(nodeEntry( recipe.config.authMethods.includes('oauth') ? recipe.config.oauth : [], )); } - if (recipe.adapter === 'node' && rel === 'vite.config.ts') { + if (recipe.adapter === 'deno' && rel === 'src/index.ts') { + content = text(denoEntry( + recipe.config.authMethods.includes('oauth') ? recipe.config.oauth : [], + recipe.resolvedFeatures.includes('desktop'), + )); + } + if ((recipe.adapter === 'node' || recipe.adapter === 'deno') && rel === 'vite.config.ts') { content = text(content.toString('utf8').replace(/\/\/ @cossack:cloudflare-start[\s\S]*?\/\/ @cossack:cloudflare-end\n?/g, '')); } files.set(rel, { content, capability }); @@ -1306,6 +1458,8 @@ export async function renderRecipe(recipe, options = {}) { ...JSON.parse(await fs.readFile(path.join(packageDir, 'tsconfig.template.json'), 'utf8')).compilerOptions, types: recipe.adapter === 'node' ? ['vite/client', 'node'] + : recipe.adapter === 'deno' + ? ['vite/client', 'node', '@types/deno'] : ['./worker-configuration.d.ts', 'node'], }, }, null, 2) + '\n'), @@ -1406,6 +1560,18 @@ export async function renderRecipe(recipe, options = {}) { capability: 'base', }); } + if (recipe.adapter === 'deno') { + files.set('deno.json', { + content: text(denoConfiguration(recipe, options.projectName ?? 'my-cossack-app')), + capability: 'base', + }); + if (recipe.resolvedFeatures.includes('desktop')) { + files.set('src/desktop/index.ts', { + content: text(desktopEntry()), + capability: 'desktop', + }); + } + } if (recipe.adapter === 'cloudflare' && recipe.resolvedFeatures.includes('auth') && recipe.config.authMethods.includes('oauth')) { @@ -1421,7 +1587,7 @@ export async function renderRecipe(recipe, options = {}) { ); files.set('src/index.ts', { ...entry, content: text(source) }); } - if (recipe.adapter === 'node') { + if (recipe.adapter === 'node' || recipe.adapter === 'deno') { const projectName = options.projectName ?? 'my-cossack-app'; const values = nodeEnvironmentValues(recipe, projectName); files.set('.env', { @@ -1489,6 +1655,7 @@ export async function detectProjectRuntime(projectDir, manifest = undefined) { const packageRuntime = pkg?.cossack?.runtime; if (ADAPTERS.includes(packageRuntime)) return packageRuntime; const dependencies = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; + if (dependencies['@cossackframework/deno-adapter']) return 'deno'; if (dependencies['@cossackframework/node-adapter']) return 'node'; if (dependencies['@cloudflare/vite-plugin'] || dependencies.wrangler) return 'cloudflare'; if (await access(path.join(projectDir, 'wrangler.jsonc'))) return 'cloudflare'; @@ -1861,7 +2028,11 @@ async function promptCreationOptions(options, previous = {}, startAtLast = false const questions = [ !options.adapter && { type: 'select', name: 'adapter', message: 'Runtime adapter', - choices: [{ title: 'Cloudflare Workers', value: 'cloudflare' }, { title: 'Node.js', value: 'node' }], + choices: [ + { title: 'Cloudflare Workers', value: 'cloudflare' }, + { title: 'Node.js', value: 'node' }, + { title: 'Deno', value: 'deno' }, + ], }, !options.preset && { type: 'select', name: 'preset', message: 'Project preset', initial: 3, @@ -2010,6 +2181,7 @@ async function inferRecipe(projectDir, manifest) { authMethods: manifest.config?.authMethods, oauth: manifest.config?.oauth, theme: manifest.config?.theme, + desktopBackend: manifest.config?.desktopBackend, dashboardModules: manifest.dashboardModules, }); } @@ -2035,7 +2207,6 @@ async function inferRecipe(projectDir, manifest) { function runtimeFromDatabase(database) { if (database === 'd1') return 'cloudflare'; - if (database === 'sqlite') return 'node'; return undefined; } @@ -2072,7 +2243,11 @@ function adapterEnvironmentDefaults(recipe) { if (recipe.resolvedFeatures.includes('database')) { values.push(['DB_CONNECTION', recipe.config.database]); if (recipe.config.database === 'turso') { - values.push(['TURSO_URL', ''], ['TURSO_TOKEN', '']); + if (recipe.adapter === 'deno' && recipe.resolvedFeatures.includes('desktop')) { + values.push(['DB_PATH', './database.turso']); + } else { + values.push(['TURSO_DATABASE_URL', ''], ['TURSO_AUTH_TOKEN', '']); + } } } if (recipe.resolvedFeatures.includes('auth') && @@ -2230,8 +2405,14 @@ export async function switchAdapter(projectDir, target, options = {}) { authMethods: manifest.config?.authMethods, oauth: manifest.config?.oauth, theme: manifest.config?.theme, + desktopBackend: manifest.config?.desktopBackend, dashboardModules: manifest.dashboardModules, }); + if (current.resolvedFeatures.includes('desktop') && target !== 'deno') { + throw new Error( + 'Remove the desktop feature before switching this project away from the deno adapter.', + ); + } const empty = { writes: [], deletes: [], conflicts: [], preserved: [] }; if (current.adapter === target) { return adapterSwitchResult( @@ -2245,7 +2426,7 @@ export async function switchAdapter(projectDir, target, options = {}) { } const databaseInstalled = current.resolvedFeatures.includes('database'); - const targetDefault = target === 'cloudflare' ? 'd1' : 'sqlite'; + const targetDefault = target === 'cloudflare' ? 'd1' : target === 'deno' ? 'turso' : 'sqlite'; const currentCompatible = DATABASE_PROVIDERS[current.config.database] ?.adapters.includes(target); const mustSelectDatabase = databaseInstalled && @@ -2274,8 +2455,8 @@ export async function switchAdapter(projectDir, target, options = {}) { selectedDatabase = selection.value; } - const targetEnvironmentRel = target === 'node' ? '.env' : '.dev.vars'; - const sourceEnvironmentRel = current.adapter === 'node' ? '.env' : '.dev.vars'; + const targetEnvironmentRel = target === 'cloudflare' ? '.dev.vars' : '.env'; + const sourceEnvironmentRel = current.adapter === 'cloudflare' ? '.dev.vars' : '.env'; const [targetEnvironment, sourceEnvironment] = await Promise.all([ readLocalEnvironment(root, targetEnvironmentRel), readLocalEnvironment(root, sourceEnvironmentRel), @@ -2292,6 +2473,7 @@ export async function switchAdapter(projectDir, target, options = {}) { authMethods: manifest.config?.authMethods, oauth: manifest.config?.oauth, theme: manifest.config?.theme, + desktopBackend: manifest.config?.desktopBackend, dashboardModules: manifest.dashboardModules, }); recipe = ensureEnvironmentSecrets(recipe, { @@ -2425,7 +2607,7 @@ async function promptAddOptions( runtimeFromDatabase(options.database ?? (databaseNeeded ? database : undefined)); if (!runtime) { throw new Error( - 'Could not determine the project runtime. Pass --runtime=cloudflare or --runtime=node.', + `Could not determine the project runtime. Pass --runtime=${ADAPTERS.join(' or --runtime=')}.`, ); } const authMethods = options.authMethods ?? @@ -2452,6 +2634,7 @@ async function promptAddOptions( choices: [ { title: 'Cloudflare Workers', value: 'cloudflare' }, { title: 'Node.js', value: 'node' }, + { title: 'Deno', value: 'deno' }, ], when: (answers) => { const database = options.database ?? answers.database; @@ -2506,7 +2689,7 @@ async function promptAddOptions( const runtime = knownRuntime ?? runtimeFromDatabase(database) ?? answers.runtime; if (!runtime) { throw new Error( - 'Could not determine the project runtime. Pass --runtime=cloudflare or --runtime=node.', + `Could not determine the project runtime. Pass --runtime=${ADAPTERS.join(' or --runtime=')}.`, ); } if (!parseList(answers.authMethods).includes('oauth')) answers.oauth = []; @@ -2557,6 +2740,7 @@ export async function addFeature(projectDir, feature, options = {}) { authMethods: prompted.authMethods ?? current.config.authMethods, oauth: prompted.oauth ?? current.config.oauth, theme: prompted.theme ?? current.config.theme, + desktopBackend: prompted.desktopBackend ?? current.config.desktopBackend, dashboardModules, }); recipe = ensureEnvironmentSecrets(recipe, environment.secrets); @@ -2660,6 +2844,7 @@ export async function removeFeatureFromProject(projectDir, feature, options = {} authMethods: current.config.authMethods, oauth: current.config.oauth, theme: current.config.theme, + desktopBackend: current.config.desktopBackend, dashboardModules: current.dashboardModules, }); const previousRendered = await renderRecipe(current, { diff --git a/packages/scaffold/src/registry.js b/packages/scaffold/src/registry.js index aaaef69b..d0009144 100644 --- a/packages/scaffold/src/registry.js +++ b/packages/scaffold/src/registry.js @@ -1,9 +1,10 @@ -export const ADAPTERS = ['cloudflare', 'node']; -export const FEATURES = ['ui', 'database', 'studio', 'auth', 'dashboard', 'markdown', 'examples']; +export const ADAPTERS = ['cloudflare', 'node', 'deno']; +export const FEATURES = ['ui', 'database', 'studio', 'auth', 'dashboard', 'markdown', 'examples', 'desktop']; export const AUTH_METHODS = ['credentials', 'oauth']; export const OAUTH_PROVIDERS = ['github', 'google', 'gitlab', 'facebook', 'microsoft']; export const UI_THEMES = ['default', 'neutral', 'zinc', 'stone', 'gray', 'slate', 'blue', 'green', 'red']; export const DASHBOARD_MODULES = ['users', 'sessions', 'settings', 'roles']; +export const DESKTOP_BACKENDS = ['webview', 'cef']; export const FEATURE_REGISTRY = { ui: { requires: [] }, @@ -13,6 +14,7 @@ export const FEATURE_REGISTRY = { dashboard: { requires: ['auth'] }, markdown: { requires: [] }, examples: { requires: ['ui', 'markdown'] }, + desktop: { requires: [] }, }; export const PRESET_REGISTRY = { @@ -27,10 +29,10 @@ export const PRESET_REGISTRY = { export const DATABASE_PROVIDERS = { d1: { adapters: ['cloudflare'] }, - sqlite: { adapters: ['node'] }, + sqlite: { adapters: ['node', 'deno'] }, turso: { adapters: ADAPTERS }, - postgres: { adapters: ['node'] }, - mysql: { adapters: ['node'] }, + postgres: { adapters: ['node', 'deno'] }, + mysql: { adapters: ['node', 'deno'] }, 'hyperdrive-postgres': { adapters: ['cloudflare'] }, 'hyperdrive-mysql': { adapters: ['cloudflare'] }, }; @@ -107,13 +109,23 @@ export function resolveRecipe(options = {}) { const resolvedFeatures = resolveFeatures(explicitFeatures); const database = options.database ?? - (adapter === 'cloudflare' ? 'd1' : 'sqlite'); + (adapter === 'cloudflare' ? 'd1' : adapter === 'deno' ? 'turso' : 'sqlite'); if (!DATABASE_PROVIDERS[database]) { throw new Error(`Unknown database provider "${database}". Supported values: ${Object.keys(DATABASE_PROVIDERS).join(', ')}`); } if (!DATABASE_PROVIDERS[database].adapters.includes(adapter)) { throw new Error(`Database provider "${database}" is not supported by the ${adapter} adapter`); } + if (resolvedFeatures.includes('desktop') && adapter !== 'deno') { + throw new Error('The desktop feature is only supported by the deno adapter'); + } + const desktopBackend = options.desktopBackend ?? 'webview'; + if (resolvedFeatures.includes('desktop') && !DESKTOP_BACKENDS.includes(desktopBackend)) { + throw new Error( + `Desktop backend "${desktopBackend}" is not supported. ` + + `Cossack requires an HTML backend: ${DESKTOP_BACKENDS.join(', ')}`, + ); + } const oauth = parseList(options.oauth); assertKnown(oauth, OAUTH_PROVIDERS, 'OAuth provider'); @@ -146,6 +158,12 @@ export function resolveRecipe(options = {}) { explicitFeatures, resolvedFeatures, dashboardModules, - config: { database, authMethods, oauth, theme }, + config: { + database, + authMethods, + oauth, + theme, + ...(resolvedFeatures.includes('desktop') ? { desktopBackend } : {}), + }, }; } diff --git a/packages/scaffold/template/vite.config.ts b/packages/scaffold/template/vite.config.ts index 0cb1f5df..2fdeec32 100644 --- a/packages/scaffold/template/vite.config.ts +++ b/packages/scaffold/template/vite.config.ts @@ -86,7 +86,7 @@ export default defineConfig({ // Bundle the parent UI graph and those icons into Node SSR output // instead of leaving imports that Node refuses to type-strip from // node_modules in production. - noExternal: ['@cossackframework/ui', '@cossackframework/solar-icons'], + noExternal: ['@cossackframework/ui', '@cossackframework/solar-icons', 'hono'], }, // Cloudflare starts the SSR worker eagerly in development. Pre-bundling // the large shared packages avoids transforming their full dependency diff --git a/packages/scaffold/tests/adapter.test.js b/packages/scaffold/tests/adapter.test.js index e3816a2f..3aaa1c30 100644 --- a/packages/scaffold/tests/adapter.test.js +++ b/packages/scaffold/tests/adapter.test.js @@ -5,9 +5,11 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { _setPromptTestOverrides, + addFeature, createApp, PromptAbortedError, readManifest, + removeFeatureFromProject, switchAdapter, } from '../src/index.js'; @@ -61,6 +63,27 @@ afterEach(async () => { }); describe('adapter switching', () => { + it('adds/removes Desktop on Deno and blocks switching while it is enabled', async () => { + const project = await create('deno'); + const added = await addFeature(project.projectDir, 'desktop', { + interactive: false, + desktopBackend: 'cef', + }); + expect(added.status).toBe('added'); + expect(added.recipe.config.desktopBackend).toBe('cef'); + await expect(fs.access(path.join(project.projectDir, 'src/desktop/index.ts'))).resolves.toBeUndefined(); + await expect(switchAdapter(project.projectDir, 'node', { interactive: false })) + .rejects.toThrow('Remove the desktop feature'); + + const removed = await removeFeatureFromProject(project.projectDir, 'desktop', { + interactive: false, + }); + expect(removed.status).toBe('removed'); + await expect(fs.access(path.join(project.projectDir, 'src/desktop/index.ts'))).rejects.toThrow(); + expect((await switchAdapter(project.projectDir, 'node', { interactive: false })).status) + .toBe('changed'); + }); + it.each(['minimal', 'database', 'auth', 'full-stack'])( 'matches direct Node creation for the %s recipe', async (preset) => { @@ -164,7 +187,7 @@ describe('adapter switching', () => { await fs.writeFile( path.join(project.projectDir, '.dev.vars'), 'APP_URL=https://source.example\nGITHUB_CLIENT_ID=source-id\n' + - 'TURSO_URL=libsql://source\nSOURCE_ONLY=keep-at-source\n', + 'TURSO_DATABASE_URL=https://source.turso.io\nTURSO_AUTH_TOKEN=source-token\nSOURCE_ONLY=keep-at-source\n', ); await fs.writeFile( path.join(project.projectDir, '.env'), @@ -178,7 +201,8 @@ describe('adapter switching', () => { const source = await fs.readFile(path.join(project.projectDir, '.dev.vars'), 'utf8'); expect(target).toContain('APP_URL=https://target.example'); expect(target).toContain('GITHUB_CLIENT_ID=source-id'); - expect(target).toContain('TURSO_URL=libsql://source'); + expect(target).toContain('TURSO_DATABASE_URL=https://source.turso.io'); + expect(target).toContain('TURSO_AUTH_TOKEN=source-token'); expect(target).toContain('DB_CONNECTION=turso'); expect(target).toContain('CUSTOM_TARGET=value'); expect(target).not.toContain('SOURCE_ONLY='); @@ -264,9 +288,9 @@ describe('adapter switching', () => { it('rejects invalid targets and projects without schema-v3 manifests', async () => { const project = await create('node'); - await expect(switchAdapter(project.projectDir, 'deno', { + await expect(switchAdapter(project.projectDir, 'bun', { interactive: false, - })).rejects.toThrow('Supported values: cloudflare, node'); + })).rejects.toThrow('Supported values: cloudflare, node, deno'); const root = await temporaryDirectory(); await fs.writeFile(path.join(root, 'package.json'), '{}\n'); await expect(switchAdapter(root, 'node', { diff --git a/packages/scaffold/tests/scaffold.test.js b/packages/scaffold/tests/scaffold.test.js index 39ec2d61..cd360c51 100644 --- a/packages/scaffold/tests/scaffold.test.js +++ b/packages/scaffold/tests/scaffold.test.js @@ -103,6 +103,10 @@ describe('recipe resolution', () => { ['node', 'database'], ['node', 'auth'], ['node', 'full-stack'], + ['deno', 'minimal'], + ['deno', 'database'], + ['deno', 'auth'], + ['deno', 'full-stack'], ])('renders the %s/%s combination', async (adapter, preset) => { const recipe = resolveRecipe({ adapter, preset }); const files = await renderRecipe(recipe); @@ -154,8 +158,9 @@ describe('recipe resolution', () => { .not.toContain('reflect-metadata'); expect(Boolean(pkg.dependencies['@cossackframework/auth'])) .toBe(recipe.resolvedFeatures.includes('auth')); - expect(files.has('.env')).toBe(adapter === 'node'); - expect(files.has('.env.example')).toBe(adapter === 'node'); + expect(files.has('.env')).toBe(adapter !== 'cloudflare'); + expect(files.has('.env.example')).toBe(adapter !== 'cloudflare'); + expect(files.has('deno.json')).toBe(adapter === 'deno'); expect(files.get('vite.config.ts').content.toString()) .toContain('minify: true'); expect(files.get('vite.config.ts').content.toString()) @@ -642,14 +647,18 @@ describe('composition', () => { expect(wrangler).toContain('"preview_database_id": "d1-app-local"'); }); - it('renders isolated recipes for all eight ORM provider targets', async () => { + it('renders isolated recipes for every runtime/provider target', async () => { const targets = [ ['node', 'sqlite', 'nodeSQLite', undefined, undefined], - ['node', 'turso', 'libsql', '@libsql/client', undefined], + ['node', 'turso', 'turso', '@tursodatabase/serverless', undefined], ['node', 'postgres', 'postgres', 'pg', undefined], ['node', 'mysql', 'mysql', 'mysql2', undefined], + ['deno', 'sqlite', 'denoSQLite', '@tursodatabase/database', undefined], + ['deno', 'turso', 'turso', '@tursodatabase/serverless', undefined], + ['deno', 'postgres', 'postgres', 'pg', undefined], + ['deno', 'mysql', 'mysql', 'mysql2', undefined], ['cloudflare', 'd1', 'd1', undefined, 'nodejs_als'], - ['cloudflare', 'turso', 'libsql', '@libsql/client', 'nodejs_als'], + ['cloudflare', 'turso', 'turso', '@tursodatabase/serverless', 'nodejs_als'], ['cloudflare', 'hyperdrive-postgres', 'hyperdrivePostgres', 'pg', 'nodejs_compat'], ['cloudflare', 'hyperdrive-mysql', 'hyperdriveMySQL', 'mysql2', 'nodejs_compat'], ]; @@ -663,7 +672,7 @@ describe('composition', () => { const pkg = JSON.parse(files.get('package.json').content.toString()); const runtime = files.get('src/orm/factory.ts').content.toString(); expect(runtime).toContain(factory); - for (const candidate of ['@libsql/client', 'pg', 'mysql2']) { + for (const candidate of ['@tursodatabase/database', '@tursodatabase/serverless', 'pg', 'mysql2']) { expect(Boolean(pkg.dependencies[candidate])).toBe(candidate === driver); } if (adapter === 'cloudflare') { @@ -676,6 +685,34 @@ describe('composition', () => { } }); + it('adds desktop only to Deno recipes and selects the embedded Turso client', async () => { + expect(() => resolveRecipe({ adapter: 'node', preset: 'minimal', features: 'desktop' })) + .toThrow('only supported by the deno adapter'); + const recipe = resolveRecipe({ adapter: 'deno', preset: 'database', features: 'desktop' }); + const files = await renderRecipe(recipe, { projectName: 'desktop-app' }); + const pkg = JSON.parse(files.get('package.json').content.toString()); + const deno = JSON.parse(files.get('deno.json').content.toString()); + expect(recipe.config.database).toBe('turso'); + expect(pkg.dependencies['@tursodatabase/database']).toBeDefined(); + expect(pkg.dependencies['@tursodatabase/serverless']).toBeUndefined(); + expect(pkg.scripts['desktop:dev']).toBe('deno desktop --hmr .'); + expect(pkg.scripts.deploy).toBe('deno task build && deno deploy'); + expect(deno.imports.hono).toMatch(/^npm:hono@/); + expect(deno.imports.vite).toMatch(/^npm:vite@/); + expect(deno.desktop.backend).toBe('webview'); + expect(files.has('src/desktop/index.ts')).toBe(true); + expect(files.get('src/orm/factory.ts').content.toString()).toContain("turso({ path:"); + + const cefRecipe = resolveRecipe({ + adapter: 'deno', preset: 'minimal', features: 'desktop', desktopBackend: 'cef', + }); + const cefFiles = await renderRecipe(cefRecipe); + expect(JSON.parse(cefFiles.get('deno.json').content.toString()).desktop.backend).toBe('cef'); + expect(() => resolveRecipe({ + adapter: 'deno', preset: 'minimal', features: 'desktop', desktopBackend: 'raw', + })).toThrow('requires an HTML backend'); + }); + it('copies project guidance files and scaffolds actionable auth/database metadata', async () => { const root = await temporaryDirectory(); const project = await createApp('app', { @@ -749,7 +786,7 @@ describe('composition', () => { })); expect(await detectProjectRuntime(root)).toBeUndefined(); await expect(addFeature(root, 'ui', { interactive: false })) - .rejects.toThrow('Pass --runtime=cloudflare or --runtime=node'); + .rejects.toThrow('Pass --runtime=cloudflare or --runtime=node or --runtime=deno'); }); it('adds only newly requested dashboard modules on a later run', async () => { diff --git a/packages/studio/README.md b/packages/studio/README.md index 7c3d3d59..611441eb 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -26,7 +26,7 @@ cossack studio --remote cossack studio --remote --database DB --env production ``` -Studio supports Cloudflare D1, SQLite, Turso/libSQL, PostgreSQL, and MySQL +Studio supports Cloudflare D1, SQLite, Turso, PostgreSQL, and MySQL schema inspection, exact row counts, adjustable pagination (100 rows by default), inline and JSON-aware keyed row editing, and one arbitrary SQL statement per execution. Its SQL editor includes syntax highlighting plus table diff --git a/packages/studio/app/src/components/studio/PragmasTab.ts b/packages/studio/app/src/components/studio/PragmasTab.ts index 95dc4636..a968eaed 100644 --- a/packages/studio/app/src/components/studio/PragmasTab.ts +++ b/packages/studio/app/src/components/studio/PragmasTab.ts @@ -20,7 +20,7 @@ export class PragmasTab extends Cossack { declare props: PragmasTabProps; render() { - const supported = ['sqlite', 'libsql', 'd1-local', 'd1-remote'] + const supported = ['sqlite', 'turso', 'd1-local', 'd1-remote'] .includes(this.props.schema.connection.provider); if (!supported) { return html` diff --git a/packages/studio/app/src/pages/index.ts b/packages/studio/app/src/pages/index.ts index bc549fca..7f840c87 100644 --- a/packages/studio/app/src/pages/index.ts +++ b/packages/studio/app/src/pages/index.ts @@ -332,7 +332,7 @@ export default class StudioPage extends Cossack { @Client() async refreshPragmas() { - if (!['sqlite', 'libsql', 'd1-local', 'd1-remote'] + if (!['sqlite', 'turso', 'd1-local', 'd1-remote'] .includes(this.activeSchema.connection.provider)) return; try { const pragmas = await this.loadPragmas(); @@ -1697,7 +1697,7 @@ export default class StudioPage extends Cossack { }) : ''} ${this.tab === 'pragmas' && - ['sqlite', 'libsql', 'd1-local', 'd1-remote'] + ['sqlite', 'turso', 'd1-local', 'd1-remote'] .includes(this.activeSchema.connection.provider) ? this.iconButton(RefreshIcon, 'Refresh pragmas', this.refreshPragmas, { disabled: Boolean(this.loading.refreshPragmas), diff --git a/packages/studio/e2e/studio.spec.ts b/packages/studio/e2e/studio.spec.ts index 3ed2a46b..fa1a5a05 100644 --- a/packages/studio/e2e/studio.spec.ts +++ b/packages/studio/e2e/studio.spec.ts @@ -10,7 +10,7 @@ import { PrimaryColumn, createORM, } from '@cossackframework/database'; -import { libsql } from '@cossackframework/database/node'; +import { nodeSQLite } from '@cossackframework/database/node'; import { expect, test, type Page } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { runStudio } from '../dist/index.js'; @@ -54,7 +54,7 @@ async function replaceEditorValue(page: Page, testId: string, value: string) { test.beforeAll(async () => { const orm = createORM({ - adapter: await libsql({ url: ':memory:' }), + adapter: await nodeSQLite({ filename: ':memory:' }), entities: [Department, Person], }); const connection = createLocalConnection({ diff --git a/packages/studio/package.json b/packages/studio/package.json index 9d254a53..b9daac8c 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -40,7 +40,8 @@ "reflect-metadata": "^0.2.2" }, "devDependencies": { - "@libsql/client": "^0.14.0", + "@tursodatabase/database": "^0.7.2", + "@tursodatabase/serverless": "^1.4.0", "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.1.0", "@types/node": "^22.19.0", diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 306e622b..3f96859c 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -107,9 +107,9 @@ function localDatabaseLabel(provider: StudioProvider, explicit?: string): string const detected = databaseLabelFromEnvironment(provider); if (detected) return detected; if (process.env.DB_PATH) return path.basename(process.env.DB_PATH); - if (process.env.TURSO_URL) { + if (process.env.TURSO_DATABASE_URL) { try { - return new URL(process.env.TURSO_URL).hostname || 'Turso database'; + return new URL(process.env.TURSO_DATABASE_URL).hostname || 'Turso database'; } catch { return 'Turso database'; } diff --git a/packages/studio/src/lib/provider.ts b/packages/studio/src/lib/provider.ts index ab1b31b2..63f8f44d 100644 --- a/packages/studio/src/lib/provider.ts +++ b/packages/studio/src/lib/provider.ts @@ -6,8 +6,7 @@ const PROVIDER_ALIASES: Record = { 'd1-local': 'd1-local', sqlite: 'sqlite', sqlite3: 'sqlite', - libsql: 'libsql', - turso: 'libsql', + turso: 'turso', pg: 'postgres', postgres: 'postgres', postgresql: 'postgres', @@ -40,7 +39,7 @@ function environmentProvider(environment: NodeJS.ProcessEnv): StudioProvider | u if (url) return url; if (environment.PGHOST || environment.PGDATABASE) return 'postgres'; if (environment.MYSQL_HOST || environment.MYSQL_DATABASE) return 'mysql'; - if (environment.TURSO_URL) return 'libsql'; + if (environment.TURSO_DATABASE_URL) return 'turso'; if (environment.D1_LOCAL_PATH) return 'd1-local'; if (environment.DB_PATH) return 'sqlite'; return undefined; @@ -53,7 +52,7 @@ export async function detectStudioProvider( const hint = environmentProvider(environment); if (orm.driver.dialect === 'postgres') return 'postgres'; if (orm.driver.dialect === 'mysql') return 'mysql'; - if (hint === 'd1-local' || hint === 'libsql' || hint === 'sqlite') return hint; + if (hint === 'd1-local' || hint === 'turso' || hint === 'sqlite') return hint; return orm.driver.dialect === 'sqlite' ? 'sqlite' : 'unknown'; } diff --git a/packages/studio/src/lib/schema-types.ts b/packages/studio/src/lib/schema-types.ts index 44c5f392..44c68deb 100644 --- a/packages/studio/src/lib/schema-types.ts +++ b/packages/studio/src/lib/schema-types.ts @@ -2,7 +2,7 @@ export type StudioProvider = | 'd1-local' | 'd1-remote' | 'sqlite' - | 'libsql' + | 'turso' | 'postgres' | 'mysql' | 'unknown'; diff --git a/packages/studio/src/lib/schema.ts b/packages/studio/src/lib/schema.ts index 7dcbcf36..82cae7f1 100644 --- a/packages/studio/src/lib/schema.ts +++ b/packages/studio/src/lib/schema.ts @@ -353,7 +353,7 @@ function rowLocators( }); } else if ( providerName === 'sqlite' || - providerName === 'libsql' || + providerName === 'turso' || providerName === 'd1-local' || providerName === 'd1-remote' ) { diff --git a/packages/studio/src/lib/service.ts b/packages/studio/src/lib/service.ts index 00f328de..ac53fb3f 100644 --- a/packages/studio/src/lib/service.ts +++ b/packages/studio/src/lib/service.ts @@ -177,7 +177,7 @@ const SQLITE_PRAGMAS: PragmaDefinition[] = [ function isSqliteProvider(provider: StudioConnection['info']['provider']): boolean { return provider === 'sqlite' || - provider === 'libsql' || + provider === 'turso' || provider === 'd1-local' || provider === 'd1-remote'; } @@ -307,7 +307,7 @@ export class StudioDatabase { async getPragmas(): Promise { if (!isSqliteProvider(this.connection.info.provider)) { - throw new Error('Pragmas are available only for SQLite, libSQL, and D1 databases.'); + throw new Error('Pragmas are available only for SQLite, Turso, and D1 databases.'); } const pragmas: StudioPragma[] = []; let firstError: unknown; @@ -342,7 +342,7 @@ export class StudioDatabase { async setPragma(name: string, value: string): Promise { if (!isSqliteProvider(this.connection.info.provider)) { - throw new Error('Pragmas are available only for SQLite, libSQL, and D1 databases.'); + throw new Error('Pragmas are available only for SQLite, Turso, and D1 databases.'); } const definition = SQLITE_PRAGMAS.find((candidate) => candidate.name === name); if (!definition) throw new Error(`PRAGMA "${name}" is not editable in Studio.`); diff --git a/packages/studio/tests/database.test.ts b/packages/studio/tests/database.test.ts index 25f68d5f..85b02abc 100644 --- a/packages/studio/tests/database.test.ts +++ b/packages/studio/tests/database.test.ts @@ -11,7 +11,7 @@ import { createORM, type Relation, } from '@cossackframework/database'; -import { libsql } from '@cossackframework/database/node'; +import { nodeSQLite } from '@cossackframework/database/node'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createLocalConnection, @@ -54,10 +54,10 @@ class StudioPerson extends BaseEntity { } beforeEach(async () => { - const orm = createORM({ adapter: await libsql({ url: ':memory:' }), entities: [] }); + const orm = createORM({ adapter: await nodeSQLite({ filename: ':memory:' }), entities: [] }); connection = createLocalConnection({ orm, - info: { provider: 'libsql', label: 'fixture' }, + info: { provider: 'sqlite', label: 'fixture' }, }); studio = new StudioDatabase(connection, { applicationName: 'Fixture application' }); await connection.execute(` @@ -165,7 +165,7 @@ describe('StudioDatabase', () => { it('overlays ORM logical types and relation metadata on SQLite storage types', async () => { const orm = createORM({ - adapter: await libsql({ url: ':memory:' }), + adapter: await nodeSQLite({ filename: ':memory:' }), entities: [StudioDepartment, StudioPerson], }); const logicalConnection = createLocalConnection({ diff --git a/packages/studio/tests/dialects.test.ts b/packages/studio/tests/dialects.test.ts index e599cd58..f97123f9 100644 --- a/packages/studio/tests/dialects.test.ts +++ b/packages/studio/tests/dialects.test.ts @@ -427,8 +427,8 @@ describe('Studio dialect detection', () => { const custom = { driver: { dialect: 'sqlite' } }; expect(await detectStudioProvider( custom as any, - { TURSO_URL: 'libsql://example.turso.io' } as NodeJS.ProcessEnv, - )).toBe('libsql'); + { TURSO_DATABASE_URL: 'https://example.turso.io' } as NodeJS.ProcessEnv, + )).toBe('turso'); expect(await detectStudioProvider( custom as any, { DB_CONNECTION: 'd1' } as NodeJS.ProcessEnv, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77c62dbb..4a065563 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,46 @@ importers: specifier: ^7.0.2 version: 7.0.2 + examples/deno-desktop-counter: + dependencies: + '@cossackframework/core': + specifier: workspace:* + version: link:../../packages/core + '@cossackframework/deno-adapter': + specifier: workspace:* + version: link:../../packages/deno-adapter + '@cossackframework/framework': + specifier: workspace:* + version: link:../../packages/framework + '@cossackframework/renderer': + specifier: workspace:* + version: link:../../packages/renderer + '@cossackframework/ui': + specifier: workspace:* + version: link:../../packages/ui + '@tailwindcss/vite': + specifier: ^4.1.0 + version: 4.2.4(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0)) + hono: + specifier: ^4.12.31 + version: 4.12.31 + tailwindcss: + specifier: ^4.1.0 + version: 4.2.4 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0) + devDependencies: + '@types/deno': + specifier: ^2.3.0 + version: 2.7.0 + '@types/node': + specifier: ^22.19.0 + version: 22.20.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + packages/auth: devDependencies: hono: @@ -73,9 +113,12 @@ importers: packages/database: dependencies: - '@libsql/client': - specifier: '>=0.14' - version: 0.14.0 + '@tursodatabase/database': + specifier: ^0.7.2 + version: 0.7.2 + '@tursodatabase/serverless': + specifier: ^1.4.0 + version: 1.4.0 better-sqlite3: specifier: '>=11' version: 13.0.2 @@ -105,6 +148,28 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(happy-dom@20.4.0)(jsdom@29.1.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0)) + packages/deno-adapter: + dependencies: + '@cossackframework/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@cossackframework/framework': + specifier: workspace:* + version: link:../framework + hono: + specifier: ^4.12.31 + version: 4.12.31 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.0.0)(happy-dom@20.4.0)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0)) + packages/framework: dependencies: '@cossackframework/core': @@ -158,13 +223,13 @@ importers: version: 10.0.1 remark-frontmatter: specifier: ^5.0.0 - version: 5.0.0 + version: 5.0.0(supports-color@10.2.2) remark-gfm: specifier: ^4.0.1 - version: 4.0.1 + version: 4.0.1(supports-color@10.2.2) remark-parse: specifier: ^11.0.0 - version: 11.0.0 + version: 11.0.0(supports-color@10.2.2) remark-rehype: specifier: ^11.1.2 version: 11.1.2 @@ -289,15 +354,18 @@ importers: specifier: ^0.2.2 version: 0.2.2 devDependencies: - '@libsql/client': - specifier: ^0.14.0 - version: 0.14.0 '@playwright/test': specifier: ^1.61.1 version: 1.61.1 '@tailwindcss/vite': specifier: ^4.1.0 version: 4.2.4(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0)) + '@tursodatabase/database': + specifier: ^0.7.2 + version: 0.7.2 + '@tursodatabase/serverless': + specifier: ^1.4.0 + version: 1.4.0 '@types/node': specifier: ^22.19.0 version: 22.20.1 @@ -933,66 +1001,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@libsql/client@0.14.0': - resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==} - - '@libsql/core@0.14.0': - resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==} - - '@libsql/darwin-arm64@0.4.7': - resolution: {integrity: sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==} - cpu: [arm64] - os: [darwin] - - '@libsql/darwin-x64@0.4.7': - resolution: {integrity: sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==} - cpu: [x64] - os: [darwin] - - '@libsql/hrana-client@0.7.0': - resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} - - '@libsql/isomorphic-fetch@0.3.1': - resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} - engines: {node: '>=18.0.0'} - - '@libsql/isomorphic-ws@0.1.5': - resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} - - '@libsql/linux-arm64-gnu@0.4.7': - resolution: {integrity: sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==} - cpu: [arm64] - os: [linux] - - '@libsql/linux-arm64-musl@0.4.7': - resolution: {integrity: sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==} - cpu: [arm64] - os: [linux] - - '@libsql/linux-x64-gnu@0.4.7': - resolution: {integrity: sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==} - cpu: [x64] - os: [linux] - - '@libsql/linux-x64-musl@0.4.7': - resolution: {integrity: sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==} - cpu: [x64] - os: [linux] - - '@libsql/win32-x64-msvc@0.4.7': - resolution: {integrity: sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==} - cpu: [x64] - os: [win32] - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@neon-rs/load@0.0.4': - resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -1292,6 +1306,37 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tursodatabase/database-common@0.7.2': + resolution: {integrity: sha512-c4uWaA5m7nyDemUrjvX6lQQ89cBxv1jCv7nmwq5JoagyXgbMxDeBfLcY7N5kn0oEcnD0Y+cf09S0wKEXKFKbfg==} + + '@tursodatabase/database-darwin-arm64@0.7.2': + resolution: {integrity: sha512-pDYbrPnmsqzWsf21bQXXGKmGhcLdfQQVjx+yrpC/IboCue5o/h9fDZ1QFzQEP6CHRXXoc+pwQCyF8KBntI8fdA==} + cpu: [arm64] + os: [darwin] + + '@tursodatabase/database-linux-arm64-gnu@0.7.2': + resolution: {integrity: sha512-/Blz7cRssWh7dk8vGs7Cq3kupHOnI0GROAg+u9J4MD5U5ABAabJGN7iW5YQ16jZW35Bv2AkmJAyjvsCWdTyfTg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tursodatabase/database-linux-x64-gnu@0.7.2': + resolution: {integrity: sha512-rXxInfTxKRPG3XeOAZvzJuX4wF+m9t9BjLbFnKU83Ad8ob2z88r1x+EV7IBzEFhz2FbvrRgriH7PhIEZ7Lx5ug==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tursodatabase/database-win32-x64-msvc@0.7.2': + resolution: {integrity: sha512-AUvN3KO1bZOpsXVofRIMM2+MJ6OghUhgK62t8/3Bsb0Zzt6PztaMvJrtaWI6u5Olx0WCfGdhaVKyegcjYPNAeA==} + cpu: [x64] + os: [win32] + + '@tursodatabase/database@0.7.2': + resolution: {integrity: sha512-B84QicyDYnKt97qxgb7861cQQC26kuv/LkUlCnpxGs4tuGT9zgS2z/Mt+BkG/wOo1fB4rsSycOgGCbi0xU0YOQ==} + + '@tursodatabase/serverless@1.4.0': + resolution: {integrity: sha512-ZU3T76NG/E0NTKULqJ9TggJfbdPgRaChOS4HDOu3Srds7w7fSPie7jbPHzz+CLGrxs2TtMYtYB1lH5Bh6q1w7A==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1304,6 +1349,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/deno@2.7.0': + resolution: {integrity: sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1549,10 +1597,6 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1580,10 +1624,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - detect-libc@2.0.2: - resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} - engines: {node: '>=8'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1644,18 +1684,10 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1735,9 +1767,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - js-base64@3.8.0: - resolution: {integrity: sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw==} - jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -1755,11 +1784,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - libsql@0.4.7: - resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} - cpu: [x64, arm64, wasm32] - os: [darwin, linux, win32] - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2016,15 +2040,6 @@ packages: resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} engines: {node: ^18 || ^20 || >= 21} - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nodemailer@9.0.3: resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} engines: {node: '>=6.0.0'} @@ -2133,9 +2148,6 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - promise-limit@2.7.0: - resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -2441,10 +2453,6 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3086,62 +3094,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@libsql/client@0.14.0': - dependencies: - '@libsql/core': 0.14.0 - '@libsql/hrana-client': 0.7.0 - js-base64: 3.8.0 - libsql: 0.4.7 - promise-limit: 2.7.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@libsql/core@0.14.0': - dependencies: - js-base64: 3.8.0 - - '@libsql/darwin-arm64@0.4.7': - optional: true - - '@libsql/darwin-x64@0.4.7': - optional: true - - '@libsql/hrana-client@0.7.0': - dependencies: - '@libsql/isomorphic-fetch': 0.3.1 - '@libsql/isomorphic-ws': 0.1.5 - js-base64: 3.8.0 - node-fetch: 3.3.2 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@libsql/isomorphic-fetch@0.3.1': {} - - '@libsql/isomorphic-ws@0.1.5': - dependencies: - '@types/ws': 8.18.1 - ws: 8.21.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@libsql/linux-arm64-gnu@0.4.7': - optional: true - - '@libsql/linux-arm64-musl@0.4.7': - optional: true - - '@libsql/linux-x64-gnu@0.4.7': - optional: true - - '@libsql/linux-x64-musl@0.4.7': - optional: true - - '@libsql/win32-x64-msvc@0.4.7': - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -3149,8 +3101,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@neon-rs/load@0.0.4': {} - '@oxc-project/types@0.139.0': {} '@oxlint/darwin-arm64@1.41.0': @@ -3373,6 +3323,31 @@ snapshots: tailwindcss: 4.2.4 vite: 8.1.4(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.9.0) + '@tursodatabase/database-common@0.7.2': {} + + '@tursodatabase/database-darwin-arm64@0.7.2': + optional: true + + '@tursodatabase/database-linux-arm64-gnu@0.7.2': + optional: true + + '@tursodatabase/database-linux-x64-gnu@0.7.2': + optional: true + + '@tursodatabase/database-win32-x64-msvc@0.7.2': + optional: true + + '@tursodatabase/database@0.7.2': + dependencies: + '@tursodatabase/database-common': 0.7.2 + optionalDependencies: + '@tursodatabase/database-darwin-arm64': 0.7.2 + '@tursodatabase/database-linux-arm64-gnu': 0.7.2 + '@tursodatabase/database-linux-x64-gnu': 0.7.2 + '@tursodatabase/database-win32-x64-msvc': 0.7.2 + + '@tursodatabase/serverless@1.4.0': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -3389,6 +3364,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/deno@2.7.0': {} + '@types/estree@1.0.8': {} '@types/hast@3.0.5': @@ -3584,8 +3561,6 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 - data-uri-to-buffer@4.0.1: {} - data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -3593,9 +3568,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -3607,8 +3584,6 @@ snapshots: dequal@2.0.3: {} - detect-libc@2.0.2: {} - detect-libc@2.1.2: {} devlop@1.1.0: @@ -3678,17 +3653,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - format@0.2.2: {} - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - fsevents@2.3.2: optional: true @@ -3813,8 +3779,6 @@ snapshots: jiti@2.6.1: {} - js-base64@3.8.0: {} - jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -3845,19 +3809,6 @@ snapshots: kleur@4.1.5: {} - libsql@0.4.7: - dependencies: - '@neon-rs/load': 0.0.4 - detect-libc: 2.0.2 - optionalDependencies: - '@libsql/darwin-arm64': 0.4.7 - '@libsql/darwin-x64': 0.4.7 - '@libsql/linux-arm64-gnu': 0.4.7 - '@libsql/linux-arm64-musl': 0.4.7 - '@libsql/linux-x64-gnu': 0.4.7 - '@libsql/linux-x64-musl': 0.4.7 - '@libsql/win32-x64-msvc': 0.4.7 - lightningcss-android-arm64@1.32.0: optional: true @@ -3928,14 +3879,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@10.2.2) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -3945,12 +3896,12 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1: + mdast-util-frontmatter@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-extension-frontmatter: 2.0.0 transitivePeerDependencies: @@ -3964,51 +3915,51 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -4234,10 +4185,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@10.2.2): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -4292,14 +4243,6 @@ snapshots: node-addon-api@8.9.0: {} - node-domexception@1.0.0: {} - - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - nodemailer@9.0.3: {} obug@2.1.3: {} @@ -4394,8 +4337,6 @@ snapshots: dependencies: xtend: 4.0.2 - promise-limit@2.7.0: {} - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -4427,30 +4368,30 @@ snapshots: hast-util-to-html: 9.0.5 unified: 11.0.5 - remark-frontmatter@5.0.0: + remark-frontmatter@5.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-frontmatter: 2.0.1 + mdast-util-frontmatter: 2.0.1(supports-color@10.2.2) micromark-extension-frontmatter: 2.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@10.2.2) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -4799,8 +4740,6 @@ snapshots: web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: {} - webidl-conversions@8.0.1: {} whatwg-mimetype@3.0.0: @@ -4847,7 +4786,8 @@ snapshots: ws@8.21.0: {} - ws@8.21.1: {} + ws@8.21.1: + optional: true xml-name-validator@5.0.0: {} diff --git a/scripts/test-local-scaffold.mjs b/scripts/test-local-scaffold.mjs index ec0bb57b..7f105840 100644 --- a/scripts/test-local-scaffold.mjs +++ b/scripts/test-local-scaffold.mjs @@ -13,6 +13,7 @@ const packageDirectories = [ 'renderer', 'core', 'node-adapter', + 'deno-adapter', 'auth', 'ui', 'framework', @@ -36,6 +37,7 @@ async function buildPublishablePackages() { await run('pnpm', ['--filter', '@cossackframework/renderer', 'build']); await run('pnpm', ['--filter', '@cossackframework/core', 'build']); await run('pnpm', ['--filter', '@cossackframework/node-adapter', 'build']); + await run('pnpm', ['--filter', '@cossackframework/deno-adapter', 'build']); await run('pnpm', ['--filter', '@cossackframework/ui', 'build']); await run('pnpm', ['--filter', '@cossackframework/framework', 'build:types']); await run('pnpm', ['--filter', '@cossackframework/database', 'build']); @@ -391,6 +393,36 @@ try { await verifyMinimalProductionBundles(minimalProjectDir); await assertStarterBundleBudgets(minimalProjectDir); + await run(process.execPath, [ + path.join(repositoryRoot, 'packages/cossack/bin/cossack.js'), + 'create', + 'deno-production', + '--adapter=deno', + '--preset=minimal', + '--yes', + ], { cwd: temporaryRoot }); + const denoProjectDir = path.join(temporaryRoot, 'deno-production'); + await useTarballs(denoProjectDir, tarballs); + await installGeneratedProject(denoProjectDir); + await run('pnpm', ['run', 'build'], { cwd: denoProjectDir }); + await fs.access(path.join(denoProjectDir, 'dist', 'server', 'index.js')); + const denoPackage = JSON.parse(await fs.readFile( + path.join(denoProjectDir, 'package.json'), + 'utf8', + )); + if (!String(denoPackage.dependencies?.['@cossackframework/deno-adapter']) + .startsWith('file:')) { + throw new Error('Deno scaffold did not consume the locally packed adapter'); + } + const denoConfig = JSON.parse(await fs.readFile( + path.join(denoProjectDir, 'deno.json'), + 'utf8', + )); + if (denoConfig.tasks?.start !== + 'deno run --allow-env --allow-net --allow-read dist/server/index.js') { + throw new Error('Deno scaffold did not emit the production start task'); + } + await run('pnpm', [ 'exec', 'cossack', diff --git a/skills/cossack-best-practices/references/database.md b/skills/cossack-best-practices/references/database.md index 646eaa89..80b13d6e 100644 --- a/skills/cossack-best-practices/references/database.md +++ b/skills/cossack-best-practices/references/database.md @@ -29,7 +29,7 @@ import { sql } from '@cossackframework/database'; const result = await sql`SELECT * FROM users WHERE id = ${id}`; ``` -Node SQLite, libSQL, PostgreSQL, and MySQL support `sql.transaction()`. D1 uses +Node SQLite, PostgreSQL, and MySQL support `sql.transaction()`. D1 uses native transactional batches and never emulates interactive transactions. Generated applications register entities, migrations, and seeders as explicit diff --git a/tsconfig.json b/tsconfig.json index f31a730c..cb57e4b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ { "path": "./packages/core" }, { "path": "./packages/renderer" }, { "path": "./packages/node-adapter" }, + { "path": "./packages/deno-adapter" }, { "path": "./packages/database" }, { "path": "./packages/test-utils" }, { "path": "./packages/ui" }, From 4a90144e9f2da72cacbe8b24a9602d31875db59f Mon Sep 17 00:00:00 2001 From: Tan Nguyen Date: Tue, 4 Aug 2026 23:53:49 +0700 Subject: [PATCH 2/7] fix: rpc for desktop, allows different runtime per project --- .gitignore | 1 + examples/deno-desktop-counter/README.md | 56 ++++- examples/deno-desktop-counter/deno.json | 18 +- examples/deno-desktop-counter/deno.lock | 1 + examples/deno-desktop-counter/package.json | 5 +- .../src/desktop/bindings.ts | 14 -- .../deno-desktop-counter/src/desktop/index.ts | 17 ++ examples/deno-desktop-counter/src/index.ts | 1 - .../deno-desktop-counter/src/pages/index.ts | 31 +-- packages/core/src/shared/component-types.ts | 10 + packages/core/src/shared/context.ts | 2 + packages/core/src/shared/cossack.ts | 19 +- packages/core/tests/cossack.client.test.ts | 8 + packages/core/tests/cossack.server.test.ts | 12 + packages/deno-adapter/README.md | 137 +++++++++++ packages/deno-adapter/docs/desktop.md | 217 ++++++++++++++++++ packages/deno-adapter/docs/installation.md | 187 +++++++++++++++ packages/deno-adapter/docs/introduction.md | 129 +++++++++++ packages/deno-adapter/docs/web.md | 130 +++++++++++ packages/deno-adapter/package.json | 2 +- packages/deno-adapter/src/index.ts | 7 +- packages/deno-adapter/tests/adapter.test.ts | 14 ++ packages/framework/src/route-ids.ts | 2 + packages/framework/src/router.ts | 32 ++- packages/framework/src/transports/http.ts | 8 +- packages/framework/src/transports/sse.ts | 8 +- .../framework/src/vite-security-plugin.ts | 200 ++++++++++++---- .../framework/tests/runtime-adapter.test.ts | 15 ++ packages/framework/tests/ssg-renderer.test.ts | 2 +- .../tests/vite-security-plugin.test.ts | 53 +++++ packages/scaffold/src/index.js | 104 +++++---- packages/scaffold/src/registry.js | 3 - packages/scaffold/tests/adapter.test.js | 10 +- packages/scaffold/tests/scaffold.test.js | 24 +- skills/README.md | 6 +- skills/cossack-best-practices/SKILL.md | 3 + .../references/desktop.md | 100 ++++++++ skills/create-desktop-app/SKILL.md | 179 +++++++++++++++ skills/create-desktop-app/agents/openai.yaml | 4 + 39 files changed, 1602 insertions(+), 169 deletions(-) delete mode 100644 examples/deno-desktop-counter/src/desktop/bindings.ts create mode 100644 examples/deno-desktop-counter/src/desktop/index.ts create mode 100644 packages/deno-adapter/README.md create mode 100644 packages/deno-adapter/docs/desktop.md create mode 100644 packages/deno-adapter/docs/installation.md create mode 100644 packages/deno-adapter/docs/introduction.md create mode 100644 packages/deno-adapter/docs/web.md create mode 100644 skills/cossack-best-practices/references/desktop.md create mode 100644 skills/create-desktop-app/SKILL.md create mode 100644 skills/create-desktop-app/agents/openai.yaml diff --git a/.gitignore b/.gitignore index f7a87f58..cb574533 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ test-results .claude reasonix.toml .reasonix +.zcode # Planning documents (local scratch) plan-*.md reviews.md diff --git a/examples/deno-desktop-counter/README.md b/examples/deno-desktop-counter/README.md index 511744c7..5fb0ad35 100644 --- a/examples/deno-desktop-counter/README.md +++ b/examples/deno-desktop-counter/README.md @@ -1,22 +1,54 @@ -# Deno desktop counter +# Deno Desktop counter -The same Cossack page runs in a normal browser and in a Deno Desktop window. -The browser counter is in-memory. Desktop calls the typed, allowlisted Deno -bindings and persists the value in Deno-side `localStorage`. +One Cossack page runs in a browser and in a Deno Desktop window. Its action +methods use normal Cossack RPC in both environments: the web build executes +them on its configured web server, while the Desktop build executes them in +the local Deno server packaged with the app. + +The page uses `this.isDesktop` to load and save the Desktop count with +Deno-side `localStorage`. The browser counter remains in memory. No manual +Desktop binding or `invoke()` call is needed. Requires Deno 2.9 or newer. +## Run the web app + ```sh pnpm install -deno task dev # browser development +deno task dev +``` + +Build and run the Deno production web server: + +```sh deno task build -deno task start # production Deno HTTP server -deno task desktop:dev # desktop HMR -deno task desktop:build # web/SSR production build, then host package +deno task start ``` -To verify persistence, increment the counter in the desktop window, close the -application, relaunch the packaged app, and confirm that the previous count is -restored. Change `desktop.backend` in `deno.json` from `webview` to `cef` when -you need a bundled Chromium engine. The `raw` backend is intentionally not +The same web application could instead use the Cossack Cloudflare Workers or +Node.js adapter. Only the Desktop entry at `src/desktop/index.ts` must use the +Deno adapter. + +## Run the Desktop app + +```sh +deno task desktop:dev +``` + +This builds the shared client and local Desktop server, then launches Deno +Desktop with HMR. Package a production app with: + +```sh +deno task desktop:build +``` + +To verify persistence, increment the counter, close the Desktop application, +relaunch it, and confirm the previous count is restored. + +The default backend is WebView. Change `desktop.backend` in `deno.json` to +`cef` when a bundled Chromium engine is required. The `raw` backend is not supported because Cossack renders HTML. + +Generated Desktop launchers, binaries, `.so` files, `.deno-desktop-app`, +`.downloaded`, and `dist/` are build output and should remain ignored. Keep +`deno.json`, `deno.lock`, and application source under version control. diff --git a/examples/deno-desktop-counter/deno.json b/examples/deno-desktop-counter/deno.json index 932ef845..1b276292 100644 --- a/examples/deno-desktop-counter/deno.json +++ b/examples/deno-desktop-counter/deno.json @@ -3,8 +3,6 @@ "imports": { "@cossackframework/core": "../../packages/core/dist/index.js", "@cossackframework/deno-adapter": "../../packages/deno-adapter/dist/index.js", - "@cossackframework/deno-adapter/desktop": "../../packages/deno-adapter/dist/desktop.js", - "@cossackframework/deno-adapter/desktop/client": "../../packages/deno-adapter/dist/desktop-client.js", "@cossackframework/framework/router": "../../packages/framework/dist/esm/router.js", "@cossackframework/framework/vite-plugin": "../../packages/framework/dist/esm/vite-plugin.js", "@cossackframework/framework/vite-security-plugin": "../../packages/framework/dist/esm/vite-security-plugin.js", @@ -14,14 +12,20 @@ "vite": "npm:vite@^8.1.4" }, "tasks": { - "dev": "vite dev", - "build": "vite build && vite build --ssr src/index.ts --outDir dist/server", + "dev": "pnpm run dev", + "build": "pnpm run build", "start": "deno run --allow-env --allow-net --allow-read dist/server/index.js", - "desktop:dev": "deno desktop --hmr .", - "desktop:build": "deno task build && deno desktop ." + "build:desktop": "pnpm run build:desktop", + "desktop:dev": "deno task build:desktop && deno desktop -A --hmr --exclude-unused-npm --include dist/client dist/desktop-server/index.js", + "desktop:build": "deno task build:desktop && deno desktop -A --exclude-unused-npm --include dist/client dist/desktop-server/index.js" }, "desktop": { "app": { "name": "Cossack Counter", "identifier": "dev.cossack.counter" }, - "backend": "webview" + "backend": "webview", + "output": { + "linux": "./dist/desktop", + "macos": "./dist/desktop", + "windows": "./dist/desktop" + } } } diff --git a/examples/deno-desktop-counter/deno.lock b/examples/deno-desktop-counter/deno.lock index faa54375..6b957a5c 100644 --- a/examples/deno-desktop-counter/deno.lock +++ b/examples/deno-desktop-counter/deno.lock @@ -615,6 +615,7 @@ }, "workspace": { "dependencies": [ + "npm:hono@^4.12.31", "npm:vite@^8.1.4" ], "packageJson": { diff --git a/examples/deno-desktop-counter/package.json b/examples/deno-desktop-counter/package.json index d0f3356d..ffe2a9aa 100644 --- a/examples/deno-desktop-counter/package.json +++ b/examples/deno-desktop-counter/package.json @@ -6,8 +6,9 @@ "dev": "vite dev", "build": "vite build && vite build --ssr src/index.ts --outDir dist/server", "start": "deno run --allow-env --allow-net --allow-read dist/server/index.js", - "desktop:dev": "deno desktop --hmr .", - "desktop:build": "deno task build && deno desktop ." + "build:desktop": "vite build && vite build --ssr src/desktop/index.ts --outDir dist/desktop-server --minify false", + "desktop:dev": "deno task build:desktop && deno desktop -A --hmr --exclude-unused-npm --include dist/client dist/desktop-server/index.js", + "desktop:build": "deno task build:desktop && deno desktop -A --exclude-unused-npm --include dist/client dist/desktop-server/index.js" }, "dependencies": { "@cossackframework/core": "workspace:*", diff --git a/examples/deno-desktop-counter/src/desktop/bindings.ts b/examples/deno-desktop-counter/src/desktop/bindings.ts deleted file mode 100644 index 142c5c35..00000000 --- a/examples/deno-desktop-counter/src/desktop/bindings.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineDesktopBindings } from '@cossackframework/deno-adapter/desktop'; - -const STORAGE_KEY = 'cossack.desktop.counter'; - -export const desktopBindings = defineDesktopBindings({ - loadCount(): number { - const value = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '0', 10); - return Number.isFinite(value) ? value : 0; - }, - saveCount(count: number): void { - if (!Number.isSafeInteger(count)) throw new TypeError('count must be a safe integer'); - localStorage.setItem(STORAGE_KEY, String(count)); - }, -}); diff --git a/examples/deno-desktop-counter/src/desktop/index.ts b/examples/deno-desktop-counter/src/desktop/index.ts new file mode 100644 index 00000000..22317856 --- /dev/null +++ b/examples/deno-desktop-counter/src/desktop/index.ts @@ -0,0 +1,17 @@ +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { createApp } from '@cossackframework/framework/router'; +import { App } from '../App'; +import { template } from '../root'; + +export const env: Record = Deno.env.toObject(); +export const runtime = createDenoAdapter({ env }); +export const app = createApp({ AppComponent: App, htmlTemplate: template, runtimeAdapter: runtime }); + +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(app, request, requestEnv), +}; + +if (import.meta.main && typeof (Deno as any).BrowserWindow !== 'function') { + runtime.serve(app); +} diff --git a/examples/deno-desktop-counter/src/index.ts b/examples/deno-desktop-counter/src/index.ts index ff1d54ac..2b1727ae 100644 --- a/examples/deno-desktop-counter/src/index.ts +++ b/examples/deno-desktop-counter/src/index.ts @@ -1,4 +1,3 @@ -import './desktop/bindings'; import { createDenoAdapter } from '@cossackframework/deno-adapter'; import { createApp } from '@cossackframework/framework/router'; import { App } from './App'; diff --git a/examples/deno-desktop-counter/src/pages/index.ts b/examples/deno-desktop-counter/src/pages/index.ts index 2d0a8a54..fac4611a 100644 --- a/examples/deno-desktop-counter/src/pages/index.ts +++ b/examples/deno-desktop-counter/src/pages/index.ts @@ -1,37 +1,38 @@ -import { Client, ClientState, Cossack, Page } from '@cossackframework/core'; +import { Cossack, Page, State } from '@cossackframework/core'; import { component, html } from '@cossackframework/renderer'; import { Button } from '@cossackframework/ui'; -import { createDesktopClient } from '@cossackframework/deno-adapter/desktop/client'; -import type { desktopBindings } from '../desktop/bindings'; -const desktop = createDesktopClient(); +const STORAGE_KEY = 'cossack.desktop.counter'; @Page({ transport: 'http' }) export default class CounterPage extends Cossack { - @ClientState() count = 0; + @State() count = 0; - @Client() - async clientInit() { - if (desktop.available) this.count = await desktop.invoke('loadCount'); + async init() { + if (!this.isDesktop) return; + const value = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '0', 10); + this.count = Number.isFinite(value) ? value : 0; } - @Client() - async increment() { + increment() { this.count += 1; - if (desktop.available) await desktop.invoke('saveCount', this.count); + if (this.isDesktop) { + localStorage.setItem(STORAGE_KEY, String(this.count)); + } } - @Client() - async decrement() { + decrement() { this.count -= 1; - if (desktop.available) await desktop.invoke('saveCount', this.count); + if (this.isDesktop) { + localStorage.setItem(STORAGE_KEY, String(this.count)); + } } render() { return html`
-

${desktop.available ? 'Deno Desktop · persistent' : 'Web · in memory'}

+

${this.isDesktop ? 'Deno Desktop · persistent' : 'Web · in memory'}

Cossack counter

${this.count} diff --git a/packages/core/src/shared/component-types.ts b/packages/core/src/shared/component-types.ts index 27fff8dd..ea652d9c 100644 --- a/packages/core/src/shared/component-types.ts +++ b/packages/core/src/shared/component-types.ts @@ -79,6 +79,14 @@ export interface CossackOptions { Channels?: string; } +export type CossackRuntimePlatform = 'web' | 'desktop'; + +/** Runtime identity available to component server methods and hydrated clients. */ +export interface CossackRuntimeInfo extends Record { + platform: CossackRuntimePlatform; + adapter?: string; +} + /** * Options for {@link Cossack.bootstrap}. * All fields are optional; pass `{}` (or omit) to bootstrap with defaults. @@ -94,6 +102,8 @@ export interface BootstrapOptions { user?: User; /** Environment bindings (server) or emulated env (client). */ env?: any; + /** Runtime target and adapter metadata. Defaults to the web platform. */ + runtime?: CossackRuntimeInfo; /** Current page route identifier. */ page?: string; /** WebSocket provider name (server only). */ diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 1791399f..742071b7 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -4,10 +4,12 @@ import { createContext } from '@cossackframework/renderer'; import { parseFormData } from './forms'; import { flash, flashInput } from './flash'; import { StoreRuleMap, validateObject, type ObjectValidationResult } from './validation'; +import type { CossackRuntimeInfo } from './component-types'; export const EnvContext = createContext(undefined); export const UserContext = createContext(undefined); export const RequestContext = createContext(undefined); +export const RuntimeContext = createContext({ platform: 'web' }); export type HydratedContext = { req: { diff --git a/packages/core/src/shared/cossack.ts b/packages/core/src/shared/cossack.ts index bb3e216c..a6749095 100644 --- a/packages/core/src/shared/cossack.ts +++ b/packages/core/src/shared/cossack.ts @@ -35,7 +35,7 @@ import { mergeHead as mergeHeadFn, applyHeadTags as applyHeadTagsFn, } from './head'; -import { createCossackContext, HydratedContext, EnvContext, UserContext, RequestContext, CossackContext } from './context'; +import { createCossackContext, HydratedContext, EnvContext, UserContext, RequestContext, RuntimeContext, CossackContext } from './context'; import { getError as getErrorFn, hasError as hasErrorFn, @@ -67,6 +67,7 @@ import type { DynamicFunction, CossackElementInternal, CossackInternalState, + CossackRuntimeInfo, } from './component-types'; import type { User } from './user'; import type { RedirectStatusCode } from 'hono/utils/http-status'; @@ -83,6 +84,7 @@ export type { SerializedComponentState, CossackOptions, BootstrapOptions, + CossackRuntimeInfo, }; export const RootContext = createContext(null); @@ -254,6 +256,7 @@ export abstract class Cossack extends private _c!: Context; private _user?: User; private _env!: Env; + private _runtimeInfo?: CossackRuntimeInfo; protected get c(): Context & CossackContext { // The context is wrapped by `createCossackContext`, whose proxy adds @@ -271,6 +274,15 @@ export abstract class Cossack extends protected get env(): Env { return this._env || this.consume(EnvContext) as Env; } protected set env(val: Env) { this._env = val; } + /** Runtime target for shared server code (`web` or local `desktop`). */ + protected get runtime(): CossackRuntimeInfo { + return this._runtimeInfo || this.consume(RuntimeContext) || { platform: 'web' }; + } + protected set runtime(val: CossackRuntimeInfo) { this._runtimeInfo = val; } + + /** True when this component is executing in the local Deno Desktop target. */ + protected get isDesktop(): boolean { return this.runtime.platform === 'desktop'; } + protected providers!: Map; public props: Record = {}; @@ -761,7 +773,7 @@ export abstract class Cossack extends return this as unknown as CossackElementInternal; } - public async bootstrap({ container, initialState, context, user, env, page, providerName, skipInit, deferMount }: BootstrapOptions = {}) { + public async bootstrap({ container, initialState, context, user, env, runtime, page, providerName, skipInit, deferMount }: BootstrapOptions = {}) { // Transition to Bootstrapping phase from Creating phase this._transitionToPhase(LifecyclePhase.Bootstrapping, [LifecyclePhase.Creating]); @@ -788,10 +800,12 @@ export abstract class Cossack extends } this.c = createCossackContext(context, true); this.env = env; + this.runtime = runtime ?? { platform: 'web' }; this._cossack_provider_name = providerName; this.initializeProviders(); } else { const clientInitialState = initialState || this.getInitialStateFromWindow(); + this.runtime = (clientInitialState?.runtime as CossackRuntimeInfo | undefined) ?? { platform: 'web' }; // Access metadata from the new state structure const metadata = clientInitialState?.metadata || {}; this.user = metadata.user; @@ -826,6 +840,7 @@ export abstract class Cossack extends this.provide(EnvContext, this.env); this.provide(UserContext, this.user); this.provide(RequestContext, this.c); + this.provide(RuntimeContext, this.runtime); this._serviceScope?.bindRequest({ context: this.c, user: this.user, env: this.env }); diff --git a/packages/core/tests/cossack.client.test.ts b/packages/core/tests/cossack.client.test.ts index 2c0e8d22..8940923f 100644 --- a/packages/core/tests/cossack.client.test.ts +++ b/packages/core/tests/cossack.client.test.ts @@ -65,6 +65,8 @@ class TestComponent extends Cossack<{}> { @Shared() async formatCountAsync(prefix: string) { return `${prefix}:${this.count}`; } + runtimePlatform() { return this.runtime.platform; } + render(): TemplateResult { const strings = [`Count: ${this.count}, Message: ${this.message}`]; return { @@ -91,6 +93,7 @@ describe('Cossack Core: Client-Side', () => { routePath: '/test', channels: ['global', 'private'], providerTargets: { page: 'durable-object-id-123' }, + runtime: { adapter: 'deno', platform: 'desktop' }, }; beforeEach(() => { @@ -125,6 +128,11 @@ describe('Cossack Core: Client-Side', () => { expect(component.message).toBe('from server'); }); + it('hydrates runtime identity for shared rendering decisions', async () => { + await component.bootstrap(); + expect(component.runtimePlatform()).toBe('desktop'); + }); + it('should setup context with params from initial state', async () => { await component.bootstrap(); expect((component as any).c.req.param('name')).toBe('cossack'); diff --git a/packages/core/tests/cossack.server.test.ts b/packages/core/tests/cossack.server.test.ts index fd24b2f4..6cf017eb 100644 --- a/packages/core/tests/cossack.server.test.ts +++ b/packages/core/tests/cossack.server.test.ts @@ -66,6 +66,8 @@ class TestComponent extends Cossack<{}> { @Shared() async formatCountAsync(prefix: string) { return `${prefix}:${this.count}`; } + runtimePlatform() { return this.runtime.platform; } + render(): TemplateResult { const strings = [`Count: ${this.count}, Message: ${this.message}`]; return { @@ -98,6 +100,16 @@ describe('Cossack Core: Server-Side', () => { expect((component as any).c).toEqual(mockContext); }); + it('exposes the runtime platform to colocated server methods', async () => { + const mockContext = { req: { param: vi.fn() } } as unknown as Context; + await component.bootstrap({ + context: mockContext, + runtime: { adapter: 'deno', platform: 'desktop' }, + }); + + expect(component.runtimePlatform()).toBe('desktop'); + }); + it('should initialize state with default values', async () => { const mockContext = { req: { param: vi.fn() } } as unknown as Context; await component.bootstrap({ context: mockContext }); diff --git a/packages/deno-adapter/README.md b/packages/deno-adapter/README.md new file mode 100644 index 00000000..dec6fda5 --- /dev/null +++ b/packages/deno-adapter/README.md @@ -0,0 +1,137 @@ +# `@cossackframework/deno-adapter` + +The runtime adapter for running one Cossack application on Deno, Deno Deploy, +and Deno Desktop. It connects Cossack's runtime-neutral router and component +model to `Deno.serve()`, Hono's Deno helpers, process-local WebSockets, built +assets, and a local Desktop server-method target. + +## Package information + +| | | +| --- | --- | +| Package | `@cossackframework/deno-adapter` | +| Runtime | Deno 2.9 or newer | +| Web targets | Local Deno and Deno Deploy | +| Desktop targets | Deno Desktop with WebView or CEF | +| Module format | ESM | +| License | MIT | + +The package exports three entry points: + +| Import | Purpose | +| --- | --- | +| `@cossackframework/deno-adapter` | `createDenoAdapter()` and runtime types | +| `@cossackframework/deno-adapter/desktop` | Runtime detection and optional low-level window bindings | +| `@cossackframework/deno-adapter/desktop/client` | Optional browser-safe direct-binding client | + +## Installation + +The recommended setup is the Cossack scaffold: + +```sh +cossack create my-app --adapter deno +cd my-app +pnpm install +``` + +Add the optional Deno Desktop target to any Cossack web project with: + +```sh +cossack add desktop +pnpm install +``` + +For manual installation: + +```sh +pnpm add @cossackframework/deno-adapter hono +pnpm add -D @types/deno +``` + +Or manage the npm dependencies with Deno: + +```sh +deno add npm:@cossackframework/deno-adapter npm:hono +``` + +## Minimal server + +```ts +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { createApp } from '@cossackframework/framework/router'; +import { App } from './App.ts'; +import { template } from './root.ts'; + +const env: Record = Deno.env.toObject(); +const runtime = createDenoAdapter({ env }); +const app = createApp({ + AppComponent: App, + htmlTemplate: template, + runtimeAdapter: runtime, +}); + +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(app, request, requestEnv), +}; + +// `deno desktop` serves the default export itself. +if (import.meta.main && typeof (Deno as any).BrowserWindow !== 'function') { + runtime.serve(app); +} +``` + +`createDenoAdapter()` injects configured environment values and an +`ASSETS.fetch()`-compatible binding, serves the Vite client output, and handles +Cossack WebSocket upgrades without moving routing, authentication, origin +validation, or scope calculation out of the framework. + +## Automatic Desktop methods + +Desktop runs a local Deno Cossack server against the same `src/pages/` tree. +Undecorated methods keep their normal server-only behavior: their bodies are +stripped from the browser bundle and calls are automatically sent through +Cossack RPC. No `desktop.invoke()` call is required. + +```ts +@State() count = 0; + +async init() { + if (this.isDesktop) { + this.count = Number.parseInt(localStorage.getItem('count') ?? '0', 10); + } +} + +increment() { + this.count += 1; + if (this.isDesktop) localStorage.setItem('count', String(this.count)); +} +``` + +Use `this.isDesktop` or `this.runtime.platform` inside server-only code. The web +target may remain Cloudflare Workers, Node.js, or Deno; only the additional +Desktop target is always Deno. + +## Documentation + +- [Introduction](./docs/introduction.md) +- [Installation](./docs/installation.md) +- [Deno web and Deno Deploy](./docs/web.md) +- [Deno Desktop](./docs/desktop.md) +- [Desktop counter example](../../examples/deno-desktop-counter/README.md) +- [Deno Desktop documentation](https://docs.deno.com/runtime/desktop/) +- [Hono on Deno](https://hono.dev/docs/getting-started/deno) + +## Runtime boundaries + +- Deno WebSocket component instances are bounded, idle-evicted, and local to + one process. They do not synchronize across Deno Deploy instances. +- `stateful: true` is rejected on the Deno adapter. Persist durable application + data through a database. +- Normal undecorated/`@Server()` methods are the automatic Desktop bridge and + remain the right place for application logic and machine-local Deno APIs. +- Low-level direct bindings remain available for unusual per-window operations + that intentionally bypass component RPC, but ordinary page actions do not + need them. +- The Desktop `raw` backend is unsupported because Cossack requires an HTML + webview. Use the default WebView backend or CEF. diff --git a/packages/deno-adapter/docs/desktop.md b/packages/deno-adapter/docs/desktop.md new file mode 100644 index 00000000..7b38a8b8 --- /dev/null +++ b/packages/deno-adapter/docs/desktop.md @@ -0,0 +1,217 @@ +--- +title: Deno Desktop +description: Run shared Cossack server methods automatically in a local Deno Desktop target. +--- + +# Deno Desktop + +Deno Desktop packages a local Cossack HTTP server and its client assets with a +native webview. It uses the same `src/pages/` tree as the web application. The +web runtime may be Cloudflare Workers, Node.js, or Deno; the additional Desktop +runtime is always Deno. + +## Add the target + +```sh +cossack add desktop +pnpm install +``` + +For a new application: + +```sh +cossack create my-app --adapter cloudflare --features ui,desktop +``` + +Replace `cloudflare` with `node` or `deno` for a different web target. Switching +the web adapter later preserves Desktop. + +The generated `deno.json` defaults to the platform WebView. Set +`desktop.backend` to `cef` when the application needs bundled Chromium. Do not +select `raw`; Cossack requires an HTML webview. + +## Target layout + +```text +src/ +├── index.ts # Cloudflare, Node.js, or Deno web entry +├── pages/ # shared pages and methods +│ └── index.ts +└── desktop/ + └── index.ts # local Deno Desktop entry +``` + +There is no Desktop-specific router. Both entries load the same pages, layouts, +middleware registry, App component, and root template. + +## Colocate native behavior with the component + +Methods without a client-safe decorator are already server-only in Cossack. +Their bodies are removed from the client bundle and replaced with automatic +RPC proxies. In a Desktop window the proxy calls the packaged local Deno server, +so no manual `desktop.invoke()` layer is needed: + +```ts +import { Cossack, Page, State } from '@cossackframework/core'; + +const STORAGE_KEY = 'counter'; + +@Page({ transport: 'http' }) +export default class CounterPage extends Cossack { + @State() count = 0; + + async init() { + if (!this.isDesktop) return; + const value = Number.parseInt( + localStorage.getItem(STORAGE_KEY) ?? '0', + 10, + ); + this.count = Number.isFinite(value) ? value : 0; + } + + increment() { + this.count += 1; + if (this.isDesktop) { + localStorage.setItem(STORAGE_KEY, String(this.count)); + } + } + + decrement() { + this.count -= 1; + if (this.isDesktop) { + localStorage.setItem(STORAGE_KEY, String(this.count)); + } + } +} +``` + +Do not add `@Client()` to these methods: that would retain and execute their +bodies inside the webview. Leave them undecorated or add `@Server()` explicitly +when you want to emphasize the boundary. + +Calls from event handlers use the normal Cossack method syntax: + +```ts +render() { + return html` + ${this.count} + + + `; +} +``` + +The client proxy chooses `/crpc` for HTTP/SSE pages or WebSockets for a +WebSocket-backed page. Returned `@State()` is synchronized through the existing +Cossack protocol. + +## Detect the target + +Components receive framework-owned runtime identity during SSR, hydration, and +every reconstructed RPC instance: + +```ts +this.isDesktop; // boolean convenience getter +this.runtime.platform; // 'web' | 'desktop' +this.runtime.adapter; // 'deno' in the Desktop target +``` + +Use this only inside server-only methods or for harmless rendering differences. +It is not an authorization check. + +## Use native APIs safely + +Native code remains in the server-only method body, so it is stripped from the +browser bundle. Prefer feature access through `globalThis` when the same source +must also build for Cloudflare or Node.js: + +```ts +async chooseFile() { + if (!this.isDesktop) throw new Error('Desktop only'); + const deno = (globalThis as any).Deno; + // Call the required Deno Desktop API here. +} +``` + +Validate all paths, identifiers, and domain values even though the method is +local. Cossack RPC only exposes registered server methods and sanitizes incoming +state, but the webview is still an input boundary. + +## Different web and Desktop runtimes + +The scaffold treats Desktop as an independent build target: + +| Target | Entry | Runtime | +| --- | --- | --- | +| Web | `src/index.ts` | Selected adapter: Cloudflare, Node.js, or Deno | +| Desktop | `src/desktop/index.ts` | Deno adapter | + +Application code must be compatible with every target in which it executes. +For a platform-specific branch, use `this.isDesktop`. For shared database calls, +select a database supported by both runtimes or isolate the provider-specific +operation behind separate server logic. D1 and Hyperdrive remain +Cloudflare-only; Deno Desktop supports SQLite, PostgreSQL, MySQL, and Turso. + +Use `@tursodatabase/database` for embedded/Desktop Turso and +`@tursodatabase/serverless` for remote Turso. Do not add the outdated libSQL +client. + +## Direct per-window bindings + +The `defineDesktopBindings()` and `createDesktopClient()` APIs remain as a +low-level escape hatch for operations that intentionally bypass component RPC, +for example a high-frequency per-window channel or a binding attached to only +one additional window. Ordinary page actions should use colocated server +methods. + +Direct bindings remain allowlisted, capability-token protected, typed across +the client boundary, and limited to serialized values. Their handlers still +require domain validation. Attach a registry explicitly to every additional +window with `attachDesktopBindings(window, registry)`. + +## Development and packaging + +The scaffold provides: + +```sh +deno task build:desktop +deno task desktop:dev +deno task desktop:build +``` + +The build uses the explicit Cossack Desktop entry, preventing Deno from +misidentifying the project as a client-only Vite application: + +```sh +vite build +vite build --ssr src/desktop/index.ts \ + --outDir dist/desktop-server \ + --minify false + +deno desktop -A \ + --exclude-unused-npm \ + --include dist/client \ + dist/desktop-server/index.js +``` + +The default output directory lives under ignored `dist/desktop`. Keep +`deno.lock` committed, but do not commit packaged executables, runtime `.so` +files, launcher metadata, `.deno-desktop-app`, or `.downloaded` markers. + +Grant narrower permissions than `-A` for production packages once the native +feature set is known. + +## Persistence example + +[`examples/deno-desktop-counter`](../../../examples/deno-desktop-counter/README.md) +uses the automatic model above. Its browser state remains in memory; the same +`init`, `increment`, and `decrement` methods execute in the local Deno server and +persist through Deno-side `localStorage` when `this.isDesktop` is true. + +## Current limits + +- Desktop server state and WebSockets remain process-local. +- `stateful: true` is unsupported on the Deno adapter. +- Menus, tray integration, deep links, auto-update, signing/notarization + automation, and Desktop OAuth flows are outside the v1 adapter surface. +- The raw renderer backend is unsupported. diff --git a/packages/deno-adapter/docs/installation.md b/packages/deno-adapter/docs/installation.md new file mode 100644 index 00000000..8c00925a --- /dev/null +++ b/packages/deno-adapter/docs/installation.md @@ -0,0 +1,187 @@ +--- +title: Installation +description: Install and configure the Cossack Deno adapter. +--- + +# Installation + +## Requirements + +- Deno 2.9 or newer +- A Cossack application using ESM +- Hono 4.12 or newer +- Vite for client and SSR builds + +Check the installed runtime before continuing: + +```sh +deno --version +``` + +## Create a web project + +Choose the web adapter independently: + +```sh +cossack create my-app --adapter deno +cd my-app +pnpm install +``` + +Create a Cloudflare web app with a Deno Desktop target, for example: + +```sh +cossack create my-app --adapter cloudflare --features ui,desktop +``` + +Node.js and Deno are also valid web adapters. Adding `desktop` always generates +`src/desktop/index.ts` and `deno.json` for the local target without changing the +web adapter's `src/index.ts`. + +The non-interactive Deno web database default is Turso. Select another compatible +provider explicitly when needed: + +```sh +cossack create my-app --adapter deno --features database --database sqlite +cossack create my-app --adapter deno --features database --database postgres +cossack create my-app --adapter deno --features database --database mysql +``` + +D1 and Hyperdrive are Cloudflare-only providers. + +When the same server method accesses a database in both web and Desktop builds, +choose a provider/configuration supported by both runtimes, such as remote +Turso or PostgreSQL. A D1-backed Cloudflare method cannot run unchanged in the +local Deno target. + +## Add the package manually + +With pnpm: + +```sh +pnpm add @cossackframework/deno-adapter hono +pnpm add -D @types/deno +``` + +With Deno's package manager: + +```sh +deno add npm:@cossackframework/deno-adapter npm:hono +``` + +When manually managing `deno.json`, map packages that are imported from Deno +configuration or source files: + +```json +{ + "nodeModulesDir": "auto", + "imports": { + "hono": "npm:hono@^4.12.0", + "vite": "npm:vite@^8.0.0" + }, + "tasks": { + "dev": "pnpm run dev", + "build": "pnpm run build", + "start": "deno run --allow-env --allow-net --allow-read dist/server/index.js", + "deploy": "deno task build && deno deploy" + } +} +``` + +Pin versions appropriate for the application rather than copying the example +constraints indefinitely. Commit `deno.lock`; it is part of a reproducible Deno +application build. + +## Configure Vite + +Use the Cossack plugins and bundle Hono into the SSR output consumed by Deno +Desktop: + +```ts +import { defineConfig } from 'vite'; +import { cossackPlugin } from '@cossackframework/framework/vite-plugin'; +import { cossackSecurityPlugin } from '@cossackframework/framework/vite-security-plugin'; + +export default defineConfig({ + plugins: [cossackPlugin(), cossackSecurityPlugin()], + ssr: { + noExternal: [ + '@cossackframework/core', + '@cossackframework/deno-adapter', + '@cossackframework/framework', + '@cossackframework/renderer', + 'hono', + ], + }, +}); +``` + +Keep the project's complete generated Vite configuration when using the +scaffold; the excerpt only highlights the Deno-specific SSR requirement. + +## Create a Deno web server entry + +```ts +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { createApp } from '@cossackframework/framework/router'; +import { App } from './App.ts'; +import { template } from './root.ts'; + +export const env: Record = Deno.env.toObject(); +export const runtime = createDenoAdapter({ env }); +export const app = createApp({ + AppComponent: App, + htmlTemplate: template, + runtimeAdapter: runtime, +}); + +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(app, request, requestEnv), +}; + +if (import.meta.main && typeof (Deno as any).BrowserWindow !== 'function') { + runtime.serve(app); +} +``` + +The Desktop guard matters because `deno desktop` automatically serves the +module's default `fetch` export. Calling `runtime.serve()` a second time would +try to bind the reserved Desktop address twice. + +## Create the independent Desktop entry + +For Node.js and Cloudflare web projects, keep `src/index.ts` unchanged and add +the generated `src/desktop/index.ts`: + +```ts +import { createDenoAdapter } from '@cossackframework/deno-adapter'; +import { createApp } from '@cossackframework/framework/router'; +import { App } from '../App.ts'; +import { template } from '../root.ts'; + +const runtime = createDenoAdapter({ env: Deno.env.toObject() }); +const app = createApp({ + AppComponent: App, + htmlTemplate: template, + runtimeAdapter: runtime, +}); + +export default { + fetch: (request: Request, env?: Record) => + runtime.fetch(app, request, env), +}; +``` + +Both entries discover the same `src/pages/` modules through the Cossack Vite +plugin. + +## Verify the web setup + +```sh +deno task build +deno task start +``` + +Then open the printed local URL. Continue with [Web](./web.md) for runtime +options or [Desktop](./desktop.md) to add a native target. diff --git a/packages/deno-adapter/docs/introduction.md b/packages/deno-adapter/docs/introduction.md new file mode 100644 index 00000000..290dc8c9 --- /dev/null +++ b/packages/deno-adapter/docs/introduction.md @@ -0,0 +1,129 @@ +--- +title: Introduction +description: Understand the Cossack adapter for Deno, Deno Deploy, and Deno Desktop. +--- + +# Introduction + +`@cossackframework/deno-adapter` lets a Cossack application run as a Deno HTTP +service, on Deno Deploy, or inside a native Deno Desktop window. A Desktop +target can also accompany a Cloudflare Workers or Node.js web target. The +adapter is deliberately narrow: Cossack still owns route resolution, +middleware, authentication, origin validation, server-computed scope keys, +SSR, hydration, and application RPC. + +The adapter supplies the runtime-specific pieces: + +- `Deno.serve()` integration for local and production HTTP serving. +- Static delivery of the Vite client build. +- An `ASSETS.fetch()`-compatible environment binding. +- Hono's Deno static-file and WebSocket primitives. +- Bounded, idle-evicted, process-local component instances for WebSockets. +- Runtime metadata used by typed Deno Desktop bindings. + +## One page tree, independent targets + +The server entry and page tree remain shared: + +```text +src/ +├── App.ts +├── index.ts # selected web adapter +├── pages/ +│ └── index.ts +└── desktop/ + └── index.ts # local Deno Desktop server +``` + +Keep shared UI, state, and routes in `src/pages/`. Reserve `src/desktop/` for +the Deno Desktop entry and optional window/menu integration. Desktop does not +introduce a second router; it creates another runtime target over the same +route modules. + +Desktop is an optional Deno side target, not the web adapter: + +```sh +cossack create my-app --adapter cloudflare --features ui,desktop +``` + +For an existing Cloudflare, Node.js, or Deno project: + +```sh +cossack add desktop +``` + +Switching the web adapter does not remove or rewrite the independent Desktop +target. `src/index.ts` continues to represent the selected web runtime while +`src/desktop/index.ts` always uses `createDenoAdapter()`. + +## Runtime adapter contract + +Pass the adapter to `createApp()` and route requests through its `fetch()` +method: + +```ts +const runtime = createDenoAdapter({ env: Deno.env.toObject() }); +const app = createApp({ + AppComponent: App, + htmlTemplate: template, + runtimeAdapter: runtime, +}); + +export default { + fetch: (request: Request, requestEnv?: Record) => + runtime.fetch(app, request, requestEnv), +}; +``` + +The optional `runtimeAdapter` contract may contribute client metadata and +perform a process-specific WebSocket upgrade. It does not receive authority to +choose routes, users, origins, or client-provided scope keys. + +## Automatic local Desktop RPC + +Use ordinary undecorated or `@Server()` methods for application behavior and +machine-local Desktop work. Cossack strips their bodies from the client and +automatically calls `/crpc` or WebSockets. In a Desktop window, that request is +handled by the packaged local Deno server, so native code can stay in the same +component class: + +```ts +@State() count = 0; + +async init() { + if (this.isDesktop) { + this.count = Number(localStorage.getItem('count') ?? 0); + } +} + +increment() { + this.count += 1; + if (this.isDesktop) localStorage.setItem('count', String(this.count)); +} +``` + +Use `this.isDesktop` for the common condition or inspect +`this.runtime.platform` (`'web' | 'desktop'`) and `this.runtime.adapter`. + +The low-level typed binding API remains available when a per-window operation +must bypass component RPC. It is an escape hatch, not the default application +model. + +## Persistence and scaling + +Deno WebSocket state lives only in the current process. The adapter limits the +number of component instances and evicts idle instances, but it does not provide +durable or cross-instance coordination. Consequently, the framework rejects +`stateful: true` with the Deno adapter. + +Persist important state in a database supported by every target that executes +the relevant method. Deno Desktop can use SQLite, PostgreSQL, MySQL, or Turso +through `@cossackframework/database`. The current +embedded/Desktop Turso driver is `@tursodatabase/database`; do not add the +outdated libSQL client. + +## Continue reading + +- [Installation](./installation.md) +- [Deno web and Deno Deploy](./web.md) +- [Deno Desktop](./desktop.md) diff --git a/packages/deno-adapter/docs/web.md b/packages/deno-adapter/docs/web.md new file mode 100644 index 00000000..6c078f81 --- /dev/null +++ b/packages/deno-adapter/docs/web.md @@ -0,0 +1,130 @@ +--- +title: Deno web and Deno Deploy +description: Serve Cossack over Deno HTTP and process-local WebSockets. +--- + +# Deno web and Deno Deploy + +## Create and serve the adapter + +`createDenoAdapter()` returns a Cossack runtime adapter plus `fetch()` and +`serve()` helpers: + +```ts +const runtime = createDenoAdapter({ + env: Deno.env.toObject(), + assetsRoot: './dist/client', + hostname: '127.0.0.1', + port: 3000, + maxInstances: 512, + idleTimeoutMs: 15 * 60_000, +}); +``` + +| Option | Default | Purpose | +| --- | --- | --- | +| `env` | `{}` | Base values exposed through `c.env` and `this.env` | +| `assetsRoot` | `./dist/client` | Vite client output served before SSR routes | +| `hostname` | Deno default | Local `Deno.serve()` hostname | +| `port` | Deno default | Local `Deno.serve()` port | +| `maxInstances` | `512` | Maximum process-local WebSocket component instances | +| `idleTimeoutMs` | 15 minutes | Idle-instance eviction threshold | + +Environment values passed to `runtime.fetch(app, request, requestEnv)` override +the adapter's configured base values for that request. + +## Static assets and `ASSETS` + +The adapter serves files from `assetsRoot` with Hono's Deno static middleware. +It also injects this compatible binding: + +```ts +interface AssetsBinding { + fetch(request: Request): Promise; +} +``` + +Application and middleware code can therefore read a static file with the same +`env.ASSETS.fetch(request)` shape used by other Cossack runtimes. Cossack retains +ownership of `/` and all document routes so SSR is not replaced by a generated +`index.html`. + +## WebSockets + +Pass the adapter to `createApp()` to enable its process-based upgrade handler. +The framework continues to resolve the component, authenticate the request, +check the origin, recompute the scope, and construct the component instance. + +```ts +@Page({ + transport: 'durable-object', + scope: (c) => `user:${c.get('user')?.id ?? 'anonymous'}`, +}) +export default class CounterPage extends Cossack { + @State() count = 0; + + @Server() + increment() { + this.count += 1; + } +} +``` + +With a runtime adapter, this transport uses the adapter's in-memory WebSocket +engine rather than a Cloudflare Durable Object. Each component/provider/scope +target gets an isolated instance. Malformed client frames are isolated, action +calls retain the authenticated client identity, nested component targets are +supported, and instances with no clients are eligible for eviction. + +Do not set `stateful: true` on Deno. There is no durable WebSocket persistence +or cross-process broadcast in this adapter. Store durable state in a database +and use the socket as a synchronization channel. + +## Origin and secure protocol behavior + +Cossack validates WebSocket origins before the adapter receives an upgrade. +Configure `allowedOrigins` through `createApp()` when additional origins are +intentional. The browser client selects `ws:` for an HTTP page and `wss:` for +an HTTPS page; do not hard-code a WebSocket scheme. + +## Authentication and email + +Normal Cossack web authentication works on Deno. Pass authentication middleware +and environment values in the same way as other runtimes. Auth projects reuse +the SMTP-compatible `env.EMAIL` contract: + +```ts +await this.env.EMAIL.send({ + to: user.email, + from: 'no-reply@example.com', + subject: 'Reset your password', + text: '...', + html: '

...

', +}); +``` + +Provide an implementation in `createDenoAdapter({ env })`; the adapter does not +select an SMTP provider. + +## Local production serving + +```sh +deno task build +deno task start +``` + +The generated start task grants only environment, network, and static-file read +permissions. Add permissions only when application-side Deno APIs require them. + +## Deno Deploy + +The exported default `fetch` handler is deployable without a separate Node +server. Build the Cossack client and SSR output before deploying: + +```sh +deno task deploy +``` + +Remember that Deno Deploy may run multiple isolated instances. WebSocket state +and broadcasts do not span those instances. Use PostgreSQL, Turso, or another +shared datastore for durable application state. diff --git a/packages/deno-adapter/package.json b/packages/deno-adapter/package.json index 8be12325..27839175 100644 --- a/packages/deno-adapter/package.json +++ b/packages/deno-adapter/package.json @@ -11,7 +11,7 @@ "./desktop": { "types": "./dist/desktop.d.ts", "import": "./dist/desktop.js" }, "./desktop/client": { "types": "./dist/desktop-client.d.ts", "import": "./dist/desktop-client.js" } }, - "files": ["dist"], + "files": ["dist", "docs", "README.md"], "publishConfig": { "access": "public" }, "scripts": { "build": "vite build && tsc -p tsconfig.declarations.json", diff --git a/packages/deno-adapter/src/index.ts b/packages/deno-adapter/src/index.ts index d1875ee7..f8307e8a 100644 --- a/packages/deno-adapter/src/index.ts +++ b/packages/deno-adapter/src/index.ts @@ -2,7 +2,7 @@ import { Hono, type Context } from 'hono'; import { serveStatic, upgradeWebSocket } from 'hono/deno'; import { InMemoryWebSocketRuntime } from '@cossackframework/core'; import type { CossackRuntimeAdapter, RuntimeWebSocketUpgrade } from '@cossackframework/framework/runtime-adapter'; -import { getDesktopClientMetadata } from './desktop.js'; +import { getDesktopClientMetadata, isDesktopRuntime } from './desktop.js'; export interface DenoAdapterOptions { env?: Record; @@ -154,7 +154,10 @@ export function createDenoAdapter(options: DenoAdapterOptions = {}): CossackDeno return { name: 'deno', get instanceCount() { return instances.size; }, - getClientMetadata: () => getDesktopClientMetadata(), + getClientMetadata: () => ({ + platform: isDesktopRuntime() ? 'desktop' : 'web', + ...getDesktopClientMetadata(), + }), handleWebSocketUpgrade, fetch(app, request, env) { return getFetchHandler(app)(request, env); diff --git a/packages/deno-adapter/tests/adapter.test.ts b/packages/deno-adapter/tests/adapter.test.ts index 833c8795..c29e2ccf 100644 --- a/packages/deno-adapter/tests/adapter.test.ts +++ b/packages/deno-adapter/tests/adapter.test.ts @@ -47,4 +47,18 @@ describe('Deno adapter fetch handler', () => { expect(() => createDenoAdapter().serve({ fetch: () => new Response() })) .toThrow('requires Deno 2.9 or newer'); }); + + it('identifies web and Desktop runtime targets', async () => { + const adapter = createDenoAdapter(); + expect(await adapter.getClientMetadata?.()).toMatchObject({ platform: 'web' }); + + (globalThis as any).Deno = { BrowserWindow: class {} }; + try { + expect(await adapter.getClientMetadata?.()).toMatchObject({ + platform: 'desktop', + }); + } finally { + delete (globalThis as any).Deno; + } + }); }); diff --git a/packages/framework/src/route-ids.ts b/packages/framework/src/route-ids.ts index e4fd095c..7800f4a9 100644 --- a/packages/framework/src/route-ids.ts +++ b/packages/framework/src/route-ids.ts @@ -172,6 +172,8 @@ export interface RouterContext { layouts: Record; /** Allowed Origin values for WS/SSE upgrades. Defaults to same-origin. */ allowedOrigins?: string[]; + /** Resolve framework-owned runtime identity for reconstructed components. */ + runtimeInfo?: () => Promise; } /** diff --git a/packages/framework/src/router.ts b/packages/framework/src/router.ts index 233f20d4..e1099e39 100644 --- a/packages/framework/src/router.ts +++ b/packages/framework/src/router.ts @@ -1,7 +1,7 @@ // src/router.ts import { Hono, type Context, type Handler } from 'hono'; import { renderRoot, TemplateHelpers } from './root.js'; -import { Page, PageOptions, Cossack, User, type Middleware } from '@cossackframework/core'; +import { Page, PageOptions, Cossack, User, type CossackRuntimeInfo, type Middleware } from '@cossackframework/core'; import { createInstance, createLayoutServiceScope, @@ -342,6 +342,14 @@ export function createApp(options: CreateAppOptions = {}) { // so users get the same page whether or not they type the slash. const app = new Hono<{ Bindings: CloudflareBindings; Variables: { user?: User; db?: any } }>({ strict: false }); + const resolveRuntimeInfo = async (): Promise => ({ + platform: 'web', + ...(options.runtimeAdapter ? { + ...(await options.runtimeAdapter.getClientMetadata?.()), + adapter: options.runtimeAdapter.name, + } : {}), + }); + // Shared context passed to transport handlers const routerContext: RouterContext = { routeIdMap, @@ -350,6 +358,7 @@ export function createApp(options: CreateAppOptions = {}) { pages, layouts, allowedOrigins: options.allowedOrigins, + runtimeInfo: resolveRuntimeInfo, }; // Request-context middleware — scopes the Hono `Context` into @@ -401,6 +410,7 @@ export function createApp(options: CreateAppOptions = {}) { const createSsrHandler = (PageComponent: new () => Cossack, path: string, pageOptions?: PageOptions) => { return async (c: Context) => { const inlineCss = await getInlineCss(c.env); + const runtimeInfo = await resolveRuntimeInfo(); const requestServiceScope = createRootServiceScope(); try { @@ -458,7 +468,7 @@ export function createApp(options: CreateAppOptions = {}) { } // Bootstrap App - await appInstance.bootstrap({ context: c, user, env: c.env, page: c.req.path }); + await appInstance.bootstrap({ context: c, user, env: c.env, runtime: runtimeInfo, page: c.req.path }); // Bootstrap Layouts const layoutStates: Record = {}; @@ -472,7 +482,7 @@ export function createApp(options: CreateAppOptions = {}) { }); layoutServiceScope.bindRequest({ context: c, user, env: c.env }); const lInst = createInstance(LComp, { serviceScope: layoutServiceScope, ownsServiceScope: true }); - await lInst.bootstrap({ context: c, user, env: c.env, page: c.req.path }); + await lInst.bootstrap({ context: c, user, env: c.env, runtime: runtimeInfo, page: c.req.path }); layoutInstances.push(lInst); layoutStates[lPath] = lInst.getInitialState(); activeServiceScope = layoutServiceScope; @@ -493,6 +503,7 @@ export function createApp(options: CreateAppOptions = {}) { context: c, user, env: c.env, + runtime: runtimeInfo, page: c.req.path, initialState: doInitialState, skipInit: shouldSkipInit, @@ -580,12 +591,7 @@ export function createApp(options: CreateAppOptions = {}) { // fallback if different) so `__()` works on the client immediately. // Other locales are dynamic-imported on demand by `setLocale()`. __cossackLang: buildLocaleHydrationData(), - ...(options.runtimeAdapter ? { - runtime: { - adapter: options.runtimeAdapter.name, - ...(await options.runtimeAdapter.getClientMetadata?.()), - }, - } : {}), + runtime: runtimeInfo, }; c.header('Content-Type', 'text/html'); @@ -661,6 +667,7 @@ export function createApp(options: CreateAppOptions = {}) { const pathname = c.req.query('pathname') || '/'; const user = c.get('user'); + const runtimeInfo = await resolveRuntimeInfo(); return options.runtimeAdapter!.handleWebSocketUpgrade!(c, { target, provider, @@ -670,7 +677,7 @@ export function createApp(options: CreateAppOptions = {}) { env: c.env as unknown as Record, createComponent: async () => { const instance = createInstance(ComponentClass) as Cossack; - await instance.bootstrap({ context: c, user, env: c.env, page: pathname, providerName: provider }); + await instance.bootstrap({ context: c, user, env: c.env, runtime: runtimeInfo, page: pathname, providerName: provider }); instance._render(); return instance; }, @@ -687,6 +694,7 @@ export function createApp(options: CreateAppOptions = {}) { const { componentRouteId, action, state, payload, target, scopeKey: clientScopeKey } = body; const isStreamRequest = !!body._cossack_stream; const user = c.get('user'); + const runtimeInfo = await resolveRuntimeInfo(); // Explicit layout-service RPC. The client addresses the owning layout and // stable service slot; no service fields or methods are projected onto a @@ -759,7 +767,7 @@ export function createApp(options: CreateAppOptions = {}) { let componentInstance: any; if (componentPath === '/src/App') { componentInstance = createInstance(options.AppComponent ?? RouterFallbackApp); - await componentInstance.bootstrap({ context: c, user, env: c.env, skipInit: true }); + await componentInstance.bootstrap({ context: c, user, env: c.env, runtime: runtimeInfo, skipInit: true }); componentInstance._render(); } else { const module = pages[componentPath] || layouts[componentPath]; @@ -767,7 +775,7 @@ export function createApp(options: CreateAppOptions = {}) { const PageComponent = Object.values(module as object)[0] as new () => Cossack; if (!PageComponent || typeof PageComponent !== 'function') return c.json({ error: 'Invalid component' }, 500); componentInstance = createInstance(PageComponent) as any; - await componentInstance.bootstrap({ context: c, user, env: c.env, skipInit: true }); + await componentInstance.bootstrap({ context: c, user, env: c.env, runtime: runtimeInfo, skipInit: true }); // Rebuild component tree componentInstance._render(); diff --git a/packages/framework/src/transports/http.ts b/packages/framework/src/transports/http.ts index e0bf086d..cd615636 100644 --- a/packages/framework/src/transports/http.ts +++ b/packages/framework/src/transports/http.ts @@ -52,7 +52,13 @@ export function handleUpload(ctx: RouterContext) { if (!PageComponent || typeof PageComponent !== 'function') return c.json({ error: 'Invalid component' }, 500); const componentInstance = createInstance(PageComponent) as any; - await componentInstance.bootstrap({ context: c, user, env: c.env, skipInit: true }); + await componentInstance.bootstrap({ + context: c, + user, + env: c.env, + runtime: await ctx.runtimeInfo?.(), + skipInit: true, + }); // Rebuild component tree to find target componentInstance._render(); diff --git a/packages/framework/src/transports/sse.ts b/packages/framework/src/transports/sse.ts index cc2bdfa1..3ff8671a 100644 --- a/packages/framework/src/transports/sse.ts +++ b/packages/framework/src/transports/sse.ts @@ -163,7 +163,13 @@ export function handleSseEndpoint(ctx: RouterContext) { // Cold start: create instance on demand const user = c.get('user'); const componentInstance = createInstance(PageComponent) as any; - await componentInstance.bootstrap({ context: c, user, env: c.env, skipInit: true }); + await componentInstance.bootstrap({ + context: c, + user, + env: c.env, + runtime: await ctx.runtimeInfo?.(), + skipInit: true, + }); componentInstance._render(); const runtime = new SseRuntime(componentInstance); diff --git a/packages/framework/src/vite-security-plugin.ts b/packages/framework/src/vite-security-plugin.ts index 847a7be2..8d362990 100644 --- a/packages/framework/src/vite-security-plugin.ts +++ b/packages/framework/src/vite-security-plugin.ts @@ -173,13 +173,29 @@ export function cossackSecurityPlugin(options: CossackSecurityPluginOptions = {} // replaces that method with the existing RPC proxy stub. if (code.includes('server$')) code = transformServerResources(code, id); - if (!isClientEnvironment) return { code, map: null }; - // Check if this file contains a Cossack class or a @Service decorated class if (!code.includes('extends Cossack') && !code.includes('extends CossackElement') && !code.includes('@Service')) { return { code, map: null }; } + // Undecorated methods are server-only by default. When one is exposed as + // a bare handler reference from client-safe code (for example + // `@click=${this.increment}` in render()), register it in both the server + // and client class metadata. The server registration makes the RPC + // allowlist accept it; the client registration lets bootstrap replace + // its stripped stub with the normal transport proxy. + if (!isClientEnvironment) { + return { + code: injectAutomaticServerMethodMetadata( + code, + id, + isClientSafeMethod, + BUILTIN_METHODS, + ), + map: null, + }; + } + try { const stripped = transformCossackClass(code, id, isClientSafeMethod, BUILTIN_METHODS, devWarning); const transformed = stripClientServerOnlyImports(stripped, id); @@ -906,6 +922,67 @@ function collectThisCalls(node: any): string[] { return names; } +/** + * Collect bare `this.` references, excluding direct calls such as + * `this.method()`. A bare reference from `render()` or another client-safe + * method is how Cossack methods are normally passed to event handlers. + */ +function collectThisBareReferences(node: any): string[] { + const names: string[] = []; + const visit = (n: any, parent?: any) => { + if (!n || typeof n.type !== 'string') return; + if ( + n.type === 'MemberExpression' && + n.object?.type === 'ThisExpression' && + !n.computed && + n.property?.type === 'Identifier' + ) { + const isDirectCall = parent?.type === 'CallExpression' && parent.callee === n; + if (!isDirectCall) names.push(n.property.name); + } + for (const k of Object.keys(n)) { + const v = n[k]; + if (Array.isArray(v)) { + for (const c of v) visit(c, n); + } else if (v && typeof v.type === 'string') { + visit(v, n); + } + } + }; + visit(node); + return names; +} + +/** Methods exposed by client-safe code as handler values become automatic RPC endpoints. */ +function computeAutomaticRpcSet( + cls: any, + methods: AstMethod[], + preserved: Set, +): Set { + const byName = new Map(methods.map((method) => [method.name, method])); + const automatic = new Set(); + + for (const member of cls?.body?.body ?? []) { + const name = memberKeyName(member.key); + if (name === null || !preserved.has(name)) continue; + const body = member.type === 'MethodDefinition' + ? member.value?.body + : member.type === 'PropertyDefinition' + ? member.value?.body ?? member.value + : undefined; + if (!body) continue; + + for (const reference of collectThisBareReferences(body)) { + const target = byName.get(reference); + if (target && !preserved.has(reference) && !target.hasServerDecorator) { + automatic.add(reference); + } + } + } + + return automatic; +} + /** * Compute the preserved set: methods that must retain their full implementation * in the client bundle. Seeds with client-safe methods (by decorator or builtin @@ -1074,18 +1151,21 @@ function createFieldStub( /** * Extract the names of server-only methods that will be stubbed, along with - * whether each one carries an explicit `@Server` decorator. Only `@Server` - * methods are eligible for RPC metadata injection — undecorated helpers that - * get stripped must NOT be auto-registered as RPC endpoints. + * whether each one carries an explicit `@Server` decorator or is exposed as an + * automatic handler RPC. Unreachable undecorated helpers remain unregistered. */ function extractServerOnlyMethodNames( methods: AstMethod[], preserved: Set, -): Array<{ name: string; hasServerDecorator: boolean }> { - const result: Array<{ name: string; hasServerDecorator: boolean }> = []; + automaticRpc: Set, +): Array<{ name: string; registerForRpc: boolean }> { + const result: Array<{ name: string; registerForRpc: boolean }> = []; for (const m of methods) { if (!preserved.has(m.name)) { - result.push({ name: m.name, hasServerDecorator: m.hasServerDecorator }); + result.push({ + name: m.name, + registerForRpc: m.hasServerDecorator || automaticRpc.has(m.name), + }); } } return result; @@ -1093,18 +1173,17 @@ function extractServerOnlyMethodNames( /** * Create metadata injection code that registers server-only methods for RPC - * proxying. Only methods that carry an explicit `@Server` decorator are - * registered — undecorated helpers that get stripped must never receive an RPC - * proxy, so their stubs throw loudly instead of silently no-op'ing. + * proxying. Explicit `@Server` methods and compiler-discovered handler methods + * are registered. Other stripped helpers receive no proxy and throw loudly. * * Returns an empty string when no method qualifies, so no constructor is * injected. This is injected at the end of the class body. */ function createMetadataInjection( - methods: Array<{ name: string; hasServerDecorator: boolean }>, + methods: Array<{ name: string; registerForRpc: boolean }>, ): string { const serverMethodNames = methods - .filter((m) => m.hasServerDecorator) + .filter((m) => m.registerForRpc) .map((m) => m.name); if (serverMethodNames.length === 0) return ''; @@ -1129,6 +1208,65 @@ function createMetadataInjection( const REGISTER_SERVER_METHODS_CALL = ' (this.constructor as any).__registerServerOnlyMethods?.();\n'; +function appendMetadataRegistration( + cls: any, + metadataInjection: string, +): Array<{ start: number; end: number; replacement: string }> { + if (!metadataInjection) return []; + const closeBrace = cls.body.end - 1; + const ctor = findConstructor(cls); + if (ctor) { + const openBrace = ctor.value.body.start; + return [ + { start: openBrace + 1, end: openBrace + 1, replacement: '\n' + REGISTER_SERVER_METHODS_CALL }, + { start: closeBrace, end: closeBrace, replacement: metadataInjection }, + ]; + } + + const superCall = cls.superClass != null ? ' super();\n' : ''; + return [{ + start: closeBrace, + end: closeBrace, + replacement: metadataInjection + ` constructor() { +${superCall}${REGISTER_SERVER_METHODS_CALL} } +`, + }]; +} + +/** + * Add compiler-owned RPC metadata to the server build while retaining method + * bodies. This mirrors the client transform's handler discovery. + */ +export function injectAutomaticServerMethodMetadata( + code: string, + _id: string, + isClientSafeMethodFn: (decorators: string[], methodName: string, builtinMethods: Set) => boolean, + builtinMethods: Set, +): string { + const program = parseProgram(code); + if (!program) return code; + const replacements: Array<{ start: number; end: number; replacement: string }> = []; + + for (const cls of findClasses(program)) { + const isCossackSubclass = superclassName(cls) === 'Cossack' || superclassName(cls) === 'CossackElement'; + const hasServiceDecorator = (cls.decorators ?? []).some((d: any) => /@Service\b/.test(sourceSlice(code, d))); + if (!isCossackSubclass && !hasServiceDecorator) continue; + + const methods = collectAstMethods(cls, code); + const preserved = computePreservedSet(cls, methods, isClientSafeMethodFn, builtinMethods); + const automaticRpc = computeAutomaticRpcSet(cls, methods, preserved); + if (automaticRpc.size === 0) continue; + const registrations = [...automaticRpc].map((name) => ({ name, registerForRpc: true })); + replacements.push(...appendMetadataRegistration(cls, createMetadataInjection(registrations))); + } + + let result = code; + for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) { + result = result.slice(0, replacement.start) + replacement.replacement + result.slice(replacement.end); + } + return result; +} + // ============================================================================ // Main transform // ============================================================================ @@ -1178,11 +1316,11 @@ export function transformCossackClass( if (!isCossackSubclass && !hasServiceDecorator) continue; const className = cls.id?.name ?? 'Anonymous'; - const hasExtends = cls.superClass != null; const methods = collectAstMethods(cls, code); const preserved = computePreservedSet(cls, methods, isClientSafeMethodFn, builtinMethods); - const serverOnlyMethods = extractServerOnlyMethodNames(methods, preserved); + const automaticRpc = computeAutomaticRpcSet(cls, methods, preserved); + const serverOnlyMethods = extractServerOnlyMethodNames(methods, preserved, automaticRpc); // 1. Stub the body of every non-preserved method, and the value of every // non-preserved @Server function field. @@ -1218,37 +1356,7 @@ export function transformCossackClass( // duplication, and that is handled below. const metadataInjection = createMetadataInjection(serverOnlyMethods); if (metadataInjection) { - const closeBrace = cls.body.end - 1; // index of class's closing `}` - const ctor = findConstructor(cls); - if (ctor) { - // Existing constructor: inject the registration as its first statement. - const openBrace = ctor.value.body.start; // index of `{` - replacements.push({ - start: openBrace + 1, - end: openBrace + 1, - replacement: '\n' + REGISTER_SERVER_METHODS_CALL, - }); - // Still append the static registration method definition. - replacements.push({ - start: closeBrace, - end: closeBrace, - replacement: metadataInjection, - }); - } else { - // No constructor: append one (with super() if the class extends), - // together with the static method definition. - const superCall = hasExtends ? ' super();\n' : ''; - const injected = - metadataInjection + - ` constructor() { -${superCall}${REGISTER_SERVER_METHODS_CALL} } -`; - replacements.push({ - start: closeBrace, - end: closeBrace, - replacement: injected, - }); - } + replacements.push(...appendMetadataRegistration(cls, metadataInjection)); } } diff --git a/packages/framework/tests/runtime-adapter.test.ts b/packages/framework/tests/runtime-adapter.test.ts index 6a5c4cea..f82fef11 100644 --- a/packages/framework/tests/runtime-adapter.test.ts +++ b/packages/framework/tests/runtime-adapter.test.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { describe, expect, it } from 'vitest'; import { assertRuntimeTransportSupport, type CossackRuntimeAdapter } from '../src/runtime-adapter'; +import { createApp } from '../src/router'; describe('runtime adapter contract', () => { const deno = { name: 'deno' } satisfies CossackRuntimeAdapter; @@ -19,4 +20,18 @@ describe('runtime adapter contract', () => { transport: 'durable-object', stateful: true, })).not.toThrow(); }); + + it('hydrates the adapter runtime identity for shared components', async () => { + const app = createApp({ + runtimeAdapter: { + name: 'deno', + getClientMetadata: () => ({ platform: 'desktop', capability: 'test-token' }), + }, + }); + + const response = await app.request('/this-page-does-not-exist'); + const html = await response.text(); + + expect(html).toContain('"runtime":{"platform":"desktop","capability":"test-token","adapter":"deno"}'); + }); }); diff --git a/packages/framework/tests/ssg-renderer.test.ts b/packages/framework/tests/ssg-renderer.test.ts index 90cb298b..3235bd71 100644 --- a/packages/framework/tests/ssg-renderer.test.ts +++ b/packages/framework/tests/ssg-renderer.test.ts @@ -81,7 +81,7 @@ describe('ssg-renderer', () => { // When a build manifest IS present (e.g. after `vite build`), the // production asset path is used instead. Accept either form. expect(html).toContain('