edge-shake
v0.2.2
Published
Diagnostic CLI for Cloudflare Workers cold-start CPU issues (Error 1102) caused by heavy top-level module scope code.
Readme
edge-shake
Diagnose (and optionally fix) Cloudflare Workers Error 1102: "Script startup exceeded CPU time limit" — the cold-start CPU crash that shows up when frameworks like OpenNext (Next.js on Cloudflare) bundle heavy dependencies (ORM clients, crypto libs, large JSON/i18n data, Zod schemas) into the global execution scope, where Cloudflare's cold-start CPU budget is extremely tight.
npx edge-shake .open-next/worker.jsWhy this happens
Cloudflare Workers give a new "isolate" only a tiny CPU budget to finish starting up before it can serve its first request. Code written to run at module scope — const db = drizzle(env.DB), a big translations object, a large Zod schema tree — all runs on every cold start, before your handler ever sees a request. If that setup work is heavy enough, Cloudflare kills the isolate and returns Error 1102.
The usual fix is deferring that work into a lazy getter so it only runs on first use, not on every cold start:
// Before — runs on every cold start
const db = drizzle(env.DB);
// After — runs once, on first actual use
let _db;
function getDB(env) {
if (!_db) _db = drizzle(env.DB);
return _db;
}edge-shake finds code like this in your compiled bundle, explains why it's risky, and (with a license) can rewrite it for you automatically.
Free tier: diagnose
npx edge-shake [path-to-worker.js] # defaults to .open-next/worker.jsRead-only. Never modifies your files. Run it as a post-build step, after opennextjs-cloudflare build and before wrangler deploy.
It parses your compiled bundle and flags top-level (module scope) code matching known-risky patterns:
- ORM/DB client construction (Drizzle, Prisma, Kysely, and known
createClient/createPoolfactories) - Large object/array literals (default threshold: 5 KB) — catches bloated i18n JSON, static config dumps
- Large Zod/jose/jsonwebtoken/yup/ajv schema trees built at module scope
- Repeated
TextEncoder/TextDecoderconstruction inside a top-level loop
Exit code is non-zero when something is flagged, so it's CI-friendly.
Real example
Here's what edge-shake finds when pointed at a genuine .open-next/worker.js-style bundle — this isn't a hand-written test fixture, it's the actual output of esbuild bundling and minifying a Drizzle client construction the same way a real Cloudflare build would (fixtures/real-minified-drizzle.js):
$ npx edge-shake fixtures/real-minified-drizzle.js
edge-shake: found 1 risky top-level pattern(s) in fixtures/real-minified-drizzle.js
[MEDIUM] fixtures/real-minified-drizzle.js:14:24929 — orm-client-construction
Matched: possible Drizzle ORM client (name mangled by bundler, matched via fingerprint)
Top-level call taking an "env"-shaped argument, in a file that contains Drizzle ORM's
runtime fingerprint — this MIGHT be a bundled/minified ORM client construction where the
original constructor name was renamed by the bundler. This is a lower-confidence heuristic
match (the fingerprint could belong to unrelated code elsewhere in the bundle) — verify
manually before treating it as confirmed.
Suggested fix (not applied):
If this is client construction, wrap it in a lazy getter so it only runs on first use:
let _client;
function getClient(env) {
if (!_client) _client = /* this call */;
return _client;
}
Summary: 1 finding(s) (0 high severity). This is a heuristic diagnostic — "risky", not
"will fail". Run with a paid license to auto-fix allowlisted patterns.Notice this is flagged MEDIUM, not HIGH. Here's why.
A note on the fingerprint fallback (and why it's medium severity, not high)
Real bundlers rename identifiers. When we tested against unminified source, edge-shake matched the actual constructor name (drizzle, PrismaClient, etc.) directly, with high confidence. Once we tested against a real minified bundle, that name was gone — esbuild had renamed drizzle itself to a single letter. Name matching alone silently missed it.
The fix: minifiers can rename identifiers, but they can't rewrite the string literals a library's own runtime depends on. Drizzle's bundled code still contains literal strings like "drizzle:entityKind" no matter how aggressively it's minified. So when a top-level call takes an env-shaped argument in a file that contains one of these known fingerprint strings, we flag it — with a caveat baked into the severity.
We deliberately cap this fallback at medium severity, even for validated fingerprints, because it's a file-wide co-occurrence signal, not proof that this specific call is the client construction. We confirmed this can genuinely false-positive: an unrelated setupLogger(env.LOG_LEVEL) call in a file that happens to mention a fingerprint string elsewhere (e.g., in an unrelated comment or log message) will also get flagged. We chose to keep the fallback rather than drop it, because a false positive here just means "look at this line and decide for yourself" — the tool never auto-modifies anything based on this signal. A missed real ORM client construction, on the other hand, is a silent failure to catch the exact thing this tool exists for.
If this trade-off doesn't work for your use case, --threshold and reading the severity field let you filter to only high-confidence findings.
Paid tier: auto-fix
npx edge-shake fix [path-to-worker.js] # prints a patch
npx edge-shake fix [path-to-worker.js] --write # applies it in placeRequires a license key, set via EDGE_SHAKE_LICENSE_KEY or a .edge-shake.json / ~/.edge-shake/config.json file ({ "licenseKey": "..." }).
The auto-fixer is intentionally narrower and more conservative than the diagnostic: it only rewrites patterns from an explicit allowlist (currently: Drizzle, Prisma, Kysely client construction), and — unlike the diagnostic scanner — it never uses the fingerprint fallback described above. Auto-fixing requires being confident about what a call actually is, not just that it might be; when it can't be fully confident, it does nothing rather than guess:
- If the tool can't trace every reference to a binding (shadowing, re-exports, destructuring it can't follow), it skips and reports why, rather than attempting a partial rewrite.
- If it can't find an
envparameter in any enclosing function scope for a call site, it skips that binding entirely — threadingenvincorrectly would be worse than not fixing it. - Every skip shows up in the report by name and line, so nothing silently falls through.
Example output (illustrative, not a captured run):
edge-shake fix: worker.js
1 rewritten, 1 skipped
[APPLIED] line 3: "db" -> lazy getter "getDb" (2 call site(s) updated)
[SKIPPED] line 12: not auto-fixable, manual intervention needed: "cache" is exported and may be consumed elsewhere in the bundleDevelopment
npm install
npm run build
npm testLicense
MIT
