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.
- package/dist/cli/publish/assetUpload.d.ts +26 -0
- package/dist/cli/publish/assetUpload.d.ts.map +1 -0
- package/dist/cli/publish/assetUpload.js +82 -0
- package/dist/cli/publish/assetUpload.js.map +1 -0
- package/dist/cli/publish/orchestrator.d.ts.map +1 -1
- package/dist/cli/publish/orchestrator.js +20 -25
- package/dist/cli/publish/orchestrator.js.map +1 -1
- package/dist/cli/publish/providers/cloudflare.d.ts +8 -8
- package/dist/cli/publish/providers/cloudflare.d.ts.map +1 -1
- package/dist/cli/publish/providers/cloudflare.js +90 -75
- package/dist/cli/publish/providers/cloudflare.js.map +1 -1
- package/dist/cli/publish/staticOps.d.ts +2 -0
- package/dist/cli/publish/staticOps.d.ts.map +1 -1
- package/dist/cli/publish/staticOps.js +16 -5
- package/dist/cli/publish/staticOps.js.map +1 -1
- package/dist/cli/publish/staticSync.d.ts +2 -0
- package/dist/cli/publish/staticSync.d.ts.map +1 -1
- package/dist/cli/publish/staticSync.js.map +1 -1
- package/dist/cli/publish/uploadAuth.d.ts +9 -0
- package/dist/cli/publish/uploadAuth.d.ts.map +1 -0
- package/dist/cli/publish/uploadAuth.js +60 -0
- package/dist/cli/publish/uploadAuth.js.map +1 -0
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/server/Auth.d.ts.map +1 -1
- package/dist/server/Auth.js +6 -0
- package/dist/server/Auth.js.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.js +11 -1
- package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
- package/dist/server/adapter/workers/edgeCache.d.ts +17 -0
- package/dist/server/adapter/workers/edgeCache.d.ts.map +1 -1
- package/dist/server/adapter/workers/edgeCache.js +38 -0
- package/dist/server/adapter/workers/edgeCache.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/publish/assetUpload.test.ts +258 -0
- package/src/cli/publish/assetUpload.ts +107 -0
- package/src/cli/publish/assetUploadS3.test.ts +55 -0
- package/src/cli/publish/orchestrator.ts +32 -27
- package/src/cli/publish/providers/cloudflare.ts +139 -108
- package/src/cli/publish/staticOps.test.ts +28 -1
- package/src/cli/publish/staticOps.ts +39 -32
- package/src/cli/publish/staticSync.ts +2 -0
- package/src/cli/publish/uploadAuth.test.ts +76 -0
- package/src/cli/publish/uploadAuth.ts +69 -0
- package/src/cli/version.ts +1 -1
- package/src/server/Auth.ts +6 -0
- package/src/server/adapter/workers/createWorkersApp.ts +14 -1
- package/src/server/adapter/workers/edgeCache.test.ts +64 -0
- 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 {
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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 =
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
848
|
-
|
|
849
|
-
|
|
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(
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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 =
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
{
|
|
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
|
|
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?: {
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
{
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
2
|
+
export const CLI_VERSION = "0.1.1071";
|