@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,192 @@
|
|
|
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 files, the ones the registry still reaches from its roots through imports: those it keeps building. */
|
|
85
|
+
async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
|
|
86
|
+
const reached = await this.reachable(graph);
|
|
87
|
+
return new Set(files.filter((file) => reached.has(this.paths.idOf(file))));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The ids its roots reach through imports, the refresh runtime included. */
|
|
91
|
+
async reachable(graph: CsrDevGraph): Promise<Set<string>> {
|
|
92
|
+
const roots = await this.rootFiles(Object.values(graph.entries).map((id) => this.paths.fileOf(id)));
|
|
93
|
+
const reached = new Set<string>();
|
|
94
|
+
const stack = [graph.refresh, ...roots.map((file) => this.paths.idOf(file))];
|
|
95
|
+
for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
|
|
96
|
+
if (reached.has(id)) continue;
|
|
97
|
+
reached.add(id);
|
|
98
|
+
stack.push(...(graph.modules[id]?.deps ?? []));
|
|
99
|
+
}
|
|
100
|
+
return reached;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async holds(files: string[]): Promise<boolean> {
|
|
104
|
+
const graph = await this.writer.readJson<CsrDevGraph>("graph.json");
|
|
105
|
+
return graph !== null && files.every((file) => graph.modules[this.paths.idOf(file)] !== undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A save of one goes to a whole build: `lib/useClient.ts` inlines what they declare through macros. */
|
|
109
|
+
isMetadataFile(file: string): boolean {
|
|
110
|
+
return isAkanRuntimeMetadataFile(file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async metadataFingerprint(): Promise<string> {
|
|
114
|
+
const libsContainer = path.resolve(this.app.workspace.workspaceRoot, "libs");
|
|
115
|
+
const roots = await new WatchRootResolver(this.app).resolve();
|
|
116
|
+
// The whole container comes back when the lib dependencies are unknown (before the first sync).
|
|
117
|
+
const libRoots = roots.includes(libsContainer)
|
|
118
|
+
? fs.readdirSync(libsContainer).map((name) => path.join(libsContainer, name))
|
|
119
|
+
: roots.filter((root) => path.dirname(root) === libsContainer);
|
|
120
|
+
return await RegistryMetadataFingerprint.of([path.resolve(this.app.cwdPath), ...libRoots], (file) =>
|
|
121
|
+
this.isMetadataFile(file),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//? Compiled before anything is written: a build that fails (a module the user broke) leaves the registry the tabs
|
|
126
|
+
//? and the next patch read as it was, where clearing the directory first left no registry at all.
|
|
127
|
+
async fullBuild(context: CsrDevContext, generation: number, reason: string): Promise<CsrDevUpdate> {
|
|
128
|
+
const metadata = await this.metadataFingerprint();
|
|
129
|
+
const entries = await this.writeEntries(context);
|
|
130
|
+
const roots = await this.rootFiles(Object.values(entries.files));
|
|
131
|
+
const resolver = new CsrDevResolver({ paths: this.paths, context, entryFiles: roots });
|
|
132
|
+
await resolver.prepass();
|
|
133
|
+
const compiler = this.compiler(context, resolver);
|
|
134
|
+
const { modules: compiled } = await compiler.compile(
|
|
135
|
+
[...roots, ...(context.refreshFile ? [context.refreshFile] : [])],
|
|
136
|
+
new Set(),
|
|
137
|
+
);
|
|
138
|
+
const graph: CsrDevGraph = {
|
|
139
|
+
version: 1,
|
|
140
|
+
configKey: context.configKey,
|
|
141
|
+
refresh: context.refreshFile
|
|
142
|
+
? this.paths.idOf(context.refreshFile)
|
|
143
|
+
: `${CsrDevPaths.vendorPrefix}react-refresh/runtime`,
|
|
144
|
+
entries: this.entryIds(entries.files),
|
|
145
|
+
modules: {},
|
|
146
|
+
resolution: {},
|
|
147
|
+
pending: [],
|
|
148
|
+
metadata,
|
|
149
|
+
};
|
|
150
|
+
this.merge(graph, resolver, compiled);
|
|
151
|
+
await this.writer.markBuilding();
|
|
152
|
+
await this.writer.writeModules(compiled);
|
|
153
|
+
const vendorFile = await this.writer.writeVendor(graph);
|
|
154
|
+
const epoch = Date.now();
|
|
155
|
+
await this.writer.writeApp(graph, { generation, vendorFile, epoch });
|
|
156
|
+
await this.writer.writeState(graph, { version: 1, generation, vendorFile, entries: graph.entries, epoch });
|
|
157
|
+
await this.writer.pruneAfterFullBuild(graph, vendorFile, { generation, assets: compiler.assets });
|
|
158
|
+
await this.writer.clearBuilding();
|
|
159
|
+
return { generation, reload: true, reason, changedIds: [], moduleCount: Object.keys(graph.modules).length, epoch };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
compiler(context: CsrDevContext, resolver: CsrDevResolver): CsrDevModuleCompiler {
|
|
163
|
+
return new CsrDevModuleCompiler({
|
|
164
|
+
app: this.app,
|
|
165
|
+
paths: this.paths,
|
|
166
|
+
resolver,
|
|
167
|
+
context,
|
|
168
|
+
outDir: this.outDir,
|
|
169
|
+
routePrefix: this.routePrefix,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
merge(graph: CsrDevGraph, resolver: CsrDevResolver, compiled: CsrDevCompiledModule[]): void {
|
|
174
|
+
for (const module of compiled)
|
|
175
|
+
graph.modules[module.id] = {
|
|
176
|
+
vendor: module.vendor,
|
|
177
|
+
mtimeMs: module.mtimeMs,
|
|
178
|
+
hash: module.hash,
|
|
179
|
+
deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
|
|
180
|
+
...(module.helpers ? { helpers: module.helpers.hash } : {}),
|
|
181
|
+
};
|
|
182
|
+
//? Only what this round compiled leaves pending: a root another route's check failed on stays for its retry.
|
|
183
|
+
const compiledIds = new Set(compiled.map((module) => module.id));
|
|
184
|
+
graph.pending = graph.pending.filter((id) => !compiledIds.has(id));
|
|
185
|
+
graph.resolution = resolver.serialize();
|
|
186
|
+
graph.runtimeResolved = resolver.serializeRuntimeResolved();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
entryIds(files: Record<string, string>): Record<string, string> {
|
|
190
|
+
return Object.fromEntries(Object.entries(files).map(([key, file]) => [key, this.paths.idOf(file)]));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { isAkanRuntimeMetadataFile } from "akanjs/server/hmr/runtimeMetadataFile";
|
|
7
|
+
import { RegistryMetadataFingerprint } from "./registryMetadataFingerprint";
|
|
8
|
+
|
|
9
|
+
describe("RegistryMetadataFingerprint", () => {
|
|
10
|
+
let root: string;
|
|
11
|
+
let later = Date.now() / 1000 + 60;
|
|
12
|
+
const file = (relative: string) => path.join(root, relative);
|
|
13
|
+
const touch = (relative: string) => {
|
|
14
|
+
later += 1;
|
|
15
|
+
fs.utimesSync(file(relative), later, later);
|
|
16
|
+
};
|
|
17
|
+
const ssrMetadata = (target: string) =>
|
|
18
|
+
isAkanRuntimeMetadataFile(target) && !target.endsWith(".dictionary.ts") && path.basename(target) !== "dict.ts";
|
|
19
|
+
const stamp = async (isMetadataFile = isAkanRuntimeMetadataFile) =>
|
|
20
|
+
await RegistryMetadataFingerprint.of([root], isMetadataFile);
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
root = await mkdtemp(path.join(os.tmpdir(), "akan-registry-metadata-"));
|
|
24
|
+
for (const relative of [
|
|
25
|
+
"lib/sig.ts",
|
|
26
|
+
"lib/dict.ts",
|
|
27
|
+
"lib/useClient.ts",
|
|
28
|
+
"lib/user/user.signal.ts",
|
|
29
|
+
"lib/user/user.dictionary.ts",
|
|
30
|
+
"lib/user/User.Zone.tsx",
|
|
31
|
+
"lib/user/user.signal.test.ts",
|
|
32
|
+
])
|
|
33
|
+
await Bun.write(file(relative), "export {};\n");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await rm(root, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("moves with a signal save and stays with a component save", async () => {
|
|
41
|
+
const before = await stamp();
|
|
42
|
+
touch("lib/user/User.Zone.tsx");
|
|
43
|
+
touch("lib/user/user.signal.test.ts");
|
|
44
|
+
expect(await stamp()).toBe(before);
|
|
45
|
+
touch("lib/user/user.signal.ts");
|
|
46
|
+
expect(await stamp()).not.toBe(before);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a registry that inlines no dictionary is not moved by a dictionary save", async () => {
|
|
50
|
+
const ssrBefore = await stamp(ssrMetadata);
|
|
51
|
+
const csrBefore = await stamp();
|
|
52
|
+
touch("lib/user/user.dictionary.ts");
|
|
53
|
+
touch("lib/dict.ts");
|
|
54
|
+
expect(await stamp(ssrMetadata)).toBe(ssrBefore);
|
|
55
|
+
expect(await stamp()).not.toBe(csrBefore);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a root without a lib folder counts for nothing", async () => {
|
|
59
|
+
expect(await RegistryMetadataFingerprint.of([root, path.join(root, "lib/user")], isAkanRuntimeMetadataFile)).toBe(
|
|
60
|
+
await stamp(),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
//* The sources `lib/useClient.ts` reads through its macros, which no registry graph lists as a dependency. A registry
|
|
5
|
+
//* records their stamp, so a builder that loads one built before a signal or dictionary save rebuilds it, whichever
|
|
6
|
+
//* process saw that save (a builder restarted for it never finishes the rebuild it started).
|
|
7
|
+
export class RegistryMetadataFingerprint {
|
|
8
|
+
static readonly #sources = new Bun.Glob("**/*.{signal,dictionary}.ts");
|
|
9
|
+
static readonly #libFiles = ["sig.ts", "dict.ts", "useClient.ts"];
|
|
10
|
+
|
|
11
|
+
/** `roots` are app and lib directories; only the metadata files under each one's `lib/` count. */
|
|
12
|
+
static async of(roots: string[], isMetadataFile: (file: string) => boolean): Promise<string> {
|
|
13
|
+
const stamps: string[] = [];
|
|
14
|
+
for (const root of new Set(roots)) {
|
|
15
|
+
const libDir = path.join(root, "lib");
|
|
16
|
+
if (!fs.existsSync(libDir)) continue;
|
|
17
|
+
const files = RegistryMetadataFingerprint.#libFiles.map((name) => path.join(libDir, name));
|
|
18
|
+
for await (const relative of RegistryMetadataFingerprint.#sources.scan({ cwd: libDir, onlyFiles: true }))
|
|
19
|
+
files.push(path.join(libDir, relative));
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
if (!isMetadataFile(file)) continue;
|
|
22
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
23
|
+
if (stat) stamps.push(`${file}:${stat.mtimeMs}:${stat.size}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Bun.hash(stamps.sort((a, b) => a.localeCompare(b)).join("\n")).toString(36);
|
|
27
|
+
}
|
|
28
|
+
}
|