react-on-rails-pro-node-renderer 17.0.0-rc.2 → 17.0.0-rc.4
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/lib/master.js +169 -29
- package/lib/shared/checkRscPeerCompatibility.js +24 -6
- package/lib/shared/configBuilder.d.ts +1 -0
- package/lib/shared/configBuilder.js +9 -0
- package/lib/shared/rscPeerSupport.d.ts +9 -2
- package/lib/shared/rscPeerSupport.js +8 -1
- package/lib/shared/utils.d.ts +7 -1
- package/lib/shared/utils.js +21 -5
- package/lib/worker/handleIncrementalRenderRequest.d.ts +3 -1
- package/lib/worker/handleIncrementalRenderRequest.js +10 -5
- package/lib/worker/vm.d.ts +16 -0
- package/lib/worker/vm.js +167 -13
- package/lib/worker/vmSourceMapSupport.d.ts +110 -0
- package/lib/worker/vmSourceMapSupport.js +664 -0
- package/lib/worker.d.ts +9 -1
- package/lib/worker.js +336 -12
- package/package.json +5 -2
package/lib/master.js
CHANGED
|
@@ -66,6 +66,8 @@ const errorReporter = __importStar(require("./shared/errorReporter.js"));
|
|
|
66
66
|
const licenseValidator_js_1 = require("./shared/licenseValidator.js");
|
|
67
67
|
const runRscPeerCompatibilityCheck_js_1 = require("./shared/runRscPeerCompatibilityCheck.js");
|
|
68
68
|
const workerMessages_js_1 = require("./shared/workerMessages.js");
|
|
69
|
+
const utils_js_1 = require("./shared/utils.js");
|
|
70
|
+
const shutdownHooks_js_1 = require("./worker/shutdownHooks.js");
|
|
69
71
|
const MILLISECONDS_IN_MINUTE = 60000;
|
|
70
72
|
// How often to scan for orphaned upload directories.
|
|
71
73
|
const ORPHAN_CLEANUP_INTERVAL_MS = 5 * MILLISECONDS_IN_MINUTE;
|
|
@@ -73,6 +75,26 @@ const ORPHAN_CLEANUP_INTERVAL_MS = 5 * MILLISECONDS_IN_MINUTE;
|
|
|
73
75
|
// Set well above the longest realistic upload duration so that large bundle
|
|
74
76
|
// uploads in progress are never deleted by the cleanup timer.
|
|
75
77
|
const ORPHAN_AGE_THRESHOLD_MS = 30 * MILLISECONDS_IN_MINUTE;
|
|
78
|
+
// Give workers a short window to receive SHUTDOWN_WORKER_MESSAGE and enter
|
|
79
|
+
// their own graceful shutdown path before the master falls back to disconnect().
|
|
80
|
+
const MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS = 1000;
|
|
81
|
+
// Hard deadline for the master to exit after it begins draining workers. It
|
|
82
|
+
// starts before workers receive SHUTDOWN_WORKER_MESSAGE, so it must include the
|
|
83
|
+
// message grace window plus the worker hook budget. If a worker is stuck after
|
|
84
|
+
// that point, this guarantees the master still exits.
|
|
85
|
+
const MASTER_SHUTDOWN_TIMEOUT_MS = MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS + shutdownHooks_js_1.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS;
|
|
86
|
+
// When a worker's event loop is blocked (synchronous render) it can process
|
|
87
|
+
// neither SHUTDOWN_WORKER_MESSAGE nor a disconnect, so only SIGKILL can stop
|
|
88
|
+
// it — and the master must send that SIGKILL while it is still alive itself.
|
|
89
|
+
// Process supervisors kill the master well before MASTER_SHUTDOWN_TIMEOUT_MS
|
|
90
|
+
// (Foreman's default SIGTERM-to-SIGKILL window is 5s), so survivors are
|
|
91
|
+
// force-killed early: after the message grace window has given draining a
|
|
92
|
+
// chance, but safely inside the supervisor's window.
|
|
93
|
+
const SHUTDOWN_WORKER_FORCE_KILL_TIMEOUT_MS = 2000;
|
|
94
|
+
const SIGNAL_EXIT_CODES = {
|
|
95
|
+
SIGINT: 130,
|
|
96
|
+
SIGTERM: 143,
|
|
97
|
+
};
|
|
76
98
|
function masterRun(runningConfig) {
|
|
77
99
|
// This is memoized after the wrapper path runs, but still protects direct `./master` entrypoint users.
|
|
78
100
|
(0, runRscPeerCompatibilityCheck_js_1.runRscPeerCompatibilityCheck)({ proVersion: packageJson_js_1.default.version });
|
|
@@ -124,34 +146,130 @@ function masterRun(runningConfig) {
|
|
|
124
146
|
})();
|
|
125
147
|
}, ORPHAN_CLEANUP_INTERVAL_MS);
|
|
126
148
|
let isAbortingForStartupFailure = false;
|
|
149
|
+
let hasReportedStartupFailure = false;
|
|
127
150
|
let fatalStartupFailure = null;
|
|
128
|
-
|
|
151
|
+
// Set as soon as any shutdown path (external signal or startup-failure abort)
|
|
152
|
+
// begins. Read by the `cluster.on('exit')` handler to suppress re-forking
|
|
153
|
+
// workers that exit because we are intentionally tearing the cluster down.
|
|
154
|
+
let isShuttingDown = false;
|
|
155
|
+
// Drain every live worker, then exit with `exitCode`. Idempotent: only the
|
|
156
|
+
// first call performs the disconnect + exit; later calls are no-ops so that
|
|
157
|
+
// an external signal and a near-simultaneous startup-failure abort can't both
|
|
158
|
+
// disconnect or schedule duplicate exits.
|
|
159
|
+
//
|
|
160
|
+
// cluster.disconnect() is async, but its callback only proves workers have
|
|
161
|
+
// disconnected from IPC. Workers may still be draining in-flight requests, so
|
|
162
|
+
// external signal shutdown also waits for their exit events before the master
|
|
163
|
+
// exits. A hard-deadline timer guarantees the master still exits if a worker
|
|
164
|
+
// is stuck.
|
|
165
|
+
const currentWorkers = () => Object.values(cluster_1.default.workers ?? {}).filter((worker) => Boolean(worker));
|
|
166
|
+
const forceKillSurvivingWorkers = (workers) => {
|
|
167
|
+
workers.forEach((worker) => {
|
|
168
|
+
const workerProcess = worker.process;
|
|
169
|
+
// isDead() only: ChildProcess.killed means a signal was sent, not that
|
|
170
|
+
// the process died — e.g. a blocked worker surviving destroy()'s SIGTERM.
|
|
171
|
+
if (worker.isDead())
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
workerProcess.kill('SIGKILL');
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
log_js_1.default.warn({ msg: 'Error sending SIGKILL to worker during node renderer master shutdown', err });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
const sendGracefulShutdownMessageToWorkers = (workers) => {
|
|
182
|
+
workers.forEach((worker) => {
|
|
183
|
+
try {
|
|
184
|
+
worker.send(utils_js_1.SHUTDOWN_WORKER_MESSAGE);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
log_js_1.default.warn({ msg: 'Error sending graceful shutdown message to worker', err });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
const waitForWorkerExits = (workers, onAllWorkersExited) => {
|
|
192
|
+
let remainingWorkers = 0;
|
|
193
|
+
let hasFinished = false;
|
|
194
|
+
const finishIfComplete = () => {
|
|
195
|
+
if (hasFinished || remainingWorkers > 0)
|
|
196
|
+
return;
|
|
197
|
+
hasFinished = true;
|
|
198
|
+
onAllWorkersExited();
|
|
199
|
+
};
|
|
200
|
+
workers.forEach((worker) => {
|
|
201
|
+
if (worker.isDead())
|
|
202
|
+
return;
|
|
203
|
+
remainingWorkers += 1;
|
|
204
|
+
const markWorkerExited = () => {
|
|
205
|
+
remainingWorkers -= 1;
|
|
206
|
+
finishIfComplete();
|
|
207
|
+
};
|
|
208
|
+
worker.once('exit', markWorkerExited);
|
|
209
|
+
if (worker.isDead()) {
|
|
210
|
+
worker.off('exit', markWorkerExited);
|
|
211
|
+
markWorkerExited();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
finishIfComplete();
|
|
215
|
+
};
|
|
216
|
+
const shutdownGracefully = (exitCode, notifyWorkersBeforeDisconnect = false) => {
|
|
217
|
+
if (isShuttingDown)
|
|
218
|
+
return;
|
|
219
|
+
isShuttingDown = true;
|
|
220
|
+
const workersAtShutdown = currentWorkers();
|
|
221
|
+
const shutdownTimer = setTimeout(() => {
|
|
222
|
+
forceKillSurvivingWorkers(workersAtShutdown);
|
|
223
|
+
process.exit(exitCode);
|
|
224
|
+
}, MASTER_SHUTDOWN_TIMEOUT_MS);
|
|
225
|
+
if (typeof shutdownTimer.unref === 'function')
|
|
226
|
+
shutdownTimer.unref();
|
|
227
|
+
// Early force-kill of workers that cannot drain (blocked event loop).
|
|
228
|
+
// Their SIGKILL-induced exits complete the disconnect, which lets the
|
|
229
|
+
// normal waitForWorkerExits path below finish the shutdown promptly.
|
|
230
|
+
const forceKillTimer = setTimeout(() => {
|
|
231
|
+
forceKillSurvivingWorkers(workersAtShutdown);
|
|
232
|
+
}, SHUTDOWN_WORKER_FORCE_KILL_TIMEOUT_MS);
|
|
233
|
+
if (typeof forceKillTimer.unref === 'function')
|
|
234
|
+
forceKillTimer.unref();
|
|
235
|
+
const finishShutdown = () => {
|
|
236
|
+
clearTimeout(shutdownTimer);
|
|
237
|
+
clearTimeout(forceKillTimer);
|
|
238
|
+
process.exit(exitCode);
|
|
239
|
+
};
|
|
240
|
+
const disconnectCluster = () => {
|
|
241
|
+
cluster_1.default.disconnect(() => {
|
|
242
|
+
if (notifyWorkersBeforeDisconnect) {
|
|
243
|
+
waitForWorkerExits(workersAtShutdown, finishShutdown);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
finishShutdown();
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
if (notifyWorkersBeforeDisconnect) {
|
|
250
|
+
sendGracefulShutdownMessageToWorkers(workersAtShutdown);
|
|
251
|
+
setTimeout(disconnectCluster, MASTER_WORKER_SHUTDOWN_MESSAGE_GRACE_MS);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
disconnectCluster();
|
|
255
|
+
};
|
|
129
256
|
const abortForStartupFailure = () => {
|
|
130
257
|
if (!(isAbortingForStartupFailure && fatalStartupFailure))
|
|
131
258
|
return false;
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
// syscall, etc.), following the same pattern as restartWorkers.ts.
|
|
147
|
-
const MASTER_SHUTDOWN_TIMEOUT_MS = 5000;
|
|
148
|
-
const shutdownTimer = setTimeout(() => process.exit(1), MASTER_SHUTDOWN_TIMEOUT_MS);
|
|
149
|
-
if (typeof shutdownTimer.unref === 'function')
|
|
150
|
-
shutdownTimer.unref();
|
|
151
|
-
cluster_1.default.disconnect(() => {
|
|
152
|
-
clearTimeout(shutdownTimer);
|
|
153
|
-
process.exit(1);
|
|
154
|
-
});
|
|
259
|
+
if (hasReportedStartupFailure)
|
|
260
|
+
return true;
|
|
261
|
+
// Note: the exiting worker may differ from the one that sent the
|
|
262
|
+
// failure message if multiple workers exit in rapid succession.
|
|
263
|
+
// We always report the first failure received.
|
|
264
|
+
const { failure, workerId: failedWorkerId } = fatalStartupFailure;
|
|
265
|
+
const msg = failure.code === 'EADDRINUSE'
|
|
266
|
+
? `Node renderer startup failed: ${failure.host}:${failure.port} is already in use`
|
|
267
|
+
: `Node renderer startup failed in worker ${failedWorkerId}: ${failure.message}`;
|
|
268
|
+
errorReporter.message(msg);
|
|
269
|
+
hasReportedStartupFailure = true;
|
|
270
|
+
if (!isShuttingDown) {
|
|
271
|
+
// Exit non-zero so supervisors (Foreman/systemd/k8s) see the failed start.
|
|
272
|
+
shutdownGracefully(1);
|
|
155
273
|
}
|
|
156
274
|
return true;
|
|
157
275
|
};
|
|
@@ -163,17 +281,23 @@ function masterRun(runningConfig) {
|
|
|
163
281
|
isAbortingForStartupFailure = true;
|
|
164
282
|
fatalStartupFailure = { workerId: worker.id, failure: message };
|
|
165
283
|
});
|
|
166
|
-
for (let i = 0; i < workersCount; i += 1) {
|
|
167
|
-
cluster_1.default.fork();
|
|
168
|
-
}
|
|
169
284
|
// Listen for dying workers:
|
|
170
285
|
cluster_1.default.on('exit', (worker) => {
|
|
171
286
|
// Once a startup failure has been detected, abort regardless of whether
|
|
172
287
|
// this particular exit was from the failing worker, a scheduled restart,
|
|
173
|
-
// or an unrelated crash. Don't fork any more workers.
|
|
288
|
+
// or an unrelated crash. Don't fork any more workers. If an external signal
|
|
289
|
+
// already started shutdown, still report the recorded failure once while
|
|
290
|
+
// preserving the signal-style exit code and avoiding duplicate disconnects.
|
|
174
291
|
if (abortForStartupFailure()) {
|
|
175
292
|
return;
|
|
176
293
|
}
|
|
294
|
+
// If we are intentionally tearing the cluster down (external SIGTERM/SIGINT
|
|
295
|
+
// or a startup-failure abort), workers are expected to exit — never re-fork
|
|
296
|
+
// them, or we would resurrect the processes we are trying to drain and leave
|
|
297
|
+
// them orphaned after the master exits.
|
|
298
|
+
if (isShuttingDown) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
177
301
|
if (worker.isScheduledRestart) {
|
|
178
302
|
log_js_1.default.info('Restarting worker #%d on schedule', worker.id);
|
|
179
303
|
cluster_1.default.fork();
|
|
@@ -182,7 +306,9 @@ function masterRun(runningConfig) {
|
|
|
182
306
|
// Give in-flight startup-failure IPC messages one event-loop turn to be
|
|
183
307
|
// processed before classifying this as an ordinary runtime crash.
|
|
184
308
|
setImmediate(() => {
|
|
185
|
-
|
|
309
|
+
// A startup failure may have arrived during this event-loop turn; report
|
|
310
|
+
// it before any signal-shutdown bail-out suppresses crash classification.
|
|
311
|
+
if (abortForStartupFailure() || isShuttingDown)
|
|
186
312
|
return;
|
|
187
313
|
// TODO: Track last rendering request per worker.id
|
|
188
314
|
// TODO: Consider blocking a given rendering request if it kills a worker more than X times
|
|
@@ -191,6 +317,20 @@ function masterRun(runningConfig) {
|
|
|
191
317
|
cluster_1.default.fork();
|
|
192
318
|
});
|
|
193
319
|
});
|
|
320
|
+
// Drain workers on external shutdown signals. Process managers (Foreman,
|
|
321
|
+
// systemd, Kubernetes, Docker) send SIGTERM (and SIGINT on Ctrl-C in a
|
|
322
|
+
// foreground terminal) to the master. Without these handlers the master is
|
|
323
|
+
// killed immediately and its forked workers are left orphaned, holding their
|
|
324
|
+
// ports.
|
|
325
|
+
const handleShutdownSignal = (signal) => {
|
|
326
|
+
log_js_1.default.info('Received %s, draining workers before shutting down the node renderer master', signal);
|
|
327
|
+
shutdownGracefully(SIGNAL_EXIT_CODES[signal] ?? 0, true);
|
|
328
|
+
};
|
|
329
|
+
process.on('SIGTERM', () => handleShutdownSignal('SIGTERM'));
|
|
330
|
+
process.on('SIGINT', () => handleShutdownSignal('SIGINT'));
|
|
331
|
+
for (let i = 0; i < workersCount; i += 1) {
|
|
332
|
+
cluster_1.default.fork();
|
|
333
|
+
}
|
|
194
334
|
// Schedule regular restarts of workers
|
|
195
335
|
if (allWorkersRestartInterval && delayBetweenIndividualWorkerRestarts) {
|
|
196
336
|
log_js_1.default.info('Scheduled workers restarts every %d minutes (%d minutes btw each)', allWorkersRestartInterval, delayBetweenIndividualWorkerRestarts);
|
|
@@ -41,8 +41,20 @@ const isAtLeast = (actual, floor) => {
|
|
|
41
41
|
return true;
|
|
42
42
|
};
|
|
43
43
|
const sameTuple = (left, right) => left.every((value, index) => value === right[index]);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const supportedRscRange = ({ supportedMajor }, { supportedRanges }) => {
|
|
45
|
+
const supportedMinors = [...new Set(supportedRanges.map((range) => range.rscMinor))].sort((left, right) => left - right);
|
|
46
|
+
return supportedMinors.map((minor) => `${supportedMajor}.${minor}.x`).join(' or ');
|
|
47
|
+
};
|
|
48
|
+
const supportedReactRange = (rscTuple, { supportedMajor, supportedRanges }) => {
|
|
49
|
+
const rscMinor = rscTuple[1];
|
|
50
|
+
const matchingRanges = supportedRanges.filter((range) => range.rscMinor === rscMinor);
|
|
51
|
+
return matchingRanges
|
|
52
|
+
.map(({ minor, minPatch }) => `${supportedMajor}.${minor}.x with patch >= ${supportedMajor}.${minor}.${minPatch}`)
|
|
53
|
+
.join(' or ');
|
|
54
|
+
};
|
|
55
|
+
const isSupportedReactTuple = ([major, minor, patch], rscTuple, { supportedMajor, supportedRanges }) => major === supportedMajor &&
|
|
56
|
+
supportedRanges.some((range) => rscTuple[1] === range.rscMinor && minor === range.minor && patch >= range.minPatch);
|
|
57
|
+
const isSupportedRscMinor = (rscTuple, { supportedRanges }) => supportedRanges.some((range) => rscTuple[1] === range.rscMinor);
|
|
46
58
|
const proLabel = (proVersion) => proVersion ? `React on Rails Pro (${proVersion})` : 'React on Rails Pro';
|
|
47
59
|
const errorMessage = (pkg, found, want, proVersion) => [
|
|
48
60
|
`[ReactOnRails] Incompatible ${pkg} version.`,
|
|
@@ -70,24 +82,30 @@ function checkRscPeerCompatibility(input) {
|
|
|
70
82
|
message: errorMessage('react-on-rails-rsc', rscVersion, `${reactOnRailsRsc.supportedMajor}.x`, proVersion),
|
|
71
83
|
};
|
|
72
84
|
}
|
|
85
|
+
if (!isSupportedRscMinor(rscTuple, react)) {
|
|
86
|
+
return {
|
|
87
|
+
level: 'error',
|
|
88
|
+
message: errorMessage('react-on-rails-rsc', rscVersion, supportedRscRange(reactOnRailsRsc, react), proVersion),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
73
91
|
// If React is not resolvable (unusual, since RSC requires React), skip this check;
|
|
74
92
|
// an app with React truly absent will fail during normal module loading.
|
|
75
93
|
let reactTuple = null;
|
|
76
94
|
if (reactVersion) {
|
|
77
95
|
reactTuple = parseTuple(reactVersion);
|
|
78
|
-
if (!isSupportedReactTuple(reactTuple, react)) {
|
|
96
|
+
if (!isSupportedReactTuple(reactTuple, rscTuple, react)) {
|
|
79
97
|
return {
|
|
80
98
|
level: 'error',
|
|
81
|
-
message: errorMessage('react', reactVersion, supportedReactRange(react), proVersion),
|
|
99
|
+
message: errorMessage('react', reactVersion, supportedReactRange(rscTuple, react), proVersion),
|
|
82
100
|
};
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
103
|
if (reactDomVersion) {
|
|
86
104
|
const reactDomTuple = parseTuple(reactDomVersion);
|
|
87
|
-
if (!isSupportedReactTuple(reactDomTuple, react)) {
|
|
105
|
+
if (!isSupportedReactTuple(reactDomTuple, rscTuple, react)) {
|
|
88
106
|
return {
|
|
89
107
|
level: 'error',
|
|
90
|
-
message: errorMessage('react-dom', reactDomVersion, supportedReactRange(react), proVersion),
|
|
108
|
+
message: errorMessage('react-dom', reactDomVersion, supportedReactRange(rscTuple, react), proVersion),
|
|
91
109
|
};
|
|
92
110
|
}
|
|
93
111
|
if (reactTuple && !sameTuple(reactTuple, reactDomTuple)) {
|
|
@@ -25,6 +25,7 @@ export interface Config {
|
|
|
25
25
|
includeTimerPolyfills?: boolean;
|
|
26
26
|
replayServerAsyncOperationLogs: boolean;
|
|
27
27
|
maxVMPoolSize: number;
|
|
28
|
+
enableHealthEndpoints: boolean;
|
|
28
29
|
}
|
|
29
30
|
export declare function getConfig(): Config;
|
|
30
31
|
export declare function logSanitizedConfig(): void;
|
|
@@ -109,6 +109,9 @@ function defaultReplayServerAsyncOperationLogs() {
|
|
|
109
109
|
}
|
|
110
110
|
return env.NODE_ENV?.toLowerCase() === 'development';
|
|
111
111
|
}
|
|
112
|
+
function truthyHealthEndpointFlag(value) {
|
|
113
|
+
return value === '1' || (0, truthy_js_1.default)(value);
|
|
114
|
+
}
|
|
112
115
|
const defaultConfig = {
|
|
113
116
|
// Use env port if we run on Heroku
|
|
114
117
|
port: Number(env.RENDERER_PORT) || DEFAULT_PORT,
|
|
@@ -142,6 +145,8 @@ const defaultConfig = {
|
|
|
142
145
|
// Maximum number of VM contexts to keep in memory. Defaults to 2 since typically only two contexts
|
|
143
146
|
// are needed - one for the server bundle and one for React Server Components (RSC) if enabled.
|
|
144
147
|
maxVMPoolSize: (env.MAX_VM_POOL_SIZE && parseInt(env.MAX_VM_POOL_SIZE, 10)) || 2,
|
|
148
|
+
// Built-in /health and /ready probe endpoints are opt-in.
|
|
149
|
+
enableHealthEndpoints: truthyHealthEndpointFlag(env.RENDERER_ENABLE_HEALTH_ENDPOINTS),
|
|
145
150
|
};
|
|
146
151
|
function envValuesUsed() {
|
|
147
152
|
return {
|
|
@@ -163,6 +168,7 @@ function envValuesUsed() {
|
|
|
163
168
|
INCLUDE_TIMER_POLYFILLS: !('includeTimerPolyfills' in userConfig) && env.INCLUDE_TIMER_POLYFILLS,
|
|
164
169
|
REPLAY_SERVER_ASYNC_OPERATION_LOGS: !userConfig.replayServerAsyncOperationLogs && env.REPLAY_SERVER_ASYNC_OPERATION_LOGS,
|
|
165
170
|
MAX_VM_POOL_SIZE: !userConfig.maxVMPoolSize && env.MAX_VM_POOL_SIZE,
|
|
171
|
+
RENDERER_ENABLE_HEALTH_ENDPOINTS: !('enableHealthEndpoints' in userConfig) && env.RENDERER_ENABLE_HEALTH_ENDPOINTS,
|
|
166
172
|
};
|
|
167
173
|
}
|
|
168
174
|
function sanitizedSettings(aConfig, defaultValue) {
|
|
@@ -248,6 +254,7 @@ function buildConfig(providedUserConfig) {
|
|
|
248
254
|
password: env.RENDERER_PASSWORD,
|
|
249
255
|
// Re-evaluate env-derived defaults at build time in case env vars are set post-import.
|
|
250
256
|
replayServerAsyncOperationLogs: defaultReplayServerAsyncOperationLogs(),
|
|
257
|
+
enableHealthEndpoints: truthyHealthEndpointFlag(env.RENDERER_ENABLE_HEALTH_ENDPOINTS),
|
|
251
258
|
};
|
|
252
259
|
config = { ...runtimeDefaultConfig, ...userConfig };
|
|
253
260
|
if (explicitUndefinedPassword) {
|
|
@@ -269,6 +276,8 @@ function buildConfig(providedUserConfig) {
|
|
|
269
276
|
'Use RENDERER_SERVER_BUNDLE_CACHE_PATH instead.');
|
|
270
277
|
}
|
|
271
278
|
config.supportModules = (0, truthy_js_1.default)(config.supportModules);
|
|
279
|
+
// Coerce in case a user config passes an env-derived string (e.g. "true").
|
|
280
|
+
config.enableHealthEndpoints = truthyHealthEndpointFlag(config.enableHealthEndpoints);
|
|
272
281
|
if (config.maxVMPoolSize <= 0 || !Number.isInteger(config.maxVMPoolSize)) {
|
|
273
282
|
throw new Error('maxVMPoolSize must be a positive integer');
|
|
274
283
|
}
|
|
@@ -5,8 +5,15 @@ export declare const RSC_PEER_SUPPORT: {
|
|
|
5
5
|
};
|
|
6
6
|
readonly react: {
|
|
7
7
|
readonly supportedMajor: 19;
|
|
8
|
-
readonly
|
|
9
|
-
|
|
8
|
+
readonly supportedRanges: readonly [{
|
|
9
|
+
readonly rscMinor: 0;
|
|
10
|
+
readonly minor: 0;
|
|
11
|
+
readonly minPatch: 4;
|
|
12
|
+
}, {
|
|
13
|
+
readonly rscMinor: 2;
|
|
14
|
+
readonly minor: 2;
|
|
15
|
+
readonly minPatch: 7;
|
|
16
|
+
}];
|
|
10
17
|
};
|
|
11
18
|
};
|
|
12
19
|
//# sourceMappingURL=rscPeerSupport.d.ts.map
|
|
@@ -26,6 +26,13 @@ exports.RSC_PEER_SUPPORT = void 0;
|
|
|
26
26
|
// (the stable 19.0.5 ship/pin is tracked by issue #3634).
|
|
27
27
|
exports.RSC_PEER_SUPPORT = {
|
|
28
28
|
reactOnRailsRsc: { recommendedMin: '19.0.2', supportedMajor: 19 },
|
|
29
|
-
react: {
|
|
29
|
+
react: {
|
|
30
|
+
supportedMajor: 19,
|
|
31
|
+
supportedRanges: [
|
|
32
|
+
{ rscMinor: 0, minor: 0, minPatch: 4 },
|
|
33
|
+
// React 19.2.7 is the coordinated floor for react-on-rails-rsc 19.2.x.
|
|
34
|
+
{ rscMinor: 2, minor: 2, minPatch: 7 },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
30
37
|
};
|
|
31
38
|
//# sourceMappingURL=rscPeerSupport.js.map
|
package/lib/shared/utils.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { MoveOptions, CopyOptions } from 'fs-extra';
|
|
|
3
3
|
import { Readable, Writable, PassThrough } from 'stream';
|
|
4
4
|
import type { TracingContext } from './tracing.js';
|
|
5
5
|
import type { RenderResult } from '../worker/vm.js';
|
|
6
|
+
import { remapStackTrace } from '../worker/vmSourceMapSupport.js';
|
|
6
7
|
export declare const TRUNCATION_FILLER = "\n... TRUNCATED ...\n";
|
|
7
8
|
export declare const SHUTDOWN_WORKER_MESSAGE = "NODE_RENDERER_SHUTDOWN_WORKER";
|
|
8
9
|
export declare function workerIdLabel(): number | "NO WORKER ID";
|
|
@@ -10,6 +11,9 @@ export declare function smartTrim(value: unknown, maxLength?: number): string |
|
|
|
10
11
|
export interface ResponseResult {
|
|
11
12
|
headers: {
|
|
12
13
|
'Cache-Control'?: string;
|
|
14
|
+
'Content-Type'?: string;
|
|
15
|
+
'X-Content-Type-Options'?: string;
|
|
16
|
+
[key: string]: string | undefined;
|
|
13
17
|
};
|
|
14
18
|
status: number;
|
|
15
19
|
data?: unknown;
|
|
@@ -27,8 +31,10 @@ export type RequestInfo = {
|
|
|
27
31
|
* @param request Either a rendering request (auto-labeled) or a { label, content } pair
|
|
28
32
|
* @param error The error that was thrown (typed as `unknown` to minimize casts in `catch`)
|
|
29
33
|
* @param context Optional context to include in the error message
|
|
34
|
+
* @param stackRemapper Defaults to scanning registered source-map bundles. VM request paths pass a
|
|
35
|
+
* registration-scoped remapper to avoid rewriting unrelated bundle paths.
|
|
30
36
|
*/
|
|
31
|
-
export declare function formatExceptionMessage(request: RequestInfo, error: unknown, context?: string): string;
|
|
37
|
+
export declare function formatExceptionMessage(request: RequestInfo, error: unknown, context?: string, stackRemapper?: typeof remapStackTrace): string;
|
|
32
38
|
export declare function saveMultipartFile(multipartFile: MultipartFile, destinationPath: string): Promise<void>;
|
|
33
39
|
export interface Asset {
|
|
34
40
|
type: 'asset';
|
package/lib/shared/utils.js
CHANGED
|
@@ -74,6 +74,7 @@ const errorReporter = __importStar(require("./errorReporter.js"));
|
|
|
74
74
|
const configBuilder_js_1 = require("./configBuilder.js");
|
|
75
75
|
const log_js_1 = __importDefault(require("./log.js"));
|
|
76
76
|
const fileExistsAsync_js_1 = __importDefault(require("./fileExistsAsync.js"));
|
|
77
|
+
const vmSourceMapSupport_js_1 = require("../worker/vmSourceMapSupport.js");
|
|
77
78
|
exports.TRUNCATION_FILLER = '\n... TRUNCATED ...\n';
|
|
78
79
|
exports.SHUTDOWN_WORKER_MESSAGE = 'NODE_RENDERER_SHUTDOWN_WORKER';
|
|
79
80
|
function workerIdLabel() {
|
|
@@ -121,10 +122,14 @@ function errorResponseResult(msg, tracingContext) {
|
|
|
121
122
|
* @param request Either a rendering request (auto-labeled) or a { label, content } pair
|
|
122
123
|
* @param error The error that was thrown (typed as `unknown` to minimize casts in `catch`)
|
|
123
124
|
* @param context Optional context to include in the error message
|
|
125
|
+
* @param stackRemapper Defaults to scanning registered source-map bundles. VM request paths pass a
|
|
126
|
+
* registration-scoped remapper to avoid rewriting unrelated bundle paths.
|
|
124
127
|
*/
|
|
125
|
-
function formatExceptionMessage(request, error, context) {
|
|
128
|
+
function formatExceptionMessage(request, error, context, stackRemapper = vmSourceMapSupport_js_1.remapStackTrace) {
|
|
126
129
|
const label = 'renderingRequest' in request ? 'JS code for rendering request was:' : request.label;
|
|
127
130
|
const content = 'renderingRequest' in request ? request.renderingRequest : request.content;
|
|
131
|
+
const rawStack = error.stack;
|
|
132
|
+
const stack = stackRemapper(rawStack) ?? rawStack;
|
|
128
133
|
return `${context ? `\nContext:\n${context}\n` : ''}
|
|
129
134
|
${label}
|
|
130
135
|
${smartTrim(content)}
|
|
@@ -133,7 +138,7 @@ EXCEPTION MESSAGE:
|
|
|
133
138
|
${error.message || error}
|
|
134
139
|
|
|
135
140
|
STACK:
|
|
136
|
-
${
|
|
141
|
+
${stack}`;
|
|
137
142
|
}
|
|
138
143
|
// https://github.com/fastify/fastify-multipart?tab=readme-ov-file#usage
|
|
139
144
|
const pump = (0, util_1.promisify)(stream_1.pipeline);
|
|
@@ -205,13 +210,24 @@ const delay = (milliseconds) => new Promise((resolve) => {
|
|
|
205
210
|
setTimeout(resolve, milliseconds);
|
|
206
211
|
});
|
|
207
212
|
exports.delay = delay;
|
|
213
|
+
// Keep aligned with ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN.
|
|
214
|
+
const BUNDLE_TIMESTAMP_PATH_COMPONENT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
215
|
+
function bundleTimestampPathComponent(bundleTimestamp) {
|
|
216
|
+
const pathComponent = String(bundleTimestamp);
|
|
217
|
+
if (!BUNDLE_TIMESTAMP_PATH_COMPONENT_PATTERN.test(pathComponent)) {
|
|
218
|
+
throw new Error(`Invalid bundle timestamp path component: ${pathComponent}. ` +
|
|
219
|
+
'Expected only letters, digits, dots, underscores, and hyphens.');
|
|
220
|
+
}
|
|
221
|
+
return pathComponent;
|
|
222
|
+
}
|
|
208
223
|
function getBundleDirectory(bundleTimestamp) {
|
|
209
224
|
const { serverBundleCachePath } = (0, configBuilder_js_1.getConfig)();
|
|
210
|
-
return path_1.default.
|
|
225
|
+
return path_1.default.resolve(serverBundleCachePath, bundleTimestampPathComponent(bundleTimestamp));
|
|
211
226
|
}
|
|
212
227
|
function getRequestBundleFilePath(bundleTimestamp) {
|
|
213
|
-
const
|
|
214
|
-
|
|
228
|
+
const pathComponent = bundleTimestampPathComponent(bundleTimestamp);
|
|
229
|
+
const bundleDirectory = getBundleDirectory(pathComponent);
|
|
230
|
+
return path_1.default.join(bundleDirectory, `${pathComponent}.js`);
|
|
215
231
|
}
|
|
216
232
|
function getAssetPath(bundleTimestamp, filename) {
|
|
217
233
|
const bundleDirectory = getBundleDirectory(bundleTimestamp);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ResponseResult } from '../shared/utils';
|
|
2
|
+
import type { ExecutionContext } from './vm';
|
|
2
3
|
export type IncrementalRenderSink = {
|
|
3
4
|
/** Called for every subsequent NDJSON object after the first one */
|
|
4
5
|
add: (chunk: unknown) => Promise<void>;
|
|
5
|
-
handleRequestClosed: () => void
|
|
6
|
+
handleRequestClosed: () => Promise<void>;
|
|
7
|
+
executionContext: ExecutionContext;
|
|
6
8
|
};
|
|
7
9
|
export type UpdateChunk = {
|
|
8
10
|
bundleTimestamp: string | number;
|
|
@@ -102,6 +102,7 @@ async function handleIncrementalRenderRequest(initial) {
|
|
|
102
102
|
return {
|
|
103
103
|
response,
|
|
104
104
|
sink: {
|
|
105
|
+
executionContext,
|
|
105
106
|
add: async (chunk) => {
|
|
106
107
|
try {
|
|
107
108
|
assertIsUpdateChunk(chunk);
|
|
@@ -122,20 +123,24 @@ async function handleIncrementalRenderRequest(initial) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
},
|
|
125
|
-
handleRequestClosed: () => {
|
|
126
|
+
handleRequestClosed: async () => {
|
|
126
127
|
if (!onRequestClosedUpdateChunk) {
|
|
127
128
|
return;
|
|
128
129
|
}
|
|
129
130
|
const bundlePath = (0, utils_1.getRequestBundleFilePath)(onRequestClosedUpdateChunk.bundleTimestamp);
|
|
130
|
-
|
|
131
|
-
.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath)
|
|
132
|
-
|
|
131
|
+
try {
|
|
132
|
+
const result = await executionContext.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath);
|
|
133
|
+
if ((0, utils_1.isErrorRenderResult)(result)) {
|
|
134
|
+
throw new Error(result.exceptionMessage);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
133
138
|
log_1.default.error({
|
|
134
139
|
msg: 'Error running onRequestClosedUpdateChunk',
|
|
135
140
|
err,
|
|
136
141
|
onRequestClosedUpdateChunk,
|
|
137
142
|
});
|
|
138
|
-
}
|
|
143
|
+
}
|
|
139
144
|
},
|
|
140
145
|
},
|
|
141
146
|
};
|
package/lib/worker/vm.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ import type { Readable } from 'stream';
|
|
|
3
3
|
import type { ReactOnRails as ROR } from 'react-on-rails' with { 'resolution-mode': 'import' };
|
|
4
4
|
import type { Context } from 'vm';
|
|
5
5
|
import SharedConsoleHistory from '../shared/sharedConsoleHistory.js';
|
|
6
|
+
import { type BundleSourceMapRegistration } from './vmSourceMapSupport.js';
|
|
6
7
|
export interface VMContext {
|
|
7
8
|
context: Context;
|
|
8
9
|
sharedConsoleHistory: SharedConsoleHistory;
|
|
10
|
+
sourceMapRegistration: BundleSourceMapRegistration;
|
|
9
11
|
lastUsed: number;
|
|
10
12
|
}
|
|
11
13
|
/**
|
|
@@ -17,6 +19,19 @@ export declare function hasVMContextForBundle(bundlePath: string): boolean;
|
|
|
17
19
|
* Get a specific VM context by bundle path
|
|
18
20
|
*/
|
|
19
21
|
export declare function getVMContext(bundlePath: string): VMContext | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Whether this worker has at least one bundle compiled into a VM context.
|
|
24
|
+
* Used by the built-in /ready readiness endpoint: a worker with zero loaded
|
|
25
|
+
* bundles cannot serve render requests until a bundle is uploaded.
|
|
26
|
+
*
|
|
27
|
+
* This intentionally stays false while a bundle is still compiling in
|
|
28
|
+
* vmCreationPromises; /ready flips to 200 only after compilation finishes and
|
|
29
|
+
* the compiled context is stored in vmContexts.
|
|
30
|
+
*
|
|
31
|
+
* Pool eviction can remove older bundle contexts, but readiness remains true as
|
|
32
|
+
* long as at least one compiled bundle remains in the pool.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasAnyVMContext(): boolean;
|
|
20
35
|
/**
|
|
21
36
|
* The type of the result returned by executing the code payload sent in the rendering request.
|
|
22
37
|
*/
|
|
@@ -39,6 +54,7 @@ export declare class VMContextNotFoundError extends Error {
|
|
|
39
54
|
export type ExecutionContext = {
|
|
40
55
|
runInVM: (renderingRequest: string, bundleFilePath: string, vmCluster?: typeof cluster) => Promise<RenderResult>;
|
|
41
56
|
getVMContext: (bundleFilePath: string) => VMContext | undefined;
|
|
57
|
+
release: () => void;
|
|
42
58
|
};
|
|
43
59
|
/**
|
|
44
60
|
* Builds an ExecutionContext that manages VM execution for a set of bundles.
|