@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +1 -1
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +25 -3
- package/applicationBuildReporter.ts +8 -1
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +76 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +16 -7
- package/codeAgent/skills/akan-service/SKILL.md +48 -19
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.ts +6 -2
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/no-deep-internal-import.grit +1 -1
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +58 -0
- package/workspaceGraph.ts +32 -3
- package/workspaceLayout.ts +1 -0
- /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
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
+
}
|
package/cloud/cloudApi.test.ts
CHANGED
|
@@ -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() +
|
|
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 (
|
|
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: {
|
|
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 {
|
|
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([
|
|
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([
|
|
181
|
-
expect(await output(await cliRun(home))).toEqual([
|
|
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
|
|
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.
|
|
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
|
}
|
package/cloud/constants.ts
CHANGED
|
@@ -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");
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ({
|
|
43
|
-
|
|
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="/
|
|
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(`/
|
|
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
|
|
40
|
+
`akan create-service openlibrary <app>` scaffolds `lib/_openlibrary/`; the adapter beside it is yours.
|
|
37
41
|
|
|
38
42
|
```ts
|
|
39
|
-
// srvkit/
|
|
40
|
-
|
|
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/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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/
|
|
73
|
-
|
|
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.
|
|
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`.
|