@vellumai/assistant 0.11.3-dev.202608131925.65f1085 → 0.11.3-dev.202608132015.f8ab72f
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
package/src/routes/worker.ts
CHANGED
|
@@ -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
|
|
114
|
-
// handler
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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,
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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(
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|