veryfront 0.1.74 → 0.1.76
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/README.md +2 -0
- package/esm/cli/commands/files/command.d.ts +3 -3
- 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 +7 -0
- package/esm/deno.js +13 -6
- 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 +65 -3
- package/esm/src/jobs/index.d.ts +34 -0
- package/esm/src/jobs/index.d.ts.map +1 -0
- package/esm/src/jobs/index.js +33 -0
- package/esm/src/jobs/jobs-client.d.ts +134 -0
- package/esm/src/jobs/jobs-client.d.ts.map +1 -0
- package/esm/src/jobs/jobs-client.js +218 -0
- package/esm/src/jobs/schemas.d.ts +1304 -0
- package/esm/src/jobs/schemas.d.ts.map +1 -0
- package/esm/src/jobs/schemas.js +159 -0
- package/esm/src/platform/adapters/veryfront-api-client/retry-handler.d.ts +4 -0
- package/esm/src/platform/adapters/veryfront-api-client/retry-handler.d.ts.map +1 -1
- package/esm/src/platform/adapters/veryfront-api-client/retry-handler.js +12 -6
- package/esm/src/proxy/handler.d.ts.map +1 -1
- package/esm/src/proxy/handler.js +21 -21
- 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 +158 -3
- package/esm/src/security/deno-permissions.d.ts +7 -1
- package/esm/src/security/deno-permissions.d.ts.map +1 -1
- package/esm/src/security/deno-permissions.js +12 -1
- 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 +63 -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 +359 -0
- package/esm/src/security/sandbox/worker-types.d.ts +167 -0
- package/esm/src/security/sandbox/worker-types.d.ts.map +1 -0
- package/esm/src/security/sandbox/worker-types.js +19 -0
- package/esm/src/server/handlers/request/internal-tasks-list.handler.d.ts +11 -0
- package/esm/src/server/handlers/request/internal-tasks-list.handler.d.ts.map +1 -0
- package/esm/src/server/handlers/request/internal-tasks-list.handler.js +72 -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/index.d.ts +1 -1
- package/esm/src/server/runtime-handler/index.d.ts.map +1 -1
- package/esm/src/server/runtime-handler/index.js +3 -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/task/control-plane.d.ts +105 -0
- package/esm/src/task/control-plane.d.ts.map +1 -0
- package/esm/src/task/control-plane.js +52 -0
- package/esm/src/task/types.d.ts +6 -0
- package/esm/src/task/types.d.ts.map +1 -1
- 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 +8 -1
- package/src/cli/commands/knowledge/command.ts +76 -1
- package/src/deno.js +13 -6
- 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 +106 -3
- package/src/src/jobs/index.ts +85 -0
- package/src/src/jobs/jobs-client.ts +503 -0
- package/src/src/jobs/schemas.ts +202 -0
- package/src/src/platform/adapters/veryfront-api-client/retry-handler.ts +15 -6
- package/src/src/proxy/handler.ts +27 -19
- 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 +258 -1
- package/src/src/security/deno-permissions.ts +13 -1
- package/src/src/security/sandbox/project-worker.ts +416 -0
- package/src/src/security/sandbox/worker-permissions.ts +77 -0
- package/src/src/security/sandbox/worker-pool.ts +459 -0
- package/src/src/security/sandbox/worker-types.ts +212 -0
- package/src/src/server/handlers/request/internal-tasks-list.handler.ts +103 -0
- package/src/src/server/project-env/storage.ts +9 -0
- package/src/src/server/runtime-handler/index.ts +3 -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/task/control-plane.ts +76 -0
- package/src/src/task/types.ts +6 -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
|
@@ -18,6 +18,9 @@ export interface RequestOptions {
|
|
|
18
18
|
returnText?: boolean;
|
|
19
19
|
/** Request timeout in milliseconds. Defaults to 30000ms (30 seconds). */
|
|
20
20
|
timeoutMs?: number;
|
|
21
|
+
method?: string;
|
|
22
|
+
body?: dntShim.BodyInit | null;
|
|
23
|
+
headers?: dntShim.HeadersInit;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** Default timeout for API requests (30 seconds) */
|
|
@@ -48,13 +51,19 @@ export async function requestWithRetry(
|
|
|
48
51
|
async () => {
|
|
49
52
|
const startTime = performance.now();
|
|
50
53
|
|
|
51
|
-
const headers = new dntShim.Headers(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const headers = new dntShim.Headers(options.headers);
|
|
55
|
+
headers.set("Authorization", `Bearer ${apiToken}`);
|
|
56
|
+
if (!headers.has("Content-Type")) {
|
|
57
|
+
headers.set("Content-Type", "application/json");
|
|
58
|
+
}
|
|
55
59
|
injectContext(headers);
|
|
56
60
|
|
|
57
|
-
const response = await dntShim.fetch(url, {
|
|
61
|
+
const response = await dntShim.fetch(url, {
|
|
62
|
+
method: options.method ?? "GET",
|
|
63
|
+
headers,
|
|
64
|
+
body: options.body,
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
58
67
|
const duration = performance.now() - startTime;
|
|
59
68
|
|
|
60
69
|
recordApiRequest(response.status);
|
|
@@ -89,7 +98,7 @@ export async function requestWithRetry(
|
|
|
89
98
|
return { data, status: response.status, duration };
|
|
90
99
|
},
|
|
91
100
|
{
|
|
92
|
-
"http.method": "GET",
|
|
101
|
+
"http.method": options.method ?? "GET",
|
|
93
102
|
"http.url": url,
|
|
94
103
|
"http.target": urlPath,
|
|
95
104
|
"http.host": urlObj.host,
|
package/src/src/proxy/handler.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { TokenManager, type TokenScope } from "./token-manager.js";
|
|
|
3
3
|
import { type ParsedDomain, parseProjectDomain } from "../server/utils/domain-parser.js";
|
|
4
4
|
import type { TokenCache } from "./cache/types.js";
|
|
5
5
|
import { createFileSystem } from "../platform/compat/fs.js";
|
|
6
|
-
import { cwd } from "../platform/compat/process.js";
|
|
6
|
+
import { cwd, getEnv } from "../platform/compat/process.js";
|
|
7
7
|
import { join } from "../platform/compat/path/index.js";
|
|
8
8
|
import { injectContext, ProxySpanNames, withSpan } from "./tracing.js";
|
|
9
9
|
import { computeContentSourceId } from "../cache/keys.js";
|
|
10
|
+
import { jwtVerify } from "jose";
|
|
10
11
|
|
|
11
12
|
export const INTERNAL_PROXY_HEADERS = [
|
|
12
13
|
"x-token",
|
|
@@ -151,19 +152,27 @@ function extractUserToken(cookieHeader: string): string | undefined {
|
|
|
151
152
|
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
function extractUserIdFromToken(
|
|
155
|
+
async function extractUserIdFromToken(
|
|
156
|
+
token: string,
|
|
157
|
+
log?: ProxyLogger,
|
|
158
|
+
): Promise<string | undefined> {
|
|
159
|
+
const jwtSecret = getEnv("JWT_SECRET");
|
|
160
|
+
|
|
161
|
+
if (!jwtSecret) {
|
|
162
|
+
log?.warn("JWT_SECRET not configured — cannot verify user token");
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
} catch (_) {
|
|
166
|
-
/* expected: malformed JWT token */
|
|
167
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
168
|
+
const { payload } = await jwtVerify(token, secret, {
|
|
169
|
+
algorithms: ["HS256"],
|
|
170
|
+
});
|
|
171
|
+
return (payload as { userId?: string }).userId;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
log?.debug("JWT verification failed", {
|
|
174
|
+
error: error instanceof Error ? error.message : String(error),
|
|
175
|
+
});
|
|
167
176
|
return undefined;
|
|
168
177
|
}
|
|
169
178
|
}
|
|
@@ -276,13 +285,13 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
|
|
|
276
285
|
return `https://veryfront.com/sign-in?from=${encodeURIComponent(returnPath)}`;
|
|
277
286
|
}
|
|
278
287
|
|
|
279
|
-
function checkProtectedAccess(
|
|
288
|
+
async function checkProtectedAccess(
|
|
280
289
|
req: dntShim.Request,
|
|
281
290
|
matchingEnv: NonNullable<DomainLookupResult["environments"]>[number] | undefined,
|
|
282
291
|
userToken: string | undefined,
|
|
283
292
|
users: DomainLookupResult["users"],
|
|
284
293
|
logContext: Record<string, unknown>,
|
|
285
|
-
): { status: number; message: string; redirectUrl?: string } | null {
|
|
294
|
+
): Promise<{ status: number; message: string; redirectUrl?: string } | null> {
|
|
286
295
|
if (!matchingEnv?.protected) return null;
|
|
287
296
|
|
|
288
297
|
if (!userToken) {
|
|
@@ -295,9 +304,8 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
|
|
|
295
304
|
return { status: 302, message: "Authentication required", redirectUrl };
|
|
296
305
|
}
|
|
297
306
|
|
|
298
|
-
const userId = extractUserIdFromToken(userToken);
|
|
307
|
+
const userId = await extractUserIdFromToken(userToken, logger);
|
|
299
308
|
if (!userId) {
|
|
300
|
-
// Malformed token — treat as unauthenticated so user can re-sign-in
|
|
301
309
|
const redirectUrl = makeAuthRedirectUrl(req);
|
|
302
310
|
logger?.info("Could not extract userId from token", {
|
|
303
311
|
...logContext,
|
|
@@ -334,7 +342,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
|
|
|
334
342
|
|
|
335
343
|
const matchingEnv = lookupResult.environments?.find(envMatcher);
|
|
336
344
|
|
|
337
|
-
const protectionError = checkProtectedAccess(
|
|
345
|
+
const protectionError = await checkProtectedAccess(
|
|
338
346
|
req,
|
|
339
347
|
matchingEnv,
|
|
340
348
|
userToken,
|
|
@@ -442,7 +450,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
|
|
|
442
450
|
releaseId = matchingEnv?.active_release_id ?? undefined;
|
|
443
451
|
environmentId = matchingEnv?.id;
|
|
444
452
|
|
|
445
|
-
const protectionError = checkProtectedAccess(
|
|
453
|
+
const protectionError = await checkProtectedAccess(
|
|
446
454
|
req,
|
|
447
455
|
matchingEnv,
|
|
448
456
|
userToken,
|
|
@@ -38,6 +38,8 @@ export interface LifecycleOptions {
|
|
|
38
38
|
projectId?: string;
|
|
39
39
|
/** Content source identifier for cache isolation (branch or release) */
|
|
40
40
|
contentSourceId?: string;
|
|
41
|
+
/** Injectable factory for testing — bypasses real service construction */
|
|
42
|
+
servicesFactory?: (adapter: RuntimeAdapter) => RendererServices;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface RendererServices {
|
|
@@ -62,6 +64,7 @@ export class RendererLifecycle {
|
|
|
62
64
|
private contentSourceId?: string;
|
|
63
65
|
private services?: RendererServices;
|
|
64
66
|
private adapter!: RuntimeAdapter;
|
|
67
|
+
private servicesFactory?: (adapter: RuntimeAdapter) => RendererServices;
|
|
65
68
|
|
|
66
69
|
constructor(options: LifecycleOptions) {
|
|
67
70
|
this.configManager = options.configManager;
|
|
@@ -69,6 +72,7 @@ export class RendererLifecycle {
|
|
|
69
72
|
this.moduleServerUrl = options.moduleServerUrl;
|
|
70
73
|
this.projectId = options.projectId;
|
|
71
74
|
this.contentSourceId = options.contentSourceId;
|
|
75
|
+
this.servicesFactory = options.servicesFactory;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
async initialize(): Promise<RendererServices> {
|
|
@@ -83,6 +87,13 @@ export class RendererLifecycle {
|
|
|
83
87
|
this.adapter = await runtime.get();
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// Allow tests to bypass the full service graph construction
|
|
91
|
+
if (this.servicesFactory) {
|
|
92
|
+
this.services = this.servicesFactory(this.adapter);
|
|
93
|
+
logger.debug("Renderer services initialized via injected factory");
|
|
94
|
+
return this.services;
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
const projectDir = this.configManager.getProjectDir();
|
|
87
98
|
const mode = this.configManager.getMode();
|
|
88
99
|
const debugMode = this.configManager.isDebugMode();
|
|
@@ -36,7 +36,7 @@ import type { PageResolver } from "../page-resolution/index.js";
|
|
|
36
36
|
import type { LayoutOrchestrator } from "./layout.js";
|
|
37
37
|
import type { SSROrchestrator } from "./ssr-orchestrator.js";
|
|
38
38
|
import type { PageDataResponse, RenderOptions, RenderResult } from "./types.js";
|
|
39
|
-
import { DataFetcher } from "../../data/index.js";
|
|
39
|
+
import { DataFetcher, type FetchDataOptions } from "../../data/index.js";
|
|
40
40
|
import type { DataContext, PageWithData } from "../../data/types.js";
|
|
41
41
|
import { clearSSRModuleCacheForProject } from "../../modules/react-loader/index.js";
|
|
42
42
|
import { setupSSRGlobals } from "../ssr-globals.js";
|
|
@@ -303,8 +303,13 @@ export class RenderPipeline {
|
|
|
303
303
|
Promise.all(
|
|
304
304
|
dataJobs.map(async (job) => {
|
|
305
305
|
try {
|
|
306
|
+
const jobPath = (job as LoadedModule & { path?: string }).path;
|
|
307
|
+
const fetchOptions: FetchDataOptions = {
|
|
308
|
+
modulePath: jobPath,
|
|
309
|
+
projectDir: this.config.projectDir,
|
|
310
|
+
};
|
|
306
311
|
const result = await this.dataFetcher
|
|
307
|
-
.fetchData(job.mod as PageWithData, dataContext, this.config.mode);
|
|
312
|
+
.fetchData(job.mod as PageWithData, dataContext, this.config.mode, fetchOptions);
|
|
308
313
|
return { ...job, result, error: null as Error | null };
|
|
309
314
|
} catch (error) {
|
|
310
315
|
return { ...job, result: null, error: error as Error };
|
|
@@ -10,6 +10,8 @@ import { computeHash } from "../utils/index.js";
|
|
|
10
10
|
import type { HTMLGenerationContext, HTMLGenerator } from "./html.js";
|
|
11
11
|
import type { RenderOptions } from "./types.js";
|
|
12
12
|
import { runWithHeadCollector } from "../../react/head-collector.js";
|
|
13
|
+
import { getWorkerPool, isSSRIsolationEnabled } from "../../security/sandbox/worker-pool.js";
|
|
14
|
+
import type { WorkerResponse } from "../../security/sandbox/worker-types.js";
|
|
13
15
|
|
|
14
16
|
const logger = rendererLogger.component("ssr-orchestrator");
|
|
15
17
|
|
|
@@ -27,6 +29,24 @@ export interface SSRRenderingResult {
|
|
|
27
29
|
ssrHash: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Options for isolated SSR rendering through the Worker pool.
|
|
34
|
+
* When provided and SSR isolation is enabled, the rendering happens
|
|
35
|
+
* in a per-project Worker instead of the main process.
|
|
36
|
+
*/
|
|
37
|
+
export interface SSRIsolationOptions {
|
|
38
|
+
/** Temp file path for the page component module */
|
|
39
|
+
pageModulePath: string;
|
|
40
|
+
/** Ordered layout module temp paths (innermost → outermost) */
|
|
41
|
+
layoutModulePaths: string[];
|
|
42
|
+
/** Page component props */
|
|
43
|
+
pageProps: Record<string, unknown>;
|
|
44
|
+
/** Layout props (one entry per layout, matching layoutModulePaths order) */
|
|
45
|
+
layoutProps: Record<string, unknown>[];
|
|
46
|
+
/** Project directory for worker scoping */
|
|
47
|
+
projectDir: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
function getElementTypeName(el: React.ReactElement | null | undefined): string {
|
|
31
51
|
if (!el?.type) return "unknown";
|
|
32
52
|
if (typeof el.type === "string") return el.type;
|
|
@@ -46,7 +66,18 @@ export class SSROrchestrator {
|
|
|
46
66
|
pageElement: React.ReactElement,
|
|
47
67
|
generationContext: Omit<HTMLGenerationContext, "html" | "ssrHash">,
|
|
48
68
|
options?: RenderOptions,
|
|
69
|
+
isolationOptions?: SSRIsolationOptions,
|
|
49
70
|
): Promise<SSRRenderingResult> {
|
|
71
|
+
// Isolated SSR path: render in per-project Worker
|
|
72
|
+
if (
|
|
73
|
+
isSSRIsolationEnabled() &&
|
|
74
|
+
isolationOptions?.pageModulePath &&
|
|
75
|
+
isolationOptions?.projectDir
|
|
76
|
+
) {
|
|
77
|
+
return this.performIsolatedSSR(generationContext, options, isolationOptions);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Default path: render in main process
|
|
50
81
|
logger.debug("performSSRRendering called", {
|
|
51
82
|
elementType: getElementTypeName(pageElement),
|
|
52
83
|
hasChildren: !!(pageElement.props as Record<string, unknown>)?.children,
|
|
@@ -133,6 +164,94 @@ export class SSROrchestrator {
|
|
|
133
164
|
};
|
|
134
165
|
}
|
|
135
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Perform SSR rendering in an isolated per-project Worker.
|
|
169
|
+
*
|
|
170
|
+
* The Worker imports user modules from their temp file paths,
|
|
171
|
+
* constructs the React element tree, and renders to HTML.
|
|
172
|
+
* For streaming, the Worker sends chunks via postMessage.
|
|
173
|
+
*/
|
|
174
|
+
private async performIsolatedSSR(
|
|
175
|
+
generationContext: Omit<HTMLGenerationContext, "html" | "ssrHash">,
|
|
176
|
+
options: RenderOptions | undefined,
|
|
177
|
+
isolation: SSRIsolationOptions,
|
|
178
|
+
): Promise<SSRRenderingResult> {
|
|
179
|
+
const wantsStream = options?.delivery === "stream";
|
|
180
|
+
const pool = getWorkerPool();
|
|
181
|
+
const requestId = dntShim.crypto.randomUUID();
|
|
182
|
+
|
|
183
|
+
return withSpan(
|
|
184
|
+
"ssr.isolated_render",
|
|
185
|
+
async () => {
|
|
186
|
+
const worker = pool.getOrCreateWorker(isolation.projectDir, [isolation.projectDir]);
|
|
187
|
+
|
|
188
|
+
if (wantsStream) {
|
|
189
|
+
// Streaming mode: get a ReadableStream of chunks from the Worker
|
|
190
|
+
const stream = worker.executeStream({
|
|
191
|
+
type: "render-ssr",
|
|
192
|
+
id: requestId,
|
|
193
|
+
pageModulePath: isolation.pageModulePath,
|
|
194
|
+
layoutModulePaths: isolation.layoutModulePaths,
|
|
195
|
+
pageProps: isolation.pageProps,
|
|
196
|
+
layoutProps: isolation.layoutProps,
|
|
197
|
+
delivery: "stream",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const ssrHash = `stream-isolated-${Date.now()}`;
|
|
201
|
+
|
|
202
|
+
// Generate HTML stream using the framework's HTML generator
|
|
203
|
+
const finalStream = await this.config.htmlGenerator.generateHTMLStream(stream, {
|
|
204
|
+
...generationContext,
|
|
205
|
+
ssrHash,
|
|
206
|
+
options: { ...generationContext.options, ...options },
|
|
207
|
+
collectedHead: undefined,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { fullHtml: "", finalStream, ssrHash };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// String mode: render to HTML in Worker, get result back
|
|
214
|
+
const workerResponse: WorkerResponse = await worker.execute({
|
|
215
|
+
type: "render-ssr",
|
|
216
|
+
id: requestId,
|
|
217
|
+
pageModulePath: isolation.pageModulePath,
|
|
218
|
+
layoutModulePaths: isolation.layoutModulePaths,
|
|
219
|
+
pageProps: isolation.pageProps,
|
|
220
|
+
layoutProps: isolation.layoutProps,
|
|
221
|
+
delivery: "string",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (workerResponse.type === "error") {
|
|
225
|
+
const err = new Error(workerResponse.error.message);
|
|
226
|
+
err.name = workerResponse.error.name;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (workerResponse.type !== "ssr-result") {
|
|
231
|
+
throw new Error(`Unexpected worker response type: ${workerResponse.type}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const html = workerResponse.html;
|
|
235
|
+
const ssrHash = await computeHash(html);
|
|
236
|
+
|
|
237
|
+
const fullHtml = await this.config.htmlGenerator.generateFullHTML({
|
|
238
|
+
...generationContext,
|
|
239
|
+
html,
|
|
240
|
+
ssrHash,
|
|
241
|
+
options: { ...generationContext.options, ...options },
|
|
242
|
+
collectedHead: undefined,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return { fullHtml, finalStream: null, ssrHash };
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"ssr.isolated": true,
|
|
249
|
+
"ssr.wants_stream": wantsStream,
|
|
250
|
+
"ssr.project_dir": isolation.projectDir,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
136
255
|
private createStream(html: string): ReadableStream | null {
|
|
137
256
|
try {
|
|
138
257
|
return new dntShim.Response(html).body ?? null;
|
|
@@ -13,7 +13,7 @@ import { ApiRouteMatcher, type RouteMatch } from "./api-route-matcher.js";
|
|
|
13
13
|
import type { APIRoute } from "./module-loader/types.js";
|
|
14
14
|
import { loadHandlerModule } from "./module-loader/loader.js";
|
|
15
15
|
import { discoverAppRoutes, discoverPagesRoutes } from "./route-discovery.js";
|
|
16
|
-
import { executeAppRoute, executePagesRoute } from "./route-executor.js";
|
|
16
|
+
import { executeAppRoute, executePagesRoute, type ExecuteRouteOptions } from "./route-executor.js";
|
|
17
17
|
import { withSpan } from "../../observability/tracing/otlp-setup.js";
|
|
18
18
|
|
|
19
19
|
/** Max entries in the loaded-handler LRU cache */
|
|
@@ -188,9 +188,22 @@ export class APIRouteHandler {
|
|
|
188
188
|
// Note: Cannot use path-based detection (/app/) as projectDir may be '/app' in production
|
|
189
189
|
const isAppRoute = /\/route\.(ts|js|tsx|jsx)$/.test(match.route.page);
|
|
190
190
|
|
|
191
|
+
const isolationOptions: ExecuteRouteOptions = {
|
|
192
|
+
modulePath: match.route.page,
|
|
193
|
+
projectDir: this.projectDir,
|
|
194
|
+
};
|
|
195
|
+
|
|
191
196
|
const response = isAppRoute
|
|
192
|
-
? await executeAppRoute(handler, request, match, pathname, adapter)
|
|
193
|
-
: await executePagesRoute(
|
|
197
|
+
? await executeAppRoute(handler, request, match, pathname, adapter, isolationOptions)
|
|
198
|
+
: await executePagesRoute(
|
|
199
|
+
handler,
|
|
200
|
+
request,
|
|
201
|
+
match,
|
|
202
|
+
pathname,
|
|
203
|
+
adapter,
|
|
204
|
+
this.projectDir,
|
|
205
|
+
isolationOptions,
|
|
206
|
+
);
|
|
194
207
|
|
|
195
208
|
const corsResponse = await applyCORSHeaders({
|
|
196
209
|
request,
|
|
@@ -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,17 @@ 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 {
|
|
28
|
+
MAX_WORKER_BODY_BYTES,
|
|
29
|
+
type SerializedRequest,
|
|
30
|
+
type SerializedResponse,
|
|
31
|
+
type WorkerResponse,
|
|
32
|
+
} from "../../security/sandbox/worker-types.js";
|
|
33
|
+
import { getProjectEnvSnapshot } from "../../server/project-env/storage.js";
|
|
23
34
|
|
|
24
35
|
function isDevelopment(adapter: RuntimeAdapter): boolean {
|
|
25
36
|
const env = adapter.env.get("MODE") ??
|
|
@@ -115,13 +126,241 @@ function toHeadResponse(response: dntShim.Response): dntShim.Response {
|
|
|
115
126
|
return new dntShim.Response(null, { status: response.status, headers: response.headers });
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Worker Isolation Helpers
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function checkContentLengthLimit(request: dntShim.Request): void {
|
|
134
|
+
const contentLength = request.headers.get("content-length");
|
|
135
|
+
if (!contentLength) return;
|
|
136
|
+
|
|
137
|
+
const bytes = parseInt(contentLength, 10);
|
|
138
|
+
if (bytes > MAX_WORKER_BODY_BYTES) {
|
|
139
|
+
throw createError({
|
|
140
|
+
type: "api",
|
|
141
|
+
message: `Request body too large for isolated execution (${
|
|
142
|
+
(bytes / 1024 / 1024).toFixed(1)
|
|
143
|
+
} MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readBodyWithSizeGuard(request: dntShim.Request): Promise<Uint8Array | null> {
|
|
149
|
+
if (!request.body) return null;
|
|
150
|
+
|
|
151
|
+
// Fast path: reject before buffering if Content-Length is known
|
|
152
|
+
checkContentLengthLimit(request);
|
|
153
|
+
|
|
154
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
155
|
+
|
|
156
|
+
// Fallback: check actual size for chunked/streaming bodies
|
|
157
|
+
if (body.byteLength > MAX_WORKER_BODY_BYTES) {
|
|
158
|
+
throw createError({
|
|
159
|
+
type: "api",
|
|
160
|
+
message: `Request body too large for isolated execution (${
|
|
161
|
+
(body.byteLength / 1024 / 1024).toFixed(1)
|
|
162
|
+
} MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function serializeRequest(request: dntShim.Request): Promise<SerializedRequest> {
|
|
170
|
+
return {
|
|
171
|
+
url: request.url,
|
|
172
|
+
method: request.method,
|
|
173
|
+
headers: [...request.headers.entries()],
|
|
174
|
+
body: await readBodyWithSizeGuard(request),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function deserializeResponse(s: SerializedResponse): dntShim.Response {
|
|
179
|
+
return new dntShim.Response(s.body as dntShim.BodyInit | null, {
|
|
180
|
+
status: s.status,
|
|
181
|
+
statusText: s.statusText,
|
|
182
|
+
headers: s.headers,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function workerResponseToResponse(
|
|
187
|
+
workerResponse: WorkerResponse,
|
|
188
|
+
pathname: string,
|
|
189
|
+
adapter: RuntimeAdapter,
|
|
190
|
+
): dntShim.Response {
|
|
191
|
+
if (workerResponse.type === "error") {
|
|
192
|
+
const { error } = workerResponse;
|
|
193
|
+
logger.error(`API route error in ${pathname} (worker):`, error.message);
|
|
194
|
+
|
|
195
|
+
// If the worker serialized RFC 9457 fields, return them directly
|
|
196
|
+
// to preserve the original status code, type, and detail.
|
|
197
|
+
if (error.status && error.type) {
|
|
198
|
+
return dntShim.Response.json(
|
|
199
|
+
{
|
|
200
|
+
type: error.type,
|
|
201
|
+
title: error.name,
|
|
202
|
+
status: error.status,
|
|
203
|
+
detail: error.detail ?? error.message,
|
|
204
|
+
instance: pathname,
|
|
205
|
+
},
|
|
206
|
+
{ status: error.status },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ctx = { isLocalProject: isDevelopment(adapter) } as HandlerContext;
|
|
211
|
+
const req = new dntShim.Request(`http://localhost${pathname}`);
|
|
212
|
+
const err = new Error(error.message);
|
|
213
|
+
err.name = error.name;
|
|
214
|
+
return errorToRFC9457Response(err, ctx, req);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (workerResponse.type === "result") {
|
|
218
|
+
return deserializeResponse(workerResponse.response);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// data-result type is not expected in API route execution
|
|
222
|
+
throw new Error(`Unexpected worker response type: ${workerResponse.type}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Isolated Execution (Worker Path)
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function executeAppRouteIsolated(
|
|
230
|
+
modulePath: string,
|
|
231
|
+
request: dntShim.Request,
|
|
232
|
+
match: RouteMatch,
|
|
233
|
+
pathname: string,
|
|
234
|
+
adapter: RuntimeAdapter,
|
|
235
|
+
projectDir: string,
|
|
236
|
+
): Promise<dntShim.Response> {
|
|
237
|
+
const method = request.method.toUpperCase() as HTTPMethod;
|
|
238
|
+
|
|
239
|
+
return withSpan(
|
|
240
|
+
"api.executeAppRoute.isolated",
|
|
241
|
+
async () => {
|
|
242
|
+
try {
|
|
243
|
+
const pool = getWorkerPool();
|
|
244
|
+
const serialized = await serializeRequest(request);
|
|
245
|
+
|
|
246
|
+
const workerResponse = await pool.execute(
|
|
247
|
+
projectDir,
|
|
248
|
+
[projectDir],
|
|
249
|
+
{
|
|
250
|
+
type: "execute-app-route",
|
|
251
|
+
id: dntShim.crypto.randomUUID(),
|
|
252
|
+
modulePath,
|
|
253
|
+
method,
|
|
254
|
+
request: serialized,
|
|
255
|
+
params: match.params,
|
|
256
|
+
projectDir,
|
|
257
|
+
projectEnv: getProjectEnvSnapshot(),
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const response = workerResponseToResponse(workerResponse, pathname, adapter);
|
|
262
|
+
return method === "HEAD" ? toHeadResponse(response) : response;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return handleAPIError(error, pathname, adapter);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"http.method": method,
|
|
269
|
+
"http.path": pathname,
|
|
270
|
+
"api.route.pattern": match.route.pattern,
|
|
271
|
+
"api.isolated": true,
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function executePagesRouteIsolated(
|
|
277
|
+
modulePath: string,
|
|
278
|
+
request: dntShim.Request,
|
|
279
|
+
match: RouteMatch,
|
|
280
|
+
pathname: string,
|
|
281
|
+
adapter: RuntimeAdapter,
|
|
282
|
+
projectDir: string,
|
|
283
|
+
): Promise<dntShim.Response> {
|
|
284
|
+
const method = request.method as string;
|
|
285
|
+
|
|
286
|
+
return withSpan(
|
|
287
|
+
"api.executePagesRoute.isolated",
|
|
288
|
+
async () => {
|
|
289
|
+
try {
|
|
290
|
+
const pool = getWorkerPool();
|
|
291
|
+
const body = await readBodyWithSizeGuard(request);
|
|
292
|
+
|
|
293
|
+
const workerResponse = await pool.execute(
|
|
294
|
+
projectDir,
|
|
295
|
+
[projectDir],
|
|
296
|
+
{
|
|
297
|
+
type: "execute-pages-route",
|
|
298
|
+
id: dntShim.crypto.randomUUID(),
|
|
299
|
+
modulePath,
|
|
300
|
+
method,
|
|
301
|
+
context: {
|
|
302
|
+
url: request.url,
|
|
303
|
+
method: request.method,
|
|
304
|
+
headers: [...request.headers.entries()],
|
|
305
|
+
body,
|
|
306
|
+
params: match.params,
|
|
307
|
+
cookies: parseCookies(request.headers.get("cookie") ?? ""),
|
|
308
|
+
},
|
|
309
|
+
projectDir,
|
|
310
|
+
projectEnv: getProjectEnvSnapshot(),
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
return workerResponseToResponse(workerResponse, pathname, adapter);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return handleAPIError(error, pathname, adapter);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
"http.method": method,
|
|
321
|
+
"http.path": pathname,
|
|
322
|
+
"api.route.pattern": match.route.pattern,
|
|
323
|
+
"api.isolated": true,
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Public API
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
export interface ExecuteRouteOptions {
|
|
333
|
+
/** Absolute path to the handler module on disk (for isolated execution) */
|
|
334
|
+
modulePath?: string;
|
|
335
|
+
/** Project directory (for isolated execution scope) */
|
|
336
|
+
projectDir?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
118
339
|
export function executeAppRoute(
|
|
119
340
|
handler: APIRoute,
|
|
120
341
|
request: dntShim.Request,
|
|
121
342
|
match: RouteMatch,
|
|
122
343
|
pathname: string,
|
|
123
344
|
adapter: RuntimeAdapter,
|
|
345
|
+
options?: ExecuteRouteOptions,
|
|
124
346
|
): Promise<dntShim.Response> {
|
|
347
|
+
// Isolated path: execute in per-project Worker, fall back to main process on error
|
|
348
|
+
if (
|
|
349
|
+
isWorkerIsolationEnabled() &&
|
|
350
|
+
options?.modulePath &&
|
|
351
|
+
options?.projectDir
|
|
352
|
+
) {
|
|
353
|
+
return executeAppRouteIsolated(
|
|
354
|
+
options.modulePath,
|
|
355
|
+
request,
|
|
356
|
+
match,
|
|
357
|
+
pathname,
|
|
358
|
+
adapter,
|
|
359
|
+
options.projectDir,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Default path: execute in main process (existing behavior)
|
|
125
364
|
const method = request.method.toUpperCase() as HTTPMethod;
|
|
126
365
|
|
|
127
366
|
return withSpan(
|
|
@@ -158,7 +397,25 @@ export function executePagesRoute(
|
|
|
158
397
|
pathname: string,
|
|
159
398
|
adapter: RuntimeAdapter,
|
|
160
399
|
projectDir?: string,
|
|
400
|
+
options?: ExecuteRouteOptions,
|
|
161
401
|
): Promise<dntShim.Response> {
|
|
402
|
+
// Isolated path: execute in per-project Worker, fall back to main process on error
|
|
403
|
+
if (
|
|
404
|
+
isWorkerIsolationEnabled() &&
|
|
405
|
+
options?.modulePath &&
|
|
406
|
+
(options?.projectDir ?? projectDir)
|
|
407
|
+
) {
|
|
408
|
+
return executePagesRouteIsolated(
|
|
409
|
+
options.modulePath,
|
|
410
|
+
request,
|
|
411
|
+
match,
|
|
412
|
+
pathname,
|
|
413
|
+
adapter,
|
|
414
|
+
options.projectDir ?? projectDir!,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Default path: execute in main process (existing behavior)
|
|
162
419
|
const method = request.method as keyof APIRoute;
|
|
163
420
|
|
|
164
421
|
return withSpan(
|