@decocms/tanstack 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Daemon middleware — intercepts x-daemon-api requests, applies auth,
3
+ * and routes to volumes API or watch SSE.
4
+ *
5
+ * Admin runtime routes (/live/_meta, /.decofile) are NOT handled here —
6
+ * they fall through to Vite SSR (worker-entry.ts) where setMetaData()
7
+ * and setBlocks() have populated shared state. The daemon middleware loads
8
+ * modules via native import() which creates separate module instances.
9
+ *
10
+ * Ported from: deco-cx/deco daemon/daemon.ts
11
+ */
12
+ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http";
13
+ import { createAuthMiddleware } from "./auth";
14
+ import { createFSHandler } from "./fs";
15
+ import { createVolumesHandler } from "./volumes";
16
+ import { createWatchHandler, watchFS } from "./watch";
17
+
18
+ const DAEMON_API_SPECIFIER = "x-daemon-api";
19
+ const HYPERVISOR_API_SPECIFIER = "x-hypervisor-api";
20
+
21
+ export interface DaemonOptions {
22
+ /** Site name for JWT validation. */
23
+ site: string;
24
+ /** Vite dev server instance. */
25
+ server: {
26
+ httpServer: HttpServer | null;
27
+ watcher: { on(event: string, cb: (...args: unknown[]) => void): void };
28
+ };
29
+ }
30
+
31
+ // Creates a Connect-style middleware that:
32
+ // 1. Checks for x-daemon-api or x-hypervisor-api header
33
+ // 2. Applies JWT auth
34
+ // 3. Routes to volumes API or SSE watch
35
+ // 4. Falls through to Vite for other daemon requests (admin routes)
36
+ export function createDaemonMiddleware(opts: DaemonOptions) {
37
+ const auth = createAuthMiddleware(opts.site);
38
+ const httpServer = opts.server.httpServer;
39
+
40
+ // Volumes handler (includes WebSocket upgrade registration)
41
+ const volumes = httpServer
42
+ ? createVolumesHandler({
43
+ httpServer,
44
+ watcher: opts.server.watcher,
45
+ })
46
+ : null;
47
+
48
+ // FS REST API handler (/fs/file/* — read, patch, delete)
49
+ const fs = createFSHandler();
50
+
51
+ // SSE watch handler — lazy port resolver for /live/_meta fetch
52
+ const watch = createWatchHandler({
53
+ getPort: () => {
54
+ const addr = httpServer?.address();
55
+ return typeof addr === "object" && addr ? addr.port : 5173;
56
+ },
57
+ });
58
+
59
+ // Wire Vite's file watcher to the broadcast channel
60
+ watchFS(opts.server.watcher);
61
+
62
+ // Version reported to admin.deco.cx — must satisfy admin's minimum version check.
63
+ // Admin compares against deco-cx/deco versions (e.g. 1.177.x), not @decocms/start versions.
64
+ const VERSION = "1.177.5";
65
+
66
+ return (
67
+ req: IncomingMessage,
68
+ res: ServerResponse,
69
+ next: () => void,
70
+ ): void => {
71
+ let pathname: string;
72
+ try {
73
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
74
+ } catch {
75
+ pathname = req.url ?? "/";
76
+ }
77
+
78
+ // Healthcheck — no auth required, admin uses this to verify env is reachable
79
+ if (pathname === "/_healthcheck") {
80
+ res.writeHead(200, {
81
+ "Content-Type": "text/plain",
82
+ "Access-Control-Allow-Origin": "*",
83
+ "Access-Control-Allow-Methods": "GET",
84
+ "Access-Control-Allow-Headers": "Content-Type",
85
+ });
86
+ res.end(VERSION);
87
+ return;
88
+ }
89
+
90
+ // Admin runtime routes (/live/_meta, /.decofile) are NOT handled here.
91
+ // They fall through to Vite SSR (worker-entry.ts / TanStack routes) where
92
+ // setMetaData() and setBlocks() have already populated the shared state.
93
+ // The daemon middleware loads modules via native import() which creates
94
+ // separate module instances from Vite SSR — they don't share state.
95
+
96
+ const isDaemonAPI =
97
+ req.headers[DAEMON_API_SPECIFIER] ??
98
+ req.headers[HYPERVISOR_API_SPECIFIER] ??
99
+ false;
100
+
101
+ // Also check query param: ?x-daemon-api=true
102
+ if (!isDaemonAPI) {
103
+ try {
104
+ const url = new URL(req.url ?? "/", "http://localhost");
105
+ if (url.searchParams.get(DAEMON_API_SPECIFIER) !== "true") {
106
+ next();
107
+ return;
108
+ }
109
+ } catch {
110
+ next();
111
+ return;
112
+ }
113
+ }
114
+
115
+ // Add CORS headers for admin.deco.cx
116
+ const origin = req.headers.origin;
117
+ if (origin) {
118
+ res.setHeader("Access-Control-Allow-Origin", origin);
119
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
120
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-daemon-api, x-hypervisor-api");
121
+ res.setHeader("Access-Control-Allow-Credentials", "true");
122
+ }
123
+
124
+ // Handle CORS preflight
125
+ if (req.method === "OPTIONS") {
126
+ res.writeHead(204);
127
+ res.end();
128
+ return;
129
+ }
130
+
131
+ // Auth → then route
132
+ auth(req, res, () => {
133
+ // FS REST API: /fs/file/* (read, patch, delete .deco/ files)
134
+ if (pathname.startsWith("/fs/")) {
135
+ fs(req, res, next);
136
+ return;
137
+ }
138
+
139
+ // Volumes API: /volumes/:id/files/*
140
+ if (pathname.includes("/volumes/") && pathname.includes("/files") && volumes) {
141
+ volumes(req, res, next);
142
+ return;
143
+ }
144
+
145
+ // SSE watch: /watch or root /
146
+ if (pathname === "/watch" || pathname === "/") {
147
+ watch(req, res, next);
148
+ return;
149
+ }
150
+
151
+ // Everything else falls through to Vite/TanStack admin routes
152
+ // (e.g., /live/_meta, /.decofile, /live/previews, /deco/invoke)
153
+ next();
154
+ });
155
+ };
156
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tunnel registration — connects local dev server to deco.cx admin
3
+ * via a WebSocket reverse proxy (@deco-cx/warp-node).
4
+ *
5
+ * Ported from: deco-cx/deco daemon/tunnel.ts
6
+ */
7
+ import { connect } from "@deco-cx/warp-node";
8
+
9
+ export interface TunnelOptions {
10
+ /** Environment name (DECO_ENV_NAME). */
11
+ env: string;
12
+ /** Site name (DECO_SITE_NAME). */
13
+ site: string;
14
+ /** Local dev server port. */
15
+ port: number;
16
+ /** Use deco.host relay (true) or simpletunnel.deco.site (false). Default true. */
17
+ decoHost?: boolean;
18
+ }
19
+
20
+ export interface TunnelConnection {
21
+ close: () => void;
22
+ domain: string;
23
+ }
24
+
25
+ const VERBOSE = process.env.VERBOSE;
26
+
27
+ export async function startTunnel(
28
+ opts: TunnelOptions,
29
+ ): Promise<TunnelConnection> {
30
+ const { env, site, port, decoHost = true } = opts;
31
+
32
+ const decoHostDomain = `${env}--${site}.deco.host`;
33
+ const { server, domain } = decoHost
34
+ ? { server: `wss://${decoHostDomain}`, domain: decoHostDomain }
35
+ : {
36
+ server: "wss://simpletunnel.deco.site",
37
+ domain: `${env}--${site}.deco.site`,
38
+ };
39
+
40
+ const localAddr = `http://localhost:${port}`;
41
+ const apiKey =
42
+ process.env.DECO_TUNNEL_SERVER_TOKEN ??
43
+ "c309424a-2dc4-46fe-bfc7-a7c10df59477";
44
+
45
+ let closed = false;
46
+ let activeConn: Awaited<ReturnType<typeof connect>> | null = null;
47
+
48
+ async function doConnect(): Promise<void> {
49
+ if (closed) return;
50
+
51
+ let r: Awaited<ReturnType<typeof connect>>;
52
+ try {
53
+ r = await connect({ domain, localAddr, server, apiKey });
54
+ activeConn = r;
55
+ } catch (err) {
56
+ if (closed) return;
57
+ console.log(
58
+ "[deco] tunnel connect failed, retrying in 500ms…",
59
+ VERBOSE ? err : "",
60
+ );
61
+ await new Promise((resolve) => setTimeout(resolve, 500));
62
+ return doConnect();
63
+ }
64
+
65
+ r.registered
66
+ .then(() => {
67
+ const adminUrl = new URL(
68
+ `/sites/${site}/spaces/dashboard?env=${env}`,
69
+ "https://admin.deco.cx",
70
+ );
71
+ console.log(
72
+ `\n[deco] tunnel connected — env \x1b[32m${env}\x1b[0m for site \x1b[34m${site}\x1b[0m` +
73
+ `\n -> Preview: \x1b[36mhttps://${domain}\x1b[0m` +
74
+ `\n -> Admin: \x1b[36m${adminUrl.href}\x1b[0m\n`,
75
+ );
76
+ })
77
+ .catch((err) => {
78
+ console.error("[deco] tunnel registration failed:", err);
79
+ });
80
+
81
+ r.closed
82
+ .then(async (reason) => {
83
+ if (closed) return;
84
+ if (
85
+ reason &&
86
+ typeof reason === "object" &&
87
+ "intentional" in reason &&
88
+ (reason as Record<string, unknown>).intentional
89
+ )
90
+ return;
91
+ console.log(
92
+ "[deco] tunnel disconnected, retrying in 500ms…",
93
+ VERBOSE ? reason : "",
94
+ );
95
+ await new Promise((resolve) => setTimeout(resolve, 500));
96
+ return doConnect();
97
+ })
98
+ .catch(async (err: unknown) => {
99
+ if (closed) return;
100
+ if (
101
+ err &&
102
+ typeof err === "object" &&
103
+ "intentional" in err &&
104
+ (err as Record<string, unknown>).intentional
105
+ )
106
+ return;
107
+ console.log(
108
+ "[deco] tunnel error, retrying in 500ms…",
109
+ VERBOSE ? err : "",
110
+ );
111
+ await new Promise((resolve) => setTimeout(resolve, 500));
112
+ return doConnect();
113
+ });
114
+ }
115
+
116
+ await doConnect();
117
+
118
+ return {
119
+ close() {
120
+ closed = true;
121
+ activeConn?.closed?.catch(() => {});
122
+ // warp-node's connect() returns a Connected object; closing the
123
+ // underlying WebSocket is handled internally when the process exits.
124
+ // Setting closed=true prevents reconnection attempts.
125
+ activeConn = null;
126
+ },
127
+ domain,
128
+ };
129
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Volumes API — CRUD for .deco/ files with JSON patch support
3
+ * and WebSocket realtime broadcast of file changes.
4
+ *
5
+ * Ported from: deco-cx/deco daemon/realtime/app.ts (without CRDT)
6
+ */
7
+ import { readdir, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises";
8
+ import { join, resolve, sep, posix } from "node:path";
9
+ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http";
10
+ import { WebSocketServer, WebSocket } from "ws";
11
+ import fjp from "fast-json-patch";
12
+ import type { Operation } from "fast-json-patch";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types — ported from daemon/realtime/types.ts
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface BaseFilePatch {
19
+ path: string;
20
+ }
21
+
22
+ interface JSONFilePatch extends BaseFilePatch {
23
+ patches: Operation[];
24
+ }
25
+
26
+ interface TextFileSet extends BaseFilePatch {
27
+ content: string | null;
28
+ }
29
+
30
+ type FilePatch = JSONFilePatch | TextFileSet;
31
+
32
+ interface VolumePatchRequest {
33
+ messageId?: string;
34
+ patches: FilePatch[];
35
+ }
36
+
37
+ interface FilePatchResult {
38
+ path: string;
39
+ accepted: boolean;
40
+ content?: string;
41
+ deleted?: boolean;
42
+ }
43
+
44
+ interface VolumePatchResponse {
45
+ results: FilePatchResult[];
46
+ timestamp: number;
47
+ }
48
+
49
+ function isJSONFilePatch(patch: FilePatch): patch is JSONFilePatch {
50
+ return "patches" in patch && Array.isArray((patch as JSONFilePatch).patches);
51
+ }
52
+
53
+ function isTextFileSet(patch: FilePatch): patch is TextFileSet {
54
+ return "content" in patch && !("patches" in patch);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const toPosix = (p: string) => p.replaceAll(sep, "/");
62
+
63
+ function safePath(base: string, untrusted: string): string | null {
64
+ const resolved = resolve(base, untrusted);
65
+ if (!resolved.startsWith(base + sep) && resolved !== base) return null;
66
+ return resolved;
67
+ }
68
+
69
+ async function readTextFileSafe(path: string): Promise<string | null> {
70
+ try {
71
+ return await readFile(path, "utf-8");
72
+ } catch (err: unknown) {
73
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ async function ensureFile(path: string): Promise<void> {
79
+ const dir = join(path, "..");
80
+ await mkdir(dir, { recursive: true });
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // WebSocket realtime sessions
85
+ // ---------------------------------------------------------------------------
86
+
87
+ interface BroadcastMessage {
88
+ path: string;
89
+ timestamp: number;
90
+ deleted?: boolean;
91
+ messageId?: string;
92
+ }
93
+
94
+ interface VolumesState {
95
+ sessions: WebSocket[];
96
+ wss: WebSocketServer;
97
+ timestamp: number;
98
+ }
99
+
100
+ function broadcast(state: VolumesState, msg: BroadcastMessage): void {
101
+ const data = JSON.stringify(msg);
102
+ for (const ws of state.sessions) {
103
+ if (ws.readyState === WebSocket.OPEN) {
104
+ ws.send(data);
105
+ }
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Walk directory (Node.js equivalent of @std/fs/walk)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ async function walkFiles(
114
+ root: string,
115
+ ): Promise<string[]> {
116
+ const results: string[] = [];
117
+ try {
118
+ const entries = await readdir(root, {
119
+ recursive: true,
120
+ withFileTypes: true,
121
+ });
122
+ for (const entry of entries) {
123
+ if (!entry.isFile()) continue;
124
+ const fullPath = join(entry.parentPath, entry.name);
125
+ const rel = toPosix(fullPath.replace(root, ""));
126
+ if (
127
+ rel.includes("/.git/") ||
128
+ rel.includes("/node_modules/") ||
129
+ rel.includes("/.agent-home/") ||
130
+ rel.includes("/.claude/")
131
+ ) {
132
+ continue;
133
+ }
134
+ results.push(fullPath);
135
+ }
136
+ } catch {
137
+ // root might be a file, not a directory
138
+ }
139
+ return results;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Request handlers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const cwd = process.cwd();
147
+
148
+ async function handleGetFiles(
149
+ req: IncomingMessage,
150
+ res: ServerResponse,
151
+ state: VolumesState,
152
+ ): Promise<void> {
153
+ const url = new URL(req.url ?? "/", "http://localhost");
154
+ const [, ...segments] = url.pathname.split("/files");
155
+ const filePath = segments.join("/files") || "/";
156
+ const withContent = url.searchParams.get("content") === "true";
157
+
158
+ const root = safePath(cwd, filePath);
159
+ if (!root) {
160
+ res.writeHead(403);
161
+ res.end("Path traversal denied");
162
+ return;
163
+ }
164
+ const fs: Record<string, { content: string | null }> = {};
165
+
166
+ const files = await walkFiles(root);
167
+ if (files.length > 0) {
168
+ for (const fullPath of files) {
169
+ const key = toPosix(fullPath.replace(root, "/"));
170
+ fs[key] = {
171
+ content: withContent ? await readTextFileSafe(fullPath) : null,
172
+ };
173
+ }
174
+ } else {
175
+ // Might be a single file
176
+ const content = withContent ? await readTextFileSafe(root) : null;
177
+ fs[toPosix(filePath)] = { content };
178
+ }
179
+
180
+ const body = JSON.stringify({ timestamp: state.timestamp, fs });
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(body);
183
+ }
184
+
185
+ async function handlePatchFiles(
186
+ req: IncomingMessage,
187
+ res: ServerResponse,
188
+ state: VolumesState,
189
+ ): Promise<void> {
190
+ const raw = await readBody(req);
191
+ let request: VolumePatchRequest;
192
+ try {
193
+ request = JSON.parse(raw);
194
+ } catch {
195
+ res.writeHead(400);
196
+ res.end("Invalid JSON");
197
+ return;
198
+ }
199
+
200
+ const results: FilePatchResult[] = [];
201
+
202
+ for (const patch of request.patches) {
203
+ // Validate path traversal for every patch
204
+ const resolvedPath = safePath(cwd, patch.path);
205
+ if (!resolvedPath) {
206
+ results.push({ accepted: false, path: patch.path });
207
+ continue;
208
+ }
209
+
210
+ if (isJSONFilePatch(patch)) {
211
+ const { path: filePath, patches: operations } = patch;
212
+ const content =
213
+ (await readTextFileSafe(resolvedPath)) ?? "{}";
214
+ try {
215
+ const newContent = JSON.stringify(
216
+ operations.reduce(fjp.applyReducer, JSON.parse(content)),
217
+ );
218
+ results.push({
219
+ accepted: true,
220
+ path: filePath,
221
+ content: newContent,
222
+ deleted: newContent === "null",
223
+ });
224
+ } catch (error) {
225
+ console.error(error);
226
+ results.push({ accepted: false, path: filePath, content });
227
+ }
228
+ } else if (isTextFileSet(patch)) {
229
+ const { path: filePath, content } = patch;
230
+ results.push({
231
+ accepted: true,
232
+ path: filePath,
233
+ content: content ?? "",
234
+ deleted: content === null,
235
+ });
236
+ }
237
+ }
238
+
239
+ state.timestamp = Date.now();
240
+
241
+ // Atomic: only commit writes if all patches accepted
242
+ const shouldWrite = results.every((r) => r.accepted);
243
+ if (shouldWrite) {
244
+ await Promise.all(
245
+ results.map(async (r) => {
246
+ try {
247
+ const system = join(cwd, r.path);
248
+ if (r.deleted) {
249
+ await rm(system, { force: true });
250
+ } else if (r.content != null) {
251
+ await ensureFile(system);
252
+ await writeFile(system, r.content, "utf-8");
253
+ }
254
+ } catch (error) {
255
+ console.error(error);
256
+ r.accepted = false;
257
+ }
258
+ }),
259
+ );
260
+ }
261
+
262
+ const body: VolumePatchResponse = { timestamp: state.timestamp, results };
263
+ res.writeHead(200, { "Content-Type": "application/json" });
264
+ res.end(JSON.stringify(body));
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Body reader helper
269
+ // ---------------------------------------------------------------------------
270
+
271
+ function readBody(req: IncomingMessage): Promise<string> {
272
+ return new Promise((resolve, reject) => {
273
+ const chunks: Buffer[] = [];
274
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
275
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
276
+ req.on("error", reject);
277
+ });
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Setup: attach WebSocket server and file watcher
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export interface VolumesOptions {
285
+ /** Vite's HTTP server to attach WebSocket upgrades to. */
286
+ httpServer: HttpServer;
287
+ /** Vite's file watcher (chokidar instance) for broadcasting changes. */
288
+ watcher: { on(event: string, cb: (...args: unknown[]) => void): void };
289
+ }
290
+
291
+ export function createVolumesHandler(opts: VolumesOptions) {
292
+ const state: VolumesState = {
293
+ sessions: [],
294
+ wss: new WebSocketServer({ noServer: true }),
295
+ timestamp: Date.now(),
296
+ };
297
+
298
+ // Handle WebSocket upgrades for /volumes/*/files paths with x-daemon-api
299
+ opts.httpServer.on("upgrade", (req, socket, head) => {
300
+ const isDaemon =
301
+ req.headers["x-daemon-api"] ?? req.headers["x-hypervisor-api"];
302
+ if (!isDaemon) return;
303
+
304
+ const url = req.url ?? "";
305
+ if (!url.includes("/volumes/") || !url.includes("/files")) return;
306
+
307
+ state.wss.handleUpgrade(req, socket, head, (ws) => {
308
+ state.sessions.push(ws);
309
+ console.log("[deco] admin websocket connected");
310
+
311
+ ws.on("close", () => {
312
+ console.log("[deco] admin websocket disconnected");
313
+ const idx = state.sessions.indexOf(ws);
314
+ if (idx > -1) state.sessions.splice(idx, 1);
315
+ });
316
+ });
317
+ });
318
+
319
+ // Broadcast file changes from Vite's watcher
320
+ const broadcastChange = (filePath: string, deleted = false) => {
321
+ const rel = toPosix(filePath).replace(toPosix(cwd), "");
322
+ if (
323
+ rel.includes("/.git/") ||
324
+ rel.includes("/node_modules/") ||
325
+ rel.includes("/.agent-home/") ||
326
+ rel.includes("/.claude/")
327
+ ) {
328
+ return;
329
+ }
330
+ broadcast(state, { path: rel, timestamp: Date.now(), deleted });
331
+ };
332
+
333
+ opts.watcher.on("change", (path: unknown) => {
334
+ if (typeof path === "string") broadcastChange(path);
335
+ });
336
+ opts.watcher.on("add", (path: unknown) => {
337
+ if (typeof path === "string") broadcastChange(path);
338
+ });
339
+ opts.watcher.on("unlink", (path: unknown) => {
340
+ if (typeof path === "string") broadcastChange(path, true);
341
+ });
342
+
343
+ // Connect-style middleware for HTTP requests
344
+ return async (
345
+ req: IncomingMessage,
346
+ res: ServerResponse,
347
+ next: () => void,
348
+ ): Promise<void> => {
349
+ const url = req.url ?? "";
350
+
351
+ // Match /volumes/:id/files patterns
352
+ if (!url.includes("/volumes/") || !url.includes("/files")) {
353
+ next();
354
+ return;
355
+ }
356
+
357
+ if (req.method === "GET") {
358
+ await handleGetFiles(req, res, state);
359
+ } else if (req.method === "PATCH") {
360
+ await handlePatchFiles(req, res, state);
361
+ } else {
362
+ res.writeHead(405);
363
+ res.end();
364
+ }
365
+ };
366
+ }