@vellumai/assistant 0.11.3-dev.202608131925.65f1085 → 0.11.3-dev.202608132218.8ea1f84

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608131925.65f1085",
3
+ "version": "0.11.3-dev.202608132218.8ea1f84",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,6 +25,11 @@ import type { IpcEnvelope } from "@vellumai/ipc-server-utils";
25
25
  import { IpcFrameReader, writeMessage } from "@vellumai/ipc-server-utils";
26
26
 
27
27
  import { disableStreamSeqStamping } from "../runtime/assistant-stream-state.js";
28
+ import {
29
+ evictRouteSourceTree,
30
+ importRouteModule,
31
+ sourceRootForHandler,
32
+ } from "../runtime/routes/user-route-import.js";
28
33
  import { getLogger } from "../util/logger.js";
29
34
  import {
30
35
  ensureProcDir,
@@ -43,6 +48,9 @@ import {
43
48
 
44
49
  const log = getLogger("route-host");
45
50
 
51
+ /** Last entry-file mtime imported in this process, keyed by handler path. */
52
+ const lastHandlerMtime = new Map<string, number>();
53
+
46
54
  const socketPath = getProcSocketPath(ROUTE_HOST_PROC_NAME);
47
55
  const pidPath = getProcPidPath(ROUTE_HOST_PROC_NAME);
48
56
 
@@ -110,11 +118,15 @@ async function handleInvoke(
110
118
  params: RouteInvokeParams,
111
119
  body: Uint8Array | undefined,
112
120
  ): Promise<void> {
113
- // Each worker has its own module cache; cache-bust per mtime so an edited
114
- // handler is re-imported.
115
- const mod = (await import(
116
- `${params.filePath}?t=${params.mtimeMs}`
117
- )) as Record<string, unknown>;
121
+ // Each worker has its own module cache. When the entry file's mtime
122
+ // moves, evict the whole source tree so a new handler cannot bind to a
123
+ // stale helper, then re-import.
124
+ const lastMtime = lastHandlerMtime.get(params.filePath);
125
+ if (lastMtime !== params.mtimeMs) {
126
+ evictRouteSourceTree(sourceRootForHandler(params.filePath));
127
+ lastHandlerMtime.set(params.filePath, params.mtimeMs);
128
+ }
129
+ const mod = await importRouteModule(params.filePath);
118
130
 
119
131
  const handler = mod[params.method];
120
132
  if (typeof handler !== "function") {
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -574,4 +574,65 @@ describe("plugin routes", () => {
574
574
  );
575
575
  expect(other.status).toBe(404);
576
576
  });
577
+
578
+ test("a new route export is not bound to a stale helper after upgrade", async () => {
579
+ // Reproduces the browser plugin 500: `/frame` loaded `src/http.ts`
580
+ // first, then an upgrade added `requireNumber` to http.ts and a new
581
+ // `/input` that imports it. Cache-busting only the entry file rebound
582
+ // input.ts to the cached http.ts, which had no such export.
583
+ const plugin = "stale-helper";
584
+ const helperPath = join(getWorkspacePluginsDir(), plugin, "src", "http.ts");
585
+ mkdirSync(dirname(helperPath), { recursive: true });
586
+ writeFileSync(
587
+ helperPath,
588
+ `export function label() { return "v1"; }\n`,
589
+ );
590
+ writePluginHandler(
591
+ plugin,
592
+ "frame.ts",
593
+ `import { label } from "../src/http.js";
594
+ export function GET() { return Response.json({ label: label() }); }`,
595
+ );
596
+ writePluginHandler(
597
+ plugin,
598
+ "input.ts",
599
+ `import { label } from "../src/http.js";
600
+ export function POST() { return Response.json({ label: label() }); }`,
601
+ );
602
+
603
+ const dispatcher = makeDispatcher();
604
+ const frame1 = await dispatcher.dispatch(
605
+ `plugins/${plugin}/frame`,
606
+ makeRequest("GET"),
607
+ );
608
+ expect(frame1.status).toBe(200);
609
+ expect((await frame1.json()).label).toBe("v1");
610
+
611
+ await new Promise((resolve) => setTimeout(resolve, 50));
612
+ writeFileSync(
613
+ helperPath,
614
+ `export function label() { return "v2"; }
615
+ export function extra() { return true; }\n`,
616
+ );
617
+ writePluginHandler(
618
+ plugin,
619
+ "input.ts",
620
+ `import { extra } from "../src/http.js";
621
+ export function POST() { return Response.json({ extra: extra() }); }`,
622
+ );
623
+
624
+ const input = await dispatcher.dispatch(
625
+ `plugins/${plugin}/input`,
626
+ makeRequest("POST"),
627
+ );
628
+ expect(input.status).toBe(200);
629
+ expect((await input.json()).extra).toBe(true);
630
+
631
+ const frame2 = await dispatcher.dispatch(
632
+ `plugins/${plugin}/frame`,
633
+ makeRequest("GET"),
634
+ );
635
+ expect(frame2.status).toBe(200);
636
+ expect((await frame2.json()).label).toBe("v2");
637
+ });
577
638
  });
@@ -27,9 +27,12 @@
27
27
  * shows no route depends on it.
28
28
  *
29
29
  * Modules are lazily loaded on first request and cached by file path +
30
- * mtime. When a file changes on disk, the next request reloads it via
31
- * Bun's dynamic `import()` with a cache-busting query parameter. A request
32
- * whose file does not exist 404s — nothing is registered ahead of time.
30
+ * mtime. When a file changes on disk, the next request evicts the whole
31
+ * source tree that file belongs to (plugin root, or the workspace
32
+ * `routes/` directory) and re-imports the entry. Cache-busting only the
33
+ * entry file would re-bind it to stale helpers still in Bun's registry.
34
+ * A request whose file does not exist 404s. Nothing is registered ahead
35
+ * of time.
33
36
  */
34
37
 
35
38
  import { statSync } from "node:fs";
@@ -46,6 +49,11 @@ import {
46
49
  buildDeprecatedRouteContext,
47
50
  type UserRouteContext,
48
51
  } from "./deprecated-route-context.js";
52
+ import {
53
+ evictRouteSourceTree,
54
+ importRouteModule,
55
+ routeSourceRoot,
56
+ } from "./user-route-import.js";
49
57
  import {
50
58
  resolveHandlerFile,
51
59
  resolveRouteLocation,
@@ -134,7 +142,7 @@ export class UserRouteDispatcher {
134
142
  ? resolveHandlerFile(location.routesDir, location.subPath)
135
143
  : null;
136
144
 
137
- if (!filePath) {
145
+ if (!location || !filePath) {
138
146
  return httpError(
139
147
  "NOT_FOUND",
140
148
  `No route handler found for /x/${routePath}`,
@@ -146,7 +154,7 @@ export class UserRouteDispatcher {
146
154
  return this.dispatchViaHost(filePath, routePath, request);
147
155
  }
148
156
 
149
- const mod = await this.loadModule(filePath);
157
+ const mod = await this.loadModule(filePath, location.routesDir);
150
158
  const method = request.method as HttpMethod;
151
159
  const handler = mod.handlers[method];
152
160
 
@@ -231,11 +239,15 @@ export class UserRouteDispatcher {
231
239
  /**
232
240
  * Load a handler module, using the mtime-based cache when possible.
233
241
  *
234
- * On cache miss or stale mtime, the module is re-imported via Bun's
235
- * dynamic `import()` with a cache-busting query parameter derived
236
- * from the file's current mtime.
242
+ * On cache miss or stale mtime, every source file in the handler's tree
243
+ * is evicted first so a new entry cannot import a helper that is still
244
+ * the pre-upgrade module. Then the entry is re-imported with an mtime
245
+ * query parameter.
237
246
  */
238
- private async loadModule(filePath: string): Promise<CachedModule> {
247
+ private async loadModule(
248
+ filePath: string,
249
+ routesDir: string,
250
+ ): Promise<CachedModule> {
239
251
  const stat = statSync(filePath);
240
252
  const mtimeMs = stat.mtimeMs;
241
253
 
@@ -244,11 +256,11 @@ export class UserRouteDispatcher {
244
256
  return cached;
245
257
  }
246
258
 
247
- // Cache-bust Bun's module cache by appending mtime as a query param.
248
- const mod = (await import(`${filePath}?t=${mtimeMs}`)) as Record<
249
- string,
250
- unknown
251
- >;
259
+ const sourceRoot = routeSourceRoot(routesDir);
260
+ evictRouteSourceTree(sourceRoot);
261
+ this.dropCachedModulesUnder(sourceRoot);
262
+
263
+ const mod = await importRouteModule(filePath);
252
264
 
253
265
  const handlers: Partial<Record<HttpMethod, RouteHandler>> = {};
254
266
  for (const method of HTTP_METHODS) {
@@ -271,6 +283,19 @@ export class UserRouteDispatcher {
271
283
  return entry;
272
284
  }
273
285
 
286
+ /**
287
+ * Drop every cached handler under `sourceRoot`. Sibling routes must not
288
+ * keep closures over the helpers we just evicted.
289
+ */
290
+ private dropCachedModulesUnder(sourceRoot: string): void {
291
+ const prefix = sourceRoot.endsWith("/") ? sourceRoot : `${sourceRoot}/`;
292
+ for (const path of this.moduleCache.keys()) {
293
+ if (path === sourceRoot || path.startsWith(prefix)) {
294
+ this.moduleCache.delete(path);
295
+ }
296
+ }
297
+ }
298
+
274
299
  /**
275
300
  * Execute a handler function with a per-request timeout and error boundary.
276
301
  */
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Import a user/plugin route module so its helpers match the files on disk.
3
+ *
4
+ * Bun caches every imported helper independently. Cache-busting only the
5
+ * entry file (`import(file?t=mtime)`) re-binds it to whatever helpers are
6
+ * already in the registry. That is how a new route that imports a new export
7
+ * from `src/http.ts` 500s with "Export named 'requireNumber' not found" while
8
+ * `/frame` (still on the old http.ts surface) keeps answering 200.
9
+ *
10
+ * Hooks and tools already treat the whole plugin directory as the reload
11
+ * unit (`plugins/source-fingerprint.ts`). Routes must do the same: when the
12
+ * entry file's mtime moves, evict every source file in that tree before the
13
+ * next import, so the new entry cannot pair with a stale helper.
14
+ */
15
+
16
+ import { statSync } from "node:fs";
17
+ import { dirname, sep } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ import { snapshotPluginSource } from "../../plugins/source-fingerprint.js";
21
+ import { evictModule } from "../../plugins/surface-import.js";
22
+ import { getWorkspaceRoutesDir } from "../../util/platform.js";
23
+
24
+ /**
25
+ * Directory whose source files must stay mutually consistent with this
26
+ * handler. An installed or default plugin uses the plugin root (so `src/`
27
+ * helpers evict with `routes/`). Workspace routes use the workspace
28
+ * `routes/` directory.
29
+ */
30
+ export function routeSourceRoot(routesDir: string): string {
31
+ if (routesDir === getWorkspaceRoutesDir()) {
32
+ return routesDir;
33
+ }
34
+ return dirname(routesDir);
35
+ }
36
+
37
+ /**
38
+ * Infer the source root from a handler path when the caller has no
39
+ * {@link routeSourceRoot} (the route-host worker only sees `filePath`).
40
+ */
41
+ export function sourceRootForHandler(filePath: string): string {
42
+ const marker = `${sep}routes${sep}`;
43
+ const idx = filePath.lastIndexOf(marker);
44
+ if (idx !== -1) {
45
+ return filePath.slice(0, idx);
46
+ }
47
+ return dirname(filePath);
48
+ }
49
+
50
+ /**
51
+ * Drop every source module under `sourceRoot` from the runtime registry,
52
+ * including query-string aliases (`file.ts?t=mtime`) the dispatcher uses to
53
+ * cache-bust the entry file.
54
+ */
55
+ export function evictRouteSourceTree(sourceRoot: string): void {
56
+ const { evictionPaths } = snapshotPluginSource(sourceRoot);
57
+ for (const path of evictionPaths) {
58
+ evictModule(path);
59
+ }
60
+ evictRegistryAliases(sourceRoot);
61
+ }
62
+
63
+ function evictRegistryAliases(sourceRoot: string): void {
64
+ const prefix = sourceRoot.endsWith("/") ? sourceRoot : `${sourceRoot}/`;
65
+ const fileUrlPrefix = pathToFileURL(prefix).href;
66
+ for (const key of Object.keys(require.cache)) {
67
+ if (!keyBelongsToTree(key, sourceRoot, prefix, fileUrlPrefix)) {
68
+ continue;
69
+ }
70
+ delete require.cache[key];
71
+ }
72
+ }
73
+
74
+ function keyBelongsToTree(
75
+ key: string,
76
+ sourceRoot: string,
77
+ prefix: string,
78
+ fileUrlPrefix: string,
79
+ ): boolean {
80
+ const bare = key.split("?")[0] ?? key;
81
+ if (bare.includes("/node_modules/") || bare.includes("/node_modules?")) {
82
+ return false;
83
+ }
84
+ if (
85
+ bare === sourceRoot ||
86
+ bare.startsWith(prefix) ||
87
+ bare.startsWith(fileUrlPrefix)
88
+ ) {
89
+ return true;
90
+ }
91
+ if (bare.startsWith("file:")) {
92
+ try {
93
+ const asPath = fileURLToPath(bare);
94
+ return asPath === sourceRoot || asPath.startsWith(prefix);
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /** Dynamic-import a handler, cache-busted by the file's current mtime. */
103
+ export async function importRouteModule(
104
+ filePath: string,
105
+ ): Promise<Record<string, unknown>> {
106
+ const mtimeMs = statSync(filePath).mtimeMs;
107
+ return (await import(`${filePath}?t=${mtimeMs}`)) as Record<string, unknown>;
108
+ }