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
|
@@ -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
|
-
|
|
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
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
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
|
});
|