@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
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/tanstack",
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./vite": "./src/vite/plugin.js",
|
|
10
|
+
"./daemon": "./src/daemon/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"test": "vitest run --root ../.. packages/tanstack/",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"lint:unused": "knip"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@decocms/blocks": "7.0.0",
|
|
20
|
+
"@decocms/blocks-admin": "7.0.0",
|
|
21
|
+
"@decocms/blocks-cli": "7.0.0",
|
|
22
|
+
"@deco-cx/warp-node": "^0.3.16",
|
|
23
|
+
"fast-json-patch": "^3.1.0",
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
28
|
+
"@tanstack/react-start": ">=1.0.0",
|
|
29
|
+
"@tanstack/store": ">=0.7.0",
|
|
30
|
+
"react": "^19.0.0",
|
|
31
|
+
"react-dom": "^19.0.0",
|
|
32
|
+
"vite": ">=6.0.0 || >=7.0.0 || >=8.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@tanstack/react-query": "^5.96.0",
|
|
36
|
+
"@tanstack/store": "^0.9.1",
|
|
37
|
+
"@types/react": "^19.0.0",
|
|
38
|
+
"@types/react-dom": "^19.0.0",
|
|
39
|
+
"@types/ws": "^8.18.0",
|
|
40
|
+
"knip": "^5.86.0",
|
|
41
|
+
"typescript": "^5.9.0",
|
|
42
|
+
"vite": ">=6.0.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"registry": "https://registry.npmjs.org",
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeRevision, KV_KEYS, type KVNamespace } from "@decocms/blocks/cms";
|
|
3
|
+
import { KVBlockSource } from "./kvBlockSource";
|
|
4
|
+
|
|
5
|
+
function makeKV(initial: Record<string, string> = {}): KVNamespace {
|
|
6
|
+
const store = new Map<string, string>(Object.entries(initial));
|
|
7
|
+
return {
|
|
8
|
+
get: (k) => Promise.resolve(store.get(k) ?? null),
|
|
9
|
+
put: (k, v) => {
|
|
10
|
+
store.set(k, v);
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
},
|
|
13
|
+
delete: (k) => {
|
|
14
|
+
store.delete(k);
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("KVBlockSource.loadSnapshot", () => {
|
|
21
|
+
it("returns null when the snapshot key is missing", async () => {
|
|
22
|
+
const src = new KVBlockSource(makeKV());
|
|
23
|
+
await expect(src.loadSnapshot()).resolves.toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns blocks with the stored revision", async () => {
|
|
27
|
+
const blocks = { Site: { name: "x" } };
|
|
28
|
+
const src = new KVBlockSource(
|
|
29
|
+
makeKV({
|
|
30
|
+
[KV_KEYS.SNAPSHOT]: JSON.stringify(blocks),
|
|
31
|
+
[KV_KEYS.REVISION]: "stored-rev",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
await expect(src.loadSnapshot()).resolves.toEqual({ blocks, revision: "stored-rev" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("recomputes the revision when only the snapshot is stored", async () => {
|
|
38
|
+
const blocks = { Site: { name: "x" } };
|
|
39
|
+
const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: JSON.stringify(blocks) }));
|
|
40
|
+
await expect(src.loadSnapshot()).resolves.toEqual({
|
|
41
|
+
blocks,
|
|
42
|
+
revision: computeRevision(blocks),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws on a non-object snapshot (treated as KV-unavailable by callers)", async () => {
|
|
47
|
+
const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: "[1,2,3]" }));
|
|
48
|
+
await expect(src.loadSnapshot()).rejects.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws on malformed JSON", async () => {
|
|
52
|
+
const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: "{not json" }));
|
|
53
|
+
await expect(src.loadSnapshot()).rejects.toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("KVBlockSource.getRevision", () => {
|
|
58
|
+
it("returns the stored revision", async () => {
|
|
59
|
+
const src = new KVBlockSource(makeKV({ [KV_KEYS.REVISION]: "r1" }));
|
|
60
|
+
await expect(src.getRevision()).resolves.toBe("r1");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null when absent", async () => {
|
|
64
|
+
const src = new KVBlockSource(makeKV());
|
|
65
|
+
await expect(src.getRevision()).resolves.toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KVBlockSource — a `BlockSource` backed by a Cloudflare KV namespace.
|
|
3
|
+
*
|
|
4
|
+
* Reads the whole decofile snapshot (`decofile:current`) and its revision
|
|
5
|
+
* (`index:revision`) from KV. Used by the runtime hydration path
|
|
6
|
+
* (`src/sdk/kvHydration.ts`) on cold start and during revision polling, and by
|
|
7
|
+
* the write-through path (`src/admin/decofile.ts`) which writes the same keys.
|
|
8
|
+
*
|
|
9
|
+
* This class is intentionally thin — error handling (KV outages, JSON parse
|
|
10
|
+
* failures) is the caller's responsibility so the framework can fall back to
|
|
11
|
+
* the bundled snapshot. See `kvHydration.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type BlockSnapshot,
|
|
16
|
+
type BlockSource,
|
|
17
|
+
computeRevision,
|
|
18
|
+
KV_KEYS,
|
|
19
|
+
type KVNamespace,
|
|
20
|
+
} from "@decocms/blocks/cms";
|
|
21
|
+
|
|
22
|
+
export class KVBlockSource implements BlockSource {
|
|
23
|
+
constructor(private readonly kv: KVNamespace) {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read and parse the full decofile snapshot from KV.
|
|
27
|
+
*
|
|
28
|
+
* Returns `null` when no snapshot is present (key missing) so the caller
|
|
29
|
+
* keeps whatever blocks are already in memory (the bundled fallback).
|
|
30
|
+
*
|
|
31
|
+
* The stored revision is preferred; if it's absent we recompute it from the
|
|
32
|
+
* blocks so the result is always self-consistent. A malformed snapshot
|
|
33
|
+
* (invalid JSON / non-object) throws — the caller treats that as "KV
|
|
34
|
+
* unavailable" and falls back.
|
|
35
|
+
*/
|
|
36
|
+
async loadSnapshot(): Promise<BlockSnapshot | null> {
|
|
37
|
+
const raw = await this.kv.get(KV_KEYS.SNAPSHOT);
|
|
38
|
+
if (raw === null) return null;
|
|
39
|
+
|
|
40
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
41
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
42
|
+
throw new Error(`[CMS/KV] ${KV_KEYS.SNAPSHOT} is not a JSON object`);
|
|
43
|
+
}
|
|
44
|
+
const blocks = parsed as Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
const storedRevision = await this.kv.get(KV_KEYS.REVISION);
|
|
47
|
+
return { blocks, revision: storedRevision ?? computeRevision(blocks) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cheap revision probe for change detection (no full snapshot transfer). */
|
|
51
|
+
getRevision(): Promise<string | null> {
|
|
52
|
+
return this.kv.get(KV_KEYS.REVISION);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT verification for admin.deco.cx requests.
|
|
3
|
+
* Uses Web Crypto only — no external dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Ported from: deco-cx/deco daemon/auth.ts + commons/jwt/*
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Public key — same key used by all sites (from commons/jwt/trusted.ts)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const ADMIN_PUBLIC_KEY =
|
|
14
|
+
process.env.DECO_ADMIN_PUBLIC_KEY ??
|
|
15
|
+
"eyJrdHkiOiJSU0EiLCJhbGciOiJSUzI1NiIsIm4iOiJ1N0Y3UklDN19Zc3ljTFhEYlBvQ1pUQnM2elZ6VjVPWkhXQ0M4akFZeFdPUnByem9WNDJDQ1JBVkVOVjJldzk1MnJOX2FTMmR3WDlmVGRvdk9zWl9jX2RVRXctdGlPN3hJLXd0YkxsanNUbUhoNFpiYXU0aUVoa0o1VGNHc2VaelhFYXNOSEhHdUo4SzY3WHluRHJSX0h4Ym9kQ2YxNFFJTmc5QnJjT3FNQmQyMUl4eUctVVhQampBTnRDTlNici1rXzFKeTZxNmtPeVJ1ZmV2Mjl0djA4Ykh5WDJQenp5Tnp3RWpjY0lROWpmSFdMN0JXX2tzdFpOOXU3TUtSLWJ4bjlSM0FKMEpZTHdXR3VnZGpNdVpBRnk0dm5BUXZzTk5Cd3p2YnFzMnZNd0dDTnF1ZE1tVmFudlNzQTJKYkE3Q0JoazI5TkRFTXRtUS1wbmo1cUlYSlEiLCJlIjoiQVFBQiIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZX0";
|
|
16
|
+
|
|
17
|
+
const BYPASS_JWT =
|
|
18
|
+
process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// JWT types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface JwtPayload {
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
iss?: string;
|
|
27
|
+
sub?: string;
|
|
28
|
+
aud?: string | string[];
|
|
29
|
+
exp?: number;
|
|
30
|
+
nbf?: number;
|
|
31
|
+
iat?: number;
|
|
32
|
+
jti?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Crypto helpers — ported from commons/jwt/keys.ts
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const ALG = "RSASSA-PKCS1-v1_5";
|
|
40
|
+
const HASH = "SHA-256";
|
|
41
|
+
|
|
42
|
+
function parseJWK(b64: string): JsonWebKey {
|
|
43
|
+
return JSON.parse(atob(b64));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let cachedKey: Promise<CryptoKey> | null = null;
|
|
47
|
+
|
|
48
|
+
function getAdminPublicKey(): Promise<CryptoKey> {
|
|
49
|
+
cachedKey ??= crypto.subtle.importKey(
|
|
50
|
+
"jwk",
|
|
51
|
+
parseJWK(ADMIN_PUBLIC_KEY),
|
|
52
|
+
{ name: ALG, hash: HASH },
|
|
53
|
+
false,
|
|
54
|
+
["verify"],
|
|
55
|
+
);
|
|
56
|
+
return cachedKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// JWT verification — ported from commons/jwt/jwt.ts
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function base64UrlDecode(str: string): Uint8Array {
|
|
64
|
+
const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
65
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
66
|
+
const binary = atob(b64 + pad);
|
|
67
|
+
const bytes = new Uint8Array(binary.length);
|
|
68
|
+
for (let i = 0; i < binary.length; i++) {
|
|
69
|
+
bytes[i] = binary.charCodeAt(i);
|
|
70
|
+
}
|
|
71
|
+
return bytes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function verifyAdminJwt(
|
|
75
|
+
token: string,
|
|
76
|
+
): Promise<JwtPayload | null> {
|
|
77
|
+
const parts = token.split(".");
|
|
78
|
+
if (parts.length !== 3) return null;
|
|
79
|
+
|
|
80
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
81
|
+
const signingInput = new TextEncoder().encode(
|
|
82
|
+
`${headerB64}.${payloadB64}`,
|
|
83
|
+
);
|
|
84
|
+
const signature = base64UrlDecode(signatureB64);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const key = await getAdminPublicKey();
|
|
88
|
+
const valid = await crypto.subtle.verify(
|
|
89
|
+
ALG,
|
|
90
|
+
key,
|
|
91
|
+
new Uint8Array(signature),
|
|
92
|
+
new Uint8Array(signingInput),
|
|
93
|
+
);
|
|
94
|
+
if (!valid) return null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const payload: JwtPayload = JSON.parse(
|
|
101
|
+
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
|
102
|
+
);
|
|
103
|
+
return payload;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// URN matching — ported from commons/jwt/engine.ts
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function matchPart(urnPart: string, otherPart: string): boolean {
|
|
114
|
+
return urnPart === "*" || otherPart === urnPart;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchParts(urn: string[], resource: string[]): boolean {
|
|
118
|
+
return urn.every((part, idx) => matchPart(part, resource[idx]));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matches(urnParts: string[]) {
|
|
122
|
+
return (resourceUrn: string) => {
|
|
123
|
+
const resourceParts = resourceUrn.split(":");
|
|
124
|
+
if (resourceParts.length > urnParts.length) return false;
|
|
125
|
+
const lastIdx = resourceParts.length - 1;
|
|
126
|
+
return resourceParts.every((part, idx) => {
|
|
127
|
+
if (part === "*") return true;
|
|
128
|
+
if (lastIdx === idx) {
|
|
129
|
+
return matchParts(part.split("/"), urnParts[idx].split("/"));
|
|
130
|
+
}
|
|
131
|
+
return part === urnParts[idx];
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function tokenIsValid(site: string, jwt: JwtPayload): boolean {
|
|
137
|
+
const { iss, sub, exp } = jwt;
|
|
138
|
+
if (!iss || !sub) return false;
|
|
139
|
+
if (exp && exp * 1000 <= Date.now()) return false;
|
|
140
|
+
const siteUrn = `urn:deco:site:*:${site}:deployment/*`;
|
|
141
|
+
return matches(sub.split(":"))(siteUrn);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Auth middleware for Connect (Vite dev server)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function extractToken(req: IncomingMessage): string | null {
|
|
149
|
+
const auth = req.headers.authorization;
|
|
150
|
+
if (auth) {
|
|
151
|
+
const parts = auth.split(/\s+/);
|
|
152
|
+
if (parts.length === 2) return parts[1];
|
|
153
|
+
}
|
|
154
|
+
// Fallback: ?token= query param
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
157
|
+
const t = url.searchParams.get("token");
|
|
158
|
+
if (t) return t;
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type NextFn = () => void;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns a Connect-style middleware that verifies JWT on every request.
|
|
169
|
+
* If invalid, responds 401/403. If valid (or bypass enabled), calls next().
|
|
170
|
+
*/
|
|
171
|
+
export function createAuthMiddleware(site: string) {
|
|
172
|
+
return async (
|
|
173
|
+
req: IncomingMessage,
|
|
174
|
+
res: ServerResponse,
|
|
175
|
+
next: NextFn,
|
|
176
|
+
): Promise<void> => {
|
|
177
|
+
if (BYPASS_JWT) {
|
|
178
|
+
next();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const token = extractToken(req);
|
|
183
|
+
if (!token) {
|
|
184
|
+
res.writeHead(401);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const jwt = await verifyAdminJwt(token);
|
|
190
|
+
if (!jwt) {
|
|
191
|
+
res.writeHead(401);
|
|
192
|
+
res.end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!tokenIsValid(site, jwt)) {
|
|
197
|
+
res.writeHead(403);
|
|
198
|
+
res.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
next();
|
|
203
|
+
};
|
|
204
|
+
}
|
package/src/daemon/fs.ts
ADDED
|
@@ -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";
|
|
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";
|
|
2
|
+
export type { TunnelOptions, TunnelConnection } from "./tunnel";
|
|
3
|
+
export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth";
|
|
4
|
+
export type { JwtPayload } from "./auth";
|
|
5
|
+
export { createDaemonMiddleware } from "./middleware";
|
|
6
|
+
export type { DaemonOptions } from "./middleware";
|
|
7
|
+
export { createVolumesHandler } from "./volumes";
|
|
8
|
+
export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch";
|