@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +1 -1
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +25 -3
  9. package/applicationBuildReporter.ts +8 -1
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +76 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +16 -7
  22. package/codeAgent/skills/akan-service/SKILL.md +48 -19
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/createTunnel.test.ts +12 -2
  26. package/declarationReader.ts +211 -0
  27. package/executors.test.ts +65 -8
  28. package/executors.ts +5 -3
  29. package/frameworkGraph.test.ts +121 -0
  30. package/frameworkGraph.ts +165 -0
  31. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  32. package/frontendBuild/allRoutesBuilder.ts +32 -12
  33. package/frontendBuild/buildRouteClient.test.ts +192 -2
  34. package/frontendBuild/bundleDefine.ts +4 -0
  35. package/frontendBuild/clientBuildTypes.ts +7 -1
  36. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  37. package/frontendBuild/clientEntriesBundler.ts +55 -4
  38. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  39. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  40. package/frontendBuild/fontOptimizer.test.ts +54 -0
  41. package/frontendBuild/fontOptimizer.ts +5 -4
  42. package/frontendBuild/index.ts +3 -0
  43. package/frontendBuild/routeClientBuilder.ts +6 -2
  44. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  45. package/frontendBuild/routeModulePreloads.ts +52 -0
  46. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  47. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  48. package/frontendBuild/ssrDevBundler.ts +2 -1
  49. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  50. package/frontendBuild/vendorImportScanner.ts +80 -0
  51. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  52. package/frontendBuild/vendorNarrowing.ts +64 -0
  53. package/frontendBuild/vendorSpecifiers.ts +13 -0
  54. package/gitRevision.ts +6 -2
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/no-deep-internal-import.grit +1 -1
  61. package/maintainabilityBaseline.ts +7 -2
  62. package/maintainabilityContract.test.ts +183 -0
  63. package/maintainabilityContract.ts +145 -30
  64. package/maintainabilityExceptionScanner.test.ts +13 -2
  65. package/maintainabilityExceptionScanner.ts +16 -6
  66. package/maintainabilityLimits.ts +12 -0
  67. package/maintainabilityReport.test.ts +35 -0
  68. package/maintainabilityReport.ts +29 -8
  69. package/mobile/nativePublicEnv.test.ts +5 -1
  70. package/package.json +3 -3
  71. package/preview/PreviewChrome.ts +3 -3
  72. package/preview/PreviewView.ts +26 -8
  73. package/preview/previewView.test.ts +87 -3
  74. package/routeJsBudget.test.ts +177 -0
  75. package/routeJsBudget.ts +222 -0
  76. package/structureScanner.test.ts +167 -0
  77. package/structureScanner.ts +164 -0
  78. package/transforms/barrelAnalyzer.ts +10 -3
  79. package/transforms/barrelImportsPlugin.ts +13 -7
  80. package/transforms/serverEnvPlugin.test.ts +1 -1
  81. package/transforms/serverEnvPlugin.ts +1 -1
  82. package/transforms/transforms.test.ts +64 -12
  83. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  84. package/workflow/plan.ts +79 -17
  85. package/workflow/types.ts +1 -0
  86. package/workspaceGraph.test.ts +58 -0
  87. package/workspaceGraph.ts +32 -3
  88. package/workspaceLayout.ts +1 -0
  89. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -2,6 +2,7 @@ import { readdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { App } from "../commandDecorators";
4
4
  import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
5
+ import { StoreBootstrap } from "./storeBootstrap";
5
6
 
6
7
  export interface PageEntry {
7
8
  key: string;
@@ -148,7 +149,7 @@ async function writeGeneratedRootLayoutFile(opts: {
148
149
  appName: string;
149
150
  boundary: RootBoundary;
150
151
  rootSourceAbsPath: string | null;
151
- includeStInit: boolean;
152
+ includeStoreBootstrap: boolean;
152
153
  includeSystemProvider: boolean;
153
154
  }): Promise<string> {
154
155
  await assertEnvClientConvention(opts.appCwdPath, opts.appName);
@@ -161,9 +162,7 @@ async function writeGeneratedRootLayoutFile(opts: {
161
162
  const inheritedSourceAbsPath =
162
163
  opts.rootSourceAbsPath && opts.rootSourceAbsPath !== opts.boundary.sourceAbsPath ? opts.rootSourceAbsPath : null;
163
164
  const inheritedSourceSpecifier = inheritedSourceAbsPath ? importSpecifier(absPath, inheritedSourceAbsPath) : null;
164
- const clientImport = opts.includeStInit
165
- ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
166
- : `import "@apps/${opts.appName}/client";\n`;
165
+ const storeImport = opts.includeStoreBootstrap ? StoreBootstrap.source(opts.appName) : "";
167
166
  //? resolveRouteModule reads a rootLayout() chain and the legacy named exports alike.
168
167
  const inheritedLabel = inheritedSourceAbsPath ? posixRelative(opts.appCwdPath, inheritedSourceAbsPath) : "";
169
168
  const inheritedImport = inheritedSourceSpecifier
@@ -223,7 +222,7 @@ import { System } from "akanjs/ui/System/index.tsx";
223
222
  import type { McpAppRootProps } from "akanjs/webkit";
224
223
  import { env } from "@apps/${opts.appName}/env/env.client";
225
224
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
226
- ${clientImport}${inheritedImport}${userImport}
225
+ ${storeImport}${inheritedImport}${userImport}
227
226
  const userFonts = userLayout.fonts ?? inheritedLayout.fonts ?? [];
228
227
  const defaultFonts = userFonts.filter((font) => font.default);
229
228
  if (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");
@@ -323,7 +322,7 @@ export async function resolveSsrPageEntries(opts: {
323
322
  appName: opts.appName,
324
323
  boundary,
325
324
  rootSourceAbsPath,
326
- includeStInit: hasSt && boundary.segments.length === 0,
325
+ includeStoreBootstrap: hasSt,
327
326
  includeSystemProvider: !hasAncestorRootBoundary(boundary, rootBoundaries),
328
327
  }),
329
328
  seedAbsPaths: [...new Set([boundary.sourceAbsPath, rootSourceAbsPath].filter((absPath) => absPath !== null))],
@@ -0,0 +1,24 @@
1
+ import type { BarrelExportMap } from "../transforms/barrelAnalyzer";
2
+
3
+ //* Not a bare `@apps/<app>/client` import: it names nothing the barrel rewrite can narrow, so every module's UI ships.
4
+ export class StoreBootstrap {
5
+ static importLines(appName: string): string[] {
6
+ return StoreBootstrap.#lines([`@apps/${appName}/lib/useClient`, `@apps/${appName}/lib/st`]);
7
+ }
8
+
9
+ static source(appName: string): string {
10
+ return `${StoreBootstrap.importLines(appName).join("\n")}\n`;
11
+ }
12
+
13
+ static ofClientBarrel(specifier: string, exports: BarrelExportMap): string[] | null {
14
+ if (!/^@(?:apps|libs)\/[^/]+\/client$/.test(specifier)) return null;
15
+ const runtime = exports.get("usePage");
16
+ const store = exports.get("st");
17
+ if (!runtime || runtime.namespace || !store || store.namespace) return StoreBootstrap.#lines([specifier]);
18
+ return StoreBootstrap.#lines([...new Set([runtime.subpath, store.subpath])]);
19
+ }
20
+
21
+ static #lines(specifiers: string[]): string[] {
22
+ return specifiers.map((specifier) => `import ${JSON.stringify(specifier)};`);
23
+ }
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { readFile, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import dayjs from "dayjs";
4
5
  import type { Workspace } from "../commandDecorators";
5
6
  import { tempDirs, writeJson, writeText } from "../testHelpers";
6
7
  import { CloudApi } from "./cloudApi";
@@ -9,8 +10,10 @@ import { CloudApi } from "./cloudApi";
9
10
  class FakeCloud {
10
11
  readonly server: ReturnType<typeof Bun.serve>;
11
12
  readonly refreshes: string[] = [];
13
+ readonly refreshBearers: (string | null)[] = [];
12
14
  reused = false;
13
15
  delayMs = 0;
16
+ lifetimeMs = 7 * 86_400_000;
14
17
  hang: boolean = false;
15
18
  readonly remoteAuthPolls: string[] = [];
16
19
  remoteAuthAnswers: (Response | { jwt: string; refreshToken?: string } | null)[] = [];
@@ -44,6 +47,7 @@ class FakeCloud {
44
47
  if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
45
48
  const { refreshToken } = (await request.json()) as { refreshToken: string };
46
49
  this.refreshes.push(refreshToken);
50
+ this.refreshBearers.push(request.headers.get("authorization"));
47
51
  if (this.#spent.has(refreshToken)) this.reused = true;
48
52
  if (!this.#refreshTokens.delete(refreshToken)) return Response.json({ error: "invalid" }, { status: 401 });
49
53
  this.#spent.add(refreshToken);
@@ -53,7 +57,7 @@ class FakeCloud {
53
57
  this.#jwts.add(next.jwt);
54
58
  if (this.hang) await new Promise(() => undefined);
55
59
  await Bun.sleep(this.delayMs);
56
- return Response.json({ ...next, expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() });
60
+ return Response.json({ ...next, expiresAt: new Date(Date.now() + this.lifetimeMs).toISOString() });
57
61
  }
58
62
  }
59
63
 
@@ -100,13 +104,21 @@ await GlobalConfig.setTestTargets({ linux: { cpus: 2 } });
100
104
  return { code: await proc.exited, stderr: await new Response(proc.stderr).text() };
101
105
  };
102
106
 
103
- const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshToken = "refresh-0" } = {}) =>
107
+ const storeSession = async (
108
+ home: string,
109
+ { expiresInMs = 30 * 60_000, refreshToken = "refresh-0", issuedAgoMs = null as number | null } = {},
110
+ ) =>
104
111
  await writeJson(path.join(home, ".akan", "config.json"), {
105
112
  cloudHost: {
106
113
  [cloud.host]: {
107
114
  host: cloud.host,
108
115
  auth: {
109
- accessToken: { jwt: "jwt-0", refreshToken, expiresAt: new Date(Date.now() + expiresInMs).toISOString() },
116
+ accessToken: {
117
+ jwt: "jwt-0",
118
+ refreshToken,
119
+ expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
120
+ issuedAt: issuedAgoMs === null ? null : new Date(Date.now() - issuedAgoMs).toISOString(),
121
+ },
110
122
  self: { id: "u1", nickname: "akan" },
111
123
  },
112
124
  },
@@ -114,9 +126,34 @@ const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshTo
114
126
  });
115
127
  const storedToken = async (home: string) => {
116
128
  const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
117
- return config.cloudHost[cloud.host].auth.accessToken as { jwt: string; refreshToken: string | null };
129
+ return config.cloudHost[cloud.host].auth.accessToken as {
130
+ jwt: string;
131
+ refreshToken: string | null;
132
+ issuedAt?: string | null;
133
+ };
118
134
  };
119
135
 
136
+ describe("CloudApi.needsSignIn", () => {
137
+ const apiWith = (accessToken: { refreshToken: string | null; expiresInMs: number }) =>
138
+ new CloudApi({ workspaceRoot: "/nowhere" } as unknown as Workspace, {
139
+ host: cloud.host,
140
+ auth: {
141
+ accessToken: {
142
+ jwt: "jwt-0",
143
+ refreshToken: accessToken.refreshToken,
144
+ expiresAt: dayjs().add(accessToken.expiresInMs, "ms"),
145
+ },
146
+ },
147
+ });
148
+
149
+ test("asks for a sign-in only when a session due for refresh has no refresh token left", () => {
150
+ cloud = new FakeCloud();
151
+ expect(apiWith({ refreshToken: null, expiresInMs: 30 * 60_000 }).needsSignIn).toBe(true);
152
+ expect(apiWith({ refreshToken: "refresh-0", expiresInMs: 30 * 60_000 }).needsSignIn).toBe(false);
153
+ expect(apiWith({ refreshToken: null, expiresInMs: 3 * 3_600_000 }).needsSignIn).toBe(false);
154
+ });
155
+ });
156
+
120
157
  describe("CloudApi.fromHost token refresh", () => {
121
158
  test("two CLI processes with a session in its last hour make exactly one refresh, and both use its result", async () => {
122
159
  cloud = new FakeCloud();
@@ -165,20 +202,46 @@ describe("CloudApi.fromHost token refresh", () => {
165
202
  cloud.hang = false;
166
203
 
167
204
  const started = Date.now();
168
- expect(await output(await cliRun(home))).toEqual([null]);
205
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
169
206
  expect(Date.now() - started).toBeLessThan(10_000);
170
207
  expect(cloud.refreshes).toEqual(["refresh-0"]);
171
208
  expect(cloud.reused).toBe(false);
172
209
  expect((await storedToken(home)).refreshToken).toBeNull();
173
210
  }, 30_000);
174
211
 
212
+ test("a cloud that issues one-hour tokens is refreshed once, and every command signs in with its token", async () => {
213
+ cloud = new FakeCloud();
214
+ cloud.lifetimeMs = 60 * 60_000;
215
+ const home = await makeHome();
216
+ await storeSession(home);
217
+
218
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
219
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
220
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
221
+ expect(cloud.refreshBearers).toEqual([null]);
222
+ expect(await storedToken(home)).toMatchObject({ jwt: "jwt-1", refreshToken: "refresh-1" });
223
+ expect((await storedToken(home)).issuedAt).toBeString();
224
+ }, 30_000);
225
+
226
+ test("a one-hour token in the second half of its life is refreshed, and one in the first half is not", async () => {
227
+ cloud = new FakeCloud();
228
+ const home = await makeHome();
229
+ await storeSession(home, { expiresInMs: 35 * 60_000, issuedAgoMs: 25 * 60_000 });
230
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
231
+ expect(cloud.refreshes).toEqual([]);
232
+
233
+ await storeSession(home, { expiresInMs: 25 * 60_000, issuedAgoMs: 35 * 60_000 });
234
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
235
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
236
+ }, 30_000);
237
+
175
238
  test("a refresh the cloud refuses is not presented again", async () => {
176
239
  cloud = new FakeCloud();
177
240
  const home = await makeHome();
178
241
  await storeSession(home, { refreshToken: "refresh-unknown" });
179
242
 
180
- expect(await output(await cliRun(home))).toEqual([null]);
181
- expect(await output(await cliRun(home))).toEqual([null]);
243
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
244
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
182
245
  expect(cloud.refreshes).toEqual(["refresh-unknown"]);
183
246
  expect((await storedToken(home)).refreshToken).toBeNull();
184
247
  }, 30_000);
package/cloud/cloudApi.ts CHANGED
@@ -144,9 +144,9 @@ export class CloudApi {
144
144
  static async fromHost(workspace: Workspace, host?: string) {
145
145
  const hostConfig = await GlobalConfig.getHostConfig(host);
146
146
  const accessToken = hostConfig.auth?.accessToken;
147
- // No refresh token on a session in its last hour can be another process's refresh in flight: wait on its lock.
147
+ // No refresh token on a session due for refresh can be another process's refresh in flight: wait on its lock.
148
148
  if (!accessToken || !GlobalConfig.needRefreshToken(accessToken)) return new CloudApi(workspace, hostConfig);
149
- const tokenless = new CloudApi(workspace, hostConfig);
149
+ const tokenless = new CloudApi(workspace, { host: hostConfig.host });
150
150
  const refreshed = await GlobalConfig.refreshHostAuth(hostConfig.host, (refreshToken) =>
151
151
  tokenless.refreshAuthToken(refreshToken),
152
152
  );
@@ -158,12 +158,18 @@ export class CloudApi {
158
158
  this.host = hostConfig.host;
159
159
  this.url = `${this.host}/api`;
160
160
  this.#api = new HttpClient(this.url);
161
- if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
161
+ if (this.#accessToken && !GlobalConfig.isExpired(this.#accessToken))
162
162
  this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
163
163
  }
164
164
 
165
+ //? With no refresh token left, a session due for refresh ends at its expiry and only a new sign-in renews it.
166
+ get needsSignIn(): boolean {
167
+ const accessToken = this.#accessToken;
168
+ return !!accessToken && !accessToken.refreshToken && GlobalConfig.needRefreshToken(accessToken);
169
+ }
170
+
165
171
  #authorize(accessTokenDto: AccessTokenDto) {
166
- this.#accessToken = GlobalConfig.toAccessToken(accessTokenDto);
172
+ this.#accessToken = GlobalConfig.toAccessToken({ ...accessTokenDto, issuedAt: new Date().toISOString() });
167
173
  this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
168
174
  return this.#accessToken;
169
175
  }
@@ -61,9 +61,12 @@ export interface AccessTokenDto {
61
61
  jwt: string;
62
62
  refreshToken: string | null;
63
63
  expiresAt: string | null;
64
+ issuedAt?: string | null;
64
65
  }
65
66
  export interface AccessToken {
66
67
  jwt: string;
67
68
  refreshToken: string | null;
68
69
  expiresAt: Dayjs | null;
70
+ //? When this CLI received the token, on its own clock: the cloud's answer names only the expiry.
71
+ issuedAt?: Dayjs | null;
69
72
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import dayjs from "dayjs";
4
5
  import { tempDirs } from "../testHelpers";
5
6
  import { GlobalConfig } from "./globalConfig";
6
7
 
@@ -22,6 +23,41 @@ const modeOf = async (target: string) => (await stat(target)).mode & 0o777;
22
23
  const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
23
24
 
24
25
  describe("GlobalConfig", () => {
26
+ test("needRefreshToken asks for a refresh in a token's last hour, or in the second half of a shorter life", () => {
27
+ const token = (issuedAgoMin: number | null, expiresInMin: number) => ({
28
+ jwt: "jwt",
29
+ refreshToken: "refresh",
30
+ expiresAt: dayjs().add(expiresInMin, "minute"),
31
+ issuedAt: issuedAgoMin === null ? null : dayjs().subtract(issuedAgoMin, "minute"),
32
+ });
33
+ const week = 7 * 24 * 60;
34
+ expect(GlobalConfig.needRefreshToken(token(0, 60))).toBe(false);
35
+ expect(GlobalConfig.needRefreshToken(token(25, 35))).toBe(false);
36
+ expect(GlobalConfig.needRefreshToken(token(35, 25))).toBe(true);
37
+ expect(GlobalConfig.needRefreshToken(token(0, 10))).toBe(false);
38
+ expect(GlobalConfig.needRefreshToken(token(week - 61, 61))).toBe(false);
39
+ expect(GlobalConfig.needRefreshToken(token(week - 59, 59))).toBe(true);
40
+ expect(GlobalConfig.needRefreshToken(token(null, 61))).toBe(false);
41
+ expect(GlobalConfig.needRefreshToken(token(null, 59))).toBe(true);
42
+ expect(GlobalConfig.isExpired(token(59, 1))).toBe(false);
43
+ expect(GlobalConfig.isExpired(token(61, -1))).toBe(true);
44
+ expect(GlobalConfig.needRefreshToken({ ...token(0, 59), issuedAt: dayjs("not a date") })).toBe(true);
45
+ });
46
+
47
+ test("keeps a token's issuedAt through config.json, and leaves the key out when there is none", () => {
48
+ const issuedAt = dayjs().subtract(10, "minute");
49
+ const token = { jwt: "jwt", refreshToken: "refresh", expiresAt: dayjs().add(50, "minute"), issuedAt };
50
+ const read = GlobalConfig.toAccessToken(
51
+ JSON.parse(JSON.stringify(GlobalConfig.toAccessTokenDto(token))) as ReturnType<
52
+ typeof GlobalConfig.toAccessTokenDto
53
+ >,
54
+ );
55
+ expect(Math.abs(read.issuedAt?.diff(issuedAt) ?? Number.NaN)).toBeLessThan(1_000);
56
+ expect(GlobalConfig.needRefreshToken(read)).toBe(false);
57
+ const legacy = { jwt: "jwt", refreshToken: "refresh", expiresAt: null };
58
+ expect(GlobalConfig.toAccessTokenDto(GlobalConfig.toAccessToken(legacy))).toEqual(legacy);
59
+ });
60
+
25
61
  test("hostFor spells --host the way the workspace's cloud.host is spelled, so one login serves both", async () => {
26
62
  for (const given of ["https://cloud.example.com/", "https://Cloud.Example.com:443", "https://cloud.example.com//"])
27
63
  expect(await GlobalConfig.hostFor(workspace(null), given)).toBe("https://cloud.example.com");
@@ -129,14 +129,22 @@ export class GlobalConfig {
129
129
  testTargets: { ...akanConfig.testTargets, ...testTargets },
130
130
  }));
131
131
  }
132
+ static readonly #refreshMarginMs = 60 * 60_000;
132
133
  static needRefreshToken(accessToken: AccessToken): boolean {
133
- return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
134
+ const { expiresAt, issuedAt } = accessToken;
135
+ if (!expiresAt) return false;
136
+ const lifetimeMs = issuedAt?.isValid() ? expiresAt.diff(issuedAt) : Number.POSITIVE_INFINITY;
137
+ return expiresAt.isBefore(dayjs().add(Math.min(GlobalConfig.#refreshMarginMs, lifetimeMs / 2), "ms"));
138
+ }
139
+ static isExpired(accessToken: AccessToken): boolean {
140
+ return !!accessToken.expiresAt && !accessToken.expiresAt.isAfter(dayjs());
134
141
  }
135
142
  static toAccessToken(accessToken: AccessTokenDto): AccessToken {
136
143
  return {
137
144
  jwt: accessToken.jwt,
138
145
  refreshToken: accessToken.refreshToken ?? null,
139
146
  expiresAt: accessToken.expiresAt ? dayjs(accessToken.expiresAt) : null,
147
+ ...(accessToken.issuedAt ? { issuedAt: dayjs(accessToken.issuedAt) } : {}),
140
148
  };
141
149
  }
142
150
  static toAccessTokenDto(accessToken: AccessToken): AccessTokenDto {
@@ -144,6 +152,7 @@ export class GlobalConfig {
144
152
  jwt: accessToken.jwt,
145
153
  refreshToken: accessToken.refreshToken ?? null,
146
154
  expiresAt: accessToken.expiresAt?.toString() ?? null,
155
+ ...(accessToken.issuedAt ? { issuedAt: accessToken.issuedAt.toString() } : {}),
147
156
  };
148
157
  }
149
158
  static toHostConfigDto(hostConfig: HostConfig): HostConfigDto {
@@ -15,7 +15,8 @@ the server errors logged meanwhile, console errors and warnings, and layout smel
15
15
  stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
16
16
  running one.
17
17
  - Paths are comma-separated (`akan preview <app> /board,/board/new`) and get the default locale when they lack
18
- one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off.
18
+ one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off. A `/_akan` route such as Studio
19
+ (`/_akan/studio?view=runtime`), a signal route or a file opens as written.
19
20
  `--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
20
21
  path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
21
22
  - The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
@@ -8,6 +8,11 @@ description: "Add or change a route under page/: the one page()/layout() chain,
8
8
  A route file exports one chain and nothing else. Every setting is a stage of it.
9
9
 
10
10
  ```tsx
11
+ import { fetch, Task, usePage } from "@apps/<app>/client";
12
+ import { signedIn } from "@libs/shared/webkit";
13
+ import { ID } from "akanjs/base";
14
+ import { page } from "akanjs/client";
15
+
11
16
  export default page()
12
17
  .param("orgId", ID)
13
18
  .guard(() => signedIn())
@@ -26,7 +31,8 @@ export default page()
26
31
  2. Name every `[x]` segment with `.param("x", Type)` and read a query key only with `.search("k", Type)`. Values
27
32
  arrive typed; `lang` reaches every stage and is never declared.
28
33
  3. Gate with `.guard(() => signedIn())` on the `_layout.tsx` (a real redirect before anything renders), and repeat
29
- a stricter guard on a page that needs more. The endpoint guards are still the real gate.
34
+ a stricter guard on a page that needs more. The endpoint guards are still the real gate. `signedIn` comes from
35
+ `@libs/shared/webkit`, and a new workspace has that lib only with `create-workspace --libs true`.
30
36
  4. In `.render()`: `usePage()`, then fetch, then return. Hand `fetch.init*` / `view*` / `edit*` fields to sections
31
37
  unawaited; await only what the shell reads, in one `Promise.all`.
32
38
  5. `.head()` takes JSX (or a function returning it), never a metadata object. A canonical link comes only from the
@@ -37,19 +43,21 @@ export default page()
37
43
  ## API at a glance
38
44
 
39
45
  `page`, `layout` and `router` come from `akanjs/client`, `Link` from `akanjs/ui`, `usePageLocation` from
40
- `akanjs/webkit`. A stage receives the declared params and search keys plus `lang`.
46
+ `akanjs/webkit`, and `fetch`, `usePage` and the model namespaces from `@apps/<app>/client`. A stage receives the
47
+ declared params and search keys plus `lang`.
41
48
 
42
- - Data-driven title: `.head(async ({ name }) => <title>{(await fetch.pokemon(name))?.label}</title>)`.
43
- - Async page: `.render(async ({ name }) => { const pokemon = await fetch.pokemon(name); if (!pokemon) router.notFound(); … })`;
49
+ - Data-driven title: `.head(async ({ id }) => <title>{((await fetch.book(id)) as Book | null)?.title}</title>)`,
50
+ where `Book` is the `common/` interface for the query's `Any` return (`akan-service`).
51
+ - Async page: `.render(async ({ id }) => { const book = (await fetch.book(id)) as Book | null; if (!book) return router.notFound(); … })`;
44
52
  the same query in `.head()` and `.render()` is one request.
45
53
  - `router.notFound()` from `.guard()`, `.head()` or `.render()` answers 404 with the nearest layout's `.notFound()`
46
54
  above it (an unmatched URL: the deepest layout covering it), else the built-in 404.
47
55
  - A layout's own 404 and error views: `.notFound(({ pathname }) => …)` and `.error(({ error }) => …)` on `layout()`
48
56
  or `rootLayout()`.
49
- - `<Link href="/pokemon/deoxys">`: write the path without the locale; Link adds it, or leaves it off for the default
57
+ - `<Link href="/books/OL45804W">`: write the path without the locale; Link adds it, or leaves it off for the default
50
58
  locale under `i18n.localePrefix: "as-needed"`. `activeClassName` marks the current one.
51
59
  - From a handler: `router.push(href)` / `router.replace(href)`; in a server stage, `router.redirect(href)`.
52
- - A client filter kept in the URL without reloading the page: ``router.replace(`/pokemon?q=${q}`, { shallow: true,
60
+ - A client filter kept in the URL without reloading the page: ``router.replace(`/books?q=${q}`, { shallow: true,
53
61
  scrollToTop: false })`` in a `"use client"` component, read back with `usePageLocation().searchParams`. Shallow
54
62
  holds only while the path stays the same; a server `.search()` sees the value at the next full navigation.
55
63
 
@@ -60,4 +68,5 @@ export default page()
60
68
  - `useState`, `useEffect`, comments, a named export beside the chain, or the legacy `export default async function`.
61
69
  - Editing a lib page through its link in `page/(libs)/` — edit the lib's own `page/` source.
62
70
 
63
- Full detail: `get_guideline` with `pageRule`, or `ssrRule` for what may render on the server.
71
+ Full detail: `get_guideline` with `pageRule`, or `ssrRule` for what may render on the server; every stage's
72
+ signature, the router's methods, Link's props and every import path: `akanApi`.
@@ -20,6 +20,10 @@ dictionary is a `serviceDictionary`.
20
20
  explicitly `void`-ed.
21
21
  - Return `null` / `false` for "not allowed" or "not found" and let the signal decide whether that is an
22
22
  error.
23
+ - `serve(name, { serverMode: "batch" }, …)` mounts the module, endpoints included, only in a `batch` or `all`
24
+ process; a `federation` replica, the one serving requests in a split deployment, skips it. The app's own
25
+ `lib/_<app>/` service is scaffolded with it for cron and queue work. Leave it off a service a page calls, as
26
+ `akan create-service` does.
23
27
 
24
28
  ## Reaching other things
25
29
 
@@ -33,11 +37,13 @@ self-registers, so do **not** add it to `lib/option.ts`.
33
37
 
34
38
  ## Recipe: pages backed by an external HTTP API
35
39
 
36
- `akan create-service pokeapi <app>` scaffolds `lib/_pokeapi/`; the adapter beside it is yours.
40
+ `akan create-service openlibrary <app>` scaffolds `lib/_openlibrary/`; the adapter beside it is yours.
37
41
 
38
42
  ```ts
39
- // srvkit/pokeApi.ts — read at boot, so the built server takes API_BASE_URL from the host that runs it
40
- export class PokeApi extends adapt("pokeApi" as const, ({ env }) => ({
43
+ // srvkit/bookApi.ts — read at boot, so the built server takes API_BASE_URL from the host that runs it
44
+ import { adapt } from "akanjs/service";
45
+
46
+ export class BookApi extends adapt("bookApi" as const, ({ env }) => ({
41
47
  baseUrl: env(() => process.env.API_BASE_URL ?? "http://localhost:9000"),
42
48
  })) {
43
49
  async get<T>(path: string): Promise<T | null> {
@@ -52,30 +58,53 @@ export class PokeApi extends adapt("pokeApi" as const, ({ env }) => ({
52
58
  }
53
59
  }
54
60
 
55
- // lib/_pokeapi/pokeapi.service.ts — shape the upstream JSON here, not in the page
56
- export class PokeapiService extends serve("pokeapi" as const, ({ plug }) => ({ pokeApi: plug(PokeApi) })) {
57
- async getPokemon(name: string) {
58
- const pokemon = await this.pokeApi.get<ApiPokemon>(`pokemon/${name}`);
59
- return pokemon && { id: pokemon.id, name: pokemon.name };
61
+ // lib/_openlibrary/openlibrary.service.ts — shape the upstream JSON here, not in the page
62
+ import { BookApi } from "@apps/<app>/srvkit";
63
+ import { serve } from "akanjs/service";
64
+
65
+ interface ApiWork {
66
+ key: string;
67
+ title: string;
68
+ }
69
+
70
+ export class OpenlibraryService extends serve("openlibrary" as const, ({ plug }) => ({ bookApi: plug(BookApi) })) {
71
+ async getBook(id: string) {
72
+ const work = await this.bookApi.get<ApiWork>(`works/${id}.json`);
73
+ return work && { id, title: work.title };
60
74
  }
61
75
  }
62
76
 
63
- // lib/_pokeapi/pokeapi.signal.ts — Any answers plain JSON; nullable lets it answer null for "not found"
64
- export class PokeapiEndpoint extends endpoint(srv.pokeapi, ({ query }) => ({
65
- pokemon: query(Any, { guards: [Public], nullable: true })
66
- .param("name", String)
67
- .exec(async function (name) {
68
- return await this.pokeapiService.getPokemon(name);
77
+ // lib/_openlibrary/openlibrary.signal.ts — Any answers plain JSON; nullable lets it answer null for "not found"
78
+ import { Any } from "akanjs/base";
79
+ import { endpoint, internal, Public } from "akanjs/signal";
80
+
81
+ import * as srv from "../srv";
82
+
83
+ export class OpenlibraryInternal extends internal(srv.openlibrary, () => ({})) {}
84
+
85
+ export class OpenlibraryEndpoint extends endpoint(srv.openlibrary, ({ query }) => ({
86
+ book: query(Any, { guards: [Public], nullable: true })
87
+ .param("id", String)
88
+ .exec(async function (id) {
89
+ return await this.openlibraryService.getBook(id);
69
90
  }),
70
91
  })) {}
71
92
 
72
- // lib/_pokeapi/pokeapi.dictionary.ts — typecheck asks a label for every endpoint
73
- .endpoint<PokeapiEndpoint>((fn) => ({ pokemon: fn(["Pokémon", "포켓몬"]) }))
93
+ // lib/_openlibrary/openlibrary.dictionary.ts — typecheck asks a label for every endpoint
94
+ import { serviceDictionary } from "akanjs/dictionary";
95
+
96
+ import type { OpenlibraryEndpoint } from "../sig";
97
+
98
+ export const dictionary = serviceDictionary(["en", "ko"])
99
+ .endpoint<OpenlibraryEndpoint>((fn) => ({ book: fn(["Book", "책"]) }))
100
+ .error({})
101
+ .translate({});
74
102
  ```
75
103
 
76
- The page calls it as `await fetch.pokemon(name)` (`fetch` from `@apps/<app>/client`) and answers
77
- `router.notFound()` on `null`. An `Any` return reaches the page as `unknown`: type it with an interface in
104
+ The page calls it as `await fetch.book(id)` (`fetch` from `@apps/<app>/client`) and ends with
105
+ `return router.notFound()` on `null`. An `Any` return reaches the page as `unknown`: type it with an interface in
78
106
  `common/`, or return a scalar (`akan create-scalar`) for a typed wire shape. A query that answers `null` without
79
107
  `nullable: true` fails the render, naming the endpoint.
80
108
 
81
- Full detail: `get_guideline` with `modelService`.
109
+ Full detail: `get_guideline` with `modelService`; every import path and the exact signatures of serve, adapt,
110
+ endpoint and serviceDictionary: `akanApi`.