plgg-bundle 0.0.3 → 0.0.6

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/bin/relocate.mjs CHANGED
@@ -3,14 +3,15 @@
3
3
  // These tools run their `src/**/*.ts` entry directly and rely on Node stripping
4
4
  // types on load. Node refuses to strip types for `.ts` files under
5
5
  // `node_modules`, so a registry-installed tool cannot run in place. This helper
6
- // copies the package to a version-stamped dir OUTSIDE `node_modules` (with the
7
- // tool's own deps reachable via a `node_modules` symlink) and re-execs the copy
8
- // there. On a monorepo `file:` link the package realpath is already outside
9
- // `node_modules`, so it is a no-op.
6
+ // copies the package to a per-(version, install-location) dir OUTSIDE
7
+ // `node_modules` (with the tool's own deps reachable via a `node_modules`
8
+ // symlink) and re-execs the copy there. On a monorepo `file:` link the package
9
+ // realpath is already outside `node_modules`, so it is a no-op.
10
10
  //
11
11
  // Plain `.mjs`, Node built-ins only — it runs at process entry, before any
12
12
  // resolver hook is registered.
13
13
  import { spawnSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
14
15
  import {
15
16
  cpSync,
16
17
  existsSync,
@@ -54,14 +55,25 @@ export const relocateOutOfNodeModules = (
54
55
  "utf8",
55
56
  ),
56
57
  );
58
+ // The node_modules that CONTAINS this package holds its deps (npm hoists
59
+ // there). Key the relocation dir by that location too — two installs of the
60
+ // same version from different trees (e.g. a publish smoke's scratch install
61
+ // and a real consumer) must NOT share a copy, or one inherits the other's
62
+ // stale (possibly deleted) deps symlink.
63
+ const depsNodeModules = dirname(pkgRoot);
64
+ const tag = createHash("sha1")
65
+ .update(depsNodeModules)
66
+ .digest("hex")
67
+ .slice(0, 12);
57
68
  const dest = join(
58
69
  tmpdir(),
59
- `plgg-relocate-${pkg.name}-${pkg.version}`,
70
+ `plgg-relocate-${pkg.name}-${pkg.version}-${tag}`,
60
71
  );
61
72
  const ready = join(
62
73
  dest,
63
74
  ".plgg-relocate-ready",
64
75
  );
76
+ const link = join(dest, "node_modules");
65
77
 
66
78
  if (!existsSync(ready)) {
67
79
  rmSync(dest, {
@@ -81,19 +93,16 @@ export const relocateOutOfNodeModules = (
81
93
  join(pkgRoot, "package.json"),
82
94
  join(dest, "package.json"),
83
95
  );
84
- // The tool's own deps (typescript; plgg for plgg-test) live in the
85
- // node_modules that CONTAINS this package (npm hoists there). Symlink it so
86
- // resolution from the relocated copy finds them.
87
- try {
88
- symlinkSync(
89
- dirname(pkgRoot),
90
- join(dest, "node_modules"),
91
- "dir",
92
- );
93
- } catch {
94
- // A pre-existing symlink from a concurrent run is fine.
95
- }
96
- writeFileSync(ready, "");
96
+ writeFileSync(ready, depsNodeModules + "\n");
97
+ }
98
+ // (Re)create the deps symlink every run so it always points at the CURRENT
99
+ // node_modules: a cached copy from a prior run may hold a symlink to a tree
100
+ // that has since been removed (a publish smoke's scratch install) or moved.
101
+ rmSync(link, { force: true });
102
+ try {
103
+ symlinkSync(depsNodeModules, link, "dir");
104
+ } catch {
105
+ // A concurrent run created it first; its target is identical.
97
106
  }
98
107
 
99
108
  const child = spawnSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plgg-bundle",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "In-house minimal library bundler for the plgg monorepo (ESM+CJS dual output, per-file .d.ts tree), zero new dependencies — reuses the project's own TypeScript, no native binding.",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,6 +23,6 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^25.6.0",
26
- "plgg-test": "^0.0.4"
26
+ "plgg-test": "^0.0.5"
27
27
  }
28
28
  }
@@ -0,0 +1,441 @@
1
+ import {
2
+ test,
3
+ check,
4
+ all,
5
+ toBe,
6
+ } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { collectModules } from "plgg-bundle/domain/usecase/collectModules";
16
+ import {
17
+ emitCjsBundle,
18
+ emitEsmBundle,
19
+ } from "plgg-bundle/domain/usecase/emitBundle";
20
+
21
+ test("collectModules records external specifiers without walking them", () => {
22
+ const root = mkdtempSync(
23
+ join(tmpdir(), "plgg-bundle-collect-ext-"),
24
+ );
25
+ try {
26
+ const entry = join(root, "src", "main.ts");
27
+ mkdirSync(join(root, "src"), {
28
+ recursive: true,
29
+ });
30
+ writeFileSync(
31
+ entry,
32
+ 'import { readFileSync } from "node:fs";\nexport const read = readFileSync;\n',
33
+ );
34
+ const graph = collectModules({
35
+ entryFile: entry,
36
+ root,
37
+ aliasPrefix: "app",
38
+ aliasSrcRoot: join(root, "src"),
39
+ external: /^node:/,
40
+ });
41
+ return all([
42
+ check(graph.modules.length, toBe(1)),
43
+ check(
44
+ graph.modules[0]?.externals[0],
45
+ toBe("node:fs"),
46
+ ),
47
+ ]);
48
+ } finally {
49
+ rmSync(root, {
50
+ recursive: true,
51
+ force: true,
52
+ });
53
+ }
54
+ });
55
+
56
+ test("collectModules throws on an unresolvable non-external specifier", () => {
57
+ const root = mkdtempSync(
58
+ join(
59
+ tmpdir(),
60
+ "plgg-bundle-collect-missing-",
61
+ ),
62
+ );
63
+ try {
64
+ const entry = join(root, "src", "main.ts");
65
+ mkdirSync(join(root, "src"), {
66
+ recursive: true,
67
+ });
68
+ writeFileSync(entry, 'import "missing";\n');
69
+ return check(
70
+ rejects(() =>
71
+ collectModules({
72
+ entryFile: entry,
73
+ root,
74
+ aliasPrefix: "app",
75
+ aliasSrcRoot: join(root, "src"),
76
+ external: /^node:/,
77
+ }),
78
+ ),
79
+ toBe(true),
80
+ );
81
+ } finally {
82
+ rmSync(root, {
83
+ recursive: true,
84
+ force: true,
85
+ });
86
+ }
87
+ });
88
+
89
+ test("collectModules inlines an installed prebundled dist entry without resolving its internal registry requires", () => {
90
+ const root = mkdtempSync(
91
+ join(tmpdir(), "plgg-bundle-collect-"),
92
+ );
93
+ try {
94
+ const entry = join(root, "src", "main.ts");
95
+ const dep = join(
96
+ root,
97
+ "node_modules",
98
+ "dep",
99
+ "dist",
100
+ "index.es.js",
101
+ );
102
+ const plgg = join(
103
+ root,
104
+ "node_modules",
105
+ "plgg",
106
+ "dist",
107
+ "index.es.js",
108
+ );
109
+ mkdirSync(join(root, "src"), {
110
+ recursive: true,
111
+ });
112
+ mkdirSync(
113
+ join(root, "node_modules", "dep", "dist"),
114
+ {
115
+ recursive: true,
116
+ },
117
+ );
118
+ mkdirSync(
119
+ join(root, "node_modules", "plgg", "dist"),
120
+ {
121
+ recursive: true,
122
+ },
123
+ );
124
+ writeFileSync(
125
+ entry,
126
+ 'import dep from "dep";\nexport const value = dep.value;\n',
127
+ );
128
+ writeFileSync(
129
+ dep,
130
+ [
131
+ 'import * as __ext0 from "plgg";',
132
+ 'const __externals = { "plgg": __ext0 };',
133
+ "const __modules = {",
134
+ '"src/index.ts": function (module, exports, require) {',
135
+ ' exports.value = require("src/internal.ts").value;',
136
+ "},",
137
+ '"src/internal.ts": function (module, exports, require) {',
138
+ " exports.value = __externals.plgg.external;",
139
+ "}",
140
+ "};",
141
+ 'const __entry = __modules["src/index.ts"];',
142
+ "export default { value: __entry };",
143
+ ].join("\n"),
144
+ );
145
+ writeFileSync(
146
+ plgg,
147
+ "export const external = 1;\n",
148
+ );
149
+
150
+ const graph = collectModules({
151
+ entryFile: entry,
152
+ root,
153
+ aliasPrefix: "app",
154
+ aliasSrcRoot: join(root, "src"),
155
+ external: /^node:/,
156
+ resolve: (specifier, _fromFile) =>
157
+ specifier === "dep"
158
+ ? dep
159
+ : specifier === "plgg"
160
+ ? plgg
161
+ : undefined,
162
+ });
163
+ const depModule = graph.modules.find(
164
+ (m) =>
165
+ m.id ===
166
+ "node_modules/dep/dist/index.es.js",
167
+ );
168
+ return all([
169
+ check(graph.modules.length, toBe(3)),
170
+ check(
171
+ depModule?.code.includes(
172
+ 'require("src/internal.ts")',
173
+ ),
174
+ toBe(true),
175
+ ),
176
+ check(
177
+ depModule?.code.includes(
178
+ 'require("node_modules/plgg/dist/index.es.js")',
179
+ ),
180
+ toBe(true),
181
+ ),
182
+ ]);
183
+ } finally {
184
+ rmSync(root, {
185
+ recursive: true,
186
+ force: true,
187
+ });
188
+ }
189
+ });
190
+
191
+ test("collectModules resolves node_modules source files without dist-registry filtering", () => {
192
+ const root = mkdtempSync(
193
+ join(tmpdir(), "plgg-bundle-collect-src-"),
194
+ );
195
+ try {
196
+ const entry = join(root, "src", "main.ts");
197
+ const dep = join(
198
+ root,
199
+ "node_modules",
200
+ "dep",
201
+ "src",
202
+ "index.ts",
203
+ );
204
+ const internal = join(
205
+ root,
206
+ "node_modules",
207
+ "dep",
208
+ "src",
209
+ "internal.ts",
210
+ );
211
+ writeImportFixture(entry, dep, internal);
212
+ const graph = collectModules({
213
+ entryFile: entry,
214
+ root,
215
+ aliasPrefix: "app",
216
+ aliasSrcRoot: join(root, "src"),
217
+ external: /^node:/,
218
+ resolve: importFixtureResolver(
219
+ dep,
220
+ internal,
221
+ ),
222
+ });
223
+ return check(graph.modules.length, toBe(3));
224
+ } finally {
225
+ rmSync(root, {
226
+ recursive: true,
227
+ force: true,
228
+ });
229
+ }
230
+ });
231
+
232
+ test("collectModules resolves non-js dist source files without dist-registry filtering", () => {
233
+ const root = mkdtempSync(
234
+ join(tmpdir(), "plgg-bundle-collect-mjs-"),
235
+ );
236
+ try {
237
+ const entry = join(root, "src", "main.ts");
238
+ const dep = join(
239
+ root,
240
+ "node_modules",
241
+ "dep",
242
+ "dist",
243
+ "index.ts",
244
+ );
245
+ const internal = join(
246
+ root,
247
+ "node_modules",
248
+ "dep",
249
+ "dist",
250
+ "internal.ts",
251
+ );
252
+ writeImportFixture(entry, dep, internal);
253
+ const graph = collectModules({
254
+ entryFile: entry,
255
+ root,
256
+ aliasPrefix: "app",
257
+ aliasSrcRoot: join(root, "src"),
258
+ external: /^node:/,
259
+ resolve: importFixtureResolver(
260
+ dep,
261
+ internal,
262
+ ),
263
+ });
264
+ return check(graph.modules.length, toBe(3));
265
+ } finally {
266
+ rmSync(root, {
267
+ recursive: true,
268
+ force: true,
269
+ });
270
+ }
271
+ });
272
+
273
+ test("an app bundling two dists with identical inner module paths keeps each dist's externals resolvable", () => {
274
+ // Regression for the plggmatic blank-page defect:
275
+ // pkg-a and pkg-b are plgg-bundle ESM dists whose
276
+ // inner registries both key "src/index.ts", and
277
+ // pkg-b requires pkg-a through its inner
278
+ // __externals table. The outer rewrite must keep
279
+ // that table's key in step with the rewritten
280
+ // require, or pkg-b's inner lookup falls into the
281
+ // async import fallback and yields a Promise where
282
+ // a namespace is expected.
283
+ const root = mkdtempSync(
284
+ join(tmpdir(), "plgg-bundle-collect-nested-"),
285
+ );
286
+ try {
287
+ const entry = join(root, "src", "main.ts");
288
+ const pkgA = join(
289
+ root,
290
+ "node_modules",
291
+ "pkg-a",
292
+ "dist",
293
+ "index.es.js",
294
+ );
295
+ const pkgB = join(
296
+ root,
297
+ "node_modules",
298
+ "pkg-b",
299
+ "dist",
300
+ "index.es.js",
301
+ );
302
+ mkdirSync(join(root, "src"), {
303
+ recursive: true,
304
+ });
305
+ mkdirSync(join(pkgA, ".."), {
306
+ recursive: true,
307
+ });
308
+ mkdirSync(join(pkgB, ".."), {
309
+ recursive: true,
310
+ });
311
+ writeFileSync(
312
+ entry,
313
+ [
314
+ 'import { box } from "pkg-a";',
315
+ 'import { useBox } from "pkg-b";',
316
+ "export const direct = box(1);",
317
+ "export const viaB = useBox();",
318
+ ].join("\n"),
319
+ );
320
+ // Both dists come from the real emitter, so the
321
+ // fixture cannot drift from the emitted shape.
322
+ writeFileSync(
323
+ pkgA,
324
+ emitEsmBundle(
325
+ {
326
+ entryId: "src/index.ts",
327
+ modules: [
328
+ {
329
+ id: "src/index.ts",
330
+ code: "exports.box = (v) => v * 2;",
331
+ externals: [],
332
+ },
333
+ ],
334
+ },
335
+ ["box"],
336
+ ),
337
+ );
338
+ writeFileSync(
339
+ pkgB,
340
+ emitEsmBundle(
341
+ {
342
+ entryId: "src/index.ts",
343
+ modules: [
344
+ {
345
+ id: "src/index.ts",
346
+ code: 'exports.useBox = () => require("pkg-a").box(21);',
347
+ externals: ["pkg-a"],
348
+ },
349
+ ],
350
+ },
351
+ ["useBox"],
352
+ ),
353
+ );
354
+ const graph = collectModules({
355
+ entryFile: entry,
356
+ root,
357
+ aliasPrefix: "app",
358
+ aliasSrcRoot: join(root, "src"),
359
+ external: /^node:/,
360
+ resolve: (specifier, _fromFile) =>
361
+ specifier === "pkg-a"
362
+ ? pkgA
363
+ : specifier === "pkg-b"
364
+ ? pkgB
365
+ : undefined,
366
+ });
367
+ const mod: {
368
+ exports: Record<string, unknown>;
369
+ } = {
370
+ exports: {},
371
+ };
372
+ new Function(
373
+ "module",
374
+ "exports",
375
+ "require",
376
+ emitCjsBundle(graph),
377
+ )(mod, mod.exports, () => ({}));
378
+ return all([
379
+ check(mod.exports.direct, toBe(2)),
380
+ check(mod.exports.viaB, toBe(42)),
381
+ ]);
382
+ } finally {
383
+ rmSync(root, {
384
+ recursive: true,
385
+ force: true,
386
+ });
387
+ }
388
+ });
389
+
390
+ const writeImportFixture = (
391
+ entry: string,
392
+ dep: string,
393
+ internal: string,
394
+ ): void => {
395
+ mkdirSync(join(entry, ".."), {
396
+ recursive: true,
397
+ });
398
+ mkdirSync(join(dep, ".."), { recursive: true });
399
+ mkdirSync(join(internal, ".."), {
400
+ recursive: true,
401
+ });
402
+ writeFileSync(
403
+ entry,
404
+ 'import { value } from "dep";\nexport const out = value;\n',
405
+ );
406
+ writeFileSync(
407
+ dep,
408
+ 'import { value } from "src/internal.ts";\nexport { value };\n',
409
+ );
410
+ writeFileSync(
411
+ internal,
412
+ "export const value = 1;\n",
413
+ );
414
+ };
415
+
416
+ const importFixtureResolver =
417
+ (
418
+ dep: string,
419
+ internal: string,
420
+ ): ((
421
+ specifier: string,
422
+ fromFile: string,
423
+ ) => string | undefined) =>
424
+ (specifier, _fromFile) =>
425
+ specifier === "dep"
426
+ ? dep
427
+ : specifier === "src/internal.ts"
428
+ ? internal
429
+ : undefined;
430
+
431
+ const rejects = (f: () => unknown): boolean => {
432
+ try {
433
+ f();
434
+ return false;
435
+ } catch (e) {
436
+ return (
437
+ e instanceof Error &&
438
+ e.message.startsWith("ResolveError")
439
+ );
440
+ }
441
+ };
@@ -135,7 +135,10 @@ const linkModule = (
135
135
  cjs: string,
136
136
  acc: Map<string, Module>,
137
137
  ): ReadonlyArray<string> => {
138
- const specifiers = requireSpecifiers(cjs);
138
+ const specifiers = inlineRequireSpecifiers(
139
+ file,
140
+ cjs,
141
+ );
139
142
  const externals: string[] = [];
140
143
  const deps: string[] = [];
141
144
  let rewritten = cjs;
@@ -156,6 +159,13 @@ const linkModule = (
156
159
  spec,
157
160
  idOf(ctx.root, resolved),
158
161
  );
162
+ if (isInstalledDist(file)) {
163
+ rewritten = replaceExternalKey(
164
+ rewritten,
165
+ spec,
166
+ idOf(ctx.root, resolved),
167
+ );
168
+ }
159
169
  }
160
170
  acc.set(id, {
161
171
  id,
@@ -165,6 +175,63 @@ const linkModule = (
165
175
  return deps;
166
176
  };
167
177
 
178
+ /**
179
+ * Literal requires that belong to the OUTER graph walk.
180
+ * Registry-installed plgg-family packages ship
181
+ * pre-bundled dist entries whose internal module table
182
+ * still contains strings like `require("src/index.ts")`.
183
+ * Those are parameters of the INNER registry runtime and
184
+ * must not be resolved or rewritten by this app bundle.
185
+ * Real top-level externals in that dist file remain, e.g.
186
+ * `require("plgg")`, and are still inlined by the app
187
+ * graph.
188
+ */
189
+ const inlineRequireSpecifiers = (
190
+ file: string,
191
+ cjs: string,
192
+ ): ReadonlyArray<string> => {
193
+ const specifiers = requireSpecifiers(cjs);
194
+ if (!isInstalledDist(file)) {
195
+ return specifiers;
196
+ }
197
+ const internal = bundledModuleIds(cjs);
198
+ return specifiers.filter(
199
+ (spec) => !internal.has(spec),
200
+ );
201
+ };
202
+
203
+ /**
204
+ * Whether a file is a registry-installed built JS entry.
205
+ * Source packages under the monorepo are never matched.
206
+ */
207
+ const isInstalledDist = (
208
+ file: string,
209
+ ): boolean => {
210
+ const normalized = file.split("\\").join("/");
211
+ return (
212
+ normalized.includes("/node_modules/") &&
213
+ normalized.includes("/dist/") &&
214
+ normalized.endsWith(".js")
215
+ );
216
+ };
217
+
218
+ /**
219
+ * Module ids declared inside a plgg-bundle emitted
220
+ * registry object: `"src/x.ts": function (...) { ... }`.
221
+ */
222
+ const bundledModuleIds = (
223
+ cjs: string,
224
+ ): ReadonlySet<string> =>
225
+ new Set(
226
+ [
227
+ ...cjs.matchAll(
228
+ /["']([^"']+)["']\s*:\s*function\s*\(/g,
229
+ ),
230
+ ].flatMap((m) =>
231
+ m[1] === undefined ? [] : [m[1]],
232
+ ),
233
+ );
234
+
168
235
  /**
169
236
  * Read a source file, re-throwing with context.
170
237
  */
@@ -209,6 +276,29 @@ const requireSpecifiers = (
209
276
  m[1] === undefined ? [] : [m[1]],
210
277
  );
211
278
 
279
+ /**
280
+ * Rewrite an inlined dist's inner `__externals` table
281
+ * key from the original specifier to the outer module
282
+ * id. A plgg-bundle ESM dist resolves an external
283
+ * required inside its inner module bodies through this
284
+ * table; {@link replaceRequire} rewrites those bodies'
285
+ * `require("<spec>")` calls to the outer id, so the key
286
+ * must follow or the inner lookup misses and falls into
287
+ * the transpiled dynamic-import fallback — a Promise
288
+ * where the consumer expects a namespace ("plgg_1.box
289
+ * is not a function"). Matches the exact
290
+ * `"<spec>": __extN` shape the TS printer emits for the
291
+ * table, so ordinary code never collides with it.
292
+ */
293
+ const replaceExternalKey = (
294
+ cjs: string,
295
+ spec: string,
296
+ id: string,
297
+ ): string =>
298
+ cjs
299
+ .split(`${JSON.stringify(spec)}: __ext`)
300
+ .join(`${JSON.stringify(id)}: __ext`);
301
+
212
302
  /**
213
303
  * Rewrite every `require("spec")` occurrence to
214
304
  * `require("id")`. Operates on the exact specifier
@@ -4,6 +4,13 @@ import {
4
4
  all,
5
5
  toBe,
6
6
  } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
7
14
  import { join } from "node:path";
8
15
  import { discoverWorkspace } from "plgg-bundle/domain/usecase/discoverWorkspace";
9
16
 
@@ -61,3 +68,107 @@ test("discoverWorkspace sorts longest-name first (so plgg-view beats plgg)", ()
61
68
  );
62
69
  return check(iView < iCore, toBe(true));
63
70
  });
71
+
72
+ test("discoverWorkspace finds dist-only packages installed under node_modules", () => {
73
+ const root = fixtureRoot();
74
+ try {
75
+ const app = join(root, "packages", "app");
76
+ writePackage(app, "app", true);
77
+ writePackage(
78
+ join(app, "node_modules", "plgg"),
79
+ "plgg",
80
+ false,
81
+ );
82
+ const found = discoverWorkspace(app).find(
83
+ (p) => p.name === "plgg",
84
+ );
85
+ return all([
86
+ check(found?.kind, toBe("dist")),
87
+ check(
88
+ found?.exports.get("."),
89
+ toBe("./dist/index.es.js"),
90
+ ),
91
+ ]);
92
+ } finally {
93
+ rmSync(root, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("discoverWorkspace keeps a sibling source package before an installed duplicate", () => {
98
+ const root = fixtureRoot();
99
+ try {
100
+ const app = join(root, "packages", "app");
101
+ writePackage(app, "app", true);
102
+ writePackage(
103
+ join(root, "packages", "plgg"),
104
+ "plgg",
105
+ true,
106
+ );
107
+ writePackage(
108
+ join(app, "node_modules", "plgg"),
109
+ "plgg",
110
+ false,
111
+ );
112
+ const found = discoverWorkspace(app).find(
113
+ (p) => p.name === "plgg",
114
+ );
115
+ return check(found?.kind, toBe("source"));
116
+ } finally {
117
+ rmSync(root, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ test("discoverWorkspace finds installed deps of a sibling source package", () => {
122
+ const root = fixtureRoot();
123
+ try {
124
+ const app = join(root, "packages", "app");
125
+ writePackage(app, "app", true);
126
+ const local = join(root, "packages", "local");
127
+ writePackage(local, "local", true);
128
+ writePackage(
129
+ join(local, "node_modules", "plgg-cms"),
130
+ "plgg-cms",
131
+ false,
132
+ );
133
+ const found = discoverWorkspace(app).find(
134
+ (p) => p.name === "plgg-cms",
135
+ );
136
+ return check(found?.kind, toBe("dist"));
137
+ } finally {
138
+ rmSync(root, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ const fixtureRoot = (): string =>
143
+ mkdtempSync(
144
+ join(tmpdir(), "plgg-bundle-discover-"),
145
+ );
146
+
147
+ const writePackage = (
148
+ dir: string,
149
+ name: string,
150
+ source: boolean,
151
+ ): void => {
152
+ mkdirSync(source ? join(dir, "src") : join(dir, "dist"), {
153
+ recursive: true,
154
+ });
155
+ writeFileSync(
156
+ join(dir, "package.json"),
157
+ JSON.stringify(
158
+ {
159
+ name,
160
+ type: "module",
161
+ exports: {
162
+ import: {
163
+ default: "./dist/index.es.js",
164
+ },
165
+ require: {
166
+ default: "./dist/index.cjs.js",
167
+ },
168
+ },
169
+ },
170
+ null,
171
+ 2,
172
+ ),
173
+ );
174
+ };
@@ -7,38 +7,65 @@ import {
7
7
  import { join, dirname } from "plgg-bundle/vendors/nodePath";
8
8
 
9
9
  /**
10
- * A workspace sibling the app bundler can INLINE from
11
- * source: its package `name`, its `dir`, and the map from
12
- * each public export subpath (`"."`, `"./client"`, …) to
13
- * the `import.default` dist path declared in its
14
- * `package.json` `exports`. The app resolver reverses
15
- * that dist path to the entry's source file (so the
16
- * `./style` → `dist/styleEntry.es.js` → `src/styleEntry`
17
- * rename is honoured), and falls back to a self-alias
18
- * `name/<path>` → `src/<path>` for non-export internal
19
- * imports.
20
- */
21
- export type WorkspacePackage = Readonly<{
10
+ * A package the app bundler can INLINE. Monorepo
11
+ * siblings are inlined from `src`; registry-installed
12
+ * packages usually ship only `dist`, so the resolver
13
+ * inlines their built ESM entry instead.
14
+ */
15
+ export type WorkspacePackage =
16
+ | SourceWorkspacePackage
17
+ | DistWorkspacePackage;
18
+
19
+ /**
20
+ * A source package, usually a sibling under `packages/`.
21
+ * Its public export subpaths are reversed from
22
+ * `exports` dist paths back to `src` entries.
23
+ */
24
+ export type SourceWorkspacePackage = Readonly<{
25
+ kind: "source";
26
+ name: string;
27
+ dir: string;
28
+ srcDir: string;
29
+ /** export subpath (`"."` | `"./x"`) → dist default. */
30
+ exports: ReadonlyMap<string, string>;
31
+ }>;
32
+
33
+ /**
34
+ * A dist package, usually installed under the app's
35
+ * `node_modules`. It has no source in the consumer tree,
36
+ * so public export subpaths resolve to built ESM files.
37
+ */
38
+ export type DistWorkspacePackage = Readonly<{
39
+ kind: "dist";
22
40
  name: string;
23
41
  dir: string;
42
+ distDir: string;
24
43
  /** export subpath (`"."` | `"./x"`) → dist default. */
25
44
  exports: ReadonlyMap<string, string>;
26
45
  }>;
27
46
 
28
47
  /**
29
- * Discover every sibling package under the directory that
30
- * holds `packageRoot` (the monorepo `packages/` dir) —
31
- * each entry that has a `package.json` with a `name`.
48
+ * Discover inlineable packages visible from `packageRoot`:
49
+ * sibling packages under the directory that holds
50
+ * `packageRoot`, plus packages installed in this package's
51
+ * own `node_modules` and in sibling source packages'
52
+ * `node_modules`. Siblings are listed first and win on
53
+ * duplicate names so monorepo builds keep reading source.
32
54
  * Sorted longest-name-first so a later prefix match picks
33
- * `plgg-view` over `plgg`. Throws on a read/parse failure.
55
+ * `plgg-view` over `plgg`.
34
56
  */
35
57
  export const discoverWorkspace = (
36
58
  packageRoot: string,
37
59
  ): ReadonlyArray<WorkspacePackage> => {
38
60
  const siblingsDir = dirname(packageRoot);
39
- return readdirSync(siblingsDir)
40
- .map((name) => join(siblingsDir, name))
41
- .filter(isPackageDir)
61
+ const siblings = packageDirsIn(siblingsDir);
62
+ return uniqueByName([
63
+ ...siblings,
64
+ ...packageDirsIn(join(packageRoot, "node_modules")),
65
+ ...siblings.flatMap((dir) =>
66
+ packageDirsIn(join(dir, "node_modules")),
67
+ ),
68
+ ])
42
69
  .map(readPackage)
43
70
  .flatMap((p) => (p === undefined ? [] : [p]))
44
71
  .sort(
@@ -47,8 +74,61 @@ export const discoverWorkspace = (
47
74
  };
48
75
 
49
76
  /**
50
- * Whether a path is a directory holding a
51
- * `package.json`.
77
+ * Direct package dirs under `dir`, including scoped
78
+ * `@scope/name` packages. Missing directories simply
79
+ * contribute no packages.
80
+ */
81
+ const packageDirsIn = (
82
+ dir: string,
83
+ ): ReadonlyArray<string> =>
84
+ existsSync(dir)
85
+ ? readdirSync(dir).flatMap((name) =>
86
+ childPackageDirs(join(dir, name)),
87
+ )
88
+ : [];
89
+
90
+ /**
91
+ * The package dirs represented by one child of a package
92
+ * collection directory. A scope directory fans out one
93
+ * level; ordinary package directories are returned as-is.
94
+ */
95
+ const childPackageDirs = (
96
+ dir: string,
97
+ ): ReadonlyArray<string> => {
98
+ if (!statSync(dir).isDirectory()) {
99
+ return [];
100
+ }
101
+ if (isPackageDir(dir)) {
102
+ return [dir];
103
+ }
104
+ return readdirSync(dir)
105
+ .map((name) => join(dir, name))
106
+ .filter(isPackageDir);
107
+ };
108
+
109
+ /**
110
+ * Keep the first directory for each package name. The
111
+ * caller supplies siblings before node_modules, so a local
112
+ * source package beats an installed copy.
113
+ */
114
+ const uniqueByName = (
115
+ dirs: ReadonlyArray<string>,
116
+ ): ReadonlyArray<string> => {
117
+ const seen = new Set<string>();
118
+ const out: string[] = [];
119
+ for (const dir of dirs) {
120
+ const name = packageName(dir);
121
+ if (name === undefined || seen.has(name)) {
122
+ continue;
123
+ }
124
+ seen.add(name);
125
+ out.push(dir);
126
+ }
127
+ return out;
128
+ };
129
+
130
+ /**
131
+ * Whether a path is a directory holding a package.json.
52
132
  */
53
133
  const isPackageDir = (dir: string): boolean =>
54
134
  statSync(dir).isDirectory() &&
@@ -66,9 +146,44 @@ const readPackage = (
66
146
  join(dir, "package.json"),
67
147
  );
68
148
  const name = pkg["name"];
69
- return typeof name === "string"
70
- ? { name, dir, exports: exportMap(pkg) }
71
- : undefined;
149
+ if (typeof name !== "string") {
150
+ return undefined;
151
+ }
152
+ const srcDir = join(dir, "src");
153
+ if (existsSync(srcDir)) {
154
+ return {
155
+ kind: "source",
156
+ name,
157
+ dir,
158
+ srcDir,
159
+ exports: exportMap(pkg),
160
+ };
161
+ }
162
+ const distDir = join(dir, "dist");
163
+ if (existsSync(distDir)) {
164
+ return {
165
+ kind: "dist",
166
+ name,
167
+ dir,
168
+ distDir,
169
+ exports: exportMap(pkg),
170
+ };
171
+ }
172
+ return undefined;
173
+ };
174
+
175
+ /**
176
+ * Read only the package name for de-duplication.
177
+ */
178
+ const packageName = (
179
+ dir: string,
180
+ ): string | undefined => {
181
+ if (!isPackageDir(dir)) {
182
+ return undefined;
183
+ }
184
+ const pkg = parseJson(join(dir, "package.json"));
185
+ const name = pkg["name"];
186
+ return typeof name === "string" ? name : undefined;
72
187
  };
73
188
 
74
189
  /**
@@ -4,6 +4,13 @@ import {
4
4
  all,
5
5
  toBe,
6
6
  } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
7
14
  import { join } from "node:path";
8
15
  import { discoverWorkspace } from "plgg-bundle/domain/usecase/discoverWorkspace";
9
16
  import { resolveWorkspaceSpecifier } from "plgg-bundle/domain/usecase/resolveWorkspaceSpecifier";
@@ -77,3 +84,93 @@ test("returns undefined for a non-workspace specifier", () =>
77
84
  check(resolve("react"), toBe(undefined)),
78
85
  check(resolve("node:fs"), toBe(undefined)),
79
86
  ]));
87
+
88
+ test("resolves an installed dist-only package export to its built ESM file", () => {
89
+ const root = fixtureRoot();
90
+ try {
91
+ const app = join(root, "packages", "app");
92
+ writePackage(app, "app", true);
93
+ writePackage(
94
+ join(app, "node_modules", "plgg"),
95
+ "plgg",
96
+ false,
97
+ );
98
+ const installed = discoverWorkspace(app);
99
+ return check(
100
+ resolveWorkspaceSpecifier({
101
+ specifier: "plgg",
102
+ fromFile: join(app, "src", "main.ts"),
103
+ packages: installed,
104
+ }),
105
+ toBe(
106
+ join(
107
+ app,
108
+ "node_modules",
109
+ "plgg",
110
+ "dist",
111
+ "index.es.js",
112
+ ),
113
+ ),
114
+ );
115
+ } finally {
116
+ rmSync(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ test("does not resolve internal paths from a dist-only installed package", () => {
121
+ const root = fixtureRoot();
122
+ try {
123
+ const app = join(root, "packages", "app");
124
+ writePackage(app, "app", true);
125
+ writePackage(
126
+ join(app, "node_modules", "plgg"),
127
+ "plgg",
128
+ false,
129
+ );
130
+ const installed = discoverWorkspace(app);
131
+ return check(
132
+ resolveWorkspaceSpecifier({
133
+ specifier: "plgg/Atomics/Num",
134
+ fromFile: join(app, "src", "main.ts"),
135
+ packages: installed,
136
+ }),
137
+ toBe(undefined),
138
+ );
139
+ } finally {
140
+ rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ const fixtureRoot = (): string =>
145
+ mkdtempSync(join(tmpdir(), "plgg-bundle-resolve-"));
146
+
147
+ const writePackage = (
148
+ dir: string,
149
+ name: string,
150
+ source: boolean,
151
+ ): void => {
152
+ const codeDir = source
153
+ ? join(dir, "src")
154
+ : join(dir, "dist");
155
+ mkdirSync(codeDir, { recursive: true });
156
+ writeFileSync(join(codeDir, "index.es.js"), "");
157
+ writeFileSync(
158
+ join(dir, "package.json"),
159
+ JSON.stringify(
160
+ {
161
+ name,
162
+ type: "module",
163
+ exports: {
164
+ import: {
165
+ default: "./dist/index.es.js",
166
+ },
167
+ require: {
168
+ default: "./dist/index.cjs.js",
169
+ },
170
+ },
171
+ },
172
+ null,
173
+ 2,
174
+ ),
175
+ );
176
+ };
@@ -8,11 +8,12 @@ import { type WorkspacePackage } from "plgg-bundle/domain/usecase/discoverWorksp
8
8
 
9
9
  /**
10
10
  * App-mode resolver: resolve an import specifier to an
11
- * absolute SOURCE file, inlining workspace siblings
12
- * (`plgg`, `plgg-view/client`, ) by walking their `src`
13
- * instead of leaving them external. This is the mirror of
14
- * the library externalization the leaf app is where
15
- * bundling deps is correct.
11
+ * absolute inlineable file. Source packages
12
+ * (`packages/plgg`, local `file:` siblings) resolve to
13
+ * `src`; published packages installed in `node_modules`
14
+ * resolve to their built ESM `dist` export. This is the
15
+ * mirror of the library externalization — the leaf app is
16
+ * where bundling deps is correct.
16
17
  *
17
18
  * Order:
18
19
  * 1. relative (`./x`) → resolve against the importer,
@@ -75,8 +76,8 @@ const matchPackage = (
75
76
 
76
77
  /**
77
78
  * Resolve a specifier already known to belong to `pkg`:
78
- * a declared export subpath via the exports reversal,
79
- * else the internal self-alias path.
79
+ * a declared export subpath through the package kind,
80
+ * else a source package's internal self-alias path.
80
81
  */
81
82
  const resolveInPackage = (
82
83
  pkg: WorkspacePackage,
@@ -89,17 +90,42 @@ const resolveInPackage = (
89
90
  pkg.name.length + 1,
90
91
  )}`;
91
92
  const dist = pkg.exports.get(subpath);
92
- return dist === undefined
93
+ return pkg.kind === "source"
94
+ ? resolveInSourcePackage(pkg, specifier, dist)
95
+ : resolveInDistPackage(pkg, dist);
96
+ };
97
+
98
+ /**
99
+ * Source packages keep the previous monorepo behavior:
100
+ * public exports reverse their emitted dist stem back to
101
+ * source; non-export self-alias paths resolve under `src`.
102
+ */
103
+ const resolveInSourcePackage = (
104
+ pkg: WorkspacePackage & { kind: "source" },
105
+ specifier: string,
106
+ dist: string | undefined,
107
+ ): string | undefined =>
108
+ dist === undefined
93
109
  ? resolveSpecifier({
94
110
  specifier,
95
111
  fromFile: pkg.dir,
96
112
  aliasPrefix: pkg.name,
97
- aliasSrcRoot: join(pkg.dir, "src"),
113
+ aliasSrcRoot: pkg.srcDir,
98
114
  })
99
- : pickExisting(
100
- join(pkg.dir, "src", srcStem(dist)),
101
- );
102
- };
115
+ : pickExisting(join(pkg.srcDir, srcStem(dist)));
116
+
117
+ /**
118
+ * Dist-only packages can safely resolve only declared
119
+ * public exports. Internal self-alias paths are not
120
+ * available in a consumer install.
121
+ */
122
+ const resolveInDistPackage = (
123
+ pkg: WorkspacePackage & { kind: "dist" },
124
+ dist: string | undefined,
125
+ ): string | undefined =>
126
+ dist === undefined
127
+ ? undefined
128
+ : pickExisting(join(pkg.dir, dist));
103
129
 
104
130
  /**
105
131
  * The source stem implied by an export's dist default —