@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|