react-on-rails-pro-node-renderer 16.4.0 → 16.5.1

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/src/worker.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import path from 'path';
7
7
  import cluster from 'cluster';
8
8
  import { randomUUID } from 'crypto';
9
- import { mkdir, rm } from 'fs/promises';
9
+ import { rm } from 'fs/promises';
10
10
  import fastify from 'fastify';
11
11
  import fastifyFormbody from '@fastify/formbody';
12
12
  import fastifyMultipart from '@fastify/multipart';
@@ -17,21 +17,20 @@ import fileExistsAsync from './shared/fileExistsAsync.js';
17
17
  import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/types.js';
18
18
  import checkProtocolVersion from './worker/checkProtocolVersionHandler.js';
19
19
  import authenticate from './worker/authHandler.js';
20
- import { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest.js';
20
+ import {
21
+ handleRenderRequest,
22
+ handleNewBundlesProvided,
23
+ type ProvidedNewBundle,
24
+ } from './worker/handleRenderRequest.js';
21
25
  import handleGracefulShutdown from './worker/handleGracefulShutdown.js';
22
26
  import {
23
27
  errorResponseResult,
24
28
  formatExceptionMessage,
25
- copyUploadedAssets,
26
29
  ResponseResult,
27
- workerIdLabel,
28
30
  saveMultipartFile,
29
31
  Asset,
30
32
  getAssetPath,
31
- getBundleDirectory,
32
- getRequestBundleFilePath,
33
33
  } from './shared/utils.js';
34
- import { lock, unlock } from './shared/locks.js';
35
34
  import { startSsrRequestOptions, trace } from './shared/tracing.js';
36
35
 
37
36
  // Uncomment the below for testing timeouts:
@@ -96,6 +95,42 @@ function assertAsset(value: unknown, key: string): asserts value is Asset {
96
95
  }
97
96
  }
98
97
 
98
+ /**
99
+ * Parses the multipart form body to separate bundle files from shared assets.
100
+ * Used by both the render and /upload-assets endpoints to avoid duplicating
101
+ * bundle-vs-asset classification logic.
102
+ *
103
+ * @param body The parsed multipart request body.
104
+ * @param primaryBundleTimestamp If provided, a field with key `"bundle"` is
105
+ * treated as a bundle for this timestamp (render endpoint convention).
106
+ */
107
+ function extractBundlesAndAssets(
108
+ body: Record<string, unknown>,
109
+ primaryBundleTimestamp?: string | number,
110
+ ): { providedNewBundles: ProvidedNewBundle[]; assetsToCopy: Asset[] } {
111
+ const providedNewBundles: ProvidedNewBundle[] = [];
112
+ const assetsToCopy: Asset[] = [];
113
+ Object.entries(body).forEach(([key, value]) => {
114
+ if (key === 'bundle' && primaryBundleTimestamp != null) {
115
+ assertAsset(value, key);
116
+ providedNewBundles.push({ timestamp: primaryBundleTimestamp, bundle: value });
117
+ } else if (key.startsWith('bundle_')) {
118
+ const timestamp = key.slice('bundle_'.length);
119
+ if (!timestamp) {
120
+ log.warn(
121
+ 'Received form field with key "bundle_" but no hash suffix — possible bug in the Ruby client',
122
+ );
123
+ } else {
124
+ assertAsset(value, key);
125
+ providedNewBundles.push({ timestamp, bundle: value });
126
+ }
127
+ } else if (isAsset(value)) {
128
+ assetsToCopy.push(value);
129
+ }
130
+ });
131
+ return { providedNewBundles, assetsToCopy };
132
+ }
133
+
99
134
  // Remove after this issue is resolved: https://github.com/fastify/light-my-request/issues/315
100
135
  let useHttp2 = true;
101
136
 
@@ -271,19 +306,7 @@ export default function run(config: Partial<Config>) {
271
306
 
272
307
  const { renderingRequest } = req.body;
273
308
  const { bundleTimestamp } = req.params;
274
- const providedNewBundles: ProvidedNewBundle[] = [];
275
- const assetsToCopy: Asset[] = [];
276
- Object.entries(req.body).forEach(([key, value]) => {
277
- if (key === 'bundle') {
278
- assertAsset(value, key);
279
- providedNewBundles.push({ timestamp: bundleTimestamp, bundle: value });
280
- } else if (key.startsWith('bundle_')) {
281
- assertAsset(value, key);
282
- providedNewBundles.push({ timestamp: key.replace('bundle_', ''), bundle: value });
283
- } else if (isAsset(value)) {
284
- assetsToCopy.push(value);
285
- }
286
- });
309
+ const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body, bundleTimestamp);
287
310
 
288
311
  try {
289
312
  const dependencyBundleTimestamps = extractBodyArrayField(req.body, 'dependencyBundleTimestamps');
@@ -315,88 +338,47 @@ export default function run(config: Partial<Config>) {
315
338
 
316
339
  // There can be additional files that might be required at the runtime.
317
340
  // Since the remote renderer doesn't contain any assets, they must be uploaded manually.
341
+ // Bundle files use the form key convention "bundle_<hash>" and are placed in
342
+ // their own directory; remaining assets are copied to every bundle directory.
318
343
  app.post<{
319
- Body: WithBodyArrayField<Record<string, Asset>, 'targetBundles'>;
344
+ Body: Record<string, unknown>;
320
345
  }>('/upload-assets', async (req, res) => {
321
346
  if (!(await requestPrechecks(req, res))) {
322
347
  return;
323
348
  }
324
- const assets: Asset[] = Object.values(req.body).filter(isAsset);
325
349
 
326
- // Handle targetBundles as either a string or an array
327
- const targetBundles = extractBodyArrayField(req.body, 'targetBundles');
328
- if (!targetBundles || targetBundles.length === 0) {
329
- const errorMsg = 'No targetBundles provided. As of protocol version 2.0.0, targetBundles is required.';
350
+ const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body);
351
+
352
+ if (providedNewBundles.length === 0) {
353
+ const errorMsg =
354
+ 'No bundle_<hash> fields provided. ' +
355
+ 'The /upload-assets endpoint requires at least one bundle file with a "bundle_<hash>" form key.';
330
356
  log.error(errorMsg);
331
357
  await setResponse(errorResponseResult(errorMsg), res);
332
358
  return;
333
359
  }
334
360
 
335
- const assetsDescription = JSON.stringify(assets.map((asset) => asset.filename));
336
- const taskDescription = `Uploading files ${assetsDescription} to bundle directories: ${targetBundles.join(', ')}`;
337
-
361
+ const bundleNames = providedNewBundles.map((b) => b.bundle.filename);
362
+ const assetNames = assetsToCopy.map((a) => a.filename);
363
+ const taskDescription = `Uploading bundles [${bundleNames.join(', ')}] with assets [${assetNames.join(', ')}]`;
338
364
  log.info(taskDescription);
339
- try {
340
- // Use per-bundle locks (same lock key as handleRenderRequest) so that
341
- // asset copies and render-request bundle writes to the same directory
342
- // are mutually exclusive. See https://github.com/shakacode/react_on_rails/issues/2463
343
- //
344
- // Use allSettled (not Promise.all) to ensure every in-flight copy
345
- // finishes before the handler returns. Otherwise the onResponse hook
346
- // can delete req.uploadDir while background copies still read from it.
347
- const copyPromises = targetBundles.map(async (bundleTimestamp) => {
348
- const bundleDirectory = getBundleDirectory(bundleTimestamp);
349
- await mkdir(bundleDirectory, { recursive: true });
350
-
351
- const bundleFilePath = getRequestBundleFilePath(bundleTimestamp);
352
- const { lockfileName, wasLockAcquired, errorMessage } = await lock(bundleFilePath);
353
-
354
- if (!wasLockAcquired) {
355
- const msg = formatExceptionMessage(
356
- taskDescription,
357
- errorMessage,
358
- `Failed to acquire lock ${lockfileName}. Worker: ${workerIdLabel()}.`,
359
- );
360
- throw new Error(msg);
361
- }
362
365
 
363
- try {
364
- await copyUploadedAssets(assets, bundleDirectory);
365
- log.info(`Copied assets to bundle directory: ${bundleDirectory}`);
366
- } finally {
367
- try {
368
- await unlock(lockfileName);
369
- } catch (error) {
370
- log.warn({
371
- msg: `Error unlocking ${lockfileName} from worker ${workerIdLabel()}`,
372
- err: error,
373
- task: taskDescription,
374
- });
375
- }
376
- }
377
- });
378
-
379
- const results = await Promise.allSettled(copyPromises);
380
- const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
381
- if (firstFailure) {
382
- throw firstFailure.reason;
366
+ try {
367
+ // Reuses the same per-bundle lock + move/copy logic as the render
368
+ // endpoint so that concurrent /upload-assets and render requests
369
+ // targeting the same bundle directory are mutually exclusive.
370
+ // See https://github.com/shakacode/react_on_rails/issues/2463
371
+ const result = await handleNewBundlesProvided(taskDescription, providedNewBundles, assetsToCopy);
372
+ if (result) {
373
+ await setResponse(result, res);
374
+ return;
383
375
  }
384
376
 
385
- await setResponse(
386
- {
387
- status: 200,
388
- headers: {},
389
- },
390
- res,
391
- );
377
+ await setResponse({ status: 200, headers: {} }, res);
392
378
  } catch (err) {
393
- const msg = 'ERROR when trying to copy assets';
379
+ const msg = 'ERROR when trying to upload bundles and assets';
394
380
  const message = `${msg}. ${err}. Task: ${taskDescription}`;
395
- log.error({
396
- msg,
397
- err,
398
- task: taskDescription,
399
- });
381
+ log.error({ msg, err, task: taskDescription });
400
382
  await setResponse(errorResponseResult(message), res);
401
383
  }
402
384
  });
@@ -0,0 +1,31 @@
1
+ import { parseWorkersCount } from '../src/ReactOnRailsProNodeRenderer';
2
+
3
+ describe('parseWorkersCount', () => {
4
+ afterEach(() => {
5
+ jest.restoreAllMocks();
6
+ });
7
+
8
+ it('returns null for missing and blank values', () => {
9
+ expect(parseWorkersCount(undefined)).toBeNull();
10
+ expect(parseWorkersCount(null)).toBeNull();
11
+ expect(parseWorkersCount('')).toBeNull();
12
+ expect(parseWorkersCount(' ')).toBeNull();
13
+ });
14
+
15
+ it('parses non-negative integers', () => {
16
+ expect(parseWorkersCount('0')).toBe(0);
17
+ expect(parseWorkersCount('3')).toBe(3);
18
+ expect(parseWorkersCount(' 4 ')).toBe(4);
19
+ });
20
+
21
+ it('warns and returns null for invalid values', () => {
22
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
23
+
24
+ expect(parseWorkersCount('workers')).toBeNull();
25
+ expect(parseWorkersCount('3.5')).toBeNull();
26
+ expect(parseWorkersCount('-1')).toBeNull();
27
+
28
+ expect(warnSpy).toHaveBeenCalledTimes(3);
29
+ expect(warnSpy.mock.calls[0][0]).toContain('Ignoring invalid worker count');
30
+ });
31
+ });
@@ -23,7 +23,7 @@ import formAutoContent from 'form-auto-content';
23
23
  // eslint-disable-next-line import/no-relative-packages
24
24
  import packageJson from '../package.json';
25
25
  import worker, { disableHttp2 } from '../src/worker';
26
- import { resetForTest, serverBundleCachePath, getFixtureBundle } from './helper';
26
+ import { resetForTest, serverBundleCachePath, getFixtureBundle, getFixtureSecondaryBundle } from './helper';
27
27
 
28
28
  const testName = 'uploadRaceCondition';
29
29
  const serverBundleCachePathForTest = () => serverBundleCachePath(testName);
@@ -176,14 +176,14 @@ describe('concurrent upload isolation (issue #2449)', () => {
176
176
  gemVersion,
177
177
  protocolVersion,
178
178
  railsEnv,
179
- targetBundles: [bundleHashA],
179
+ [`bundle_${bundleHashA}`]: fs.createReadStream(getFixtureBundle()),
180
180
  asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
181
181
  });
182
182
  const formB = formAutoContent({
183
183
  gemVersion,
184
184
  protocolVersion,
185
185
  railsEnv,
186
- targetBundles: [bundleHashB],
186
+ [`bundle_${bundleHashB}`]: fs.createReadStream(getFixtureBundle()),
187
187
  asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
188
188
  });
189
189
 
@@ -282,7 +282,7 @@ describe('concurrent upload isolation (issue #2449)', () => {
282
282
  gemVersion,
283
283
  protocolVersion,
284
284
  railsEnv,
285
- targetBundles: [bundleHashA],
285
+ [`bundle_${bundleHashA}`]: fs.createReadStream(getFixtureBundle()),
286
286
  asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
287
287
  asset2: fs.createReadStream(path.join(tmpDirA, 'manifest.json')),
288
288
  });
@@ -290,7 +290,7 @@ describe('concurrent upload isolation (issue #2449)', () => {
290
290
  gemVersion,
291
291
  protocolVersion,
292
292
  railsEnv,
293
- targetBundles: [bundleHashB],
293
+ [`bundle_${bundleHashB}`]: fs.createReadStream(getFixtureBundle()),
294
294
  asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
295
295
  asset2: fs.createReadStream(path.join(tmpDirB, 'manifest.json')),
296
296
  });
@@ -349,14 +349,14 @@ describe('concurrent upload isolation (issue #2449)', () => {
349
349
  gemVersion,
350
350
  protocolVersion,
351
351
  railsEnv,
352
- targetBundles: [sharedBundleHash],
352
+ [`bundle_${sharedBundleHash}`]: fs.createReadStream(getFixtureBundle()),
353
353
  asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
354
354
  });
355
355
  const formB = formAutoContent({
356
356
  gemVersion,
357
357
  protocolVersion,
358
358
  railsEnv,
359
- targetBundles: [sharedBundleHash],
359
+ [`bundle_${sharedBundleHash}`]: fs.createReadStream(getFixtureBundle()),
360
360
  asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
361
361
  });
362
362
 
@@ -528,12 +528,12 @@ describe('concurrent upload isolation (issue #2449)', () => {
528
528
  asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
529
529
  });
530
530
 
531
- // Upload-assets request: sends the same-named asset to the same bundle
531
+ // Upload-assets request: sends bundle + the same-named asset to the same bundle
532
532
  const uploadForm = formAutoContent({
533
533
  gemVersion,
534
534
  protocolVersion,
535
535
  railsEnv,
536
- targetBundles: [bundleTimestamp],
536
+ [`bundle_${bundleTimestamp}`]: fs.createReadStream(getFixtureBundle()),
537
537
  asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
538
538
  });
539
539
 
@@ -332,7 +332,7 @@ describe('worker', () => {
332
332
  protocolVersion,
333
333
  railsEnv,
334
334
  password: 'my_password',
335
- targetBundles: [bundleHash],
335
+ [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()),
336
336
  asset1: createReadStream(getFixtureAsset()),
337
337
  asset2: createReadStream(getOtherFixtureAsset()),
338
338
  });
@@ -342,6 +342,30 @@ describe('worker', () => {
342
342
  expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true);
343
343
  });
344
344
 
345
+ test('post /upload-assets ignores targetBundles when bundle_<hash> fields are present (backward compat)', async () => {
346
+ const bundleHash = 'compat-bundle-hash';
347
+
348
+ const app = worker({
349
+ serverBundleCachePath: serverBundleCachePathForTest(),
350
+ password: 'my_password',
351
+ });
352
+
353
+ // Simulates the Ruby client sending both bundle_<hash> (new) and targetBundles (legacy).
354
+ // The endpoint should derive targets from bundle_<hash> and ignore targetBundles.
355
+ const form = formAutoContent({
356
+ gemVersion,
357
+ protocolVersion,
358
+ railsEnv,
359
+ password: 'my_password',
360
+ [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()),
361
+ targetBundles: [bundleHash],
362
+ asset1: createReadStream(getFixtureAsset()),
363
+ });
364
+ const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end();
365
+ expect(res.statusCode).toBe(200);
366
+ expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true);
367
+ });
368
+
345
369
  test('post /upload-assets with multiple bundles and assets', async () => {
346
370
  const bundleHash = 'some-bundle-hash';
347
371
  const bundleHashOther = 'some-other-bundle-hash';
@@ -356,7 +380,8 @@ describe('worker', () => {
356
380
  protocolVersion,
357
381
  railsEnv,
358
382
  password: 'my_password',
359
- targetBundles: [bundleHash, bundleHashOther],
383
+ [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()),
384
+ [`bundle_${bundleHashOther}`]: createReadStream(getFixtureSecondaryBundle()),
360
385
  asset1: createReadStream(getFixtureAsset()),
361
386
  asset2: createReadStream(getOtherFixtureAsset()),
362
387
  });