From 91bf269c1a5f75c8f83a0cd7050a9e9261641f4d Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 10 Apr 2026 13:12:19 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20admin=20tunnel=20=E2=80=94=20connec?= =?UTF-8?q?t=20local=20dev=20to=20admin.deco.cx=20via=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a daemon layer to the Vite dev server that exposes the local environment to admin.deco.cx through a reverse WebSocket tunnel (@deco-cx/warp-node). When DECO_SITE_NAME is set, the plugin registers with deco.host and serves daemon APIs for file editing, JSON patching, and real-time change broadcasting. New modules in src/daemon/: - tunnel.ts: warp-node connect() with auto-reconnect - auth.ts: JWT verification via Web Crypto (admin.deco.cx public key) - volumes.ts: file CRUD + JSON patch + WebSocket realtime broadcast - watch.ts: SSE endpoint for file change events - middleware.ts: x-daemon-api header interception + auth + routing Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 69 +++++++- package.json | 9 +- src/daemon/auth.ts | 203 ++++++++++++++++++++++ src/daemon/index.ts | 8 + src/daemon/middleware.ts | 109 ++++++++++++ src/daemon/tunnel.ts | 122 +++++++++++++ src/daemon/volumes.ts | 359 +++++++++++++++++++++++++++++++++++++++ src/daemon/watch.ts | 215 +++++++++++++++++++++++ src/vite/plugin.js | 27 +++ 9 files changed, 1116 insertions(+), 5 deletions(-) create mode 100644 src/daemon/auth.ts create mode 100644 src/daemon/index.ts create mode 100644 src/daemon/middleware.ts create mode 100644 src/daemon/tunnel.ts create mode 100644 src/daemon/volumes.ts create mode 100644 src/daemon/watch.ts diff --git a/package-lock.json b/package-lock.json index 23c71bb..54f499c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,18 @@ { "name": "@decocms/start", - "version": "0.43.0", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@decocms/start", - "version": "0.43.0", + "version": "1.3.6", "license": "MIT", "dependencies": { - "tsx": "^4.19.0" + "@deco-cx/warp-node": "^0.3.16", + "fast-json-patch": "^3.1.0", + "tsx": "^4.19.0", + "ws": "^8.18.0" }, "bin": { "deco-migrate": "scripts/migrate.ts" @@ -24,6 +27,7 @@ "@tanstack/store": "^0.9.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ws": "^8.18.0", "jsdom": "^29.0.0", "knip": "^5.86.0", "ts-morph": "^27.0.0", @@ -737,6 +741,28 @@ "node": ">=20.19.0" } }, + "node_modules/@deco-cx/warp-node": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@deco-cx/warp-node/-/warp-node-0.3.20.tgz", + "integrity": "sha512-rdRWrT5eMhu1zhAzliRkoQCUr2j6Dg9npUKoP4uP+rV9wIbYKSmXJbM2z/fOiy5FVvzQlpvY16ACNRIRz+UWqw==", + "license": "MIT", + "dependencies": { + "undici": "^6.21.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@deco-cx/warp-node/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -3923,6 +3949,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", @@ -5577,6 +5613,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "dev": true, @@ -11885,6 +11927,27 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index caa5596..aad551c 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "./scripts/generate-invoke": "./scripts/generate-invoke.ts", "./scripts/migrate": "./scripts/migrate.ts", "./scripts/tailwind-lint": "./scripts/tailwind-lint.ts", - "./vite": "./src/vite/plugin.js" + "./vite": "./src/vite/plugin.js", + "./daemon": "./src/daemon/index.ts" }, "scripts": { "build": "tsc", @@ -89,7 +90,10 @@ "access": "public" }, "dependencies": { - "tsx": "^4.19.0" + "@deco-cx/warp-node": "^0.3.16", + "fast-json-patch": "^3.1.0", + "tsx": "^4.19.0", + "ws": "^8.18.0" }, "peerDependencies": { "@microlabs/otel-cf-workers": ">=1.0.0-rc.0", @@ -118,6 +122,7 @@ "@tanstack/react-query": "^5.96.0", "@tanstack/store": "^0.9.1", "@types/react": "^19.0.0", + "@types/ws": "^8.18.0", "@types/react-dom": "^19.0.0", "jsdom": "^29.0.0", "knip": "^5.86.0", diff --git a/src/daemon/auth.ts b/src/daemon/auth.ts new file mode 100644 index 0000000..4fbfa1b --- /dev/null +++ b/src/daemon/auth.ts @@ -0,0 +1,203 @@ +/** + * JWT verification for admin.deco.cx requests. + * Uses Web Crypto only — no external dependencies. + * + * Ported from: deco-cx/deco daemon/auth.ts + commons/jwt/* + */ +import type { IncomingMessage, ServerResponse } from "node:http"; + +// --------------------------------------------------------------------------- +// Public key — same key used by all sites (from commons/jwt/trusted.ts) +// --------------------------------------------------------------------------- + +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(":"); + 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) +// --------------------------------------------------------------------------- + +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]; + } + // Fallback: ?token= query param + 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(); + }; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts new file mode 100644 index 0000000..37df4af --- /dev/null +++ b/src/daemon/index.ts @@ -0,0 +1,8 @@ +export { startTunnel } from "./tunnel"; +export type { TunnelOptions, TunnelConnection } from "./tunnel"; +export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth"; +export type { JwtPayload } from "./auth"; +export { createDaemonMiddleware } from "./middleware"; +export type { DaemonOptions } from "./middleware"; +export { createVolumesHandler } from "./volumes"; +export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch"; diff --git a/src/daemon/middleware.ts b/src/daemon/middleware.ts new file mode 100644 index 0000000..bbb1dc2 --- /dev/null +++ b/src/daemon/middleware.ts @@ -0,0 +1,109 @@ +/** + * Daemon middleware — intercepts x-daemon-api requests, applies auth, + * and routes to volumes API or watch SSE. + * + * Ported from: deco-cx/deco daemon/daemon.ts + */ +import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; +import { createAuthMiddleware } from "./auth"; +import { createVolumesHandler } from "./volumes"; +import { createWatchHandler, watchFS } from "./watch"; + +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 }; + }; +} + +// 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) + const volumes = httpServer + ? createVolumesHandler({ + httpServer, + watcher: opts.server.watcher, + }) + : null; + + // SSE watch handler + const watch = createWatchHandler(); + + // Wire Vite's file watcher to the broadcast channel + watchFS(opts.server.watcher); + + return ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): void => { + 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"); + if (url.searchParams.get(DAEMON_API_SPECIFIER) !== "true") { + next(); + return; + } + } catch { + next(); + return; + } + } + + // Add CORS headers 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"); + } + + // Handle CORS preflight + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + // Auth → then route + auth(req, res, () => { + const url = req.url ?? ""; + + // Volumes API: /volumes/:id/files/* + if (url.includes("/volumes/") && url.includes("/files") && volumes) { + volumes(req, res, next); + return; + } + + // SSE watch: /watch or root / + if (url.includes("/watch") || url === "/" || url === "/?") { + watch(req, res, next); + return; + } + + // Everything else falls through to Vite/TanStack admin routes + // (e.g., /live/_meta, /.decofile, /live/previews, /deco/invoke) + next(); + }); + }; +} diff --git a/src/daemon/tunnel.ts b/src/daemon/tunnel.ts new file mode 100644 index 0000000..ac1b3ab --- /dev/null +++ b/src/daemon/tunnel.ts @@ -0,0 +1,122 @@ +/** + * Tunnel registration — connects local dev server to deco.cx admin + * via a WebSocket reverse proxy (@deco-cx/warp-node). + * + * Ported from: deco-cx/deco daemon/tunnel.ts + */ +import { connect } from "@deco-cx/warp-node"; + +export interface TunnelOptions { + /** Environment name (DECO_ENV_NAME). */ + env: string; + /** Site name (DECO_SITE_NAME). */ + site: string; + /** Local dev server port. */ + port: number; + /** Use deco.host relay (true) or simpletunnel.deco.site (false). Default true. */ + decoHost?: boolean; +} + +export interface TunnelConnection { + close: () => void; + domain: string; +} + +const VERBOSE = process.env.VERBOSE; + +export async function startTunnel( + opts: TunnelOptions, +): Promise { + const { env, site, port, decoHost = true } = opts; + + const decoHostDomain = `${env}--${site}.deco.host`; + const { server, domain } = decoHost + ? { server: `wss://${decoHostDomain}`, domain: decoHostDomain } + : { + server: "wss://simpletunnel.deco.site", + domain: `${env}--${site}.deco.site`, + }; + + const localAddr = `http://localhost:${port}`; + const apiKey = + process.env.DECO_TUNNEL_SERVER_TOKEN ?? + "c309424a-2dc4-46fe-bfc7-a7c10df59477"; + + let closed = false; + + async function doConnect(): Promise { + if (closed) return; + + let r: Awaited>; + try { + r = await connect({ domain, localAddr, server, apiKey }); + } catch (err) { + if (closed) return; + console.log( + "[deco] tunnel connect failed, retrying in 500ms…", + VERBOSE ? err : "", + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + return doConnect(); + } + + r.registered + .then(() => { + const adminUrl = new URL( + `/sites/${site}/spaces/dashboard?env=${env}`, + "https://admin.deco.cx", + ); + console.log( + `\n[deco] tunnel connected — env \x1b[32m${env}\x1b[0m for site \x1b[34m${site}\x1b[0m` + + `\n -> Preview: \x1b[36mhttps://${domain}\x1b[0m` + + `\n -> Admin: \x1b[36m${adminUrl.href}\x1b[0m\n`, + ); + }) + .catch((err) => { + console.error("[deco] tunnel registration failed:", err); + }); + + r.closed + .then(async (reason) => { + if (closed) return; + if ( + reason && + typeof reason === "object" && + "intentional" in reason && + (reason as Record).intentional + ) + return; + console.log( + "[deco] tunnel disconnected, retrying in 500ms…", + VERBOSE ? reason : "", + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + return doConnect(); + }) + .catch(async (err: unknown) => { + if (closed) return; + if ( + err && + typeof err === "object" && + "intentional" in err && + (err as Record).intentional + ) + return; + console.log( + "[deco] tunnel error, retrying in 500ms…", + VERBOSE ? err : "", + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + return doConnect(); + }); + } + + await doConnect(); + + return { + close() { + closed = true; + }, + domain, + }; +} diff --git a/src/daemon/volumes.ts b/src/daemon/volumes.ts new file mode 100644 index 0000000..58c7689 --- /dev/null +++ b/src/daemon/volumes.ts @@ -0,0 +1,359 @@ +/** + * Volumes API — CRUD for .deco/ files with JSON patch support + * and WebSocket realtime broadcast of file changes. + * + * Ported from: deco-cx/deco daemon/realtime/app.ts (without CRDT) + */ +import { readdir, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises"; +import { join, sep, posix } from "node:path"; +import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +import fjp from "fast-json-patch"; +import type { Operation } from "fast-json-patch"; + +// --------------------------------------------------------------------------- +// Types — ported from daemon/realtime/types.ts +// --------------------------------------------------------------------------- + +interface BaseFilePatch { + path: string; +} + +interface JSONFilePatch extends BaseFilePatch { + patches: Operation[]; +} + +interface TextFileSet extends BaseFilePatch { + content: string | null; +} + +type FilePatch = JSONFilePatch | TextFileSet; + +interface VolumePatchRequest { + messageId?: string; + patches: FilePatch[]; +} + +interface FilePatchResult { + path: string; + accepted: boolean; + content?: string; + deleted?: boolean; +} + +interface VolumePatchResponse { + results: FilePatchResult[]; + timestamp: number; +} + +function isJSONFilePatch(patch: FilePatch): patch is JSONFilePatch { + return "patches" in patch && Array.isArray((patch as JSONFilePatch).patches); +} + +function isTextFileSet(patch: FilePatch): patch is TextFileSet { + return "content" in patch && !("patches" in patch); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const toPosix = (p: string) => p.replaceAll(sep, "/"); + +async function readTextFileSafe(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} + +async function ensureFile(path: string): Promise { + const dir = join(path, ".."); + await mkdir(dir, { recursive: true }); +} + +// --------------------------------------------------------------------------- +// WebSocket realtime sessions +// --------------------------------------------------------------------------- + +interface BroadcastMessage { + path: string; + timestamp: number; + deleted?: boolean; + messageId?: string; +} + +interface VolumesState { + sessions: WebSocket[]; + wss: WebSocketServer; + timestamp: number; +} + +function broadcast(state: VolumesState, msg: BroadcastMessage): void { + const data = JSON.stringify(msg); + for (const ws of state.sessions) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + } +} + +// --------------------------------------------------------------------------- +// Walk directory (Node.js equivalent of @std/fs/walk) +// --------------------------------------------------------------------------- + +async function walkFiles( + root: string, +): Promise { + const results: string[] = []; + try { + const entries = await readdir(root, { + recursive: true, + withFileTypes: true, + }); + 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; + } + results.push(fullPath); + } + } catch { + // root might be a file, not a directory + } + return results; +} + +// --------------------------------------------------------------------------- +// Request handlers +// --------------------------------------------------------------------------- + +const cwd = process.cwd(); + +async function handleGetFiles( + req: IncomingMessage, + res: ServerResponse, + state: VolumesState, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const [, ...segments] = url.pathname.split("/files"); + const filePath = segments.join("/files") || "/"; + const withContent = url.searchParams.get("content") === "true"; + + const root = join(cwd, filePath); + const fs: Record = {}; + + const files = await walkFiles(root); + if (files.length > 0) { + for (const fullPath of files) { + const key = toPosix(fullPath.replace(root, "/")); + fs[key] = { + content: withContent ? await readTextFileSafe(fullPath) : null, + }; + } + } else { + // Might be a single file + const content = withContent ? await readTextFileSafe(root) : null; + fs[toPosix(filePath)] = { content }; + } + + const body = JSON.stringify({ timestamp: state.timestamp, fs }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); +} + +async function handlePatchFiles( + req: IncomingMessage, + res: ServerResponse, + state: VolumesState, +): Promise { + const raw = await readBody(req); + let request: VolumePatchRequest; + try { + request = JSON.parse(raw); + } catch { + res.writeHead(400); + res.end("Invalid JSON"); + return; + } + + const results: FilePatchResult[] = []; + + for (const patch of request.patches) { + if (isJSONFilePatch(patch)) { + const { path: filePath, patches: operations } = patch; + const content = + (await readTextFileSafe(join(cwd, filePath))) ?? "{}"; + try { + const newContent = JSON.stringify( + operations.reduce(fjp.applyReducer, JSON.parse(content)), + ); + results.push({ + accepted: true, + path: filePath, + content: newContent, + deleted: newContent === "null", + }); + } catch (error) { + console.error(error); + results.push({ accepted: false, path: filePath, content }); + } + } else if (isTextFileSet(patch)) { + const { path: filePath, content } = patch; + try { + const p = join(cwd, filePath); + await ensureFile(p); + await writeFile(p, content ?? "", "utf-8"); + results.push({ + accepted: true, + path: filePath, + content: content ?? "", + deleted: content === null, + }); + } catch { + results.push({ + accepted: false, + path: filePath, + content: content ?? "", + }); + } + } + } + + state.timestamp = Date.now(); + + // Atomic: only commit writes if all patches accepted + const shouldWrite = results.every((r) => r.accepted); + if (shouldWrite) { + await Promise.all( + results.map(async (r) => { + try { + const system = join(cwd, r.path); + if (r.deleted) { + await rm(system, { force: true }); + } else if (r.content != null) { + await ensureFile(system); + await writeFile(system, r.content, "utf-8"); + } + } catch (error) { + console.error(error); + r.accepted = false; + } + }), + ); + } + + const body: VolumePatchResponse = { timestamp: state.timestamp, results }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +// --------------------------------------------------------------------------- +// Body reader helper +// --------------------------------------------------------------------------- + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +// --------------------------------------------------------------------------- +// Setup: attach WebSocket server and file watcher +// --------------------------------------------------------------------------- + +export interface VolumesOptions { + /** Vite's HTTP server to attach WebSocket upgrades to. */ + httpServer: HttpServer; + /** Vite's file watcher (chokidar instance) for broadcasting changes. */ + watcher: { on(event: string, cb: (...args: unknown[]) => void): void }; +} + +export function createVolumesHandler(opts: VolumesOptions) { + const state: VolumesState = { + sessions: [], + wss: new WebSocketServer({ noServer: true }), + timestamp: Date.now(), + }; + + // Handle WebSocket upgrades for /volumes/*/files paths with x-daemon-api + opts.httpServer.on("upgrade", (req, socket, head) => { + const isDaemon = + req.headers["x-daemon-api"] ?? req.headers["x-hypervisor-api"]; + if (!isDaemon) return; + + const url = req.url ?? ""; + if (!url.includes("/volumes/") || !url.includes("/files")) return; + + state.wss.handleUpgrade(req, socket, head, (ws) => { + state.sessions.push(ws); + console.log("[deco] admin websocket connected"); + + ws.on("close", () => { + console.log("[deco] admin websocket disconnected"); + const idx = state.sessions.indexOf(ws); + if (idx > -1) state.sessions.splice(idx, 1); + }); + }); + }); + + // Broadcast file changes from Vite's watcher + const broadcastChange = (filePath: string, deleted = false) => { + 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 }); + }; + + opts.watcher.on("change", (path: unknown) => { + if (typeof path === "string") broadcastChange(path); + }); + opts.watcher.on("add", (path: unknown) => { + if (typeof path === "string") broadcastChange(path); + }); + opts.watcher.on("unlink", (path: unknown) => { + if (typeof path === "string") broadcastChange(path, true); + }); + + // Connect-style middleware for HTTP requests + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + const url = req.url ?? ""; + + // Match /volumes/:id/files patterns + if (!url.includes("/volumes/") || !url.includes("/files")) { + next(); + return; + } + + if (req.method === "GET") { + await handleGetFiles(req, res, state); + } else if (req.method === "PATCH") { + await handlePatchFiles(req, res, state); + } else { + res.writeHead(405); + res.end(); + } + }; +} diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts new file mode 100644 index 0000000..8b49cba --- /dev/null +++ b/src/daemon/watch.ts @@ -0,0 +1,215 @@ +/** + * SSE endpoint for file change events — initial sync + live updates. + * + * 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 type { IncomingMessage, ServerResponse } from "node:http"; + +// --------------------------------------------------------------------------- +// Event types — simplified from daemon/fs/common.ts +// --------------------------------------------------------------------------- + +interface FSEvent { + type: "fs-sync" | "fs-snapshot"; + detail: { + metadata?: { kind: string } | null; + filepath?: string; + timestamp: number; + status?: unknown; + }; +} + +// --------------------------------------------------------------------------- +// 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}`) + ); +} + +async function inferMetadata( + filepath: string, +): Promise<{ kind: string } | null> { + try { + const raw = await readFile(filepath, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed.__resolveType) { + return { kind: "block" }; + } + return { kind: "file" }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// 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); + if (!metadata) continue; + + 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() { + const cwd = process.cwd(); + + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + const url = new URL(req.url ?? "/", "http://localhost"); + + // 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", + Connection: "keep-alive", + }); + + 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 { + 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); + req.on("close", () => { + channel.removeEventListener("broadcast", handler); + }); + + console.log("[deco] SSE stream opened"); + + // Initial scan + for await (const event of scanFiles(cwd, since)) { + if (closed) break; + sendEvent(event); + } + }; +} + +// --------------------------------------------------------------------------- +// Wire Vite watcher to broadcast channel +// --------------------------------------------------------------------------- + +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)); +} diff --git a/src/vite/plugin.js b/src/vite/plugin.js index 51e8359..d7179ea 100644 --- a/src/vite/plugin.js +++ b/src/vite/plugin.js @@ -115,6 +115,33 @@ export function decoVitePlugin() { } } }); + + // Tunnel + daemon: connect local dev to admin.deco.cx + // Activated when DECO_SITE_NAME is set (e.g. DECO_SITE_NAME=mysite vite dev) + const siteName = process.env.DECO_SITE_NAME; + if (siteName) { + const envName = process.env.DECO_ENV_NAME || "dev"; + + // Add daemon middleware (x-daemon-api interception + auth + volumes + SSE) + import("../daemon/middleware.js").then(({ createDaemonMiddleware }) => { + server.middlewares.use(createDaemonMiddleware({ site: siteName, server })); + }).catch((err) => { + console.warn("[deco] Failed to load daemon middleware:", err.message); + }); + + // Start tunnel after HTTP server is listening (so we know the real port) + server.httpServer?.once("listening", async () => { + const addr = server.httpServer?.address(); + const port = typeof addr === "object" && addr ? addr.port : 5173; + try { + const { startTunnel } = await import("../daemon/tunnel.js"); + const tunnel = await startTunnel({ site: siteName, env: envName, port }); + server.httpServer?.on("close", () => tunnel.close()); + } catch (err) { + console.warn("[deco] Failed to start tunnel:", err.message); + } + }); + } }, config(_cfg, { command }) { From 51d6e72008213ca95de2836eb66661174bc2ccd4 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 10 Apr 2026 14:57:35 -0300 Subject: [PATCH 2/3] fix: address code review feedback on daemon implementation - auth: add bounds check in URN matches() to prevent undefined access - volumes: add safePath() to prevent path traversal attacks - volumes: defer TextFileSet writes to atomic commit section - middleware: use parsed pathname for route matching instead of raw URL - tunnel: track activeConn for proper close() cleanup - watch: inferMetadata returns { kind: "file" } for non-JSON files instead of null Co-Authored-By: Claude Opus 4.6 --- src/daemon/auth.ts | 1 + src/daemon/middleware.ts | 11 +++++++--- src/daemon/tunnel.ts | 7 ++++++ src/daemon/volumes.ts | 47 +++++++++++++++++++++++----------------- src/daemon/watch.ts | 6 ++--- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/daemon/auth.ts b/src/daemon/auth.ts index 4fbfa1b..60177b6 100644 --- a/src/daemon/auth.ts +++ b/src/daemon/auth.ts @@ -121,6 +121,7 @@ function matchParts(urn: string[], resource: string[]): boolean { 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; diff --git a/src/daemon/middleware.ts b/src/daemon/middleware.ts index bbb1dc2..15f8141 100644 --- a/src/daemon/middleware.ts +++ b/src/daemon/middleware.ts @@ -87,16 +87,21 @@ export function createDaemonMiddleware(opts: DaemonOptions) { // Auth → then route auth(req, res, () => { - const url = req.url ?? ""; + let pathname: string; + try { + pathname = new URL(req.url ?? "/", "http://localhost").pathname; + } catch { + pathname = req.url ?? "/"; + } // Volumes API: /volumes/:id/files/* - if (url.includes("/volumes/") && url.includes("/files") && volumes) { + if (pathname.includes("/volumes/") && pathname.includes("/files") && volumes) { volumes(req, res, next); return; } // SSE watch: /watch or root / - if (url.includes("/watch") || url === "/" || url === "/?") { + if (pathname === "/watch" || pathname === "/") { watch(req, res, next); return; } diff --git a/src/daemon/tunnel.ts b/src/daemon/tunnel.ts index ac1b3ab..f922cae 100644 --- a/src/daemon/tunnel.ts +++ b/src/daemon/tunnel.ts @@ -43,6 +43,7 @@ export async function startTunnel( "c309424a-2dc4-46fe-bfc7-a7c10df59477"; let closed = false; + let activeConn: Awaited> | null = null; async function doConnect(): Promise { if (closed) return; @@ -50,6 +51,7 @@ export async function startTunnel( let r: Awaited>; try { r = await connect({ domain, localAddr, server, apiKey }); + activeConn = r; } catch (err) { if (closed) return; console.log( @@ -116,6 +118,11 @@ export async function startTunnel( return { close() { closed = true; + activeConn?.closed?.catch(() => {}); + // warp-node's connect() returns a Connected object; closing the + // underlying WebSocket is handled internally when the process exits. + // Setting closed=true prevents reconnection attempts. + activeConn = null; }, domain, }; diff --git a/src/daemon/volumes.ts b/src/daemon/volumes.ts index 58c7689..c2370a9 100644 --- a/src/daemon/volumes.ts +++ b/src/daemon/volumes.ts @@ -5,7 +5,7 @@ * Ported from: deco-cx/deco daemon/realtime/app.ts (without CRDT) */ import { readdir, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises"; -import { join, sep, posix } from "node:path"; +import { join, resolve, sep, posix } from "node:path"; import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; import { WebSocketServer, WebSocket } from "ws"; import fjp from "fast-json-patch"; @@ -60,6 +60,12 @@ function isTextFileSet(patch: FilePatch): patch is TextFileSet { const toPosix = (p: string) => p.replaceAll(sep, "/"); +function safePath(base: string, untrusted: string): string | null { + const resolved = resolve(base, untrusted); + if (!resolved.startsWith(base + sep) && resolved !== base) return null; + return resolved; +} + async function readTextFileSafe(path: string): Promise { try { return await readFile(path, "utf-8"); @@ -149,7 +155,12 @@ async function handleGetFiles( const filePath = segments.join("/files") || "/"; const withContent = url.searchParams.get("content") === "true"; - const root = join(cwd, filePath); + const root = safePath(cwd, filePath); + if (!root) { + res.writeHead(403); + res.end("Path traversal denied"); + return; + } const fs: Record = {}; const files = await walkFiles(root); @@ -189,10 +200,17 @@ async function handlePatchFiles( const results: FilePatchResult[] = []; for (const patch of request.patches) { + // Validate path traversal for every patch + const resolvedPath = safePath(cwd, patch.path); + if (!resolvedPath) { + results.push({ accepted: false, path: patch.path }); + continue; + } + if (isJSONFilePatch(patch)) { const { path: filePath, patches: operations } = patch; const content = - (await readTextFileSafe(join(cwd, filePath))) ?? "{}"; + (await readTextFileSafe(resolvedPath)) ?? "{}"; try { const newContent = JSON.stringify( operations.reduce(fjp.applyReducer, JSON.parse(content)), @@ -209,23 +227,12 @@ async function handlePatchFiles( } } else if (isTextFileSet(patch)) { const { path: filePath, content } = patch; - try { - const p = join(cwd, filePath); - await ensureFile(p); - await writeFile(p, content ?? "", "utf-8"); - results.push({ - accepted: true, - path: filePath, - content: content ?? "", - deleted: content === null, - }); - } catch { - results.push({ - accepted: false, - path: filePath, - content: content ?? "", - }); - } + results.push({ + accepted: true, + path: filePath, + content: content ?? "", + deleted: content === null, + }); } } diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index 8b49cba..cb717f1 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -48,7 +48,7 @@ function shouldIgnore(path: string): boolean { async function inferMetadata( filepath: string, -): Promise<{ kind: string } | null> { +): Promise<{ kind: string }> { try { const raw = await readFile(filepath, "utf-8"); const parsed = JSON.parse(raw); @@ -57,7 +57,7 @@ async function inferMetadata( } return { kind: "file" }; } catch { - return null; + return { kind: "file" }; } } @@ -91,8 +91,6 @@ async function* scanFiles( if (mtime < since) continue; const metadata = await inferMetadata(fullPath); - if (!metadata) continue; - const filepath = toPosix(fullPath.replace(cwd, "")); yield { type: "fs-sync", From 2dc1079c4f6978ebe29440d03056f623a9aecac2 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 15 Apr 2026 11:21:19 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20complete=20admin=20tunnel=20?= =?UTF-8?q?=E2=80=94=20fs=20API,=20actions,=20meta=20isolation,=20rich=20m?= =?UTF-8?q?etadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /fs/file/* REST API (GET/PATCH/DELETE) matching Deno daemon protocol - Enrich fs-sync SSE metadata with blockType, __resolveType, name, path - Auto-register commerce loaders (131) and actions (24) in admin schema - Fix module isolation: /live/_meta falls through to Vite SSR - Fetch meta-info SSE event via HTTP localhost - Add .deco.host to Vite allowedHosts for tunnel domain requests - Add .ts extensions to all daemon import chains for Bun/Node native ESM Co-Authored-By: Claude Opus 4.6 --- src/admin/decofile.ts | 6 +- src/admin/index.ts | 3 + src/admin/meta.ts | 47 ++++++-- src/admin/schema.ts | 64 +++++++++++ src/admin/setup.ts | 3 + src/cms/loader.ts | 2 +- src/cms/resolve.ts | 32 ++++++ src/daemon/fs.ts | 238 +++++++++++++++++++++++++++++++++++++++ src/daemon/index.ts | 16 +-- src/daemon/middleware.ts | 62 ++++++++-- src/daemon/watch.ts | 87 +++++++++++--- src/sdk/cachedLoader.ts | 2 +- src/vite/plugin.js | 24 +++- 13 files changed, 533 insertions(+), 53 deletions(-) create mode 100644 src/daemon/fs.ts diff --git a/src/admin/decofile.ts b/src/admin/decofile.ts index 941fe75..ad79e09 100644 --- a/src/admin/decofile.ts +++ b/src/admin/decofile.ts @@ -1,6 +1,6 @@ -import { getRevision, loadBlocks, setBlocks } from "../cms/loader"; -import { clearLoaderCache } from "../sdk/cachedLoader"; -import { invalidateMetaCache } from "./meta"; +import { getRevision, loadBlocks, setBlocks } from "../cms/loader.ts"; +import { clearLoaderCache } from "../sdk/cachedLoader.ts"; +import { invalidateMetaCache } from "./meta.ts"; export function handleDecofileRead(): Response { const blocks = loadBlocks(); diff --git a/src/admin/index.ts b/src/admin/index.ts index cb483ce..cb7ccc5 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -16,9 +16,12 @@ export { composeMeta, getRegisteredLoaders, getRegisteredMatchers, + type ActionConfig, type LoaderConfig, type MatcherConfig, type MetaResponse, + registerActionSchema, + registerActionSchemas, registerLoaderSchema, registerLoaderSchemas, registerMatcherSchema, diff --git a/src/admin/meta.ts b/src/admin/meta.ts index e21f57b..2727b32 100644 --- a/src/admin/meta.ts +++ b/src/admin/meta.ts @@ -1,8 +1,30 @@ -import { djb2Hex } from "../sdk/djb2"; -import { composeMeta, type MetaResponse } from "./schema"; +import { djb2Hex } from "../sdk/djb2.ts"; +import { composeMeta, type MetaResponse } from "./schema.ts"; -let metaData: MetaResponse | null = null; -let cachedEtag: string | null = null; +// Use globalThis to share meta state across module instances. +// The daemon middleware imports this module via native import() (outside Vite SSR), +// while setup.ts calls setMetaData() via Vite SSR — these are different module instances. +// globalThis bridges them so both see the same metaData. +const G = globalThis as unknown as { + __deco_meta_data?: MetaResponse | null; + __deco_meta_etag?: string | null; +}; + +function getMetaData(): MetaResponse | null { + return G.__deco_meta_data ?? null; +} + +function setMetaDataInternal(data: MetaResponse | null) { + G.__deco_meta_data = data; +} + +function getCachedEtag(): string | null { + return G.__deco_meta_etag ?? null; +} + +function setCachedEtag(etag: string | null) { + G.__deco_meta_etag = etag; +} /** * Invalidate the cached ETag so the admin re-fetches meta after a @@ -12,7 +34,7 @@ let cachedEtag: string | null = null; * needed here, keeping this module safe for client-side bundles. */ export function invalidateMetaCache() { - cachedEtag = null; + setCachedEtag(null); } /** @@ -21,8 +43,8 @@ export function invalidateMetaCache() { * on top of the site-generated section schemas. */ export function setMetaData(data: MetaResponse) { - metaData = composeMeta(data); - cachedEtag = null; + setMetaDataInternal(composeMeta(data)); + setCachedEtag(null); } /** @@ -31,14 +53,17 @@ export function setMetaData(data: MetaResponse) { * results in a different ETag, forcing admin to re-fetch. */ function getEtag(): string { - if (!cachedEtag) { - const str = JSON.stringify(metaData || {}); - cachedEtag = `"meta-${djb2Hex(str)}"`; + let etag = getCachedEtag(); + if (!etag) { + const str = JSON.stringify(getMetaData() || {}); + etag = `"meta-${djb2Hex(str)}"`; + setCachedEtag(etag); } - return cachedEtag; + return etag; } export function handleMeta(request: Request): Response { + const metaData = getMetaData(); if (!metaData) { return new Response(JSON.stringify({ error: "Schema not initialized" }), { status: 503, diff --git a/src/admin/schema.ts b/src/admin/schema.ts index 18c24ac..5f7dd85 100644 --- a/src/admin/schema.ts +++ b/src/admin/schema.ts @@ -94,6 +94,64 @@ function getProductListLoaderKeys(): string[] { return loaderRegistry.filter((l) => l.tags?.includes("product-list")).map((l) => l.key); } +// --------------------------------------------------------------------------- +// Action definitions — dynamic registry +// --------------------------------------------------------------------------- + +export interface ActionConfig { + key: string; + title: string; + namespace: string; + propsSchema: Record; +} + +const actionRegistry: ActionConfig[] = []; + +/** Register a single action schema for the admin. */ +export function registerActionSchema(config: ActionConfig) { + const idx = actionRegistry.findIndex((a) => a.key === config.key); + if (idx >= 0) { + actionRegistry[idx] = config; + } else { + actionRegistry.push(config); + } +} + +/** Register multiple action schemas at once. */ +export function registerActionSchemas(configs: ActionConfig[]) { + for (const config of configs) registerActionSchema(config); +} + +function buildActionDefinitions() { + const definitions: Record = {}; + const manifestBlocks: Record = {}; + + for (const action of actionRegistry) { + const defKey = toBase64(action.key); + + definitions[defKey] = { + title: action.key, + type: "object", + required: ["__resolveType"], + properties: { + __resolveType: { + type: "string", + enum: [action.key], + default: action.key, + }, + props: action.propsSchema, + }, + }; + + manifestBlocks[action.key] = { + $ref: `#/definitions/${defKey}`, + namespace: action.namespace, + }; + } + + return { definitions, manifestBlocks }; +} + // --------------------------------------------------------------------------- // Matcher definitions — dynamic registry // --------------------------------------------------------------------------- @@ -774,6 +832,7 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse { const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf]; const page = buildPageSchema(fullSectionAnyOf); const loaders = buildLoaderDefinitions(); + const actions = buildActionDefinitions(); const matchers = buildMatcherDefinitions(); const sectionRefDef = { title: "Section", anyOf: fullSectionAnyOf }; @@ -786,6 +845,7 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse { ...fwSections.definitions, ...page.definitions, ...loaders.definitions, + ...actions.definitions, ...matchers.definitions, [SECTION_REF_DEF_KEY]: sectionRefDef, [RESOLVABLE_LITERAL_KEY]: resolvableDef, @@ -813,6 +873,10 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse { ...(siteMeta.manifest?.blocks?.loaders || {}), ...loaders.manifestBlocks, }, + actions: { + ...(siteMeta.manifest?.blocks?.actions || {}), + ...actions.manifestBlocks, + }, matchers: { ...(siteMeta.manifest?.blocks?.matchers || {}), ...matchers.manifestBlocks, diff --git a/src/admin/setup.ts b/src/admin/setup.ts index f3c08ea..3743cfc 100644 --- a/src/admin/setup.ts +++ b/src/admin/setup.ts @@ -17,8 +17,11 @@ export { } from "./invoke"; export { setMetaData } from "./meta"; export { + type ActionConfig, type LoaderConfig, type MatcherConfig, + registerActionSchema, + registerActionSchemas, registerLoaderSchema, registerLoaderSchemas, registerMatcherSchema, diff --git a/src/cms/loader.ts b/src/cms/loader.ts index 6e282e9..31b0095 100644 --- a/src/cms/loader.ts +++ b/src/cms/loader.ts @@ -1,5 +1,5 @@ import * as asyncHooks from "node:async_hooks"; -import { djb2Hex } from "../sdk/djb2"; +import { djb2Hex } from "../sdk/djb2.ts"; export type Resolvable = { __resolveType?: string; diff --git a/src/cms/resolve.ts b/src/cms/resolve.ts index bec38a6..82829ab 100644 --- a/src/cms/resolve.ts +++ b/src/cms/resolve.ts @@ -3,6 +3,7 @@ import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } fro import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders"; import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; import { djb2Hex } from "../sdk/djb2"; +import { registerLoaderSchemas, registerActionSchemas, type LoaderConfig, type ActionConfig } from "../admin/schema"; // globalThis-backed: share state across Vite server function split modules const G = globalThis as any; @@ -296,6 +297,37 @@ export function registerCommerceLoader(key: string, loader: CommerceLoader) { export function registerCommerceLoaders(loaders: Record) { Object.assign(commerceLoaders, loaders); + + // Auto-register loader + action schemas for the admin manifest. + // Separate actions (keys containing "/actions/") from loaders. + const loaderConfigs: LoaderConfig[] = []; + const actionConfigs: ActionConfig[] = []; + + for (const key of Object.keys(loaders)) { + const namespace = key.startsWith("vtex/") ? "vtex" : "site"; + const schema = { type: "object" as const, additionalProperties: true }; + + if (key.includes("/actions/")) { + actionConfigs.push({ key, title: key, namespace, propsSchema: schema }); + } else { + loaderConfigs.push({ key, title: key, namespace, propsSchema: schema, tags: inferLoaderTags(key) }); + } + } + + registerLoaderSchemas(loaderConfigs); + registerActionSchemas(actionConfigs); +} + +function inferLoaderTags(key: string): string[] { + if ( + key.includes("productList") || + key.includes("ProductList") || + key.includes("ProductShelf") || + key.includes("SearchResult") + ) { + return ["product-list"]; + } + return []; } // --------------------------------------------------------------------------- diff --git a/src/daemon/fs.ts b/src/daemon/fs.ts new file mode 100644 index 0000000..cfafd28 --- /dev/null +++ b/src/daemon/fs.ts @@ -0,0 +1,238 @@ +/** + * Filesystem REST API — read, patch, delete .deco/ files. + * + * The admin UI reads individual files via GET /fs/file/. + * This is separate from the volumes/realtime WebSocket API. + * + * Ported from: deco-cx/deco daemon/fs/api.ts + */ +import { readFile, writeFile, rm, stat, mkdir } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import fjp from "fast-json-patch"; +import type { Operation } from "fast-json-patch"; +import { inferMetadata, broadcastFSEvent, type Metadata } from "./watch.ts"; + +const cwd = process.cwd(); +const toPosix = (p: string) => p.replaceAll(sep, "/"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safePath(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 { + // URL: /fs/file/.deco/blocks/site.json + const [, ...segments] = url.split("/file"); + return segments.join("/file") || "/"; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +async function mtimeFor(filepath: string): Promise { + try { + const stats = await stat(filepath); + return stats.mtimeMs; + } catch { + return Date.now(); + } +} + +// --------------------------------------------------------------------------- +// Patch application — matches daemon/fs/common.ts +// --------------------------------------------------------------------------- + +interface Patch { + type: "json" | "text"; + payload: Operation[]; +} + +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 }; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export function createFSHandler() { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ): Promise => { + const url = new URL(req.url ?? "/", "http://localhost"); + const { pathname } = url; + + // Only handle /fs/file/* paths + if (!pathname.startsWith("/fs/file")) { + // Also handle /fs/grep (admin search) + if (pathname === "/fs/grep") { + // Minimal grep stub — return empty results + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ matches: [], totalMatches: 0 })); + return; + } + next(); + return; + } + + const filePath = extractFilePath(pathname); + const systemPath = safePath(filePath); + + if (!systemPath) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Path traversal denied" })); + return; + } + + // GET /fs/file/* — read file + if (req.method === "GET") { + try { + const [content, metadata, mtime] = await Promise.all([ + readFile(systemPath, "utf-8"), + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ content, metadata, timestamp: mtime })); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ timestamp: Date.now() })); + return; + } + throw err; + } + return; + } + + // PATCH /fs/file/* — apply JSON patch + if (req.method === "PATCH") { + const raw = await readBody(req); + let body: { patch: Patch; timestamp: number }; + try { + body = JSON.parse(raw); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + return; + } + + 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) { + const dir = join(systemPath, ".."); + await mkdir(dir, { recursive: true }); + await writeFile(systemPath, result.content, "utf-8"); + } + + const [metadata, mtimeAfter] = await Promise.all([ + inferMetadata(systemPath), + mtimeFor(systemPath), + ]); + + // Broadcast change for SSE listeners + broadcastFSEvent({ + type: "fs-sync", + detail: { + metadata, + timestamp: mtimeAfter, + filepath: toPosix(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, + }; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(update)); + return; + } + + // DELETE /fs/file/* + if (req.method === "DELETE") { + try { + await rm(systemPath, { force: true }); + } catch { + // ignore + } + + broadcastFSEvent({ + type: "fs-sync", + detail: { + metadata: null, + timestamp: Date.now(), + filepath: toPosix(systemPath.replace(cwd, "")), + }, + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + conflict: false, + metadata: null, + timestamp: Date.now(), + }), + ); + return; + } + + res.writeHead(405); + res.end(); + }; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 37df4af..acc99d7 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -1,8 +1,8 @@ -export { startTunnel } from "./tunnel"; -export type { TunnelOptions, TunnelConnection } from "./tunnel"; -export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth"; -export type { JwtPayload } from "./auth"; -export { createDaemonMiddleware } from "./middleware"; -export type { DaemonOptions } from "./middleware"; -export { createVolumesHandler } from "./volumes"; -export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch"; +export { startTunnel } from "./tunnel.ts"; +export type { TunnelOptions, TunnelConnection } from "./tunnel.ts"; +export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth.ts"; +export type { JwtPayload } from "./auth.ts"; +export { createDaemonMiddleware } from "./middleware.ts"; +export type { DaemonOptions } from "./middleware.ts"; +export { createVolumesHandler } from "./volumes.ts"; +export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch.ts"; diff --git a/src/daemon/middleware.ts b/src/daemon/middleware.ts index 15f8141..01edfec 100644 --- a/src/daemon/middleware.ts +++ b/src/daemon/middleware.ts @@ -2,12 +2,18 @@ * Daemon middleware — intercepts x-daemon-api requests, applies auth, * and routes to volumes API or watch SSE. * + * 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 */ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http"; -import { createAuthMiddleware } from "./auth"; -import { createVolumesHandler } from "./volumes"; -import { createWatchHandler, watchFS } from "./watch"; +import { createAuthMiddleware } from "./auth.ts"; +import { createFSHandler } from "./fs.ts"; +import { createVolumesHandler } from "./volumes.ts"; +import { createWatchHandler, watchFS } from "./watch.ts"; const DAEMON_API_SPECIFIER = "x-daemon-api"; const HYPERVISOR_API_SPECIFIER = "x-hypervisor-api"; @@ -39,17 +45,54 @@ export function createDaemonMiddleware(opts: DaemonOptions) { }) : null; - // SSE watch handler - const watch = createWatchHandler(); + // FS REST API handler (/fs/file/* — read, patch, delete) + const fs = createFSHandler(); + + // SSE watch handler — lazy port resolver for /live/_meta fetch + const watch = createWatchHandler({ + getPort: () => { + const addr = httpServer?.address(); + return typeof addr === "object" && addr ? addr.port : 5173; + }, + }); // 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"; + 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 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); + 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] ?? @@ -87,11 +130,10 @@ export function createDaemonMiddleware(opts: DaemonOptions) { // Auth → then route auth(req, res, () => { - let pathname: string; - try { - pathname = new URL(req.url ?? "/", "http://localhost").pathname; - } catch { - pathname = req.url ?? "/"; + // FS REST API: /fs/file/* (read, patch, delete .deco/ files) + if (pathname.startsWith("/fs/")) { + fs(req, res, next); + return; } // Volumes API: /volumes/:id/files/* diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index cb717f1..a37fa48 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -12,13 +12,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; // --------------------------------------------------------------------------- interface FSEvent { - type: "fs-sync" | "fs-snapshot"; - detail: { - metadata?: { kind: string } | null; - filepath?: string; - timestamp: number; - status?: unknown; - }; + type: "fs-sync" | "fs-snapshot" | "worker-status" | "meta-info"; + detail: Record; } // --------------------------------------------------------------------------- @@ -46,16 +41,55 @@ function shouldIgnore(path: string): boolean { ); } -async function inferMetadata( - filepath: string, -): Promise<{ kind: string }> { +/** + * Infer block type from __resolveType string. + * Maps to manifest block categories (pages, sections, loaders, etc.). + */ +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); - if (parsed.__resolveType) { - return { kind: "block" }; + 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: "file" }; + + return { kind: "block", blockType, __resolveType }; } catch { return { kind: "file" }; } @@ -111,8 +145,9 @@ async function* scanFiles( // SSE handler — Connect-style middleware // --------------------------------------------------------------------------- -export function createWatchHandler() { +export function createWatchHandler(opts?: { getPort?: () => number }) { const cwd = process.cwd(); + const getPort = opts?.getPort ?? (() => 5173); return async ( req: IncomingMessage, @@ -170,6 +205,30 @@ export function createWatchHandler() { if (closed) break; sendEvent(event); } + + if (closed) return; + + // Worker status — Vite dev server is always 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() }, + }); + } + } catch { + // Schema may not be initialized yet — admin will retry via /live/_meta + } }; } diff --git a/src/sdk/cachedLoader.ts b/src/sdk/cachedLoader.ts index 8397b5b..d18895e 100644 --- a/src/sdk/cachedLoader.ts +++ b/src/sdk/cachedLoader.ts @@ -11,7 +11,7 @@ * (e.g. "product") which derives timing from the unified profile system. */ -import { loaderCacheOptions, type CacheProfileName } from "./cacheHeaders"; +import { loaderCacheOptions, type CacheProfileName } from "./cacheHeaders.ts"; export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate"; diff --git a/src/vite/plugin.js b/src/vite/plugin.js index d7179ea..3f23001 100644 --- a/src/vite/plugin.js +++ b/src/vite/plugin.js @@ -122,8 +122,8 @@ export function decoVitePlugin() { if (siteName) { const envName = process.env.DECO_ENV_NAME || "dev"; - // Add daemon middleware (x-daemon-api interception + auth + volumes + SSE) - import("../daemon/middleware.js").then(({ createDaemonMiddleware }) => { + // Add daemon middleware (x-daemon-api interception + auth + volumes + SSE + admin routes) + import("../daemon/middleware.ts").then(({ createDaemonMiddleware }) => { server.middlewares.use(createDaemonMiddleware({ site: siteName, server })); }).catch((err) => { console.warn("[deco] Failed to load daemon middleware:", err.message); @@ -134,8 +134,13 @@ export function decoVitePlugin() { const addr = server.httpServer?.address(); const port = typeof addr === "object" && addr ? addr.port : 5173; try { - const { startTunnel } = await import("../daemon/tunnel.js"); - const tunnel = await startTunnel({ site: siteName, env: envName, port }); + const { startTunnel } = await import("../daemon/tunnel.ts"); + const tunnel = await startTunnel({ + site: siteName, + env: envName, + port, + decoHost: process.env.DECO_HOST === "true", + }); server.httpServer?.on("close", () => tunnel.close()); } catch (err) { console.warn("[deco] Failed to start tunnel:", err.message); @@ -145,9 +150,18 @@ export function decoVitePlugin() { }, config(_cfg, { command }) { + /** @type {import("vite").UserConfig} */ + const cfg = {}; + + // Allow tunnel domains through Vite's host check + if (process.env.DECO_SITE_NAME) { + cfg.server = { allowedHosts: [".deco.host", ".decocdn.com"] }; + } + // Only split chunks for production builds — dev uses unbundled ESM. - if (command !== "build") return; + if (command !== "build") return cfg; return { + ...cfg, build: { rollupOptions: { output: {