@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.41
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -1
- package/aiEditor.ts +1 -1
- package/akanConfig/akanConfig.test.ts +0 -10
- package/akanConfig/akanConfig.ts +1 -11
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +1 -2
- package/biome.base.json +321 -0
- package/biomeBase.ts +9 -0
- package/executors.test.ts +29 -5
- package/executors.ts +11 -4
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/cssCandidateCache.test.ts +11 -0
- package/frontendBuild/cssCandidateCache.ts +9 -2
- package/frontendBuild/cssCompiler.ts +122 -11
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +88 -1
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +26 -7
- package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/getCredentials.ts +1 -3
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
- package/integration/devResourceProbe.ts +7 -2
- package/integration/devStabilityHarness.ts +1 -5
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/linter.ts +17 -12
- package/package.json +4 -4
- package/qualityScanner.test.ts +163 -1
- package/qualityScanner.ts +51 -46
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
package/biomeBase.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** `extends` target for a workspace `biome.json`; Biome resolves it through node_modules. */
|
|
2
|
+
export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
|
|
3
|
+
|
|
4
|
+
// Biome moves rules between groups across minors — `noUnnecessaryConditions` sat in `nursery` at 2.4 and moved to
|
|
5
|
+
// `suspicious` at 2.5 — and the stale position is a hard "unknown key" error, not a warning. A workspace whose
|
|
6
|
+
// Biome disagrees with the shipped base config therefore fails to load it at all, which is why the version is
|
|
7
|
+
// pinned here instead of resolved to latest at create time. Bump this and `biome.base.json` in one commit, and run
|
|
8
|
+
// `biome migrate --write` in the workspace root and in `pkgs/@akanjs/devkit` so both configs move together.
|
|
9
|
+
export const biomeVersion = "2.5.8";
|
package/executors.test.ts
CHANGED
|
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
|
|
|
31
31
|
react: "19.0.0",
|
|
32
32
|
"react-dom": "19.0.0",
|
|
33
33
|
"react-server-dom-webpack": "19.0.0",
|
|
34
|
-
sharp: "1.0.0",
|
|
35
34
|
lodash: "4.0.0",
|
|
36
35
|
},
|
|
37
36
|
devDependencies: {
|
|
@@ -149,8 +148,10 @@ describe("Executor filesystem helpers", () => {
|
|
|
149
148
|
"AI Development Guide",
|
|
150
149
|
);
|
|
151
150
|
expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
|
|
151
|
+
// Rules and their plugin registrations live in the package's base config, so a framework release reaches an
|
|
152
|
+
// existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
|
|
152
153
|
expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
|
|
153
|
-
"
|
|
154
|
+
'"extends": ["@akanjs/devkit/biome.base.json"]',
|
|
154
155
|
);
|
|
155
156
|
});
|
|
156
157
|
|
|
@@ -758,6 +759,29 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
758
759
|
expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
|
|
759
760
|
expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
|
|
760
761
|
});
|
|
762
|
+
|
|
763
|
+
test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
|
|
764
|
+
const root = await makeTempRoot();
|
|
765
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
766
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
767
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
768
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
769
|
+
await mkdir(path.join(root, "apps/ambient"), { recursive: true });
|
|
770
|
+
await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
|
|
771
|
+
|
|
772
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
773
|
+
process.env.NODE_ENV = "production";
|
|
774
|
+
try {
|
|
775
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
776
|
+
const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
|
|
777
|
+
expect(started.env.NODE_ENV).toBe("development");
|
|
778
|
+
// The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
|
|
779
|
+
expect(process.env.NODE_ENV).toBe("development");
|
|
780
|
+
} finally {
|
|
781
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
782
|
+
else process.env.NODE_ENV = originalNodeEnv;
|
|
783
|
+
}
|
|
784
|
+
});
|
|
761
785
|
});
|
|
762
786
|
|
|
763
787
|
describe("PkgExecutor package generation", () => {
|
|
@@ -771,7 +795,7 @@ describe("PkgExecutor package generation", () => {
|
|
|
771
795
|
exports: { "./extra": { import: "./extra.ts" } },
|
|
772
796
|
peerDependencies: { react: "19.0.0" },
|
|
773
797
|
peerDependenciesMeta: { react: { optional: true } },
|
|
774
|
-
optionalDependencies: {
|
|
798
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
775
799
|
});
|
|
776
800
|
|
|
777
801
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
@@ -781,12 +805,12 @@ describe("PkgExecutor package generation", () => {
|
|
|
781
805
|
expect(distPackageJson).toMatchObject({
|
|
782
806
|
name: "@sample/tool",
|
|
783
807
|
type: "module",
|
|
784
|
-
engines: { bun: ">=1.
|
|
808
|
+
engines: { bun: ">=1.4.0" },
|
|
785
809
|
dependencies: { lodash: "4.0.0" },
|
|
786
810
|
devDependencies: { typescript: "6.0.0" },
|
|
787
811
|
peerDependencies: { react: "19.0.0" },
|
|
788
812
|
peerDependenciesMeta: { react: { optional: true } },
|
|
789
|
-
optionalDependencies: {
|
|
813
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
790
814
|
});
|
|
791
815
|
expect(distPackageJson.exports?.["."]).toEqual({
|
|
792
816
|
import: "./index.ts",
|
package/executors.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
|
|
|
42
42
|
import { FileSys } from "./fileSys";
|
|
43
43
|
import { getDirname } from "./getDirname";
|
|
44
44
|
import { Linter } from "./linter";
|
|
45
|
+
import { resolveRepoName } from "./repoIdentity";
|
|
45
46
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
46
47
|
import { Spinner } from "./spinner";
|
|
47
48
|
// Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
|
|
@@ -232,7 +233,7 @@ export class Executor {
|
|
|
232
233
|
);
|
|
233
234
|
});
|
|
234
235
|
proc.on("exit", (code, signal) => {
|
|
235
|
-
if (
|
|
236
|
+
if (code || signal)
|
|
236
237
|
reject(
|
|
237
238
|
new CommandExecutionError({
|
|
238
239
|
command,
|
|
@@ -341,7 +342,7 @@ export class Executor {
|
|
|
341
342
|
);
|
|
342
343
|
});
|
|
343
344
|
proc.on("exit", (code, signal) => {
|
|
344
|
-
if (
|
|
345
|
+
if (code || signal)
|
|
345
346
|
reject(
|
|
346
347
|
new CommandExecutionError({
|
|
347
348
|
command: modulePath,
|
|
@@ -788,7 +789,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
788
789
|
static #execs = new Map<string, WorkspaceExecutor>();
|
|
789
790
|
static fromRoot({
|
|
790
791
|
workspaceRoot = process.cwd(),
|
|
791
|
-
repoName =
|
|
792
|
+
repoName = resolveRepoName(workspaceRoot),
|
|
792
793
|
}: {
|
|
793
794
|
workspaceRoot?: string;
|
|
794
795
|
repoName?: string;
|
|
@@ -1363,6 +1364,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1363
1364
|
this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
|
|
1364
1365
|
]);
|
|
1365
1366
|
} else await this.removeDir(".akan");
|
|
1367
|
+
//? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
|
|
1368
|
+
//? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
|
|
1369
|
+
//? and a dev server that believes it is production serves from the production route cache, whose every
|
|
1370
|
+
//? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
|
|
1371
|
+
//? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
|
|
1372
|
+
if (type === "start") process.env.NODE_ENV = "development";
|
|
1366
1373
|
const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
|
|
1367
1374
|
const env = this.getCommandEnv({
|
|
1368
1375
|
AKAN_COMMAND_TYPE: type,
|
|
@@ -1806,7 +1813,7 @@ export class PkgExecutor extends Executor {
|
|
|
1806
1813
|
default: "./index.ts",
|
|
1807
1814
|
},
|
|
1808
1815
|
},
|
|
1809
|
-
engines: { bun: ">=1.
|
|
1816
|
+
engines: { bun: ">=1.4.0" },
|
|
1810
1817
|
...dependencyMaps,
|
|
1811
1818
|
};
|
|
1812
1819
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/tmp/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (content: string) =>
|
|
14
|
+
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
|
+
|
|
16
|
+
describe("FormSetterScanner", () => {
|
|
17
|
+
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
18
|
+
expect(
|
|
19
|
+
scan(`export const General = () => (
|
|
20
|
+
<Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
|
|
21
|
+
);
|
|
22
|
+
`),
|
|
23
|
+
).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
|
|
27
|
+
const warnings = scan(`export const General = () => (
|
|
28
|
+
<>
|
|
29
|
+
<Field.Text onChange={st.do.setTitleOnTask} />
|
|
30
|
+
<Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
|
|
31
|
+
<Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
|
|
32
|
+
<Field.Parent
|
|
33
|
+
onChange={(project) => {
|
|
34
|
+
st.do.setProjectOnTask(project);
|
|
35
|
+
if (project) st.do.addMembersOnTask(project.members ?? []);
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
expect(warnings).toHaveLength(1);
|
|
43
|
+
expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
|
|
44
|
+
expect(warnings[0]?.scope).toBe("agent");
|
|
45
|
+
expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
|
|
46
|
+
expect(warnings[0]?.locations).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a nested path write is unannotatable by design and is not counted", () => {
|
|
50
|
+
expect(
|
|
51
|
+
scan(`export const Rows = ({ idx }: { idx: number }) => (
|
|
52
|
+
<Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
|
|
53
|
+
);
|
|
54
|
+
`),
|
|
55
|
+
).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a zero-parameter handler is a button setting a constant, not a form control", () => {
|
|
59
|
+
expect(
|
|
60
|
+
scan(`export const Actions = () => (
|
|
61
|
+
<Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
|
|
62
|
+
);
|
|
63
|
+
`),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-setter action behind a wrapper is somebody else's rule", () => {
|
|
68
|
+
expect(
|
|
69
|
+
scan(`export const Filters = () => (
|
|
70
|
+
<Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
|
|
71
|
+
);
|
|
72
|
+
`),
|
|
73
|
+
).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only .tsx is scanned", () => {
|
|
77
|
+
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
|
+
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inventory of model fields a screen writes but does not publish.
|
|
6
|
+
*
|
|
7
|
+
* A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
|
|
8
|
+
* `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
|
|
9
|
+
* an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
|
|
10
|
+
* accessibility tree.
|
|
11
|
+
*
|
|
12
|
+
* `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
|
|
13
|
+
* because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
|
|
14
|
+
* per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
|
|
15
|
+
* warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
|
|
16
|
+
*
|
|
17
|
+
* Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
|
|
18
|
+
* ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
|
|
19
|
+
*/
|
|
20
|
+
export class FormSetterScanner {
|
|
21
|
+
static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
|
|
22
|
+
|
|
23
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
24
|
+
return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
28
|
+
const wrapped: { setter: string; line: number }[] = [];
|
|
29
|
+
const visit = (node: ts.Node) => {
|
|
30
|
+
if (ts.isJsxAttribute(node)) {
|
|
31
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
32
|
+
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(sourceFile);
|
|
37
|
+
if (!wrapped.length) return [];
|
|
38
|
+
const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
rule: "akan.agent.unpublished-form-setter",
|
|
42
|
+
scope: "agent",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message:
|
|
45
|
+
setters.length === 1
|
|
46
|
+
? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
|
|
47
|
+
: `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
|
|
48
|
+
file,
|
|
49
|
+
line: wrapped[0]?.line,
|
|
50
|
+
locations: wrapped.map(({ line }) => ({ file, line })),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
56
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
57
|
+
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
58
|
+
const initializer = attribute.initializer;
|
|
59
|
+
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
60
|
+
const handler = initializer.expression;
|
|
61
|
+
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
62
|
+
if (!handler.parameters.length) return null;
|
|
63
|
+
return FormSetterScanner.#setterCallIn(handler.body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #setterCallIn(node: ts.Node): string | null {
|
|
67
|
+
let found: string | null = null;
|
|
68
|
+
const visit = (current: ts.Node) => {
|
|
69
|
+
if (found) return;
|
|
70
|
+
if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
71
|
+
const { expression, name } = current.expression;
|
|
72
|
+
if (
|
|
73
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
74
|
+
ts.isIdentifier(expression.expression) &&
|
|
75
|
+
expression.expression.text === "st" &&
|
|
76
|
+
expression.name.text === "do" &&
|
|
77
|
+
FormSetterScanner.#fieldSetter.test(name.text)
|
|
78
|
+
) {
|
|
79
|
+
found = name.text;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(current, visit);
|
|
84
|
+
};
|
|
85
|
+
visit(node);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
90
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
|
|
|
47
47
|
this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
|
|
48
48
|
this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
49
49
|
|
|
50
|
+
// Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
|
|
51
|
+
// dependency shared by entries from different routes was emitted once per route that reached it.
|
|
52
|
+
// Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
|
|
53
|
+
const allEntries: string[] = [];
|
|
54
|
+
const seen = new Set<string>();
|
|
50
55
|
for (const entry of seedIndex.entries) {
|
|
51
56
|
const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
|
|
52
|
-
const
|
|
53
|
-
|
|
57
|
+
for (const discovered of await this.#discovery.discover(seeds)) {
|
|
58
|
+
if (seen.has(discovered)) continue;
|
|
59
|
+
seen.add(discovered);
|
|
60
|
+
allEntries.push(discovered);
|
|
61
|
+
}
|
|
62
|
+
this.#routeIds.push(entry.routeId);
|
|
54
63
|
}
|
|
64
|
+
this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
|
|
65
|
+
|
|
66
|
+
const delta = await this.#buildEntries(allEntries);
|
|
67
|
+
this.#mergeDelta(delta);
|
|
55
68
|
this.#merged.knownEntries = Array.from(this.#knownSet);
|
|
56
69
|
|
|
57
70
|
const manifest: RoutesManifest = {
|
|
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
|
|
|
76
89
|
return { manifest, manifestPath, seedIndex };
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
async #
|
|
92
|
+
async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
|
|
80
93
|
if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
|
|
94
|
+
if (entries.length === 0)
|
|
95
|
+
return {
|
|
96
|
+
manifestDelta: {},
|
|
97
|
+
ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
|
|
98
|
+
newEntries: [],
|
|
99
|
+
clientDeps: [],
|
|
100
|
+
};
|
|
81
101
|
const started = Date.now();
|
|
82
102
|
const delta = await new RouteClientBuilder({
|
|
83
103
|
app: this.#app,
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
seeds: [],
|
|
105
|
+
entries,
|
|
86
106
|
artifact: this.#artifact,
|
|
87
107
|
knownEntries: this.#knownSet,
|
|
88
108
|
discovery: this.#discovery,
|
|
89
109
|
command: this.#command,
|
|
90
110
|
}).build();
|
|
91
|
-
this.#app.verbose(`[build-all] ${
|
|
111
|
+
this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
|
|
92
112
|
return delta;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
#
|
|
115
|
+
#mergeDelta(delta: BuildRouteClientResult): void {
|
|
96
116
|
for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
|
|
97
117
|
for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
|
|
98
118
|
this.#merged.ssrManifest.moduleMap[url] = byName;
|
|
99
119
|
}
|
|
100
120
|
for (const abs of delta.newEntries) this.#knownSet.add(abs);
|
|
101
|
-
this.#routeIds.push(routeId);
|
|
102
121
|
}
|
|
103
122
|
}
|
|
@@ -82,16 +82,42 @@ describe("route client store bootstrap", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test("bundles akan fetch into production SSR client chunks", () => {
|
|
85
|
-
expect(RouteClientBuilder.
|
|
85
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("start")).toMatchObject({
|
|
86
86
|
external: expect.arrayContaining(["akanjs/fetch"]),
|
|
87
87
|
externalSubpaths: ["akanjs/fetch"],
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
expect(RouteClientBuilder.
|
|
90
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("build")).toEqual({
|
|
91
|
+
target: "bun",
|
|
91
92
|
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
92
93
|
});
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
test("targets the server so SSR client chunks never resolve a browser export condition", async () => {
|
|
97
|
+
const root = await makeTempRoot();
|
|
98
|
+
const pkgDir = path.join(root, "node_modules/dom-conditioned-pkg");
|
|
99
|
+
await write(
|
|
100
|
+
path.join(pkgDir, "package.json"),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
name: "dom-conditioned-pkg",
|
|
103
|
+
type: "module",
|
|
104
|
+
exports: { ".": { browser: "./index.dom.js", default: "./index.js" } },
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
await write(path.join(pkgDir, "index.dom.js"), 'export const element = document.createElement("i");\n');
|
|
108
|
+
await write(path.join(pkgDir, "index.js"), "export const element = null;\n");
|
|
109
|
+
const entry = path.join(root, "entry.ts");
|
|
110
|
+
await write(entry, 'export { element } from "dom-conditioned-pkg";\n');
|
|
111
|
+
|
|
112
|
+
for (const command of ["start", "build"] as const) {
|
|
113
|
+
const { target } = RouteClientBuilder.resolveSsrClientBundleOptions(command);
|
|
114
|
+
const built = await Bun.build({ entrypoints: [entry], target, format: "esm" });
|
|
115
|
+
|
|
116
|
+
expect(built.success).toBe(true);
|
|
117
|
+
expect(await built.outputs[0].text()).not.toContain("document.createElement");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
95
121
|
test("rewrites SSR external imports to runtime aliases", () => {
|
|
96
122
|
const source = [
|
|
97
123
|
'import React, { useState } from "react";',
|
|
@@ -55,6 +55,8 @@ export interface BuildClientResult {
|
|
|
55
55
|
rscClientUrl: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type ClientBundleTarget = "browser" | "bun";
|
|
59
|
+
|
|
58
60
|
export const CLIENT_BUNDLE_NAMING = {
|
|
59
61
|
entry: "[name]-[hash].[ext]",
|
|
60
62
|
chunk: "chunks/[hash].[ext]",
|
|
@@ -79,6 +81,8 @@ export interface BundleClientEntriesOptions {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export interface BundleClientEntriesInternalOptions extends BundleClientEntriesOptions {
|
|
84
|
+
/** Module-resolution target. `"bun"` for the server-executed `client-ssr` bundle, `"browser"` otherwise. */
|
|
85
|
+
target?: ClientBundleTarget;
|
|
82
86
|
external?: readonly string[];
|
|
83
87
|
externalSubpaths?: readonly string[];
|
|
84
88
|
externalAliases?: Partial<Record<string, string>>;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type BundleClientEntriesInternalOptions,
|
|
8
8
|
type BundleClientEntriesResult,
|
|
9
9
|
CLIENT_BUNDLE_NAMING,
|
|
10
|
+
type ClientBundleTarget,
|
|
10
11
|
type ClientManifest,
|
|
11
12
|
type MetafileOutput,
|
|
12
13
|
type OpaqueEntryAliases,
|
|
@@ -25,6 +26,7 @@ export class ClientEntriesBundler {
|
|
|
25
26
|
#externalSubpaths: readonly string[];
|
|
26
27
|
#externalAliases: Partial<Record<string, string>>;
|
|
27
28
|
#command: "build" | "start";
|
|
29
|
+
#target: ClientBundleTarget;
|
|
28
30
|
#outputSubdir: string;
|
|
29
31
|
#reactFastRefresh: boolean;
|
|
30
32
|
#artifactDir: string;
|
|
@@ -47,6 +49,7 @@ export class ClientEntriesBundler {
|
|
|
47
49
|
this.#externalSubpaths = options.externalSubpaths ?? [];
|
|
48
50
|
this.#externalAliases = options.externalAliases ?? {};
|
|
49
51
|
this.#command = options.command ?? "start";
|
|
52
|
+
this.#target = options.target ?? "browser";
|
|
50
53
|
this.#outputSubdir = options.outputSubdir ?? "client";
|
|
51
54
|
this.#reactFastRefresh = options.reactFastRefresh ?? false;
|
|
52
55
|
this.#artifactDir = `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
|
|
@@ -61,7 +64,7 @@ export class ClientEntriesBundler {
|
|
|
61
64
|
entrypoints: this.#opaqueEntries.entries,
|
|
62
65
|
outdir: this.#outdir,
|
|
63
66
|
splitting: true,
|
|
64
|
-
target:
|
|
67
|
+
target: this.#target,
|
|
65
68
|
format: "esm",
|
|
66
69
|
naming: CLIENT_BUNDLE_NAMING,
|
|
67
70
|
metafile: true,
|
|
@@ -24,4 +24,15 @@ describe("CssCandidateCache token extraction", () => {
|
|
|
24
24
|
expect(candidates).toEqual(expect.arrayContaining(["[&_td]:px-3", "[&>*]:gap-2", "[&_tbody_tr]:border-t"]));
|
|
25
25
|
expect(candidates).not.toContain("_td");
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
// A labelled tuple type looks like an arbitrary property, and compiles to a rule whose declaration is
|
|
29
|
+
// TypeScript. `optimize()` cannot parse that, and drops far more than the offending rule on its way
|
|
30
|
+
// out — every variant in the build went missing, so nothing hovered, focused or responded to a
|
|
31
|
+
// breakpoint. Tailwind writes a space in an arbitrary value as `_`, so whitespace rules the token out.
|
|
32
|
+
test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
|
|
33
|
+
const candidates = await scan(
|
|
34
|
+
`type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
|
|
35
|
+
);
|
|
36
|
+
expect(candidates.filter((candidate) => candidate.startsWith("["))).toEqual([]);
|
|
37
|
+
});
|
|
27
38
|
});
|
|
@@ -5,8 +5,15 @@ import { stat } from "node:fs/promises";
|
|
|
5
5
|
* matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
|
|
6
6
|
* on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
|
|
7
7
|
* to nothing and the element silently renders unstyled.
|
|
8
|
+
*
|
|
9
|
+
* Brackets must hold no whitespace. Tailwind spells a space in an arbitrary value `_`, so a bracket
|
|
10
|
+
* group containing one is never a class — but it is exactly the shape of a labelled tuple type
|
|
11
|
+
* (`[key: string, make: () => unknown]`). Scanned as a candidate, that compiles to an arbitrary
|
|
12
|
+
* *property* rule carrying TypeScript as its declaration, which the lightningcss pass in `optimize()`
|
|
13
|
+
* cannot parse; its error recovery then drops a whole span of the stylesheet — in practice every
|
|
14
|
+
* variant rule in the build, leaving hover, focus, responsive and dark styles silently absent.
|
|
8
15
|
*/
|
|
9
|
-
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]]+\])[\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
|
|
16
|
+
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
|
|
10
17
|
|
|
11
18
|
interface CachedFile {
|
|
12
19
|
mtimeMs: number;
|
|
@@ -32,7 +39,7 @@ interface CacheFile {
|
|
|
32
39
|
*/
|
|
33
40
|
export class CssCandidateCache {
|
|
34
41
|
/** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
|
|
35
|
-
static readonly #version =
|
|
42
|
+
static readonly #version = 3;
|
|
36
43
|
readonly #path: string;
|
|
37
44
|
readonly #entries = new Map<string, CachedFile>();
|
|
38
45
|
#dirty = false;
|