diff --git a/.github/workflows/test-and-release.yaml b/.github/workflows/test-and-release.yaml index e735873..29a12bd 100644 --- a/.github/workflows/test-and-release.yaml +++ b/.github/workflows/test-and-release.yaml @@ -58,6 +58,12 @@ jobs: - run: npx cdk deploy --require-approval never + - name: Upload dist folder as artifact + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + - name: Run end-to-end tests run: npx tsx --test e2e.spec.ts diff --git a/cdk/TestStack.ts b/cdk/TestStack.ts index a9b3a15..852f631 100644 --- a/cdk/TestStack.ts +++ b/cdk/TestStack.ts @@ -45,9 +45,31 @@ export class TestStack extends Stack { description: 'API endpoint', value: url.url, }) + + const lambdaAliasImports = new PackedLambdaFn( + this, + 'aliasImportsFn', + lambdaSources.testAliasImports, + { + timeout: Duration.seconds(1), + description: 'Uses aliased imports', + layers: [baseLayer], + }, + ) + + const urlAliasImports = lambdaAliasImports.fn.addFunctionUrl({ + authType: Lambda.FunctionUrlAuthType.NONE, + }) + + new CfnOutput(this, 'lambdaAliasImportsURL', { + exportName: `${this.stackName}:lambdaAliasImportsURL`, + description: 'API endpoint for the lambda using alias imports', + value: urlAliasImports.url, + }) } } export type StackOutputs = { lambdaURL: string + lambdaAliasImportsURL: string } diff --git a/cdk/lambda-with-subpath.ts b/cdk/lambda-with-subpath.ts new file mode 100644 index 0000000..b6aa03d --- /dev/null +++ b/cdk/lambda-with-subpath.ts @@ -0,0 +1,8 @@ +import { foo } from '#lib' +import { foo2 } from '#lib/2.js' +import type { APIGatewayProxyResultV2 } from 'aws-lambda' + +export const handler = async (): Promise => ({ + statusCode: 201, + body: (foo() + foo2()).toString(), +}) diff --git a/cdk/packTestLambdas.ts b/cdk/packTestLambdas.ts index 78629f6..ac8ce40 100644 --- a/cdk/packTestLambdas.ts +++ b/cdk/packTestLambdas.ts @@ -1,8 +1,12 @@ +import path from 'node:path' import type { PackedLambda } from '../src/packLambda.js' import { packLambdaFromPath } from '../src/packLambdaFromPath.js' +const __dirname = path.dirname(new URL(import.meta.url).pathname) + export type TestLambdas = { test: PackedLambda + testAliasImports: PackedLambda } export const packTestLambdas = async (): Promise => ({ @@ -10,4 +14,9 @@ export const packTestLambdas = async (): Promise => ({ id: 'test', sourceFilePath: 'cdk/lambda.ts', }), + testAliasImports: await packLambdaFromPath({ + id: 'testAliasImports', + sourceFilePath: 'cdk/lambda-with-subpath.ts', + tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'), + }), }) diff --git a/e2e.spec.ts b/e2e.spec.ts index eeade04..bfac07d 100644 --- a/e2e.spec.ts +++ b/e2e.spec.ts @@ -19,4 +19,18 @@ void describe('end-to-end tests', () => { assert.equal(res.status, 201) assert.match(await res.text(), /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/) }) + + void it('the lambda with aliased imports should work', async () => { + const { stackName } = fromEnv({ + stackName: 'STACK_NAME', + })(process.env) + const { lambdaAliasImportsURL } = await stackOutput( + new CloudFormationClient({}), + )(stackName) + + const res = await fetch(new URL(lambdaAliasImportsURL)) + assert.equal(res.ok, true) + assert.equal(res.status, 201) + assert.equal(parseInt(await res.text(), 10), 42 + 17) + }) }) diff --git a/src/findDependencies.spec.ts b/src/findDependencies.spec.ts index b66c799..176c0be 100644 --- a/src/findDependencies.spec.ts +++ b/src/findDependencies.spec.ts @@ -8,7 +8,7 @@ const __dirname = new URL('.', import.meta.url).pathname void describe('findDependencies()', () => { void it('should honor tsconfig.json paths', () => { - const dependencies = findDependencies({ + const { dependencies } = findDependencies({ sourceFilePath: path.join( __dirname, 'test-data', @@ -29,6 +29,13 @@ void describe('findDependencies()', () => { true, 'Should include the index.ts file', ) + assert.equal( + dependencies.includes( + path.join(__dirname, 'test-data', 'resolve-paths', 'foo', '1.ts'), + ), + true, + 'Should include the module referenced in the index.ts file', + ) assert.equal( dependencies.includes( path.join(__dirname, 'test-data', 'resolve-paths', 'foo', '2.ts'), @@ -37,4 +44,26 @@ void describe('findDependencies()', () => { 'Should include the module file', ) }) + + void it('should return an import map', () => { + const { importsSubpathPatterns } = findDependencies({ + sourceFilePath: path.join( + __dirname, + 'test-data', + 'resolve-paths', + 'lambda.ts', + ), + tsConfigFilePath: path.join( + __dirname, + 'test-data', + 'resolve-paths', + 'tsconfig.json', + ), + }) + + assert.deepEqual(importsSubpathPatterns, { + '#foo': './foo/index.js', + '#foo/*': './foo/*', + }) + }) }) diff --git a/src/findDependencies.ts b/src/findDependencies.ts index 8cb23f8..4b77162 100644 --- a/src/findDependencies.ts +++ b/src/findDependencies.ts @@ -12,20 +12,27 @@ type TSConfigWithPaths = { /** * Resolve project-level dependencies for the given file using TypeScript compiler API */ -export const findDependencies = ({ - sourceFilePath, - tsConfigFilePath, - imports: importsArg, - visited: visitedArg, -}: { +export const findDependencies = (args: { sourceFilePath: string - tsConfigFilePath?: string imports?: string[] visited?: string[] -}): string[] => { - const visited = visitedArg ?? [] - const imports = importsArg ?? [] - if (visited.includes(sourceFilePath)) return imports + tsConfigFilePath?: string + importsSubpathPatterns?: Record +}): { + dependencies: string[] + /** + * A map of import subpath patterns to their resolved paths + * @see https://nodejs.org/api/packages.html#subpath-patterns + */ + importsSubpathPatterns: Record +} => { + const sourceFilePath = args.sourceFilePath + const visited = args.visited ?? [] + const dependencies = args.imports ?? [] + let importsSubpathPatterns = args.importsSubpathPatterns ?? {} + if (visited.includes(sourceFilePath)) + return { dependencies, importsSubpathPatterns } + const tsConfigFilePath = args.tsConfigFilePath const tsConfig = tsConfigFilePath !== undefined ? JSON.parse(readFileSync(tsConfigFilePath, 'utf-8').toString()) @@ -39,19 +46,28 @@ export const findDependencies = ({ ) const parseChild = (node: ts.Node) => { - if (node.kind !== ts.SyntaxKind.ImportDeclaration) return + if ( + node.kind !== ts.SyntaxKind.ImportDeclaration && + node.kind !== ts.SyntaxKind.ExportDeclaration + ) + return const moduleSpecifier = ( (node as ImportDeclaration).moduleSpecifier as StringLiteral ).text - const file = resolve({ + const { + resolvedPath: file, + importsSubpathPatterns: updatedImportsSubpathPatterns, + } = resolve({ moduleSpecifier, sourceFilePath, tsConfigFilePath, tsConfig, + importsSubpathPatterns, }) + importsSubpathPatterns = updatedImportsSubpathPatterns try { const s = statSync(file) - if (!s.isDirectory()) imports.push(file) + if (!s.isDirectory()) dependencies.push(file) } catch { // Module or file not found visited.push(file) @@ -60,16 +76,17 @@ export const findDependencies = ({ ts.forEachChild(fileNode, parseChild) visited.push(sourceFilePath) - for (const file of imports) { + for (const file of dependencies) { findDependencies({ sourceFilePath: file, - imports, + imports: dependencies, visited, tsConfigFilePath, + importsSubpathPatterns, }) } - return imports + return { dependencies, importsSubpathPatterns } } const resolve = ({ @@ -77,27 +94,36 @@ const resolve = ({ sourceFilePath, tsConfigFilePath, tsConfig, + importsSubpathPatterns, }: { moduleSpecifier: string sourceFilePath: string + importsSubpathPatterns: Record } & ( | { tsConfigFilePath: undefined tsConfig: undefined } - | { tsConfigFilePath: string; tsConfig: TSConfigWithPaths } -)): string => { + | { + tsConfigFilePath: string + tsConfig: TSConfigWithPaths + } +)): { + resolvedPath: string + importsSubpathPatterns: Record +} => { if (moduleSpecifier.startsWith('.')) - return ( - path + return { + resolvedPath: path .resolve(path.parse(sourceFilePath).dir, moduleSpecifier) // In ECMA Script modules, all imports from local files must have an extension. // See https://nodejs.org/api/esm.html#mandatory-file-extensions // So we need to replace the `.js` in the import specification to find the TypeScript source for the file. // Example: import { Network, notifyClients } from './notifyClients.js' // The source file for that is actually in './notifyClients.ts' - .replace(/\.js$/, '.ts') - ) + .replace(/\.js$/, '.ts'), + importsSubpathPatterns, + } if ( tsConfigFilePath !== undefined && tsConfig?.compilerOptions?.paths !== undefined @@ -107,28 +133,50 @@ const resolve = ({ if (resolvedPath === undefined) continue // Exact match if (moduleSpecifier === key) { - return path.join( + const fullResolvedPath = path.join( path.parse(tsConfigFilePath).dir, tsConfig.compilerOptions.baseUrl, resolvedPath, ) + return { + resolvedPath: fullResolvedPath, + importsSubpathPatterns: { + ...importsSubpathPatterns, + [key]: [ + tsConfig.compilerOptions.baseUrl, + path.sep, + resolvedPath.replace(/\.ts$/, '.js'), + ].join(''), + }, + } } // Wildcard match if (!key.includes('*')) continue const rx = new RegExp(`^${key.replace('*', '(?.*)')}`) const maybeMatch = rx.exec(moduleSpecifier) if (maybeMatch?.groups?.wildcard === undefined) continue - return ( - path + return { + resolvedPath: path .resolve( path.parse(tsConfigFilePath).dir, tsConfig.compilerOptions.baseUrl, resolvedPath.replace('*', maybeMatch.groups.wildcard), ) // Same as above, replace `.js` with `.ts` - .replace(/\.js$/, '.ts') - ) + .replace(/\.js$/, '.ts'), + importsSubpathPatterns: { + ...importsSubpathPatterns, + [key]: [ + tsConfig.compilerOptions.baseUrl, + path.sep, + resolvedPath.replace(/\.ts$/, '.js'), + ].join(''), + }, + } } } - return moduleSpecifier + return { + resolvedPath: moduleSpecifier, + importsSubpathPatterns, + } } diff --git a/src/packLambda.ts b/src/packLambda.ts index a59c383..e7d15a1 100644 --- a/src/packLambda.ts +++ b/src/packLambda.ts @@ -49,7 +49,7 @@ export const packLambda = async ({ debug?: (label: string, info: string) => void progress?: (label: string, info: string) => void }): Promise<{ handler: string; hash: string }> => { - const deps = findDependencies({ + const { dependencies: deps, importsSubpathPatterns } = findDependencies({ sourceFilePath, tsConfigFilePath, }) @@ -103,6 +103,7 @@ export const packLambda = async ({ Buffer.from( JSON.stringify({ type: 'module', + imports: importsSubpathPatterns, }), 'utf-8', ), diff --git a/tsconfig.json b/tsconfig.json index da02997..77c5aa8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,12 @@ "noUnusedLocals": true, "noEmit": true, "verbatimModuleSyntax": true, - "skipLibCheck": true + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "#lib": ["src/test-data/resolve-paths/foo/index.ts"], + "#lib/*": ["src/test-data/resolve-paths/foo/*"] + } }, "exclude": ["src/test-data/**"] }