react-on-rails-pro-node-renderer 16.6.0-rc.0 → 16.6.0

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 (41) hide show
  1. package/lib/master.d.ts.map +1 -1
  2. package/lib/master.js +56 -4
  3. package/lib/master.js.map +1 -1
  4. package/lib/shared/configBuilder.d.ts.map +1 -1
  5. package/lib/shared/configBuilder.js +15 -0
  6. package/lib/shared/configBuilder.js.map +1 -1
  7. package/lib/shared/utils.d.ts +9 -2
  8. package/lib/shared/utils.d.ts.map +1 -1
  9. package/lib/shared/utils.js +15 -8
  10. package/lib/shared/utils.js.map +1 -1
  11. package/lib/shared/workerMessages.d.ts +13 -0
  12. package/lib/shared/workerMessages.d.ts.map +1 -0
  13. package/lib/shared/workerMessages.js +23 -0
  14. package/lib/shared/workerMessages.js.map +1 -0
  15. package/lib/tsconfig.tsbuildinfo +1 -1
  16. package/lib/worker/handleRenderRequest.d.ts +2 -2
  17. package/lib/worker/handleRenderRequest.d.ts.map +1 -1
  18. package/lib/worker/handleRenderRequest.js +5 -5
  19. package/lib/worker/handleRenderRequest.js.map +1 -1
  20. package/lib/worker/startupErrorHandler.d.ts +10 -0
  21. package/lib/worker/startupErrorHandler.d.ts.map +1 -0
  22. package/lib/worker/startupErrorHandler.js +57 -0
  23. package/lib/worker/startupErrorHandler.js.map +1 -0
  24. package/lib/worker/vm.js +2 -2
  25. package/lib/worker/vm.js.map +1 -1
  26. package/lib/worker.d.ts.map +1 -1
  27. package/lib/worker.js +73 -9
  28. package/lib/worker.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/master.ts +64 -4
  31. package/src/shared/configBuilder.ts +18 -0
  32. package/src/shared/utils.ts +18 -8
  33. package/src/shared/workerMessages.ts +34 -0
  34. package/src/worker/handleRenderRequest.ts +18 -7
  35. package/src/worker/startupErrorHandler.ts +65 -0
  36. package/src/worker/vm.ts +2 -2
  37. package/src/worker.ts +86 -15
  38. package/tests/configBuilder.test.ts +34 -0
  39. package/tests/masterStartupFailure.test.ts +295 -0
  40. package/tests/worker.test.ts +148 -0
  41. package/tests/workerStartupFailure.test.ts +250 -0
@@ -0,0 +1,34 @@
1
+ export const WORKER_STARTUP_FAILURE = 'NODE_RENDERER_WORKER_STARTUP_FAILURE' as const;
2
+
3
+ export interface WorkerStartupFailureMessage {
4
+ type: typeof WORKER_STARTUP_FAILURE;
5
+ stage: 'listen';
6
+ code?: string;
7
+ errno?: number;
8
+ syscall?: string;
9
+ host: string;
10
+ port: number;
11
+ message: string;
12
+ }
13
+
14
+ export function isWorkerStartupFailureMessage(value: unknown): value is WorkerStartupFailureMessage {
15
+ if (typeof value !== 'object' || value === null) {
16
+ return false;
17
+ }
18
+
19
+ const message = value as Partial<WorkerStartupFailureMessage>;
20
+
21
+ // stage: 'listen' is the only supported stage today. To handle pre-listen
22
+ // failures (e.g. plugin registration), add a new stage value here and
23
+ // update the master handler accordingly.
24
+ return (
25
+ message.type === WORKER_STARTUP_FAILURE &&
26
+ message.stage === 'listen' &&
27
+ typeof message.host === 'string' &&
28
+ typeof message.port === 'number' &&
29
+ Number.isInteger(message.port) &&
30
+ message.port >= 0 &&
31
+ message.port <= 65535 &&
32
+ typeof message.message === 'string'
33
+ );
34
+ }
@@ -22,6 +22,7 @@ import {
22
22
  isReadableStream,
23
23
  isErrorRenderResult,
24
24
  getRequestBundleFilePath,
25
+ type RequestInfo,
25
26
  } from '../shared/utils.js';
26
27
  import { getConfig } from '../shared/configBuilder.js';
27
28
  import type { TracingContext } from '../shared/tracing.js';
@@ -41,8 +42,14 @@ async function prepareResult(
41
42
 
42
43
  let exceptionMessage = null;
43
44
  if (!result) {
44
- const error = new Error('INVALID NIL or NULL result for rendering');
45
- exceptionMessage = formatExceptionMessage(renderingRequest, error, 'INVALID result for prepareResult');
45
+ const error = new Error(
46
+ 'INVALID NIL or NULL result for rendering. Ensure renderingRequest is a valid string and returns a value.',
47
+ );
48
+ exceptionMessage = formatExceptionMessage(
49
+ { renderingRequest },
50
+ error,
51
+ 'INVALID result for prepareResult',
52
+ );
46
53
  } else if (isErrorRenderResult(result)) {
47
54
  ({ exceptionMessage } = result);
48
55
  }
@@ -65,7 +72,11 @@ async function prepareResult(
65
72
  data: result,
66
73
  };
67
74
  } catch (err) {
68
- const exceptionMessage = formatExceptionMessage(renderingRequest, err, 'Unknown error calling runInVM');
75
+ const exceptionMessage = formatExceptionMessage(
76
+ { renderingRequest },
77
+ err,
78
+ 'Unknown error calling runInVM',
79
+ );
69
80
  return errorResponseResult(exceptionMessage);
70
81
  }
71
82
  }
@@ -77,7 +88,7 @@ async function prepareResult(
77
88
  * @param assetsToCopy might be null
78
89
  */
79
90
  async function handleNewBundleProvided(
80
- requestContext: string,
91
+ requestContext: RequestInfo,
81
92
  providedNewBundle: ProvidedNewBundle,
82
93
  assetsToCopy: Asset[] | null | undefined,
83
94
  ): Promise<ResponseResult | undefined> {
@@ -154,7 +165,7 @@ to ${bundleFilePathPerTimestamp})`,
154
165
  }
155
166
 
156
167
  export async function handleNewBundlesProvided(
157
- requestContext: string,
168
+ requestContext: RequestInfo,
158
169
  providedNewBundles: ProvidedNewBundle[],
159
170
  assetsToCopy: Asset[] | null | undefined,
160
171
  ): Promise<ResponseResult | undefined> {
@@ -226,7 +237,7 @@ export async function handleRenderRequest({
226
237
 
227
238
  // If gem has posted updated bundle:
228
239
  if (providedNewBundles && providedNewBundles.length > 0) {
229
- const result = await handleNewBundlesProvided(renderingRequest, providedNewBundles, assetsToCopy);
240
+ const result = await handleNewBundlesProvided({ renderingRequest }, providedNewBundles, assetsToCopy);
230
241
  if (result) {
231
242
  return result;
232
243
  }
@@ -261,7 +272,7 @@ export async function handleRenderRequest({
261
272
  return await prepareResult(renderingRequest, entryBundleFilePath);
262
273
  } catch (error) {
263
274
  const msg = formatExceptionMessage(
264
- renderingRequest,
275
+ { renderingRequest },
265
276
  error,
266
277
  'Caught top level error in handleRenderRequest',
267
278
  );
@@ -0,0 +1,65 @@
1
+ import cluster from 'cluster';
2
+ import log from '../shared/log.js';
3
+ import { WORKER_STARTUP_FAILURE, type WorkerStartupFailureMessage } from '../shared/workerMessages.js';
4
+
5
+ export type StartupListenErrorHandlerOptions = {
6
+ err: Error;
7
+ host: string;
8
+ port: number;
9
+ isWorker?: boolean;
10
+ send?: NodeJS.Process['send'];
11
+ exit?: NodeJS.Process['exit'];
12
+ };
13
+
14
+ export function handleStartupListenError({
15
+ err,
16
+ host,
17
+ port,
18
+ isWorker = cluster.isWorker,
19
+ send,
20
+ exit,
21
+ }: StartupListenErrorHandlerOptions) {
22
+ const sendFn = send ?? process.send?.bind(process);
23
+ const exitFn = exit ?? ((code?: number) => process.exit(code));
24
+
25
+ log.error({ err, host, port }, 'Node renderer failed to start');
26
+
27
+ if (isWorker) {
28
+ if (!sendFn) {
29
+ log.error('Cluster worker has no IPC channel; cannot notify master of startup failure');
30
+ exitFn(1);
31
+ return;
32
+ }
33
+
34
+ const startupFailure: WorkerStartupFailureMessage = {
35
+ type: WORKER_STARTUP_FAILURE,
36
+ stage: 'listen',
37
+ code: (err as NodeJS.ErrnoException).code,
38
+ errno: (err as NodeJS.ErrnoException).errno,
39
+ syscall: (err as NodeJS.ErrnoException).syscall,
40
+ host,
41
+ port,
42
+ message: err.message,
43
+ };
44
+ try {
45
+ let exited = false;
46
+ const doExit = (sendErr?: Error | null) => {
47
+ if (exited) return;
48
+ exited = true;
49
+ if (sendErr) log.error({ err: sendErr }, 'Failed to send startup failure message to master');
50
+ exitFn(1);
51
+ };
52
+ sendFn(startupFailure, undefined, undefined, doExit);
53
+ // Safety net: if the IPC channel is half-broken the callback may never
54
+ // fire, leaving this worker alive indefinitely. Force exit after a timeout.
55
+ const IPC_SEND_TIMEOUT_MS = 2000;
56
+ const timer = setTimeout(() => doExit(), IPC_SEND_TIMEOUT_MS);
57
+ if (typeof timer.unref === 'function') timer.unref();
58
+ } catch (sendErr) {
59
+ log.error({ err: sendErr as Error }, 'Failed to send startup failure message to master');
60
+ exitFn(1);
61
+ }
62
+ } else {
63
+ exitFn(1);
64
+ }
65
+ }
package/src/worker/vm.ts CHANGED
@@ -153,7 +153,7 @@ ${smartTrim(renderingRequest)}`);
153
153
 
154
154
  if (isReadableStream(result)) {
155
155
  const newStreamAfterHandlingError = handleStreamError(result, (error) => {
156
- const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream');
156
+ const msg = formatExceptionMessage({ renderingRequest }, error, 'Error in a rendering stream');
157
157
  errorReporter.message(msg);
158
158
  });
159
159
  return newStreamAfterHandlingError;
@@ -172,7 +172,7 @@ ${smartTrim(result)}`);
172
172
 
173
173
  return result;
174
174
  } catch (exception) {
175
- const exceptionMessage = formatExceptionMessage(renderingRequest, exception);
175
+ const exceptionMessage = formatExceptionMessage({ renderingRequest }, exception);
176
176
  log.debug('Caught exception in rendering request: %s', exceptionMessage);
177
177
  return Promise.resolve({ exceptionMessage });
178
178
  }
package/src/worker.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  type ProvidedNewBundle,
24
24
  } from './worker/handleRenderRequest.js';
25
25
  import handleGracefulShutdown from './worker/handleGracefulShutdown.js';
26
+ import { handleStartupListenError } from './worker/startupErrorHandler.js';
26
27
  import {
28
+ badRequestResponseResult,
27
29
  errorResponseResult,
28
30
  formatExceptionMessage,
29
31
  ResponseResult,
@@ -141,6 +143,56 @@ export const disableHttp2 = () => {
141
143
 
142
144
  type WithBodyArrayField<T, K extends string> = T & { [P in K | `${K}[]`]?: string | string[] };
143
145
 
146
+ const INVALID_CONTENT_LENGTH_ERROR_CODE = 'FST_ERR_CTP_INVALID_CONTENT_LENGTH';
147
+
148
+ const errorCode = (error: unknown): string | undefined => {
149
+ const code = (error as { code?: unknown })?.code;
150
+ return typeof code === 'string' ? code : undefined;
151
+ };
152
+
153
+ const isValidRenderingRequest = (value: unknown): value is string =>
154
+ typeof value === 'string' && value.length > 0;
155
+
156
+ const SENSITIVE_REQUEST_BODY_KEYS = new Set([
157
+ 'password',
158
+ 'token',
159
+ 'secret',
160
+ 'api_key',
161
+ 'api-key',
162
+ 'apikey',
163
+ 'authorization',
164
+ 'auth_token',
165
+ 'auth-token',
166
+ 'authtoken',
167
+ 'access_token',
168
+ 'accesstoken',
169
+ 'bearer',
170
+ 'credentials',
171
+ ]);
172
+
173
+ const invalidRenderingRequestMessage = (body: Record<string, unknown>) => {
174
+ const { renderingRequest } = body;
175
+ let renderingRequestType: string = typeof renderingRequest;
176
+ if (renderingRequest === null) {
177
+ renderingRequestType = 'null';
178
+ } else if (Array.isArray(renderingRequest)) {
179
+ renderingRequestType = 'array';
180
+ } else if (renderingRequest === '') {
181
+ renderingRequestType = 'empty string';
182
+ }
183
+ const bodyKeys = Object.keys(body).filter(
184
+ (key) => key !== 'renderingRequest' && !SENSITIVE_REQUEST_BODY_KEYS.has(key.toLowerCase()),
185
+ );
186
+
187
+ return [
188
+ 'Invalid "renderingRequest" field in render request.',
189
+ 'Expected a non-empty string of JavaScript to execute in the SSR VM.',
190
+ `Received type: ${renderingRequestType}.`,
191
+ `Received body keys: ${bodyKeys.length > 0 ? bodyKeys.join(', ') : '(none)'}.`,
192
+ 'Likely causes: request body truncation, malformed multipart form data, or Content-Length mismatch in a proxy/client.',
193
+ ].join('\n');
194
+ };
195
+
144
196
  const extractBodyArrayField = <Key extends string>(
145
197
  body: WithBodyArrayField<Record<string, unknown>, Key>,
146
198
  key: Key,
@@ -178,7 +230,17 @@ export default function run(config: Partial<Config>) {
178
230
  // We shouldn't have unhandled errors here, but just in case
179
231
  app.addHook('onError', (req, res, err, done) => {
180
232
  // Not errorReporter.error so that integrations can decide how to log the errors.
181
- app.log.error({ msg: 'Unhandled Fastify error', err, req, res });
233
+ if (errorCode(err) === INVALID_CONTENT_LENGTH_ERROR_CODE) {
234
+ app.log.error({
235
+ msg: 'Invalid request body framing',
236
+ hint: 'Body size did not match Content-Length. Check client/proxy truncation and Content-Length handling.',
237
+ err,
238
+ req,
239
+ res,
240
+ });
241
+ } else {
242
+ app.log.error({ msg: 'Unhandled Fastify error', err, req, res });
243
+ }
182
244
  done();
183
245
  });
184
246
 
@@ -283,12 +345,7 @@ export default function run(config: Partial<Config>) {
283
345
  // the digest is part of the request URL. Yes, it's not used here, but the
284
346
  // server logs might show it to distinguish different requests.
285
347
  app.post<{
286
- Body: WithBodyArrayField<
287
- {
288
- renderingRequest: string;
289
- },
290
- 'dependencyBundleTimestamps'
291
- >;
348
+ Body: WithBodyArrayField<Record<string, unknown>, 'dependencyBundleTimestamps'>;
292
349
  // Can't infer from the route like Express can
293
350
  Params: { bundleTimestamp: string; renderRequestDigest: string };
294
351
  }>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => {
@@ -307,12 +364,22 @@ export default function run(config: Partial<Config>) {
307
364
  // await delay(100000);
308
365
  // }
309
366
 
310
- const { renderingRequest } = req.body;
367
+ const { body } = req;
368
+ if (!body || typeof body !== 'object') {
369
+ await setResponse(badRequestResponseResult('Invalid or missing request body.'), res);
370
+ return;
371
+ }
372
+ const { renderingRequest } = body;
373
+ if (!isValidRenderingRequest(renderingRequest)) {
374
+ await setResponse(badRequestResponseResult(invalidRenderingRequestMessage(body)), res);
375
+ return;
376
+ }
377
+
311
378
  const { bundleTimestamp } = req.params;
312
- const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body, bundleTimestamp);
379
+ const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(body, bundleTimestamp);
313
380
 
314
381
  try {
315
- const dependencyBundleTimestamps = extractBodyArrayField(req.body, 'dependencyBundleTimestamps');
382
+ const dependencyBundleTimestamps = extractBodyArrayField(body, 'dependencyBundleTimestamps');
316
383
  await trace(async (context) => {
317
384
  try {
318
385
  const result = await handleRenderRequest({
@@ -326,7 +393,7 @@ export default function run(config: Partial<Config>) {
326
393
  await setResponse(result, res);
327
394
  } catch (err) {
328
395
  const exceptionMessage = formatExceptionMessage(
329
- renderingRequest,
396
+ { renderingRequest },
330
397
  err,
331
398
  'UNHANDLED error in handleRenderRequest',
332
399
  );
@@ -334,7 +401,7 @@ export default function run(config: Partial<Config>) {
334
401
  }
335
402
  }, startSsrRequestOptions({ renderingRequest }));
336
403
  } catch (theErr) {
337
- const exceptionMessage = formatExceptionMessage(renderingRequest, theErr);
404
+ const exceptionMessage = formatExceptionMessage({ renderingRequest }, theErr);
338
405
  await setResponse(errorResponseResult(`Unhandled top level error: ${exceptionMessage}`), res);
339
406
  }
340
407
  });
@@ -371,7 +438,11 @@ export default function run(config: Partial<Config>) {
371
438
  // endpoint so that concurrent /upload-assets and render requests
372
439
  // targeting the same bundle directory are mutually exclusive.
373
440
  // See https://github.com/shakacode/react_on_rails/issues/2463
374
- const result = await handleNewBundlesProvided(taskDescription, providedNewBundles, assetsToCopy);
441
+ const result = await handleNewBundlesProvided(
442
+ { label: 'Request:', content: taskDescription },
443
+ providedNewBundles,
444
+ assetsToCopy,
445
+ );
375
446
  if (result) {
376
447
  await setResponse(result, res);
377
448
  return;
@@ -449,8 +520,8 @@ export default function run(config: Partial<Config>) {
449
520
  if (workersCount === 0 || cluster.isWorker) {
450
521
  app.listen({ port, host }, (err, address) => {
451
522
  if (err) {
452
- log.error({ err, host, port }, 'Node renderer failed to start');
453
- process.exit(1);
523
+ handleStartupListenError({ err, host, port });
524
+ return;
454
525
  }
455
526
  const workerName = worker ? `worker #${worker.id}` : 'master (single-process)';
456
527
  log.info({ workerName, address }, 'Node renderer listening');
@@ -1,6 +1,7 @@
1
1
  describe('configBuilder', () => {
2
2
  const envVarsToRestore = [
3
3
  'RENDERER_HOST',
4
+ 'RENDERER_PORT',
4
5
  'NODE_ENV',
5
6
  'RENDERER_PASSWORD',
6
7
  'RAILS_ENV',
@@ -113,6 +114,39 @@ describe('configBuilder', () => {
113
114
  expect(finalSettings.password).toBe('<EMPTY STRING>');
114
115
  });
115
116
 
117
+ describe('port validation', () => {
118
+ it('throws when configured port is outside the valid TCP range', () => {
119
+ process.env.NODE_ENV = 'development';
120
+ process.env.RAILS_ENV = 'development';
121
+ const processExit = mockProcessExit();
122
+ const { buildConfig, error } = loadConfigBuilderWithMockedLogger();
123
+
124
+ expect(() => buildConfig({ port: 70000 })).toThrow('process.exit: 1');
125
+ expect(processExit).toHaveBeenCalledWith(1);
126
+ expect(error).toHaveBeenCalledWith(
127
+ 'RENDERER_PORT must be an integer between 0 and 65535. Received: 70000',
128
+ );
129
+ });
130
+
131
+ it('allows port 0 for ephemeral-port test setups', () => {
132
+ process.env.NODE_ENV = 'development';
133
+ process.env.RAILS_ENV = 'development';
134
+ const { buildConfig } = loadConfigBuilderWithMockedLogger();
135
+
136
+ expect(buildConfig({ port: 0 }).port).toBe(0);
137
+ });
138
+
139
+ it('coerces a string port from env vars to a number', () => {
140
+ process.env.NODE_ENV = 'development';
141
+ process.env.RAILS_ENV = 'development';
142
+ const { buildConfig } = loadConfigBuilderWithMockedLogger();
143
+
144
+ // Simulates `port: env.RENDERER_PORT || 3800` where env var is the string "3800"
145
+ const config = buildConfig({ port: '3800' as unknown as number });
146
+ expect(config.port).toBe(3800);
147
+ });
148
+ });
149
+
116
150
  describe('password validation in production-like environments', () => {
117
151
  it('throws when no password is set in production', () => {
118
152
  process.env.NODE_ENV = 'production';