@davidwells/llrt-analyzer 0.1.0

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 ADDED
@@ -0,0 +1,122 @@
1
+ # @davidwells/llrt-analyzer
2
+
3
+ Decide — **per Lambda function** — whether it can run on [AWS LLRT](https://github.com/awslabs/llrt)
4
+ (QuickJS, no JIT) for **~10x faster cold starts**, and help flip it. LLRT is not a
5
+ Node drop-in (no `node:http`/`https`/`tls`, no `worker_threads`, partial
6
+ `crypto`/`fs`/`stream`, pre-bundled AWS SDK subset), and you can't tell
7
+ statically — so this tool **actually bundles the handler and runs it under both
8
+ Node and the LLRT binary**, then diffs behavior. **If it runs identically, it
9
+ recommends switching.**
10
+
11
+ ## Install
12
+
13
+ ```
14
+ npm i -D @davidwells/llrt-analyzer # CLI command is `llrt-analyzer`
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```bash
20
+ # One function
21
+ llrt-analyzer services/mcp-gateway/src/app.ts --project services/mcp-gateway --fn api
22
+
23
+ # One function, definitive (runs it under the real LLRT binary)
24
+ llrt-analyzer services/mcp-gateway/src/app.ts --project services/mcp-gateway --fn api --local
25
+
26
+ # Whole service (discovers functions from serverless.yml) + machine output
27
+ llrt-analyzer services/mcp-gateway --local --out result.json
28
+
29
+ # Scaffold the serverless.yml snippet, and publish the LLRT layer (+ write SSM)
30
+ llrt-analyzer init
31
+ llrt-analyzer publish-layer --arch arm64 --region us-east-1 --ssm /my-svc/prod/llrt-layer-arn
32
+ ```
33
+
34
+ ## Tiers
35
+
36
+ 1. **Static** (default, no binary) — resolves the transitive import graph and
37
+ flags `node:` blockers, a per-run disallowed-API guard, a **known-quirks**
38
+ scan (confirmed behavioral breaks — e.g. `hono/cors` emptying the body, or
39
+ `timingSafeEqual` — caught from source signatures, no run needed), the AWS-SDK
40
+ bundle, known-bad npm, and CPU-heavy workloads — each with a concrete fix.
41
+ 2. **Local call-comparison** (`--local`, the decisive tier) — bundles for LLRT
42
+ (aliasing `@aws-sdk`/`@smithy` to a recorder shim, no side effects), runs the
43
+ handler under **Node and LLRT** with `fetch` + SDK calls intercepted, and
44
+ diffs **semantically**. LLRT crashes are attributed to the exact missing API.
45
+ Also **measures the cold-start win** (node vs LLRT init) and stamps
46
+ **confidence + provenance** (see below). Results are **cached** by built-bundle
47
+ content, so repeat/CI runs are instant on unchanged inputs (`--no-cache` to
48
+ bypass).
49
+ 3. **Deploy-verify** (opt-in, heavy) — ephemeral-stage deploy + integration
50
+ tests + a real cold-start measurement. Gated; the local tier covers
51
+ correctness without a deploy.
52
+
53
+ ## Verdict
54
+
55
+ `switch` · `compatible-but-not-recommended` (e.g. CPU-heavy) · `incompatible`
56
+ (with the exact blocker + fix) · `unknown` (static-ambiguous — run `--local`).
57
+ `result.json.llrtCandidates[]` is jq-able for CI.
58
+
59
+ Every verdict carries a **`confidence`** (`high`/`medium`/`low`) and
60
+ **`provenance`**: static-only is `low`; a local run on the **host** arch is
61
+ `high`, but if the host ≠ Lambda's target (e.g. verified on darwin, Lambda is
62
+ linux/arm64) it is downgraded to `medium` and the report says so — the tool
63
+ never silently passes off a host-arch result as a Lambda guarantee. Local runs
64
+ also report `coldStartDeltaMs` (`node`/`llrt`/`ratio`), the number that
65
+ justifies the flip.
66
+
67
+ ## Known-quirks DB
68
+
69
+ `data/known-quirks.<version>.json` holds confirmed **behavioral** LLRT breaks
70
+ that plain import/builtin analysis can't see, each with a source-detectable
71
+ signature so the static tier (and the deploy guard) flag them **before** a local
72
+ run or deploy. Add to it as new quirks are found — findings accrete into cheap
73
+ checks. The serverless plugin's guard runs the static tier, so it inherits these
74
+ automatically.
75
+
76
+ ## Serverless plugin (flip a function)
77
+
78
+ The Serverless Framework consumer ships as its own package,
79
+ [`serverless-llrt-analyzer`](../serverless-llrt-analyzer) (depends on this core):
80
+
81
+ ```yaml
82
+ plugins:
83
+ - serverless-llrt-analyzer
84
+ custom:
85
+ llrt:
86
+ layerArn: arn:aws:lambda:us-east-1:xxxx:layer:llrt-arm64:1 # your LLRT layer
87
+ verify: true # guard: fail deploy if a flagged fn is incompatible
88
+ functions:
89
+ api: { handler: src/app.handler, llrt: true } # -> provided.al2023 + arm64 + LLRT layer
90
+ warmer: { handler: src/warmer.warm, llrt: true }
91
+ processImg: { handler: src/img.handler } # stays nodejs
92
+ ```
93
+
94
+ Reverting is one line (remove `llrt: true`). Mixed-runtime services are
95
+ first-class.
96
+
97
+ ## smart-ci integration
98
+
99
+ - **Validator**: `@davidwells/llrt-analyzer/src/smart-ci-check` returns the canonical
100
+ `{ valid, errors:[{check:'llrt-compat',…}], verified }` shape; wire it into
101
+ `src/validate/index.js` behind a `checks.llrtCompat` toggle.
102
+ - **Persistence** (`src/persist.js`): cache the "llrtable" verdict keyed on
103
+ `hash(bundle + lockfile + llrtVersion)`; re-verify only on change. The Tier-1
104
+ disallowed-API scan still runs every CI run as a guard.
105
+ - **CI cadence** (consumer-configurable): (a) on-demand/opt-in, (b) a nightly
106
+ audit that opens "X is now LLRT-ready" PRs, (c) every changed candidate. Fold
107
+ `result.json.llrtCandidates` into `detect-changes.yml` via `jq`.
108
+
109
+ ## LLRT version pinning
110
+
111
+ One org-wide pinned version (`ORG_PINNED_LLRT_VERSION`, currently `0.8.1-beta`).
112
+ `scripts/refresh.js` regenerates the compat table from the LLRT repo; bumping the
113
+ pin is deliberate and re-verifies all llrtable functions. The LLRT binary is
114
+ downloaded from GitHub releases and **executed**, so it is verified against a
115
+ pinned sha256 in `data/llrt-binary-checksums.<version>.json` — a mismatch aborts
116
+ (an unlisted asset is allowed but logs its observed hash to pin).
117
+
118
+ ## When NOT to use LLRT
119
+
120
+ Compute-heavy functions (big loops, large-data transforms, heavy hashing): LLRT
121
+ has no JIT and can be **slower** than Node even when compatible — the analyzer
122
+ warns (`compatible-but-not-recommended`).
@@ -0,0 +1,67 @@
1
+ {
2
+ "llrtVersion": "0.8.1-beta",
3
+ "source": "https://github.com/awslabs/llrt#compatibility-matrix",
4
+ "_status_legend": {
5
+ "supported": "works",
6
+ "partial": "present but incomplete API surface \u2014 MUST run to confirm the specific calls used",
7
+ "planned": "not implemented yet (LLRT marks it) \u2014 treat as a blocker",
8
+ "unsupported": "will not work \u2014 blocker"
9
+ },
10
+ "builtins": {
11
+ "assert": "partial",
12
+ "async_hooks": "partial",
13
+ "buffer": "partial",
14
+ "child_process": "unsupported",
15
+ "cluster": "unsupported",
16
+ "console": "partial",
17
+ "crypto": "partial",
18
+ "dgram": "unsupported",
19
+ "diagnostics_channel": "unsupported",
20
+ "dns": "unsupported",
21
+ "domain": "unsupported",
22
+ "events": "partial",
23
+ "fs": "partial",
24
+ "fs/promises": "partial",
25
+ "http": "planned",
26
+ "http2": "unsupported",
27
+ "https": "planned",
28
+ "inspector": "unsupported",
29
+ "module": "partial",
30
+ "net": "planned",
31
+ "os": "partial",
32
+ "path": "partial",
33
+ "perf_hooks": "partial",
34
+ "process": "partial",
35
+ "punycode": "unsupported",
36
+ "querystring": "unsupported",
37
+ "readline": "unsupported",
38
+ "repl": "unsupported",
39
+ "stream": "partial",
40
+ "stream/web": "partial",
41
+ "string_decoder": "supported",
42
+ "sys": "unsupported",
43
+ "timers": "supported",
44
+ "timers/promises": "supported",
45
+ "tls": "planned",
46
+ "trace_events": "unsupported",
47
+ "tty": "unsupported",
48
+ "url": "partial",
49
+ "util": "partial",
50
+ "v8": "unsupported",
51
+ "vm": "unsupported",
52
+ "wasi": "unsupported",
53
+ "worker_threads": "unsupported",
54
+ "zlib": "partial"
55
+ },
56
+ "notes": {
57
+ "http": "node:http is not implemented; use fetch instead.",
58
+ "https": "node:https is not implemented; use fetch instead.",
59
+ "net": "raw sockets not implemented.",
60
+ "tls": "not implemented; outbound TLS is via fetch (TLS 1.2 default, LLRT_TLS_VERSION=1.3 opt-in).",
61
+ "crypto": "Hash/HMAC/randomBytes/randomUUID/randomFillSync present; createDiffieHellman, some ciphers and KDFs are NOT available. Run to confirm the exact fns used.",
62
+ "stream": "native LLRT implementation, not full Node stream compatibility.",
63
+ "worker_threads": "no multithreading.",
64
+ "child_process": "extremely limited; treat as unsupported for Lambda handlers.",
65
+ "querystring": "node:querystring is not resolvable in LLRT 0.8.1 (empirically verified)."
66
+ }
67
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "_note": "npm packages known to break under LLRT (QuickJS, no JIT, partial node API). status: 'blocker' (won't run) or 'warn' (works but risky/slow). 'reason' feeds the fix suggestion. Curated — extend as discovered.",
3
+ "packages": {
4
+ "sharp": { "status": "blocker", "reason": "native addon (node-gyp); LLRT has no native addon support" },
5
+ "canvas": { "status": "blocker", "reason": "native addon; unsupported under LLRT" },
6
+ "bcrypt": { "status": "blocker", "reason": "native addon; use bcryptjs (pure JS) or keep on Node" },
7
+ "puppeteer": { "status": "blocker", "reason": "spawns a browser via child_process; unsupported" },
8
+ "playwright": { "status": "blocker", "reason": "child_process + native; unsupported" },
9
+ "express": { "status": "blocker", "reason": "built on node:http server which is not implemented in LLRT" },
10
+ "koa": { "status": "blocker", "reason": "node:http server based; not implemented in LLRT" },
11
+ "ws": { "status": "blocker", "reason": "node:net/http upgrade; not implemented" },
12
+ "node-fetch": { "status": "warn", "reason": "prefer global fetch (built-in) under LLRT" },
13
+ "axios": { "status": "warn", "reason": "may use node:http adapter transitively; ensure the fetch/xhr adapter is used" },
14
+ "undici": { "status": "warn", "reason": "pulls node:http internals; prefer global fetch" }
15
+ }
16
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "llrtVersion": "0.8.1-beta",
3
+ "note": "Confirmed BEHAVIORAL incompatibilities that static import/builtin analysis alone cannot see — each with a source-detectable signature so the static tier (and the deploy guard) can flag them BEFORE a local run or deploy. Signatures are matched against reachable first-party files. Findings here were discovered empirically (and, where noted, verified on real Lambda); add to this file as new quirks are found so the knowledge accretes into cheap checks.",
4
+ "quirks": [
5
+ {
6
+ "id": "hono-cors-empty-body",
7
+ "signature": "(?:from\\s+|require\\(\\s*)[\"']hono/cors[\"']",
8
+ "files": "firstParty",
9
+ "severity": "blocker",
10
+ "api": "hono/cors (Response#body)",
11
+ "note": "Hono's cors() middleware mutates response headers AFTER the handler, forcing Hono to rebuild the response via new Response(res.body, init). LLRT 0.8.x does not implement Response.prototype.body (the getter returns undefined), so the rebuild drops the body — clients get the right status/headers but an EMPTY body. Verified on real Lambda: nodejs22.x keeps the body, provided.al2023+LLRT empties it.",
12
+ "fix": "Replace hono/cors with a prepared-headers CORS middleware: set the CORS headers via c.header() BEFORE next() (so they fold into the handler's response with no rebuild), and answer OPTIONS preflight with a body-less 204. See the serverless-llrt-analyzer README.",
13
+ "tracking": "https://github.com/awslabs/llrt/issues"
14
+ },
15
+ {
16
+ "id": "crypto-timing-safe-equal",
17
+ "signature": "\\btimingSafeEqual\\s*\\(",
18
+ "files": "firstParty",
19
+ "severity": "blocker",
20
+ "api": "node:crypto.timingSafeEqual",
21
+ "note": "LLRT 0.8.x node:crypto does not export timingSafeEqual — importing/calling it throws at runtime.",
22
+ "fix": "Use a pure-JS constant-time compare (XOR-accumulate over two equal-length buffers, length-check first) instead of timingSafeEqual.",
23
+ "tracking": "https://github.com/honojs/hono/issues/3914"
24
+ },
25
+ {
26
+ "id": "response-body-rewrap",
27
+ "signature": "new\\s+Response\\s*\\([^)]{0,80}\\.body\\b",
28
+ "files": "firstParty",
29
+ "severity": "warning",
30
+ "api": "Response.prototype.body",
31
+ "note": "Constructing a Response from another Response/Request .body relies on Response.prototype.body, which is undefined in LLRT 0.8.x — the resulting body is empty.",
32
+ "fix": "Avoid reading Response#body; pass the string/JSON body directly, or keep this function on Node until LLRT ships Response#body.",
33
+ "tracking": "https://github.com/awslabs/llrt/issues"
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "llrtVersion": "0.8.1-beta",
3
+ "algorithm": "sha256",
4
+ "note": "sha256 of the EXTRACTED llrt executable per release asset, pinned after review. The tool downloads and EXECUTES this binary, so downloads are verified against these hashes — a mismatch aborts (tamper/corruption). Assets not listed here are downloaded with a warning that logs the observed hash so a maintainer can pin it. Regenerate/extend with scripts/refresh-checksums.js.",
5
+ "binaries": {
6
+ "llrt-darwin-arm64-full-sdk.zip": "a63f3330d9a834fbc148c62f3ec8473e6a7af5a3215454285c0c7a1b9291d668",
7
+ "llrt-darwin-arm64-no-sdk.zip": "494da93c5d88fc464607fa2b5017f856f954409c4fa5049dabeb59a4c188e071"
8
+ }
9
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "llrtVersion": "0.8.1-beta",
3
+ "source": "https://github.com/awslabs/llrt#using-aws-sdk-v3-with-llrt",
4
+ "_note": "LLRT ships pre-bundled AWS SDK v3 clients. 'always' = in every bundle (mark external). 'std' = in the default (std-sdk) bundle. 'full' = only in the -full-sdk bundle. Anything not listed must be bundled INTO the artifact (bundleIn), not marked external.",
5
+ "always": [
6
+ "@smithy/",
7
+ "@aws-crypto/",
8
+ "@aws-sdk/credential-providers",
9
+ "@aws-sdk/lib-dynamodb",
10
+ "@aws-sdk/lib-storage",
11
+ "@aws-sdk/s3-presigned-post",
12
+ "@aws-sdk/s3-request-presigner",
13
+ "@aws-sdk/util-dynamodb"
14
+ ],
15
+ "std": [
16
+ "@aws-sdk/client-s3",
17
+ "@aws-sdk/client-dynamodb",
18
+ "@aws-sdk/client-dynamodb-streams",
19
+ "@aws-sdk/client-sqs",
20
+ "@aws-sdk/client-sns",
21
+ "@aws-sdk/client-eventbridge",
22
+ "@aws-sdk/client-kms",
23
+ "@aws-sdk/client-secrets-manager",
24
+ "@aws-sdk/client-sts",
25
+ "@aws-sdk/client-lambda",
26
+ "@aws-sdk/client-cloudwatch",
27
+ "@aws-sdk/client-cloudwatch-logs"
28
+ ],
29
+ "full": [
30
+ "@aws-sdk/client-ssm",
31
+ "@aws-sdk/client-cognito-identity-provider",
32
+ "@aws-sdk/client-api-gateway",
33
+ "@aws-sdk/client-apigatewayv2",
34
+ "@aws-sdk/client-step-functions",
35
+ "@aws-sdk/client-ses",
36
+ "@aws-sdk/client-sesv2",
37
+ "@aws-sdk/client-firehose",
38
+ "@aws-sdk/client-kinesis"
39
+ ]
40
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@davidwells/llrt-analyzer",
3
+ "version": "0.1.0",
4
+ "description": "Per-function analyzer that decides (statically + by actually running under LLRT) whether a Lambda function can switch to AWS LLRT for ~10x faster cold starts, and helps flip it.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "llrt-analyzer": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "README.md",
11
+ "data/compat-table.0.8.1-beta.json",
12
+ "data/known-bad.json",
13
+ "data/known-quirks.0.8.1-beta.json",
14
+ "data/llrt-binary-checksums.0.8.1-beta.json",
15
+ "data/sdk-bundle.0.8.1-beta.json",
16
+ "src/binary-manager.js",
17
+ "src/build-for-llrt.js",
18
+ "src/cli.js",
19
+ "src/cold-start.js",
20
+ "src/corpus.js",
21
+ "src/deploy-verify.js",
22
+ "src/diff.js",
23
+ "src/error-map.js",
24
+ "src/import-graph.js",
25
+ "src/index.js",
26
+ "src/knowledge-base.js",
27
+ "src/local-compare.js",
28
+ "src/persist.js",
29
+ "src/run-under.js",
30
+ "src/runtime/aws-recorder.cjs",
31
+ "src/service.js",
32
+ "src/smart-ci-check.js",
33
+ "src/static-scan.js",
34
+ "src/verdict.js"
35
+ ],
36
+ "scripts": {
37
+ "test": "uvu src \".*\\.test\\.js$\"",
38
+ "types": "tsc"
39
+ },
40
+ "keywords": [
41
+ "llrt",
42
+ "aws-lambda",
43
+ "serverless",
44
+ "cold-start",
45
+ "quickjs",
46
+ "compatibility",
47
+ "runtime"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/DavidWells/smart-ci.git",
52
+ "directory": "packages/llrt-analyzer"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/DavidWells/smart-ci/issues"
56
+ },
57
+ "homepage": "https://github.com/DavidWells/smart-ci/tree/master/packages/llrt-analyzer#readme",
58
+ "engines": {
59
+ "node": ">=18"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "license": "ISC",
65
+ "dependencies": {
66
+ "@davidwells/smart-log": "^2.0.4",
67
+ "esbuild": "^0.27.0",
68
+ "precinct": "^8.3.1"
69
+ },
70
+ "devDependencies": {
71
+ "uvu": "^0.5.6"
72
+ }
73
+ }
@@ -0,0 +1,152 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * LLRT binary manager (bead sci-xwk.14).
5
+ *
6
+ * Ensure the pinned LLRT binary for the host platform/arch (+ sdk bundle
7
+ * variant) is downloaded, unzipped, and cached under ~/.smart-ci/llrt/<ver>/ so
8
+ * Tier-2/3 runs are reproducible. Returns the executable path. Skips-with-a-
9
+ * clear-error when offline and not cached.
10
+ */
11
+ const fs = require('fs')
12
+ const os = require('os')
13
+ const path = require('path')
14
+ const https = require('https')
15
+ const crypto = require('crypto')
16
+ const { spawnSync } = require('child_process')
17
+ const { ORG_PINNED_LLRT_VERSION, loadBinaryChecksums } = require('./knowledge-base')
18
+
19
+ const CACHE_ROOT = path.join(os.homedir(), '.smart-ci', 'llrt')
20
+
21
+ /** @returns {{ platform: string, arch: string }} */
22
+ function hostTarget() {
23
+ const platform = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
24
+ const arch = { arm64: 'arm64', x64: 'x64' }[process.arch]
25
+ if (!platform || !arch) throw new Error(`llrt-analyzer: unsupported host ${process.platform}/${process.arch}`)
26
+ return { platform, arch }
27
+ }
28
+
29
+ /**
30
+ * @param {{ version?: string, sdk?: 'no-sdk'|'std-sdk'|'full-sdk' }} [opts]
31
+ * @returns {Promise<string>} absolute path to the `llrt` executable
32
+ */
33
+ async function getLlrtBinary(opts = {}) {
34
+ const version = opts.version || ORG_PINNED_LLRT_VERSION
35
+ const sdk = opts.sdk || 'full-sdk'
36
+ const { platform, arch } = hostTarget()
37
+ const suffix = sdk === 'std-sdk' ? '' : sdk === 'no-sdk' ? '-no-sdk' : '-full-sdk'
38
+ const asset = `llrt-${platform}-${arch}${suffix}.zip`
39
+ const dir = path.join(CACHE_ROOT, version, `${platform}-${arch}-${sdk}`)
40
+ const bin = path.join(dir, 'llrt')
41
+
42
+ if (fs.existsSync(bin)) return bin
43
+
44
+ const url = `https://github.com/awslabs/llrt/releases/download/v${version}/${asset}`
45
+ fs.mkdirSync(dir, { recursive: true })
46
+ const zip = path.join(dir, asset)
47
+ try {
48
+ await download(url, zip)
49
+ } catch (err) {
50
+ throw new Error(
51
+ `llrt-analyzer: could not fetch the LLRT binary (${asset} @ v${version}). ` +
52
+ `${err.message}. Tier-2/3 need it; run online once to cache, or pin a released version.`,
53
+ )
54
+ }
55
+ const unzip = spawnSync('unzip', ['-o', zip, '-d', dir], { encoding: 'utf8' })
56
+ if (unzip.status !== 0) throw new Error(`llrt-analyzer: unzip failed for ${asset}: ${unzip.stderr || unzip.stdout}`)
57
+ if (!fs.existsSync(bin)) {
58
+ // Some archives nest the binary; find it.
59
+ const found = findBinary(dir)
60
+ if (found && found !== bin) fs.renameSync(found, bin)
61
+ }
62
+ if (!fs.existsSync(bin)) throw new Error(`llrt-analyzer: llrt executable not found after unzip in ${dir}`)
63
+
64
+ // Supply-chain: we EXECUTE this binary, so verify it against the pinned hash.
65
+ // A mismatch aborts (and removes the file); an unlisted asset is allowed but
66
+ // logs its observed hash so a maintainer can pin it.
67
+ verifyChecksum(bin, asset, version)
68
+
69
+ fs.chmodSync(bin, 0o755)
70
+ try {
71
+ fs.unlinkSync(zip)
72
+ } catch (_) {
73
+ /* ignore */
74
+ }
75
+ return bin
76
+ }
77
+
78
+ /** sha256 a file. */
79
+ function sha256File(file) {
80
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
81
+ }
82
+
83
+ /**
84
+ * Verify the extracted binary against data/llrt-binary-checksums.<version>.json.
85
+ * @param {string} bin @param {string} asset @param {string} version
86
+ */
87
+ function verifyChecksum(bin, asset, version) {
88
+ const pinned = (loadBinaryChecksums(version).binaries || {})[asset]
89
+ const observed = sha256File(bin)
90
+ if (!pinned) {
91
+ process.stderr.write(
92
+ `llrt-analyzer: no pinned checksum for ${asset} @ v${version} — proceeding (TOFU). ` +
93
+ `Pin it in data/llrt-binary-checksums.${version}.json:\n "${asset}": "${observed}"\n`,
94
+ )
95
+ return
96
+ }
97
+ if (observed !== pinned) {
98
+ try {
99
+ fs.unlinkSync(bin)
100
+ } catch (_) {
101
+ /* ignore */
102
+ }
103
+ throw new Error(
104
+ `llrt-analyzer: checksum MISMATCH for ${asset} @ v${version}.\n expected ${pinned}\n got ${observed}\n` +
105
+ 'Refusing to run a binary that does not match the pinned hash (possible tampering/corruption).',
106
+ )
107
+ }
108
+ }
109
+
110
+ /** Is a usable llrt binary already cached (no network)? */
111
+ function isCached(opts = {}) {
112
+ const version = opts.version || ORG_PINNED_LLRT_VERSION
113
+ const sdk = opts.sdk || 'full-sdk'
114
+ const { platform, arch } = hostTarget()
115
+ return fs.existsSync(path.join(CACHE_ROOT, version, `${platform}-${arch}-${sdk}`, 'llrt'))
116
+ }
117
+
118
+ function findBinary(dir) {
119
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
120
+ const full = path.join(dir, e.name)
121
+ if (e.isFile() && (e.name === 'llrt' || e.name === 'bootstrap')) return full
122
+ if (e.isDirectory()) {
123
+ const nested = findBinary(full)
124
+ if (nested) return nested
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ function download(url, dest, redirects = 0) {
131
+ return new Promise((resolve, reject) => {
132
+ if (redirects > 5) return reject(new Error('too many redirects'))
133
+ https
134
+ .get(url, { headers: { 'user-agent': 'llrt-analyzer' } }, (res) => {
135
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
136
+ res.resume()
137
+ return resolve(download(res.headers.location, dest, redirects + 1))
138
+ }
139
+ if (res.statusCode !== 200) {
140
+ res.resume()
141
+ return reject(new Error(`GET ${url} -> ${res.statusCode}`))
142
+ }
143
+ const out = fs.createWriteStream(dest)
144
+ res.pipe(out)
145
+ out.on('finish', () => out.close(() => resolve(dest)))
146
+ out.on('error', reject)
147
+ })
148
+ .on('error', reject)
149
+ })
150
+ }
151
+
152
+ module.exports = { getLlrtBinary, isCached, hostTarget, sha256File, download, CACHE_ROOT }
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * buildForLlrt (bead sci-xwk.15) — esbuild-bundle a handler into an LLRT-ready
5
+ * .mjs VERIFY artifact: es2023 / esm, TS transpiled, and every @aws-sdk/@smithy
6
+ * import aliased to the recorder shim (sci-xwk.16) so runs have no side effects
7
+ * and capture SDK calls uniformly under Node and LLRT. A generated entry inlines
8
+ * the fetch recorder + a run harness that invokes the handler with a corpus
9
+ * event (read from process.env.VERIFY_EVENT_FILE) and prints a JSON result.
10
+ */
11
+ const fs = require('fs')
12
+ const os = require('os')
13
+ const path = require('path')
14
+ const crypto = require('crypto')
15
+
16
+ const RECORDER = path.join(__dirname, 'runtime', 'aws-recorder.cjs')
17
+ const RESULT_MARKER = '__LLRT_VERIFY_RESULT__'
18
+
19
+ /**
20
+ * @param {{ entryPoint: string, outDir?: string, handlerExport?: string }} input
21
+ * @returns {Promise<{ outFile: string, resultMarker: string, warnings: string[], errors: string[] }>}
22
+ */
23
+ async function buildForLlrt(input) {
24
+ // eslint-disable-next-line global-require
25
+ const esbuild = require('esbuild')
26
+ const outDir = input.outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'llrt-verify-'))
27
+ fs.mkdirSync(outDir, { recursive: true })
28
+ const entryAbs = path.resolve(input.entryPoint)
29
+ const genEntry = path.join(outDir, '__verify_entry.mjs')
30
+ fs.writeFileSync(genEntry, verifyEntrySource(entryAbs, input.handlerExport))
31
+
32
+ const outFile = path.join(outDir, 'verify.mjs')
33
+ const result = await esbuild.build({
34
+ entryPoints: [genEntry],
35
+ bundle: true,
36
+ format: 'esm',
37
+ target: 'es2023',
38
+ platform: 'node', // keep node: builtins external — LLRT provides its (partial) versions
39
+ outfile: outFile,
40
+ logLevel: 'silent',
41
+ metafile: false,
42
+ plugins: [awsRecorderAlias()],
43
+ })
44
+ // LLRT resolves `node:querystring` but not bare `querystring`. esbuild
45
+ // auto-externalizes builtins without the prefix, so rewrite them (only in
46
+ // import/require positions, never in data string literals).
47
+ fs.writeFileSync(outFile, nodePrefixBundle(fs.readFileSync(outFile, 'utf8')))
48
+ return {
49
+ outFile,
50
+ resultMarker: RESULT_MARKER,
51
+ warnings: (result.warnings || []).map((w) => w.text),
52
+ errors: (result.errors || []).map((e) => e.text),
53
+ }
54
+ }
55
+
56
+ /** Rewrite bare node-builtin specifiers to `node:`-prefixed ones, ONLY in
57
+ * import/require positions (from"x" / import"x" / require("x") / __require("x")),
58
+ * never in data string literals. */
59
+ function nodePrefixBundle(code) {
60
+ const nodeModule = require('module')
61
+ const isBuiltin =
62
+ typeof nodeModule.isBuiltin === 'function'
63
+ ? (n) => nodeModule.isBuiltin(n)
64
+ : ((set) => (n) => set.has(String(n).replace(/^node:/, '')))(new Set(nodeModule.builtinModules))
65
+ const re = /\b(from|import|require|__require\d*|__toESM\s*\(\s*require)\s*\(?\s*(["'])([a-z][a-z0-9_/]*)\2/g
66
+ return code.replace(re, (match, kw, quote, name) => {
67
+ if (!isBuiltin(name) || name.startsWith('node:')) return match
68
+ return match.replace(`${quote}${name}${quote}`, `${quote}node:${name}${quote}`)
69
+ })
70
+ }
71
+
72
+ /** esbuild plugin: alias every AWS SDK / smithy import to the recorder shim. */
73
+ function awsRecorderAlias() {
74
+ return {
75
+ name: 'aws-recorder-alias',
76
+ setup(build) {
77
+ build.onResolve({ filter: /^@aws-sdk\/|^@smithy\/|^@aws-crypto\// }, () => ({ path: RECORDER }))
78
+ },
79
+ }
80
+ }
81
+
82
+ /** The generated verify entry (ESM) — runs identically under Node and LLRT. */
83
+ function verifyEntrySource(entryAbs, handlerExport) {
84
+ const pick = handlerExport
85
+ ? `__mod[${JSON.stringify(handlerExport)}]`
86
+ : '__mod.handler || (__mod.default && (__mod.default.handler || __mod.default)) || __mod.default'
87
+ return `// generated by llrt-analyzer buildForLlrt — do not edit
88
+ import * as __mod from ${JSON.stringify(entryAbs)}
89
+ import __fs from 'node:fs'
90
+
91
+ globalThis.__LLRT_CALLS__ = globalThis.__LLRT_CALLS__ || []
92
+ const __origFetch = globalThis.fetch
93
+ globalThis.fetch = async (input, init) => {
94
+ try {
95
+ const url = typeof input === 'string' ? input : (input && input.url) || ''
96
+ const method = (init && init.method) || (input && input.method) || 'GET'
97
+ let body
98
+ if (init && init.body != null) body = typeof init.body === 'string' ? init.body : '[stream]'
99
+ globalThis.__LLRT_CALLS__.push({ kind: 'fetch', method: String(method).toUpperCase(), url, body })
100
+ } catch (e) { /* ignore */ }
101
+ return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })
102
+ }
103
+
104
+ const RESULT = ${JSON.stringify(RESULT_MARKER)}
105
+
106
+ // Cold-start measurement: when LLRT_INIT_ONLY is set, all imports above have
107
+ // already executed (that IS the init/cold-start cost) — print + exit before
108
+ // invoking the handler so the caller times pure init under Node vs LLRT.
109
+ if (typeof process !== 'undefined' && process.env && process.env.LLRT_INIT_ONLY) {
110
+ console.log(RESULT + JSON.stringify({ ok: true, initOnly: true, calls: [] }))
111
+ } else {
112
+ __run()
113
+ }
114
+
115
+ async function __run() {
116
+ const handler = ${pick}
117
+ if (typeof handler !== 'function') {
118
+ console.log(RESULT + JSON.stringify({ ok: false, error: 'no handler export found', calls: [] }))
119
+ return
120
+ }
121
+ let event = {}
122
+ try {
123
+ const p = typeof process !== 'undefined' && process.env && process.env.VERIFY_EVENT_FILE
124
+ if (p) event = JSON.parse(__fs.readFileSync(p, 'utf8'))
125
+ } catch (e) { /* default {} */ }
126
+ const ctx = { awsRequestId: 'llrt-verify', functionName: 'verify', getRemainingTimeInMillis: () => 30000, callbackWaitsForEmptyEventLoop: false }
127
+ try {
128
+ const response = await handler(event, ctx)
129
+ console.log(RESULT + JSON.stringify({ ok: true, response, calls: globalThis.__LLRT_CALLS__ }))
130
+ } catch (err) {
131
+ console.log(RESULT + JSON.stringify({ ok: false, error: String((err && err.message) || err), stack: String((err && err.stack) || ''), calls: globalThis.__LLRT_CALLS__ }))
132
+ }
133
+ }
134
+ `
135
+ }
136
+
137
+ module.exports = { buildForLlrt, RESULT_MARKER }