@decocms/runtime 1.0.0-alpha.38 → 1.0.0-alpha.40
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 +5 -3
- package/src/asset-server/index.test.ts +306 -0
- package/src/asset-server/index.ts +213 -42
- package/src/bindings.ts +10 -9
- package/src/events.ts +0 -23
- package/src/index.ts +1 -1
- package/src/tools.ts +5 -4
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/runtime",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.40",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"check": "tsc --noEmit"
|
|
6
|
+
"check": "tsc --noEmit",
|
|
7
|
+
"test": "bun test"
|
|
7
8
|
},
|
|
8
9
|
"dependencies": {
|
|
9
10
|
"@cloudflare/workers-types": "^4.20250617.0",
|
|
10
11
|
"@deco/mcp": "npm:@jsr/deco__mcp@0.7.8",
|
|
11
|
-
"@decocms/bindings": "1.0.1-alpha.
|
|
12
|
+
"@decocms/bindings": "1.0.1-alpha.23",
|
|
12
13
|
"@modelcontextprotocol/sdk": "1.20.2",
|
|
13
14
|
"@ai-sdk/provider": "^2.0.0",
|
|
14
15
|
"hono": "^4.10.7",
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"./tools": "./src/tools.ts"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.3.5",
|
|
28
30
|
"@types/mime-db": "^1.43.6",
|
|
29
31
|
"ts-json-schema-generator": "^2.4.0"
|
|
30
32
|
},
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isPathWithinDirectory,
|
|
4
|
+
resolveAssetPathWithTraversalCheck,
|
|
5
|
+
createAssetHandler,
|
|
6
|
+
} from "./index";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
9
|
+
|
|
10
|
+
describe("isPathWithinDirectory", () => {
|
|
11
|
+
const baseDir = "/app/client";
|
|
12
|
+
|
|
13
|
+
describe("safe paths", () => {
|
|
14
|
+
test("allows file directly in base directory", () => {
|
|
15
|
+
expect(isPathWithinDirectory("/app/client/index.html", baseDir)).toBe(
|
|
16
|
+
true,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("allows file in subdirectory", () => {
|
|
21
|
+
expect(
|
|
22
|
+
isPathWithinDirectory("/app/client/assets/style.css", baseDir),
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("allows deeply nested file", () => {
|
|
27
|
+
expect(
|
|
28
|
+
isPathWithinDirectory("/app/client/assets/images/logo.png", baseDir),
|
|
29
|
+
).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("allows base directory itself", () => {
|
|
33
|
+
expect(isPathWithinDirectory("/app/client", baseDir)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("allows file with spaces in name", () => {
|
|
37
|
+
expect(
|
|
38
|
+
isPathWithinDirectory("/app/client/logos/deco logo.svg", baseDir),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("path traversal attacks - BLOCKED", () => {
|
|
44
|
+
test("blocks simple traversal to parent", () => {
|
|
45
|
+
expect(isPathWithinDirectory("/app/style.css", baseDir)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("blocks traversal to root", () => {
|
|
49
|
+
expect(isPathWithinDirectory("/etc/passwd", baseDir)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("blocks traversal with ../ sequence", () => {
|
|
53
|
+
const traversalPath = resolve(baseDir, "../../../etc/passwd");
|
|
54
|
+
expect(isPathWithinDirectory(traversalPath, baseDir)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("blocks traversal to sibling directory", () => {
|
|
58
|
+
expect(isPathWithinDirectory("/app/server/secrets.json", baseDir)).toBe(
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("blocks path that starts with baseDir but is actually sibling", () => {
|
|
64
|
+
// /app/client-secrets is NOT within /app/client
|
|
65
|
+
expect(isPathWithinDirectory("/app/client-secrets/key", baseDir)).toBe(
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("blocks absolute path outside base", () => {
|
|
71
|
+
expect(isPathWithinDirectory("/var/log/system.log", baseDir)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("resolveAssetPathWithTraversalCheck", () => {
|
|
77
|
+
const clientDir = "/app/dist/client";
|
|
78
|
+
|
|
79
|
+
// Helper to reduce boilerplate
|
|
80
|
+
const resolvePath = (requestPath: string) =>
|
|
81
|
+
resolveAssetPathWithTraversalCheck({ requestPath, clientDir });
|
|
82
|
+
|
|
83
|
+
describe("valid paths", () => {
|
|
84
|
+
test("resolves root to clientDir", () => {
|
|
85
|
+
expect(resolvePath("/")).toBe(clientDir);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolves CSS file", () => {
|
|
89
|
+
expect(resolvePath("/style.css")).toBe("/app/dist/client/style.css");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolves nested path", () => {
|
|
93
|
+
expect(resolvePath("/assets/app.js")).toBe(
|
|
94
|
+
"/app/dist/client/assets/app.js",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("resolves path without extension", () => {
|
|
99
|
+
expect(resolvePath("/dashboard")).toBe("/app/dist/client/dashboard");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("resolves path with dots (SPA route)", () => {
|
|
103
|
+
expect(resolvePath("/user/john.doe")).toBe(
|
|
104
|
+
"/app/dist/client/user/john.doe",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("resolves file with spaces", () => {
|
|
109
|
+
expect(resolvePath("/logos/deco logo.svg")).toBe(
|
|
110
|
+
"/app/dist/client/logos/deco logo.svg",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("path traversal attacks - BLOCKED", () => {
|
|
116
|
+
test("blocks /../../../etc/passwd", () => {
|
|
117
|
+
expect(resolvePath("/../../../etc/passwd")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("blocks /assets/../../../etc/passwd", () => {
|
|
121
|
+
expect(resolvePath("/assets/../../../etc/passwd")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("blocks /./../../etc/passwd", () => {
|
|
125
|
+
expect(resolvePath("/./../../etc/passwd")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("blocks /../etc/passwd", () => {
|
|
129
|
+
expect(resolvePath("/../etc/passwd")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("allows backslash paths (treated as literal on Unix)", () => {
|
|
133
|
+
// On Unix, backslashes are literal characters - path stays in clientDir
|
|
134
|
+
expect(resolvePath("/..\\..\\etc\\passwd")).not.toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("blocks /assets/../../package.json", () => {
|
|
138
|
+
expect(resolvePath("/assets/../../package.json")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("blocks //etc/passwd (resolves to absolute path)", () => {
|
|
142
|
+
// Double slash after stripping leading / becomes /etc/passwd (absolute)
|
|
143
|
+
expect(resolvePath("//etc/passwd")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("blocks /valid/../../../etc/passwd", () => {
|
|
147
|
+
expect(resolvePath("/valid/../../../etc/passwd")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("createAssetHandler", () => {
|
|
153
|
+
// Temp directory for tests that need real files
|
|
154
|
+
const tempDir = resolve(import.meta.dir, ".test-temp-client");
|
|
155
|
+
const indexContent = "<!DOCTYPE html><html><body>SPA</body></html>";
|
|
156
|
+
const cssContent = "body { color: red; }";
|
|
157
|
+
|
|
158
|
+
beforeAll(() => {
|
|
159
|
+
// Create temp directory with test files
|
|
160
|
+
mkdirSync(resolve(tempDir, "assets"), { recursive: true });
|
|
161
|
+
writeFileSync(resolve(tempDir, "index.html"), indexContent);
|
|
162
|
+
writeFileSync(resolve(tempDir, "assets/style.css"), cssContent);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterAll(() => {
|
|
166
|
+
// Clean up temp directory
|
|
167
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("malformed URL encoding", () => {
|
|
171
|
+
test("handles malformed percent-encoded sequences gracefully", async () => {
|
|
172
|
+
// Create handler in production mode to test the decodeURIComponent path
|
|
173
|
+
const handler = createAssetHandler({
|
|
174
|
+
env: "production",
|
|
175
|
+
clientDir: "/app/dist/client",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// %E0%A4%A is an incomplete UTF-8 sequence that causes decodeURIComponent to throw
|
|
179
|
+
const malformedUrl = "http://localhost:3000/%E0%A4%A";
|
|
180
|
+
const request = new Request(malformedUrl);
|
|
181
|
+
|
|
182
|
+
// Should return null (graceful fallback) instead of throwing
|
|
183
|
+
const result = await handler(request);
|
|
184
|
+
expect(result).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("handles %FF (invalid UTF-8 byte) gracefully", async () => {
|
|
188
|
+
const handler = createAssetHandler({
|
|
189
|
+
env: "production",
|
|
190
|
+
clientDir: "/app/dist/client",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// %FF is not valid in UTF-8
|
|
194
|
+
const malformedUrl = "http://localhost:3000/%FF";
|
|
195
|
+
const request = new Request(malformedUrl);
|
|
196
|
+
|
|
197
|
+
const result = await handler(request);
|
|
198
|
+
expect(result).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("handles truncated multi-byte sequence gracefully", async () => {
|
|
202
|
+
const handler = createAssetHandler({
|
|
203
|
+
env: "production",
|
|
204
|
+
clientDir: "/app/dist/client",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// %C2 expects a continuation byte but is truncated
|
|
208
|
+
const malformedUrl = "http://localhost:3000/file%C2.txt";
|
|
209
|
+
const request = new Request(malformedUrl);
|
|
210
|
+
|
|
211
|
+
const result = await handler(request);
|
|
212
|
+
expect(result).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("SPA fallback for routes with dots", () => {
|
|
217
|
+
test("serves index.html for /user/john.doe (non-existent path with dot)", async () => {
|
|
218
|
+
const handler = createAssetHandler({
|
|
219
|
+
env: "production",
|
|
220
|
+
clientDir: tempDir,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const request = new Request("http://localhost:3000/user/john.doe");
|
|
224
|
+
const result = await handler(request);
|
|
225
|
+
|
|
226
|
+
expect(result).not.toBeNull();
|
|
227
|
+
expect(result?.status).toBe(200);
|
|
228
|
+
const text = await result?.text();
|
|
229
|
+
expect(text).toBe(indexContent);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("serves index.html for /page/v2.0 (version-like route)", async () => {
|
|
233
|
+
const handler = createAssetHandler({
|
|
234
|
+
env: "production",
|
|
235
|
+
clientDir: tempDir,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const request = new Request("http://localhost:3000/page/v2.0");
|
|
239
|
+
const result = await handler(request);
|
|
240
|
+
|
|
241
|
+
expect(result).not.toBeNull();
|
|
242
|
+
const text = await result?.text();
|
|
243
|
+
expect(text).toBe(indexContent);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("serves index.html for /files/report.2024 (date-like route)", async () => {
|
|
247
|
+
const handler = createAssetHandler({
|
|
248
|
+
env: "production",
|
|
249
|
+
clientDir: tempDir,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const request = new Request("http://localhost:3000/files/report.2024");
|
|
253
|
+
const result = await handler(request);
|
|
254
|
+
|
|
255
|
+
expect(result).not.toBeNull();
|
|
256
|
+
const text = await result?.text();
|
|
257
|
+
expect(text).toBe(indexContent);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("serves actual file when it exists", async () => {
|
|
261
|
+
const handler = createAssetHandler({
|
|
262
|
+
env: "production",
|
|
263
|
+
clientDir: tempDir,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const request = new Request("http://localhost:3000/assets/style.css");
|
|
267
|
+
const result = await handler(request);
|
|
268
|
+
|
|
269
|
+
expect(result).not.toBeNull();
|
|
270
|
+
expect(result?.status).toBe(200);
|
|
271
|
+
const text = await result?.text();
|
|
272
|
+
expect(text).toBe(cssContent);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("serves index.html for non-existent .css file", async () => {
|
|
276
|
+
const handler = createAssetHandler({
|
|
277
|
+
env: "production",
|
|
278
|
+
clientDir: tempDir,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// This CSS file doesn't exist, so it should fall back to index.html
|
|
282
|
+
const request = new Request(
|
|
283
|
+
"http://localhost:3000/assets/nonexistent.css",
|
|
284
|
+
);
|
|
285
|
+
const result = await handler(request);
|
|
286
|
+
|
|
287
|
+
expect(result).not.toBeNull();
|
|
288
|
+
const text = await result?.text();
|
|
289
|
+
expect(text).toBe(indexContent);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("serves index.html for route without dots", async () => {
|
|
293
|
+
const handler = createAssetHandler({
|
|
294
|
+
env: "production",
|
|
295
|
+
clientDir: tempDir,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const request = new Request("http://localhost:3000/dashboard");
|
|
299
|
+
const result = await handler(request);
|
|
300
|
+
|
|
301
|
+
expect(result).not.toBeNull();
|
|
302
|
+
const text = await result?.text();
|
|
303
|
+
expect(text).toBe(indexContent);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -1,56 +1,227 @@
|
|
|
1
|
-
import { serveStatic } from "hono/bun";
|
|
2
|
-
import { Hono } from "hono";
|
|
3
1
|
import { devServerProxy } from "./dev-server-proxy";
|
|
4
|
-
import {
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
5
3
|
|
|
6
|
-
interface AssetServerConfig {
|
|
7
|
-
env: "development" | "production" | "test";
|
|
8
|
-
localDevProxyUrl?: string | URL;
|
|
4
|
+
export interface AssetServerConfig {
|
|
9
5
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* Environment mode. Determines whether to proxy to dev server or serve static files.
|
|
7
|
+
* @default process.env.NODE_ENV || "development"
|
|
12
8
|
*/
|
|
13
|
-
|
|
9
|
+
env?: "development" | "production" | "test";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* URL of the Vite dev server for development mode.
|
|
13
|
+
* @default "http://localhost:4000"
|
|
14
|
+
*/
|
|
15
|
+
devServerUrl?: string | URL;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Directory containing the built client assets.
|
|
19
|
+
* For production bundles, use `resolveClientDir()` to get the correct path.
|
|
20
|
+
* @default "./dist/client"
|
|
21
|
+
*/
|
|
22
|
+
clientDir?: string;
|
|
23
|
+
|
|
14
24
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
25
|
+
* Function to check if a path should be handled by the API server.
|
|
26
|
+
* Return true for API routes, false for static files.
|
|
27
|
+
* If not provided, defaults to serving everything as static.
|
|
17
28
|
*/
|
|
18
|
-
|
|
29
|
+
isServerPath?: (path: string) => boolean;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
32
|
+
const DEFAULT_DEV_SERVER_URL = "http://localhost:4000";
|
|
33
|
+
const DEFAULT_CLIENT_DIR = "./dist/client";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a resolved file path is safely within the allowed base directory.
|
|
37
|
+
* Prevents path traversal attacks (e.g., /../../../etc/passwd).
|
|
38
|
+
*
|
|
39
|
+
* @param filePath - The resolved absolute file path
|
|
40
|
+
* @param baseDir - The base directory that files must be within
|
|
41
|
+
* @returns true if the path is safe, false if it's a traversal attempt
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* isPathWithinDirectory("/app/client/style.css", "/app/client") // true
|
|
46
|
+
* isPathWithinDirectory("/etc/passwd", "/app/client") // false
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function isPathWithinDirectory(
|
|
50
|
+
filePath: string,
|
|
51
|
+
baseDir: string,
|
|
52
|
+
): boolean {
|
|
53
|
+
const resolvedBase = resolve(baseDir);
|
|
54
|
+
const resolvedPath = resolve(filePath);
|
|
55
|
+
return (
|
|
56
|
+
resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + "/")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ResolveAssetPathOptions {
|
|
61
|
+
/** The decoded URL pathname (e.g., "/assets/style.css") */
|
|
62
|
+
requestPath: string;
|
|
63
|
+
/** The base directory containing static files */
|
|
64
|
+
clientDir: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a URL pathname to a file path, with path traversal protection.
|
|
69
|
+
* Returns null if the path is a traversal attempt.
|
|
70
|
+
*
|
|
71
|
+
* @returns File path, or null if unsafe
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* resolveAssetPathWithTraversalCheck({ requestPath: "/style.css", clientDir: "/app/client" })
|
|
76
|
+
* // "/app/client/style.css"
|
|
77
|
+
*
|
|
78
|
+
* resolveAssetPathWithTraversalCheck({ requestPath: "/../../../etc/passwd", clientDir: "/app/client" })
|
|
79
|
+
* // null (blocked)
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function resolveAssetPathWithTraversalCheck({
|
|
83
|
+
requestPath,
|
|
84
|
+
clientDir,
|
|
85
|
+
}: ResolveAssetPathOptions): string | null {
|
|
86
|
+
const relativePath = requestPath.startsWith("/")
|
|
87
|
+
? requestPath.slice(1)
|
|
88
|
+
: requestPath;
|
|
89
|
+
const filePath = resolve(clientDir, relativePath);
|
|
90
|
+
|
|
91
|
+
// Security: block path traversal attempts
|
|
92
|
+
if (!isPathWithinDirectory(filePath, clientDir)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return filePath;
|
|
97
|
+
}
|
|
24
98
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the client directory path relative to the running script.
|
|
101
|
+
* Use this for production bundles where the script location differs from CWD.
|
|
102
|
+
*
|
|
103
|
+
* @param importMetaUrl - Pass `import.meta.url` from the calling module
|
|
104
|
+
* @param relativePath - Path relative to the script (default: "../client")
|
|
105
|
+
* @returns Absolute path to the client directory
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* const clientDir = resolveClientDir(import.meta.url, "../client");
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function resolveClientDir(
|
|
113
|
+
importMetaUrl: string,
|
|
114
|
+
relativePath = "../client",
|
|
115
|
+
): string {
|
|
116
|
+
const scriptUrl = new URL(importMetaUrl);
|
|
117
|
+
const scriptDir = dirname(scriptUrl.pathname);
|
|
118
|
+
return resolve(scriptDir, relativePath);
|
|
28
119
|
}
|
|
29
120
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
121
|
+
/**
|
|
122
|
+
* TODO(@camudo): make "modes" so we can for example try to serve the asset then fallback to api on miss
|
|
123
|
+
* or try to serve the api call then fallback to asset or index.html on 404
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create an asset handler that works with Bun.serve.
|
|
128
|
+
*
|
|
129
|
+
* In development: Proxies requests to Vite dev server
|
|
130
|
+
* In production: Serves static files with SPA fallback
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const handleAssets = createAssetHandler({
|
|
135
|
+
* env: process.env.NODE_ENV as "development" | "production",
|
|
136
|
+
* clientDir: resolveClientDir(import.meta.url),
|
|
137
|
+
* isServerPath: (path) => path.startsWith("/api/") || path.startsWith("/mcp/"),
|
|
138
|
+
* });
|
|
139
|
+
*
|
|
140
|
+
* Bun.serve({
|
|
141
|
+
* fetch: async (request) => {
|
|
142
|
+
* return await handleAssets(request) ?? app.fetch(request);
|
|
143
|
+
* },
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function createAssetHandler(config: AssetServerConfig = {}) {
|
|
148
|
+
const {
|
|
149
|
+
env = (process.env.NODE_ENV as "development" | "production" | "test") ||
|
|
150
|
+
"development",
|
|
151
|
+
devServerUrl = DEFAULT_DEV_SERVER_URL,
|
|
152
|
+
clientDir = DEFAULT_CLIENT_DIR,
|
|
153
|
+
isServerPath = () => false,
|
|
154
|
+
} = config;
|
|
155
|
+
|
|
156
|
+
// Development: Create a proxy handler
|
|
157
|
+
if (env === "development") {
|
|
158
|
+
const proxyHandler = devServerProxy(devServerUrl);
|
|
159
|
+
|
|
160
|
+
return async function handleAssets(
|
|
161
|
+
request: Request,
|
|
162
|
+
): Promise<Response | null> {
|
|
163
|
+
// In dev, proxy everything except server paths
|
|
164
|
+
const url = new URL(request.url);
|
|
165
|
+
if (isServerPath(url.pathname)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create a minimal Hono context for the proxy
|
|
170
|
+
const fakeContext = {
|
|
171
|
+
req: { raw: request, url: request.url },
|
|
172
|
+
};
|
|
173
|
+
return proxyHandler(fakeContext as any);
|
|
174
|
+
};
|
|
46
175
|
}
|
|
47
|
-
};
|
|
48
176
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
177
|
+
// Production: Serve static files
|
|
178
|
+
return async function handleAssets(
|
|
179
|
+
request: Request,
|
|
180
|
+
): Promise<Response | null> {
|
|
181
|
+
// Only handle GET requests
|
|
182
|
+
if (request.method !== "GET") {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
54
185
|
|
|
55
|
-
|
|
56
|
-
|
|
186
|
+
const requestUrl = new URL(request.url);
|
|
187
|
+
|
|
188
|
+
// Decode the pathname to handle URL-encoded characters (e.g., %20 -> space)
|
|
189
|
+
// decodeURIComponent can throw URIError for malformed sequences (e.g., %E0%A4%A)
|
|
190
|
+
let path: string;
|
|
191
|
+
try {
|
|
192
|
+
path = decodeURIComponent(requestUrl.pathname);
|
|
193
|
+
} catch {
|
|
194
|
+
// Malformed URL encoding - return null to let API server handle or return 400
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Let API server handle its routes
|
|
199
|
+
if (isServerPath(path)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolve path with traversal check
|
|
204
|
+
const filePath = resolveAssetPathWithTraversalCheck({
|
|
205
|
+
requestPath: path,
|
|
206
|
+
clientDir,
|
|
207
|
+
});
|
|
208
|
+
if (!filePath) {
|
|
209
|
+
return null; // Path traversal attempt blocked
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Try to serve the requested file, fall back to index.html for SPA routing
|
|
213
|
+
const indexPath = resolve(clientDir, "index.html");
|
|
214
|
+
for (const pathToTry of [filePath, indexPath]) {
|
|
215
|
+
try {
|
|
216
|
+
const file = Bun.file(pathToTry);
|
|
217
|
+
if (await file.exists()) {
|
|
218
|
+
return new Response(file);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Continue to next path
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
}
|
package/src/bindings.ts
CHANGED
|
@@ -31,11 +31,14 @@ export type BindingRegistry = Record<string, readonly ToolBinder[]>;
|
|
|
31
31
|
/**
|
|
32
32
|
* Function that returns Zod Schema
|
|
33
33
|
*/
|
|
34
|
-
export const BindingOf = <
|
|
35
|
-
|
|
34
|
+
export const BindingOf = <
|
|
35
|
+
TRegistry extends BindingRegistry,
|
|
36
|
+
TName extends keyof TRegistry | "*",
|
|
37
|
+
>(
|
|
38
|
+
name: TName,
|
|
36
39
|
) => {
|
|
37
40
|
return z.object({
|
|
38
|
-
__type: z.literal(name).default(name
|
|
41
|
+
__type: z.literal<TName>(name).default(name),
|
|
39
42
|
value: z.string(),
|
|
40
43
|
});
|
|
41
44
|
};
|
|
@@ -84,10 +87,8 @@ export const isBinding = (v: unknown): v is Binding => {
|
|
|
84
87
|
return (
|
|
85
88
|
typeof v === "object" &&
|
|
86
89
|
v !== null &&
|
|
87
|
-
|
|
88
|
-
typeof v.
|
|
89
|
-
"value" in v &&
|
|
90
|
-
typeof v.value === "string"
|
|
90
|
+
typeof (v as { __type: string }).__type === "string" &&
|
|
91
|
+
typeof (v as { value: string }).value === "string"
|
|
91
92
|
);
|
|
92
93
|
};
|
|
93
94
|
|
|
@@ -169,9 +170,9 @@ export const initializeBindings = <
|
|
|
169
170
|
TBindings extends BindingRegistry = BindingRegistry,
|
|
170
171
|
>(
|
|
171
172
|
ctx: RequestContext,
|
|
172
|
-
):
|
|
173
|
+
): ResolvedBindings<T, TBindings> => {
|
|
173
174
|
// resolves the state in-place
|
|
174
|
-
traverseAndReplace(ctx.state, ctx) as ResolvedBindings<T, TBindings>;
|
|
175
|
+
return traverseAndReplace(ctx.state, ctx) as ResolvedBindings<T, TBindings>;
|
|
175
176
|
};
|
|
176
177
|
|
|
177
178
|
interface DefaultRegistry extends BindingRegistry {
|
package/src/events.ts
CHANGED
|
@@ -184,28 +184,6 @@ const getEventTypesForBinding = <TEnv, TSchema extends z.ZodTypeAny>(
|
|
|
184
184
|
return Object.keys(bindingHandler);
|
|
185
185
|
};
|
|
186
186
|
|
|
187
|
-
/**
|
|
188
|
-
* Get scopes from event handlers for subscription
|
|
189
|
-
*/
|
|
190
|
-
const scopesFromEvents = <TEnv, TSchema extends z.ZodTypeAny = never>(
|
|
191
|
-
handlers: EventHandlers<TEnv, TSchema>,
|
|
192
|
-
): string[] => {
|
|
193
|
-
if (isGlobalHandler<TEnv>(handlers)) {
|
|
194
|
-
// Global handler - scopes are based on explicit events array
|
|
195
|
-
// Note: "*" binding means all bindings
|
|
196
|
-
return handlers.events.map((event) => `*::event@${event}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const scopes: string[] = [];
|
|
200
|
-
for (const binding of getBindingKeys(handlers)) {
|
|
201
|
-
const eventTypes = getEventTypesForBinding(handlers, binding);
|
|
202
|
-
for (const eventType of eventTypes) {
|
|
203
|
-
scopes.push(`${binding}::event@${eventType}`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return scopes;
|
|
207
|
-
};
|
|
208
|
-
|
|
209
187
|
/**
|
|
210
188
|
* Get subscriptions from event handlers and state
|
|
211
189
|
* Returns flat array of { eventType, publisher } for EVENT_SYNC_SUBSCRIPTIONS
|
|
@@ -490,6 +468,5 @@ const executeEventHandlers = async <TEnv, TSchema extends z.ZodTypeAny>(
|
|
|
490
468
|
*/
|
|
491
469
|
export const Event = {
|
|
492
470
|
subscriptions: eventsSubscriptions,
|
|
493
|
-
scopes: scopesFromEvents,
|
|
494
471
|
execute: executeEventHandlers,
|
|
495
472
|
};
|
package/src/index.ts
CHANGED
package/src/tools.ts
CHANGED
|
@@ -279,7 +279,9 @@ const getEventBus = (
|
|
|
279
279
|
env: DefaultEnv,
|
|
280
280
|
): EventBusBindingClient | undefined => {
|
|
281
281
|
const bus = env as unknown as { [prop]: EventBusBindingClient };
|
|
282
|
-
return typeof bus[prop] !== "undefined"
|
|
282
|
+
return typeof bus[prop] !== "undefined"
|
|
283
|
+
? bus[prop]
|
|
284
|
+
: env?.MESH_REQUEST_CONTEXT.state[prop];
|
|
283
285
|
};
|
|
284
286
|
|
|
285
287
|
const toolsFor = <TSchema extends z.ZodTypeAny = never>({
|
|
@@ -291,7 +293,7 @@ const toolsFor = <TSchema extends z.ZodTypeAny = never>({
|
|
|
291
293
|
: { type: "object", properties: {} };
|
|
292
294
|
const busProp = String(events?.bus ?? "EVENT_BUS");
|
|
293
295
|
return [
|
|
294
|
-
...(onChange
|
|
296
|
+
...(onChange || events
|
|
295
297
|
? [
|
|
296
298
|
createTool({
|
|
297
299
|
id: "ON_MCP_CONFIGURATION",
|
|
@@ -307,7 +309,7 @@ const toolsFor = <TSchema extends z.ZodTypeAny = never>({
|
|
|
307
309
|
outputSchema: z.object({}),
|
|
308
310
|
execute: async (input) => {
|
|
309
311
|
const state = input.context.state as z.infer<TSchema>;
|
|
310
|
-
await onChange(input.runtimeContext.env, {
|
|
312
|
+
await onChange?.(input.runtimeContext.env, {
|
|
311
313
|
state,
|
|
312
314
|
scopes: input.context.scopes,
|
|
313
315
|
});
|
|
@@ -361,7 +363,6 @@ const toolsFor = <TSchema extends z.ZodTypeAny = never>({
|
|
|
361
363
|
stateSchema: jsonSchema,
|
|
362
364
|
scopes: [
|
|
363
365
|
...((scopes as string[]) ?? []),
|
|
364
|
-
...Event.scopes(events?.handlers ?? {}),
|
|
365
366
|
...(events ? [`${busProp}::EVENT_SYNC_SUBSCRIPTIONS`] : []),
|
|
366
367
|
],
|
|
367
368
|
});
|
package/tsconfig.json
CHANGED