veryfront 0.1.74 → 0.1.75
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/esm/cli/commands/knowledge/command.d.ts +2 -0
- package/esm/cli/commands/knowledge/command.d.ts.map +1 -1
- package/esm/cli/commands/knowledge/command.js +64 -1
- package/esm/deno.d.ts +2 -0
- package/esm/deno.js +3 -1
- package/esm/src/data/data-fetcher.d.ts +11 -1
- package/esm/src/data/data-fetcher.d.ts.map +1 -1
- package/esm/src/data/data-fetcher.js +5 -2
- package/esm/src/data/index.d.ts +1 -1
- package/esm/src/data/index.d.ts.map +1 -1
- package/esm/src/data/server-data-fetcher.d.ts +14 -1
- package/esm/src/data/server-data-fetcher.d.ts.map +1 -1
- package/esm/src/data/server-data-fetcher.js +49 -3
- package/esm/src/rendering/orchestrator/lifecycle.d.ts +4 -0
- package/esm/src/rendering/orchestrator/lifecycle.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/lifecycle.js +8 -0
- package/esm/src/rendering/orchestrator/pipeline.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/pipeline.js +6 -1
- package/esm/src/rendering/orchestrator/ssr-orchestrator.d.ts +26 -1
- package/esm/src/rendering/orchestrator/ssr-orchestrator.d.ts.map +1 -1
- package/esm/src/rendering/orchestrator/ssr-orchestrator.js +77 -1
- package/esm/src/routing/api/handler.d.ts.map +1 -1
- package/esm/src/routing/api/handler.js +6 -2
- package/esm/src/routing/api/route-executor.d.ts +8 -2
- package/esm/src/routing/api/route-executor.d.ts.map +1 -1
- package/esm/src/routing/api/route-executor.js +131 -3
- package/esm/src/security/deno-permissions.d.ts +6 -0
- package/esm/src/security/deno-permissions.d.ts.map +1 -1
- package/esm/src/security/deno-permissions.js +10 -0
- package/esm/src/security/sandbox/project-worker.d.ts +61 -0
- package/esm/src/security/sandbox/project-worker.d.ts.map +1 -0
- package/esm/src/security/sandbox/project-worker.js +318 -0
- package/esm/src/security/sandbox/worker-permissions.d.ts +30 -0
- package/esm/src/security/sandbox/worker-permissions.d.ts.map +1 -0
- package/esm/src/security/sandbox/worker-permissions.js +60 -0
- package/esm/src/security/sandbox/worker-pool.d.ts +87 -0
- package/esm/src/security/sandbox/worker-pool.d.ts.map +1 -0
- package/esm/src/security/sandbox/worker-pool.js +356 -0
- package/esm/src/security/sandbox/worker-types.d.ts +165 -0
- package/esm/src/security/sandbox/worker-types.d.ts.map +1 -0
- package/esm/src/security/sandbox/worker-types.js +17 -0
- package/esm/src/server/project-env/storage.d.ts +6 -0
- package/esm/src/server/project-env/storage.d.ts.map +1 -1
- package/esm/src/server/project-env/storage.js +8 -0
- package/esm/src/server/runtime-handler/project-isolation.d.ts +5 -0
- package/esm/src/server/runtime-handler/project-isolation.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/project-isolation.js +44 -0
- package/esm/src/server/shared/renderer/memory/pressure.d.ts +7 -0
- package/esm/src/server/shared/renderer/memory/pressure.d.ts.map +1 -1
- package/esm/src/server/shared/renderer/memory/pressure.js +7 -0
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.d.ts +4 -4
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.d.ts.map +1 -1
- package/esm/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.js +15 -15
- package/esm/src/utils/index.d.ts +10 -1
- package/esm/src/utils/index.d.ts.map +1 -1
- package/esm/src/utils/index.js +9 -1
- package/esm/src/utils/logger/index.d.ts +1 -1
- package/esm/src/utils/logger/index.d.ts.map +1 -1
- package/esm/src/utils/logger/index.js +1 -1
- package/esm/src/utils/logger/logger.d.ts +14 -0
- package/esm/src/utils/logger/logger.d.ts.map +1 -1
- package/esm/src/utils/logger/logger.js +17 -0
- package/esm/src/workflow/claude-code/tool.d.ts +5 -5
- package/package.json +4 -1
- package/src/cli/commands/knowledge/command.ts +76 -1
- package/src/deno.js +3 -1
- package/src/src/data/data-fetcher.ts +18 -2
- package/src/src/data/index.ts +1 -1
- package/src/src/data/server-data-fetcher.ts +78 -3
- package/src/src/rendering/orchestrator/lifecycle.ts +11 -0
- package/src/src/rendering/orchestrator/pipeline.ts +7 -2
- package/src/src/rendering/orchestrator/ssr-orchestrator.ts +119 -0
- package/src/src/routing/api/handler.ts +16 -3
- package/src/src/routing/api/route-executor.ts +222 -1
- package/src/src/security/deno-permissions.ts +11 -0
- package/src/src/security/sandbox/project-worker.ts +416 -0
- package/src/src/security/sandbox/worker-permissions.ts +74 -0
- package/src/src/security/sandbox/worker-pool.ts +451 -0
- package/src/src/security/sandbox/worker-types.ts +209 -0
- package/src/src/server/project-env/storage.ts +9 -0
- package/src/src/server/runtime-handler/project-isolation.ts +53 -0
- package/src/src/server/shared/renderer/memory/pressure.ts +8 -0
- package/src/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +18 -12
- package/src/src/utils/index.ts +11 -0
- package/src/src/utils/logger/index.ts +1 -0
- package/src/src/utils/logger/logger.ts +34 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as dntShim from "../../../_dnt.shims.js";
|
|
2
2
|
import type { FileSystemAdapter, RuntimeAdapter } from "../../platform/adapters/base.js";
|
|
3
|
-
import { createContext, normalizeParams } from "./context-builder.js";
|
|
3
|
+
import { createContext, normalizeParams, parseCookies } from "./context-builder.js";
|
|
4
4
|
import type { RouteMatch } from "./api-route-matcher.js";
|
|
5
5
|
import { createError, toError } from "../../errors/veryfront-error.js";
|
|
6
6
|
import type {
|
|
@@ -20,6 +20,16 @@ import { errorToRFC9457Response } from "../../errors/middleware/http-error-bound
|
|
|
20
20
|
import { serverLogger as logger } from "../../utils/index.js";
|
|
21
21
|
import { isDevelopment as isDevelopmentEnv } from "../../build/config/environment.js";
|
|
22
22
|
import type { HandlerContext } from "../../types/index.js";
|
|
23
|
+
import {
|
|
24
|
+
getWorkerPool,
|
|
25
|
+
isWorkerIsolationEnabled,
|
|
26
|
+
} from "../../security/sandbox/worker-pool.js";
|
|
27
|
+
import type {
|
|
28
|
+
SerializedRequest,
|
|
29
|
+
SerializedResponse,
|
|
30
|
+
WorkerResponse,
|
|
31
|
+
} from "../../security/sandbox/worker-types.js";
|
|
32
|
+
import { getProjectEnvSnapshot } from "../../server/project-env/storage.js";
|
|
23
33
|
|
|
24
34
|
function isDevelopment(adapter: RuntimeAdapter): boolean {
|
|
25
35
|
const env = adapter.env.get("MODE") ??
|
|
@@ -115,13 +125,206 @@ function toHeadResponse(response: dntShim.Response): dntShim.Response {
|
|
|
115
125
|
return new dntShim.Response(null, { status: response.status, headers: response.headers });
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Worker Isolation Helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
async function serializeRequest(request: dntShim.Request): Promise<SerializedRequest> {
|
|
133
|
+
const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
|
|
134
|
+
return {
|
|
135
|
+
url: request.url,
|
|
136
|
+
method: request.method,
|
|
137
|
+
headers: [...request.headers.entries()],
|
|
138
|
+
body,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deserializeResponse(s: SerializedResponse): dntShim.Response {
|
|
143
|
+
return new dntShim.Response(s.body as dntShim.BodyInit | null, {
|
|
144
|
+
status: s.status,
|
|
145
|
+
statusText: s.statusText,
|
|
146
|
+
headers: s.headers,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function workerResponseToResponse(
|
|
151
|
+
workerResponse: WorkerResponse,
|
|
152
|
+
pathname: string,
|
|
153
|
+
adapter: RuntimeAdapter,
|
|
154
|
+
): dntShim.Response {
|
|
155
|
+
if (workerResponse.type === "error") {
|
|
156
|
+
const { error } = workerResponse;
|
|
157
|
+
logger.error(`API route error in ${pathname} (worker):`, error.message);
|
|
158
|
+
|
|
159
|
+
// If the worker serialized RFC 9457 fields, return them directly
|
|
160
|
+
// to preserve the original status code, type, and detail.
|
|
161
|
+
if (error.status && error.type) {
|
|
162
|
+
return dntShim.Response.json(
|
|
163
|
+
{
|
|
164
|
+
type: error.type,
|
|
165
|
+
title: error.name,
|
|
166
|
+
status: error.status,
|
|
167
|
+
detail: error.detail ?? error.message,
|
|
168
|
+
instance: pathname,
|
|
169
|
+
},
|
|
170
|
+
{ status: error.status },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ctx = { isLocalProject: isDevelopment(adapter) } as HandlerContext;
|
|
175
|
+
const req = new dntShim.Request(`http://localhost${pathname}`);
|
|
176
|
+
const err = new Error(error.message);
|
|
177
|
+
err.name = error.name;
|
|
178
|
+
return errorToRFC9457Response(err, ctx, req);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (workerResponse.type === "result") {
|
|
182
|
+
return deserializeResponse(workerResponse.response);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// data-result type is not expected in API route execution
|
|
186
|
+
throw new Error(`Unexpected worker response type: ${workerResponse.type}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Isolated Execution (Worker Path)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
function executeAppRouteIsolated(
|
|
194
|
+
modulePath: string,
|
|
195
|
+
request: dntShim.Request,
|
|
196
|
+
match: RouteMatch,
|
|
197
|
+
pathname: string,
|
|
198
|
+
adapter: RuntimeAdapter,
|
|
199
|
+
projectDir: string,
|
|
200
|
+
): Promise<dntShim.Response> {
|
|
201
|
+
const method = request.method.toUpperCase() as HTTPMethod;
|
|
202
|
+
|
|
203
|
+
return withSpan(
|
|
204
|
+
"api.executeAppRoute.isolated",
|
|
205
|
+
async () => {
|
|
206
|
+
try {
|
|
207
|
+
const pool = getWorkerPool();
|
|
208
|
+
const serialized = await serializeRequest(request);
|
|
209
|
+
|
|
210
|
+
const workerResponse = await pool.execute(
|
|
211
|
+
projectDir,
|
|
212
|
+
[projectDir],
|
|
213
|
+
{
|
|
214
|
+
type: "execute-app-route",
|
|
215
|
+
id: dntShim.crypto.randomUUID(),
|
|
216
|
+
modulePath,
|
|
217
|
+
method,
|
|
218
|
+
request: serialized,
|
|
219
|
+
params: match.params,
|
|
220
|
+
projectDir,
|
|
221
|
+
projectEnv: getProjectEnvSnapshot(),
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const response = workerResponseToResponse(workerResponse, pathname, adapter);
|
|
226
|
+
return method === "HEAD" ? toHeadResponse(response) : response;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return handleAPIError(error, pathname, adapter);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"http.method": method,
|
|
233
|
+
"http.path": pathname,
|
|
234
|
+
"api.route.pattern": match.route.pattern,
|
|
235
|
+
"api.isolated": true,
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function executePagesRouteIsolated(
|
|
241
|
+
modulePath: string,
|
|
242
|
+
request: dntShim.Request,
|
|
243
|
+
match: RouteMatch,
|
|
244
|
+
pathname: string,
|
|
245
|
+
adapter: RuntimeAdapter,
|
|
246
|
+
projectDir: string,
|
|
247
|
+
): Promise<dntShim.Response> {
|
|
248
|
+
const method = request.method as string;
|
|
249
|
+
|
|
250
|
+
return withSpan(
|
|
251
|
+
"api.executePagesRoute.isolated",
|
|
252
|
+
async () => {
|
|
253
|
+
try {
|
|
254
|
+
const pool = getWorkerPool();
|
|
255
|
+
const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
|
|
256
|
+
|
|
257
|
+
const workerResponse = await pool.execute(
|
|
258
|
+
projectDir,
|
|
259
|
+
[projectDir],
|
|
260
|
+
{
|
|
261
|
+
type: "execute-pages-route",
|
|
262
|
+
id: dntShim.crypto.randomUUID(),
|
|
263
|
+
modulePath,
|
|
264
|
+
method,
|
|
265
|
+
context: {
|
|
266
|
+
url: request.url,
|
|
267
|
+
method: request.method,
|
|
268
|
+
headers: [...request.headers.entries()],
|
|
269
|
+
body,
|
|
270
|
+
params: match.params,
|
|
271
|
+
cookies: parseCookies(request.headers.get("cookie") ?? ""),
|
|
272
|
+
},
|
|
273
|
+
projectDir,
|
|
274
|
+
projectEnv: getProjectEnvSnapshot(),
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return workerResponseToResponse(workerResponse, pathname, adapter);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return handleAPIError(error, pathname, adapter);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"http.method": method,
|
|
285
|
+
"http.path": pathname,
|
|
286
|
+
"api.route.pattern": match.route.pattern,
|
|
287
|
+
"api.isolated": true,
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Public API
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export interface ExecuteRouteOptions {
|
|
297
|
+
/** Absolute path to the handler module on disk (for isolated execution) */
|
|
298
|
+
modulePath?: string;
|
|
299
|
+
/** Project directory (for isolated execution scope) */
|
|
300
|
+
projectDir?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
118
303
|
export function executeAppRoute(
|
|
119
304
|
handler: APIRoute,
|
|
120
305
|
request: dntShim.Request,
|
|
121
306
|
match: RouteMatch,
|
|
122
307
|
pathname: string,
|
|
123
308
|
adapter: RuntimeAdapter,
|
|
309
|
+
options?: ExecuteRouteOptions,
|
|
124
310
|
): Promise<dntShim.Response> {
|
|
311
|
+
// Isolated path: execute in per-project Worker, fall back to main process on error
|
|
312
|
+
if (
|
|
313
|
+
isWorkerIsolationEnabled() &&
|
|
314
|
+
options?.modulePath &&
|
|
315
|
+
options?.projectDir
|
|
316
|
+
) {
|
|
317
|
+
return executeAppRouteIsolated(
|
|
318
|
+
options.modulePath,
|
|
319
|
+
request,
|
|
320
|
+
match,
|
|
321
|
+
pathname,
|
|
322
|
+
adapter,
|
|
323
|
+
options.projectDir,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Default path: execute in main process (existing behavior)
|
|
125
328
|
const method = request.method.toUpperCase() as HTTPMethod;
|
|
126
329
|
|
|
127
330
|
return withSpan(
|
|
@@ -158,7 +361,25 @@ export function executePagesRoute(
|
|
|
158
361
|
pathname: string,
|
|
159
362
|
adapter: RuntimeAdapter,
|
|
160
363
|
projectDir?: string,
|
|
364
|
+
options?: ExecuteRouteOptions,
|
|
161
365
|
): Promise<dntShim.Response> {
|
|
366
|
+
// Isolated path: execute in per-project Worker, fall back to main process on error
|
|
367
|
+
if (
|
|
368
|
+
isWorkerIsolationEnabled() &&
|
|
369
|
+
options?.modulePath &&
|
|
370
|
+
(options?.projectDir ?? projectDir)
|
|
371
|
+
) {
|
|
372
|
+
return executePagesRouteIsolated(
|
|
373
|
+
options.modulePath,
|
|
374
|
+
request,
|
|
375
|
+
match,
|
|
376
|
+
pathname,
|
|
377
|
+
adapter,
|
|
378
|
+
options.projectDir ?? projectDir!,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Default path: execute in main process (existing behavior)
|
|
162
383
|
const method = request.method as keyof APIRoute;
|
|
163
384
|
|
|
164
385
|
return withSpan(
|
|
@@ -41,3 +41,14 @@ export const BUILD_HELPER_PERMISSIONS = [
|
|
|
41
41
|
"--allow-write",
|
|
42
42
|
"--allow-env",
|
|
43
43
|
] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* RENDER_WORKER — Per-project Worker for isolated code execution.
|
|
47
|
+
* Read-only filesystem (transformed modules), network (data fetchers),
|
|
48
|
+
* env (API keys and config). No subprocess/ffi/sys.
|
|
49
|
+
*/
|
|
50
|
+
export const RENDER_WORKER_PERMISSIONS = [
|
|
51
|
+
"--allow-read",
|
|
52
|
+
"--allow-net",
|
|
53
|
+
"--allow-env",
|
|
54
|
+
] as const;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Worker
|
|
3
|
+
*
|
|
4
|
+
* Wraps a single Deno Worker for one project. Manages the Worker lifecycle,
|
|
5
|
+
* sends/receives structured messages, enforces per-request timeouts,
|
|
6
|
+
* and serializes errors across the Worker boundary.
|
|
7
|
+
*
|
|
8
|
+
* @module security/sandbox/project-worker
|
|
9
|
+
*/
|
|
10
|
+
import * as dntShim from "../../../_dnt.shims.js";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import { serverLogger } from "../../utils/index.js";
|
|
14
|
+
import { isCompiledBinary } from "../../utils/index.js";
|
|
15
|
+
import { withSpan } from "../../observability/tracing/otlp-setup.js";
|
|
16
|
+
import { TIMEOUT_ERROR, UNKNOWN_ERROR } from "../../errors/index.js";
|
|
17
|
+
import type { WorkerPermissions } from "./worker-permissions.js";
|
|
18
|
+
import type {
|
|
19
|
+
WorkerRequest,
|
|
20
|
+
WorkerResponse,
|
|
21
|
+
WorkerStreamChunk,
|
|
22
|
+
WorkerStreamEnd,
|
|
23
|
+
} from "./worker-types.js";
|
|
24
|
+
|
|
25
|
+
const logger = serverLogger.component("project-worker");
|
|
26
|
+
|
|
27
|
+
type ExtendedWorkerOptions = {
|
|
28
|
+
type: "module";
|
|
29
|
+
name?: string;
|
|
30
|
+
deno?: { permissions: WorkerPermissions };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface ProjectWorkerOptions {
|
|
34
|
+
projectId: string;
|
|
35
|
+
permissions: WorkerPermissions;
|
|
36
|
+
requestTimeoutMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PendingRequest {
|
|
40
|
+
resolve: (value: WorkerResponse) => void;
|
|
41
|
+
reject: (error: Error) => void;
|
|
42
|
+
timer: ReturnType<typeof dntShim.setTimeout>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface StreamHandler {
|
|
46
|
+
onChunk: (chunk: Uint8Array) => void;
|
|
47
|
+
onEnd: () => void;
|
|
48
|
+
onError: (error: Error) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Status of a project worker.
|
|
53
|
+
*/
|
|
54
|
+
export type WorkerStatus = "idle" | "busy" | "crashed" | "terminated";
|
|
55
|
+
|
|
56
|
+
export class ProjectWorker {
|
|
57
|
+
readonly projectId: string;
|
|
58
|
+
|
|
59
|
+
private worker: Worker | null = null;
|
|
60
|
+
private pending = new Map<string, PendingRequest>();
|
|
61
|
+
private streamHandlers = new Map<string, StreamHandler>();
|
|
62
|
+
private requestTimeoutMs: number;
|
|
63
|
+
private permissions: WorkerPermissions;
|
|
64
|
+
private _requestCount = 0;
|
|
65
|
+
private _lastActivityAt = Date.now();
|
|
66
|
+
private _status: WorkerStatus = "idle";
|
|
67
|
+
|
|
68
|
+
constructor(options: ProjectWorkerOptions) {
|
|
69
|
+
this.projectId = options.projectId;
|
|
70
|
+
this.permissions = options.permissions;
|
|
71
|
+
this.requestTimeoutMs = options.requestTimeoutMs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get status(): WorkerStatus {
|
|
75
|
+
return this._status;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get requestCount(): number {
|
|
79
|
+
return this._requestCount;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get lastActivityAt(): number {
|
|
83
|
+
return this._lastActivityAt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get hasPendingRequests(): boolean {
|
|
87
|
+
return this.pending.size > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Start the worker. Idempotent — safe to call if already running.
|
|
92
|
+
*/
|
|
93
|
+
start(): void {
|
|
94
|
+
if (this.worker) return;
|
|
95
|
+
|
|
96
|
+
const workerUrl = this.getWorkerScriptUrl();
|
|
97
|
+
|
|
98
|
+
const workerOptions: ExtendedWorkerOptions = {
|
|
99
|
+
type: "module",
|
|
100
|
+
name: `project-worker-${this.projectId}`,
|
|
101
|
+
deno: { permissions: this.permissions },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// @ts-ignore - Deno Worker accepts extended options
|
|
105
|
+
this.worker = new Worker(workerUrl, workerOptions);
|
|
106
|
+
this._status = "idle";
|
|
107
|
+
|
|
108
|
+
this.worker.onmessage = (event: MessageEvent) => {
|
|
109
|
+
this.handleMessage(event.data);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
this.worker.onerror = (event) => {
|
|
113
|
+
logger.error("Worker error", {
|
|
114
|
+
projectId: this.projectId,
|
|
115
|
+
error: event.message ?? String(event),
|
|
116
|
+
});
|
|
117
|
+
this._status = "crashed";
|
|
118
|
+
this.rejectAllPending("Worker crashed");
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
logger.debug("Worker started", { projectId: this.projectId });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a request in this worker. Returns a typed response.
|
|
126
|
+
*/
|
|
127
|
+
execute(request: WorkerRequest): Promise<WorkerResponse> {
|
|
128
|
+
return withSpan(
|
|
129
|
+
"worker.execute",
|
|
130
|
+
() => {
|
|
131
|
+
if (!this.worker || this._status === "crashed" || this._status === "terminated") {
|
|
132
|
+
return Promise.reject(
|
|
133
|
+
UNKNOWN_ERROR.create({ detail: `Worker not available (status: ${this._status})` }),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._requestCount++;
|
|
138
|
+
this._lastActivityAt = Date.now();
|
|
139
|
+
this._status = "busy";
|
|
140
|
+
|
|
141
|
+
return new Promise<WorkerResponse>((resolve, reject) => {
|
|
142
|
+
const timer = dntShim.setTimeout(() => {
|
|
143
|
+
this.pending.delete(request.id);
|
|
144
|
+
this.updateIdleStatus();
|
|
145
|
+
reject(
|
|
146
|
+
TIMEOUT_ERROR.create({
|
|
147
|
+
detail: `Worker request timed out after ${this.requestTimeoutMs}ms`,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}, this.requestTimeoutMs);
|
|
151
|
+
|
|
152
|
+
this.pending.set(request.id, { resolve, reject, timer });
|
|
153
|
+
this.worker!.postMessage(request);
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"worker.projectId": this.projectId,
|
|
158
|
+
"worker.requestType": request.type,
|
|
159
|
+
"worker.requestId": request.id,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Execute a streaming request. Returns a ReadableStream that yields
|
|
166
|
+
* chunks as they arrive from the Worker via postMessage.
|
|
167
|
+
*
|
|
168
|
+
* Used for streaming SSR where the Worker sends chunks progressively.
|
|
169
|
+
* Falls back to a single-chunk stream if the Worker returns a non-streaming
|
|
170
|
+
* response (ssr-result with full HTML).
|
|
171
|
+
*/
|
|
172
|
+
executeStream(request: WorkerRequest): ReadableStream<Uint8Array> {
|
|
173
|
+
if (!this.worker || this._status === "crashed" || this._status === "terminated") {
|
|
174
|
+
throw UNKNOWN_ERROR.create({ detail: `Worker not available (status: ${this._status})` });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this._requestCount++;
|
|
178
|
+
this._lastActivityAt = Date.now();
|
|
179
|
+
this._status = "busy";
|
|
180
|
+
|
|
181
|
+
const requestId = request.id;
|
|
182
|
+
const encoder = new TextEncoder();
|
|
183
|
+
|
|
184
|
+
return new ReadableStream<Uint8Array>({
|
|
185
|
+
start: (controller) => {
|
|
186
|
+
let timer = dntShim.setTimeout(() => {
|
|
187
|
+
this.streamHandlers.delete(requestId);
|
|
188
|
+
this.pending.delete(requestId);
|
|
189
|
+
this.updateIdleStatus();
|
|
190
|
+
controller.error(
|
|
191
|
+
TIMEOUT_ERROR.create({
|
|
192
|
+
detail: `Worker stream timed out after ${this.requestTimeoutMs}ms`,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}, this.requestTimeoutMs);
|
|
196
|
+
|
|
197
|
+
const resetTimer = () => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
timer = dntShim.setTimeout(() => {
|
|
200
|
+
this.streamHandlers.delete(requestId);
|
|
201
|
+
this.pending.delete(requestId);
|
|
202
|
+
this.updateIdleStatus();
|
|
203
|
+
controller.error(
|
|
204
|
+
TIMEOUT_ERROR.create({
|
|
205
|
+
detail: `Worker stream timed out after ${this.requestTimeoutMs}ms`,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}, this.requestTimeoutMs);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Register a stream handler for this request
|
|
212
|
+
this.streamHandlers.set(requestId, {
|
|
213
|
+
onChunk: (chunk: Uint8Array) => {
|
|
214
|
+
resetTimer();
|
|
215
|
+
controller.enqueue(chunk);
|
|
216
|
+
},
|
|
217
|
+
onEnd: () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
this.streamHandlers.delete(requestId);
|
|
220
|
+
this.pending.delete(requestId);
|
|
221
|
+
this.updateIdleStatus();
|
|
222
|
+
controller.close();
|
|
223
|
+
},
|
|
224
|
+
onError: (error: Error) => {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
this.streamHandlers.delete(requestId);
|
|
227
|
+
this.pending.delete(requestId);
|
|
228
|
+
this.updateIdleStatus();
|
|
229
|
+
controller.error(error);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Also register in pending for non-streaming responses (fallback)
|
|
234
|
+
this.pending.set(requestId, {
|
|
235
|
+
resolve: (response) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
this.streamHandlers.delete(requestId);
|
|
238
|
+
this.pending.delete(requestId);
|
|
239
|
+
this.updateIdleStatus();
|
|
240
|
+
|
|
241
|
+
// If we get an ssr-result, emit it as a single chunk
|
|
242
|
+
if (response.type === "ssr-result") {
|
|
243
|
+
controller.enqueue(encoder.encode(response.html));
|
|
244
|
+
controller.close();
|
|
245
|
+
} else if (response.type === "error") {
|
|
246
|
+
const err = new Error(response.error.message);
|
|
247
|
+
err.name = response.error.name;
|
|
248
|
+
controller.error(err);
|
|
249
|
+
} else {
|
|
250
|
+
controller.close();
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
reject: (error) => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
this.streamHandlers.delete(requestId);
|
|
256
|
+
this.pending.delete(requestId);
|
|
257
|
+
this.updateIdleStatus();
|
|
258
|
+
controller.error(error);
|
|
259
|
+
},
|
|
260
|
+
timer,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
this.worker!.postMessage(request);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Health check — send a ping and wait for pong.
|
|
270
|
+
*/
|
|
271
|
+
async isHealthy(timeoutMs = 5_000): Promise<boolean> {
|
|
272
|
+
if (!this.worker || this._status === "crashed" || this._status === "terminated") {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const id = dntShim.crypto.randomUUID();
|
|
277
|
+
|
|
278
|
+
return new Promise<boolean>((resolve) => {
|
|
279
|
+
const timer = dntShim.setTimeout(() => {
|
|
280
|
+
this.pending.delete(id);
|
|
281
|
+
resolve(false);
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
|
|
284
|
+
this.pending.set(id, {
|
|
285
|
+
resolve: () => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
this.pending.delete(id);
|
|
288
|
+
resolve(true);
|
|
289
|
+
},
|
|
290
|
+
reject: () => {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
this.pending.delete(id);
|
|
293
|
+
resolve(false);
|
|
294
|
+
},
|
|
295
|
+
timer,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
this.worker!.postMessage({ type: "ping", id });
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Clear the worker's module cache. Used for dev mode hot reload.
|
|
304
|
+
*/
|
|
305
|
+
clearModuleCache(): void {
|
|
306
|
+
if (!this.worker || this._status === "crashed" || this._status === "terminated") return;
|
|
307
|
+
this.worker.postMessage({ type: "clear-cache" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Terminate the worker. Rejects all pending requests.
|
|
312
|
+
*/
|
|
313
|
+
terminate(): void {
|
|
314
|
+
if (!this.worker) return;
|
|
315
|
+
|
|
316
|
+
this._status = "terminated";
|
|
317
|
+
this.rejectAllPending("Worker terminated");
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
this.worker.terminate();
|
|
321
|
+
} catch (error) {
|
|
322
|
+
logger.debug("Worker terminate failed", {
|
|
323
|
+
projectId: this.projectId,
|
|
324
|
+
error,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this.worker = null;
|
|
329
|
+
logger.debug("Worker terminated", { projectId: this.projectId });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -----------------------------------------------------------------------
|
|
333
|
+
// Private
|
|
334
|
+
// -----------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
private getWorkerScriptUrl(): string {
|
|
337
|
+
// In compiled binary mode, use a data URL because blob URLs don't work
|
|
338
|
+
// See: deno-sandbox.ts for the same pattern
|
|
339
|
+
if (isCompiledBinary()) {
|
|
340
|
+
// For compiled binaries, we'd need to inline the worker script.
|
|
341
|
+
// For now, fall through to the import.meta.resolve path which works
|
|
342
|
+
// in development and standard Deno execution.
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Use import.meta.resolve to get the absolute URL of the worker script.
|
|
346
|
+
// This works in both `deno run` and `deno compile` contexts.
|
|
347
|
+
return import.meta.resolve("./worker-script.ts");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private handleMessage(
|
|
351
|
+
data:
|
|
352
|
+
| WorkerResponse
|
|
353
|
+
| WorkerStreamChunk
|
|
354
|
+
| WorkerStreamEnd
|
|
355
|
+
| { type: "pong"; id: string },
|
|
356
|
+
): void {
|
|
357
|
+
if (data.type === "pong") {
|
|
358
|
+
const pending = this.pending.get((data as { id: string }).id);
|
|
359
|
+
if (pending) {
|
|
360
|
+
clearTimeout(pending.timer);
|
|
361
|
+
pending.resolve(data as unknown as WorkerResponse);
|
|
362
|
+
this.pending.delete((data as { id: string }).id);
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Handle streaming SSR chunks
|
|
368
|
+
if (data.type === "stream-chunk") {
|
|
369
|
+
const handler = this.streamHandlers.get(data.id);
|
|
370
|
+
if (handler) handler.onChunk(data.chunk);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (data.type === "stream-end") {
|
|
375
|
+
const handler = this.streamHandlers.get(data.id);
|
|
376
|
+
if (handler) handler.onEnd();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const response = data as WorkerResponse;
|
|
381
|
+
const pending = this.pending.get(response.id);
|
|
382
|
+
if (!pending) {
|
|
383
|
+
logger.warn("Received response for unknown request", {
|
|
384
|
+
projectId: this.projectId,
|
|
385
|
+
id: response.id,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
clearTimeout(pending.timer);
|
|
391
|
+
this.pending.delete(response.id);
|
|
392
|
+
this.updateIdleStatus();
|
|
393
|
+
|
|
394
|
+
pending.resolve(response);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private updateIdleStatus(): void {
|
|
398
|
+
if (this.pending.size === 0 && this._status === "busy") {
|
|
399
|
+
this._status = "idle";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private rejectAllPending(reason: string): void {
|
|
404
|
+
for (const [id, pending] of this.pending) {
|
|
405
|
+
clearTimeout(pending.timer);
|
|
406
|
+
pending.reject(UNKNOWN_ERROR.create({ detail: reason }));
|
|
407
|
+
this.pending.delete(id);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Clean up stream handlers
|
|
411
|
+
for (const [id, handler] of this.streamHandlers) {
|
|
412
|
+
handler.onError(UNKNOWN_ERROR.create({ detail: reason }));
|
|
413
|
+
this.streamHandlers.delete(id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|