@akanjs/devkit 3.1.0-rc.0 → 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/cloud/cloudApi.ts +6 -1
- package/cloud/updatePackUpload.test.ts +58 -3
- package/cloud/updatePackUpload.ts +25 -8
- package/mobile/nativeApp.test.ts +33 -0
- package/mobile/nativeApp.ts +10 -0
- package/package.json +2 -2
package/cloud/cloudApi.ts
CHANGED
|
@@ -184,6 +184,8 @@ export class CloudApi {
|
|
|
184
184
|
target: string;
|
|
185
185
|
distribution: AppBinaryDistribution;
|
|
186
186
|
bundle: Blob;
|
|
187
|
+
/** The pack staged with UpdatePackUpload.stage; the multipart `pack` is for a server older than staging. */
|
|
188
|
+
packUploadId?: string;
|
|
187
189
|
pack?: Blob;
|
|
188
190
|
channel?: string;
|
|
189
191
|
},
|
|
@@ -192,8 +194,11 @@ export class CloudApi {
|
|
|
192
194
|
for (const key of ["appName", "target", "distribution"] as const) formData.append(key, binary[key]);
|
|
193
195
|
if (binary.channel) formData.append("channel", binary.channel);
|
|
194
196
|
formData.append("bundle", binary.bundle, "bundle.json");
|
|
197
|
+
if (binary.packUploadId) formData.append("packUploadId", binary.packUploadId);
|
|
195
198
|
if (binary.pack) formData.append("pack", binary.pack, "pack.tgz");
|
|
196
|
-
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData
|
|
199
|
+
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData, {
|
|
200
|
+
signal: AbortSignal.timeout(11 * 60_000),
|
|
201
|
+
});
|
|
197
202
|
}
|
|
198
203
|
async downloadEnv(devProjectId: string): Promise<unknown> {
|
|
199
204
|
const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
|
|
@@ -18,8 +18,9 @@ class FakeAkasys {
|
|
|
18
18
|
readonly requests: Record<string, unknown>[] = [];
|
|
19
19
|
readonly puts: Put[] = [];
|
|
20
20
|
readonly commits: string[] = [];
|
|
21
|
+
readonly registrations: Record<string, string>[] = [];
|
|
21
22
|
putStatuses: number[] = [];
|
|
22
|
-
commitAnswers: number[] = [];
|
|
23
|
+
commitAnswers: (number | [number, string])[] = [];
|
|
23
24
|
release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
|
|
24
25
|
requestRefusal: Response | null = null;
|
|
25
26
|
|
|
@@ -35,6 +36,7 @@ class FakeAkasys {
|
|
|
35
36
|
this.requests.length = 0;
|
|
36
37
|
this.puts.length = 0;
|
|
37
38
|
this.commits.length = 0;
|
|
39
|
+
this.registrations.length = 0;
|
|
38
40
|
this.putStatuses = [];
|
|
39
41
|
this.commitAnswers = [];
|
|
40
42
|
this.requestRefusal = null;
|
|
@@ -64,12 +66,28 @@ class FakeAkasys {
|
|
|
64
66
|
if (url.pathname === "/api/commitUpdateUpload/project-1") {
|
|
65
67
|
const { uploadId } = (await request.json()) as { uploadId: string };
|
|
66
68
|
this.commits.push(uploadId);
|
|
67
|
-
const answer = this
|
|
68
|
-
if (answer) return
|
|
69
|
+
const answer = this.#answer();
|
|
70
|
+
if (answer) return answer;
|
|
69
71
|
return Response.json({ ...this.release, uploadId });
|
|
70
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
|
+
}
|
|
71
82
|
return new Response("not found", { status: 404 });
|
|
72
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
|
+
}
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
const makeDir = tempDirs("akan-update-upload-");
|
|
@@ -187,6 +205,43 @@ describe("UpdatePackUpload", () => {
|
|
|
187
205
|
expect(akasys.puts).toEqual([]);
|
|
188
206
|
});
|
|
189
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
|
+
|
|
190
245
|
test("refuses an empty archive before asking for a ticket", async () => {
|
|
191
246
|
const archive = await archiveOf("");
|
|
192
247
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
|
|
@@ -10,7 +10,8 @@ interface UpdatePackUploadOptions {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
//* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
|
|
13
|
-
//* only sees
|
|
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.
|
|
14
15
|
export class UpdatePackUpload {
|
|
15
16
|
static readonly maxBytes = 200 * 1024 * 1024;
|
|
16
17
|
static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
|
|
@@ -39,7 +40,14 @@ export class UpdatePackUpload {
|
|
|
39
40
|
this.#commitLimitMs = commitLimitMs;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Stages the pack and commits it as a release of `fields.channel`. */
|
|
42
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> {
|
|
43
51
|
const file = Bun.file(archive);
|
|
44
52
|
if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
|
|
45
53
|
throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
|
|
@@ -50,7 +58,7 @@ export class UpdatePackUpload {
|
|
|
50
58
|
for (let ticket = 1; ; ticket++) {
|
|
51
59
|
const { uploadId, uploadUrl, contentType } = await this.#request(upload);
|
|
52
60
|
const status = await this.#put(uploadUrl, contentType, file);
|
|
53
|
-
if (status >= 200 && status < 300) return
|
|
61
|
+
if (status >= 200 && status < 300) return uploadId;
|
|
54
62
|
if (status !== 403 || ticket === 2)
|
|
55
63
|
throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
|
|
56
64
|
}
|
|
@@ -83,18 +91,21 @@ export class UpdatePackUpload {
|
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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> {
|
|
90
101
|
const limit = performance.now() + this.#commitLimitMs;
|
|
91
102
|
let cutOffSince: number | null = null;
|
|
92
103
|
for (;;) {
|
|
93
104
|
try {
|
|
94
|
-
return await
|
|
105
|
+
return await call();
|
|
95
106
|
} catch (error) {
|
|
96
107
|
const now = performance.now();
|
|
97
|
-
if (
|
|
108
|
+
if (UpdatePackUpload.#inProgress(error)) cutOffSince = null;
|
|
98
109
|
else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
|
|
99
110
|
else throw error;
|
|
100
111
|
const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
|
|
@@ -107,6 +118,12 @@ export class UpdatePackUpload {
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
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
|
+
|
|
110
127
|
static #cutOff(error: unknown) {
|
|
111
128
|
const status = CloudApi.statusOf(error);
|
|
112
129
|
if (status === null) return !(error instanceof SyntaxError);
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -232,3 +232,36 @@ describe("NativeApp", () => {
|
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("a pack read back from its folder", () => {
|
|
237
|
+
test("takes the folder as the pack's place, and refuses what is not a takeable pack", async () => {
|
|
238
|
+
const root = await makeTempRoot();
|
|
239
|
+
const pack = {
|
|
240
|
+
schema: 1,
|
|
241
|
+
app: "lu",
|
|
242
|
+
target: "default",
|
|
243
|
+
platform: "android",
|
|
244
|
+
env: "main",
|
|
245
|
+
channel: "main",
|
|
246
|
+
appId: "com.lu",
|
|
247
|
+
appVersion: "1.0.0",
|
|
248
|
+
nativeApi: "0123456789abcdef",
|
|
249
|
+
webHash: "f".repeat(64),
|
|
250
|
+
files: 1,
|
|
251
|
+
size: 13,
|
|
252
|
+
out: "/elsewhere/updates/android",
|
|
253
|
+
};
|
|
254
|
+
const moved = path.join(root, "moved");
|
|
255
|
+
await writeText(path.join(moved, "pack.json"), JSON.stringify(pack));
|
|
256
|
+
expect(await NativeApp.readPack(moved)).toEqual({ ...pack, out: moved } as never);
|
|
257
|
+
|
|
258
|
+
await expect(NativeApp.readPack(path.join(root, "empty"))).rejects.toThrow("holds no pack.json");
|
|
259
|
+
const later = path.join(root, "later");
|
|
260
|
+
await writeText(path.join(later, "pack.json"), JSON.stringify({ ...pack, schema: 2 }));
|
|
261
|
+
await expect(NativeApp.readPack(later)).rejects.toThrow("schema 2");
|
|
262
|
+
const refused = path.join(root, "refused");
|
|
263
|
+
const compat = { against: "store/bundle.json", compatible: false, problems: ["deepLinks differ"] };
|
|
264
|
+
await writeText(path.join(refused, "pack.json"), JSON.stringify({ ...pack, compat }));
|
|
265
|
+
expect((await NativeApp.readPack(refused)).compat).toEqual(compat);
|
|
266
|
+
});
|
|
267
|
+
});
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -467,6 +467,16 @@ export class NativeApp {
|
|
|
467
467
|
await Bun.write(path.join(pack.out, "pack.json"), `${JSON.stringify(pack, null, 2)}\n`);
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
/** A pack `packUpdate` wrote, read back from its folder wherever it was moved. */
|
|
471
|
+
static async readPack(dir: string): Promise<NativeUpdatePack> {
|
|
472
|
+
const file = Bun.file(path.join(dir, "pack.json"));
|
|
473
|
+
if (!(await file.exists()))
|
|
474
|
+
throw new Error(`${dir} holds no pack.json: give the folder \`akan pack-update\` wrote.`);
|
|
475
|
+
const pack = (await file.json()) as NativeUpdatePack;
|
|
476
|
+
if (pack.schema !== 1) throw new Error(`${dir}/pack.json is schema ${pack.schema}, and this akan reads schema 1.`);
|
|
477
|
+
return { ...pack, out: path.resolve(dir) };
|
|
478
|
+
}
|
|
479
|
+
|
|
470
480
|
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
471
481
|
return (await NativeApi.load(appDir)).compareBundles(shipped, bundle);
|
|
472
482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "0.86.1",
|
|
49
49
|
"@inquirer/prompts": "^8.4.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.0",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.1",
|
|
52
52
|
"chalk": "^5.6.2",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"dayjs": "^1.11.20",
|