@spawndotfamily/sdk 0.2.7
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/AGENTS.md +55 -0
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/cli/api.d.ts +19 -0
- package/dist/cli/api.js +187 -0
- package/dist/cli/files.d.ts +4 -0
- package/dist/cli/files.js +40 -0
- package/dist/cli/index.d.ts +61 -0
- package/dist/cli/index.js +503 -0
- package/dist/cli/listing.d.ts +14 -0
- package/dist/cli/listing.js +155 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +18 -0
- package/dist/cli/upload-client.d.ts +84 -0
- package/dist/cli/upload-client.js +737 -0
- package/dist/dev/economy.d.ts +29 -0
- package/dist/dev/economy.js +49 -0
- package/dist/dev/host.d.ts +1 -0
- package/dist/dev/host.js +225 -0
- package/dist/dev/panel.d.ts +6 -0
- package/dist/dev/panel.js +63 -0
- package/dist/dev/run.d.ts +2 -0
- package/dist/dev/run.js +19 -0
- package/dist/dev/server.d.ts +5 -0
- package/dist/dev/server.js +188 -0
- package/dist/dev/shell.d.ts +1 -0
- package/dist/dev/shell.js +18 -0
- package/dist/dev/state.d.ts +33 -0
- package/dist/dev/state.js +77 -0
- package/dist/dev/styles.d.ts +1 -0
- package/dist/dev/styles.js +25 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +403 -0
- package/dist/multiplayer.d.ts +17 -0
- package/dist/multiplayer.js +174 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +112 -0
- package/dist/startup.d.ts +21 -0
- package/dist/startup.js +85 -0
- package/docs/creator-checklist.md +62 -0
- package/docs/integration.md +69 -0
- package/docs/multiplayer.md +89 -0
- package/docs/publishing.md +115 -0
- package/docs/security.md +83 -0
- package/docs/startup.md +35 -0
- package/docs/testing.md +90 -0
- package/examples/creator-server.js +16 -0
- package/examples/github-browser-build.yml +28 -0
- package/examples/multiplayer-game.js +38 -0
- package/examples/preview-game.js +13 -0
- package/package.json +69 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { PublishCliError, isRecord, requestJson, validatePublishConfig, } from "./api.js";
|
|
2
|
+
// @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
// @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
// @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
|
|
7
|
+
import { lstat, open, opendir, realpath } from 'node:fs/promises';
|
|
8
|
+
// @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
|
|
9
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
/** The worker contract fixes every upload chunk at eight mebibytes. */
|
|
11
|
+
export const CHUNK_BYTES = 8_388_608;
|
|
12
|
+
/** Safety ceiling for client-side inspection; the platform's default admission is 1 GB. */
|
|
13
|
+
export const STREAM_MAX_TOTAL_BYTES = 8_000_000_000;
|
|
14
|
+
export const STREAM_MAX_FILE_BYTES = 8_000_000_000;
|
|
15
|
+
export const STREAM_MAX_HTML_BYTES = 1_000_000;
|
|
16
|
+
export const STREAM_MAX_FILES = 1_000;
|
|
17
|
+
export const STREAM_MAX_TRAVERSED_ENTRIES = 10_000;
|
|
18
|
+
export const STREAM_MAX_DIRECTORY_DEPTH = 64;
|
|
19
|
+
export const STREAM_MAX_RETRIES = 3;
|
|
20
|
+
const BROWSER_ASSET_EXTENSIONS = new Set([
|
|
21
|
+
'html',
|
|
22
|
+
'js',
|
|
23
|
+
'mjs',
|
|
24
|
+
'css',
|
|
25
|
+
'json',
|
|
26
|
+
'wasm',
|
|
27
|
+
'data',
|
|
28
|
+
'pck',
|
|
29
|
+
'unityweb',
|
|
30
|
+
'bundle',
|
|
31
|
+
'png',
|
|
32
|
+
'jpg',
|
|
33
|
+
'jpeg',
|
|
34
|
+
'webp',
|
|
35
|
+
'gif',
|
|
36
|
+
'svg',
|
|
37
|
+
'ico',
|
|
38
|
+
'avif',
|
|
39
|
+
'mp3',
|
|
40
|
+
'ogg',
|
|
41
|
+
'wav',
|
|
42
|
+
'mp4',
|
|
43
|
+
'webm',
|
|
44
|
+
'woff',
|
|
45
|
+
'woff2',
|
|
46
|
+
'ttf',
|
|
47
|
+
'otf',
|
|
48
|
+
'txt',
|
|
49
|
+
'atlas',
|
|
50
|
+
'bin',
|
|
51
|
+
'glb',
|
|
52
|
+
'gltf',
|
|
53
|
+
'ktx2',
|
|
54
|
+
]);
|
|
55
|
+
const SECRET_NAME = /(^|[._-])(secret|secrets|credential|credentials|private[_-]?key|api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd|token)([._-]|$)/i;
|
|
56
|
+
const SECRET_SUFFIX = /\.(pem|key|p12|pfx|jks|keystore|crt)$/i;
|
|
57
|
+
const PRIVATE_KEY_MARKER = /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/;
|
|
58
|
+
const SOURCE_SECRET_MARKER = /(?:["']?(?:publish[_-]?key|private[_-]?key|client[_-]?secret|api[_-]?key|access[_-]?token)["']?\s*[:=]|sp_pub_[A-Za-z0-9_-]{20,})/i;
|
|
59
|
+
function fail(message) {
|
|
60
|
+
throw new PublishCliError(message);
|
|
61
|
+
}
|
|
62
|
+
function formatLimit(value) {
|
|
63
|
+
return value.toLocaleString('en-US');
|
|
64
|
+
}
|
|
65
|
+
function hashFactory() {
|
|
66
|
+
return createHash;
|
|
67
|
+
}
|
|
68
|
+
function validateSourceCommit(value) {
|
|
69
|
+
if (!/^[0-9a-f]{40}$/i.test(value)) {
|
|
70
|
+
throw new PublishCliError('sourceCommit must contain exactly 40 hexadecimal characters.');
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function compareCodePoints(left, right) {
|
|
75
|
+
const a = Array.from(left, (character) => character.codePointAt(0) ?? 0);
|
|
76
|
+
const b = Array.from(right, (character) => character.codePointAt(0) ?? 0);
|
|
77
|
+
const length = Math.min(a.length, b.length);
|
|
78
|
+
for (let index = 0; index < length; index += 1) {
|
|
79
|
+
if (a[index] !== b[index])
|
|
80
|
+
return a[index] - b[index];
|
|
81
|
+
}
|
|
82
|
+
return a.length - b.length;
|
|
83
|
+
}
|
|
84
|
+
function snapshot(stat) {
|
|
85
|
+
return {
|
|
86
|
+
ino: stat.ino,
|
|
87
|
+
dev: stat.dev,
|
|
88
|
+
size: stat.size,
|
|
89
|
+
...(typeof stat.mtimeMs === 'number' ? { mtimeMs: stat.mtimeMs } : {}),
|
|
90
|
+
...(typeof stat.ctimeMs === 'number' ? { ctimeMs: stat.ctimeMs } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function sameSnapshot(left, right) {
|
|
94
|
+
return left.ino === right.ino &&
|
|
95
|
+
left.dev === right.dev &&
|
|
96
|
+
left.size === right.size &&
|
|
97
|
+
(left.mtimeMs === undefined || right.mtimeMs === undefined || left.mtimeMs === right.mtimeMs) &&
|
|
98
|
+
(left.ctimeMs === undefined || right.ctimeMs === undefined || left.ctimeMs === right.ctimeMs);
|
|
99
|
+
}
|
|
100
|
+
function validateSegment(segment) {
|
|
101
|
+
if (!segment || segment === '.' || segment === '..' || segment.startsWith('.')) {
|
|
102
|
+
throw new PublishCliError('Hidden files and dot segments are not allowed in browser builds.');
|
|
103
|
+
}
|
|
104
|
+
if (['node_modules', '__MACOSX'].includes(segment.toLowerCase())) {
|
|
105
|
+
throw new PublishCliError('node_modules and __MACOSX are not allowed in browser builds.');
|
|
106
|
+
}
|
|
107
|
+
if (SECRET_NAME.test(segment) || SECRET_SUFFIX.test(segment)) {
|
|
108
|
+
throw new PublishCliError('Source secret files are not allowed in browser builds.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function extensionOf(filePath) {
|
|
112
|
+
const name = basename(filePath);
|
|
113
|
+
const dot = name.lastIndexOf('.');
|
|
114
|
+
return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : '';
|
|
115
|
+
}
|
|
116
|
+
function validateBrowserAsset(filePath) {
|
|
117
|
+
if (extensionOf(filePath) === 'map') {
|
|
118
|
+
throw new PublishCliError('Source maps (.map) are not accepted in browser builds.');
|
|
119
|
+
}
|
|
120
|
+
if (!BROWSER_ASSET_EXTENSIONS.has(extensionOf(filePath))) {
|
|
121
|
+
throw new PublishCliError(`Unsupported browser asset extension for ${extensionOf(filePath) || 'file'}.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function assertRelativePayloadPath(filePath) {
|
|
125
|
+
if (filePath.length < 1 ||
|
|
126
|
+
filePath.length > 240 ||
|
|
127
|
+
filePath.startsWith('/') ||
|
|
128
|
+
filePath.includes('\\') ||
|
|
129
|
+
!/^[A-Za-z0-9_][A-Za-z0-9_./ -]*$/.test(filePath)) {
|
|
130
|
+
throw new PublishCliError('Browser bundle paths must be safe relative POSIX paths.');
|
|
131
|
+
}
|
|
132
|
+
const segments = filePath.split('/');
|
|
133
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
134
|
+
throw new PublishCliError('Browser bundle paths cannot contain dot segments.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function inspectTextChunk(bytes, decoder, carry, entry, captureEntry, forbiddenSecret) {
|
|
138
|
+
const decoded = decoder.decode(bytes, { stream: true });
|
|
139
|
+
const text = carry.value + decoded;
|
|
140
|
+
if (PRIVATE_KEY_MARKER.test(text) || SOURCE_SECRET_MARKER.test(text)) {
|
|
141
|
+
throw new PublishCliError('Private key material is not allowed in browser builds.');
|
|
142
|
+
}
|
|
143
|
+
if (forbiddenSecret && text.includes(forbiddenSecret)) {
|
|
144
|
+
throw new PublishCliError('Publishing credentials must not appear in browser build files.');
|
|
145
|
+
}
|
|
146
|
+
carry.value = text.slice(-Math.max(512, forbiddenSecret ? forbiddenSecret.length - 1 : 0));
|
|
147
|
+
if (captureEntry && entry.value.length <= STREAM_MAX_HTML_BYTES)
|
|
148
|
+
entry.value += decoded;
|
|
149
|
+
}
|
|
150
|
+
async function inspectFile(absolutePath, stat, isEntry, isHtml, forbiddenSecret) {
|
|
151
|
+
if (stat.size > STREAM_MAX_FILE_BYTES) {
|
|
152
|
+
throw new PublishCliError(`A browser asset exceeds the ${formatLimit(STREAM_MAX_FILE_BYTES)} byte file limit.`);
|
|
153
|
+
}
|
|
154
|
+
if (isHtml && stat.size > STREAM_MAX_HTML_BYTES) {
|
|
155
|
+
throw new PublishCliError(`An HTML file exceeds the ${formatLimit(STREAM_MAX_HTML_BYTES)} byte limit.`);
|
|
156
|
+
}
|
|
157
|
+
let handle;
|
|
158
|
+
try {
|
|
159
|
+
handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
160
|
+
const opened = await handle.stat();
|
|
161
|
+
if (!opened.isFile() || opened.isSymbolicLink() || !sameSnapshot(opened, stat)) {
|
|
162
|
+
fail('Build file changed while it was being inspected.');
|
|
163
|
+
}
|
|
164
|
+
const hash = hashFactory()('sha256');
|
|
165
|
+
const chunkSha256 = [];
|
|
166
|
+
let chunkHash = hashFactory()('sha256');
|
|
167
|
+
let chunkOffset = 0;
|
|
168
|
+
const buffer = new Uint8Array(Math.min(1_048_576, STREAM_MAX_FILE_BYTES));
|
|
169
|
+
const decoder = new TextDecoder();
|
|
170
|
+
const carry = { value: '' };
|
|
171
|
+
const entry = { value: '' };
|
|
172
|
+
let bytes = 0;
|
|
173
|
+
while (bytes < stat.size) {
|
|
174
|
+
const want = Math.min(buffer.byteLength, stat.size - bytes);
|
|
175
|
+
const { bytesRead } = await handle.read(buffer, 0, want, bytes);
|
|
176
|
+
if (bytesRead <= 0)
|
|
177
|
+
fail('Build file ended while it was being inspected.');
|
|
178
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
179
|
+
hash.update(chunk);
|
|
180
|
+
let chunkCursor = 0;
|
|
181
|
+
while (chunkCursor < chunk.byteLength) {
|
|
182
|
+
const chunkLength = Math.min(chunk.byteLength - chunkCursor, CHUNK_BYTES - chunkOffset);
|
|
183
|
+
chunkHash.update(chunk.subarray(chunkCursor, chunkCursor + chunkLength));
|
|
184
|
+
chunkCursor += chunkLength;
|
|
185
|
+
chunkOffset += chunkLength;
|
|
186
|
+
if (chunkOffset === CHUNK_BYTES) {
|
|
187
|
+
chunkSha256.push(chunkHash.digest('hex'));
|
|
188
|
+
chunkHash = hashFactory()('sha256');
|
|
189
|
+
chunkOffset = 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
inspectTextChunk(chunk, decoder, carry, entry, isEntry, forbiddenSecret);
|
|
193
|
+
bytes += bytesRead;
|
|
194
|
+
}
|
|
195
|
+
const finalText = decoder.decode();
|
|
196
|
+
const finalScan = carry.value + finalText;
|
|
197
|
+
if (PRIVATE_KEY_MARKER.test(finalScan) || SOURCE_SECRET_MARKER.test(finalScan)) {
|
|
198
|
+
throw new PublishCliError('Private key material is not allowed in browser builds.');
|
|
199
|
+
}
|
|
200
|
+
if (forbiddenSecret && finalScan.includes(forbiddenSecret)) {
|
|
201
|
+
throw new PublishCliError('Publishing credentials must not appear in browser build files.');
|
|
202
|
+
}
|
|
203
|
+
if (isEntry && finalText && entry.value.length <= STREAM_MAX_HTML_BYTES)
|
|
204
|
+
entry.value += finalText;
|
|
205
|
+
const after = await handle.stat();
|
|
206
|
+
if (!sameSnapshot(after, stat))
|
|
207
|
+
fail('Build file changed while it was being inspected.');
|
|
208
|
+
if (chunkOffset > 0)
|
|
209
|
+
chunkSha256.push(chunkHash.digest('hex'));
|
|
210
|
+
return { sha256: hash.digest('hex'), bytes, entryText: entry.value, chunkSha256 };
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error instanceof PublishCliError)
|
|
214
|
+
throw error;
|
|
215
|
+
throw new PublishCliError('Unable to read a regular browser build file.');
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await handle?.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function prepareBrowserBuild(directory, sourceCommit, forbiddenSecret) {
|
|
222
|
+
if (typeof directory !== 'string' || directory.trim() === '') {
|
|
223
|
+
throw new PublishCliError('A prebuilt browser directory is required.');
|
|
224
|
+
}
|
|
225
|
+
const requestedRoot = resolve(directory);
|
|
226
|
+
let rootStat;
|
|
227
|
+
try {
|
|
228
|
+
rootStat = await lstat(requestedRoot);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
throw new PublishCliError('Unable to inspect the browser build directory.');
|
|
232
|
+
}
|
|
233
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
234
|
+
throw new PublishCliError('The browser build path must be an existing directory.');
|
|
235
|
+
}
|
|
236
|
+
let root;
|
|
237
|
+
try {
|
|
238
|
+
root = await realpath(requestedRoot);
|
|
239
|
+
const rootNow = await lstat(root);
|
|
240
|
+
if (!sameSnapshot(rootNow, rootStat))
|
|
241
|
+
fail('Build directory changed; stop the build watcher and try again.');
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof PublishCliError)
|
|
245
|
+
throw error;
|
|
246
|
+
throw new PublishCliError('Unable to inspect the browser build directory.');
|
|
247
|
+
}
|
|
248
|
+
const files = [];
|
|
249
|
+
let totalBytes = 0;
|
|
250
|
+
let traversedEntries = 0;
|
|
251
|
+
let entryText = '';
|
|
252
|
+
async function visit(currentDirectory, depth) {
|
|
253
|
+
if (depth > STREAM_MAX_DIRECTORY_DEPTH) {
|
|
254
|
+
throw new PublishCliError(`Browser build directory nesting exceeds the ${formatLimit(STREAM_MAX_DIRECTORY_DEPTH)} level limit.`);
|
|
255
|
+
}
|
|
256
|
+
let directoryHandle;
|
|
257
|
+
try {
|
|
258
|
+
directoryHandle = await opendir(currentDirectory);
|
|
259
|
+
const entries = [];
|
|
260
|
+
for await (const entry of directoryHandle) {
|
|
261
|
+
traversedEntries += 1;
|
|
262
|
+
if (traversedEntries > STREAM_MAX_TRAVERSED_ENTRIES) {
|
|
263
|
+
throw new PublishCliError(`Browser builds may contain at most ${formatLimit(STREAM_MAX_TRAVERSED_ENTRIES)} traversed entries.`);
|
|
264
|
+
}
|
|
265
|
+
entries.push(entry);
|
|
266
|
+
}
|
|
267
|
+
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
validateSegment(entry.name);
|
|
270
|
+
const absolutePath = join(currentDirectory, entry.name);
|
|
271
|
+
let stat;
|
|
272
|
+
try {
|
|
273
|
+
stat = await lstat(absolutePath);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
throw new PublishCliError('Unable to inspect the browser build directory.');
|
|
277
|
+
}
|
|
278
|
+
if (entry.isSymbolicLink() || stat.isSymbolicLink()) {
|
|
279
|
+
throw new PublishCliError('Symlinks are not allowed in browser builds.');
|
|
280
|
+
}
|
|
281
|
+
if (stat.isDirectory()) {
|
|
282
|
+
await visit(absolutePath, depth + 1);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!stat.isFile())
|
|
286
|
+
throw new PublishCliError('Browser builds may contain only regular files.');
|
|
287
|
+
if (files.length >= STREAM_MAX_FILES) {
|
|
288
|
+
throw new PublishCliError(`Browser builds may contain at most ${formatLimit(STREAM_MAX_FILES)} files.`);
|
|
289
|
+
}
|
|
290
|
+
let resolvedPath;
|
|
291
|
+
try {
|
|
292
|
+
resolvedPath = await realpath(absolutePath);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new PublishCliError('Unable to inspect the browser build file.');
|
|
296
|
+
}
|
|
297
|
+
if (resolvedPath !== absolutePath)
|
|
298
|
+
throw new PublishCliError('Symlinked build paths are not accepted.');
|
|
299
|
+
const relativePath = relative(root, absolutePath).split(sep).join('/');
|
|
300
|
+
assertRelativePayloadPath(relativePath);
|
|
301
|
+
validateBrowserAsset(relativePath);
|
|
302
|
+
if (forbiddenSecret && relativePath.includes(forbiddenSecret)) {
|
|
303
|
+
throw new PublishCliError('Publishing credentials must not appear in browser build paths.');
|
|
304
|
+
}
|
|
305
|
+
if (!Number.isSafeInteger(stat.size) || stat.size < 0 || stat.size > STREAM_MAX_TOTAL_BYTES - totalBytes) {
|
|
306
|
+
throw new PublishCliError(`The browser build exceeds the ${formatLimit(STREAM_MAX_TOTAL_BYTES)} byte decoded size limit.`);
|
|
307
|
+
}
|
|
308
|
+
const inspected = await inspectFile(absolutePath, stat, relativePath === 'index.html', extensionOf(relativePath) === 'html', forbiddenSecret);
|
|
309
|
+
totalBytes += inspected.bytes;
|
|
310
|
+
if (totalBytes > STREAM_MAX_TOTAL_BYTES) {
|
|
311
|
+
throw new PublishCliError(`The browser build exceeds the ${formatLimit(STREAM_MAX_TOTAL_BYTES)} byte decoded size limit.`);
|
|
312
|
+
}
|
|
313
|
+
const item = {
|
|
314
|
+
path: relativePath,
|
|
315
|
+
bytes: inspected.bytes,
|
|
316
|
+
sha256: inspected.sha256,
|
|
317
|
+
absolutePath,
|
|
318
|
+
snapshot: snapshot(stat),
|
|
319
|
+
chunkSha256: inspected.chunkSha256,
|
|
320
|
+
};
|
|
321
|
+
files.push(item);
|
|
322
|
+
if (relativePath === 'index.html')
|
|
323
|
+
entryText = inspected.entryText;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof PublishCliError)
|
|
328
|
+
throw error;
|
|
329
|
+
throw new PublishCliError('Unable to inspect the browser build directory.');
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
try {
|
|
333
|
+
await directoryHandle?.close();
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// The directory is already closed after iteration.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await visit(root, 0);
|
|
341
|
+
files.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
342
|
+
if (!files.some((file) => file.path === 'index.html')) {
|
|
343
|
+
throw new PublishCliError('Browser builds must contain a root index.html entry file.');
|
|
344
|
+
}
|
|
345
|
+
if (!entryText || !/<(?:html|body|canvas|script|div|button)\b/i.test(entryText)) {
|
|
346
|
+
throw new PublishCliError('The index.html entry must contain a browser page.');
|
|
347
|
+
}
|
|
348
|
+
const manifestFiles = files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 }));
|
|
349
|
+
const manifest = {
|
|
350
|
+
entry: 'index.html',
|
|
351
|
+
files: manifestFiles,
|
|
352
|
+
...(sourceCommit === undefined ? {} : { sourceCommit: validateSourceCommit(sourceCommit) }),
|
|
353
|
+
};
|
|
354
|
+
return { ...manifest, bytes: totalBytes, root, rootSnapshot: snapshot(rootStat), preparedFiles: files };
|
|
355
|
+
}
|
|
356
|
+
export async function inspectBrowserBuild(directory, sourceCommit) {
|
|
357
|
+
const prepared = await prepareBrowserBuild(directory, sourceCommit);
|
|
358
|
+
return {
|
|
359
|
+
entry: prepared.entry,
|
|
360
|
+
files: prepared.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
|
|
361
|
+
bytes: prepared.bytes,
|
|
362
|
+
...(prepared.sourceCommit === undefined ? {} : { sourceCommit: prepared.sourceCommit }),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Prepare a local build once for the development launcher. The returned
|
|
367
|
+
* descriptors retain the canonical root and inspection snapshots so serving
|
|
368
|
+
* can reopen and verify the same files without rebuilding paths from the
|
|
369
|
+
* caller's input directory.
|
|
370
|
+
*/
|
|
371
|
+
export async function inspectBrowserBuildForLocal(directory, sourceCommit) {
|
|
372
|
+
return prepareBrowserBuild(directory, sourceCommit);
|
|
373
|
+
}
|
|
374
|
+
function isLoopbackHost(hostname) {
|
|
375
|
+
return hostname.toLowerCase() === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
|
376
|
+
}
|
|
377
|
+
/** Derive the only worker origin accepted when credentials omit an explicit one. */
|
|
378
|
+
export function deriveUploadOrigin(platformOrigin) {
|
|
379
|
+
const platform = new URL(platformOrigin);
|
|
380
|
+
if (isLoopbackHost(platform.hostname)) {
|
|
381
|
+
const hostname = platform.hostname === 'localhost' ? '127.0.0.1' : platform.hostname;
|
|
382
|
+
const port = platform.port === '3003' ? '3401' : platform.port;
|
|
383
|
+
const authority = hostname === '[::1]' ? `[${hostname.slice(1, -1)}]` : hostname;
|
|
384
|
+
return `${platform.protocol}//${authority}${port ? `:${port}` : ''}`;
|
|
385
|
+
}
|
|
386
|
+
return `${platform.protocol}//uploads.${platform.hostname}`;
|
|
387
|
+
}
|
|
388
|
+
function trustedUploadOrigin(config) {
|
|
389
|
+
const expected = config.uploadOrigin ?? deriveUploadOrigin(config.apiUrl);
|
|
390
|
+
let normalized;
|
|
391
|
+
try {
|
|
392
|
+
normalized = new URL(expected);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
throw new PublishCliError('The upload origin configuration is invalid.');
|
|
396
|
+
}
|
|
397
|
+
if (!['http:', 'https:'].includes(normalized.protocol) || normalized.username || normalized.password || normalized.pathname !== '/' || normalized.search || normalized.hash) {
|
|
398
|
+
throw new PublishCliError('The upload origin configuration must be an origin.');
|
|
399
|
+
}
|
|
400
|
+
const platform = new URL(config.apiUrl);
|
|
401
|
+
if (normalized.origin === platform.origin) {
|
|
402
|
+
throw new PublishCliError('The upload origin must be a separate origin.');
|
|
403
|
+
}
|
|
404
|
+
if (normalized.protocol !== platform.protocol || (!isLoopbackHost(normalized.hostname) && normalized.protocol !== 'https:')) {
|
|
405
|
+
throw new PublishCliError('The upload origin must use the platform transport security.');
|
|
406
|
+
}
|
|
407
|
+
if (isLoopbackHost(platform.hostname)) {
|
|
408
|
+
if (!isLoopbackHost(normalized.hostname))
|
|
409
|
+
throw new PublishCliError('Local uploads must stay on a loopback origin.');
|
|
410
|
+
}
|
|
411
|
+
else if (normalized.port || normalized.hostname === platform.hostname) {
|
|
412
|
+
throw new PublishCliError('The upload origin is not an allowed separate host.');
|
|
413
|
+
}
|
|
414
|
+
return normalized.origin;
|
|
415
|
+
}
|
|
416
|
+
function safeUploadId(value) {
|
|
417
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
|
418
|
+
throw new PublishCliError('Spawn returned an invalid upload identifier.');
|
|
419
|
+
}
|
|
420
|
+
return value;
|
|
421
|
+
}
|
|
422
|
+
function safeTicket(value) {
|
|
423
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 8192 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
424
|
+
throw new PublishCliError('Spawn returned an invalid upload ticket.');
|
|
425
|
+
}
|
|
426
|
+
return value;
|
|
427
|
+
}
|
|
428
|
+
function safeExpiry(value) {
|
|
429
|
+
const parsed = typeof value === 'number'
|
|
430
|
+
? Number.isSafeInteger(value) ? value : NaN
|
|
431
|
+
: typeof value === 'string' ? Date.parse(value) : NaN;
|
|
432
|
+
if (!Number.isFinite(parsed) || parsed <= Date.now()) {
|
|
433
|
+
throw new PublishCliError('Spawn returned an invalid or expired upload session.');
|
|
434
|
+
}
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
437
|
+
function validateUploadSession(value, config) {
|
|
438
|
+
if (!isRecord(value))
|
|
439
|
+
throw new PublishCliError('Spawn returned an invalid upload session.');
|
|
440
|
+
const uploadOrigin = typeof value.uploadOrigin === 'string' ? value.uploadOrigin : '';
|
|
441
|
+
let parsedOrigin;
|
|
442
|
+
try {
|
|
443
|
+
parsedOrigin = new URL(uploadOrigin);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
throw new PublishCliError('Spawn returned an invalid upload origin.');
|
|
447
|
+
}
|
|
448
|
+
if (!['http:', 'https:'].includes(parsedOrigin.protocol) ||
|
|
449
|
+
parsedOrigin.username ||
|
|
450
|
+
parsedOrigin.password ||
|
|
451
|
+
parsedOrigin.pathname !== '/' ||
|
|
452
|
+
parsedOrigin.search ||
|
|
453
|
+
parsedOrigin.hash) {
|
|
454
|
+
throw new PublishCliError('Spawn returned an invalid upload origin.');
|
|
455
|
+
}
|
|
456
|
+
const normalized = parsedOrigin.origin;
|
|
457
|
+
if (normalized !== trustedUploadOrigin(config)) {
|
|
458
|
+
throw new PublishCliError('Spawn returned an unexpected upload origin.');
|
|
459
|
+
}
|
|
460
|
+
const chunkBytes = value.chunkBytes;
|
|
461
|
+
if (chunkBytes !== CHUNK_BYTES) {
|
|
462
|
+
throw new PublishCliError('Spawn returned an unsupported upload chunk size.');
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
uploadId: safeUploadId(value.uploadId),
|
|
466
|
+
uploadOrigin: normalized,
|
|
467
|
+
token: safeTicket(value.token),
|
|
468
|
+
chunkBytes,
|
|
469
|
+
expiresAt: safeExpiry(value.expiresAt),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function uploadPath(session, suffix) {
|
|
473
|
+
return `${session.uploadOrigin}/v1/uploads/${encodeURIComponent(session.uploadId)}${suffix}`;
|
|
474
|
+
}
|
|
475
|
+
function retryable(error) {
|
|
476
|
+
if (!(error instanceof Error))
|
|
477
|
+
return false;
|
|
478
|
+
return /HTTP (?:408|425|429|5\d\d)|network|timed out|timeout|fetch|aborted|temporar/i.test(error.message);
|
|
479
|
+
}
|
|
480
|
+
async function retryJson(operation, options) {
|
|
481
|
+
const attempts = Math.max(1, Math.min(options.retries ?? STREAM_MAX_RETRIES, STREAM_MAX_RETRIES));
|
|
482
|
+
const delay = Math.max(0, Math.min(options.retryDelayMs ?? 100, 2_000));
|
|
483
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)));
|
|
484
|
+
let lastError;
|
|
485
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
486
|
+
try {
|
|
487
|
+
return await operation();
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
lastError = error;
|
|
491
|
+
if (attempt + 1 >= attempts || !retryable(error))
|
|
492
|
+
throw error;
|
|
493
|
+
await sleep(delay * (2 ** attempt));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw lastError instanceof Error ? lastError : new PublishCliError('Spawn upload failed.');
|
|
497
|
+
}
|
|
498
|
+
function validateProgress(value, uploadId, totalBytes) {
|
|
499
|
+
if (!isRecord(value))
|
|
500
|
+
throw new PublishCliError('Spawn returned an invalid upload progress response.');
|
|
501
|
+
if (value.uploadId !== undefined && value.uploadId !== uploadId) {
|
|
502
|
+
throw new PublishCliError('Spawn returned progress for a different upload.');
|
|
503
|
+
}
|
|
504
|
+
for (const key of ['bytes', 'receivedBytes', 'uploadedBytes']) {
|
|
505
|
+
const bytes = value[key];
|
|
506
|
+
if (bytes !== undefined && (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 0 || bytes > totalBytes)) {
|
|
507
|
+
throw new PublishCliError('Spawn returned invalid upload progress.');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return value;
|
|
511
|
+
}
|
|
512
|
+
function progressFile(value, fileIndex) {
|
|
513
|
+
if (!Array.isArray(value.files))
|
|
514
|
+
return undefined;
|
|
515
|
+
const item = value.files[fileIndex];
|
|
516
|
+
return isRecord(item) ? item : undefined;
|
|
517
|
+
}
|
|
518
|
+
function receivedChunks(value, fileIndex, fileBytes) {
|
|
519
|
+
const file = progressFile(value, fileIndex);
|
|
520
|
+
if (!file)
|
|
521
|
+
return new Set();
|
|
522
|
+
const result = new Set();
|
|
523
|
+
if (file.complete === true || (typeof file.receivedBytes === 'number' && file.receivedBytes >= fileBytes)) {
|
|
524
|
+
for (let index = 0, offset = 0; offset < fileBytes; index += 1, offset += CHUNK_BYTES)
|
|
525
|
+
result.add(index);
|
|
526
|
+
}
|
|
527
|
+
for (const key of ['receivedChunks', 'uploadedChunks', 'completeChunks', 'chunks']) {
|
|
528
|
+
const chunks = file[key];
|
|
529
|
+
if (!Array.isArray(chunks))
|
|
530
|
+
continue;
|
|
531
|
+
for (const item of chunks) {
|
|
532
|
+
const index = typeof item === 'number'
|
|
533
|
+
? item
|
|
534
|
+
: isRecord(item) && typeof item.index === 'number'
|
|
535
|
+
? item.index
|
|
536
|
+
: isRecord(item) && typeof item.chunkIndex === 'number'
|
|
537
|
+
? item.chunkIndex
|
|
538
|
+
: NaN;
|
|
539
|
+
if (Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(fileBytes / CHUNK_BYTES))
|
|
540
|
+
result.add(index);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
async function openForUpload(file) {
|
|
546
|
+
let resolved;
|
|
547
|
+
try {
|
|
548
|
+
resolved = await realpath(file.absolutePath);
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
throw new PublishCliError('Unable to reopen a browser build file.');
|
|
552
|
+
}
|
|
553
|
+
if (resolved !== file.absolutePath)
|
|
554
|
+
throw new PublishCliError('Build file changed to a symlink.');
|
|
555
|
+
let handle;
|
|
556
|
+
try {
|
|
557
|
+
handle = await open(file.absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
558
|
+
const current = await handle.stat();
|
|
559
|
+
if (!current.isFile() || current.isSymbolicLink() || !sameSnapshot(current, file.snapshot)) {
|
|
560
|
+
throw new PublishCliError('Build file changed after inspection.');
|
|
561
|
+
}
|
|
562
|
+
return handle;
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
await handle?.close().catch(() => undefined);
|
|
566
|
+
if (error instanceof PublishCliError)
|
|
567
|
+
throw error;
|
|
568
|
+
throw new PublishCliError('Unable to reopen a browser build file.');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** Reopen a prepared local file and verify its retained root, metadata, and digest before serving it. */
|
|
572
|
+
export async function openValidatedBuildFile(build, file, cache) {
|
|
573
|
+
let rootHandle;
|
|
574
|
+
try {
|
|
575
|
+
rootHandle = await open(build.root, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
576
|
+
const currentRoot = await rootHandle.stat();
|
|
577
|
+
if (!currentRoot.isDirectory() || currentRoot.isSymbolicLink() || !sameSnapshot(currentRoot, build.rootSnapshot)) {
|
|
578
|
+
throw new PublishCliError('Build directory changed after inspection.');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (error instanceof PublishCliError)
|
|
583
|
+
throw error;
|
|
584
|
+
throw new PublishCliError('Unable to reopen the inspected browser build.');
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
await rootHandle?.close().catch(() => undefined);
|
|
588
|
+
}
|
|
589
|
+
let handle;
|
|
590
|
+
try {
|
|
591
|
+
handle = await open(file.absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
592
|
+
const current = await handle.stat();
|
|
593
|
+
if (!current.isFile() || current.isSymbolicLink() || !sameSnapshot(current, file.snapshot)) {
|
|
594
|
+
throw new PublishCliError('Build file changed after inspection.');
|
|
595
|
+
}
|
|
596
|
+
const cachedSnapshot = cache?.snapshot;
|
|
597
|
+
if (cachedSnapshot && sameSnapshot(current, cachedSnapshot))
|
|
598
|
+
return handle;
|
|
599
|
+
let validation = cache?.validation;
|
|
600
|
+
if (!validation) {
|
|
601
|
+
validation = (async () => {
|
|
602
|
+
const hash = hashFactory()('sha256');
|
|
603
|
+
const buffer = new Uint8Array(Math.min(1_048_576, file.bytes));
|
|
604
|
+
let bytes = 0;
|
|
605
|
+
while (bytes < file.bytes) {
|
|
606
|
+
const want = Math.min(buffer.byteLength, file.bytes - bytes);
|
|
607
|
+
const { bytesRead } = await handle.read(buffer, 0, want, bytes);
|
|
608
|
+
if (bytesRead <= 0)
|
|
609
|
+
throw new PublishCliError('Build file ended while it was being served.');
|
|
610
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
611
|
+
bytes += bytesRead;
|
|
612
|
+
}
|
|
613
|
+
const after = await handle.stat();
|
|
614
|
+
if (!sameSnapshot(after, file.snapshot) || bytes !== file.bytes || hash.digest('hex') !== file.sha256) {
|
|
615
|
+
throw new PublishCliError('Build file changed after inspection.');
|
|
616
|
+
}
|
|
617
|
+
if (cache)
|
|
618
|
+
cache.snapshot = snapshot(after);
|
|
619
|
+
})();
|
|
620
|
+
if (cache)
|
|
621
|
+
cache.validation = validation;
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
await validation;
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (cache?.validation === validation) {
|
|
628
|
+
cache.validation = undefined;
|
|
629
|
+
cache.snapshot = undefined;
|
|
630
|
+
}
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
return handle;
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
await handle?.close().catch(() => undefined);
|
|
637
|
+
if (error instanceof PublishCliError)
|
|
638
|
+
throw error;
|
|
639
|
+
throw new PublishCliError('Unable to reopen the inspected browser build file.');
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function uploadFile(session, file, fileIndex, prior, totalBytes, workerConfig, fetchImplementation, options) {
|
|
643
|
+
const handle = await openForUpload(file);
|
|
644
|
+
const skipped = receivedChunks(prior, fileIndex, file.bytes);
|
|
645
|
+
const buffer = new Uint8Array(session.chunkBytes);
|
|
646
|
+
let offset = 0;
|
|
647
|
+
let chunkIndex = 0;
|
|
648
|
+
let progress = prior;
|
|
649
|
+
try {
|
|
650
|
+
while (offset < file.bytes) {
|
|
651
|
+
const length = Math.min(session.chunkBytes, file.bytes - offset);
|
|
652
|
+
let read = 0;
|
|
653
|
+
while (read < length) {
|
|
654
|
+
const result = await handle.read(buffer, read, length - read, offset + read);
|
|
655
|
+
if (result.bytesRead <= 0)
|
|
656
|
+
throw new PublishCliError('Build file ended while it was being uploaded.');
|
|
657
|
+
read += result.bytesRead;
|
|
658
|
+
}
|
|
659
|
+
const actualChunkSha256 = hashFactory()('sha256').update(buffer.subarray(0, length)).digest('hex');
|
|
660
|
+
if (actualChunkSha256 !== file.chunkSha256[chunkIndex]) {
|
|
661
|
+
throw new PublishCliError('Build file changed while it was being uploaded.');
|
|
662
|
+
}
|
|
663
|
+
if (!skipped.has(chunkIndex)) {
|
|
664
|
+
const payload = buffer.subarray(0, length);
|
|
665
|
+
const next = await retryJson(async () => validateProgress(await requestJson(workerConfig, uploadPath(session, `/files/${fileIndex}/chunks/${chunkIndex}`), {
|
|
666
|
+
method: 'PUT',
|
|
667
|
+
headers: {
|
|
668
|
+
Authorization: `Bearer ${session.token}`,
|
|
669
|
+
'Content-Type': 'application/octet-stream',
|
|
670
|
+
'Content-Length': String(length),
|
|
671
|
+
},
|
|
672
|
+
body: payload,
|
|
673
|
+
}, fetchImplementation), session.uploadId, totalBytes), options);
|
|
674
|
+
progress = next;
|
|
675
|
+
options.onProgress?.(progress);
|
|
676
|
+
}
|
|
677
|
+
offset += length;
|
|
678
|
+
chunkIndex += 1;
|
|
679
|
+
}
|
|
680
|
+
const after = await handle.stat();
|
|
681
|
+
if (!sameSnapshot(after, file.snapshot))
|
|
682
|
+
throw new PublishCliError('Build file changed while it was uploaded.');
|
|
683
|
+
return progress;
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
await handle.close();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
export async function publishBrowserDirectory(config, directory, sourceCommit, fetchImplementation = globalThis.fetch, options = {}) {
|
|
690
|
+
const validatedConfig = validatePublishConfig(config);
|
|
691
|
+
const prepared = await prepareBrowserBuild(directory, sourceCommit ?? options.sourceCommit, validatedConfig.publishKey);
|
|
692
|
+
const manifest = {
|
|
693
|
+
entry: prepared.entry,
|
|
694
|
+
files: prepared.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
|
|
695
|
+
...(prepared.sourceCommit === undefined ? {} : { sourceCommit: prepared.sourceCommit }),
|
|
696
|
+
};
|
|
697
|
+
const coreBase = validatedConfig.apiUrl.replace(/\/+$/, '');
|
|
698
|
+
const coreUploadUrl = `${coreBase}/api/v1/publish/${encodeURIComponent(validatedConfig.projectId)}/uploads`;
|
|
699
|
+
const sessionResponse = await requestJson(validatedConfig, coreUploadUrl, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
headers: {
|
|
702
|
+
Authorization: `Bearer ${validatedConfig.publishKey}`,
|
|
703
|
+
'Content-Type': 'application/json',
|
|
704
|
+
},
|
|
705
|
+
body: JSON.stringify(manifest),
|
|
706
|
+
}, fetchImplementation);
|
|
707
|
+
const session = validateUploadSession(sessionResponse, validatedConfig);
|
|
708
|
+
const workerConfig = {
|
|
709
|
+
...validatedConfig,
|
|
710
|
+
apiUrl: session.uploadOrigin,
|
|
711
|
+
publishKey: session.token,
|
|
712
|
+
};
|
|
713
|
+
let progress = await retryJson(async () => validateProgress(await requestJson(workerConfig, uploadPath(session, ''), {
|
|
714
|
+
method: 'POST',
|
|
715
|
+
headers: {
|
|
716
|
+
Authorization: `Bearer ${session.token}`,
|
|
717
|
+
'Content-Type': 'application/json',
|
|
718
|
+
},
|
|
719
|
+
body: JSON.stringify(manifest),
|
|
720
|
+
}, fetchImplementation), session.uploadId, prepared.bytes), options);
|
|
721
|
+
options.onProgress?.(progress);
|
|
722
|
+
for (const [fileIndex, file] of prepared.preparedFiles.entries()) {
|
|
723
|
+
progress = await uploadFile(session, file, fileIndex, progress, prepared.bytes, workerConfig, fetchImplementation, options);
|
|
724
|
+
}
|
|
725
|
+
const sealed = await retryJson(async () => validateProgress(await requestJson(workerConfig, uploadPath(session, '/seal'), {
|
|
726
|
+
method: 'POST',
|
|
727
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
728
|
+
}, fetchImplementation), session.uploadId, prepared.bytes), options);
|
|
729
|
+
if (sealed.sealed !== true) {
|
|
730
|
+
throw new PublishCliError('Spawn did not seal the browser build.');
|
|
731
|
+
}
|
|
732
|
+
options.onProgress?.(sealed);
|
|
733
|
+
return requestJson(validatedConfig, `${coreBase}/api/v1/publish/${encodeURIComponent(validatedConfig.projectId)}/uploads/${encodeURIComponent(session.uploadId)}/complete`, {
|
|
734
|
+
method: 'POST',
|
|
735
|
+
headers: { Authorization: `Bearer ${validatedConfig.publishKey}` },
|
|
736
|
+
}, fetchImplementation);
|
|
737
|
+
}
|