@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26
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 +44 -26
- package/akanApp/akanApp.host.ts +113 -23
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +71 -1
- package/akanApp/devHostPolicy.ts +29 -4
- package/cloud/globalConfig.ts +3 -17
- package/csrE2e/csrE2eHarness.fixture.ts +66 -11
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +117 -70
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +158 -24
- package/frontendBuild/csrDevBundler.ts +44 -205
- package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +56 -10
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +81 -1
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +119 -18
- package/frontendBuild/csrDevTypes.ts +16 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/incrementalBuilder/buildBatch.proc.ts +94 -16
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
- package/integration/devStability.integration.test.ts +25 -7
- package/package.json +2 -2
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
6
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
7
|
+
import type { CsrDevContext } from "./csrDevTypes";
|
|
8
|
+
|
|
9
|
+
const context: CsrDevContext = {
|
|
10
|
+
pageEntries: [],
|
|
11
|
+
basePaths: [],
|
|
12
|
+
htmlBasePaths: [],
|
|
13
|
+
define: {},
|
|
14
|
+
optimizeImports: [],
|
|
15
|
+
configKey: "key",
|
|
16
|
+
refreshFile: null,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("CsrDevResolver before its resolution build", () => {
|
|
20
|
+
let root: string;
|
|
21
|
+
let paths: CsrDevPaths;
|
|
22
|
+
const file = (relative: string) => path.join(paths.root, relative);
|
|
23
|
+
const resolver = (runtimeResolved: Record<string, string[]> = {}) =>
|
|
24
|
+
new CsrDevResolver({
|
|
25
|
+
paths,
|
|
26
|
+
context,
|
|
27
|
+
entryFiles: [],
|
|
28
|
+
resolution: { "node_modules/pkg/a.js": { dep: "node_modules/dep/index.js" } },
|
|
29
|
+
runtimeResolved,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-resolver-"));
|
|
34
|
+
for (const relative of [
|
|
35
|
+
"apps/demo/ui/Card.tsx",
|
|
36
|
+
"node_modules/pkg/a.js",
|
|
37
|
+
"node_modules/pkg/b.js",
|
|
38
|
+
"node_modules/dep/index.js",
|
|
39
|
+
"node_modules/fresh/index.js",
|
|
40
|
+
])
|
|
41
|
+
await Bun.write(path.join(root, relative), "export {};\n");
|
|
42
|
+
await Bun.write(path.join(root, "node_modules/fresh/package.json"), '{ "name": "fresh", "main": "index.js" }');
|
|
43
|
+
paths = new CsrDevPaths(root);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await rm(root, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a file of the same package reuses what the browser build resolved for a sibling", () => {
|
|
51
|
+
expect(resolver().resolve(file("node_modules/pkg/b.js"), "dep")).toBe(file("node_modules/dep/index.js"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("but not what Bun's runtime resolver found for it: that ignores the browser condition", () => {
|
|
55
|
+
const withFallback = resolver({ "node_modules/pkg/a.js": ["dep"] });
|
|
56
|
+
expect(withFallback.resolve(file("node_modules/pkg/b.js"), "dep")).toBeNull();
|
|
57
|
+
expect(withFallback.serializeRuntimeResolved()).toEqual({ "node_modules/pkg/a.js": ["dep"] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the resolution build clears a runtime resolution it records itself, so a sibling may reuse it again", async () => {
|
|
61
|
+
await Bun.write(
|
|
62
|
+
file("apps/demo/ui/Fresh.tsx"),
|
|
63
|
+
'import * as fresh from "fresh";\nexport const Fresh = () => fresh;\n',
|
|
64
|
+
);
|
|
65
|
+
const prepassed = new CsrDevResolver({
|
|
66
|
+
paths,
|
|
67
|
+
context,
|
|
68
|
+
entryFiles: [file("apps/demo/ui/Fresh.tsx")],
|
|
69
|
+
runtimeResolved: { "apps/demo/ui/Fresh.tsx": ["fresh"] },
|
|
70
|
+
});
|
|
71
|
+
await prepassed.prepass();
|
|
72
|
+
expect(prepassed.serializeRuntimeResolved()).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a relative import follows a file moved into a folder or renamed, which Bun's resolver cache does not", async () => {
|
|
76
|
+
const card = file("apps/demo/ui/Card.tsx");
|
|
77
|
+
await Bun.write(file("apps/demo/ui/Moved.tsx"), "export {};\n");
|
|
78
|
+
await Bun.write(file("apps/demo/ui/renamed.ts"), "export {};\n");
|
|
79
|
+
expect(CsrDevPaths.tryResolve("./Moved", path.dirname(card))).toBe(file("apps/demo/ui/Moved.tsx"));
|
|
80
|
+
expect(CsrDevPaths.tryResolve("./renamed", path.dirname(card))).toBe(file("apps/demo/ui/renamed.ts"));
|
|
81
|
+
await rm(file("apps/demo/ui/Moved.tsx"));
|
|
82
|
+
await Bun.write(file("apps/demo/ui/Moved/index.tsx"), "export {};\n");
|
|
83
|
+
await rm(file("apps/demo/ui/renamed.ts"));
|
|
84
|
+
await Bun.write(file("apps/demo/ui/renamed.tsx"), "export {};\n");
|
|
85
|
+
|
|
86
|
+
expect(resolver().resolve(card, "./Moved")).toBe(file("apps/demo/ui/Moved/index.tsx"));
|
|
87
|
+
expect(resolver().resolve(card, "./renamed")).toBe(file("apps/demo/ui/renamed.tsx"));
|
|
88
|
+
expect(() => resolver().resolve(card, "./Missing")).toThrow('cannot resolve "./Missing"');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a recorded target moved away since is reused by no sibling, nor by its importer, nor kept in the graph", () => {
|
|
92
|
+
const stale = new CsrDevResolver({
|
|
93
|
+
paths,
|
|
94
|
+
context,
|
|
95
|
+
entryFiles: [],
|
|
96
|
+
resolution: {
|
|
97
|
+
"node_modules/pkg/a.js": { dep: "node_modules/dep/old.js" },
|
|
98
|
+
"node_modules/pkg/b.js": { dep: "node_modules/dep/old.js" },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
expect(stale.resolve(file("node_modules/pkg/b.js"), "dep")).toBeNull();
|
|
102
|
+
expect(stale.serialize()).toEqual({ "node_modules/pkg/a.js": {}, "node_modules/pkg/b.js": {} });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a new package in the user's code asks for the resolution build, and a typo fails like a missing file", () => {
|
|
106
|
+
expect(resolver().resolve(file("apps/demo/ui/Card.tsx"), "fresh")).toBeNull();
|
|
107
|
+
expect(() => resolver().resolve(file("apps/demo/ui/Card.tsx"), "frseh")).toThrow('cannot resolve "frseh"');
|
|
108
|
+
expect(resolver().resolve(file("node_modules/pkg/b.js"), "frseh")).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("CsrDevPaths.resolveOnDisk", () => {
|
|
113
|
+
let root: string;
|
|
114
|
+
const at = (relative: string) => path.join(root, relative);
|
|
115
|
+
|
|
116
|
+
beforeAll(async () => {
|
|
117
|
+
root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-disk-")));
|
|
118
|
+
for (const relative of [
|
|
119
|
+
"pair/x.js",
|
|
120
|
+
"pair/x.mjs",
|
|
121
|
+
"fields/b.js",
|
|
122
|
+
"fields/m.js",
|
|
123
|
+
"fields/main.js",
|
|
124
|
+
"fields/index.js",
|
|
125
|
+
"dirmain/lib/index.js",
|
|
126
|
+
"dirmain/index.js",
|
|
127
|
+
"shadow/Foo/index.tsx",
|
|
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",
|
|
145
|
+
])
|
|
146
|
+
await Bun.write(at(relative), "export {};\n");
|
|
147
|
+
await Bun.write(at("fields/package.json"), '{ "browser": "b.js", "module": "m.js", "main": "main.js" }');
|
|
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" } }');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(async () => {
|
|
155
|
+
await rm(root, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("weighs extensions as Bun's browser build does, a require call apart from an import", () => {
|
|
159
|
+
expect(CsrDevPaths.resolveOnDisk(at("pair/x"))).toBe(at("pair/x.mjs"));
|
|
160
|
+
expect(CsrDevPaths.resolveOnDisk(at("pair/x"), "require")).toBe(at("pair/x.js"));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("enters a folder through browser, then module, then main (which may name a folder), then index", () => {
|
|
164
|
+
expect(CsrDevPaths.resolveOnDisk(at("fields"))).toBe(at("fields/b.js"));
|
|
165
|
+
expect(CsrDevPaths.resolveOnDisk(at("dirmain"))).toBe(at("dirmain/lib/index.js"));
|
|
166
|
+
});
|
|
167
|
+
|
|
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 () => {
|
|
211
|
+
const paths = new CsrDevPaths(root);
|
|
212
|
+
const resolver = new CsrDevResolver({
|
|
213
|
+
paths,
|
|
214
|
+
context,
|
|
215
|
+
entryFiles: [],
|
|
216
|
+
resolution: {
|
|
217
|
+
"shadow/Page.tsx": { "./Foo": "shadow/Foo/index.tsx" },
|
|
218
|
+
"pair/entry.ts": { "./x": "pair/x.js" },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
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"));
|
|
223
|
+
expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo/index.tsx"));
|
|
224
|
+
await Bun.write(at("shadow/Foo.tsx"), "export {};\n");
|
|
225
|
+
expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo.tsx"));
|
|
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
|
+
});
|
|
239
|
+
});
|
|
@@ -20,6 +20,10 @@ export interface CsrDevResolverOptions {
|
|
|
20
20
|
context: CsrDevContext;
|
|
21
21
|
entryFiles: string[];
|
|
22
22
|
resolution?: Record<string, Record<string, string>>;
|
|
23
|
+
/** Specifiers per importer id whose recorded resolution came from Bun's runtime resolver, not the browser build. */
|
|
24
|
+
runtimeResolved?: Record<string, string[]>;
|
|
25
|
+
/** When this process began: a package installed after it is one Bun's resolver here has not seen. */
|
|
26
|
+
startedAt?: number;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
//* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
|
|
@@ -31,12 +35,25 @@ export class CsrDevResolver {
|
|
|
31
35
|
readonly #entryFiles: string[];
|
|
32
36
|
readonly #resolution = new Map<string, Map<string, string>>();
|
|
33
37
|
readonly #fallbacks = new Set<string>();
|
|
38
|
+
readonly #runtimeResolved = new Map<string, Set<string>>();
|
|
39
|
+
readonly #browserMaps = new Map<string, boolean>();
|
|
40
|
+
readonly #startedAt: number;
|
|
34
41
|
#prepassDone = false;
|
|
35
42
|
|
|
36
|
-
constructor({
|
|
43
|
+
constructor({
|
|
44
|
+
paths,
|
|
45
|
+
context,
|
|
46
|
+
entryFiles,
|
|
47
|
+
resolution = {},
|
|
48
|
+
runtimeResolved = {},
|
|
49
|
+
startedAt = Date.now() - process.uptime() * 1000,
|
|
50
|
+
}: CsrDevResolverOptions) {
|
|
37
51
|
this.#paths = paths;
|
|
52
|
+
this.#startedAt = startedAt;
|
|
38
53
|
this.#context = context;
|
|
39
54
|
this.#entryFiles = entryFiles;
|
|
55
|
+
for (const [importer, specifiers] of Object.entries(runtimeResolved))
|
|
56
|
+
this.#runtimeResolved.set(paths.fileOf(importer), new Set(specifiers));
|
|
40
57
|
for (const [importer, bySpecifier] of Object.entries(resolution))
|
|
41
58
|
this.#resolution.set(
|
|
42
59
|
paths.fileOf(importer),
|
|
@@ -59,9 +76,10 @@ export class CsrDevResolver {
|
|
|
59
76
|
|
|
60
77
|
async prepass(): Promise<void> {
|
|
61
78
|
const result = await Bun.build({
|
|
62
|
-
entrypoints: [...this.#entryFiles, this.#context.refreshFile],
|
|
79
|
+
entrypoints: [...this.#entryFiles, ...(this.#context.refreshFile ? [this.#context.refreshFile] : [])],
|
|
63
80
|
target: "browser",
|
|
64
81
|
metafile: true,
|
|
82
|
+
external: [...(this.#context.externals ?? [])],
|
|
65
83
|
env: "AKAN_PUBLIC_*",
|
|
66
84
|
define: this.#context.define,
|
|
67
85
|
optimizeImports: this.#context.optimizeImports,
|
|
@@ -78,23 +96,39 @@ export class CsrDevResolver {
|
|
|
78
96
|
continue;
|
|
79
97
|
}
|
|
80
98
|
const target = path.resolve(record.path);
|
|
81
|
-
if (fs.existsSync(target))
|
|
99
|
+
if (!fs.existsSync(target)) continue;
|
|
100
|
+
this.#remember(importer, record.original, CsrDevPaths.realpath(target));
|
|
101
|
+
this.#runtimeResolved.get(importer)?.delete(record.original);
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
104
|
this.#prepassDone = true;
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
resolve(importer: string, specifier: string): string | null {
|
|
107
|
+
resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
|
|
88
108
|
if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
|
|
89
109
|
const known = this.#resolution.get(importer)?.get(specifier);
|
|
90
|
-
const usable = known && (
|
|
91
|
-
const target = usable ?? this.#resolveUnknown(importer, specifier);
|
|
110
|
+
const usable = known && this.#stillAnswers(importer, specifier, known, kind) ? known : null;
|
|
111
|
+
const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
|
|
92
112
|
if (target === null || CsrDevPaths.isStub(target)) return target;
|
|
93
113
|
if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
|
|
94
114
|
if (!usable) this.#remember(importer, specifier, target);
|
|
95
115
|
return target;
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
//? A relative record in app code stands while the disk still resolves it to the same file, as Bun would: a sibling
|
|
119
|
+
//? with a stronger extension (`x.tsx` beside `x.ts`), a file beside the folder it went into, or a record a CommonJS
|
|
120
|
+
//? re-export folded onto another file (`./a` → `x.js`) resolves anew. A package's files, and app code under a
|
|
121
|
+
//? package.json that maps files with a `browser` object, keep the prepass's answer, which a disk lookup would only
|
|
122
|
+
//? approximate.
|
|
123
|
+
#stillAnswers(importer: string, specifier: string, known: string, kind: "import" | "require"): boolean {
|
|
124
|
+
if (CsrDevPaths.isStub(known)) return true;
|
|
125
|
+
if (!fs.existsSync(known)) return false;
|
|
126
|
+
if (!specifier.startsWith(".") || CsrDevPaths.isVendorFile(importer)) return true;
|
|
127
|
+
if (this.#hasBrowserMap(path.dirname(importer))) return true;
|
|
128
|
+
const onDisk = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
|
|
129
|
+
return onDisk !== null && CsrDevPaths.realpath(onDisk) === known;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
isRegistryModule(file: string): boolean {
|
|
99
133
|
if (!CsrDevPaths.isScript(file)) return false;
|
|
100
134
|
const packageName = CsrDevResolver.#packageNameOf(file);
|
|
@@ -105,42 +139,109 @@ export class CsrDevResolver {
|
|
|
105
139
|
});
|
|
106
140
|
}
|
|
107
141
|
|
|
142
|
+
#hasBrowserMap(dir: string): boolean {
|
|
143
|
+
const cached = this.#browserMaps.get(dir);
|
|
144
|
+
if (cached !== undefined) return cached;
|
|
145
|
+
const pkgFile = path.join(dir, "package.json");
|
|
146
|
+
const parent = path.dirname(dir);
|
|
147
|
+
let mapped: boolean;
|
|
148
|
+
if (fs.existsSync(pkgFile)) {
|
|
149
|
+
try {
|
|
150
|
+
const browser = (JSON.parse(fs.readFileSync(pkgFile, "utf8")) as { browser?: unknown }).browser;
|
|
151
|
+
mapped = typeof browser === "object" && browser !== null;
|
|
152
|
+
} catch {
|
|
153
|
+
// An unreadable package.json maps nothing; Bun's build fails on it anyway.
|
|
154
|
+
mapped = false;
|
|
155
|
+
}
|
|
156
|
+
} else mapped = parent !== dir && this.#hasBrowserMap(parent);
|
|
157
|
+
this.#browserMaps.set(dir, mapped);
|
|
158
|
+
return mapped;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A module that left the graph takes its records with it. */
|
|
162
|
+
forget(importer: string): void {
|
|
163
|
+
this.#resolution.delete(importer);
|
|
164
|
+
this.#runtimeResolved.delete(importer);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
serializeRuntimeResolved(): Record<string, string[]> {
|
|
168
|
+
return Object.fromEntries(
|
|
169
|
+
[...this.#runtimeResolved]
|
|
170
|
+
.filter(([, specifiers]) => specifiers.size > 0)
|
|
171
|
+
.map(([importer, specifiers]) => [this.#paths.idOf(importer), [...specifiers].sort()]),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A target moved away is left out, so the graph does not carry it past a builder restart.
|
|
108
176
|
serialize(): Record<string, Record<string, string>> {
|
|
109
177
|
return Object.fromEntries(
|
|
110
178
|
[...this.#resolution].map(([importer, bySpecifier]) => [
|
|
111
179
|
this.#paths.idOf(importer),
|
|
112
180
|
Object.fromEntries(
|
|
113
|
-
[...bySpecifier]
|
|
114
|
-
|
|
115
|
-
CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target),
|
|
116
|
-
]),
|
|
181
|
+
[...bySpecifier]
|
|
182
|
+
.filter(([, target]) => CsrDevPaths.isStub(target) || fs.existsSync(target))
|
|
183
|
+
.map(([specifier, target]) => [specifier, CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target)]),
|
|
117
184
|
),
|
|
118
185
|
]),
|
|
119
186
|
);
|
|
120
187
|
}
|
|
121
188
|
|
|
122
|
-
|
|
189
|
+
//? Bun.resolveSync keeps what it found per directory until a build walks that directory again, so in the resident
|
|
190
|
+
//? builder it may hand back a file moved away since (`Foo.tsx` → `Foo/index.tsx`, `.ts` → `.tsx`). A relative import
|
|
191
|
+
//? is therefore looked up on the disk as it is now, and a bare one it names a missing file for goes to a fresh worker.
|
|
192
|
+
#resolveUnknown(importer: string, specifier: string, kind: "import" | "require"): string | null {
|
|
123
193
|
if (specifier.startsWith("node:")) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
124
194
|
const relative = specifier.startsWith(".") || path.isAbsolute(specifier);
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
const
|
|
128
|
-
|
|
195
|
+
const cannotResolve = () => new Error(`[csr-dev] cannot resolve "${specifier}" from ${this.#paths.idOf(importer)}`);
|
|
196
|
+
if (relative) {
|
|
197
|
+
const onDisk =
|
|
198
|
+
CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind) ??
|
|
199
|
+
CsrDevResolver.#onDisk(CsrDevPaths.tryResolve(specifier, path.dirname(importer)));
|
|
200
|
+
if (!onDisk) throw cannotResolve();
|
|
201
|
+
return CsrDevPaths.realpath(onDisk);
|
|
129
202
|
}
|
|
203
|
+
//? Ahead of the prepass too: a new file's bare imports (`akanjs/ui`, `@apps/<app>/client`) are ones its package
|
|
204
|
+
//? already resolved, and asking for the prepass would hand every such save to a build worker.
|
|
205
|
+
const sibling = this.#resolvedBySibling(importer, specifier);
|
|
206
|
+
if (sibling) return sibling;
|
|
130
207
|
const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
|
|
131
|
-
|
|
208
|
+
const moved = resolved !== null && path.isAbsolute(resolved) && !fs.existsSync(resolved);
|
|
209
|
+
//? Bun keeps a process's node_modules listings: a package installed while the builder runs (`bun add`) resolves only
|
|
210
|
+
//? in a fresh process, so one installed since this process began goes to a worker. Any other miss, a subpath typo
|
|
211
|
+
//? of a package installed long ago included, fails here as the worker would.
|
|
212
|
+
const installed =
|
|
213
|
+
resolved === null && CsrDevPaths.installedSince(specifier, path.dirname(importer), this.#startedAt);
|
|
214
|
+
if (!this.#prepassDone || moved) {
|
|
215
|
+
//? A specifier no resolver finds in the user's own code is a typo, failed here like a missing relative file: the
|
|
216
|
+
//? resolution build a worker would run for it fails the same way. A package's `browser` field may map one away.
|
|
217
|
+
if (resolved === null && !installed && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (!resolved) {
|
|
221
|
+
if (installed) return null;
|
|
222
|
+
throw cannotResolve();
|
|
223
|
+
}
|
|
132
224
|
if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
133
|
-
|
|
225
|
+
this.#fallbacks.add(specifier);
|
|
226
|
+
this.#runtimeResolved.set(importer, (this.#runtimeResolved.get(importer) ?? new Set<string>()).add(specifier));
|
|
134
227
|
return CsrDevPaths.realpath(resolved);
|
|
135
228
|
}
|
|
136
229
|
|
|
230
|
+
static #onDisk(resolved: string | null): string | null {
|
|
231
|
+
return resolved && path.isAbsolute(resolved) && fs.existsSync(resolved) ? resolved : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
137
234
|
//? The browser build tree-shakes the unused re-exports of a side-effect-free barrel, so their imports have no
|
|
138
235
|
//? recorded resolution; a file of the same package resolves a bare specifier to the same target, conditions included.
|
|
236
|
+
//? Not one the runtime resolver found (it ignores the `browser` condition the prepass exists to honour), not the
|
|
237
|
+
//? importer's own record, and not a target moved away since.
|
|
139
238
|
#resolvedBySibling(importer: string, specifier: string): string | null {
|
|
140
239
|
const scope = CsrDevResolver.#packageScopeOf(importer);
|
|
141
240
|
for (const [other, bySpecifier] of this.#resolution) {
|
|
142
241
|
const target = bySpecifier.get(specifier);
|
|
143
|
-
if (target
|
|
242
|
+
if (!target || other === importer || this.#runtimeResolved.get(other)?.has(specifier)) continue;
|
|
243
|
+
if (!CsrDevPaths.isStub(target) && !fs.existsSync(target)) continue;
|
|
244
|
+
if (CsrDevResolver.#packageScopeOf(other) === scope) return target;
|
|
144
245
|
}
|
|
145
246
|
return null;
|
|
146
247
|
}
|
|
@@ -15,7 +15,13 @@ export interface CsrDevGraph {
|
|
|
15
15
|
entries: Record<string, string>;
|
|
16
16
|
modules: Record<string, CsrDevGraphModule>;
|
|
17
17
|
resolution: Record<string, Record<string, string>>;
|
|
18
|
+
/** Specifiers per importer id `resolution` holds from Bun's runtime resolver rather than the browser build. */
|
|
19
|
+
runtimeResolved?: Record<string, string[]>;
|
|
18
20
|
pending: string[];
|
|
21
|
+
/** A build worker's failed round: the ids it built, and the files it failed in or named, by hash. */
|
|
22
|
+
failed?: { roots: string[]; files: Record<string, string> };
|
|
23
|
+
/** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
|
|
24
|
+
metadata?: string;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export interface CsrDevContext {
|
|
@@ -25,7 +31,10 @@ export interface CsrDevContext {
|
|
|
25
31
|
define: Record<string, string>;
|
|
26
32
|
optimizeImports: string[];
|
|
27
33
|
configKey: string;
|
|
28
|
-
|
|
34
|
+
/** Null when the page already loads the React Refresh runtime (an SSR page's import map). */
|
|
35
|
+
refreshFile: string | null;
|
|
36
|
+
/** Bare specifiers the page already loads (an SSR page's import map): required as `vendor:<specifier>`, not compiled. */
|
|
37
|
+
externals?: readonly string[];
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export interface CsrDevSharedHelpers {
|
|
@@ -44,3 +53,9 @@ export interface CsrDevCompiledModule {
|
|
|
44
53
|
mtimeMs: number;
|
|
45
54
|
hash: string;
|
|
46
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Module factories by id and helper definitions by hash, kept in memory so app.js is written without a pass over the disk. */
|
|
58
|
+
export interface CsrDevCode {
|
|
59
|
+
modules: Map<string, string>;
|
|
60
|
+
helpers: Map<string, string>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
|
+
|
|
4
|
+
//* The CSR entry file of each basePath's HTML, shared by the CSR artifact and the dev registry. Apart from both
|
|
5
|
+
//* builders so the resident dev builder names entries without loading the artifact builder's CSS compiler (tailwindcss).
|
|
6
|
+
export class CsrEntryFiles {
|
|
7
|
+
static entryFilename(basePath: string): string {
|
|
8
|
+
return `${basePath || "index"}.csr.tsx`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
12
|
+
return pageEntries.filter((entry) => {
|
|
13
|
+
const entryBasePath = CsrEntryFiles.basePathOfPageKey(entry.key, basePaths);
|
|
14
|
+
return entryBasePath === null || entryBasePath === basePath;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static basePathOfPageKey(pageKey: string, basePaths: string[]): string | null {
|
|
19
|
+
const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
|
|
20
|
+
const segments = normalized.split("/");
|
|
21
|
+
const firstPublicSegment = segments.find((segment) => segment !== "[lang]" && !/^\(.+\)$/.test(segment));
|
|
22
|
+
return firstPublicSegment && basePaths.includes(firstPublicSegment) ? firstPublicSegment : null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -5,6 +5,7 @@ import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
|
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
|
|
7
7
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
8
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
8
9
|
import { CssCandidateCache } from "./cssCandidateCache";
|
|
9
10
|
import { CssImportResolver } from "./cssImportResolver";
|
|
10
11
|
|
|
@@ -393,8 +394,5 @@ export function declaredCustomProperties(css: string): string[] {
|
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
export function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
396
|
-
|
|
397
|
-
const segments = normalized.split("/");
|
|
398
|
-
const firstPublicSegment = segments.find((segment) => segment !== "[lang]" && !/^\(.+\)$/.test(segment));
|
|
399
|
-
return firstPublicSegment && basePaths.includes(firstPublicSegment) ? firstPublicSegment : null;
|
|
397
|
+
return CsrEntryFiles.basePathOfPageKey(pageKey, basePaths);
|
|
400
398
|
}
|