diff --git a/.eslintignore b/.eslintignore index adadeb8..d5a40f7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ node_modules __tests__ *.test.js dist -benchmarks \ No newline at end of file +benchmarks +e2e \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 939de86..16790c4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,11 +7,15 @@ "extends": ["eslint:recommended", "prettier"], "parserOptions": { "ecmaVersion": 2018, - "sourceType": "module" + "sourceType": "script" }, - "rules": {}, - "globals": { - "expect": true, - "it": true - } + "overrides": [ + { + "files": ["src/**/*.js", "__tests__/**/*.mjs"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + } + } + ] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ab3fc50..fa62a5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,10 @@ jobs: strategy: matrix: - node-version: [14.x] + # The SWC build toolchain requires Node >= 16.14, so coverage runs on a + # modern LTS. Runtime compatibility across older Node is covered by the + # release workflow's test matrix against the prebuilt dist. + node-version: [20.x] steps: - name: Get branch name (merge) @@ -28,9 +31,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - name: Install NPM - run: npm install -g npm@9 - - run: npm ci + cache: 'npm' + - run: npm ci --ignore-scripts - run: npm run test-ci env: COVERALLS_SERVICE_NAME: GithubActions diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..68810ba --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,38 @@ +name: 'E2E' + +# Dual-package (CJS/ESM) end-to-end validation. Manual only — the Docker/LocalStack layer is +# heavy and these are not fast unit checks, so they never gate PRs. +on: + workflow_dispatch: + +jobs: + layer1: + name: 'Layer 1 (Node ${{ matrix.node }})' + runs-on: ubuntu-latest + strategy: + matrix: + node: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: 'npm' + - run: npm ci --ignore-scripts + - run: npm run test:e2e + + layer2-localstack: + name: 'Layer 2 (LocalStack)' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci --ignore-scripts + - name: Install LocalStack driver deps + working-directory: e2e/localstack + run: npm install --no-audit --no-fund + - name: Run LocalStack e2e + run: npm run test:e2e:localstack diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 829caf3..4377909 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,12 +4,42 @@ on: types: [opened, reopened, synchronize] jobs: + # Build the dual CJS/ESM output once on a modern Node (the SWC toolchain requires + # Node >= 16.14), then share dist/ with the test matrix so older Node versions are + # exercised against the compiled runtime without having to run the build themselves. + build: + name: 'Build' + runs-on: ubuntu-latest + steps: + - name: 'Checkout latest code' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Build dist (CJS + ESM) + run: npm run build + - name: Type check + run: npm run test:types + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 1 + test: + name: Node ${{ matrix.node }} + needs: build runs-on: ubuntu-latest strategy: matrix: node: [14, 16, 18, 20, 22] - name: Node ${{ matrix.node }} steps: - name: 'Checkout latest code' uses: actions/checkout@v4 @@ -23,9 +53,14 @@ jobs: - name: Install NPM (if node 14) run: if [ "${{ matrix.node }}" == "14" ]; then npm install -g npm@9; fi - name: Install dependencies - run: npm ci - - name: Run tests - run: npm run test + run: npm ci --ignore-scripts + - name: Download prebuilt dist + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Run tests against prebuilt dist + run: npm run test:prebuilt lint: name: 'ESLint' @@ -38,10 +73,10 @@ jobs: - name: Set up node uses: actions/setup-node@v4 with: - node-version: '16' + node-version: '20' cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run ESLint run: npm run lint:check @@ -56,8 +91,9 @@ jobs: - name: Set up node uses: actions/setup-node@v4 with: - node-version: '16' + node-version: '20' + cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run Prettier run: npm run prettier:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aef5202..83cb793 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,36 @@ permissions: contents: read jobs: + # Build the dual CJS/ESM output once (SWC needs Node >= 16.14), then run the test + # matrix against the prebuilt dist so Node 14-22 runtime compatibility is verified + # without building on the older versions. + build: + name: 'Build' + runs-on: ubuntu-latest + steps: + - name: Checkout latest code + uses: actions/checkout@v4 + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Build dist (CJS + ESM) + run: npm run build + - name: Type check + run: npm run test:types + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 1 + test: name: 'Test (Node ${{ matrix.node }})' + needs: build runs-on: ubuntu-latest strategy: matrix: @@ -32,9 +60,14 @@ jobs: - name: Install NPM (if node 14) run: if [ "${{ matrix.node }}" == "14" ]; then npm install -g npm@9; fi - name: Install dependencies - run: npm ci - - name: Run tests - run: npm run test + run: npm ci --ignore-scripts + - name: Download prebuilt dist + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Run tests against prebuilt dist + run: npm run test:prebuilt publish: name: 'Publish to npm' @@ -55,7 +88,7 @@ jobs: # Trusted publishing requires npm >= 11.5.1. run: npm install -g npm@latest - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Set version to publish # package.json ships a 0.0.0-development placeholder; the real version # comes from the manual input (workflow_dispatch) or the release tag, diff --git a/.gitignore b/.gitignore index 346bd84..8e34b80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ dump.rdb # Coverage reports .nyc_output coverage + +# Build output +dist diff --git a/.prettierignore b/.prettierignore index adadeb8..d5a40f7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,5 @@ node_modules __tests__ *.test.js dist -benchmarks \ No newline at end of file +benchmarks +e2e \ No newline at end of file diff --git a/.swcrc.cjs.json b/.swcrc.cjs.json new file mode 100644 index 0000000..ee6de16 --- /dev/null +++ b/.swcrc.cjs.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://swc.rs/schema.json", + "jsc": { + "parser": { + "syntax": "ecmascript", + "dynamicImport": true + }, + "target": "es2018", + "keepClassNames": true + }, + "module": { + "type": "commonjs", + "noInterop": false + }, + "sourceMaps": false +} diff --git a/.swcrc.esm.json b/.swcrc.esm.json new file mode 100644 index 0000000..9e20d9f --- /dev/null +++ b/.swcrc.esm.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://swc.rs/schema.json", + "jsc": { + "parser": { + "syntax": "ecmascript", + "dynamicImport": true + }, + "target": "es2018", + "keepClassNames": true + }, + "module": { + "type": "es6", + "strict": false, + "noInterop": false + }, + "sourceMaps": false +} diff --git a/__tests__/esm-compat.mjs b/__tests__/esm-compat.mjs new file mode 100644 index 0000000..8cd8bc0 --- /dev/null +++ b/__tests__/esm-compat.mjs @@ -0,0 +1,51 @@ +'use strict'; + +import createAPI from '../dist/esm/index.js'; +import * as utils from '../dist/esm/lib/utils.js'; +import prettyPrint from '../dist/esm/lib/prettyPrint.js'; +import { ApiError } from '../dist/esm/lib/errors.js'; + +const event = { + httpMethod: 'GET', + path: '/compat', + headers: {}, + multiValueHeaders: {}, +}; + +async function main() { + const createApi = createAPI({ version: 'v1.0' }); + createApi.get('/compat', (req, res) => { + res.json({ ok: true, method: req.method }); + }); + + const result = await createApi.run(event, {}); + + if (typeof createAPI !== 'function') { + throw new Error('Expected default export to be a function'); + } + + if (typeof utils.escapeHtml !== 'function') { + throw new Error('Expected utils.escapeHtml to be a function'); + } + + if (typeof prettyPrint !== 'function') { + throw new Error('Expected prettyPrint default export to be a function'); + } + + if (typeof ApiError !== 'function') { + throw new Error('Expected ApiError export to be a function'); + } + + if (result.statusCode !== 200) { + throw new Error(`Expected statusCode 200, received ${result.statusCode}`); + } + + if (JSON.parse(result.body).ok !== true) { + throw new Error('Expected successful ESM route response'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/__tests__/module-compat.unit.js b/__tests__/module-compat.unit.js new file mode 100644 index 0000000..a894e04 --- /dev/null +++ b/__tests__/module-compat.unit.js @@ -0,0 +1,86 @@ +'use strict'; + +const { execFileSync } = require('child_process'); +const path = require('path'); + +const event = { + httpMethod: 'GET', + path: '/compat', + headers: {}, + multiValueHeaders: {}, +}; + +const runRoute = async (api) => { + api.get('/compat', (req, res) => { + res.json({ ok: true, method: req.method }); + }); + + return api.run(event, {}); +}; + +describe('Module Compatibility Tests:', function () { + describe('CommonJS build output', function () { + it('loads the package factory from dist/cjs', function () { + const createAPI = require('../dist/cjs/index.js'); + expect(typeof createAPI).toBe('function'); + }); + + it('exposes a .default self-reference (require("lambda-api").default)', function () { + // Preserves the pre-dual-package behavior so TS consumers compiled to CommonJS with + // esModuleInterop:false (they emit `require('lambda-api').default(...)`) keep working. + const createAPI = require('../dist/cjs/index.js'); + expect(createAPI.default).toBe(createAPI); + }); + + it('runs a route from dist/cjs', async function () { + const createAPI = require('../dist/cjs/index.js'); + const api = createAPI({ version: 'v1.0' }); + const result = await runRoute(api); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual({ ok: true, method: 'GET' }); + }); + + it('loads deep lib subpaths from dist/cjs', function () { + const utils = require('../dist/cjs/lib/utils.js'); + const errors = require('../dist/cjs/lib/errors.js'); + const prettyPrint = require('../dist/cjs/lib/prettyPrint.js'); + + expect(typeof utils.escapeHtml).toBe('function'); + expect(typeof errors.ApiError).toBe('function'); + expect(typeof prettyPrint).toBe('function'); + }); + }); + + describe('ESM build output', function () { + it('passes Node ESM compatibility checks', function () { + execFileSync(process.execPath, ['__tests__/esm-compat.mjs'], { + cwd: path.join(__dirname, '..'), + stdio: 'pipe', + }); + }); + }); + + describe('Package exports resolution', function () { + it('resolves the package root and lib subpaths', function () { + execFileSync( + process.execPath, + [ + '-e', + "const { createRequire } = require('module');" + + "const packageRequire = createRequire(require('path').resolve('package.json'));" + + "const createAPI = packageRequire('.');" + + "const utils = packageRequire('lambda-api/lib/utils');" + + "const utilsWithExtension = packageRequire('lambda-api/lib/utils.js');" + + "if (typeof createAPI !== 'function') throw new Error('Expected package root export to be a function');" + + "if (typeof utils.escapeHtml !== 'function') throw new Error('Expected lib/utils export to expose escapeHtml');" + + "if (typeof utilsWithExtension.escapeHtml !== 'function') throw new Error('Expected lib/utils.js export to expose escapeHtml');", + ], + { + cwd: path.join(__dirname, '..'), + stdio: 'pipe', + } + ); + }); + }); +}); diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..ad83a22 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.tgz +*.zip +fixtures/**/dist/ +fixtures/**/bundle.* diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..8f27487 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,65 @@ +# lambda-api dual-package e2e suite + +End-to-end validation that the published **dual CJS/ESM** package actually works for real +consumers and on the real AWS Lambda runtime. Unlike the in-repo `__tests__/module-compat` +tests (which load `dist/` directly with the optional AWS SDK present), this suite installs the +**packed tarball** into isolated consumer projects, honoring the published `exports` map and +`files` whitelist — the shape users actually get from npm. + +It is excluded from the npm tarball by the root `package.json` `files` whitelist. + +## What it proves + +- `require('lambda-api')` and `import 'lambda-api'` load and serve **without** the optional + `@aws-sdk/*` peer deps installed (the SDK is lazy — issue #290). +- **Issue #295**: bundling a handler to ESM `.mjs` with esbuild no longer throws + `Dynamic require of "querystring" is not supported` at load / Lambda cold start. +- Deep imports (`lambda-api/lib/*`), the `exports` subpath map (incl. `package.json`), and + TypeScript `node16` module resolution + types. +- The S3 path (`getLink` presign, `sendFile` GetObject) still works after the lazy-SDK refactor, + against real S3. + +## Layer 1 — fast, no Docker + +```bash +npm run test:e2e +``` + +Packs the tarball, installs it into isolated temp roots (with and without `@aws-sdk/*`), and +runs each fixture in `e2e/fixtures/` in its own child process with synthetic API Gateway events. +No Docker required; suitable for CI on a Node 18/20/22 matrix. + +## Layer 2 — LocalStack (real Lambda runtime + real S3 + API Gateway) + +```bash +cd e2e/localstack && npm install # one-time: installs the AWS SDK clients used to drive it +npm run test:e2e:localstack # from the repo root +``` + +Requires **Docker**. Brings up `localstack/localstack` via `docker-compose.yml`, deploys the +handler variants (CJS, ESM, an esbuild-bundled `.mjs`, and an S3 handler) as Lambda functions on +`nodejs20.x`, then invokes them directly and through a real API Gateway REST API, asserting +responses and checking logs for the #295 `Dynamic require` error. LocalStack executes functions +in the actual AWS Lambda Node runtime containers, so a passing run is the authoritative proof the +package loads clean at cold start. The suite tears LocalStack down when done. + +## Layout + +``` +e2e/ + run-layer1.mjs # Layer 1 orchestrator (no Docker) + helpers/harness.mjs # pack tarball, isolated installs, child-process runner, assertions + fixtures/ # consumer apps: cjs, esm, deep-import-*, s3, typescript + localstack/ + docker-compose.yml # LocalStack service + package.json # AWS SDK clients that drive LocalStack + run-all.mjs # deploy + invoke + assert + teardown + handlers/ # Lambda handler variants deployed to LocalStack +``` + +## Adding a case + +Add a fixture directory under `e2e/fixtures//` (a `package.json` + handler that reads an +event path from `process.argv[2]` and writes the response JSON to stdout), then wire a check in +`run-layer1.mjs`. For a real-runtime case, add a handler under `localstack/handlers/` and a +function entry in `localstack/run-all.mjs`. diff --git a/e2e/fixtures/cjs/handler.js b/e2e/fixtures/cjs/handler.js new file mode 100644 index 0000000..9fb5f91 --- /dev/null +++ b/e2e/fixtures/cjs/handler.js @@ -0,0 +1,12 @@ +'use strict'; +const createAPI = require('lambda-api'); +const { readFileSync } = require('fs'); +const api = createAPI({ version: 'v1' }); +api.get('/', (req, res) => res.json({ hello: 'world', lang: 'cjs' })); +api.get('/users/:id', (req, res) => res.json({ id: req.params.id })); +api.post('/users', (req, res) => res.json({ created: req.body })); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +api + .run(event, { getRemainingTimeInMillis: () => 3000 }) + .then((r) => process.stdout.write(JSON.stringify(r))) + .catch((e) => { process.stderr.write('HANDLER_ERROR:' + (e && e.stack || e)); process.exit(1); }); diff --git a/e2e/fixtures/cjs/package.json b/e2e/fixtures/cjs/package.json new file mode 100644 index 0000000..a697fa4 --- /dev/null +++ b/e2e/fixtures/cjs/package.json @@ -0,0 +1 @@ +{ "name": "fixture-cjs", "private": true } diff --git a/e2e/fixtures/deep-import-cjs/handler.js b/e2e/fixtures/deep-import-cjs/handler.js new file mode 100644 index 0000000..c4c0bb3 --- /dev/null +++ b/e2e/fixtures/deep-import-cjs/handler.js @@ -0,0 +1,11 @@ +'use strict'; +const createAPI = require('lambda-api'); +const utils = require('lambda-api/lib/utils'); +const { readFileSync } = require('fs'); +const api = createAPI({ version: 'v1' }); +api.get('/', (req, res) => + res.json({ escaped: utils.escapeHtml(''), hasFn: typeof utils.escapeHtml === 'function' }) +); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +api.run(event, {}).then((r) => process.stdout.write(JSON.stringify(r))) + .catch((e) => { process.stderr.write(String(e.stack || e)); process.exit(1); }); diff --git a/e2e/fixtures/deep-import-cjs/package.json b/e2e/fixtures/deep-import-cjs/package.json new file mode 100644 index 0000000..57fb510 --- /dev/null +++ b/e2e/fixtures/deep-import-cjs/package.json @@ -0,0 +1 @@ +{ "name": "fixture-deep-cjs", "private": true } diff --git a/e2e/fixtures/deep-import-esm/handler.mjs b/e2e/fixtures/deep-import-esm/handler.mjs new file mode 100644 index 0000000..b03a708 --- /dev/null +++ b/e2e/fixtures/deep-import-esm/handler.mjs @@ -0,0 +1,10 @@ +import createAPI from 'lambda-api'; +import * as utils from 'lambda-api/lib/utils.js'; +import { readFileSync } from 'node:fs'; +const api = createAPI({ version: 'v1' }); +api.get('/', (req, res) => + res.json({ escaped: utils.escapeHtml(''), hasFn: typeof utils.escapeHtml === 'function' }) +); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +const r = await api.run(event, {}); +process.stdout.write(JSON.stringify(r)); diff --git a/e2e/fixtures/deep-import-esm/package.json b/e2e/fixtures/deep-import-esm/package.json new file mode 100644 index 0000000..655482d --- /dev/null +++ b/e2e/fixtures/deep-import-esm/package.json @@ -0,0 +1 @@ +{ "name": "fixture-deep-esm", "private": true, "type": "module" } diff --git a/e2e/fixtures/esm/handler.mjs b/e2e/fixtures/esm/handler.mjs new file mode 100644 index 0000000..c1762b8 --- /dev/null +++ b/e2e/fixtures/esm/handler.mjs @@ -0,0 +1,9 @@ +import createAPI from 'lambda-api'; +import { readFileSync } from 'node:fs'; +const api = createAPI({ version: 'v1' }); +api.get('/', (req, res) => res.json({ hello: 'world', lang: 'esm' })); +api.get('/users/:id', (req, res) => res.json({ id: req.params.id })); +api.post('/users', (req, res) => res.json({ created: req.body })); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +const r = await api.run(event, { getRemainingTimeInMillis: () => 3000 }); +process.stdout.write(JSON.stringify(r)); diff --git a/e2e/fixtures/esm/package.json b/e2e/fixtures/esm/package.json new file mode 100644 index 0000000..6f4c566 --- /dev/null +++ b/e2e/fixtures/esm/package.json @@ -0,0 +1 @@ +{ "name": "fixture-esm", "private": true, "type": "module" } diff --git a/e2e/fixtures/s3-no-sdk/handler.js b/e2e/fixtures/s3-no-sdk/handler.js new file mode 100644 index 0000000..aba4297 --- /dev/null +++ b/e2e/fixtures/s3-no-sdk/handler.js @@ -0,0 +1,18 @@ +'use strict'; +// An S3 route in a project WITHOUT the optional @aws-sdk installed must fail GRACEFULLY +// (handled 5xx), never crash the process with an unhandled rejection. Node's default +// (unhandled rejections terminate the process) means a leak here exits non-zero -> the +// runner sees a non-zero status and fails. The 50ms settle lets any stray rejection surface. +const createAPI = require('lambda-api'); +const { readFileSync } = require('fs'); +const api = createAPI({ version: 'v1' }); +api.get('/ok', (req, res) => res.json({ ok: true })); +api.get('/link', async (req, res) => { + const url = await res.getLink('s3://bucket/key.txt'); + return { url }; +}); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +api + .run(event, {}) + .then((r) => setTimeout(() => process.stdout.write(JSON.stringify(r)), 50)) + .catch((e) => { process.stderr.write(String(e.stack || e)); process.exit(1); }); diff --git a/e2e/fixtures/s3-no-sdk/package.json b/e2e/fixtures/s3-no-sdk/package.json new file mode 100644 index 0000000..b0f9cad --- /dev/null +++ b/e2e/fixtures/s3-no-sdk/package.json @@ -0,0 +1 @@ +{ "name": "fixture-s3-no-sdk", "private": true } diff --git a/e2e/fixtures/s3/handler.mjs b/e2e/fixtures/s3/handler.mjs new file mode 100644 index 0000000..34cbc71 --- /dev/null +++ b/e2e/fixtures/s3/handler.mjs @@ -0,0 +1,14 @@ +import createAPI from 'lambda-api'; +import { readFileSync } from 'node:fs'; +// getLink() produces a presigned URL by SIGNING locally (no network) given region+creds. +const api = createAPI({ + version: 'v1', + s3Config: { region: 'us-east-1', credentials: { accessKeyId: 'test', secretAccessKey: 'test' } }, +}); +api.get('/link', async (req, res) => { + const url = await res.getLink('s3://my-bucket/path/key.txt'); + return { url }; +}); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +const r = await api.run(event, {}); +process.stdout.write(JSON.stringify(r)); diff --git a/e2e/fixtures/s3/package.json b/e2e/fixtures/s3/package.json new file mode 100644 index 0000000..ebb79bd --- /dev/null +++ b/e2e/fixtures/s3/package.json @@ -0,0 +1 @@ +{ "name": "fixture-s3", "private": true, "type": "module" } diff --git a/e2e/fixtures/typescript/handler.ts b/e2e/fixtures/typescript/handler.ts new file mode 100644 index 0000000..076bb5f --- /dev/null +++ b/e2e/fixtures/typescript/handler.ts @@ -0,0 +1,6 @@ +import createAPI from 'lambda-api'; +import { readFileSync } from 'node:fs'; +const api = createAPI({ version: 'v1' }); +api.get('/', (req, res) => res.json({ hello: 'world', lang: 'ts' })); +const event = JSON.parse(readFileSync(process.argv[2], 'utf8')); +api.run(event, {} as any).then((r: any) => process.stdout.write(JSON.stringify(r))); diff --git a/e2e/fixtures/typescript/package.json b/e2e/fixtures/typescript/package.json new file mode 100644 index 0000000..2728208 --- /dev/null +++ b/e2e/fixtures/typescript/package.json @@ -0,0 +1 @@ +{ "name": "fixture-ts", "private": true } diff --git a/e2e/fixtures/typescript/tsconfig.json b/e2e/fixtures/typescript/tsconfig.json new file mode 100644 index 0000000..7b22cc4 --- /dev/null +++ b/e2e/fixtures/typescript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "node16", + "moduleResolution": "node16", + "esModuleInterop": true, + "strict": false, + "noImplicitAny": false, + "skipLibCheck": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["handler.ts"] +} diff --git a/e2e/helpers/harness.mjs b/e2e/helpers/harness.mjs new file mode 100644 index 0000000..e158274 --- /dev/null +++ b/e2e/helpers/harness.mjs @@ -0,0 +1,131 @@ +// Shared helpers for the Layer 1 (no-Docker) e2e suite. +// +// The suite installs the REAL packed tarball into isolated fixture roots and runs each +// consumer app in its own child process, so module resolution honors the published `exports` +// map + `files` whitelist — the exact thing the in-repo module-compat tests miss (they load +// dist/ directly with the optional AWS SDK present). + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, cpSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const HERE = dirname(fileURLToPath(import.meta.url)); +export const E2E_DIR = join(HERE, '..'); +export const REPO_ROOT = join(E2E_DIR, '..'); +export const FIXTURES = join(E2E_DIR, 'fixtures'); + +const results = []; +let currentFixture = '(setup)'; + +export function section(name) { + currentFixture = name; + process.stdout.write(`\n▸ ${name}\n`); +} + +export function check(desc, fn) { + try { + fn(); + results.push({ ok: true, fixture: currentFixture, desc }); + process.stdout.write(` ✓ ${desc}\n`); + } catch (err) { + results.push({ ok: false, fixture: currentFixture, desc, err: err.message }); + process.stdout.write(` ✗ ${desc}\n ${String(err.message).split('\n')[0]}\n`); + } +} + +export function assert(cond, msg) { + if (!cond) throw new Error(msg || 'assertion failed'); +} + +export function summarize() { + const failed = results.filter((r) => !r.ok); + process.stdout.write( + `\n${'='.repeat(60)}\nLayer 1: ${results.length - failed.length}/${results.length} checks passed\n` + ); + if (failed.length) { + process.stdout.write('\nFAILURES:\n'); + for (const f of failed) process.stdout.write(` - [${f.fixture}] ${f.desc}\n ${f.err}\n`); + } + return failed.length === 0; +} + +// npm pack the repo once; returns the absolute path to the produced .tgz. +export function packTarball() { + const out = execFileSync('npm', ['pack', '--silent', '--pack-destination', tmpdir()], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); + const tgz = join(tmpdir(), out.split('\n').pop().trim()); + assert(existsSync(tgz), `npm pack did not produce a tarball (${tgz})`); + return tgz; +} + +// Create a fresh temp project dir with the tarball (and optional extra deps) installed. +// `deps` e.g. ['@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner']. +export function makeInstallRoot(label, tgz, deps = []) { + const root = mkdtempSync(join(tmpdir(), `lambda-api-e2e-${label}-`)); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: `e2e-${label}`, private: true }, null, 2)); + const args = ['install', '--no-audit', '--no-fund', '--loglevel=error', tgz, ...deps]; + execFileSync('npm', args, { cwd: root, stdio: 'pipe' }); + return root; +} + +// Copy a fixture directory into an install root (so it resolves lambda-api from ../node_modules). +export function stageFixture(installRoot, fixtureName) { + const dest = join(installRoot, fixtureName); + cpSync(join(FIXTURES, fixtureName), dest, { recursive: true }); + return dest; +} + +export function writeEvent(dir, name, obj) { + const p = join(dir, name); + writeFileSync(p, JSON.stringify(obj)); + return p; +} + +// Run `node