@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/src/cli.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
'use strict'
|
|
4
|
+
/**
|
|
5
|
+
* llrt-analyzer CLI.
|
|
6
|
+
*
|
|
7
|
+
* llrt-analyzer <entryFile|serviceDir> [--local] [--project <dir>] [--fn <name>] [--json]
|
|
8
|
+
* llrt-analyzer init scaffold the serverless.yml snippet
|
|
9
|
+
* llrt-analyzer publish-layer [options] publish the LLRT layer + optionally write SSM
|
|
10
|
+
* llrt-analyzer --help
|
|
11
|
+
*
|
|
12
|
+
* The analyze path runs the static tier (add --local for the decisive
|
|
13
|
+
* run-under-LLRT tier) and prints a readable verdict + fixes + cold-start win.
|
|
14
|
+
*/
|
|
15
|
+
const fs = require('fs')
|
|
16
|
+
const os = require('os')
|
|
17
|
+
const path = require('path')
|
|
18
|
+
const { spawnSync } = require('child_process')
|
|
19
|
+
const { analyzeFunction, analyzeService, ORG_PINNED_LLRT_VERSION } = require('./index')
|
|
20
|
+
const { download, sha256File } = require('./binary-manager')
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = { _: [], flags: {} }
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
const a = argv[i]
|
|
26
|
+
if (a === '--help' || a === '-h') args.flags.help = true
|
|
27
|
+
else if (a === '--json') args.flags.json = true
|
|
28
|
+
else if (a === '--local') args.flags.local = true
|
|
29
|
+
else if (a === '--no-cache') args.flags.cache = false
|
|
30
|
+
else if (a === '--project') args.flags.project = argv[(i += 1)]
|
|
31
|
+
else if (a === '--fn') args.flags.fn = argv[(i += 1)]
|
|
32
|
+
else if (a === '--handler-export') args.flags.handlerExport = argv[(i += 1)]
|
|
33
|
+
else if (a === '--out') args.flags.out = argv[(i += 1)]
|
|
34
|
+
else if (a === '--cache-file') args.flags.cacheFile = argv[(i += 1)]
|
|
35
|
+
else if (a === '--arch') args.flags.arch = argv[(i += 1)]
|
|
36
|
+
else if (a === '--region') args.flags.region = argv[(i += 1)]
|
|
37
|
+
else if (a === '--profile') args.flags.profile = argv[(i += 1)]
|
|
38
|
+
else if (a === '--layer-name') args.flags.layerName = argv[(i += 1)]
|
|
39
|
+
else if (a === '--ssm') args.flags.ssm = argv[(i += 1)]
|
|
40
|
+
else if (a === '--stage') args.flags.stage = argv[(i += 1)]
|
|
41
|
+
else args._.push(a)
|
|
42
|
+
}
|
|
43
|
+
return args
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HELP = `llrt-analyzer — can this Lambda function run on AWS LLRT (~10x faster cold starts)?
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
llrt-analyzer <entryFile|serviceDir> [options] analyze one function or a whole service
|
|
50
|
+
llrt-analyzer init print the serverless.yml snippet to flip functions
|
|
51
|
+
llrt-analyzer publish-layer [options] publish the LLRT layer (+ optionally write its ARN to SSM)
|
|
52
|
+
|
|
53
|
+
Analyze options:
|
|
54
|
+
--local Run the decisive tier: actually execute under the LLRT binary and diff behavior.
|
|
55
|
+
--project <dir> Project root (for relative file paths). Default: entry's dir.
|
|
56
|
+
--fn <name> Function name label. Default: entry basename.
|
|
57
|
+
--out <file> Write machine-readable result.json (service mode).
|
|
58
|
+
--json Emit the raw FunctionVerdict JSON.
|
|
59
|
+
--no-cache Ignore the local-tier result cache.
|
|
60
|
+
--cache-file <f> Use a specific cache store (default ~/.smart-ci/llrt/verdict-cache.json).
|
|
61
|
+
|
|
62
|
+
publish-layer options:
|
|
63
|
+
--arch <arm64|x64> Layer architecture (default arm64).
|
|
64
|
+
--region <region> AWS region (default us-east-1).
|
|
65
|
+
--profile <name> AWS profile.
|
|
66
|
+
--layer-name <name> Layer name (default llrt-<arch>).
|
|
67
|
+
--ssm <param> Also write the published ARN to this SSM parameter.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
llrt-analyzer services/mcp-gateway --local --out result.json
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
const { _, flags } = parseArgs(process.argv.slice(2))
|
|
75
|
+
if (flags.help || _.length === 0) {
|
|
76
|
+
process.stdout.write(HELP)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (_[0] === 'init') return cmdInit()
|
|
80
|
+
if (_[0] === 'publish-layer') return cmdPublishLayer(flags)
|
|
81
|
+
|
|
82
|
+
const target = path.resolve(_[0])
|
|
83
|
+
const tiers = flags.local ? ['static', 'local'] : ['static']
|
|
84
|
+
const isDir = fs.existsSync(target) && fs.statSync(target).isDirectory()
|
|
85
|
+
|
|
86
|
+
// Whole-service mode: a directory -> discover functions from serverless.yml.
|
|
87
|
+
if (isDir) {
|
|
88
|
+
const verdicts = await analyzeService({ projectDir: target, tiers, resultFile: flags.out, cache: flags.cache, cacheFile: flags.cacheFile })
|
|
89
|
+
if (!verdicts.length) {
|
|
90
|
+
process.stderr.write(`No functions discovered in ${target} (need a serverless.yml with functions).\n`)
|
|
91
|
+
process.exit(2)
|
|
92
|
+
}
|
|
93
|
+
if (flags.json) {
|
|
94
|
+
process.stdout.write(`${JSON.stringify(verdicts, null, 2)}\n`)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
for (const v of verdicts) process.stdout.write(renderReport(v))
|
|
98
|
+
if (flags.out) process.stdout.write(`\nresult.json written to ${flags.out}\n`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Single-function mode.
|
|
103
|
+
const entryPoint = target
|
|
104
|
+
const projectDir = flags.project ? path.resolve(flags.project) : path.dirname(entryPoint)
|
|
105
|
+
const verdict = await analyzeFunction({ entryPoint, projectDir, fn: flags.fn, tiers, handlerExport: flags.handlerExport, cache: flags.cache, cacheFile: flags.cacheFile })
|
|
106
|
+
if (flags.json) {
|
|
107
|
+
process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write(renderReport(verdict))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderReport(v) {
|
|
114
|
+
const icon = { switch: '🟢', 'compatible-but-not-recommended': '🟡', unknown: '⚪', incompatible: '🔴' }[v.recommendation]
|
|
115
|
+
const lines = []
|
|
116
|
+
lines.push('')
|
|
117
|
+
lines.push(`${icon} ${v.fn}: ${v.recommendation.toUpperCase()} (LLRT ${v.llrtVersion}, confidence: ${v.confidence}, static tier: ${v.staticTier})`)
|
|
118
|
+
lines.push(` sdk bundle: ${v.sdkBundle}${v.bundleIn.length ? ` bundleIn: ${v.bundleIn.join(', ')}` : ''}`)
|
|
119
|
+
const cs = v.coldStartDeltaMs
|
|
120
|
+
if (cs && cs.measured) {
|
|
121
|
+
lines.push(` cold start (init): node ~${cs.node}ms → llrt ~${cs.llrt}ms${cs.ratio ? ` (${cs.ratio}× faster)` : ''}`)
|
|
122
|
+
}
|
|
123
|
+
if (v.provenance && v.provenance.tier === 'local' && v.provenance.hostMatchesTarget === false) {
|
|
124
|
+
lines.push(` ⚠ ran on ${v.provenance.hostPlatform}/${v.provenance.hostArch}; Lambda is ${v.provenance.targetPlatform}/${v.provenance.targetArch} → confidence: ${v.confidence}`)
|
|
125
|
+
}
|
|
126
|
+
if (v.provenance && v.provenance.cached) lines.push(' (result served from cache — inputs unchanged)')
|
|
127
|
+
if (v.verified.length) lines.push(` verified: ${v.verified.join(', ')}`)
|
|
128
|
+
if (v.blockers.length) {
|
|
129
|
+
lines.push(' BLOCKERS:')
|
|
130
|
+
for (const b of v.blockers) lines.push(` ✗ ${b.api}${b.via && b.via !== '(direct)' ? ` (via ${b.via})` : ''}${b.file ? ` [${b.file}]` : ''}\n fix: ${b.fix}`)
|
|
131
|
+
}
|
|
132
|
+
if (v.warnings.length) {
|
|
133
|
+
lines.push(' WARNINGS:')
|
|
134
|
+
for (const w of v.warnings) lines.push(` ! ${w.note}${w.fix ? `\n fix: ${w.fix}` : ''}`)
|
|
135
|
+
}
|
|
136
|
+
if (v.diffs && v.diffs.length) {
|
|
137
|
+
lines.push(` BEHAVIORAL DIFFS (Node vs LLRT — same input, different output):`)
|
|
138
|
+
for (const d of v.diffs) {
|
|
139
|
+
lines.push(` ≠ ${d.event} ${d.path}`)
|
|
140
|
+
if (d.hint) lines.push(` ${d.hint}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (v.recommendation === 'switch' || v.recommendation === 'compatible-but-not-recommended') {
|
|
144
|
+
lines.push(` → to flip: add \`llrt: true\` to the ${v.fn} function in serverless.yml`)
|
|
145
|
+
}
|
|
146
|
+
if (v.staticTier !== 'likely-incompatible' && !v.ranLocal) {
|
|
147
|
+
lines.push(' (static tier only — run the local call-comparison for a definitive verdict)')
|
|
148
|
+
}
|
|
149
|
+
lines.push('')
|
|
150
|
+
return lines.join('\n')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `init` — print the serverless.yml snippet (non-destructive; paste it in). */
|
|
154
|
+
function cmdInit() {
|
|
155
|
+
const snippet = `# --- llrt-analyzer: flip functions to AWS LLRT for ~10x faster cold starts ---
|
|
156
|
+
plugins:
|
|
157
|
+
- serverless-llrt-analyzer
|
|
158
|
+
|
|
159
|
+
custom:
|
|
160
|
+
llrt:
|
|
161
|
+
# Publish the layer once with: llrt-analyzer publish-layer --ssm /my-svc/\${self:provider.stage}/llrt-layer-arn
|
|
162
|
+
layerArn: \${ssm:/my-svc/\${self:provider.stage}/llrt-layer-arn}
|
|
163
|
+
verify: true # fail the deploy if a flagged function is LLRT-incompatible
|
|
164
|
+
|
|
165
|
+
functions:
|
|
166
|
+
# add \`llrt: true\` to any function you want on LLRT (mixed-runtime is fine):
|
|
167
|
+
myFunction:
|
|
168
|
+
handler: src/handler.main
|
|
169
|
+
llrt: true
|
|
170
|
+
`
|
|
171
|
+
process.stdout.write(snippet)
|
|
172
|
+
process.stdout.write('\n# Then run `llrt-analyzer <serviceDir> --local` to confirm each flagged function.\n')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `publish-layer` — download the LLRT lambda bootstrap, publish it as a layer,
|
|
176
|
+
* print the ARN, and optionally write it to SSM. Wraps the manual curl→publish→SSM dance. */
|
|
177
|
+
async function cmdPublishLayer(flags) {
|
|
178
|
+
const arch = flags.arch === 'x64' ? 'x64' : 'arm64'
|
|
179
|
+
const region = flags.region || 'us-east-1'
|
|
180
|
+
const version = ORG_PINNED_LLRT_VERSION
|
|
181
|
+
const layerName = flags.layerName || `llrt-${arch}`
|
|
182
|
+
const asset = `llrt-lambda-${arch}.zip`
|
|
183
|
+
const url = `https://github.com/awslabs/llrt/releases/download/v${version}/${asset}`
|
|
184
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llrt-layer-'))
|
|
185
|
+
const zip = path.join(tmp, asset)
|
|
186
|
+
|
|
187
|
+
process.stderr.write(`[publish-layer] downloading ${asset} @ v${version}\n`)
|
|
188
|
+
await download(url, zip)
|
|
189
|
+
process.stderr.write(`[publish-layer] sha256(${asset}) = ${sha256File(zip)}\n`)
|
|
190
|
+
|
|
191
|
+
const runtimes = 'provided.al2023'
|
|
192
|
+
const awsArgs = [
|
|
193
|
+
'lambda', 'publish-layer-version',
|
|
194
|
+
'--layer-name', layerName,
|
|
195
|
+
'--zip-file', `fileb://${zip}`,
|
|
196
|
+
'--compatible-architectures', arch,
|
|
197
|
+
'--compatible-runtimes', runtimes,
|
|
198
|
+
'--description', `AWS LLRT ${version} bootstrap (${arch})`,
|
|
199
|
+
'--region', region,
|
|
200
|
+
'--query', 'LayerVersionArn', '--output', 'text',
|
|
201
|
+
]
|
|
202
|
+
if (flags.profile) awsArgs.push('--profile', flags.profile)
|
|
203
|
+
process.stderr.write(`[publish-layer] aws ${awsArgs.join(' ')}\n`)
|
|
204
|
+
const pub = spawnSync('aws', awsArgs, { encoding: 'utf8' })
|
|
205
|
+
if (pub.status !== 0) {
|
|
206
|
+
process.stderr.write(`[publish-layer] publish failed:\n${pub.stderr || pub.stdout}\n`)
|
|
207
|
+
process.exit(1)
|
|
208
|
+
}
|
|
209
|
+
const arn = (pub.stdout || '').trim()
|
|
210
|
+
process.stdout.write(`${arn}\n`)
|
|
211
|
+
|
|
212
|
+
if (flags.ssm) {
|
|
213
|
+
const ssmArgs = ['ssm', 'put-parameter', '--name', flags.ssm, '--type', 'String', '--overwrite', '--value', arn, '--region', region]
|
|
214
|
+
if (flags.profile) ssmArgs.push('--profile', flags.profile)
|
|
215
|
+
const put = spawnSync('aws', ssmArgs, { encoding: 'utf8' })
|
|
216
|
+
if (put.status !== 0) {
|
|
217
|
+
process.stderr.write(`[publish-layer] wrote layer but SSM put failed:\n${put.stderr || put.stdout}\n`)
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
process.stderr.write(`[publish-layer] wrote ARN to SSM ${flags.ssm}\n`)
|
|
221
|
+
} else {
|
|
222
|
+
process.stderr.write(`[publish-layer] set custom.llrt.layerArn to the ARN above (or re-run with --ssm <param>).\n`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main().catch((err) => {
|
|
227
|
+
process.stderr.write(`llrt-analyzer error: ${err && err.stack ? err.stack : err}\n`)
|
|
228
|
+
process.exit(1)
|
|
229
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* Cold-start init benchmark (idea #3).
|
|
5
|
+
*
|
|
6
|
+
* The whole value prop is "~10x faster cold starts", but the verdict never
|
|
7
|
+
* showed a number. This measures the dominant cold-start cost — interpreter
|
|
8
|
+
* startup + module init — by spawning the verify bundle with LLRT_INIT_ONLY=1
|
|
9
|
+
* (which imports everything then exits BEFORE running the handler) under Node
|
|
10
|
+
* and under the LLRT binary, and timing wall-clock. We take the MIN over a few
|
|
11
|
+
* samples (least noisy = closest to the machine's best case) and report the
|
|
12
|
+
* ratio. This is a proxy for the real Lambda Init Duration; the opt-in deploy
|
|
13
|
+
* tier (deploy-verify.js) measures the real thing.
|
|
14
|
+
*/
|
|
15
|
+
const { spawnSync } = require('child_process')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ cmd: string, bundleFile: string, samples?: number, timeoutMs?: number }} input
|
|
19
|
+
* @returns {number|null} best-case init time in ms, or null if it never succeeded
|
|
20
|
+
*/
|
|
21
|
+
function timeInit(input) {
|
|
22
|
+
const samples = input.samples || 5
|
|
23
|
+
let best = null
|
|
24
|
+
for (let i = 0; i < samples; i += 1) {
|
|
25
|
+
const start = Date.now()
|
|
26
|
+
const res = spawnSync(input.cmd, [input.bundleFile], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: input.timeoutMs || 20000,
|
|
29
|
+
env: { ...process.env, LLRT_INIT_ONLY: '1', VERIFY_EVENT_FILE: '' },
|
|
30
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
31
|
+
})
|
|
32
|
+
const ms = Date.now() - start
|
|
33
|
+
// Only count runs that actually reached the init-only exit cleanly.
|
|
34
|
+
if (res.status === 0 && (best === null || ms < best)) best = ms
|
|
35
|
+
}
|
|
36
|
+
return best
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Measure Node vs LLRT init for a built verify bundle.
|
|
41
|
+
* @param {{ nodeCmd?: string, llrtBin: string, bundleFile: string, samples?: number }} input
|
|
42
|
+
* @returns {{ node: number|null, llrt: number|null, ratio: number|null, measured: boolean }}
|
|
43
|
+
*/
|
|
44
|
+
function measureColdStart(input) {
|
|
45
|
+
const node = timeInit({ cmd: input.nodeCmd || process.execPath, bundleFile: input.bundleFile, samples: input.samples })
|
|
46
|
+
const llrt = timeInit({ cmd: input.llrtBin, bundleFile: input.bundleFile, samples: input.samples })
|
|
47
|
+
const ratio = node != null && llrt != null && llrt > 0 ? Math.round((node / llrt) * 10) / 10 : null
|
|
48
|
+
return { node, llrt, ratio, measured: node != null && llrt != null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { measureColdStart, timeInit }
|
package/src/corpus.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* Verify corpus (beads sci-xwk.23 + .24). Events to replay under Node + LLRT.
|
|
5
|
+
* Fixtures first (deterministic, no PII): __fixtures__/llrt/<fn>/*.json or
|
|
6
|
+
* __fixtures__/llrt/*.json. Optional CloudWatch capture through a redaction
|
|
7
|
+
* pass. Falls back to a synthetic per-adapter event.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
|
|
12
|
+
const REDACT_KEY = [/authoriz/i, /secret/i, /token/i, /password/i, /passwd/i, /email/i, /api[-_]?key/i, /credential/i, /cookie/i, /session/i, /ssn/i]
|
|
13
|
+
|
|
14
|
+
/** Deep-redact values of sensitive-looking keys. Returns a new object. */
|
|
15
|
+
function redact(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.map(redact)
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
const out = {}
|
|
19
|
+
for (const k of Object.keys(value)) out[k] = REDACT_KEY.some((re) => re.test(k)) ? '__REDACTED__' : redact(value[k])
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal API Gateway v2 (HTTP API) event. */
|
|
26
|
+
function synthApiGwV2(method, routePath) {
|
|
27
|
+
return {
|
|
28
|
+
version: '2.0',
|
|
29
|
+
routeKey: `${method} ${routePath}`,
|
|
30
|
+
rawPath: routePath,
|
|
31
|
+
rawQueryString: '',
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
requestContext: { http: { method, path: routePath, sourceIp: '127.0.0.1' }, requestId: 'verify', stage: '$default' },
|
|
34
|
+
isBase64Encoded: false,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SYNTH = {
|
|
39
|
+
httpApi: [synthApiGwV2('GET', '/'), synthApiGwV2('GET', '/health')],
|
|
40
|
+
sqs: [{ Records: [{ messageId: 'verify', body: '{}', attributes: {}, messageAttributes: {} }] }],
|
|
41
|
+
eventbridge: [{ version: '0', 'detail-type': 'verify', source: 'verify', detail: {} }],
|
|
42
|
+
s3: [{ Records: [{ eventName: 'ObjectCreated:Put', s3: { bucket: { name: 'verify' }, object: { key: 'k' } } }] }],
|
|
43
|
+
generic: [{}],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ projectDir: string, fnName?: string, adapter?: keyof typeof SYNTH, explicit?: any[] }} input
|
|
48
|
+
* @returns {any[]}
|
|
49
|
+
*/
|
|
50
|
+
function loadCorpus(input) {
|
|
51
|
+
if (input.explicit && input.explicit.length) return input.explicit
|
|
52
|
+
const roots = [
|
|
53
|
+
input.fnName && path.join(input.projectDir, '__fixtures__', 'llrt', input.fnName),
|
|
54
|
+
path.join(input.projectDir, '__fixtures__', 'llrt'),
|
|
55
|
+
].filter(Boolean)
|
|
56
|
+
for (const dir of roots) {
|
|
57
|
+
const events = readJsonDir(dir)
|
|
58
|
+
if (events.length) return events
|
|
59
|
+
}
|
|
60
|
+
return SYNTH[input.adapter || 'httpApi'] || SYNTH.generic
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readJsonDir(dir) {
|
|
64
|
+
let entries
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(dir)
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
const out = []
|
|
71
|
+
for (const name of entries) {
|
|
72
|
+
if (!name.endsWith('.json')) continue
|
|
73
|
+
try {
|
|
74
|
+
out.push(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')))
|
|
75
|
+
} catch (_) {
|
|
76
|
+
/* skip malformed */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Best-effort capture of recent invocation events from CloudWatch Logs, redacted.
|
|
84
|
+
* Requires @aws-sdk/client-cloudwatch-logs + AWS creds; throws a clear error
|
|
85
|
+
* otherwise (fixtures remain the default).
|
|
86
|
+
* @param {{ logGroup: string, limit?: number, region?: string }} input
|
|
87
|
+
* @returns {Promise<any[]>}
|
|
88
|
+
*/
|
|
89
|
+
async function captureFromCloudWatch(input) {
|
|
90
|
+
let CWL
|
|
91
|
+
try {
|
|
92
|
+
const modName = '@aws-sdk/client-cloudwatch-logs' // indirect so tsc/bundlers don't require it
|
|
93
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
94
|
+
CWL = require(modName)
|
|
95
|
+
} catch (_) {
|
|
96
|
+
throw new Error('captureFromCloudWatch requires @aws-sdk/client-cloudwatch-logs to be installed.')
|
|
97
|
+
}
|
|
98
|
+
const client = new CWL.CloudWatchLogsClient({ region: input.region })
|
|
99
|
+
const res = await client.send(
|
|
100
|
+
new CWL.FilterLogEventsCommand({ logGroupName: input.logGroup, limit: input.limit || 25, filterPattern: '' }),
|
|
101
|
+
)
|
|
102
|
+
const events = []
|
|
103
|
+
for (const e of res.events || []) {
|
|
104
|
+
const m = String(e.message || '')
|
|
105
|
+
const start = m.indexOf('{')
|
|
106
|
+
if (start < 0) continue
|
|
107
|
+
try {
|
|
108
|
+
events.push(redact(JSON.parse(m.slice(start))))
|
|
109
|
+
} catch (_) {
|
|
110
|
+
/* not JSON */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return events
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { loadCorpus, redact, synthApiGwV2, captureFromCloudWatch }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* Tier-3 deploy-verify (bead sci-xwk.33) — OPT-IN, heavy. The local tier
|
|
5
|
+
* (call-comparison) is the default and covers correctness with no side effects;
|
|
6
|
+
* this tier exists for the extra confidence of running on real Lambda + a real
|
|
7
|
+
* cold-start measurement. It is intentionally gated (slow, costs AWS).
|
|
8
|
+
*
|
|
9
|
+
* Flow: deploy the LLRT variant to an ISOLATED ephemeral stage (own sandbox
|
|
10
|
+
* resources), replay the corpus + run the service's integration tests, MEASURE
|
|
11
|
+
* cold starts (Node vs LLRT), then tear down. Deploy/teardown are delegated to
|
|
12
|
+
* the Serverless Framework via child_process; measurement is implemented here.
|
|
13
|
+
*/
|
|
14
|
+
const { spawnSync } = require('child_process')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Measure cold-start Init Duration by invoking a deployed function with a fresh
|
|
18
|
+
* container N times (forcing cold starts via an env bump between calls is
|
|
19
|
+
* service-specific; here we read Init Duration from the tail logs of cold
|
|
20
|
+
* invocations). Requires @aws-sdk/client-lambda + creds.
|
|
21
|
+
* @param {{ functionName: string, region?: string, samples?: number }} input
|
|
22
|
+
* @returns {Promise<{ p50: number|null, samples: number[] }>}
|
|
23
|
+
*/
|
|
24
|
+
async function measureColdStart(input) {
|
|
25
|
+
let Lambda
|
|
26
|
+
try {
|
|
27
|
+
const mod = '@aws-sdk/client-lambda'
|
|
28
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
29
|
+
Lambda = require(mod)
|
|
30
|
+
} catch (_) {
|
|
31
|
+
throw new Error('measureColdStart requires @aws-sdk/client-lambda + AWS credentials.')
|
|
32
|
+
}
|
|
33
|
+
const client = new Lambda.LambdaClient({ region: input.region })
|
|
34
|
+
const samples = []
|
|
35
|
+
const n = input.samples || 10
|
|
36
|
+
for (let i = 0; i < n; i += 1) {
|
|
37
|
+
// eslint-disable-next-line no-await-in-loop
|
|
38
|
+
const res = await client.send(new Lambda.InvokeCommand({ FunctionName: input.functionName, LogType: 'Tail', Payload: Buffer.from('{}') }))
|
|
39
|
+
const log = res.LogResult ? Buffer.from(res.LogResult, 'base64').toString('utf8') : ''
|
|
40
|
+
const m = log.match(/Init Duration: ([0-9.]+) ms/)
|
|
41
|
+
if (m) samples.push(Number(m[1]))
|
|
42
|
+
}
|
|
43
|
+
samples.sort((a, b) => a - b)
|
|
44
|
+
const p50 = samples.length ? samples[Math.floor(samples.length / 2)] : null
|
|
45
|
+
return { p50, samples }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deploy the service to an ephemeral stage, run the caller's verify callback,
|
|
50
|
+
* then tear down. The heavy orchestration (sandbox resources) is service-owned;
|
|
51
|
+
* this wraps deploy/remove so a caller can run replay + integration tests + a
|
|
52
|
+
* cold-start measurement in between.
|
|
53
|
+
* @param {{ serviceDir: string, stage: string, verify: () => Promise<any> }} input
|
|
54
|
+
*/
|
|
55
|
+
async function withEphemeralStage(input) {
|
|
56
|
+
const deploy = spawnSync('npx', ['serverless', 'deploy', '--stage', input.stage], { cwd: input.serviceDir, encoding: 'utf8', stdio: 'inherit' })
|
|
57
|
+
if (deploy.status !== 0) throw new Error(`ephemeral deploy failed (stage ${input.stage})`)
|
|
58
|
+
try {
|
|
59
|
+
return await input.verify()
|
|
60
|
+
} finally {
|
|
61
|
+
spawnSync('npx', ['serverless', 'remove', '--stage', input.stage], { cwd: input.serviceDir, encoding: 'utf8', stdio: 'inherit' })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { measureColdStart, withEphemeralStage }
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* Semantic diff (bead sci-xwk.20). Compare a Node run vs an LLRT run:
|
|
5
|
+
* - downstream calls compared by kind + service/command/method/url + input,
|
|
6
|
+
* ignoring SDK-internal noise (LLRT ships a different SDK build);
|
|
7
|
+
* - HTTP responses compared after normalizing a denylist of volatile fields.
|
|
8
|
+
* Returns diffs[]; empty = behaved identically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const VOLATILE_KEYS = [/date/i, /time/i, /timestamp/i, /requestid/i, /x-amz-/i, /etag/i, /^id$/i, /Id$/, /trace/i, /nonce/i]
|
|
12
|
+
|
|
13
|
+
function isVolatileKey(k) {
|
|
14
|
+
return VOLATILE_KEYS.some((re) => re.test(String(k)))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Strip volatile keys + sort, recursively, for stable comparison. */
|
|
18
|
+
function normalize(value) {
|
|
19
|
+
if (Array.isArray(value)) return value.map(normalize)
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
const out = {}
|
|
22
|
+
for (const k of Object.keys(value).sort()) {
|
|
23
|
+
if (isVolatileKey(k)) continue
|
|
24
|
+
out[k] = normalize(value[k])
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical key for a downstream call (order-independent multiset compare). */
|
|
32
|
+
function callKey(c) {
|
|
33
|
+
if (c.kind === 'aws') return `aws:${c.service}.${c.command}:${stable(normalize(c.input))}`
|
|
34
|
+
if (c.kind === 'fetch') return `fetch:${c.method}:${stripQueryVolatile(c.url)}:${c.body ? stable(c.body) : ''}`
|
|
35
|
+
return `other:${stable(c)}`
|
|
36
|
+
}
|
|
37
|
+
function stripQueryVolatile(url) {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url)
|
|
40
|
+
return `${u.origin}${u.pathname}`
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return String(url).split('?')[0]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function stable(v) {
|
|
46
|
+
return typeof v === 'string' ? v : JSON.stringify(normalize(v))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} event label for reporting
|
|
51
|
+
* @param {{ok:boolean,response?:any,calls:any[],error?:string}} node
|
|
52
|
+
* @param {{ok:boolean,response?:any,calls:any[],error?:string}} llrt
|
|
53
|
+
* @returns {import('./verdict').CallDiff[]}
|
|
54
|
+
*/
|
|
55
|
+
function diffRun(event, node, llrt) {
|
|
56
|
+
const diffs = []
|
|
57
|
+
// 1. one crashed, the other didn't
|
|
58
|
+
if (node.ok !== llrt.ok) {
|
|
59
|
+
diffs.push({ event, path: '$run', node: node.ok ? 'ok' : `error: ${node.error}`, llrt: llrt.ok ? 'ok' : `error: ${llrt.error}` })
|
|
60
|
+
return diffs // no point diffing further
|
|
61
|
+
}
|
|
62
|
+
if (!node.ok && !llrt.ok) return diffs // both failed the same way (attributed elsewhere)
|
|
63
|
+
|
|
64
|
+
// 2. downstream call multiset
|
|
65
|
+
const nk = (node.calls || []).map(callKey).sort()
|
|
66
|
+
const lk = (llrt.calls || []).map(callKey).sort()
|
|
67
|
+
if (stable(nk) !== stable(lk)) {
|
|
68
|
+
diffs.push({ event, path: '$calls', node: nk, llrt: lk })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. response
|
|
72
|
+
const rn = stable(normalize(node.response))
|
|
73
|
+
const rl = stable(normalize(llrt.response))
|
|
74
|
+
if (rn !== rl) {
|
|
75
|
+
const d = { event, path: '$response', node: normalize(node.response), llrt: normalize(llrt.response) }
|
|
76
|
+
const hint = explainResponseDiff(d)
|
|
77
|
+
if (hint) d.hint = hint
|
|
78
|
+
diffs.push(d)
|
|
79
|
+
}
|
|
80
|
+
return diffs
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Attribute well-understood LLRT behavioral diffs to a root cause + fix, so a
|
|
85
|
+
* `$response` diff isn't just raw JSON the user has to decode. Empirically
|
|
86
|
+
* discovered + verified on real Lambda (see docs/PILOT-mcp-gateway.md).
|
|
87
|
+
* @returns {string} explanation, or '' if the pattern isn't recognized
|
|
88
|
+
*/
|
|
89
|
+
function explainResponseDiff(d) {
|
|
90
|
+
const n = d.node && typeof d.node === 'object' ? d.node : {}
|
|
91
|
+
const l = d.llrt && typeof d.llrt === 'object' ? d.llrt : {}
|
|
92
|
+
const nodeBody = typeof n.body === 'string' ? n.body : ''
|
|
93
|
+
const llrtBody = typeof l.body === 'string' ? l.body : ''
|
|
94
|
+
// Same status, headers largely intact, but LLRT returns an empty body where
|
|
95
|
+
// Node returned one: LLRT 0.8.x does not implement Response.prototype.body
|
|
96
|
+
// (the ReadableStream getter returns undefined). Any framework that re-wraps
|
|
97
|
+
// a response via `new Response(res.body, init)` — e.g. Hono when middleware
|
|
98
|
+
// mutates headers post-handler (CORS) — then produces `new Response(undefined)`
|
|
99
|
+
// → empty body. `.text()`/`.arrayBuffer()` still work; only `.body` is missing.
|
|
100
|
+
if (nodeBody.length > 0 && llrtBody.length === 0 && n.statusCode === l.statusCode) {
|
|
101
|
+
return (
|
|
102
|
+
'LLRT dropped the response body (Node returned one). Root cause: LLRT 0.8.x ' +
|
|
103
|
+
'does not implement Response.prototype.body (ReadableStream) — it returns undefined. ' +
|
|
104
|
+
'Frameworks that re-wrap a response from res.body (Hono when header-mutating ' +
|
|
105
|
+
'middleware like CORS runs after the handler) yield an empty body under LLRT. ' +
|
|
106
|
+
'Fix: avoid reading Response#body (return the string/JSON directly, or drop the ' +
|
|
107
|
+
'post-handler header mutation), or keep this function on Node until LLRT ships it.'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { diffRun, normalize, explainResponseDiff }
|
package/src/error-map.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* Error-signature -> offending API attribution (bead sci-xwk.21). Turns an
|
|
5
|
+
* opaque LLRT runtime error into an actionable blocker with a fix.
|
|
6
|
+
*/
|
|
7
|
+
const SIGNATURES = [
|
|
8
|
+
{
|
|
9
|
+
// esbuild bundled `import { X } from 'Y'` but LLRT's Y has no export X.
|
|
10
|
+
re: /Could not find export '([^']+)' in module '([^']+)'/i,
|
|
11
|
+
derive: (m) => ({
|
|
12
|
+
api: `node:${m[2]}.${m[1]}`,
|
|
13
|
+
fix: `LLRT's node:${m[2]} does not export "${m[1]}" — avoid that call (e.g. use a supported alternative) or keep this function on Node.`,
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
{ re: /\bhttp\b.*(not implemented|unsupported)|createServer is not a function/i, api: 'node:http', fix: 'node:http is not implemented in LLRT — use global fetch, or keep this function on Node.' },
|
|
17
|
+
{ re: /createDiffieHellman is not a function/i, api: 'crypto.createDiffieHellman', fix: 'no LLRT equivalent — move Diffie-Hellman off this path or keep on Node.' },
|
|
18
|
+
{ re: /generatePrime.*is not a function/i, api: 'crypto.generatePrime', fix: 'not available under LLRT — keep on Node.' },
|
|
19
|
+
{ re: /(createCipheriv|createDecipheriv|scrypt|pbkdf2).*is not a function/i, api: 'crypto.<cipher/kdf>', fix: 'this crypto primitive is not available under LLRT — keep on Node or use a supported algorithm.' },
|
|
20
|
+
{ re: /Worker is not (defined|a constructor)/i, api: 'worker_threads', fix: 'worker_threads is unsupported in LLRT.' },
|
|
21
|
+
{ re: /Cannot find module|Unable to resolve|Unknown module|Module not found/i, api: 'module-resolution', fix: 'a dependency or builtin the handler imports is not available under LLRT — check the static blockers.' },
|
|
22
|
+
{ re: /is not a function/i, api: 'unknown-api', fix: 'an API the handler calls is missing under LLRT — run with LLRT_ASYNC_HOOKS/verbose to locate it.' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} errText
|
|
27
|
+
* @returns {{ api: string, fix: string }|null}
|
|
28
|
+
*/
|
|
29
|
+
function attributeError(errText) {
|
|
30
|
+
if (!errText) return null
|
|
31
|
+
for (const s of SIGNATURES) {
|
|
32
|
+
const m = errText.match(s.re)
|
|
33
|
+
if (m) return s.derive ? s.derive(m) : { api: s.api, fix: s.fix }
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { attributeError }
|