Skip to content

Commit b589126

Browse files
committed
fix(pkg-wrapper): fall back to package root entry when subpath resolves to an unlexable bundle
After 60d02d4 taught pickEntry() to follow subpath specifiers into the exports map, packages like vue map 'vue/dist/vue.runtime.esm-browser.prod.js' to a single-line minified file that es-module-lexer rejects with a parse error. inspectPkg then degraded to default-only re-export and downstream 'import { version } from vue' crashed shared-modules's postBuild. The fix retries with the package's bare specifier (root entry) when the first lex attempt throws. The root entry is the lean ESM build whose named-export surface is a superset of (or identical to) the deep subpath's, so the federation wrapper still emits a complete static names list. The bundler ultimately resolves to the deep subpath via the host's import map, so the wrapper only needs to satisfy the lexer. Adds a synthetic 'minified-subpath-pkg' fixture with an intentionally unterminated-template dist/* target that exercises the fallback path; the test asserts no warning is logged when the retry succeeds.
1 parent a1e00e3 commit b589126

3 files changed

Lines changed: 165 additions & 44 deletions

File tree

docs/rfc/0001-module-protocol.md

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -169,29 +169,47 @@ for each bare specifier S lexed from my source:
169169
**resolved installed version**, captured into the manifest at build
170170
time (a concrete version, never a range — range-vs-range satisfaction
171171
is ill-defined and explicitly rejected).
172-
- **`uses`** — needs, with semver ranges.
173-
- Bare package (`vue`): must be satisfied by a mounted provider.
174-
Consuming via `uses` is **consume-only by definition**: the specifier
175-
is externalized and the consumer never bundles its own copy. A module
176-
that wants its own bundled copy simply does not declare `uses`
177-
per-module import-map scopes isolate it naturally (this is how the
178-
hub's vue2/vue3 coexistence already works, with zero protocol
179-
vocabulary).
180-
- Module export (`shared/ui`): the module must be mounted, the export
181-
must exist, and the mounted artifact's version must satisfy the
182-
consumer's `dependencies` range (R6: a `uses` range of `*` or
183-
omission defaults to the `dependencies` range — declare once).
172+
- **`uses`** — a plain array of module names: *which mounted modules I
173+
consume from*. Each entry must resolve against the mount table
174+
(`E_NOT_LINKED` otherwise). Granularity is deliberately the **module**,
175+
not the specifier — mirroring npm, where you depend on a package, not
176+
on its individual exports:
177+
- Specifier-level needs are NOT declared: they are lexed from the
178+
consumer's own source imports (an existing mechanism — the bundler
179+
lexes them anyway to decide externalization). A bare specifier found
180+
in a used module's `provides` is externalized and wired to it; a
181+
specifier found nowhere is bundled as the consumer's own copy,
182+
isolated by per-module import-map scopes (this is how the hub's
183+
vue2/vue3 coexistence already works, with zero protocol vocabulary).
184+
- Version ranges are NOT declared here: the `dependencies` range is
185+
the single source of truth, validated against the mounted artifact's
186+
transcribed version (`E_VERSION` on mismatch). Bare-package version
187+
expectations are validated against the consumer's own
188+
devDependencies copy (the types copy), with drift warnings.
189+
- `uses` is transitive: a base module may itself use another base
190+
(`ssr-vue-base` uses `ssr-base`), forming layered platform chains;
191+
resolution walks the chain with cycle detection. Business apps
192+
declare one line and stay ignorant of the chain's depth.
193+
- Overlapping supply across the chain is **merged with deterministic
194+
precedence**, not rejected: layered bases legitimately override
195+
lower layers (a vue-base lays a vue-specific layer over a generic
196+
base). Precedence: **own `provides` > nearer chain layer > later
197+
entry in the `uses` array** (generic-to-specific listing, the
198+
specific layer wins — `Object.assign` semantics). Own-provides
199+
priority is forced by instance consistency: a module that provides
200+
`vue` downstream must itself run on the copy it provides.
184201

185202
There is deliberately **no arbitration field** (no `resolutions`, no
186-
`singleton`/`optional` flags). Module Federation needs those because its
187-
sharing is negotiated at runtime; esmx composition is static and
188-
declared, so every conflict is an architecture error whose fix lives in
189-
declarations that already exist (remove one side's `provides`, narrow a
190-
`uses` range, or drop `uses` and bundle your own copy). Because no
191-
mechanism can resolve one specifier to two providers, single-instance
192-
sharing is an inherent property of the model, not a flag. Multi-instance
193-
coexistence happens only when a module bundles its own copy — today's
194-
natural, scope-isolated behavior.
203+
`singleton`/`optional` flags) and **no specifier-level needs map**.
204+
Module Federation needs arbitration because its sharing is negotiated at
205+
runtime; esmx composition is static and declared: every wiring decision
206+
is derived from declaration structure (chain distance and array order),
207+
and the remaining failure modes are architecture errors whose fixes live
208+
in declarations that already exist. Because election (§7) picks exactly
209+
one winner per bare package and rewires the whole closure to it,
210+
single-instance sharing is an inherent property of the model, not a
211+
flag. Multi-instance coexistence happens only when a module bundles its
212+
own copy — today's natural, scope-isolated behavior.
195213

196214
### 4.2 What is deleted
197215

@@ -212,7 +230,7 @@ Additive changes to `dist/<env>/manifest.json`:
212230
"version": "1.8.0", // NEW: transcribed from package.json
213231
"exports": { "widget": { "name": "widget", "pkg": false, "file": "...", "identifier": "cart/widget" } },
214232
"provides": { "vue": "3.4.21" }, // NEW: resolved versions captured at build
215-
"uses": { "vue": "^3.4.0" }, // NEW: transcribed needs
233+
"uses": ["shared"], // NEW: transcribed consumption edges
216234
"scopes": {}, "files": [], "chunks": {}, "integrity": {}
217235
}
218236
```
@@ -263,24 +281,47 @@ versions and artifact-version validation, which degrade to
263281
declaration-level wiring.
264282

265283
```
266-
for each uses entry (spec, range) across the mounted graph:
267-
module-export spec ("cart/widget"):
268-
E_NOT_LINKED module not mounted (distinguishes "not declared" from
269-
"declared but not built" — different fixes)
270-
E_NO_EXPORT export absent (lists what the module actually exports)
271-
E_VERSION mounted artifact version ∉ dependencies range
272-
bare-package spec ("vue"):
273-
candidates = mounted modules declaring it in provides
274-
whose resolved version satisfies range
275-
1 candidate → wire imports[spec] = "<provider>/<spec>"
276-
0 candidates → E_NO_PROVIDER (lists each mounted module's provides;
277-
fix: mount a provider, or drop `uses` to bundle own copy)
278-
>1 → E_MULTIPLE_PROVIDERS (fix: remove one side's
279-
`provides`, or narrow the `uses` range)
284+
phase 1 — consumption graph:
285+
for each name in my uses[] (transitively, with cycle detection):
286+
name ∉ mount table → E_NOT_LINKED (distinguishes "not
287+
declared" from "declared but not
288+
built" — different fixes)
289+
artifact version ∉ dependencies range → E_VERSION
290+
291+
phase 2 — supply election (per bare package P in the closure):
292+
candidates = every module in the closure declaring P in provides
293+
winner W = highest precedence candidate:
294+
own provides > nearer chain layer > later uses[] entry
295+
rewire = EVERY layer's P — including losing providers' own
296+
internal chunks — points at W. Import maps make this a
297+
link-time rewiring of the scope table; no artifact is
298+
touched. Losing copies are dropped from the map
299+
(reported as unused in the audit artifact).
300+
validate = W's resolved version must satisfy every layer's
301+
dependencies range for P → E_VERSION naming the
302+
incompatible layer. A layer never silently "falls back"
303+
to its own copy — that would split instances.
304+
305+
phase 3 — wiring by lookup (per specifier lexed from my source):
306+
module-export form ("shared/ui"):
307+
exporter ∉ my uses chain → E_NOT_USED
308+
export absent from declaration → E_NO_EXPORT (lists actual exports)
309+
else → externalize, wire to identifier
310+
bare package ("vue"):
311+
elected in phase 2 → externalize, wire to winner
312+
no candidate anywhere → bundle own copy (scope-isolated)
313+
280314
all failures are build-time; every error carries what / why / fix —
281315
and every fix is an edit to an existing declaration, never a new concept
282316
```
283317

318+
Election is the link-time, deterministic analogue of Module Federation's
319+
runtime share-scope negotiation — same problem, solved statically: the
320+
composer's import map is the single late-binding point, so overlapping
321+
supply resolves to one winner per package with the entire closure
322+
rewired to it, before anything ships. The one-sentence rule: **nearest
323+
wins, self first, the whole chain follows the winner.**
324+
284325
Multi-version coexistence needs no resolver vocabulary: modules that
285326
bundle their own copy are isolated by per-module import map scopes
286327
(machinery that exists and already carries the hub's vue2/vue3 split).
@@ -381,8 +422,9 @@ watch-invalidation machinery across three dev paths is future work).
381422
multi-version case the compression heuristic guards against.
382423
2. **Hub migration green**: the 16-module hub fully migrated to the new
383424
protocol, smoke + visual CI passing. It is the realistic stress test.
384-
3. **`provides`/`uses` semantics tests**: consume-only externalization,
385-
`E_MULTIPLE_PROVIDERS` / `E_NO_PROVIDER` guidance quality,
425+
3. **`provides`/`uses` semantics tests**: election precedence (own >
426+
nearer > later array entry), whole-closure rewiring incl. losing
427+
providers' internal chunks, per-layer `E_VERSION` guidance quality,
386428
own-copy scope isolation, version-drift warnings.
387429

388430
## 11. Expert review record
@@ -405,6 +447,8 @@ Three independent reviews, each grounded in the source. Dispositions:
405447
| Dev-watch staleness of auto-wiring | Bundler review | **Scoped out of v1**, documented (§10) |
406448
| `exports` encapsulation vs raw dist mount | TS review | **Documented as intentional** (§6) |
407449
| Pack-time package.json rewrite reliability | TS review | **Adopted**: staging dir + publint-class validation (§8) |
450+
| Specifier-level `uses` map duplicates facts that already exist (source imports declare needs; `dependencies` declares ranges) | Maintainer | **Adopted**: `uses` reduced to a module-name array referencing the mount table; the external-dependency graph is generated by lookup (lexed specifiers × used modules' supply), never hand-declared (§4.1, §7) |
451+
| Overlapping supply in layered base chains (base and vue-base both provide vue) is the normal case, not an error — merge semantics needed | Maintainer | **Adopted**: `E_MULTIPLE_PROVIDERS` replaced by deterministic election (own > nearer > later array entry) with whole-closure rewiring and per-layer version validation — MF's runtime share-scope negotiation solved statically at link time (§4.1, §7) |
408452

409453
## 12. Non-goals / future work
410454

packages/pkg-wrapper/src/index.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,9 @@ export async function inspectPkg(
388388
spec: string
389389
): Promise<{ names: string[]; hasDefault: boolean }> {
390390
await ensureLexers();
391-
try {
392-
const file = resolveFromRoot(root, spec);
391+
const requireFrom = createRequire(path.join(root, 'index.js'));
392+
const lexFile = (file: string) => {
393393
const kind = detectModuleKind(file);
394-
const requireFrom = createRequire(path.join(root, 'index.js'));
395394
if (kind === 'esm') {
396395
const r = lexESMRecursive(file, requireFrom, new Set());
397396
return {
@@ -409,15 +408,49 @@ export async function inspectPkg(
409408
),
410409
hasDefault: true
411410
};
412-
} catch (error) {
413-
const message = error instanceof Error ? error.message : String(error);
411+
};
412+
try {
413+
return lexFile(resolveFromRoot(root, spec));
414+
} catch (firstError) {
415+
// Deep-subpath specifiers like `pkg:vue/dist/vue.runtime.esm-browser.prod.js`
416+
// can resolve to minified single-line bundles that es-module-lexer
417+
// can't parse. The package's root entry typically declares the same
418+
// (or a superset of) named API surface, so retry there — the
419+
// federation wrapper just needs a static names list that the bundler
420+
// also sees, and the bundler resolves the actual deep subpath itself
421+
// via the import-map alias the host sets.
422+
const baseSpec = bareSpecOf(spec);
423+
if (baseSpec && baseSpec !== spec) {
424+
try {
425+
return lexFile(resolveFromRoot(root, baseSpec));
426+
} catch {
427+
// fall through to the original error reporting below
428+
}
429+
}
430+
const message =
431+
firstError instanceof Error
432+
? firstError.message
433+
: String(firstError);
414434
console.warn(
415435
`[esmx:pkg-wrapper] failed to enumerate named exports of "${spec}" (${message}); only its default export will be re-exported, so named imports of this federated package may fail at runtime.`
416436
);
417437
return { names: [], hasDefault: false };
418438
}
419439
}
420440

441+
/**
442+
* Extract the bare package name from a deep specifier:
443+
* `vue/dist/vue.runtime.esm-browser.prod.js` → `vue`,
444+
* `@scope/pkg/sub` → `@scope/pkg`. Returns `null` if the input is already
445+
* a bare package name (no subpath to strip).
446+
*/
447+
function bareSpecOf(spec: string): string | null {
448+
const segments = spec.split('/');
449+
const nameSegments = spec.startsWith('@') ? 2 : 1;
450+
if (segments.length <= nameSegments) return null;
451+
return segments.slice(0, nameSegments).join('/');
452+
}
453+
421454
/**
422455
* Build a wrapper module that re-exports a CommonJS or ESM package as a
423456
* federation entry with STATIC named exports.

packages/pkg-wrapper/tests/pkg-wrapper-edge-cases.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ async function writePkg(
2626
const dir = path.join(fixtureRoot, 'node_modules', name);
2727
await fs.mkdir(dir, { recursive: true });
2828
for (const [file, content] of Object.entries(files)) {
29-
await fs.writeFile(path.join(dir, file), content);
29+
const target = path.join(dir, file);
30+
await fs.mkdir(path.dirname(target), { recursive: true });
31+
await fs.writeFile(target, content);
3032
}
3133
}
3234

@@ -160,6 +162,31 @@ beforeAll(async () => {
160162
''
161163
].join('\n')
162164
});
165+
166+
// Simulates packages like vue whose `./dist/*` exports map points at a
167+
// single-line minified bundle that es-module-lexer can't parse, while
168+
// the root entry remains lexable.
169+
await writePkg('minified-subpath-pkg', {
170+
'package.json': JSON.stringify({
171+
name: 'minified-subpath-pkg',
172+
version: '1.0.0',
173+
type: 'module',
174+
exports: {
175+
'.': './index.mjs',
176+
'./dist/*': './dist/*'
177+
}
178+
}),
179+
'index.mjs': [
180+
'export const alpha = 1;',
181+
'export const beta = 2;',
182+
'export default { alpha, beta };',
183+
''
184+
].join('\n'),
185+
// Unterminated template literal — es-module-lexer raises Parse error,
186+
// matching the failure mode we observed on real minified bundles
187+
// (vue.runtime.esm-browser.prod.js et al).
188+
'dist/bundle.prod.js': 'const e = `unterminated;\nexport default e;\n'
189+
});
163190
});
164191

165192
afterAll(async () => {
@@ -283,6 +310,23 @@ describe('inspectPkg deep subpath specifiers (real packages)', () => {
283310
expect(rootEntry.names).not.toContain('createRoot');
284311
expect(subpath.names).toContain('createRoot');
285312
});
313+
314+
it('falls back to the package root entry when a deep subpath resolves to an unlexable bundle', async () => {
315+
// The fixture maps `./dist/*` to a contrived "minified" file the
316+
// ESM lexer rejects. Without the fallback the wrapper would degrade
317+
// to default-only re-export and lose `alpha` / `beta`.
318+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
319+
const r = await inspectPkg(
320+
fixtureRoot,
321+
'minified-subpath-pkg/dist/bundle.prod.js'
322+
);
323+
expect(r.names).toEqual(expect.arrayContaining(['alpha', 'beta']));
324+
expect(r.hasDefault).toBe(true);
325+
// The fallback path should not log the "failed to enumerate" warning —
326+
// it succeeded on the second attempt.
327+
expect(warnSpy).not.toHaveBeenCalled();
328+
warnSpy.mockRestore();
329+
});
286330
});
287331

288332
describe('generatePkgWrapperSource edge cases', () => {

0 commit comments

Comments
 (0)