@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.25
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 +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- 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 +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- 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.ts +51 -9
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +60 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +70 -18
- package/frontendBuild/csrDevTypes.ts +14 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -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 +91 -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 +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -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
|
@@ -20,6 +20,8 @@ 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[]>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
//* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
|
|
@@ -31,12 +33,15 @@ export class CsrDevResolver {
|
|
|
31
33
|
readonly #entryFiles: string[];
|
|
32
34
|
readonly #resolution = new Map<string, Map<string, string>>();
|
|
33
35
|
readonly #fallbacks = new Set<string>();
|
|
36
|
+
readonly #runtimeResolved = new Map<string, Set<string>>();
|
|
34
37
|
#prepassDone = false;
|
|
35
38
|
|
|
36
|
-
constructor({ paths, context, entryFiles, resolution = {} }: CsrDevResolverOptions) {
|
|
39
|
+
constructor({ paths, context, entryFiles, resolution = {}, runtimeResolved = {} }: CsrDevResolverOptions) {
|
|
37
40
|
this.#paths = paths;
|
|
38
41
|
this.#context = context;
|
|
39
42
|
this.#entryFiles = entryFiles;
|
|
43
|
+
for (const [importer, specifiers] of Object.entries(runtimeResolved))
|
|
44
|
+
this.#runtimeResolved.set(paths.fileOf(importer), new Set(specifiers));
|
|
40
45
|
for (const [importer, bySpecifier] of Object.entries(resolution))
|
|
41
46
|
this.#resolution.set(
|
|
42
47
|
paths.fileOf(importer),
|
|
@@ -59,9 +64,10 @@ export class CsrDevResolver {
|
|
|
59
64
|
|
|
60
65
|
async prepass(): Promise<void> {
|
|
61
66
|
const result = await Bun.build({
|
|
62
|
-
entrypoints: [...this.#entryFiles, this.#context.refreshFile],
|
|
67
|
+
entrypoints: [...this.#entryFiles, ...(this.#context.refreshFile ? [this.#context.refreshFile] : [])],
|
|
63
68
|
target: "browser",
|
|
64
69
|
metafile: true,
|
|
70
|
+
external: [...(this.#context.externals ?? [])],
|
|
65
71
|
env: "AKAN_PUBLIC_*",
|
|
66
72
|
define: this.#context.define,
|
|
67
73
|
optimizeImports: this.#context.optimizeImports,
|
|
@@ -78,23 +84,35 @@ export class CsrDevResolver {
|
|
|
78
84
|
continue;
|
|
79
85
|
}
|
|
80
86
|
const target = path.resolve(record.path);
|
|
81
|
-
if (fs.existsSync(target))
|
|
87
|
+
if (!fs.existsSync(target)) continue;
|
|
88
|
+
this.#remember(importer, record.original, CsrDevPaths.realpath(target));
|
|
89
|
+
this.#runtimeResolved.get(importer)?.delete(record.original);
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
this.#prepassDone = true;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
resolve(importer: string, specifier: string): string | null {
|
|
95
|
+
resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
|
|
88
96
|
if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
|
|
89
97
|
const known = this.#resolution.get(importer)?.get(specifier);
|
|
90
|
-
const usable = known && (
|
|
91
|
-
const target = usable ?? this.#resolveUnknown(importer, specifier);
|
|
98
|
+
const usable = known && this.#stillAnswers(importer, specifier, known) ? known : null;
|
|
99
|
+
const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
|
|
92
100
|
if (target === null || CsrDevPaths.isStub(target)) return target;
|
|
93
101
|
if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
|
|
94
102
|
if (!usable) this.#remember(importer, specifier, target);
|
|
95
103
|
return target;
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
//? A record stands until its file is gone, or until a file created beside the folder it went into takes the import
|
|
107
|
+
//? over (`Foo.tsx` next to `Foo/index.tsx`), as it does for Bun and every other build. Nothing else overturns it: the
|
|
108
|
+
//? prepass chose between sibling files and package fields as Bun does, which a disk lookup would only approximate.
|
|
109
|
+
#stillAnswers(importer: string, specifier: string, known: string): boolean {
|
|
110
|
+
if (CsrDevPaths.isStub(known)) return true;
|
|
111
|
+
if (!fs.existsSync(known)) return false;
|
|
112
|
+
if (!specifier.startsWith(".") || CsrDevPaths.isVendorFile(importer)) return true;
|
|
113
|
+
return !CsrDevPaths.isShadowed(path.resolve(path.dirname(importer), specifier), known);
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
isRegistryModule(file: string): boolean {
|
|
99
117
|
if (!CsrDevPaths.isScript(file)) return false;
|
|
100
118
|
const packageName = CsrDevResolver.#packageNameOf(file);
|
|
@@ -105,42 +123,76 @@ export class CsrDevResolver {
|
|
|
105
123
|
});
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
serializeRuntimeResolved(): Record<string, string[]> {
|
|
127
|
+
return Object.fromEntries(
|
|
128
|
+
[...this.#runtimeResolved]
|
|
129
|
+
.filter(([, specifiers]) => specifiers.size > 0)
|
|
130
|
+
.map(([importer, specifiers]) => [this.#paths.idOf(importer), [...specifiers].sort()]),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A target moved away is left out, so the graph does not carry it past a builder restart.
|
|
108
135
|
serialize(): Record<string, Record<string, string>> {
|
|
109
136
|
return Object.fromEntries(
|
|
110
137
|
[...this.#resolution].map(([importer, bySpecifier]) => [
|
|
111
138
|
this.#paths.idOf(importer),
|
|
112
139
|
Object.fromEntries(
|
|
113
|
-
[...bySpecifier]
|
|
114
|
-
|
|
115
|
-
CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target),
|
|
116
|
-
]),
|
|
140
|
+
[...bySpecifier]
|
|
141
|
+
.filter(([, target]) => CsrDevPaths.isStub(target) || fs.existsSync(target))
|
|
142
|
+
.map(([specifier, target]) => [specifier, CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target)]),
|
|
117
143
|
),
|
|
118
144
|
]),
|
|
119
145
|
);
|
|
120
146
|
}
|
|
121
147
|
|
|
122
|
-
|
|
148
|
+
//? Bun.resolveSync keeps what it found per directory until a build walks that directory again, so in the resident
|
|
149
|
+
//? builder it may hand back a file moved away since (`Foo.tsx` → `Foo/index.tsx`, `.ts` → `.tsx`). A relative import
|
|
150
|
+
//? 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.
|
|
151
|
+
#resolveUnknown(importer: string, specifier: string, kind: "import" | "require"): string | null {
|
|
123
152
|
if (specifier.startsWith("node:")) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
124
153
|
const relative = specifier.startsWith(".") || path.isAbsolute(specifier);
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
const
|
|
128
|
-
|
|
154
|
+
const cannotResolve = () => new Error(`[csr-dev] cannot resolve "${specifier}" from ${this.#paths.idOf(importer)}`);
|
|
155
|
+
if (relative) {
|
|
156
|
+
const onDisk =
|
|
157
|
+
CsrDevPaths.resolveOnDisk(path.resolve(path.dirname(importer), specifier), kind) ??
|
|
158
|
+
CsrDevResolver.#onDisk(CsrDevPaths.tryResolve(specifier, path.dirname(importer)));
|
|
159
|
+
if (!onDisk) throw cannotResolve();
|
|
160
|
+
return CsrDevPaths.realpath(onDisk);
|
|
129
161
|
}
|
|
162
|
+
//? Ahead of the prepass too: a new file's bare imports (`akanjs/ui`, `@apps/<app>/client`) are ones its package
|
|
163
|
+
//? already resolved, and asking for the prepass would hand every such save to a build worker.
|
|
164
|
+
const sibling = this.#resolvedBySibling(importer, specifier);
|
|
165
|
+
if (sibling) return sibling;
|
|
130
166
|
const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
|
|
131
|
-
|
|
167
|
+
const moved = resolved !== null && path.isAbsolute(resolved) && !fs.existsSync(resolved);
|
|
168
|
+
if (!this.#prepassDone || moved) {
|
|
169
|
+
//? A specifier no resolver finds in the user's own code is a typo, failed here like a missing relative file: the
|
|
170
|
+
//? resolution build a worker would run for it fails the same way. A package's `browser` field may map one away.
|
|
171
|
+
if (resolved === null && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (!resolved) throw cannotResolve();
|
|
132
175
|
if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
|
|
133
|
-
|
|
176
|
+
this.#fallbacks.add(specifier);
|
|
177
|
+
this.#runtimeResolved.set(importer, (this.#runtimeResolved.get(importer) ?? new Set<string>()).add(specifier));
|
|
134
178
|
return CsrDevPaths.realpath(resolved);
|
|
135
179
|
}
|
|
136
180
|
|
|
181
|
+
static #onDisk(resolved: string | null): string | null {
|
|
182
|
+
return resolved && path.isAbsolute(resolved) && fs.existsSync(resolved) ? resolved : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
//? The browser build tree-shakes the unused re-exports of a side-effect-free barrel, so their imports have no
|
|
138
186
|
//? recorded resolution; a file of the same package resolves a bare specifier to the same target, conditions included.
|
|
187
|
+
//? Not one the runtime resolver found (it ignores the `browser` condition the prepass exists to honour), not the
|
|
188
|
+
//? importer's own record, and not a target moved away since.
|
|
139
189
|
#resolvedBySibling(importer: string, specifier: string): string | null {
|
|
140
190
|
const scope = CsrDevResolver.#packageScopeOf(importer);
|
|
141
191
|
for (const [other, bySpecifier] of this.#resolution) {
|
|
142
192
|
const target = bySpecifier.get(specifier);
|
|
143
|
-
if (target
|
|
193
|
+
if (!target || other === importer || this.#runtimeResolved.get(other)?.has(specifier)) continue;
|
|
194
|
+
if (!CsrDevPaths.isStub(target) && !fs.existsSync(target)) continue;
|
|
195
|
+
if (CsrDevResolver.#packageScopeOf(other) === scope) return target;
|
|
144
196
|
}
|
|
145
197
|
return null;
|
|
146
198
|
}
|
|
@@ -15,7 +15,11 @@ 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
|
+
/** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
|
|
22
|
+
metadata?: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export interface CsrDevContext {
|
|
@@ -25,7 +29,10 @@ export interface CsrDevContext {
|
|
|
25
29
|
define: Record<string, string>;
|
|
26
30
|
optimizeImports: string[];
|
|
27
31
|
configKey: string;
|
|
28
|
-
|
|
32
|
+
/** Null when the page already loads the React Refresh runtime (an SSR page's import map). */
|
|
33
|
+
refreshFile: string | null;
|
|
34
|
+
/** Bare specifiers the page already loads (an SSR page's import map): required as `vendor:<specifier>`, not compiled. */
|
|
35
|
+
externals?: readonly string[];
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface CsrDevSharedHelpers {
|
|
@@ -44,3 +51,9 @@ export interface CsrDevCompiledModule {
|
|
|
44
51
|
mtimeMs: number;
|
|
45
52
|
hash: string;
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Module factories by id and helper definitions by hash, kept in memory so app.js is written without a pass over the disk. */
|
|
56
|
+
export interface CsrDevCode {
|
|
57
|
+
modules: Map<string, string>;
|
|
58
|
+
helpers: Map<string, string>;
|
|
59
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isAkanRuntimeMetadataFile } from "akanjs/server/hmr/runtimeMetadataFile";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { CsrDevArtifactWriter } from "./csrDevArtifactWriter";
|
|
6
|
+
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
7
|
+
import { CsrDevPatcher } from "./csrDevPatcher";
|
|
8
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
9
|
+
import { CsrDevResolver } from "./csrDevResolver";
|
|
10
|
+
import type { CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
|
|
11
|
+
import { RegistryMetadataFingerprint } from "./registryMetadataFingerprint";
|
|
12
|
+
import { WatchRootResolver } from "./watchRootResolver";
|
|
13
|
+
|
|
14
|
+
export interface CsrDevUpdate {
|
|
15
|
+
generation: number;
|
|
16
|
+
reload: boolean;
|
|
17
|
+
reason?: string;
|
|
18
|
+
patchUrl?: string;
|
|
19
|
+
changedIds: string[];
|
|
20
|
+
moduleCount: number;
|
|
21
|
+
epoch?: number;
|
|
22
|
+
/** The registry did not exist before this build, so no tab holds a module of it. */
|
|
23
|
+
first?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CsrDevUpdateOptions {
|
|
27
|
+
/** Called once an update exists: for a patch before app.js is rewritten, for a reload after everything is on disk. */
|
|
28
|
+
announce?: (update: CsrDevUpdate) => void;
|
|
29
|
+
/** Files the registry must hold once this update is done; the ones it lacks are compiled into it. */
|
|
30
|
+
roots?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Compile only the roots the registry lacks: a route build asks for its entries, and a file saved meanwhile is the
|
|
33
|
+
* save's own patch to make, with the hold only that patch knows.
|
|
34
|
+
*/
|
|
35
|
+
onlyRoots?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DevRegistryTarget {
|
|
39
|
+
dirName: string;
|
|
40
|
+
routePrefix: string;
|
|
41
|
+
library: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//* A dev module registry under `.akan/artifact/<dirName>`: this owns the context, the entries and the full build;
|
|
45
|
+
//* `CsrDevPatcher` owns the incremental path, which the resident builder keeps in memory between saves.
|
|
46
|
+
export abstract class DevRegistryBundler {
|
|
47
|
+
readonly outDir: string;
|
|
48
|
+
readonly routePrefix: string;
|
|
49
|
+
readonly paths: CsrDevPaths;
|
|
50
|
+
readonly writer: CsrDevArtifactWriter;
|
|
51
|
+
protected readonly app: App;
|
|
52
|
+
protected readonly entryDir: string;
|
|
53
|
+
/** A CSR entry holds the route table, so moving one reloads the tab; an SSR root is one more module to hold. */
|
|
54
|
+
abstract readonly reloadsOnEntryChange: boolean;
|
|
55
|
+
|
|
56
|
+
constructor(app: App, { dirName, routePrefix, library }: DevRegistryTarget) {
|
|
57
|
+
this.app = app;
|
|
58
|
+
this.routePrefix = routePrefix;
|
|
59
|
+
this.paths = new CsrDevPaths(app.workspace.workspaceRoot);
|
|
60
|
+
this.outDir = path.join(app.cwdPath, ".akan/artifact", dirName);
|
|
61
|
+
this.entryDir = path.join(app.cwdPath, ".akan/generated", dirName);
|
|
62
|
+
this.writer = new CsrDevArtifactWriter(this.outDir, { routePrefix, library });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Null when there is nothing to build. */
|
|
66
|
+
abstract context(): Promise<CsrDevContext | null>;
|
|
67
|
+
|
|
68
|
+
abstract writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }>;
|
|
69
|
+
|
|
70
|
+
/** Every file a full build compiles from, the entries included. */
|
|
71
|
+
protected abstract rootFiles(entryFiles: string[]): Promise<string[]>;
|
|
72
|
+
|
|
73
|
+
// A build worker: a throwaway patcher that starts from the disk, and builds everything itself when it cannot patch.
|
|
74
|
+
async update(changedFiles: string[] = [], options: CsrDevUpdateOptions = {}): Promise<CsrDevUpdate | null> {
|
|
75
|
+
const result = await new CsrDevPatcher(this).update(changedFiles, { ...options, allowWholeAppBuilds: true });
|
|
76
|
+
if (result.kind === "unchanged") return null;
|
|
77
|
+
if (result.kind === "update") return result.update;
|
|
78
|
+
const built = await this.fullBuild(result.context, result.generation, result.reason);
|
|
79
|
+
const update = result.first ? { ...built, first: true } : built;
|
|
80
|
+
options.announce?.(update);
|
|
81
|
+
return update;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Of these roots outside the graph, the ones something still reaches (an entry, a module's import): those stay pending. */
|
|
85
|
+
async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
|
|
86
|
+
const reached = new Set([...Object.values(graph.entries), ...Object.values(graph.modules).flatMap((m) => m.deps)]);
|
|
87
|
+
return new Set(files.filter((file) => reached.has(this.paths.idOf(file))));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async holds(files: string[]): Promise<boolean> {
|
|
91
|
+
const graph = await this.writer.readJson<CsrDevGraph>("graph.json");
|
|
92
|
+
return graph !== null && files.every((file) => graph.modules[this.paths.idOf(file)] !== undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A save of one goes to a whole build: `lib/useClient.ts` inlines what they declare through macros. */
|
|
96
|
+
isMetadataFile(file: string): boolean {
|
|
97
|
+
return isAkanRuntimeMetadataFile(file);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async metadataFingerprint(): Promise<string> {
|
|
101
|
+
const libsContainer = path.resolve(this.app.workspace.workspaceRoot, "libs");
|
|
102
|
+
const roots = await new WatchRootResolver(this.app).resolve();
|
|
103
|
+
// The whole container comes back when the lib dependencies are unknown (before the first sync).
|
|
104
|
+
const libRoots = roots.includes(libsContainer)
|
|
105
|
+
? fs.readdirSync(libsContainer).map((name) => path.join(libsContainer, name))
|
|
106
|
+
: roots.filter((root) => path.dirname(root) === libsContainer);
|
|
107
|
+
return await RegistryMetadataFingerprint.of([path.resolve(this.app.cwdPath), ...libRoots], (file) =>
|
|
108
|
+
this.isMetadataFile(file),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
//? Compiled before anything is written: a build that fails (a module the user broke) leaves the registry the tabs
|
|
113
|
+
//? and the next patch read as it was, where clearing the directory first left no registry at all.
|
|
114
|
+
async fullBuild(context: CsrDevContext, generation: number, reason: string): Promise<CsrDevUpdate> {
|
|
115
|
+
const metadata = await this.metadataFingerprint();
|
|
116
|
+
const entries = await this.writeEntries(context);
|
|
117
|
+
const roots = await this.rootFiles(Object.values(entries.files));
|
|
118
|
+
const resolver = new CsrDevResolver({ paths: this.paths, context, entryFiles: roots });
|
|
119
|
+
await resolver.prepass();
|
|
120
|
+
const compiler = this.compiler(context, resolver);
|
|
121
|
+
const { modules: compiled } = await compiler.compile(
|
|
122
|
+
[...roots, ...(context.refreshFile ? [context.refreshFile] : [])],
|
|
123
|
+
new Set(),
|
|
124
|
+
);
|
|
125
|
+
const graph: CsrDevGraph = {
|
|
126
|
+
version: 1,
|
|
127
|
+
configKey: context.configKey,
|
|
128
|
+
refresh: context.refreshFile
|
|
129
|
+
? this.paths.idOf(context.refreshFile)
|
|
130
|
+
: `${CsrDevPaths.vendorPrefix}react-refresh/runtime`,
|
|
131
|
+
entries: this.entryIds(entries.files),
|
|
132
|
+
modules: {},
|
|
133
|
+
resolution: {},
|
|
134
|
+
pending: [],
|
|
135
|
+
metadata,
|
|
136
|
+
};
|
|
137
|
+
this.merge(graph, resolver, compiled);
|
|
138
|
+
await this.writer.markBuilding();
|
|
139
|
+
await this.writer.writeModules(compiled);
|
|
140
|
+
const vendorFile = await this.writer.writeVendor(graph);
|
|
141
|
+
const epoch = Date.now();
|
|
142
|
+
await this.writer.writeApp(graph, { generation, vendorFile, epoch });
|
|
143
|
+
await this.writer.writeState(graph, { version: 1, generation, vendorFile, entries: graph.entries, epoch });
|
|
144
|
+
await this.writer.pruneAfterFullBuild(graph, vendorFile, { generation, assets: compiler.assets });
|
|
145
|
+
await this.writer.clearBuilding();
|
|
146
|
+
return { generation, reload: true, reason, changedIds: [], moduleCount: Object.keys(graph.modules).length, epoch };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
compiler(context: CsrDevContext, resolver: CsrDevResolver): CsrDevModuleCompiler {
|
|
150
|
+
return new CsrDevModuleCompiler({
|
|
151
|
+
app: this.app,
|
|
152
|
+
paths: this.paths,
|
|
153
|
+
resolver,
|
|
154
|
+
context,
|
|
155
|
+
outDir: this.outDir,
|
|
156
|
+
routePrefix: this.routePrefix,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
merge(graph: CsrDevGraph, resolver: CsrDevResolver, compiled: CsrDevCompiledModule[]): void {
|
|
161
|
+
for (const module of compiled)
|
|
162
|
+
graph.modules[module.id] = {
|
|
163
|
+
vendor: module.vendor,
|
|
164
|
+
mtimeMs: module.mtimeMs,
|
|
165
|
+
hash: module.hash,
|
|
166
|
+
deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
|
|
167
|
+
...(module.helpers ? { helpers: module.helpers.hash } : {}),
|
|
168
|
+
};
|
|
169
|
+
graph.pending = [];
|
|
170
|
+
graph.resolution = resolver.serialize();
|
|
171
|
+
graph.runtimeResolved = resolver.serializeRuntimeResolved();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
entryIds(files: Record<string, string>): Record<string, string> {
|
|
175
|
+
return Object.fromEntries(Object.entries(files).map(([key, file]) => [key, this.paths.idOf(file)]));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -49,6 +49,57 @@ afterEach(() => {
|
|
|
49
49
|
const makeRoot = tempDirs("akan-hmr-watcher-");
|
|
50
50
|
|
|
51
51
|
describe("HmrWatcher", () => {
|
|
52
|
+
test(
|
|
53
|
+
"marks when a batch's window opened and when it was handed over",
|
|
54
|
+
async () => {
|
|
55
|
+
const root = await makeRoot();
|
|
56
|
+
const source = await seed(root, "lib/a.ts");
|
|
57
|
+
const { batches } = await watch(root);
|
|
58
|
+
|
|
59
|
+
const writtenAt = Date.now();
|
|
60
|
+
await writeFile(source, "export const x = 2;\n");
|
|
61
|
+
await sleep(SETTLE_MS);
|
|
62
|
+
|
|
63
|
+
const trace = batches.filter((batch) => batch.files.includes(source)).at(-1)?.trace;
|
|
64
|
+
expect(trace?.eventAt).toBeGreaterThanOrEqual(writtenAt);
|
|
65
|
+
expect(trace?.flushAt).toBeGreaterThanOrEqual(trace?.eventAt ?? Number.POSITIVE_INFINITY);
|
|
66
|
+
},
|
|
67
|
+
TEST_TIMEOUT_MS,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test(
|
|
71
|
+
"takes its debounce window from AKAN_DEV_WATCH_DEBOUNCE_MS when no option names one",
|
|
72
|
+
async () => {
|
|
73
|
+
const previous = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
74
|
+
process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = "400";
|
|
75
|
+
try {
|
|
76
|
+
const root = await makeRoot();
|
|
77
|
+
const source = await seed(root, "lib/a.ts");
|
|
78
|
+
const batches: ChangeBatch[] = [];
|
|
79
|
+
// Without the verification scan, which would otherwise hand the batch over at 250ms.
|
|
80
|
+
const watcher = new HmrWatcher({
|
|
81
|
+
roots: [root],
|
|
82
|
+
logger: silentLogger,
|
|
83
|
+
verifyDelayMs: 0,
|
|
84
|
+
onBatch: (batch) => void batches.push(batch),
|
|
85
|
+
});
|
|
86
|
+
started.push(watcher);
|
|
87
|
+
await watcher.start();
|
|
88
|
+
await sleep(STREAM_WARMUP_MS);
|
|
89
|
+
|
|
90
|
+
await writeFile(source, "export const x = 3;\n");
|
|
91
|
+
await sleep(SETTLE_MS);
|
|
92
|
+
|
|
93
|
+
const trace = batches.find((batch) => batch.files.includes(source))?.trace;
|
|
94
|
+
expect((trace?.flushAt ?? 0) - (trace?.eventAt ?? 0)).toBeGreaterThanOrEqual(395);
|
|
95
|
+
} finally {
|
|
96
|
+
if (previous === undefined) delete process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
97
|
+
else process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = previous;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
TEST_TIMEOUT_MS,
|
|
101
|
+
);
|
|
102
|
+
|
|
52
103
|
test(
|
|
53
104
|
"reports every file of a save-all",
|
|
54
105
|
async () => {
|
|
@@ -31,6 +31,7 @@ export class HmrWatcher {
|
|
|
31
31
|
readonly #index: SourceMtimeIndex;
|
|
32
32
|
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
33
33
|
#verifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
#windowStartedAt: number | null = null;
|
|
34
35
|
#stopped = false;
|
|
35
36
|
#flushing = false;
|
|
36
37
|
#unreportedChanges = 0;
|
|
@@ -39,7 +40,7 @@ export class HmrWatcher {
|
|
|
39
40
|
|
|
40
41
|
constructor(opts: WatcherOptions) {
|
|
41
42
|
this.#roots = [...new Set(opts.roots.map((r) => path.resolve(r)))];
|
|
42
|
-
this.#debounceMs = opts.debounceMs ??
|
|
43
|
+
this.#debounceMs = opts.debounceMs ?? HmrWatcher.#envDebounceMs() ?? 30;
|
|
43
44
|
this.#verifyDelayMs = opts.verifyDelayMs ?? 250;
|
|
44
45
|
this.#onBatch = opts.onBatch;
|
|
45
46
|
this.#logger = opts.logger;
|
|
@@ -119,6 +120,7 @@ export class HmrWatcher {
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
#scheduleFlush(): void {
|
|
123
|
+
this.#windowStartedAt ??= Date.now();
|
|
122
124
|
if (this.#flushing) return;
|
|
123
125
|
if (this.#timer) clearTimeout(this.#timer);
|
|
124
126
|
this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
|
|
@@ -135,13 +137,19 @@ export class HmrWatcher {
|
|
|
135
137
|
try {
|
|
136
138
|
while (!this.#stopped) {
|
|
137
139
|
await this.#mergeDetectedChanges();
|
|
138
|
-
if (this.#pending.size === 0)
|
|
140
|
+
if (this.#pending.size === 0) {
|
|
141
|
+
this.#windowStartedAt = null;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
139
144
|
const files = Array.from(this.#pending.keys());
|
|
140
145
|
const kinds = new Set(this.#pending.values());
|
|
146
|
+
const flushAt = Date.now();
|
|
147
|
+
const trace = { eventAt: this.#windowStartedAt ?? flushAt, flushAt };
|
|
148
|
+
this.#windowStartedAt = null;
|
|
141
149
|
this.#pending.clear();
|
|
142
150
|
this.#hinted.clear();
|
|
143
151
|
try {
|
|
144
|
-
await this.#onBatch({ files, kinds });
|
|
152
|
+
await this.#onBatch({ files, kinds, trace });
|
|
145
153
|
} catch (e) {
|
|
146
154
|
this.#logger.error(`[hmr] onBatch error: ${(e as Error).message}`);
|
|
147
155
|
}
|
|
@@ -201,6 +209,12 @@ export class HmrWatcher {
|
|
|
201
209
|
);
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
static #envDebounceMs(): number | null {
|
|
213
|
+
const raw = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
214
|
+
const ms = raw ? Number(raw) : Number.NaN;
|
|
215
|
+
return Number.isInteger(ms) && ms >= 0 ? ms : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
204
218
|
// A write in the same window as a delivered event raises no event of its own, so one scan follows each window.
|
|
205
219
|
#scheduleVerify(): void {
|
|
206
220
|
if (this.#stopped || this.#verifyDelayMs <= 0) return;
|
package/frontendBuild/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./cssCompiler";
|
|
|
9
9
|
export * from "./cssImportResolver";
|
|
10
10
|
export * from "./devChangePlanner";
|
|
11
11
|
export * from "./devGeneratedIndexSync";
|
|
12
|
+
export * from "./devRegistryBundler";
|
|
12
13
|
export * from "./fontOptimizer";
|
|
13
14
|
export * from "./fontPruner";
|
|
14
15
|
export * from "./hmrChangeClassifier";
|
|
@@ -18,8 +19,10 @@ export * from "./pagesEntrySourceGenerator";
|
|
|
18
19
|
export * from "./precompressArtifacts";
|
|
19
20
|
export * from "./routeClientBuilder";
|
|
20
21
|
export * from "./routesManifestArtifactSerializer";
|
|
22
|
+
export * from "./serverGraphFile";
|
|
21
23
|
export * from "./sourceMtimeIndex";
|
|
22
24
|
export * from "./ssrBaseArtifactBuilder";
|
|
25
|
+
export * from "./ssrDevBundler";
|
|
23
26
|
export * from "./styleContract";
|
|
24
27
|
export * from "./themeValidator";
|
|
25
28
|
export * from "./vendorSpecifiers";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type { BunPlugin } from "bun";
|
|
3
4
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
@@ -9,7 +10,9 @@ import { loaderFor } from "../transforms/moduleSyntax";
|
|
|
9
10
|
import { transformUseClient } from "../transforms/rscUseClientTransform";
|
|
10
11
|
import { createUseClientBundlePlugin } from "../transforms/useClientBundlePlugin";
|
|
11
12
|
import { bundleDefine } from "./bundleDefine";
|
|
13
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
12
14
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
15
|
+
import { ServerGraphFile } from "./serverGraphFile";
|
|
13
16
|
|
|
14
17
|
export interface BuildPagesBundleResult {
|
|
15
18
|
bundlePath: string;
|
|
@@ -42,6 +45,13 @@ export class PagesBundleBuilder {
|
|
|
42
45
|
this.#pageEntries ?? (await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys()));
|
|
43
46
|
const entrySource = PagesEntrySourceGenerator.generate(resolvedEntries);
|
|
44
47
|
const workspaceRoot = this.#app.workspace.workspaceRoot;
|
|
48
|
+
const dev = this.#command === "start";
|
|
49
|
+
const clientExports: Record<string, string[]> = {};
|
|
50
|
+
const onClientModule = dev
|
|
51
|
+
? (file: string, exports: string[]) => {
|
|
52
|
+
clientExports[CsrDevPaths.realpath(file)] = exports;
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
45
55
|
const result = await Bun.build({
|
|
46
56
|
entrypoints: [VIRTUAL_PAGES_ENTRY],
|
|
47
57
|
outdir: `${this.#artifactDir}/server`,
|
|
@@ -55,6 +65,7 @@ export class PagesBundleBuilder {
|
|
|
55
65
|
asset: "assets/[name]-[hash].[ext]",
|
|
56
66
|
},
|
|
57
67
|
define: bundleDefine(this.#app, this.#command, "ssr"),
|
|
68
|
+
metafile: dev,
|
|
58
69
|
plugins: [
|
|
59
70
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
60
71
|
PagesBundleBuilder.createCssStubPlugin(),
|
|
@@ -66,9 +77,10 @@ export class PagesBundleBuilder {
|
|
|
66
77
|
transformUseClient(source, {
|
|
67
78
|
path: args.path,
|
|
68
79
|
workspaceRoot,
|
|
80
|
+
onClientModule,
|
|
69
81
|
}),
|
|
70
82
|
})
|
|
71
|
-
: createUseClientBundlePlugin({ workspaceRoot }),
|
|
83
|
+
: createUseClientBundlePlugin({ workspaceRoot, onClientModule }),
|
|
72
84
|
],
|
|
73
85
|
});
|
|
74
86
|
|
|
@@ -79,6 +91,8 @@ export class PagesBundleBuilder {
|
|
|
79
91
|
|
|
80
92
|
const bundlePath = path.resolve(entryArtifact.path);
|
|
81
93
|
const buildId = Date.now();
|
|
94
|
+
if (dev && result.metafile)
|
|
95
|
+
await ServerGraphFile.write(this.#artifactDir, this.#serverGraph(result.metafile, clientExports));
|
|
82
96
|
const outputBytes = result.outputs.reduce((sum, output) => sum + output.size, 0);
|
|
83
97
|
const chunkCount = result.outputs.filter((output) => output.kind === "chunk").length;
|
|
84
98
|
this.#app.verbose(
|
|
@@ -95,6 +109,15 @@ export class PagesBundleBuilder {
|
|
|
95
109
|
};
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
#serverGraph(metafile: Bun.BuildMetafile, clientExports: Record<string, string[]>) {
|
|
113
|
+
const inputs = Object.keys(metafile.inputs)
|
|
114
|
+
.filter((input) => !input.includes("node_modules"))
|
|
115
|
+
.map((input) => path.resolve(input))
|
|
116
|
+
.filter((input) => fs.existsSync(input))
|
|
117
|
+
.map((input) => CsrDevPaths.realpath(input));
|
|
118
|
+
return { inputs, clientExports };
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
get #artifactDir(): string {
|
|
99
122
|
return `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
|
|
100
123
|
}
|
|
@@ -2,12 +2,19 @@ import path from "node:path";
|
|
|
2
2
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
3
|
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
4
4
|
|
|
5
|
+
export interface PagesEntrySourceOptions {
|
|
6
|
+
/** Detects an async default export; a caller that outlives one generation keeps its own cache across saves. */
|
|
7
|
+
isAsyncDefault?: (moduleAbsPath: string) => Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export class PagesEntrySourceGenerator {
|
|
6
11
|
#pageEntries: PageEntry[];
|
|
7
12
|
readonly #asyncDefaults = new Map<string, Promise<boolean>>();
|
|
13
|
+
readonly #detect: (moduleAbsPath: string) => Promise<boolean>;
|
|
8
14
|
|
|
9
|
-
constructor(pageEntries: PageEntry[]) {
|
|
15
|
+
constructor(pageEntries: PageEntry[], { isAsyncDefault }: PagesEntrySourceOptions = {}) {
|
|
10
16
|
this.#pageEntries = pageEntries;
|
|
17
|
+
this.#detect = isAsyncDefault ?? ((moduleAbsPath) => AsyncDefaultExportDetector.detect(moduleAbsPath));
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
static generate(pageEntries: PageEntry[]): string {
|
|
@@ -72,7 +79,7 @@ ${entries.join("\n")}
|
|
|
72
79
|
#isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
|
|
73
80
|
const known = this.#asyncDefaults.get(moduleAbsPath);
|
|
74
81
|
if (known) return known;
|
|
75
|
-
const detected =
|
|
82
|
+
const detected = this.#detect(moduleAbsPath);
|
|
76
83
|
this.#asyncDefaults.set(moduleAbsPath, detected);
|
|
77
84
|
return detected;
|
|
78
85
|
}
|