@rsc-kit/core 0.1.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/README.md +95 -0
- package/SECURITY.md +60 -0
- package/dist/action.d.ts +90 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +137 -0
- package/dist/action.js.map +1 -0
- package/dist/build-rsc-vite.d.ts +39 -0
- package/dist/build-rsc-vite.d.ts.map +1 -0
- package/dist/build-rsc-vite.js +172 -0
- package/dist/build-rsc-vite.js.map +1 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +108 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +85 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +55 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +141 -0
- package/dist/compile.js.map +1 -0
- package/dist/devServer.d.ts +18 -0
- package/dist/devServer.d.ts.map +1 -0
- package/dist/devServer.js +70 -0
- package/dist/devServer.js.map +1 -0
- package/dist/devUrls.d.ts +11 -0
- package/dist/devUrls.d.ts.map +1 -0
- package/dist/devUrls.js +68 -0
- package/dist/devUrls.js.map +1 -0
- package/dist/embed.d.ts +21 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +119 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +41 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +100 -0
- package/dist/export.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +146 -0
- package/dist/files.js.map +1 -0
- package/dist/headers.d.ts +81 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +96 -0
- package/dist/headers.js.map +1 -0
- package/dist/host.d.ts +86 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +590 -0
- package/dist/host.js.map +1 -0
- package/dist/js/ActivityRouter.d.ts +47 -0
- package/dist/js/ActivityRouter.d.ts.map +1 -0
- package/dist/js/ActivityRouter.js +87 -0
- package/dist/js/ActivityRouter.js.map +1 -0
- package/dist/js/DocumentTitle.d.ts +22 -0
- package/dist/js/DocumentTitle.d.ts.map +1 -0
- package/dist/js/DocumentTitle.js +28 -0
- package/dist/js/DocumentTitle.js.map +1 -0
- package/dist/js/Form.d.ts +54 -0
- package/dist/js/Form.d.ts.map +1 -0
- package/dist/js/Form.js +225 -0
- package/dist/js/Form.js.map +1 -0
- package/dist/js/Link.d.ts +29 -0
- package/dist/js/Link.d.ts.map +1 -0
- package/dist/js/Link.js +112 -0
- package/dist/js/Link.js.map +1 -0
- package/dist/js/PathnameProvider.d.ts +8 -0
- package/dist/js/PathnameProvider.d.ts.map +1 -0
- package/dist/js/PathnameProvider.js +24 -0
- package/dist/js/PathnameProvider.js.map +1 -0
- package/dist/js/RedirectBoundary.d.ts +18 -0
- package/dist/js/RedirectBoundary.d.ts.map +1 -0
- package/dist/js/RedirectBoundary.js +34 -0
- package/dist/js/RedirectBoundary.js.map +1 -0
- package/dist/js/SegmentBoundary.d.ts +8 -0
- package/dist/js/SegmentBoundary.d.ts.map +1 -0
- package/dist/js/SegmentBoundary.js +38 -0
- package/dist/js/SegmentBoundary.js.map +1 -0
- package/dist/js/SlotBoundary.d.ts +15 -0
- package/dist/js/SlotBoundary.d.ts.map +1 -0
- package/dist/js/SlotBoundary.js +17 -0
- package/dist/js/SlotBoundary.js.map +1 -0
- package/dist/js/createViteRscApp.d.ts +8 -0
- package/dist/js/createViteRscApp.d.ts.map +1 -0
- package/dist/js/createViteRscApp.js +185 -0
- package/dist/js/createViteRscApp.js.map +1 -0
- package/dist/js/errors.d.ts +51 -0
- package/dist/js/errors.d.ts.map +1 -0
- package/dist/js/errors.js +93 -0
- package/dist/js/errors.js.map +1 -0
- package/dist/js/navigate.d.ts +118 -0
- package/dist/js/navigate.d.ts.map +1 -0
- package/dist/js/navigate.js +780 -0
- package/dist/js/navigate.js.map +1 -0
- package/dist/js/onlineStore.d.ts +23 -0
- package/dist/js/onlineStore.d.ts.map +1 -0
- package/dist/js/onlineStore.js +42 -0
- package/dist/js/onlineStore.js.map +1 -0
- package/dist/js/pagePayload.d.ts +15 -0
- package/dist/js/pagePayload.d.ts.map +1 -0
- package/dist/js/pagePayload.js +39 -0
- package/dist/js/pagePayload.js.map +1 -0
- package/dist/js/router.d.ts +29 -0
- package/dist/js/router.d.ts.map +1 -0
- package/dist/js/router.js +40 -0
- package/dist/js/router.js.map +1 -0
- package/dist/js/section.d.ts +16 -0
- package/dist/js/section.d.ts.map +1 -0
- package/dist/js/section.js +55 -0
- package/dist/js/section.js.map +1 -0
- package/dist/js/segmentStore.d.ts +69 -0
- package/dist/js/segmentStore.d.ts.map +1 -0
- package/dist/js/segmentStore.js +127 -0
- package/dist/js/segmentStore.js.map +1 -0
- package/dist/js/slotStore.d.ts +35 -0
- package/dist/js/slotStore.d.ts.map +1 -0
- package/dist/js/slotStore.js +46 -0
- package/dist/js/slotStore.js.map +1 -0
- package/dist/js/standardSchema.d.ts +47 -0
- package/dist/js/standardSchema.d.ts.map +1 -0
- package/dist/js/standardSchema.js +41 -0
- package/dist/js/standardSchema.js.map +1 -0
- package/dist/js/useForm.d.ts +43 -0
- package/dist/js/useForm.d.ts.map +1 -0
- package/dist/js/useForm.js +164 -0
- package/dist/js/useForm.js.map +1 -0
- package/dist/js/useLinkStatus.d.ts +2 -0
- package/dist/js/useLinkStatus.d.ts.map +1 -0
- package/dist/js/useLinkStatus.js +3 -0
- package/dist/js/useLinkStatus.js.map +1 -0
- package/dist/js/useOnline.d.ts +8 -0
- package/dist/js/useOnline.d.ts.map +1 -0
- package/dist/js/useOnline.js +20 -0
- package/dist/js/useOnline.js.map +1 -0
- package/dist/js/usePathname.d.ts +9 -0
- package/dist/js/usePathname.d.ts.map +1 -0
- package/dist/js/usePathname.js +32 -0
- package/dist/js/usePathname.js.map +1 -0
- package/dist/js/useSearchParams.d.ts +2 -0
- package/dist/js/useSearchParams.d.ts.map +1 -0
- package/dist/js/useSearchParams.js +60 -0
- package/dist/js/useSearchParams.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +13 -0
- package/dist/manifest.js.map +1 -0
- package/dist/prerender.d.ts +138 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +387 -0
- package/dist/prerender.js.map +1 -0
- package/dist/redirect.d.ts +34 -0
- package/dist/redirect.d.ts.map +1 -0
- package/dist/redirect.js +108 -0
- package/dist/redirect.js.map +1 -0
- package/dist/redirectDigest.d.ts +20 -0
- package/dist/redirectDigest.d.ts.map +1 -0
- package/dist/redirectDigest.js +66 -0
- package/dist/redirectDigest.js.map +1 -0
- package/dist/request.d.ts +93 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +308 -0
- package/dist/request.js.map +1 -0
- package/dist/revalidate.d.ts +43 -0
- package/dist/revalidate.d.ts.map +1 -0
- package/dist/revalidate.js +99 -0
- package/dist/revalidate.js.map +1 -0
- package/dist/routes.d.ts +37 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +28 -0
- package/dist/routes.js.map +1 -0
- package/dist/routing.d.ts +52 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +146 -0
- package/dist/routing.js.map +1 -0
- package/dist/runtime.d.ts +36 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +86 -0
- package/dist/runtime.js.map +1 -0
- package/dist/safeUrl.d.ts +10 -0
- package/dist/safeUrl.d.ts.map +1 -0
- package/dist/safeUrl.js +40 -0
- package/dist/safeUrl.js.map +1 -0
- package/dist/streaming.d.ts +62 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +108 -0
- package/dist/streaming.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/vite.d.ts +109 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +1909 -0
- package/dist/vite.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +863 -0
- package/dist/worker.js.map +1 -0
- package/package.json +223 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
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
|
+
* Handles rsc-action messages (server action calls).
|
|
418
|
+
*
|
|
419
|
+
* Same streaming pattern as handleRscStreamMessage — writes Flight
|
|
420
|
+
* data frames back on the main socket with action-specific frame types.
|
|
421
|
+
*/
|
|
422
|
+
async function handleRscActionMessage(mainSocket, message, draft) {
|
|
423
|
+
if (!rscHandler) {
|
|
424
|
+
writeFrame(mainSocket, '{"error":"RSC not enabled"}');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!message.actionId) {
|
|
428
|
+
writeFrame(mainSocket, '{"error":"Missing actionId in rsc-action message"}');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
let cleanupPhp = null;
|
|
432
|
+
// Held beyond the block below: what the host marks stale during the action
|
|
433
|
+
// is collected against this connection and read after the action returns.
|
|
434
|
+
let cbConn = null;
|
|
435
|
+
try {
|
|
436
|
+
if (message.callbackId) {
|
|
437
|
+
cbConn = await getCallbackConnection(message.callbackId);
|
|
438
|
+
cleanupPhp = rscHandler.installHostFn(createPhpFn(cbConn));
|
|
439
|
+
}
|
|
440
|
+
// Already bytes when a body arrived on its own frame; there is nothing to
|
|
441
|
+
// decode and nothing to reinterpret.
|
|
442
|
+
const actionBody = message.body ?? "";
|
|
443
|
+
const { stream } = await rscHandler.handleAction(message.actionId, actionBody, message.contentType ?? "text/plain",
|
|
444
|
+
// The page the action was invoked from, resolved by the host — the
|
|
445
|
+
// browser knows the url but not which components render it.
|
|
446
|
+
message.page, () => takeRevalidated(cbConn));
|
|
447
|
+
// An action's whole point may be the cookie it leaves behind, so its
|
|
448
|
+
// start frame carries them the same way a render's does.
|
|
449
|
+
writeFrame(mainSocket, JSON.stringify({ type: "action-start", headers: draft && draftHeaders(draft) }));
|
|
450
|
+
await yieldToEventLoop();
|
|
451
|
+
const reader = stream.getReader();
|
|
452
|
+
const decoder = new TextDecoder();
|
|
453
|
+
while (true) {
|
|
454
|
+
const { done, value } = await reader.read();
|
|
455
|
+
if (done)
|
|
456
|
+
break;
|
|
457
|
+
const text = typeof value === "string"
|
|
458
|
+
? value
|
|
459
|
+
: decoder.decode(value, { stream: true });
|
|
460
|
+
writeFrame(mainSocket, JSON.stringify({ type: "action-chunk", data: text }));
|
|
461
|
+
await yieldToEventLoop();
|
|
462
|
+
}
|
|
463
|
+
// What the host marked stale while the action ran. Reported on the way
|
|
464
|
+
// out so the answer can carry it; the parts themselves follow once the
|
|
465
|
+
// worker is given the page context to render them against.
|
|
466
|
+
const revalidated = takeRevalidated(cbConn);
|
|
467
|
+
writeFrame(mainSocket, JSON.stringify(revalidated.length > 0 ? { type: "action-end", revalidated } : { type: "action-end" }));
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
let errorJson;
|
|
471
|
+
if (err instanceof ServerAuthenticationError) {
|
|
472
|
+
errorJson = JSON.stringify({
|
|
473
|
+
unauthenticated: true,
|
|
474
|
+
error: err.message,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
else if (err instanceof ServerAuthorizationError) {
|
|
478
|
+
errorJson = JSON.stringify({
|
|
479
|
+
unauthorized: true,
|
|
480
|
+
error: err.message,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
else if (err instanceof ServerRedirectError) {
|
|
484
|
+
errorJson = JSON.stringify({
|
|
485
|
+
redirect: err.location,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
else if (err instanceof ServerValidationError) {
|
|
489
|
+
errorJson = JSON.stringify({
|
|
490
|
+
error: err.message,
|
|
491
|
+
validation_errors: err.errors,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
errorJson = JSON.stringify({
|
|
496
|
+
error: err instanceof Error ? err.message : String(err),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
writeFrame(mainSocket, errorJson);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Best effort
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
cleanupPhp?.();
|
|
508
|
+
if (message.callbackId)
|
|
509
|
+
callbackConnections.delete(message.callbackId);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!isTcp) {
|
|
513
|
+
try {
|
|
514
|
+
unlinkSync(socketPath);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// File doesn't exist
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const pendingWriteBuffers = new Map();
|
|
521
|
+
const socketBuffers = new Map();
|
|
522
|
+
function drainSocket(socket) {
|
|
523
|
+
const pending = pendingWriteBuffers.get(socket);
|
|
524
|
+
if (!pending)
|
|
525
|
+
return;
|
|
526
|
+
const written = socket.write(pending);
|
|
527
|
+
if (written < pending.length) {
|
|
528
|
+
pendingWriteBuffers.set(socket, pending.subarray(written));
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
pendingWriteBuffers.delete(socket);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function writeFrame(socket, json) {
|
|
535
|
+
const payload = Buffer.from(json, "utf-8");
|
|
536
|
+
const header = Buffer.alloc(4);
|
|
537
|
+
header.writeUInt32BE(payload.length, 0);
|
|
538
|
+
const frame = Buffer.concat([header, payload]);
|
|
539
|
+
// If there are pending writes from a previous partial write,
|
|
540
|
+
// queue this frame behind them to maintain frame ordering.
|
|
541
|
+
// Writing directly would interleave with pending data on the wire.
|
|
542
|
+
const existing = pendingWriteBuffers.get(socket);
|
|
543
|
+
if (existing) {
|
|
544
|
+
pendingWriteBuffers.set(socket, Buffer.concat([existing, frame]));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const written = socket.write(frame);
|
|
548
|
+
if (written < frame.length) {
|
|
549
|
+
pendingWriteBuffers.set(socket, frame.subarray(written));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const MAX_FRAME_SIZE = parseInt(process.env.RSC_MAX_FRAME_SIZE || "1048576", 10); // 1MB default
|
|
553
|
+
/**
|
|
554
|
+
* Messages whose body is arriving as the next frame.
|
|
555
|
+
*
|
|
556
|
+
* An upload body is bytes, and frames are bytes, so it travels as its own
|
|
557
|
+
* frame instead of being encoded to fit inside the JSON one. The frame after
|
|
558
|
+
* such a header is that body and must not be parsed as JSON.
|
|
559
|
+
*/
|
|
560
|
+
const pendingBody = new Map();
|
|
561
|
+
/**
|
|
562
|
+
* What the host marked stale while an action was running, per callback
|
|
563
|
+
* connection.
|
|
564
|
+
*
|
|
565
|
+
* An action knows what it changed and the page does not, so the marks ride
|
|
566
|
+
* back with each callback's result. Collected here so the answer to the action
|
|
567
|
+
* can carry the re-rendered parts — rather than telling the browser what went
|
|
568
|
+
* stale and waiting for it to ask.
|
|
569
|
+
*/
|
|
570
|
+
const revalidations = new Map();
|
|
571
|
+
function markRevalidated(socket, targets) {
|
|
572
|
+
const marked = revalidations.get(socket) ?? new Set();
|
|
573
|
+
for (const target of targets)
|
|
574
|
+
marked.add(target);
|
|
575
|
+
revalidations.set(socket, marked);
|
|
576
|
+
}
|
|
577
|
+
/** Take what a connection collected and forget it. */
|
|
578
|
+
function takeRevalidated(socket) {
|
|
579
|
+
if (!socket)
|
|
580
|
+
return [];
|
|
581
|
+
const marked = revalidations.get(socket);
|
|
582
|
+
revalidations.delete(socket);
|
|
583
|
+
return marked ? [...marked] : [];
|
|
584
|
+
}
|
|
585
|
+
// Create the socket files owner-only. Without this, any local user could
|
|
586
|
+
// connect to the predictable socket path and drive the bridge (invoke server
|
|
587
|
+
// actions / php() callables) with no authenticated session. PHP-FPM must run
|
|
588
|
+
// as the same user as this worker (already implied by sharing the socket).
|
|
589
|
+
try {
|
|
590
|
+
process.umask(0o077);
|
|
591
|
+
}
|
|
592
|
+
catch { }
|
|
593
|
+
/** Restrict a bound Unix socket to owner read/write only. */
|
|
594
|
+
function secureSocket(path) {
|
|
595
|
+
try {
|
|
596
|
+
chmodSync(path, 0o600);
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
log("Failed to secure socket permissions:", err instanceof Error ? err.message : String(err));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const server = listen(isTcp ? { hostname: tcpHost, port: mainPort } : { unix: socketPath }, {
|
|
603
|
+
async data(socket, rawData) {
|
|
604
|
+
let buf = socketBuffers.get(socket);
|
|
605
|
+
buf = buf ? Buffer.concat([buf, Buffer.from(rawData)]) : Buffer.from(rawData);
|
|
606
|
+
while (buf.length >= 4) {
|
|
607
|
+
const frameLength = buf.readUInt32BE(0);
|
|
608
|
+
if (frameLength <= 0 || frameLength > MAX_FRAME_SIZE) {
|
|
609
|
+
// Hang up, rather than drop the buffer and read on.
|
|
610
|
+
//
|
|
611
|
+
// Dropping it leaves the connection open, so the next chunk is
|
|
612
|
+
// parsed as a fresh frame from an arbitrary offset inside the
|
|
613
|
+
// payload that was just rejected — and that payload is an action
|
|
614
|
+
// body, which is to say whatever was uploaded. Bytes chosen to look
|
|
615
|
+
// like a length prefix and a JSON message then arrive as a control
|
|
616
|
+
// message on a pooled, long-lived socket: a host function invoked
|
|
617
|
+
// with arguments from inside a file. There is no way to resynchronise
|
|
618
|
+
// a stream whose framing is already lost, so the only correct move
|
|
619
|
+
// is to stop reading it.
|
|
620
|
+
log("Invalid frame length, closing the connection:", frameLength);
|
|
621
|
+
socketBuffers.delete(socket);
|
|
622
|
+
pendingBody.delete(socket);
|
|
623
|
+
socket.end?.();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (buf.length < 4 + frameLength) {
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
const payload = buf.subarray(4, 4 + frameLength);
|
|
630
|
+
buf = buf.subarray(4 + frameLength);
|
|
631
|
+
const awaiting = pendingBody.get(socket);
|
|
632
|
+
if (awaiting) {
|
|
633
|
+
pendingBody.delete(socket);
|
|
634
|
+
// Copied because the read buffer is reused for the frames after it.
|
|
635
|
+
awaiting.body = Buffer.from(payload);
|
|
636
|
+
// Through inRequest, like every other message. Dispatching bare left
|
|
637
|
+
// an action whose body arrived as bytes — which is every file upload
|
|
638
|
+
// — with no request scope, no response draft and no memo table:
|
|
639
|
+
// headers() and cookies() threw, cookies().set() threw, and nothing
|
|
640
|
+
// the action set could reach the response, because the action-start
|
|
641
|
+
// frame carried no headers to put there.
|
|
642
|
+
setTimeout(() => void inRequest(awaiting, (draft) => handleRscActionMessage(socket, awaiting, draft)), 0);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const message = JSON.parse(payload.toString("utf-8"));
|
|
647
|
+
if (message.bodyEncoding === "binary" && (message.bodyLength ?? 0) > 0) {
|
|
648
|
+
pendingBody.set(socket, message);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (message.type === "rsc-stream" || message.type === "rsc-html-stream" || message.type === "rsc-action" || message.type === "rsc-ppr-shell") {
|
|
652
|
+
// Run outside the data handler so socket writes are not corked
|
|
653
|
+
// and setTimeout/Promise.race timeouts can fire.
|
|
654
|
+
if (message.type === "rsc-ppr-shell") {
|
|
655
|
+
setTimeout(async () => {
|
|
656
|
+
const response = await inRequest(message, () => handleMessage(message));
|
|
657
|
+
writeFrame(socket, response);
|
|
658
|
+
}, 0);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
const handler = message.type === "rsc-html-stream"
|
|
662
|
+
? handleRscHtmlStreamMessage
|
|
663
|
+
: message.type === "rsc-action"
|
|
664
|
+
? handleRscActionMessage
|
|
665
|
+
: handleRscStreamMessage;
|
|
666
|
+
// One memo table per message, which is one per request: the
|
|
667
|
+
// scope has to be open before the render starts, because that is
|
|
668
|
+
// where a guard and a layout ask the same question.
|
|
669
|
+
setTimeout(() => void inRequest(message, (draft) => handler(socket, message, draft)), 0);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
const response = await inRequest(message, () => handleMessage(message));
|
|
674
|
+
writeFrame(socket, response);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
log("Failed to parse message:", err);
|
|
679
|
+
writeFrame(socket, '{"error":"Invalid JSON"}');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (buf.length > 0) {
|
|
683
|
+
socketBuffers.set(socket, buf);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
socketBuffers.delete(socket);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
drain(socket) {
|
|
690
|
+
drainSocket(socket);
|
|
691
|
+
},
|
|
692
|
+
open() { },
|
|
693
|
+
close(socket) {
|
|
694
|
+
pendingWriteBuffers.delete(socket);
|
|
695
|
+
socketBuffers.delete(socket);
|
|
696
|
+
},
|
|
697
|
+
error(_, err) {
|
|
698
|
+
log("Socket error:", err.message);
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
if (!isTcp)
|
|
702
|
+
secureSocket(socketPath);
|
|
703
|
+
log(`Listening on ${isTcp ? `${tcpHost}:${mainPort}` : socketPath}`);
|
|
704
|
+
log(`Discovered ${Object.keys(functions).length} functions: ${Object.keys(functions).join(", ")}`);
|
|
705
|
+
// ─── Persistent Callback Server ─────────────────────────────────────────────
|
|
706
|
+
// PHP connects here for php() callbacks instead of per-request temp sockets.
|
|
707
|
+
// Each connection registers with a callbackId that matches the render request.
|
|
708
|
+
const callbackSocketPath = socketPath + ".cb";
|
|
709
|
+
if (!isTcp) {
|
|
710
|
+
try {
|
|
711
|
+
unlinkSync(callbackSocketPath);
|
|
712
|
+
}
|
|
713
|
+
catch { }
|
|
714
|
+
}
|
|
715
|
+
const callbackConnections = new Map();
|
|
716
|
+
const cbSocketBuffers = new Map();
|
|
717
|
+
const pendingPhpCallbacks = new Map();
|
|
718
|
+
function handleCbResponse(response) {
|
|
719
|
+
const id = response.id;
|
|
720
|
+
const pending = pendingPhpCallbacks.get(id);
|
|
721
|
+
if (!pending)
|
|
722
|
+
return;
|
|
723
|
+
pendingPhpCallbacks.delete(id);
|
|
724
|
+
if (Array.isArray(response.revalidate) && response.revalidate.length > 0) {
|
|
725
|
+
markRevalidated(pending.socket, response.revalidate);
|
|
726
|
+
}
|
|
727
|
+
if (response.unauthenticated) {
|
|
728
|
+
pending.reject(new ServerAuthenticationError(response.error));
|
|
729
|
+
}
|
|
730
|
+
else if (response.unauthorized) {
|
|
731
|
+
pending.reject(new ServerAuthorizationError(response.error));
|
|
732
|
+
}
|
|
733
|
+
else if (response.validation_errors) {
|
|
734
|
+
pending.reject(new ServerValidationError(response.error ?? "Validation failed", response.validation_errors));
|
|
735
|
+
}
|
|
736
|
+
else if (response.redirect) {
|
|
737
|
+
pending.reject(new ServerRedirectError(response.redirect));
|
|
738
|
+
}
|
|
739
|
+
else if (response.error) {
|
|
740
|
+
pending.reject(new Error(response.error));
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
pending.resolve(response.result);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const cbServer = listen(isTcp ? { hostname: tcpHost, port: cbPort } : { unix: callbackSocketPath }, {
|
|
747
|
+
data(socket, rawData) {
|
|
748
|
+
let buf = cbSocketBuffers.get(socket);
|
|
749
|
+
buf = buf ? Buffer.concat([buf, Buffer.from(rawData)]) : Buffer.from(rawData);
|
|
750
|
+
while (buf.length >= 4) {
|
|
751
|
+
const frameLength = buf.readUInt32BE(0);
|
|
752
|
+
if (frameLength <= 0 || frameLength > MAX_FRAME_SIZE) {
|
|
753
|
+
// Hang up, as on the main listener: framing that cannot be parsed
|
|
754
|
+
// cannot be resynchronised, and reading on from an arbitrary offset
|
|
755
|
+
// turns payload bytes into control messages.
|
|
756
|
+
log("Invalid callback frame length, closing the connection:", frameLength);
|
|
757
|
+
cbSocketBuffers.delete(socket);
|
|
758
|
+
socket.end?.();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (buf.length < 4 + frameLength)
|
|
762
|
+
break;
|
|
763
|
+
const json = buf.subarray(4, 4 + frameLength).toString("utf-8");
|
|
764
|
+
buf = buf.subarray(4 + frameLength);
|
|
765
|
+
try {
|
|
766
|
+
const msg = JSON.parse(json);
|
|
767
|
+
if (msg.type === "register" && msg.id) {
|
|
768
|
+
callbackConnections.set(msg.id, socket);
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
handleCbResponse(msg);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch { }
|
|
775
|
+
}
|
|
776
|
+
if (buf.length > 0) {
|
|
777
|
+
cbSocketBuffers.set(socket, buf);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
cbSocketBuffers.delete(socket);
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
open() { },
|
|
784
|
+
close(socket) {
|
|
785
|
+
for (const [id, s] of callbackConnections) {
|
|
786
|
+
if (s === socket)
|
|
787
|
+
callbackConnections.delete(id);
|
|
788
|
+
}
|
|
789
|
+
cbSocketBuffers.delete(socket);
|
|
790
|
+
// Reject any php() callbacks still awaiting a response on this
|
|
791
|
+
// connection. Without this the awaiting render hangs forever and the
|
|
792
|
+
// pending map grows for the life of the worker (a slow whole-app leak
|
|
793
|
+
// when requests are aborted or the PHP side disconnects mid-render).
|
|
794
|
+
for (const [id, pending] of pendingPhpCallbacks) {
|
|
795
|
+
if (pending.socket === socket) {
|
|
796
|
+
pendingPhpCallbacks.delete(id);
|
|
797
|
+
pending.reject(new Error("Callback connection closed before php() responded"));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
drain(socket) { drainSocket(socket); },
|
|
802
|
+
error() { },
|
|
803
|
+
});
|
|
804
|
+
if (!isTcp)
|
|
805
|
+
secureSocket(callbackSocketPath);
|
|
806
|
+
log(`Callback listener on ${isTcp ? `${tcpHost}:${cbPort}` : callbackSocketPath}`);
|
|
807
|
+
let cbIdCounter = 0;
|
|
808
|
+
function getCallbackConnection(id, timeoutMs = 3000) {
|
|
809
|
+
const existing = callbackConnections.get(id);
|
|
810
|
+
if (existing)
|
|
811
|
+
return Promise.resolve(existing);
|
|
812
|
+
return new Promise((resolve, reject) => {
|
|
813
|
+
const check = setInterval(() => {
|
|
814
|
+
const conn = callbackConnections.get(id);
|
|
815
|
+
if (conn) {
|
|
816
|
+
clearInterval(check);
|
|
817
|
+
clearTimeout(timer);
|
|
818
|
+
resolve(conn);
|
|
819
|
+
}
|
|
820
|
+
}, 5);
|
|
821
|
+
const timer = setTimeout(() => {
|
|
822
|
+
clearInterval(check);
|
|
823
|
+
reject(new Error(`Callback ${id} not registered within ${timeoutMs}ms`));
|
|
824
|
+
}, timeoutMs);
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
function createPhpFn(cbSocket) {
|
|
828
|
+
return (functionName, ...args) => {
|
|
829
|
+
const id = `cb_${++cbIdCounter}`;
|
|
830
|
+
writeFrame(cbSocket, JSON.stringify({ type: "callback", id, function: functionName, args }));
|
|
831
|
+
return new Promise((resolve, reject) => {
|
|
832
|
+
pendingPhpCallbacks.set(id, { resolve, reject, socket: cbSocket });
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function shutdown(signal) {
|
|
837
|
+
log(`Received ${signal}, shutting down`);
|
|
838
|
+
server.stop();
|
|
839
|
+
cbServer.stop();
|
|
840
|
+
if (!isTcp) {
|
|
841
|
+
try {
|
|
842
|
+
unlinkSync(socketPath);
|
|
843
|
+
}
|
|
844
|
+
catch { }
|
|
845
|
+
try {
|
|
846
|
+
unlinkSync(callbackSocketPath);
|
|
847
|
+
}
|
|
848
|
+
catch { }
|
|
849
|
+
}
|
|
850
|
+
process.exit(0);
|
|
851
|
+
}
|
|
852
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
853
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
854
|
+
// Prevent unhandled errors from crashing the worker process.
|
|
855
|
+
// These can occur during socket cleanup (e.g., callback socket closed by PHP)
|
|
856
|
+
// or from deferred React rendering microtasks.
|
|
857
|
+
process.on("uncaughtException", (err) => {
|
|
858
|
+
log("Uncaught exception (worker kept alive):", err.message);
|
|
859
|
+
});
|
|
860
|
+
process.on("unhandledRejection", (reason) => {
|
|
861
|
+
log("Unhandled rejection (worker kept alive):", reason instanceof Error ? reason.message : String(reason));
|
|
862
|
+
});
|
|
863
|
+
//# sourceMappingURL=worker.js.map
|