react-on-rails-pro-node-renderer 17.0.0-rc.6 → 17.0.0-rc.8

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.
@@ -22,10 +22,17 @@ function formatControlMessageChunk(metadata) {
22
22
  const header = `${serializedMetadata}\t${'0'.padStart(8, '0')}\n`;
23
23
  return Buffer.from(header);
24
24
  }
25
+ // Control-message type strings: the TS end of the streaming wire protocol. The guarded
26
+ // region below wraps only this single-line declaration (mirroring the Ruby single-line
27
+ // CONTROL_MESSAGE_TYPES arrays) so unrelated literals in the functions cannot leak in.
28
+ // MIRROR VALUES OF: react_on_rails/lib/react_on_rails/length_prefixed_parser.rb
29
+ // MIRROR VALUES OF: react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
30
+ const CONTROL_MESSAGE_TYPES = { propRequest: 'propRequest', renderComplete: 'renderComplete' };
31
+ // MIRROR VALUES END
25
32
  function formatPropRequestChunk(propName) {
26
- return formatControlMessageChunk({ messageType: 'propRequest', propName });
33
+ return formatControlMessageChunk({ messageType: CONTROL_MESSAGE_TYPES.propRequest, propName });
27
34
  }
28
35
  function formatRenderCompleteChunk() {
29
- return formatControlMessageChunk({ messageType: 'renderComplete' });
36
+ return formatControlMessageChunk({ messageType: CONTROL_MESSAGE_TYPES.renderComplete });
30
37
  }
31
38
  //# sourceMappingURL=streamingUtils.js.map
package/lib/worker.d.ts CHANGED
@@ -12,6 +12,7 @@ declare module '@fastify/multipart' {
12
12
  declare module 'fastify' {
13
13
  interface FastifyRequest {
14
14
  uploadDir: string;
15
+ uploadAssetValidationError: string | null;
15
16
  }
16
17
  }
17
18
  /** @internal Used in tests */
package/lib/worker.js CHANGED
@@ -85,13 +85,16 @@ const fastifyConfig_js_1 = require("./worker/fastifyConfig.js");
85
85
  const vm_js_1 = require("./worker/vm.js");
86
86
  var fastifyConfig_js_2 = require("./worker/fastifyConfig.js");
87
87
  Object.defineProperty(exports, "configureFastify", { enumerable: true, get: function () { return fastifyConfig_js_2.configureFastify; } });
88
- const INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS = 1000;
88
+ const INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS = 1_000;
89
89
  // Standard response streams use this only to release retained renderer context;
90
90
  // they are not aborted. Incremental responses may still close after request EOF.
91
91
  // These release/finish windows are intentionally equal so both stream paths
92
92
  // retain VM source-map registrations for the same idle period.
93
93
  const STREAM_CONTEXT_RELEASE_TIMEOUT_MS = constants_js_1.STREAM_CHUNK_TIMEOUT_MS;
94
94
  const INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS = STREAM_CONTEXT_RELEASE_TIMEOUT_MS;
95
+ // Pull-mode clients can legitimately pause longer than the normal chunk window;
96
+ // this coarse deadman only bounds abandoned request/response pairs.
97
+ const INCREMENTAL_PULL_MODE_IDLE_TIMEOUT_MS = constants_js_1.STREAM_CHUNK_TIMEOUT_MS * 15;
95
98
  const HEALTH_ENDPOINT_ROUTES = ['/health', '/ready'];
96
99
  // TODO: Reassess the duplicated-route message format when upgrading Fastify.
97
100
  const FASTIFY_DUPLICATED_ROUTE_ERROR_CODE = 'FST_ERR_DUPLICATED_ROUTE';
@@ -262,7 +265,11 @@ const setResponseAndReleaseExecutionContext = async (result, res, executionConte
262
265
  executionContext.release();
263
266
  }
264
267
  };
265
- const isAsset = (value) => value.type === 'asset';
268
+ const isAsset = (value) => typeof value === 'object' &&
269
+ value !== null &&
270
+ value.type === 'asset' &&
271
+ typeof value.savedFilePath === 'string' &&
272
+ typeof value.filename === 'string';
266
273
  function conflictingHealthEndpointPath(error) {
267
274
  if (typeof error !== 'object' || error === null) {
268
275
  return undefined;
@@ -348,6 +355,17 @@ const errorCode = (error) => {
348
355
  return typeof code === 'string' ? code : undefined;
349
356
  };
350
357
  const isValidRenderingRequest = (value) => typeof value === 'string' && value.length > 0;
358
+ const isBooleanField = (value) => typeof value === 'boolean' || value === 'true' || value === 'false';
359
+ const parseOptionalBooleanField = (body, key) => {
360
+ const value = body[key];
361
+ if (value === undefined || value === null) {
362
+ return undefined;
363
+ }
364
+ if (!isBooleanField(value)) {
365
+ return undefined;
366
+ }
367
+ return value === true || value === 'true';
368
+ };
351
369
  const invalidRenderingRequestMessage = (body) => {
352
370
  const { renderingRequest } = body;
353
371
  let renderingRequestType = typeof renderingRequest;
@@ -379,6 +397,15 @@ const extractBodyArrayField = (body, key) => {
379
397
  }
380
398
  return undefined;
381
399
  };
400
+ function discardMultipartFile(part) {
401
+ part.file.resume();
402
+ // eslint-disable-next-line no-param-reassign
403
+ part.value = {
404
+ filename: part.filename,
405
+ savedFilePath: '',
406
+ type: 'asset',
407
+ };
408
+ }
382
409
  function run(config) {
383
410
  (0, runRscPeerCompatibilityCheck_js_1.runRscPeerCompatibilityCheck)({ proVersion: packageJson_js_1.default.version });
384
411
  // Store config in app state. From now it can be loaded by any module using
@@ -416,6 +443,7 @@ function run(config) {
416
443
  // from overwriting each other's files (GitHub issue #2449).
417
444
  // The directory path is lazily assigned in onFile (only for requests with file uploads).
418
445
  app.decorateRequest('uploadDir', '');
446
+ app.decorateRequest('uploadAssetValidationError', null);
419
447
  // Clean up the per-request upload directory after the response is sent.
420
448
  // Safe from a rate-limiting perspective (CodeQL js/missing-rate-limiting):
421
449
  // this is an internal service not exposed to the internet, the path is
@@ -433,6 +461,7 @@ function run(config) {
433
461
  // Supports multipart/form-data
434
462
  void app.register(multipart_1.default, {
435
463
  attachFieldsToBody: 'keyValues',
464
+ preservePath: true,
436
465
  limits: {
437
466
  fieldSize: constants_js_1.FIELD_SIZE_LIMIT,
438
467
  // For bundles and assets
@@ -446,16 +475,23 @@ function run(config) {
446
475
  if (typeof this?.uploadDir !== 'string') {
447
476
  throw new Error('onFile: expected `this` to be bound to the Fastify request');
448
477
  }
449
- // Lazily assign a per-request upload directory on first file upload
478
+ if (this.uploadAssetValidationError) {
479
+ discardMultipartFile(part);
480
+ return;
481
+ }
482
+ let safeFilename;
483
+ try {
484
+ safeFilename = (0, utils_js_1.validateAssetFilename)(part.filename);
485
+ }
486
+ catch (err) {
487
+ this.uploadAssetValidationError = err.message;
488
+ discardMultipartFile(part);
489
+ return;
490
+ }
491
+ // Lazily assign a per-request upload directory on first valid file upload.
450
492
  if (this.uploadDir === '') {
451
493
  this.uploadDir = path_1.default.join(serverBundleCachePath, 'uploads', (0, crypto_1.randomUUID)());
452
494
  }
453
- // Use path.basename to strip any directory components from the filename,
454
- // preventing path traversal attacks (e.g. filename "../../etc/shadow").
455
- const safeFilename = path_1.default.basename(part.filename);
456
- if (!safeFilename) {
457
- throw new Error(`onFile: received file with empty or invalid filename: ${JSON.stringify(part.filename)}`);
458
- }
459
495
  const destinationPath = path_1.default.join(this.uploadDir, safeFilename);
460
496
  await (0, utils_js_1.saveMultipartFile)(part, destinationPath);
461
497
  // eslint-disable-next-line no-param-reassign
@@ -500,6 +536,15 @@ function run(config) {
500
536
  await setResponse((0, utils_js_1.badRequestResponseResult)(invalidRenderingRequestMessage(body)), res);
501
537
  return;
502
538
  }
539
+ const rscStreamObservability = parseOptionalBooleanField(body, 'rscStreamObservability');
540
+ if (body.rscStreamObservability != null && rscStreamObservability === undefined) {
541
+ await setResponse((0, utils_js_1.badRequestResponseResult)('Invalid "rscStreamObservability" field in render request.'), res);
542
+ return;
543
+ }
544
+ if (req.uploadAssetValidationError) {
545
+ await setResponse((0, utils_js_1.badRequestResponseResult)(req.uploadAssetValidationError), res);
546
+ return;
547
+ }
503
548
  const { bundleTimestamp } = req.params;
504
549
  const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(body, bundleTimestamp);
505
550
  try {
@@ -512,6 +557,7 @@ function run(config) {
512
557
  dependencyBundleTimestamps,
513
558
  providedNewBundles,
514
559
  assetsToCopy,
560
+ rscStreamObservability,
515
561
  tracingContext: context,
516
562
  });
517
563
  await setResponseAndReleaseExecutionContext(result.response, res, result.executionContext);
@@ -538,7 +584,11 @@ function run(config) {
538
584
  let incrementalRequestClosed = false;
539
585
  let incrementalResponseFinished = false;
540
586
  let incrementalExecutionContextReleased = false;
587
+ let pullModeRequestCanIdle = false;
588
+ let incrementalRequestClosePromise;
541
589
  let incrementalResponseFinishTimeoutId;
590
+ let incrementalPullModeIdleTimeoutId;
591
+ let stopIncrementalRequestReader;
542
592
  let incrementalTracingContext;
543
593
  const clearIncrementalResponseFinishTimeout = () => {
544
594
  if (!incrementalResponseFinishTimeoutId) {
@@ -547,6 +597,26 @@ function run(config) {
547
597
  clearTimeout(incrementalResponseFinishTimeoutId);
548
598
  incrementalResponseFinishTimeoutId = undefined;
549
599
  };
600
+ const clearIncrementalPullModeIdleTimeout = () => {
601
+ if (!incrementalPullModeIdleTimeoutId) {
602
+ return;
603
+ }
604
+ clearTimeout(incrementalPullModeIdleTimeoutId);
605
+ incrementalPullModeIdleTimeoutId = undefined;
606
+ };
607
+ const shouldStopReadingIncrementalRequest = () => pullModeRequestCanIdle && responseStarted && incrementalResponseFinished;
608
+ const wakeIncrementalRequestReader = () => {
609
+ stopIncrementalRequestReader?.();
610
+ stopIncrementalRequestReader = undefined;
611
+ };
612
+ const waitForIncrementalRequestReaderStop = () => {
613
+ if (shouldStopReadingIncrementalRequest()) {
614
+ return Promise.resolve();
615
+ }
616
+ return new Promise((resolve) => {
617
+ stopIncrementalRequestReader = resolve;
618
+ });
619
+ };
550
620
  const releaseIncrementalExecutionContextWhenDone = () => {
551
621
  if (incrementalExecutionContextReleased ||
552
622
  !incrementalRequestClosed ||
@@ -560,7 +630,9 @@ function run(config) {
560
630
  };
561
631
  const markIncrementalResponseFinished = () => {
562
632
  clearIncrementalResponseFinishTimeout();
633
+ clearIncrementalPullModeIdleTimeout();
563
634
  incrementalResponseFinished = true;
635
+ wakeIncrementalRequestReader();
564
636
  releaseIncrementalExecutionContextWhenDone();
565
637
  };
566
638
  const scheduleIncrementalResponseFinishTimeout = () => {
@@ -605,8 +677,13 @@ function run(config) {
605
677
  if (!incrementalSink || incrementalRequestClosed) {
606
678
  return;
607
679
  }
680
+ clearIncrementalPullModeIdleTimeout();
681
+ if (incrementalRequestClosePromise) {
682
+ await incrementalRequestClosePromise;
683
+ return;
684
+ }
608
685
  const sink = incrementalSink;
609
- try {
686
+ incrementalRequestClosePromise = (async () => {
610
687
  await waitForIncrementalRequestClose(Promise.resolve()
611
688
  .then(() => sink.handleRequestClosed())
612
689
  .catch((closeError) => {
@@ -615,13 +692,104 @@ function run(config) {
615
692
  error: closeError,
616
693
  });
617
694
  }), timeoutMs);
695
+ })();
696
+ try {
697
+ await incrementalRequestClosePromise;
618
698
  }
619
699
  finally {
700
+ incrementalRequestClosePromise = undefined;
620
701
  incrementalRequestClosed = true;
702
+ clearIncrementalPullModeIdleTimeout();
621
703
  scheduleIncrementalResponseFinishTimeout();
622
704
  releaseIncrementalExecutionContextWhenDone();
623
705
  }
624
706
  };
707
+ const shouldWatchPullModeIdleProgress = () => pullModeRequestCanIdle &&
708
+ responseStarted &&
709
+ !incrementalRequestClosed &&
710
+ !incrementalResponseFinished &&
711
+ !incrementalExecutionContextReleased &&
712
+ !!incrementalSink &&
713
+ !res.raw.destroyed &&
714
+ !res.raw.writableEnded;
715
+ const scheduleIncrementalPullModeIdleTimeout = () => {
716
+ if (incrementalPullModeIdleTimeoutId || !shouldWatchPullModeIdleProgress()) {
717
+ return;
718
+ }
719
+ incrementalPullModeIdleTimeoutId = setTimeout(() => {
720
+ incrementalPullModeIdleTimeoutId = undefined;
721
+ if (!shouldWatchPullModeIdleProgress()) {
722
+ return;
723
+ }
724
+ log_js_1.default.warn({
725
+ msg: 'Timed out waiting for pull-mode incremental render progress after response started',
726
+ timeoutMs: INCREMENTAL_PULL_MODE_IDLE_TIMEOUT_MS,
727
+ });
728
+ if (!res.raw.destroyed) {
729
+ res.raw.destroy();
730
+ }
731
+ void closeIncrementalRequest({
732
+ timeoutMs: INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS,
733
+ });
734
+ markIncrementalResponseFinished();
735
+ }, INCREMENTAL_PULL_MODE_IDLE_TIMEOUT_MS);
736
+ };
737
+ const refreshIncrementalPullModeIdleTimeout = () => {
738
+ if (!shouldWatchPullModeIdleProgress()) {
739
+ clearIncrementalPullModeIdleTimeout();
740
+ return;
741
+ }
742
+ clearIncrementalPullModeIdleTimeout();
743
+ scheduleIncrementalPullModeIdleTimeout();
744
+ };
745
+ const refreshIncrementalResponseFinishTimeout = () => {
746
+ if (!incrementalRequestClosed ||
747
+ incrementalResponseFinished ||
748
+ incrementalExecutionContextReleased ||
749
+ !incrementalSink) {
750
+ return;
751
+ }
752
+ clearIncrementalResponseFinishTimeout();
753
+ scheduleIncrementalResponseFinishTimeout();
754
+ };
755
+ const trackIncrementalResponseProgress = (stream) => {
756
+ const progressStream = new stream_1.Transform({
757
+ transform(chunk, encoding, callback) {
758
+ refreshIncrementalPullModeIdleTimeout();
759
+ refreshIncrementalResponseFinishTimeout();
760
+ callback(null, chunk);
761
+ },
762
+ });
763
+ const forwardSourceError = (error) => progressStream.destroy(error);
764
+ const endProgressStream = () => {
765
+ if (!progressStream.writableEnded && !progressStream.destroyed) {
766
+ progressStream.end();
767
+ }
768
+ };
769
+ stream.once('close', endProgressStream);
770
+ stream.once('error', forwardSourceError);
771
+ progressStream.once('close', () => {
772
+ stream.off('close', endProgressStream);
773
+ stream.off('error', forwardSourceError);
774
+ if (!progressStream.writableEnded && !stream.destroyed) {
775
+ stream.destroy();
776
+ }
777
+ });
778
+ stream.pipe(progressStream);
779
+ return progressStream;
780
+ };
781
+ const getIncrementalRequestChunkTimeoutMs = () => {
782
+ if (pullModeRequestCanIdle &&
783
+ responseStarted &&
784
+ !incrementalResponseFinished &&
785
+ !res.raw.destroyed &&
786
+ !res.raw.writableEnded) {
787
+ scheduleIncrementalPullModeIdleTimeout();
788
+ return Number.POSITIVE_INFINITY;
789
+ }
790
+ clearIncrementalPullModeIdleTimeout();
791
+ return constants_js_1.STREAM_CHUNK_TIMEOUT_MS;
792
+ };
625
793
  try {
626
794
  await (0, tracing_js_1.trace)(async (context) => {
627
795
  incrementalTracingContext = context;
@@ -649,6 +817,7 @@ function run(config) {
649
817
  try {
650
818
  const { response, sink } = await (0, handleIncrementalRenderRequest_js_1.handleIncrementalRenderRequest)(initial);
651
819
  incrementalSink = sink;
820
+ pullModeRequestCanIdle = !!incrementalSink && tempReqBody.pullEnabled === true;
652
821
  return {
653
822
  response,
654
823
  shouldContinue: !!incrementalSink,
@@ -667,6 +836,7 @@ function run(config) {
667
836
  log_js_1.default.error({ msg: 'Unexpected update chunk received after rendering was aborted', obj });
668
837
  return;
669
838
  }
839
+ refreshIncrementalPullModeIdleTimeout();
670
840
  try {
671
841
  await incrementalSink.add(obj);
672
842
  }
@@ -678,9 +848,10 @@ function run(config) {
678
848
  onResponseStart: async (response) => {
679
849
  responseStarted = true;
680
850
  if (response.stream) {
681
- const markFinished = runWhenStreamFinishes(response.stream, res, markIncrementalResponseFinished);
851
+ const responseStream = trackIncrementalResponseProgress(response.stream);
852
+ const markFinished = runWhenStreamFinishes(responseStream, res, markIncrementalResponseFinished);
682
853
  try {
683
- await setResponse(response, res);
854
+ await setResponse({ ...response, stream: responseStream }, res);
684
855
  }
685
856
  catch (error) {
686
857
  markFinished();
@@ -698,6 +869,9 @@ function run(config) {
698
869
  onRequestEnded: async () => {
699
870
  await closeIncrementalRequest();
700
871
  },
872
+ getChunkTimeoutMs: getIncrementalRequestChunkTimeoutMs,
873
+ shouldStopReading: shouldStopReadingIncrementalRequest,
874
+ waitForStopReading: waitForIncrementalRequestReaderStop,
701
875
  });
702
876
  }, (0, tracing_js_1.startSsrRequestOptions)({ renderingRequest: 'ReactOnRails.incrementalRender' }));
703
877
  }
@@ -754,6 +928,10 @@ function run(config) {
754
928
  await setResponse(precheckResult, res);
755
929
  return;
756
930
  }
931
+ if (req.uploadAssetValidationError) {
932
+ await setResponse((0, utils_js_1.badRequestResponseResult)(req.uploadAssetValidationError), res);
933
+ return;
934
+ }
757
935
  const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body);
758
936
  if (providedNewBundles.length === 0) {
759
937
  const errorMsg = 'No bundle_<hash> fields provided. ' +
@@ -815,6 +993,16 @@ function run(config) {
815
993
  await setResponse((0, utils_js_1.errorResponseResult)(message), res);
816
994
  return;
817
995
  }
996
+ let assetFilename;
997
+ try {
998
+ assetFilename = (0, utils_js_1.validateAssetFilename)(filename);
999
+ }
1000
+ catch (err) {
1001
+ const { message } = err;
1002
+ log_js_1.default.info(message);
1003
+ await setResponse((0, utils_js_1.badRequestResponseResult)(message), res);
1004
+ return;
1005
+ }
818
1006
  // Handle targetBundles as either a string or an array
819
1007
  const targetBundles = extractBodyArrayField(req.body, 'targetBundles');
820
1008
  if (!targetBundles || targetBundles.length === 0) {
@@ -825,7 +1013,7 @@ function run(config) {
825
1013
  }
826
1014
  // Check if the asset exists in each of the target bundles
827
1015
  const results = await Promise.all(targetBundles.map(async (bundleHash) => {
828
- const assetPath = (0, utils_js_1.getAssetPath)(bundleHash, filename);
1016
+ const assetPath = (0, utils_js_1.getAssetPath)(bundleHash, assetFilename);
829
1017
  const exists = await (0, fileExistsAsync_js_1.default)(assetPath);
830
1018
  if (exists) {
831
1019
  log_js_1.default.info(`/asset-exists Uploaded asset DOES exist in bundle ${bundleHash}: ${assetPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro-node-renderer",
3
- "version": "17.0.0-rc.6",
3
+ "version": "17.0.0-rc.8",
4
4
  "protocolVersion": "2.0.0",
5
5
  "description": "React on Rails Pro Node Renderer for server-side rendering",
6
6
  "engines": {
@@ -44,8 +44,7 @@
44
44
  "pino": "^9.14.0 || ^10.1.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@babel/core": "^7.26.10",
48
- "@babel/eslint-parser": "^7.27.0",
47
+ "@babel/core": "^7.29.6",
49
48
  "@babel/preset-env": "^7.20.2",
50
49
  "@babel/preset-react": "^7.26.3",
51
50
  "@babel/preset-typescript": "^7.27.1",
@@ -66,18 +65,18 @@
66
65
  "@types/lockfile": "^1.0.4",
67
66
  "@types/touch": "^3.1.5",
68
67
  "babel-jest": "^29.7.0",
69
- "form-auto-content": "^3.2.1",
70
- "form-data": "^4.0.1",
68
+ "form-data": "^4.0.6",
71
69
  "jest-junit": "^16.0.0",
72
- "jsdom": "^16.5.0",
73
70
  "node-html-parser": "^7.0.1",
74
- "nps": "^5.9.12",
75
71
  "pino-pretty": "^13.0.0",
72
+ "react": "~19.2.7",
73
+ "react-dom": "~19.2.7",
74
+ "react-on-rails-rsc": "19.2.1-rc.0",
76
75
  "redis": "^5.0.1",
77
76
  "sentry-testkit": "^5.0.6",
78
77
  "touch": "^3.1.0",
79
78
  "typescript": "^5.4.3",
80
- "react-on-rails": "17.0.0-rc.6"
79
+ "react-on-rails": "17.0.0-rc.8"
81
80
  },
82
81
  "peerDependencies": {
83
82
  "@honeybadger-io/js": ">=4.0.0",
@@ -173,7 +172,6 @@
173
172
  "build-watch": "pnpm run clean && tsc --watch --project src/tsconfig.json",
174
173
  "clean": "rm -rf ./lib",
175
174
  "ci": "jest --ci --runInBand --reporters=default --reporters=jest-junit",
176
- "developing": "nps node-renderer.debug",
177
175
  "test": "jest tests",
178
176
  "type-check": "tsc --noEmit --noErrorTruncation --project src/tsconfig.json"
179
177
  }