@akanjs/devkit 2.4.1-rc.7 → 2.4.2-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 (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/DEV_RUNTIME_KNOBS.md +97 -0
  3. package/README.md +6 -0
  4. package/akanApp/akanApp.host.test.ts +75 -6
  5. package/akanApp/akanApp.host.ts +303 -25
  6. package/akanConfig/akanConfig.ts +3 -0
  7. package/applicationBuildRunner.ts +8 -0
  8. package/commandDecorators/command.ts +16 -1
  9. package/executors.test.ts +290 -1
  10. package/executors.ts +210 -59
  11. package/fileSys.ts +7 -1
  12. package/frontendBuild/clientEntryDiscovery.ts +91 -53
  13. package/frontendBuild/cssCandidateCache.ts +109 -0
  14. package/frontendBuild/cssCompiler.ts +70 -18
  15. package/frontendBuild/fontOptimizer.ts +10 -2
  16. package/frontendBuild/frontendBuild.test.ts +3 -0
  17. package/frontendBuild/hmrWatcher.ts +6 -0
  18. package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
  19. package/frontendBuild/sourceMtimeIndex.ts +66 -5
  20. package/incrementalBuilder/buildBatchRunner.ts +10 -1
  21. package/incrementalBuilder/builderChannel.test.ts +16 -7
  22. package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
  23. package/incrementalBuilder/builderRequestRouter.ts +66 -0
  24. package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
  25. package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
  26. package/incrementalBuilder/incrementalBuilder.proc.ts +36 -26
  27. package/integration/devResourceProbe.ts +319 -0
  28. package/integration/devStability.integration.test.ts +153 -4
  29. package/integration/devStabilityHarness.ts +30 -5
  30. package/package.json +2 -2
  31. package/packageExportsMap.ts +1 -1
  32. package/routeSourceValidator.ts +51 -1
  33. package/scanInfo.ts +4 -0
  34. package/transforms/barrelImportsPlugin.ts +20 -13
package/executors.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AkanAppConfig } from "./akanConfig";
@@ -21,6 +21,8 @@ const writeJson = async (filePath: string, value: object) => {
21
21
  await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
22
22
  };
23
23
 
24
+ const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
25
+
24
26
  const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
25
27
  name: "fixture",
26
28
  version: "1.0.0",
@@ -271,6 +273,293 @@ describe("Workspace and app executor environment contracts", () => {
271
273
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
272
274
  });
273
275
 
276
+ describe("syncPages", () => {
277
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
278
+ const makeAppWithLibPages = async (
279
+ appName: string,
280
+ { config = "export default {};\n", libs = { shared: ["about"] } as Record<string, string[] | null> } = {},
281
+ ) => {
282
+ const root = await makeTempRoot();
283
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
284
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
285
+ process.env.AKAN_PUBLIC_ENV = "local";
286
+ process.env.PORT_OFFSET = "0";
287
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
288
+ for (const [lib, routes] of Object.entries(libs)) {
289
+ await mkdir(path.join(root, "libs", lib), { recursive: true });
290
+ for (const route of routes ?? []) {
291
+ await mkdir(path.join(root, "libs", lib, "page", route), { recursive: true });
292
+ await writeFile(path.join(root, "libs", lib, "page", route, "_index.tsx"), PAGE_SOURCE);
293
+ }
294
+ }
295
+ await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
296
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), config);
297
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
298
+ return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
299
+ };
300
+
301
+ test("links every lib dep that ships a page folder when enabled with true", async () => {
302
+ const { app, appRoot } = await makeAppWithLibPages("pages-true", {
303
+ config: "export default { syncPageLibs: true };\n",
304
+ libs: { shared: ["about"], util: null },
305
+ });
306
+ expect(await app.syncPages(["shared", "util"])).toBe(true);
307
+
308
+ const link = path.join(appRoot, "page/(libs)/(shared)");
309
+ expect((await lstat(link)).isSymbolicLink()).toBe(true);
310
+ expect(await lstat(path.join(appRoot, "page/(libs)/(util)")).catch(() => null)).toBeNull();
311
+ expect(await app.getPageKeys({ refresh: true })).toEqual(["./(libs)/(shared)/about/_index.tsx"]);
312
+ });
313
+
314
+ test("is a no-op when the links already match the config", async () => {
315
+ const { app } = await makeAppWithLibPages("pages-noop", {
316
+ config: "export default { syncPageLibs: ['shared'] };\n",
317
+ });
318
+ expect(await app.syncPages(["shared"])).toBe(true);
319
+ expect(await app.syncPages(["shared"])).toBe(false);
320
+ });
321
+
322
+ test("removes the synced page folder when disabled", async () => {
323
+ const { app, appRoot } = await makeAppWithLibPages("pages-disable", {
324
+ config: "export default { syncPageLibs: true };\n",
325
+ });
326
+ await app.syncPages(["shared"]);
327
+ expect((await lstat(path.join(appRoot, "page/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
328
+
329
+ await writeFile(path.join(appRoot, "akan.config.ts"), "export default { syncPageLibs: false };\n");
330
+ await app.getConfig({ refresh: true });
331
+ expect(await app.syncPages(["shared"])).toBe(true);
332
+ expect(await lstat(path.join(appRoot, "page/(libs)")).catch(() => null)).toBeNull();
333
+ });
334
+
335
+ test("clears a link whose lib page folder was deleted, and keeps the workspace walkable", async () => {
336
+ const { root, app, appRoot } = await makeAppWithLibPages("pages-dangling", {
337
+ config: "export default { syncPageLibs: true };\n",
338
+ });
339
+ await app.syncPages(["shared"]);
340
+ await rm(path.join(root, "libs/shared/page"), { recursive: true, force: true });
341
+
342
+ // A dangling link sits 3 levels under apps/, which is inside the workspace app scan's walk.
343
+ expect(await app.workspace.getApps()).toEqual(["pages-dangling"]);
344
+ expect(await app.syncPages(["shared"])).toBe(true);
345
+ expect(await lstat(path.join(appRoot, "page/(libs)")).catch(() => null)).toBeNull();
346
+ });
347
+
348
+ test("rejects a lib the app does not depend on, and one without a page folder", async () => {
349
+ const { app } = await makeAppWithLibPages("pages-unknown", {
350
+ config: "export default { syncPageLibs: ['missing'] };\n",
351
+ });
352
+ await expect(app.syncPages(["shared"])).rejects.toThrow("does not depend on it");
353
+
354
+ const { app: noPage } = await makeAppWithLibPages("pages-nopage", {
355
+ config: "export default { syncPageLibs: ['util'] };\n",
356
+ libs: { util: null },
357
+ });
358
+ await expect(noPage.syncPages(["util"])).rejects.toThrow("libs/util/page does not exist");
359
+ });
360
+
361
+ test("links into every basePath when the app declares subRoutes", async () => {
362
+ const { app, appRoot } = await makeAppWithLibPages("pages-baseroutes", {
363
+ config: [
364
+ "export default {",
365
+ " syncPageLibs: true,",
366
+ ' routes: [{ basePath: "admin", domains: {} }, { basePath: "shop", domains: {} }],',
367
+ "};",
368
+ "",
369
+ ].join("\n"),
370
+ });
371
+ await app.syncPages(["shared"]);
372
+
373
+ expect((await lstat(path.join(appRoot, "page/admin/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
374
+ expect((await lstat(path.join(appRoot, "page/shop/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
375
+ expect(await app.getPageKeys({ refresh: true })).toEqual([
376
+ "./admin/(libs)/(shared)/about/_index.tsx",
377
+ "./shop/(libs)/(shared)/about/_index.tsx",
378
+ ]);
379
+ });
380
+
381
+ test("rejects a lib route that collides with an app route", async () => {
382
+ const { app, appRoot } = await makeAppWithLibPages("pages-collide", {
383
+ config: "export default { syncPageLibs: true };\n",
384
+ });
385
+ await mkdir(path.join(appRoot, "page/(marketing)/about"), { recursive: true });
386
+ await writeFile(path.join(appRoot, "page/(marketing)/about/_index.tsx"), PAGE_SOURCE);
387
+ await app.syncPages(["shared"]);
388
+
389
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('duplicate page route "/:lang/about"');
390
+ });
391
+
392
+ test("rejects two libs that mount the same route", async () => {
393
+ const { app } = await makeAppWithLibPages("pages-collide-libs", {
394
+ config: "export default { syncPageLibs: true };\n",
395
+ libs: { shared: ["about"], social: ["about"] },
396
+ });
397
+ await app.syncPages(["shared", "social"]);
398
+
399
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('duplicate page route "/:lang/about"');
400
+ });
401
+ });
402
+
403
+ describe("syncAssets", () => {
404
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
405
+ const makeAppWithLibAssets = async (appName: string) => {
406
+ const root = await makeTempRoot();
407
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
408
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
409
+ process.env.AKAN_PUBLIC_ENV = "local";
410
+ process.env.PORT_OFFSET = "0";
411
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
412
+ await mkdir(path.join(root, "libs/shared/public"), { recursive: true });
413
+ await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo");
414
+ await mkdir(path.join(root, "libs/shared/private"), { recursive: true });
415
+ await writeFile(path.join(root, "libs/shared/private/rules.json"), "{}");
416
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
417
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
418
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
419
+ return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
420
+ };
421
+
422
+ test("links lib assets into the app instead of copying them", async () => {
423
+ const { app, appRoot } = await makeAppWithLibAssets("assets-link");
424
+ await app.syncAssets(["shared"]);
425
+
426
+ const publicLink = path.join(appRoot, "public/libs/shared");
427
+ const privateLink = path.join(appRoot, "private/libs/shared");
428
+ expect((await lstat(publicLink)).isSymbolicLink()).toBe(true);
429
+ expect((await lstat(privateLink)).isSymbolicLink()).toBe(true);
430
+ expect(await readFile(path.join(publicLink, "logo.png"), "utf8")).toBe("logo");
431
+ expect(await readFile(path.join(privateLink, "rules.json"), "utf8")).toBe("{}");
432
+ if (process.platform !== "win32") expect(path.isAbsolute(await readlink(publicLink))).toBe(false);
433
+ });
434
+
435
+ test("drops links for deps that no longer ship assets", async () => {
436
+ const { app, appRoot } = await makeAppWithLibAssets("assets-drop");
437
+ await app.syncAssets(["shared"]);
438
+ await app.syncAssets([]);
439
+
440
+ expect(await lstat(path.join(appRoot, "public/libs")).catch(() => null)).toBeNull();
441
+ expect(await lstat(path.join(appRoot, "private/libs")).catch(() => null)).toBeNull();
442
+ });
443
+
444
+ test("removes a link whose target disappeared", async () => {
445
+ const { root, app, appRoot } = await makeAppWithLibAssets("assets-dangling");
446
+ await app.syncAssets(["shared"]);
447
+ await rm(path.join(root, "libs/shared/public"), { recursive: true, force: true });
448
+
449
+ const publicLink = path.join(appRoot, "public/libs/shared");
450
+ await app.removeDir(publicLink);
451
+ expect(await lstat(publicLink).catch(() => null)).toBeNull();
452
+ });
453
+
454
+ test("removing a linked dir with a trailing separator keeps the lib source", async () => {
455
+ const { root, app, appRoot } = await makeAppWithLibAssets("assets-trailing");
456
+ await app.syncAssets(["shared"]);
457
+
458
+ const publicLink = path.join(appRoot, "public/libs/shared");
459
+ await app.removeDir(`${publicLink}${path.sep}`);
460
+ expect(await lstat(publicLink).catch(() => null)).toBeNull();
461
+ expect(await readFile(path.join(root, "libs/shared/public/logo.png"), "utf8")).toBe("logo");
462
+ });
463
+
464
+ test("materializes linked lib assets into dist on build", async () => {
465
+ const { root, app } = await makeAppWithLibAssets("assets-dist");
466
+ await app.syncAssets(["shared"]);
467
+ await app.prepareCommand("build");
468
+
469
+ const distPublicLib = path.join(root, "dist/apps/assets-dist/public/libs/shared");
470
+ expect((await lstat(distPublicLib)).isSymbolicLink()).toBe(false);
471
+ expect(await readFile(path.join(distPublicLib, "logo.png"), "utf8")).toBe("logo");
472
+ expect(await readFile(path.join(root, "dist/apps/assets-dist/private/libs/shared/rules.json"), "utf8")).toBe(
473
+ "{}",
474
+ );
475
+ });
476
+ });
477
+
478
+ describe("devOnly routes", () => {
479
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
480
+ const makeAppWithRoutes = async (appName: string, routes: Record<string, string>) => {
481
+ const root = await makeTempRoot();
482
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
483
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
484
+ process.env.AKAN_PUBLIC_ENV = "local";
485
+ process.env.PORT_OFFSET = "0";
486
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
487
+ await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
488
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
489
+ for (const [rel, source] of Object.entries(routes)) {
490
+ const filePath = path.join(root, "apps", appName, "page", rel);
491
+ await mkdir(path.dirname(filePath), { recursive: true });
492
+ await writeFile(filePath, source);
493
+ }
494
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
495
+ return { root, app: AppExecutor.from(workspace, appName) };
496
+ };
497
+ const devOnlyPage = `export const pageConfig = { devOnly: true };\n${PAGE_SOURCE}`;
498
+ const layout = "export default function Layout({ children }) { return children; }\n";
499
+ const devOnlyLayout = `export const pageConfig = { devOnly: true };\n${layout}`;
500
+
501
+ test("keeps dev-only routes outside of a build", async () => {
502
+ const { app } = await makeAppWithRoutes("devonly-start", {
503
+ "_index.tsx": PAGE_SOURCE,
504
+ "debug/_index.tsx": devOnlyPage,
505
+ });
506
+
507
+ expect(await app.getPageKeys({ refresh: true })).toEqual(["./_index.tsx", "./debug/_index.tsx"]);
508
+ });
509
+
510
+ test("drops a dev-only page from the build", async () => {
511
+ const { app } = await makeAppWithRoutes("devonly-page", {
512
+ "_index.tsx": PAGE_SOURCE,
513
+ "debug/_index.tsx": devOnlyPage,
514
+ });
515
+ await app.prepareCommand("build");
516
+
517
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
518
+ });
519
+
520
+ test("drops a dev-only layout together with every route under it", async () => {
521
+ const { app } = await makeAppWithRoutes("devonly-layout", {
522
+ "_index.tsx": PAGE_SOURCE,
523
+ "(dev)/_layout.tsx": devOnlyLayout,
524
+ "(dev)/debug/_index.tsx": PAGE_SOURCE,
525
+ "(dev)/debug/deep/_index.tsx": PAGE_SOURCE,
526
+ "keep/_index.tsx": PAGE_SOURCE,
527
+ });
528
+ await app.prepareCommand("build");
529
+
530
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx", "./keep/_index.tsx"]);
531
+ });
532
+
533
+ test("treats devOnly: false as a normal route", async () => {
534
+ const { app } = await makeAppWithRoutes("devonly-false", {
535
+ "_index.tsx": `export const pageConfig = { devOnly: false, cache: true };\n${PAGE_SOURCE}`,
536
+ });
537
+ await app.prepareCommand("build");
538
+
539
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
540
+ });
541
+
542
+ test("rejects a devOnly value the build cannot read statically", async () => {
543
+ const { app } = await makeAppWithRoutes("devonly-dynamic", {
544
+ "_index.tsx": `export const pageConfig = { devOnly: process.env.NODE_ENV !== "production" };\n${PAGE_SOURCE}`,
545
+ });
546
+
547
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
548
+ "pageConfig.devOnly must be a literal true or false",
549
+ );
550
+ });
551
+
552
+ test("reads devOnly through a satisfies annotation", async () => {
553
+ const { app } = await makeAppWithRoutes("devonly-satisfies", {
554
+ "_index.tsx": PAGE_SOURCE,
555
+ "debug/_index.tsx": `export const pageConfig = { devOnly: true } satisfies { devOnly: boolean };\n${PAGE_SOURCE}`,
556
+ });
557
+ await app.prepareCommand("build");
558
+
559
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
560
+ });
561
+ });
562
+
274
563
  describe("getDevPort", () => {
275
564
  const makeWorkspaceWithApps = async (names: string[]) => {
276
565
  const root = await makeTempRoot();
package/executors.ts CHANGED
@@ -8,7 +8,16 @@ import {
8
8
  spawn,
9
9
  } from "node:child_process";
10
10
  import { readFileSync } from "node:fs";
11
- import { copyFile, mkdir, readdir as readDirEntries, stat } from "node:fs/promises";
11
+ import {
12
+ copyFile,
13
+ cp as cpEntry,
14
+ mkdir,
15
+ readdir as readDirEntries,
16
+ realpath,
17
+ rm,
18
+ stat,
19
+ symlink,
20
+ } from "node:fs/promises";
12
21
  import path from "node:path";
13
22
  import { pathToFileURL } from "node:url";
14
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
@@ -33,6 +42,15 @@ import { Spinner } from "./spinner";
33
42
  import type { TypeChecker } from "./typeChecker";
34
43
  import type { FileContent, PackageJson, TsConfigJson } from "./types";
35
44
 
45
+ export interface PageRoot {
46
+ /** App-relative location of the route files, i.e. the symlink for a synced lib. */
47
+ dir: string;
48
+ /** Where the files actually live, which is what a file watcher reports. */
49
+ realDir: string;
50
+ /** Prefix that turns a `dir`-relative path into an app page key (empty for the app's own `page`). */
51
+ keyPrefix: string;
52
+ }
53
+
36
54
  const staticTemplateFileExtensions = new Set([
37
55
  ".avif",
38
56
  ".bmp",
@@ -389,8 +407,10 @@ export class Executor {
389
407
  return this;
390
408
  }
391
409
  async removeDir(dirPath: string) {
392
- const readPath = this.getPath(dirPath);
393
- if (await FileSys.dirExists(readPath)) await $`rm -rf ${readPath}`;
410
+ //* XXX: `path.join(…, ".")` drops a trailing separator — `rm -rf link/` resolves through a symlink
411
+ //* and wipes its target, while `rm -rf link` only unlinks it.
412
+ const readPath = path.join(this.getPath(dirPath), ".");
413
+ if (await FileSys.entryExists(readPath)) await rm(readPath, { recursive: true, force: true });
394
414
  this.logger.verbose(`Remove directory ${readPath}`);
395
415
  return this;
396
416
  }
@@ -435,13 +455,16 @@ export class Executor {
435
455
  const readPath = this.getPath(filePath);
436
456
  return await FileSys.readJson<object>(readPath);
437
457
  }
438
- async cp(srcPath: string, destPath: string) {
458
+ async cp(srcPath: string, destPath: string, { dereference = false }: { dereference?: boolean } = {}) {
439
459
  const src = this.getPath(srcPath);
440
460
  const dest = this.getPath(destPath);
441
461
  if (!(await FileSys.exists(src))) return;
442
462
  const isDirectory = (await stat(src)).isDirectory();
443
463
  if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
444
- await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
464
+ //* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
465
+ //* real files regardless of platform has to say so explicitly.
466
+ if (dereference) await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
467
+ else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
445
468
  }
446
469
  log(msg: string) {
447
470
  this.logger.info(msg);
@@ -890,7 +913,9 @@ export class WorkspaceExecutor extends Executor {
890
913
  dirs.map(async (dir) => {
891
914
  if (AVOID_DIRS.includes(dir)) return;
892
915
  const dirPath = path.join(dirname, dir);
893
- if ((await stat(dirPath)).isDirectory()) {
916
+ //* A dangling symlink (e.g. a synced lib page whose source was deleted) must not fail the walk —
917
+ //* this runs for `getApps`, so throwing here would break every command until it is repaired.
918
+ if (await FileSys.dirExists(dirPath)) {
894
919
  const hasTargetFile = await FileSys.fileExists(path.join(dirPath, targetFilename));
895
920
  if (hasTargetFile) results.push(`${prefix}${dir}`);
896
921
  if (maxDepth > 0) await getDirs(dirPath, maxDepth - 1, results, `${prefix}${dir}/`);
@@ -1299,11 +1324,17 @@ export class AppExecutor extends SysExecutor {
1299
1324
  };
1300
1325
  Object.assign(process.env, routeEnv);
1301
1326
  if (type === "build") {
1327
+ //* `scanSync` already read the route set, and it reads it unfiltered — dev-only routes are still
1328
+ //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1329
+ this.#excludeDevOnlyPages = true;
1330
+ this.#pageKeys = null;
1302
1331
  if (await this.exists(this.dist.cwdPath)) await this.dist.exec(`rm -rf ${this.dist.cwdPath}`);
1303
1332
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1333
+ //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1334
+ //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1304
1335
  await Promise.all([
1305
- this.cp("private", `${this.dist.cwdPath}/private`),
1306
- this.cp("public", `${this.dist.cwdPath}/public`),
1336
+ this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1337
+ this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1307
1338
  ]);
1308
1339
  } else await this.removeDir(".akan");
1309
1340
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
@@ -1351,75 +1382,192 @@ export class AppExecutor extends SysExecutor {
1351
1382
  }
1352
1383
 
1353
1384
  #pageKeys: string[] | null = null;
1385
+ /** Set once `prepareCommand("build")` runs, so every later consumer of the route set agrees on it. */
1386
+ #excludeDevOnlyPages = false;
1354
1387
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1355
1388
  if (this.#pageKeys && !refresh) return this.#pageKeys;
1356
1389
  const akanConfig = await this.getConfig();
1357
1390
  const glob = new Bun.Glob("**/*");
1358
1391
  const pageKeys: string[] = [];
1359
- const pageDir = `${this.cwdPath}/page`;
1360
- if (!(await FileSys.dirExists(pageDir))) {
1361
- this.#pageKeys = [];
1362
- return this.#pageKeys;
1363
- }
1364
- for await (const rel of glob.scan({
1365
- cwd: pageDir,
1366
- absolute: false,
1367
- onlyFiles: true,
1368
- })) {
1369
- const segments = rel.split(path.sep);
1370
- if (segments.some((s) => s === "node_modules")) continue;
1371
- const posix = segments.join("/");
1372
- const absPath = path.join(pageDir, posix);
1373
- validatePageSourceFile(posix, { filePath: absPath });
1374
- if (!isRouteSourceFile(posix)) continue;
1375
- const key = `./${posix}`;
1376
- validateSubRoutePageKey(key, akanConfig.basePaths, {
1377
- appName: this.name,
1378
- filePath: absPath,
1379
- });
1380
- const parsed = parseRouteModuleKey(key);
1381
- if (parsed.isInternalRootLayout) {
1382
- throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
1392
+ const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1393
+ const devOnlyKeys = new Set<string>();
1394
+ const devOnlyDirs: string[] = [];
1395
+ for (const root of await this.getPageRoots()) {
1396
+ if (!(await FileSys.dirExists(root.dir))) continue;
1397
+ for await (const rel of glob.scan({
1398
+ cwd: root.dir,
1399
+ absolute: false,
1400
+ onlyFiles: true,
1401
+ })) {
1402
+ const segments = rel.split(path.sep);
1403
+ if (segments.some((s) => s === "node_modules")) continue;
1404
+ const posix = `${root.keyPrefix}${segments.join("/")}`;
1405
+ const absPath = path.join(root.dir, ...segments);
1406
+ validatePageSourceFile(posix, { filePath: absPath });
1407
+ if (!isRouteSourceFile(posix)) continue;
1408
+ const key = `./${posix}`;
1409
+ validateSubRoutePageKey(key, akanConfig.basePaths, {
1410
+ appName: this.name,
1411
+ filePath: absPath,
1412
+ });
1413
+ const parsed = parseRouteModuleKey(key);
1414
+ if (parsed.isInternalRootLayout) {
1415
+ throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
1416
+ }
1417
+ const fromLib = !!root.keyPrefix;
1418
+ const routeId = `${parsed.kind}:${parsed.pattern}`;
1419
+ const owner = owners.get(routeId);
1420
+ //* App-owned routes have always been allowed to collide (two groups, one pattern); only report a
1421
+ //* collision once a synced lib is involved, where neither side can see the other.
1422
+ if (owner && (owner.fromLib || fromLib)) {
1423
+ throw new Error(
1424
+ `[route-convention] duplicate ${parsed.kind} route "${parsed.pattern}" in app "${this.name}":\n- ${owner.absPath}\n- ${absPath}`,
1425
+ );
1426
+ }
1427
+ if (!owner) owners.set(routeId, { absPath, fromLib });
1428
+ const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1429
+ const routeSource = await Bun.file(absPath).text();
1430
+ const validator = await AppExecutor.#getRouteSourceValidator();
1431
+ if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
1432
+ else {
1433
+ const info = validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, {
1434
+ rootLayout: isRootLayout,
1435
+ });
1436
+ if (info.devOnly) {
1437
+ devOnlyKeys.add(key);
1438
+ //* A layout owns its directory, so a dev-only one takes the whole subtree with it — leaving its
1439
+ //* pages behind would ship them stripped of the chrome they were written under.
1440
+ if (parsed.kind === "layout") devOnlyDirs.push(key.replace(/[^/]+$/, ""));
1441
+ }
1442
+ }
1443
+ pageKeys.push(key);
1383
1444
  }
1384
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1385
- const routeSource = await Bun.file(absPath).text();
1386
- const validator = await AppExecutor.#getRouteSourceValidator();
1387
- if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
1388
- else validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
1389
- pageKeys.push(key);
1390
1445
  }
1391
1446
  pageKeys.sort();
1392
- this.#pageKeys = pageKeys;
1447
+ this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1393
1448
  return this.#pageKeys;
1394
1449
  }
1450
+ #dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
1451
+ if (!devOnlyKeys.size) return pageKeys;
1452
+ const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
1453
+ const dropped = pageKeys.filter(isDevOnly);
1454
+ this.log(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1455
+ return pageKeys.filter((key) => !isDevOnly(key));
1456
+ }
1457
+ /**
1458
+ * Every directory that contributes route files, as `page`-relative key prefixes. Lib roots are the
1459
+ * symlinks `syncPages` created, so route keys stay app-relative while `realDir` is what a file watcher
1460
+ * reports. Enumeration never crosses a symlink (neither Bun's glob nor TypeScript's `include` does),
1461
+ * which is why linked page folders have to be listed here instead of found by walking `page`.
1462
+ */
1463
+ async getPageRoots(): Promise<PageRoot[]> {
1464
+ const akanConfig = await this.getConfig();
1465
+ const pageDir = `${this.cwdPath}/page`;
1466
+ const roots: PageRoot[] = [{ dir: pageDir, realDir: pageDir, keyPrefix: "" }];
1467
+ for (const parent of AppExecutor.#pageLibParents(akanConfig.basePaths)) {
1468
+ const libsDir = `${pageDir}/${parent}${AppExecutor.#pageLibsDir}`;
1469
+ const entries = await readDirEntries(libsDir).catch(() => [] as string[]);
1470
+ for (const entry of entries.sort()) {
1471
+ const dir = `${libsDir}/${entry}`;
1472
+ if (!(await FileSys.dirExists(dir))) continue;
1473
+ roots.push({ dir, realDir: await realpath(dir), keyPrefix: `${parent}${AppExecutor.#pageLibsDir}/${entry}/` });
1474
+ }
1475
+ }
1476
+ return roots;
1477
+ }
1395
1478
  setPageKeys(pageKeys: string[]) {
1396
1479
  this.#pageKeys = pageKeys;
1397
1480
  }
1398
1481
 
1399
- async syncAssets(libDeps: string[]) {
1400
- const projectPublicPath = `${this.cwdPath}/public`;
1401
- const projectAssetsPath = `${this.cwdPath}/private`;
1402
- const projectPublicLibPath = `${projectPublicPath}/libs`;
1403
- const projectAssetsLibPath = `${projectAssetsPath}/libs`;
1404
- await Promise.all([this.removeDir(projectPublicLibPath), this.removeDir(projectAssetsLibPath)]);
1405
- const targetPublicDeps = [] as string[];
1406
- for (const dep of libDeps) {
1407
- if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/public`)) targetPublicDeps.push(dep);
1482
+ static readonly #pageLibsDir = "(libs)";
1483
+ /** Where `(libs)` may live: once at the page root, or once per basePath when the app declares subRoutes. */
1484
+ static #pageLibParents(basePaths: Iterable<string>): string[] {
1485
+ const parents = [...basePaths].map((basePath) => `${basePath}/`);
1486
+ return parents.length ? parents : [""];
1487
+ }
1488
+ /** Returns whether the linked page set changed, which is what makes the app's route keys stale. */
1489
+ async syncPages(libDeps: string[]): Promise<boolean> {
1490
+ const akanConfig = await this.getConfig();
1491
+ const parents = AppExecutor.#pageLibParents(akanConfig.basePaths);
1492
+ const libs = await this.#resolvePageLibs(akanConfig.syncPageLibs, libDeps);
1493
+ //* Listed rather than taken from `getPageRoots`, which drops links whose target is gone — those are
1494
+ //* exactly the ones a sync has to clean up.
1495
+ const linked = (
1496
+ await Promise.all(
1497
+ parents.map(async (parent) => {
1498
+ const libsDir = `${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`;
1499
+ const entries = await readDirEntries(libsDir).catch(() => [] as string[]);
1500
+ return entries.map((entry) => `${parent}${AppExecutor.#pageLibsDir}/${entry}/`);
1501
+ }),
1502
+ )
1503
+ ).flat();
1504
+ const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1505
+ if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1506
+ await Promise.all(
1507
+ parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1508
+ );
1509
+ for (const parent of parents) {
1510
+ if (!libs.length) break;
1511
+ const libsDir = `${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`;
1512
+ await this.mkdir(libsDir);
1513
+ await Promise.all(
1514
+ libs.map((lib) =>
1515
+ AppExecutor.#linkLibAsset(`${this.workspace.workspaceRoot}/libs/${lib}/page`, `${libsDir}/(${lib})`),
1516
+ ),
1517
+ );
1518
+ }
1519
+ this.#pageKeys = null;
1520
+ return true;
1521
+ }
1522
+ async #resolvePageLibs(syncPageLibs: string[] | boolean, libDeps: string[]): Promise<string[]> {
1523
+ if (!syncPageLibs) return [];
1524
+ const hasPageDir = async (lib: string) =>
1525
+ await FileSys.dirExists(`${this.workspace.workspaceRoot}/libs/${lib}/page`);
1526
+ if (syncPageLibs === true) {
1527
+ const libs: string[] = [];
1528
+ for (const lib of libDeps) if (await hasPageDir(lib)) libs.push(lib);
1529
+ return libs;
1408
1530
  }
1409
- const targetAssetsDeps = [] as string[];
1531
+ for (const lib of syncPageLibs) {
1532
+ if (!libDeps.includes(lib))
1533
+ throw new Error(
1534
+ `[syncPageLibs] app "${this.name}" lists lib "${lib}" but does not depend on it (deps: ${libDeps.join(", ") || "none"})`,
1535
+ );
1536
+ if (!(await hasPageDir(lib)))
1537
+ throw new Error(`[syncPageLibs] app "${this.name}" lists lib "${lib}" but libs/${lib}/page does not exist`);
1538
+ }
1539
+ return [...syncPageLibs];
1540
+ }
1541
+ async syncAssets(libDeps: string[]) {
1542
+ await Promise.all((["public", "private"] as const).map((facet) => this.#syncLibAssets(facet, libDeps)));
1543
+ }
1544
+ async #syncLibAssets(facet: "public" | "private", libDeps: string[]) {
1545
+ const libLinkPath = `${this.cwdPath}/${facet}/libs`;
1546
+ await this.removeDir(libLinkPath);
1547
+ const targetDeps = [] as string[];
1410
1548
  for (const dep of libDeps) {
1411
- if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/private`)) targetAssetsDeps.push(dep);
1549
+ if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/${facet}`)) targetDeps.push(dep);
1412
1550
  }
1413
- await Promise.all(targetPublicDeps.map((dep) => this.mkdir(`${projectPublicLibPath}/${dep}`)));
1414
- await Promise.all(targetAssetsDeps.map((dep) => this.mkdir(`${projectAssetsLibPath}/${dep}`)));
1415
- await Promise.all([
1416
- ...targetPublicDeps.map((dep) =>
1417
- this.cp(`${this.workspace.workspaceRoot}/libs/${dep}/public`, `${projectPublicLibPath}/${dep}`),
1418
- ),
1419
- ...targetAssetsDeps.map((dep) =>
1420
- this.cp(`${this.workspace.workspaceRoot}/libs/${dep}/private`, `${projectAssetsLibPath}/${dep}`),
1551
+ if (!targetDeps.length) return;
1552
+ await this.mkdir(libLinkPath);
1553
+ await Promise.all(
1554
+ targetDeps.map((dep) =>
1555
+ AppExecutor.#linkLibAsset(`${this.workspace.workspaceRoot}/libs/${dep}/${facet}`, `${libLinkPath}/${dep}`),
1421
1556
  ),
1422
- ]);
1557
+ );
1558
+ }
1559
+ static async #linkLibAsset(targetPath: string, linkPath: string) {
1560
+ //* A relative link keeps working when the workspace is mounted at another path (containers, CI);
1561
+ //* Windows junctions are the exception and resolve their target as an absolute path.
1562
+ const isWindows = process.platform === "win32";
1563
+ try {
1564
+ const target = isWindows ? targetPath : path.relative(path.dirname(linkPath), targetPath);
1565
+ await symlink(target, linkPath, isWindows ? "junction" : "dir");
1566
+ } catch (error) {
1567
+ if (!isWindows) throw error;
1568
+ await mkdir(linkPath, { recursive: true });
1569
+ await cpEntry(targetPath, linkPath, { recursive: true, dereference: true, force: true });
1570
+ }
1423
1571
  }
1424
1572
  async scanSync({ refresh = false, write = true }: { refresh?: boolean; write?: boolean } = {}) {
1425
1573
  const scanInfo = (await this.scan({
@@ -1428,6 +1576,9 @@ export class AppExecutor extends SysExecutor {
1428
1576
  writeLib: write,
1429
1577
  })) as AppInfo;
1430
1578
  if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
1579
+ //* `scan` read the routes off the page tree this sync may be about to change, so re-read them.
1580
+ if (write && (await this.syncPages(scanInfo.getScanResult().libDeps)))
1581
+ scanInfo.setRoutes(await this.getPageKeys({ refresh: true }));
1431
1582
  if (write) await this.#runPluginSyncAssets();
1432
1583
  return scanInfo;
1433
1584
  }