@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +82 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +195 -0
  19. package/cloud/updatePackUpload.ts +115 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +1 -0
  40. package/mobile/nativeApp.ts +96 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. 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(1);
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(1);
422
+ process.exit(failureExitCode);
417
423
  });
418
424
  };
@@ -43,6 +43,7 @@ export interface TargetOption {
43
43
  desc?: string;
44
44
  runsOnWorkspaceRoot?: boolean;
45
45
  stdio?: boolean;
46
+ failureExitCode?: number;
46
47
  }
47
48
 
48
49
  export const Commands = () => {
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 this._applyTemplate({ ...options, scanInfo, dict: withCapitalizedDict(options.dict) });
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
- constructor(app: App, command: "build" | "start" = "start", lang = "en") {
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
- const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
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 = 3;
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
- constructor(app: App, command: "build" | "start" = "start") {
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
- export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr";
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 dayjs from "dayjs"; // @flag
2
+ import { z } from "zod"; // @flag
3
+ import fs from "node:fs"; // @ok
4
+ import { Database } from "bun:sqlite"; // @ok
5
+ import { describe } from "bun:test"; // @ok
6
+ import { cnst } from "../cnst"; // @ok
@@ -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", () => {