@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 CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.38",
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.21",
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 { Handler } from "hono/types";
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
- * The prefix to use for serving the assets.
11
- * Default: "/assets/*"
6
+ * Environment mode. Determines whether to proxy to dev server or serve static files.
7
+ * @default process.env.NODE_ENV || "development"
12
8
  */
13
- assetsMiddlewarePath?: string;
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
- * The directory to serve the assets from.
16
- * Default: "./dist/client"
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
- assetsDirectory?: string;
29
+ isServerPath?: (path: string) => boolean;
19
30
  }
20
31
 
21
- const DEFAULT_LOCAL_DEV_PROXY_URL = "http://localhost:4000";
22
- const DEFAULT_ASSETS_DIRECTORY = "./dist/client";
23
- const DEFAULT_ASSETS_MIDDLEWARE_PATH = "/assets/*";
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
- interface HonoApp {
26
- use: (path: string, handler: Handler) => void;
27
- get: (path: string, handler: Handler) => void;
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
- export const applyAssetServerRoutes = (
31
- app: HonoApp,
32
- config: AssetServerConfig,
33
- ) => {
34
- const environment = config.env;
35
- const localDevProxyUrl =
36
- config.localDevProxyUrl ?? DEFAULT_LOCAL_DEV_PROXY_URL;
37
- const assetsDirectory = config.assetsDirectory ?? DEFAULT_ASSETS_DIRECTORY;
38
- const assetsMiddlewarePath =
39
- config.assetsMiddlewarePath ?? DEFAULT_ASSETS_MIDDLEWARE_PATH;
40
-
41
- if (environment === "development") {
42
- app.use("*", devServerProxy(localDevProxyUrl));
43
- } else if (environment === "production") {
44
- app.use(assetsMiddlewarePath, serveStatic({ root: assetsDirectory }));
45
- app.get("*", serveStatic({ path: `${assetsDirectory}/index.html` }));
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
- export const createAssetServer = (config: AssetServerConfig) => {
50
- const app = new Hono();
51
- applyAssetServerRoutes(app, config);
52
- return app;
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
- export const createAssetServerFetcher = (config: AssetServerConfig) =>
56
- createAssetServer(config).fetch;
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 = <TRegistry extends BindingRegistry>(
35
- name: keyof TRegistry | "*",
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 as any),
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
- "__type" in v &&
88
- typeof v.__type === "string" &&
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
- ): void => {
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
@@ -214,7 +214,7 @@ export const withBindings = <TEnv>({
214
214
  }
215
215
 
216
216
  env.MESH_REQUEST_CONTEXT = context;
217
- initializeBindings(context);
217
+ context.state = initializeBindings(context);
218
218
 
219
219
  withDefaultBindings({
220
220
  env,
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" ? 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../tsconfig.json",
3
3
  "compilerOptions": {
4
- "types": ["@cloudflare/workers-types", "@types/node"]
4
+ "types": ["@cloudflare/workers-types", "@types/node", "bun"]
5
5
  },
6
6
  "include": ["src/**/*"],
7
7
  "exclude": ["node_modules", "dist", "scripts/**/*"]