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/README.md +23 -22
- package/lib/ReactOnRailsProNodeRenderer.d.ts +1 -0
- package/lib/ReactOnRailsProNodeRenderer.d.ts.map +1 -1
- package/lib/ReactOnRailsProNodeRenderer.js +13 -0
- package/lib/ReactOnRailsProNodeRenderer.js.map +1 -1
- package/lib/master.d.ts.map +1 -1
- package/lib/master.js +3 -3
- package/lib/master.js.map +1 -1
- package/lib/shared/configBuilder.js +1 -1
- package/lib/shared/configBuilder.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/worker/handleRenderRequest.d.ts +1 -0
- package/lib/worker/handleRenderRequest.d.ts.map +1 -1
- package/lib/worker/handleRenderRequest.js +13 -9
- package/lib/worker/handleRenderRequest.js.map +1 -1
- package/lib/worker.d.ts.map +1 -1
- package/lib/worker.js +54 -70
- package/lib/worker.js.map +1 -1
- package/package.json +3 -3
- package/src/ReactOnRailsProNodeRenderer.ts +10 -0
- package/src/master.ts +3 -7
- package/src/shared/configBuilder.ts +5 -5
- package/src/worker/handleRenderRequest.ts +14 -11
- package/src/worker.ts +67 -85
- package/tests/parseWorkersCount.test.ts +31 -0
- package/tests/uploadRaceCondition.test.ts +9 -9
- package/tests/worker.test.ts +27 -2
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 {
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
const errorMsg =
|
|
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
|
|
336
|
-
const
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
+
[`bundle_${bundleTimestamp}`]: fs.createReadStream(getFixtureBundle()),
|
|
537
537
|
asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
|
|
538
538
|
});
|
|
539
539
|
|
package/tests/worker.test.ts
CHANGED
|
@@ -332,7 +332,7 @@ describe('worker', () => {
|
|
|
332
332
|
protocolVersion,
|
|
333
333
|
railsEnv,
|
|
334
334
|
password: 'my_password',
|
|
335
|
-
|
|
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
|
-
|
|
383
|
+
[`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()),
|
|
384
|
+
[`bundle_${bundleHashOther}`]: createReadStream(getFixtureSecondaryBundle()),
|
|
360
385
|
asset1: createReadStream(getFixtureAsset()),
|
|
361
386
|
asset2: createReadStream(getOtherFixtureAsset()),
|
|
362
387
|
});
|