@akanjs/devkit 3.0.0 → 3.0.1
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 +1819 -0
- package/artifact/implicitRootLayout.test.ts +2 -1
- package/artifact/implicitRootLayout.ts +2 -1
- package/artifact/routeSeedIndex.test.ts +0 -14
- package/biome.base.json +4 -0
- package/csrE2e/csrE2eHarness.fixture.ts +3 -2
- package/csrE2e/csrPageStack.e2e.test.ts +13 -0
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/integration/devStability.integration.test.ts +8 -5
- package/integration/devStabilityHarness.ts +2 -0
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
- package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
- package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
- package/lint/no-static-in-object-light-model.grit +27 -0
- package/package.json +2 -2
- package/subspace.test.ts +10 -1
|
@@ -69,7 +69,8 @@ describe("resolveSsrPageEntries", () => {
|
|
|
69
69
|
|
|
70
70
|
const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
|
|
71
71
|
expect(generatedSource).toContain("<System.Provider");
|
|
72
|
-
expect(generatedSource).toContain("theme=
|
|
72
|
+
expect(generatedSource).toContain("export const theme = userLayout.theme ?? inheritedLayout.theme;");
|
|
73
|
+
expect(generatedSource).toContain("theme={theme}");
|
|
73
74
|
expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
|
|
74
75
|
expect(generatedSource).toContain(
|
|
75
76
|
'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
|
|
@@ -215,6 +215,7 @@ const defaultFonts = userFonts.filter((font) => font.default);
|
|
|
215
215
|
if (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");
|
|
216
216
|
const defaultFont = defaultFonts[0];
|
|
217
217
|
const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;
|
|
218
|
+
export const theme = userLayout.theme ?? inheritedLayout.theme;
|
|
218
219
|
|
|
219
220
|
${layoutExports}(
|
|
220
221
|
<System.Provider
|
|
@@ -223,7 +224,7 @@ ${layoutExports}(
|
|
|
223
224
|
${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}
|
|
224
225
|
manifest={userLayout.manifest ?? inheritedLayout.manifest}
|
|
225
226
|
env={env}
|
|
226
|
-
theme={
|
|
227
|
+
theme={theme}
|
|
227
228
|
fonts={loadFonts(userFonts)}
|
|
228
229
|
className={defaultFontClassName}
|
|
229
230
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
@@ -33,20 +33,6 @@ describe("computeRouteSeedIndex", () => {
|
|
|
33
33
|
).toThrow("route conflict");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("keeps special route seeds outside implicit locale", () => {
|
|
37
|
-
const index = computeRouteSeedIndex([
|
|
38
|
-
{
|
|
39
|
-
key: "./__root_layout.tsx",
|
|
40
|
-
moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx",
|
|
41
|
-
seedAbsPaths: ["/app/page/_layout.tsx"],
|
|
42
|
-
},
|
|
43
|
-
{ key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
|
|
44
|
-
]);
|
|
45
|
-
const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
|
|
46
|
-
expect(robots?.seeds).not.toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
|
|
47
|
-
expect(robots?.seeds).not.toContain(path.resolve("/app/page/_layout.tsx"));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
36
|
test("serializes seed paths relative to the artifact directory", () => {
|
|
51
37
|
const artifactDir = "/repo/dist/apps/akan/.akan/artifact";
|
|
52
38
|
const serialized = serializeRouteSeedIndexForArtifact(
|
package/biome.base.json
CHANGED
|
@@ -176,6 +176,10 @@
|
|
|
176
176
|
"includes": ["{apps,libs}/**/*.store.ts"],
|
|
177
177
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
|
|
178
178
|
},
|
|
179
|
+
{
|
|
180
|
+
"includes": ["{apps,libs}/**/*.constant.ts"],
|
|
181
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-static-in-object-light-model.grit"]
|
|
182
|
+
},
|
|
179
183
|
{
|
|
180
184
|
"includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
|
|
181
185
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
|
|
@@ -203,9 +203,10 @@ export class CsrE2eHarness {
|
|
|
203
203
|
await Bun.sleep(50);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
//? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would
|
|
206
|
+
//? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would, and a
|
|
207
|
+
//? `back` onto the entry under the page goes back, as another screen's relayed back does.
|
|
207
208
|
//? The frame drops a navigation that starts while a transition runs, so each one waits the transition out.
|
|
208
|
-
async navigate(href: string, kind: "push" | "replace" = "push", { settleMs = SETTLE_MS } = {}) {
|
|
209
|
+
async navigate(href: string, kind: "push" | "replace" | "back" = "push", { settleMs = SETTLE_MS } = {}) {
|
|
209
210
|
const target = new URL(href, this.origin);
|
|
210
211
|
await this.evaluate(
|
|
211
212
|
(next: string, how: string) => {
|
|
@@ -63,6 +63,19 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
63
63
|
expect(await csr.reloaded()).toBe(false);
|
|
64
64
|
}, 90_000);
|
|
65
65
|
|
|
66
|
+
test("a back relayed from another screen pops onto the entry under the page, keeping its state", async () => {
|
|
67
|
+
await csr.open(TAB_A);
|
|
68
|
+
await csr.navigate(item("31"));
|
|
69
|
+
await csr.type(currentInput, "thirty-one");
|
|
70
|
+
await csr.navigate(item("32"));
|
|
71
|
+
await csr.navigate(item("31"), "back");
|
|
72
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["31"]);
|
|
73
|
+
expect(await csr.values(currentInput)).toEqual(["thirty-one"]);
|
|
74
|
+
await csr.back();
|
|
75
|
+
expect((await csr.currentPath()).split("?")[0]).toEndWith(TAB_A);
|
|
76
|
+
expect(await csr.reloaded()).toBe(false);
|
|
77
|
+
}, 60_000);
|
|
78
|
+
|
|
66
79
|
test("a replace within one route updates the page in place", async () => {
|
|
67
80
|
await csr.open(item("21"));
|
|
68
81
|
await csr.type(currentInput, "kept");
|
|
@@ -61,6 +61,12 @@ export class HmrWatcher {
|
|
|
61
61
|
const abs = path.resolve(root, filename.toString());
|
|
62
62
|
this.#queue(abs);
|
|
63
63
|
});
|
|
64
|
+
//? Linux emits an unreadable subdirectory as an async EPERM error event, which throws when unhandled.
|
|
65
|
+
//? The coverage-gap scan already warns about it, so only other errors are worth a warning here.
|
|
66
|
+
w.on("error", (err: NodeJS.ErrnoException) => {
|
|
67
|
+
if (err.code === "EACCES" || err.code === "EPERM") this.#logger.verbose(`[hmr] ${err.message}`);
|
|
68
|
+
else this.#logger.warn(`[hmr] watcher error under ${root}: ${err.message}`);
|
|
69
|
+
});
|
|
64
70
|
this.#watchers.push(w);
|
|
65
71
|
this.#logger.verbose(`[hmr] watching ${root}`);
|
|
66
72
|
} catch (err) {
|
|
@@ -583,10 +583,13 @@ describe("dev resource budgets", () => {
|
|
|
583
583
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
584
584
|
});
|
|
585
585
|
|
|
586
|
+
//? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles.
|
|
587
|
+
//? macOS keeps the bundler arenas (~380MiB built); Linux measured 101-115MiB fresh, 157-176MiB built and settled.
|
|
588
|
+
const CEILING_MB = process.platform === "linux" ? 135 : 200;
|
|
589
|
+
|
|
586
590
|
budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
|
|
587
591
|
const harness = await createHarness();
|
|
588
|
-
|
|
589
|
-
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
|
|
592
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
|
|
590
593
|
const start = host.markLog();
|
|
591
594
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
592
595
|
|
|
@@ -598,7 +601,7 @@ describe("dev resource budgets", () => {
|
|
|
598
601
|
|
|
599
602
|
const recycleLog = await host.waitForLogSince(
|
|
600
603
|
start,
|
|
601
|
-
|
|
604
|
+
new RegExp(`recycling builder pid=(\\d+) \\((rss=\\d+MiB>=${CEILING_MB}MiB after \\d+ build\\(s\\))[;)]`),
|
|
602
605
|
WAIT_MS,
|
|
603
606
|
);
|
|
604
607
|
await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
|
|
@@ -634,7 +637,7 @@ describe("dev resource budgets", () => {
|
|
|
634
637
|
const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
635
638
|
const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
|
|
636
639
|
//? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
|
|
637
|
-
if ((settled?.rssBytes ?? 0) >=
|
|
640
|
+
if ((settled?.rssBytes ?? 0) >= CEILING_MB * MB && !warned)
|
|
638
641
|
await host.waitForLogSince(
|
|
639
642
|
settledFrom,
|
|
640
643
|
/ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
|
|
@@ -645,7 +648,7 @@ describe("dev resource budgets", () => {
|
|
|
645
648
|
|
|
646
649
|
budgetTest("serves a page requested while the builder is being replaced", async () => {
|
|
647
650
|
const harness = await createHarness();
|
|
648
|
-
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB:
|
|
651
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
|
|
649
652
|
const port = await harness.resolvePort();
|
|
650
653
|
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
651
654
|
|
|
@@ -269,6 +269,8 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
269
269
|
// The dev-plan/hmr assertions match verbose-level lines.
|
|
270
270
|
AKAN_PUBLIC_LOG_LEVEL: "verbose",
|
|
271
271
|
NODE_NO_WARNINGS: "1",
|
|
272
|
+
//? CI's `.env` names the branch; any env but local makes `akan start` ssh-tunnel to that cluster's Redis.
|
|
273
|
+
AKAN_PUBLIC_ENV: "local",
|
|
272
274
|
PORT_OFFSET: String(offset),
|
|
273
275
|
// Pinned: `getDevPort()` derives from the fixture's index in `apps/`, which parallel runs keep moving.
|
|
274
276
|
AKAN_DEV_PORT: String(port),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { enumOf, ID, Int } from "akanjs/base";
|
|
2
|
+
import { via } from "akanjs/constant";
|
|
3
|
+
|
|
4
|
+
export class QuizInput extends via((field) => ({
|
|
5
|
+
userId: field(ID, { ref: "user", immutable: true }),
|
|
6
|
+
})) {}
|
|
7
|
+
|
|
8
|
+
export class QuizObject extends via(QuizInput, (field) => ({
|
|
9
|
+
correctNum: field(Int, { default: 0, min: 0 }),
|
|
10
|
+
})) {
|
|
11
|
+
static maxNum = 4; // @flag
|
|
12
|
+
static readonly minNum = 1; // @flag
|
|
13
|
+
private static secretNum = 2; // @flag
|
|
14
|
+
static { // @flag
|
|
15
|
+
QuizObject.maxNum = 5;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class LightQuiz extends via(
|
|
20
|
+
QuizObject,
|
|
21
|
+
["userId", "correctNum"] as const,
|
|
22
|
+
(resolve) => ({}),
|
|
23
|
+
) {
|
|
24
|
+
static choiceNum = 4; // @flag
|
|
25
|
+
static xpOfCorrect(comboNum: number) { // @flag
|
|
26
|
+
return comboNum * 10;
|
|
27
|
+
}
|
|
28
|
+
static async loadAll() { // @flag
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
static get defaultNum() { // @flag
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
isPerfect() {
|
|
35
|
+
return this.correctNum === 4;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class LightRenamed extends via(QuizObject, ["userId"] as const, (resolve) => ({})) {
|
|
40
|
+
static sizeNum = 1; // @flag
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "path": "lib/quiz/quiz.constant.ts" }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ID, Int } from "akanjs/base";
|
|
2
|
+
import { via } from "akanjs/constant";
|
|
3
|
+
|
|
4
|
+
export class QuizInput extends via((field) => ({
|
|
5
|
+
userId: field(ID, { ref: "user", immutable: true }),
|
|
6
|
+
})) {
|
|
7
|
+
static minSize = 4; // @ok
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class QuizObject extends via(QuizInput, (field) => ({
|
|
11
|
+
correctNum: field(Int, { default: 0, min: 0 }),
|
|
12
|
+
})) {}
|
|
13
|
+
|
|
14
|
+
export class LightQuiz extends via(QuizObject, ["userId", "correctNum"] as const, (resolve) => ({})) {
|
|
15
|
+
isPerfect() { // @ok
|
|
16
|
+
return this.correctNum === 4;
|
|
17
|
+
}
|
|
18
|
+
staticNum = 1; // @ok
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {
|
|
22
|
+
static choiceNum = 4; // @ok
|
|
23
|
+
static xpOfCorrect(comboNum: number) { // @ok
|
|
24
|
+
return comboNum * 10;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class QuizInsight extends via(Quiz, (field) => ({})) {
|
|
29
|
+
static emptyNum = 0; // @ok
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Coordinate extends via((field) => ({
|
|
33
|
+
lat: field(Int),
|
|
34
|
+
})) {
|
|
35
|
+
static getDistanceKm(a: Coordinate, b: Coordinate) { // @ok
|
|
36
|
+
return a.lat - b.lat;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ObjectRegistry {
|
|
41
|
+
static instance = new ObjectRegistry(); // @ok
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `via(XObject, LightX, ...)` mixes in only the prototypes of XObject and LightX (applyMixins in
|
|
5
|
+
// akanjs/common), so a static declared on either never reaches the full model `X`. Statics belong on
|
|
6
|
+
// XInput (or a scalar), X, or XInsight. The Object class is recognised by its `*Object` name, because its
|
|
7
|
+
// via() shape is identical to the Insight's; the Light class also by its `[...] as const` field tuple.
|
|
8
|
+
or {
|
|
9
|
+
JsMethodClassMember(),
|
|
10
|
+
JsPropertyClassMember(),
|
|
11
|
+
JsGetterClassMember(),
|
|
12
|
+
JsSetterClassMember(),
|
|
13
|
+
JsStaticInitializationBlockClassMember()
|
|
14
|
+
} as $member where {
|
|
15
|
+
$member <: r"(?:(?:public|protected|private|declare|override|readonly|accessor)\s+)*static\b[\s\S]*",
|
|
16
|
+
$member <: within JsClassDeclaration() as $model where {
|
|
17
|
+
$model <: or {
|
|
18
|
+
r"class\s+(?:Light[A-Za-z0-9_$]*|[A-Za-z0-9_$]*Object)\s+extends\s+via\([\s\S]*",
|
|
19
|
+
r"class\s+[A-Za-z0-9_$]+\s+extends\s+via\(\s*[A-Za-z0-9_$.]+\s*,\s*\[[\s\S]*"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
register_diagnostic(
|
|
23
|
+
span = $member,
|
|
24
|
+
message = "A static on an XObject or LightX class never reaches the full model, because via() mixes in their prototypes only. Declare it on XInput, the full model X, or XInsight instead.",
|
|
25
|
+
severity = "error"
|
|
26
|
+
)
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.
|
|
50
|
+
"akanjs": "3.0.1",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"commander": "^14.0.3",
|
|
53
53
|
"dayjs": "^1.11.20",
|
package/subspace.test.ts
CHANGED
|
@@ -10,7 +10,16 @@ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
|
|
|
10
10
|
//? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
|
|
11
11
|
setDefaultTimeout(20_000);
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
//? Subspace commits in its clone with the host's identity, and a CI host has none configured.
|
|
14
|
+
isolateEnv({
|
|
15
|
+
AKAN_PUBLIC_REPO_NAME: "workspace",
|
|
16
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "example.com",
|
|
17
|
+
AKAN_PUBLIC_ENV: "local",
|
|
18
|
+
GIT_AUTHOR_NAME: "t",
|
|
19
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
20
|
+
GIT_COMMITTER_NAME: "t",
|
|
21
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
22
|
+
});
|
|
14
23
|
const makeTempRoot = tempDirs("akan-subspace-");
|
|
15
24
|
|
|
16
25
|
const git = async (cwd: string, args: string[]) =>
|