@akanjs/devkit 3.0.0-beta.23 → 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.
Files changed (60) hide show
  1. package/DEV_RUNTIME_KNOBS.md +37 -22
  2. package/akanApp/akanApp.host.ts +34 -1
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +25 -0
  6. package/akanApp/devHostPolicy.ts +27 -2
  7. package/cloud/globalConfig.ts +3 -2
  8. package/csrE2e/csrE2eHarness.fixture.ts +455 -0
  9. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  10. package/csrE2e/csrPageStack.e2e.test.ts +187 -0
  11. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  12. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  13. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  14. package/fileSys.ts +16 -1
  15. package/frontendBuild/buildRouteClient.test.ts +61 -0
  16. package/frontendBuild/bundleDefine.test.ts +12 -0
  17. package/frontendBuild/bundleDefine.ts +5 -1
  18. package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
  19. package/frontendBuild/clientEntryDiscovery.ts +10 -5
  20. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  21. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  22. package/frontendBuild/csrDevArtifactWriter.ts +303 -0
  23. package/frontendBuild/csrDevBundler.test.ts +66 -0
  24. package/frontendBuild/csrDevBundler.ts +95 -0
  25. package/frontendBuild/csrDevModuleCompiler.ts +304 -0
  26. package/frontendBuild/csrDevPatcher.test.ts +340 -0
  27. package/frontendBuild/csrDevPatcher.ts +374 -0
  28. package/frontendBuild/csrDevPaths.ts +124 -0
  29. package/frontendBuild/csrDevResolver.test.ts +165 -0
  30. package/frontendBuild/csrDevResolver.ts +234 -0
  31. package/frontendBuild/csrDevTypes.ts +59 -0
  32. package/frontendBuild/csrEntryFiles.ts +24 -0
  33. package/frontendBuild/cssCompiler.ts +2 -4
  34. package/frontendBuild/devRegistryBundler.ts +177 -0
  35. package/frontendBuild/hmrWatcher.test.ts +51 -0
  36. package/frontendBuild/hmrWatcher.ts +17 -3
  37. package/frontendBuild/index.ts +4 -0
  38. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  39. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  40. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  41. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  42. package/frontendBuild/routeClientBuilder.ts +79 -18
  43. package/frontendBuild/serverGraphFile.test.ts +77 -0
  44. package/frontendBuild/serverGraphFile.ts +93 -0
  45. package/frontendBuild/ssrDevBundler.ts +91 -0
  46. package/incrementalBuilder/buildBatch.proc.ts +119 -6
  47. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  48. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  49. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  50. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  51. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  52. package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
  53. package/incrementalBuilder/incrementalBuilder.host.ts +33 -3
  54. package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
  55. package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
  56. package/integration/devStability.integration.test.ts +18 -7
  57. package/package.json +3 -3
  58. package/transforms/rscUseClientTransform.ts +7 -0
  59. package/transforms/transforms.test.ts +13 -0
  60. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -0,0 +1,234 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CsrDevPaths } from "./csrDevPaths";
4
+ import type { CsrDevContext } from "./csrDevTypes";
5
+ import { PagesBundleBuilder } from "./pagesBundleBuilder";
6
+
7
+ interface MetafileImport {
8
+ path: string;
9
+ kind: string;
10
+ original?: string;
11
+ external?: boolean;
12
+ }
13
+
14
+ interface PrepassMetafile {
15
+ inputs: Record<string, { imports: MetafileImport[] }>;
16
+ }
17
+
18
+ export interface CsrDevResolverOptions {
19
+ paths: CsrDevPaths;
20
+ context: CsrDevContext;
21
+ entryFiles: string[];
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
+ }
26
+
27
+ //* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
28
+ //* builds (`@firebase/util` resolves to its node-esm entry), which the registry build would then ship.
29
+ export class CsrDevResolver {
30
+ static readonly inline = "inline";
31
+ readonly #paths: CsrDevPaths;
32
+ readonly #context: CsrDevContext;
33
+ readonly #entryFiles: string[];
34
+ readonly #resolution = new Map<string, Map<string, string>>();
35
+ readonly #fallbacks = new Set<string>();
36
+ readonly #runtimeResolved = new Map<string, Set<string>>();
37
+ #prepassDone = false;
38
+
39
+ constructor({ paths, context, entryFiles, resolution = {}, runtimeResolved = {} }: CsrDevResolverOptions) {
40
+ this.#paths = paths;
41
+ this.#context = context;
42
+ this.#entryFiles = entryFiles;
43
+ for (const [importer, specifiers] of Object.entries(runtimeResolved))
44
+ this.#runtimeResolved.set(paths.fileOf(importer), new Set(specifiers));
45
+ for (const [importer, bySpecifier] of Object.entries(resolution))
46
+ this.#resolution.set(
47
+ paths.fileOf(importer),
48
+ new Map(
49
+ Object.entries(bySpecifier).map(([specifier, target]) => [
50
+ specifier,
51
+ CsrDevPaths.isStub(target) ? target : paths.fileOf(target),
52
+ ]),
53
+ ),
54
+ );
55
+ }
56
+
57
+ get prepassDone(): boolean {
58
+ return this.#prepassDone;
59
+ }
60
+
61
+ get fallbacks(): ReadonlySet<string> {
62
+ return this.#fallbacks;
63
+ }
64
+
65
+ async prepass(): Promise<void> {
66
+ const result = await Bun.build({
67
+ entrypoints: [...this.#entryFiles, ...(this.#context.refreshFile ? [this.#context.refreshFile] : [])],
68
+ target: "browser",
69
+ metafile: true,
70
+ external: [...(this.#context.externals ?? [])],
71
+ env: "AKAN_PUBLIC_*",
72
+ define: this.#context.define,
73
+ optimizeImports: this.#context.optimizeImports,
74
+ plugins: [PagesBundleBuilder.createCssStubPlugin()],
75
+ });
76
+ const { inputs } = result.metafile as unknown as PrepassMetafile;
77
+ for (const [input, { imports }] of Object.entries(inputs)) {
78
+ const importer = CsrDevPaths.realpath(path.resolve(input));
79
+ for (const record of imports) {
80
+ if (!record.original) continue;
81
+ if (record.external) {
82
+ if (record.original.startsWith("node:"))
83
+ this.#remember(importer, record.original, `${CsrDevPaths.stubPrefix}${record.original}`);
84
+ continue;
85
+ }
86
+ const target = path.resolve(record.path);
87
+ if (!fs.existsSync(target)) continue;
88
+ this.#remember(importer, record.original, CsrDevPaths.realpath(target));
89
+ this.#runtimeResolved.get(importer)?.delete(record.original);
90
+ }
91
+ }
92
+ this.#prepassDone = true;
93
+ }
94
+
95
+ resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
96
+ if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
97
+ const known = this.#resolution.get(importer)?.get(specifier);
98
+ const usable = known && this.#stillAnswers(importer, specifier, known) ? known : null;
99
+ const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
100
+ if (target === null || CsrDevPaths.isStub(target)) return target;
101
+ if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
102
+ if (!usable) this.#remember(importer, specifier, target);
103
+ return target;
104
+ }
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
+
116
+ isRegistryModule(file: string): boolean {
117
+ if (!CsrDevPaths.isScript(file)) return false;
118
+ const packageName = CsrDevResolver.#packageNameOf(file);
119
+ if (packageName === null) return true;
120
+ return !this.#context.optimizeImports.some((pattern) => {
121
+ const base = CsrDevResolver.#patternBase(pattern);
122
+ return base === packageName || base.startsWith(`${packageName}/`);
123
+ });
124
+ }
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.
135
+ serialize(): Record<string, Record<string, string>> {
136
+ return Object.fromEntries(
137
+ [...this.#resolution].map(([importer, bySpecifier]) => [
138
+ this.#paths.idOf(importer),
139
+ Object.fromEntries(
140
+ [...bySpecifier]
141
+ .filter(([, target]) => CsrDevPaths.isStub(target) || fs.existsSync(target))
142
+ .map(([specifier, target]) => [specifier, CsrDevPaths.isStub(target) ? target : this.#paths.idOf(target)]),
143
+ ),
144
+ ]),
145
+ );
146
+ }
147
+
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 {
152
+ if (specifier.startsWith("node:")) return `${CsrDevPaths.stubPrefix}${specifier}`;
153
+ const relative = specifier.startsWith(".") || path.isAbsolute(specifier);
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);
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;
166
+ const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
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();
175
+ if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
176
+ this.#fallbacks.add(specifier);
177
+ this.#runtimeResolved.set(importer, (this.#runtimeResolved.get(importer) ?? new Set<string>()).add(specifier));
178
+ return CsrDevPaths.realpath(resolved);
179
+ }
180
+
181
+ static #onDisk(resolved: string | null): string | null {
182
+ return resolved && path.isAbsolute(resolved) && fs.existsSync(resolved) ? resolved : null;
183
+ }
184
+
185
+ //? The browser build tree-shakes the unused re-exports of a side-effect-free barrel, so their imports have no
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.
189
+ #resolvedBySibling(importer: string, specifier: string): string | null {
190
+ const scope = CsrDevResolver.#packageScopeOf(importer);
191
+ for (const [other, bySpecifier] of this.#resolution) {
192
+ const target = bySpecifier.get(specifier);
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;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ #remember(importer: string, specifier: string, target: string): void {
201
+ const byImporter = this.#resolution.get(importer) ?? new Map<string, string>();
202
+ this.#resolution.set(importer, byImporter.set(specifier, target));
203
+ }
204
+
205
+ //? Only the barrel a bare import names is inlined, so its icons tree-shake per importer; what the barrel itself
206
+ //? imports stays a registry module, keeping shared state such as react-icons' IconContext a single instance.
207
+ #isInlineSpecifier(specifier: string): boolean {
208
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("node:")) return false;
209
+ return this.#context.optimizeImports.some((pattern) => {
210
+ const base = CsrDevResolver.#patternBase(pattern);
211
+ return specifier === base || specifier.startsWith(`${base}/`);
212
+ });
213
+ }
214
+
215
+ static #patternBase(pattern: string): string {
216
+ return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern;
217
+ }
218
+
219
+ static #packageNameOf(file: string): string | null {
220
+ const marker = `${path.sep}node_modules${path.sep}`;
221
+ const index = file.lastIndexOf(marker);
222
+ if (index < 0) return null;
223
+ const [scope, name] = file.slice(index + marker.length).split(path.sep);
224
+ return (scope?.startsWith("@") ? `${scope}/${name}` : scope) || null;
225
+ }
226
+
227
+ static #packageScopeOf(file: string): string {
228
+ const marker = `${path.sep}node_modules${path.sep}`;
229
+ const index = file.lastIndexOf(marker);
230
+ if (index < 0) return "";
231
+ const [scope, name] = file.slice(index + marker.length).split(path.sep);
232
+ return file.slice(0, index + marker.length) + (scope?.startsWith("@") ? `${scope}${path.sep}${name}` : scope);
233
+ }
234
+ }
@@ -0,0 +1,59 @@
1
+ import type { PageEntry } from "../artifact/implicitRootLayout";
2
+
3
+ export interface CsrDevGraphModule {
4
+ vendor: boolean;
5
+ mtimeMs: number;
6
+ hash: string;
7
+ deps: string[];
8
+ helpers?: string;
9
+ }
10
+
11
+ export interface CsrDevGraph {
12
+ version: 1;
13
+ configKey: string;
14
+ refresh: string;
15
+ entries: Record<string, string>;
16
+ modules: Record<string, CsrDevGraphModule>;
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[]>;
20
+ pending: string[];
21
+ /** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
22
+ metadata?: string;
23
+ }
24
+
25
+ export interface CsrDevContext {
26
+ pageEntries: PageEntry[];
27
+ basePaths: string[];
28
+ htmlBasePaths: string[];
29
+ define: Record<string, string>;
30
+ optimizeImports: string[];
31
+ configKey: string;
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[];
36
+ }
37
+
38
+ export interface CsrDevSharedHelpers {
39
+ hash: string;
40
+ definition: string;
41
+ }
42
+
43
+ export interface CsrDevCompiledModule {
44
+ id: string;
45
+ file: string;
46
+ vendor: boolean;
47
+ factory: string;
48
+ helpers: CsrDevSharedHelpers | null;
49
+ sourceMap?: string;
50
+ deps: string[];
51
+ mtimeMs: number;
52
+ hash: string;
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
- const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
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 ?? 80;
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) break;
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;
@@ -4,10 +4,12 @@ export * from "./clientBuildTypes";
4
4
  export * from "./clientEntriesBundler";
5
5
  export * from "./clientEntryDiscovery";
6
6
  export * from "./csrArtifactBuilder";
7
+ export * from "./csrDevBundler";
7
8
  export * from "./cssCompiler";
8
9
  export * from "./cssImportResolver";
9
10
  export * from "./devChangePlanner";
10
11
  export * from "./devGeneratedIndexSync";
12
+ export * from "./devRegistryBundler";
11
13
  export * from "./fontOptimizer";
12
14
  export * from "./fontPruner";
13
15
  export * from "./hmrChangeClassifier";
@@ -17,8 +19,10 @@ export * from "./pagesEntrySourceGenerator";
17
19
  export * from "./precompressArtifacts";
18
20
  export * from "./routeClientBuilder";
19
21
  export * from "./routesManifestArtifactSerializer";
22
+ export * from "./serverGraphFile";
20
23
  export * from "./sourceMtimeIndex";
21
24
  export * from "./ssrBaseArtifactBuilder";
25
+ export * from "./ssrDevBundler";
22
26
  export * from "./styleContract";
23
27
  export * from "./themeValidator";
24
28
  export * from "./vendorSpecifiers";