@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 +122 -0
- package/data/compat-table.0.8.1-beta.json +67 -0
- package/data/known-bad.json +16 -0
- package/data/known-quirks.0.8.1-beta.json +36 -0
- package/data/llrt-binary-checksums.0.8.1-beta.json +9 -0
- package/data/sdk-bundle.0.8.1-beta.json +40 -0
- package/package.json +73 -0
- package/src/binary-manager.js +152 -0
- package/src/build-for-llrt.js +137 -0
- package/src/cli.js +229 -0
- package/src/cold-start.js +51 -0
- package/src/corpus.js +116 -0
- package/src/deploy-verify.js +65 -0
- package/src/diff.js +113 -0
- package/src/error-map.js +38 -0
- package/src/import-graph.js +167 -0
- package/src/index.js +86 -0
- package/src/knowledge-base.js +139 -0
- package/src/local-compare.js +156 -0
- package/src/persist.js +110 -0
- package/src/run-under.js +52 -0
- package/src/runtime/aws-recorder.cjs +99 -0
- package/src/service.js +77 -0
- package/src/smart-ci-check.js +34 -0
- package/src/static-scan.js +195 -0
- package/src/verdict.js +133 -0
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 }
|