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/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 setResponse(result.response, res);
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
- await setResponse(response, res);
399
- },
400
- onRequestEnded: () => {
401
- if (!incrementalSink) {
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
- incrementalSink.handleRequestClosed();
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
- if (incrementalSink) {
425
- incrementalSink.handleRequestClosed();
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
- await setResponse(errorResponse, res);
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
- (0, fastifyConfig_js_1.applyFastifyConfigFunctions)(app);
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.2",
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.2"
80
+ "react-on-rails": "17.0.0-rc.4"
78
81
  },
79
82
  "peerDependencies": {
80
83
  "@honeybadger-io/js": ">=4.0.0",