@decocms/start 2.0.1 → 2.1.1

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,238 @@
1
+ /**
2
+ * Filesystem REST API — read, patch, delete .deco/ files.
3
+ *
4
+ * The admin UI reads individual files via GET /fs/file/<path>.
5
+ * This is separate from the volumes/realtime WebSocket API.
6
+ *
7
+ * Ported from: deco-cx/deco daemon/fs/api.ts
8
+ */
9
+ import { readFile, writeFile, rm, stat, mkdir } from "node:fs/promises";
10
+ import { join, resolve, sep } from "node:path";
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import fjp from "fast-json-patch";
13
+ import type { Operation } from "fast-json-patch";
14
+ import { inferMetadata, broadcastFSEvent, type Metadata } from "./watch.ts";
15
+
16
+ const cwd = process.cwd();
17
+ const toPosix = (p: string) => p.replaceAll(sep, "/");
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function safePath(untrusted: string): string | null {
24
+ const resolved = resolve(cwd, untrusted.startsWith("/") ? `.${untrusted}` : untrusted);
25
+ if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return null;
26
+ return resolved;
27
+ }
28
+
29
+ function extractFilePath(url: string): string {
30
+ // URL: /fs/file/.deco/blocks/site.json
31
+ const [, ...segments] = url.split("/file");
32
+ return segments.join("/file") || "/";
33
+ }
34
+
35
+ function readBody(req: IncomingMessage): Promise<string> {
36
+ return new Promise((resolve, reject) => {
37
+ const chunks: Buffer[] = [];
38
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
39
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
40
+ req.on("error", reject);
41
+ });
42
+ }
43
+
44
+ async function mtimeFor(filepath: string): Promise<number> {
45
+ try {
46
+ const stats = await stat(filepath);
47
+ return stats.mtimeMs;
48
+ } catch {
49
+ return Date.now();
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Patch application — matches daemon/fs/common.ts
55
+ // ---------------------------------------------------------------------------
56
+
57
+ interface Patch {
58
+ type: "json" | "text";
59
+ payload: Operation[];
60
+ }
61
+
62
+ function applyPatch(
63
+ content: string | null,
64
+ patch: Patch,
65
+ ): { conflict: boolean; content?: string } {
66
+ try {
67
+ if (patch.type === "json") {
68
+ const result = patch.payload.reduce(
69
+ fjp.applyReducer,
70
+ JSON.parse(content ?? "{}"),
71
+ );
72
+ return { conflict: false, content: JSON.stringify(result, null, 2) };
73
+ }
74
+ if (patch.type === "text") {
75
+ const result = patch.payload.reduce(
76
+ fjp.applyReducer,
77
+ content?.split("\n") ?? [],
78
+ );
79
+ return { conflict: false, content: (result as string[]).join("\n") };
80
+ }
81
+ } catch (err: unknown) {
82
+ if (
83
+ err instanceof fjp.JsonPatchError &&
84
+ err.name === "TEST_OPERATION_FAILED"
85
+ ) {
86
+ return { conflict: true };
87
+ }
88
+ throw err;
89
+ }
90
+ return { conflict: true };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Handler
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export function createFSHandler() {
98
+ return async (
99
+ req: IncomingMessage,
100
+ res: ServerResponse,
101
+ next: () => void,
102
+ ): Promise<void> => {
103
+ const url = new URL(req.url ?? "/", "http://localhost");
104
+ const { pathname } = url;
105
+
106
+ // Only handle /fs/file/* paths
107
+ if (!pathname.startsWith("/fs/file")) {
108
+ // Also handle /fs/grep (admin search)
109
+ if (pathname === "/fs/grep") {
110
+ // Minimal grep stub — return empty results
111
+ res.writeHead(200, { "Content-Type": "application/json" });
112
+ res.end(JSON.stringify({ matches: [], totalMatches: 0 }));
113
+ return;
114
+ }
115
+ next();
116
+ return;
117
+ }
118
+
119
+ const filePath = extractFilePath(pathname);
120
+ const systemPath = safePath(filePath);
121
+
122
+ if (!systemPath) {
123
+ res.writeHead(403, { "Content-Type": "application/json" });
124
+ res.end(JSON.stringify({ error: "Path traversal denied" }));
125
+ return;
126
+ }
127
+
128
+ // GET /fs/file/* — read file
129
+ if (req.method === "GET") {
130
+ try {
131
+ const [content, metadata, mtime] = await Promise.all([
132
+ readFile(systemPath, "utf-8"),
133
+ inferMetadata(systemPath),
134
+ mtimeFor(systemPath),
135
+ ]);
136
+
137
+ res.writeHead(200, { "Content-Type": "application/json" });
138
+ res.end(JSON.stringify({ content, metadata, timestamp: mtime }));
139
+ } catch (err: unknown) {
140
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
141
+ res.writeHead(404, { "Content-Type": "application/json" });
142
+ res.end(JSON.stringify({ timestamp: Date.now() }));
143
+ return;
144
+ }
145
+ throw err;
146
+ }
147
+ return;
148
+ }
149
+
150
+ // PATCH /fs/file/* — apply JSON patch
151
+ if (req.method === "PATCH") {
152
+ const raw = await readBody(req);
153
+ let body: { patch: Patch; timestamp: number };
154
+ try {
155
+ body = JSON.parse(raw);
156
+ } catch {
157
+ res.writeHead(400, { "Content-Type": "application/json" });
158
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
159
+ return;
160
+ }
161
+
162
+ const mtimeBefore = await mtimeFor(systemPath);
163
+ let content: string | null;
164
+ try {
165
+ content = await readFile(systemPath, "utf-8");
166
+ } catch {
167
+ content = null;
168
+ }
169
+
170
+ const result = applyPatch(content, body.patch);
171
+
172
+ if (!result.conflict && result.content != null) {
173
+ const dir = join(systemPath, "..");
174
+ await mkdir(dir, { recursive: true });
175
+ await writeFile(systemPath, result.content, "utf-8");
176
+ }
177
+
178
+ const [metadata, mtimeAfter] = await Promise.all([
179
+ inferMetadata(systemPath),
180
+ mtimeFor(systemPath),
181
+ ]);
182
+
183
+ // Broadcast change for SSE listeners
184
+ broadcastFSEvent({
185
+ type: "fs-sync",
186
+ detail: {
187
+ metadata,
188
+ timestamp: mtimeAfter,
189
+ filepath: toPosix(systemPath.replace(cwd, "")),
190
+ },
191
+ });
192
+
193
+ const update = result.conflict
194
+ ? { conflict: true, metadata, timestamp: mtimeAfter, content }
195
+ : {
196
+ conflict: false,
197
+ metadata,
198
+ timestamp: mtimeAfter,
199
+ content: mtimeBefore !== body.timestamp ? result.content : undefined,
200
+ };
201
+
202
+ res.writeHead(200, { "Content-Type": "application/json" });
203
+ res.end(JSON.stringify(update));
204
+ return;
205
+ }
206
+
207
+ // DELETE /fs/file/*
208
+ if (req.method === "DELETE") {
209
+ try {
210
+ await rm(systemPath, { force: true });
211
+ } catch {
212
+ // ignore
213
+ }
214
+
215
+ broadcastFSEvent({
216
+ type: "fs-sync",
217
+ detail: {
218
+ metadata: null,
219
+ timestamp: Date.now(),
220
+ filepath: toPosix(systemPath.replace(cwd, "")),
221
+ },
222
+ });
223
+
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(
226
+ JSON.stringify({
227
+ conflict: false,
228
+ metadata: null,
229
+ timestamp: Date.now(),
230
+ }),
231
+ );
232
+ return;
233
+ }
234
+
235
+ res.writeHead(405);
236
+ res.end();
237
+ };
238
+ }
@@ -0,0 +1,8 @@
1
+ export { startTunnel } from "./tunnel.ts";
2
+ export type { TunnelOptions, TunnelConnection } from "./tunnel.ts";
3
+ export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth.ts";
4
+ export type { JwtPayload } from "./auth.ts";
5
+ export { createDaemonMiddleware } from "./middleware.ts";
6
+ export type { DaemonOptions } from "./middleware.ts";
7
+ export { createVolumesHandler } from "./volumes.ts";
8
+ export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch.ts";
@@ -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.ts";
14
+ import { createFSHandler } from "./fs.ts";
15
+ import { createVolumesHandler } from "./volumes.ts";
16
+ import { createWatchHandler, watchFS } from "./watch.ts";
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
+ }