@akanjs/devkit 3.0.2 → 3.1.0-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/CHANGELOG.md +46 -7
- package/akanApp/akanApp.host.ts +3 -2
- package/akanConfig/akanConfig.test.ts +76 -6
- package/akanConfig/akanConfig.ts +111 -13
- package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
- package/akanConfig/akanWorkspaceConfig.ts +63 -0
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +23 -1
- package/akanContext.ts +1 -1
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +73 -84
- package/artifact/implicitRootLayout.test.ts +6 -2
- package/artifact/implicitRootLayout.ts +12 -7
- package/biome.base.json +7 -2
- package/cloud/cloudApi.ts +87 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +250 -0
- package/cloud/updatePackUpload.ts +132 -0
- package/commandDecorators/command.ts +8 -2
- package/commandDecorators/targetMeta.ts +1 -0
- package/executors.test.ts +21 -0
- package/executors.ts +9 -2
- package/frontendBuild/csrArtifactBuilder.ts +10 -1
- package/frontendBuild/cssCandidateCache.test.ts +10 -0
- package/frontendBuild/cssCandidateCache.ts +4 -2
- package/frontendBuild/pagesBundleBuilder.ts +5 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
- package/incrementalBuilder/buildBatch.proc.ts +14 -2
- package/incrementalBuilder/buildBatchProtocol.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
- package/lint/no-import-external-library-in-test.grit +24 -0
- package/mobile/desktopServerStage.test.ts +6 -0
- package/mobile/desktopServerStage.ts +25 -3
- package/mobile/index.ts +1 -0
- package/mobile/nativeApp.test.ts +34 -0
- package/mobile/nativeApp.ts +106 -34
- package/mobile/nativeConfig.test.ts +59 -1
- package/mobile/nativeConfig.ts +13 -3
- package/mobile/serverBundleSecrets.test.ts +61 -0
- package/mobile/serverBundleSecrets.ts +85 -0
- package/mobile/updateUploader.test.ts +49 -0
- package/mobile/updateUploader.ts +71 -0
- package/package.json +3 -2
- package/pipeline/PipelineCompiler.test.ts +631 -0
- package/pipeline/PipelineCompiler.ts +646 -0
- package/pipeline/PipelineDefinition.test.ts +89 -0
- package/pipeline/PipelineDefinition.ts +117 -0
- package/pipeline/PipelineDryRun.test.ts +93 -0
- package/pipeline/PipelineDryRun.ts +89 -0
- package/pipeline/PipelineInputReader.ts +739 -0
- package/pipeline/index.ts +4 -0
- package/qualityScanner.test.ts +9 -3
- package/qualityScanner.ts +5 -2
- package/scanInfo.ts +13 -6
- package/subspace.test.ts +108 -4
- package/subspace.ts +162 -10
- package/subspaceConfig.ts +11 -14
- package/testHelpers.ts +4 -0
- package/transforms/externalizeFrameworkPlugin.ts +1 -7
- package/transforms/serverEnvPlugin.test.ts +93 -2
- package/transforms/serverEnvPlugin.ts +71 -18
- package/transforms/serverEnvProfile.test.ts +82 -0
- package/transforms/serverEnvProfile.ts +114 -0
- package/typecheck/projectTypecheck.test.ts +32 -0
- package/typecheck/projectTypecheck.ts +71 -0
- package/workspaceLayout.test.ts +26 -3
- package/workspaceLayout.ts +15 -4
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { Workspace } from "../commandDecorators";
|
|
6
|
+
import { tempDirs } from "../testHelpers";
|
|
7
|
+
import { CloudApi } from "./cloudApi";
|
|
8
|
+
import { UpdatePackUpload } from "./updatePackUpload";
|
|
9
|
+
|
|
10
|
+
interface Put {
|
|
11
|
+
uploadId: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
body: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class FakeAkasys {
|
|
17
|
+
readonly server: ReturnType<typeof Bun.serve>;
|
|
18
|
+
readonly requests: Record<string, unknown>[] = [];
|
|
19
|
+
readonly puts: Put[] = [];
|
|
20
|
+
readonly commits: string[] = [];
|
|
21
|
+
readonly registrations: Record<string, string>[] = [];
|
|
22
|
+
putStatuses: number[] = [];
|
|
23
|
+
commitAnswers: (number | [number, string])[] = [];
|
|
24
|
+
release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
|
|
25
|
+
requestRefusal: Response | null = null;
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get host() {
|
|
32
|
+
return `http://127.0.0.1:${this.server.port}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reset() {
|
|
36
|
+
this.requests.length = 0;
|
|
37
|
+
this.puts.length = 0;
|
|
38
|
+
this.commits.length = 0;
|
|
39
|
+
this.registrations.length = 0;
|
|
40
|
+
this.putStatuses = [];
|
|
41
|
+
this.commitAnswers = [];
|
|
42
|
+
this.requestRefusal = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async #route(request: Request) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
if (url.pathname === "/api/requestUpdateUpload/project-1") {
|
|
48
|
+
if (this.requestRefusal) return this.requestRefusal;
|
|
49
|
+
this.requests.push({ ...(await request.json()), authorization: request.headers.get("authorization") });
|
|
50
|
+
const uploadId = `upload-${this.requests.length}`;
|
|
51
|
+
return Response.json({
|
|
52
|
+
uploadId,
|
|
53
|
+
uploadUrl: `${this.host}/storage/${uploadId}?X-Amz-Signature=secret-signature`,
|
|
54
|
+
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
|
55
|
+
contentType: "application/gzip",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname.startsWith("/storage/") && request.method === "PUT") {
|
|
59
|
+
this.puts.push({
|
|
60
|
+
uploadId: url.pathname.slice("/storage/".length),
|
|
61
|
+
headers: Object.fromEntries(request.headers),
|
|
62
|
+
body: new Uint8Array(await request.arrayBuffer()),
|
|
63
|
+
});
|
|
64
|
+
return new Response(null, { status: this.putStatuses.shift() ?? 200 });
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === "/api/commitUpdateUpload/project-1") {
|
|
67
|
+
const { uploadId } = (await request.json()) as { uploadId: string };
|
|
68
|
+
this.commits.push(uploadId);
|
|
69
|
+
const answer = this.#answer();
|
|
70
|
+
if (answer) return answer;
|
|
71
|
+
return Response.json({ ...this.release, uploadId });
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === "/api/registerAppBinary/project-1") {
|
|
74
|
+
const form = await request.formData();
|
|
75
|
+
this.registrations.push(
|
|
76
|
+
Object.fromEntries(
|
|
77
|
+
[...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : "<file>"]),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
return this.#answer() ?? Response.json({ id: "binary-1" });
|
|
81
|
+
}
|
|
82
|
+
return new Response("not found", { status: 404 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#answer() {
|
|
86
|
+
const answer = this.commitAnswers.shift();
|
|
87
|
+
if (!answer) return null;
|
|
88
|
+
const [status, key] = typeof answer === "number" ? [answer, "release.error.uploadCommitInProgress"] : answer;
|
|
89
|
+
return Response.json({ error: key }, { status });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const makeDir = tempDirs("akan-update-upload-");
|
|
94
|
+
const akasys = new FakeAkasys();
|
|
95
|
+
const api = new CloudApi({} as Workspace, {
|
|
96
|
+
host: akasys.host,
|
|
97
|
+
auth: { accessToken: { jwt: "jwt-1", refreshToken: null, expiresAt: null } },
|
|
98
|
+
});
|
|
99
|
+
const fields = { appName: "lu", target: "default", platform: "android" as const, channel: "develop", notes: null };
|
|
100
|
+
const quick = { putRetryMs: 1, commitRetryMs: 1, cutOffWindowMs: 1_000, commitLimitMs: 1_000 };
|
|
101
|
+
|
|
102
|
+
const archiveOf = async (bytes: string) => {
|
|
103
|
+
const archive = path.join(await makeDir(), "pack.tgz");
|
|
104
|
+
await writeFile(archive, bytes);
|
|
105
|
+
return archive;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
beforeEach(() => akasys.reset());
|
|
109
|
+
afterAll(() => akasys.server.stop(true));
|
|
110
|
+
|
|
111
|
+
describe("UpdatePackUpload", () => {
|
|
112
|
+
test("announces the archive's size and digest, PUTs it with nothing but its content type, then commits", async () => {
|
|
113
|
+
const archive = await archiveOf("pack bytes");
|
|
114
|
+
|
|
115
|
+
const release = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
116
|
+
|
|
117
|
+
expect(release).toEqual({ id: "release-1", status: "published", problems: [], uploadId: "upload-1" });
|
|
118
|
+
expect(akasys.requests).toEqual([
|
|
119
|
+
{
|
|
120
|
+
...fields,
|
|
121
|
+
size: 10,
|
|
122
|
+
sha256: createHash("sha256").update("pack bytes").digest("hex"),
|
|
123
|
+
authorization: "Bearer jwt-1",
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
const [put] = akasys.puts;
|
|
127
|
+
expect(new TextDecoder().decode(put?.body)).toBe("pack bytes");
|
|
128
|
+
expect(put?.headers["content-type"]).toBe("application/gzip");
|
|
129
|
+
expect(put?.headers["content-length"]).toBe("10");
|
|
130
|
+
expect(put?.headers.authorization).toBeUndefined();
|
|
131
|
+
expect(Object.keys(put?.headers ?? {}).filter((name) => name.startsWith("x-amz-"))).toEqual([]);
|
|
132
|
+
expect(akasys.commits).toEqual(["upload-1"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("takes one fresh ticket when storage answers 403, and gives up on the second without naming the URL", async () => {
|
|
136
|
+
const archive = await archiveOf("pack");
|
|
137
|
+
akasys.putStatuses = [403];
|
|
138
|
+
|
|
139
|
+
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
140
|
+
expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
|
|
141
|
+
expect(akasys.commits).toEqual(["upload-2"]);
|
|
142
|
+
|
|
143
|
+
akasys.reset();
|
|
144
|
+
akasys.putStatuses = [403, 403];
|
|
145
|
+
const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
|
|
146
|
+
expect(String(refusal)).toContain("HTTP 403");
|
|
147
|
+
expect(String(refusal)).not.toContain("secret-signature");
|
|
148
|
+
expect(akasys.commits).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("retries a PUT the way curl --retry does, and stops at a refusal that is not transient", async () => {
|
|
152
|
+
const archive = await archiveOf("pack");
|
|
153
|
+
akasys.putStatuses = [503, 502];
|
|
154
|
+
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
155
|
+
expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
|
|
156
|
+
|
|
157
|
+
akasys.reset();
|
|
158
|
+
akasys.putStatuses = [400];
|
|
159
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("HTTP 400");
|
|
160
|
+
expect(akasys.puts).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("asks again with the same id while the commit is cut off or still running, but not past a real answer", async () => {
|
|
164
|
+
const archive = await archiveOf("pack");
|
|
165
|
+
akasys.commitAnswers = [524, 409, 502];
|
|
166
|
+
await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
|
|
167
|
+
expect(akasys.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
|
|
168
|
+
|
|
169
|
+
akasys.reset();
|
|
170
|
+
akasys.commitAnswers = [404];
|
|
171
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("404");
|
|
172
|
+
expect(akasys.commits).toEqual(["upload-1"]);
|
|
173
|
+
|
|
174
|
+
akasys.reset();
|
|
175
|
+
akasys.commitAnswers = Array.from({ length: 100 }, () => 524);
|
|
176
|
+
const cutOff = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 35, commitLimitMs: 10_000 };
|
|
177
|
+
await expect(new UpdatePackUpload(api, "project-1", cutOff).send(archive, fields)).rejects.toThrow("524");
|
|
178
|
+
expect(akasys.commits.length).toBeLessThan(6);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("keeps waiting on a live claim past the cut-off window, up to the overall limit", async () => {
|
|
182
|
+
const archive = await archiveOf("pack");
|
|
183
|
+
akasys.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
|
|
184
|
+
const timing = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 25, commitLimitMs: 10_000 };
|
|
185
|
+
await new UpdatePackUpload(api, "project-1", timing).send(archive, fields);
|
|
186
|
+
expect(akasys.commits).toHaveLength(14);
|
|
187
|
+
|
|
188
|
+
akasys.reset();
|
|
189
|
+
akasys.commitAnswers = Array.from({ length: 100 }, () => 409);
|
|
190
|
+
const limited = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 10_000, commitLimitMs: 35 };
|
|
191
|
+
await expect(new UpdatePackUpload(api, "project-1", limited).send(archive, fields)).rejects.toThrow("409");
|
|
192
|
+
expect(akasys.commits.length).toBeLessThan(6);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("tells a server without staged uploads apart from one whose 404 is a refusal", async () => {
|
|
196
|
+
const archive = await archiveOf("pack");
|
|
197
|
+
akasys.requestRefusal = new Response("Not Found", { status: 404 });
|
|
198
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
|
|
199
|
+
"does not take direct pack uploads yet",
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
akasys.requestRefusal = Response.json({ error: "devProject.error.appNotFound", statusCode: 404 }, { status: 404 });
|
|
203
|
+
const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
|
|
204
|
+
expect(String(refusal)).toContain("devProject.error.appNotFound");
|
|
205
|
+
expect(akasys.puts).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("stops at once on a 409 that is not a commit in progress: the upload is already used", async () => {
|
|
209
|
+
const archive = await archiveOf("pack");
|
|
210
|
+
akasys.commitAnswers = [[409, "release.error.uploadEmbedded"]];
|
|
211
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("uploadEmbedded");
|
|
212
|
+
expect(akasys.commits).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("stages a binary's pack and registers it by upload id, asking again while the server is still at it", async () => {
|
|
216
|
+
const archive = await archiveOf("pack");
|
|
217
|
+
akasys.commitAnswers = [524, 409];
|
|
218
|
+
const upload = new UpdatePackUpload(api, "project-1", quick);
|
|
219
|
+
const binary = await upload.stage(archive, fields).then((packUploadId) =>
|
|
220
|
+
upload.settle(() =>
|
|
221
|
+
api.registerAppBinary("project-1", {
|
|
222
|
+
appName: "lu",
|
|
223
|
+
target: "default",
|
|
224
|
+
distribution: "playTesting",
|
|
225
|
+
bundle: new Blob(["{}"]),
|
|
226
|
+
packUploadId,
|
|
227
|
+
channel: "develop",
|
|
228
|
+
}),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(binary).toEqual({ id: "binary-1" });
|
|
233
|
+
expect(akasys.commits).toEqual([]);
|
|
234
|
+
expect(akasys.registrations).toHaveLength(3);
|
|
235
|
+
expect(akasys.registrations[2]).toEqual({
|
|
236
|
+
appName: "lu",
|
|
237
|
+
target: "default",
|
|
238
|
+
distribution: "playTesting",
|
|
239
|
+
channel: "develop",
|
|
240
|
+
bundle: "<file>",
|
|
241
|
+
packUploadId: "upload-1",
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("refuses an empty archive before asking for a ticket", async () => {
|
|
246
|
+
const archive = await archiveOf("");
|
|
247
|
+
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
|
|
248
|
+
expect(akasys.requests).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Logger, sleep } from "akanjs/common";
|
|
2
|
+
import type { BunFile } from "bun";
|
|
3
|
+
import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
|
|
4
|
+
|
|
5
|
+
interface UpdatePackUploadOptions {
|
|
6
|
+
putRetryMs?: number;
|
|
7
|
+
commitRetryMs?: number;
|
|
8
|
+
cutOffWindowMs?: number;
|
|
9
|
+
commitLimitMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
//* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
|
|
13
|
+
//* only sees small calls: request a presigned PUT, then commit the object, or register a binary with it as its
|
|
14
|
+
//* embedded pack; either re-hashes it, and an upload is used once, one way.
|
|
15
|
+
export class UpdatePackUpload {
|
|
16
|
+
static readonly maxBytes = 200 * 1024 * 1024;
|
|
17
|
+
static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
|
|
18
|
+
readonly #api: CloudApi;
|
|
19
|
+
readonly #devProjectId: string;
|
|
20
|
+
readonly #putRetryMs: number;
|
|
21
|
+
readonly #commitRetryMs: number;
|
|
22
|
+
readonly #cutOffWindowMs: number;
|
|
23
|
+
readonly #commitLimitMs: number;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
api: CloudApi,
|
|
27
|
+
devProjectId: string,
|
|
28
|
+
{
|
|
29
|
+
putRetryMs = 1_000,
|
|
30
|
+
commitRetryMs = 10_000,
|
|
31
|
+
cutOffWindowMs = 15 * 60_000,
|
|
32
|
+
commitLimitMs = 60 * 60_000,
|
|
33
|
+
}: UpdatePackUploadOptions = {},
|
|
34
|
+
) {
|
|
35
|
+
this.#api = api;
|
|
36
|
+
this.#devProjectId = devProjectId;
|
|
37
|
+
this.#putRetryMs = putRetryMs;
|
|
38
|
+
this.#commitRetryMs = commitRetryMs;
|
|
39
|
+
this.#cutOffWindowMs = cutOffWindowMs;
|
|
40
|
+
this.#commitLimitMs = commitLimitMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stages the pack and commits it as a release of `fields.channel`. */
|
|
44
|
+
async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
|
|
45
|
+
const uploadId = await this.stage(archive, fields);
|
|
46
|
+
return await this.settle(() => this.#api.commitUpdateUpload(this.#devProjectId, uploadId));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
|
|
50
|
+
async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
|
|
51
|
+
const file = Bun.file(archive);
|
|
52
|
+
if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
|
|
53
|
+
throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
|
|
54
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
55
|
+
for await (const chunk of file.stream()) hasher.update(chunk);
|
|
56
|
+
const upload = { ...fields, size: file.size, sha256: hasher.digest("hex") };
|
|
57
|
+
//? A URL lives 15 minutes and storage answers 403 past it: one fresh ticket, then the refusal is real.
|
|
58
|
+
for (let ticket = 1; ; ticket++) {
|
|
59
|
+
const { uploadId, uploadUrl, contentType } = await this.#request(upload);
|
|
60
|
+
const status = await this.#put(uploadUrl, contentType, file);
|
|
61
|
+
if (status >= 200 && status < 300) return uploadId;
|
|
62
|
+
if (status !== 403 || ticket === 2)
|
|
63
|
+
throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async #request(upload: UpdateUploadRequest) {
|
|
68
|
+
return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
|
|
69
|
+
//? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
|
|
72
|
+
throw new Error(
|
|
73
|
+
"The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
|
|
74
|
+
);
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
|
|
80
|
+
// A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
|
|
81
|
+
async #put(url: string, contentType: string, file: BunFile) {
|
|
82
|
+
for (let retry = 0; ; retry++) {
|
|
83
|
+
const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
|
|
84
|
+
.then(async (response) => {
|
|
85
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
86
|
+
return response.status;
|
|
87
|
+
})
|
|
88
|
+
.catch(() => 0);
|
|
89
|
+
if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
|
|
90
|
+
await sleep(this.#putRetryMs * (retry + 1));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sends `call`, which consumes a staged upload, until the server answers it: the server finishes whoever stopped
|
|
96
|
+
* listening, and the same upload id answers the same record afterwards.
|
|
97
|
+
*/
|
|
98
|
+
// `uploadCommitInProgress` is a live claim the server heartbeats, so only the overall limit bounds it; a run of
|
|
99
|
+
// cut-offs with none between them may be a server that died, whose claim lapses within minutes: the shorter window.
|
|
100
|
+
async settle<T>(call: () => Promise<T>): Promise<T> {
|
|
101
|
+
const limit = performance.now() + this.#commitLimitMs;
|
|
102
|
+
let cutOffSince: number | null = null;
|
|
103
|
+
for (;;) {
|
|
104
|
+
try {
|
|
105
|
+
return await call();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const now = performance.now();
|
|
108
|
+
if (UpdatePackUpload.#inProgress(error)) cutOffSince = null;
|
|
109
|
+
else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
|
|
110
|
+
else throw error;
|
|
111
|
+
const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
|
|
112
|
+
if (now + this.#commitRetryMs > deadline) throw error;
|
|
113
|
+
Logger.rawLog(
|
|
114
|
+
`The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
|
|
115
|
+
);
|
|
116
|
+
await sleep(this.#commitRetryMs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//? Not every 409: an upload already committed or embedded is answered 409 too, and asking again changes nothing.
|
|
122
|
+
static #inProgress(error: unknown) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
return CloudApi.statusOf(error) === 409 && message.includes("uploadCommitInProgress");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static #cutOff(error: unknown) {
|
|
128
|
+
const status = CloudApi.statusOf(error);
|
|
129
|
+
if (status === null) return !(error instanceof SyntaxError);
|
|
130
|
+
return status === 502 || status === 504 || (status >= 520 && status <= 527);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -288,9 +288,10 @@ export const getInternalArgumentValue = async (
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
export const runCommands = async (...commands: CommandCls[]) => {
|
|
291
|
+
let failureExitCode = 1;
|
|
291
292
|
process.on("unhandledRejection", (error) => {
|
|
292
293
|
printCliError(error);
|
|
293
|
-
process.exit(
|
|
294
|
+
process.exit(failureExitCode);
|
|
294
295
|
});
|
|
295
296
|
const __dirname = getDirname(import.meta.url);
|
|
296
297
|
const packageJsonCandidates = [`${path.dirname(Bun.main)}/package.json`, `${__dirname}/../package.json`];
|
|
@@ -335,6 +336,10 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
335
336
|
let programCommand = program.command(commandName, {
|
|
336
337
|
hidden: targetMeta.targetOption.devOnly,
|
|
337
338
|
});
|
|
339
|
+
const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
|
|
340
|
+
//? commander exits 1 on a flag or argument it refuses, before any action runs.
|
|
341
|
+
if (targetFailureExitCode !== undefined)
|
|
342
|
+
programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
|
|
338
343
|
const [allArgMetas] = getArgMetas(command, targetMeta.key);
|
|
339
344
|
for (const argMeta of allArgMetas) {
|
|
340
345
|
if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
|
|
@@ -364,6 +369,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
364
369
|
};
|
|
365
370
|
|
|
366
371
|
programCommand.action(async (...args: unknown[]) => {
|
|
372
|
+
failureExitCode = targetFailureExitCode ?? 1;
|
|
367
373
|
if (!targetMeta.targetOption.stdio) Logger.rawLog();
|
|
368
374
|
const cmdArgs = args.slice(0, args.length - 2);
|
|
369
375
|
const opt = args[args.length - 2] as Record<string, unknown>;
|
|
@@ -413,6 +419,6 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
413
419
|
// Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
|
|
414
420
|
await program.parseAsync(process.argv).catch((error: unknown) => {
|
|
415
421
|
printCliError(error);
|
|
416
|
-
process.exit(
|
|
422
|
+
process.exit(failureExitCode);
|
|
417
423
|
});
|
|
418
424
|
};
|
package/executors.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { DevGeneratedIndexSync } from "./frontendBuild/devGeneratedIndexSync";
|
|
15
15
|
import { AppInfo } from "./scanInfo";
|
|
16
16
|
import {
|
|
17
|
+
createTempApp,
|
|
17
18
|
createTempLib,
|
|
18
19
|
formatWithBiome,
|
|
19
20
|
hasBiome,
|
|
@@ -167,6 +168,26 @@ describe("Executor filesystem helpers", () => {
|
|
|
167
168
|
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
168
169
|
});
|
|
169
170
|
|
|
171
|
+
test("an app hands its scaffolded files to the formatter too", async () => {
|
|
172
|
+
const { root, app } = trackRoot(await createTempApp("demo"));
|
|
173
|
+
const formatted: string[] = [];
|
|
174
|
+
app.getLinter = () =>
|
|
175
|
+
({
|
|
176
|
+
fixFiles: async (filePaths: string[]) => {
|
|
177
|
+
formatted.push(...filePaths);
|
|
178
|
+
return { fixed: [] };
|
|
179
|
+
},
|
|
180
|
+
}) as unknown as ReturnType<Executor["getLinter"]>;
|
|
181
|
+
|
|
182
|
+
await app.applyTemplate({
|
|
183
|
+
basePath: "page/task",
|
|
184
|
+
template: "crudSinglePage",
|
|
185
|
+
dict: { model: "task", appName: "demo" },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
189
|
+
});
|
|
190
|
+
|
|
170
191
|
test("a formatter that cannot run does not fail the scaffold", async () => {
|
|
171
192
|
// create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
|
|
172
193
|
const root = await makeTempRoot();
|
package/executors.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
scanScopeRecipes,
|
|
41
41
|
upsertAgentBlock,
|
|
42
42
|
} from "./agentsIndex";
|
|
43
|
-
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
43
|
+
import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
44
44
|
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
45
45
|
import { CodegenLock } from "./codegenLock";
|
|
46
46
|
import { FileSys, getDirname } from "./fileSys";
|
|
@@ -533,6 +533,7 @@ export class Executor {
|
|
|
533
533
|
async applyTemplate(options: {
|
|
534
534
|
basePath: string;
|
|
535
535
|
template: string;
|
|
536
|
+
scanInfo?: AppInfo | LibInfo | null;
|
|
536
537
|
dict?: { [key: string]: string };
|
|
537
538
|
options?: { [key: string]: unknown };
|
|
538
539
|
overwrite?: boolean;
|
|
@@ -707,6 +708,12 @@ export class WorkspaceExecutor extends Executor {
|
|
|
707
708
|
async scan(): Promise<WorkspaceInfo> {
|
|
708
709
|
return await WorkspaceInfo.fromExecutor(this);
|
|
709
710
|
}
|
|
711
|
+
#akanConfig: AkanWorkspaceConfig | null = null;
|
|
712
|
+
async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
713
|
+
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
714
|
+
this.#akanConfig = await AkanWorkspaceConfig.from(this, { bustImportCache: refresh });
|
|
715
|
+
return this.#akanConfig;
|
|
716
|
+
}
|
|
710
717
|
async getApps() {
|
|
711
718
|
return await this.#getDirHasFile(`${this.workspaceRoot}/apps`, "akan.config.ts");
|
|
712
719
|
}
|
|
@@ -1091,7 +1098,7 @@ export class SysExecutor extends Executor {
|
|
|
1091
1098
|
overwrite?: boolean;
|
|
1092
1099
|
}): Promise<FileContent[]> {
|
|
1093
1100
|
const scanInfo = await this.scan();
|
|
1094
|
-
const fileContents = await
|
|
1101
|
+
const fileContents = await super.applyTemplate({ ...options, scanInfo });
|
|
1095
1102
|
await this.scan();
|
|
1096
1103
|
return fileContents;
|
|
1097
1104
|
}
|
|
@@ -18,11 +18,19 @@ export class CsrArtifactBuilder {
|
|
|
18
18
|
#app: App;
|
|
19
19
|
#command: "build" | "start";
|
|
20
20
|
#lang: string;
|
|
21
|
+
#cssAssets: Record<string, CssAsset> | null;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
//? `cssAssets` is a dev batch's current stylesheets: `base-artifact.json` keeps the ones the boot build compiled.
|
|
24
|
+
constructor(
|
|
25
|
+
app: App,
|
|
26
|
+
command: "build" | "start" = "start",
|
|
27
|
+
lang = "en",
|
|
28
|
+
{ cssAssets = null }: { cssAssets?: Record<string, CssAsset> | null } = {},
|
|
29
|
+
) {
|
|
23
30
|
this.#app = app;
|
|
24
31
|
this.#command = command;
|
|
25
32
|
this.#lang = lang;
|
|
33
|
+
this.#cssAssets = cssAssets;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
async build(): Promise<BuildCsrArtifactResult | null> {
|
|
@@ -152,6 +160,7 @@ void bootCsr(pages);
|
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
async #loadCssAssets(): Promise<Record<string, CssAsset>> {
|
|
163
|
+
if (this.#cssAssets) return this.#cssAssets;
|
|
155
164
|
const artifactFile = Bun.file(path.join(this.#artifactDir, "base-artifact.json"));
|
|
156
165
|
if (!(await artifactFile.exists())) return {};
|
|
157
166
|
const artifact = (await artifactFile.json()) as Partial<Pick<BaseBuildArtifact, "cssAssets">>;
|
|
@@ -23,6 +23,16 @@ describe("CssCandidateCache token extraction", () => {
|
|
|
23
23
|
expect(candidates).not.toContain("_td");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("keeps Tailwind v4's variable shorthand whole, and leaves a call's argument its own candidate", async () => {
|
|
27
|
+
const candidates = await scan(
|
|
28
|
+
`const c = "max-md:top-(--keyboard-top) min-h-(--x) bg-(color:--brand)/50"; cn("flex"); fn(value);`,
|
|
29
|
+
);
|
|
30
|
+
expect(candidates).toEqual(
|
|
31
|
+
expect.arrayContaining(["max-md:top-(--keyboard-top)", "min-h-(--x)", "bg-(color:--brand)/50", "flex"]),
|
|
32
|
+
);
|
|
33
|
+
expect(candidates).not.toContain("-keyboard-top");
|
|
34
|
+
});
|
|
35
|
+
|
|
26
36
|
test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
|
|
27
37
|
const candidates = await scan(
|
|
28
38
|
`type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
|
|
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
|
|
3
3
|
// The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole. Brackets hold no whitespace: a labelled
|
|
4
4
|
// tuple type scanned as a class compiles to a rule lightningcss cannot parse, and it then drops every variant rule.
|
|
5
|
-
|
|
5
|
+
// Parentheses pass only as Tailwind v4's variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields
|
|
6
|
+
// `flex` on its own.
|
|
7
|
+
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:(?:\[[^\]\s]+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
|
|
6
8
|
|
|
7
9
|
interface CachedFile {
|
|
8
10
|
mtimeMs: number;
|
|
@@ -18,7 +20,7 @@ interface CacheFile {
|
|
|
18
20
|
/** Tailwind candidates per source file, on disk: the batch worker that compiles CSS exits before the next save. */
|
|
19
21
|
export class CssCandidateCache {
|
|
20
22
|
// Bump when the token regex or the entry shape changes, so stale extractions are not reused.
|
|
21
|
-
static readonly #version =
|
|
23
|
+
static readonly #version = 4;
|
|
22
24
|
readonly #path: string;
|
|
23
25
|
readonly #entries = new Map<string, CachedFile>();
|
|
24
26
|
#dirty = false;
|
|
@@ -32,11 +32,13 @@ export class PagesBundleBuilder {
|
|
|
32
32
|
#command: "build" | "start";
|
|
33
33
|
#pageEntries?: PageEntry[];
|
|
34
34
|
#started = Date.now();
|
|
35
|
+
#serverEnv: BunPlugin | undefined;
|
|
35
36
|
|
|
36
|
-
constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[]) {
|
|
37
|
+
constructor(app: App, command: "build" | "start" = "start", pageEntries?: PageEntry[], serverEnv?: BunPlugin) {
|
|
37
38
|
this.#app = app;
|
|
38
39
|
this.#command = command;
|
|
39
40
|
this.#pageEntries = pageEntries;
|
|
41
|
+
this.#serverEnv = serverEnv;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
async build(): Promise<BuildPagesBundleResult> {
|
|
@@ -67,6 +69,8 @@ export class PagesBundleBuilder {
|
|
|
67
69
|
define: bundleDefine(this.#app, this.#command, "ssr"),
|
|
68
70
|
metafile: dev,
|
|
69
71
|
plugins: [
|
|
72
|
+
//? First: the use-client plugins below load every module, and an env file must be swapped before they do.
|
|
73
|
+
...(this.#serverEnv ? [this.#serverEnv] : []),
|
|
70
74
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
71
75
|
PagesBundleBuilder.createCssStubPlugin(),
|
|
72
76
|
PagesBundleBuilder.createServerUseClientFetchPlugin(),
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { optimize } from "@tailwindcss/node";
|
|
3
3
|
import type { AkanNativeAppId, AkanWebConfig } from "akanjs";
|
|
4
4
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
5
|
+
import type { BunPlugin } from "bun";
|
|
5
6
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
6
7
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
7
8
|
import type { App } from "../commandDecorators";
|
|
@@ -29,10 +30,13 @@ export class SsrBaseArtifactBuilder {
|
|
|
29
30
|
#artifactDir: string;
|
|
30
31
|
#absArtifactDir: string;
|
|
31
32
|
#started = Date.now();
|
|
33
|
+
#serverEnv: BunPlugin | undefined;
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/** `serverEnv`: the build's server env plugin, so the route bundle carries the env files the backend does and no more. */
|
|
36
|
+
constructor(app: App, command: "build" | "start" = "start", { serverEnv }: { serverEnv?: BunPlugin } = {}) {
|
|
34
37
|
this.#app = app;
|
|
35
38
|
this.#command = command;
|
|
39
|
+
this.#serverEnv = serverEnv;
|
|
36
40
|
this.#artifactDir = `${command === "build" ? app.dist.cwdPath : app.cwdPath}/.akan/artifact`;
|
|
37
41
|
this.#absArtifactDir = path.resolve(this.#artifactDir);
|
|
38
42
|
}
|
|
@@ -52,7 +56,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
52
56
|
|
|
53
57
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
54
58
|
const { cssCompiler, optimizedFonts, cssAssets } = await this.#buildStyleAssets();
|
|
55
|
-
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries).build();
|
|
59
|
+
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries, this.#serverEnv).build();
|
|
56
60
|
this.#app.verbose(
|
|
57
61
|
`[base-artifact] pages bundle -> ${pagesBundle.bundlePath} (buildId=${pagesBundle.buildId}, splitting=${pagesBundle.splitting}, entry=${pagesBundle.entryBytes} bytes, outputs=${pagesBundle.outputCount}, chunks=${pagesBundle.chunkCount}, total=${pagesBundle.outputBytes} bytes)`,
|
|
58
62
|
);
|
|
@@ -35,6 +35,7 @@ class BuildBatch {
|
|
|
35
35
|
async run(): Promise<BuildBatchResult> {
|
|
36
36
|
if (this.#request.needs.includes("base")) await this.#buildBase();
|
|
37
37
|
if (this.#request.needs.includes("csr")) await this.#buildCsr();
|
|
38
|
+
if (this.#request.needs.includes("shell")) await this.#buildShell();
|
|
38
39
|
if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
|
|
39
40
|
// Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
|
|
40
41
|
if (this.#request.needs.includes("css")) await this.#buildCss();
|
|
@@ -72,7 +73,7 @@ class BuildBatch {
|
|
|
72
73
|
const message = ApplicationBuildReporter.formatError(err, this.#request.workspaceRoot);
|
|
73
74
|
this.#logger.error(`${label} failed: ${message}`);
|
|
74
75
|
this.#result.errors[need] = message;
|
|
75
|
-
if (need !== "base") this.#emitStatus(need, message);
|
|
76
|
+
if (need !== "base" && need !== "shell") this.#emitStatus(need, message);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Streams nothing: the builder is not serving yet, so the watcher reads the outcome from the batch result.
|
|
@@ -99,8 +100,19 @@ class BuildBatch {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
//? No `csr-updated`: under the registry no tab runs this build, and the reload would send every CSR tab back to it.
|
|
104
|
+
async #buildShell(): Promise<void> {
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
try {
|
|
107
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).build();
|
|
108
|
+
this.#logger.verbose(`csr-shell ok (${Date.now() - started}ms)`);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
this.#fail("shell", "csr-shell", err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
async #rebuildCsrArtifact(started: number): Promise<void> {
|
|
103
|
-
await new CsrArtifactBuilder(this.#app).build();
|
|
115
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).build();
|
|
104
116
|
this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
|
|
105
117
|
// A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
|
|
106
118
|
if (this.#request.changedFiles.length === 0) return;
|