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/worker.js
CHANGED
|
@@ -51,6 +51,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
51
51
|
};
|
|
52
52
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
53
|
exports.disableHttp2 = exports.configureFastify = void 0;
|
|
54
|
+
exports.releaseExecutionContextWhenStreamFinishes = releaseExecutionContextWhenStreamFinishes;
|
|
54
55
|
exports.default = run;
|
|
55
56
|
/**
|
|
56
57
|
* Entry point for worker process that handles requests.
|
|
@@ -60,6 +61,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
60
61
|
const cluster_1 = __importDefault(require("cluster"));
|
|
61
62
|
const crypto_1 = require("crypto");
|
|
62
63
|
const promises_1 = require("fs/promises");
|
|
64
|
+
const stream_1 = require("stream");
|
|
63
65
|
const fastify_1 = __importDefault(require("fastify"));
|
|
64
66
|
const formbody_1 = __importDefault(require("@fastify/formbody"));
|
|
65
67
|
const multipart_1 = __importDefault(require("@fastify/multipart"));
|
|
@@ -80,17 +82,52 @@ const constants_js_1 = require("./shared/constants.js");
|
|
|
80
82
|
const utils_js_1 = require("./shared/utils.js");
|
|
81
83
|
const tracing_js_1 = require("./shared/tracing.js");
|
|
82
84
|
const fastifyConfig_js_1 = require("./worker/fastifyConfig.js");
|
|
85
|
+
const vm_js_1 = require("./worker/vm.js");
|
|
83
86
|
var fastifyConfig_js_2 = require("./worker/fastifyConfig.js");
|
|
84
87
|
Object.defineProperty(exports, "configureFastify", { enumerable: true, get: function () { return fastifyConfig_js_2.configureFastify; } });
|
|
88
|
+
const INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS = 1000;
|
|
89
|
+
// Standard response streams use this only to release retained renderer context;
|
|
90
|
+
// they are not aborted. Incremental responses may still close after request EOF.
|
|
91
|
+
// These release/finish windows are intentionally equal so both stream paths
|
|
92
|
+
// retain VM source-map registrations for the same idle period.
|
|
93
|
+
const STREAM_CONTEXT_RELEASE_TIMEOUT_MS = constants_js_1.STREAM_CHUNK_TIMEOUT_MS;
|
|
94
|
+
const INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS = STREAM_CONTEXT_RELEASE_TIMEOUT_MS;
|
|
95
|
+
const HEALTH_ENDPOINT_ROUTES = ['/health', '/ready'];
|
|
96
|
+
// TODO: Reassess the duplicated-route message format when upgrading Fastify.
|
|
97
|
+
const FASTIFY_DUPLICATED_ROUTE_ERROR_CODE = 'FST_ERR_DUPLICATED_ROUTE';
|
|
98
|
+
const READY_RETRY_AFTER_SECONDS = 5;
|
|
85
99
|
function setHeaders(headers, res) {
|
|
86
100
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- fixing it with `void` just violates no-void
|
|
87
101
|
Object.entries(headers).forEach(([key, header]) => res.header(key, header));
|
|
88
102
|
}
|
|
103
|
+
function hasHeader(headers, headerName) {
|
|
104
|
+
const lowerHeaderName = headerName.toLowerCase();
|
|
105
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === lowerHeaderName);
|
|
106
|
+
}
|
|
107
|
+
function setStringResponseHeaders(headers, res) {
|
|
108
|
+
if (!hasHeader(headers, 'Content-Type')) {
|
|
109
|
+
res.type('text/plain; charset=utf-8');
|
|
110
|
+
}
|
|
111
|
+
if (!hasHeader(headers, 'X-Content-Type-Options')) {
|
|
112
|
+
res.header('X-Content-Type-Options', 'nosniff');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
89
115
|
const setResponse = async (result, res) => {
|
|
90
116
|
const { status, data, headers, stream } = result;
|
|
91
117
|
if (status !== 200 && status !== 410) {
|
|
92
118
|
log_js_1.default.info({ msg: 'Sending non-200, non-410 data back', data });
|
|
93
119
|
}
|
|
120
|
+
if (!stream && typeof data === 'string' && status >= 400) {
|
|
121
|
+
setHeaders(headers, res);
|
|
122
|
+
res.header('Content-Type', 'text/plain; charset=utf-8');
|
|
123
|
+
res.header('X-Content-Type-Options', 'nosniff');
|
|
124
|
+
res.status(status);
|
|
125
|
+
res.send(data);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!stream && typeof data === 'string') {
|
|
129
|
+
setStringResponseHeaders(headers, res);
|
|
130
|
+
}
|
|
94
131
|
setHeaders(headers, res);
|
|
95
132
|
res.status(status);
|
|
96
133
|
if (stream) {
|
|
@@ -100,7 +137,142 @@ const setResponse = async (result, res) => {
|
|
|
100
137
|
res.send(data);
|
|
101
138
|
}
|
|
102
139
|
};
|
|
140
|
+
function runWhenStreamFinishes(stream, res, onFinished) {
|
|
141
|
+
let finished = false;
|
|
142
|
+
const finish = () => {
|
|
143
|
+
if (finished) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
finished = true;
|
|
147
|
+
stream.off('close', finish);
|
|
148
|
+
stream.off('end', finish);
|
|
149
|
+
stream.off('error', finish);
|
|
150
|
+
res.raw.off('close', finish);
|
|
151
|
+
onFinished();
|
|
152
|
+
};
|
|
153
|
+
stream.once('close', finish);
|
|
154
|
+
stream.once('end', finish);
|
|
155
|
+
stream.once('error', finish);
|
|
156
|
+
res.raw.once('close', finish);
|
|
157
|
+
return finish;
|
|
158
|
+
}
|
|
159
|
+
/** @internal Used in tests */
|
|
160
|
+
function releaseExecutionContextWhenStreamFinishes(stream, res, executionContext) {
|
|
161
|
+
let timeoutId;
|
|
162
|
+
let executionContextReleased = false;
|
|
163
|
+
const clearResponseFinishTimeout = () => {
|
|
164
|
+
if (timeoutId) {
|
|
165
|
+
clearTimeout(timeoutId);
|
|
166
|
+
timeoutId = undefined;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const releaseExecutionContext = () => {
|
|
170
|
+
if (executionContextReleased) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
executionContextReleased = true;
|
|
174
|
+
clearResponseFinishTimeout();
|
|
175
|
+
executionContext.release();
|
|
176
|
+
};
|
|
177
|
+
const refreshResponseFinishTimeout = () => {
|
|
178
|
+
if (executionContextReleased) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const timeoutMs = STREAM_CONTEXT_RELEASE_TIMEOUT_MS;
|
|
182
|
+
clearResponseFinishTimeout();
|
|
183
|
+
timeoutId = setTimeout(() => {
|
|
184
|
+
timeoutId = undefined;
|
|
185
|
+
log_js_1.default.warn({
|
|
186
|
+
msg: 'Timed out waiting for render response stream to finish; releasing retained execution context',
|
|
187
|
+
timeoutMs,
|
|
188
|
+
});
|
|
189
|
+
releaseExecutionContext();
|
|
190
|
+
}, timeoutMs);
|
|
191
|
+
};
|
|
192
|
+
const progressStream = new stream_1.Transform({
|
|
193
|
+
transform(chunk, encoding, callback) {
|
|
194
|
+
refreshResponseFinishTimeout();
|
|
195
|
+
callback(null, chunk);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const forwardSourceError = (error) => progressStream.destroy(error);
|
|
199
|
+
const endProgressStream = () => {
|
|
200
|
+
if (!progressStream.writableEnded && !progressStream.destroyed) {
|
|
201
|
+
progressStream.end();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const release = runWhenStreamFinishes(progressStream, res, () => {
|
|
205
|
+
stream.off('close', endProgressStream);
|
|
206
|
+
stream.off('error', forwardSourceError);
|
|
207
|
+
releaseExecutionContext();
|
|
208
|
+
});
|
|
209
|
+
stream.once('close', endProgressStream);
|
|
210
|
+
stream.once('error', forwardSourceError);
|
|
211
|
+
stream.pipe(progressStream);
|
|
212
|
+
refreshResponseFinishTimeout();
|
|
213
|
+
return {
|
|
214
|
+
release,
|
|
215
|
+
stream: progressStream,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const setResponseAndReleaseExecutionContext = async (result, res, executionContext) => {
|
|
219
|
+
if (!executionContext) {
|
|
220
|
+
await setResponse(result, res);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (result.stream) {
|
|
224
|
+
const releaseHandle = releaseExecutionContextWhenStreamFinishes(result.stream, res, executionContext);
|
|
225
|
+
try {
|
|
226
|
+
await setResponse({ ...result, stream: releaseHandle.stream }, res);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
releaseHandle.release();
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
await setResponse(result, res);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
executionContext.release();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
103
241
|
const isAsset = (value) => value.type === 'asset';
|
|
242
|
+
function conflictingHealthEndpointPath(error) {
|
|
243
|
+
if (typeof error !== 'object' || error === null) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
const { code, message } = error;
|
|
247
|
+
if (code !== FASTIFY_DUPLICATED_ROUTE_ERROR_CODE || typeof message !== 'string') {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
// Format-dependent: Fastify's FST_ERR_DUPLICATED_ROUTE message currently
|
|
251
|
+
// includes `route '/health'` or `route '/ready'`. If that wording changes,
|
|
252
|
+
// this safely returns undefined and the caller rethrows the raw Fastify error,
|
|
253
|
+
// losing only the migration hint; verify this when upgrading Fastify.
|
|
254
|
+
return HEALTH_ENDPOINT_ROUTES.find((routePath) => message.includes(`route '${routePath}'`));
|
|
255
|
+
}
|
|
256
|
+
function applyFastifyConfigWithHealthEndpointMigrationHint(app, enableHealthEndpoints) {
|
|
257
|
+
// This wraps synchronous configureFastify route registration only. Async
|
|
258
|
+
// Fastify plugins still surface Fastify's duplicate-route error during boot.
|
|
259
|
+
try {
|
|
260
|
+
(0, fastifyConfig_js_1.applyFastifyConfigFunctions)(app);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
const conflictingPath = enableHealthEndpoints ? conflictingHealthEndpointPath(error) : undefined;
|
|
264
|
+
if (conflictingPath) {
|
|
265
|
+
const message = `enableHealthEndpoints registers built-in GET ${conflictingPath} before configureFastify callbacks run, ` +
|
|
266
|
+
`and a configureFastify callback also tried to register that route. Remove or rename the custom ${conflictingPath} route when migrating ` +
|
|
267
|
+
'to the built-in health endpoints. See docs/oss/building-features/node-renderer/health-checks.md.';
|
|
268
|
+
log_js_1.default.error({ err: error, route: conflictingPath }, message);
|
|
269
|
+
const migrationError = new Error(message);
|
|
270
|
+
migrationError.cause = error;
|
|
271
|
+
throw migrationError;
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
104
276
|
function assertAsset(value, key) {
|
|
105
277
|
if (!isAsset(value)) {
|
|
106
278
|
throw new Error(`React On Rails Error: Expected an asset for key: ${key}`);
|
|
@@ -188,7 +360,7 @@ function run(config) {
|
|
|
188
360
|
// Store config in app state. From now it can be loaded by any module using
|
|
189
361
|
// getConfig():
|
|
190
362
|
(0, configBuilder_js_1.buildConfig)(config);
|
|
191
|
-
const { serverBundleCachePath, logHttpLevel, port, host, fastifyServerOptions, workersCount } = (0, configBuilder_js_1.getConfig)();
|
|
363
|
+
const { serverBundleCachePath, logHttpLevel, port, host, fastifyServerOptions, workersCount, enableHealthEndpoints, } = (0, configBuilder_js_1.getConfig)();
|
|
192
364
|
// The renderer uses cleartext HTTP/2 (h2c). Node's `allowHTTP1` option only
|
|
193
365
|
// applies to TLS servers (http2.createSecureServer), so it cannot enable
|
|
194
366
|
// HTTP/1.1 Kubernetes httpGet probes on this listener.
|
|
@@ -318,7 +490,7 @@ function run(config) {
|
|
|
318
490
|
assetsToCopy,
|
|
319
491
|
tracingContext: context,
|
|
320
492
|
});
|
|
321
|
-
await
|
|
493
|
+
await setResponseAndReleaseExecutionContext(result.response, res, result.executionContext);
|
|
322
494
|
}
|
|
323
495
|
catch (err) {
|
|
324
496
|
const exceptionMessage = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, err, 'UNHANDLED error in handleRenderRequest');
|
|
@@ -339,7 +511,93 @@ function run(config) {
|
|
|
339
511
|
// Track whether we've already started sending a response (streaming or otherwise)
|
|
340
512
|
// If true, we can't send an error response on failure - headers are already sent
|
|
341
513
|
let responseStarted = false;
|
|
514
|
+
let incrementalRequestClosed = false;
|
|
515
|
+
let incrementalResponseFinished = false;
|
|
516
|
+
let incrementalExecutionContextReleased = false;
|
|
517
|
+
let incrementalResponseFinishTimeoutId;
|
|
342
518
|
let incrementalTracingContext;
|
|
519
|
+
const clearIncrementalResponseFinishTimeout = () => {
|
|
520
|
+
if (!incrementalResponseFinishTimeoutId) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
clearTimeout(incrementalResponseFinishTimeoutId);
|
|
524
|
+
incrementalResponseFinishTimeoutId = undefined;
|
|
525
|
+
};
|
|
526
|
+
const releaseIncrementalExecutionContextWhenDone = () => {
|
|
527
|
+
if (incrementalExecutionContextReleased ||
|
|
528
|
+
!incrementalRequestClosed ||
|
|
529
|
+
!incrementalResponseFinished ||
|
|
530
|
+
!incrementalSink) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
clearIncrementalResponseFinishTimeout();
|
|
534
|
+
incrementalExecutionContextReleased = true;
|
|
535
|
+
incrementalSink.executionContext.release();
|
|
536
|
+
};
|
|
537
|
+
const markIncrementalResponseFinished = () => {
|
|
538
|
+
clearIncrementalResponseFinishTimeout();
|
|
539
|
+
incrementalResponseFinished = true;
|
|
540
|
+
releaseIncrementalExecutionContextWhenDone();
|
|
541
|
+
};
|
|
542
|
+
const scheduleIncrementalResponseFinishTimeout = () => {
|
|
543
|
+
if (incrementalResponseFinishTimeoutId ||
|
|
544
|
+
incrementalResponseFinished ||
|
|
545
|
+
incrementalExecutionContextReleased ||
|
|
546
|
+
!incrementalSink) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
incrementalResponseFinishTimeoutId = setTimeout(() => {
|
|
550
|
+
incrementalResponseFinishTimeoutId = undefined;
|
|
551
|
+
if (incrementalResponseFinished || incrementalExecutionContextReleased) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
log_js_1.default.warn({
|
|
555
|
+
msg: 'Timed out waiting for incremental render response stream to finish after request closed',
|
|
556
|
+
timeoutMs: INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS,
|
|
557
|
+
});
|
|
558
|
+
if (!res.raw.destroyed) {
|
|
559
|
+
res.raw.destroy();
|
|
560
|
+
}
|
|
561
|
+
markIncrementalResponseFinished();
|
|
562
|
+
}, INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS);
|
|
563
|
+
};
|
|
564
|
+
const waitForIncrementalRequestClose = async (closeRequestPromise, timeoutMs) => {
|
|
565
|
+
let timeoutId;
|
|
566
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
567
|
+
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
568
|
+
});
|
|
569
|
+
const result = await Promise.race([closeRequestPromise.then(() => 'closed'), timeoutPromise]);
|
|
570
|
+
if (timeoutId) {
|
|
571
|
+
clearTimeout(timeoutId);
|
|
572
|
+
}
|
|
573
|
+
if (result === 'timeout') {
|
|
574
|
+
log_js_1.default.warn({
|
|
575
|
+
msg: 'Timed out waiting for incremental render close hook after response started',
|
|
576
|
+
timeoutMs,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
const closeIncrementalRequest = async ({ timeoutMs = INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS, } = {}) => {
|
|
581
|
+
if (!incrementalSink || incrementalRequestClosed) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const sink = incrementalSink;
|
|
585
|
+
try {
|
|
586
|
+
await waitForIncrementalRequestClose(Promise.resolve()
|
|
587
|
+
.then(() => sink.handleRequestClosed())
|
|
588
|
+
.catch((closeError) => {
|
|
589
|
+
log_js_1.default.error({
|
|
590
|
+
msg: 'Error while closing incremental render request after response started',
|
|
591
|
+
error: closeError,
|
|
592
|
+
});
|
|
593
|
+
}), timeoutMs);
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
incrementalRequestClosed = true;
|
|
597
|
+
scheduleIncrementalResponseFinishTimeout();
|
|
598
|
+
releaseIncrementalExecutionContextWhenDone();
|
|
599
|
+
}
|
|
600
|
+
};
|
|
343
601
|
try {
|
|
344
602
|
await (0, tracing_js_1.trace)(async (context) => {
|
|
345
603
|
incrementalTracingContext = context;
|
|
@@ -395,13 +653,26 @@ function run(config) {
|
|
|
395
653
|
},
|
|
396
654
|
onResponseStart: async (response) => {
|
|
397
655
|
responseStarted = true;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
656
|
+
if (response.stream) {
|
|
657
|
+
const markFinished = runWhenStreamFinishes(response.stream, res, markIncrementalResponseFinished);
|
|
658
|
+
try {
|
|
659
|
+
await setResponse(response, res);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
markFinished();
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
402
665
|
return;
|
|
403
666
|
}
|
|
404
|
-
|
|
667
|
+
try {
|
|
668
|
+
await setResponse(response, res);
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
markIncrementalResponseFinished();
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
onRequestEnded: async () => {
|
|
675
|
+
await closeIncrementalRequest();
|
|
405
676
|
},
|
|
406
677
|
});
|
|
407
678
|
}, (0, tracing_js_1.startSsrRequestOptions)({ renderingRequest: 'ReactOnRails.incrementalRender' }));
|
|
@@ -421,9 +692,9 @@ function run(config) {
|
|
|
421
692
|
// The React stream is waiting for async props (e.g., asyncPropsManager.getProp("researches")).
|
|
422
693
|
// If we don't call endStream(), the stream will hang forever waiting for props that will never arrive.
|
|
423
694
|
// This causes onResponse to never fire, leaving activeRequestsCount stuck and preventing worker shutdown.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
695
|
+
const closeRequestPromise = closeIncrementalRequest({
|
|
696
|
+
timeoutMs: INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS,
|
|
697
|
+
});
|
|
427
698
|
// CRITICAL: Destroy the response connection to immediately close it.
|
|
428
699
|
// Without this, the response stream stays open waiting for the client (httpx) to timeout,
|
|
429
700
|
// which can take 30+ seconds. This delays worker shutdown during graceful termination.
|
|
@@ -431,11 +702,21 @@ function run(config) {
|
|
|
431
702
|
if (!res.raw.destroyed) {
|
|
432
703
|
res.raw.destroy();
|
|
433
704
|
}
|
|
705
|
+
await closeRequestPromise;
|
|
434
706
|
}
|
|
435
707
|
else {
|
|
436
708
|
// Response hasn't started yet, we can send an error response
|
|
709
|
+
const closeRequestPromise = closeIncrementalRequest({
|
|
710
|
+
timeoutMs: INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS,
|
|
711
|
+
});
|
|
437
712
|
const errorResponse = (0, utils_js_1.errorResponseResult)(errorMessage, incrementalTracingContext);
|
|
438
|
-
|
|
713
|
+
try {
|
|
714
|
+
await setResponse(errorResponse, res);
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
markIncrementalResponseFinished();
|
|
718
|
+
await closeRequestPromise;
|
|
719
|
+
}
|
|
439
720
|
}
|
|
440
721
|
}
|
|
441
722
|
});
|
|
@@ -540,6 +821,49 @@ function run(config) {
|
|
|
540
821
|
renderer_version: packageJson_js_1.default.version,
|
|
541
822
|
});
|
|
542
823
|
});
|
|
824
|
+
// Built-in, opt-in probe endpoints (enableHealthEndpoints config option).
|
|
825
|
+
// Like /info, they are plain GET routes outside the authenticated render and
|
|
826
|
+
// asset endpoints: orchestrator probes cannot carry the renderer password.
|
|
827
|
+
// Both intentionally return status-only bodies — no versions, paths, or
|
|
828
|
+
// license details — so leaving them reachable exposes nothing sensitive.
|
|
829
|
+
// NOTE: this listener speaks cleartext HTTP/2 (h2c), so HTTP/1.1-only probes
|
|
830
|
+
// (e.g. Kubernetes httpGet) cannot reach these routes. Use tcpSocket or exec
|
|
831
|
+
// probes (`curl --http2-prior-knowledge`). See
|
|
832
|
+
// docs/oss/building-features/node-renderer/health-checks.md.
|
|
833
|
+
if (enableHealthEndpoints) {
|
|
834
|
+
// Liveness: 200 whenever this process can answer — i.e. the event loop is
|
|
835
|
+
// responsive. Intentionally checks no dependencies (no bundle, Rails, or
|
|
836
|
+
// license state) so a transient dependency issue never restarts the pod.
|
|
837
|
+
// Safe from a rate-limiting perspective (CodeQL js/missing-rate-limiting):
|
|
838
|
+
// this is an internal renderer service not exposed to the internet, returns
|
|
839
|
+
// a static status string, and exposes no sensitive runtime data.
|
|
840
|
+
// codeql[js/missing-rate-limiting]
|
|
841
|
+
// lgtm[js/missing-rate-limiting]
|
|
842
|
+
app.get('/health', (_req, res) => {
|
|
843
|
+
res.send({ status: 'ok' });
|
|
844
|
+
});
|
|
845
|
+
// Readiness: 200 only when this process can actually serve render requests.
|
|
846
|
+
// Answering at all proves the worker is online; additionally require at
|
|
847
|
+
// least one bundle compiled into the VM pool, because a renderer with zero
|
|
848
|
+
// bundles responds 410 to renders until the Rails client uploads one.
|
|
849
|
+
// With workersCount > 1 the cluster module distributes probe connections
|
|
850
|
+
// across workers, so a probe checks one worker per request.
|
|
851
|
+
// Safe from a rate-limiting perspective (CodeQL js/missing-rate-limiting):
|
|
852
|
+
// same rationale as /health; this returns only a static readiness status.
|
|
853
|
+
// codeql[js/missing-rate-limiting]
|
|
854
|
+
// lgtm[js/missing-rate-limiting]
|
|
855
|
+
app.get('/ready', (_req, res) => {
|
|
856
|
+
if ((0, vm_js_1.hasAnyVMContext)()) {
|
|
857
|
+
res.send({ status: 'ready' });
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
res
|
|
861
|
+
.status(503)
|
|
862
|
+
.header('Retry-After', String(READY_RETRY_AFTER_SECONDS))
|
|
863
|
+
.send({ status: 'waiting_for_bundle' });
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
}
|
|
543
867
|
// In tests we will run worker in master thread, so we need to ensure server
|
|
544
868
|
// will not listen:
|
|
545
869
|
// we are extracting worker from cluster to avoid false TS error
|
|
@@ -556,7 +880,7 @@ function run(config) {
|
|
|
556
880
|
}
|
|
557
881
|
// Integration hooks registered before the worker loads are applied here, immediately after
|
|
558
882
|
// listen() is scheduled and before Fastify finishes booting.
|
|
559
|
-
(
|
|
883
|
+
applyFastifyConfigWithHealthEndpointMigrationHint(app, enableHealthEndpoints);
|
|
560
884
|
return app;
|
|
561
885
|
}
|
|
562
886
|
//# sourceMappingURL=worker.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-on-rails-pro-node-renderer",
|
|
3
|
-
"version": "17.0.0-rc.
|
|
3
|
+
"version": "17.0.0-rc.4",
|
|
4
4
|
"protocolVersion": "2.0.0",
|
|
5
5
|
"description": "React on Rails Pro Node Renderer for server-side rendering",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.19.0"
|
|
8
|
+
},
|
|
6
9
|
"main": "lib/ReactOnRailsProNodeRenderer.js",
|
|
7
10
|
"directories": {
|
|
8
11
|
"doc": "docs"
|
|
@@ -74,7 +77,7 @@
|
|
|
74
77
|
"sentry-testkit": "^5.0.6",
|
|
75
78
|
"touch": "^3.1.0",
|
|
76
79
|
"typescript": "^5.4.3",
|
|
77
|
-
"react-on-rails": "17.0.0-rc.
|
|
80
|
+
"react-on-rails": "17.0.0-rc.4"
|
|
78
81
|
},
|
|
79
82
|
"peerDependencies": {
|
|
80
83
|
"@honeybadger-io/js": ">=4.0.0",
|