@akanjs/devkit 3.0.2 → 3.1.0-rc.0
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/CHANGELOG.md +46 -7
- package/akanApp/akanApp.host.ts +3 -2
- package/akanConfig/akanConfig.test.ts +76 -6
- package/akanConfig/akanConfig.ts +111 -13
- package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
- package/akanConfig/akanWorkspaceConfig.ts +63 -0
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +23 -1
- package/akanContext.ts +1 -1
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +73 -84
- package/artifact/implicitRootLayout.test.ts +6 -2
- package/artifact/implicitRootLayout.ts +12 -7
- package/biome.base.json +7 -2
- package/cloud/cloudApi.ts +82 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +195 -0
- package/cloud/updatePackUpload.ts +115 -0
- package/commandDecorators/command.ts +8 -2
- package/commandDecorators/targetMeta.ts +1 -0
- package/executors.test.ts +21 -0
- package/executors.ts +9 -2
- package/frontendBuild/csrArtifactBuilder.ts +10 -1
- package/frontendBuild/cssCandidateCache.test.ts +10 -0
- package/frontendBuild/cssCandidateCache.ts +4 -2
- package/frontendBuild/pagesBundleBuilder.ts +5 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
- package/incrementalBuilder/buildBatch.proc.ts +14 -2
- package/incrementalBuilder/buildBatchProtocol.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
- package/lint/no-import-external-library-in-test.grit +24 -0
- package/mobile/desktopServerStage.test.ts +6 -0
- package/mobile/desktopServerStage.ts +25 -3
- package/mobile/index.ts +1 -0
- package/mobile/nativeApp.test.ts +1 -0
- package/mobile/nativeApp.ts +96 -34
- package/mobile/nativeConfig.test.ts +59 -1
- package/mobile/nativeConfig.ts +13 -3
- package/mobile/serverBundleSecrets.test.ts +61 -0
- package/mobile/serverBundleSecrets.ts +85 -0
- package/mobile/updateUploader.test.ts +49 -0
- package/mobile/updateUploader.ts +71 -0
- package/package.json +3 -2
- package/pipeline/PipelineCompiler.test.ts +631 -0
- package/pipeline/PipelineCompiler.ts +646 -0
- package/pipeline/PipelineDefinition.test.ts +89 -0
- package/pipeline/PipelineDefinition.ts +117 -0
- package/pipeline/PipelineDryRun.test.ts +93 -0
- package/pipeline/PipelineDryRun.ts +89 -0
- package/pipeline/PipelineInputReader.ts +739 -0
- package/pipeline/index.ts +4 -0
- package/qualityScanner.test.ts +9 -3
- package/qualityScanner.ts +5 -2
- package/scanInfo.ts +13 -6
- package/subspace.test.ts +108 -4
- package/subspace.ts +162 -10
- package/subspaceConfig.ts +11 -14
- package/testHelpers.ts +4 -0
- package/transforms/externalizeFrameworkPlugin.ts +1 -7
- package/transforms/serverEnvPlugin.test.ts +93 -2
- package/transforms/serverEnvPlugin.ts +71 -18
- package/transforms/serverEnvProfile.test.ts +82 -0
- package/transforms/serverEnvProfile.ts +114 -0
- package/typecheck/projectTypecheck.test.ts +32 -0
- package/typecheck/projectTypecheck.ts +71 -0
- package/workspaceLayout.test.ts +26 -3
- package/workspaceLayout.ts +15 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Logger, sleep } from "akanjs/common";
|
|
2
|
+
import type { BunFile } from "bun";
|
|
3
|
+
import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
|
|
4
|
+
|
|
5
|
+
interface UpdatePackUploadOptions {
|
|
6
|
+
putRetryMs?: number;
|
|
7
|
+
commitRetryMs?: number;
|
|
8
|
+
cutOffWindowMs?: number;
|
|
9
|
+
commitLimitMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
//* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
|
|
13
|
+
//* only sees two small JSON calls: request a presigned PUT, then commit the object, which akasys re-hashes.
|
|
14
|
+
export class UpdatePackUpload {
|
|
15
|
+
static readonly maxBytes = 200 * 1024 * 1024;
|
|
16
|
+
static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
|
|
17
|
+
readonly #api: CloudApi;
|
|
18
|
+
readonly #devProjectId: string;
|
|
19
|
+
readonly #putRetryMs: number;
|
|
20
|
+
readonly #commitRetryMs: number;
|
|
21
|
+
readonly #cutOffWindowMs: number;
|
|
22
|
+
readonly #commitLimitMs: number;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
api: CloudApi,
|
|
26
|
+
devProjectId: string,
|
|
27
|
+
{
|
|
28
|
+
putRetryMs = 1_000,
|
|
29
|
+
commitRetryMs = 10_000,
|
|
30
|
+
cutOffWindowMs = 15 * 60_000,
|
|
31
|
+
commitLimitMs = 60 * 60_000,
|
|
32
|
+
}: UpdatePackUploadOptions = {},
|
|
33
|
+
) {
|
|
34
|
+
this.#api = api;
|
|
35
|
+
this.#devProjectId = devProjectId;
|
|
36
|
+
this.#putRetryMs = putRetryMs;
|
|
37
|
+
this.#commitRetryMs = commitRetryMs;
|
|
38
|
+
this.#cutOffWindowMs = cutOffWindowMs;
|
|
39
|
+
this.#commitLimitMs = commitLimitMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
|
|
43
|
+
const file = Bun.file(archive);
|
|
44
|
+
if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
|
|
45
|
+
throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
|
|
46
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
47
|
+
for await (const chunk of file.stream()) hasher.update(chunk);
|
|
48
|
+
const upload = { ...fields, size: file.size, sha256: hasher.digest("hex") };
|
|
49
|
+
//? A URL lives 15 minutes and storage answers 403 past it: one fresh ticket, then the refusal is real.
|
|
50
|
+
for (let ticket = 1; ; ticket++) {
|
|
51
|
+
const { uploadId, uploadUrl, contentType } = await this.#request(upload);
|
|
52
|
+
const status = await this.#put(uploadUrl, contentType, file);
|
|
53
|
+
if (status >= 200 && status < 300) return await this.#commit(uploadId);
|
|
54
|
+
if (status !== 403 || ticket === 2)
|
|
55
|
+
throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async #request(upload: UpdateUploadRequest) {
|
|
60
|
+
return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
|
|
61
|
+
//? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
|
|
64
|
+
throw new Error(
|
|
65
|
+
"The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
|
|
66
|
+
);
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
|
|
72
|
+
// A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
|
|
73
|
+
async #put(url: string, contentType: string, file: BunFile) {
|
|
74
|
+
for (let retry = 0; ; retry++) {
|
|
75
|
+
const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
|
|
76
|
+
.then(async (response) => {
|
|
77
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
78
|
+
return response.status;
|
|
79
|
+
})
|
|
80
|
+
.catch(() => 0);
|
|
81
|
+
if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
|
|
82
|
+
await sleep(this.#putRetryMs * (retry + 1));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The server finishes a commit whoever stopped listening, and the same id answers the same release afterwards.
|
|
87
|
+
// A 409 is a live claim the server heartbeats, so only the overall limit bounds it; a run of cut-offs with no 409
|
|
88
|
+
// between them may be a server that died, whose claim lapses within minutes, so that run gets the shorter window.
|
|
89
|
+
async #commit(uploadId: string) {
|
|
90
|
+
const limit = performance.now() + this.#commitLimitMs;
|
|
91
|
+
let cutOffSince: number | null = null;
|
|
92
|
+
for (;;) {
|
|
93
|
+
try {
|
|
94
|
+
return await this.#api.commitUpdateUpload(this.#devProjectId, uploadId);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const now = performance.now();
|
|
97
|
+
if (CloudApi.statusOf(error) === 409) cutOffSince = null;
|
|
98
|
+
else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
|
|
99
|
+
else throw error;
|
|
100
|
+
const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
|
|
101
|
+
if (now + this.#commitRetryMs > deadline) throw error;
|
|
102
|
+
Logger.rawLog(
|
|
103
|
+
`The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
|
|
104
|
+
);
|
|
105
|
+
await sleep(this.#commitRetryMs);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static #cutOff(error: unknown) {
|
|
111
|
+
const status = CloudApi.statusOf(error);
|
|
112
|
+
if (status === null) return !(error instanceof SyntaxError);
|
|
113
|
+
return status === 502 || status === 504 || (status >= 520 && status <= 527);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -288,9 +288,10 @@ export const getInternalArgumentValue = async (
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
export const runCommands = async (...commands: CommandCls[]) => {
|
|
291
|
+
let failureExitCode = 1;
|
|
291
292
|
process.on("unhandledRejection", (error) => {
|
|
292
293
|
printCliError(error);
|
|
293
|
-
process.exit(
|
|
294
|
+
process.exit(failureExitCode);
|
|
294
295
|
});
|
|
295
296
|
const __dirname = getDirname(import.meta.url);
|
|
296
297
|
const packageJsonCandidates = [`${path.dirname(Bun.main)}/package.json`, `${__dirname}/../package.json`];
|
|
@@ -335,6 +336,10 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
335
336
|
let programCommand = program.command(commandName, {
|
|
336
337
|
hidden: targetMeta.targetOption.devOnly,
|
|
337
338
|
});
|
|
339
|
+
const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
|
|
340
|
+
//? commander exits 1 on a flag or argument it refuses, before any action runs.
|
|
341
|
+
if (targetFailureExitCode !== undefined)
|
|
342
|
+
programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
|
|
338
343
|
const [allArgMetas] = getArgMetas(command, targetMeta.key);
|
|
339
344
|
for (const argMeta of allArgMetas) {
|
|
340
345
|
if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
|
|
@@ -364,6 +369,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
364
369
|
};
|
|
365
370
|
|
|
366
371
|
programCommand.action(async (...args: unknown[]) => {
|
|
372
|
+
failureExitCode = targetFailureExitCode ?? 1;
|
|
367
373
|
if (!targetMeta.targetOption.stdio) Logger.rawLog();
|
|
368
374
|
const cmdArgs = args.slice(0, args.length - 2);
|
|
369
375
|
const opt = args[args.length - 2] as Record<string, unknown>;
|
|
@@ -413,6 +419,6 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
413
419
|
// Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
|
|
414
420
|
await program.parseAsync(process.argv).catch((error: unknown) => {
|
|
415
421
|
printCliError(error);
|
|
416
|
-
process.exit(
|
|
422
|
+
process.exit(failureExitCode);
|
|
417
423
|
});
|
|
418
424
|
};
|
package/executors.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { DevGeneratedIndexSync } from "./frontendBuild/devGeneratedIndexSync";
|
|
15
15
|
import { AppInfo } from "./scanInfo";
|
|
16
16
|
import {
|
|
17
|
+
createTempApp,
|
|
17
18
|
createTempLib,
|
|
18
19
|
formatWithBiome,
|
|
19
20
|
hasBiome,
|
|
@@ -167,6 +168,26 @@ describe("Executor filesystem helpers", () => {
|
|
|
167
168
|
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
168
169
|
});
|
|
169
170
|
|
|
171
|
+
test("an app hands its scaffolded files to the formatter too", async () => {
|
|
172
|
+
const { root, app } = trackRoot(await createTempApp("demo"));
|
|
173
|
+
const formatted: string[] = [];
|
|
174
|
+
app.getLinter = () =>
|
|
175
|
+
({
|
|
176
|
+
fixFiles: async (filePaths: string[]) => {
|
|
177
|
+
formatted.push(...filePaths);
|
|
178
|
+
return { fixed: [] };
|
|
179
|
+
},
|
|
180
|
+
}) as unknown as ReturnType<Executor["getLinter"]>;
|
|
181
|
+
|
|
182
|
+
await app.applyTemplate({
|
|
183
|
+
basePath: "page/task",
|
|
184
|
+
template: "crudSinglePage",
|
|
185
|
+
dict: { model: "task", appName: "demo" },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
189
|
+
});
|
|
190
|
+
|
|
170
191
|
test("a formatter that cannot run does not fail the scaffold", async () => {
|
|
171
192
|
// create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
|
|
172
193
|
const root = await makeTempRoot();
|
package/executors.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
scanScopeRecipes,
|
|
41
41
|
upsertAgentBlock,
|
|
42
42
|
} from "./agentsIndex";
|
|
43
|
-
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
43
|
+
import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
44
44
|
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
45
45
|
import { CodegenLock } from "./codegenLock";
|
|
46
46
|
import { FileSys, getDirname } from "./fileSys";
|
|
@@ -533,6 +533,7 @@ export class Executor {
|
|
|
533
533
|
async applyTemplate(options: {
|
|
534
534
|
basePath: string;
|
|
535
535
|
template: string;
|
|
536
|
+
scanInfo?: AppInfo | LibInfo | null;
|
|
536
537
|
dict?: { [key: string]: string };
|
|
537
538
|
options?: { [key: string]: unknown };
|
|
538
539
|
overwrite?: boolean;
|
|
@@ -707,6 +708,12 @@ export class WorkspaceExecutor extends Executor {
|
|
|
707
708
|
async scan(): Promise<WorkspaceInfo> {
|
|
708
709
|
return await WorkspaceInfo.fromExecutor(this);
|
|
709
710
|
}
|
|
711
|
+
#akanConfig: AkanWorkspaceConfig | null = null;
|
|
712
|
+
async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
713
|
+
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
714
|
+
this.#akanConfig = await AkanWorkspaceConfig.from(this, { bustImportCache: refresh });
|
|
715
|
+
return this.#akanConfig;
|
|
716
|
+
}
|
|
710
717
|
async getApps() {
|
|
711
718
|
return await this.#getDirHasFile(`${this.workspaceRoot}/apps`, "akan.config.ts");
|
|
712
719
|
}
|
|
@@ -1091,7 +1098,7 @@ export class SysExecutor extends Executor {
|
|
|
1091
1098
|
overwrite?: boolean;
|
|
1092
1099
|
}): Promise<FileContent[]> {
|
|
1093
1100
|
const scanInfo = await this.scan();
|
|
1094
|
-
const fileContents = await
|
|
1101
|
+
const fileContents = await super.applyTemplate({ ...options, scanInfo });
|
|
1095
1102
|
await this.scan();
|
|
1096
1103
|
return fileContents;
|
|
1097
1104
|
}
|
|
@@ -18,11 +18,19 @@ export class CsrArtifactBuilder {
|
|
|
18
18
|
#app: App;
|
|
19
19
|
#command: "build" | "start";
|
|
20
20
|
#lang: string;
|
|
21
|
+
#cssAssets: Record<string, CssAsset> | null;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
//? `cssAssets` is a dev batch's current stylesheets: `base-artifact.json` keeps the ones the boot build compiled.
|
|
24
|
+
constructor(
|
|
25
|
+
app: App,
|
|
26
|
+
command: "build" | "start" = "start",
|
|
27
|
+
lang = "en",
|
|
28
|
+
{ cssAssets = null }: { cssAssets?: Record<string, CssAsset> | null } = {},
|
|
29
|
+
) {
|
|
23
30
|
this.#app = app;
|
|
24
31
|
this.#command = command;
|
|
25
32
|
this.#lang = lang;
|
|
33
|
+
this.#cssAssets = cssAssets;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
async build(): Promise<BuildCsrArtifactResult | null> {
|
|
@@ -152,6 +160,7 @@ void bootCsr(pages);
|
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
async #loadCssAssets(): Promise<Record<string, CssAsset>> {
|
|
163
|
+
if (this.#cssAssets) return this.#cssAssets;
|
|
155
164
|
const artifactFile = Bun.file(path.join(this.#artifactDir, "base-artifact.json"));
|
|
156
165
|
if (!(await artifactFile.exists())) return {};
|
|
157
166
|
const artifact = (await artifactFile.json()) as Partial<Pick<BaseBuildArtifact, "cssAssets">>;
|
|
@@ -23,6 +23,16 @@ describe("CssCandidateCache token extraction", () => {
|
|
|
23
23
|
expect(candidates).not.toContain("_td");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("keeps Tailwind v4's variable shorthand whole, and leaves a call's argument its own candidate", async () => {
|
|
27
|
+
const candidates = await scan(
|
|
28
|
+
`const c = "max-md:top-(--keyboard-top) min-h-(--x) bg-(color:--brand)/50"; cn("flex"); fn(value);`,
|
|
29
|
+
);
|
|
30
|
+
expect(candidates).toEqual(
|
|
31
|
+
expect.arrayContaining(["max-md:top-(--keyboard-top)", "min-h-(--x)", "bg-(color:--brand)/50", "flex"]),
|
|
32
|
+
);
|
|
33
|
+
expect(candidates).not.toContain("-keyboard-top");
|
|
34
|
+
});
|
|
35
|
+
|
|
26
36
|
test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
|
|
27
37
|
const candidates = await scan(
|
|
28
38
|
`type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
|
|
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
|
|
3
3
|
// The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole. Brackets hold no whitespace: a labelled
|
|
4
4
|
// tuple type scanned as a class compiles to a rule lightningcss cannot parse, and it then drops every variant rule.
|
|
5
|
-
|
|
5
|
+
// Parentheses pass only as Tailwind v4's variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields
|
|
6
|
+
// `flex` on its own.
|
|
7
|
+
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:(?:\[[^\]\s]+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
|
|
6
8
|
|
|
7
9
|
interface CachedFile {
|
|
8
10
|
mtimeMs: number;
|
|
@@ -18,7 +20,7 @@ interface CacheFile {
|
|
|
18
20
|
/** Tailwind candidates per source file, on disk: the batch worker that compiles CSS exits before the next save. */
|
|
19
21
|
export class CssCandidateCache {
|
|
20
22
|
// Bump when the token regex or the entry shape changes, so stale extractions are not reused.
|
|
21
|
-
static readonly #version =
|
|
23
|
+
static readonly #version = 4;
|
|
22
24
|
readonly #path: string;
|
|
23
25
|
readonly #entries = new Map<string, CachedFile>();
|
|
24
26
|
#dirty = false;
|
|
@@ -32,11 +32,13 @@ export class PagesBundleBuilder {
|
|
|
32
32
|
#command: "build" | "start";
|
|
33
33
|
#pageEntries?: PageEntry[];
|
|
34
34
|
#started = Date.now();
|
|
35
|
+
#serverEnv: BunPlugin | undefined;
|
|
35
36
|
|
|
36
|
-
constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[]) {
|
|
37
|
+
constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[], serverEnv?: BunPlugin) {
|
|
37
38
|
this.#app = app;
|
|
38
39
|
this.#command = command;
|
|
39
40
|
this.#pageEntries = pageEntries;
|
|
41
|
+
this.#serverEnv = serverEnv;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
async build(): Promise<BuildPagesBundleResult> {
|
|
@@ -67,6 +69,8 @@ export class PagesBundleBuilder {
|
|
|
67
69
|
define: bundleDefine(this.#app, this.#command, "ssr"),
|
|
68
70
|
metafile: dev,
|
|
69
71
|
plugins: [
|
|
72
|
+
//? First: the use-client plugins below load every module, and an env file must be swapped before they do.
|
|
73
|
+
...(this.#serverEnv ? [this.#serverEnv] : []),
|
|
70
74
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
71
75
|
PagesBundleBuilder.createCssStubPlugin(),
|
|
72
76
|
PagesBundleBuilder.createServerUseClientFetchPlugin(),
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { optimize } from "@tailwindcss/node";
|
|
3
3
|
import type { AkanNativeAppId, AkanWebConfig } from "akanjs";
|
|
4
4
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
5
|
+
import type { BunPlugin } from "bun";
|
|
5
6
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
6
7
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
7
8
|
import type { App } from "../commandDecorators";
|
|
@@ -29,10 +30,13 @@ export class SsrBaseArtifactBuilder {
|
|
|
29
30
|
#artifactDir: string;
|
|
30
31
|
#absArtifactDir: string;
|
|
31
32
|
#started = Date.now();
|
|
33
|
+
#serverEnv: BunPlugin | undefined;
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/** `serverEnv`: the build's server env plugin, so the route bundle carries the env files the backend does and no more. */
|
|
36
|
+
constructor(app: App, command: "build" | "start" = "start", { serverEnv }: { serverEnv?: BunPlugin } = {}) {
|
|
34
37
|
this.#app = app;
|
|
35
38
|
this.#command = command;
|
|
39
|
+
this.#serverEnv = serverEnv;
|
|
36
40
|
this.#artifactDir = `${command === "build" ? app.dist.cwdPath : app.cwdPath}/.akan/artifact`;
|
|
37
41
|
this.#absArtifactDir = path.resolve(this.#artifactDir);
|
|
38
42
|
}
|
|
@@ -52,7 +56,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
52
56
|
|
|
53
57
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
54
58
|
const { cssCompiler, optimizedFonts, cssAssets } = await this.#buildStyleAssets();
|
|
55
|
-
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries).build();
|
|
59
|
+
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries, this.#serverEnv).build();
|
|
56
60
|
this.#app.verbose(
|
|
57
61
|
`[base-artifact] pages bundle -> ${pagesBundle.bundlePath} (buildId=${pagesBundle.buildId}, splitting=${pagesBundle.splitting}, entry=${pagesBundle.entryBytes} bytes, outputs=${pagesBundle.outputCount}, chunks=${pagesBundle.chunkCount}, total=${pagesBundle.outputBytes} bytes)`,
|
|
58
62
|
);
|
|
@@ -35,6 +35,7 @@ class BuildBatch {
|
|
|
35
35
|
async run(): Promise<BuildBatchResult> {
|
|
36
36
|
if (this.#request.needs.includes("base")) await this.#buildBase();
|
|
37
37
|
if (this.#request.needs.includes("csr")) await this.#buildCsr();
|
|
38
|
+
if (this.#request.needs.includes("shell")) await this.#buildShell();
|
|
38
39
|
if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
|
|
39
40
|
// Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
|
|
40
41
|
if (this.#request.needs.includes("css")) await this.#buildCss();
|
|
@@ -72,7 +73,7 @@ class BuildBatch {
|
|
|
72
73
|
const message = ApplicationBuildReporter.formatError(err, this.#request.workspaceRoot);
|
|
73
74
|
this.#logger.error(`${label} failed: ${message}`);
|
|
74
75
|
this.#result.errors[need] = message;
|
|
75
|
-
if (need !== "base") this.#emitStatus(need, message);
|
|
76
|
+
if (need !== "base" && need !== "shell") this.#emitStatus(need, message);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Streams nothing: the builder is not serving yet, so the watcher reads the outcome from the batch result.
|
|
@@ -99,8 +100,19 @@ class BuildBatch {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
//? No `csr-updated`: under the registry no tab runs this build, and the reload would send every CSR tab back to it.
|
|
104
|
+
async #buildShell(): Promise<void> {
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
try {
|
|
107
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).build();
|
|
108
|
+
this.#logger.verbose(`csr-shell ok (${Date.now() - started}ms)`);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
this.#fail("shell", "csr-shell", err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
async #rebuildCsrArtifact(started: number): Promise<void> {
|
|
103
|
-
await new CsrArtifactBuilder(this.#app).build();
|
|
115
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).build();
|
|
104
116
|
this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
|
|
105
117
|
// A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
|
|
106
118
|
if (this.#request.changedFiles.length === 0) return;
|
|
@@ -3,8 +3,9 @@ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from
|
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
// `base` is the boot build: it always runs alone, before the builder can serve anything.
|
|
7
|
-
|
|
6
|
+
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the single-file
|
|
7
|
+
// CSR build an MCP Apps view needs under the dev CSR registry, made on request and reported to no tab.
|
|
8
|
+
export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr" | "shell";
|
|
8
9
|
|
|
9
10
|
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
10
11
|
export interface BuildBatchRequest {
|
|
@@ -82,6 +82,102 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
|
|
|
82
82
|
expect(log).toContain(reason);
|
|
83
83
|
}, 30_000);
|
|
84
84
|
|
|
85
|
+
test("builds a view's shell once on request, answers with its failure, and reports it to no tab", async () => {
|
|
86
|
+
const { root } = track(await createTempApp("demo"));
|
|
87
|
+
const appDir = path.join(root, "apps/demo");
|
|
88
|
+
// A stand-in batch: boots, and fails a shell batch by naming the needs it was handed.
|
|
89
|
+
await writeText(
|
|
90
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
91
|
+
`const { generation, needs } = JSON.parse(process.argv[2]);
|
|
92
|
+
const errors = needs.includes("base") ? {} : { shell: "handed " + needs.join(",") };
|
|
93
|
+
process.send({ type: "build-batch-result", data: { generation, errors, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
94
|
+
`,
|
|
95
|
+
);
|
|
96
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
97
|
+
const messages: BuilderMessage[] = [];
|
|
98
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
99
|
+
cwd: appDir,
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
103
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
104
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
105
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
106
|
+
AKAN_PUBLIC_ENV: "local",
|
|
107
|
+
AKAN_WATCH: "0",
|
|
108
|
+
},
|
|
109
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
110
|
+
serialization: "advanced",
|
|
111
|
+
ipc: (message: BuilderMessage) => {
|
|
112
|
+
messages.push(message);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
117
|
+
proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
118
|
+
await until(proc, "build-csr-res", () => messages.some((message) => message.type === "build-csr-res"));
|
|
119
|
+
} finally {
|
|
120
|
+
proc.kill();
|
|
121
|
+
await proc.exited;
|
|
122
|
+
}
|
|
123
|
+
expect(messages.find((message) => message.type === "build-csr-res")).toEqual({
|
|
124
|
+
type: "build-csr-res",
|
|
125
|
+
id: 7,
|
|
126
|
+
ok: false,
|
|
127
|
+
error: "handed shell",
|
|
128
|
+
});
|
|
129
|
+
expect(messages.some((message) => message.type === "build-status")).toBe(false);
|
|
130
|
+
}, 30_000);
|
|
131
|
+
|
|
132
|
+
test("answers a second shell request in the same generation without building again", async () => {
|
|
133
|
+
const { root } = track(await createTempApp("demo"));
|
|
134
|
+
const appDir = path.join(root, "apps/demo");
|
|
135
|
+
const ledger = path.join(root, "batches.log");
|
|
136
|
+
await writeText(
|
|
137
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
138
|
+
`const { generation, needs } = JSON.parse(process.argv[2]);
|
|
139
|
+
require("node:fs").appendFileSync(${JSON.stringify(ledger)}, needs.join(",") + "\\n");
|
|
140
|
+
process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
141
|
+
`,
|
|
142
|
+
);
|
|
143
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
144
|
+
const messages: BuilderMessage[] = [];
|
|
145
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
146
|
+
cwd: appDir,
|
|
147
|
+
env: {
|
|
148
|
+
...process.env,
|
|
149
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
150
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
151
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
152
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
153
|
+
AKAN_PUBLIC_ENV: "local",
|
|
154
|
+
AKAN_WATCH: "0",
|
|
155
|
+
},
|
|
156
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
157
|
+
serialization: "advanced",
|
|
158
|
+
ipc: (message: BuilderMessage) => {
|
|
159
|
+
messages.push(message);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const answers = () => messages.filter((message) => message.type === "build-csr-res");
|
|
163
|
+
try {
|
|
164
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
165
|
+
proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
166
|
+
await until(proc, "the first build-csr-res", () => answers().length === 1);
|
|
167
|
+
proc.send({ type: "build-csr", id: 8, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
168
|
+
await until(proc, "the second build-csr-res", () => answers().length === 2);
|
|
169
|
+
} finally {
|
|
170
|
+
proc.kill();
|
|
171
|
+
await proc.exited;
|
|
172
|
+
}
|
|
173
|
+
expect(answers()).toEqual([
|
|
174
|
+
{ type: "build-csr-res", id: 7, ok: true },
|
|
175
|
+
{ type: "build-csr-res", id: 8, ok: true },
|
|
176
|
+
]);
|
|
177
|
+
const batches = (await Bun.file(ledger).text()).trim().split("\n");
|
|
178
|
+
expect(batches.filter((needs) => needs === "shell")).toHaveLength(1);
|
|
179
|
+
}, 30_000);
|
|
180
|
+
|
|
85
181
|
test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
|
|
86
182
|
// Inside the checkout so the route's client bundle resolves the framework from its source.
|
|
87
183
|
const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
|
|
@@ -63,6 +63,7 @@ class IncrementalBuilder {
|
|
|
63
63
|
#autoImportSync: AutoImportSync;
|
|
64
64
|
#watcher: HmrWatcher | null = null;
|
|
65
65
|
#generation = 0;
|
|
66
|
+
#shellGeneration: number | null = null;
|
|
66
67
|
#discoveredThrough = 0;
|
|
67
68
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
68
69
|
#csrBundler: CsrDevBundler;
|
|
@@ -798,7 +799,7 @@ class IncrementalBuilder {
|
|
|
798
799
|
if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
|
|
799
800
|
// `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
|
|
800
801
|
for (const need of crashedNeeds)
|
|
801
|
-
if (need !== "base")
|
|
802
|
+
if (need !== "base" && need !== "shell")
|
|
802
803
|
this.#sendBuildStatus(need, {
|
|
803
804
|
generation,
|
|
804
805
|
ok: false,
|
|
@@ -874,6 +875,10 @@ class IncrementalBuilder {
|
|
|
874
875
|
|
|
875
876
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
876
877
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
878
|
+
if (msg.shell) {
|
|
879
|
+
await this.#buildShell(msg);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
877
882
|
//? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
|
|
878
883
|
this.#beginCsrArm();
|
|
879
884
|
this.#csrActive = true;
|
|
@@ -903,6 +908,30 @@ class IncrementalBuilder {
|
|
|
903
908
|
await armed;
|
|
904
909
|
}
|
|
905
910
|
|
|
911
|
+
// Asked on every view read and rebuilt only when a save moved the generation, instead of armed per save: a restarted
|
|
912
|
+
// builder has no last build to trust, and so rebuilds once on the next read.
|
|
913
|
+
async #buildShell(msg: BuilderCsrReq): Promise<void> {
|
|
914
|
+
await this.#workQueue.enqueue("build-shell", async (): Promise<void> => {
|
|
915
|
+
const generation = this.#generation;
|
|
916
|
+
let error: string | undefined;
|
|
917
|
+
if (this.#shellGeneration !== generation)
|
|
918
|
+
try {
|
|
919
|
+
const result = await this.#batchRunner.run(
|
|
920
|
+
await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }),
|
|
921
|
+
);
|
|
922
|
+
error = result.errors.shell;
|
|
923
|
+
if (!error) this.#shellGeneration = generation;
|
|
924
|
+
} catch (err) {
|
|
925
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
926
|
+
}
|
|
927
|
+
await BuilderChannel.send(
|
|
928
|
+
error
|
|
929
|
+
? { type: "build-csr-res", id: msg.id, ok: false, error }
|
|
930
|
+
: { type: "build-csr-res", id: msg.id, ok: true },
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
906
935
|
//? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
|
|
907
936
|
//? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
|
|
908
937
|
//? patch CSR first while the second still builds.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises"; // @ok
|
|
2
|
+
import { createServer } from "node:net"; // @ok
|
|
3
|
+
import { tmpdir } from "node:os"; // @ok
|
|
4
|
+
import { Database } from "bun:sqlite"; // @ok
|
|
5
|
+
import { describe, expect, test } from "bun:test"; // @ok
|
|
6
|
+
import { fetch } from "../sig"; // @ok
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A module test still reaches vendors through a lib, but it may use the runtime's own primitives — a temp dir, a
|
|
5
|
+
// socket, sqlite — that a module file leaves to srvkit/.
|
|
6
|
+
JsModuleSource() as $source where {
|
|
7
|
+
$source <: within JsImport(),
|
|
8
|
+
not $source <: or {
|
|
9
|
+
r"\"\..*",
|
|
10
|
+
r"\"akanjs.*",
|
|
11
|
+
r"\"@akanjs.*",
|
|
12
|
+
r"\"@playwright.*",
|
|
13
|
+
r"\"@apps/.*",
|
|
14
|
+
r"\"@libs/.*",
|
|
15
|
+
r"\"@pkgs/.*",
|
|
16
|
+
r"\"bun:.*",
|
|
17
|
+
r"\"node:.*",
|
|
18
|
+
r"\"react.*"
|
|
19
|
+
},
|
|
20
|
+
register_diagnostic(
|
|
21
|
+
span = $source,
|
|
22
|
+
message = "External libraries should not be imported in a module test. Only internal packages and the runtime's node: and bun: modules are permitted."
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -46,6 +46,12 @@ describe("DesktopServerStage", () => {
|
|
|
46
46
|
});
|
|
47
47
|
expect(env.PORT).toBeUndefined();
|
|
48
48
|
expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
|
|
49
|
+
expect(env.AKAN_MODULES).toBeUndefined();
|
|
50
|
+
expect(DesktopServerStage.env(appConfig(), "main", { modules: ["@edge", "edgeAgent"] }).AKAN_MODULES).toBe(
|
|
51
|
+
"@edge,edgeAgent",
|
|
52
|
+
);
|
|
53
|
+
const withEnv = DesktopServerStage.env(appConfig(), "main", { env: { CLOUD_ORIGIN: "https://cloud.example" } });
|
|
54
|
+
expect(withEnv).toMatchObject({ CLOUD_ORIGIN: "https://cloud.example", AKAN_PUBLIC_OPERATION_MODE: "edge" });
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
|