ugly-app 0.1.1069 → 0.1.1071

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.
Files changed (50) hide show
  1. package/dist/cli/publish/assetUpload.d.ts +26 -0
  2. package/dist/cli/publish/assetUpload.d.ts.map +1 -0
  3. package/dist/cli/publish/assetUpload.js +82 -0
  4. package/dist/cli/publish/assetUpload.js.map +1 -0
  5. package/dist/cli/publish/orchestrator.d.ts.map +1 -1
  6. package/dist/cli/publish/orchestrator.js +20 -25
  7. package/dist/cli/publish/orchestrator.js.map +1 -1
  8. package/dist/cli/publish/providers/cloudflare.d.ts +8 -8
  9. package/dist/cli/publish/providers/cloudflare.d.ts.map +1 -1
  10. package/dist/cli/publish/providers/cloudflare.js +90 -75
  11. package/dist/cli/publish/providers/cloudflare.js.map +1 -1
  12. package/dist/cli/publish/staticOps.d.ts +2 -0
  13. package/dist/cli/publish/staticOps.d.ts.map +1 -1
  14. package/dist/cli/publish/staticOps.js +16 -5
  15. package/dist/cli/publish/staticOps.js.map +1 -1
  16. package/dist/cli/publish/staticSync.d.ts +2 -0
  17. package/dist/cli/publish/staticSync.d.ts.map +1 -1
  18. package/dist/cli/publish/staticSync.js.map +1 -1
  19. package/dist/cli/publish/uploadAuth.d.ts +9 -0
  20. package/dist/cli/publish/uploadAuth.d.ts.map +1 -0
  21. package/dist/cli/publish/uploadAuth.js +60 -0
  22. package/dist/cli/publish/uploadAuth.js.map +1 -0
  23. package/dist/cli/version.d.ts +1 -1
  24. package/dist/cli/version.js +1 -1
  25. package/dist/server/Auth.d.ts.map +1 -1
  26. package/dist/server/Auth.js +6 -0
  27. package/dist/server/Auth.js.map +1 -1
  28. package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
  29. package/dist/server/adapter/workers/createWorkersApp.js +11 -1
  30. package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
  31. package/dist/server/adapter/workers/edgeCache.d.ts +17 -0
  32. package/dist/server/adapter/workers/edgeCache.d.ts.map +1 -1
  33. package/dist/server/adapter/workers/edgeCache.js +38 -0
  34. package/dist/server/adapter/workers/edgeCache.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/publish/assetUpload.test.ts +258 -0
  37. package/src/cli/publish/assetUpload.ts +107 -0
  38. package/src/cli/publish/assetUploadS3.test.ts +55 -0
  39. package/src/cli/publish/orchestrator.ts +32 -27
  40. package/src/cli/publish/providers/cloudflare.ts +139 -108
  41. package/src/cli/publish/staticOps.test.ts +28 -1
  42. package/src/cli/publish/staticOps.ts +39 -32
  43. package/src/cli/publish/staticSync.ts +2 -0
  44. package/src/cli/publish/uploadAuth.test.ts +76 -0
  45. package/src/cli/publish/uploadAuth.ts +69 -0
  46. package/src/cli/version.ts +1 -1
  47. package/src/server/Auth.ts +6 -0
  48. package/src/server/adapter/workers/createWorkersApp.ts +14 -1
  49. package/src/server/adapter/workers/edgeCache.test.ts +64 -0
  50. package/src/server/adapter/workers/edgeCache.ts +38 -0
@@ -25,8 +25,14 @@
25
25
  */
26
26
 
27
27
  import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
28
- import { readdirSync, readFileSync, statSync } from 'node:fs';
28
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
29
+ import { uploadFiles, type UploadOptions } from '../assetUpload.js';
30
+ import {
31
+ fetchWithUploadAuth,
32
+ type UploadTokenProvider,
33
+ } from '../uploadAuth.js';
29
34
  import path from 'node:path';
35
+ import { finished } from 'node:stream/promises';
30
36
 
31
37
  import { PublishError, publishErrorFromResponse } from '../errors.js';
32
38
  import { projectPackageManager, pmExec } from '../packageManager.js';
@@ -46,7 +52,7 @@ const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
46
52
  * runs this BEFORE the script upload, so a partial asset upload is
47
53
  * recoverable (re-deploy and the stragglers get retried).
48
54
  */
49
- export interface UploadR2AssetsArgs {
55
+ export interface UploadR2AssetsArgs extends UploadOptions {
50
56
  endpoint: string;
51
57
  accessKeyId: string;
52
58
  secretAccessKey: string;
@@ -60,11 +66,8 @@ export interface UploadR2AssetsArgs {
60
66
  /**
61
67
  * Keys already present in the bucket that must NOT be re-uploaded.
62
68
  *
63
- * Only ever populated for content-addressed keys (the `static/` prefix),
64
- * where the key IS the content — so "already there" means "already correct".
65
- * `builds/{buildId}/…` keys are new every deploy and never appear here.
66
- * Without this a CDN-hosted bundle is re-PUT in full on every deploy even
67
- * though not a byte changed.
69
+ * Populated after comparing remote size and single-part MD5 against local
70
+ * bytes. Stable names and Vite assets can both be skipped safely.
68
71
  */
69
72
  skipKeys?: ReadonlySet<string>;
70
73
  }
@@ -390,6 +393,18 @@ function walkFiles(dir: string, base: string = dir): string[] {
390
393
  return out;
391
394
  }
392
395
 
396
+ function stagedUploadFiles(directory: string, skipKeys?: ReadonlySet<string>) {
397
+ const all = walkFiles(directory);
398
+ const pending = all
399
+ .filter((rel) => !skipKeys?.has(rel.split(path.sep).join('/')))
400
+ .map((rel) => ({
401
+ key: rel.split(path.sep).join('/'),
402
+ full: path.join(directory, rel),
403
+ bytes: statSync(path.join(directory, rel)).size,
404
+ }));
405
+ return { pending, skipped: all.length - pending.length };
406
+ }
407
+
393
408
  export async function uploadR2Assets(
394
409
  args: UploadR2AssetsArgs,
395
410
  ): Promise<{ uploaded: number; bytes: number }> {
@@ -428,40 +443,44 @@ export async function uploadR2Assets(
428
443
  forcePathStyle: true,
429
444
  });
430
445
 
431
- const files = walkFiles(r2UploadDir);
432
- let uploaded = 0;
433
- let bytes = 0;
434
- // Sequential keeps memory usage flat and is fast enough for the
435
- // typical SPA bundle (a few dozen files).
436
- for (const rel of files) {
437
- const key = rel.split(path.sep).join('/');
438
- if (skipKeys?.has(key)) continue;
439
- const full = path.join(r2UploadDir, rel);
440
- const body = readFileSync(full);
441
- try {
442
- await s3.send(
443
- new PutObjectCommand({
444
- Bucket: bucket,
445
- Key: key,
446
- Body: body,
447
- ContentType: guessContentType(rel),
448
- CacheControl: 'public, max-age=31536000, immutable',
449
- }),
450
- );
451
- } catch (err) {
452
- throw new PublishError(
453
- `failed to upload ${key}: ${(err as Error).message}`,
454
- {
455
- provider: 'cloudflare',
456
- step: 'uploadR2Assets',
457
- cause: err,
458
- },
459
- );
460
- }
461
- uploaded++;
462
- bytes += body.byteLength;
446
+ const files = stagedUploadFiles(r2UploadDir, skipKeys);
447
+ try {
448
+ return await uploadFiles(
449
+ files.pending,
450
+ files.skipped,
451
+ async (file) => {
452
+ const body = createReadStream(file.full);
453
+ const closed = finished(body).catch(() => {});
454
+ try {
455
+ await s3.send(
456
+ new PutObjectCommand({
457
+ Bucket: bucket,
458
+ Key: file.key,
459
+ Body: body,
460
+ ContentLength: file.bytes,
461
+ ContentType: guessContentType(file.key),
462
+ CacheControl: 'public, max-age=31536000, immutable',
463
+ }),
464
+ );
465
+ } catch (err) {
466
+ throw new PublishError(
467
+ `failed to upload ${file.key}: ${(err as Error).message}`,
468
+ {
469
+ provider: 'cloudflare',
470
+ step: 'uploadR2Assets',
471
+ cause: err,
472
+ },
473
+ );
474
+ } finally {
475
+ body.destroy();
476
+ await closed;
477
+ }
478
+ },
479
+ args,
480
+ );
481
+ } finally {
482
+ s3.destroy();
463
483
  }
464
- return { uploaded, bytes };
465
484
  }
466
485
 
467
486
  export interface CloudflareProvisionResult {
@@ -844,84 +863,96 @@ async function sleepBackoff(
844
863
  attempt: number,
845
864
  hintSec?: string | null,
846
865
  ): Promise<void> {
847
- const ms = Number.isFinite(Number(hintSec))
848
- ? Number(hintSec) * 1000
849
- : Math.min(2 ** attempt * 500, 8000);
866
+ const seconds = hintSec?.trim() ? Number(hintSec) : NaN;
867
+ const ms =
868
+ Number.isFinite(seconds) && seconds >= 0
869
+ ? Math.min(seconds * 1000, 60000)
870
+ : Math.min(2 ** attempt * 500, 8000);
850
871
  await new Promise((r) => setTimeout(r, ms));
851
872
  }
852
873
 
853
- export async function uploadR2AssetsViaApi(args: {
854
- token: string;
855
- accountId: string;
856
- bucket: string;
857
- r2UploadDir: string;
858
- /** See `UploadR2AssetsArgs.skipKeys` — content-addressed keys already present. */
859
- skipKeys?: ReadonlySet<string>;
860
- }): Promise<{ uploaded: number; bytes: number }> {
861
- const { token, accountId, bucket, r2UploadDir, skipKeys } = args;
874
+ export async function uploadR2AssetsViaApi(
875
+ args: UploadOptions & {
876
+ token: string;
877
+ getToken?: UploadTokenProvider;
878
+ accountId: string;
879
+ bucket: string;
880
+ r2UploadDir: string;
881
+ /** Keys verified to contain the same bytes. Force-upload passes an empty set. */
882
+ skipKeys?: ReadonlySet<string>;
883
+ },
884
+ ): Promise<{ uploaded: number; bytes: number }> {
885
+ const { accountId, bucket, r2UploadDir, skipKeys } = args;
862
886
  try {
863
887
  statSync(r2UploadDir);
864
888
  } catch {
865
889
  return { uploaded: 0, bytes: 0 };
866
890
  }
867
- const files = walkFiles(r2UploadDir);
868
- let uploaded = 0;
869
- let bytes = 0;
870
- for (const rel of files) {
871
- const key = rel.split(path.sep).join('/');
872
- if (skipKeys?.has(key)) continue;
873
- const full = path.join(r2UploadDir, rel);
874
- const body = readFileSync(full);
875
- // A first-time upload can be many hundreds of objects, and Cloudflare
876
- // rate-limits it — observed on ugly-meal, where fixing publicDir meant
877
- // shipping every icon/image/audio file for the first time and the run died
878
- // partway through on a 429. An upload that aborts midway leaves the build
879
- // prefix incomplete, so retry rather than fail: 429 and 5xx are the API
880
- // asking us to wait, and a dropped connection is not a real error either.
881
- let res: Response | undefined;
882
- for (let attempt = 0; ; attempt++) {
883
- const canRetry = attempt < R2_PUT_MAX_RETRIES;
884
- try {
885
- res = await globalThis.fetch(
886
- `${CLOUDFLARE_API_BASE}/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(
887
- key,
888
- )}`,
889
- {
890
- method: 'PUT',
891
- headers: {
892
- 'Authorization': `Bearer ${token}`,
893
- 'Content-Type': guessContentType(full),
894
- // Must match the S3 path above. Without it the stored object has
895
- // no cache-control, and an R2 CUSTOM DOMAIN then refuses to
896
- // edge-cache it — `cf-cache-status: DYNAMIC` on every request, so
897
- // a CDN-hosted bundle silently reverts to hitting R2 for every
898
- // byte. Only bites projects with no captured S3 keys, which is
899
- // why it went unnoticed until world3 (328 MB, all uncached).
900
- 'Cache-Control': 'public, max-age=31536000, immutable',
891
+ const files = stagedUploadFiles(r2UploadDir, skipKeys);
892
+ // Reject before starting any writes. Use S3 for larger individual objects.
893
+ for (const file of files.pending) {
894
+ if (file.bytes > 300 * 1024 * 1024)
895
+ throw new Error(
896
+ `${file.key} exceeds the 300 MiB REST upload limit; use S3 or split the asset`,
897
+ );
898
+ }
899
+ return uploadFiles(
900
+ files.pending,
901
+ files.skipped,
902
+ async (file) => {
903
+ for (let attempt = 0; ; attempt++) {
904
+ const canRetry = attempt < R2_PUT_MAX_RETRIES;
905
+ let res: Response;
906
+ let stream: ReturnType<typeof createReadStream> | undefined;
907
+ const closed: Promise<void>[] = [];
908
+ try {
909
+ res = await fetchWithUploadAuth(
910
+ `${CLOUDFLARE_API_BASE}/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(file.key)}`,
911
+ () => {
912
+ stream?.destroy();
913
+ stream = createReadStream(file.full);
914
+ closed.push(finished(stream).catch(() => {}));
915
+ return {
916
+ method: 'PUT',
917
+ headers: {
918
+ 'Content-Type': guessContentType(file.key),
919
+ 'Content-Length': String(file.bytes),
920
+ 'Cache-Control': 'public, max-age=31536000, immutable',
921
+ },
922
+ body: stream as unknown as BodyInit,
923
+ duplex: 'half',
924
+ signal: AbortSignal.timeout(5 * 60 * 1000),
925
+ };
901
926
  },
902
- body,
903
- },
904
- );
905
- } catch (err) {
906
- if (!canRetry) throw err;
907
- await sleepBackoff(attempt);
908
- continue;
909
- }
910
- if (res.ok) break;
911
- if ((res.status === 429 || res.status >= 500) && canRetry) {
912
- await sleepBackoff(attempt, res.headers.get('retry-after'));
913
- continue;
927
+ args,
928
+ );
929
+ } catch (error) {
930
+ if (!canRetry) throw error;
931
+ await sleepBackoff(attempt);
932
+ continue;
933
+ } finally {
934
+ stream?.destroy();
935
+ await Promise.all(closed);
936
+ }
937
+ if (res.ok) {
938
+ await res.body?.cancel();
939
+ return;
940
+ }
941
+ if ((res.status === 429 || res.status >= 500) && canRetry) {
942
+ const delay = res.headers.get('retry-after');
943
+ await res.body?.cancel();
944
+ await sleepBackoff(attempt, delay);
945
+ continue;
946
+ }
947
+ throw await publishErrorFromResponse(res, {
948
+ provider: 'cloudflare-r2',
949
+ step: 'uploadR2AssetsViaApi',
950
+ message: `failed to PUT r2 object "${file.key}"`,
951
+ });
914
952
  }
915
- throw await publishErrorFromResponse(res, {
916
- provider: 'cloudflare-r2',
917
- step: 'uploadR2AssetsViaApi',
918
- message: `failed to PUT r2 object "${key}"`,
919
- });
920
- }
921
- uploaded++;
922
- bytes += body.byteLength;
923
- }
924
- return { uploaded, bytes };
953
+ },
954
+ args,
955
+ );
925
956
  }
926
957
 
927
958
  /**
@@ -24,7 +24,12 @@ describe('createApiR2Ops', () => {
24
24
  JSON.stringify({
25
25
  success: true,
26
26
  result: [
27
- { key: 'static/a', last_modified: '2026-07-01T00:00:00Z' },
27
+ {
28
+ key: 'static/a',
29
+ last_modified: '2026-07-01T00:00:00Z',
30
+ etag: '"f00"',
31
+ size: 123,
32
+ },
28
33
  ],
29
34
  result_info: { cursor: 'CUR1', is_truncated: true },
30
35
  }),
@@ -48,10 +53,32 @@ describe('createApiR2Ops', () => {
48
53
  const ops = createApiR2Ops({ token: 't', accountId: 'acct', bucket: 'b' });
49
54
  const objs = await ops.list('static/');
50
55
  expect(objs.map((o) => o.key)).toEqual(['static/a', 'static/b']);
56
+ expect(objs[0]).toMatchObject({ etag: '"f00"', size: 123 });
51
57
  expect(fetchMock).toHaveBeenCalledTimes(2);
52
58
  expect(String(fetchMock.mock.calls[1]![0])).toContain('cursor=CUR1');
53
59
  });
54
60
 
61
+ it('recovers listing authentication before computing unchanged files', async () => {
62
+ const fetchMock = vi
63
+ .fn()
64
+ .mockResolvedValueOnce(new Response('', { status: 401 }))
65
+ .mockResolvedValueOnce(
66
+ new Response(JSON.stringify({ result: [] }), { status: 200 }),
67
+ );
68
+ vi.stubGlobal('fetch', fetchMock);
69
+ const getToken = vi.fn(async (stale?: string) => (stale ? 'new' : 'old'));
70
+ const ops = createApiR2Ops({
71
+ token: 'old',
72
+ getToken,
73
+ accountId: 'acct',
74
+ bucket: 'b',
75
+ });
76
+ expect(await ops.list('static/')).toEqual([]);
77
+ expect(
78
+ new Headers(fetchMock.mock.calls[1]![1].headers).get('Authorization'),
79
+ ).toBe('Bearer new');
80
+ });
81
+
55
82
  it('rejects a file over the REST single-PUT cap with an actionable message', async () => {
56
83
  vi.stubGlobal('fetch', vi.fn());
57
84
  const ops = createApiR2Ops({ token: 't', accountId: 'acct', bucket: 'b' });
@@ -23,6 +23,8 @@ import {
23
23
  import { createReadStream, readFileSync } from 'fs';
24
24
  import type { R2Ops, RemoteObject } from './staticSync.js';
25
25
 
26
+ import { fetchWithUploadAuth, type UploadTokenProvider } from './uploadAuth.js';
27
+
26
28
  const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
27
29
 
28
30
  /** Cloudflare's REST object endpoint rejects a single PUT beyond this. */
@@ -102,6 +104,8 @@ function createOpsFromClient(s3: S3Client, bucket: string): R2Ops {
102
104
  out.push({
103
105
  key: o.Key,
104
106
  lastModified: o.LastModified ?? new Date(0),
107
+ etag: o.ETag,
108
+ size: o.Size,
105
109
  });
106
110
  }
107
111
  }
@@ -188,12 +192,15 @@ export function createClientR2Ops(client: S3Client, bucket: string): R2Ops {
188
192
 
189
193
  export function createApiR2Ops(args: {
190
194
  token: string;
195
+ getToken?: UploadTokenProvider;
191
196
  accountId: string;
192
197
  bucket: string;
193
198
  }): R2Ops {
194
199
  const { token, accountId, bucket } = args;
195
200
  const base = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/r2/buckets/${bucket}`;
196
201
  const auth = { Authorization: `Bearer ${token}` };
202
+ const request = (url: string, init: RequestInit) =>
203
+ fetchWithUploadAuth(url, () => init, args);
197
204
 
198
205
  return {
199
206
  async list(prefix) {
@@ -203,17 +210,24 @@ export function createApiR2Ops(args: {
203
210
  const url =
204
211
  `${base}/objects?prefix=${encodeURIComponent(prefix)}&per_page=1000` +
205
212
  (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');
206
- const res = await globalThis.fetch(url, { headers: auth });
213
+ const res = await request(url, { headers: auth });
207
214
  if (!res.ok) {
208
215
  throw new Error(`r2 list ${prefix} failed: ${res.status}`);
209
216
  }
210
217
  const body = (await res.json()) as {
211
- result?: { key: string; last_modified?: string }[];
218
+ result?: {
219
+ key: string;
220
+ last_modified?: string;
221
+ etag?: string;
222
+ size?: number;
223
+ }[];
212
224
  result_info?: { cursor?: string; is_truncated?: boolean };
213
225
  };
214
226
  for (const o of body.result ?? []) {
215
227
  out.push({
216
228
  key: o.key,
229
+ etag: o.etag,
230
+ size: o.size,
217
231
  lastModified: o.last_modified
218
232
  ? new Date(o.last_modified)
219
233
  : new Date(0),
@@ -227,27 +241,23 @@ export function createApiR2Ops(args: {
227
241
  },
228
242
 
229
243
  async getText(key) {
230
- const res = await globalThis.fetch(
231
- `${base}/objects/${encodeURIComponent(key)}`,
232
- { headers: auth },
233
- );
244
+ const res = await request(`${base}/objects/${encodeURIComponent(key)}`, {
245
+ headers: auth,
246
+ });
234
247
  if (!res.ok) return null;
235
248
  return await res.text();
236
249
  },
237
250
 
238
251
  async put(key, body, contentType) {
239
- const res = await globalThis.fetch(
240
- `${base}/objects/${encodeURIComponent(key)}`,
241
- {
242
- method: 'PUT',
243
- headers: {
244
- ...auth,
245
- 'Content-Type': contentType,
246
- 'Cache-Control': 'no-cache',
247
- },
248
- body: asBody(body),
252
+ const res = await request(`${base}/objects/${encodeURIComponent(key)}`, {
253
+ method: 'PUT',
254
+ headers: {
255
+ ...auth,
256
+ 'Content-Type': contentType,
257
+ 'Cache-Control': 'no-cache',
249
258
  },
250
- );
259
+ body: asBody(body),
260
+ });
251
261
  if (!res.ok) {
252
262
  throw new Error(`r2 put ${key} failed: ${res.status}`);
253
263
  }
@@ -262,18 +272,15 @@ export function createApiR2Ops(args: {
262
272
  `project so publish uses the S3 path (5 GB limit), or split the file.`,
263
273
  );
264
274
  }
265
- const res = await globalThis.fetch(
266
- `${base}/objects/${encodeURIComponent(key)}`,
267
- {
268
- method: 'PUT',
269
- headers: {
270
- ...auth,
271
- 'Content-Type': contentType,
272
- 'Cache-Control': IMMUTABLE,
273
- },
274
- body: asBody(readFileSync(absPath)),
275
+ const res = await request(`${base}/objects/${encodeURIComponent(key)}`, {
276
+ method: 'PUT',
277
+ headers: {
278
+ ...auth,
279
+ 'Content-Type': contentType,
280
+ 'Cache-Control': IMMUTABLE,
275
281
  },
276
- );
282
+ body: asBody(readFileSync(absPath)),
283
+ });
277
284
  if (!res.ok) {
278
285
  throw new Error(`r2 put ${key} failed: ${res.status}`);
279
286
  }
@@ -303,10 +310,10 @@ export function createApiR2Ops(args: {
303
310
  const canRetry = attempt < DELETE_MAX_RETRIES;
304
311
  let res: Response;
305
312
  try {
306
- res = await globalThis.fetch(
307
- `${base}/objects/${encodeURIComponent(key)}`,
308
- { method: 'DELETE', headers: auth },
309
- );
313
+ res = await request(`${base}/objects/${encodeURIComponent(key)}`, {
314
+ method: 'DELETE',
315
+ headers: auth,
316
+ });
310
317
  } catch (err) {
311
318
  if (!canRetry) throw err;
312
319
  await new Promise((r) => setTimeout(r, backoff(attempt)));
@@ -26,6 +26,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
26
26
  export interface RemoteObject {
27
27
  key: string;
28
28
  lastModified: Date;
29
+ etag?: string | undefined;
30
+ size?: number | undefined;
29
31
  }
30
32
 
31
33
  /** The bucket operations the sync needs. Adapters live in the provider layer. */
@@ -0,0 +1,76 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('../auth/cloudflareOauth.js', () => ({
3
+ getFreshCloudflareCredentials: vi.fn(),
4
+ forceRefreshCloudflareCredentials: vi.fn(),
5
+ }));
6
+ import {
7
+ getFreshCloudflareCredentials,
8
+ forceRefreshCloudflareCredentials,
9
+ } from '../auth/cloudflareOauth.js';
10
+ import { createUploadTokenProvider } from './uploadAuth.js';
11
+ const credential = (token: string, accountId = 'account') => ({
12
+ accessToken: token,
13
+ accountId,
14
+ expiresAt: Date.now() + 3600000,
15
+ });
16
+ afterEach(() => vi.resetAllMocks());
17
+ describe('upload OAuth recovery', () => {
18
+ it('shares a refresh across concurrent 401s and persists once', async () => {
19
+ vi.mocked(getFreshCloudflareCredentials).mockResolvedValue(
20
+ credential('old'),
21
+ );
22
+ vi.mocked(forceRefreshCloudflareCredentials).mockImplementation(
23
+ async () => {
24
+ await new Promise((r) => setTimeout(r, 5));
25
+ return credential('new');
26
+ },
27
+ );
28
+ const persist = vi.fn();
29
+ const token = createUploadTokenProvider('old', 'account', persist);
30
+ expect(
31
+ await Promise.all([
32
+ token('old'),
33
+ token('old'),
34
+ token('old'),
35
+ token('old'),
36
+ ]),
37
+ ).toEqual(['new', 'new', 'new', 'new']);
38
+ expect(forceRefreshCloudflareCredentials).toHaveBeenCalledTimes(1);
39
+ expect(persist).toHaveBeenCalledExactlyOnceWith('new');
40
+ });
41
+ it('adopts a credential already refreshed by another deploy', async () => {
42
+ vi.mocked(getFreshCloudflareCredentials).mockResolvedValue(
43
+ credential('new'),
44
+ );
45
+ const persist = vi.fn();
46
+ const token = createUploadTokenProvider('old', 'account', persist);
47
+ expect(await token('old')).toBe('new');
48
+ expect(forceRefreshCloudflareCredentials).not.toHaveBeenCalled();
49
+ expect(persist).toHaveBeenCalledWith('new');
50
+ });
51
+ it('does not replace a project token with another account credential', async () => {
52
+ vi.mocked(getFreshCloudflareCredentials).mockResolvedValue(
53
+ credential('other', 'other-account'),
54
+ );
55
+ const persist = vi.fn();
56
+ const token = createUploadTokenProvider('project', 'account', persist);
57
+ expect(await token('project')).toBe('project');
58
+ expect(persist).not.toHaveBeenCalled();
59
+ expect(forceRefreshCloudflareCredentials).not.toHaveBeenCalled();
60
+ });
61
+ it('still refreshes a 401 that arrived during a proactive credential read', async () => {
62
+ vi.mocked(getFreshCloudflareCredentials).mockImplementation(async () => {
63
+ await new Promise((r) => setTimeout(r, 5));
64
+ return credential('old');
65
+ });
66
+ vi.mocked(forceRefreshCloudflareCredentials).mockResolvedValue(
67
+ credential('new'),
68
+ );
69
+ const token = createUploadTokenProvider('old', 'account', vi.fn());
70
+ const proactive = token();
71
+ const rejected = token('old');
72
+ expect(await proactive).toBe('old');
73
+ expect(await rejected).toBe('new');
74
+ expect(forceRefreshCloudflareCredentials).toHaveBeenCalledTimes(1);
75
+ });
76
+ });
@@ -0,0 +1,69 @@
1
+ import {
2
+ forceRefreshCloudflareCredentials,
3
+ getFreshCloudflareCredentials,
4
+ } from '../auth/cloudflareOauth.js';
5
+
6
+ export type UploadTokenProvider = (staleToken?: string) => Promise<string>;
7
+
8
+ /** Use the existing locked OAuth refresh path; never switch Cloudflare accounts. */
9
+ export function createUploadTokenProvider(
10
+ initialToken: string,
11
+ accountId: string,
12
+ persist: (token: string) => Promise<void>,
13
+ ): UploadTokenProvider {
14
+ let current = initialToken;
15
+ let pending: Promise<string> | undefined;
16
+ return async (staleToken) => {
17
+ // A proactive read may already be in flight when a PUT returns 401. Wait
18
+ // for it, then still refresh if it left us with the rejected credential.
19
+ if (pending) await pending;
20
+ if (staleToken && staleToken !== current) return current;
21
+ if (!pending) {
22
+ pending = (async () => {
23
+ let cf = await getFreshCloudflareCredentials({ awaitRefresh: true });
24
+ if (cf?.accountId !== accountId) return current;
25
+ if (staleToken && cf.accessToken === staleToken) {
26
+ cf = await forceRefreshCloudflareCredentials(staleToken, {
27
+ awaitRefresh: true,
28
+ });
29
+ }
30
+ if (cf?.accountId === accountId && cf.accessToken !== current) {
31
+ await persist(cf.accessToken);
32
+ current = cf.accessToken;
33
+ }
34
+ return current;
35
+ })();
36
+ }
37
+ const active = pending;
38
+ try {
39
+ return await active;
40
+ } finally {
41
+ if (pending === active) pending = undefined;
42
+ }
43
+ };
44
+ }
45
+
46
+ /** Body factory lets authentication retries reopen a consumed upload stream. */
47
+ export async function fetchWithUploadAuth(
48
+ url: string,
49
+ init: () => RequestInit,
50
+ auth: { token: string; getToken?: UploadTokenProvider },
51
+ ): Promise<Response> {
52
+ let token = auth.getToken ? await auth.getToken() : auth.token;
53
+ const send = () => {
54
+ const options = init();
55
+ const headers = new Headers(options.headers);
56
+ headers.set('Authorization', `Bearer ${token}`);
57
+ return globalThis.fetch(url, { ...options, headers });
58
+ };
59
+ let response = await send();
60
+ if (response.status === 401 && auth.getToken) {
61
+ const fresh = await auth.getToken(token);
62
+ if (fresh !== token) {
63
+ await response.body?.cancel();
64
+ token = fresh;
65
+ response = await send();
66
+ }
67
+ }
68
+ return response;
69
+ }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.1069";
2
+ export const CLI_VERSION = "0.1.1071";