Skip to content

Commit b54e3bf

Browse files
committed
fix(aws-lambda-nodejs): address PR review findings for spawn refactor
- Use getTsconfigCompilerOptionsArray in Docker path instead of naive string splitting to properly handle compiler option values with spaces - Wrap callback step operations with try-catch for contextual error messages during local bundling file operations - Add regression tests for Docker preCompilation tsconfig handling - Add test for callback failure error context - Add getTsconfigCompilerOptionsArray parity tests in util.test.ts - Remove unused getTsconfigCompilerOptions import from bundling.ts
1 parent 6e5b253 commit b54e3bf

File tree

3 files changed

+134
-17
lines changed

3 files changed

+134
-17
lines changed

packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PackageInstallation } from './package-installation';
66
import { LockFile, PackageManager } from './package-manager';
77
import type { BundlingOptions } from './types';
88
import { OutputFormat, SourceMapMode } from './types';
9-
import { exec, extractDependencies, findUp, getTsconfigCompilerOptions, getTsconfigCompilerOptionsArray, isSdkV2Runtime } from './util';
9+
import { exec, extractDependencies, findUp, getTsconfigCompilerOptionsArray, isSdkV2Runtime } from './util';
1010
import type { Architecture, AssetCode } from '../../aws-lambda';
1111
import { Code, Runtime } from '../../aws-lambda';
1212
import * as cdk from '../../core';
@@ -269,8 +269,8 @@ export class Bundling implements cdk.BundlingOptions {
269269
if (!tsconfig) {
270270
throw new ValidationError('CannotFindTsconfigJsonPre', 'Cannot find a `tsconfig.json` but `preCompilation` is set to `true`, please specify it via `tsconfig`', scope);
271271
}
272-
const compilerOptions = getTsconfigCompilerOptions(tsconfig);
273-
tscCommand = preparePosixShellCommand([options.tscRunner!, relativeEntryPath, ...compilerOptions.split(/\s+/).filter(Boolean)]);
272+
const compilerOptionsArray = getTsconfigCompilerOptionsArray(tsconfig);
273+
tscCommand = preparePosixShellCommand([options.tscRunner!, relativeEntryPath, ...compilerOptionsArray]);
274274
relativeEntryPath = relativeEntryPath.replace(/\.ts(x?)$/, '.js$1');
275275
}
276276

@@ -371,7 +371,11 @@ export class Bundling implements cdk.BundlingOptions {
371371
});
372372
break;
373373
case 'callback':
374-
step.operation();
374+
try {
375+
step.operation();
376+
} catch (err) {
377+
throw new Error(`Local bundling file operation failed: ${err instanceof Error ? err.message : String(err)}`);
378+
}
375379
break;
376380
}
377381
}

packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -253,22 +253,22 @@ test('esbuild bundling with esbuild options', () => {
253253
});
254254

255255
// Correctly bundles with esbuild
256-
const defineInstructions = "'--define:process.env.KEY=\"VALUE\"' '--define:process.env.BOOL=true' '--define:process.env.NUMBER=7777' '--define:process.env.STRING=\"this is a \\\"test\\\"\"'";
256+
const defineInstructions = '\'--define:process.env.KEY="VALUE"\' \'--define:process.env.BOOL=true\' \'--define:process.env.NUMBER=7777\' \'--define:process.env.STRING="this is a \\"test\\""\'';
257257
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(depsLockFilePath), {
258258
assetHashType: AssetHashType.OUTPUT,
259259
bundling: expect.objectContaining({
260260
command: [
261261
'bash',
262262
'-c',
263263
[
264-
`'esbuild' '--bundle' '/asset-input/lib/handler.ts'`,
265-
`'--target=es2020' '--platform=node' '--format=esm' '--outfile=/asset-output/index.mjs'`,
264+
'\'esbuild\' \'--bundle\' \'/asset-input/lib/handler.ts\'',
265+
'\'--target=es2020\' \'--platform=node\' \'--format=esm\' \'--outfile=/asset-output/index.mjs\'',
266266
`'--minify' '--sourcemap' '--sources-content=false' '--external:${STANDARD_EXTERNAL}' '--loader:.png=dataurl'`,
267267
defineInstructions,
268-
`'--log-level=silent' '--keep-names' '--tsconfig=/asset-input/lib/custom-tsconfig.ts'`,
269-
`'--metafile=/asset-output/index.meta.json' '--banner:js=/* comments */' '--footer:js=/* comments */'`,
270-
`'--main-fields=module,main' '--inject:./my-shim.js' '--inject:./path with space/second-shim.js'`,
271-
`'--log-limit=0' '--resolve-extensions=.ts,.js' '--splitting' '--keep-names' '--out-extension:.js=.mjs'`,
268+
'\'--log-level=silent\' \'--keep-names\' \'--tsconfig=/asset-input/lib/custom-tsconfig.ts\'',
269+
'\'--metafile=/asset-output/index.meta.json\' \'--banner:js=/* comments */\' \'--footer:js=/* comments */\'',
270+
'\'--main-fields=module,main\' \'--inject:./my-shim.js\' \'--inject:./path with space/second-shim.js\'',
271+
'\'--log-limit=0\' \'--resolve-extensions=.ts,.js\' \'--splitting\' \'--keep-names\' \'--out-extension:.js=.mjs\'',
272272
].join(' '),
273273
],
274274
}),
@@ -394,7 +394,7 @@ test('esbuild bundling with feature flag enabled using Node Latest', () => {
394394
bundling: expect.objectContaining({
395395
command: [
396396
'bash', '-c',
397-
`'esbuild' '--bundle' '/asset-input/lib/handler.ts' '--target=node22' '--platform=node' '--outfile=/asset-output/index.js'`,
397+
'\'esbuild\' \'--bundle\' \'/asset-input/lib/handler.ts\' \'--target=node22\' \'--platform=node\' \'--outfile=/asset-output/index.js\'',
398398
],
399399
}),
400400
});
@@ -421,7 +421,7 @@ test('esbuild bundling with feature flag enabled using Node 16', () => {
421421
bundling: expect.objectContaining({
422422
command: [
423423
'bash', '-c',
424-
`'esbuild' '--bundle' '/asset-input/lib/handler.ts' '--target=node16' '--platform=node' '--outfile=/asset-output/index.js' '--external:aws-sdk'`,
424+
'\'esbuild\' \'--bundle\' \'/asset-input/lib/handler.ts\' \'--target=node16\' \'--platform=node\' \'--outfile=/asset-output/index.js\' \'--external:aws-sdk\'',
425425
],
426426
}),
427427
});
@@ -869,8 +869,8 @@ test('esbuild bundling with pre compilations', () => {
869869
architecture: Architecture.X86_64,
870870
});
871871

872-
const compilerOptions = util.getTsconfigCompilerOptions(findParentTsConfigPath(__dirname));
873-
const quotedCompilerOptions = compilerOptions.split(/\s+/).filter(Boolean).map((a: string) => `'${a}'`).join(' ');
872+
const compilerOptionsArray = util.getTsconfigCompilerOptionsArray(findParentTsConfigPath(__dirname));
873+
const quotedCompilerOptions = compilerOptionsArray.map((a: string) => `'${a}'`).join(' ');
874874

875875
// Correctly bundles with esbuild
876876
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(packageLock), {
@@ -1087,7 +1087,7 @@ test('bundling using NODEJS_LATEST doesn\'t externalize anything by default', ()
10871087
bundling: expect.objectContaining({
10881088
command: [
10891089
'bash', '-c',
1090-
`'esbuild' '--bundle' '/asset-input/lib/handler.ts' '--target=node22' '--platform=node' '--outfile=/asset-output/index.js'`,
1090+
'\'esbuild\' \'--bundle\' \'/asset-input/lib/handler.ts\' \'--target=node22\' \'--platform=node\' \'--outfile=/asset-output/index.js\'',
10911091
],
10921092
}),
10931093
});
@@ -1188,6 +1188,76 @@ test('Node 16 runtimes warn about sdk v2 upgrades', () => {
11881188
);
11891189
});
11901190

1191+
// --- Regression tests for PR review findings ---
1192+
1193+
test('Docker bundling with preCompilation uses getTsconfigCompilerOptionsArray for proper escaping', () => {
1194+
const packageLock = path.join(__dirname, '..', 'package-lock.json');
1195+
1196+
Bundling.bundle(stack, {
1197+
entry: __filename.replace('.js', '.ts'),
1198+
projectRoot: path.dirname(packageLock),
1199+
depsLockFilePath: packageLock,
1200+
runtime: STANDARD_RUNTIME,
1201+
preCompilation: true,
1202+
forceDockerBundling: true,
1203+
architecture: Architecture.X86_64,
1204+
});
1205+
1206+
// The Docker tsc command should use getTsconfigCompilerOptionsArray (not naive string splitting)
1207+
// to properly handle compiler option values that may contain spaces.
1208+
// Verify the Docker command matches what getTsconfigCompilerOptionsArray produces.
1209+
const compilerOptionsArray = util.getTsconfigCompilerOptionsArray(findParentTsConfigPath(__dirname));
1210+
const quotedCompilerOptions = compilerOptionsArray.map((a: string) => `'${a}'`).join(' ');
1211+
1212+
expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(packageLock), {
1213+
assetHashType: AssetHashType.OUTPUT,
1214+
bundling: expect.objectContaining({
1215+
command: [
1216+
'bash', '-c',
1217+
[
1218+
`'tsc' '/asset-input/test/bundling.test.ts' ${quotedCompilerOptions} &&`,
1219+
`'esbuild' '--bundle' '/asset-input/test/bundling.test.js' '--target=${STANDARD_TARGET}' '--platform=node' '--outfile=/asset-output/index.js' '--external:${STANDARD_EXTERNAL}'`,
1220+
].join(' '),
1221+
],
1222+
}),
1223+
});
1224+
});
1225+
1226+
test('Local bundling callback failure includes contextual error message', () => {
1227+
const spawnSyncMock = jest.spyOn(child_process, 'spawnSync').mockReturnValue({
1228+
status: 0,
1229+
stderr: Buffer.from('stderr'),
1230+
stdout: Buffer.from('stdout'),
1231+
pid: 123,
1232+
output: ['stdout', 'stderr'],
1233+
signal: null,
1234+
});
1235+
const writeFileSyncMock = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
1236+
throw new Error('EACCES: permission denied');
1237+
});
1238+
const copyFileSyncMock = jest.spyOn(fs, 'copyFileSync').mockReturnValue();
1239+
1240+
const packageLock = path.join(__dirname, '..', 'package-lock.json');
1241+
const bundler = new Bundling(stack, {
1242+
entry: __filename,
1243+
projectRoot: path.dirname(packageLock),
1244+
depsLockFilePath: packageLock,
1245+
runtime: STANDARD_RUNTIME,
1246+
architecture: Architecture.X86_64,
1247+
externalModules: ['abc'],
1248+
nodeModules: ['delay'],
1249+
});
1250+
1251+
// The callback step should wrap fs errors with context
1252+
expect(() => {
1253+
bundler.local?.tryBundle('/outdir', { image: STANDARD_RUNTIME.bundlingDockerImage });
1254+
}).toThrow(/Local bundling file operation failed.*EACCES/);
1255+
1256+
spawnSyncMock.mockRestore();
1257+
writeFileSyncMock.mockRestore();
1258+
copyFileSyncMock.mockRestore();
1259+
});
1260+
11911261
// --- Local bundling spawn tests ---
11921262

11931263
const spawnSyncMockReturnValue = {

packages/aws-cdk-lib/aws-lambda-nodejs/test/util.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as child_process from 'child_process';
22
import * as fs from 'fs';
33
import * as path from 'path';
44
import { bockfs } from '@aws-cdk/cdk-build-tools';
5-
import { callsites, exec, extractDependencies, findUp, findUpMultiple, getTsconfigCompilerOptions } from '../lib/util';
5+
import { callsites, exec, extractDependencies, findUp, findUpMultiple, getTsconfigCompilerOptions, getTsconfigCompilerOptionsArray } from '../lib/util';
66

77
beforeEach(() => {
88
jest.clearAllMocks();
@@ -262,3 +262,46 @@ describe('getTsconfigCompilerOptions', () => {
262262
].join(' '));
263263
});
264264
});
265+
266+
describe('getTsconfigCompilerOptionsArray', () => {
267+
test('should produce semantically equivalent output to getTsconfigCompilerOptions', () => {
268+
const tsconfig = path.join(__dirname, 'testtsconfig.json');
269+
const stringResult = getTsconfigCompilerOptions(tsconfig);
270+
const arrayResult = getTsconfigCompilerOptionsArray(tsconfig);
271+
272+
// The array joined with spaces should equal the string result
273+
expect(arrayResult.join(' ')).toEqual(stringResult);
274+
});
275+
276+
test('should produce semantically equivalent output with extended config', () => {
277+
const tsconfig = path.join(__dirname, 'testtsconfig-extended.json');
278+
const stringResult = getTsconfigCompilerOptions(tsconfig);
279+
const arrayResult = getTsconfigCompilerOptionsArray(tsconfig);
280+
281+
expect(arrayResult.join(' ')).toEqual(stringResult);
282+
});
283+
284+
test('should return array elements for each flag and value', () => {
285+
const tsconfig = path.join(__dirname, 'testtsconfig.json');
286+
const arrayResult = getTsconfigCompilerOptionsArray(tsconfig);
287+
288+
// Boolean true flags are single elements
289+
expect(arrayResult).toContain('--alwaysStrict');
290+
expect(arrayResult).toContain('--declaration');
291+
292+
// Boolean false flags have the flag and 'false' as separate elements
293+
const declMapIdx = arrayResult.indexOf('--declarationMap');
294+
expect(declMapIdx).toBeGreaterThanOrEqual(0);
295+
expect(arrayResult[declMapIdx + 1]).toBe('false');
296+
297+
// String values are separate elements
298+
const targetIdx = arrayResult.indexOf('--target');
299+
expect(targetIdx).toBeGreaterThanOrEqual(0);
300+
expect(arrayResult[targetIdx + 1]).toBe('ES2022');
301+
302+
// Array values are joined with commas as a single element
303+
const libIdx = arrayResult.indexOf('--lib');
304+
expect(libIdx).toBeGreaterThanOrEqual(0);
305+
expect(arrayResult[libIdx + 1]).toBe('es2022,dom');
306+
});
307+
});

0 commit comments

Comments
 (0)