Skip to content

Commit

Permalink
feat: add subpath patterns to package.json (#145)
Browse files Browse the repository at this point in the history
This adds the corresponding subpath patterns to the created
package.json, so the import declarations can be kept as-is.
  • Loading branch information
coderbyheart authored Nov 14, 2024
1 parent a7acb07 commit 06450d7
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 32 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/test-and-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions cdk/TestStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions cdk/lambda-with-subpath.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResultV2> => ({

Check warning on line 5 in cdk/lambda-with-subpath.ts

View workflow job for this annotation

GitHub Actions / tests

Async arrow function 'handler' has no 'await' expression
statusCode: 201,
body: (foo() + foo2()).toString(),
})
9 changes: 9 additions & 0 deletions cdk/packTestLambdas.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
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<TestLambdas> => ({
test: await packLambdaFromPath({
id: 'test',
sourceFilePath: 'cdk/lambda.ts',
}),
testAliasImports: await packLambdaFromPath({
id: 'testAliasImports',
sourceFilePath: 'cdk/lambda-with-subpath.ts',
tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'),
}),
})
14 changes: 14 additions & 0 deletions e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
)<StackOutputs>(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)
})
})
31 changes: 30 additions & 1 deletion src/findDependencies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'),
Expand All @@ -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/*',
})
})
})
106 changes: 77 additions & 29 deletions src/findDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
}): {
dependencies: string[]
/**
* A map of import subpath patterns to their resolved paths
* @see https://nodejs.org/api/packages.html#subpath-patterns
*/
importsSubpathPatterns: Record<string, string>
} => {
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 =

Check warning on line 36 in src/findDependencies.ts

View workflow job for this annotation

GitHub Actions / tests

Unsafe assignment of an `any` value
tsConfigFilePath !== undefined
? JSON.parse(readFileSync(tsConfigFilePath, 'utf-8').toString())
Expand All @@ -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,

Check warning on line 64 in src/findDependencies.ts

View workflow job for this annotation

GitHub Actions / tests

Unsafe assignment of an `any` value
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)
Expand All @@ -60,44 +76,54 @@ 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 = ({
moduleSpecifier,
sourceFilePath,
tsConfigFilePath,
tsConfig,
importsSubpathPatterns,
}: {
moduleSpecifier: string
sourceFilePath: string
importsSubpathPatterns: Record<string, string>
} & (
| {
tsConfigFilePath: undefined
tsConfig: undefined
}
| { tsConfigFilePath: string; tsConfig: TSConfigWithPaths }
)): string => {
| {
tsConfigFilePath: string
tsConfig: TSConfigWithPaths
}
)): {
resolvedPath: string
importsSubpathPatterns: Record<string, string>
} => {
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
Expand All @@ -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('*', '(?<wildcard>.*)')}`)
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,
}
}
3 changes: 2 additions & 1 deletion src/packLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -103,6 +103,7 @@ export const packLambda = async ({
Buffer.from(
JSON.stringify({
type: 'module',
imports: importsSubpathPatterns,
}),
'utf-8',
),
Expand Down
7 changes: 6 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"]
}

0 comments on commit 06450d7

Please sign in to comment.