fix(plugins): hoist id/version regexes into defineNativePlugin to avoid TDZ on Workers (#1370)#1542
Conversation
…id TDZ on Workers Module-scope regex consts (SIMPLE_ID/SCOPED_ID/SEMVER_PATTERN) could be read before initialization during the worker's circular module init under ssr.noExternal, throwing "Cannot access 'SIMPLE_ID' before initialization" and 500-ing every route on Cloudflare Workers. Moving them function-local makes them evaluate at call time, eliminating the temporal dead zone regardless of bundle ordering. Closes emdash-cms#1370 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: eea6ccd The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Approach
This is the right change for the right problem. The bug (#1370) is severe — HTTP 500 on every route on Cloudflare Workers when a site registers native plugins via definePlugin — and the diagnosis is sound: under ssr.noExternal, Rollup flattens the worker-entry ↔ emdash chunk cycle, a native plugin's definePlugin(...) default export runs at module-init mid-cycle, and the module-scope const regexes are read inside their temporal dead zone. Moving the three regexes into defineNativePlugin makes them evaluate at call time (after the literals parse), which closes the TDZ window regardless of bundle ordering. That's a better fix than var (hoisted declaration, but the assignment still wouldn't have run — you'd trade the ReferenceError for a TypeError on undefined.test) or a leaf module (Rollup can flatten it back into the same chunk). Minimal, targeted, well-commented, and the lint suppressions are justified. I'd merge this.
What I checked
- Fix is complete for this module. After the move there are no top-level
const/letbindings left indefine-plugin.ts(verified by grep) — only imports and hoisted function declarations.defineNativePlugin's only other call-time dependency isnormalizeCapabilities, which is re-exported from the external@emdash-cms/plugin-typespackage, i.e. outside the worker↔emdash cycle, so it's fully initialized before the function runs. No remaining module-scope binding in the cycle is read at call time. - Validation semantics unchanged. The three regex literals are byte-identical to the base (
/^[a-z0-9-]+$/,/^@[a-z0-9-]+\/[a-z0-9-]+$/,/^\d+\.\d+\.\d+/), and they're still used only insidedefineNativePlugin(not exported, no other consumers). - Lint suppressions are genuinely needed.
e18e/prefer-static-regexiserrorglobally in.oxlintrc.jsonand is only disabled for test files —define-plugin.tsis not in that override. Theoxlint-disable-next-linedirectives are correctly placed directly above each regex literal. Without them this PR would fail lint. - No perf concern.
definePluginis only called at registration time (PluginManager.register, plus the two internal plugins inemdash-runtime.ts), not per-request, so re-creating three regexes per call is negligible (and V8 caches regex literals per function site anyway). - Changeset/bump correct. Targets
emdash(the package that exportsdefinePlugin) atpatch— appropriate for an internal-only fix with no API change. - Test. The added
#1370test is a behavioral guard confirming id/version validation still works after the move — not a TDZ repro. The PR description is upfront about this, and a faithful repro isn't feasible in plain vitest without modifying production code to construct the cycle (the actual cycle is worker-entry ↔ emdash chunk, transitively throughemdash-runtime;define-plugin.tsitself has no import a test could leverage to build the cycle). Given that infeasibility and the documented manual repro, I don't treat the missing reproducing test as blocking. The describe-block comment is genuinely useful as a "do not re-hoist these to module scope" guardrail.
Conclusion
Solid fix — correct, complete for the affected code path, minimal, and well-documented. One optional changeset-polish nit below; nothing blocking.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
What does this PR do?
Fixes a crash that returns HTTP 500 on every route on Cloudflare Workers (
workerd) when a site registers native plugins viadefinePlugin.The plugin id/version validation regexes (
SIMPLE_ID,SCOPED_ID,SEMVER_PATTERN) were module-scopeconsts read insidedefineNativePlugin. Under the integration'sssr.noExternal: ["emdash", …], Rollup re-bundles emdash into the worker and flattens a circular import between the worker entry and this chunk. When a top-leveldefinePlugin(...)(a native plugin's default export, mounted viacreatePlugin) runs at module-init time mid-cycle, it reachesdefineNativePluginbefore thoseconsts have initialized — andconstbindings throw inside their temporal dead zone:astro build/astro checkpass because it's purely a bundled init-order issue; 0.15.0 was unaffected only because its bundle ordering happened to differ.Moving the three regexes into the function body makes them evaluate at call time (after the literals are parsed), so the TDZ cannot occur regardless of how the bundler orders the circular init. Validation behavior is byte-identical — they're used only inside
defineNativePlugin. Thee18e/prefer-static-regexlint is suppressed on these three lines with a justification: module scope is exactly what triggers the bug, and the per-call compile cost is negligible (once per plugin registration). A leaf-module orvaralternative would not survive Rollup's chunk flattening — only call-time evaluation is robust.Closes #1370
Type of change
Checklist
pnpm typecheckpasses — not run cleanly locally: branching offmainleft the installed workspace deps (@emdash-cms/plugin-types) out of sync, producing unrelated pre-existing errors inmanifest-schema.ts/types.ts. This change is type-neutral and adds no diagnostics in the touched file.pnpm lintpasses (oxlint, 0 errors repo-wide)pnpm testpasses (targeted:define-plugin.test.ts— 40 passed)pnpm formathas been run (oxfmt clean)AI-generated code disclosure
Screenshots / test output
The literal TDZ depends on bundler output, so it isn't reproducible in a plain vitest. The existing suite already covers the validation semantics; this PR adds an issue-anchored regression test, and the manual repro below covers the runtime crash.
Manual repro (Cloudflare adapter site that registers a native plugin):
Unit test (
packages/core/tests/unit/plugins/define-plugin.test.ts):