@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.10
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/akanConfig/akanConfig.test.ts +13 -2
- package/akanConfig/akanConfig.ts +5 -5
- package/akanConfig/productionLockfile.test.ts +80 -0
- package/akanConfig/productionLockfile.ts +32 -0
- package/akanContext.ts +25 -0
- package/applicationBuildRunner.test.ts +41 -0
- package/applicationBuildRunner.ts +23 -3
- package/biomeBase.test.ts +2 -2
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +23 -13
- package/cloud/cloudHttpError.test.ts +8 -0
- package/cloud/cloudHttpError.ts +11 -1
- package/cloud/cloudUpdates.ts +48 -2
- package/cloud/updatePackUpload.test.ts +38 -2
- package/cloud/updatePackUpload.ts +5 -1
- package/commandDecorators/command.ts +3 -14
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
- package/commandDecorators/workspaceVersionCheck.ts +50 -0
- 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/mobile/desktopRelease.test.ts +473 -0
- package/mobile/desktopRelease.ts +307 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +29 -5
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -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
|
@@ -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
|
-
};
|