react_on_rails 16.2.0.beta.3 → 16.2.0.beta.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -5
  3. data/CLAUDE.md +59 -0
  4. data/CONTRIBUTING.md +49 -1
  5. data/Gemfile.development_dependencies +1 -1
  6. data/Gemfile.lock +25 -10
  7. data/SWITCHING_CI_CONFIGS.md +55 -6
  8. data/Steepfile +51 -0
  9. data/bin/ci-rerun-failures +68 -22
  10. data/bin/ci-run-failed-specs +26 -2
  11. data/bin/ci-switch-config +262 -34
  12. data/bin/lefthook/check-trailing-newlines +2 -12
  13. data/bin/lefthook/eslint-lint +0 -10
  14. data/bin/lefthook/prettier-format +0 -10
  15. data/bin/lefthook/ruby-autofix +3 -6
  16. data/knip.ts +35 -9
  17. data/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +32 -52
  18. data/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml +5 -1
  19. data/lib/react_on_rails/configuration.rb +56 -12
  20. data/lib/react_on_rails/controller.rb +3 -3
  21. data/lib/react_on_rails/dev/server_manager.rb +11 -4
  22. data/lib/react_on_rails/doctor.rb +249 -2
  23. data/lib/react_on_rails/helper.rb +12 -3
  24. data/lib/react_on_rails/pro_helper.rb +2 -44
  25. data/lib/react_on_rails/react_component/render_options.rb +7 -7
  26. data/lib/react_on_rails/utils.rb +40 -0
  27. data/lib/react_on_rails/version.rb +1 -1
  28. data/react_on_rails_pro/CHANGELOG.md +142 -29
  29. data/react_on_rails_pro/CONTRIBUTING.md +2 -13
  30. data/react_on_rails_pro/Gemfile.development_dependencies +1 -0
  31. data/react_on_rails_pro/Gemfile.lock +24 -3
  32. data/react_on_rails_pro/README.md +559 -38
  33. data/react_on_rails_pro/docs/code-splitting-loadable-components.md +1 -1
  34. data/react_on_rails_pro/docs/contributors-info/releasing.md +2 -2
  35. data/react_on_rails_pro/docs/installation.md +129 -109
  36. data/react_on_rails_pro/docs/node-renderer/basics.md +29 -22
  37. data/react_on_rails_pro/docs/node-renderer/error-reporting-and-tracing.md +8 -8
  38. data/react_on_rails_pro/docs/node-renderer/js-configuration.md +25 -23
  39. data/react_on_rails_pro/docs/node-renderer/troubleshooting.md +2 -0
  40. data/react_on_rails_pro/docs/updating.md +209 -15
  41. data/react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb +58 -4
  42. data/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb +17 -3
  43. data/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb +9 -9
  44. data/react_on_rails_pro/lib/react_on_rails_pro/request.rb +41 -25
  45. data/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb +27 -7
  46. data/react_on_rails_pro/lib/react_on_rails_pro/utils.rb +3 -3
  47. data/react_on_rails_pro/lib/react_on_rails_pro/version.rb +1 -1
  48. data/react_on_rails_pro/package-scripts.yml +1 -1
  49. data/react_on_rails_pro/package.json +5 -8
  50. data/react_on_rails_pro/packages/node-renderer/src/integrations/api.ts +1 -1
  51. data/react_on_rails_pro/packages/node-renderer/src/master/restartWorkers.ts +39 -17
  52. data/react_on_rails_pro/packages/node-renderer/src/master.ts +15 -4
  53. data/react_on_rails_pro/packages/node-renderer/src/shared/configBuilder.ts +44 -5
  54. data/react_on_rails_pro/packages/node-renderer/src/shared/utils.ts +4 -2
  55. data/react_on_rails_pro/packages/node-renderer/src/worker/handleGracefulShutdown.ts +49 -0
  56. data/react_on_rails_pro/packages/node-renderer/src/worker/vm.ts +3 -3
  57. data/react_on_rails_pro/packages/node-renderer/src/worker.ts +5 -2
  58. data/react_on_rails_pro/packages/node-renderer/tests/helper.ts +8 -8
  59. data/react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.js +1 -1
  60. data/react_on_rails_pro/packages/node-renderer/tests/worker.test.ts +19 -19
  61. data/react_on_rails_pro/rakelib/public_key_management.rake +6 -5
  62. data/react_on_rails_pro/rakelib/rbs.rake +47 -0
  63. data/react_on_rails_pro/react_on_rails_pro.gemspec +1 -0
  64. data/react_on_rails_pro/sig/react_on_rails_pro/cache.rbs +13 -0
  65. data/react_on_rails_pro/sig/react_on_rails_pro/configuration.rbs +100 -0
  66. data/react_on_rails_pro/sig/react_on_rails_pro/error.rbs +4 -0
  67. data/react_on_rails_pro/sig/react_on_rails_pro/utils.rbs +7 -0
  68. data/react_on_rails_pro/sig/react_on_rails_pro.rbs +5 -0
  69. data/react_on_rails_pro/spec/dummy/Gemfile.lock +23 -3
  70. data/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +3 -3
  71. data/react_on_rails_pro/spec/dummy/bin/dev +4 -8
  72. data/react_on_rails_pro/spec/dummy/client/node-renderer.js +4 -4
  73. data/react_on_rails_pro/spec/dummy/config/environments/production.rb +1 -1
  74. data/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails.rb +28 -12
  75. data/react_on_rails_pro/spec/dummy/config.ru +1 -1
  76. data/react_on_rails_pro/spec/dummy/package.json +2 -2
  77. data/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +40 -11
  78. data/react_on_rails_pro/spec/dummy/spec/rails_helper.rb +1 -1
  79. data/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb +5 -5
  80. data/react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb +15 -10
  81. data/react_on_rails_pro/spec/dummy/spec/system/renderer_integration_spec.rb +3 -3
  82. data/react_on_rails_pro/spec/dummy/yarn.lock +4 -4
  83. data/react_on_rails_pro/spec/execjs-compatible-dummy/config/environments/production.rb +1 -1
  84. data/react_on_rails_pro/spec/execjs-compatible-dummy/config/initializers/react_on_rails.rb +16 -43
  85. data/react_on_rails_pro/spec/react_on_rails_pro/assets_precompile_spec.rb +15 -18
  86. data/react_on_rails_pro/spec/react_on_rails_pro/cache_spec.rb +1 -1
  87. data/react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb +5 -3
  88. data/react_on_rails_pro/spec/react_on_rails_pro/license_validator_spec.rb +27 -12
  89. data/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb +0 -27
  90. data/react_on_rails_pro/spec/react_on_rails_pro/spec_helper.rb +1 -1
  91. data/react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb +89 -0
  92. data/react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb +144 -0
  93. data/react_on_rails_pro/spec/react_on_rails_pro/support/caching.rb +1 -1
  94. data/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb +4 -2
  95. data/sig/react_on_rails/controller.rbs +1 -1
  96. data/sig/react_on_rails/error.rbs +4 -0
  97. data/sig/react_on_rails/helper.rbs +2 -2
  98. data/sig/react_on_rails/json_parse_error.rbs +10 -0
  99. data/sig/react_on_rails/prerender_error.rbs +21 -0
  100. data/sig/react_on_rails/smart_error.rbs +28 -0
  101. data/sig/react_on_rails.rbs +3 -24
  102. metadata +14 -4
  103. data/lib/react_on_rails/pro_utils.rb +0 -37
  104. data/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/TestingStreamableComponent.jsx +0 -15
@@ -5,6 +5,7 @@
5
5
 
6
6
  import cluster from 'cluster';
7
7
  import log from '../shared/log';
8
+ import { SHUTDOWN_WORKER_MESSAGE } from '../shared/utils';
8
9
 
9
10
  const MILLISECONDS_IN_MINUTE = 60000;
10
11
 
@@ -14,26 +15,47 @@ declare module 'cluster' {
14
15
  }
15
16
  }
16
17
 
17
- export = function restartWorkers(delayBetweenIndividualWorkerRestarts: number) {
18
+ export = async function restartWorkers(
19
+ delayBetweenIndividualWorkerRestarts: number,
20
+ gracefulWorkerRestartTimeout: number | undefined,
21
+ ) {
18
22
  log.info('Started scheduled restart of workers');
19
23
 
20
- let delay = 0;
21
24
  if (!cluster.workers) {
22
25
  throw new Error('No workers to restart');
23
26
  }
24
- Object.values(cluster.workers).forEach((worker) => {
25
- const killWorker = () => {
26
- if (!worker) return;
27
- log.debug('Kill worker #%d', worker.id);
28
- // eslint-disable-next-line no-param-reassign -- necessary change
29
- worker.isScheduledRestart = true;
30
- worker.destroy();
31
- };
32
- setTimeout(killWorker, delay);
33
- delay += delayBetweenIndividualWorkerRestarts * MILLISECONDS_IN_MINUTE;
34
- });
35
-
36
- setTimeout(() => {
37
- log.info('Finished scheduled restart of workers');
38
- }, delay);
27
+ for (const worker of Object.values(cluster.workers).filter((w) => !!w)) {
28
+ log.debug('Kill worker #%d', worker.id);
29
+ worker.isScheduledRestart = true;
30
+
31
+ worker.send(SHUTDOWN_WORKER_MESSAGE);
32
+
33
+ // It's inteded to restart worker in sequence, it shouldn't happens in parallel
34
+ // eslint-disable-next-line no-await-in-loop
35
+ await new Promise<void>((resolve) => {
36
+ let timeout: NodeJS.Timeout;
37
+
38
+ const onExit = () => {
39
+ clearTimeout(timeout);
40
+ resolve();
41
+ };
42
+ worker.on('exit', onExit);
43
+
44
+ // Zero means no timeout
45
+ if (gracefulWorkerRestartTimeout) {
46
+ timeout = setTimeout(() => {
47
+ log.debug('Worker #%d timed out, forcing kill it', worker.id);
48
+ worker.destroy();
49
+ worker.off('exit', onExit);
50
+ resolve();
51
+ }, gracefulWorkerRestartTimeout);
52
+ }
53
+ });
54
+ // eslint-disable-next-line no-await-in-loop
55
+ await new Promise((resolve) => {
56
+ setTimeout(resolve, delayBetweenIndividualWorkerRestarts * MILLISECONDS_IN_MINUTE);
57
+ });
58
+ }
59
+
60
+ log.info('Finished scheduled restart of workers');
39
61
  };
@@ -19,7 +19,12 @@ export = function masterRun(runningConfig?: Partial<Config>) {
19
19
 
20
20
  // Store config in app state. From now it can be loaded by any module using getConfig():
21
21
  const config = buildConfig(runningConfig);
22
- const { workersCount, allWorkersRestartInterval, delayBetweenIndividualWorkerRestarts } = config;
22
+ const {
23
+ workersCount,
24
+ allWorkersRestartInterval,
25
+ delayBetweenIndividualWorkerRestarts,
26
+ gracefulWorkerRestartTimeout,
27
+ } = config;
23
28
 
24
29
  logSanitizedConfig();
25
30
 
@@ -48,9 +53,15 @@ export = function masterRun(runningConfig?: Partial<Config>) {
48
53
  allWorkersRestartInterval,
49
54
  delayBetweenIndividualWorkerRestarts,
50
55
  );
51
- setInterval(() => {
52
- restartWorkers(delayBetweenIndividualWorkerRestarts);
53
- }, allWorkersRestartInterval * MILLISECONDS_IN_MINUTE);
56
+
57
+ const allWorkersRestartIntervalMS = allWorkersRestartInterval * MILLISECONDS_IN_MINUTE;
58
+ const scheduleWorkersRestart = () => {
59
+ void restartWorkers(delayBetweenIndividualWorkerRestarts, gracefulWorkerRestartTimeout).finally(() => {
60
+ setTimeout(scheduleWorkersRestart, allWorkersRestartIntervalMS);
61
+ });
62
+ };
63
+
64
+ setTimeout(scheduleWorkersRestart, allWorkersRestartIntervalMS);
54
65
  } else if (allWorkersRestartInterval || delayBetweenIndividualWorkerRestarts) {
55
66
  log.error(
56
67
  "Misconfiguration, please provide both 'allWorkersRestartInterval' and " +
@@ -34,8 +34,11 @@ export interface Config {
34
34
  // Additional options to pass to the Fastify server factory.
35
35
  // See https://fastify.dev/docs/latest/Reference/Server/#factory.
36
36
  fastifyServerOptions: FastifyServerOptions<http2.Http2Server>;
37
- // Path to a temp directory where uploaded bundle files will be stored.
38
- bundlePath: string;
37
+ // Path to a cache directory where uploaded server bundle files will be stored.
38
+ // This is distinct from Shakapacker's public asset directory.
39
+ serverBundleCachePath: string;
40
+ // @deprecated Use serverBundleCachePath instead. This will be removed in a future version.
41
+ bundlePath?: string;
39
42
  // If set to true, `supportModules` enables the server-bundle code to call a default set of NodeJS
40
43
  // global objects and functions that get added to the VM context:
41
44
  // `{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }`.
@@ -58,6 +61,9 @@ export interface Config {
58
61
  allWorkersRestartInterval: number | undefined;
59
62
  // Time in minutes between each worker restarting when restarting all workers
60
63
  delayBetweenIndividualWorkerRestarts: number | undefined;
64
+ // Time in seconds to wait for worker to restart before killing it
65
+ // Set it to 0 or undefined to never kill the worker
66
+ gracefulWorkerRestartTimeout: number | undefined;
61
67
  // If the rendering request is longer than this, it will be truncated in exception and logging messages
62
68
  maxDebugSnippetLength: number;
63
69
  // @deprecated See https://www.shakacode.com/react-on-rails-pro/docs/node-renderer/error-reporting-and-tracing.
@@ -99,7 +105,7 @@ function defaultWorkersCount() {
99
105
  }
100
106
 
101
107
  // Find the .node-renderer-bundles folder if it exists, otherwise use /tmp
102
- function defaultBundlePath() {
108
+ function defaultServerBundleCachePath() {
103
109
  let currentDir = process.cwd();
104
110
  const maxDepth = 10;
105
111
  for (let i = 0; i < maxDepth; i += 1) {
@@ -145,7 +151,8 @@ const defaultConfig: Config = {
145
151
 
146
152
  fastifyServerOptions: {},
147
153
 
148
- bundlePath: env.RENDERER_BUNDLE_PATH || defaultBundlePath(),
154
+ serverBundleCachePath:
155
+ env.RENDERER_SERVER_BUNDLE_CACHE_PATH || env.RENDERER_BUNDLE_PATH || defaultServerBundleCachePath(),
149
156
 
150
157
  supportModules: truthy(env.RENDERER_SUPPORT_MODULES),
151
158
 
@@ -165,6 +172,10 @@ const defaultConfig: Config = {
165
172
  ? parseInt(env.RENDERER_DELAY_BETWEEN_INDIVIDUAL_WORKER_RESTARTS, 10)
166
173
  : undefined,
167
174
 
175
+ gracefulWorkerRestartTimeout: env.GRACEFUL_WORKER_RESTART_TIMEOUT
176
+ ? parseInt(env.GRACEFUL_WORKER_RESTART_TIMEOUT, 10)
177
+ : undefined,
178
+
168
179
  maxDebugSnippetLength: MAX_DEBUG_SNIPPET_LENGTH,
169
180
 
170
181
  // default to true if empty, otherwise it is set to false
@@ -185,7 +196,10 @@ function envValuesUsed() {
185
196
  RENDERER_PORT: !userConfig.port && env.RENDERER_PORT,
186
197
  RENDERER_LOG_LEVEL: !userConfig.logLevel && env.RENDERER_LOG_LEVEL,
187
198
  RENDERER_LOG_HTTP_LEVEL: !userConfig.logHttpLevel && env.RENDERER_LOG_HTTP_LEVEL,
188
- RENDERER_BUNDLE_PATH: !userConfig.bundlePath && env.RENDERER_BUNDLE_PATH,
199
+ RENDERER_SERVER_BUNDLE_CACHE_PATH:
200
+ !userConfig.serverBundleCachePath && env.RENDERER_SERVER_BUNDLE_CACHE_PATH,
201
+ RENDERER_BUNDLE_PATH:
202
+ !userConfig.serverBundleCachePath && !userConfig.bundlePath && env.RENDERER_BUNDLE_PATH,
189
203
  RENDERER_WORKERS_COUNT: !userConfig.workersCount && env.RENDERER_WORKERS_COUNT,
190
204
  RENDERER_PASSWORD: !userConfig.password && env.RENDERER_PASSWORD && '<MASKED>',
191
205
  RENDERER_SUPPORT_MODULES: !('supportModules' in userConfig) && env.RENDERER_SUPPORT_MODULES,
@@ -195,6 +209,8 @@ function envValuesUsed() {
195
209
  RENDERER_DELAY_BETWEEN_INDIVIDUAL_WORKER_RESTARTS:
196
210
  !userConfig.delayBetweenIndividualWorkerRestarts &&
197
211
  env.RENDERER_DELAY_BETWEEN_INDIVIDUAL_WORKER_RESTARTS,
212
+ GRACEFUL_WORKER_RESTART_TIMEOUT:
213
+ !userConfig.gracefulWorkerRestartTimeout && env.GRACEFUL_WORKER_RESTART_TIMEOUT,
198
214
  INCLUDE_TIMER_POLYFILLS: !('includeTimerPolyfills' in userConfig) && env.INCLUDE_TIMER_POLYFILLS,
199
215
  REPLAY_SERVER_ASYNC_OPERATION_LOGS:
200
216
  !userConfig.replayServerAsyncOperationLogs && env.REPLAY_SERVER_ASYNC_OPERATION_LOGS,
@@ -209,6 +225,7 @@ function sanitizedSettings(aConfig: Partial<Config> | undefined, defaultValue?:
209
225
  password: aConfig.password != null ? '<MASKED>' : defaultValue,
210
226
  allWorkersRestartInterval: aConfig.allWorkersRestartInterval || defaultValue,
211
227
  delayBetweenIndividualWorkerRestarts: aConfig.delayBetweenIndividualWorkerRestarts || defaultValue,
228
+ gracefulWorkerRestartTimeout: aConfig.gracefulWorkerRestartTimeout || defaultValue,
212
229
  }
213
230
  : {};
214
231
  }
@@ -231,6 +248,28 @@ export function buildConfig(providedUserConfig?: Partial<Config>): Config {
231
248
  userConfig = providedUserConfig || {};
232
249
  config = { ...defaultConfig, ...userConfig };
233
250
 
251
+ // Handle bundlePath deprecation
252
+ if ('bundlePath' in userConfig) {
253
+ log.warn(
254
+ 'bundlePath is deprecated and will be removed in a future version. ' +
255
+ 'Use serverBundleCachePath instead. This path stores uploaded server bundles for the node renderer, ' +
256
+ 'not client-side webpack assets from Shakapacker.',
257
+ );
258
+ // If serverBundleCachePath is not set, use bundlePath as fallback
259
+ if (
260
+ userConfig.bundlePath &&
261
+ (!config.serverBundleCachePath || config.serverBundleCachePath === defaultConfig.serverBundleCachePath)
262
+ ) {
263
+ config.serverBundleCachePath = userConfig.bundlePath;
264
+ }
265
+ }
266
+ if (env.RENDERER_BUNDLE_PATH && !env.RENDERER_SERVER_BUNDLE_CACHE_PATH) {
267
+ log.warn(
268
+ 'RENDERER_BUNDLE_PATH environment variable is deprecated and will be removed in a future version. ' +
269
+ 'Use RENDERER_SERVER_BUNDLE_CACHE_PATH instead.',
270
+ );
271
+ }
272
+
234
273
  config.supportModules = truthy(config.supportModules);
235
274
 
236
275
  if (config.maxVMPoolSize <= 0 || !Number.isInteger(config.maxVMPoolSize)) {
@@ -11,6 +11,8 @@ import type { RenderResult } from '../worker/vm';
11
11
 
12
12
  export const TRUNCATION_FILLER = '\n... TRUNCATED ...\n';
13
13
 
14
+ export const SHUTDOWN_WORKER_MESSAGE = 'NODE_RENDERER_SHUTDOWN_WORKER';
15
+
14
16
  export function workerIdLabel() {
15
17
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- worker is nullable in the primary process
16
18
  return cluster?.worker?.id || 'NO WORKER ID';
@@ -155,8 +157,8 @@ export const delay = (milliseconds: number) =>
155
157
  });
156
158
 
157
159
  export function getBundleDirectory(bundleTimestamp: string | number) {
158
- const { bundlePath } = getConfig();
159
- return path.join(bundlePath, `${bundleTimestamp}`);
160
+ const { serverBundleCachePath } = getConfig();
161
+ return path.join(serverBundleCachePath, `${bundleTimestamp}`);
160
162
  }
161
163
 
162
164
  export function getRequestBundleFilePath(bundleTimestamp: string | number) {
@@ -0,0 +1,49 @@
1
+ import cluster from 'cluster';
2
+ import { FastifyInstance } from './types';
3
+ import { SHUTDOWN_WORKER_MESSAGE } from '../shared/utils';
4
+ import log from '../shared/log';
5
+
6
+ const handleGracefulShutdown = (app: FastifyInstance) => {
7
+ const { worker } = cluster;
8
+ if (!worker) {
9
+ log.error('handleGracefulShutdown is called on master, expected to call it on worker only');
10
+ return;
11
+ }
12
+
13
+ let activeRequestsCount = 0;
14
+ let isShuttingDown = false;
15
+
16
+ process.on('message', (msg) => {
17
+ if (msg === SHUTDOWN_WORKER_MESSAGE) {
18
+ log.debug('Worker #%d received graceful shutdown message', worker.id);
19
+ isShuttingDown = true;
20
+ if (activeRequestsCount === 0) {
21
+ log.debug('Worker #%d has no active requests, killing the worker', worker.id);
22
+ worker.destroy();
23
+ } else {
24
+ log.debug(
25
+ 'Worker #%d has "%d" active requests, disconnecting the worker',
26
+ worker.id,
27
+ activeRequestsCount,
28
+ );
29
+ worker.disconnect();
30
+ }
31
+ }
32
+ });
33
+
34
+ app.addHook('onRequest', (_req, _reply, done) => {
35
+ activeRequestsCount += 1;
36
+ done();
37
+ });
38
+
39
+ app.addHook('onResponse', (_req, _reply, done) => {
40
+ activeRequestsCount -= 1;
41
+ if (isShuttingDown && activeRequestsCount === 0) {
42
+ log.debug('Worker #%d served all active requests and going to be killed', worker.id);
43
+ worker.destroy();
44
+ }
45
+ done();
46
+ });
47
+ };
48
+
49
+ export default handleGracefulShutdown;
@@ -112,7 +112,7 @@ export async function runInVM(
112
112
  filePath: string,
113
113
  vmCluster?: typeof cluster,
114
114
  ): Promise<RenderResult> {
115
- const { bundlePath } = getConfig();
115
+ const { serverBundleCachePath } = getConfig();
116
116
 
117
117
  try {
118
118
  // Wait for VM creation if it's in progress
@@ -137,7 +137,7 @@ export async function runInVM(
137
137
  const workerId = vmCluster?.worker?.id;
138
138
  log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${filePath} with code
139
139
  ${smartTrim(renderingRequest)}`);
140
- const debugOutputPathCode = path.join(bundlePath, 'code.js');
140
+ const debugOutputPathCode = path.join(serverBundleCachePath, 'code.js');
141
141
  log.debug(`Full code executed written to: ${debugOutputPathCode}`);
142
142
  await writeFileAsync(debugOutputPathCode, renderingRequest);
143
143
  }
@@ -165,7 +165,7 @@ ${smartTrim(renderingRequest)}`);
165
165
  if (log.level === 'debug') {
166
166
  log.debug(`result from JS:
167
167
  ${smartTrim(result)}`);
168
- const debugOutputPathResult = path.join(bundlePath, 'result.json');
168
+ const debugOutputPathResult = path.join(serverBundleCachePath, 'result.json');
169
169
  log.debug(`Wrote result to file: ${debugOutputPathResult}`);
170
170
  await writeFileAsync(debugOutputPathResult, result);
171
171
  }
@@ -17,6 +17,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/typ
17
17
  import checkProtocolVersion from './worker/checkProtocolVersionHandler';
18
18
  import authenticate from './worker/authHandler';
19
19
  import { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest';
20
+ import handleGracefulShutdown from './worker/handleGracefulShutdown';
20
21
  import {
21
22
  errorResponseResult,
22
23
  formatExceptionMessage,
@@ -117,7 +118,7 @@ export default function run(config: Partial<Config>) {
117
118
  // getConfig():
118
119
  buildConfig(config);
119
120
 
120
- const { bundlePath, logHttpLevel, port, fastifyServerOptions, workersCount } = getConfig();
121
+ const { serverBundleCachePath, logHttpLevel, port, fastifyServerOptions, workersCount } = getConfig();
121
122
 
122
123
  const app = fastify({
123
124
  http2: useHttp2 as true,
@@ -127,6 +128,8 @@ export default function run(config: Partial<Config>) {
127
128
  ...fastifyServerOptions,
128
129
  });
129
130
 
131
+ handleGracefulShutdown(app);
132
+
130
133
  // We shouldn't have unhandled errors here, but just in case
131
134
  app.addHook('onError', (req, res, err, done) => {
132
135
  // Not errorReporter.error so that integrations can decide how to log the errors.
@@ -148,7 +151,7 @@ export default function run(config: Partial<Config>) {
148
151
  fileSize: Infinity,
149
152
  },
150
153
  onFile: async (part) => {
151
- const destinationPath = path.join(bundlePath, 'uploads', part.filename);
154
+ const destinationPath = path.join(serverBundleCachePath, 'uploads', part.filename);
152
155
  // TODO: inline here
153
156
  await saveMultipartFile(part, destinationPath);
154
157
  // eslint-disable-next-line no-param-reassign
@@ -35,23 +35,23 @@ export function getOtherFixtureAsset() {
35
35
  return path.resolve(__dirname, `./fixtures/${ASSET_UPLOAD_OTHER_FILE}`);
36
36
  }
37
37
 
38
- export function bundlePath(testName: string) {
38
+ export function serverBundleCachePath(testName: string) {
39
39
  return path.resolve(__dirname, 'tmp', testName);
40
40
  }
41
41
 
42
42
  export function setConfig(testName: string) {
43
43
  buildConfig({
44
- bundlePath: bundlePath(testName),
44
+ serverBundleCachePath: serverBundleCachePath(testName),
45
45
  });
46
46
  }
47
47
 
48
48
  export function vmBundlePath(testName: string) {
49
- return path.resolve(bundlePath(testName), `${BUNDLE_TIMESTAMP}`, `${BUNDLE_TIMESTAMP}.js`);
49
+ return path.resolve(serverBundleCachePath(testName), `${BUNDLE_TIMESTAMP}`, `${BUNDLE_TIMESTAMP}.js`);
50
50
  }
51
51
 
52
52
  export function vmSecondaryBundlePath(testName: string) {
53
53
  return path.resolve(
54
- bundlePath(testName),
54
+ serverBundleCachePath(testName),
55
55
  `${SECONDARY_BUNDLE_TIMESTAMP}`,
56
56
  `${SECONDARY_BUNDLE_TIMESTAMP}.js`,
57
57
  );
@@ -76,7 +76,7 @@ export function secondaryLockfilePath(testName: string) {
76
76
  }
77
77
 
78
78
  export function uploadedBundleDir(testName: string) {
79
- return path.resolve(bundlePath(testName), 'uploads');
79
+ return path.resolve(serverBundleCachePath(testName), 'uploads');
80
80
  }
81
81
 
82
82
  export function uploadedBundlePath(testName: string) {
@@ -96,11 +96,11 @@ export function uploadedAssetOtherPath(testName: string) {
96
96
  }
97
97
 
98
98
  export function assetPath(testName: string, bundleTimestamp: string) {
99
- return path.resolve(bundlePath(testName), bundleTimestamp, ASSET_UPLOAD_FILE);
99
+ return path.resolve(serverBundleCachePath(testName), bundleTimestamp, ASSET_UPLOAD_FILE);
100
100
  }
101
101
 
102
102
  export function assetPathOther(testName: string, bundleTimestamp: string) {
103
- return path.resolve(bundlePath(testName), bundleTimestamp, ASSET_UPLOAD_OTHER_FILE);
103
+ return path.resolve(serverBundleCachePath(testName), bundleTimestamp, ASSET_UPLOAD_OTHER_FILE);
104
104
  }
105
105
 
106
106
  export async function createUploadedBundle(testName: string) {
@@ -129,7 +129,7 @@ export async function createAsset(testName: string, bundleTimestamp: string) {
129
129
  }
130
130
 
131
131
  export async function resetForTest(testName: string) {
132
- await fsExtra.emptyDir(bundlePath(testName));
132
+ await fsExtra.emptyDir(serverBundleCachePath(testName));
133
133
  resetVM();
134
134
  setConfig(testName);
135
135
  }
@@ -8,7 +8,7 @@ if (fs.existsSync(BUNDLE_PATH)) {
8
8
 
9
9
  const config = {
10
10
  // This is the default but avoids searching for the Rails root
11
- bundlePath: BUNDLE_PATH,
11
+ serverBundleCachePath: BUNDLE_PATH,
12
12
  port: env.RENDERER_PORT || 3800, // Listen at RENDERER_PORT env value or default port 3800
13
13
  logLevel: env.RENDERER_LOG_LEVEL || 'info',
14
14
 
@@ -15,14 +15,14 @@ import {
15
15
  getFixtureAsset,
16
16
  getOtherFixtureAsset,
17
17
  createAsset,
18
- bundlePath,
18
+ serverBundleCachePath,
19
19
  assetPath,
20
20
  assetPathOther,
21
21
  } from './helper';
22
22
 
23
23
  const testName = 'worker';
24
24
  const createVmBundleForTest = () => createVmBundle(testName);
25
- const bundlePathForTest = () => bundlePath(testName);
25
+ const serverBundleCachePathForTest = () => serverBundleCachePath(testName);
26
26
 
27
27
  const gemVersion = packageJson.version;
28
28
  const { protocolVersion } = packageJson;
@@ -41,7 +41,7 @@ describe('worker', () => {
41
41
 
42
42
  test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest when bundle is provided and did not yet exist', async () => {
43
43
  const app = worker({
44
- bundlePath: bundlePathForTest(),
44
+ serverBundleCachePath: serverBundleCachePathForTest(),
45
45
  });
46
46
 
47
47
  const form = formAutoContent({
@@ -69,7 +69,7 @@ describe('worker', () => {
69
69
 
70
70
  test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest', async () => {
71
71
  const app = worker({
72
- bundlePath: bundlePathForTest(),
72
+ serverBundleCachePath: serverBundleCachePathForTest(),
73
73
  });
74
74
 
75
75
  const form = formAutoContent({
@@ -105,7 +105,7 @@ describe('worker', () => {
105
105
  await createVmBundleForTest();
106
106
 
107
107
  const app = worker({
108
- bundlePath: bundlePathForTest(),
108
+ serverBundleCachePath: serverBundleCachePathForTest(),
109
109
  password: 'password',
110
110
  });
111
111
 
@@ -132,7 +132,7 @@ describe('worker', () => {
132
132
  await createVmBundleForTest();
133
133
 
134
134
  const app = worker({
135
- bundlePath: bundlePathForTest(),
135
+ serverBundleCachePath: serverBundleCachePathForTest(),
136
136
  password: 'password',
137
137
  });
138
138
 
@@ -159,7 +159,7 @@ describe('worker', () => {
159
159
  await createVmBundleForTest();
160
160
 
161
161
  const app = worker({
162
- bundlePath: bundlePathForTest(),
162
+ serverBundleCachePath: serverBundleCachePathForTest(),
163
163
  password: 'my_password',
164
164
  });
165
165
 
@@ -187,7 +187,7 @@ describe('worker', () => {
187
187
  await createVmBundleForTest();
188
188
 
189
189
  const app = worker({
190
- bundlePath: bundlePathForTest(),
190
+ serverBundleCachePath: serverBundleCachePathForTest(),
191
191
  });
192
192
 
193
193
  const res = await app
@@ -211,7 +211,7 @@ describe('worker', () => {
211
211
  await createAsset(testName, bundleHash);
212
212
 
213
213
  const app = worker({
214
- bundlePath: bundlePathForTest(),
214
+ serverBundleCachePath: serverBundleCachePathForTest(),
215
215
  password: 'my_password',
216
216
  });
217
217
 
@@ -237,7 +237,7 @@ describe('worker', () => {
237
237
  await createAsset(testName, bundleHash);
238
238
 
239
239
  const app = worker({
240
- bundlePath: bundlePathForTest(),
240
+ serverBundleCachePath: serverBundleCachePathForTest(),
241
241
  password: 'my_password',
242
242
  });
243
243
 
@@ -261,7 +261,7 @@ describe('worker', () => {
261
261
  test('post /asset-exists requires targetBundles (protocol version 2.0.0)', async () => {
262
262
  await createAsset(testName, String(BUNDLE_TIMESTAMP));
263
263
  const app = worker({
264
- bundlePath: bundlePathForTest(),
264
+ serverBundleCachePath: serverBundleCachePathForTest(),
265
265
  password: 'my_password',
266
266
  });
267
267
 
@@ -283,7 +283,7 @@ describe('worker', () => {
283
283
  const bundleHash = 'some-bundle-hash';
284
284
 
285
285
  const app = worker({
286
- bundlePath: bundlePathForTest(),
286
+ serverBundleCachePath: serverBundleCachePathForTest(),
287
287
  password: 'my_password',
288
288
  });
289
289
 
@@ -307,7 +307,7 @@ describe('worker', () => {
307
307
  const bundleHashOther = 'some-other-bundle-hash';
308
308
 
309
309
  const app = worker({
310
- bundlePath: bundlePathForTest(),
310
+ serverBundleCachePath: serverBundleCachePathForTest(),
311
311
  password: 'my_password',
312
312
  });
313
313
 
@@ -334,7 +334,7 @@ describe('worker', () => {
334
334
  await createVmBundleForTest();
335
335
 
336
336
  const app = worker({
337
- bundlePath: bundlePathForTest(),
337
+ serverBundleCachePath: serverBundleCachePathForTest(),
338
338
  });
339
339
 
340
340
  const res = await app
@@ -355,7 +355,7 @@ describe('worker', () => {
355
355
  await createVmBundleForTest();
356
356
 
357
357
  const app = worker({
358
- bundlePath: bundlePathForTest(),
358
+ serverBundleCachePath: serverBundleCachePathForTest(),
359
359
  });
360
360
 
361
361
  const res = await app
@@ -379,7 +379,7 @@ describe('worker', () => {
379
379
  await createVmBundleForTest();
380
380
 
381
381
  const app = worker({
382
- bundlePath: bundlePathForTest(),
382
+ serverBundleCachePath: serverBundleCachePathForTest(),
383
383
  });
384
384
 
385
385
  const res = await app
@@ -400,7 +400,7 @@ describe('worker', () => {
400
400
  await createVmBundleForTest();
401
401
 
402
402
  const app = worker({
403
- bundlePath: bundlePathForTest(),
403
+ serverBundleCachePath: serverBundleCachePathForTest(),
404
404
  });
405
405
 
406
406
  // If package version is 4.0.0, this tests that 4.0.0.rc.1 gets normalized to 4.0.0-rc.1
@@ -426,7 +426,7 @@ describe('worker', () => {
426
426
  await createVmBundleForTest();
427
427
 
428
428
  const app = worker({
429
- bundlePath: bundlePathForTest(),
429
+ serverBundleCachePath: serverBundleCachePathForTest(),
430
430
  });
431
431
 
432
432
  const gemVersionUpperCase = packageJson.version.toUpperCase();
@@ -449,7 +449,7 @@ describe('worker', () => {
449
449
  await createVmBundleForTest();
450
450
 
451
451
  const app = worker({
452
- bundlePath: bundlePathForTest(),
452
+ serverBundleCachePath: serverBundleCachePathForTest(),
453
453
  });
454
454
 
455
455
  const gemVersionWithWhitespace = ` ${packageJson.version} `;
@@ -13,9 +13,9 @@ require "uri"
13
13
  # rake react_on_rails_pro:verify_public_key # Verify current configuration
14
14
  # rake react_on_rails_pro:public_key_help # Show help
15
15
 
16
- namespace :react_on_rails_pro do
16
+ namespace :react_on_rails_pro do # rubocop:disable Metrics/BlockLength
17
17
  desc "Update the public key for React on Rails Pro license validation"
18
- task :update_public_key, [:source] do |_task, args|
18
+ task :update_public_key, [:source] do |_task, args| # rubocop:disable Metrics/BlockLength
19
19
  source = args[:source] || "production"
20
20
 
21
21
  # Determine the API URL based on the source
@@ -68,7 +68,7 @@ namespace :react_on_rails_pro do
68
68
  # ShakaCode's public key for React on Rails Pro license verification
69
69
  # The private key corresponding to this public key is held by ShakaCode
70
70
  # and is never committed to the repository
71
- # Last updated: #{Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")}
71
+ # Last updated: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}
72
72
  # Source: #{api_url}
73
73
  #
74
74
  # You can update this public key by running the rake task:
@@ -86,12 +86,13 @@ namespace :react_on_rails_pro do
86
86
  puts "✅ Updated Ruby public key: #{ruby_file_path}"
87
87
 
88
88
  # Update Node/TypeScript public key file
89
- node_file_path = File.join(File.dirname(__FILE__), "..", "packages", "node-renderer", "src", "shared", "licensePublicKey.ts")
89
+ node_file_path = File.join(File.dirname(__FILE__), "..", "packages", "node-renderer", "src", "shared",
90
+ "licensePublicKey.ts")
90
91
  node_content = <<~TYPESCRIPT
91
92
  // ShakaCode's public key for React on Rails Pro license verification
92
93
  // The private key corresponding to this public key is held by ShakaCode
93
94
  // and is never committed to the repository
94
- // Last updated: #{Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")}
95
+ // Last updated: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}
95
96
  // Source: #{api_url}
96
97
  //
97
98
  // You can update this public key by running the rake task:
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ # NOTE: Pro package does not include Steep tasks (:steep, :all) as it does not
7
+ # use Steep type checker. Only RBS validation is performed.
8
+ # rubocop:disable Metrics/BlockLength
9
+ namespace :rbs do
10
+ desc "Validate RBS type signatures"
11
+ task :validate do
12
+ require "rbs"
13
+ require "rbs/cli"
14
+
15
+ puts "Validating RBS type signatures..."
16
+
17
+ # Use Open3 for better error handling - captures stdout, stderr, and exit status separately
18
+ # This allows us to distinguish between actual validation errors and warnings
19
+ # Note: Must use bundle exec even though rake runs in bundle context because
20
+ # spawned shell commands via Open3.capture3() do NOT inherit bundle context
21
+ # Wrap in Timeout to prevent hung processes in CI environments (60 second timeout)
22
+ stdout, stderr, status = Timeout.timeout(60) do
23
+ Open3.capture3("bundle exec rbs -I sig validate")
24
+ end
25
+
26
+ if status.success?
27
+ puts "✓ RBS validation passed"
28
+ else
29
+ puts "✗ RBS validation failed"
30
+ puts stdout unless stdout.empty?
31
+ warn stderr unless stderr.empty?
32
+ exit 1
33
+ end
34
+ end
35
+
36
+ desc "Check RBS type signatures (alias for validate)"
37
+ task check: :validate
38
+
39
+ desc "List all RBS files"
40
+ task :list do
41
+ sig_files = Dir.glob("sig/**/*.rbs")
42
+ puts "RBS type signature files:"
43
+ sig_files.each { |f| puts " #{f}" }
44
+ puts "\nTotal: #{sig_files.count} files"
45
+ end
46
+ end
47
+ # rubocop:enable Metrics/BlockLength