@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 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.commitAnswers.shift();
68
- if (answer) return Response.json({ error: "release.error.uploadCommitInProgress" }, { status: answer });
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 two small JSON calls: request a presigned PUT, then commit the object, which akasys re-hashes.
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 await this.#commit(uploadId);
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
- // The server finishes a commit whoever stopped listening, and the same id answers the same release afterwards.
87
- // A 409 is a live claim the server heartbeats, so only the overall limit bounds it; a run of cut-offs with no 409
88
- // between them may be a server that died, whose claim lapses within minutes, so that run gets the shorter window.
89
- async #commit(uploadId: string) {
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 this.#api.commitUpdateUpload(this.#devProjectId, uploadId);
105
+ return await call();
95
106
  } catch (error) {
96
107
  const now = performance.now();
97
- if (CloudApi.statusOf(error) === 409) cutOffSince = null;
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);
@@ -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
+ });
@@ -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.0",
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.0",
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",