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
@@ -0,0 +1,258 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import { createServer } from 'node:http';
5
+ import { once } from 'node:events';
6
+ import type { AddressInfo } from 'node:net';
7
+ import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { unchangedUploadKeys, uploadFiles } from './assetUpload.js';
10
+ import { uploadR2AssetsViaApi } from './providers/cloudflare.js';
11
+
12
+ const dirs: string[] = [];
13
+ function stage(files: Record<string, string>) {
14
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'publish-assets-'));
15
+ dirs.push(dir);
16
+ for (const [key, text] of Object.entries(files)) {
17
+ const file = path.join(dir, key);
18
+ mkdirSync(path.dirname(file), { recursive: true });
19
+ writeFileSync(file, text);
20
+ }
21
+ return dir;
22
+ }
23
+ const md5 = (text: string) => createHash('md5').update(text).digest('hex');
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals();
26
+ vi.useRealTimers();
27
+ for (const dir of dirs.splice(0))
28
+ rmSync(dir, { recursive: true, force: true });
29
+ });
30
+
31
+ describe('resumable asset uploads', () => {
32
+ it('skips matching Vite and stable names but never changed, missing or multipart objects', async () => {
33
+ const dir = stage({
34
+ 'static/game-C5-_7PKy.js': 'same',
35
+ 'static/char.ugm': 'same',
36
+ 'static/changed.fbx': 'NEW!',
37
+ 'static/multipart.bin': 'same',
38
+ 'static/no-size': 'same',
39
+ });
40
+ const keys = [
41
+ 'static/game-C5-_7PKy.js',
42
+ 'static/char.ugm',
43
+ 'static/changed.fbx',
44
+ 'static/multipart.bin',
45
+ 'static/no-size',
46
+ 'static/missing',
47
+ ];
48
+ const remote = keys.map((key) => ({
49
+ key,
50
+ lastModified: new Date(),
51
+ size: 4,
52
+ etag: '"' + md5('same') + '"',
53
+ }));
54
+ remote[3]!.etag = md5('same') + '-2';
55
+ const result = await unchangedUploadKeys(dir, [
56
+ ...remote.filter((o) => o.key !== 'static/no-size'),
57
+ { key: 'static/no-size', lastModified: new Date(), etag: md5('same') },
58
+ ]);
59
+ expect([...result]).toEqual(keys.slice(0, 2));
60
+ });
61
+
62
+ it('runs a bounded pool and drains active uploads before surfacing failure', async () => {
63
+ const files = Array.from({ length: 8 }, (_, i) => ({
64
+ key: String(i),
65
+ full: String(i),
66
+ bytes: 1,
67
+ }));
68
+ let active = 0,
69
+ peak = 0;
70
+ const started: string[] = [];
71
+ await expect(
72
+ uploadFiles(
73
+ files,
74
+ 0,
75
+ async (file) => {
76
+ active++;
77
+ peak = Math.max(peak, active);
78
+ started.push(file.key);
79
+ try {
80
+ if (file.key === '0') throw Error('failed');
81
+ await new Promise((r) => setTimeout(r, 15));
82
+ } finally {
83
+ active--;
84
+ }
85
+ },
86
+ { concurrency: 3, onProgress: () => {} },
87
+ ),
88
+ ).rejects.toThrow('failed');
89
+ expect(peak).toBeLessThanOrEqual(3);
90
+ expect(started).toHaveLength(3);
91
+ expect(active).toBe(0);
92
+ });
93
+
94
+ it('streams parallel PUTs, skips verified files, and exposes useful progress', async () => {
95
+ const dir = stage(
96
+ Object.fromEntries(
97
+ Array.from({ length: 9 }, (_, i) => [
98
+ 'static/file' + i + '.js',
99
+ 'body' + i,
100
+ ]),
101
+ ),
102
+ );
103
+ let active = 0,
104
+ peak = 0;
105
+ const received = new Map<string, string>();
106
+ const progress = vi.fn();
107
+ vi.stubGlobal(
108
+ 'fetch',
109
+ vi.fn(async (url: string, init: RequestInit) => {
110
+ active++;
111
+ peak = Math.max(peak, active);
112
+ expect(init.body).not.toBeInstanceOf(Buffer);
113
+ const parts: Buffer[] = [];
114
+ for await (const part of init.body as unknown as AsyncIterable<Buffer>)
115
+ parts.push(part);
116
+ await new Promise((r) => setTimeout(r, 5));
117
+ received.set(
118
+ decodeURIComponent(url.split('/objects/')[1]!),
119
+ Buffer.concat(parts).toString(),
120
+ );
121
+ active--;
122
+ return new Response('', { status: 200 });
123
+ }),
124
+ );
125
+ const result = await uploadR2AssetsViaApi({
126
+ token: 'token',
127
+ accountId: 'account',
128
+ bucket: 'bucket',
129
+ r2UploadDir: dir,
130
+ skipKeys: new Set(['static/file0.js']),
131
+ onProgress: progress,
132
+ });
133
+ expect(result).toEqual({ uploaded: 8, bytes: 40 });
134
+ expect(peak).toBe(4);
135
+ expect(received.size).toBe(8);
136
+ expect(received.get('static/file8.js')).toBe('body8');
137
+ expect(progress).toHaveBeenLastCalledWith({
138
+ total: 8,
139
+ uploaded: 8,
140
+ bytes: 40,
141
+ totalBytes: 40,
142
+ skipped: 1,
143
+ });
144
+ });
145
+
146
+ it('reopens the complete stream on 401 and uses the refreshed token', async () => {
147
+ const dir = stage({ 'static/game.js': 'full stream' });
148
+ const bodies: string[] = [];
149
+ const tokens: string[] = [];
150
+ const getToken = vi.fn(async (stale?: string) => (stale ? 'fresh' : 'old'));
151
+ vi.stubGlobal(
152
+ 'fetch',
153
+ vi.fn(async (_url: string, init: RequestInit) => {
154
+ tokens.push(new Headers(init.headers).get('Authorization')!);
155
+ const parts: Buffer[] = [];
156
+ for await (const part of init.body as unknown as AsyncIterable<Buffer>)
157
+ parts.push(part);
158
+ bodies.push(Buffer.concat(parts).toString());
159
+ return new Response('', { status: bodies.length === 1 ? 401 : 200 });
160
+ }),
161
+ );
162
+ await uploadR2AssetsViaApi({
163
+ token: 'old',
164
+ getToken,
165
+ accountId: 'a',
166
+ bucket: 'b',
167
+ r2UploadDir: dir,
168
+ onProgress: () => {},
169
+ });
170
+ expect(tokens).toEqual(['Bearer old', 'Bearer fresh']);
171
+ expect(bodies).toEqual(['full stream', 'full stream']);
172
+ expect(getToken).toHaveBeenCalledWith('old');
173
+ });
174
+
175
+ it('sends complete bodies through real HTTP streaming and retries a rejected request', async () => {
176
+ const text = 'voxel'.repeat(30000);
177
+ const dir = stage({ 'static/kit.bin': text });
178
+ const received: string[] = [];
179
+ const server = createServer(async (req, res) => {
180
+ const parts: Buffer[] = [];
181
+ for await (const part of req) parts.push(part as Buffer);
182
+ received.push(Buffer.concat(parts).toString());
183
+ res.writeHead(received.length === 1 ? 401 : 200);
184
+ res.end();
185
+ });
186
+ server.listen(0, '127.0.0.1');
187
+ await once(server, 'listening');
188
+ const nativeFetch = globalThis.fetch;
189
+ vi.stubGlobal('fetch', (_url: string, init: RequestInit) =>
190
+ nativeFetch(
191
+ `http://127.0.0.1:${(server.address() as AddressInfo).port}/upload`,
192
+ init,
193
+ ),
194
+ );
195
+ try {
196
+ await uploadR2AssetsViaApi({
197
+ token: 'old',
198
+ getToken: async (stale) => (stale ? 'new' : 'old'),
199
+ accountId: 'a',
200
+ bucket: 'b',
201
+ r2UploadDir: dir,
202
+ onProgress: () => {},
203
+ });
204
+ expect(received).toEqual([text, text]);
205
+ } finally {
206
+ server.closeAllConnections();
207
+ await new Promise<void>((resolve, reject) =>
208
+ server.close((error) => (error ? reject(error) : resolve())),
209
+ );
210
+ }
211
+ });
212
+
213
+ it('does not loop on a revoked credential or permanent permission failure', async () => {
214
+ const dir = stage({ 'static/a.js': 'a' });
215
+ const fetch = vi.fn(
216
+ async () =>
217
+ new Response(JSON.stringify({ errors: [{ message: 'denied' }] }), {
218
+ status: 401,
219
+ }),
220
+ );
221
+ vi.stubGlobal('fetch', fetch);
222
+ await expect(
223
+ uploadR2AssetsViaApi({
224
+ token: 'revoked',
225
+ getToken: async () => 'revoked',
226
+ accountId: 'a',
227
+ bucket: 'b',
228
+ r2UploadDir: dir,
229
+ onProgress: () => {},
230
+ }),
231
+ ).rejects.toThrow(/failed to PUT/);
232
+ expect(fetch).toHaveBeenCalledTimes(1);
233
+ });
234
+
235
+ it('backs off on 503 without Retry-After instead of retrying immediately', async () => {
236
+ const dir = stage({ 'static/a.js': 'a' });
237
+ const attempts: number[] = [];
238
+ vi.stubGlobal(
239
+ 'fetch',
240
+ vi.fn(async (_url: string, init: RequestInit) => {
241
+ for await (const _chunk of init.body as unknown as AsyncIterable<Buffer>) {
242
+ /* consume stream */
243
+ }
244
+ attempts.push(Date.now());
245
+ return new Response('', { status: attempts.length === 1 ? 503 : 200 });
246
+ }),
247
+ );
248
+ await uploadR2AssetsViaApi({
249
+ token: 't',
250
+ accountId: 'a',
251
+ bucket: 'b',
252
+ r2UploadDir: dir,
253
+ onProgress: () => {},
254
+ });
255
+ expect(attempts).toHaveLength(2);
256
+ expect(attempts[1]! - attempts[0]!).toBeGreaterThanOrEqual(450);
257
+ });
258
+ });
@@ -0,0 +1,107 @@
1
+ import { createReadStream, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import type { RemoteObject } from './staticSync.js';
5
+
6
+ /** Compare bytes, not filename conventions. Multipart ETags are not MD5s. */
7
+ export async function unchangedUploadKeys(
8
+ directory: string,
9
+ remote: readonly RemoteObject[],
10
+ ): Promise<Set<string>> {
11
+ const unchanged = new Set<string>();
12
+ for (const object of remote) {
13
+ const etag = object.etag?.replace(/^"|"$/g, '').toLowerCase();
14
+ if (!etag || !/^[a-f0-9]{32}$/.test(etag) || object.size === undefined)
15
+ continue;
16
+ const file = path.resolve(directory, object.key);
17
+ const relative = path.relative(path.resolve(directory), file);
18
+ if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
19
+ let stat;
20
+ try {
21
+ stat = await fs.stat(file);
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
24
+ throw error;
25
+ }
26
+ if (!stat.isFile() || stat.size !== object.size) continue;
27
+ const hash = createHash('md5');
28
+ for await (const chunk of createReadStream(file))
29
+ hash.update(chunk as Buffer);
30
+ if (hash.digest('hex') === etag) unchanged.add(object.key);
31
+ }
32
+ return unchanged;
33
+ }
34
+
35
+ export interface UploadFile {
36
+ key: string;
37
+ full: string;
38
+ bytes: number;
39
+ }
40
+ export interface UploadProgress {
41
+ total: number;
42
+ uploaded: number;
43
+ bytes: number;
44
+ totalBytes: number;
45
+ skipped: number;
46
+ }
47
+ export interface UploadOptions {
48
+ /** Four streams by default; capped to avoid API/memory pressure. */
49
+ concurrency?: number;
50
+ onProgress?: (progress: UploadProgress) => void;
51
+ }
52
+
53
+ /** Stop admitting new files on failure, and settle every active upload before returning. */
54
+ export async function uploadFiles(
55
+ files: readonly UploadFile[],
56
+ skipped: number,
57
+ upload: (file: UploadFile) => Promise<void>,
58
+ options: UploadOptions = {},
59
+ ): Promise<{ uploaded: number; bytes: number }> {
60
+ const concurrency = Math.max(
61
+ 1,
62
+ Math.min(8, Math.floor(options.concurrency ?? 4) || 4),
63
+ );
64
+ const progress: UploadProgress = {
65
+ total: files.length,
66
+ uploaded: 0,
67
+ bytes: 0,
68
+ totalBytes: files.reduce((sum, f) => sum + f.bytes, 0),
69
+ skipped,
70
+ };
71
+ const report = () => {
72
+ if (options.onProgress) options.onProgress({ ...progress });
73
+ else
74
+ console.log(
75
+ `[publish-assets] ${progress.uploaded}/${progress.total} files, ` +
76
+ `${(progress.bytes / 1048576).toFixed(1)}/${(progress.totalBytes / 1048576).toFixed(1)} MiB uploaded; ${skipped} unchanged`,
77
+ );
78
+ };
79
+ report();
80
+ const timer = setInterval(report, 10000);
81
+ timer.unref();
82
+ let next = 0;
83
+ let failed = false;
84
+ let failure: unknown;
85
+ try {
86
+ await Promise.all(
87
+ Array.from({ length: Math.min(concurrency, files.length) }, async () => {
88
+ while (!failed && next < files.length) {
89
+ const file = files[next++]!;
90
+ try {
91
+ await upload(file);
92
+ progress.uploaded++;
93
+ progress.bytes += file.bytes;
94
+ } catch (error) {
95
+ if (!failed) failure = error;
96
+ failed = true;
97
+ }
98
+ }
99
+ }),
100
+ );
101
+ if (failed) throw failure;
102
+ report();
103
+ return { uploaded: progress.uploaded, bytes: progress.bytes };
104
+ } finally {
105
+ clearInterval(timer);
106
+ }
107
+ }
@@ -0,0 +1,55 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ const { send, destroy } = vi.hoisted(() => ({
6
+ send: vi.fn(),
7
+ destroy: vi.fn(),
8
+ }));
9
+ vi.mock('@aws-sdk/client-s3', async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import('@aws-sdk/client-s3')>()),
11
+ S3Client: class {
12
+ send = send;
13
+ destroy = destroy;
14
+ },
15
+ }));
16
+ import { uploadR2Assets } from './providers/cloudflare.js';
17
+ afterEach(() => vi.resetAllMocks());
18
+ it('uses bounded streams and closes the S3 client after all uploads settle', async () => {
19
+ const dir = mkdtempSync(path.join(os.tmpdir(), 's3-upload-'));
20
+ for (let i = 0; i < 6; i++)
21
+ writeFileSync(path.join(dir, `${i}.bin`), `kit${i}`);
22
+ let active = 0,
23
+ peak = 0;
24
+ const bodies: string[] = [];
25
+ send.mockImplementation(async (command) => {
26
+ active++;
27
+ peak = Math.max(peak, active);
28
+ const parts: Buffer[] = [];
29
+ for await (const part of command.input.Body) parts.push(part);
30
+ const body = Buffer.concat(parts);
31
+ expect(command.input.ContentLength).toBe(body.length);
32
+ bodies.push(body.toString());
33
+ await new Promise((r) => setTimeout(r, 5));
34
+ active--;
35
+ });
36
+ try {
37
+ expect(
38
+ await uploadR2Assets({
39
+ endpoint: 'https://r2.example',
40
+ accessKeyId: 'a',
41
+ secretAccessKey: 's',
42
+ bucket: 'b',
43
+ r2UploadDir: dir,
44
+ skipKeys: new Set(['0.bin']),
45
+ onProgress: () => {},
46
+ }),
47
+ ).toEqual({ uploaded: 5, bytes: 20 });
48
+ expect(peak).toBe(4);
49
+ expect(active).toBe(0);
50
+ expect(bodies.sort()).toEqual(['kit1', 'kit2', 'kit3', 'kit4', 'kit5']);
51
+ expect(destroy).toHaveBeenCalledOnce();
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
@@ -46,6 +46,8 @@ import { projectPackageManager } from './packageManager.js';
46
46
  import type { RecipeDoc, RecipeStep } from './types.js';
47
47
  import { promises as dnsPromises } from 'node:dns';
48
48
  import { getFreshCloudflareCredentials } from '../auth/cloudflareOauth.js';
49
+ import { unchangedUploadKeys } from './assetUpload.js';
50
+ import { createUploadTokenProvider } from './uploadAuth.js';
49
51
  import { getNeonCredentials } from '../auth/credentials.js';
50
52
  import {
51
53
  importTs,
@@ -56,7 +58,6 @@ import {
56
58
  } from '../uglyappConfig.js';
57
59
  import {
58
60
  DEFAULT_STATIC_DIR,
59
- isContentAddressedKey,
60
61
  runBuildStatic,
61
62
  scanStaticDir,
62
63
  STATIC_PREFIX,
@@ -2437,18 +2438,26 @@ export async function runPublish(
2437
2438
  'workers-deploy: missing upstream provisioning state',
2438
2439
  );
2439
2440
  }
2440
- const cloudflareToken = await getSecret(
2441
+ let cloudflareToken = await getSecret(
2441
2442
  `cloudflare:token:${input.projectId}`,
2442
2443
  );
2443
2444
  if (!cloudflareToken) {
2444
2445
  throw new Error('workers-deploy: missing cloudflare:token secret');
2445
2446
  }
2446
- // Content-addressed keys already in the bucket. A CDN-hosted bundle
2447
- // keeps the SAME keys across deploys (that's the point), so without
2448
- // this the r2-upload pass below re-PUTs the whole bundle every time
2449
- // even though nothing changed. `builds/{buildId}/…` keys are new
2450
- // each deploy and are never in this set.
2451
- const alreadyPresentStatic = new Set<string>();
2447
+ const getUploadToken = createUploadTokenProvider(
2448
+ cloudflareToken,
2449
+ state.cloudflare.accountId,
2450
+ async (token) => {
2451
+ await setSecret(`cloudflare:token:${input.projectId}`, token);
2452
+ cloudflareToken = token;
2453
+ },
2454
+ );
2455
+ cloudflareToken = await getUploadToken();
2456
+ const r2UploadDir = path.join(
2457
+ input.projectPath,
2458
+ 'dist/worker/r2-upload',
2459
+ );
2460
+ let alreadyPresentStatic = new Set<string>();
2452
2461
  // Escape hatch: re-PUT every static object even though the key is
2453
2462
  // already there. Needed when the OBJECT METADATA has to change —
2454
2463
  // content-addressed keys mean the bytes are right, so presence alone
@@ -2475,24 +2484,20 @@ export async function runPublish(
2475
2484
  })
2476
2485
  : createApiR2Ops({
2477
2486
  token: cloudflareToken,
2487
+ getToken: getUploadToken,
2478
2488
  accountId: state.cloudflare.accountId,
2479
2489
  bucket: state.cloudflare.bucket,
2480
2490
  });
2481
- for (const o of await probe.list(STATIC_PREFIX)) {
2482
- // ⚠️ PRESENCE SKIPS CONTENT-ADDRESSED KEYS ONLY. Their md5
2483
- // fragment pins the bytes (`staticKeyFor`), so an existing key
2484
- // is provably the same object. A STABLE key — the character
2485
- // kit's `anims/*.fbx`, UGMs, anything `staticKeyFor` never
2486
- // named — keeps one name across byte changes, and skipping it
2487
- // on presence meant a replaced FBX never reached the CDN
2488
- // (found when a new T-Pose deployed "successfully" and the CDN
2489
- // kept serving the old one). Stable keys re-PUT every deploy;
2490
- // that is the price of a key that does not name its bytes.
2491
- if (isContentAddressedKey(o.key)) alreadyPresentStatic.add(o.key);
2492
- }
2493
- } catch {
2494
- // Listing is an optimisation. If it fails, upload everything —
2495
- // slower, still correct.
2491
+ alreadyPresentStatic = await unchangedUploadKeys(
2492
+ r2UploadDir,
2493
+ await probe.list(STATIC_PREFIX),
2494
+ );
2495
+ } catch (error) {
2496
+ // Listing/hashing is an optimisation; fail safely by uploading.
2497
+ if (!forceStatic)
2498
+ console.warn(
2499
+ `[publish-assets] Cannot compare remote assets; uploading all: ${(error as Error).message}`,
2500
+ );
2496
2501
  }
2497
2502
 
2498
2503
  const scriptBody = await fs.readFile(
@@ -2505,10 +2510,6 @@ export async function runPublish(
2505
2510
  // /{buildId}/* directly from R2. The build:workers step
2506
2511
  // staged everything under `dist/worker/r2-upload/` with
2507
2512
  // paths that mirror their R2 keys exactly.
2508
- const r2UploadDir = path.join(
2509
- input.projectPath,
2510
- 'dist/worker/r2-upload',
2511
- );
2512
2513
  // Upload assets to R2. Preferred path: the Cloudflare REST object
2513
2514
  // API authenticated with the same Bearer token (no S3 keys, fully
2514
2515
  // non-interactive). If a project still has captured S3 keys (legacy
@@ -2561,6 +2562,7 @@ export async function runPublish(
2561
2562
  } else {
2562
2563
  r2Result = await cloudflare.uploadR2AssetsViaApi({
2563
2564
  token: cloudflareToken,
2565
+ getToken: getUploadToken,
2564
2566
  accountId: state.cloudflare.accountId,
2565
2567
  bucket: state.cloudflare.bucket,
2566
2568
  r2UploadDir,
@@ -2575,6 +2577,8 @@ export async function runPublish(
2575
2577
  );
2576
2578
  }
2577
2579
 
2580
+ cloudflareToken = await getUploadToken();
2581
+
2578
2582
  // ── Content-addressed static assets ─────────────────────────
2579
2583
  // These do NOT go through the Worker. The bucket gets its own
2580
2584
  // custom domain so Cloudflare's CDN serves them directly: a Worker
@@ -2598,6 +2602,7 @@ export async function runPublish(
2598
2602
  })
2599
2603
  : createApiR2Ops({
2600
2604
  token: cloudflareToken,
2605
+ getToken: getUploadToken,
2601
2606
  accountId: state.cloudflare.accountId,
2602
2607
  bucket: state.cloudflare.bucket,
2603
2608
  });