@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.27
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/DEV_RUNTIME_KNOBS.md +9 -6
- package/akanApp/akanApp.host.ts +79 -22
- package/akanApp/devHostPolicy.test.ts +56 -11
- package/akanApp/devHostPolicy.ts +10 -10
- package/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
- package/frontendBuild/clientEntryDiscovery.ts +112 -70
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
- package/frontendBuild/csrDevModuleCompiler.ts +95 -20
- package/frontendBuild/csrDevPatcher.test.ts +234 -2
- package/frontendBuild/csrDevPatcher.ts +184 -10
- package/frontendBuild/csrDevPaths.ts +32 -11
- package/frontendBuild/csrDevResolver.test.ts +76 -2
- package/frontendBuild/csrDevResolver.ts +141 -12
- package/frontendBuild/csrDevTypes.ts +2 -0
- package/frontendBuild/devRegistryBundler.ts +18 -3
- package/frontendBuild/ssrDevBundler.ts +2 -8
- package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
- package/integration/devStability.integration.test.ts +9 -2
- package/mobile/nativeApp.test.ts +15 -0
- package/mobile/nativeApp.ts +54 -14
- package/mobile/nativeAppLine.test.ts +62 -0
- package/mobile/nativeAppLine.ts +63 -0
- package/package.json +2 -2
|
@@ -35,6 +35,9 @@ export class CsrDevPatcher {
|
|
|
35
35
|
readonly #bundler: DevRegistryBundler;
|
|
36
36
|
readonly #resident: boolean;
|
|
37
37
|
#state: CsrDevPatcherState | null = null;
|
|
38
|
+
//? A pending root handed to a worker, by the hash it had: one that fails there too waits for an edit of its own or of
|
|
39
|
+
//? a file the worker failed in, instead of sending every save to a worker.
|
|
40
|
+
readonly #delegated = new Map<string, { hash: string; failed?: Record<string, string>; before: string }>();
|
|
38
41
|
|
|
39
42
|
constructor(bundler: DevRegistryBundler, { resident = false }: { resident?: boolean } = {}) {
|
|
40
43
|
this.#bundler = bundler;
|
|
@@ -46,6 +49,11 @@ export class CsrDevPatcher {
|
|
|
46
49
|
this.#state = null;
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/** Roots handed to a worker that died before it wrote their failure: the next round hands them over again. */
|
|
53
|
+
releaseHandedOver(): void {
|
|
54
|
+
for (const [id, handedOver] of this.#delegated) if (!handedOver.failed) this.#delegated.delete(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
async update(
|
|
50
58
|
changedFiles: string[] = [],
|
|
51
59
|
{ announce, roots = [], onlyRoots = false, allowWholeAppBuilds = false }: CsrDevPatchOptions = {},
|
|
@@ -104,7 +112,10 @@ export class CsrDevPatcher {
|
|
|
104
112
|
for (const id of this.#withoutFactory(state, present)) changed.add(id);
|
|
105
113
|
for (const file of entries.changed) changed.add(paths.idOf(file));
|
|
106
114
|
const rootFiles = [...Object.values(entries.files), ...roots.filter((file) => fs.existsSync(file))];
|
|
107
|
-
for (const file of rootFiles)
|
|
115
|
+
for (const file of rootFiles) {
|
|
116
|
+
const id = paths.idOf(file);
|
|
117
|
+
if (!graph.modules[id] && !(onlyRoots && (await this.#parked(graph, id)))) changed.add(id);
|
|
118
|
+
}
|
|
108
119
|
const carried = onlyRoots ? [] : await this.#carriedRoots(graph, changed, present);
|
|
109
120
|
const routesMoved =
|
|
110
121
|
this.#bundler.reloadsOnEntryChange &&
|
|
@@ -124,15 +135,26 @@ export class CsrDevPatcher {
|
|
|
124
135
|
runtimeResolved: graph.runtimeResolved,
|
|
125
136
|
});
|
|
126
137
|
const known = new Set(Object.keys(graph.modules).map((id) => paths.fileOf(id)));
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
[...changed].map((id) => paths.fileOf(id)),
|
|
138
|
+
const compile = async (ids: string[]) =>
|
|
139
|
+
await this.#bundler.compiler(context, resolver).compile(
|
|
140
|
+
ids.map((id) => paths.fileOf(id)),
|
|
131
141
|
known,
|
|
132
142
|
{ refuseNewVendor: !allowWholeAppBuilds, refusePrepass: !allowWholeAppBuilds },
|
|
133
143
|
);
|
|
144
|
+
let result: CsrDevCompileResult;
|
|
145
|
+
let orphans: string[];
|
|
146
|
+
try {
|
|
147
|
+
({ result, orphans } = await this.#compileLeavingOrphans(compile, graph, changed));
|
|
148
|
+
for (const module of result.modules) known.add(module.file);
|
|
149
|
+
result = await this.#withTwinImporters(compile, graph, result);
|
|
134
150
|
} catch (error) {
|
|
135
|
-
|
|
151
|
+
//? Joined, not replaced: a route's root another round failed stays pending for its own retry.
|
|
152
|
+
graph.pending = [...new Set([...graph.pending, ...changed, ...carried])];
|
|
153
|
+
if (!this.#resident)
|
|
154
|
+
graph.failed = {
|
|
155
|
+
roots: [...changed, ...carried],
|
|
156
|
+
files: await CsrDevPatcher.#hashesOf(paths, CsrDevPatcher.#implicatedFiles(error)),
|
|
157
|
+
};
|
|
136
158
|
await writer.writeJson("graph.json", graph);
|
|
137
159
|
throw error;
|
|
138
160
|
}
|
|
@@ -146,13 +168,29 @@ export class CsrDevPatcher {
|
|
|
146
168
|
//? The entry files are already rewritten, so the worker would see them unchanged and patch a moved route table:
|
|
147
169
|
//? a graph whose entries name none makes it reload instead.
|
|
148
170
|
if (routesMoved) await writer.writeJson("graph.json", { ...graph, entries: {} });
|
|
171
|
+
if (onlyRoots)
|
|
172
|
+
for (const id of changed)
|
|
173
|
+
if (!graph.modules[id])
|
|
174
|
+
this.#delegated.set(id, {
|
|
175
|
+
hash: await CsrDevPaths.hashOf(paths.fileOf(id)),
|
|
176
|
+
before: JSON.stringify(graph.failed ?? null),
|
|
177
|
+
});
|
|
149
178
|
return this.#handBack(refusal, generation, context);
|
|
150
179
|
}
|
|
151
180
|
const compiled = result.modules;
|
|
152
181
|
const vendorJoined = compiled.some((module) => module.vendor && !graph.modules[module.id]);
|
|
153
182
|
//? Read before the merge: a new module's file is written after announcing, and one without a file is compiled now.
|
|
154
183
|
const code = await this.#code(state);
|
|
184
|
+
for (const id of [...orphans, ...this.#caseTwins(graph, compiled)]) {
|
|
185
|
+
delete graph.modules[id];
|
|
186
|
+
resolver.forget(paths.fileOf(id));
|
|
187
|
+
code?.modules.delete(id);
|
|
188
|
+
forgotten.push(id);
|
|
189
|
+
}
|
|
190
|
+
graph.pending = graph.pending.filter((id) => !orphans.includes(id));
|
|
155
191
|
this.#bundler.merge(graph, resolver, compiled);
|
|
192
|
+
if (!this.#resident) delete graph.failed;
|
|
193
|
+
for (const module of compiled) this.#delegated.delete(module.id);
|
|
156
194
|
graph.entries = entryIds;
|
|
157
195
|
for (const module of compiled) {
|
|
158
196
|
if (module.vendor || !code) continue;
|
|
@@ -169,6 +207,13 @@ export class CsrDevPatcher {
|
|
|
169
207
|
: constantId
|
|
170
208
|
? `${path.basename(constantId)} changed`
|
|
171
209
|
: undefined;
|
|
210
|
+
//? Only orphans left the graph: nothing a tab holds changed, so no patch goes out.
|
|
211
|
+
if (compiled.length === 0 && !reason) {
|
|
212
|
+
await writer.writeJson("graph.json", graph);
|
|
213
|
+
await writer.forgetModules(forgotten);
|
|
214
|
+
if (carried.length === 0) return { kind: "unchanged" };
|
|
215
|
+
return await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
|
|
216
|
+
}
|
|
172
217
|
const previous = state.manifest;
|
|
173
218
|
const next: CsrDevManifest = { ...previous, generation, entries: entryIds };
|
|
174
219
|
const update = (patchUrl?: string): CsrDevUpdate => ({
|
|
@@ -241,6 +286,108 @@ export class CsrDevPatcher {
|
|
|
241
286
|
return retried;
|
|
242
287
|
}
|
|
243
288
|
|
|
289
|
+
//? One Bun.build fails whole: a module that fails to compile and that nothing reaches any more (its last importer
|
|
290
|
+
//? dropped the import in this very save) leaves the graph, where it would fail every later save until fixed itself.
|
|
291
|
+
//? Everything else the roots stopped reaching goes with it, or an importer left behind would bring it back.
|
|
292
|
+
async #compileLeavingOrphans(
|
|
293
|
+
compile: (ids: string[]) => Promise<CsrDevCompileResult>,
|
|
294
|
+
graph: CsrDevGraph,
|
|
295
|
+
changed: Set<string>,
|
|
296
|
+
): Promise<{ result: CsrDevCompileResult; orphans: string[] }> {
|
|
297
|
+
const { paths } = this.#bundler;
|
|
298
|
+
try {
|
|
299
|
+
return { result: await compile([...changed]), orphans: [] };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const failing = new Set(CsrDevPatcher.#failingFiles(error).map((file) => paths.idOf(file)));
|
|
302
|
+
const broken = [...changed].filter((id) => failing.has(id) && graph.modules[id]);
|
|
303
|
+
if (broken.length === 0) throw error;
|
|
304
|
+
const rest = [...changed].filter((id) => !broken.includes(id));
|
|
305
|
+
const empty: CsrDevCompileResult = { modules: [], refusedVendors: [], unresolved: [] };
|
|
306
|
+
const result =
|
|
307
|
+
rest.length === 0
|
|
308
|
+
? empty
|
|
309
|
+
: await compile(rest).catch(() => {
|
|
310
|
+
throw error;
|
|
311
|
+
});
|
|
312
|
+
const modules = { ...graph.modules };
|
|
313
|
+
for (const module of result.modules)
|
|
314
|
+
modules[module.id] = {
|
|
315
|
+
vendor: module.vendor,
|
|
316
|
+
mtimeMs: module.mtimeMs,
|
|
317
|
+
hash: module.hash,
|
|
318
|
+
deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : paths.idOf(dep))),
|
|
319
|
+
};
|
|
320
|
+
const reached = await this.#bundler.reachable({ ...graph, modules });
|
|
321
|
+
if (broken.some((id) => reached.has(id))) throw error;
|
|
322
|
+
const orphans = Object.keys(modules).filter((id) => !reached.has(id) && !modules[id]?.vendor);
|
|
323
|
+
return { result, orphans };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
//? A case-only rename the patcher never saw (made while CSR was unarmed) compiles under the disk's spelling: the old
|
|
328
|
+
//? id goes, or its stale mtime would compile it again on every save. Only where the disk folds case: on ext4 the two
|
|
329
|
+
//? spellings are two files.
|
|
330
|
+
#caseTwins(graph: CsrDevGraph, compiled: CsrDevCompileResult["modules"]): string[] {
|
|
331
|
+
const { paths } = this.#bundler;
|
|
332
|
+
const idsByFold = new Map<string, string[]>();
|
|
333
|
+
for (const id of Object.keys(graph.modules))
|
|
334
|
+
idsByFold.set(id.toLowerCase(), [...(idsByFold.get(id.toLowerCase()) ?? []), id]);
|
|
335
|
+
return compiled.flatMap((module) =>
|
|
336
|
+
(idsByFold.get(module.id.toLowerCase()) ?? []).filter(
|
|
337
|
+
(id) => id !== module.id && !graph.modules[id]?.vendor && !CsrDevPaths.isNamed(paths.fileOf(id)),
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
//? Their importers compile in the same round: one still importing the old spelling (valid on a disk that folds case)
|
|
343
|
+
//? would otherwise require a module app.js no longer defines.
|
|
344
|
+
async #withTwinImporters(
|
|
345
|
+
compile: (ids: string[]) => Promise<CsrDevCompileResult>,
|
|
346
|
+
graph: CsrDevGraph,
|
|
347
|
+
result: CsrDevCompileResult,
|
|
348
|
+
): Promise<CsrDevCompileResult> {
|
|
349
|
+
const twins = new Set(this.#caseTwins(graph, result.modules));
|
|
350
|
+
if (twins.size === 0) return result;
|
|
351
|
+
const compiledIds = new Set(result.modules.map((module) => module.id));
|
|
352
|
+
const importers = Object.entries(graph.modules)
|
|
353
|
+
.filter(([id, module]) => !module.vendor && !compiledIds.has(id) && !twins.has(id))
|
|
354
|
+
.filter(([, module]) => module.deps.some((dep) => twins.has(dep)))
|
|
355
|
+
.map(([id]) => id);
|
|
356
|
+
if (importers.length === 0) return result;
|
|
357
|
+
const more = await compile(importers);
|
|
358
|
+
return {
|
|
359
|
+
modules: [...result.modules, ...more.modules],
|
|
360
|
+
refusedVendors: [...result.refusedVendors, ...more.refusedVendors],
|
|
361
|
+
unresolved: [...result.unresolved, ...more.unresolved],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//? Where it failed, and the files its messages name: Bun puts a missing export at the importer and names the module
|
|
366
|
+
//? that lacks it only in the text ("No matching export in \"../ui/C.tsx\""), relative to the working directory.
|
|
367
|
+
static #implicatedFiles(error: unknown): string[] {
|
|
368
|
+
const failing = CsrDevPatcher.#failingFiles(error);
|
|
369
|
+
const messages: unknown[] = error instanceof AggregateError ? error.errors : [];
|
|
370
|
+
const named = messages.flatMap((message) => {
|
|
371
|
+
const text = String((message as { message?: unknown }).message ?? "");
|
|
372
|
+
const bases = [process.cwd(), ...failing.map((file) => path.dirname(file))];
|
|
373
|
+
return [...text.matchAll(/"([^"\n]+)"/g)].flatMap(([, quoted = ""]) => {
|
|
374
|
+
const found = bases
|
|
375
|
+
.map((base) => path.resolve(base, quoted))
|
|
376
|
+
.find((file) => CsrDevPaths.isScript(file) && fs.existsSync(file));
|
|
377
|
+
return found ? [CsrDevPaths.realpath(found)] : [];
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
return [...new Set([...failing, ...named])];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
static #failingFiles(error: unknown): string[] {
|
|
384
|
+
const messages: unknown[] = error instanceof AggregateError ? error.errors : [];
|
|
385
|
+
return messages
|
|
386
|
+
.map((message) => (message as { position?: { file?: unknown } | null }).position?.file)
|
|
387
|
+
.filter((file): file is string => typeof file === "string" && path.isAbsolute(file))
|
|
388
|
+
.map((file) => CsrDevPaths.realpath(file));
|
|
389
|
+
}
|
|
390
|
+
|
|
244
391
|
//? A pending root outside the graph (a route's entry that failed to compile) is retried while a route still reaches
|
|
245
392
|
//? it; one no route names any more (the page stopped importing it) leaves the pending list instead.
|
|
246
393
|
async #carriedRoots(graph: CsrDevGraph, changed: Set<string>, present: (file: string) => boolean): Promise<string[]> {
|
|
@@ -251,11 +398,35 @@ export class CsrDevPatcher {
|
|
|
251
398
|
outside.map((id) => paths.fileOf(id)),
|
|
252
399
|
graph,
|
|
253
400
|
);
|
|
254
|
-
const
|
|
255
|
-
graph.pending = graph.pending.filter((id) => graph.modules[id] ||
|
|
401
|
+
const kept = outside.filter((id) => wanted.has(paths.fileOf(id)));
|
|
402
|
+
graph.pending = graph.pending.filter((id) => graph.modules[id] || kept.includes(id));
|
|
403
|
+
const carried: string[] = [];
|
|
404
|
+
for (const id of kept) if (!(await this.#parked(graph, id))) carried.push(id);
|
|
256
405
|
return carried;
|
|
257
406
|
}
|
|
258
407
|
|
|
408
|
+
//? The worker's failure is read from the graph it wrote, once, and only a round that built this root. Until that
|
|
409
|
+
//? worker writes, the record on disk is the one from before the hand-over, and the root waits for it.
|
|
410
|
+
async #parked(graph: CsrDevGraph, id: string): Promise<boolean> {
|
|
411
|
+
const { paths } = this.#bundler;
|
|
412
|
+
const handedOver = this.#delegated.get(id);
|
|
413
|
+
if (!handedOver || handedOver.hash !== (await CsrDevPaths.hashOf(paths.fileOf(id)))) return false;
|
|
414
|
+
if (!handedOver.failed) {
|
|
415
|
+
if (JSON.stringify(graph.failed ?? null) === handedOver.before) return true;
|
|
416
|
+
if (graph.failed?.roots.includes(id)) handedOver.failed = graph.failed.files;
|
|
417
|
+
}
|
|
418
|
+
if (!handedOver.failed) return false;
|
|
419
|
+
for (const [failedId, hash] of Object.entries(handedOver.failed))
|
|
420
|
+
if ((await CsrDevPaths.hashOf(paths.fileOf(failedId))) !== hash) return false;
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
static async #hashesOf(paths: CsrDevPaths, files: string[]): Promise<Record<string, string>> {
|
|
425
|
+
return Object.fromEntries(
|
|
426
|
+
await Promise.all(files.map(async (file) => [paths.idOf(file), await CsrDevPaths.hashOf(file)] as const)),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
259
430
|
// Missing factory files are recompiled, not read: a registry whose graph outlived them would fail every read. The
|
|
260
431
|
// resident patcher holds every factory it has read or written, so only a registry just read from disk is stat'ed.
|
|
261
432
|
#withoutFactory({ graph, code }: CsrDevPatcherState, present: (file: string) => boolean): string[] {
|
|
@@ -268,7 +439,8 @@ export class CsrDevPatcher {
|
|
|
268
439
|
|
|
269
440
|
//? A case-only rename is the one move `existsSync` misses (`ui/card.tsx` still exists after a rename to
|
|
270
441
|
//? `ui/Card.tsx` on APFS and NTFS), and it reaches the patcher as a save of both names: the realpath of every
|
|
271
|
-
//? module is paid for only then (2.2ms a save on a 385-module registry, against 0.3ms).
|
|
442
|
+
//? module is paid for only then (2.2ms a save on a 385-module registry, against 0.3ms). One it never saw goes with
|
|
443
|
+
//? the compile that meets the new spelling (#caseTwins).
|
|
272
444
|
static #presence(changedFiles: string[]): (file: string) => boolean {
|
|
273
445
|
const caseMoved = changedFiles.some((file) => fs.existsSync(file) && !CsrDevPaths.isNamed(file));
|
|
274
446
|
return caseMoved ? (file) => CsrDevPaths.isNamed(file) : (file) => fs.existsSync(file);
|
|
@@ -327,7 +499,9 @@ export class CsrDevPatcher {
|
|
|
327
499
|
if (changed.has(id)) continue;
|
|
328
500
|
for (const dep of module.deps) {
|
|
329
501
|
if (graph.modules[dep] || CsrDevPaths.isStub(dep)) continue;
|
|
330
|
-
|
|
502
|
+
//? Named, not only present: a dep a case rename replaced still exists under its old spelling.
|
|
503
|
+
const file = paths.fileOf(dep);
|
|
504
|
+
if (present(file) && CsrDevPaths.isNamed(file)) changed.add(dep);
|
|
331
505
|
else if (!module.vendor) changed.add(id);
|
|
332
506
|
}
|
|
333
507
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
export class CsrDevPaths {
|
|
5
5
|
static readonly modulePrefix = "akan-module:";
|
|
6
6
|
static readonly stubPrefix = "stub:";
|
|
7
|
+
static readonly emptyStubPrefix = "stub:empty:";
|
|
7
8
|
static readonly vendorPrefix = "vendor:";
|
|
8
9
|
readonly root: string;
|
|
9
10
|
|
|
@@ -43,16 +44,18 @@ export class CsrDevPaths {
|
|
|
43
44
|
return (await source.exists()) ? Bun.hash(await source.arrayBuffer()).toString(36) : "";
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
//? `.native`: on Windows Bun's `fs.realpathSync` keeps the spelling it was given, where the native call returns the
|
|
48
|
+
//? disk's (as macOS's plain call does), and a module id must not depend on which spelling reached it first.
|
|
46
49
|
static realpath(file: string): string {
|
|
47
50
|
// A deleted file keeps its own path, so its id still matches the graph entry it leaves behind.
|
|
48
|
-
return fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file);
|
|
51
|
+
return fs.existsSync(file) ? fs.realpathSync.native(file) : path.resolve(file);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
//? Present under this very name: on a case-insensitive disk (APFS, NTFS) `ui/card.tsx` still exists after a rename to
|
|
52
55
|
//? `ui/Card.tsx`, but the compiler names the module by the disk's spelling, so the old id is gone.
|
|
53
56
|
static isNamed(file: string): boolean {
|
|
54
57
|
try {
|
|
55
|
-
return fs.realpathSync(file) === file;
|
|
58
|
+
return fs.realpathSync.native(file) === file;
|
|
56
59
|
} catch {
|
|
57
60
|
// Missing is an answer here.
|
|
58
61
|
return false;
|
|
@@ -71,15 +74,22 @@ export class CsrDevPaths {
|
|
|
71
74
|
return CsrDevPaths.#asFile(base, kind) ?? CsrDevPaths.#asFolder(base, kind);
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
//? A specifier ending in `/`, `.` or `..` names the folder alone, for Bun too: `./Foo/` never takes a `Foo.tsx`.
|
|
78
|
+
static resolveRelative(dir: string, specifier: string, kind: "import" | "require" = "import"): string | null {
|
|
79
|
+
const base = path.resolve(dir, specifier);
|
|
80
|
+
const folder = /(^|[\\/])\.\.?$|[\\/]$/.test(specifier);
|
|
81
|
+
return folder ? CsrDevPaths.#asFolder(base, kind) : CsrDevPaths.resolveOnDisk(base, kind);
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
static #asFile(base: string, kind: "import" | "require"): string | null {
|
|
75
85
|
const isFile = (candidate: string) => fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
76
86
|
return [base, ...CsrDevPaths.#extensions[kind].map((extension) => `${base}${extension}`)].find(isFile) ?? null;
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
//? A folder's package.json names its entry as a browser build reads it: `browser` (the string form), then `module
|
|
80
|
-
//? then `main`, which may itself name a folder; without one, its `index`.
|
|
89
|
+
//? A folder's package.json names its entry as a browser build reads it: `browser` (the string form), then `module`
|
|
90
|
+
//? (an import only), then `main`, which may itself name a folder; without one, its `index`.
|
|
81
91
|
static #asFolder(dir: string, kind: "import" | "require"): string | null {
|
|
82
|
-
const entry = CsrDevPaths.#packageEntryOf(dir);
|
|
92
|
+
const entry = CsrDevPaths.#packageEntryOf(dir, kind);
|
|
83
93
|
const named = entry
|
|
84
94
|
? (CsrDevPaths.#asFile(path.join(dir, entry), kind) ?? CsrDevPaths.#indexOf(path.join(dir, entry), kind))
|
|
85
95
|
: null;
|
|
@@ -90,10 +100,11 @@ export class CsrDevPaths {
|
|
|
90
100
|
return CsrDevPaths.#asFile(path.join(dir, "index"), kind);
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
static #packageEntryOf(dir: string): string | null {
|
|
103
|
+
static #packageEntryOf(dir: string, kind: "import" | "require"): string | null {
|
|
94
104
|
try {
|
|
95
105
|
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as Record<string, unknown>;
|
|
96
|
-
const
|
|
106
|
+
const fields = kind === "require" ? [pkg.browser, pkg.main] : [pkg.browser, pkg.module, pkg.main];
|
|
107
|
+
const entry = fields.find((field) => typeof field === "string");
|
|
97
108
|
return typeof entry === "string" ? entry : null;
|
|
98
109
|
} catch {
|
|
99
110
|
// No package.json, or one that names no entry: the folder's index answers.
|
|
@@ -101,10 +112,20 @@ export class CsrDevPaths {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
//? The package folder's ctime: Bun installs by cloning (macOS) or hard-linking (Linux) its cache, so a file keeps the
|
|
116
|
+
//? cache's mtime, and a hard link's ctime moves whenever another project links the same version; the folder is made
|
|
117
|
+
//? by this project's install alone.
|
|
118
|
+
/** A bare specifier whose package landed in a `node_modules` at or above `from` after `since`. */
|
|
119
|
+
static installedSince(specifier: string, from: string, since: number): boolean {
|
|
120
|
+
const [first = "", second = ""] = specifier.split("/");
|
|
121
|
+
const name = first.startsWith("@") ? `${first}/${second}` : first;
|
|
122
|
+
if (!name || name.startsWith(".")) return false;
|
|
123
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
124
|
+
const pkgDir = path.join(dir, "node_modules", name);
|
|
125
|
+
if (fs.existsSync(path.join(pkgDir, "package.json")))
|
|
126
|
+
return (fs.statSync(pkgDir, { throwIfNoEntry: false })?.ctimeMs ?? 0) > since;
|
|
127
|
+
if (path.dirname(dir) === dir) return false;
|
|
128
|
+
}
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
static tryResolve(specifier: string, from: string): string | null {
|
|
@@ -126,10 +126,29 @@ describe("CsrDevPaths.resolveOnDisk", () => {
|
|
|
126
126
|
"dirmain/index.js",
|
|
127
127
|
"shadow/Foo/index.tsx",
|
|
128
128
|
"shadow/Page.tsx",
|
|
129
|
+
"sibling/x.ts",
|
|
130
|
+
"sibling/Page.tsx",
|
|
131
|
+
"slash/Foo/index.ts",
|
|
132
|
+
"slash/Foo.ts",
|
|
133
|
+
"slash/Page.tsx",
|
|
134
|
+
"req/index.js",
|
|
135
|
+
"req/main.js",
|
|
136
|
+
"req/m.js",
|
|
137
|
+
"modonly/index.js",
|
|
138
|
+
"modonly/m.js",
|
|
139
|
+
"dot/Foo.tsx",
|
|
140
|
+
"dot/Foo/index.tsx",
|
|
141
|
+
"dot/Foo/Sub/x.ts",
|
|
142
|
+
"bmap/lib/server.ts",
|
|
143
|
+
"bmap/lib/client.ts",
|
|
144
|
+
"bmap/entry.ts",
|
|
129
145
|
])
|
|
130
146
|
await Bun.write(at(relative), "export {};\n");
|
|
131
147
|
await Bun.write(at("fields/package.json"), '{ "browser": "b.js", "module": "m.js", "main": "main.js" }');
|
|
132
148
|
await Bun.write(at("dirmain/package.json"), '{ "main": "lib" }');
|
|
149
|
+
await Bun.write(at("req/package.json"), '{ "module": "m.js", "main": "main.js" }');
|
|
150
|
+
await Bun.write(at("modonly/package.json"), '{ "module": "m.js" }');
|
|
151
|
+
await Bun.write(at("bmap/package.json"), '{ "browser": { "./lib/server.ts": "./lib/client.ts" } }');
|
|
133
152
|
});
|
|
134
153
|
|
|
135
154
|
afterAll(async () => {
|
|
@@ -146,7 +165,49 @@ describe("CsrDevPaths.resolveOnDisk", () => {
|
|
|
146
165
|
expect(CsrDevPaths.resolveOnDisk(at("dirmain"))).toBe(at("dirmain/lib/index.js"));
|
|
147
166
|
});
|
|
148
167
|
|
|
149
|
-
test("a
|
|
168
|
+
test("a require call reads no module field: main when there is one, otherwise the index", () => {
|
|
169
|
+
expect(CsrDevPaths.resolveOnDisk(at("req"))).toBe(at("req/m.js"));
|
|
170
|
+
expect(CsrDevPaths.resolveOnDisk(at("req"), "require")).toBe(at("req/main.js"));
|
|
171
|
+
expect(CsrDevPaths.resolveOnDisk(at("modonly"), "require")).toBe(at("modonly/index.js"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a bare import Bun cannot resolve here goes to a worker only when its package landed after this process began", async () => {
|
|
175
|
+
//? A package Bun's resolver misses in this process: it keeps node_modules listings, so one added by `bun add` while
|
|
176
|
+
//? the builder runs looks like this until a fresh process reads it.
|
|
177
|
+
await Bun.write(at("inst/node_modules/half/package.json"), '{ "name": "half", "main": "missing.js" }');
|
|
178
|
+
await Bun.write(at("inst/src/a.ts"), 'import "half";\n');
|
|
179
|
+
const started = (startedAt: number) =>
|
|
180
|
+
new CsrDevResolver({ paths: new CsrDevPaths(root), context, entryFiles: [], startedAt });
|
|
181
|
+
const before = started(Date.now() - 60_000);
|
|
182
|
+
expect(before.resolve(at("inst/src/a.ts"), "half")).toBeNull();
|
|
183
|
+
expect(before.resolve(at("inst/src/a.ts"), "half/formatt")).toBeNull();
|
|
184
|
+
expect(() => before.resolve(at("inst/src/a.ts"), "nowhere-to-be-found")).toThrow("cannot resolve");
|
|
185
|
+
//? Installed before the builder started: its listing is Bun's, so a miss is a typo or a half-typed import.
|
|
186
|
+
const after = started(Date.now() + 60_000);
|
|
187
|
+
expect(() => after.resolve(at("inst/src/a.ts"), "half/formatt")).toThrow("cannot resolve");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('"." and ".." name the folder alone, as a trailing slash does', () => {
|
|
191
|
+
expect(CsrDevPaths.resolveRelative(at("dot/Foo"), ".")).toBe(at("dot/Foo/index.tsx"));
|
|
192
|
+
expect(CsrDevPaths.resolveRelative(at("dot/Foo/Sub"), "..")).toBe(at("dot/Foo/index.tsx"));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("app code under a package.json that maps files with a browser object keeps the recorded answer", () => {
|
|
196
|
+
const resolver = new CsrDevResolver({
|
|
197
|
+
paths: new CsrDevPaths(root),
|
|
198
|
+
context,
|
|
199
|
+
entryFiles: [],
|
|
200
|
+
resolution: { "bmap/entry.ts": { "./lib/server": "bmap/lib/client.ts" } },
|
|
201
|
+
});
|
|
202
|
+
expect(resolver.resolve(at("bmap/entry.ts"), "./lib/server")).toBe(at("bmap/lib/client.ts"));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a specifier ending in a slash names the folder alone", () => {
|
|
206
|
+
expect(CsrDevPaths.resolveRelative(at("slash"), "./Foo/")).toBe(at("slash/Foo/index.ts"));
|
|
207
|
+
expect(CsrDevPaths.resolveRelative(at("slash"), "./Foo")).toBe(at("slash/Foo.ts"));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a recorded relative resolution stands while the disk resolves it the same way", async () => {
|
|
150
211
|
const paths = new CsrDevPaths(root);
|
|
151
212
|
const resolver = new CsrDevResolver({
|
|
152
213
|
paths,
|
|
@@ -157,9 +218,22 @@ describe("CsrDevPaths.resolveOnDisk", () => {
|
|
|
157
218
|
"pair/entry.ts": { "./x": "pair/x.js" },
|
|
158
219
|
},
|
|
159
220
|
});
|
|
160
|
-
expect(resolver.resolve(at("pair/entry.ts"), "./x")).toBe(at("pair/x.js"));
|
|
221
|
+
expect(resolver.resolve(at("pair/entry.ts"), "./x", "require")).toBe(at("pair/x.js"));
|
|
222
|
+
expect(resolver.resolve(at("pair/entry.ts"), "./x")).toBe(at("pair/x.mjs"));
|
|
161
223
|
expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo/index.tsx"));
|
|
162
224
|
await Bun.write(at("shadow/Foo.tsx"), "export {};\n");
|
|
163
225
|
expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo.tsx"));
|
|
164
226
|
});
|
|
227
|
+
|
|
228
|
+
test("a sibling with a stronger extension takes over a recorded import", async () => {
|
|
229
|
+
const resolver = new CsrDevResolver({
|
|
230
|
+
paths: new CsrDevPaths(root),
|
|
231
|
+
context,
|
|
232
|
+
entryFiles: [],
|
|
233
|
+
resolution: { "sibling/Page.tsx": { "./x": "sibling/x.ts" } },
|
|
234
|
+
});
|
|
235
|
+
expect(resolver.resolve(at("sibling/Page.tsx"), "./x")).toBe(at("sibling/x.ts"));
|
|
236
|
+
await Bun.write(at("sibling/x.tsx"), "export {};\n");
|
|
237
|
+
expect(resolver.resolve(at("sibling/Page.tsx"), "./x")).toBe(at("sibling/x.tsx"));
|
|
238
|
+
});
|
|
165
239
|
});
|