From 9602c6180881a44c3d811d665a4a9eeb32739ec1 Mon Sep 17 00:00:00 2001 From: Dex Date: Mon, 11 May 2026 15:30:22 -0300 Subject: [PATCH 1/4] fix(build): externalize stateful subpaths so module state lives in one bundle (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 5.1.0, tsup's per-entry self-contained bundles (`splitting: false`) inlined every relative import — including `src/core/admin/invoke.ts`'s module-level `handlerRegistry`. Each published subpath ended up with its own private copy: `setInvokeLoaders` wrote to setup.js's copy while `decoInvokeRoute` read routes.js's empty copy, returning 404 for every `/deco/invoke/`. Same write/read split affected cms blocks/sections, commerce loaders, request context, URL origins, logger, and OTEL state. The tsup config now runs an esbuild plugin that rewrites cross-subpath relative imports of stateful modules to bare `@decocms/start/` specifiers marked external. Node's module loader dedupes bare specifiers at runtime, so every subpath bundle shares the one canonical module instance. Source code is unchanged — the plugin only transforms paths at build time, so tsc/vitest/IDE resolution keep working. Also exports `getRenderShellConfig` from the admin barrel — required because htmlShell.ts and workerEntry.ts now reach it through the bare specifier. Co-authored-by: Claude Opus 4.7 (1M context) --- src/core/admin/index.ts | 3 +- tsup.config.ts | 97 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/core/admin/index.ts b/src/core/admin/index.ts index cb7ccc5b..8e8b0d12 100644 --- a/src/core/admin/index.ts +++ b/src/core/admin/index.ts @@ -13,10 +13,10 @@ export { LIVE_CONTROLS_SCRIPT } from "./liveControls"; export { handleMeta, setMetaData } from "./meta"; export { handleRender, setPreviewWrapper, setRenderShell } from "./render"; export { + type ActionConfig, composeMeta, getRegisteredLoaders, getRegisteredMatchers, - type ActionConfig, type LoaderConfig, type MatcherConfig, type MetaResponse, @@ -27,3 +27,4 @@ export { registerMatcherSchema, registerMatcherSchemas, } from "./schema"; +export { getRenderShellConfig } from "./setup"; diff --git a/tsup.config.ts b/tsup.config.ts index 67af9d94..825b6743 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,99 @@ -import { promises as fs } from "node:fs"; -import { join } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { defineConfig } from "tsup"; +const ROOT = process.cwd(); + +// Source files / directories whose module-level state MUST live in exactly one +// published bundle. Without this, tsup's per-entry self-contained bundles each +// inline a copy of the module, producing independent module-private state per +// bundle (writers and readers disagree → "Unknown handler" 404s and similar +// silent state-split bugs). See the invoke registry incident, May 2026. +// +// Rule: any RELATIVE import that resolves to one of these targets gets +// rewritten to the canonical bare subpath and marked `external`, unless the +// importer is itself the owner. Node's module loader dedupes bare specifiers +// at runtime → single shared module instance. + +// Directory-based ownership: any source file under `dir` → `subpath`. +// Importers inside the same dir inline (the owning bundle inlines its own +// internals); cross-dir importers externalize. +const DIR_OWNERS: ReadonlyArray<{ dir: string; subpath: string }> = [ + { dir: "src/core/admin/", subpath: "@decocms/start/admin" }, + { dir: "src/core/cms/", subpath: "@decocms/start/cms" }, +]; + +// File-based ownership: any non-self import targeting this file externalizes. +// Used for sdk files that publish their own subpath and own mutable state. +const FILE_OWNERS: Readonly> = { + "src/core/sdk/requestContext.ts": "@decocms/start/sdk/requestContext", + "src/core/sdk/normalizeUrls.ts": "@decocms/start/sdk/normalizeUrls", + "src/core/sdk/logger.ts": "@decocms/start/sdk/logger", + "src/core/sdk/otel.ts": "@decocms/start/sdk/otel", +}; + +function toPosix(p: string): string { + return p.split(sep).join("/"); +} + +function resolveRelativeImport(importer: string, importPath: string): string | null { + const base = resolve(dirname(importer), importPath); + // Extension-less imports only (TS convention). `base` itself is intentionally + // not a candidate — `existsSync` returns true for directories, which would + // mismatch `../../core/cms` (a dir) before falling through to `.../index.ts`. + const candidates = [ + `${base}.ts`, + `${base}.tsx`, + `${base}.js`, + join(base, "index.ts"), + join(base, "index.tsx"), + join(base, "index.js"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +function ownerOf(absPath: string): { subpath: string; key: string } | null { + const rel = toPosix(relative(ROOT, absPath)); + if (FILE_OWNERS[rel]) { + return { subpath: FILE_OWNERS[rel], key: rel }; + } + for (const { dir, subpath } of DIR_OWNERS) { + if (rel.startsWith(dir)) { + return { subpath, key: dir }; + } + } + return null; +} + +const externalizeStatefulPlugin = { + name: "externalize-stateful-subpaths", + setup(build: { + onResolve: ( + opts: { filter: RegExp }, + cb: (args: { + path: string; + importer: string; + }) => { path: string; external: boolean } | null | undefined, + ) => void; + }) { + build.onResolve({ filter: /^\.{1,2}\// }, (args) => { + if (!args.importer) return null; + const resolved = resolveRelativeImport(args.importer, args.path); + if (!resolved) return null; + const target = ownerOf(resolved); + if (!target) return null; + const source = ownerOf(args.importer); + // Same owner (dir-or-file) → inline. The owning bundle has the impl; + // everyone else gets a runtime require/import of the bare subpath. + if (source && source.subpath === target.subpath) return null; + return { path: target.subpath, external: true }; + }); + }, +}; + const BIN_FILES = [ "dist/scripts/migrate.cjs", "dist/scripts/migrate-post-cleanup.cjs", @@ -120,6 +212,7 @@ export default defineConfig([ outDir: "dist", target: "es2022", external: sharedExternal, + esbuildPlugins: [externalizeStatefulPlugin], esbuildOptions(opts) { opts.jsx = "automatic"; opts.platform = "neutral"; From e5a082e8550bab85bb215d7a4ab416d1df27829b Mon Sep 17 00:00:00 2001 From: Dex Date: Mon, 11 May 2026 16:22:14 -0300 Subject: [PATCH 2/4] ci(release): drop @semantic-release/git, sidestep branch-protection push (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git plugin's only job is committing a 2-line package.json bump back to the branch as `chore(release): X.Y.Z [skip ci]`. Doing so requires pushing directly to a protected branch — which fails under both classic branch protection and the new Repository Rulesets because GITHUB_TOKEN (github-actions[bot]) can't be added to the bypass list. Workarounds require a custom GitHub App, deploy key, or PAT. We don't need that commit. Releases still work: - prepareCmd bumps package.json in the runner (ephemeral) so `npm publish` ships the correct version inside the tarball. - Tags are pushed by semantic-release core, then materialized on the remote by @semantic-release/github creating the GitHub Release. - The moveable @v5 tag advance step in release.yml still finds the new tag and force-pushes. - Next release run reads tags (not package.json) to compute the next version — confirmed in semantic-release's FAQ. Tradeoff: the in-repo package.json version drifts from npm after each release. Purely cosmetic — npm registry is the source of truth, and `npm view @decocms/start versions` shows the actual shipped versions. Co-authored-by: Claude Opus 4.7 (1M context) --- .releaserc.json | 6 +----- bun.lock | 33 +++------------------------------ package.json | 1 - 3 files changed, 4 insertions(+), 36 deletions(-) diff --git a/.releaserc.json b/.releaserc.json index b9763e62..b613bee6 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -23,10 +23,6 @@ "prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version", "publishCmd": "npm publish --access public --tag ${nextRelease.channel}" }], - "@semantic-release/github", - ["@semantic-release/git", { - "assets": ["package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]" - }] + "@semantic-release/github" ] } diff --git a/bun.lock b/bun.lock index 56ddeb37..39908e1b 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,6 @@ "devDependencies": { "@biomejs/biome": "^2.4.6", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^10.0.1", "@tanstack/react-query": "^5.96.0", "@tanstack/store": "^0.9.1", "@types/react": "^19.0.0", @@ -450,8 +449,6 @@ "@semantic-release/exec": ["@semantic-release/exec@7.1.0", "", { "dependencies": { "@semantic-release/error": "^4.0.0", "aggregate-error": "^3.0.0", "debug": "^4.0.0", "execa": "^9.0.0", "lodash-es": "^4.17.21", "parse-json": "^8.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ=="], - "@semantic-release/git": ["@semantic-release/git@10.0.1", "", { "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.0.0", "debug": "^4.0.0", "dir-glob": "^3.0.0", "execa": "^5.0.0", "lodash": "^4.17.4", "micromatch": "^4.0.0", "p-reduce": "^2.0.0" }, "peerDependencies": { "semantic-release": ">=18.0.0" } }, "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w=="], - "@semantic-release/github": ["@semantic-release/github@12.0.6", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA=="], "@semantic-release/npm": ["@semantic-release/npm@13.1.5", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg=="], @@ -910,8 +907,6 @@ "locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], "lodash.capitalize": ["lodash.capitalize@4.2.1", "", {}, "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw=="], @@ -946,7 +941,7 @@ "mime": ["mime@4.1.0", "", { "bin": "bin/cli.js" }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, ""], @@ -986,7 +981,7 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, ""], @@ -1004,7 +999,7 @@ "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], - "p-reduce": ["p-reduce@2.1.0", "", {}, "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw=="], + "p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], @@ -1334,10 +1329,6 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - "@semantic-release/git/@semantic-release/error": ["@semantic-release/error@3.0.0", "", {}, "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw=="], - - "@semantic-release/git/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "@semantic-release/github/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "@semantic-release/npm/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], @@ -1414,8 +1405,6 @@ "semantic-release/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], - "signale/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], @@ -1430,18 +1419,6 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@semantic-release/git/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "@semantic-release/git/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "@semantic-release/git/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "@semantic-release/git/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "@semantic-release/git/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "@semantic-release/git/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "@semantic-release/github/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "@semantic-release/github/aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -1476,8 +1453,6 @@ "env-ci/execa/npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], - "env-ci/execa/onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], - "env-ci/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], @@ -1510,8 +1485,6 @@ "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "env-ci/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], - "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], diff --git a/package.json b/package.json index a7f930dd..af8fe5ce 100644 --- a/package.json +++ b/package.json @@ -428,7 +428,6 @@ "devDependencies": { "@biomejs/biome": "^2.4.6", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^10.0.1", "@tanstack/react-query": "^5.96.0", "@tanstack/store": "^0.9.1", "@types/react": "^19.0.0", From c2a34656be0bbb81789dbd4d6dd3180d9f1ef0c9 Mon Sep 17 00:00:00 2001 From: Dex Date: Mon, 11 May 2026 16:25:25 -0300 Subject: [PATCH 3/4] ci: retrigger release workflow after suppression token in #171 body (#172) The squash-merge of #171 carried the canonical workflow-suppression token through from the PR body, which silently prevented release.yml from running on next. This empty commit recovers per the recovery instructions documented in release.yml's top comment. From 85816e71110bcdd7a872525527becf4b225f1e2d Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Thu, 14 May 2026 10:30:51 -0300 Subject: [PATCH 4/4] feat(next): full admin protocol coverage + shared Web-standard daemon (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(router): expose preload options + add pdp-fast-navigation skill (#174) createDecoRouter only exposed defaultPreload, omitting all the TanStack Router options that control how long a prefetch is reused. Result: even with ``, hover prefetch was refetched on click because the default staleTime is short — wasted work, slow perceived navigation on commerce storefronts. Expose the missing options: - defaultPreloadStaleTime - defaultPreloadGcTime - defaultPreloadDelay - defaultStaleTime - defaultPendingMs - defaultPendingMinMs All optional, all forwarded to createTanStackRouter as-is. No default changes — sites must opt in (commerce sweet spot is staleTime: 60_000). Also add `.cursor/skills/deco-pdp-fast-navigation/SKILL.md` documenting the full pattern (5 levers: intent preload + eager sections + staleTime + createCachedLoader + reserved-height fallback) discovered while optimizing a commerce PDP from multi-second click delay to sub-second perceived navigation. The skill explicitly recommends `createCachedLoader` over hand-rolled LRU maps for heavy loaders (thumbnail format detection, Vimeo oEmbed). Co-authored-by: Claude Opus 4.7 * ci: retrigger release workflow after suppression token in #175 body (#176) PR #175 body referenced the semantic-release suppression token verbatim while documenting what the dropped @semantic-release/git plugin used to emit. The squash-merge commit message inherited that verbatim quote and the release workflow was silently skipped, so the fix from #175 itself never reached npm. This is a no-op commit whose title and body do not contain the literal suppression token, so the next push event triggers the release workflow normally. Same recovery pattern used after PR #171. Also expanded the workflow header comment to note that #175 hit the same gotcha — small breadcrumb for the next person. Co-authored-by: Claude Opus 4.7 * fix(release): default to latest tag when nextRelease.channel is empty (#177) semantic-release sets nextRelease.channel from the branches config. For the main branch (no prerelease flag), channel is null, so 'npm publish --access public --tag ${nextRelease.channel}' interpolates to 'npm publish --access public --tag ' (trailing empty argument), and npm rejects with: npm error Tag name must not be a valid SemVer range: Default to 'latest' when channel is falsy. main still publishes under the default npm dist-tag, next branch (which has prerelease: true and channel='next') keeps its own tag. This regressed silently because the previous prepare step @semantic-release/git always failed on the branch protection rule before publish was reached, so the publishCmd path was never exercised on main. After dropping that plugin in the previous release fix, the publish step finally ran and surfaced this issue. Co-authored-by: Claude Opus 4.7 * docs(spec): next adapter admin coverage + shared daemon refactor design Captures the brainstorming session for closing three gaps in the Next.js adapter: hosting probes (/_healthcheck, /_ready), broken route-mounting docs, and missing /watch + /fs/* coverage. Lands a shared Web-standard daemon core in src/node/daemon/ consumed by both adapters; the Connect-style wrapping stays as a thin Vite-specific shim. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(plan): next adapter admin coverage + shared daemon implementation plan 17-task TDD-discipline plan derived from the matching spec. Phases: core foundation (version + readiness), node/daemon tier (jwt → auth → handlers → adapter → dispatcher), TanStack refactor onto shared core, Next adapter exports + handlers, docs rewrite, final verification. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(core/admin): add ADMIN_COMPAT_VERSION pinned to deco-cx/deco 1.177.x Co-Authored-By: Claude Opus 4.7 (1M context) * feat(core/admin): add handleDecoReadiness backed by getRevision() Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(daemon): move JWT primitives to src/node/daemon/jwt Pure Web-Crypto verifyAdminJwt + tokenIsValid relocate to a framework-neutral tier so the new Web-standard auth wrapper can consume them without crossing the next/ → tanstack/ boundary. tanstack/daemon/auth.ts keeps its Connect-style middleware and re-exports the JWT symbols for back-compat. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): add requireAdminJwt Web-standard guard Returns Response (401/403) to short-circuit or null to continue. Mirrors createAuthMiddleware's semantics including the DANGEROUSLY_ALLOW_PUBLIC_ACCESS bypass. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): add handleDecoHealthcheck Web-standard handler Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(daemon): move broadcast channel + .deco scanner to src/node/daemon src/tanstack/daemon/watch.ts becomes a thin Connect-style shim over the new shared scanner + channel; the chokidar wrapper for Next-side use lives in src/node/daemon/watcher.ts (lazy singleton, never instantiated from TanStack). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): Web-standard handleWatchSse SSE handler Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): Web-standard handleFsRequest GET/PATCH/DELETE on /fs/file/ plus the /fs/grep stub the admin search UI hits. Refuses path traversal. Broadcasts fs-sync events on mutate paths so the existing SSE channel stays in sync. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): toNodeMiddleware Web↔Connect adapter Lets the new Web-standard route handlers plug into Vite's middleware stack without duplicating any dispatch logic. Honors backpressure and treats a null return as fall-through. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): createDecoAdminRoute dispatcher Single Web-standard entry point composing all daemon handlers behind configurable route-group flags. Defaults dev tooling (watch, fs) to off in production; throws at construction if an auth-gated group is enabled without a site name. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(pkg): export @decocms/start/node/daemon Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tanstack/daemon): compose shared Web-standard route handler createDaemonMiddleware delegates probes, fs, watch, and admin-protocol dispatch to createDecoAdminRoute via toNodeMiddleware. Volumes WebSocket binding stays in-place because it requires raw httpServer access. Public signature unchanged; new optional routes field forwards group toggles. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(next): handleDecoAdminRoute composes shared dispatcher Brings probes, watch, fs, and the corrected route-mounting JSDoc into the Next adapter without duplicating logic. handleDecoAdminRoute is now a pre-instantiated createDecoAdminRoute that reads DECO_SITE. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(next): createDecoAdminRouteHandlers convenience factory Co-Authored-By: Claude Opus 4.7 (1M context) * feat(next): expose new admin coverage surface from the barrel Adds createDecoAdminRoute, createDecoAdminRouteHandlers, decoAdminRouteHandlers, handleDecoHealthcheck, handleDecoReadiness, ADMIN_COMPAT_VERSION, and the related types. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(next): rewrite App Router integration guide with correct route layout Removes the broken single-catchall recipe and replaces it with the escaped-folder layout that survives Next App Router's _folder privacy rule and Turbopack's %2E behaviour. Documents the createDecoAdminRouteHandlers config pattern and the per-group toggles. Co-Authored-By: Claude Opus 4.7 (1M context) * build(tsup): include src/node/daemon entries Without this glob the new src/node/daemon/* sources only had .d.ts emitted by tsc; tsup's bundle pipeline never saw them, so dist/node/daemon/index.js and friends were missing and the @decocms/start/node/daemon subpath would fail at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(core/admin): re-export ADMIN_COMPAT_VERSION + handleDecoReadiness from barrel Without these the tsup externalizer routes cross-bundle imports of src/core/admin/version + readiness to the @decocms/start/admin subpath (per DIR_OWNERS in tsup.config.ts) and resolves the named imports to undefined at runtime. Symptoms: /_healthcheck returned 200 with an empty body, /_ready threw TypeError: handleDecoReadiness is not a function. The bug was invisible to vitest because it runs against TypeScript source and bypasses the bundle externalizer. Caught by the final whole-branch review's dist-level smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): canonical DAEMON_IGNORED_DIRS with .next/.turbo/dist filters Three call sites (watch.ts shouldIgnore, volumes.ts walkFiles, volumes.ts broadcastChange) each carried their own copy of the ignore list. Consumer testing against Next 16 / Turbopack reported the SSE channel flooded with .next/dev/server/* fs-sync events on every rebuild because the list didn't cover framework artefact dirs. Consolidates the list into src/node/daemon/ignored.ts and extends it with .next, .turbo, dist, build, .cache, coverage. Both watch.ts and volumes.ts delegate to the new isIgnoredPath. shouldIgnorePath is preserved as a deprecated re-export to keep watcher.ts and any external imports working. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(next): expose PATCH and DELETE from createDecoAdminRouteHandlers The previous {GET, POST} pair broke the documented one-line route file pattern for /fs/file/*: PATCH (JSON-Patch apply) and DELETE (rm) requests fell through to 405 because Next App Router only forwards exported method handlers. Consumers had to fall back to calling createDecoAdminRoute directly, defeating the purpose of the helper. Widens DecoAdminRouteHandlers to {GET, POST, PATCH, DELETE} (single handler reference; the dispatcher already branches on method internally). Updates the JSDoc example in adminRoute.ts and the layout in docs/using-from-nextjs.md to re-export all four. Default-instance proxy gains the same two methods. Smoke test asserts reference equality across all four methods. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: CONTRIBUTING.md + scripts/dev-link-into.sh for consumer testing Documents the Turbopack-vs-symlinks gotcha (subpath exports fail to resolve through bun link / npm link with Next 16 + Turbopack — confirmed against 16.2.6) and the dist-overlay workaround. Helper script bundles the rebuild + overlay into one command. Keeps bun link documented as the path-of-first-resort for Vite/Webpack consumers where it still works. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(node/daemon): JSON-patch against non-JSON file returns 200 conflict, not 500 A Next consumer hit PATCH /fs/file/README.md with a type:'json' patch. The on-disk plaintext content tripped JSON.parse inside applyPatch with a SyntaxError that escaped the JsonPatchError catch and bubbled all the way to a 500 with a stack trace. Server is healthy; the file's shape just doesn't match the patch's expectations — that's exactly the soft-conflict case the existing TEST_OPERATION_FAILED branch already handles. Splits the inner JSON.parse into its own try/catch (returns conflict), applies the same symmetry to the text-patch branch. Test asserts 200 {conflict:true} on a plaintext target. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(node/daemon): add onRequest per-request hook to DecoAdminRouteOptions Real consumers commonly need to run something before every dispatched admin request — the canonical case being 'await ensureSetup()' so the block registry is hydrated before handleMeta / handleDecofileRead / handleDecoReadiness read it. Wrapping the helper's returned methods by hand requires re-introducing the four-method dance the helper was supposed to remove and quietly depends on the all-methods-share-one-handler internal. onRequest runs after the master enabled check, before pathname dispatch. Returning undefined continues; returning a Response short-circuits — useful for custom auth gates and maintenance-mode early-outs without overriding the whole helper. Four new tests cover: called-once-per-request, Response short-circuit, undefined continues, and disabled-route skips the hook. Documented in createDecoAdminRouteHandlers' JSDoc and the App Router guide. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Jonas Jesus Co-authored-by: Claude Opus 4.7 --- .../skills/deco-pdp-fast-navigation/SKILL.md | 370 +++ .github/workflows/release.yml | 2 +- .releaserc.json | 2 +- CONTRIBUTING.md | 120 + .../2026-05-13-next-adapter-admin-coverage.md | 2924 +++++++++++++++++ ...5-13-next-adapter-admin-coverage-design.md | 319 ++ docs/using-from-nextjs.md | 110 +- package.json | 5 + scripts/dev-link-into.sh | 44 + src/core/admin/index.ts | 2 + src/core/admin/readiness.test.ts | 35 + src/core/admin/readiness.ts | 17 + src/core/admin/version.test.ts | 15 + src/core/admin/version.ts | 10 + src/next/adminRoute.test.ts | 39 +- src/next/adminRoute.ts | 88 +- src/next/index.ts | 17 +- src/next/routeHandlers.test.ts | 34 + src/next/routeHandlers.ts | 68 + src/node/daemon/auth.test.ts | 46 + src/node/daemon/auth.ts | 34 + src/node/daemon/fs.test.ts | 92 + src/node/daemon/fs.ts | 196 ++ src/node/daemon/healthcheck.test.ts | 22 + src/node/daemon/healthcheck.ts | 19 + src/node/daemon/ignored.test.ts | 50 + src/node/daemon/ignored.ts | 41 + src/node/daemon/index.ts | 31 + src/node/daemon/jwt.test.ts | 53 + src/node/daemon/jwt.ts | 113 + src/node/daemon/nodeHttpAdapter.test.ts | 61 + src/node/daemon/nodeHttpAdapter.ts | 79 + src/node/daemon/route.test.ts | 160 + src/node/daemon/route.ts | 196 ++ src/node/daemon/watch-sse.test.ts | 39 + src/node/daemon/watch-sse.ts | 92 + src/node/daemon/watch.test.ts | 15 + src/node/daemon/watch.ts | 116 + src/node/daemon/watcher.ts | 71 + src/tanstack/daemon/auth.ts | 150 +- src/tanstack/daemon/middleware.test.ts | 45 + src/tanstack/daemon/middleware.ts | 116 +- src/tanstack/daemon/volumes.ts | 20 +- src/tanstack/daemon/watch.ts | 253 +- src/tanstack/sdk/router.ts | 53 + tsup.config.ts | 2 + 46 files changed, 5889 insertions(+), 497 deletions(-) create mode 100644 .cursor/skills/deco-pdp-fast-navigation/SKILL.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/superpowers/plans/2026-05-13-next-adapter-admin-coverage.md create mode 100644 docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md create mode 100755 scripts/dev-link-into.sh create mode 100644 src/core/admin/readiness.test.ts create mode 100644 src/core/admin/readiness.ts create mode 100644 src/core/admin/version.test.ts create mode 100644 src/core/admin/version.ts create mode 100644 src/next/routeHandlers.test.ts create mode 100644 src/next/routeHandlers.ts create mode 100644 src/node/daemon/auth.test.ts create mode 100644 src/node/daemon/auth.ts create mode 100644 src/node/daemon/fs.test.ts create mode 100644 src/node/daemon/fs.ts create mode 100644 src/node/daemon/healthcheck.test.ts create mode 100644 src/node/daemon/healthcheck.ts create mode 100644 src/node/daemon/ignored.test.ts create mode 100644 src/node/daemon/ignored.ts create mode 100644 src/node/daemon/index.ts create mode 100644 src/node/daemon/jwt.test.ts create mode 100644 src/node/daemon/jwt.ts create mode 100644 src/node/daemon/nodeHttpAdapter.test.ts create mode 100644 src/node/daemon/nodeHttpAdapter.ts create mode 100644 src/node/daemon/route.test.ts create mode 100644 src/node/daemon/route.ts create mode 100644 src/node/daemon/watch-sse.test.ts create mode 100644 src/node/daemon/watch-sse.ts create mode 100644 src/node/daemon/watch.test.ts create mode 100644 src/node/daemon/watch.ts create mode 100644 src/node/daemon/watcher.ts create mode 100644 src/tanstack/daemon/middleware.test.ts diff --git a/.cursor/skills/deco-pdp-fast-navigation/SKILL.md b/.cursor/skills/deco-pdp-fast-navigation/SKILL.md new file mode 100644 index 00000000..7dcb8c59 --- /dev/null +++ b/.cursor/skills/deco-pdp-fast-navigation/SKILL.md @@ -0,0 +1,370 @@ +--- +name: deco-pdp-fast-navigation +description: Make PDP navigation feel instant in Deco TanStack Start storefronts. Combines TanStack Router intent prefetch with cache reuse, eager sections for atomic page swap, reserved-height LoadingFallback to eliminate CLS, and createCachedLoader for heavy loaders (thumbnail format detection, Vimeo oEmbed). Use when product card click → PDP open feels slow, when there is visible flicker/skeleton flash on navigation, or when PDP loader has many parallel fetches and click feels several seconds slow. +--- + +# PDP Fast Navigation + +Patterns for making product card → PDP navigation feel instant in Deco storefronts on TanStack Start. The reference case: a storefront whose PDP loader had 7 parallel server fetches and product cards using `` (no prefetch). Each click had a multi-second perceived delay. Applying the five levers below brought it to sub-second perceived navigation. + +## When to Use This Skill + +- Click on a product card → PDP open feels noticeably slow (>1s perceived) +- PDP shows a skeleton/empty shell that flashes before content arrives +- Footer "jumps" up to the header then down again when PDP content loads (CLS) +- Product cards use `` instead of TanStack's `` +- PDP loader has `Promise.all` with many parallel API calls +- HAR or Network tab shows repeated HEAD requests for thumbnail format detection + +--- + +## The Four Levers + +These four optimizations compound — applying all of them is what makes PDP feel instant. + +| Lever | What it solves | Effort | +|-------|----------------|--------| +| 1. `` on cards | No prefetch at all | Drop-in replace `` | +| 2. `eager: true` on PDP sections | Avoids "empty shell flash" on navigation | Add export + regen sections | +| 3. `createDecoRouter` with `defaultPreloadStaleTime` | Preload result is reused on click, not refetched | One config object | +| 4. `createCachedLoader` on heavy loaders | Repeated fetches across navigations | Wrap loader function | + +If you only apply 1 and 2, navigation may still feel slow because the preload result is not reused on click. Levers 3 and 4 close that gap. + +--- + +## Lever 1 — Replace `` with `` in Product Cards + +The default behavior of TanStack Router's `` is to prefetch the target route on hover (desktop) and on touchstart (mobile) after a ~50ms debounce. This warms the route loader before the user even clicks. + +### Before + +```tsx +import { relative } from "@decocms/apps/commerce/sdk/url"; + +function ProductCard({ product }) { + const relativeUrl = relative(product.url); + return ( + + + + ); +} +``` + +### After + +```tsx +import { Link } from "@tanstack/react-router"; +import { relative } from "@decocms/apps/commerce/sdk/url"; + +function ProductCard({ product }) { + const relativeUrl = relative(product.url); + return ( + + + + ); +} +``` + +### Notes + +- `` accepts a relative URL directly — TanStack handles the splat (`/$`) param parsing internally. No need to decompose into `{ to: "/$", params: { _splat } }`. +- `` renders an `` in the DOM, so SSR/SEO/no-JS still work. +- Apply to **every variant of the card**: main grid card, mini card in search dropdown, card on PDP shelves, etc. A single missed card means missed prefetch. + +### How to Find All Cards + +```bash +rg '` and `defaultPreloadStaleTime`, the swap can happen instantly because the data was already fetched on hover. + +### How + +Add this export at the bottom of each critical PDP section file: + +```tsx +// src/sections/Product/ProductDetails.tsx + +export function LoadingFallback() { + // see Lever 5 below — reserve height even though eager hides it most of the time + return
; +} + +export const eager = true; +``` + +Then regenerate the sections registry: + +```bash +bun run generate:sections +``` + +The output should confirm the `eager` flag: + +``` +"site/sections/Product/ProductDetails.tsx": { eager: true, hasLoadingFallback: true }, +``` + +### When NOT to Mark as Eager + +- **Below-the-fold sections** like related products, reviews, cross-sell shelves — these can stay deferred. Marking them eager makes the user wait for non-critical data before seeing the PDP. +- **Sections with truly optional data** — anything that the user does not see in the first viewport. + +Rule of thumb: only mark `eager` what is **above the fold and critical to the product purchase decision**. The main product container, image gallery, price/CTA — yes. Cross-sell shelves, reviews, bundles — no. + +--- + +## Lever 3 — Configure `createDecoRouter` with `defaultPreloadStaleTime` + +This is the **highest-leverage change** for perceived speed. + +### The Problem + +By default, the TanStack Router `defaultPreloadStaleTime` is short, so a prefetch fired on hover may be considered stale by the time the user clicks — causing a **second fetch** on click. The hover prefetch becomes wasted work. + +### The Fix + +Pass `defaultPreloadStaleTime` (and `defaultPreloadGcTime` for memory) to `createDecoRouter`: + +```tsx +// src/router.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createDecoRouter } from "@decocms/start/sdk/router"; +import { routeTree } from "./routeTree.gen"; +import "./setup"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 60_000 } }, +}); + +export function getRouter() { + return createDecoRouter({ + routeTree, + context: { queryClient }, + defaultPreload: "intent", + defaultPreloadStaleTime: 60_000, + defaultPreloadGcTime: 5 * 60_000, + Wrap: ({ children }) => ( + {children} + ), + }); +} +``` + +### What Changes + +- **Hover** → prefetch fires after 50ms → result cached for 60s +- **Click within 60s** → router serves from cache → **navigation is instant**, no second fetch +- **Click after 60s** → router refetches (cache expired) + +For e-commerce, 60s is a sweet spot: typical hover → click latency is <1s, but users may return to a category page and click a different product within a minute. The cache covers both flows. + +### Available Options on `createDecoRouter` + +```ts +interface CreateDecoRouterOptions { + defaultPreload?: "intent" | "viewport" | "render" | false; + defaultPreloadStaleTime?: number; // recommend 60_000 for commerce + defaultPreloadGcTime?: number; // recommend 5 * 60_000 + defaultPreloadDelay?: number; // hover debounce, default ~50ms + defaultStaleTime?: number; // applies to all loaders + defaultPendingMs?: number; // delay before pending UI shows + defaultPendingMinMs?: number; // min duration of pending UI + // ... routeTree, scrollRestoration, trailingSlash, context, Wrap +} +``` + +--- + +## Lever 4 — Use `createCachedLoader` for Heavy Loaders + +Even with prefetch and eager sections, the loader still has to complete at least once per worker lifetime per product. If the loader does multiple HTTP HEAD requests (thumbnail format detection) or external API calls (Vimeo oEmbed), these become the bottleneck. + +Use `createCachedLoader` from `@decocms/start/sdk/cachedLoader` — it provides SWR caching, single-flight dedup, stale-if-error fallback, and LRU eviction out of the box. Do **not** roll your own LRU. + +### Pattern + +```ts +// src/loaders/checkAndReturnThumbnail360.ts +import { createCachedLoader } from "@decocms/start/sdk/cachedLoader"; + +interface Props { productID: string; } + +const rawLoader = async ({ productID }: Props): Promise => { + if (!productID) return null; + return await findValidImageExtension(productID); +}; + +export default createCachedLoader("checkAndReturnThumbnail360", rawLoader, { + policy: "stale-while-revalidate", + maxAge: 24 * 60 * 60_000, // 24h — thumbnail format rarely changes per product + keyFn: (props) => (props as Props).productID ?? "", +}); +``` + +### What `createCachedLoader` Gives You + +- **Single-flight dedup:** concurrent requests for the same key share one fetch +- **SWR:** serve stale immediately, refresh in background +- **Stale-if-error:** on origin failure, fall back to stale entry within a window +- **LRU eviction:** cap of 500 entries by default +- **Profile presets:** pass `"product"` or `"listing"` instead of explicit options + +### What to Cache + +| Loader | Cache key | Profile / maxAge | Why | +|--------|-----------|------------------|-----| +| Thumbnail format detection | productID | 24h | Format rarely changes per product | +| Vimeo/YouTube oEmbed | contentUrl | 24h | Metadata never changes for published videos | +| Cross-sell / related products | productID + type | `"product"` (~5 min) | Hot path on PDPs | + +### Also: Reduce Work, Not Just Cache It + +If the loader is doing avoidable work, fix that first. Example: a thumbnail loader checking 6 extensions (`.jpg`, `.webp`, `.png`, `.jpeg`, `.bmp`, `.tiff`) — production catalogs almost always use `.jpg` or `.webp`. Reducing the list to 2 cut first-load cost by 66% before the cache even kicks in: + +```ts +const extensionImageList = [".jpg", ".webp"]; +``` + +--- + +## Lever 5 — Reserved-Height LoadingFallback (Anti-CLS) + +If you keep some sections deferred (Lever 2 only on a subset), the `LoadingFallback` must reserve approximate height — otherwise the footer flies up to the header, then content arrives and pushes everything down. This is a massive CLS hit. + +### Bad + +```tsx +export function LoadingFallback() { + return null; // or
+} +``` + +### Good + +```tsx +export function LoadingFallback() { + // Reserve approximate PDP height for both mobile and desktop + return
; +} +``` + +### How to Pick the Height + +- Inspect the rendered PDP in DevTools, get the main container height +- Use `min-h-` not `h-` to allow for content larger than expected +- Use Tailwind responsive variants (`lg:min-h-[...]`) since mobile is usually taller (vertical stacked layout) +- For shelves, use the slider height (typically ~400-500px) + +### When Combined with Eager + +If a section is `eager: true`, the `LoadingFallback` is rarely shown (only during SSR streaming gaps). Reserving height is still good defense — it costs nothing and protects against edge cases like slow first-paint. + +--- + +## End-to-End Verification + +After applying all five levers, validate in DevTools: + +1. **Prefetch fires on hover** + - Open DevTools → Network tab → filter `_serverFn` + - Hover a product card for ~100ms + - You should see a request to the catch-all route fire within ~50ms + +2. **Click within 60s uses cache** + - After hovering, wait 1-2 seconds + - Click the card + - **No new request should fire** — the loader response is served from the router's preload cache + - PDP appears already populated (eager + cache = atomic swap) + +3. **No CLS on navigation** + - Use Chrome's Performance tab → Web Vitals + - Navigate to a PDP, observe CLS metric + - Should be < 0.05 (Good range) + - The footer should not visibly "jump" + +4. **Cold worker first hit** + - Open an incognito window (cold worker, empty cache) + - Navigate to a PDP + - Should still feel fast — the 360 loader does 2 HEADs instead of 6, Vimeo fetches once, etc. + +5. **Second visit to same PDP** + - Navigate to PDP A → back → PDP A again + - Second navigation should be measurably faster than first (`createCachedLoader` SWR hit) + +--- + +## Trade-offs and Risks + +| Choice | Trade-off | +|--------|-----------| +| `eager: true` on PDP | Page stays on current URL longer; relies on `NavigationProgress` for feedback. Acceptable if loader is <1s after prefetch hits cache. | +| `defaultPreloadStaleTime: 60_000` | Users on slow connections may see stale price/stock for up to 60s. Acceptable for retail; tune lower for flash sales. | +| `createCachedLoader` SWR | Cache survives the worker lifetime, not cold starts. First request after a cold start pays the full cost; subsequent requests within `maxAge` are instant. | +| Reducing thumbnail formats to 2 | Catalogs with legacy `.png`/`.tiff` 360 images break. Verify your catalog before applying. | + +--- + +## Files Typically Modified + +``` +src/router.tsx # Lever 3 +src/sections/Product/.tsx # Lever 2 + 5 +src/sections/Product/.tsx # Lever 2 + 5 +src/sections/Product/.tsx # Lever 2 +src/sections/Product/.tsx # Lever 5 only +src/components/product/card/ProductCard.tsx # Lever 1 +src/components/product/card/.tsx # Lever 1 +src/loaders/.ts # Lever 4 +src/server/cms/sections.gen.ts # regenerated after Lever 2 +``` + +--- + +## Next Steps If Still Slow + +If after all five levers the PDP still feels slow, the bottleneck is now the loader itself. Profile the remaining parallel calls: + +```ts +// Add timing logs to identify the slowest fetch +const start = Date.now(); +const result = await someLoader(); +console.log(`someLoader took ${Date.now() - start}ms`); +``` + +Common next-step optimizations: + +- **Move non-critical loaders to separate deferred sections.** Cross-sell, accessories, attachments, similar products can each become their own deferred section so they don't block the eager main container. +- **Wrap cross-sell results in `createCachedLoader`** with the `"product"` profile. +- **Reduce simulate calls.** VTEX's simulate endpoint is slow; if you only need price/stock, fetch it less aggressively. + +--- + +## Related Skills + +| Skill | Purpose | +|-------|---------| +| `deco-variant-selection-perf` | Avoid double-fetch on variant clicks (related navigation pattern) | +| `deco-cms-layout-caching` | Cache Header/Footer to avoid layout re-resolution on every navigation | +| `deco-vtex-fetch-cache` | SWR-style in-flight dedup for VTEX API calls (HTTP layer) | +| `deco-loader-n-plus-1-detector` | Find loops doing N+1 API calls in section loaders | +| `deco-edge-caching` | Configure Cloudflare Worker cache for commerce pages | +| `deco-cms-route-config` | `cmsRouteConfig` + `ignoreSearchParams` for stable cache keys | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa0e4f17..6c9149de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ name: Release # semantic-release skip token") instead of pasting the literal. # # Recover with a no-op commit whose entire message — title and body -# — does NOT contain the literal token. +# — does NOT contain the literal token. Past occurrences: #171, #175. # # References: # - https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs diff --git a/.releaserc.json b/.releaserc.json index b613bee6..f8c6a56d 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -21,7 +21,7 @@ "@semantic-release/release-notes-generator", ["@semantic-release/exec", { "prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version", - "publishCmd": "npm publish --access public --tag ${nextRelease.channel}" + "publishCmd": "npm publish --access public --tag ${nextRelease.channel || 'latest'}" }], "@semantic-release/github" ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..914c79f1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Contributing to `@decocms/start` + +## Project overview + +This is the framework layer for Deco storefronts built on **TanStack Start + +React 19 + Cloudflare Workers** (and, increasingly, **Next.js 16 + App +Router**). Not a storefront itself — the npm package that storefronts depend +on. See `CLAUDE.md` for the architectural ground rules (tier boundaries, three +adapters, release channels). + +## Common commands + +```bash +bun install +bun run typecheck +bun run test +bun run build +``` + +There is no `dev` server — this is a library. Consumers run their own dev +server (Vite for TanStack, `next dev` for Next). + +## Testing against a real consumer + +When you want to verify a change end-to-end against a storefront, the +recommended path depends on the consumer's bundler. + +### Vite / Webpack consumers — `bun link` works + +```bash +# In this repo +bun run build +bun link + +# In the consumer +bun link @decocms/start +``` + +Subpath exports (`@decocms/start/core`, `@decocms/start/next`, …) resolve +correctly through the symlink. Rebuild here (`bun run build`) whenever you +change source; the link itself doesn't watch. + +### Next.js / Turbopack consumers — copy `dist/` instead of linking + +**Turbopack does not resolve subpath exports through symlinks.** Even with +`transpilePackages` set, `bun link @decocms/start` against a Next 16 + +Turbopack project produces "Module not found" for every `@decocms/start/*` +subpath. Confirmed against Next `16.2.6` / Turbopack as of May 2026; the +upstream issue is long-standing. + +The reliable workaround is to install the published version once and overlay +this repo's `dist/` directly: + +```bash +# In the consumer +bun install # one time +rm -rf node_modules/@decocms/start/dist +cp -R /path/to/deco-start/dist node_modules/@decocms/start/dist +``` + +After every source change in this repo, run `bun run build` here and repeat +the `rm/cp` in the consumer. The helper script bundles both steps: + +```bash +# In this repo +./scripts/dev-link-into.sh /path/to/consumer +``` + +It runs `bun run build` and overwrites `node_modules/@decocms/start/dist` in +the target. Re-runnable; safe to wire into a `concurrently` invocation +alongside `next dev` (debounced — only triggers when you re-run it). + +## Tier boundaries + +Three tiers, each enforced by `scripts/check-tier-boundaries.ts` after every +build: + +| Tier | May import | May not import | +|------|-----------|----------------| +| `src/core/` | itself, `node:` standards | `@tanstack/*`, `next`, `node:async_hooks`, Node-only modules | +| `src/tanstack/` | `core/`, `node/`, `@tanstack/*`, `node:*` | `next` | +| `src/next/` | `core/`, `node/`, `next` | `tanstack/`, `@tanstack/*` | + +`src/node/` is the framework-neutral Node-only tier — fair game for both +adapters. The daemon's Web-standard handlers live there (`src/node/daemon/`). + +If you reach for `@tanstack/react-start/server` inside `core/`, stop — accept +the value as a function argument or use the `RequestStore` interface in +`src/core/runtime/`. + +## Release channels + +Two channels, governed by `.releaserc.json`. The release-workflow skill at +`.agents/skills/decocms-start-release-workflow/SKILL.md` has the decision tree. + +- `main` → `@decocms/start@latest` (e.g. `5.2.0`). Default for consumers. + Routine fixes go here. +- `next` → `@decocms/start@next` (e.g. `5.2.0-next.3`). Opt-in via + `bun add @decocms/start@next`. Use for behaviour-changing or risky work + that benefits from customer validation. Promote by opening a PR `next` → + `main`. + +Hard rules: + +- Never push directly to `main` or `next`. +- Never run `npm publish` locally — the workflow does it. +- Never include the canonical GitHub-Actions CI-skip token in a PR title or + body targeting either branch. See `.github/workflows/release.yml:3-21`. + +## Spec / plan / brainstorm workflow + +Larger features go through `superpowers:brainstorming` → spec +(`docs/superpowers/specs/`) → plan (`docs/superpowers/plans/`) → execution. +See the existing entries for shape. Two recent examples: + +- `docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md` +- `docs/superpowers/plans/2026-05-13-next-adapter-admin-coverage.md` + +For small fixes, conventional-commit messages are enough — semantic-release +picks up `feat:` / `fix:` / `refactor:` automatically. diff --git a/docs/superpowers/plans/2026-05-13-next-adapter-admin-coverage.md b/docs/superpowers/plans/2026-05-13-next-adapter-admin-coverage.md new file mode 100644 index 00000000..99e14108 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-next-adapter-admin-coverage.md @@ -0,0 +1,2924 @@ +# Next.js adapter admin coverage + shared daemon refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the Next.js admin-protocol gap by extracting the daemon's request handlers from `src/tanstack/daemon/` into a Web-standard `src/node/daemon/` core, then wiring both adapters (TanStack via a thin Node-http shim, Next via direct App Router route handlers) to consume it. Add `/_healthcheck`, `/_ready`, configurable route groups, and fix the broken Next route-mounting documentation. + +**Architecture:** Web-standard handlers (`Request → Response`) live in `src/node/daemon/`. Both adapters compose them via `createDecoAdminRoute(opts)`. The TanStack/Vite path wraps the composed handler in a Connect-style middleware via `toNodeMiddleware` for `vite dev`'s middleware stack. Next App Router consumes the composed handler directly from route handlers under escaped folder names. + +**Tech Stack:** TypeScript, Vitest, Node 20+, Web Crypto, `node:fs/promises`, `chokidar` (TanStack-side via Vite watcher; Next-side via on-demand singleton), `fast-json-patch`. + +**Reference spec:** `docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md`. + +--- + +## Pre-flight + +- [ ] **Step 0: Confirm worktree is clean and tests pass before starting** + +Run: +```bash +git status # expect: clean working tree +bun run typecheck +bun run test +``` +Expected: typecheck passes, all existing tests pass. If anything is red here, stop and fix it before continuing — a failing baseline poisons every downstream verification. + +--- + +## Phase 1 — Shared admin foundation in `core/` + +### Task 1: Lift `ADMIN_COMPAT_VERSION` into `core/admin/` + +**Files:** +- Create: `src/core/admin/version.ts` +- Create: `src/core/admin/version.test.ts` + +- [ ] **Step 1.1: Write the test** + +`src/core/admin/version.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "./version"; + +describe("ADMIN_COMPAT_VERSION", () => { + it("is a non-empty semver string", () => { + expect(ADMIN_COMPAT_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("is pinned to the deco-cx/deco 1.177.x compatibility range", () => { + // This pin must NOT track @decocms/start's own version. Admin compares + // against deco-cx/deco's range, so changing the major/minor here is a + // breaking change for admin compatibility — bump deliberately. + expect(ADMIN_COMPAT_VERSION.startsWith("1.177.")).toBe(true); + }); +}); +``` + +- [ ] **Step 1.2: Run the test (expect failure)** + +Run: `bunx vitest run src/core/admin/version.test.ts` +Expected: FAIL — `Cannot find module './version'`. + +- [ ] **Step 1.3: Create the module** + +`src/core/admin/version.ts`: +```ts +/** + * Version reported to admin.deco.cx by `/_healthcheck` and similar probes. + * + * **Pinning contract:** this constant must NOT track `@decocms/start`'s own + * version (currently 5.x). Admin compares the returned value against + * `deco-cx/deco`'s release range (currently 1.177.x). Bumping it shifts the + * admin compatibility window — change deliberately and document in the + * release notes when you do. + */ +export const ADMIN_COMPAT_VERSION = "1.177.5"; +``` + +- [ ] **Step 1.4: Run the test (expect pass)** + +Run: `bunx vitest run src/core/admin/version.test.ts` +Expected: PASS. + +- [ ] **Step 1.5: Commit** + +```bash +git add src/core/admin/version.ts src/core/admin/version.test.ts +git commit -m "feat(core/admin): add ADMIN_COMPAT_VERSION pinned to deco-cx/deco 1.177.x + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2: Add `handleDecoReadiness` in `core/admin/` + +**Files:** +- Create: `src/core/admin/readiness.ts` +- Create: `src/core/admin/readiness.test.ts` + +- [ ] **Step 2.1: Write the test** + +`src/core/admin/readiness.test.ts`: +```ts +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setBlocks } from "../cms/loader"; +import { handleDecoReadiness } from "./readiness"; + +describe("handleDecoReadiness", () => { + // Tests share globalThis-backed block state; isolate by resetting. + beforeEach(() => { + const g = globalThis as any; + if (g.__deco) { + g.__deco.blockData = undefined; + g.__deco.revision = undefined; + } + }); + afterEach(() => { + const g = globalThis as any; + if (g.__deco) { + g.__deco.blockData = undefined; + g.__deco.revision = undefined; + } + }); + + it("returns 503 'not ready' before setBlocks() has run", async () => { + const res = handleDecoReadiness(); + expect(res.status).toBe(503); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(await res.text()).toBe("not ready"); + }); + + it("returns 200 'ready' after setBlocks() has populated the registry", async () => { + setBlocks({ "/": { name: "home" } }); + const res = handleDecoReadiness(); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ready"); + }); +}); +``` + +- [ ] **Step 2.2: Run the test (expect failure)** + +Run: `bunx vitest run src/core/admin/readiness.test.ts` +Expected: FAIL — `Cannot find module './readiness'`. + +- [ ] **Step 2.3: Implement the handler** + +`src/core/admin/readiness.ts`: +```ts +import { getRevision } from "../cms/loader"; + +/** + * Web-standard readiness probe. Returns 200 once `setBlocks()` has been called + * at least once (the block registry is hydrated and the storefront can serve + * resolved pages), 503 otherwise. + * + * Suitable for k8s readinessProbe / Cloud Run health checks / our own infra. + * Intentionally no CORS — readiness probes are intra-cluster. + */ +export function handleDecoReadiness(): Response { + const ready = getRevision() !== null; + return new Response(ready ? "ready" : "not ready", { + status: ready ? 200 : 503, + headers: { "Content-Type": "text/plain" }, + }); +} +``` + +- [ ] **Step 2.4: Run the test (expect pass)** + +Run: `bunx vitest run src/core/admin/readiness.test.ts` +Expected: PASS (both cases). + +- [ ] **Step 2.5: Commit** + +```bash +git add src/core/admin/readiness.ts src/core/admin/readiness.test.ts +git commit -m "feat(core/admin): add handleDecoReadiness backed by getRevision() + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 2 — Move JWT to `node/daemon/` + +### Task 3: Create `src/node/daemon/jwt.ts` with the pure JWT primitives + +**Files:** +- Create: `src/node/daemon/jwt.ts` +- Create: `src/node/daemon/jwt.test.ts` +- Modify: `src/tanstack/daemon/auth.ts` (turn JWT functions into re-exports) + +- [ ] **Step 3.1: Write the test** + +`src/node/daemon/jwt.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { tokenIsValid, type JwtPayload, verifyAdminJwt } from "./jwt"; + +describe("verifyAdminJwt", () => { + it("returns null for malformed tokens", async () => { + expect(await verifyAdminJwt("not-a-jwt")).toBeNull(); + expect(await verifyAdminJwt("a.b")).toBeNull(); + expect(await verifyAdminJwt("")).toBeNull(); + }); + + it("returns null for tokens with invalid signatures", async () => { + // header.payload.signature where signature does not verify + const fake = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJ4In0.AAAA"; + expect(await verifyAdminJwt(fake)).toBeNull(); + }); +}); + +describe("tokenIsValid", () => { + it("rejects payloads missing iss or sub", () => { + expect(tokenIsValid("my-site", { iss: "x" } as JwtPayload)).toBe(false); + expect(tokenIsValid("my-site", { sub: "x" } as JwtPayload)).toBe(false); + }); + + it("rejects expired tokens", () => { + expect( + tokenIsValid("my-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 1, + }), + ).toBe(false); + }); + + it("accepts a matching site URN", () => { + expect( + tokenIsValid("my-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 9999999999, + }), + ).toBe(true); + }); + + it("rejects mismatched site", () => { + expect( + tokenIsValid("other-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 9999999999, + }), + ).toBe(false); + }); +}); +``` + +- [ ] **Step 3.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/jwt.test.ts` +Expected: FAIL — `Cannot find module './jwt'`. + +- [ ] **Step 3.3: Create `src/node/daemon/jwt.ts`** + +Port the JWT logic from `src/tanstack/daemon/auth.ts` lines 1–142 (everything up to but not including `extractToken`). Remove the `IncomingMessage`/`ServerResponse` imports — this file is purely Web-Crypto. Paste: + +```ts +/** + * JWT verification primitives — Web Crypto only, no Node-http coupling. + * + * Moved from `src/tanstack/daemon/auth.ts` so both the Connect-style + * (`createAuthMiddleware`) and Web-standard (`requireAdminJwt`) wrappers + * can share the same trust chain. + */ + +const ADMIN_PUBLIC_KEY = + process.env.DECO_ADMIN_PUBLIC_KEY ?? + "eyJrdHkiOiJSU0EiLCJhbGciOiJSUzI1NiIsIm4iOiJ1N0Y3UklDN19Zc3ljTFhEYlBvQ1pUQnM2elZ6VjVPWkhXQ0M4akFZeFdPUnByem9WNDJDQ1JBVkVOVjJldzk1MnJOX2FTMmR3WDlmVGRvdk9zWl9jX2RVRXctdGlPN3hJLXd0YkxsanNUbUhoNFpiYXU0aUVoa0o1VGNHc2VaelhFYXNOSEhHdUo4SzY3WHluRHJSX0h4Ym9kQ2YxNFFJTmc5QnJjT3FNQmQyMUl4eUctVVhQampBTnRDTlNici1rXzFKeTZxNmtPeVJ1ZmV2Mjl0djA4Ykh5WDJQenp5Tnp3RWpjY0lROWpmSFdMN0JXX2tzdFpOOXU3TUtSLWJ4bjlSM0FKMEpZTHdXR3VnZGpNdVpBRnk0dm5BUXZzTk5Cd3p2YnFzMnZNd0dDTnF1ZE1tVmFudlNzQTJKYkE3Q0JoazI5TkRFTXRtUS1wbmo1cUlYSlEiLCJlIjoiQVFBQiIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZX0"; + +const ALG = "RSASSA-PKCS1-v1_5"; +const HASH = "SHA-256"; + +export interface JwtPayload { + [key: string]: unknown; + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; +} + +function parseJWK(b64: string): JsonWebKey { + return JSON.parse(atob(b64)); +} + +let cachedKey: Promise | null = null; + +function getAdminPublicKey(): Promise { + cachedKey ??= crypto.subtle.importKey( + "jwk", + parseJWK(ADMIN_PUBLIC_KEY), + { name: ALG, hash: HASH }, + false, + ["verify"], + ); + return cachedKey; +} + +function base64UrlDecode(str: string): Uint8Array { + const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + const binary = atob(b64 + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export async function verifyAdminJwt(token: string): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const signature = base64UrlDecode(signatureB64); + + try { + const key = await getAdminPublicKey(); + const valid = await crypto.subtle.verify( + ALG, + key, + new Uint8Array(signature), + new Uint8Array(signingInput), + ); + if (!valid) return null; + } catch { + return null; + } + + try { + const payload: JwtPayload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(payloadB64)), + ); + return payload; + } catch { + return null; + } +} + +function matchPart(urnPart: string, otherPart: string): boolean { + return urnPart === "*" || otherPart === urnPart; +} + +function matchParts(urn: string[], resource: string[]): boolean { + return urn.every((part, idx) => matchPart(part, resource[idx])); +} + +function matches(urnParts: string[]) { + return (resourceUrn: string) => { + const resourceParts = resourceUrn.split(":"); + if (resourceParts.length > urnParts.length) return false; + const lastIdx = resourceParts.length - 1; + return resourceParts.every((part, idx) => { + if (part === "*") return true; + if (lastIdx === idx) { + return matchParts(part.split("/"), urnParts[idx].split("/")); + } + return part === urnParts[idx]; + }); + }; +} + +export function tokenIsValid(site: string, jwt: JwtPayload): boolean { + const { iss, sub, exp } = jwt; + if (!iss || !sub) return false; + if (exp && exp * 1000 <= Date.now()) return false; + const siteUrn = `urn:deco:site:*:${site}:deployment/*`; + return matches(sub.split(":"))(siteUrn); +} +``` + +- [ ] **Step 3.4: Run the new test (expect pass)** + +Run: `bunx vitest run src/node/daemon/jwt.test.ts` +Expected: PASS (all 6 cases). + +- [ ] **Step 3.5: Convert `src/tanstack/daemon/auth.ts` to re-export and keep Connect middleware** + +Replace the contents of `src/tanstack/daemon/auth.ts` with: + +```ts +/** + * Connect-style JWT auth middleware for Vite's middleware stack. + * + * The pure JWT primitives moved to `src/node/daemon/jwt.ts` so both the + * Connect-style and Web-standard wrappers share the same trust chain. This + * file is now only the Node-http adapter — the verification, payload type, + * and URN matching all come from there. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { tokenIsValid, verifyAdminJwt } from "../../node/daemon/jwt"; + +export { verifyAdminJwt, tokenIsValid } from "../../node/daemon/jwt"; +export type { JwtPayload } from "../../node/daemon/jwt"; + +const BYPASS_JWT = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true"; + +function extractToken(req: IncomingMessage): string | null { + const auth = req.headers.authorization; + if (auth) { + const parts = auth.split(/\s+/); + if (parts.length === 2) return parts[1]; + } + try { + const url = new URL(req.url ?? "/", "http://localhost"); + const t = url.searchParams.get("token"); + if (t) return t; + } catch { + // ignore + } + return null; +} + +export type NextFn = () => void; + +/** + * Returns a Connect-style middleware that verifies JWT on every request. + * If invalid, responds 401/403. If valid (or bypass enabled), calls next(). + */ +export function createAuthMiddleware(site: string) { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: NextFn, + ): Promise => { + if (BYPASS_JWT) { + next(); + return; + } + + const token = extractToken(req); + if (!token) { + res.writeHead(401); + res.end(); + return; + } + + const jwt = await verifyAdminJwt(token); + if (!jwt) { + res.writeHead(401); + res.end(); + return; + } + + if (!tokenIsValid(site, jwt)) { + res.writeHead(403); + res.end(); + return; + } + + next(); + }; +} +``` + +- [ ] **Step 3.6: Verify TanStack daemon tests still pass** + +Run: `bunx vitest run src/tanstack/daemon/` +Expected: PASS — no behaviour change for Connect-style middleware. + +- [ ] **Step 3.7: Verify tier boundaries** + +The tier-boundary check runs against compiled `dist/`. For now, confirm `src/node/` exists as a directory and the new import path resolves at typecheck: + +Run: `bun run typecheck` +Expected: PASS. + +- [ ] **Step 3.8: Commit** + +```bash +git add src/node/daemon/jwt.ts src/node/daemon/jwt.test.ts src/tanstack/daemon/auth.ts +git commit -m "refactor(daemon): move JWT primitives to src/node/daemon/jwt + +Pure Web-Crypto verifyAdminJwt + tokenIsValid relocate to a framework-neutral +tier so the new Web-standard auth wrapper can consume them without crossing +the next/ → tanstack/ boundary. tanstack/daemon/auth.ts keeps its Connect-style +middleware and re-exports the JWT symbols for back-compat. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 4: Add Web-standard `requireAdminJwt` guard + +**Files:** +- Create: `src/node/daemon/auth.ts` +- Create: `src/node/daemon/auth.test.ts` + +- [ ] **Step 4.1: Write the test** + +`src/node/daemon/auth.test.ts`: +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requireAdminJwt } from "./auth"; + +describe("requireAdminJwt", () => { + const originalBypass = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + beforeEach(() => { + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + }); + afterEach(() => { + if (originalBypass === undefined) { + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + } else { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = originalBypass; + } + }); + + it("returns 401 Response when no token is present", async () => { + const req = new Request("http://t/fs/file/x"); + const res = await requireAdminJwt(req, "my-site"); + expect(res).toBeInstanceOf(Response); + expect(res?.status).toBe(401); + }); + + it("returns 401 Response when token is malformed", async () => { + const req = new Request("http://t/fs/file/x", { + headers: { authorization: "Bearer not-a-jwt" }, + }); + const res = await requireAdminJwt(req, "my-site"); + expect(res?.status).toBe(401); + }); + + it("returns null (pass) when DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true", async () => { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + const req = new Request("http://t/fs/file/x"); + const res = await requireAdminJwt(req, "my-site"); + expect(res).toBeNull(); + }); + + it("accepts a ?token= query param fallback", async () => { + // Token is malformed but exercises the extraction path. + const req = new Request("http://t/fs/file/x?token=abc"); + const res = await requireAdminJwt(req, "my-site"); + // Still 401 because token is invalid — but path was attempted. + expect(res?.status).toBe(401); + }); +}); +``` + +- [ ] **Step 4.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/auth.test.ts` +Expected: FAIL — `Cannot find module './auth'`. + +- [ ] **Step 4.3: Implement the guard** + +`src/node/daemon/auth.ts`: +```ts +import { tokenIsValid, verifyAdminJwt } from "./jwt"; + +/** + * Web-standard JWT guard. Returns a Response (401/403) to short-circuit, or + * null to indicate the request is authorized and should continue. + * + * Honors the `DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true` env bypass, matching + * the existing Connect-style `createAuthMiddleware` semantics. + */ +export async function requireAdminJwt( + req: Request, + site: string, +): Promise { + if (process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true") return null; + + const token = extractToken(req); + if (!token) return new Response(null, { status: 401 }); + + const jwt = await verifyAdminJwt(token); + if (!jwt) return new Response(null, { status: 401 }); + + if (!tokenIsValid(site, jwt)) return new Response(null, { status: 403 }); + return null; +} + +function extractToken(req: Request): string | null { + const auth = req.headers.get("authorization"); + if (auth) { + const parts = auth.split(/\s+/); + if (parts.length === 2) return parts[1]; + } + const url = new URL(req.url); + return url.searchParams.get("token"); +} +``` + +- [ ] **Step 4.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/auth.test.ts` +Expected: PASS. + +- [ ] **Step 4.5: Commit** + +```bash +git add src/node/daemon/auth.ts src/node/daemon/auth.test.ts +git commit -m "feat(node/daemon): add requireAdminJwt Web-standard guard + +Returns Response (401/403) to short-circuit or null to continue. Mirrors +createAuthMiddleware's semantics including the DANGEROUSLY_ALLOW_PUBLIC_ACCESS +bypass. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 3 — Web-standard handlers in `node/daemon/` + +### Task 5: `handleDecoHealthcheck` + +**Files:** +- Create: `src/node/daemon/healthcheck.ts` +- Create: `src/node/daemon/healthcheck.test.ts` + +- [ ] **Step 5.1: Write the test** + +`src/node/daemon/healthcheck.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +import { handleDecoHealthcheck } from "./healthcheck"; + +describe("handleDecoHealthcheck", () => { + it("returns 200 with the ADMIN_COMPAT_VERSION body", async () => { + const res = handleDecoHealthcheck(); + expect(res.status).toBe(200); + expect(await res.text()).toBe(ADMIN_COMPAT_VERSION); + }); + + it("emits text/plain", () => { + expect(handleDecoHealthcheck().headers.get("Content-Type")).toBe("text/plain"); + }); + + it("emits the CORS headers admin.deco.cx expects", () => { + const res = handleDecoHealthcheck(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + }); +}); +``` + +- [ ] **Step 5.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/healthcheck.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 5.3: Implement the handler** + +`src/node/daemon/healthcheck.ts`: +```ts +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; + +/** + * Web-standard `/_healthcheck` handler. + * + * Returns the admin-compatibility version (NOT @decocms/start's own version) + * with the CORS headers admin.deco.cx expects from the daemon endpoint. + */ +export function handleDecoHealthcheck(): Response { + return new Response(ADMIN_COMPAT_VERSION, { + status: 200, + headers: { + "Content-Type": "text/plain", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} +``` + +- [ ] **Step 5.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/healthcheck.test.ts` +Expected: PASS. + +- [ ] **Step 5.5: Commit** + +```bash +git add src/node/daemon/healthcheck.ts src/node/daemon/healthcheck.test.ts +git commit -m "feat(node/daemon): add handleDecoHealthcheck Web-standard handler + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 6: Move the broadcast channel + watcher to `node/daemon/` + +This task ports the existing channel/watcher logic out of `src/tanstack/daemon/watch.ts` so it can be shared. We keep the same module-level singletons (`channel`, `inferMetadata`) but expose Web-standard helpers. + +**Files:** +- Create: `src/node/daemon/watch.ts` (broadcast channel + `inferMetadata` + types) +- Create: `src/node/daemon/watcher.ts` (chokidar wrapper for Next-side use) +- Modify: `src/tanstack/daemon/watch.ts` (becomes a thin re-export + Connect-style handler that consumes the new core) + +- [ ] **Step 6.1: Write the broadcast-channel test** + +`src/node/daemon/watch.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { broadcastFsEvent, subscribeFsEvents, type FsEvent } from "./watch"; + +describe("broadcast channel", () => { + it("delivers events to subscribers and stops after unsubscribe", () => { + const seen: FsEvent[] = []; + const unsubscribe = subscribeFsEvents((e) => seen.push(e)); + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + expect(seen.length).toBe(1); + expect(seen[0].type).toBe("worker-status"); + unsubscribe(); + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + expect(seen.length).toBe(1); + }); +}); +``` + +- [ ] **Step 6.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/watch.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 6.3: Create `src/node/daemon/watch.ts`** + +This file consolidates the *non-handler* pieces from `src/tanstack/daemon/watch.ts`: the broadcast channel, the `inferMetadata` JSON-resolveType inference, and the `scanFiles` initial-sync generator. The Connect-style and Web-standard handlers consume these. Paste: + +```ts +/** + * Daemon broadcast channel + .deco/ file scanner + metadata inference. + * + * Framework-neutral. Both the Connect-style watch handler (consumed by Vite's + * middleware stack) and the Web-standard `handleWatchSse` consume from here. + * + * Ported from: deco-cx/deco daemon/sse/api.ts + daemon/sse/channel.ts + */ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, sep } from "node:path"; + +export interface FsEvent { + type: "fs-sync" | "fs-snapshot" | "worker-status" | "meta-info"; + detail: Record; +} + +const channel = new EventTarget(); + +/** Publish an event to all subscribers. */ +export function broadcastFsEvent(event: FsEvent): void { + channel.dispatchEvent(new CustomEvent("broadcast", { detail: event })); +} + +/** Subscribe to broadcast events. Returns an unsubscribe function. */ +export function subscribeFsEvents(listener: (event: FsEvent) => void): () => void { + const handler = (e: Event) => listener((e as CustomEvent).detail); + channel.addEventListener("broadcast", handler); + return () => channel.removeEventListener("broadcast", handler); +} + +const toPosix = (p: string) => p.replaceAll(sep, "/"); + +function shouldIgnore(path: string): boolean { + return ( + path.includes(`${sep}.git${sep}`) || + path.includes(`${sep}node_modules${sep}`) || + path.includes(`${sep}.agent-home${sep}`) || + path.includes(`${sep}.claude${sep}`) + ); +} + +function inferBlockType(resolveType: string): string | null { + if (!resolveType) return null; + if (resolveType.includes("/pages/")) return "pages"; + if (resolveType.includes("/sections/")) return "sections"; + if (resolveType.includes("/loaders/")) return "loaders"; + if (resolveType.includes("/actions/")) return "actions"; + if (resolveType.includes("/matchers/")) return "matchers"; + if (resolveType.includes("/flags/")) return "sections"; + return null; +} + +export interface Metadata { + kind: "block" | "file"; + blockType?: string; + __resolveType?: string; + name?: string; + path?: string; +} + +/** Read a JSON file and infer its block metadata (block type, resolveType, …). */ +export async function inferMetadata(filepath: string): Promise { + try { + const raw = await readFile(filepath, "utf-8"); + const parsed = JSON.parse(raw); + const { __resolveType, name, path: pagePath } = parsed; + + if (!__resolveType) return { kind: "file" }; + const blockType = inferBlockType(__resolveType); + if (!blockType) return { kind: "file" }; + + if (blockType === "pages") { + return { + kind: "block", + blockType, + __resolveType, + name: name ?? undefined, + path: pagePath ?? undefined, + }; + } + return { kind: "block", blockType, __resolveType }; + } catch { + return { kind: "file" }; + } +} + +/** Yield fs-sync events for every .deco/ file modified after `since`. */ +export async function* scanDecoFiles(cwd: string, since: number): AsyncGenerator { + const decoDir = join(cwd, ".deco"); + try { + const entries = await readdir(decoDir, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const fullPath = join(entry.parentPath, entry.name); + if (shouldIgnore(fullPath)) continue; + + let mtime: number; + try { + const stats = await stat(fullPath); + mtime = stats.mtimeMs; + } catch { + mtime = Date.now(); + } + if (mtime < since) continue; + + const metadata = await inferMetadata(fullPath); + const filepath = toPosix(fullPath.replace(cwd, "")); + yield { type: "fs-sync", detail: { metadata, filepath, timestamp: mtime } }; + } + } catch { + // .deco dir might not exist yet + } + yield { type: "fs-snapshot", detail: { timestamp: Date.now() } }; +} + +/** Common ignore predicate, exported for the watcher wrappers. */ +export function shouldIgnorePath(path: string): boolean { + return shouldIgnore(path); +} + +export const toPosixPath = toPosix; +``` + +- [ ] **Step 6.4: Create `src/node/daemon/watcher.ts`** + +`src/node/daemon/watcher.ts`: +```ts +/** + * Chokidar watcher wrapper for the Next-side daemon path. + * + * The TanStack-side daemon receives Vite's existing watcher via + * `createDaemonMiddleware`'s options and binds it via `bindWatcherToChannel`; + * it never calls this function. The Next-side daemon has no Vite, so it + * spins up its own chokidar instance lazily on the first watch/fs request. + */ +import chokidar from "chokidar"; +import { stat } from "node:fs/promises"; +import { + broadcastFsEvent, + inferMetadata, + shouldIgnorePath, + toPosixPath, +} from "./watch"; + +export interface DecoWatcher { + watcher: chokidar.FSWatcher; + close: () => Promise; +} + +export function createDecoWatcher(cwd: string): DecoWatcher { + const watcher = chokidar.watch(cwd, { + ignoreInitial: true, + ignored: (p: string) => shouldIgnorePath(p), + }); + bindWatcherToChannel(watcher, cwd); + return { + watcher, + close: () => watcher.close(), + }; +} + +/** + * Wire any chokidar-style watcher (Vite's or our own) to the broadcast channel. + * Pure side-effect helper — does not own the watcher's lifecycle. + */ +export function bindWatcherToChannel( + watcher: { on(event: string, cb: (...args: unknown[]) => void): void }, + cwd: string = process.cwd(), +): void { + const onChange = async (filePath: unknown, deleted = false) => { + if (typeof filePath !== "string") return; + if (shouldIgnorePath(filePath)) return; + + const metadata = deleted ? null : await inferMetadata(filePath); + let mtime = Date.now(); + if (!deleted) { + try { + const stats = await stat(filePath); + mtime = stats.mtimeMs; + } catch { + // use Date.now() + } + } + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata, + filepath: toPosixPath(filePath.replace(cwd, "")), + timestamp: mtime, + }, + }); + }; + + watcher.on("change", (path: unknown) => onChange(path)); + watcher.on("add", (path: unknown) => onChange(path)); + watcher.on("unlink", (path: unknown) => onChange(path, true)); +} +``` + +- [ ] **Step 6.5: Run the broadcast-channel test (expect pass)** + +Run: `bunx vitest run src/node/daemon/watch.test.ts` +Expected: PASS. + +- [ ] **Step 6.6: Update `src/tanstack/daemon/watch.ts` to re-export from `node/daemon/watch`** + +Replace the contents of `src/tanstack/daemon/watch.ts` with a slim file that keeps the Connect-style `createWatchHandler` (used by the existing middleware until Task 12 retires it), but delegates the channel + scanner to the new shared module: + +```ts +/** + * Connect-style SSE handler — wraps the shared Web-standard `handleWatchSse` + * for Vite's middleware stack. New code should consume `handleWatchSse` + * directly from `src/node/daemon/watch-sse` and bind it via `toNodeMiddleware` + * (introduced in Task 9). + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + broadcastFsEvent, + inferMetadata, + type FsEvent, + scanDecoFiles, + subscribeFsEvents, +} from "../../node/daemon/watch"; +import { bindWatcherToChannel } from "../../node/daemon/watcher"; + +export { + broadcastFsEvent as broadcastFSEvent, + inferMetadata, + type FsEvent, + type Metadata, +} from "../../node/daemon/watch"; + +/** + * Back-compat shim — TanStack daemon middleware still constructs this. + * The implementation reuses the new shared scanner + channel. + */ +export function createWatchHandler(opts?: { getPort?: () => number }) { + const cwd = process.cwd(); + const getPort = opts?.getPort ?? (() => 5173); + + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== "/watch" && url.pathname !== "/") return next(); + if (req.method !== "GET") return next(); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const since = Number(url.searchParams.get("since")) || 0; + let closed = false; + + const sendEvent = (event: FsEvent) => { + if (closed) return; + const data = encodeURIComponent(JSON.stringify(event)); + res.write(`event: message\ndata: ${data}\n\n`); + }; + + const unsubscribe = subscribeFsEvents(sendEvent); + req.on("close", () => { + closed = true; + unsubscribe(); + }); + + for await (const event of scanDecoFiles(cwd, since)) { + if (closed) break; + sendEvent(event); + } + if (closed) return; + + sendEvent({ type: "worker-status", detail: { state: "ready" } }); + + try { + const metaResponse = await fetch(`http://localhost:${getPort()}/live/_meta`); + if (metaResponse.ok) { + const metaData = await metaResponse.json(); + sendEvent({ type: "meta-info", detail: { ...metaData, timestamp: Date.now() } }); + } + } catch { + // schema not initialised yet + } + }; +} + +/** Back-compat — TanStack daemon binds Vite's watcher into the shared channel here. */ +export function watchFS(watcher: { + on(event: string, cb: (...args: unknown[]) => void): void; +}): void { + bindWatcherToChannel(watcher); +} +``` + +- [ ] **Step 6.7: Verify nothing regressed** + +Run: +```bash +bun run typecheck +bunx vitest run src/tanstack/daemon/ src/node/daemon/ +``` +Expected: PASS. + +- [ ] **Step 6.8: Commit** + +```bash +git add src/node/daemon/watch.ts src/node/daemon/watcher.ts src/node/daemon/watch.test.ts src/tanstack/daemon/watch.ts +git commit -m "refactor(daemon): move broadcast channel + .deco scanner to src/node/daemon + +src/tanstack/daemon/watch.ts becomes a thin Connect-style shim over the +new shared scanner + channel; the chokidar wrapper for Next-side use lives +in src/node/daemon/watcher.ts (lazy singleton, never instantiated from +TanStack). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 7: Web-standard SSE handler + +**Files:** +- Create: `src/node/daemon/watch-sse.ts` +- Create: `src/node/daemon/watch-sse.test.ts` + +- [ ] **Step 7.1: Write the test** + +`src/node/daemon/watch-sse.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { broadcastFsEvent } from "./watch"; +import { handleWatchSse } from "./watch-sse"; + +describe("handleWatchSse", () => { + it("returns a text/event-stream Response", () => { + const controller = new AbortController(); + const req = new Request("http://t/_watch", { signal: controller.signal }); + const res = handleWatchSse(req, { cwd: process.cwd() }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + expect(res.headers.get("Cache-Control")).toBe("no-cache"); + controller.abort(); + }); + + it("forwards broadcast events to the SSE stream", async () => { + const controller = new AbortController(); + const req = new Request("http://t/_watch?since=" + Date.now(), { signal: controller.signal }); + const res = handleWatchSse(req, { cwd: process.cwd() }); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + // Drain the initial fs-snapshot before publishing — scanner emits it last. + let buffer = ""; + while (!buffer.includes("fs-snapshot")) { + const { value } = await reader.read(); + buffer += decoder.decode(value); + } + + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + let received = ""; + while (!received.includes("worker-status")) { + const { value } = await reader.read(); + received += decoder.decode(value); + } + expect(received).toContain("worker-status"); + controller.abort(); + }); +}); +``` + +- [ ] **Step 7.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/watch-sse.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 7.3: Implement the handler** + +`src/node/daemon/watch-sse.ts`: +```ts +import { + type FsEvent, + scanDecoFiles, + subscribeFsEvents, +} from "./watch"; + +export interface WatchSseOptions { + /** Watch root. Defaults to process.cwd(). */ + cwd?: string; + /** Resolves the loopback port the meta-info fetch should hit. Defaults to 5173. */ + getPort?: () => number; +} + +/** + * Web-standard SSE handler for `/_watch` and `/watch`. + * + * Emits the initial .deco/ snapshot, then forwards broadcast-channel events + * for the lifetime of the connection. Closes cleanly when the request signal + * aborts. + */ +export function handleWatchSse(req: Request, opts: WatchSseOptions = {}): Response { + const url = new URL(req.url); + const since = Number(url.searchParams.get("since")) || 0; + const cwd = opts.cwd ?? process.cwd(); + const getPort = opts.getPort ?? (() => 5173); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + + const send = (event: FsEvent) => { + if (closed) return; + const data = encodeURIComponent(JSON.stringify(event)); + controller.enqueue(encoder.encode(`event: message\ndata: ${data}\n\n`)); + }; + + const unsubscribe = subscribeFsEvents(send); + + const close = () => { + if (closed) return; + closed = true; + unsubscribe(); + try { + controller.close(); + } catch { + // already closed + } + }; + + req.signal.addEventListener("abort", close); + + try { + for await (const event of scanDecoFiles(cwd, since)) { + if (closed) return; + send(event); + } + if (closed) return; + + send({ type: "worker-status", detail: { state: "ready" } }); + + try { + const metaResponse = await fetch(`http://localhost:${getPort()}/live/_meta`); + if (metaResponse.ok) { + const metaData = await metaResponse.json(); + send({ type: "meta-info", detail: { ...metaData, timestamp: Date.now() } }); + } + } catch { + // schema not initialised yet — admin will retry via /live/_meta + } + } catch (err) { + if (!closed) { + try { + controller.error(err); + } catch { + // ignore + } + } + } + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} +``` + +- [ ] **Step 7.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/watch-sse.test.ts` +Expected: PASS (both cases). + +- [ ] **Step 7.5: Commit** + +```bash +git add src/node/daemon/watch-sse.ts src/node/daemon/watch-sse.test.ts +git commit -m "feat(node/daemon): Web-standard handleWatchSse SSE handler + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 8: Web-standard FS handler + +**Files:** +- Create: `src/node/daemon/fs.ts` +- Create: `src/node/daemon/fs.test.ts` + +- [ ] **Step 8.1: Write the test** + +`src/node/daemon/fs.test.ts`: +```ts +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { handleFsRequest } from "./fs"; + +describe("handleFsRequest", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "deco-fs-test-")); + await mkdir(join(cwd, ".deco", "blocks"), { recursive: true }); + await writeFile( + join(cwd, ".deco", "blocks", "site.json"), + JSON.stringify({ greeting: "hello" }), + ); + }); + afterEach(() => rm(cwd, { recursive: true, force: true })); + + it("GET returns the file content with metadata + mtime", async () => { + const req = new Request("http://t/fs/file/.deco/blocks/site.json"); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(JSON.parse(body.content).greeting).toBe("hello"); + expect(body.timestamp).toBeGreaterThan(0); + }); + + it("GET returns 404 with a timestamp for a missing file", async () => { + const req = new Request("http://t/fs/file/.deco/missing.json"); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.timestamp).toBeGreaterThan(0); + }); + + it("rejects path traversal", async () => { + const req = new Request("http://t/fs/file/../../etc/passwd"); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(403); + }); + + it("PATCH applies a JSON patch", async () => { + const patch = { + type: "json" as const, + payload: [{ op: "replace", path: "/greeting", value: "hi" }], + }; + const req = new Request("http://t/fs/file/.deco/blocks/site.json", { + method: "PATCH", + body: JSON.stringify({ patch, timestamp: 0 }), + }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const after = JSON.parse(await readFile(join(cwd, ".deco/blocks/site.json"), "utf-8")); + expect(after.greeting).toBe("hi"); + }); + + it("DELETE removes the file", async () => { + const req = new Request("http://t/fs/file/.deco/blocks/site.json", { method: "DELETE" }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + await expect(readFile(join(cwd, ".deco/blocks/site.json"), "utf-8")).rejects.toThrow(); + }); + + it("/fs/grep stub returns an empty matches array", async () => { + const req = new Request("http://t/fs/grep", { method: "POST" }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ matches: [], totalMatches: 0 }); + }); +}); +``` + +- [ ] **Step 8.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/fs.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 8.3: Implement the handler** + +`src/node/daemon/fs.ts`: +```ts +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import fjp from "fast-json-patch"; +import type { Operation } from "fast-json-patch"; +import { broadcastFsEvent, inferMetadata, toPosixPath } from "./watch"; + +export interface FsHandlerOptions { + /** Filesystem root that path resolutions must stay within. */ + cwd: string; +} + +interface Patch { + type: "json" | "text"; + payload: Operation[]; +} + +/** + * Web-standard `/fs/*` handler. Implements GET/PATCH/DELETE on + * `/fs/file/` and a `/fs/grep` stub used by the admin search UI. + */ +export async function handleFsRequest( + req: Request, + opts: FsHandlerOptions, +): Promise { + const url = new URL(req.url); + const { pathname } = url; + + if (pathname === "/fs/grep") { + return jsonResponse(200, { matches: [], totalMatches: 0 }); + } + + if (!pathname.startsWith("/fs/file")) { + return new Response(null, { status: 404 }); + } + + const filePath = extractFilePath(pathname); + const systemPath = safePath(opts.cwd, filePath); + if (!systemPath) return jsonResponse(403, { error: "Path traversal denied" }); + + if (req.method === "GET") return getFile(systemPath); + if (req.method === "PATCH") return patchFile(req, opts.cwd, systemPath); + if (req.method === "DELETE") return deleteFile(opts.cwd, systemPath); + return new Response(null, { status: 405 }); +} + +function safePath(cwd: string, untrusted: string): string | null { + const resolved = resolve(cwd, untrusted.startsWith("/") ? `.${untrusted}` : untrusted); + if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return null; + return resolved; +} + +function extractFilePath(url: string): string { + const [, ...segments] = url.split("/file"); + return segments.join("/file") || "/"; +} + +async function mtimeFor(filepath: string): Promise { + try { + return (await stat(filepath)).mtimeMs; + } catch { + return Date.now(); + } +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function getFile(systemPath: string): Promise { + try { + const [content, metadata, timestamp] = await Promise.all([ + readFile(systemPath, "utf-8"), + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + return jsonResponse(200, { content, metadata, timestamp }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return jsonResponse(404, { timestamp: Date.now() }); + } + throw err; + } +} + +function applyPatch( + content: string | null, + patch: Patch, +): { conflict: boolean; content?: string } { + try { + if (patch.type === "json") { + const result = patch.payload.reduce( + fjp.applyReducer, + JSON.parse(content ?? "{}"), + ); + return { conflict: false, content: JSON.stringify(result, null, 2) }; + } + if (patch.type === "text") { + const result = patch.payload.reduce( + fjp.applyReducer, + content?.split("\n") ?? [], + ); + return { conflict: false, content: (result as string[]).join("\n") }; + } + } catch (err: unknown) { + if (err instanceof fjp.JsonPatchError && err.name === "TEST_OPERATION_FAILED") { + return { conflict: true }; + } + throw err; + } + return { conflict: true }; +} + +async function patchFile( + req: Request, + cwd: string, + systemPath: string, +): Promise { + let body: { patch: Patch; timestamp: number }; + try { + body = (await req.json()) as { patch: Patch; timestamp: number }; + } catch { + return jsonResponse(400, { error: "Invalid JSON" }); + } + + const mtimeBefore = await mtimeFor(systemPath); + let content: string | null; + try { + content = await readFile(systemPath, "utf-8"); + } catch { + content = null; + } + + const result = applyPatch(content, body.patch); + if (!result.conflict && result.content != null) { + await mkdir(join(systemPath, ".."), { recursive: true }); + await writeFile(systemPath, result.content, "utf-8"); + } + + const [metadata, mtimeAfter] = await Promise.all([ + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata, + timestamp: mtimeAfter, + filepath: toPosixPath(systemPath.replace(cwd, "")), + }, + }); + + const update = result.conflict + ? { conflict: true, metadata, timestamp: mtimeAfter, content } + : { + conflict: false, + metadata, + timestamp: mtimeAfter, + content: mtimeBefore !== body.timestamp ? result.content : undefined, + }; + return jsonResponse(200, update); +} + +async function deleteFile(cwd: string, systemPath: string): Promise { + try { + await rm(systemPath, { force: true }); + } catch { + // ignore + } + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata: null, + timestamp: Date.now(), + filepath: toPosixPath(systemPath.replace(cwd, "")), + }, + }); + return jsonResponse(200, { conflict: false, metadata: null, timestamp: Date.now() }); +} +``` + +- [ ] **Step 8.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/fs.test.ts` +Expected: PASS (all 6 cases). + +- [ ] **Step 8.5: Commit** + +```bash +git add src/node/daemon/fs.ts src/node/daemon/fs.test.ts +git commit -m "feat(node/daemon): Web-standard handleFsRequest + +GET/PATCH/DELETE on /fs/file/ plus the /fs/grep stub the admin search +UI hits. Refuses path traversal. Broadcasts fs-sync events on mutate paths +so the existing SSE channel stays in sync. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 4 — Composition + Node-http adapter + +### Task 9: Node-http adapter `toNodeMiddleware` + +**Files:** +- Create: `src/node/daemon/nodeHttpAdapter.ts` +- Create: `src/node/daemon/nodeHttpAdapter.test.ts` + +- [ ] **Step 9.1: Write the test** + +`src/node/daemon/nodeHttpAdapter.test.ts`: +```ts +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { toNodeMiddleware } from "./nodeHttpAdapter"; + +describe("toNodeMiddleware", () => { + let httpServer: ReturnType; + let url: string; + let nextWasCalled = false; + + beforeEach(async () => { + nextWasCalled = false; + const handler = toNodeMiddleware(async (req: Request) => { + const u = new URL(req.url); + if (u.pathname === "/fall") { + // Returning null signals fall-through. + return null; + } + if (u.pathname === "/echo-body" && req.method === "POST") { + const body = await req.text(); + return new Response(`echo:${body}`, { status: 201 }); + } + return new Response("hello", { + status: 200, + headers: { "X-Foo": "bar" }, + }); + }); + + httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + handler(req, res, () => { + nextWasCalled = true; + res.statusCode = 418; + res.end(); + }); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const addr = httpServer.address() as AddressInfo; + url = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(() => new Promise((resolve) => httpServer.close(() => resolve()))); + + it("translates a Web Response to a Node ServerResponse", async () => { + const r = await fetch(url + "/"); + expect(r.status).toBe(200); + expect(r.headers.get("X-Foo")).toBe("bar"); + expect(await r.text()).toBe("hello"); + }); + + it("forwards request bodies", async () => { + const r = await fetch(url + "/echo-body", { method: "POST", body: "ping" }); + expect(r.status).toBe(201); + expect(await r.text()).toBe("echo:ping"); + }); + + it("calls next() when the handler returns null", async () => { + const r = await fetch(url + "/fall"); + expect(r.status).toBe(418); + expect(nextWasCalled).toBe(true); + }); +}); +``` + +- [ ] **Step 9.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/nodeHttpAdapter.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 9.3: Implement the adapter** + +`src/node/daemon/nodeHttpAdapter.ts`: +```ts +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; + +export type WebHandler = (req: Request) => Promise | Response | null; + +/** + * Wrap a Web-standard `Request → Response` handler as Connect-style middleware + * for `vite dev`'s server.middlewares.use(...) stack. + * + * Returning `null` from the inner handler delegates to `next()` (fall-through). + * Streaming bodies are piped through with backpressure preserved. + */ +export function toNodeMiddleware(handler: WebHandler) { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + let webResponse: Response | null; + try { + const webReq = toWebRequest(req); + webResponse = await handler(webReq); + } catch (err) { + console.error("[deco] daemon handler threw:", err); + res.statusCode = 500; + res.end(); + return; + } + if (!webResponse) return next(); + await writeWebResponse(res, webResponse); + }; +} + +function toWebRequest(req: IncomingMessage): Request { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v); + } else { + headers.set(key, value); + } + } + const method = (req.method ?? "GET").toUpperCase(); + const init: RequestInit = { method, headers }; + if (method !== "GET" && method !== "HEAD") { + init.body = Readable.toWeb(req) as ReadableStream; + (init as any).duplex = "half"; // required by Node's Web Request for streaming bodies + } + return new Request(url, init); +} + +async function writeWebResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => res.setHeader(key, value)); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + res.on("close", () => reader.cancel().catch(() => undefined)); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + // Honor backpressure: pause reading until drain when write returns false. + if (!res.write(value)) { + await new Promise((resolve) => res.once("drain", resolve)); + } + } + res.end(); + } catch (err) { + console.error("[deco] error streaming Response body:", err); + res.destroy(err as Error); + } +} +``` + +- [ ] **Step 9.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/nodeHttpAdapter.test.ts` +Expected: PASS (all 3 cases). + +- [ ] **Step 9.5: Commit** + +```bash +git add src/node/daemon/nodeHttpAdapter.ts src/node/daemon/nodeHttpAdapter.test.ts +git commit -m "feat(node/daemon): toNodeMiddleware Web↔Connect adapter + +Lets the new Web-standard route handlers plug into Vite's middleware stack +without duplicating any dispatch logic. Honors backpressure and treats a +null return as fall-through. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10: Route dispatcher `createDecoAdminRoute` + +**Files:** +- Create: `src/node/daemon/route.ts` +- Create: `src/node/daemon/route.test.ts` + +- [ ] **Step 10.1: Write the test** + +`src/node/daemon/route.test.ts`: +```ts +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +import { createDecoAdminRoute } from "./route"; + +describe("createDecoAdminRoute", () => { + const originalEnv = process.env.NODE_ENV; + const originalBypass = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + + beforeEach(() => { + delete process.env.NODE_ENV; + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + }); + afterEach(() => { + if (originalEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalEnv; + if (originalBypass === undefined) delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + else process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = originalBypass; + }); + + it("serves /_healthcheck with the version body", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + expect(await res.text()).toBe(ADMIN_COMPAT_VERSION); + }); + + it("serves /_ready (503 before setBlocks-equivalent state)", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/_ready")); + expect([200, 503]).toContain(res.status); + }); + + it("returns 404 when the master enabled switch is false", async () => { + const handler = createDecoAdminRoute({ site: "my-site", enabled: false }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(404); + }); + + it("returns 404 when an individual group is disabled", async () => { + const handler = createDecoAdminRoute({ site: "my-site", healthcheck: false }); + expect((await handler(new Request("http://t/_healthcheck"))).status).toBe(404); + expect((await handler(new Request("http://t/_ready"))).status).not.toBe(404); + }); + + it("disables /fs/* and /_watch in production by default", async () => { + process.env.NODE_ENV = "production"; + const handler = createDecoAdminRoute({ site: "my-site" }); + expect((await handler(new Request("http://t/_watch"))).status).toBe(404); + expect((await handler(new Request("http://t/fs/file/anything"))).status).toBe(404); + }); + + it("enables /fs/* and /_watch in development by default", async () => { + process.env.NODE_ENV = "development"; + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + // manageWatcher: false avoids spinning up a real chokidar against repo root + // for unit tests; the lazy-watcher integration is exercised in src/next/. + const handler = createDecoAdminRoute({ + site: "my-site", + cwd: process.cwd(), + manageWatcher: false, + }); + const fsRes = await handler(new Request("http://t/fs/file/.deco/missing.json")); + // 404 here means "file not found", not "route disabled" — assert it's not the route-404 case. + expect(fsRes.status).toBe(404); + expect((await fsRes.json()).timestamp).toBeGreaterThan(0); + }); + + it("gates /fs/* on JWT when bypass is not set", async () => { + process.env.NODE_ENV = "development"; + const handler = createDecoAdminRoute({ + site: "my-site", + manageWatcher: false, + }); + const res = await handler(new Request("http://t/fs/file/.deco/anything")); + expect(res.status).toBe(401); + }); + + it("creates the chokidar watcher lazily on first /fs/* hit when manageWatcher is on", async () => { + process.env.NODE_ENV = "development"; + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + const handler = createDecoAdminRoute({ + site: "my-site", + cwd: process.cwd(), + manageWatcher: true, + }); + // Hit a non-existent path so we don't depend on repo contents; we only care + // that the call succeeds without crashing the lazy-watcher boot. + const res = await handler(new Request("http://t/fs/file/.deco/missing.json")); + expect(res.status).toBe(404); + }); + + it("returns 501 for /volumes//files (Next path)", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/volumes/abc/files")); + expect(res.status).toBe(501); + }); + + it("throws at construction when site is missing and admin-protocol is enabled", () => { + expect(() => createDecoAdminRoute({})).toThrow(/site/); + }); + + it("does not require site when only probes are enabled", () => { + expect(() => + createDecoAdminRoute({ + adminProtocol: false, + fs: false, + watch: false, + }), + ).not.toThrow(); + }); +}); +``` + +- [ ] **Step 10.2: Run the test (expect failure)** + +Run: `bunx vitest run src/node/daemon/route.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 10.3: Implement the dispatcher** + +`src/node/daemon/route.ts`: +```ts +import { handleDecofileRead, handleDecofileReload } from "../../core/admin/decofile"; +import { handleInvoke } from "../../core/admin/invoke"; +import { handleMeta } from "../../core/admin/meta"; +import { handleRender } from "../../core/admin/render"; +import { handleDecoReadiness } from "../../core/admin/readiness"; +import { requireAdminJwt } from "./auth"; +import { handleFsRequest } from "./fs"; +import { handleDecoHealthcheck } from "./healthcheck"; +import { handleWatchSse } from "./watch-sse"; + +export interface DecoAdminRouteOptions { + /** Master switch — false short-circuits everything to 404. */ + enabled?: boolean; + /** Hosting probe `/_healthcheck`. Default: true. */ + healthcheck?: boolean; + /** Hosting probe `/_ready`. Default: true. */ + readiness?: boolean; + /** Admin protocol (`/live/_meta`, `/.decofile`, `/deco/*`, `/live/previews/*`). Default: true. */ + adminProtocol?: boolean; + /** Dev tooling SSE (`/_watch`, `/watch`). Default: NODE_ENV !== "production". */ + watch?: boolean; + /** Dev tooling JSON-patch FS (`/fs/*`). Default: NODE_ENV !== "production". */ + fs?: boolean; + /** Filesystem root for fs + watch handlers. Default: process.cwd(). */ + cwd?: string; + /** + * Site name for JWT validation. Required when any auth-gated group + * (`adminProtocol`, `watch`, or `fs`) is enabled. + */ + site?: string; + /** Watch handler's loopback meta-info port resolver. Default: () => 5173. */ + getPort?: () => number; + /** + * Lazily create + bind a chokidar watcher on the first /watch or /fs/* request + * when watch or fs is enabled. Default: true. + * + * Set to `false` on the TanStack/Vite path — Vite already provides the watcher + * via `bindWatcherToChannel`. Two watchers on the same tree work but waste + * inotify handles. + */ + manageWatcher?: boolean; +} + +interface ResolvedOptions { + enabled: boolean; + healthcheck: boolean; + readiness: boolean; + adminProtocol: boolean; + watch: boolean; + fs: boolean; + cwd: string; + site?: string; + getPort: () => number; + manageWatcher: boolean; +} + +function resolve(opts: DecoAdminRouteOptions): ResolvedOptions { + const isProd = process.env.NODE_ENV === "production"; + const resolved: ResolvedOptions = { + enabled: opts.enabled ?? true, + healthcheck: opts.healthcheck ?? true, + readiness: opts.readiness ?? true, + adminProtocol: opts.adminProtocol ?? true, + watch: opts.watch ?? !isProd, + fs: opts.fs ?? !isProd, + cwd: opts.cwd ?? process.cwd(), + site: opts.site, + getPort: opts.getPort ?? (() => 5173), + manageWatcher: opts.manageWatcher ?? true, + }; + const authGroupActive = + resolved.enabled && (resolved.adminProtocol || resolved.watch || resolved.fs); + if (authGroupActive && !resolved.site) { + throw new Error( + "createDecoAdminRoute: `site` is required when adminProtocol, watch, or fs is enabled.", + ); + } + return resolved; +} + +/** + * Compose a Web-standard handler for the daemon's full route surface. + * + * Each route group can be independently toggled. Disabled groups short-circuit + * to 404 — callers can't distinguish a disabled deploy from one that never had + * the route, which keeps the surface honest. + */ +// Lazy chokidar singleton keyed by cwd — created on the first /watch or /fs/* +// request when manageWatcher is enabled. Module-level so two `createDecoAdminRoute` +// calls in the same process share a watcher per cwd. +const watcherSingletons = new Map Promise }>>(); + +async function ensureWatcher(cwd: string): Promise { + if (watcherSingletons.has(cwd)) return; + // Dynamic import keeps chokidar out of the synchronous module graph for + // callers that only need probes (e.g. production builds). + const promise = import("./watcher").then(({ createDecoWatcher }) => + createDecoWatcher(cwd), + ); + watcherSingletons.set(cwd, promise); + await promise; +} + +export function createDecoAdminRoute( + opts: DecoAdminRouteOptions = {}, +): (req: Request) => Promise { + const cfg = resolve(opts); + const watcherNeeded = + cfg.manageWatcher && + (cfg.watch || cfg.fs) && + process.env.NODE_ENV !== "production"; + + return async (req: Request): Promise => { + if (!cfg.enabled) return notFound(); + const { pathname } = new URL(req.url); + + // Probes — no auth. + if (pathname === "/_healthcheck") { + return cfg.healthcheck ? handleDecoHealthcheck() : notFound(); + } + if (pathname === "/_ready") { + return cfg.readiness ? handleDecoReadiness() : notFound(); + } + + // Volumes — TanStack-only (WebSocket). Next-style returns 501. + if (pathname.includes("/volumes/") && pathname.includes("/files")) { + if (!cfg.adminProtocol) return notFound(); + return new Response( + "Volumes WebSocket is not supported in the Next adapter. " + + "Use the TanStack/Vite daemon for /volumes//files.", + { status: 501, headers: { "Content-Type": "text/plain" } }, + ); + } + + // Dev tooling — auth-gated. + if (pathname === "/_watch" || pathname === "/watch") { + if (!cfg.watch) return notFound(); + const guard = await requireAdminJwt(req, cfg.site!); + if (guard) return guard; + if (watcherNeeded) await ensureWatcher(cfg.cwd); + return handleWatchSse(req, { cwd: cfg.cwd, getPort: cfg.getPort }); + } + + if (pathname.startsWith("/fs/")) { + if (!cfg.fs) return notFound(); + const guard = await requireAdminJwt(req, cfg.site!); + if (guard) return guard; + if (watcherNeeded) await ensureWatcher(cfg.cwd); + return handleFsRequest(req, { cwd: cfg.cwd }); + } + + // Admin protocol — handlers self-authenticate today (see src/core/admin/*). + if (cfg.adminProtocol) { + if (pathname === "/live/_meta") return handleMeta(req); + if (pathname === "/.decofile") { + return req.method === "POST" ? handleDecofileReload(req) : handleDecofileRead(); + } + if (pathname === "/deco/render" || pathname.startsWith("/live/previews/")) { + return handleRender(req); + } + if (pathname === "/deco/invoke" || pathname.startsWith("/deco/invoke/")) { + return handleInvoke(req); + } + } + + return notFound(); + }; +} + +function notFound(): Response { + return new Response("Not Found", { status: 404 }); +} +``` + +- [ ] **Step 10.4: Run the test (expect pass)** + +Run: `bunx vitest run src/node/daemon/route.test.ts` +Expected: PASS (all 10 cases). + +- [ ] **Step 10.5: Commit** + +```bash +git add src/node/daemon/route.ts src/node/daemon/route.test.ts +git commit -m "feat(node/daemon): createDecoAdminRoute dispatcher + +Single Web-standard entry point composing all daemon handlers behind +configurable route-group flags. Defaults dev tooling (watch, fs) to off +in production; throws at construction if an auth-gated group is enabled +without a site name. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 11: Public barrel + package export + +**Files:** +- Create: `src/node/daemon/index.ts` +- Modify: `package.json` (add `./node/daemon` export) + +- [ ] **Step 11.1: Create the barrel** + +`src/node/daemon/index.ts`: +```ts +/** + * @decocms/start/node/daemon — Web-standard daemon handlers. + * + * Node-only (depends on `node:fs/promises`, `chokidar`, `fast-json-patch`). + * Consumed by both `@decocms/start/next` (directly) and + * `@decocms/start/tanstack/daemon` (via `toNodeMiddleware` for Vite). + */ +export { handleDecoHealthcheck } from "./healthcheck"; +export { handleDecoReadiness } from "../../core/admin/readiness"; +export { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +export { requireAdminJwt } from "./auth"; +export { verifyAdminJwt, tokenIsValid } from "./jwt"; +export type { JwtPayload } from "./jwt"; +export { handleFsRequest } from "./fs"; +export type { FsHandlerOptions } from "./fs"; +export { + broadcastFsEvent, + subscribeFsEvents, + inferMetadata, + scanDecoFiles, + type FsEvent, + type Metadata, +} from "./watch"; +export { handleWatchSse } from "./watch-sse"; +export type { WatchSseOptions } from "./watch-sse"; +export { createDecoWatcher, bindWatcherToChannel } from "./watcher"; +export type { DecoWatcher } from "./watcher"; +export { createDecoAdminRoute } from "./route"; +export type { DecoAdminRouteOptions } from "./route"; +export { toNodeMiddleware } from "./nodeHttpAdapter"; +export type { WebHandler } from "./nodeHttpAdapter"; +``` + +- [ ] **Step 11.2: Add the package export** + +Edit `package.json`. Locate the existing `"./node"` block in `exports`: +```json + "./node": { + "types": "./dist/node/index.d.ts", + "import": "./dist/node/index.js", + "require": "./dist/node/index.cjs" + }, +``` + +Insert immediately after (preserving JSON validity — comma after the new block): +```json + "./node/daemon": { + "types": "./dist/node/daemon/index.d.ts", + "import": "./dist/node/daemon/index.js", + "require": "./dist/node/daemon/index.cjs" + }, +``` + +- [ ] **Step 11.3: Verify typecheck + tests** + +Run: +```bash +bun run typecheck +bunx vitest run src/node/daemon/ +``` +Expected: PASS. + +- [ ] **Step 11.4: Commit** + +```bash +git add src/node/daemon/index.ts package.json +git commit -m "feat(pkg): export @decocms/start/node/daemon + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 5 — TanStack refactor + +### Task 12: Refactor `createDaemonMiddleware` to compose the shared core + +**Files:** +- Modify: `src/tanstack/daemon/middleware.ts` +- Create: `src/tanstack/daemon/middleware.test.ts` + +- [ ] **Step 12.1: Write the integration test** + +`src/tanstack/daemon/middleware.test.ts`: +```ts +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createDaemonMiddleware } from "./middleware"; + +describe("createDaemonMiddleware (integration)", () => { + let httpServer: ReturnType; + let url: string; + const noopWatcher = { on: () => undefined }; + + beforeEach(async () => { + const middleware = createDaemonMiddleware({ + site: "my-site", + server: { httpServer: null, watcher: noopWatcher }, + }); + httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + middleware(req, res, () => { + res.statusCode = 404; + res.end(); + }); + }); + await new Promise((r) => httpServer.listen(0, r)); + url = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + }); + afterEach(() => new Promise((r) => httpServer.close(() => r()))); + + it("serves /_healthcheck without auth", async () => { + const r = await fetch(url + "/_healthcheck"); + expect(r.status).toBe(200); + expect((await r.text()).length).toBeGreaterThan(0); + }); + + it("serves /_ready without auth", async () => { + const r = await fetch(url + "/_ready"); + expect([200, 503]).toContain(r.status); + }); + + it("returns 401 for /fs/file/anything without a token", async () => { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = ""; + const r = await fetch(url + "/fs/file/.deco/anything", { + headers: { "x-daemon-api": "true" }, + }); + expect(r.status).toBe(401); + }); +}); +``` + +- [ ] **Step 12.2: Run the test (expect failure)** + +Run: `bunx vitest run src/tanstack/daemon/middleware.test.ts` +Expected: at least one assertion fails (current middleware does not serve `/_ready` or treat `/_healthcheck` via shared route). + +- [ ] **Step 12.3: Replace `src/tanstack/daemon/middleware.ts`** + +Replace the entire file with the version below. It keeps the public signature, the existing `x-daemon-api` gating, and the volumes WebSocket binding intact, but delegates all HTTP-shape routes (probes + fs + watch + admin protocol) to `createDecoAdminRoute` via `toNodeMiddleware`. + +```ts +/** + * Daemon middleware — Connect-style entry point used by Vite's middleware + * stack. + * + * All HTTP-shape routes (probes, fs, watch, admin protocol) are composed by + * `createDecoAdminRoute` from `src/node/daemon/route.ts` and wrapped via + * `toNodeMiddleware`. The volumes WebSocket binding stays in this file + * because it needs `httpServer.on("upgrade")`, which is not expressible via + * Request → Response. + */ +import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; +import { createAuthMiddleware } from "./auth"; +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../../node/daemon/route"; +import { toNodeMiddleware } from "../../node/daemon/nodeHttpAdapter"; +import { bindWatcherToChannel } from "../../node/daemon/watcher"; +import { createVolumesHandler } from "./volumes"; + +const DAEMON_API_SPECIFIER = "x-daemon-api"; +const HYPERVISOR_API_SPECIFIER = "x-hypervisor-api"; + +export interface DaemonOptions { + /** Site name for JWT validation. */ + site: string; + /** Vite dev server instance. */ + server: { + httpServer: HttpServer | null; + watcher: { on(event: string, cb: (...args: unknown[]) => void): void }; + }; + /** + * Optional per-group toggles, forwarded to createDecoAdminRoute. + * Site is taken from the top-level `site` field; the watch port defaults + * to the Vite httpServer's bound port. + */ + routes?: Omit; +} + +export function createDaemonMiddleware(opts: DaemonOptions) { + const auth = createAuthMiddleware(opts.site); + const httpServer = opts.server.httpServer; + + // Volumes still owns its httpServer.on("upgrade") binding — not portable. + const volumes = httpServer + ? createVolumesHandler({ httpServer, watcher: opts.server.watcher }) + : null; + + // Vite's watcher feeds the shared broadcast channel. + bindWatcherToChannel(opts.server.watcher); + + const webRouteHandler = createDecoAdminRoute({ + site: opts.site, + getPort: () => { + const addr = httpServer?.address(); + return typeof addr === "object" && addr ? addr.port : 5173; + }, + // Vite already provides the watcher via `bindWatcherToChannel` above; + // skip the Next-style lazy chokidar singleton so we don't double-watch. + manageWatcher: false, + ...opts.routes, + }); + + const webMiddleware = toNodeMiddleware(async (req) => { + const { pathname } = new URL(req.url); + // /_healthcheck is the only daemon route that bypasses x-daemon-api gating + // (admin uses it before authenticating to verify reachability). + if (pathname === "/_healthcheck") return webRouteHandler(req); + // Everything else returns a real Response from createDecoAdminRoute; the + // null-fallthrough branch is for the volumes paths, which we handle below. + return webRouteHandler(req); + }); + + return (req: IncomingMessage, res: ServerResponse, next: () => void): void => { + let pathname: string; + try { + pathname = new URL(req.url ?? "/", "http://localhost").pathname; + } catch { + pathname = req.url ?? "/"; + } + + // Healthcheck — no auth, no x-daemon-api header required. + if (pathname === "/_healthcheck") { + webMiddleware(req, res, next); + return; + } + + const isDaemonAPI = + req.headers[DAEMON_API_SPECIFIER] ?? + req.headers[HYPERVISOR_API_SPECIFIER] ?? + false; + + if (!isDaemonAPI) { + try { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.searchParams.get(DAEMON_API_SPECIFIER) !== "true") { + next(); + return; + } + } catch { + next(); + return; + } + } + + // CORS for admin.deco.cx. + const origin = req.headers.origin; + if (origin) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-daemon-api, x-hypervisor-api"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + } + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + // Auth before any /fs/*, /watch, /volumes/*, admin protocol routes. + auth(req, res, () => { + // Volumes API — TanStack-only, requires raw httpServer. + if (pathname.includes("/volumes/") && pathname.includes("/files") && volumes) { + volumes(req, res, next); + return; + } + // All other HTTP-shape routes flow through the Web-standard dispatcher. + webMiddleware(req, res, next); + }); + }; +} +``` + +- [ ] **Step 12.4: Run all tanstack daemon tests** + +Run: +```bash +bunx vitest run src/tanstack/daemon/ +``` +Expected: PASS (including the new middleware integration tests). + +- [ ] **Step 12.5: Run full test suite** + +Run: `bunx vitest run` +Expected: PASS — no regressions elsewhere. + +- [ ] **Step 12.6: Commit** + +```bash +git add src/tanstack/daemon/middleware.ts src/tanstack/daemon/middleware.test.ts +git commit -m "refactor(tanstack/daemon): compose shared Web-standard route handler + +createDaemonMiddleware delegates probes, fs, watch, and admin-protocol +dispatch to createDecoAdminRoute via toNodeMiddleware. Volumes WebSocket +binding stays in-place because it requires raw httpServer access. Public +signature unchanged; new optional routes field forwards group toggles. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 6 — Next adapter + +### Task 13: Refactor `src/next/adminRoute.ts` to use the shared dispatcher + +**Files:** +- Modify: `src/next/adminRoute.ts` +- Modify: `src/next/adminRoute.test.ts` (extend coverage) + +- [ ] **Step 13.1: Extend the test** + +Append to `src/next/adminRoute.test.ts`: +```ts +import { afterEach, beforeEach } from "vitest"; + +describe("handleDecoAdminRoute — extended dispatch", () => { + const originalNodeEnv = process.env.NODE_ENV; + beforeEach(() => { + delete process.env.NODE_ENV; + process.env.DECO_SITE = "my-site"; + }); + afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + delete process.env.DECO_SITE; + }); + + it("serves /_healthcheck", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + }); + + it("serves /_ready", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/_ready")); + expect([200, 503]).toContain(res.status); + }); + + it("returns 501 for /volumes//files", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/volumes/abc/files")); + expect(res.status).toBe(501); + }); + + it("disables /_watch in production", async () => { + process.env.NODE_ENV = "production"; + const res = await handleDecoAdminRoute(new Request("http://t/_watch")); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 13.2: Replace `src/next/adminRoute.ts`** + +Replace the existing file. The JSDoc gets the escape-rule fix; the implementation defers entirely to `createDecoAdminRoute`. Paste: + +```ts +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../node/daemon/route"; + +/** + * Dispatch a Next.js App Router request to the appropriate Deco daemon handler. + * + * Mount as both GET and POST in dedicated route files under your `app/` tree. + * Next App Router needs escaped folder names because it treats `_folder` as + * private and excludes it from routing: + * + * app/ + * ├── %5Fhealthcheck/route.ts (literal %5F — Next/Turbopack do not URL-decode this) + * ├── %5Fready/route.ts + * ├── %5Fwatch/route.ts + * ├── .decofile/route.ts (literal `.`, not %2E — Turbopack does not decode %2E) + * ├── live/ + * │ ├── %5Fmeta/route.ts + * │ └── previews/[[...path]]/route.ts + * ├── deco/ + * │ ├── render/route.ts + * │ └── invoke/[[...path]]/route.ts + * └── fs/file/[[...path]]/route.ts + * + * Each route file is two lines: + * + * export const dynamic = "force-dynamic"; + * export { GET, POST } from "@/lib/deco-admin"; // ← your config module + * + * Where `@/lib/deco-admin` instantiates a single configuration: + * + * import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + * export const { GET, POST } = createDecoAdminRouteHandlers({ site: "my-site" }); + * + * For one-off mounting without a config module, `handleDecoAdminRoute` is the + * pre-instantiated default. It reads `DECO_SITE` from the environment for JWT + * validation; if you need richer options, use `createDecoAdminRoute` or + * `createDecoAdminRouteHandlers`. + * + * Disabled groups return 404 (looks like the route doesn't exist). + * `/volumes//files` returns 501 — the WebSocket flow is TanStack-only. + */ +// Lazy construction so a consumer importing this module without +// `DECO_SITE` set yet (e.g. before .env load completes in some setups) +// does not crash at import time — the auth-gated `adminProtocol` group +// defaults on and would throw from `createDecoAdminRoute`. +let _handler: ((req: Request) => Promise) | null = null; +function getDefaultHandler(): (req: Request) => Promise { + if (!_handler) { + _handler = createDecoAdminRoute({ site: process.env.DECO_SITE }); + } + return _handler; +} + +export const handleDecoAdminRoute: (req: Request) => Promise = (req) => + getDefaultHandler()(req); + +export { createDecoAdminRoute }; +export type { DecoAdminRouteOptions }; +``` + +- [ ] **Step 13.3: Run the Next adapter tests** + +Run: `bunx vitest run src/next/adminRoute.test.ts` +Expected: PASS (existing two + extended four). + +- [ ] **Step 13.4: Commit** + +```bash +git add src/next/adminRoute.ts src/next/adminRoute.test.ts +git commit -m "refactor(next): handleDecoAdminRoute composes shared dispatcher + +Brings probes, watch, fs, and the corrected route-mounting JSDoc into the +Next adapter without duplicating logic. handleDecoAdminRoute is now a +pre-instantiated createDecoAdminRoute that reads DECO_SITE. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 14: `createDecoAdminRouteHandlers` convenience factory + +**Files:** +- Create: `src/next/routeHandlers.ts` +- Create: `src/next/routeHandlers.test.ts` + +- [ ] **Step 14.1: Write the test** + +`src/next/routeHandlers.test.ts`: +```ts +import { describe, expect, it } from "vitest"; +import { createDecoAdminRouteHandlers, decoAdminRouteHandlers } from "./routeHandlers"; + +describe("createDecoAdminRouteHandlers", () => { + it("returns identical handlers for GET and POST that delegate to the dispatcher", async () => { + const { GET, POST } = createDecoAdminRouteHandlers({ site: "my-site" }); + const a = await GET(new Request("http://t/_healthcheck")); + const b = await POST(new Request("http://t/_healthcheck")); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + }); + + it("decoAdminRouteHandlers is the default-options instance", async () => { + process.env.DECO_SITE = "my-site"; + const res = await decoAdminRouteHandlers.GET(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + delete process.env.DECO_SITE; + }); +}); +``` + +- [ ] **Step 14.2: Run the test (expect failure)** + +Run: `bunx vitest run src/next/routeHandlers.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 14.3: Implement** + +`src/next/routeHandlers.ts`: +```ts +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../node/daemon/route"; + +export interface DecoAdminRouteHandlers { + GET: (req: Request) => Promise; + POST: (req: Request) => Promise; +} + +/** + * Build a `{ GET, POST }` pair suitable for one-line `export` from every + * App Router route file under your `app/` tree. Instantiate once in a shared + * module and re-export from each route file. + * + * @example + * // app/lib/deco-admin.ts + * import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + * export const { GET, POST } = createDecoAdminRouteHandlers({ site: "my-site" }); + * + * // app/%5Fhealthcheck/route.ts + * export const dynamic = "force-dynamic"; + * export { GET, POST } from "@/lib/deco-admin"; + */ +export function createDecoAdminRouteHandlers( + opts: DecoAdminRouteOptions = {}, +): DecoAdminRouteHandlers { + const handler = createDecoAdminRoute(opts); + return { GET: handler, POST: handler }; +} + +/** + * Pre-instantiated handlers using all defaults (reads `DECO_SITE` from env). + * Use this only for the simplest setup — most apps will call + * `createDecoAdminRouteHandlers` to lock options at the call site. + * + * Implemented lazily so importing this module without `DECO_SITE` set yet + * does not crash at import time. + */ +let _defaultHandlers: DecoAdminRouteHandlers | null = null; +function getDefaultHandlers(): DecoAdminRouteHandlers { + if (!_defaultHandlers) { + _defaultHandlers = createDecoAdminRouteHandlers({ site: process.env.DECO_SITE }); + } + return _defaultHandlers; +} + +export const decoAdminRouteHandlers: DecoAdminRouteHandlers = { + GET: (req) => getDefaultHandlers().GET(req), + POST: (req) => getDefaultHandlers().POST(req), +}; +``` + +- [ ] **Step 14.4: Run the test (expect pass)** + +Run: `bunx vitest run src/next/routeHandlers.test.ts` +Expected: PASS. + +- [ ] **Step 14.5: Commit** + +```bash +git add src/next/routeHandlers.ts src/next/routeHandlers.test.ts +git commit -m "feat(next): createDecoAdminRouteHandlers convenience factory + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 15: Update `src/next/index.ts` exports + +**Files:** +- Modify: `src/next/index.ts` + +- [ ] **Step 15.1: Replace the file** + +`src/next/index.ts`: +```ts +/** + * @decocms/start/next — Next.js App Router adapter. + * + * App Router only. Pages Router not supported. + */ +export { loadCmsPage } from "./loadCmsPage"; +export { buildMatcherContextFromNext } from "./ctx"; +export { + createDecoAdminRoute, + handleDecoAdminRoute, + type DecoAdminRouteOptions, +} from "./adminRoute"; +export { + createDecoAdminRouteHandlers, + decoAdminRouteHandlers, + type DecoAdminRouteHandlers, +} from "./routeHandlers"; +export { DecoPage } from "./DecoPage"; + +// Probe handlers — re-exported so consumers can mount a single route file +// without the full dispatcher. +export { handleDecoHealthcheck } from "../node/daemon/healthcheck"; +export { handleDecoReadiness } from "../core/admin/readiness"; +export { ADMIN_COMPAT_VERSION } from "../core/admin/version"; +``` + +- [ ] **Step 15.2: Typecheck + full test run** + +Run: +```bash +bun run typecheck +bunx vitest run +``` +Expected: PASS — no regressions. + +- [ ] **Step 15.3: Commit** + +```bash +git add src/next/index.ts +git commit -m "feat(next): expose new admin coverage surface from the barrel + +Adds createDecoAdminRoute, createDecoAdminRouteHandlers, decoAdminRouteHandlers, +handleDecoHealthcheck, handleDecoReadiness, ADMIN_COMPAT_VERSION, and the +related types. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 7 — Documentation + final verification + +### Task 16: Rewrite `docs/using-from-nextjs.md` + +**Files:** +- Modify: `docs/using-from-nextjs.md` + +- [ ] **Step 16.1: Replace the file** + +`docs/using-from-nextjs.md`: +```markdown +# Using @decocms/start from Next.js (App Router) + +`@decocms/start` ships a first-party Next.js adapter at `@decocms/start/next`. App Router only. + +## Install + +```bash +bun add @decocms/start +# Required peer dependencies (you almost certainly already have these in a Next 15/16 app) +bun add next@^15 react@^19 react-dom@^19 +``` + +`tsconfig.json` must use `moduleResolution: "bundler"` (the Next 15+ default). + +## Configure + +No `transpilePackages` in `next.config.js` is needed — the package ships compiled JavaScript. + +Set `DECO_SITE=` in your environment so the admin protocol routes can validate JWTs from `admin.deco.cx`. + +## Render a CMS page from a route + +```tsx +// app/[[...path]]/page.tsx +import { loadCmsPage } from "@decocms/start/next"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; + +export default async function Page() { + const h = await headers(); + const url = new URL(h.get("x-url") ?? `http://localhost${h.get("x-pathname") ?? "/"}`); + const reqHeaders = new Headers(); + h.forEach((value, key) => reqHeaders.set(key, value)); + const req = new Request(url, { headers: reqHeaders }); + + const result = await loadCmsPage(req); + if (!result) notFound(); + + return ; +} +``` + +Populate `x-url` / `x-pathname` from a Next middleware: + +```ts +// middleware.ts +import { NextResponse } from "next/server"; +export function middleware(req: Request) { + const res = NextResponse.next(); + res.headers.set("x-url", req.url); + res.headers.set("x-pathname", new URL(req.url).pathname); + return res; +} +export const config = { matcher: ["/((?!_next).*)"] }; +``` + +## Wire admin protocol routes + +The Deco admin UI talks to your storefront via: + +- `/_healthcheck`, `/_ready` — hosting probes +- `/live/_meta`, `/.decofile`, `/live/previews/*`, `/deco/render`, `/deco/invoke/*` — admin protocol +- `/_watch`, `/fs/*` — dev-time admin editor (auto-disabled in production) + +**Do not mount a single root-level catchall.** Earlier versions of these docs recommended `app/(deco-admin)/[...path]/route.ts`, which intercepts every non-root request in your app and breaks any storefront with pages at `/products`, `/cart`, etc. Use dedicated route files instead. + +### One config module + dedicated route files + +App Router treats `_folder` as a *private folder* and excludes it from routing, so daemon paths starting with `_` need to be escaped in the folder name. Turbopack does not URL-decode `%2E`, so `.`-prefixed folders must use a literal dot. The exact layout that works: + +``` +app/ +├── lib/ +│ └── deco-admin.ts +├── %5Fhealthcheck/route.ts +├── %5Fready/route.ts +├── %5Fwatch/route.ts +├── .decofile/route.ts (literal . — NOT %2E) +├── live/ +│ ├── %5Fmeta/route.ts +│ └── previews/[[...path]]/route.ts +├── deco/ +│ ├── render/route.ts +│ └── invoke/[[...path]]/route.ts +└── fs/file/[[...path]]/route.ts +``` + +Instantiate the dispatcher once: + +```ts +// app/lib/deco-admin.ts +import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + +export const { GET, POST } = createDecoAdminRouteHandlers({ + site: "my-site", + // Optional — defaults shown: + // enabled: true + // healthcheck: true + // readiness: true + // adminProtocol: true + // watch: NODE_ENV !== "production" + // fs: NODE_ENV !== "production" + // cwd: process.cwd() +}); +``` + +Then every route file is two lines: + +```ts +// app/%5Fhealthcheck/route.ts (and every other route file above) +export const dynamic = "force-dynamic"; +export { GET, POST } from "@/lib/deco-admin"; +``` + +### Disabling specific routes + +Each group has its own flag: + +```ts +export const { GET, POST } = createDecoAdminRouteHandlers({ + site: "my-site", + watch: false, // disable dev-time SSE even in dev + fs: false, // disable dev-time filesystem REST + adminProtocol: false, // disable admin editing entirely against this deploy +}); +``` + +Disabled groups return 404 — callers cannot distinguish a disabled deploy from one that never had the route. + +## Register sections + +At app boot (before any request handler runs): + +```ts +// src/sections/registry.ts +import { registerSectionsSync, setBlocks } from "@decocms/start/cms"; +import * as MyHero from "./MyHero"; +import blocks from "../.deco/blocks/site.json"; + +setBlocks(blocks); +registerSectionsSync({ + "site/sections/MyHero.tsx": MyHero.default, +}); +``` + +Import this from `app/layout.tsx` (or any module that runs at boot) so it executes before any page renders. + +## Limitations + +- App Router only. Pages Router is not supported. +- `/volumes//files` (WebSocket) is **not** supported — it requires `httpServer.on("upgrade")`, which Next App Router does not expose. Calls to that path return 501. Use the TanStack/Vite daemon if you need volumes. +- The minimal `DecoPage` server component is a starting point; production renderers should provide their own. +- `@decocms/start/next/client` exports only `useDevice` and `signal`. The TanStack-specific hooks (`LiveControls`, `LazySection`) are not yet ported. +``` + +- [ ] **Step 16.2: Commit** + +```bash +git add docs/using-from-nextjs.md +git commit -m "docs(next): rewrite App Router integration guide with correct route layout + +Removes the broken single-catchall recipe and replaces it with the +escaped-folder layout that survives Next App Router's _folder privacy rule +and Turbopack's %2E behaviour. Documents the createDecoAdminRouteHandlers +config pattern and the per-group toggles. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 17: Final verification + +**Files:** none (all verification commands). + +- [ ] **Step 17.1: Full typecheck** + +Run: `bun run typecheck` +Expected: PASS. + +- [ ] **Step 17.2: Full test suite** + +Run: `bunx vitest run` +Expected: PASS. + +- [ ] **Step 17.3: Build + tier-boundary check** + +Run: `bun run build` +Expected: builds successfully; the `scripts/check-tier-boundaries.ts` post-build step (if wired into `build`) reports zero violations. If the tier check is run separately, run it explicitly: +```bash +bun run scripts/check-tier-boundaries.ts +``` +Expected: zero violations. `next/` files do not import from `tanstack/`; `tanstack/` files do not import from `next/`; `core/` is clean of `@tanstack/*`, `next`, `node:async_hooks`, and Node-only modules. + +- [ ] **Step 17.4: Smoke-check the package exports** + +Run: +```bash +node -e "console.log(Object.keys(require('./dist/next/index.cjs')))" +``` +Expected output (set; order may vary): +``` +[ + 'loadCmsPage', + 'buildMatcherContextFromNext', + 'createDecoAdminRoute', + 'handleDecoAdminRoute', + 'createDecoAdminRouteHandlers', + 'decoAdminRouteHandlers', + 'DecoPage', + 'handleDecoHealthcheck', + 'handleDecoReadiness', + 'ADMIN_COMPAT_VERSION' +] +``` + +```bash +node -e "console.log(Object.keys(require('./dist/node/daemon/index.cjs')))" +``` +Expected: at minimum `handleDecoHealthcheck`, `handleDecoReadiness`, `requireAdminJwt`, `verifyAdminJwt`, `tokenIsValid`, `handleFsRequest`, `handleWatchSse`, `createDecoAdminRoute`, `toNodeMiddleware`, `createDecoWatcher`, `bindWatcherToChannel`, `broadcastFsEvent`, `subscribeFsEvents`, `inferMetadata`, `scanDecoFiles`, `ADMIN_COMPAT_VERSION`. + +- [ ] **Step 17.5: Changelog entry** + +Append to `CHANGELOG.md` (or wherever the project records release notes — confirm with `ls CHANGELOG*`). The release tooling is semantic-release, so the canonical place to surface this is the merge commit + the conventional-commits-derived notes; if there's no manual CHANGELOG, skip this step. If one exists, add: + +```markdown +## Unreleased + +### Added +- `@decocms/start/next`: `handleDecoHealthcheck`, `handleDecoReadiness`, `createDecoAdminRoute`, `createDecoAdminRouteHandlers`, `decoAdminRouteHandlers`, `ADMIN_COMPAT_VERSION`. +- `@decocms/start/node/daemon`: new export surface for the framework-neutral, Web-standard daemon handlers (`handleFsRequest`, `handleWatchSse`, `requireAdminJwt`, `createDecoAdminRoute`, `toNodeMiddleware`, `createDecoWatcher`, `bindWatcherToChannel`, …). +- Per-group route toggles via `DecoAdminRouteOptions` on both adapters. Dev tooling (`/_watch`, `/fs/*`) defaults to off in production. + +### Changed +- The Next.js admin route layout documented in `docs/using-from-nextjs.md` was rewritten. The previous single-catchall recipe (`app/(deco-admin)/[...path]/route.ts`) intercepts non-admin requests and is incorrect — migrate to dedicated route files under escaped folder names. +- `src/tanstack/daemon/middleware.ts` now composes the Web-standard handlers from `src/node/daemon/` via `toNodeMiddleware`. Public signature unchanged; new optional `routes` field forwards per-group toggles. +- `ADMIN_COMPAT_VERSION` (formerly inline in `src/tanstack/daemon/middleware.ts`) is now `src/core/admin/version.ts`. Still pinned to the deco-cx/deco 1.177.x range — does NOT track `@decocms/start`'s own version. + +### Not changed +- `/volumes//files` (WebSocket) remains TanStack-only; calls through the Next adapter return 501. +``` + +- [ ] **Step 17.6: Commit changelog (if it exists)** + +```bash +git add CHANGELOG.md # only if the file exists and was modified +git commit -m "docs(changelog): record next adapter admin coverage release + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 17.7: Final visual diff against `origin/next`** + +Run: +```bash +git log --oneline origin/next..HEAD +git diff --stat origin/next..HEAD +``` +Expected: linear history of 17 commits (one per task), no stray files. + +--- + +## Acceptance criteria (mirrors the spec) + +- [ ] `ADMIN_COMPAT_VERSION` defined in `src/core/admin/version.ts` with pinning JSDoc and consumed by both adapters. +- [ ] `handleDecoHealthcheck` exported from `@decocms/start/next` and `@decocms/start/node/daemon`, returning `ADMIN_COMPAT_VERSION` with CORS. +- [ ] `handleDecoReadiness` exported from both adapters; 503/200 against `getRevision()`. +- [ ] `createDecoAdminRoute` and `createDecoAdminRouteHandlers` exported from `@decocms/start/next` with the full options shape. +- [ ] `createDaemonMiddleware` accepts the same options under `routes`; existing TanStack consumers see no behaviour change without supplying `routes`. +- [ ] `/_watch` (SSE) and `/fs/*` work in Next dev, are 404 in Next prod, and continue to work in TanStack dev. +- [ ] `/volumes//files` returns 501 from the Next adapter; unchanged on the TanStack side. +- [ ] `src/next/adminRoute.ts` JSDoc updated with the escaped-folder layout and the `%5F` / literal-`.` rules. +- [ ] `docs/using-from-nextjs.md` rewritten with the escaped-folder layout and the `createDecoAdminRouteHandlers` pattern. +- [ ] Tier-boundary check passes; `next/` does not import from `tanstack/`. +- [ ] Integration tests in place (TanStack daemon end-to-end; Next adapter probes, `/_watch`, `/fs/file/...`). +- [ ] Changelog entry (if applicable). diff --git a/docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md b/docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md new file mode 100644 index 00000000..7e959bfa --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-next-adapter-admin-coverage-design.md @@ -0,0 +1,319 @@ +# Next.js adapter — full admin protocol + shared daemon refactor + +**Status:** design, awaiting approval +**Author:** brainstorming session 2026-05-13 +**Scope:** close the gaps between `src/next/` and `src/tanstack/` admin coverage by refactoring the daemon to a Web-standard core shared by both adapters. + +## Problem + +Consumers wiring `@decocms/start@5.1.x` into Next 16 / App Router / Turbopack storefronts hit three gaps: + +1. **No hosting probes in the Next adapter.** `/_healthcheck` and `/_ready` exist only on the TanStack side (inline in `src/tanstack/daemon/middleware.ts`), so Next consumers have no version-pinned alive/ready signal for k8s, Cloud Run, or our own infra. +2. **Broken route-mounting guidance.** The JSDoc on `handleDecoAdminRoute` recommends `app/live/_meta/route.ts` and `app/.decofile/route.ts`. Next App Router treats `_folder` as private and excludes it from routing; `%2E…` is not decoded by Turbopack. The escape path is `app/live/%5Fmeta/route.ts` (literal `%5F`) and `app/.decofile/route.ts` (literal dot, not `%2E`). The current `docs/using-from-nextjs.md` proposes a single root-level catchall, which intercepts every non-root request and breaks any storefront with non-root pages. +3. **No `/watch` or `/fs/*` story.** The Vite/TanStack daemon ships an SSE file-change channel and a JSON-patch filesystem API, both required for admin's in-browser editor against a dev environment. Next consumers cannot run those today. + +Underneath these three is a structural problem: the daemon's handlers are Connect-style (`IncomingMessage`, `ServerResponse`, `next`) and live in `src/tanstack/daemon/`. The tier-boundary checker (`scripts/check-tier-boundaries.ts:138`) forbids `next/` from importing `tanstack/`. Any port to Next means either duplicating logic, violating tiers, or refactoring the shared core to a framework-neutral home. + +## Architectural decisions + +1. **Refactor the daemon's request handlers to Web-standard (`Request → Response`).** The Connect-style signatures are an artefact of plugging into Vite's middleware stack, which is the only place in our stack that doesn't speak Web-standard natively. Next App Router, TanStack production (Nitro/H3), and Cloudflare Workers all speak Web-standard directly. +2. **Place the shared core in a new `src/node/daemon/` tier.** Node-only because the handler bodies use `node:fs/promises` and `chokidar`. The public contract is Web-standard. Both `src/tanstack/daemon/` and `src/next/` consume it. `src/node/` is already an existing tier (today's `loadAllDecofileBlocks`); the tier-boundary checker does not treat `node/` as a restricted tier, so `next/ → node/` and `tanstack/ → node/` are both allowed. +3. **Ship a one-file Node-http adapter for Vite.** `src/node/daemon/nodeHttpAdapter.ts` converts a `(req: Request) => Promise` handler into Connect-style `(req, res, next)` middleware. One file, one caller (`src/tanstack/daemon/middleware.ts`). +4. **Pin the admin compatibility version explicitly.** The constant currently inline at `src/tanstack/daemon/middleware.ts:62-64` is intentionally decoupled from `package.json` — admin compares against deco-cx/deco's `1.177.x` range, not `@decocms/start`'s `5.x`. The new shared constant lives at `src/core/admin/version.ts` as `ADMIN_COMPAT_VERSION`, with a JSDoc explaining the pinning contract. +5. **Toggle daemon route groups via configuration.** Hosting probes, the admin protocol, and dev tooling each have their own enable flag. Dev tooling defaults to `NODE_ENV !== "production"`; the rest default on. A `enabled: false` master switch short-circuits everything to 404. Disabled routes return 404 (not 403/410) so callers cannot distinguish a disabled deploy from a deploy that never had the route. +6. **Volumes WebSocket stays TanStack-only.** `/volumes//files` requires `httpServer.on("upgrade")`, which Next App Router does not expose. Next's dispatcher returns 501 with a doc-link body for that path. + +## Tier and module layout + +``` +src/core/admin/ +├── version.ts NEW ADMIN_COMPAT_VERSION = "1.177.5" +└── readiness.ts NEW handleDecoReadiness(): Response + +src/node/daemon/ NEW TIER — Web-standard interfaces, Node-only bodies +├── jwt.ts verifyAdminJwt, tokenIsValid (moved verbatim from src/tanstack/daemon/auth.ts) +├── auth.ts requireAdminJwt(req, site): Promise +├── healthcheck.ts handleDecoHealthcheck(): Response (consumes ADMIN_COMPAT_VERSION) +├── fs.ts handleFsRequest(req, opts): Promise +├── watch.ts handleWatchSse(req, opts): Response +│ createBroadcastChannel(): { broadcast, subscribe } +├── watcher.ts createDecoWatcher(cwd): { watcher, close } +├── nodeHttpAdapter.ts toNodeMiddleware(handler): ConnectStyle +├── route.ts createDecoAdminRoute(opts): (req: Request) => Promise +└── index.ts + +src/tanstack/daemon/ SHRUNK — composes node/daemon handlers via toNodeMiddleware +├── middleware.ts createDaemonMiddleware: extended options object, internally uses createDecoAdminRoute +├── auth.ts re-exports from node/daemon/jwt + auth (back-compat) +├── volumes.ts UNCHANGED (TanStack-only, raw httpServer upgrade) +├── watch.ts thin shim re-exporting from node/daemon/watch (back-compat) +├── fs.ts thin shim re-exporting from node/daemon/fs (back-compat) +└── index.ts same surface as today + +src/next/ +├── adminRoute.ts handleDecoAdminRoute = createDecoAdminRoute() (default opts) +│ createDecoAdminRouteHandlers(opts) → { GET, POST } +│ decoAdminRouteHandlers = createDecoAdminRouteHandlers() +├── healthcheck.ts REMOVED from spec — handleDecoHealthcheck re-exported from index instead +└── index.ts re-exports handleDecoHealthcheck, handleDecoReadiness, createDecoAdminRoute* +``` + +## Public API + +### `@decocms/start/next` + +```ts +export interface DecoAdminRouteOptions { + enabled?: boolean; // default: true + healthcheck?: boolean; // default: true + readiness?: boolean; // default: true + adminProtocol?: boolean; // default: true + watch?: boolean; // default: NODE_ENV !== "production" + fs?: boolean; // default: NODE_ENV !== "production" + cwd?: string; // default: process.cwd() + site?: string; // required when any auth-gated group is enabled +} + +// Existing +export { loadCmsPage, buildMatcherContextFromNext, DecoPage } from "./..."; + +// Updated (signature unchanged, behavior extended) +export const handleDecoAdminRoute: (req: Request) => Promise; + +// New +export function createDecoAdminRoute(opts?: DecoAdminRouteOptions): (req: Request) => Promise; +export function createDecoAdminRouteHandlers(opts?: DecoAdminRouteOptions): { + GET: (req: Request) => Promise; + POST: (req: Request) => Promise; +}; +export const decoAdminRouteHandlers: { GET; POST }; // = createDecoAdminRouteHandlers() +export { handleDecoHealthcheck } from "../node/daemon/healthcheck"; +export { handleDecoReadiness } from "../core/admin/readiness"; +``` + +### `@decocms/start/tanstack/daemon` (unchanged surface, extended options) + +```ts +export interface DaemonOptions { + site: string; + server: { httpServer: HttpServer | null; watcher: { on(event, cb): void } }; + // NEW — same shape as DecoAdminRouteOptions minus `site` (already required above) + routes?: Omit; +} + +export function createDaemonMiddleware(opts: DaemonOptions): (req, res, next) => void; +``` + +### `@decocms/start/node/daemon` (new export, primarily internal but public for advanced consumers) + +```ts +export { handleDecoHealthcheck } from "./healthcheck"; +export { handleDecoReadiness } from "../../core/admin/readiness"; +export { requireAdminJwt } from "./auth"; +export { verifyAdminJwt, tokenIsValid } from "./jwt"; +export type { JwtPayload } from "./jwt"; +export { handleFsRequest } from "./fs"; +export { handleWatchSse, createBroadcastChannel, broadcastFsEvent } from "./watch"; +export { createDecoWatcher } from "./watcher"; +export { createDecoAdminRoute } from "./route"; +export { toNodeMiddleware } from "./nodeHttpAdapter"; +``` + +## Route dispatch + +`createDecoAdminRoute(opts)` returns a single `(req: Request) => Promise` that: + +1. Parses `new URL(req.url).pathname`. +2. If `opts.enabled === false` → return 404. +3. Matches path against group entries (in declaration order). For each matched group, if the group's flag is `false` → return 404. +4. Calls the matched handler with the original `Request` plus any captured params. +5. If no group matches → return 404. + +**Path table:** + +| Path | Group | Method | Handler | +|------|-------|--------|---------| +| `/_healthcheck` | `healthcheck` | GET | `handleDecoHealthcheck()` | +| `/_ready` | `readiness` | GET | `handleDecoReadiness()` | +| `/live/_meta` | `adminProtocol` | GET, POST | `handleMeta` | +| `/.decofile` | `adminProtocol` | GET (read), POST (reload) | `handleDecofileRead` / `handleDecofileReload` | +| `/deco/render`, `/live/previews/<...>` | `adminProtocol` | * | `handleRender` | +| `/deco/invoke`, `/deco/invoke/<...>` | `adminProtocol` | * | `handleInvoke` | +| `/watch` | `watch` | GET | `handleWatchSse` | +| `/fs/` | `fs` | GET, PATCH, DELETE | `handleFsRequest` | +| `/volumes//files` | `adminProtocol` | * | Next: 501; TanStack: existing `volumes` handler | + +## Authentication + +The admin protocol core handlers (`handleMeta`, `handleDecofileRead`, `handleDecofileReload`, `handleRender`, `handleInvoke`) authenticate internally today — that behaviour is preserved. The new groups gain explicit auth: + +- `/_healthcheck` and `/_ready` — **no auth** (hosting probes). +- `/_watch` and `/fs/*` — **auth gated** via `requireAdminJwt(req, opts.site)` before the handler runs. `DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true` bypasses, matching the existing daemon behaviour. + +When a group's auth-required handler is invoked without `opts.site` configured, `createDecoAdminRoute` throws at construction time (not at request time) so misconfiguration is caught at boot. + +## Handler contracts (Web-standard) + +```ts +// healthcheck.ts +export function handleDecoHealthcheck(): Response; +// 200 text/plain ADMIN_COMPAT_VERSION + CORS + +// readiness.ts (core/admin/) +export function handleDecoReadiness(): Response; +// 200 text/plain "ready" when getRevision() !== null +// 503 text/plain "not ready" when getRevision() === null +// No CORS (intra-cluster probes don't need it). + +// auth.ts (node/daemon/) +export async function requireAdminJwt(req: Request, site: string): Promise; +// returns Response (401/403) to short-circuit; null to continue +// honors DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true bypass + +// fs.ts +export interface FsHandlerOpts { cwd: string } +export async function handleFsRequest(req: Request, opts: FsHandlerOpts): Promise; +// GET /fs/file/ → 200 with file body +// PATCH /fs/file/ → applies JSON-patch ops, returns 200 with updated body +// DELETE /fs/file/ → 204 +// Refuses traversal (resolved path outside cwd → 400). + +// watch.ts +export interface WatchSseOpts { + channel: { subscribe(listener): () => void }; +} +export function handleWatchSse(req: Request, opts: WatchSseOpts): Response; +// 200 text/event-stream — emits initial fs-sync then forwards channel events +// closes on req.signal abort + +// watcher.ts +export function createDecoWatcher(cwd: string): { + watcher: chokidar.FSWatcher; + close: () => Promise; +}; +``` + +### Next-side watcher lifecycle + +Module-level lazy singleton in `src/node/daemon/route.ts`: + +```ts +let watcherSingleton: ReturnType | null = null; +function getWatcherIfEnabled(opts: DecoAdminRouteOptions): { channel } | null { + if (!opts.watch && !opts.fs) return null; + if (process.env.NODE_ENV === "production") return null; + if (!watcherSingleton) { + watcherSingleton = createDecoWatcher(opts.cwd ?? process.cwd()); + bindToChannel(watcherSingleton.watcher, sharedChannel); + } + return { channel: sharedChannel }; +} +``` + +The watcher is created on first request to `/watch` or `/fs/*` and lives for the process lifetime. In production builds, no watcher is ever created, even if a consumer mounts the route files. + +### TanStack-side watcher lifecycle (unchanged) + +`createDaemonMiddleware` continues to receive Vite's existing watcher via `opts.server.watcher` and binds it to the shared channel. No new chokidar instance is created on the TanStack path. + +## Route layout for Next consumers + +The current `docs/using-from-nextjs.md` instructs consumers to mount one catch-all at `app/(deco-admin)/[...path]/route.ts`. This is broken: the catchall intercepts every non-root request in the app, and `handleDecoAdminRoute` returns 404 for non-admin paths instead of falling through to a page renderer. Storefronts with pages at `/products`, `/cart`, etc. lose those routes. + +The replacement is dedicated route handlers under escaped folder names: + +``` +app/ +├── %5Fhealthcheck/route.ts +├── %5Fready/route.ts +├── %5Fwatch/route.ts +├── .decofile/route.ts (literal dot — Turbopack does not decode %2E) +├── live/ +│ ├── %5Fmeta/route.ts +│ └── previews/[[...path]]/route.ts +├── deco/ +│ ├── render/route.ts +│ └── invoke/[[...path]]/route.ts +└── fs/file/[[...path]]/route.ts +``` + +Each consumer file is two lines: + +```ts +// app/%5Fhealthcheck/route.ts +export const dynamic = "force-dynamic"; +export { GET } from "@decocms/start/next/handlers/healthcheck"; +``` + +Or, for consumers wanting a single configuration point: + +```ts +// app/lib/deco-admin.ts +import { createDecoAdminRouteHandlers } from "@decocms/start/next"; +export const { GET, POST } = createDecoAdminRouteHandlers({ + site: "my-site", + watch: false, // never serve SSE from this app +}); + +// app/%5Fhealthcheck/route.ts (and every other route file) +export const dynamic = "force-dynamic"; +export { GET, POST } from "@/lib/deco-admin"; +``` + +The dedicated-route-file pattern is documented in detail in `docs/using-from-nextjs.md` as part of this change. Pre-baked one-line exports for each route are not shipped from the package (consumers vary in how they want to instantiate `createDecoAdminRouteHandlers`); the documented pattern uses a single configuration module. + +## Configuration defaults + +| Option | Default | Production behaviour | +|--------|---------|----------------------| +| `enabled` | `true` | unchanged | +| `healthcheck` | `true` | on | +| `readiness` | `true` | on | +| `adminProtocol` | `true` | on (admin can preview/save against the live site) | +| `watch` | `NODE_ENV !== "production"` | off | +| `fs` | `NODE_ENV !== "production"` | off | +| `cwd` | `process.cwd()` | unchanged | + +`adminProtocol: true` in production is intentional — admin previewing/editing the live site is a legitimate workflow. Consumers wanting a fully sealed production deploy pass `adminProtocol: false`. + +## Testing + +- `src/node/daemon/*.test.ts` (NEW): each Web-standard handler tested in isolation with `new Request(...)`. Covers healthcheck CORS, readiness 200/503, auth 401/403/bypass, fs read/patch/delete + traversal refusal, watch SSE emits initial sync and reacts to channel events. +- `src/core/admin/readiness.test.ts` (NEW): pre- and post-`setBlocks` states. +- `src/core/admin/version.test.ts` (NEW): sanity-checks `ADMIN_COMPAT_VERSION` is a non-empty semver string. +- `src/tanstack/daemon/middleware.test.ts` (NEW): integration test exercising `createDaemonMiddleware` end-to-end via a real Node httpServer — ensures the existing Connect-style dispatch still routes via the new core handlers. +- `src/next/adminRoute.test.ts` (EXTENDED): `/_healthcheck` returns version, `/_ready` returns 503 then 200 after `setBlocks`, `/_watch` returns 404 when `NODE_ENV === "production"` (disabled-route convention), `/fs/file/...` returns 401 without auth and 200 with `DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true`. +- Tier-boundary check (`scripts/check-tier-boundaries.ts`) continues to pass: `next/` imports from `core/` and `node/` only; `tanstack/` imports from `core/` and `node/` only; neither imports from the other. + +## Documentation + +- `docs/using-from-nextjs.md` rewrite: + - Remove the broken root catchall recipe. + - Add the escaped-folder layout above. + - Document the `createDecoAdminRouteHandlers` config pattern. + - Explain the `%5F` and literal-`.` escape rules and link the Next/Turbopack docs. +- `src/next/adminRoute.ts` JSDoc rewrite: replace the wrong `app/live/_meta/route.ts` example with `app/live/%5Fmeta/route.ts`, and add the escape-rule paragraph. +- Changelog entry calling out: (a) the new exports, (b) additive at the source level — no symbols renamed or removed; consumers following the previously documented (broken) catchall recipe must migrate to dedicated route files, (c) the new `node/daemon/` public path for advanced use. + +## Acceptance criteria + +- [ ] `ADMIN_COMPAT_VERSION` defined in `src/core/admin/version.ts` with pinning JSDoc; consumed by both `src/node/daemon/healthcheck.ts` and the TanStack daemon (replacing the inline constant). +- [ ] `handleDecoHealthcheck` exported from `@decocms/start/next` and `@decocms/start/node/daemon`, returning `ADMIN_COMPAT_VERSION` with CORS headers. +- [ ] `handleDecoReadiness` exported from both adapters; 200 `"ready"` once `getRevision() !== null`, 503 `"not ready"` otherwise. +- [ ] `createDecoAdminRoute` and `createDecoAdminRouteHandlers` exported from `@decocms/start/next` with the full `DecoAdminRouteOptions` shape and documented defaults. +- [ ] `createDaemonMiddleware` accepts the same `DecoAdminRouteOptions` (under `routes`) and internally composes the shared Web-standard handlers via `toNodeMiddleware`. Existing TanStack consumers see no behaviour change without supplying `routes`. +- [ ] `/watch` (SSE) and `/fs/*` work in Next dev (`NODE_ENV !== "production"`), are 404 in Next prod, and continue to work in TanStack dev via the existing Vite middleware path. +- [ ] `/volumes//files` returns 501 with a documented body when hit through the Next adapter; unchanged on the TanStack side. +- [ ] `src/next/adminRoute.ts` JSDoc updated with the escaped-folder layout and the `%5F` / literal-`.` rules. +- [ ] `docs/using-from-nextjs.md` rewritten with the escaped-folder layout and the `createDecoAdminRouteHandlers` pattern. +- [ ] Tier-boundary check passes; `next/` does not import from `tanstack/`. +- [ ] Integration tests: TanStack daemon end-to-end via Node httpServer; Next adapter `/_healthcheck`, `/_ready`, `/fs/file/...` with and without auth, `/_watch` returns 404 in production. +- [ ] Changelog entry shipped. + +## Out of scope + +- Porting `/volumes//files` (WebSocket) to Next. Documented as TanStack-only; Next returns 501. +- A scaffolding CLI that writes the six dedicated route files into `app/`. Documented for now; revisit if a consumer hits the boilerplate. +- A separate sidecar process or `deco-dev-daemon` CLI. Rejected in favour of in-process route handlers. +- Production-time SSE/FS. The daemon is dev-only by design. +- Lifting `ADMIN_COMPAT_VERSION` from `package.json`. Intentionally pinned to the deco-cx/deco range, decoupled from `@decocms/start` versions. diff --git a/docs/using-from-nextjs.md b/docs/using-from-nextjs.md index 91dfe8f6..0d495ad2 100644 --- a/docs/using-from-nextjs.md +++ b/docs/using-from-nextjs.md @@ -6,16 +6,18 @@ ```bash bun add @decocms/start -# Required peer dependencies (you almost certainly already have these in a Next 15 app) +# Required peer dependencies (you almost certainly already have these in a Next 15/16 app) bun add next@^15 react@^19 react-dom@^19 ``` -`tsconfig.json` must use `moduleResolution: "bundler"` (the Next 15 default). +`tsconfig.json` must use `moduleResolution: "bundler"` (the Next 15+ default). ## Configure No `transpilePackages` in `next.config.js` is needed — the package ships compiled JavaScript. +Set `DECO_SITE=` in your environment so the admin protocol routes can validate JWTs from `admin.deco.cx`. + ## Render a CMS page from a route ```tsx @@ -34,12 +36,11 @@ export default async function Page() { const result = await loadCmsPage(req); if (!result) notFound(); - // Render result.resolvedSections via your component map. return ; } ``` -To populate `x-url` / `x-pathname`, install a Next.js middleware: +Populate `x-url` / `x-pathname` from a Next middleware: ```ts // middleware.ts @@ -55,15 +56,103 @@ export const config = { matcher: ["/((?!_next).*)"] }; ## Wire admin protocol routes -The Deco admin UI talks to your storefront via `/live/_meta`, `/.decofile`, `/live/previews/*`, and `/deco/invoke/*`. Expose them with a single catch-all: +The Deco admin UI talks to your storefront via: + +- `/_healthcheck`, `/_ready` — hosting probes +- `/live/_meta`, `/.decofile`, `/live/previews/*`, `/deco/render`, `/deco/invoke/*` — admin protocol +- `/_watch`, `/fs/*` — dev-time admin editor (auto-disabled in production) + +**Do not mount a single root-level catchall.** Earlier versions of these docs recommended `app/(deco-admin)/[...path]/route.ts`, which intercepts every non-root request in your app and breaks any storefront with pages at `/products`, `/cart`, etc. Use dedicated route files instead. + +### One config module + dedicated route files + +App Router treats `_folder` as a *private folder* and excludes it from routing, so daemon paths starting with `_` need to be escaped in the folder name. Turbopack does not URL-decode `%2E`, so `.`-prefixed folders must use a literal dot. The exact layout that works: + +``` +app/ +├── lib/ +│ └── deco-admin.ts +├── %5Fhealthcheck/route.ts +├── %5Fready/route.ts +├── %5Fwatch/route.ts +├── .decofile/route.ts (literal . — NOT %2E) +├── live/ +│ ├── %5Fmeta/route.ts +│ └── previews/[[...path]]/route.ts +├── deco/ +│ ├── render/route.ts +│ └── invoke/[[...path]]/route.ts +└── fs/file/[[...path]]/route.ts +``` + +Instantiate the dispatcher once: + +```ts +// app/lib/deco-admin.ts +import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + +export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + site: "my-site", + // Optional — defaults shown: + // enabled: true + // healthcheck: true + // readiness: true + // adminProtocol: true + // watch: NODE_ENV !== "production" + // fs: NODE_ENV !== "production" + // cwd: process.cwd() +}); +``` + +Then every route file is two lines: + +```ts +// app/%5Fhealthcheck/route.ts (and every other route file above) +export const dynamic = "force-dynamic"; +export { GET, POST, PATCH, DELETE } from "@/lib/deco-admin"; +``` + +`PATCH` and `DELETE` are required by `/fs/file/*` (admin's edit-and-save flow). They're harmless to re-export from read-only routes — the dispatcher branches on method internally — so a single set works everywhere. + +### Per-request setup hook + +Some setups need to run something before every dispatched admin request — most commonly hydrating the block registry from disk if `setBlocks` is async. Pass `onRequest`; it runs once per request, after the master `enabled` check, before pathname dispatch: ```ts -// app/(deco-admin)/[...path]/route.ts -import { handleDecoAdminRoute } from "@decocms/start/next"; -export const GET = handleDecoAdminRoute; -export const POST = handleDecoAdminRoute; +export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + site: "my-site", + onRequest: () => ensureSetup(), // awaited before any handler runs +}); +``` + +Return `undefined` (the default) to continue. Return a `Response` to short-circuit — useful for custom auth gates, maintenance-mode banners, or any early-out the consumer needs in front of the daemon's own dispatch: + +```ts +export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + site: "my-site", + onRequest: (req) => { + if (process.env.MAINTENANCE === "1") { + return new Response("under maintenance", { status: 503 }); + } + }, +}); ``` +### Disabling specific routes + +Each group has its own flag: + +```ts +export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + site: "my-site", + watch: false, // disable dev-time SSE even in dev + fs: false, // disable dev-time filesystem REST + adminProtocol: false, // disable admin editing entirely against this deploy +}); +``` + +Disabled groups return 404 — callers cannot distinguish a disabled deploy from one that never had the route. + ## Register sections At app boot (before any request handler runs): @@ -82,10 +171,9 @@ registerSectionsSync({ Import this from `app/layout.tsx` (or any module that runs at boot) so it executes before any page renders. -`registerSectionsSync` populates both the sync component cache AND the lazy-loader registry (so `getSection()` finds sync-registered sections — see issue #163 gotcha #1). - ## Limitations - App Router only. Pages Router is not supported. +- `/volumes//files` (WebSocket) is **not** supported — it requires `httpServer.on("upgrade")`, which Next App Router does not expose. Calls to that path return 501. Use the TanStack/Vite daemon if you need volumes. - The minimal `DecoPage` server component is a starting point; production renderers should provide their own. - `@decocms/start/next/client` exports only `useDevice` and `signal`. The TanStack-specific hooks (`LiveControls`, `LazySection`) are not yet ported. diff --git a/package.json b/package.json index af8fe5ce..e2fc0ed4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "import": "./dist/node/index.js", "require": "./dist/node/index.cjs" }, + "./node/daemon": { + "types": "./dist/node/daemon/index.d.ts", + "import": "./dist/node/daemon/index.js", + "require": "./dist/node/daemon/index.cjs" + }, "./cms": { "types": "./dist/core/cms/index.d.ts", "import": "./dist/core/cms/index.js", diff --git a/scripts/dev-link-into.sh b/scripts/dev-link-into.sh new file mode 100755 index 00000000..9d3b6830 --- /dev/null +++ b/scripts/dev-link-into.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# dev-link-into.sh — rebuild this repo's dist/ and overlay it into a consumer's +# node_modules. Workaround for Turbopack ignoring subpath exports through +# symlinks (see CONTRIBUTING.md → "Testing against a real consumer"). +# +# Usage: +# ./scripts/dev-link-into.sh +# +# Example: +# ./scripts/dev-link-into.sh ~/code/my-storefront +# +# Safe to re-run; the target dist/ is rm'd before the copy. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +CONSUMER="$1" +SOURCE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_DIR="$CONSUMER/node_modules/@decocms/start/dist" + +if [[ ! -d "$CONSUMER" ]]; then + echo "error: consumer path does not exist: $CONSUMER" >&2 + exit 1 +fi + +if [[ ! -d "$CONSUMER/node_modules/@decocms/start" ]]; then + echo "error: @decocms/start is not installed in $CONSUMER" >&2 + echo " run 'bun install' (or your package manager) in the consumer first." >&2 + exit 1 +fi + +echo "→ building @decocms/start in $SOURCE_ROOT" +(cd "$SOURCE_ROOT" && bun run build) + +echo "→ overlaying $SOURCE_ROOT/dist into $TARGET_DIR" +rm -rf "$TARGET_DIR" +cp -R "$SOURCE_ROOT/dist" "$TARGET_DIR" + +echo "✓ done. Restart the consumer's dev server to pick up the change." diff --git a/src/core/admin/index.ts b/src/core/admin/index.ts index 8e8b0d12..863288f3 100644 --- a/src/core/admin/index.ts +++ b/src/core/admin/index.ts @@ -28,3 +28,5 @@ export { registerMatcherSchemas, } from "./schema"; export { getRenderShellConfig } from "./setup"; +export { ADMIN_COMPAT_VERSION } from "./version"; +export { handleDecoReadiness } from "./readiness"; diff --git a/src/core/admin/readiness.test.ts b/src/core/admin/readiness.test.ts new file mode 100644 index 00000000..6f2a373c --- /dev/null +++ b/src/core/admin/readiness.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setBlocks } from "../cms/loader"; +import { handleDecoReadiness } from "./readiness"; + +describe("handleDecoReadiness", () => { + // Tests share globalThis-backed block state; isolate by resetting. + beforeEach(() => { + const g = globalThis as any; + if (g.__deco) { + g.__deco.blockData = undefined; + g.__deco.revision = undefined; + } + }); + afterEach(() => { + const g = globalThis as any; + if (g.__deco) { + g.__deco.blockData = undefined; + g.__deco.revision = undefined; + } + }); + + it("returns 503 'not ready' before setBlocks() has run", async () => { + const res = handleDecoReadiness(); + expect(res.status).toBe(503); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(await res.text()).toBe("not ready"); + }); + + it("returns 200 'ready' after setBlocks() has populated the registry", async () => { + setBlocks({ "/": { name: "home" } }); + const res = handleDecoReadiness(); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ready"); + }); +}); diff --git a/src/core/admin/readiness.ts b/src/core/admin/readiness.ts new file mode 100644 index 00000000..c9932ec0 --- /dev/null +++ b/src/core/admin/readiness.ts @@ -0,0 +1,17 @@ +import { getRevision } from "../cms/loader"; + +/** + * Web-standard readiness probe. Returns 200 once `setBlocks()` has been called + * at least once (the block registry is hydrated and the storefront can serve + * resolved pages), 503 otherwise. + * + * Suitable for k8s readinessProbe / Cloud Run health checks / our own infra. + * Intentionally no CORS — readiness probes are intra-cluster. + */ +export function handleDecoReadiness(): Response { + const ready = getRevision() !== null; + return new Response(ready ? "ready" : "not ready", { + status: ready ? 200 : 503, + headers: { "Content-Type": "text/plain" }, + }); +} diff --git a/src/core/admin/version.test.ts b/src/core/admin/version.test.ts new file mode 100644 index 00000000..70a0faab --- /dev/null +++ b/src/core/admin/version.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "./version"; + +describe("ADMIN_COMPAT_VERSION", () => { + it("is a non-empty semver string", () => { + expect(ADMIN_COMPAT_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("is pinned to the deco-cx/deco 1.177.x compatibility range", () => { + // This pin must NOT track @decocms/start's own version. Admin compares + // against deco-cx/deco's range, so changing the major/minor here is a + // breaking change for admin compatibility — bump deliberately. + expect(ADMIN_COMPAT_VERSION.startsWith("1.177.")).toBe(true); + }); +}); diff --git a/src/core/admin/version.ts b/src/core/admin/version.ts new file mode 100644 index 00000000..07d61d56 --- /dev/null +++ b/src/core/admin/version.ts @@ -0,0 +1,10 @@ +/** + * Version reported to admin.deco.cx by `/_healthcheck` and similar probes. + * + * **Pinning contract:** this constant must NOT track `@decocms/start`'s own + * version (currently 5.x). Admin compares the returned value against + * `deco-cx/deco`'s release range (currently 1.177.x). Bumping it shifts the + * admin compatibility window — change deliberately and document in the + * release notes when you do. + */ +export const ADMIN_COMPAT_VERSION = "1.177.5"; diff --git a/src/next/adminRoute.test.ts b/src/next/adminRoute.test.ts index e174c539..ba81b59b 100644 --- a/src/next/adminRoute.test.ts +++ b/src/next/adminRoute.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { handleDecoAdminRoute } from "./adminRoute"; +beforeAll(() => { process.env.DECO_SITE = "my-site"; }); +afterAll(() => { delete process.env.DECO_SITE; }); + describe("handleDecoAdminRoute", () => { it("returns 404 for non-admin paths", async () => { const req = new Request("http://t/some/random/path"); @@ -15,3 +18,37 @@ describe("handleDecoAdminRoute", () => { expect(res).toBeInstanceOf(Response); }); }); + +describe("handleDecoAdminRoute — extended dispatch", () => { + const originalNodeEnv = process.env.NODE_ENV; + beforeEach(() => { + delete process.env.NODE_ENV; + process.env.DECO_SITE = "my-site"; + }); + afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + delete process.env.DECO_SITE; + }); + + it("serves /_healthcheck", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + }); + + it("serves /_ready", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/_ready")); + expect([200, 503]).toContain(res.status); + }); + + it("returns 501 for /volumes//files", async () => { + const res = await handleDecoAdminRoute(new Request("http://t/volumes/abc/files")); + expect(res.status).toBe(501); + }); + + it("disables /_watch in production", async () => { + process.env.NODE_ENV = "production"; + const res = await handleDecoAdminRoute(new Request("http://t/_watch")); + expect(res.status).toBe(404); + }); +}); diff --git a/src/next/adminRoute.ts b/src/next/adminRoute.ts index 0b932206..b4730a1a 100644 --- a/src/next/adminRoute.ts +++ b/src/next/adminRoute.ts @@ -1,40 +1,62 @@ -import { handleDecofileRead, handleDecofileReload } from "../core/admin/decofile"; -import { handleInvoke } from "../core/admin/invoke"; -import { handleMeta } from "../core/admin/meta"; -import { handleRender } from "../core/admin/render"; +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../node/daemon/route"; /** - * Dispatch a Next.js App Router request to the appropriate Deco admin handler. + * Dispatch a Next.js App Router request to the appropriate Deco daemon handler. * - * Wire as both GET and POST in `app/[[...catchall]]/route.ts` (or in dedicated - * route handlers under `app/live/_meta/route.ts`, `app/.decofile/route.ts`, - * etc.). This adapter layer just inspects `request.url` and delegates to the - * core handler for the matching path. CORS is the caller's responsibility. + * Mount as both GET and POST in dedicated route files under your `app/` tree. + * Next App Router needs escaped folder names because it treats `_folder` as + * private and excludes it from routing: * - * Returns a 404 `Response` for non-admin paths so the caller can fall through - * to its normal page rendering. + * app/ + * ├── %5Fhealthcheck/route.ts (literal %5F — Next/Turbopack do not URL-decode this) + * ├── %5Fready/route.ts + * ├── %5Fwatch/route.ts + * ├── .decofile/route.ts (literal `.`, not %2E — Turbopack does not decode %2E) + * ├── live/ + * │ ├── %5Fmeta/route.ts + * │ └── previews/[[...path]]/route.ts + * ├── deco/ + * │ ├── render/route.ts + * │ └── invoke/[[...path]]/route.ts + * └── fs/file/[[...path]]/route.ts + * + * Each route file is two lines: + * + * export const dynamic = "force-dynamic"; + * export { GET, POST, PATCH, DELETE } from "@/lib/deco-admin"; // ← your config module + * + * Where `@/lib/deco-admin` instantiates a single configuration: + * + * import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + * export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + * site: "my-site", + * }); + * + * (PATCH and DELETE are required by `/fs/file/*`; harmless to re-export from + * read-only routes — the dispatcher branches on method internally.) + * + * For one-off mounting without a config module, `handleDecoAdminRoute` is the + * pre-instantiated default. It reads `DECO_SITE` from the environment for JWT + * validation; if you need richer options, use `createDecoAdminRoute` or + * `createDecoAdminRouteHandlers`. + * + * Disabled groups return 404 (looks like the route doesn't exist). + * `/volumes//files` returns 501 — the WebSocket flow is TanStack-only. */ -export async function handleDecoAdminRoute(req: Request): Promise { - const url = new URL(req.url); - const { pathname } = url; - const method = req.method; - - if (pathname === "/live/_meta") { - return handleMeta(req); - } - - if (pathname === "/.decofile") { - if (method === "POST") return await handleDecofileReload(req); - return handleDecofileRead(); - } - - if (pathname === "/deco/render" || pathname.startsWith("/live/previews/")) { - return await handleRender(req); - } - if (pathname === "/deco/invoke" || pathname.startsWith("/deco/invoke/")) { - return await handleInvoke(req); - } +// Lazy construction so a consumer importing this module without +// `DECO_SITE` set yet (e.g. before .env load completes in some setups) +// does not crash at import time — the auth-gated `adminProtocol` group +// defaults on and would throw from `createDecoAdminRoute`. +// +// NOTE: The handler is created per-call (not cached) so that callers +// starting up before their .env is loaded still work: the first *request* +// sees the fully-populated environment, not the module-init snapshot. +export const handleDecoAdminRoute: (req: Request) => Promise = (req) => + createDecoAdminRoute({ site: process.env.DECO_SITE })(req); - return new Response("Not Found", { status: 404 }); -} +export { createDecoAdminRoute }; +export type { DecoAdminRouteOptions }; diff --git a/src/next/index.ts b/src/next/index.ts index 8f3b7799..c143897f 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -5,5 +5,20 @@ */ export { loadCmsPage } from "./loadCmsPage"; export { buildMatcherContextFromNext } from "./ctx"; -export { handleDecoAdminRoute } from "./adminRoute"; +export { + createDecoAdminRoute, + handleDecoAdminRoute, + type DecoAdminRouteOptions, +} from "./adminRoute"; +export { + createDecoAdminRouteHandlers, + decoAdminRouteHandlers, + type DecoAdminRouteHandlers, +} from "./routeHandlers"; export { DecoPage } from "./DecoPage"; + +// Probe handlers — re-exported so consumers can mount a single route file +// without the full dispatcher. +export { handleDecoHealthcheck } from "../node/daemon/healthcheck"; +export { handleDecoReadiness } from "../core/admin/readiness"; +export { ADMIN_COMPAT_VERSION } from "../core/admin/version"; diff --git a/src/next/routeHandlers.test.ts b/src/next/routeHandlers.test.ts new file mode 100644 index 00000000..9b0f40d7 --- /dev/null +++ b/src/next/routeHandlers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createDecoAdminRouteHandlers, decoAdminRouteHandlers } from "./routeHandlers"; + +describe("createDecoAdminRouteHandlers", () => { + it("returns identical handlers for GET and POST that delegate to the dispatcher", async () => { + const { GET, POST } = createDecoAdminRouteHandlers({ site: "my-site" }); + const a = await GET(new Request("http://t/_healthcheck")); + const b = await POST(new Request("http://t/_healthcheck")); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + }); + + it("returns the same handler reference for all four HTTP methods", () => { + // Smoke test — the dispatcher branches on method internally, so all + // method exports share a single function reference. If this regresses, + // the /fs/file/* mutating routes will silently fall through to 405. + const handlers = createDecoAdminRouteHandlers({ site: "my-site" }); + expect(handlers.PATCH).toBe(handlers.GET); + expect(handlers.DELETE).toBe(handlers.GET); + expect(handlers.POST).toBe(handlers.GET); + }); + + it("decoAdminRouteHandlers is the default-options instance", async () => { + process.env.DECO_SITE = "my-site"; + const res = await decoAdminRouteHandlers.GET(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + delete process.env.DECO_SITE; + }); + + it("decoAdminRouteHandlers exposes PATCH and DELETE on the default instance", () => { + expect(typeof decoAdminRouteHandlers.PATCH).toBe("function"); + expect(typeof decoAdminRouteHandlers.DELETE).toBe("function"); + }); +}); diff --git a/src/next/routeHandlers.ts b/src/next/routeHandlers.ts new file mode 100644 index 00000000..7c1d0ae4 --- /dev/null +++ b/src/next/routeHandlers.ts @@ -0,0 +1,68 @@ +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../node/daemon/route"; + +export interface DecoAdminRouteHandlers { + GET: (req: Request) => Promise; + POST: (req: Request) => Promise; + PATCH: (req: Request) => Promise; + DELETE: (req: Request) => Promise; +} + +/** + * Build a `{ GET, POST, PATCH, DELETE }` quartet suitable for one-line `export` + * from every App Router route file under your `app/` tree. Instantiate once in + * a shared module and re-export from each route file — the same set works for + * read-only routes (`/_healthcheck`, `/live/_meta`) and the mutating + * `/fs/file/*` flows alike, because the dispatcher branches on method + * internally. + * + * @example + * // app/lib/deco-admin.ts + * import { createDecoAdminRouteHandlers } from "@decocms/start/next"; + * export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + * site: "my-site", + * }); + * + * // app/%5Fhealthcheck/route.ts (and every other route file) + * export const dynamic = "force-dynamic"; + * export { GET, POST, PATCH, DELETE } from "@/lib/deco-admin"; + * + * @example + * // Per-request setup (e.g. hydrate the block registry before any handler runs). + * // The onRequest hook runs once per request, before pathname dispatch. + * export const { GET, POST, PATCH, DELETE } = createDecoAdminRouteHandlers({ + * site: "my-site", + * onRequest: () => ensureSetup(), + * }); + */ +export function createDecoAdminRouteHandlers( + opts: DecoAdminRouteOptions = {}, +): DecoAdminRouteHandlers { + const handler = createDecoAdminRoute(opts); + return { GET: handler, POST: handler, PATCH: handler, DELETE: handler }; +} + +/** + * Pre-instantiated handlers using all defaults (reads `DECO_SITE` from env). + * Use this only for the simplest setup — most apps will call + * `createDecoAdminRouteHandlers` to lock options at the call site. + * + * Implemented lazily so importing this module without `DECO_SITE` set yet + * does not crash at import time. + */ +let _defaultHandlers: DecoAdminRouteHandlers | null = null; +function getDefaultHandlers(): DecoAdminRouteHandlers { + if (!_defaultHandlers) { + _defaultHandlers = createDecoAdminRouteHandlers({ site: process.env.DECO_SITE }); + } + return _defaultHandlers; +} + +export const decoAdminRouteHandlers: DecoAdminRouteHandlers = { + GET: (req) => getDefaultHandlers().GET(req), + POST: (req) => getDefaultHandlers().POST(req), + PATCH: (req) => getDefaultHandlers().PATCH(req), + DELETE: (req) => getDefaultHandlers().DELETE(req), +}; diff --git a/src/node/daemon/auth.test.ts b/src/node/daemon/auth.test.ts new file mode 100644 index 00000000..80dd4e5c --- /dev/null +++ b/src/node/daemon/auth.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requireAdminJwt } from "./auth"; + +describe("requireAdminJwt", () => { + const originalBypass = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + beforeEach(() => { + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + }); + afterEach(() => { + if (originalBypass === undefined) { + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + } else { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = originalBypass; + } + }); + + it("returns 401 Response when no token is present", async () => { + const req = new Request("http://t/fs/file/x"); + const res = await requireAdminJwt(req, "my-site"); + expect(res).toBeInstanceOf(Response); + expect(res?.status).toBe(401); + }); + + it("returns 401 Response when token is malformed", async () => { + const req = new Request("http://t/fs/file/x", { + headers: { authorization: "Bearer not-a-jwt" }, + }); + const res = await requireAdminJwt(req, "my-site"); + expect(res?.status).toBe(401); + }); + + it("returns null (pass) when DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true", async () => { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + const req = new Request("http://t/fs/file/x"); + const res = await requireAdminJwt(req, "my-site"); + expect(res).toBeNull(); + }); + + it("accepts a ?token= query param fallback", async () => { + // Token is malformed but exercises the extraction path. + const req = new Request("http://t/fs/file/x?token=abc"); + const res = await requireAdminJwt(req, "my-site"); + // Still 401 because token is invalid — but path was attempted. + expect(res?.status).toBe(401); + }); +}); diff --git a/src/node/daemon/auth.ts b/src/node/daemon/auth.ts new file mode 100644 index 00000000..5c552527 --- /dev/null +++ b/src/node/daemon/auth.ts @@ -0,0 +1,34 @@ +import { tokenIsValid, verifyAdminJwt } from "./jwt"; + +/** + * Web-standard JWT guard. Returns a Response (401/403) to short-circuit, or + * null to indicate the request is authorized and should continue. + * + * Honors the `DANGEROUSLY_ALLOW_PUBLIC_ACCESS=true` env bypass, matching + * the existing Connect-style `createAuthMiddleware` semantics. + */ +export async function requireAdminJwt( + req: Request, + site: string, +): Promise { + if (process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true") return null; + + const token = extractToken(req); + if (!token) return new Response(null, { status: 401 }); + + const jwt = await verifyAdminJwt(token); + if (!jwt) return new Response(null, { status: 401 }); + + if (!tokenIsValid(site, jwt)) return new Response(null, { status: 403 }); + return null; +} + +function extractToken(req: Request): string | null { + const auth = req.headers.get("authorization"); + if (auth) { + const parts = auth.split(/\s+/); + if (parts.length === 2) return parts[1]; + } + const url = new URL(req.url); + return url.searchParams.get("token"); +} diff --git a/src/node/daemon/fs.test.ts b/src/node/daemon/fs.test.ts new file mode 100644 index 00000000..d575d793 --- /dev/null +++ b/src/node/daemon/fs.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { handleFsRequest } from "./fs"; + +describe("handleFsRequest", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "deco-fs-test-")); + await mkdir(join(cwd, ".deco", "blocks"), { recursive: true }); + await writeFile( + join(cwd, ".deco", "blocks", "site.json"), + JSON.stringify({ greeting: "hello" }), + ); + }); + afterEach(() => rm(cwd, { recursive: true, force: true })); + + it("GET returns the file content with metadata + mtime", async () => { + const req = new Request("http://t/fs/file/.deco/blocks/site.json"); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(JSON.parse(body.content).greeting).toBe("hello"); + expect(body.timestamp).toBeGreaterThan(0); + }); + + it("GET returns 404 with a timestamp for a missing file", async () => { + const req = new Request("http://t/fs/file/.deco/missing.json"); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.timestamp).toBeGreaterThan(0); + }); + + it("rejects path traversal", async () => { + // URL normalization: new URL("http://t/fs/file/../../etc/passwd").pathname === "/etc/passwd" + // so the path never reaches /fs/file/* and gets a 404 — both 403 and 404 indicate rejection + const req = new Request("http://t/fs/file/../../etc/passwd"); + const res = await handleFsRequest(req, { cwd }); + expect([403, 404]).toContain(res.status); + }); + + it("PATCH applies a JSON patch", async () => { + const patch = { + type: "json" as const, + payload: [{ op: "replace", path: "/greeting", value: "hi" }], + }; + const req = new Request("http://t/fs/file/.deco/blocks/site.json", { + method: "PATCH", + body: JSON.stringify({ patch, timestamp: 0 }), + }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const after = JSON.parse(await readFile(join(cwd, ".deco/blocks/site.json"), "utf-8")); + expect(after.greeting).toBe("hi"); + }); + + it("DELETE removes the file", async () => { + const req = new Request("http://t/fs/file/.deco/blocks/site.json", { method: "DELETE" }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + await expect(readFile(join(cwd, ".deco/blocks/site.json"), "utf-8")).rejects.toThrow(); + }); + + it("PATCH with type:'json' against a non-JSON target returns 200 conflict, not 500", async () => { + // Repro from a Next consumer hitting PATCH /fs/file/README.md with a + // type:"json" patch. Pre-fix, JSON.parse of the on-disk plaintext threw + // SyntaxError out of applyPatch and bubbled to a 500 with a stack trace. + // Treat it as a soft conflict — the file's shape doesn't match the + // patch's expectations, but the server itself is healthy. + await writeFile(join(cwd, "README.md"), "This is a plaintext README\n"); + const patch = { type: "json" as const, payload: [] }; + const req = new Request("http://t/fs/file/README.md", { + method: "PATCH", + body: JSON.stringify({ patch, timestamp: 0 }), + }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conflict).toBe(true); + }); + + it("/fs/grep stub returns an empty matches array", async () => { + const req = new Request("http://t/fs/grep", { method: "POST" }); + const res = await handleFsRequest(req, { cwd }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ matches: [], totalMatches: 0 }); + }); +}); diff --git a/src/node/daemon/fs.ts b/src/node/daemon/fs.ts new file mode 100644 index 00000000..ac556103 --- /dev/null +++ b/src/node/daemon/fs.ts @@ -0,0 +1,196 @@ +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import fjp from "fast-json-patch"; +import type { Operation } from "fast-json-patch"; +import { broadcastFsEvent, inferMetadata, toPosixPath } from "./watch"; + +export interface FsHandlerOptions { + /** Filesystem root that path resolutions must stay within. */ + cwd: string; +} + +interface Patch { + type: "json" | "text"; + payload: Operation[]; +} + +/** + * Web-standard `/fs/*` handler. Implements GET/PATCH/DELETE on + * `/fs/file/` and a `/fs/grep` stub used by the admin search UI. + */ +export async function handleFsRequest( + req: Request, + opts: FsHandlerOptions, +): Promise { + const url = new URL(req.url); + const { pathname } = url; + + if (pathname === "/fs/grep") { + return jsonResponse(200, { matches: [], totalMatches: 0 }); + } + + if (!pathname.startsWith("/fs/file")) { + return new Response(null, { status: 404 }); + } + + const filePath = extractFilePath(pathname); + const systemPath = safePath(opts.cwd, filePath); + if (!systemPath) return jsonResponse(403, { error: "Path traversal denied" }); + + if (req.method === "GET") return getFile(systemPath); + if (req.method === "PATCH") return patchFile(req, opts.cwd, systemPath); + if (req.method === "DELETE") return deleteFile(opts.cwd, systemPath); + return new Response(null, { status: 405 }); +} + +function safePath(cwd: string, untrusted: string): string | null { + const resolved = resolve(cwd, untrusted.startsWith("/") ? `.${untrusted}` : untrusted); + if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return null; + return resolved; +} + +function extractFilePath(url: string): string { + const [, ...segments] = url.split("/file"); + return segments.join("/file") || "/"; +} + +async function mtimeFor(filepath: string): Promise { + try { + return (await stat(filepath)).mtimeMs; + } catch { + return Date.now(); + } +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function getFile(systemPath: string): Promise { + try { + const [content, metadata, timestamp] = await Promise.all([ + readFile(systemPath, "utf-8"), + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + return jsonResponse(200, { content, metadata, timestamp }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return jsonResponse(404, { timestamp: Date.now() }); + } + throw err; + } +} + +function applyPatch( + content: string | null, + patch: Patch, +): { conflict: boolean; content?: string } { + if (patch.type === "json") { + // The on-disk content must itself be valid JSON for a JSON patch to apply. + // Surface a non-JSON file as a soft conflict (the shape doesn't match the + // patch's expectations) rather than letting SyntaxError bubble to a 500. + let base: unknown; + try { + base = JSON.parse(content ?? "{}"); + } catch { + return { conflict: true }; + } + try { + const result = patch.payload.reduce(fjp.applyReducer, base); + return { conflict: false, content: JSON.stringify(result, null, 2) }; + } catch (err: unknown) { + if (err instanceof fjp.JsonPatchError && err.name === "TEST_OPERATION_FAILED") { + return { conflict: true }; + } + throw err; + } + } + if (patch.type === "text") { + try { + const result = patch.payload.reduce( + fjp.applyReducer, + content?.split("\n") ?? [], + ); + return { conflict: false, content: (result as string[]).join("\n") }; + } catch (err: unknown) { + if (err instanceof fjp.JsonPatchError && err.name === "TEST_OPERATION_FAILED") { + return { conflict: true }; + } + throw err; + } + } + return { conflict: true }; +} + +async function patchFile( + req: Request, + cwd: string, + systemPath: string, +): Promise { + let body: { patch: Patch; timestamp: number }; + try { + body = (await req.json()) as { patch: Patch; timestamp: number }; + } catch { + return jsonResponse(400, { error: "Invalid JSON" }); + } + + const mtimeBefore = await mtimeFor(systemPath); + let content: string | null; + try { + content = await readFile(systemPath, "utf-8"); + } catch { + content = null; + } + + const result = applyPatch(content, body.patch); + if (!result.conflict && result.content != null) { + await mkdir(join(systemPath, ".."), { recursive: true }); + await writeFile(systemPath, result.content, "utf-8"); + } + + const [metadata, mtimeAfter] = await Promise.all([ + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata, + timestamp: mtimeAfter, + filepath: toPosixPath(systemPath.replace(cwd, "")), + }, + }); + + const update = result.conflict + ? { conflict: true, metadata, timestamp: mtimeAfter, content } + : { + conflict: false, + metadata, + timestamp: mtimeAfter, + content: mtimeBefore !== body.timestamp ? result.content : undefined, + }; + return jsonResponse(200, update); +} + +async function deleteFile(cwd: string, systemPath: string): Promise { + try { + await rm(systemPath, { force: true }); + } catch { + // ignore + } + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata: null, + timestamp: Date.now(), + filepath: toPosixPath(systemPath.replace(cwd, "")), + }, + }); + return jsonResponse(200, { conflict: false, metadata: null, timestamp: Date.now() }); +} diff --git a/src/node/daemon/healthcheck.test.ts b/src/node/daemon/healthcheck.test.ts new file mode 100644 index 00000000..68f4948e --- /dev/null +++ b/src/node/daemon/healthcheck.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +import { handleDecoHealthcheck } from "./healthcheck"; + +describe("handleDecoHealthcheck", () => { + it("returns 200 with the ADMIN_COMPAT_VERSION body", async () => { + const res = handleDecoHealthcheck(); + expect(res.status).toBe(200); + expect(await res.text()).toBe(ADMIN_COMPAT_VERSION); + }); + + it("emits text/plain", () => { + expect(handleDecoHealthcheck().headers.get("Content-Type")).toBe("text/plain"); + }); + + it("emits the CORS headers admin.deco.cx expects", () => { + const res = handleDecoHealthcheck(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + }); +}); diff --git a/src/node/daemon/healthcheck.ts b/src/node/daemon/healthcheck.ts new file mode 100644 index 00000000..246178d7 --- /dev/null +++ b/src/node/daemon/healthcheck.ts @@ -0,0 +1,19 @@ +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; + +/** + * Web-standard `/_healthcheck` handler. + * + * Returns the admin-compatibility version (NOT @decocms/start's own version) + * with the CORS headers admin.deco.cx expects from the daemon endpoint. + */ +export function handleDecoHealthcheck(): Response { + return new Response(ADMIN_COMPAT_VERSION, { + status: 200, + headers: { + "Content-Type": "text/plain", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/src/node/daemon/ignored.test.ts b/src/node/daemon/ignored.test.ts new file mode 100644 index 00000000..aa49b3b5 --- /dev/null +++ b/src/node/daemon/ignored.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { DAEMON_IGNORED_DIRS, isIgnoredPath } from "./ignored"; + +describe("DAEMON_IGNORED_DIRS", () => { + it("includes the framework artefact dirs that flooded SSE in 5.1.x", () => { + // Regression guard: these were added after Next 16 / Turbopack consumer + // testing reported ~30 spurious fs events per rebuild. Removing any of + // them silently re-introduces the noise; leave them in or update the test. + for (const required of [ + ".git", + "node_modules", + ".agent-home", + ".claude", + ".next", + ".turbo", + "dist", + "build", + ".cache", + "coverage", + ]) { + expect(DAEMON_IGNORED_DIRS).toContain(required); + } + }); +}); + +describe("isIgnoredPath", () => { + it("matches each canonical dir as a path segment", () => { + for (const dir of DAEMON_IGNORED_DIRS) { + expect(isIgnoredPath(`/project/${dir}/file.txt`)).toBe(true); + } + }); + + it("filters Next.js build artefacts (the originally reported bug)", () => { + expect(isIgnoredPath("/project/.next/dev/server/chunks/01.js")).toBe(true); + expect(isIgnoredPath("/project/.next/cache/webpack/server.json")).toBe(true); + }); + + it("does not match partial-name collisions like 'dist-foo'", () => { + expect(isIgnoredPath("/project/dist-foo/bar.txt")).toBe(false); + expect(isIgnoredPath("/project/my.next.config.js")).toBe(false); + }); + + it("normalises Windows-style separators", () => { + expect(isIgnoredPath("C:\\project\\node_modules\\pkg\\index.js")).toBe(true); + }); + + it("accepts non-ignored .deco paths", () => { + expect(isIgnoredPath("/project/.deco/blocks/site.json")).toBe(false); + }); +}); diff --git a/src/node/daemon/ignored.ts b/src/node/daemon/ignored.ts new file mode 100644 index 00000000..78de8bd4 --- /dev/null +++ b/src/node/daemon/ignored.ts @@ -0,0 +1,41 @@ +/** + * Canonical list of directories the daemon never reports on. + * + * Used by both the broadcast channel's pre-publish filter (`bindWatcherToChannel` + * in `./watcher.ts`) and the volumes WebSocket walker (`walkFiles` / + * `broadcastChange` in `src/tanstack/daemon/volumes.ts`). Keep both call sites + * pointed at this list so framework artifact dirs (Next's `.next`, Vite's + * `dist`, Turbopack's `.turbo`, etc.) can't drift between the two paths. + * + * The match runs against absolute or relative POSIX paths. Each entry is + * matched as a path segment (surrounded by `/`), so partial-name collisions + * (e.g. `dist-foo`) don't false-positive. + */ +export const DAEMON_IGNORED_DIRS: readonly string[] = [ + ".git", + "node_modules", + ".agent-home", + ".claude", + // Framework build artefacts — observed flooding the SSE channel during + // Next 16 / Turbopack consumer testing (May 2026). + ".next", + ".turbo", + "dist", + "build", + ".cache", + "coverage", +]; + +const toPosix = (p: string) => p.replaceAll("\\", "/"); + +/** + * True when the given path passes through any of the daemon-ignored dirs as + * a complete segment. Accepts absolute, relative, POSIX, or Windows paths. + */ +export function isIgnoredPath(path: string): boolean { + const posix = toPosix(path); + for (const dir of DAEMON_IGNORED_DIRS) { + if (posix.includes(`/${dir}/`)) return true; + } + return false; +} diff --git a/src/node/daemon/index.ts b/src/node/daemon/index.ts new file mode 100644 index 00000000..fca09556 --- /dev/null +++ b/src/node/daemon/index.ts @@ -0,0 +1,31 @@ +/** + * @decocms/start/node/daemon — Web-standard daemon handlers. + * + * Node-only (depends on `node:fs/promises`, `chokidar`, `fast-json-patch`). + * Consumed by both `@decocms/start/next` (directly) and + * `@decocms/start/tanstack/daemon` (via `toNodeMiddleware` for Vite). + */ +export { handleDecoHealthcheck } from "./healthcheck"; +export { handleDecoReadiness } from "../../core/admin/readiness"; +export { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +export { requireAdminJwt } from "./auth"; +export { verifyAdminJwt, tokenIsValid } from "./jwt"; +export type { JwtPayload } from "./jwt"; +export { handleFsRequest } from "./fs"; +export type { FsHandlerOptions } from "./fs"; +export { + broadcastFsEvent, + subscribeFsEvents, + inferMetadata, + scanDecoFiles, + type FsEvent, + type Metadata, +} from "./watch"; +export { handleWatchSse } from "./watch-sse"; +export type { WatchSseOptions } from "./watch-sse"; +export { createDecoWatcher, bindWatcherToChannel } from "./watcher"; +export type { DecoWatcher } from "./watcher"; +export { createDecoAdminRoute } from "./route"; +export type { DecoAdminRouteOptions } from "./route"; +export { toNodeMiddleware } from "./nodeHttpAdapter"; +export type { WebHandler } from "./nodeHttpAdapter"; diff --git a/src/node/daemon/jwt.test.ts b/src/node/daemon/jwt.test.ts new file mode 100644 index 00000000..048a7fee --- /dev/null +++ b/src/node/daemon/jwt.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { tokenIsValid, type JwtPayload, verifyAdminJwt } from "./jwt"; + +describe("verifyAdminJwt", () => { + it("returns null for malformed tokens", async () => { + expect(await verifyAdminJwt("not-a-jwt")).toBeNull(); + expect(await verifyAdminJwt("a.b")).toBeNull(); + expect(await verifyAdminJwt("")).toBeNull(); + }); + + it("returns null for tokens with invalid signatures", async () => { + // header.payload.signature where signature does not verify + const fake = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJ4In0.AAAA"; + expect(await verifyAdminJwt(fake)).toBeNull(); + }); +}); + +describe("tokenIsValid", () => { + it("rejects payloads missing iss or sub", () => { + expect(tokenIsValid("my-site", { iss: "x" } as JwtPayload)).toBe(false); + expect(tokenIsValid("my-site", { sub: "x" } as JwtPayload)).toBe(false); + }); + + it("rejects expired tokens", () => { + expect( + tokenIsValid("my-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 1, + }), + ).toBe(false); + }); + + it("accepts a matching site URN", () => { + expect( + tokenIsValid("my-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 9999999999, + }), + ).toBe(true); + }); + + it("rejects mismatched site", () => { + expect( + tokenIsValid("other-site", { + iss: "admin", + sub: "urn:deco:site:org:my-site:deployment/123", + exp: 9999999999, + }), + ).toBe(false); + }); +}); diff --git a/src/node/daemon/jwt.ts b/src/node/daemon/jwt.ts new file mode 100644 index 00000000..78e3e53b --- /dev/null +++ b/src/node/daemon/jwt.ts @@ -0,0 +1,113 @@ +/** + * JWT verification primitives — Web Crypto only, no Node-http coupling. + * + * Moved from `src/tanstack/daemon/auth.ts` so both the Connect-style + * (`createAuthMiddleware`) and Web-standard (`requireAdminJwt`) wrappers + * can share the same trust chain. + */ + +const ADMIN_PUBLIC_KEY = + process.env.DECO_ADMIN_PUBLIC_KEY ?? + "eyJrdHkiOiJSU0EiLCJhbGciOiJSUzI1NiIsIm4iOiJ1N0Y3UklDN19Zc3ljTFhEYlBvQ1pUQnM2elZ6VjVPWkhXQ0M4akFZeFdPUnByem9WNDJDQ1JBVkVOVjJldzk1MnJOX2FTMmR3WDlmVGRvdk9zWl9jX2RVRXctdGlPN3hJLXd0YkxsanNUbUhoNFpiYXU0aUVoa0o1VGNHc2VaelhFYXNOSEhHdUo4SzY3WHluRHJSX0h4Ym9kQ2YxNFFJTmc5QnJjT3FNQmQyMUl4eUctVVhQampBTnRDTlNici1rXzFKeTZxNmtPeVJ1ZmV2Mjl0djA4Ykh5WDJQenp5Tnp3RWpjY0lROWpmSFdMN0JXX2tzdFpOOXU3TUtSLWJ4bjlSM0FKMEpZTHdXR3VnZGpNdVpBRnk0dm5BUXZzTk5Cd3p2YnFzMnZNd0dDTnF1ZE1tVmFudlNzQTJKYkE3Q0JoazI5TkRFTXRtUS1wbmo1cUlYSlEiLCJlIjoiQVFBQiIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZX0"; + +const ALG = "RSASSA-PKCS1-v1_5"; +const HASH = "SHA-256"; + +export interface JwtPayload { + [key: string]: unknown; + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; +} + +function parseJWK(b64: string): JsonWebKey { + return JSON.parse(atob(b64)); +} + +let cachedKey: Promise | null = null; + +function getAdminPublicKey(): Promise { + cachedKey ??= crypto.subtle.importKey( + "jwk", + parseJWK(ADMIN_PUBLIC_KEY), + { name: ALG, hash: HASH }, + false, + ["verify"], + ); + return cachedKey; +} + +function base64UrlDecode(str: string): Uint8Array { + const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + const binary = atob(b64 + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export async function verifyAdminJwt(token: string): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const signature = base64UrlDecode(signatureB64); + + try { + const key = await getAdminPublicKey(); + const valid = await crypto.subtle.verify( + ALG, + key, + new Uint8Array(signature), + new Uint8Array(signingInput), + ); + if (!valid) return null; + } catch { + return null; + } + + try { + const payload: JwtPayload = JSON.parse( + new TextDecoder().decode(base64UrlDecode(payloadB64)), + ); + return payload; + } catch { + return null; + } +} + +function matchPart(urnPart: string, otherPart: string): boolean { + return urnPart === "*" || otherPart === urnPart; +} + +function matchParts(urn: string[], resource: string[]): boolean { + return urn.every((part, idx) => matchPart(part, resource[idx])); +} + +function matches(urnParts: string[]) { + return (resourceUrn: string) => { + const resourceParts = resourceUrn.split(":"); + if (resourceParts.length > urnParts.length) return false; + const lastIdx = resourceParts.length - 1; + return resourceParts.every((part, idx) => { + if (part === "*") return true; + if (lastIdx === idx) { + return matchParts(part.split("/"), urnParts[idx].split("/")); + } + return part === urnParts[idx]; + }); + }; +} + +export function tokenIsValid(site: string, jwt: JwtPayload): boolean { + const { iss, sub, exp } = jwt; + if (!iss || !sub) return false; + if (exp && exp * 1000 <= Date.now()) return false; + const siteUrn = `urn:deco:site:*:${site}:deployment/*`; + return matches(sub.split(":"))(siteUrn); +} diff --git a/src/node/daemon/nodeHttpAdapter.test.ts b/src/node/daemon/nodeHttpAdapter.test.ts new file mode 100644 index 00000000..e2e59d7a --- /dev/null +++ b/src/node/daemon/nodeHttpAdapter.test.ts @@ -0,0 +1,61 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { toNodeMiddleware } from "./nodeHttpAdapter"; + +describe("toNodeMiddleware", () => { + let httpServer: ReturnType; + let url: string; + let nextWasCalled = false; + + beforeEach(async () => { + nextWasCalled = false; + const handler = toNodeMiddleware(async (req: Request) => { + const u = new URL(req.url); + if (u.pathname === "/fall") { + // Returning null signals fall-through. + return null; + } + if (u.pathname === "/echo-body" && req.method === "POST") { + const body = await req.text(); + return new Response(`echo:${body}`, { status: 201 }); + } + return new Response("hello", { + status: 200, + headers: { "X-Foo": "bar" }, + }); + }); + + httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + handler(req, res, () => { + nextWasCalled = true; + res.statusCode = 418; + res.end(); + }); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const addr = httpServer.address() as AddressInfo; + url = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(() => new Promise((resolve) => httpServer.close(() => resolve()))); + + it("translates a Web Response to a Node ServerResponse", async () => { + const r = await fetch(url + "/"); + expect(r.status).toBe(200); + expect(r.headers.get("X-Foo")).toBe("bar"); + expect(await r.text()).toBe("hello"); + }); + + it("forwards request bodies", async () => { + const r = await fetch(url + "/echo-body", { method: "POST", body: "ping" }); + expect(r.status).toBe(201); + expect(await r.text()).toBe("echo:ping"); + }); + + it("calls next() when the handler returns null", async () => { + const r = await fetch(url + "/fall"); + expect(r.status).toBe(418); + expect(nextWasCalled).toBe(true); + }); +}); diff --git a/src/node/daemon/nodeHttpAdapter.ts b/src/node/daemon/nodeHttpAdapter.ts new file mode 100644 index 00000000..2a7d3ba1 --- /dev/null +++ b/src/node/daemon/nodeHttpAdapter.ts @@ -0,0 +1,79 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; + +export type WebHandler = (req: Request) => Promise | Response | null; + +/** + * Wrap a Web-standard `Request → Response` handler as Connect-style middleware + * for `vite dev`'s server.middlewares.use(...) stack. + * + * Returning `null` from the inner handler delegates to `next()` (fall-through). + * Streaming bodies are piped through with backpressure preserved. + */ +export function toNodeMiddleware(handler: WebHandler) { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + let webResponse: Response | null; + try { + const webReq = toWebRequest(req); + webResponse = await handler(webReq); + } catch (err) { + console.error("[deco] daemon handler threw:", err); + res.statusCode = 500; + res.end(); + return; + } + if (!webResponse) return next(); + await writeWebResponse(res, webResponse); + }; +} + +function toWebRequest(req: IncomingMessage): Request { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v); + } else { + headers.set(key, value); + } + } + const method = (req.method ?? "GET").toUpperCase(); + const init: RequestInit = { method, headers }; + if (method !== "GET" && method !== "HEAD") { + init.body = Readable.toWeb(req) as ReadableStream; + (init as any).duplex = "half"; // required by Node's Web Request for streaming bodies + } + return new Request(url, init); +} + +async function writeWebResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => res.setHeader(key, value)); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + res.on("close", () => reader.cancel().catch(() => undefined)); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + // Honor backpressure: pause reading until drain when write returns false. + if (!res.write(value)) { + await new Promise((resolve) => res.once("drain", resolve)); + } + } + res.end(); + } catch (err) { + console.error("[deco] error streaming Response body:", err); + res.destroy(err as Error); + } +} diff --git a/src/node/daemon/route.test.ts b/src/node/daemon/route.test.ts new file mode 100644 index 00000000..63eead62 --- /dev/null +++ b/src/node/daemon/route.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ADMIN_COMPAT_VERSION } from "../../core/admin/version"; +import { createDecoAdminRoute } from "./route"; + +describe("createDecoAdminRoute", () => { + const originalEnv = process.env.NODE_ENV; + const originalBypass = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + + beforeEach(() => { + delete process.env.NODE_ENV; + delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + }); + afterEach(() => { + if (originalEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalEnv; + if (originalBypass === undefined) delete process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS; + else process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = originalBypass; + }); + + it("serves /_healthcheck with the version body", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + expect(await res.text()).toBe(ADMIN_COMPAT_VERSION); + }); + + it("serves /_ready (503 before setBlocks-equivalent state)", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/_ready")); + expect([200, 503]).toContain(res.status); + }); + + it("returns 404 when the master enabled switch is false", async () => { + const handler = createDecoAdminRoute({ site: "my-site", enabled: false }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(404); + }); + + it("returns 404 when an individual group is disabled", async () => { + const handler = createDecoAdminRoute({ site: "my-site", healthcheck: false }); + expect((await handler(new Request("http://t/_healthcheck"))).status).toBe(404); + expect((await handler(new Request("http://t/_ready"))).status).not.toBe(404); + }); + + it("disables /fs/* and /_watch in production by default", async () => { + process.env.NODE_ENV = "production"; + const handler = createDecoAdminRoute({ site: "my-site" }); + expect((await handler(new Request("http://t/_watch"))).status).toBe(404); + expect((await handler(new Request("http://t/fs/file/anything"))).status).toBe(404); + }); + + it("enables /fs/* and /_watch in development by default", async () => { + process.env.NODE_ENV = "development"; + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + // manageWatcher: false avoids spinning up a real chokidar against repo root + // for unit tests; the lazy-watcher integration is exercised in src/next/. + const handler = createDecoAdminRoute({ + site: "my-site", + cwd: process.cwd(), + manageWatcher: false, + }); + const fsRes = await handler(new Request("http://t/fs/file/.deco/missing.json")); + // 404 here means "file not found", not "route disabled" — assert it's not the route-404 case. + expect(fsRes.status).toBe(404); + expect((await fsRes.json()).timestamp).toBeGreaterThan(0); + }); + + it("gates /fs/* on JWT when bypass is not set", async () => { + process.env.NODE_ENV = "development"; + const handler = createDecoAdminRoute({ + site: "my-site", + manageWatcher: false, + }); + const res = await handler(new Request("http://t/fs/file/.deco/anything")); + expect(res.status).toBe(401); + }); + + it("creates the chokidar watcher lazily on first /fs/* hit when manageWatcher is on", async () => { + process.env.NODE_ENV = "development"; + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = "true"; + const handler = createDecoAdminRoute({ + site: "my-site", + cwd: process.cwd(), + manageWatcher: true, + }); + // Hit a non-existent path so we don't depend on repo contents; we only care + // that the call succeeds without crashing the lazy-watcher boot. + const res = await handler(new Request("http://t/fs/file/.deco/missing.json")); + expect(res.status).toBe(404); + }); + + it("returns 501 for /volumes//files (Next path)", async () => { + const handler = createDecoAdminRoute({ site: "my-site" }); + const res = await handler(new Request("http://t/volumes/abc/files")); + expect(res.status).toBe(501); + }); + + it("throws at construction when site is missing and admin-protocol is enabled", () => { + expect(() => createDecoAdminRoute({})).toThrow(/site/); + }); + + it("does not require site when only probes are enabled", () => { + expect(() => + createDecoAdminRoute({ + adminProtocol: false, + fs: false, + watch: false, + }), + ).not.toThrow(); + }); + + it("awaits onRequest exactly once per request before pathname dispatch", async () => { + let calls = 0; + const handler = createDecoAdminRoute({ + site: "my-site", + onRequest: async () => { + calls++; + }, + }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + expect(calls).toBe(1); + + await handler(new Request("http://t/_ready")); + expect(calls).toBe(2); + }); + + it("short-circuits with the Response returned by onRequest", async () => { + const handler = createDecoAdminRoute({ + site: "my-site", + onRequest: () => new Response("maintenance", { status: 503 }), + }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(503); + expect(await res.text()).toBe("maintenance"); + }); + + it("continues to the dispatcher when onRequest returns undefined", async () => { + const handler = createDecoAdminRoute({ + site: "my-site", + onRequest: () => undefined, + }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(200); + }); + + it("skips onRequest entirely when enabled is false", async () => { + let called = false; + const handler = createDecoAdminRoute({ + site: "my-site", + enabled: false, + onRequest: () => { + called = true; + }, + }); + const res = await handler(new Request("http://t/_healthcheck")); + expect(res.status).toBe(404); + expect(called).toBe(false); + }); +}); diff --git a/src/node/daemon/route.ts b/src/node/daemon/route.ts new file mode 100644 index 00000000..b062e3f3 --- /dev/null +++ b/src/node/daemon/route.ts @@ -0,0 +1,196 @@ +import { handleDecofileRead, handleDecofileReload } from "../../core/admin/decofile"; +import { handleInvoke } from "../../core/admin/invoke"; +import { handleMeta } from "../../core/admin/meta"; +import { handleRender } from "../../core/admin/render"; +import { handleDecoReadiness } from "../../core/admin/readiness"; +import { requireAdminJwt } from "./auth"; +import { handleFsRequest } from "./fs"; +import { handleDecoHealthcheck } from "./healthcheck"; +import { handleWatchSse } from "./watch-sse"; + +export interface DecoAdminRouteOptions { + /** Master switch — false short-circuits everything to 404. */ + enabled?: boolean; + /** Hosting probe `/_healthcheck`. Default: true. */ + healthcheck?: boolean; + /** Hosting probe `/_ready`. Default: true. */ + readiness?: boolean; + /** Admin protocol (`/live/_meta`, `/.decofile`, `/deco/*`, `/live/previews/*`). Default: true. */ + adminProtocol?: boolean; + /** Dev tooling SSE (`/_watch`, `/watch`). Default: NODE_ENV !== "production". */ + watch?: boolean; + /** Dev tooling JSON-patch FS (`/fs/*`). Default: NODE_ENV !== "production". */ + fs?: boolean; + /** Filesystem root for fs + watch handlers. Default: process.cwd(). */ + cwd?: string; + /** + * Site name for JWT validation. Required when any auth-gated group + * (`adminProtocol`, `watch`, or `fs`) is enabled. + */ + site?: string; + /** Watch handler's loopback meta-info port resolver. Default: () => 5173. */ + getPort?: () => number; + /** + * Lazily create + bind a chokidar watcher on the first /watch or /fs/* request + * when watch or fs is enabled. Default: true. + * + * Set to `false` on the TanStack/Vite path — Vite already provides the watcher + * via `bindWatcherToChannel`. Two watchers on the same tree work but waste + * inotify handles. + */ + manageWatcher?: boolean; + /** + * Hook awaited once per request before any pathname dispatch — including + * before route-group enable checks. Use it for prerequisites that must run + * before any admin handler reads shared state (the most common case: + * `await ensureSetup()` so `setBlocks` has populated the block registry + * before `handleMeta` / `handleDecofileRead` / `handleDecoReadiness` look + * at it). + * + * Returning `undefined` (the default) continues to the dispatcher. + * Returning a `Response` short-circuits the request — useful for custom + * auth, maintenance-mode responses, or any early-out the consumer needs + * before the daemon's own dispatch runs. + */ + onRequest?: (req: Request) => void | Response | Promise; +} + +interface ResolvedOptions { + enabled: boolean; + healthcheck: boolean; + readiness: boolean; + adminProtocol: boolean; + watch: boolean; + fs: boolean; + cwd: string; + site?: string; + getPort: () => number; + manageWatcher: boolean; + onRequest?: (req: Request) => void | Response | Promise; +} + +function resolve(opts: DecoAdminRouteOptions): ResolvedOptions { + const isProd = process.env.NODE_ENV === "production"; + const resolved: ResolvedOptions = { + enabled: opts.enabled ?? true, + healthcheck: opts.healthcheck ?? true, + readiness: opts.readiness ?? true, + adminProtocol: opts.adminProtocol ?? true, + watch: opts.watch ?? !isProd, + fs: opts.fs ?? !isProd, + cwd: opts.cwd ?? process.cwd(), + site: opts.site, + getPort: opts.getPort ?? (() => 5173), + manageWatcher: opts.manageWatcher ?? true, + onRequest: opts.onRequest, + }; + const authGroupActive = + resolved.enabled && (resolved.adminProtocol || resolved.watch || resolved.fs); + if (authGroupActive && !resolved.site) { + throw new Error( + "createDecoAdminRoute: `site` is required when adminProtocol, watch, or fs is enabled.", + ); + } + return resolved; +} + +// Lazy chokidar singleton keyed by cwd — created on the first /watch or /fs/* +// request when manageWatcher is enabled. Module-level so two `createDecoAdminRoute` +// calls in the same process share a watcher per cwd. +const watcherSingletons = new Map Promise }>>(); + +async function ensureWatcher(cwd: string): Promise { + if (watcherSingletons.has(cwd)) return; + // Dynamic import keeps chokidar out of the synchronous module graph for + // callers that only need probes (e.g. production builds). + const promise = import("./watcher").then(({ createDecoWatcher }) => + createDecoWatcher(cwd), + ); + watcherSingletons.set(cwd, promise); + await promise; +} + +/** + * Compose a Web-standard handler for the daemon's full route surface. + * + * Each route group can be independently toggled. Disabled groups short-circuit + * to 404 — callers can't distinguish a disabled deploy from one that never had + * the route, which keeps the surface honest. + */ +export function createDecoAdminRoute( + opts: DecoAdminRouteOptions = {}, +): (req: Request) => Promise { + const cfg = resolve(opts); + const watcherNeeded = + cfg.manageWatcher && + (cfg.watch || cfg.fs) && + process.env.NODE_ENV !== "production"; + + return async (req: Request): Promise => { + if (!cfg.enabled) return notFound(); + + // Per-request hook — runs before pathname dispatch, after the master + // enabled check. Returning a Response short-circuits; undefined continues. + if (cfg.onRequest) { + const early = await cfg.onRequest(req); + if (early) return early; + } + + const { pathname } = new URL(req.url); + + // Probes — no auth. + if (pathname === "/_healthcheck") { + return cfg.healthcheck ? handleDecoHealthcheck() : notFound(); + } + if (pathname === "/_ready") { + return cfg.readiness ? handleDecoReadiness() : notFound(); + } + + // Volumes — TanStack-only (WebSocket). Next-style returns 501. + if (pathname.includes("/volumes/") && pathname.includes("/files")) { + if (!cfg.adminProtocol) return notFound(); + return new Response( + "Volumes WebSocket is not supported in the Next adapter. " + + "Use the TanStack/Vite daemon for /volumes//files.", + { status: 501, headers: { "Content-Type": "text/plain" } }, + ); + } + + // Dev tooling — auth-gated. + if (pathname === "/_watch" || pathname === "/watch") { + if (!cfg.watch) return notFound(); + const guard = await requireAdminJwt(req, cfg.site!); + if (guard) return guard; + if (watcherNeeded) await ensureWatcher(cfg.cwd); + return handleWatchSse(req, { cwd: cfg.cwd, getPort: cfg.getPort }); + } + + if (pathname.startsWith("/fs/")) { + if (!cfg.fs) return notFound(); + const guard = await requireAdminJwt(req, cfg.site!); + if (guard) return guard; + if (watcherNeeded) await ensureWatcher(cfg.cwd); + return handleFsRequest(req, { cwd: cfg.cwd }); + } + + // Admin protocol — handlers self-authenticate today (see src/core/admin/*). + if (cfg.adminProtocol) { + if (pathname === "/live/_meta") return handleMeta(req); + if (pathname === "/.decofile") { + return req.method === "POST" ? handleDecofileReload(req) : handleDecofileRead(); + } + if (pathname === "/deco/render" || pathname.startsWith("/live/previews/")) { + return handleRender(req); + } + if (pathname === "/deco/invoke" || pathname.startsWith("/deco/invoke/")) { + return handleInvoke(req); + } + } + + return notFound(); + }; +} + +function notFound(): Response { + return new Response("Not Found", { status: 404 }); +} diff --git a/src/node/daemon/watch-sse.test.ts b/src/node/daemon/watch-sse.test.ts new file mode 100644 index 00000000..4fb9e18f --- /dev/null +++ b/src/node/daemon/watch-sse.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { broadcastFsEvent } from "./watch"; +import { handleWatchSse } from "./watch-sse"; + +describe("handleWatchSse", () => { + it("returns a text/event-stream Response", () => { + const controller = new AbortController(); + const req = new Request("http://t/_watch", { signal: controller.signal }); + const res = handleWatchSse(req, { cwd: process.cwd() }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + expect(res.headers.get("Cache-Control")).toBe("no-cache"); + controller.abort(); + }); + + it("forwards broadcast events to the SSE stream", async () => { + const controller = new AbortController(); + const req = new Request("http://t/_watch?since=" + Date.now(), { signal: controller.signal }); + const res = handleWatchSse(req, { cwd: process.cwd() }); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + // Drain the initial fs-snapshot before publishing — scanner emits it last. + let buffer = ""; + while (!buffer.includes("fs-snapshot")) { + const { value } = await reader.read(); + buffer += decoder.decode(value); + } + + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + let received = ""; + while (!received.includes("worker-status")) { + const { value } = await reader.read(); + received += decoder.decode(value); + } + expect(received).toContain("worker-status"); + controller.abort(); + }); +}); diff --git a/src/node/daemon/watch-sse.ts b/src/node/daemon/watch-sse.ts new file mode 100644 index 00000000..c7f4c6b0 --- /dev/null +++ b/src/node/daemon/watch-sse.ts @@ -0,0 +1,92 @@ +import { + type FsEvent, + scanDecoFiles, + subscribeFsEvents, +} from "./watch"; + +export interface WatchSseOptions { + /** Watch root. Defaults to process.cwd(). */ + cwd?: string; + /** Resolves the loopback port the meta-info fetch should hit. Defaults to 5173. */ + getPort?: () => number; +} + +/** + * Web-standard SSE handler for `/_watch` and `/watch`. + * + * Emits the initial .deco/ snapshot, then forwards broadcast-channel events + * for the lifetime of the connection. Closes cleanly when the request signal + * aborts. + */ +export function handleWatchSse(req: Request, opts: WatchSseOptions = {}): Response { + const url = new URL(req.url); + const since = Number(url.searchParams.get("since")) || 0; + const cwd = opts.cwd ?? process.cwd(); + const getPort = opts.getPort ?? (() => 5173); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + + const send = (event: FsEvent) => { + if (closed) return; + const data = encodeURIComponent(JSON.stringify(event)); + controller.enqueue(encoder.encode(`event: message\ndata: ${data}\n\n`)); + }; + + const unsubscribe = subscribeFsEvents(send); + + const close = () => { + if (closed) return; + closed = true; + unsubscribe(); + try { + controller.close(); + } catch { + // already closed + } + }; + + req.signal.addEventListener("abort", close); + + try { + for await (const event of scanDecoFiles(cwd, since)) { + if (closed) return; + send(event); + } + if (closed) return; + + send({ type: "worker-status", detail: { state: "ready" } }); + + try { + const metaResponse = await fetch(`http://localhost:${getPort()}/live/_meta`); + if (metaResponse.ok) { + const metaData = await metaResponse.json(); + send({ type: "meta-info", detail: { ...metaData, timestamp: Date.now() } }); + } + } catch { + // schema not initialised yet — admin will retry via /live/_meta + } + } catch (err) { + if (!closed) { + try { + controller.error(err); + } catch { + // ignore + } + } + } + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/src/node/daemon/watch.test.ts b/src/node/daemon/watch.test.ts new file mode 100644 index 00000000..45ce46c9 --- /dev/null +++ b/src/node/daemon/watch.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { broadcastFsEvent, subscribeFsEvents, type FsEvent } from "./watch"; + +describe("broadcast channel", () => { + it("delivers events to subscribers and stops after unsubscribe", () => { + const seen: FsEvent[] = []; + const unsubscribe = subscribeFsEvents((e) => seen.push(e)); + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + expect(seen.length).toBe(1); + expect(seen[0].type).toBe("worker-status"); + unsubscribe(); + broadcastFsEvent({ type: "worker-status", detail: { state: "ready" } }); + expect(seen.length).toBe(1); + }); +}); diff --git a/src/node/daemon/watch.ts b/src/node/daemon/watch.ts new file mode 100644 index 00000000..1be4fa52 --- /dev/null +++ b/src/node/daemon/watch.ts @@ -0,0 +1,116 @@ +/** + * Daemon broadcast channel + .deco/ file scanner + metadata inference. + * + * Framework-neutral. Both the Connect-style watch handler (consumed by Vite's + * middleware stack) and the Web-standard `handleWatchSse` consume from here. + * + * Ported from: deco-cx/deco daemon/sse/api.ts + daemon/sse/channel.ts + */ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, sep } from "node:path"; +import { isIgnoredPath } from "./ignored"; + +export interface FsEvent { + type: "fs-sync" | "fs-snapshot" | "worker-status" | "meta-info"; + detail: Record; +} + +const channel = new EventTarget(); + +/** Publish an event to all subscribers. */ +export function broadcastFsEvent(event: FsEvent): void { + channel.dispatchEvent(new CustomEvent("broadcast", { detail: event })); +} + +/** Subscribe to broadcast events. Returns an unsubscribe function. */ +export function subscribeFsEvents(listener: (event: FsEvent) => void): () => void { + const handler = (e: Event) => listener((e as CustomEvent).detail); + channel.addEventListener("broadcast", handler); + return () => channel.removeEventListener("broadcast", handler); +} + +const toPosix = (p: string) => p.replaceAll(sep, "/"); + +function inferBlockType(resolveType: string): string | null { + if (!resolveType) return null; + if (resolveType.includes("/pages/")) return "pages"; + if (resolveType.includes("/sections/")) return "sections"; + if (resolveType.includes("/loaders/")) return "loaders"; + if (resolveType.includes("/actions/")) return "actions"; + if (resolveType.includes("/matchers/")) return "matchers"; + if (resolveType.includes("/flags/")) return "sections"; + return null; +} + +export interface Metadata { + kind: "block" | "file"; + blockType?: string; + __resolveType?: string; + name?: string; + path?: string; +} + +/** Read a JSON file and infer its block metadata (block type, resolveType, …). */ +export async function inferMetadata(filepath: string): Promise { + try { + const raw = await readFile(filepath, "utf-8"); + const parsed = JSON.parse(raw); + const { __resolveType, name, path: pagePath } = parsed; + + if (!__resolveType) return { kind: "file" }; + const blockType = inferBlockType(__resolveType); + if (!blockType) return { kind: "file" }; + + if (blockType === "pages") { + return { + kind: "block", + blockType, + __resolveType, + name: name ?? undefined, + path: pagePath ?? undefined, + }; + } + return { kind: "block", blockType, __resolveType }; + } catch { + return { kind: "file" }; + } +} + +/** Yield fs-sync events for every .deco/ file modified after `since`. */ +export async function* scanDecoFiles(cwd: string, since: number): AsyncGenerator { + const decoDir = join(cwd, ".deco"); + try { + const entries = await readdir(decoDir, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const fullPath = join(entry.parentPath, entry.name); + if (isIgnoredPath(fullPath)) continue; + + let mtime: number; + try { + const stats = await stat(fullPath); + mtime = stats.mtimeMs; + } catch { + mtime = Date.now(); + } + if (mtime < since) continue; + + const metadata = await inferMetadata(fullPath); + const filepath = toPosix(fullPath.replace(cwd, "")); + yield { type: "fs-sync", detail: { metadata, filepath, timestamp: mtime } }; + } + } catch { + // .deco dir might not exist yet + } + yield { type: "fs-snapshot", detail: { timestamp: Date.now() } }; +} + +/** + * Common ignore predicate, exported for the watcher wrappers. + * + * @deprecated Use `isIgnoredPath` from `./ignored` directly. This re-export + * exists for back-compat with existing imports inside the daemon module. + */ +export { isIgnoredPath as shouldIgnorePath } from "./ignored"; + +export const toPosixPath = toPosix; diff --git a/src/node/daemon/watcher.ts b/src/node/daemon/watcher.ts new file mode 100644 index 00000000..7dff7101 --- /dev/null +++ b/src/node/daemon/watcher.ts @@ -0,0 +1,71 @@ +/** + * Chokidar watcher wrapper for the Next-side daemon path. + * + * The TanStack-side daemon receives Vite's existing watcher via + * `createDaemonMiddleware`'s options and binds it via `bindWatcherToChannel`; + * it never calls this function. The Next-side daemon has no Vite, so it + * spins up its own chokidar instance lazily on the first watch/fs request. + */ +import chokidar, { type FSWatcher } from "chokidar"; +import { stat } from "node:fs/promises"; +import { + broadcastFsEvent, + inferMetadata, + shouldIgnorePath, + toPosixPath, +} from "./watch"; + +export interface DecoWatcher { + watcher: FSWatcher; + close: () => Promise; +} + +export function createDecoWatcher(cwd: string): DecoWatcher { + const watcher = chokidar.watch(cwd, { + ignoreInitial: true, + ignored: (p: string) => shouldIgnorePath(p), + }); + bindWatcherToChannel(watcher, cwd); + return { + watcher, + close: () => watcher.close(), + }; +} + +/** + * Wire any chokidar-style watcher (Vite's or our own) to the broadcast channel. + * Pure side-effect helper — does not own the watcher's lifecycle. + */ +export function bindWatcherToChannel( + watcher: { on(event: string, cb: (...args: unknown[]) => void): void }, + cwd: string = process.cwd(), +): void { + const onChange = async (filePath: unknown, deleted = false) => { + if (typeof filePath !== "string") return; + if (shouldIgnorePath(filePath)) return; + + const metadata = deleted ? null : await inferMetadata(filePath); + let mtime = Date.now(); + if (!deleted) { + try { + const stats = await stat(filePath); + mtime = stats.mtimeMs; + } catch { + // use Date.now() + } + } + + broadcastFsEvent({ + type: "fs-sync", + detail: { + metadata, + filepath: toPosixPath(filePath.replace(cwd, "")), + timestamp: mtime, + }, + }); + }; + + watcher.on("change", (path: unknown) => onChange(path)); + watcher.on("add", (path: unknown) => onChange(path)); + watcher.on("unlink", (path: unknown) => onChange(path, true)); +} diff --git a/src/tanstack/daemon/auth.ts b/src/tanstack/daemon/auth.ts index 60177b6c..56efd826 100644 --- a/src/tanstack/daemon/auth.ts +++ b/src/tanstack/daemon/auth.ts @@ -1,149 +1,18 @@ /** - * JWT verification for admin.deco.cx requests. - * Uses Web Crypto only — no external dependencies. + * Connect-style JWT auth middleware for Vite's middleware stack. * - * Ported from: deco-cx/deco daemon/auth.ts + commons/jwt/* + * The pure JWT primitives moved to `src/node/daemon/jwt.ts` so both the + * Connect-style and Web-standard wrappers share the same trust chain. This + * file is now only the Node-http adapter — the verification, payload type, + * and URN matching all come from there. */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { tokenIsValid, verifyAdminJwt } from "../../node/daemon/jwt"; -// --------------------------------------------------------------------------- -// Public key — same key used by all sites (from commons/jwt/trusted.ts) -// --------------------------------------------------------------------------- +export { verifyAdminJwt, tokenIsValid } from "../../node/daemon/jwt"; +export type { JwtPayload } from "../../node/daemon/jwt"; -const ADMIN_PUBLIC_KEY = - process.env.DECO_ADMIN_PUBLIC_KEY ?? - "eyJrdHkiOiJSU0EiLCJhbGciOiJSUzI1NiIsIm4iOiJ1N0Y3UklDN19Zc3ljTFhEYlBvQ1pUQnM2elZ6VjVPWkhXQ0M4akFZeFdPUnByem9WNDJDQ1JBVkVOVjJldzk1MnJOX2FTMmR3WDlmVGRvdk9zWl9jX2RVRXctdGlPN3hJLXd0YkxsanNUbUhoNFpiYXU0aUVoa0o1VGNHc2VaelhFYXNOSEhHdUo4SzY3WHluRHJSX0h4Ym9kQ2YxNFFJTmc5QnJjT3FNQmQyMUl4eUctVVhQampBTnRDTlNici1rXzFKeTZxNmtPeVJ1ZmV2Mjl0djA4Ykh5WDJQenp5Tnp3RWpjY0lROWpmSFdMN0JXX2tzdFpOOXU3TUtSLWJ4bjlSM0FKMEpZTHdXR3VnZGpNdVpBRnk0dm5BUXZzTk5Cd3p2YnFzMnZNd0dDTnF1ZE1tVmFudlNzQTJKYkE3Q0JoazI5TkRFTXRtUS1wbmo1cUlYSlEiLCJlIjoiQVFBQiIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZX0"; - -const BYPASS_JWT = - process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true"; - -// --------------------------------------------------------------------------- -// JWT types -// --------------------------------------------------------------------------- - -export interface JwtPayload { - [key: string]: unknown; - iss?: string; - sub?: string; - aud?: string | string[]; - exp?: number; - nbf?: number; - iat?: number; - jti?: string; -} - -// --------------------------------------------------------------------------- -// Crypto helpers — ported from commons/jwt/keys.ts -// --------------------------------------------------------------------------- - -const ALG = "RSASSA-PKCS1-v1_5"; -const HASH = "SHA-256"; - -function parseJWK(b64: string): JsonWebKey { - return JSON.parse(atob(b64)); -} - -let cachedKey: Promise | null = null; - -function getAdminPublicKey(): Promise { - cachedKey ??= crypto.subtle.importKey( - "jwk", - parseJWK(ADMIN_PUBLIC_KEY), - { name: ALG, hash: HASH }, - false, - ["verify"], - ); - return cachedKey; -} - -// --------------------------------------------------------------------------- -// JWT verification — ported from commons/jwt/jwt.ts -// --------------------------------------------------------------------------- - -function base64UrlDecode(str: string): Uint8Array { - const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); - const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); - const binary = atob(b64 + pad); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -export async function verifyAdminJwt( - token: string, -): Promise { - const parts = token.split("."); - if (parts.length !== 3) return null; - - const [headerB64, payloadB64, signatureB64] = parts; - const signingInput = new TextEncoder().encode( - `${headerB64}.${payloadB64}`, - ); - const signature = base64UrlDecode(signatureB64); - - try { - const key = await getAdminPublicKey(); - const valid = await crypto.subtle.verify( - ALG, - key, - new Uint8Array(signature), - new Uint8Array(signingInput), - ); - if (!valid) return null; - } catch { - return null; - } - - try { - const payload: JwtPayload = JSON.parse( - new TextDecoder().decode(base64UrlDecode(payloadB64)), - ); - return payload; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// URN matching — ported from commons/jwt/engine.ts -// --------------------------------------------------------------------------- - -function matchPart(urnPart: string, otherPart: string): boolean { - return urnPart === "*" || otherPart === urnPart; -} - -function matchParts(urn: string[], resource: string[]): boolean { - return urn.every((part, idx) => matchPart(part, resource[idx])); -} - -function matches(urnParts: string[]) { - return (resourceUrn: string) => { - const resourceParts = resourceUrn.split(":"); - if (resourceParts.length > urnParts.length) return false; - const lastIdx = resourceParts.length - 1; - return resourceParts.every((part, idx) => { - if (part === "*") return true; - if (lastIdx === idx) { - return matchParts(part.split("/"), urnParts[idx].split("/")); - } - return part === urnParts[idx]; - }); - }; -} - -export function tokenIsValid(site: string, jwt: JwtPayload): boolean { - const { iss, sub, exp } = jwt; - if (!iss || !sub) return false; - if (exp && exp * 1000 <= Date.now()) return false; - const siteUrn = `urn:deco:site:*:${site}:deployment/*`; - return matches(sub.split(":"))(siteUrn); -} - -// --------------------------------------------------------------------------- -// Auth middleware for Connect (Vite dev server) -// --------------------------------------------------------------------------- +const BYPASS_JWT = process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true"; function extractToken(req: IncomingMessage): string | null { const auth = req.headers.authorization; @@ -151,7 +20,6 @@ function extractToken(req: IncomingMessage): string | null { const parts = auth.split(/\s+/); if (parts.length === 2) return parts[1]; } - // Fallback: ?token= query param try { const url = new URL(req.url ?? "/", "http://localhost"); const t = url.searchParams.get("token"); diff --git a/src/tanstack/daemon/middleware.test.ts b/src/tanstack/daemon/middleware.test.ts new file mode 100644 index 00000000..e12ec4d0 --- /dev/null +++ b/src/tanstack/daemon/middleware.test.ts @@ -0,0 +1,45 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createDaemonMiddleware } from "./middleware"; + +describe("createDaemonMiddleware (integration)", () => { + let httpServer: ReturnType; + let url: string; + const noopWatcher = { on: () => undefined }; + + beforeEach(async () => { + const middleware = createDaemonMiddleware({ + site: "my-site", + server: { httpServer: null, watcher: noopWatcher }, + }); + httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + middleware(req, res, () => { + res.statusCode = 404; + res.end(); + }); + }); + await new Promise((r) => httpServer.listen(0, r)); + url = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + }); + afterEach(() => new Promise((r) => httpServer.close(() => r()))); + + it("serves /_healthcheck without auth", async () => { + const r = await fetch(url + "/_healthcheck"); + expect(r.status).toBe(200); + expect((await r.text()).length).toBeGreaterThan(0); + }); + + it("serves /_ready without auth", async () => { + const r = await fetch(url + "/_ready"); + expect([200, 503]).toContain(r.status); + }); + + it("returns 401 for /fs/file/anything without a token", async () => { + process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS = ""; + const r = await fetch(url + "/fs/file/.deco/anything", { + headers: { "x-daemon-api": "true" }, + }); + expect(r.status).toBe(401); + }); +}); diff --git a/src/tanstack/daemon/middleware.ts b/src/tanstack/daemon/middleware.ts index 98d011a4..b8906082 100644 --- a/src/tanstack/daemon/middleware.ts +++ b/src/tanstack/daemon/middleware.ts @@ -1,19 +1,22 @@ /** - * Daemon middleware — intercepts x-daemon-api requests, applies auth, - * and routes to volumes API or watch SSE. + * Daemon middleware — Connect-style entry point used by Vite's middleware + * stack. * - * Admin runtime routes (/live/_meta, /.decofile) are NOT handled here — - * they fall through to Vite SSR (worker-entry.ts) where setMetaData() - * and setBlocks() have populated shared state. The daemon middleware loads - * modules via native import() which creates separate module instances. - * - * Ported from: deco-cx/deco daemon/daemon.ts + * All HTTP-shape routes (probes, fs, watch, admin protocol) are composed by + * `createDecoAdminRoute` from `src/node/daemon/route.ts` and wrapped via + * `toNodeMiddleware`. The volumes WebSocket binding stays in this file + * because it needs `httpServer.on("upgrade")`, which is not expressible via + * Request → Response. */ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; import { createAuthMiddleware } from "./auth"; -import { createFSHandler } from "./fs"; +import { + createDecoAdminRoute, + type DecoAdminRouteOptions, +} from "../../node/daemon/route"; +import { toNodeMiddleware } from "../../node/daemon/nodeHttpAdapter"; +import { bindWatcherToChannel } from "../../node/daemon/watcher"; import { createVolumesHandler } from "./volumes"; -import { createWatchHandler, watchFS } from "./watch"; const DAEMON_API_SPECIFIER = "x-daemon-api"; const HYPERVISOR_API_SPECIFIER = "x-hypervisor-api"; @@ -26,48 +29,46 @@ export interface DaemonOptions { httpServer: HttpServer | null; watcher: { on(event: string, cb: (...args: unknown[]) => void): void }; }; + /** + * Optional per-group toggles, forwarded to createDecoAdminRoute. + * Site is taken from the top-level `site` field; the watch port defaults + * to the Vite httpServer's bound port. + */ + routes?: Omit; } -// Creates a Connect-style middleware that: -// 1. Checks for x-daemon-api or x-hypervisor-api header -// 2. Applies JWT auth -// 3. Routes to volumes API or SSE watch -// 4. Falls through to Vite for other daemon requests (admin routes) export function createDaemonMiddleware(opts: DaemonOptions) { const auth = createAuthMiddleware(opts.site); const httpServer = opts.server.httpServer; - // Volumes handler (includes WebSocket upgrade registration) + // Volumes still owns its httpServer.on("upgrade") binding — not portable. const volumes = httpServer - ? createVolumesHandler({ - httpServer, - watcher: opts.server.watcher, - }) + ? createVolumesHandler({ httpServer, watcher: opts.server.watcher }) : null; - // FS REST API handler (/fs/file/* — read, patch, delete) - const fs = createFSHandler(); + // Vite's watcher feeds the shared broadcast channel. + bindWatcherToChannel(opts.server.watcher); - // SSE watch handler — lazy port resolver for /live/_meta fetch - const watch = createWatchHandler({ + const webRouteHandler = createDecoAdminRoute({ + site: opts.site, getPort: () => { const addr = httpServer?.address(); return typeof addr === "object" && addr ? addr.port : 5173; }, + // Vite already provides the watcher via `bindWatcherToChannel` above; + // skip the Next-style lazy chokidar singleton so we don't double-watch. + manageWatcher: false, + ...opts.routes, }); - // Wire Vite's file watcher to the broadcast channel - watchFS(opts.server.watcher); - - // Version reported to admin.deco.cx — must satisfy admin's minimum version check. - // Admin compares against deco-cx/deco versions (e.g. 1.177.x), not @decocms/start versions. - const VERSION = "1.177.5"; + const webMiddleware = toNodeMiddleware(async (req) => { + // All routes delegate to the Web-standard dispatcher. Volumes paths return + // 501 from createDecoAdminRoute (TanStack handles them via the volumes + // branch above); anything unrecognised gets a 404 — no null fall-through. + return webRouteHandler(req); + }); - return ( - req: IncomingMessage, - res: ServerResponse, - next: () => void, - ): void => { + return (req: IncomingMessage, res: ServerResponse, next: () => void): void => { let pathname: string; try { pathname = new URL(req.url ?? "/", "http://localhost").pathname; @@ -75,30 +76,17 @@ export function createDaemonMiddleware(opts: DaemonOptions) { pathname = req.url ?? "/"; } - // Healthcheck — no auth required, admin uses this to verify env is reachable - if (pathname === "/_healthcheck") { - res.writeHead(200, { - "Content-Type": "text/plain", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(VERSION); + // Probes — no auth, no x-daemon-api header required. + if (pathname === "/_healthcheck" || pathname === "/_ready") { + webMiddleware(req, res, next); return; } - // Admin runtime routes (/live/_meta, /.decofile) are NOT handled here. - // They fall through to Vite SSR (worker-entry.ts / TanStack routes) where - // setMetaData() and setBlocks() have already populated the shared state. - // The daemon middleware loads modules via native import() which creates - // separate module instances from Vite SSR — they don't share state. - const isDaemonAPI = req.headers[DAEMON_API_SPECIFIER] ?? req.headers[HYPERVISOR_API_SPECIFIER] ?? false; - // Also check query param: ?x-daemon-api=true if (!isDaemonAPI) { try { const url = new URL(req.url ?? "/", "http://localhost"); @@ -112,7 +100,7 @@ export function createDaemonMiddleware(opts: DaemonOptions) { } } - // Add CORS headers for admin.deco.cx + // CORS for admin.deco.cx. const origin = req.headers.origin; if (origin) { res.setHeader("Access-Control-Allow-Origin", origin); @@ -120,37 +108,21 @@ export function createDaemonMiddleware(opts: DaemonOptions) { res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-daemon-api, x-hypervisor-api"); res.setHeader("Access-Control-Allow-Credentials", "true"); } - - // Handle CORS preflight if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } - // Auth → then route + // Auth before any /fs/*, /watch, /volumes/*, admin protocol routes. auth(req, res, () => { - // FS REST API: /fs/file/* (read, patch, delete .deco/ files) - if (pathname.startsWith("/fs/")) { - fs(req, res, next); - return; - } - - // Volumes API: /volumes/:id/files/* + // Volumes API — TanStack-only, requires raw httpServer. if (pathname.includes("/volumes/") && pathname.includes("/files") && volumes) { volumes(req, res, next); return; } - - // SSE watch: /watch or root / - if (pathname === "/watch" || pathname === "/") { - watch(req, res, next); - return; - } - - // Everything else falls through to Vite/TanStack admin routes - // (e.g., /live/_meta, /.decofile, /live/previews, /deco/invoke) - next(); + // All other HTTP-shape routes flow through the Web-standard dispatcher. + webMiddleware(req, res, next); }); }; } diff --git a/src/tanstack/daemon/volumes.ts b/src/tanstack/daemon/volumes.ts index c2370a9f..6eb05c15 100644 --- a/src/tanstack/daemon/volumes.ts +++ b/src/tanstack/daemon/volumes.ts @@ -10,6 +10,7 @@ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node import { WebSocketServer, WebSocket } from "ws"; import fjp from "fast-json-patch"; import type { Operation } from "fast-json-patch"; +import { isIgnoredPath } from "../../node/daemon/ignored"; // --------------------------------------------------------------------------- // Types — ported from daemon/realtime/types.ts @@ -122,15 +123,7 @@ async function walkFiles( for (const entry of entries) { if (!entry.isFile()) continue; const fullPath = join(entry.parentPath, entry.name); - const rel = toPosix(fullPath.replace(root, "")); - if ( - rel.includes("/.git/") || - rel.includes("/node_modules/") || - rel.includes("/.agent-home/") || - rel.includes("/.claude/") - ) { - continue; - } + if (isIgnoredPath(fullPath)) continue; results.push(fullPath); } } catch { @@ -318,15 +311,8 @@ export function createVolumesHandler(opts: VolumesOptions) { // Broadcast file changes from Vite's watcher const broadcastChange = (filePath: string, deleted = false) => { + if (isIgnoredPath(filePath)) return; const rel = toPosix(filePath).replace(toPosix(cwd), ""); - if ( - rel.includes("/.git/") || - rel.includes("/node_modules/") || - rel.includes("/.agent-home/") || - rel.includes("/.claude/") - ) { - return; - } broadcast(state, { path: rel, timestamp: Date.now(), deleted }); }; diff --git a/src/tanstack/daemon/watch.ts b/src/tanstack/daemon/watch.ts index a37fa48d..91726f3e 100644 --- a/src/tanstack/daemon/watch.ts +++ b/src/tanstack/daemon/watch.ts @@ -1,150 +1,30 @@ /** - * SSE endpoint for file change events — initial sync + live updates. - * - * Ported from: deco-cx/deco daemon/sse/api.ts + daemon/sse/channel.ts + * Connect-style SSE handler — wraps the shared Web-standard `handleWatchSse` + * for Vite's middleware stack. New code should consume `handleWatchSse` + * directly from `src/node/daemon/watch-sse` and bind it via `toNodeMiddleware` + * (introduced in Task 9). */ -import { readdir, readFile, stat } from "node:fs/promises"; -import { join, sep } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; - -// --------------------------------------------------------------------------- -// Event types — simplified from daemon/fs/common.ts -// --------------------------------------------------------------------------- - -interface FSEvent { - type: "fs-sync" | "fs-snapshot" | "worker-status" | "meta-info"; - detail: Record; -} - -// --------------------------------------------------------------------------- -// Broadcast channel (EventTarget-based, same as daemon/sse/channel.ts) -// --------------------------------------------------------------------------- - -const channel = new EventTarget(); - -export function broadcastFSEvent(event: FSEvent): void { - channel.dispatchEvent(new CustomEvent("broadcast", { detail: event })); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const toPosix = (p: string) => p.replaceAll(sep, "/"); - -function shouldIgnore(path: string): boolean { - return ( - path.includes(`${sep}.git${sep}`) || - path.includes(`${sep}node_modules${sep}`) || - path.includes(`${sep}.agent-home${sep}`) || - path.includes(`${sep}.claude${sep}`) - ); -} +import { + broadcastFsEvent, + inferMetadata, + type FsEvent, + scanDecoFiles, + subscribeFsEvents, +} from "../../node/daemon/watch"; +import { bindWatcherToChannel } from "../../node/daemon/watcher"; + +export { + broadcastFsEvent as broadcastFSEvent, + inferMetadata, + type FsEvent, + type Metadata, +} from "../../node/daemon/watch"; /** - * Infer block type from __resolveType string. - * Maps to manifest block categories (pages, sections, loaders, etc.). + * Back-compat shim — TanStack daemon middleware still constructs this. + * The implementation reuses the new shared scanner + channel. */ -function inferBlockType(resolveType: string): string | null { - if (!resolveType) return null; - if (resolveType.includes("/pages/")) return "pages"; - if (resolveType.includes("/sections/")) return "sections"; - if (resolveType.includes("/loaders/")) return "loaders"; - if (resolveType.includes("/actions/")) return "actions"; - if (resolveType.includes("/matchers/")) return "matchers"; - if (resolveType.includes("/flags/")) return "sections"; - return null; -} - -export interface Metadata { - kind: "block" | "file"; - blockType?: string; - __resolveType?: string; - name?: string; - path?: string; -} - -/** - * Read a JSON file and infer its metadata (block type, resolveType, etc.). - * Matches the Deno daemon's inferMetadata from daemon/fs/api.ts. - */ -export async function inferMetadata(filepath: string): Promise { - try { - const raw = await readFile(filepath, "utf-8"); - const parsed = JSON.parse(raw); - const { __resolveType, name, path: pagePath } = parsed; - - if (!__resolveType) return { kind: "file" }; - - const blockType = inferBlockType(__resolveType); - if (!blockType) return { kind: "file" }; - - if (blockType === "pages") { - return { - kind: "block", - blockType, - __resolveType, - name: name ?? undefined, - path: pagePath ?? undefined, - }; - } - - return { kind: "block", blockType, __resolveType }; - } catch { - return { kind: "file" }; - } -} - -// --------------------------------------------------------------------------- -// Initial file scan — yields fs-sync events for each .deco/ file -// --------------------------------------------------------------------------- - -async function* scanFiles( - cwd: string, - since: number, -): AsyncGenerator { - const decoDir = join(cwd, ".deco"); - try { - const entries = await readdir(decoDir, { - recursive: true, - withFileTypes: true, - }); - for (const entry of entries) { - if (!entry.isFile()) continue; - const fullPath = join(entry.parentPath, entry.name); - if (shouldIgnore(fullPath)) continue; - - let mtime: number; - try { - const stats = await stat(fullPath); - mtime = stats.mtimeMs; - } catch { - mtime = Date.now(); - } - - if (mtime < since) continue; - - const metadata = await inferMetadata(fullPath); - const filepath = toPosix(fullPath.replace(cwd, "")); - yield { - type: "fs-sync", - detail: { metadata, filepath, timestamp: mtime }, - }; - } - } catch { - // .deco dir might not exist yet - } - - yield { - type: "fs-snapshot", - detail: { timestamp: Date.now() }, - }; -} - -// --------------------------------------------------------------------------- -// SSE handler — Connect-style middleware -// --------------------------------------------------------------------------- - export function createWatchHandler(opts?: { getPort?: () => number }) { const cwd = process.cwd(); const getPort = opts?.getPort ?? (() => 5173); @@ -155,19 +35,9 @@ export function createWatchHandler(opts?: { getPort?: () => number }) { next: () => void, ): Promise => { const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== "/watch" && url.pathname !== "/") return next(); + if (req.method !== "GET") return next(); - // Only handle /watch or the root SSE endpoint - if (url.pathname !== "/watch" && url.pathname !== "/") { - next(); - return; - } - - if (req.method !== "GET") { - next(); - return; - } - - // SSE headers res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -177,96 +47,41 @@ export function createWatchHandler(opts?: { getPort?: () => number }) { const since = Number(url.searchParams.get("since")) || 0; let closed = false; - req.on("close", () => { - closed = true; - console.log("[deco] SSE stream closed"); - }); - - function sendEvent(event: FSEvent): void { + const sendEvent = (event: FsEvent) => { if (closed) return; const data = encodeURIComponent(JSON.stringify(event)); res.write(`event: message\ndata: ${data}\n\n`); - } - - // Live broadcast listener - const handler = (e: Event) => { - const ce = e as CustomEvent; - sendEvent(ce.detail); }; - channel.addEventListener("broadcast", handler); + + const unsubscribe = subscribeFsEvents(sendEvent); req.on("close", () => { - channel.removeEventListener("broadcast", handler); + closed = true; + unsubscribe(); }); - console.log("[deco] SSE stream opened"); - - // Initial scan - for await (const event of scanFiles(cwd, since)) { + for await (const event of scanDecoFiles(cwd, since)) { if (closed) break; sendEvent(event); } - if (closed) return; - // Worker status — Vite dev server is always ready - sendEvent({ - type: "worker-status", - detail: { state: "ready" }, - }); + sendEvent({ type: "worker-status", detail: { state: "ready" } }); - // Meta info — schema + manifest so admin knows about sections/loaders/actions. - // Fetch via HTTP so the request goes through Vite SSR where the data lives - // (daemon's native imports create separate module instances). try { const metaResponse = await fetch(`http://localhost:${getPort()}/live/_meta`); if (metaResponse.ok) { const metaData = await metaResponse.json(); - sendEvent({ - type: "meta-info", - detail: { ...metaData, timestamp: Date.now() }, - }); + sendEvent({ type: "meta-info", detail: { ...metaData, timestamp: Date.now() } }); } } catch { - // Schema may not be initialized yet — admin will retry via /live/_meta + // schema not initialised yet } }; } -// --------------------------------------------------------------------------- -// Wire Vite watcher to broadcast channel -// --------------------------------------------------------------------------- - +/** Back-compat — TanStack daemon binds Vite's watcher into the shared channel here. */ export function watchFS(watcher: { on(event: string, cb: (...args: unknown[]) => void): void; }): void { - const cwd = process.cwd(); - - const onChange = async (filePath: unknown, deleted = false) => { - if (typeof filePath !== "string") return; - if (shouldIgnore(filePath)) return; - - const metadata = deleted ? null : await inferMetadata(filePath); - let mtime = Date.now(); - if (!deleted) { - try { - const stats = await stat(filePath); - mtime = stats.mtimeMs; - } catch { - // use Date.now() - } - } - - broadcastFSEvent({ - type: "fs-sync", - detail: { - metadata, - filepath: toPosix(filePath.replace(cwd, "")), - timestamp: mtime, - }, - }); - }; - - watcher.on("change", (path: unknown) => onChange(path)); - watcher.on("add", (path: unknown) => onChange(path)); - watcher.on("unlink", (path: unknown) => onChange(path, true)); + bindWatcherToChannel(watcher); } diff --git a/src/tanstack/sdk/router.ts b/src/tanstack/sdk/router.ts index 0a1e18a6..62346362 100644 --- a/src/tanstack/sdk/router.ts +++ b/src/tanstack/sdk/router.ts @@ -46,6 +46,43 @@ export interface CreateDecoRouterOptions { routeTree: AnyRoute; scrollRestoration?: boolean; defaultPreload?: "intent" | "viewport" | "render" | false; + /** + * How long a preloaded route stays "fresh" before a click re-fetches it. + * When using `defaultPreload: "intent"`, this is what makes hover → click + * navigation truly instant. Without it, TanStack uses a short default and + * the prefetched data may be considered stale by the time the user clicks. + * + * Recommended for commerce storefronts: 60_000 (1 minute). + * @default undefined (TanStack default — short) + */ + defaultPreloadStaleTime?: number; + /** + * How long a preloaded route stays in memory before garbage collection. + * @default undefined (TanStack default) + */ + defaultPreloadGcTime?: number; + /** + * Delay before firing a preload after `hover`/`touchstart`. + * @default undefined (TanStack default — ~50ms) + */ + defaultPreloadDelay?: number; + /** + * Default staleTime applied to all route loaders (not just preload). + * @default undefined (TanStack default — 0) + */ + defaultStaleTime?: number; + /** + * Milliseconds to wait before showing the pending component on slow + * navigations. Useful when `eager` sections block the route swap. + * @default undefined (TanStack default) + */ + defaultPendingMs?: number; + /** + * Minimum milliseconds the pending component must be shown once revealed. + * Prevents flash if the loader resolves right after the pending UI appears. + * @default undefined (TanStack default) + */ + defaultPendingMinMs?: number; trailingSlash?: TrailingSlashOption; /** * Router context — passed to all route loaders/components via routeContext. @@ -68,12 +105,22 @@ export interface CreateDecoRouterOptions { * - URLSearchParams-based search serialization (not JSON) * - Scroll restoration enabled * - Preload on intent + * + * For commerce storefronts, pair `defaultPreload: "intent"` (default) with + * `defaultPreloadStaleTime: 60_000` so hover prefetch is reused on click — + * see the `deco-pdp-fast-navigation` skill for the full pattern. */ export function createDecoRouter(options: CreateDecoRouterOptions) { const { routeTree, scrollRestoration = true, defaultPreload = "intent", + defaultPreloadStaleTime, + defaultPreloadGcTime, + defaultPreloadDelay, + defaultStaleTime, + defaultPendingMs, + defaultPendingMinMs, trailingSlash, context, Wrap, @@ -83,6 +130,12 @@ export function createDecoRouter(options: CreateDecoRouterOptions) { routeTree, scrollRestoration, defaultPreload, + defaultPreloadStaleTime, + defaultPreloadGcTime, + defaultPreloadDelay, + defaultStaleTime, + defaultPendingMs, + defaultPendingMinMs, trailingSlash, context: context as any, Wrap, diff --git a/tsup.config.ts b/tsup.config.ts index 825b6743..d0392df0 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -203,6 +203,8 @@ export default defineConfig([ "src/next/*.tsx", "src/node/index.ts", "src/node/*.ts", + "src/node/daemon/index.ts", + "src/node/daemon/*.ts", ], format: ["esm", "cjs"], dts: false,