@akanjs/devkit 3.1.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.
- package/akanConfig/akanConfig.ts +1 -1
- package/artifact/implicitRootLayout.ts +1 -1
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +14 -10
- package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
- package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
- package/frontendBuild/precompressArtifacts.test.ts +42 -0
- package/frontendBuild/precompressArtifacts.ts +21 -10
- package/frontendBuild/routeClientBuilder.ts +1 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/index.ts +0 -1
- package/mobile/desktopServerStage.ts +1 -1
- package/mobile/nativeApi.ts +1 -1
- package/mobile/nativeApp.ts +1 -1
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updatePackFolder.ts +1 -1
- package/mobile/updateUploader.ts +1 -1
- 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/transforms/barrelImportsPlugin.ts +4 -2
- package/uploadRelease.ts +0 -80
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
|
}
|
|
@@ -10,14 +10,16 @@ export interface BarrelImportsPluginOptions {
|
|
|
10
10
|
skipPath?: (absPath: string) => boolean;
|
|
11
11
|
/** Runs after the rewrite in the same onLoad (Bun's onLoad cannot fall through); null means no change. */
|
|
12
12
|
pipeAfter?: (source: string, args: { path: string }) => string | Promise<string | null> | null;
|
|
13
|
+
/** Barrels the bundle leaves external: a rewritten import would inline the modules the import map already serves. */
|
|
14
|
+
external?: readonly string[];
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export const createBarrelImportsPlugin = async (
|
|
16
18
|
app: App,
|
|
17
|
-
{ skipPath = defaultSkipPath, pipeAfter }: BarrelImportsPluginOptions = {},
|
|
19
|
+
{ skipPath = defaultSkipPath, pipeAfter, external = [] }: BarrelImportsPluginOptions = {},
|
|
18
20
|
): Promise<BunPlugin> => {
|
|
19
21
|
const akanConfig = await app.getConfig();
|
|
20
|
-
const barrels = [...new Set(akanConfig.barrelImports)].filter(
|
|
22
|
+
const barrels = [...new Set(akanConfig.barrelImports)].filter((barrel) => barrel && !external.includes(barrel));
|
|
21
23
|
const analyzer = new BarrelAnalyzer({
|
|
22
24
|
resolvePackage: await createTsconfigPackageResolver(app),
|
|
23
25
|
});
|
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
|
-
};
|