@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- try {
217
- return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
218
- } catch (_) {
219
- return null;
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
- try {
246
- return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
247
- } catch {
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/index.ts CHANGED
@@ -31,5 +31,4 @@ export type * from "./stripAnsi";
31
31
  export type * from "./transforms";
32
32
  export type * from "./typeChecker";
33
33
  export type * from "./types";
34
- export type * from "./uploadRelease";
35
34
  export type * from "./workflow";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.0",
3
+ "version": "3.1.1-rc.1",
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.0",
51
+ "akanjs": "3.1.1-rc.1",
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
+ }
@@ -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
- fileWarning(
157
- "akan.ssr.client-mount-load",
158
- "ssr",
159
- sourceFile.file,
160
- `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
161
- this.#getLine(sourceFile.sourceFile, load.node),
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
- };