@rsc-kit/core 0.2.0 → 0.4.0

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/dist/worker.js DELETED
@@ -1,926 +0,0 @@
1
- import { chmodSync, unlinkSync } from "node:fs";
2
- import { listen, runtimeName, scanScripts, yieldToEventLoop } from "./runtime.js";
3
- import { createDeferredHost, streamWithDeferredRelease } from "./streaming.js";
4
- import { join, resolve } from "node:path";
5
- import { ServerAuthenticationError, ServerAuthorizationError, ServerRedirectError, ServerValidationError } from "./errors.js";
6
- import { isRedirectSignal } from "./redirectDigest.js";
7
- import { withCache } from "./cache.js";
8
- import { withRequest, withResponseDraft } from "./request.js";
9
- const functions = {};
10
- function log(...args) {
11
- console.error(`[rsc-worker:${runtimeName}]`, ...args);
12
- }
13
- async function discoverFunctions(dir) {
14
- const scripts = scanScripts(dir);
15
- for (const path of scripts) {
16
- const mod = await import(join(dir, path));
17
- for (const [name, fn] of Object.entries(mod)) {
18
- if (typeof fn !== "function")
19
- continue;
20
- if (name in functions) {
21
- log(`Warning: duplicate function "${name}" from ${path}, skipping`);
22
- continue;
23
- }
24
- functions[name] = fn;
25
- }
26
- }
27
- }
28
- async function loadEntryPoints() {
29
- const raw = process.env.RSC_ENTRY_POINTS ?? "";
30
- const paths = raw.split(",").map((p) => p.trim()).filter(Boolean);
31
- for (const entryPath of paths) {
32
- const absolute = resolve(entryPath);
33
- try {
34
- const mod = await import(absolute);
35
- for (const [name, fn] of Object.entries(mod)) {
36
- if (name === "default" || typeof fn !== "function" || name.length <= 2)
37
- continue;
38
- if (name in functions) {
39
- log(`Warning: duplicate function "${name}" from ${entryPath}, skipping`);
40
- continue;
41
- }
42
- functions[name] = fn;
43
- }
44
- if (typeof mod.default === "function" && !("default" in functions)) {
45
- const baseName = absolute.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "default";
46
- const fnName = baseName in functions ? "default" : baseName;
47
- if (fnName in functions) {
48
- log(`Warning: duplicate function "${fnName}" from ${entryPath}, skipping`);
49
- }
50
- else {
51
- functions[fnName] = mod.default;
52
- }
53
- }
54
- }
55
- catch (err) {
56
- log(`Failed to load entry point "${entryPath}":`, err instanceof Error ? err.message : String(err));
57
- }
58
- }
59
- }
60
- async function handleMessage(message) {
61
- switch (message.type) {
62
- case "ping":
63
- return '{"type":"pong"}';
64
- case "call": {
65
- // hasOwn: `constructor` and `toString` are not host functions, and a
66
- // caller naming one should be told the function is unknown rather than
67
- // handed Object's own method to invoke.
68
- const name = message.function ?? "";
69
- const fn = Object.hasOwn(functions, name) ? functions[name] : undefined;
70
- if (!fn) {
71
- return JSON.stringify({
72
- error: `Function "${message.function}" not found. Available: ${Object.keys(functions).join(", ")}`,
73
- });
74
- }
75
- try {
76
- const result = await fn(message.args ?? {});
77
- return JSON.stringify({ result });
78
- }
79
- catch (err) {
80
- return JSON.stringify({
81
- error: err instanceof Error ? err.message : String(err),
82
- });
83
- }
84
- }
85
- case "rsc-payload": {
86
- if (!rscHandler) {
87
- return '{"error":"RSC not enabled."}';
88
- }
89
- if (!message.component) {
90
- return '{"error":"Missing component in RSC message"}';
91
- }
92
- try {
93
- const result = await rscHandler.handleRscPayload(message.component, message.props ?? {}, message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {}, message.from ?? 0, message.pageKey ?? "");
94
- return JSON.stringify({ result });
95
- }
96
- catch (err) {
97
- return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
98
- }
99
- }
100
- case "rsc": {
101
- if (!rscHandler) {
102
- return '{"error":"RSC not enabled. Set RSC_ENABLED=true and run: bun run build:rsc"}';
103
- }
104
- if (!message.component) {
105
- return '{"error":"Missing component in RSC message"}';
106
- }
107
- try {
108
- // Install the host callable if a callback connection is available
109
- let cleanupPhp = null;
110
- if (message.callbackId) {
111
- const cbConn = await getCallbackConnection(message.callbackId);
112
- cleanupPhp = rscHandler.installHostFn(createPhpFn(cbConn));
113
- }
114
- try {
115
- // A target asks for one part of this page rather than the whole of
116
- // it, and needs the same host connection a page does.
117
- if (message.target) {
118
- const result = await rscHandler.handleRscRevalidate(message.target, {
119
- component: message.component,
120
- props: message.props ?? {},
121
- layouts: message.layouts ?? [],
122
- loadings: message.loadings ?? [],
123
- parallelSlots: message.parallelSlots ?? {},
124
- });
125
- return JSON.stringify({ result });
126
- }
127
- const metadata = await rscHandler.resolveMetadata(message.component, message.props ?? {}, message.layouts ?? []);
128
- const result = await rscHandler.handleRsc(message.component, message.props ?? {}, message.callbackSocket ?? null, // fallback for old protocol
129
- message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {}, message.from ?? 0, message.pageKey ?? "", message.bootstrap !== false);
130
- return JSON.stringify({ result: { ...result, metadata } });
131
- }
132
- finally {
133
- cleanupPhp?.();
134
- if (message.callbackId)
135
- callbackConnections.delete(message.callbackId);
136
- }
137
- }
138
- catch (err) {
139
- return JSON.stringify({
140
- error: err instanceof Error ? err.message : String(err),
141
- });
142
- }
143
- }
144
- case "rsc-ppr-shell": {
145
- if (!rscHandler) {
146
- return '{"error":"RSC not enabled. Set RSC_ENABLED=true and run: bun run build:rsc"}';
147
- }
148
- if (!message.component) {
149
- return '{"error":"Missing component in RSC message"}';
150
- }
151
- try {
152
- const result = await rscHandler.handleRscPprShell(message.component, message.props ?? {}, message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {},
153
- // The url this shell is for, when it is for exactly one. Dropped
154
- // here, every shell was rendered as if it were a pattern: params
155
- // never settle, a page that awaits them suspends immediately, and
156
- // the frozen shell is its loading fallback with no document around
157
- // it and no bootstrap in it — so nothing hydrates and nothing fills.
158
- message.pageKey ?? "");
159
- return JSON.stringify({ result });
160
- }
161
- catch (err) {
162
- return JSON.stringify({
163
- error: err instanceof Error ? err.message : String(err),
164
- });
165
- }
166
- }
167
- case "list":
168
- return JSON.stringify({ result: Object.keys(functions) });
169
- default:
170
- return JSON.stringify({ error: `Unknown message type: "${message.type}"` });
171
- }
172
- }
173
- const functionsDir = process.env.RSC_FUNCTIONS_DIR;
174
- const socketPath = process.env.RSC_SOCKET ?? "/tmp/bun-bridge.sock";
175
- // Transport: 'unix' (default) listens on socketPath; 'tcp' listens on
176
- // RSC_HOST:RSC_MAIN_PORT (main) and RSC_HOST:RSC_CB_PORT (callbacks).
177
- //
178
- // The protocol has no authentication of any kind. On a unix socket that is
179
- // fine — the filesystem permissions are the authentication, and the socket is
180
- // created private to its owner. Over TCP there is nothing: whoever can open
181
- // the port can render any component, run any registered host function and read
182
- // anything either returns. It is for a container where the port is not
183
- // reachable from outside, and binding it anywhere else is a decision to serve
184
- // the engine to whoever asks.
185
- const isTcp = process.env.RSC_TRANSPORT === "tcp";
186
- const tcpHost = process.env.RSC_HOST ?? "127.0.0.1";
187
- const mainPort = parseInt(process.env.RSC_MAIN_PORT ?? "0", 10);
188
- const cbPort = parseInt(process.env.RSC_CB_PORT ?? "0", 10);
189
- // Said once, loudly, rather than left in a comment nobody reads: an
190
- // unauthenticated protocol bound to a routable address is a remote code
191
- // execution surface, and the default of 127.0.0.1 is the only reason this is
192
- // usually safe.
193
- if (isTcp && tcpHost !== "127.0.0.1" && tcpHost !== "::1" && tcpHost !== "localhost") {
194
- log(`WARNING: listening on ${tcpHost} over TCP. This protocol has no ` +
195
- `authentication — anyone who can reach that address can render any component ` +
196
- `and call any host function. Bind 127.0.0.1 unless the network is private.`);
197
- }
198
- if (functionsDir) {
199
- await discoverFunctions(functionsDir);
200
- }
201
- await loadEntryPoints();
202
- let rscHandler = null;
203
- if (process.env.RSC_DEV_CONFIG) {
204
- // Dev mode: the entry comes from Vite's runnable rsc environment rather than
205
- // a bundle, so an edit is live without a rebuild. Same module contract.
206
- try {
207
- const { startDevServer, devEntryPath } = await import("./devServer.js");
208
- const outDir = process.env.RSC_OUT_DIR;
209
- const dev = await startDevServer({
210
- projectRoot: process.env.RSC_PROJECT_ROOT ?? process.cwd(),
211
- configFile: process.env.RSC_DEV_CONFIG,
212
- entry: devEntryPath(outDir),
213
- port: parseInt(process.env.RSC_DEV_PORT ?? "5173", 10),
214
- });
215
- rscHandler = dev.handler;
216
- log(`RSC dev server on ${process.env.RSC_DEV_ORIGIN}`);
217
- }
218
- catch (err) {
219
- log("Failed to start RSC dev server:", err instanceof Error ? err.message : String(err));
220
- }
221
- }
222
- else if (process.env.RSC_BUNDLE) {
223
- try {
224
- // The built @vitejs/plugin-rsc entry IS the handler — it exports the
225
- // installHostFn / handleRsc* / handleAction / resolveMetadata contract.
226
- rscHandler = (await import(process.env.RSC_BUNDLE));
227
- log("RSC handler loaded");
228
- }
229
- catch (err) {
230
- log("Failed to load RSC handler:", err instanceof Error ? err.message : String(err));
231
- }
232
- }
233
- if (Object.keys(functions).length === 0 && !rscHandler) {
234
- log("No functions discovered. Provide a functions directory or entry points.");
235
- process.exit(1);
236
- }
237
- /**
238
- * Handles rsc-stream messages.
239
- *
240
- * Writes Flight data frames back on the main listener's socket (same path as
241
- * SSR responses). The listener's drain handler handles
242
- * backpressure for large payloads. Runs via setTimeout so writes are not
243
- * corked by the data handler's async callback buffering.
244
- */
245
- async function handleRscStreamMessage(mainSocket, message, draft) {
246
- if (!rscHandler) {
247
- writeFrame(mainSocket, '{"error":"RSC not enabled"}');
248
- return;
249
- }
250
- if (!message.component) {
251
- writeFrame(mainSocket, '{"error":"Missing component in RSC message"}');
252
- return;
253
- }
254
- let cleanupPhp = null;
255
- try {
256
- let deferred = null;
257
- if (message.callbackId) {
258
- const cbConn = await getCallbackConnection(message.callbackId);
259
- deferred = createDeferredHost(createPhpFn(cbConn));
260
- cleanupPhp = rscHandler.installHostFn(deferred.hostFn);
261
- }
262
- const metadata = await rscHandler.resolveMetadata(message.component, message.props ?? {}, message.layouts ?? []);
263
- deferred?.begin();
264
- const { stream, clientChunks, segmentDepth } = await rscHandler.handleRscStream(message.component, message.props ?? {}, message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {}, message.slotOverrides ?? undefined, message.from ?? 0, message.pageKey ?? "");
265
- const reader = stream.getReader();
266
- const decoder = new TextDecoder();
267
- const writeStreamChunk = (value) => {
268
- const text = typeof value === "string" ? value : decoder.decode(value, { stream: true });
269
- writeFrame(mainSocket, JSON.stringify({ type: "stream-chunk", data: text }));
270
- };
271
- writeFrame(mainSocket, JSON.stringify({ type: "stream-start", clientChunks, metadata, segmentDepth, headers: draft && draftHeaders(draft) }));
272
- // Flight's root model and the rows for each Suspense fallback go out before
273
- // any host call is released; see streamWithDeferredRelease.
274
- await streamWithDeferredRelease(reader, writeStreamChunk, () => deferred?.flush());
275
- writeFrame(mainSocket, '{"type":"stream-end"}');
276
- }
277
- catch (err) {
278
- const errorJson = failureFrame(err, draft);
279
- try {
280
- writeFrame(mainSocket, errorJson);
281
- }
282
- catch {
283
- // Best effort
284
- }
285
- }
286
- finally {
287
- cleanupPhp?.();
288
- if (message.callbackId)
289
- callbackConnections.delete(message.callbackId);
290
- }
291
- }
292
- /**
293
- * The frame that describes what went wrong, or where to go instead.
294
- *
295
- * A render that redirects is not a render that failed, and the host has to
296
- * tell them apart: one becomes a Location header, the other becomes a 500.
297
- * Reported before the shell is written whenever the redirect happened above
298
- * every Suspense boundary, which is what leaves PHP a status line to use.
299
- */
300
- function failureFrame(err, draft) {
301
- if (isRedirectSignal(err)) {
302
- // A redirect is still an answer, and middleware that sets a cookie before
303
- // sending someone to the login page means the cookie to survive the trip —
304
- // remembering where they were going is the usual one.
305
- return JSON.stringify({
306
- redirect: err.location,
307
- redirectStatus: err.status,
308
- headers: draft && draftHeaders(draft),
309
- });
310
- }
311
- return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
312
- }
313
- /**
314
- * One request's scopes, opened together.
315
- *
316
- * The worker has a socket rather than a request, so the host forwards the url
317
- * and headers on the message and this rebuilds enough of a Request for
318
- * headers() and cookies() to read. A host that forwards nothing leaves the
319
- * scope empty, and reading throws — which is the honest answer, since
320
- * answering with no headers would look like a visitor who sent none.
321
- *
322
- * The response draft is open here too, so middleware can set a header or a
323
- * cookie. It travels on the stream-start frame, which is the last thing sent
324
- * before the body — PHP has not written its status line yet, so there is still
325
- * an answer to put them on. Sealed the instant that frame goes out.
326
- */
327
- function inRequest(message, run) {
328
- // The parts, not a rebuilt Request: constructing one drops Cookie, because a
329
- // Headers built with the request guard refuses the forbidden header names.
330
- const forwarded = message.url
331
- ? { url: message.url, headers: message.headers ?? {} }
332
- : null;
333
- return withRequest(forwarded, () => withCache(() => withResponseDraft(run)));
334
- }
335
- /**
336
- * The draft's headers as the wire carries them: repeats preserved.
337
- *
338
- * Reported once. An error can arrive after stream-start, and reporting again
339
- * there would set every cookie twice on a host that applies what it is given.
340
- */
341
- const reported = new WeakSet();
342
- function draftHeaders(draft) {
343
- if (reported.has(draft))
344
- return undefined;
345
- reported.add(draft);
346
- const headers = draft.taken();
347
- const collected = [];
348
- headers.forEach((value, name) => {
349
- // Set-Cookie is handled once below. forEach already visits it per cookie in
350
- // some runtimes and as one joined value in others, and neither is what goes
351
- // on the wire — a joined header is one malformed cookie and no session.
352
- if (name.toLowerCase() !== "set-cookie")
353
- collected.push([name, value]);
354
- });
355
- for (const cookie of headers.getSetCookie())
356
- collected.push(["Set-Cookie", cookie]);
357
- draft.seal();
358
- return collected;
359
- }
360
- /**
361
- * Handles rsc-html-stream messages for initial page loads with Suspense.
362
- *
363
- * Writes HTML + Flight payload frames back on the main listener's socket
364
- * (same path as SSR responses). The drain handler
365
- * handles backpressure for large payloads.
366
- */
367
- async function handleRscHtmlStreamMessage(mainSocket, message, draft) {
368
- if (!rscHandler) {
369
- writeFrame(mainSocket, '{"error":"RSC not enabled"}');
370
- return;
371
- }
372
- if (!message.component) {
373
- writeFrame(mainSocket, '{"error":"Missing component in RSC message"}');
374
- return;
375
- }
376
- let cleanupPhp = null;
377
- try {
378
- let deferred = null;
379
- if (message.callbackId) {
380
- const cbConn = await getCallbackConnection(message.callbackId);
381
- deferred = createDeferredHost(createPhpFn(cbConn));
382
- cleanupPhp = rscHandler.installHostFn(deferred.hostFn);
383
- }
384
- const metadata = await rscHandler.resolveMetadata(message.component, message.props ?? {}, message.layouts ?? []);
385
- deferred?.begin();
386
- const { htmlStream, rscPayloadPromise, clientChunks } = await rscHandler.handleRscHtmlStream(message.component, message.props ?? {}, message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {}, message.slotOverrides ?? undefined, message.nonce ?? undefined, message.pageKey ?? "");
387
- const reader = htmlStream.getReader();
388
- const decoder = new TextDecoder();
389
- const writeHtmlChunk = (value) => {
390
- const text = typeof value === "string" ? value : decoder.decode(value, { stream: true });
391
- writeFrame(mainSocket, JSON.stringify({ type: "html-chunk", data: text }));
392
- };
393
- // Write html-start and the shell without yielding the event loop
394
- writeFrame(mainSocket, JSON.stringify({ type: "html-start", clientChunks, metadata }));
395
- // The whole shell, with every Suspense fallback in it, goes out before any
396
- // host call is released; see streamWithDeferredRelease.
397
- await streamWithDeferredRelease(reader, writeHtmlChunk, () => deferred?.flush());
398
- const rscPayload = await rscPayloadPromise;
399
- writeFrame(mainSocket, JSON.stringify({ type: "html-end", rscPayload }));
400
- }
401
- catch (err) {
402
- const errorJson = failureFrame(err, draft);
403
- try {
404
- writeFrame(mainSocket, errorJson);
405
- }
406
- catch {
407
- // Best effort
408
- }
409
- }
410
- finally {
411
- cleanupPhp?.();
412
- if (message.callbackId)
413
- callbackConnections.delete(message.callbackId);
414
- }
415
- }
416
- /**
417
- * Finish a shell that was frozen at build time.
418
- *
419
- * The same frames as an html-stream — html-start, html-chunk, html-end — so the
420
- * host reads it with the reader it already has. What comes back is only the
421
- * boundaries the shell left open, meant to be written straight after it.
422
- *
423
- * There is no payload at the end. A resume renders HTML for a document that has
424
- * already been sent; the client fetches its own payload when it hydrates, the
425
- * same as it always did.
426
- */
427
- async function handleRscResumeMessage(mainSocket, message, draft) {
428
- if (!rscHandler) {
429
- writeFrame(mainSocket, '{"error":"RSC not enabled"}');
430
- return;
431
- }
432
- if (!message.component) {
433
- writeFrame(mainSocket, '{"error":"Missing component in RSC message"}');
434
- return;
435
- }
436
- if (!rscHandler.handleRscResume) {
437
- writeFrame(mainSocket, '{"error":"This engine cannot resume. Rebuild against the current @rsc-kit/core."}');
438
- return;
439
- }
440
- let cleanupPhp = null;
441
- try {
442
- let deferred = null;
443
- if (message.callbackId) {
444
- const cbConn = await getCallbackConnection(message.callbackId);
445
- deferred = createDeferredHost(createPhpFn(cbConn));
446
- cleanupPhp = rscHandler.installHostFn(deferred.hostFn);
447
- }
448
- deferred?.begin();
449
- const { htmlStream } = await rscHandler.handleRscResume(message.component, message.props ?? {}, message.layouts ?? [], message.loadings ?? [], message.parallelSlots ?? {}, message.slotOverrides ?? undefined, message.postponed, message.nonce ?? undefined, message.pageKey ?? "");
450
- const reader = htmlStream.getReader();
451
- const decoder = new TextDecoder();
452
- const writeHtmlChunk = (value) => {
453
- const text = typeof value === "string" ? value : decoder.decode(value, { stream: true });
454
- writeFrame(mainSocket, JSON.stringify({ type: "html-chunk", data: text }));
455
- };
456
- // No metadata and no client chunks: both belong to the shell, which was
457
- // resolved at build time and is already on its way to the browser.
458
- writeFrame(mainSocket, JSON.stringify({ type: "html-start", clientChunks: {}, metadata: null }));
459
- await streamWithDeferredRelease(reader, writeHtmlChunk, () => deferred?.flush());
460
- writeFrame(mainSocket, JSON.stringify({ type: "html-end", rscPayload: "" }));
461
- }
462
- catch (err) {
463
- const errorJson = failureFrame(err, draft);
464
- try {
465
- writeFrame(mainSocket, errorJson);
466
- }
467
- catch {
468
- // Best effort
469
- }
470
- }
471
- finally {
472
- cleanupPhp?.();
473
- if (message.callbackId)
474
- callbackConnections.delete(message.callbackId);
475
- }
476
- }
477
- /**
478
- * Handles rsc-action messages (server action calls).
479
- *
480
- * Same streaming pattern as handleRscStreamMessage — writes Flight
481
- * data frames back on the main socket with action-specific frame types.
482
- */
483
- async function handleRscActionMessage(mainSocket, message, draft) {
484
- if (!rscHandler) {
485
- writeFrame(mainSocket, '{"error":"RSC not enabled"}');
486
- return;
487
- }
488
- if (!message.actionId) {
489
- writeFrame(mainSocket, '{"error":"Missing actionId in rsc-action message"}');
490
- return;
491
- }
492
- let cleanupPhp = null;
493
- // Held beyond the block below: what the host marks stale during the action
494
- // is collected against this connection and read after the action returns.
495
- let cbConn = null;
496
- try {
497
- if (message.callbackId) {
498
- cbConn = await getCallbackConnection(message.callbackId);
499
- cleanupPhp = rscHandler.installHostFn(createPhpFn(cbConn));
500
- }
501
- // Already bytes when a body arrived on its own frame; there is nothing to
502
- // decode and nothing to reinterpret.
503
- const actionBody = message.body ?? "";
504
- const { stream } = await rscHandler.handleAction(message.actionId, actionBody, message.contentType ?? "text/plain",
505
- // The page the action was invoked from, resolved by the host — the
506
- // browser knows the url but not which components render it.
507
- message.page, () => takeRevalidated(cbConn));
508
- // An action's whole point may be the cookie it leaves behind, so its
509
- // start frame carries them the same way a render's does.
510
- writeFrame(mainSocket, JSON.stringify({ type: "action-start", headers: draft && draftHeaders(draft) }));
511
- await yieldToEventLoop();
512
- const reader = stream.getReader();
513
- const decoder = new TextDecoder();
514
- while (true) {
515
- const { done, value } = await reader.read();
516
- if (done)
517
- break;
518
- const text = typeof value === "string"
519
- ? value
520
- : decoder.decode(value, { stream: true });
521
- writeFrame(mainSocket, JSON.stringify({ type: "action-chunk", data: text }));
522
- await yieldToEventLoop();
523
- }
524
- // What the host marked stale while the action ran. Reported on the way
525
- // out so the answer can carry it; the parts themselves follow once the
526
- // worker is given the page context to render them against.
527
- const revalidated = takeRevalidated(cbConn);
528
- writeFrame(mainSocket, JSON.stringify(revalidated.length > 0 ? { type: "action-end", revalidated } : { type: "action-end" }));
529
- }
530
- catch (err) {
531
- let errorJson;
532
- if (err instanceof ServerAuthenticationError) {
533
- errorJson = JSON.stringify({
534
- unauthenticated: true,
535
- error: err.message,
536
- });
537
- }
538
- else if (err instanceof ServerAuthorizationError) {
539
- errorJson = JSON.stringify({
540
- unauthorized: true,
541
- error: err.message,
542
- });
543
- }
544
- else if (err instanceof ServerRedirectError) {
545
- errorJson = JSON.stringify({
546
- redirect: err.location,
547
- });
548
- }
549
- else if (err instanceof ServerValidationError) {
550
- errorJson = JSON.stringify({
551
- error: err.message,
552
- validation_errors: err.errors,
553
- });
554
- }
555
- else {
556
- errorJson = JSON.stringify({
557
- error: err instanceof Error ? err.message : String(err),
558
- });
559
- }
560
- try {
561
- writeFrame(mainSocket, errorJson);
562
- }
563
- catch {
564
- // Best effort
565
- }
566
- }
567
- finally {
568
- cleanupPhp?.();
569
- if (message.callbackId)
570
- callbackConnections.delete(message.callbackId);
571
- }
572
- }
573
- if (!isTcp) {
574
- try {
575
- unlinkSync(socketPath);
576
- }
577
- catch {
578
- // File doesn't exist
579
- }
580
- }
581
- const pendingWriteBuffers = new Map();
582
- const socketBuffers = new Map();
583
- function drainSocket(socket) {
584
- const pending = pendingWriteBuffers.get(socket);
585
- if (!pending)
586
- return;
587
- const written = socket.write(pending);
588
- if (written < pending.length) {
589
- pendingWriteBuffers.set(socket, pending.subarray(written));
590
- }
591
- else {
592
- pendingWriteBuffers.delete(socket);
593
- }
594
- }
595
- function writeFrame(socket, json) {
596
- const payload = Buffer.from(json, "utf-8");
597
- const header = Buffer.alloc(4);
598
- header.writeUInt32BE(payload.length, 0);
599
- const frame = Buffer.concat([header, payload]);
600
- // If there are pending writes from a previous partial write,
601
- // queue this frame behind them to maintain frame ordering.
602
- // Writing directly would interleave with pending data on the wire.
603
- const existing = pendingWriteBuffers.get(socket);
604
- if (existing) {
605
- pendingWriteBuffers.set(socket, Buffer.concat([existing, frame]));
606
- return;
607
- }
608
- const written = socket.write(frame);
609
- if (written < frame.length) {
610
- pendingWriteBuffers.set(socket, frame.subarray(written));
611
- }
612
- }
613
- const MAX_FRAME_SIZE = parseInt(process.env.RSC_MAX_FRAME_SIZE || "1048576", 10); // 1MB default
614
- /**
615
- * Messages whose body is arriving as the next frame.
616
- *
617
- * An upload body is bytes, and frames are bytes, so it travels as its own
618
- * frame instead of being encoded to fit inside the JSON one. The frame after
619
- * such a header is that body and must not be parsed as JSON.
620
- */
621
- const pendingBody = new Map();
622
- /**
623
- * What the host marked stale while an action was running, per callback
624
- * connection.
625
- *
626
- * An action knows what it changed and the page does not, so the marks ride
627
- * back with each callback's result. Collected here so the answer to the action
628
- * can carry the re-rendered parts — rather than telling the browser what went
629
- * stale and waiting for it to ask.
630
- */
631
- const revalidations = new Map();
632
- function markRevalidated(socket, targets) {
633
- const marked = revalidations.get(socket) ?? new Set();
634
- for (const target of targets)
635
- marked.add(target);
636
- revalidations.set(socket, marked);
637
- }
638
- /** Take what a connection collected and forget it. */
639
- function takeRevalidated(socket) {
640
- if (!socket)
641
- return [];
642
- const marked = revalidations.get(socket);
643
- revalidations.delete(socket);
644
- return marked ? [...marked] : [];
645
- }
646
- // Create the socket files owner-only. Without this, any local user could
647
- // connect to the predictable socket path and drive the bridge (invoke server
648
- // actions / php() callables) with no authenticated session. PHP-FPM must run
649
- // as the same user as this worker (already implied by sharing the socket).
650
- try {
651
- process.umask(0o077);
652
- }
653
- catch { }
654
- /** Restrict a bound Unix socket to owner read/write only. */
655
- function secureSocket(path) {
656
- try {
657
- chmodSync(path, 0o600);
658
- }
659
- catch (err) {
660
- log("Failed to secure socket permissions:", err instanceof Error ? err.message : String(err));
661
- }
662
- }
663
- const server = listen(isTcp ? { hostname: tcpHost, port: mainPort } : { unix: socketPath }, {
664
- async data(socket, rawData) {
665
- let buf = socketBuffers.get(socket);
666
- buf = buf ? Buffer.concat([buf, Buffer.from(rawData)]) : Buffer.from(rawData);
667
- while (buf.length >= 4) {
668
- const frameLength = buf.readUInt32BE(0);
669
- if (frameLength <= 0 || frameLength > MAX_FRAME_SIZE) {
670
- // Hang up, rather than drop the buffer and read on.
671
- //
672
- // Dropping it leaves the connection open, so the next chunk is
673
- // parsed as a fresh frame from an arbitrary offset inside the
674
- // payload that was just rejected — and that payload is an action
675
- // body, which is to say whatever was uploaded. Bytes chosen to look
676
- // like a length prefix and a JSON message then arrive as a control
677
- // message on a pooled, long-lived socket: a host function invoked
678
- // with arguments from inside a file. There is no way to resynchronise
679
- // a stream whose framing is already lost, so the only correct move
680
- // is to stop reading it.
681
- log("Invalid frame length, closing the connection:", frameLength);
682
- socketBuffers.delete(socket);
683
- pendingBody.delete(socket);
684
- socket.end?.();
685
- return;
686
- }
687
- if (buf.length < 4 + frameLength) {
688
- break;
689
- }
690
- const payload = buf.subarray(4, 4 + frameLength);
691
- buf = buf.subarray(4 + frameLength);
692
- const awaiting = pendingBody.get(socket);
693
- if (awaiting) {
694
- pendingBody.delete(socket);
695
- // Copied because the read buffer is reused for the frames after it.
696
- awaiting.body = Buffer.from(payload);
697
- // Through inRequest, like every other message. Dispatching bare left
698
- // an action whose body arrived as bytes — which is every file upload
699
- // — with no request scope, no response draft and no memo table:
700
- // headers() and cookies() threw, cookies().set() threw, and nothing
701
- // the action set could reach the response, because the action-start
702
- // frame carried no headers to put there.
703
- setTimeout(() => void inRequest(awaiting, (draft) => handleRscActionMessage(socket, awaiting, draft)), 0);
704
- continue;
705
- }
706
- try {
707
- const message = JSON.parse(payload.toString("utf-8"));
708
- if (message.bodyEncoding === "binary" && (message.bodyLength ?? 0) > 0) {
709
- pendingBody.set(socket, message);
710
- continue;
711
- }
712
- if (message.type === "rsc-stream" || message.type === "rsc-html-stream" || message.type === "rsc-action" || message.type === "rsc-ppr-shell" || message.type === "rsc-resume") {
713
- // Run outside the data handler so socket writes are not corked
714
- // and setTimeout/Promise.race timeouts can fire.
715
- if (message.type === "rsc-ppr-shell") {
716
- setTimeout(async () => {
717
- const response = await inRequest(message, () => handleMessage(message));
718
- writeFrame(socket, response);
719
- }, 0);
720
- }
721
- else {
722
- const handler = message.type === "rsc-html-stream"
723
- ? handleRscHtmlStreamMessage
724
- : message.type === "rsc-action"
725
- ? handleRscActionMessage
726
- : message.type === "rsc-resume"
727
- ? handleRscResumeMessage
728
- : handleRscStreamMessage;
729
- // One memo table per message, which is one per request: the
730
- // scope has to be open before the render starts, because that is
731
- // where a guard and a layout ask the same question.
732
- setTimeout(() => void inRequest(message, (draft) => handler(socket, message, draft)), 0);
733
- }
734
- }
735
- else {
736
- const response = await inRequest(message, () => handleMessage(message));
737
- writeFrame(socket, response);
738
- }
739
- }
740
- catch (err) {
741
- log("Failed to parse message:", err);
742
- writeFrame(socket, '{"error":"Invalid JSON"}');
743
- }
744
- }
745
- if (buf.length > 0) {
746
- socketBuffers.set(socket, buf);
747
- }
748
- else {
749
- socketBuffers.delete(socket);
750
- }
751
- },
752
- drain(socket) {
753
- drainSocket(socket);
754
- },
755
- open() { },
756
- close(socket) {
757
- pendingWriteBuffers.delete(socket);
758
- socketBuffers.delete(socket);
759
- },
760
- error(_, err) {
761
- log("Socket error:", err.message);
762
- },
763
- });
764
- if (!isTcp)
765
- secureSocket(socketPath);
766
- log(`Listening on ${isTcp ? `${tcpHost}:${mainPort}` : socketPath}`);
767
- log(`Discovered ${Object.keys(functions).length} functions: ${Object.keys(functions).join(", ")}`);
768
- // ─── Persistent Callback Server ─────────────────────────────────────────────
769
- // PHP connects here for php() callbacks instead of per-request temp sockets.
770
- // Each connection registers with a callbackId that matches the render request.
771
- const callbackSocketPath = socketPath + ".cb";
772
- if (!isTcp) {
773
- try {
774
- unlinkSync(callbackSocketPath);
775
- }
776
- catch { }
777
- }
778
- const callbackConnections = new Map();
779
- const cbSocketBuffers = new Map();
780
- const pendingPhpCallbacks = new Map();
781
- function handleCbResponse(response) {
782
- const id = response.id;
783
- const pending = pendingPhpCallbacks.get(id);
784
- if (!pending)
785
- return;
786
- pendingPhpCallbacks.delete(id);
787
- if (Array.isArray(response.revalidate) && response.revalidate.length > 0) {
788
- markRevalidated(pending.socket, response.revalidate);
789
- }
790
- if (response.unauthenticated) {
791
- pending.reject(new ServerAuthenticationError(response.error));
792
- }
793
- else if (response.unauthorized) {
794
- pending.reject(new ServerAuthorizationError(response.error));
795
- }
796
- else if (response.validation_errors) {
797
- pending.reject(new ServerValidationError(response.error ?? "Validation failed", response.validation_errors));
798
- }
799
- else if (response.redirect) {
800
- pending.reject(new ServerRedirectError(response.redirect));
801
- }
802
- else if (response.error) {
803
- pending.reject(new Error(response.error));
804
- }
805
- else {
806
- pending.resolve(response.result);
807
- }
808
- }
809
- const cbServer = listen(isTcp ? { hostname: tcpHost, port: cbPort } : { unix: callbackSocketPath }, {
810
- data(socket, rawData) {
811
- let buf = cbSocketBuffers.get(socket);
812
- buf = buf ? Buffer.concat([buf, Buffer.from(rawData)]) : Buffer.from(rawData);
813
- while (buf.length >= 4) {
814
- const frameLength = buf.readUInt32BE(0);
815
- if (frameLength <= 0 || frameLength > MAX_FRAME_SIZE) {
816
- // Hang up, as on the main listener: framing that cannot be parsed
817
- // cannot be resynchronised, and reading on from an arbitrary offset
818
- // turns payload bytes into control messages.
819
- log("Invalid callback frame length, closing the connection:", frameLength);
820
- cbSocketBuffers.delete(socket);
821
- socket.end?.();
822
- return;
823
- }
824
- if (buf.length < 4 + frameLength)
825
- break;
826
- const json = buf.subarray(4, 4 + frameLength).toString("utf-8");
827
- buf = buf.subarray(4 + frameLength);
828
- try {
829
- const msg = JSON.parse(json);
830
- if (msg.type === "register" && msg.id) {
831
- callbackConnections.set(msg.id, socket);
832
- }
833
- else {
834
- handleCbResponse(msg);
835
- }
836
- }
837
- catch { }
838
- }
839
- if (buf.length > 0) {
840
- cbSocketBuffers.set(socket, buf);
841
- }
842
- else {
843
- cbSocketBuffers.delete(socket);
844
- }
845
- },
846
- open() { },
847
- close(socket) {
848
- for (const [id, s] of callbackConnections) {
849
- if (s === socket)
850
- callbackConnections.delete(id);
851
- }
852
- cbSocketBuffers.delete(socket);
853
- // Reject any php() callbacks still awaiting a response on this
854
- // connection. Without this the awaiting render hangs forever and the
855
- // pending map grows for the life of the worker (a slow whole-app leak
856
- // when requests are aborted or the PHP side disconnects mid-render).
857
- for (const [id, pending] of pendingPhpCallbacks) {
858
- if (pending.socket === socket) {
859
- pendingPhpCallbacks.delete(id);
860
- pending.reject(new Error("Callback connection closed before php() responded"));
861
- }
862
- }
863
- },
864
- drain(socket) { drainSocket(socket); },
865
- error() { },
866
- });
867
- if (!isTcp)
868
- secureSocket(callbackSocketPath);
869
- log(`Callback listener on ${isTcp ? `${tcpHost}:${cbPort}` : callbackSocketPath}`);
870
- let cbIdCounter = 0;
871
- function getCallbackConnection(id, timeoutMs = 3000) {
872
- const existing = callbackConnections.get(id);
873
- if (existing)
874
- return Promise.resolve(existing);
875
- return new Promise((resolve, reject) => {
876
- const check = setInterval(() => {
877
- const conn = callbackConnections.get(id);
878
- if (conn) {
879
- clearInterval(check);
880
- clearTimeout(timer);
881
- resolve(conn);
882
- }
883
- }, 5);
884
- const timer = setTimeout(() => {
885
- clearInterval(check);
886
- reject(new Error(`Callback ${id} not registered within ${timeoutMs}ms`));
887
- }, timeoutMs);
888
- });
889
- }
890
- function createPhpFn(cbSocket) {
891
- return (functionName, ...args) => {
892
- const id = `cb_${++cbIdCounter}`;
893
- writeFrame(cbSocket, JSON.stringify({ type: "callback", id, function: functionName, args }));
894
- return new Promise((resolve, reject) => {
895
- pendingPhpCallbacks.set(id, { resolve, reject, socket: cbSocket });
896
- });
897
- };
898
- }
899
- function shutdown(signal) {
900
- log(`Received ${signal}, shutting down`);
901
- server.stop();
902
- cbServer.stop();
903
- if (!isTcp) {
904
- try {
905
- unlinkSync(socketPath);
906
- }
907
- catch { }
908
- try {
909
- unlinkSync(callbackSocketPath);
910
- }
911
- catch { }
912
- }
913
- process.exit(0);
914
- }
915
- process.on("SIGINT", () => shutdown("SIGINT"));
916
- process.on("SIGTERM", () => shutdown("SIGTERM"));
917
- // Prevent unhandled errors from crashing the worker process.
918
- // These can occur during socket cleanup (e.g., callback socket closed by PHP)
919
- // or from deferred React rendering microtasks.
920
- process.on("uncaughtException", (err) => {
921
- log("Uncaught exception (worker kept alive):", err.message);
922
- });
923
- process.on("unhandledRejection", (reason) => {
924
- log("Unhandled rejection (worker kept alive):", reason instanceof Error ? reason.message : String(reason));
925
- });
926
- //# sourceMappingURL=worker.js.map