@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.2
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/biomeBase.test.ts +2 -2
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +14 -10
- package/executors.test.ts +21 -1
- package/index.ts +0 -1
- package/lint/gritRules.test.ts +3 -3
- package/linter.test.ts +18 -1
- package/linter.ts +10 -6
- package/package.json +2 -2
- package/renderMutationScanner.test.ts +223 -0
- package/renderMutationScanner.ts +209 -0
- package/signalMutationIndex.test.ts +120 -0
- package/signalMutationIndex.ts +127 -0
- package/ssrScanner.test.ts +31 -0
- package/ssrScanner.ts +31 -12
- package/uploadRelease.ts +0 -80
package/biomeBase.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { biomeBaseConfig } from "./biomeBase";
|
|
|
7
7
|
import { Linter, parseBiomeReport } from "./linter";
|
|
8
8
|
|
|
9
9
|
const devkitDir = import.meta.dir;
|
|
10
|
-
const
|
|
10
|
+
const [biome, ...biomeArgs] = Linter.biomeCommand(path.resolve(devkitDir, "../../.."));
|
|
11
11
|
|
|
12
12
|
const hookProbe = `export const Early = ({ id }: { id?: string }) => {
|
|
13
13
|
if (!id) return null;
|
|
@@ -28,7 +28,7 @@ afterAll(async () => {
|
|
|
28
28
|
|
|
29
29
|
const runBiome = (args: string[], cwd: string) =>
|
|
30
30
|
new Promise<string>((resolve, reject) => {
|
|
31
|
-
const proc = spawn(
|
|
31
|
+
const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
32
32
|
let stdout = "";
|
|
33
33
|
let stderr = "";
|
|
34
34
|
proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
package/cloud/cloudApi.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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 type { Workspace } from "../commandDecorators";
|
|
4
5
|
import { tempDirs, writeJson, writeText } from "../testHelpers";
|
|
6
|
+
import { CloudApi } from "./cloudApi";
|
|
5
7
|
|
|
6
8
|
// The cloud rotates a refresh token on every use and treats a second use as theft, revoking every session.
|
|
7
9
|
class FakeCloud {
|
|
@@ -10,6 +12,8 @@ class FakeCloud {
|
|
|
10
12
|
reused = false;
|
|
11
13
|
delayMs = 0;
|
|
12
14
|
hang: boolean = false;
|
|
15
|
+
readonly remoteAuthPolls: string[] = [];
|
|
16
|
+
remoteAuthAnswers: (Response | { jwt: string; refreshToken?: string } | null)[] = [];
|
|
13
17
|
readonly #refreshTokens = new Set(["refresh-0"]);
|
|
14
18
|
readonly #spent = new Set<string>();
|
|
15
19
|
readonly #jwts = new Set(["jwt-0"]);
|
|
@@ -29,6 +33,14 @@ class FakeCloud {
|
|
|
29
33
|
const jwt = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
|
30
34
|
return Response.json(this.#jwts.has(jwt) ? { id: "u1", nickname: "akan" } : null);
|
|
31
35
|
}
|
|
36
|
+
const remoteAuthPrefix = "/api/getRemoteAuthToken/";
|
|
37
|
+
if (url.pathname.startsWith(remoteAuthPrefix)) {
|
|
38
|
+
this.remoteAuthPolls.push(decodeURIComponent(url.pathname.slice(remoteAuthPrefix.length)));
|
|
39
|
+
const answer = this.remoteAuthAnswers.shift() ?? null;
|
|
40
|
+
if (answer instanceof Response) return answer;
|
|
41
|
+
if (answer) this.#jwts.add(answer.jwt);
|
|
42
|
+
return Response.json(answer);
|
|
43
|
+
}
|
|
32
44
|
if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
|
|
33
45
|
const { refreshToken } = (await request.json()) as { refreshToken: string };
|
|
34
46
|
this.refreshes.push(refreshToken);
|
|
@@ -200,3 +212,37 @@ describe("CloudApi.fromHost token refresh", () => {
|
|
|
200
212
|
expect(await storedToken(home)).toMatchObject({ jwt: "jwt-0", refreshToken: "refresh-0" });
|
|
201
213
|
}, 30_000);
|
|
202
214
|
});
|
|
215
|
+
|
|
216
|
+
describe("CloudApi remote sign-in", () => {
|
|
217
|
+
const remoteId = "0f6a1d2e-8c43-4b7a-9e11-5d2c3b4a6f70";
|
|
218
|
+
const apiOf = () => new CloudApi({ workspaceRoot: "/unused" } as unknown as Workspace, { host: cloud.host });
|
|
219
|
+
|
|
220
|
+
test("reads the server's null as pending, then takes the session it hands out and calls with it", async () => {
|
|
221
|
+
cloud = new FakeCloud();
|
|
222
|
+
cloud.remoteAuthAnswers = [null, { jwt: "jwt-remote", refreshToken: "refresh-remote" }];
|
|
223
|
+
const api = apiOf();
|
|
224
|
+
|
|
225
|
+
expect(await api.getRemoteAuthToken(remoteId)).toBeNull();
|
|
226
|
+
expect(await api.getRemoteSelf()).toBeNull();
|
|
227
|
+
expect(await api.getRemoteAuthToken(remoteId)).toMatchObject({ jwt: "jwt-remote", refreshToken: "refresh-remote" });
|
|
228
|
+
expect(await api.getRemoteSelf()).toEqual({ id: "u1", nickname: "akan" });
|
|
229
|
+
expect(cloud.remoteAuthPolls).toEqual([remoteId, remoteId]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("throws a refusal, a server error, an answer that is no session, and an unreachable server", async () => {
|
|
233
|
+
cloud = new FakeCloud();
|
|
234
|
+
cloud.remoteAuthAnswers = [
|
|
235
|
+
new Response("<html>Not Found</html>", { status: 404 }),
|
|
236
|
+
Response.json({ error: "base.error.serverUnavailable" }, { status: 503 }),
|
|
237
|
+
Response.json({ token: "not-a-session" }),
|
|
238
|
+
];
|
|
239
|
+
const api = apiOf();
|
|
240
|
+
|
|
241
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 404 });
|
|
242
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 503 });
|
|
243
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow("something other than a session");
|
|
244
|
+
cloud.server.stop(true);
|
|
245
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow();
|
|
246
|
+
await expect(api.getRemoteSelf()).rejects.toThrow();
|
|
247
|
+
});
|
|
248
|
+
});
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "akanjs/common";
|
|
1
2
|
import type { Workspace } from "../commandDecorators";
|
|
2
3
|
import { CloudHttpError } from "./cloudHttpError";
|
|
3
4
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
@@ -127,6 +128,7 @@ export class CloudApi {
|
|
|
127
128
|
return new Error(`${unavailable}: ${errKey}.`, { cause: error });
|
|
128
129
|
return error;
|
|
129
130
|
}
|
|
131
|
+
static readonly #remoteAuthTimeoutMs = 20_000;
|
|
130
132
|
readonly #api: HttpClient;
|
|
131
133
|
#accessToken: AccessToken | null = null;
|
|
132
134
|
#workspace: Workspace;
|
|
@@ -212,12 +214,15 @@ export class CloudApi {
|
|
|
212
214
|
await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
|
|
213
215
|
return localPath;
|
|
214
216
|
}
|
|
217
|
+
//* The server answers `null` until the login is approved, so a failed request throws rather than read as "not yet".
|
|
215
218
|
async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
const answer = await this.#api.get<unknown>(`/getRemoteAuthToken/${encodeURIComponent(remoteId)}`, {
|
|
220
|
+
signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
|
|
221
|
+
});
|
|
222
|
+
if (answer === null) return null;
|
|
223
|
+
if (!isRecord(answer) || typeof answer.jwt !== "string" || !answer.jwt)
|
|
224
|
+
throw new Error(`${this.host} answered the sign-in poll with something other than a session.`);
|
|
225
|
+
return this.#authorize(answer as unknown as AccessTokenDto);
|
|
221
226
|
}
|
|
222
227
|
async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
|
|
223
228
|
return this.#authorize(
|
|
@@ -241,11 +246,10 @@ export class CloudApi {
|
|
|
241
246
|
async revokeTunnel(code: string): Promise<boolean> {
|
|
242
247
|
return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
|
|
243
248
|
}
|
|
249
|
+
/** `null` when the server takes no sign-in from this session; a request that fails throws. */
|
|
244
250
|
async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
251
|
+
return await this.#api.get<{ id: string; nickname: string } | null>(`/getRemoteSelf`, {
|
|
252
|
+
signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
|
|
253
|
+
});
|
|
250
254
|
}
|
|
251
255
|
}
|
package/executors.test.ts
CHANGED
|
@@ -1383,7 +1383,13 @@ describe("SysExecutor scan", () => {
|
|
|
1383
1383
|
expect(await lib.readFile("srvkit/index.ts")).toBe(
|
|
1384
1384
|
["a9", "a10", "aa", "aB"].map((name) => `export * from "./${name}";\n`).join(""),
|
|
1385
1385
|
);
|
|
1386
|
-
for (const file of [
|
|
1386
|
+
for (const file of [
|
|
1387
|
+
"ui/index.ts",
|
|
1388
|
+
"srvkit/index.ts",
|
|
1389
|
+
"lib/post/index.ts",
|
|
1390
|
+
"akan.lib.json",
|
|
1391
|
+
"env/env.server.type.ts",
|
|
1392
|
+
]) {
|
|
1387
1393
|
const generated = await lib.readFile(file);
|
|
1388
1394
|
expect(await formatWithBiome(generated, `libs/biomestable/${file}`)).toBe(generated);
|
|
1389
1395
|
}
|
|
@@ -1392,6 +1398,20 @@ describe("SysExecutor scan", () => {
|
|
|
1392
1398
|
expect(await devSync.syncForBatch(events)).toEqual({ changedFiles: [], errors: [] });
|
|
1393
1399
|
});
|
|
1394
1400
|
|
|
1401
|
+
test.skipIf(!hasBiome)(
|
|
1402
|
+
"writes env.server.type.ts the way Biome prints it, spreading libs in dependency order",
|
|
1403
|
+
async () => {
|
|
1404
|
+
const { default: getContent } = (await import("../cli/templates/env/_env.server.type")) as {
|
|
1405
|
+
default: (scanInfo: unknown) => { content: string };
|
|
1406
|
+
};
|
|
1407
|
+
const libs = ["util", "shared", "shopAdmin", "shop", "a10", "a9"];
|
|
1408
|
+
const { content } = getContent({ getLibs: () => libs });
|
|
1409
|
+
|
|
1410
|
+
expect(await formatWithBiome(content, "apps/biomestable/env/env.server.type.ts")).toBe(content);
|
|
1411
|
+
expect([...content.matchAll(/\.\.\.(\w+)Option/g)].map(([, lib]) => lib)).toEqual(libs);
|
|
1412
|
+
},
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1395
1415
|
test("refuses a hand-written ui/index.tsx beside the generated barrel, naming the file and the fix", async () => {
|
|
1396
1416
|
const { lib } = await createScannableLib("shadowedbarrel", {
|
|
1397
1417
|
files: {
|
package/index.ts
CHANGED
package/lint/gritRules.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Linter } from "../linter";
|
|
|
11
11
|
const lintDir = import.meta.dir;
|
|
12
12
|
const fixturesDir = path.join(lintDir, "__fixtures__");
|
|
13
13
|
const workspaceRoot = path.resolve(lintDir, "../../../..");
|
|
14
|
-
const
|
|
14
|
+
const [biome, ...biomeArgs] = Linter.biomeCommand(workspaceRoot);
|
|
15
15
|
|
|
16
16
|
interface BiomeDiagnostic {
|
|
17
17
|
severity: string;
|
|
@@ -42,7 +42,7 @@ afterAll(async () => {
|
|
|
42
42
|
|
|
43
43
|
const runBiome = (args: string[], cwd: string) =>
|
|
44
44
|
new Promise<string>((resolve, reject) => {
|
|
45
|
-
const proc = spawn(
|
|
45
|
+
const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
46
46
|
let stdout = "";
|
|
47
47
|
let stderr = "";
|
|
48
48
|
proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
|
@@ -127,7 +127,7 @@ const fixtures = await collectCases();
|
|
|
127
127
|
|
|
128
128
|
describe("grit lint rules", () => {
|
|
129
129
|
test("the harness found the biome binary and at least one fixture", () => {
|
|
130
|
-
expect(
|
|
130
|
+
expect(biome).toBe(process.execPath);
|
|
131
131
|
expect(fixtures.length).toBeGreaterThan(0);
|
|
132
132
|
});
|
|
133
133
|
|
package/linter.test.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type BiomeReport, Linter, parseBiomeReport } from "./linter";
|
|
5
|
+
import { hasBiome } from "./testHelpers";
|
|
3
6
|
|
|
4
7
|
const reportOf = (diagnostics: BiomeReport["diagnostics"]): string =>
|
|
5
8
|
JSON.stringify({ summary: { errors: diagnostics?.length ?? 0, warnings: 0 }, diagnostics });
|
|
@@ -77,3 +80,17 @@ describe("parseBiomeReport", () => {
|
|
|
77
80
|
expect(() => parseBiomeReport(" \n")).toThrow("No Biome JSON output");
|
|
78
81
|
});
|
|
79
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("Linter", () => {
|
|
85
|
+
test.skipIf(!hasBiome || process.platform === "win32")("runs Biome where no node is on PATH", async () => {
|
|
86
|
+
const pathBefore = process.env.PATH;
|
|
87
|
+
process.env.PATH = path.join(tmpdir(), "akan-path-without-node");
|
|
88
|
+
try {
|
|
89
|
+
const { results, errors } = await new Linter(import.meta.dir).lintFile(path.join(import.meta.dir, "linter.ts"));
|
|
90
|
+
expect(results).toHaveLength(1);
|
|
91
|
+
expect(errors).toEqual([]);
|
|
92
|
+
} finally {
|
|
93
|
+
process.env.PATH = pathBefore;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
package/linter.ts
CHANGED
|
@@ -113,18 +113,21 @@ const emptyResult = (filePath: string): LintResult => ({
|
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
export class Linter {
|
|
116
|
-
//?
|
|
117
|
-
|
|
116
|
+
//? The package's bin is npm's `#!/usr/bin/env node` wrapper, which picks the platform binary; run through the Bun
|
|
117
|
+
//? running akan, it works in an image with no Node (exit 127 otherwise). A Biome on PATH is the fallback.
|
|
118
|
+
static biomeCommand(root: string): [string, ...string[]] {
|
|
119
|
+
const wrapper = path.join(root, "node_modules", "@biomejs", "biome", "bin", "biome");
|
|
120
|
+
return existsSync(wrapper) ? [process.execPath, wrapper] : ["biome"];
|
|
121
|
+
}
|
|
118
122
|
|
|
119
123
|
lintRoot: string;
|
|
120
124
|
configPath: string;
|
|
121
|
-
#
|
|
125
|
+
#biomeCommand: [string, ...string[]];
|
|
122
126
|
|
|
123
127
|
constructor(cwdPath: string) {
|
|
124
128
|
this.lintRoot = this.#findBiomeRootPath(cwdPath);
|
|
125
129
|
this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
|
|
126
|
-
|
|
127
|
-
this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
|
|
130
|
+
this.#biomeCommand = Linter.biomeCommand(this.lintRoot);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
static #configPathIn(dir: string): string | null {
|
|
@@ -158,7 +161,8 @@ export class Linter {
|
|
|
158
161
|
stderr: string;
|
|
159
162
|
code: number | null;
|
|
160
163
|
}>((resolve, reject) => {
|
|
161
|
-
const
|
|
164
|
+
const [command, ...prefixArgs] = this.#biomeCommand;
|
|
165
|
+
const proc = spawn(command, [...prefixArgs, ...args], {
|
|
162
166
|
cwd: this.lintRoot,
|
|
163
167
|
stdio: ["pipe", "pipe", "pipe"],
|
|
164
168
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1-rc.
|
|
3
|
+
"version": "3.1.1-rc.2",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.1-rc.
|
|
51
|
+
"akanjs": "3.1.1-rc.2",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { RenderMutationScanner } from "./renderMutationScanner";
|
|
5
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
6
|
+
|
|
7
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
8
|
+
file,
|
|
9
|
+
absolutePath: `/repo/${file}`,
|
|
10
|
+
content,
|
|
11
|
+
sourceFile: ts.createSourceFile(
|
|
12
|
+
file,
|
|
13
|
+
content,
|
|
14
|
+
ts.ScriptTarget.Latest,
|
|
15
|
+
true,
|
|
16
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const signal = fileOf(
|
|
21
|
+
"apps/demo/lib/item/item.signal.ts",
|
|
22
|
+
`
|
|
23
|
+
export class ItemSlice extends slice(srv.item, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
|
|
24
|
+
export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
25
|
+
approveItem: mutation(cnst.Item, { guards: [User] }).param("itemId", ID).exec(async function (itemId) {
|
|
26
|
+
return await this.itemService.approve(itemId);
|
|
27
|
+
}),
|
|
28
|
+
itemCount: query(Int, { guards: [User] }).exec(async function () {
|
|
29
|
+
return 0;
|
|
30
|
+
}),
|
|
31
|
+
})) {}
|
|
32
|
+
`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const PAGE = "apps/demo/page/item/[itemId].tsx";
|
|
36
|
+
const pageOf = (chain: string, file = PAGE) =>
|
|
37
|
+
fileOf(
|
|
38
|
+
file,
|
|
39
|
+
`import { fetch, usePage } from "@apps/demo/client";\nimport { ID } from "akanjs/base";\nimport { page } from "akanjs/client";\n\nexport default ${chain};\n`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const scan = (...files: SourceFileInfo[]) =>
|
|
43
|
+
new RenderMutationScanner(new SignalMutationIndex([signal, ...files])).scan(files);
|
|
44
|
+
|
|
45
|
+
describe("akan.ssr.render-mutation", () => {
|
|
46
|
+
test("flags one diagnostic per mutation a render sends, awaited or not", () => {
|
|
47
|
+
const warnings = scan(
|
|
48
|
+
pageOf(`page()
|
|
49
|
+
.param("itemId", ID)
|
|
50
|
+
.render(async ({ itemId }) => {
|
|
51
|
+
const [item] = await Promise.all([fetch.approveItem(itemId), fetch.itemCount()]);
|
|
52
|
+
void fetch.updateItem(itemId, {});
|
|
53
|
+
return <p>{item.id}</p>;
|
|
54
|
+
})`),
|
|
55
|
+
);
|
|
56
|
+
expect(warnings.map(({ rule, line }) => ({ rule, line }))).toEqual([
|
|
57
|
+
{ rule: "akan.ssr.render-mutation", line: 8 },
|
|
58
|
+
{ rule: "akan.ssr.render-mutation", line: 9 },
|
|
59
|
+
]);
|
|
60
|
+
expect(warnings[0]?.message).toContain("fetch.approveItem()");
|
|
61
|
+
expect(warnings[0]?.fix).toContain("{ duringRender: true }");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a query, a store action and a client loader are not findings", () => {
|
|
65
|
+
const warnings = scan(
|
|
66
|
+
pageOf(`page()
|
|
67
|
+
.param("itemId", ID)
|
|
68
|
+
.render(({ itemId }) => {
|
|
69
|
+
const { itemInitInOrg } = fetch.initItemInOrg(itemId);
|
|
70
|
+
return <Item.Zone.Card init={itemInitInOrg} count={fetch.itemCount()} />;
|
|
71
|
+
})`),
|
|
72
|
+
);
|
|
73
|
+
expect(warnings).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a JSX event handler runs on a user action, while a mapped call still runs in the render", () => {
|
|
77
|
+
const warnings = scan(
|
|
78
|
+
pageOf(`page()
|
|
79
|
+
.param("itemId", ID)
|
|
80
|
+
.render(({ itemId }) => {
|
|
81
|
+
return (
|
|
82
|
+
<form onSubmit={async () => { await fetch.approveItem(itemId); }}>
|
|
83
|
+
{[itemId].map((id) => <p key={id}>{String(fetch.approveItem(id))}</p>)}
|
|
84
|
+
</form>
|
|
85
|
+
);
|
|
86
|
+
})`),
|
|
87
|
+
);
|
|
88
|
+
expect(warnings.map(({ line }) => line)).toEqual([10]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a call that opts in with { duringRender: true } is not a finding", () => {
|
|
92
|
+
expect(
|
|
93
|
+
scan(
|
|
94
|
+
pageOf(
|
|
95
|
+
`page().param("itemId", ID).render(async ({ itemId }) => { await fetch.approveItem(itemId, { duringRender: true }); return null; })`,
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
).toEqual([]);
|
|
99
|
+
expect(
|
|
100
|
+
scan(
|
|
101
|
+
pageOf(`page().render(async () => { await fetch.approveItem("x", { duringRender: false }); return null; })`),
|
|
102
|
+
),
|
|
103
|
+
).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("reads the opt-in only from the slot the runtime reads, right after the declared arguments", () => {
|
|
107
|
+
const renderOf = (call: string) =>
|
|
108
|
+
scan(pageOf(`page().param("itemId", ID).render(async ({ itemId }) => { await ${call}; return null; })`)).length;
|
|
109
|
+
expect(renderOf(`fetch.updateItem(itemId, { duringRender: true })`)).toBe(1);
|
|
110
|
+
expect(renderOf(`fetch.updateItem(itemId, { title: "x", duringRender: true })`)).toBe(1);
|
|
111
|
+
expect(renderOf(`fetch.updateItem(itemId, { title: "x" }, { duringRender: true })`)).toBe(0);
|
|
112
|
+
expect(renderOf(`fetch.mergeItem(itemId, {}, { duringRender: true } as const)`)).toBe(0);
|
|
113
|
+
expect(renderOf(`fetch.addItemFiles([], { duringRender: true })`)).toBe(1);
|
|
114
|
+
expect(renderOf(`fetch.addItemFiles([], undefined, { duringRender: true })`)).toBe(0);
|
|
115
|
+
expect(renderOf(`fetch.createItem({ duringRender: true })`)).toBe(1);
|
|
116
|
+
expect(renderOf(`fetch.approveItem(itemId, { "duringRender": true })`)).toBe(0);
|
|
117
|
+
expect(renderOf(`fetch.approveItem(itemId, { duringRender: undefined })`)).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("takes a policy it cannot evaluate as an opt-in, and a value that cannot be one as none", () => {
|
|
121
|
+
const renderOf = (call: string) =>
|
|
122
|
+
scan(
|
|
123
|
+
pageOf(
|
|
124
|
+
`page().param("itemId", ID).render(async ({ itemId }) => { const warm = { duringRender: true }; await ${call}; return null; })`,
|
|
125
|
+
),
|
|
126
|
+
).length;
|
|
127
|
+
for (const call of [
|
|
128
|
+
"fetch.approveItem(itemId, warm)",
|
|
129
|
+
"fetch.approveItem(itemId, { ...warm })",
|
|
130
|
+
"fetch.approveItem(itemId, policyOf(itemId))",
|
|
131
|
+
"fetch.approveItem(...[itemId, warm])",
|
|
132
|
+
])
|
|
133
|
+
expect(renderOf(call)).toBe(0);
|
|
134
|
+
for (const call of [
|
|
135
|
+
"fetch.approveItem(itemId, undefined)",
|
|
136
|
+
"fetch.approveItem(itemId, null)",
|
|
137
|
+
'fetch.approveItem(itemId, "warm")',
|
|
138
|
+
"fetch.updateItem(itemId, warm)",
|
|
139
|
+
])
|
|
140
|
+
expect(renderOf(call)).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("follows a same-file function the stage calls or renders, one level deep", () => {
|
|
144
|
+
const file = fileOf(
|
|
145
|
+
PAGE,
|
|
146
|
+
`import { fetch } from "@apps/demo/client";
|
|
147
|
+
import { ID } from "akanjs/base";
|
|
148
|
+
import { page } from "akanjs/client";
|
|
149
|
+
|
|
150
|
+
const approve = async (id: string) => await fetch.approveItem(id);
|
|
151
|
+
function Approval({ id }: { id: string }) {
|
|
152
|
+
void fetch.removeItem(id);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const unused = async () => await fetch.createItem({});
|
|
156
|
+
const onClickOnly = () => fetch.updateItem("x", {});
|
|
157
|
+
|
|
158
|
+
export default page()
|
|
159
|
+
.param("itemId", ID)
|
|
160
|
+
.render(async ({ itemId }) => {
|
|
161
|
+
await approve(itemId);
|
|
162
|
+
return <Approval id={itemId} onSelect={onClickOnly} />;
|
|
163
|
+
});
|
|
164
|
+
`,
|
|
165
|
+
);
|
|
166
|
+
expect(scan(file).map(({ line }) => line)).toEqual([5, 7]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("reads a page exported by name, a stage given by name and a call by string key", () => {
|
|
170
|
+
const legacy = fileOf(
|
|
171
|
+
PAGE,
|
|
172
|
+
`import { fetch } from "@apps/demo/client";\nconst Page = async () => {\n await fetch.approveItem("x");\n return null;\n};\nexport default Page;\n`,
|
|
173
|
+
);
|
|
174
|
+
expect(scan(legacy).map(({ line }) => line)).toEqual([3]);
|
|
175
|
+
const named = fileOf(
|
|
176
|
+
PAGE,
|
|
177
|
+
`import { fetch } from "@apps/demo/client";\nimport { page } from "akanjs/client";\nconst render = async () => {\n await fetch["approveItem"]("x");\n return null;\n};\nexport default page().render(render);\n`,
|
|
178
|
+
);
|
|
179
|
+
expect(scan(named).map(({ line, message }) => [line, message.split("()")[0]])).toEqual([
|
|
180
|
+
[4, 'fetch["approveItem"]'],
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reads the guard, the head and a layout's render too", () => {
|
|
185
|
+
const guarded = pageOf(`page()
|
|
186
|
+
.guard(async () => { await fetch.removeItem("x"); })
|
|
187
|
+
.head(async () => { await fetch.approveItem("x"); return <title>x</title>; })
|
|
188
|
+
.render(() => null)`);
|
|
189
|
+
const wrapping = pageOf(
|
|
190
|
+
`layout().render(async ({ children }) => { await fetch.createItem({}); return children; })`,
|
|
191
|
+
"apps/demo/page/item/_layout.tsx",
|
|
192
|
+
);
|
|
193
|
+
expect(scan(guarded, wrapping).map(({ file, line }) => `${file}:${line}`)).toEqual([
|
|
194
|
+
`${PAGE}:6`,
|
|
195
|
+
`${PAGE}:7`,
|
|
196
|
+
"apps/demo/page/item/_layout.tsx:5",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("reads a legacy default-exported page function", () => {
|
|
201
|
+
expect(scan(pageOf(`async function Page() { await fetch.approveItem("x"); return null; }`))).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an app load, a file outside page/ and a page with no fetch import are not read", () => {
|
|
205
|
+
expect(
|
|
206
|
+
scan(
|
|
207
|
+
pageOf(
|
|
208
|
+
`page().app("showItem", "Show the item.", async () => { await fetch.approveItem("x"); return null; }).render(() => null)`,
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
).toEqual([]);
|
|
212
|
+
expect(
|
|
213
|
+
scan(
|
|
214
|
+
pageOf(`page().render(async () => { await fetch.approveItem("x"); return null; })`, "apps/demo/ui/Item.tsx"),
|
|
215
|
+
),
|
|
216
|
+
).toEqual([]);
|
|
217
|
+
const global = fileOf(
|
|
218
|
+
PAGE,
|
|
219
|
+
`export default page().render(async () => { await fetch.approveItem("x"); return null; });`,
|
|
220
|
+
);
|
|
221
|
+
expect(scan(global)).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
import type { SignalMutationIndex } from "./signalMutationIndex";
|
|
4
|
+
|
|
5
|
+
//? A server render sends the visitor's credential with no Origin, so its writes run for whoever opens a link.
|
|
6
|
+
export class RenderMutationScanner {
|
|
7
|
+
static readonly rule = "akan.ssr.render-mutation";
|
|
8
|
+
static readonly fix =
|
|
9
|
+
"Send the write from an explicit user action, such as a button that calls a store action. Only a write that is harmless to repeat and safe for anyone to trigger may stay in the render, with `{ duringRender: true }` as the call's option.";
|
|
10
|
+
static #builders = new Set(["page", "layout", "rootLayout"]);
|
|
11
|
+
static #stages = new Set(["render", "guard", "head", "loading", "notFound", "error"]);
|
|
12
|
+
readonly #mutations: SignalMutationIndex;
|
|
13
|
+
|
|
14
|
+
constructor(mutations: SignalMutationIndex) {
|
|
15
|
+
this.#mutations = mutations;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
19
|
+
return sourceFiles
|
|
20
|
+
.filter(({ file }) => /^(apps|libs)\/[^/]+\/page\/.+(?<!\.test|\.spec)\.tsx$/.test(file))
|
|
21
|
+
.flatMap((sourceFile) => this.#scanRoute(sourceFile));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#scanRoute({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
25
|
+
const fetchNames = RenderMutationScanner.#fetchBindingsOf(sourceFile);
|
|
26
|
+
if (!fetchNames.size) return [];
|
|
27
|
+
const functions = RenderMutationScanner.#functionsOf(sourceFile);
|
|
28
|
+
const calls = new Set<ts.CallExpression>();
|
|
29
|
+
const helpers = new Set<ts.Node>();
|
|
30
|
+
const visit = (node: ts.Node, follow: boolean) => {
|
|
31
|
+
if (RenderMutationScanner.#isEventHandler(node)) return;
|
|
32
|
+
if (this.#isRenderMutation(file, node, fetchNames)) calls.add(node);
|
|
33
|
+
const helper = follow ? RenderMutationScanner.#helperOf(node, functions) : undefined;
|
|
34
|
+
if (helper) helpers.add(helper);
|
|
35
|
+
ts.forEachChild(node, (child) => visit(child, follow));
|
|
36
|
+
};
|
|
37
|
+
for (const body of RenderMutationScanner.#renderBodiesOf(sourceFile, functions)) visit(body, true);
|
|
38
|
+
for (const helper of helpers) visit(helper, false);
|
|
39
|
+
return [...calls]
|
|
40
|
+
.sort((a, b) => a.getStart(sourceFile) - b.getStart(sourceFile))
|
|
41
|
+
.map((call) => ({
|
|
42
|
+
rule: RenderMutationScanner.rule,
|
|
43
|
+
scope: "ssr",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
file,
|
|
46
|
+
line: sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)).line + 1,
|
|
47
|
+
message: `${call.expression.getText(sourceFile)}() is a mutation sent while the route renders: anyone who opens a link to this page would perform it, and the call is refused unless it passes { duringRender: true }.`,
|
|
48
|
+
fix: RenderMutationScanner.fix,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#isRenderMutation(file: string, node: ts.Node, fetchNames: Set<string>): node is ts.CallExpression {
|
|
53
|
+
if (!ts.isCallExpression(node)) return false;
|
|
54
|
+
const name = RenderMutationScanner.#endpointOf(node.expression, fetchNames);
|
|
55
|
+
if (!name || !this.#mutations.isMutation(file, name)) return false;
|
|
56
|
+
return !RenderMutationScanner.#optsIn(node, this.#mutations.policyIndexOf(file, name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static #isEventHandler(node: ts.Node) {
|
|
60
|
+
return ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && /^on[A-Z]/.test(node.name.text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #endpointOf(callee: ts.Expression, fetchNames: Set<string>) {
|
|
64
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
65
|
+
return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.name.text : null;
|
|
66
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression))
|
|
67
|
+
return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.argumentExpression.text : null;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #isFetch(expression: ts.Expression, fetchNames: Set<string>) {
|
|
72
|
+
return ts.isIdentifier(expression) && fetchNames.has(expression.text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//? The runtime reads only the policy slot, and a value there this cannot evaluate (a variable, a call) may opt in.
|
|
76
|
+
static #optsIn(call: ts.CallExpression, policyIndex: number | null) {
|
|
77
|
+
if (policyIndex === null) return false;
|
|
78
|
+
const leading = call.arguments.slice(0, policyIndex + 1);
|
|
79
|
+
if (leading.some((argument) => ts.isSpreadElement(argument))) return true;
|
|
80
|
+
const policy = leading[policyIndex];
|
|
81
|
+
return policy ? RenderMutationScanner.#policyOptsIn(policy) : false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static #policyOptsIn(argument: ts.Expression): boolean {
|
|
85
|
+
const policy = RenderMutationScanner.#unwrap(argument);
|
|
86
|
+
if (!ts.isObjectLiteralExpression(policy)) return !RenderMutationScanner.#cannotBePolicy(policy);
|
|
87
|
+
return policy.properties.some((property) => {
|
|
88
|
+
if (ts.isSpreadAssignment(property)) return true;
|
|
89
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return false;
|
|
90
|
+
if (!(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) return false;
|
|
91
|
+
if (property.name.text !== "duringRender") return false;
|
|
92
|
+
if (ts.isShorthandPropertyAssignment(property)) return true;
|
|
93
|
+
const value = RenderMutationScanner.#unwrap(property.initializer);
|
|
94
|
+
return value.kind !== ts.SyntaxKind.FalseKeyword && !(ts.isIdentifier(value) && value.text === "undefined");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static #unwrap(expression: ts.Expression): ts.Expression {
|
|
99
|
+
let current = expression;
|
|
100
|
+
while (
|
|
101
|
+
ts.isParenthesizedExpression(current) ||
|
|
102
|
+
ts.isAsExpression(current) ||
|
|
103
|
+
ts.isSatisfiesExpression(current) ||
|
|
104
|
+
ts.isNonNullExpression(current) ||
|
|
105
|
+
ts.isTypeAssertionExpression(current)
|
|
106
|
+
)
|
|
107
|
+
current = current.expression;
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #cannotBePolicy(expression: ts.Expression) {
|
|
112
|
+
if (ts.isIdentifier(expression)) return expression.text === "undefined";
|
|
113
|
+
return (
|
|
114
|
+
ts.isLiteralExpression(expression) ||
|
|
115
|
+
ts.isNoSubstitutionTemplateLiteral(expression) ||
|
|
116
|
+
ts.isTemplateExpression(expression) ||
|
|
117
|
+
ts.isArrayLiteralExpression(expression) ||
|
|
118
|
+
ts.isArrowFunction(expression) ||
|
|
119
|
+
ts.isFunctionExpression(expression) ||
|
|
120
|
+
ts.isVoidExpression(expression) ||
|
|
121
|
+
ts.isPrefixUnaryExpression(expression) ||
|
|
122
|
+
[ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(expression.kind)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #fetchBindingsOf(sourceFile: ts.SourceFile) {
|
|
127
|
+
const names = new Set<string>();
|
|
128
|
+
for (const statement of sourceFile.statements) {
|
|
129
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
130
|
+
const bindings = statement.importClause?.namedBindings;
|
|
131
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
132
|
+
for (const element of bindings.elements)
|
|
133
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === "fetch")
|
|
134
|
+
names.add(element.name.text);
|
|
135
|
+
}
|
|
136
|
+
return names;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #functionsOf(sourceFile: ts.SourceFile) {
|
|
140
|
+
const functions = new Map<string, ts.Node>();
|
|
141
|
+
for (const statement of sourceFile.statements) {
|
|
142
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body)
|
|
143
|
+
functions.set(statement.name.text, statement.body);
|
|
144
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
145
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
146
|
+
const initializer = declaration.initializer && RenderMutationScanner.#unwrap(declaration.initializer);
|
|
147
|
+
if (!ts.isIdentifier(declaration.name) || !initializer) continue;
|
|
148
|
+
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
|
|
149
|
+
functions.set(declaration.name.text, initializer.body);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return functions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//? One level only: a function the stage calls, or renders as a component, runs while the route renders.
|
|
156
|
+
static #helperOf(node: ts.Node, functions: Map<string, ts.Node>) {
|
|
157
|
+
const callee = ts.isCallExpression(node)
|
|
158
|
+
? node.expression
|
|
159
|
+
: ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)
|
|
160
|
+
? node.tagName
|
|
161
|
+
: undefined;
|
|
162
|
+
return callee && ts.isIdentifier(callee) ? functions.get(callee.text) : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static #renderBodiesOf(sourceFile: ts.SourceFile, functions: Map<string, ts.Node>) {
|
|
166
|
+
const bodies: ts.Node[] = [];
|
|
167
|
+
const bodyOf = (node: ts.Node) => (ts.isIdentifier(node) ? functions.get(node.text) : node);
|
|
168
|
+
const visit = (node: ts.Node) => {
|
|
169
|
+
if (
|
|
170
|
+
ts.isCallExpression(node) &&
|
|
171
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
172
|
+
RenderMutationScanner.#stages.has(node.expression.name.text) &&
|
|
173
|
+
RenderMutationScanner.#isRouteChain(node.expression.expression)
|
|
174
|
+
)
|
|
175
|
+
for (const argument of node.arguments) {
|
|
176
|
+
const body = bodyOf(argument);
|
|
177
|
+
if (body) bodies.push(body);
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(node, visit);
|
|
180
|
+
};
|
|
181
|
+
visit(sourceFile);
|
|
182
|
+
for (const statement of sourceFile.statements) {
|
|
183
|
+
const legacy = RenderMutationScanner.#legacyPageOf(statement, functions);
|
|
184
|
+
if (legacy) bodies.push(legacy);
|
|
185
|
+
}
|
|
186
|
+
return bodies;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static #isRouteChain(expression: ts.Expression) {
|
|
190
|
+
let current = expression;
|
|
191
|
+
while (ts.isCallExpression(current)) {
|
|
192
|
+
if (ts.isIdentifier(current.expression)) return RenderMutationScanner.#builders.has(current.expression.text);
|
|
193
|
+
if (!ts.isPropertyAccessExpression(current.expression)) return false;
|
|
194
|
+
current = current.expression.expression;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
static #legacyPageOf(statement: ts.Statement, functions: Map<string, ts.Node>) {
|
|
200
|
+
if (ts.isFunctionDeclaration(statement))
|
|
201
|
+
return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
202
|
+
? statement.body
|
|
203
|
+
: undefined;
|
|
204
|
+
if (!ts.isExportAssignment(statement)) return undefined;
|
|
205
|
+
const expression = RenderMutationScanner.#unwrap(statement.expression);
|
|
206
|
+
if (ts.isIdentifier(expression)) return functions.get(expression.text);
|
|
207
|
+
return ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) ? expression.body : undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ticketSignal = fileOf(
|
|
14
|
+
"apps/demo/lib/ticket/ticket.signal.ts",
|
|
15
|
+
`
|
|
16
|
+
export class TicketSlice extends slice(srv.ticket, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
|
|
17
|
+
|
|
18
|
+
export class TicketEndpoint extends endpoint(srv.ticket.with(srv.util.security), ({ query, mutation: write, message }) => ({
|
|
19
|
+
approveTicket: write(cnst.Ticket, { guards: [User] })
|
|
20
|
+
.param("ticketId", ID)
|
|
21
|
+
.exec(async function (ticketId) {
|
|
22
|
+
return await this.ticketService.approve(ticketId);
|
|
23
|
+
}),
|
|
24
|
+
ticketCount: query(Int, { guards: [User] }).exec(async function () {
|
|
25
|
+
return 0;
|
|
26
|
+
}),
|
|
27
|
+
nudgeTicket: message(Boolean, { guards: [User] })
|
|
28
|
+
.msg("ticketId", ID)
|
|
29
|
+
.exec(function (ticketId) {
|
|
30
|
+
return !!ticketId;
|
|
31
|
+
}),
|
|
32
|
+
}))
|
|
33
|
+
{}
|
|
34
|
+
`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
describe("SignalMutationIndex", () => {
|
|
38
|
+
test("reads each endpoint's kind from its declaration, aliased builders included", () => {
|
|
39
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
40
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "approveTicket")).toBe(true);
|
|
41
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
|
|
42
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "initTicketInOrg")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("counts a socket message as a mutation, its policy after the message arguments", () => {
|
|
46
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
47
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "nudgeTicket")).toBe(true);
|
|
48
|
+
expect(index.policyIndexOf("apps/demo/page/x.tsx", "nudgeTicket")).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("counts the CRUD a model's slice generates as mutations", () => {
|
|
52
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
53
|
+
for (const name of ["createTicket", "updateTicket", "removeTicket", "mergeTicket", "addTicketFiles"])
|
|
54
|
+
expect(index.isMutation("apps/demo/page/x.tsx", name)).toBe(true);
|
|
55
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticket")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("places each mutation's policy right after the arguments the client takes", () => {
|
|
59
|
+
const signal = fileOf(
|
|
60
|
+
"apps/demo/lib/item/item.signal.ts",
|
|
61
|
+
`export class ItemSlice extends slice(srv.item, { guards: { root: Admin, cru: User } }, () => ({})) {}
|
|
62
|
+
export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
63
|
+
noteItem: mutation(cnst.Item, { guards: [User] })
|
|
64
|
+
.param("itemId", ID)
|
|
65
|
+
.body("note", String)
|
|
66
|
+
.search("tag", String)
|
|
67
|
+
.with(Ip)
|
|
68
|
+
.exec(async function (itemId, note, tag, ip) {
|
|
69
|
+
return await this.itemService.note(itemId, note, tag, ip);
|
|
70
|
+
}),
|
|
71
|
+
touchItems: mutation(Boolean, { guards: [User] }).exec(() => true),
|
|
72
|
+
itemCount: query(Int, { guards: [User] }).param("itemId", ID).exec(() => 0),
|
|
73
|
+
})) {}`,
|
|
74
|
+
);
|
|
75
|
+
const index = new SignalMutationIndex([signal]);
|
|
76
|
+
const policyOf = (name: string) => index.policyIndexOf("apps/demo/page/x.tsx", name);
|
|
77
|
+
expect(policyOf("noteItem")).toBe(3);
|
|
78
|
+
expect(policyOf("touchItems")).toBe(0);
|
|
79
|
+
expect([policyOf("createItem"), policyOf("removeItem")]).toEqual([1, 1]);
|
|
80
|
+
expect([policyOf("updateItem"), policyOf("mergeItem"), policyOf("addItemFiles")]).toEqual([2, 2, 2]);
|
|
81
|
+
expect(policyOf("itemCount")).toBeNull();
|
|
82
|
+
expect(policyOf("unknownItem")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reads a builder given as one parameter and a block body", () => {
|
|
86
|
+
const signal = fileOf(
|
|
87
|
+
"libs/shared/lib/_util/util.signal.ts",
|
|
88
|
+
`export class UtilEndpoint extends endpoint(srv.util, (b) => {
|
|
89
|
+
return { ping: b.query(String, { guards: [Public] }).exec(() => "pong"), touch: b.mutation(Boolean, { guards: [User] }).exec(() => true) };
|
|
90
|
+
}) {}`,
|
|
91
|
+
);
|
|
92
|
+
const index = new SignalMutationIndex([signal]);
|
|
93
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "touch")).toBe(true);
|
|
94
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "ping")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("scopes an app's endpoints to that app and every lib's to all of them", () => {
|
|
98
|
+
const otherApp = fileOf(
|
|
99
|
+
"apps/other/lib/_other/other.signal.ts",
|
|
100
|
+
`export class OtherEndpoint extends endpoint(srv.other, ({ mutation }) => ({ approveTicket: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
101
|
+
);
|
|
102
|
+
const lib = fileOf(
|
|
103
|
+
"libs/shared/lib/_shared/shared.signal.ts",
|
|
104
|
+
`export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ ticketCount: mutation(Boolean, {}).exec(() => true), touchShared: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
105
|
+
);
|
|
106
|
+
const index = new SignalMutationIndex([ticketSignal, otherApp, lib]);
|
|
107
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "touchShared")).toBe(true);
|
|
108
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
|
|
109
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "approveTicket")).toBe(false);
|
|
110
|
+
expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("ignores signal tests and specs", () => {
|
|
114
|
+
const spec = fileOf(
|
|
115
|
+
"apps/demo/lib/ticket/ticket.signal.spec.ts",
|
|
116
|
+
`endpoint(srv.ticket, ({ mutation }) => ({ fixture: mutation(Boolean, {}).exec(() => true) }));`,
|
|
117
|
+
);
|
|
118
|
+
expect(new SignalMutationIndex([spec]).isMutation("apps/demo/page/x.tsx", "fixture")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
interface EndpointShape {
|
|
5
|
+
kind: "query" | "mutation";
|
|
6
|
+
argCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class SignalMutationIndex {
|
|
10
|
+
//? As the client generates them: `update<Model>(id, data, option?)`, `add<Model>Files(files, parentId?, option?)`.
|
|
11
|
+
static #generatedArgCounts = { create: 1, update: 2, remove: 1, merge: 2 } as const;
|
|
12
|
+
static #argMethods = new Set(["param", "body", "search", "room", "msg"]);
|
|
13
|
+
readonly #shapesByOwner = new Map<string, Map<string, EndpointShape>>();
|
|
14
|
+
|
|
15
|
+
constructor(sourceFiles: SourceFileInfo[]) {
|
|
16
|
+
for (const { file, sourceFile } of sourceFiles) {
|
|
17
|
+
if (!/^(apps|libs)\/[^/]+\/lib\/.+\.signal\.ts$/.test(file)) continue;
|
|
18
|
+
const owner = file.split("/").slice(0, 2).join("/");
|
|
19
|
+
const shapes = this.#shapesByOwner.get(owner) ?? new Map<string, EndpointShape>();
|
|
20
|
+
SignalMutationIndex.#collect(sourceFile, shapes);
|
|
21
|
+
this.#shapesByOwner.set(owner, shapes);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isMutation(consumerFile: string, name: string) {
|
|
26
|
+
return this.#shapeOf(consumerFile, name)?.kind === "mutation";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a call to the mutation passes its `FetchPolicy`: the argument right after the ones it declares. */
|
|
30
|
+
policyIndexOf(consumerFile: string, name: string): number | null {
|
|
31
|
+
const shape = this.#shapeOf(consumerFile, name);
|
|
32
|
+
return shape?.kind === "mutation" ? shape.argCount : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#shapeOf(consumerFile: string, name: string) {
|
|
36
|
+
const [root, owner] = consumerFile.split("/");
|
|
37
|
+
const own = root === "apps" ? this.#shapesByOwner.get(`apps/${owner}`)?.get(name) : undefined;
|
|
38
|
+
if (own) return own;
|
|
39
|
+
for (const [key, shapes] of this.#shapesByOwner) {
|
|
40
|
+
const shape = shapes.get(name);
|
|
41
|
+
if (key.startsWith("libs/") && shape?.kind === "mutation") return shape;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static #collect(sourceFile: ts.SourceFile, kinds: Map<string, EndpointShape>) {
|
|
47
|
+
const visit = (node: ts.Node) => {
|
|
48
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
49
|
+
if (node.expression.text === "slice") SignalMutationIndex.#collectGenerated(node, kinds);
|
|
50
|
+
else if (node.expression.text === "endpoint") SignalMutationIndex.#collectEndpoints(node, kinds);
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(node, visit);
|
|
53
|
+
};
|
|
54
|
+
visit(sourceFile);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static #collectGenerated(slice: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
58
|
+
let service: ts.Expression | undefined = slice.arguments[0];
|
|
59
|
+
while (service && ts.isCallExpression(service) && ts.isPropertyAccessExpression(service.expression))
|
|
60
|
+
service = service.expression.expression;
|
|
61
|
+
if (!service || !ts.isPropertyAccessExpression(service) || !SignalMutationIndex.#isNamed(service.expression, "srv"))
|
|
62
|
+
return;
|
|
63
|
+
const model = service.name.text;
|
|
64
|
+
const capModel = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
65
|
+
for (const [verb, argCount] of Object.entries(SignalMutationIndex.#generatedArgCounts))
|
|
66
|
+
kinds.set(`${verb}${capModel}`, { kind: "mutation", argCount });
|
|
67
|
+
kinds.set(`add${capModel}Files`, { kind: "mutation", argCount: 2 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #collectEndpoints(endpoint: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
71
|
+
const builder = endpoint.arguments[1];
|
|
72
|
+
if (!builder || (!ts.isArrowFunction(builder) && !ts.isFunctionExpression(builder))) return;
|
|
73
|
+
const map = SignalMutationIndex.#returnedObjectOf(builder);
|
|
74
|
+
if (!map) return;
|
|
75
|
+
const verbOf = SignalMutationIndex.#verbReaderOf(builder.parameters[0]);
|
|
76
|
+
for (const property of map.properties) {
|
|
77
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
78
|
+
if (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name)) continue;
|
|
79
|
+
const { root, argCount } = SignalMutationIndex.#chainOf(property.initializer);
|
|
80
|
+
const verb = verbOf(root);
|
|
81
|
+
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
82
|
+
const kind = verb === "message" ? "mutation" : verb;
|
|
83
|
+
if (kind === "query" || kind === "mutation") kinds.set(property.name.text, { kind, argCount });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static #returnedObjectOf(builder: ts.ArrowFunction | ts.FunctionExpression) {
|
|
88
|
+
let body: ts.Node | undefined = builder.body;
|
|
89
|
+
if (ts.isBlock(body)) body = body.statements.find(ts.isReturnStatement)?.expression;
|
|
90
|
+
while (body && ts.isParenthesizedExpression(body)) body = body.expression;
|
|
91
|
+
return body && ts.isObjectLiteralExpression(body) ? body : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static #verbReaderOf(parameter: ts.ParameterDeclaration | undefined) {
|
|
95
|
+
const verbs = new Map<string, string>();
|
|
96
|
+
if (parameter && ts.isObjectBindingPattern(parameter.name))
|
|
97
|
+
for (const element of parameter.name.elements) {
|
|
98
|
+
const verb = element.propertyName ?? element.name;
|
|
99
|
+
if (ts.isIdentifier(verb) && ts.isIdentifier(element.name)) verbs.set(element.name.text, verb.text);
|
|
100
|
+
}
|
|
101
|
+
const holder = parameter && ts.isIdentifier(parameter.name) ? parameter.name.text : null;
|
|
102
|
+
return (callee: ts.Expression | undefined) => {
|
|
103
|
+
if (callee && ts.isIdentifier(callee)) return verbs.get(callee.text);
|
|
104
|
+
if (callee && ts.isPropertyAccessExpression(callee) && SignalMutationIndex.#isNamed(callee.expression, holder))
|
|
105
|
+
return callee.name.text;
|
|
106
|
+
return undefined;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static #isNamed(expression: ts.Expression, name: string | null) {
|
|
111
|
+
return ts.isIdentifier(expression) && expression.text === name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static #chainOf(initializer: ts.Expression) {
|
|
115
|
+
let root = initializer;
|
|
116
|
+
let argCount = 0;
|
|
117
|
+
while (
|
|
118
|
+
ts.isCallExpression(root) &&
|
|
119
|
+
ts.isPropertyAccessExpression(root.expression) &&
|
|
120
|
+
ts.isCallExpression(root.expression.expression)
|
|
121
|
+
) {
|
|
122
|
+
if (SignalMutationIndex.#argMethods.has(root.expression.name.text)) argCount += 1;
|
|
123
|
+
root = root.expression.expression;
|
|
124
|
+
}
|
|
125
|
+
return { root: ts.isCallExpression(root) ? root.expression : undefined, argCount };
|
|
126
|
+
}
|
|
127
|
+
}
|
package/ssrScanner.test.ts
CHANGED
|
@@ -176,6 +176,37 @@ describe("akan.ssr.client-mount-load", () => {
|
|
|
176
176
|
test("a store action that is not a load is not a finding", () => {
|
|
177
177
|
expect(scanOne(UI, mount("void st.do.openModal();"))).not.toContain("akan.ssr.client-mount-load");
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test("never sends a mutation to the route", () => {
|
|
181
|
+
const signal = fileOf(
|
|
182
|
+
"apps/demo/lib/task/task.signal.ts",
|
|
183
|
+
`export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({ markTaskSeen: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
184
|
+
);
|
|
185
|
+
const [load, write] = new SsrScanner().scan([
|
|
186
|
+
signal,
|
|
187
|
+
fileOf(UI, mount("void fetch.viewTask(id); void fetch.markTaskSeen(id);")),
|
|
188
|
+
]).warnings;
|
|
189
|
+
expect(load?.message).toContain("The route can fetch this");
|
|
190
|
+
expect(load?.fix).toBeUndefined();
|
|
191
|
+
expect(write?.rule).toBe("akan.ssr.client-mount-load");
|
|
192
|
+
expect(write?.message).toContain("sends the mutation fetch.markTaskSeen()");
|
|
193
|
+
expect(write?.fix).toContain("explicit user action");
|
|
194
|
+
expect(write?.fix).not.toContain("init/view");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("akan.ssr.render-mutation", () => {
|
|
199
|
+
test("is reported for a route file beside the component rules", () => {
|
|
200
|
+
const signal = fileOf(
|
|
201
|
+
"apps/demo/lib/task/task.signal.ts",
|
|
202
|
+
`export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({ approveTask: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
203
|
+
);
|
|
204
|
+
const route = fileOf(
|
|
205
|
+
"apps/demo/page/task/_index.tsx",
|
|
206
|
+
`import { fetch } from "@apps/demo/client";\nexport default page().render(async () => { await fetch.approveTask("x"); return null; });\n`,
|
|
207
|
+
);
|
|
208
|
+
expect(rulesOf(signal, route)).toEqual(["akan.ssr.render-mutation"]);
|
|
209
|
+
});
|
|
179
210
|
});
|
|
180
211
|
|
|
181
212
|
describe("akan.ssr.template-client-state", () => {
|
package/ssrScanner.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
import { RenderMutationScanner } from "./renderMutationScanner";
|
|
4
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
3
5
|
|
|
4
6
|
/** Server/client render split for one app or lib, measured in JSX elements rather than files. */
|
|
5
7
|
export interface SsrBalanceEntry {
|
|
@@ -50,19 +52,23 @@ export class SsrScanner {
|
|
|
50
52
|
);
|
|
51
53
|
// Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
|
|
52
54
|
static #clientRuntimeImports = new Set(["st", "fetch"]);
|
|
55
|
+
static #mountWriteFix =
|
|
56
|
+
"Send the write from an explicit user action, such as a button that calls a store action. Do not move it into the route: a write there runs for anyone who opens a link to the page.";
|
|
53
57
|
|
|
54
58
|
scan(sourceFiles: SourceFileInfo[]): SsrScanResult {
|
|
59
|
+
const mutations = new SignalMutationIndex(sourceFiles);
|
|
55
60
|
const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
|
|
56
61
|
return {
|
|
57
62
|
warnings: [
|
|
58
|
-
...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
|
|
63
|
+
...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile, mutations)),
|
|
59
64
|
...this.#scanModules(componentFiles),
|
|
65
|
+
...new RenderMutationScanner(mutations).scan(sourceFiles),
|
|
60
66
|
],
|
|
61
67
|
balance: this.#measureBalance(componentFiles),
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
#scanFile(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
71
|
+
#scanFile(sourceFile: SourceFileInfo, mutations: SignalMutationIndex): QualityWarning[] {
|
|
66
72
|
if (!this.#hasUseClient(sourceFile.sourceFile)) return [];
|
|
67
73
|
const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
|
|
68
74
|
const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
|
|
@@ -118,7 +124,7 @@ export class SsrScanner {
|
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
warnings.push(...this.#getMountLoadWarnings(sourceFile));
|
|
127
|
+
warnings.push(...this.#getMountLoadWarnings(sourceFile, mutations));
|
|
122
128
|
warnings.push(...this.#getTemplateStateWarnings(sourceFile));
|
|
123
129
|
return warnings;
|
|
124
130
|
}
|
|
@@ -147,20 +153,33 @@ export class SsrScanner {
|
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
// Only an empty-dependency effect is a finding: a reactive effect answers client state and has no server equivalent.
|
|
150
|
-
#getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
156
|
+
#getMountLoadWarnings(sourceFile: SourceFileInfo, mutations: SignalMutationIndex): QualityWarning[] {
|
|
151
157
|
const warnings: QualityWarning[] = [];
|
|
152
158
|
SsrScanner.#walk(sourceFile.sourceFile, (node) => {
|
|
153
159
|
if (!this.#isMountEffect(sourceFile.sourceFile, node)) return;
|
|
154
|
-
for (const load of this.#getLoadCalls(sourceFile.sourceFile, node))
|
|
160
|
+
for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
|
|
161
|
+
const line = this.#getLine(sourceFile.sourceFile, load.node);
|
|
155
162
|
warnings.push(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
load.callee.startsWith("fetch.") && mutations.isMutation(sourceFile.file, load.callee.slice("fetch.".length))
|
|
164
|
+
? {
|
|
165
|
+
...fileWarning(
|
|
166
|
+
"akan.ssr.client-mount-load",
|
|
167
|
+
"ssr",
|
|
168
|
+
sourceFile.file,
|
|
169
|
+
`Mount-only effect sends the mutation ${load.callee}() each time the component mounts. A write is not data the route can load.`,
|
|
170
|
+
line,
|
|
171
|
+
),
|
|
172
|
+
fix: SsrScanner.#mountWriteFix,
|
|
173
|
+
}
|
|
174
|
+
: fileWarning(
|
|
175
|
+
"akan.ssr.client-mount-load",
|
|
176
|
+
"ssr",
|
|
177
|
+
sourceFile.file,
|
|
178
|
+
`Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
|
|
179
|
+
line,
|
|
180
|
+
),
|
|
163
181
|
);
|
|
182
|
+
}
|
|
164
183
|
});
|
|
165
184
|
return warnings;
|
|
166
185
|
}
|
package/uploadRelease.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { Logger, RestClient } from "akanjs/common";
|
|
2
|
-
|
|
3
|
-
import { Spinner } from "./spinner";
|
|
4
|
-
|
|
5
|
-
const spinning = (message: string) => new Spinner(message, { prefix: message, enableSpin: true }).start();
|
|
6
|
-
|
|
7
|
-
export const uploadRelease = async (
|
|
8
|
-
appName: string,
|
|
9
|
-
{
|
|
10
|
-
workspaceRoot,
|
|
11
|
-
environment,
|
|
12
|
-
buildNum,
|
|
13
|
-
platformVersion,
|
|
14
|
-
os,
|
|
15
|
-
local,
|
|
16
|
-
}: {
|
|
17
|
-
workspaceRoot: string;
|
|
18
|
-
environment: string;
|
|
19
|
-
buildNum: number;
|
|
20
|
-
platformVersion?: string;
|
|
21
|
-
os?: "android" | "ios";
|
|
22
|
-
local?: boolean;
|
|
23
|
-
},
|
|
24
|
-
) => {
|
|
25
|
-
const logger = new Logger("uploadRelease");
|
|
26
|
-
const basePath = local ? "http://localhost:8282/backend" : "https://cloud.akanjs.com/backend";
|
|
27
|
-
const httpClient = new RestClient(basePath);
|
|
28
|
-
const readingFilesSpinner = spinning("Reading files...");
|
|
29
|
-
try {
|
|
30
|
-
const uploads = [
|
|
31
|
-
[`builds/${appName}-release.tar.gz`, `${appName}-release.tar.gz`],
|
|
32
|
-
[`sources/${appName}-source.tar.gz`, `${appName}-source.tar.gz`],
|
|
33
|
-
[`builds/${appName}-appBuild.zip`, `${appName}-appBuild.zip`],
|
|
34
|
-
].map(([relativePath, name]) => ({ file: Bun.file(`${workspaceRoot}/releases/${relativePath}`), name }));
|
|
35
|
-
const metas = uploads.map(({ file }) => ({ lastModifiedAt: new Date(file.lastModified), size: file.size }));
|
|
36
|
-
readingFilesSpinner.succeed("Reading files... done");
|
|
37
|
-
|
|
38
|
-
const preparingFormSpinner = spinning("Preparing form data...");
|
|
39
|
-
const formData = new FormData();
|
|
40
|
-
for (const { file, name } of uploads) formData.append("files", file, name);
|
|
41
|
-
formData.append("metas", JSON.stringify(metas));
|
|
42
|
-
formData.append("type", "release");
|
|
43
|
-
preparingFormSpinner.succeed("Preparing form data... done");
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const uploadingFilesSpinner = spinning("Uploading files to server...");
|
|
47
|
-
const [buildFile, sourceFile, appBuildFile] = await httpClient.post<
|
|
48
|
-
[{ id: string }, { id: string }, { id: string }]
|
|
49
|
-
>("/file/addFiles", formData);
|
|
50
|
-
uploadingFilesSpinner.succeed("Uploading files to server... done");
|
|
51
|
-
|
|
52
|
-
const fetchingAppSpinner = spinning(`Fetching dev app information for ${appName}...`);
|
|
53
|
-
const major = platformVersion ? parseInt(platformVersion.split(".")[0]) : 1;
|
|
54
|
-
const minor = platformVersion ? parseInt(platformVersion.split(".")[1]) : 0;
|
|
55
|
-
const patch = platformVersion ? parseInt(platformVersion.split(".")[2]) : 0;
|
|
56
|
-
|
|
57
|
-
const devApp = await httpClient.get<{ id: string }>(`/devApp/devAppInName/${appName}`);
|
|
58
|
-
fetchingAppSpinner.succeed(`Fetching dev app information for ${appName}... done`);
|
|
59
|
-
|
|
60
|
-
const pushingReleaseSpinner = spinning(`Pushing release to ${environment} environment...`);
|
|
61
|
-
const release = await httpClient.post<{ id: string }>(
|
|
62
|
-
`/release/pushRelease/${devApp.id}/${environment}/${major}/${minor}/${patch}/${sourceFile.id}/${buildFile.id}/${appBuildFile.id}${os ? `/${os}` : ""}`,
|
|
63
|
-
);
|
|
64
|
-
pushingReleaseSpinner.succeed(`Pushing release to ${environment} environment... done`);
|
|
65
|
-
new Spinner(`Successfully pushed release to ${appName}-${environment} server. `, {
|
|
66
|
-
prefix: `Successfully pushed release to ${appName}-${environment} server. `,
|
|
67
|
-
enableSpin: false,
|
|
68
|
-
}).succeed(`Successfully pushed release to ${appName}-${environment} server. `);
|
|
69
|
-
return release;
|
|
70
|
-
} catch (e) {
|
|
71
|
-
const errorMessage = e instanceof Error ? e.message : "Unknown error";
|
|
72
|
-
logger.error(`Upload release failed: ${errorMessage}`);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
} catch (e) {
|
|
76
|
-
const errorMessage = e instanceof Error ? e.message : "Unknown error";
|
|
77
|
-
readingFilesSpinner.fail(`Reading files failed: ${errorMessage}`);
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
};
|