@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +82 -43
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +134 -4
- package/executors.ts +114 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/akanContext.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
workflowRunArtifactPath,
|
|
16
16
|
workflowSyncDir,
|
|
17
17
|
} from "./workflow";
|
|
18
|
-
import {
|
|
18
|
+
import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
19
19
|
|
|
20
20
|
export type AkanContextFormat = "json" | "markdown";
|
|
21
21
|
export type AkanModuleKind = "domain" | "service" | "scalar";
|
|
@@ -130,15 +130,29 @@ export type CursorMcpConfig = {
|
|
|
130
130
|
mcpServers?: Record<string, unknown>;
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
-
export const
|
|
133
|
+
export const guidelineResourceUri = (name: string) => `akan://guidelines/${name}`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resources that exist whatever the workspace holds. The guideline entries are **not** here: they are
|
|
137
|
+
* one per directory under `cli/guidelines/`, and a hardcoded pair listed 2 of 31 while `readResource`
|
|
138
|
+
* threw on the other 29. `buildResourceList` is the only place the two halves meet.
|
|
139
|
+
*/
|
|
140
|
+
const staticResourceList = [
|
|
134
141
|
{ uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
|
|
135
|
-
{ uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
|
|
136
|
-
{ uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
|
|
137
142
|
{ uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
|
|
138
143
|
{ uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
|
|
139
144
|
{ uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
|
|
140
145
|
];
|
|
141
146
|
|
|
147
|
+
export const buildResourceList = (guidelineNames: readonly string[]) => [
|
|
148
|
+
...staticResourceList,
|
|
149
|
+
...guidelineNames.map((name) => ({
|
|
150
|
+
uri: guidelineResourceUri(name),
|
|
151
|
+
name: `${name} guideline`,
|
|
152
|
+
mimeType: "text/markdown",
|
|
153
|
+
})),
|
|
154
|
+
];
|
|
155
|
+
|
|
142
156
|
export const cursorMcpConfigPath = ".cursor/mcp.json";
|
|
143
157
|
// Claude Code reads project-scoped MCP servers from `.mcp.json` at the workspace root.
|
|
144
158
|
export const claudeMcpConfigPath = ".mcp.json";
|
|
@@ -666,23 +680,25 @@ export class AkanContextAnalyzer {
|
|
|
666
680
|
),
|
|
667
681
|
];
|
|
668
682
|
|
|
669
|
-
for (const
|
|
670
|
-
const
|
|
671
|
-
for (const entry of await safeReadDir(
|
|
672
|
-
if (!
|
|
673
|
-
const allowed = entry.isDirectory()
|
|
683
|
+
for (const sys of [...context.apps, ...context.libs]) {
|
|
684
|
+
const sysPath = path.join(workspace.workspaceRoot, sys.path);
|
|
685
|
+
for (const entry of await safeReadDir(sysPath)) {
|
|
686
|
+
if (!isScannedRootEntry(sys.type, entry.name)) continue;
|
|
687
|
+
const allowed = entry.isDirectory()
|
|
688
|
+
? rootAllowedDirs[sys.type].has(entry.name)
|
|
689
|
+
: rootAllowedFiles[sys.type].has(entry.name);
|
|
674
690
|
if (!allowed) {
|
|
675
691
|
const action = repairAction(
|
|
676
692
|
"module-shape",
|
|
677
|
-
`akan repair module-shape --app ${
|
|
678
|
-
|
|
693
|
+
`akan repair module-shape --app ${sys.name}`,
|
|
694
|
+
`Review ${sys.type} root shape and remove or move the unknown entry.`,
|
|
679
695
|
false,
|
|
680
696
|
);
|
|
681
697
|
diagnostics.push({
|
|
682
698
|
severity: "error",
|
|
683
|
-
code:
|
|
684
|
-
path: `${
|
|
685
|
-
message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in
|
|
699
|
+
code: `${sys.type}-root-unknown-entry`,
|
|
700
|
+
path: `${sys.path}/${entry.name}`,
|
|
701
|
+
message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
|
|
686
702
|
repairActions: [action],
|
|
687
703
|
});
|
|
688
704
|
repairActions.push(action);
|
package/akanMcpContract.ts
CHANGED
|
@@ -391,10 +391,31 @@ export const applyMcpTools: McpToolDefinition[] = [
|
|
|
391
391
|
},
|
|
392
392
|
];
|
|
393
393
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
394
|
+
/**
|
|
395
|
+
* `guidelineNames` fills the `get_guideline` enum. Without it the argument is a bare string and a model
|
|
396
|
+
* has to guess one of the ~30 directory names, which it cannot see from here — the names are a
|
|
397
|
+
* filesystem listing, so they can only arrive from a caller that already read it.
|
|
398
|
+
*/
|
|
399
|
+
export const listAkanMcpTools = (
|
|
400
|
+
mode: "readonly" | "plan" | "apply" = "readonly",
|
|
401
|
+
{ guidelineNames = [] }: { guidelineNames?: readonly string[] } = {},
|
|
402
|
+
) => {
|
|
403
|
+
const tools =
|
|
404
|
+
mode === "readonly"
|
|
405
|
+
? readonlyMcpTools
|
|
406
|
+
: mode === "plan"
|
|
407
|
+
? [...readonlyMcpTools, ...planMcpTools]
|
|
408
|
+
: [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
|
|
409
|
+
if (guidelineNames.length === 0) return tools;
|
|
410
|
+
return tools.map((tool) =>
|
|
411
|
+
tool.name === "get_guideline"
|
|
412
|
+
? {
|
|
413
|
+
...tool,
|
|
414
|
+
description: `${tool.description ?? ""} One of: ${guidelineNames.join(", ")}.`.trim(),
|
|
415
|
+
inputSchema: { ...tool.inputSchema, properties: { name: { type: "string", enum: [...guidelineNames] } } },
|
|
416
|
+
}
|
|
417
|
+
: tool,
|
|
418
|
+
);
|
|
398
419
|
};
|
|
399
420
|
|
|
400
421
|
export const createAkanValidationContract = (listTools = listAkanMcpTools) => ({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FileSys } from "./fileSys";
|
|
3
|
+
|
|
4
|
+
interface StoredSelection {
|
|
5
|
+
apps: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The apps the last interactive pick chose, so `akan start` + Enter repeats a multi-app session instead
|
|
10
|
+
* of re-ticking it. Advisory only: a missing, unreadable or stale file just means nothing is pre-ticked.
|
|
11
|
+
*/
|
|
12
|
+
export class AppSelectionMemory {
|
|
13
|
+
static pathIn(workspaceRoot: string) {
|
|
14
|
+
return path.join(workspaceRoot, "local", ".akan", "lastStart.json");
|
|
15
|
+
}
|
|
16
|
+
static async read(workspaceRoot: string): Promise<string[]> {
|
|
17
|
+
const stored = await Bun.file(AppSelectionMemory.pathIn(workspaceRoot))
|
|
18
|
+
.json()
|
|
19
|
+
.catch(() => null);
|
|
20
|
+
const apps = (stored as StoredSelection | null)?.apps;
|
|
21
|
+
return Array.isArray(apps) ? apps.filter((name): name is string => typeof name === "string") : [];
|
|
22
|
+
}
|
|
23
|
+
static async write(workspaceRoot: string, apps: string[]) {
|
|
24
|
+
await FileSys.writeJson(AppSelectionMemory.pathIn(workspaceRoot), { apps } satisfies StoredSelection).catch(
|
|
25
|
+
() => undefined,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
|
|
3
4
|
|
|
4
5
|
describe("ApplicationBuildRunner", () => {
|
|
5
6
|
test("externalizes Akan optional backend dependencies", () => {
|
|
6
7
|
expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
|
|
7
|
-
expect.arrayContaining(["@libsql/client", "bullmq", "
|
|
8
|
+
expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
|
|
8
9
|
);
|
|
9
10
|
});
|
|
11
|
+
|
|
12
|
+
test("keeps backend identifiers so a class names its own logger and stack frames", () => {
|
|
13
|
+
expect(AKAN_BACKEND_MINIFY.identifiers).toBe(false);
|
|
14
|
+
expect(AKAN_BACKEND_MINIFY.whitespace).toBe(true);
|
|
15
|
+
expect(AKAN_BACKEND_MINIFY.syntax).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a bundler that mangles identifiers loses the class name a Logger reads", async () => {
|
|
19
|
+
const dir = `${process.env.TMPDIR ?? "/tmp"}/akan-minify-${Bun.randomUUIDv7()}`;
|
|
20
|
+
const entry = `${dir}/entry.ts`;
|
|
21
|
+
await Bun.write(
|
|
22
|
+
entry,
|
|
23
|
+
`const serve = () => class Service { readonly name = this.constructor.name; };
|
|
24
|
+
export class SampleService extends serve() {}
|
|
25
|
+
console.info(new SampleService().name);
|
|
26
|
+
`,
|
|
27
|
+
);
|
|
28
|
+
const build = async (minify: Bun.BuildConfig["minify"]) => {
|
|
29
|
+
const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: `${dir}/out` });
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
const proc = Bun.spawn([process.execPath, result.outputs[0].path], { stdout: "pipe" });
|
|
32
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
expect(await build(AKAN_BACKEND_MINIFY)).toBe("SampleService");
|
|
36
|
+
expect(await build(true)).not.toBe("SampleService");
|
|
37
|
+
// Bun 1.4.2 accepts `keepNames` and ignores it; drop this expectation once it keeps the name.
|
|
38
|
+
expect(await build({ whitespace: true, syntax: true, identifiers: true, keepNames: true })).not.toBe(
|
|
39
|
+
"SampleService",
|
|
40
|
+
);
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(dir, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
10
45
|
});
|
|
@@ -7,7 +7,13 @@ import type {
|
|
|
7
7
|
ApplicationBuildResult,
|
|
8
8
|
} from "./applicationBuildReporter";
|
|
9
9
|
import type { App } from "./commandDecorators";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AllRoutesBuilder,
|
|
12
|
+
CsrArtifactBuilder,
|
|
13
|
+
FontPruner,
|
|
14
|
+
precompressArtifacts,
|
|
15
|
+
SsrBaseArtifactBuilder,
|
|
16
|
+
} from "./frontendBuild";
|
|
11
17
|
import { Spinner } from "./spinner";
|
|
12
18
|
|
|
13
19
|
export interface TypecheckOptions {
|
|
@@ -15,7 +21,7 @@ export interface TypecheckOptions {
|
|
|
15
21
|
incremental?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "compress" | "metadata";
|
|
24
|
+
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
|
|
19
25
|
|
|
20
26
|
export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
|
|
21
27
|
export type BuildResult = ApplicationBuildResult;
|
|
@@ -37,6 +43,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
|
|
|
37
43
|
backend: "📦",
|
|
38
44
|
ssr: "🧭",
|
|
39
45
|
csr: "🎨",
|
|
46
|
+
assets: "✂️",
|
|
40
47
|
compress: "🗜️",
|
|
41
48
|
metadata: "📝",
|
|
42
49
|
};
|
|
@@ -53,10 +60,14 @@ const SSR_RENDER_EXTERNALS = [
|
|
|
53
60
|
"react-server-dom-webpack/client.browser",
|
|
54
61
|
] as const;
|
|
55
62
|
|
|
63
|
+
// Identifier mangling renames every class, and `this.constructor.name` is what names a service's logger, an
|
|
64
|
+
// `Exception`, a guard, and every frame of a stack trace — for ~2% of boot on server bytes nothing downloads.
|
|
65
|
+
// `minify.keepNames` typechecks and does nothing as of Bun 1.4.2.
|
|
66
|
+
export const AKAN_BACKEND_MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
|
|
67
|
+
|
|
56
68
|
export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
|
|
57
69
|
"@libsql/client",
|
|
58
70
|
"bullmq",
|
|
59
|
-
"croner",
|
|
60
71
|
"ioredis",
|
|
61
72
|
"postgres",
|
|
62
73
|
"protobufjs",
|
|
@@ -78,6 +89,7 @@ export class ApplicationBuildRunner {
|
|
|
78
89
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
79
90
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
80
91
|
const phaseOptions = { spinner };
|
|
92
|
+
const { web, assets } = await this.#app.getConfig();
|
|
81
93
|
await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
|
|
82
94
|
spinner,
|
|
83
95
|
});
|
|
@@ -92,18 +104,30 @@ export class ApplicationBuildRunner {
|
|
|
92
104
|
await this.#runPhase(
|
|
93
105
|
"ssr",
|
|
94
106
|
"Building SSR route artifacts",
|
|
95
|
-
() => this.#buildSsr(),
|
|
107
|
+
async () => (web.ssr ? await this.#buildSsr() : null),
|
|
96
108
|
(result) =>
|
|
97
109
|
result
|
|
98
110
|
? `${result.allRoutes.manifest.routeIds.length} routes, ${result.allRoutes.manifest.knownEntries.length} entries`
|
|
99
|
-
:
|
|
111
|
+
: web.ssr
|
|
112
|
+
? "skipped"
|
|
113
|
+
: "disabled by akan.config.ts web.ssr",
|
|
100
114
|
phaseOptions,
|
|
101
115
|
);
|
|
102
116
|
await this.#runPhase(
|
|
103
117
|
"csr",
|
|
104
118
|
"Building CSR assets",
|
|
105
|
-
() => this.#buildCsr(),
|
|
106
|
-
(result) => result?.outputDir ?? "skipped",
|
|
119
|
+
async () => (web.csr ? await this.#buildCsr() : null),
|
|
120
|
+
(result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
|
|
121
|
+
phaseOptions,
|
|
122
|
+
);
|
|
123
|
+
await this.#runPhase(
|
|
124
|
+
"assets",
|
|
125
|
+
"Trimming unread static assets",
|
|
126
|
+
async () => (assets.pruneFonts ? await new FontPruner(this.#app, assets).prune() : null),
|
|
127
|
+
(result) =>
|
|
128
|
+
result
|
|
129
|
+
? `${result.removed.length} font file(s) dropped, ${ApplicationBuildRunner.formatBytes(result.freedBytes)} freed; ${result.kept.length} kept`
|
|
130
|
+
: "disabled by akan.config.ts assets.pruneFonts",
|
|
107
131
|
phaseOptions,
|
|
108
132
|
);
|
|
109
133
|
await this.#runPhase(
|
|
@@ -166,7 +190,7 @@ export class ApplicationBuildRunner {
|
|
|
166
190
|
const akanConfig = await this.#app.getConfig();
|
|
167
191
|
await Promise.all([
|
|
168
192
|
this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
|
|
169
|
-
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.
|
|
193
|
+
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
|
|
170
194
|
]);
|
|
171
195
|
}
|
|
172
196
|
|
|
@@ -175,6 +199,7 @@ export class ApplicationBuildRunner {
|
|
|
175
199
|
const backendExternals = [
|
|
176
200
|
...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
|
|
177
201
|
];
|
|
202
|
+
const { web } = akanConfig;
|
|
178
203
|
const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
179
204
|
for (const entrypoint of backendEntryPoints) {
|
|
180
205
|
if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
|
|
@@ -183,34 +208,38 @@ export class ApplicationBuildRunner {
|
|
|
183
208
|
entrypoints: backendEntryPoints,
|
|
184
209
|
outdir: this.#app.dist.cwdPath,
|
|
185
210
|
target: "bun",
|
|
186
|
-
minify:
|
|
187
|
-
naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
|
|
188
|
-
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
189
|
-
plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
|
|
190
|
-
});
|
|
191
|
-
const rscWorkerResult = await this.#buildOrThrow("rsc-worker", {
|
|
192
|
-
entrypoints: [this.#resolveRscWorkerBuildEntry()],
|
|
193
|
-
outdir: this.#app.dist.cwdPath,
|
|
194
|
-
target: "bun",
|
|
195
|
-
minify: true,
|
|
211
|
+
minify: AKAN_BACKEND_MINIFY,
|
|
196
212
|
naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
|
|
197
|
-
conditions: ["react-server"],
|
|
198
|
-
// `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
|
|
199
213
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
200
214
|
plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
|
|
201
215
|
});
|
|
216
|
+
// Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
|
|
217
|
+
const rscWorkerResult = web.ssr
|
|
218
|
+
? await this.#buildOrThrow("rsc-worker", {
|
|
219
|
+
entrypoints: [this.#resolveRscWorkerBuildEntry()],
|
|
220
|
+
outdir: this.#app.dist.cwdPath,
|
|
221
|
+
target: "bun",
|
|
222
|
+
minify: AKAN_BACKEND_MINIFY,
|
|
223
|
+
naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
|
|
224
|
+
conditions: ["react-server"],
|
|
225
|
+
// `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
|
|
226
|
+
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
227
|
+
plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
|
|
228
|
+
})
|
|
229
|
+
: null;
|
|
202
230
|
const consoleRuntimeResult = await this.#buildOrThrow("console-runtime", {
|
|
203
231
|
entrypoints: [this.#resolveConsoleRuntimeBuildEntry()],
|
|
204
232
|
outdir: this.#app.dist.cwdPath,
|
|
205
233
|
target: "bun",
|
|
206
|
-
minify:
|
|
234
|
+
minify: AKAN_BACKEND_MINIFY,
|
|
207
235
|
naming: { entry: "console-runtime.[ext]", chunk: "chunk-[hash].[ext]" },
|
|
208
236
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
209
237
|
});
|
|
210
238
|
await this.#writeConsoleShim();
|
|
211
239
|
return {
|
|
212
|
-
entrypoints: backendEntryPoints.length + 2,
|
|
213
|
-
outputs:
|
|
240
|
+
entrypoints: backendEntryPoints.length + (rscWorkerResult ? 2 : 1),
|
|
241
|
+
outputs:
|
|
242
|
+
backendResult.outputs.length + (rscWorkerResult?.outputs.length ?? 0) + consoleRuntimeResult.outputs.length + 1,
|
|
214
243
|
};
|
|
215
244
|
}
|
|
216
245
|
|
|
@@ -102,4 +102,71 @@ describe("resolveSsrPageEntries", () => {
|
|
|
102
102
|
);
|
|
103
103
|
expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
|
|
107
|
+
const appRoot = await makeTempRoot();
|
|
108
|
+
const pageRoot = path.join(appRoot, "page");
|
|
109
|
+
|
|
110
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
111
|
+
await write(
|
|
112
|
+
path.join(pageRoot, "(user)", "_layout.tsx"),
|
|
113
|
+
"export default async function Layout({ children }) { await Promise.resolve(); return <div>{children}</div>; }\n",
|
|
114
|
+
);
|
|
115
|
+
await write(
|
|
116
|
+
path.join(pageRoot, "(sign)", "_layout.tsx"),
|
|
117
|
+
"export default function Layout({ children }) { return children; }\n",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const entries = await resolveSsrPageEntries({
|
|
121
|
+
appCwdPath: appRoot,
|
|
122
|
+
appName: "demo",
|
|
123
|
+
pageKeys: ["./(user)/_layout.tsx", "./(user)/self/_index.tsx", "./(sign)/_layout.tsx", "./(sign)/signin.tsx"],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const userRoot = entries.find((entry) => entry.key === "./(user)/__root_layout.tsx");
|
|
127
|
+
const userSource = await Bun.file(userRoot?.moduleAbsPath ?? "").text();
|
|
128
|
+
expect(userSource).toContain("<System.Provider");
|
|
129
|
+
expect(userSource).toContain("export default async function GeneratedLayout(");
|
|
130
|
+
expect(userSource).toContain('process.env.AKAN_PUBLIC_RENDER_ENV === "csr"');
|
|
131
|
+
expect(userSource).toContain("? await UserLayout({ params, searchParams, children })");
|
|
132
|
+
expect(userSource).toContain(": <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;");
|
|
133
|
+
expect(userSource).toContain(" {layout}\n </System.Provider>");
|
|
134
|
+
|
|
135
|
+
const signRoot = entries.find((entry) => entry.key === "./(sign)/__root_layout.tsx");
|
|
136
|
+
const signSource = await Bun.file(signRoot?.moduleAbsPath ?? "").text();
|
|
137
|
+
expect(signSource).toContain("<System.Provider");
|
|
138
|
+
expect(signSource).toContain("export default function GeneratedLayout(");
|
|
139
|
+
expect(signSource).not.toContain("await UserLayout(");
|
|
140
|
+
expect(signSource).toContain(
|
|
141
|
+
" <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("returns the awaited node directly for an async nested root boundary", async () => {
|
|
146
|
+
const appRoot = await makeTempRoot();
|
|
147
|
+
const pageRoot = path.join(appRoot, "page");
|
|
148
|
+
|
|
149
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
150
|
+
await write(
|
|
151
|
+
path.join(pageRoot, "_layout.tsx"),
|
|
152
|
+
"export default function Layout({ children }) { return children; }\n",
|
|
153
|
+
);
|
|
154
|
+
await write(
|
|
155
|
+
path.join(pageRoot, "(home)", "_layout.tsx"),
|
|
156
|
+
"const Layout = async ({ children }) => children;\nexport default Layout;\n",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const entries = await resolveSsrPageEntries({
|
|
160
|
+
appCwdPath: appRoot,
|
|
161
|
+
appName: "demo",
|
|
162
|
+
pageKeys: ["./_layout.tsx", "./(home)/_layout.tsx", "./(home)/_index.tsx"],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
|
|
166
|
+
const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
|
|
167
|
+
expect(generatedSource).not.toContain("<System.Provider");
|
|
168
|
+
expect(generatedSource).toContain("export default async function GeneratedLayout(");
|
|
169
|
+
expect(generatedSource).toContain("? await UserLayout({ params, searchParams, children })");
|
|
170
|
+
expect(generatedSource).toContain(" return layout;\n}");
|
|
171
|
+
});
|
|
105
172
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
4
5
|
|
|
5
6
|
export interface PageEntry {
|
|
6
7
|
key: string;
|
|
@@ -55,7 +56,7 @@ interface RootBoundary {
|
|
|
55
56
|
segments: string[];
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function getRootBoundarySegments(key: string): string[] | null {
|
|
59
|
+
export function getRootBoundarySegments(key: string): string[] | null {
|
|
59
60
|
const match = LAYOUT_KEY_RE.exec(key);
|
|
60
61
|
if (!match) return null;
|
|
61
62
|
const prefix = match[1]?.replace(/\/$/, "");
|
|
@@ -76,7 +77,7 @@ function implicitDictionaryMacroAbsPath(appCwdPath: string): string {
|
|
|
76
77
|
return path.join(path.resolve(appCwdPath), IMPLICIT_DICT_DIR, "useDict.ts");
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
|
|
80
|
+
export function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
|
|
80
81
|
const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
|
|
81
82
|
if (firstVisibleIndex === -1) return segments.length <= 1;
|
|
82
83
|
if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;
|
|
@@ -191,6 +192,25 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
191
192
|
const userImport = sourceSpecifier
|
|
192
193
|
? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
|
|
193
194
|
: "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
|
|
195
|
+
const isAsyncUserLayout = opts.boundary.sourceAbsPath
|
|
196
|
+
? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
|
|
197
|
+
: false;
|
|
198
|
+
const userLayoutElement = "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>";
|
|
199
|
+
// React has no async client component, so the CSR bundle calls an async layout and awaits its node the way
|
|
200
|
+
// `RenderLayer` does for pages. The RSC render keeps the element: awaiting there would hold the shell behind
|
|
201
|
+
// the layout's own awaits instead of streaming it as its own Flight chunk.
|
|
202
|
+
const layoutSignature = isAsyncUserLayout
|
|
203
|
+
? "export default async function GeneratedLayout"
|
|
204
|
+
: "export default function GeneratedLayout";
|
|
205
|
+
const layoutBinding = isAsyncUserLayout
|
|
206
|
+
? ` const layout =
|
|
207
|
+
process.env.AKAN_PUBLIC_RENDER_ENV === "csr"
|
|
208
|
+
? await UserLayout({ params, searchParams, children })
|
|
209
|
+
: ${userLayoutElement};
|
|
210
|
+
`
|
|
211
|
+
: "";
|
|
212
|
+
const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
|
|
213
|
+
const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
|
|
194
214
|
const source = opts.includeSystemProvider
|
|
195
215
|
? `import type { LayoutProps, PageProps } from "akanjs/client";
|
|
196
216
|
import { loadFonts } from "akanjs/client";
|
|
@@ -222,8 +242,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
|
222
242
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
223
243
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
224
244
|
|
|
225
|
-
|
|
226
|
-
return (
|
|
245
|
+
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
246
|
+
${layoutBinding} return (
|
|
227
247
|
<System.Provider
|
|
228
248
|
of={GeneratedLayout as never}
|
|
229
249
|
appName=${JSON.stringify(opts.appName)}
|
|
@@ -239,7 +259,7 @@ export default function GeneratedLayout({ children, params, searchParams }: Layo
|
|
|
239
259
|
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
|
|
240
260
|
allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
|
|
241
261
|
>
|
|
242
|
-
|
|
262
|
+
${layoutChild}
|
|
243
263
|
</System.Provider>
|
|
244
264
|
);
|
|
245
265
|
}
|
|
@@ -264,8 +284,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
|
264
284
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
265
285
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
266
286
|
|
|
267
|
-
|
|
268
|
-
return
|
|
287
|
+
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
288
|
+
${layoutBinding} return ${layoutReturn};
|
|
269
289
|
}
|
|
270
290
|
`;
|
|
271
291
|
await Bun.write(absPath, source);
|