@akanjs/devkit 3.0.2-rc.0 → 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 (76) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +16 -4
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +77 -7
  7. package/akanConfig/akanConfig.ts +111 -13
  8. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  9. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  10. package/akanConfig/index.ts +1 -0
  11. package/akanConfig/types.ts +23 -1
  12. package/akanContext.ts +1 -1
  13. package/applicationBuildRunner.test.ts +1 -1
  14. package/applicationBuildRunner.ts +73 -84
  15. package/artifact/implicitRootLayout.test.ts +6 -2
  16. package/artifact/implicitRootLayout.ts +12 -7
  17. package/biome.base.json +7 -2
  18. package/cloud/cloudApi.ts +82 -0
  19. package/cloud/globalConfig.ts +4 -0
  20. package/cloud/index.ts +1 -0
  21. package/cloud/updatePackUpload.test.ts +195 -0
  22. package/cloud/updatePackUpload.ts +115 -0
  23. package/commandDecorators/command.ts +8 -2
  24. package/commandDecorators/targetMeta.ts +1 -0
  25. package/createTunnel.test.ts +51 -0
  26. package/createTunnel.ts +32 -14
  27. package/executors.test.ts +21 -0
  28. package/executors.ts +9 -2
  29. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  30. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  31. package/frontendBuild/cssCandidateCache.ts +4 -2
  32. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  33. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  34. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  35. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  36. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  37. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  38. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  39. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  40. package/lint/no-import-external-library-in-test.grit +24 -0
  41. package/mobile/desktopServerStage.test.ts +6 -0
  42. package/mobile/desktopServerStage.ts +25 -3
  43. package/mobile/index.ts +1 -0
  44. package/mobile/nativeApp.test.ts +1 -0
  45. package/mobile/nativeApp.ts +96 -34
  46. package/mobile/nativeConfig.test.ts +59 -1
  47. package/mobile/nativeConfig.ts +13 -3
  48. package/mobile/serverBundleSecrets.test.ts +61 -0
  49. package/mobile/serverBundleSecrets.ts +85 -0
  50. package/mobile/updateUploader.test.ts +49 -0
  51. package/mobile/updateUploader.ts +71 -0
  52. package/package.json +3 -2
  53. package/pipeline/PipelineCompiler.test.ts +631 -0
  54. package/pipeline/PipelineCompiler.ts +646 -0
  55. package/pipeline/PipelineDefinition.test.ts +89 -0
  56. package/pipeline/PipelineDefinition.ts +117 -0
  57. package/pipeline/PipelineDryRun.test.ts +93 -0
  58. package/pipeline/PipelineDryRun.ts +89 -0
  59. package/pipeline/PipelineInputReader.ts +739 -0
  60. package/pipeline/index.ts +4 -0
  61. package/qualityScanner.test.ts +9 -3
  62. package/qualityScanner.ts +5 -2
  63. package/scanInfo.ts +13 -6
  64. package/subspace.test.ts +108 -4
  65. package/subspace.ts +162 -10
  66. package/subspaceConfig.ts +11 -14
  67. package/testHelpers.ts +4 -0
  68. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  69. package/transforms/serverEnvPlugin.test.ts +93 -2
  70. package/transforms/serverEnvPlugin.ts +71 -18
  71. package/transforms/serverEnvProfile.test.ts +82 -0
  72. package/transforms/serverEnvProfile.ts +114 -0
  73. package/typecheck/projectTypecheck.test.ts +32 -0
  74. package/typecheck/projectTypecheck.ts +71 -0
  75. package/workspaceLayout.test.ts +80 -3
  76. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,63 @@
1
+ import path from "node:path";
2
+ import type { SubspaceConfigInput, WorkspaceConfig, WorkspacePipelineConfig } from "akanjs";
3
+ import type { WorkspaceExecutor } from "../executors";
4
+ import { FileSys } from "../fileSys";
5
+ import { AkanAppConfig } from "./akanConfig";
6
+
7
+ /** The workspace root's own `akan.config.ts`: settings that belong to no app. Absent, it is an empty one. */
8
+ export class AkanWorkspaceConfig {
9
+ static readonly fileName = "akan.config.ts";
10
+ static readonly #keys = ["subspace", "pipeline", "cloud"];
11
+ static readonly #loopbackHosts = ["localhost", "127.0.0.1", "[::1]"];
12
+
13
+ static async from(workspace: WorkspaceExecutor, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
14
+ const file = path.join(workspace.workspaceRoot, AkanWorkspaceConfig.fileName);
15
+ if (!(await FileSys.fileExists(file))) return new AkanWorkspaceConfig(null, {});
16
+ const input = await AkanAppConfig.importConfigModule(workspace.workspaceRoot, { bustImportCache });
17
+ return new AkanWorkspaceConfig(file, input);
18
+ }
19
+
20
+ /** Absolute path, or null when the workspace has none. */
21
+ readonly file: string | null;
22
+ readonly subspace: SubspaceConfigInput | null;
23
+ readonly pipeline: WorkspacePipelineConfig | null;
24
+ /** `cloud.host` without a trailing slash, or null for Akan Cloud. */
25
+ readonly cloudHost: string | null;
26
+
27
+ constructor(file: string | null, input: unknown) {
28
+ this.file = file;
29
+ if (typeof input !== "object" || input === null || Array.isArray(input))
30
+ throw new Error("The workspace root akan.config.ts must default-export a WorkspaceConfig object.");
31
+ for (const key of Object.keys(input))
32
+ if (!AkanWorkspaceConfig.#keys.includes(key))
33
+ throw new Error(
34
+ `The workspace root akan.config.ts declares \`${key}\`, which it does not take: it holds \`subspace\`, \`pipeline\` and \`cloud\`, and app or lib settings belong in apps/<app>/akan.config.ts or libs/<lib>/akan.config.ts.`,
35
+ );
36
+ const config = input as WorkspaceConfig;
37
+ this.subspace = config.subspace ?? null;
38
+ this.pipeline = config.pipeline ?? null;
39
+ this.cloudHost = AkanWorkspaceConfig.#cloudHost(config.cloud);
40
+ }
41
+
42
+ //? The CLI sends its cloud login token to this host, so plain http is refused anywhere but this computer.
43
+ static #cloudHost(cloud: unknown): string | null {
44
+ if (cloud === undefined) return null;
45
+ const refuse = (why: string): never => {
46
+ throw new Error(`The workspace root akan.config.ts has cloud ${why}.`);
47
+ };
48
+ if (typeof cloud !== "object" || cloud === null || Array.isArray(cloud)) return refuse("that is not an object");
49
+ const unknown = Object.keys(cloud).filter((key) => key !== "host");
50
+ if (unknown.length) return refuse(`keys it does not take: ${unknown.join(", ")} (only \`host\`)`);
51
+ const { host } = cloud as { host?: unknown };
52
+ if (host === undefined) return null;
53
+ const url = typeof host === "string" && URL.canParse(host) ? new URL(host) : null;
54
+ if (!url || url.search || url.hash || url.username || url.password)
55
+ return refuse(`.host ${JSON.stringify(host)}, which is not a URL like "https://cloud.example.com"`);
56
+ if (
57
+ url.protocol !== "https:" &&
58
+ !(url.protocol === "http:" && AkanWorkspaceConfig.#loopbackHosts.includes(url.hostname))
59
+ )
60
+ return refuse(`.host ${JSON.stringify(host)}: https only, or http on this computer`);
61
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
62
+ }
63
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./akanBin";
2
2
  export * from "./akanConfig";
3
+ export * from "./akanWorkspaceConfig";
3
4
  export * from "./types";
@@ -30,6 +30,10 @@ export type {
30
30
  AppConfig,
31
31
  AppConfigInput,
32
32
  AppConfigResult,
33
+ AppPipelineConfig,
34
+ AppPipelineContext,
35
+ AppPipelineInput,
36
+ AppPipelineStageInput,
33
37
  AppScanResult,
34
38
  Arch,
35
39
  BinPlatform,
@@ -48,8 +52,26 @@ export type {
48
52
  LibScanResult,
49
53
  NativeEnv,
50
54
  NativePermission,
55
+ PipelineActionStageInput,
56
+ PipelineContext,
57
+ PipelineDuration,
58
+ PipelineGlob,
59
+ PipelineMatrixStageInput,
60
+ PipelineParallelStageInput,
61
+ PipelinePostInput,
62
+ PipelineShellStageInput,
63
+ PipelineStageBase,
64
+ PipelineStepInput,
65
+ PipelineStepLike,
66
+ PipelineWhen,
51
67
  PkgScanResult,
52
68
  ScanResult,
69
+ SubspaceConfigInput,
70
+ SubspaceDeclaration,
71
+ WorkspaceConfig,
72
+ WorkspacePipelineConfig,
73
+ WorkspacePipelineInput,
74
+ WorkspacePipelineStageInput,
53
75
  WorkspaceScanResult,
54
76
  } from "akanjs";
55
- export { archs, binPlatforms } from "akanjs";
77
+ export { archs, binPlatforms, defaultAppPipeline } from "akanjs";
package/akanContext.ts CHANGED
@@ -282,7 +282,7 @@ const generatedFiles = [
282
282
  const validationCommands = [
283
283
  "akan sync <app-or-lib>",
284
284
  "akan lint <app-or-lib-or-pkg>",
285
- "akan typecheck <app-name>",
285
+ "akan typecheck <app-or-lib>",
286
286
  "akan test <app-or-lib-or-pkg>",
287
287
  "akan build <app-name>",
288
288
  "akan doctor --strict --format json",
@@ -10,7 +10,7 @@ import { Executor } from "./executors";
10
10
  describe("ApplicationBuildRunner", () => {
11
11
  test("externalizes Akan optional backend dependencies", () => {
12
12
  expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
13
- expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
13
+ expect.arrayContaining(["bullmq", "ioredis", "postgres", "protobufjs"]),
14
14
  );
15
15
  });
16
16
 
@@ -1,4 +1,3 @@
1
- import { mkdir, rm } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import type { BunPlugin } from "bun";
4
3
  import type {
@@ -16,10 +15,14 @@ import {
16
15
  } from "./frontendBuild";
17
16
  import { Spinner } from "./spinner";
18
17
  import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
18
+ import { ServerEnvProfile } from "./transforms/serverEnvProfile";
19
+ import { ProjectTypecheck } from "./typecheck/projectTypecheck";
19
20
 
20
21
  export interface TypecheckOptions {
21
22
  clean?: boolean;
22
23
  incremental?: boolean;
24
+ /** The app's own tests and specs too (`akan typecheck`); a build leaves them out. */
25
+ tests?: boolean;
23
26
  }
24
27
 
25
28
  export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
@@ -32,6 +35,8 @@ export interface ApplicationBuildRunnerOptions {
32
35
  reporter?: BuildProgressReporter;
33
36
  /** The backend env the build is for, when it is not the workspace's own (a mobile or desktop build's `--env`). */
34
37
  environment?: string;
38
+ /** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
39
+ profile?: string;
35
40
  }
36
41
  export interface BuildOptions {
37
42
  spinner?: boolean;
@@ -67,34 +72,41 @@ const SSR_RENDER_EXTERNALS = [
67
72
  // is a no-op as of Bun 1.4.2.
68
73
  export const AKAN_BACKEND_MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
69
74
 
70
- export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
71
- "@libsql/client",
72
- "bullmq",
73
- "ioredis",
74
- "postgres",
75
- "protobufjs",
76
- ] as const;
75
+ export const AKAN_OPTIONAL_BACKEND_EXTERNALS = ["bullmq", "ioredis", "postgres", "protobufjs"] as const;
77
76
 
78
77
  export class ApplicationBuildRunner {
79
78
  #app: App;
80
79
  #fast: boolean;
81
80
  #reporter?: BuildProgressReporter;
82
81
  #environment?: string;
82
+ #profile?: string;
83
83
  #spinner?: boolean;
84
84
  #startedAt = Date.now();
85
85
  #phases: BuildPhaseResult[] = [];
86
86
 
87
- constructor(app: App, { fast = false, reporter, environment }: ApplicationBuildRunnerOptions = {}) {
87
+ constructor(app: App, { fast = false, reporter, environment, profile }: ApplicationBuildRunnerOptions = {}) {
88
88
  this.#app = app;
89
89
  this.#fast = fast;
90
90
  this.#reporter = reporter;
91
91
  this.#environment = environment;
92
+ this.#profile = profile;
93
+ }
94
+
95
+ get #envDir() {
96
+ return path.join(this.#app.cwdPath, "env");
92
97
  }
93
98
 
94
99
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
95
100
  // serial build is needed because of Bun.build is unstable for parallel build
96
101
  this.#spinner = spinner;
97
- const { web, assets } = await this.#app.getConfig();
102
+ const { web, assets, baseDevEnv } = await this.#app.getConfig();
103
+ //? Before any phase: a profile that would ship a secret stops the build, not the image after it.
104
+ if (this.#profile)
105
+ await ServerEnvProfile.assertShippable({
106
+ envDir: this.#envDir,
107
+ environment: this.#environment ?? baseDevEnv.env,
108
+ profile: this.#profile,
109
+ });
98
110
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"));
99
111
  if (!this.#fast) await this.#runPhase("typecheck", "Typechecking", () => this.typecheck());
100
112
  await this.#runPhase(
@@ -148,11 +160,31 @@ export class ApplicationBuildRunner {
148
160
  }
149
161
 
150
162
  async typecheck(options: TypecheckOptions = {}) {
151
- const { clean = false, incremental = true } = options;
152
163
  await this.#app.getPageKeys({ refresh: true });
153
- const { typecheckDir, tsconfigPath } = await this.#writeTypecheckTsconfig(incremental);
154
- if (clean) await rm(path.join(typecheckDir, "tsconfig.tsbuildinfo"), { force: true });
155
- await this.#checkProjectInChildProcess(tsconfigPath);
164
+ const typecheck = new ProjectTypecheck(
165
+ this.#app.cwdPath,
166
+ this.#app.workspace.workspaceRoot,
167
+ this.#app.getCommandEnv(),
168
+ );
169
+ //* TypeScript's `include` globs do not cross a symlink, so synced lib pages need their real path.
170
+ const libPageIncludes = (await this.#app.getPageRoots())
171
+ .filter((root) => root.keyPrefix)
172
+ .flatMap((root) => {
173
+ const rel = path.relative(typecheck.typecheckDir, root.realDir).split(path.sep).join("/");
174
+ return [`${rel}/**/*.ts`, `${rel}/**/*.tsx`];
175
+ });
176
+ await typecheck.run(
177
+ [
178
+ "../../main.ts",
179
+ "../../server.ts",
180
+ "../../client.ts",
181
+ "../../page/**/*.ts",
182
+ "../../page/**/*.tsx",
183
+ ...libPageIncludes,
184
+ "../../../../pkgs/akanjs/*/types/**/*.d.ts",
185
+ ],
186
+ options,
187
+ );
156
188
  }
157
189
 
158
190
  async #runPhase<T>(
@@ -209,6 +241,28 @@ export class ApplicationBuildRunner {
209
241
  return `${sha.stdout.toString().trim()}${suffix}`;
210
242
  }
211
243
 
244
+ #serverEnv: BunPlugin | null = null;
245
+ #warnedLibEnvs = new Set<string>();
246
+ //? One plugin for every server-side bundle of the build: the backend, the RSC worker and the SSR route bundle.
247
+ #serverEnvPlugin(baseEnv: string, branches: Iterable<string>): BunPlugin {
248
+ this.#serverEnv ??= createServerEnvPlugin({
249
+ envDir: this.#envDir,
250
+ //? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
251
+ environment: this.#environment ?? baseEnv,
252
+ environments: ["local", "testing", ...branches],
253
+ profile: this.#profile,
254
+ onLibEnv: (file, { profileFile, stripped }) => {
255
+ if (profileFile || !stripped.length || this.#warnedLibEnvs.has(file)) return;
256
+ this.#warnedLibEnvs.add(file);
257
+ const lib = path.relative(this.#app.workspace.workspaceRoot, file);
258
+ this.#app.logger.warn(
259
+ `${lib}: the "${this.#profile}" build leaves out ${stripped.join(", ")}. Give the lib ${path.basename(ServerEnvProfile.profileFileOf(file, this.#profile ?? ""))} to say what this profile runs with.`,
260
+ );
261
+ },
262
+ });
263
+ return this.#serverEnv;
264
+ }
265
+
212
266
  async #buildBackend() {
213
267
  const { externalLibs, web, baseDevEnv, branches } = await this.#app.getConfig();
214
268
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
@@ -223,12 +277,7 @@ export class ApplicationBuildRunner {
223
277
  // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
224
278
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
225
279
  } satisfies Omit<Bun.BuildConfig, "entrypoints">;
226
- const serverEnvPlugin = createServerEnvPlugin({
227
- envDir: path.join(this.#app.cwdPath, "env"),
228
- //? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
229
- environment: this.#environment ?? baseDevEnv.env,
230
- environments: ["local", "testing", ...branches],
231
- });
280
+ const serverEnvPlugin = this.#serverEnvPlugin(baseDevEnv.env, branches);
232
281
  const backendConfig = {
233
282
  ...sharedConfig,
234
283
  plugins: [this.#createExternalSpecifiersPlugin(externalLibs), serverEnvPlugin],
@@ -306,74 +355,14 @@ void run().catch((error) => {
306
355
  this.#app.log(`[cli] no route files under ${this.#app.cwdPath}/page — skipping SSR build`);
307
356
  return null;
308
357
  }
309
- const base = await new SsrBaseArtifactBuilder(this.#app, "build").build();
358
+ const { baseDevEnv, branches } = await this.#app.getConfig();
359
+ const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
360
+ serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
361
+ }).build();
310
362
  const allRoutes = await new AllRoutesBuilder(this.#app, base.artifact, "build").build();
311
363
  return { base, allRoutes };
312
364
  }
313
365
 
314
- async #writeTypecheckTsconfig(incremental: boolean) {
315
- const typecheckDir = path.join(this.#app.cwdPath, ".akan", "typecheck");
316
- await mkdir(typecheckDir, { recursive: true });
317
- //* TypeScript's `include` globs do not cross a symlink, so synced lib pages need their real path.
318
- const libPageIncludes = (await this.#app.getPageRoots())
319
- .filter((root) => root.keyPrefix)
320
- .flatMap((root) => {
321
- const rel = path.relative(typecheckDir, root.realDir).split(path.sep).join("/");
322
- return [`${rel}/**/*.ts`, `${rel}/**/*.tsx`];
323
- });
324
- const tsconfig = {
325
- extends: "../../tsconfig.json",
326
- compilerOptions: {
327
- noEmit: true,
328
- incremental,
329
- tsBuildInfoFile: "./tsconfig.tsbuildinfo",
330
- },
331
- include: [
332
- "../../main.ts",
333
- "../../server.ts",
334
- "../../client.ts",
335
- "../../page/**/*.ts",
336
- "../../page/**/*.tsx",
337
- ...libPageIncludes,
338
- "../../../../pkgs/akanjs/*/types/**/*.d.ts",
339
- ],
340
- references: [],
341
- };
342
- const tsconfigPath = path.join(typecheckDir, "tsconfig.json");
343
- await Bun.write(tsconfigPath, `${JSON.stringify(tsconfig, null, 2)}\n`);
344
- return { typecheckDir, tsconfigPath };
345
- }
346
-
347
- async #checkProjectInChildProcess(tsconfigPath: string) {
348
- const entry = await this.#resolveTypecheckWorkerEntry();
349
- const proc = Bun.spawn([process.execPath, entry], {
350
- cwd: this.#app.workspace.workspaceRoot,
351
- env: this.#app.getCommandEnv({
352
- AKAN_COMMAND_TYPE: "typecheck",
353
- AKAN_TYPECHECK_TSCONFIG: tsconfigPath,
354
- }),
355
- stdout: "pipe",
356
- stderr: "pipe",
357
- });
358
- const [stdout, stderr, exitCode] = await Promise.all([
359
- new Response(proc.stdout).text(),
360
- new Response(proc.stderr).text(),
361
- proc.exited,
362
- ]);
363
- if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
364
- }
365
-
366
- async #resolveTypecheckWorkerEntry() {
367
- const candidates = [
368
- path.join(this.#app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
369
- path.join(this.#app.workspace.workspaceRoot, "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
370
- path.join(import.meta.dir, "typecheck.proc.js"),
371
- path.join(import.meta.dir, "typecheck.proc.ts"),
372
- ];
373
- for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
374
- throw new Error(`[cli] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
375
- }
376
-
377
366
  async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
378
367
  const result = await Bun.build(config);
379
368
  if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
@@ -39,8 +39,9 @@ describe("resolveSsrPageEntries", () => {
39
39
  expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
40
40
  expect(generatedSource).not.toContain("<System.Provider");
41
41
  expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
42
- expect(generatedSource).toContain("if (userLayout.generateHead) return userLayout.generateHead(props);");
43
- expect(generatedSource).toContain("return inheritedLayout.head;");
42
+ // The ancestor root is a level of the route tree, which merges its head; this boundary returns only its own.
43
+ expect(generatedSource).toContain("[userLayout].map((layout)");
44
+ expect(generatedSource).toContain("return RouteHead.mergeNodes(heads);");
44
45
  expect(generatedSource).not.toContain("generateMetadata");
45
46
  expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
46
47
  expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
@@ -71,6 +72,9 @@ describe("resolveSsrPageEntries", () => {
71
72
  expect(generatedSource).toContain("<System.Provider");
72
73
  expect(generatedSource).toContain("export const theme = userLayout.theme ?? inheritedLayout.theme;");
73
74
  expect(generatedSource).toContain("theme={theme}");
75
+ expect(generatedSource).toContain("export const viewport = userLayout.viewport ?? inheritedLayout.viewport;");
76
+ expect(generatedSource).toContain("viewport={viewport}");
77
+ expect(generatedSource).toContain("[inheritedLayout, userLayout].map((layout)");
74
78
  expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
75
79
  expect(generatedSource).toContain(
76
80
  'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
@@ -189,11 +189,13 @@ async function writeGeneratedRootLayoutFile(opts: {
189
189
  : "";
190
190
  const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
191
191
  const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
192
+ //? Under an ancestor root boundary the route tree merges that root's head itself; merging it here would repeat it.
193
+ const headLayouts = opts.includeSystemProvider ? "[inheritedLayout, userLayout]" : "[userLayout]";
192
194
  const layoutExports = `export async function generateHead(props: PageProps) {
193
- if (userLayout.generateHead) return userLayout.generateHead(props);
194
- if (userLayout.head !== undefined) return userLayout.head;
195
- if (inheritedLayout.generateHead) return inheritedLayout.generateHead(props);
196
- return inheritedLayout.head;
195
+ const heads = await Promise.all(
196
+ ${headLayouts}.map((layout) => (layout.generateHead ? layout.generateHead(props) : layout.head)),
197
+ );
198
+ return RouteHead.mergeNodes(heads);
197
199
  }
198
200
 
199
201
  export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
@@ -204,7 +206,7 @@ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
204
206
  ${layoutBinding} return `;
205
207
  const source = opts.includeSystemProvider
206
208
  ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
207
- import { loadFonts, resolveRouteModule } from "akanjs/client";
209
+ import { loadFonts, resolveRouteModule, RouteHead } from "akanjs/client";
208
210
  import type { ReactNode } from "react";
209
211
  import { System } from "akanjs/ui";
210
212
  import { env } from "@apps/${opts.appName}/env/env.client";
@@ -216,15 +218,18 @@ if (defaultFonts.length > 1) throw new Error("[route-convention] only one defaul
216
218
  const defaultFont = defaultFonts[0];
217
219
  const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;
218
220
  export const theme = userLayout.theme ?? inheritedLayout.theme;
221
+ export const viewport = userLayout.viewport ?? inheritedLayout.viewport;
222
+ export const manifest = userLayout.manifest ?? inheritedLayout.manifest;
219
223
 
220
224
  ${layoutExports}(
221
225
  <System.Provider
222
226
  of={GeneratedLayout as never}
223
227
  appName=${JSON.stringify(opts.appName)}
224
228
  ${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}
225
- manifest={userLayout.manifest ?? inheritedLayout.manifest}
229
+ manifest={manifest}
226
230
  env={env}
227
231
  theme={theme}
232
+ viewport={viewport}
228
233
  fonts={loadFonts(userFonts)}
229
234
  className={defaultFontClassName}
230
235
  layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
@@ -238,7 +243,7 @@ ${layoutExports}(
238
243
  }
239
244
  `
240
245
  : `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
241
- import { resolveRouteModule } from "akanjs/client";
246
+ import { resolveRouteModule, RouteHead } from "akanjs/client";
242
247
  import type { ReactNode } from "react";
243
248
  ${inheritedImport}${userImport}
244
249
  ${layoutExports}${layoutReturn};
package/biome.base.json CHANGED
@@ -301,8 +301,6 @@
301
301
  "{apps,libs}/**/*.document.ts",
302
302
  "{apps,libs}/**/*.service.ts",
303
303
  "{apps,libs}/**/*.signal.ts",
304
- "{apps,libs}/**/*.signal.test.ts",
305
- "{apps,libs}/**/*.service.test.ts",
306
304
  "{apps,libs}/**/*.store.ts",
307
305
  "{apps,libs}/**/*.Template.tsx",
308
306
  "{apps,libs}/**/*.Unit.tsx",
@@ -315,6 +313,13 @@
315
313
  "./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"
316
314
  ]
317
315
  },
316
+ {
317
+ "includes": ["{apps,libs}/**/*.signal.test.ts", "{apps,libs}/**/*.service.test.ts"],
318
+ "plugins": [
319
+ "./node_modules/@akanjs/devkit/lint/no-import-external-library-in-test.grit",
320
+ "./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"
321
+ ]
322
+ },
318
323
  {
319
324
  "includes": [
320
325
  "apps/**/*.tsx",
package/cloud/cloudApi.ts CHANGED
@@ -63,6 +63,45 @@ export interface TunnelGrant {
63
63
  expiresAt: string | null;
64
64
  }
65
65
 
66
+ /** `scope` is `"*"` for every branch or the one branch it overrides. No call ever returns a value. */
67
+ export interface PipelineSecretSummary {
68
+ name: string;
69
+ scope: string;
70
+ updatedAt: string;
71
+ }
72
+
73
+ export interface PipelineSecretListing extends PipelineSecretSummary {
74
+ updatedBy: string;
75
+ }
76
+
77
+ /** What akasys answers for an uploaded pack or a registered binary; a pack's release also says how it was taken. */
78
+ export interface CloudRecord {
79
+ id: string;
80
+ status?: string;
81
+ problems?: string[];
82
+ [key: string]: unknown;
83
+ }
84
+
85
+ export interface UpdateUploadRequest {
86
+ appName: string;
87
+ target: string;
88
+ platform: "ios" | "android";
89
+ channel: string;
90
+ notes: string | null;
91
+ size: number;
92
+ sha256: string;
93
+ }
94
+
95
+ export interface UpdateUploadTicket {
96
+ uploadId: string;
97
+ /** A presigned PUT: whoever holds it writes the staging object, so it is never logged or put in an argv. */
98
+ uploadUrl: string;
99
+ expiresAt: string;
100
+ contentType: string;
101
+ }
102
+
103
+ export type AppBinaryDistribution = "appStore" | "testFlight" | "playStore" | "playTesting" | "direct";
104
+
66
105
  export interface TunnelSummary {
67
106
  code: string;
68
107
  hostname: string;
@@ -74,6 +113,11 @@ export interface TunnelSummary {
74
113
  }
75
114
 
76
115
  export class CloudApi {
116
+ /** The HTTP status an error from this client carries, or null for one without (a dropped connection, a timeout). */
117
+ static statusOf(error: unknown) {
118
+ const status = Number(/(?:^|: )(\d{3}) /.exec(error instanceof Error ? error.message : String(error))?.[1]);
119
+ return Number.isNaN(status) ? null : status;
120
+ }
77
121
  readonly #api: HttpClient;
78
122
  #accessToken: AccessToken | null = null;
79
123
  #workspace: Workspace;
@@ -113,6 +157,44 @@ export class CloudApi {
113
157
  formData.append("file", file);
114
158
  return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
115
159
  }
160
+ async uploadSecret(devProjectId: string, secret: { scope: string; name: string; value: string }) {
161
+ return await this.#api.post<PipelineSecretSummary>(`/uploadSecret/${encodeURIComponent(devProjectId)}`, secret);
162
+ }
163
+ async listSecrets(devProjectId: string) {
164
+ return await this.#api.get<PipelineSecretListing[]>(`/listSecrets/${encodeURIComponent(devProjectId)}`);
165
+ }
166
+ async removeSecret(devProjectId: string, secret: { scope: string; name: string }) {
167
+ return await this.#api.post<boolean>(`/removeSecret/${encodeURIComponent(devProjectId)}`, secret);
168
+ }
169
+ async requestUpdateUpload(devProjectId: string, upload: UpdateUploadRequest) {
170
+ return await this.#api.post<UpdateUploadTicket>(`/requestUpdateUpload/${encodeURIComponent(devProjectId)}`, upload);
171
+ }
172
+ // The endpoint declares ten minutes; Cloudflare cuts at 100 s anyway, which is what UpdatePackUpload's retry is for.
173
+ async commitUpdateUpload(devProjectId: string, uploadId: string) {
174
+ return await this.#api.post<CloudRecord>(
175
+ `/commitUpdateUpload/${encodeURIComponent(devProjectId)}`,
176
+ { uploadId },
177
+ { signal: AbortSignal.timeout(11 * 60_000) },
178
+ );
179
+ }
180
+ async registerAppBinary(
181
+ devProjectId: string,
182
+ binary: {
183
+ appName: string;
184
+ target: string;
185
+ distribution: AppBinaryDistribution;
186
+ bundle: Blob;
187
+ pack?: Blob;
188
+ channel?: string;
189
+ },
190
+ ) {
191
+ const formData = new FormData();
192
+ for (const key of ["appName", "target", "distribution"] as const) formData.append(key, binary[key]);
193
+ if (binary.channel) formData.append("channel", binary.channel);
194
+ formData.append("bundle", binary.bundle, "bundle.json");
195
+ if (binary.pack) formData.append("pack", binary.pack, "pack.tgz");
196
+ return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData);
197
+ }
116
198
  async downloadEnv(devProjectId: string): Promise<unknown> {
117
199
  const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
118
200
  await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
@@ -53,6 +53,10 @@ export class GlobalConfig {
53
53
  await GlobalConfig.#setAkanGlobalConfig(mutate(await GlobalConfig.#getAkanGlobalConfig()));
54
54
  });
55
55
  }
56
+ static async hostFor(workspace: { getConfig(): Promise<{ cloudHost: string | null }> }, host?: string | null) {
57
+ if (host) return host;
58
+ return (await workspace.getConfig()).cloudHost ?? GlobalConfig.akanCloudHost;
59
+ }
56
60
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
57
61
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
58
62
  return GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
package/cloud/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./cloudApi";
2
2
  export * from "./constants";
3
3
  export * from "./globalConfig";
4
+ export * from "./updatePackUpload";