@constructive-io/site-deploy 0.1.0
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/LICENSE +23 -0
- package/README.md +165 -0
- package/cas-upload.d.ts +59 -0
- package/cas-upload.js +132 -0
- package/content-type.d.ts +15 -0
- package/content-type.js +61 -0
- package/deploy.d.ts +23 -0
- package/deploy.js +266 -0
- package/documents.d.ts +24 -0
- package/documents.js +147 -0
- package/esm/cas-upload.js +126 -0
- package/esm/content-type.js +57 -0
- package/esm/deploy.js +262 -0
- package/esm/documents.js +137 -0
- package/esm/index.js +25 -0
- package/esm/manifest.js +100 -0
- package/esm/types.js +22 -0
- package/esm/walk.js +49 -0
- package/index.d.ts +27 -0
- package/index.js +42 -0
- package/manifest.d.ts +48 -0
- package/manifest.js +109 -0
- package/package.json +47 -0
- package/types.d.ts +191 -0
- package/types.js +26 -0
- package/walk.d.ts +16 -0
- package/walk.js +85 -0
package/deploy.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The deploy pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Everything below the manifest is server-owned: writing the release row fires
|
|
6
|
+
* the trigger that commits the manifest into the site's own merkle store, so
|
|
7
|
+
* the commit id this returns *is* the release — history, time travel and
|
|
8
|
+
* rollback are then pointer moves, not client bookkeeping.
|
|
9
|
+
*
|
|
10
|
+
* Ordering is the contract: bytes land before the manifest names them, and the
|
|
11
|
+
* manifest lands before anything points at it. A failure anywhere leaves the
|
|
12
|
+
* live site exactly as it was.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.deploySite = deploySite;
|
|
16
|
+
exports.publishCommit = publishCommit;
|
|
17
|
+
const cas_upload_1 = require("./cas-upload");
|
|
18
|
+
const documents_1 = require("./documents");
|
|
19
|
+
const manifest_1 = require("./manifest");
|
|
20
|
+
const types_1 = require("./types");
|
|
21
|
+
const walk_1 = require("./walk");
|
|
22
|
+
/** Preview ref names become hostname labels, so they must be one. */
|
|
23
|
+
const PREVIEW_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
24
|
+
/**
|
|
25
|
+
* Deploy a build as an immutable release.
|
|
26
|
+
*
|
|
27
|
+
* Hashes every file, uploads only the bytes the server does not already have,
|
|
28
|
+
* writes one manifest — and optionally publishes it or points a preview ref at
|
|
29
|
+
* it.
|
|
30
|
+
*/
|
|
31
|
+
async function deploySite(options) {
|
|
32
|
+
const { api, storage = options.api, siteId, databaseId, bucketKey, scope = 'database', publish = false, preview, previewApex, concurrency = 8, batchSize = 100, retries = 2, skipIfUnchanged = false, dryRun = false, signal, onProgress, } = options;
|
|
33
|
+
const names = { ...(0, documents_1.deployNames)(scope), ...options.names };
|
|
34
|
+
if (preview !== undefined && !PREVIEW_NAME_PATTERN.test(preview)) {
|
|
35
|
+
throw new types_1.DeployError('INVALID_PREVIEW_NAME', `Preview name must be a lowercase DNS label: "${preview}"`);
|
|
36
|
+
}
|
|
37
|
+
// 1. Hash the build. Content addressing means this is also the diff key.
|
|
38
|
+
const files = [];
|
|
39
|
+
let index = 0;
|
|
40
|
+
for await (const file of iterateSource(options.source, options.ignore)) {
|
|
41
|
+
(0, cas_upload_1.throwIfAborted)(signal);
|
|
42
|
+
if (options.ignore?.(file.path))
|
|
43
|
+
continue;
|
|
44
|
+
const hashed = (0, manifest_1.hashFile)(file, options.contentTypes);
|
|
45
|
+
files.push(hashed);
|
|
46
|
+
onProgress?.({
|
|
47
|
+
type: 'hashed',
|
|
48
|
+
path: hashed.path,
|
|
49
|
+
hash: hashed.hash,
|
|
50
|
+
size: hashed.size,
|
|
51
|
+
index: index++,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const manifest = (0, manifest_1.buildManifest)(files);
|
|
55
|
+
// 2. The release row is UNIQUE per site, so the first deploy creates it and
|
|
56
|
+
// every later one patches it — the caller should not have to know which.
|
|
57
|
+
const existing = await fetchExistingRelease(api, names, siteId);
|
|
58
|
+
if (skipIfUnchanged &&
|
|
59
|
+
existing?.commitId &&
|
|
60
|
+
existing.storeId &&
|
|
61
|
+
existing.manifest &&
|
|
62
|
+
(0, manifest_1.manifestsEqual)(existing.manifest, manifest)) {
|
|
63
|
+
onProgress?.({ type: 'unchanged', commitId: existing.commitId });
|
|
64
|
+
return {
|
|
65
|
+
commitId: existing.commitId,
|
|
66
|
+
storeId: existing.storeId,
|
|
67
|
+
releaseId: existing.id,
|
|
68
|
+
manifest,
|
|
69
|
+
files: manifest.file_count,
|
|
70
|
+
uploaded: 0,
|
|
71
|
+
skipped: manifest.file_count,
|
|
72
|
+
bytesUploaded: 0,
|
|
73
|
+
published: false,
|
|
74
|
+
previewUrl: null,
|
|
75
|
+
unchanged: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// 3. Register the bytes and learn which ones are missing.
|
|
79
|
+
const uploadOptions = {
|
|
80
|
+
storage,
|
|
81
|
+
mutation: names.bulkUploadMutation,
|
|
82
|
+
bucketKey,
|
|
83
|
+
batchSize,
|
|
84
|
+
concurrency,
|
|
85
|
+
retries,
|
|
86
|
+
putObject: options.putObject,
|
|
87
|
+
signal,
|
|
88
|
+
};
|
|
89
|
+
const diff = await (0, cas_upload_1.diffCas)(files, uploadOptions);
|
|
90
|
+
onProgress?.({
|
|
91
|
+
type: 'diffed',
|
|
92
|
+
files: manifest.file_count,
|
|
93
|
+
toUpload: diff.missing.length,
|
|
94
|
+
skipped: diff.deduplicated.length,
|
|
95
|
+
bytesToUpload: diff.missing.reduce((sum, entry) => sum + entry.file.size, 0),
|
|
96
|
+
});
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
return {
|
|
99
|
+
commitId: existing?.commitId ?? '',
|
|
100
|
+
storeId: existing?.storeId ?? '',
|
|
101
|
+
releaseId: existing?.id ?? '',
|
|
102
|
+
manifest,
|
|
103
|
+
files: manifest.file_count,
|
|
104
|
+
uploaded: 0,
|
|
105
|
+
skipped: diff.deduplicated.length,
|
|
106
|
+
bytesUploaded: 0,
|
|
107
|
+
published: false,
|
|
108
|
+
previewUrl: null,
|
|
109
|
+
unchanged: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// 4. Upload. Any failure here throws, so the manifest is never written over
|
|
113
|
+
// bytes that are not there.
|
|
114
|
+
let done = 0;
|
|
115
|
+
const uploadResult = await (0, cas_upload_1.uploadMissing)(diff, {
|
|
116
|
+
...uploadOptions,
|
|
117
|
+
onUploaded: (file) => {
|
|
118
|
+
done += 1;
|
|
119
|
+
onProgress?.({
|
|
120
|
+
type: 'uploaded',
|
|
121
|
+
path: file.path,
|
|
122
|
+
key: file.key,
|
|
123
|
+
size: file.size,
|
|
124
|
+
done,
|
|
125
|
+
total: diff.missing.length,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
// 5. Write the manifest — this is the commit.
|
|
130
|
+
const release = existing
|
|
131
|
+
? await updateRelease(api, names, existing.id, manifest)
|
|
132
|
+
: await createRelease(api, names, siteId, databaseId, manifest);
|
|
133
|
+
onProgress?.({
|
|
134
|
+
type: 'manifest',
|
|
135
|
+
commitId: release.commitId,
|
|
136
|
+
storeId: release.storeId,
|
|
137
|
+
created: !existing,
|
|
138
|
+
});
|
|
139
|
+
const result = {
|
|
140
|
+
commitId: release.commitId,
|
|
141
|
+
storeId: release.storeId,
|
|
142
|
+
releaseId: release.id,
|
|
143
|
+
manifest,
|
|
144
|
+
files: manifest.file_count,
|
|
145
|
+
uploaded: uploadResult.uploaded,
|
|
146
|
+
skipped: uploadResult.skipped,
|
|
147
|
+
bytesUploaded: uploadResult.bytesUploaded,
|
|
148
|
+
published: false,
|
|
149
|
+
previewUrl: null,
|
|
150
|
+
unchanged: false,
|
|
151
|
+
};
|
|
152
|
+
// 6. Point things at the commit. Separate steps: a failure here leaves a
|
|
153
|
+
// complete, retryable release rather than a half-published site.
|
|
154
|
+
if (preview !== undefined) {
|
|
155
|
+
result.previewUrl = await pointPreview(api, names, {
|
|
156
|
+
siteId,
|
|
157
|
+
name: preview,
|
|
158
|
+
commitId: release.commitId,
|
|
159
|
+
apex: previewApex,
|
|
160
|
+
});
|
|
161
|
+
onProgress?.({
|
|
162
|
+
type: 'preview',
|
|
163
|
+
name: preview,
|
|
164
|
+
commitId: release.commitId,
|
|
165
|
+
url: result.previewUrl,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (publish) {
|
|
169
|
+
await publishCommit(api, names, siteId, release.commitId);
|
|
170
|
+
result.published = true;
|
|
171
|
+
onProgress?.({ type: 'published', commitId: release.commitId });
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
/** Move `site.activeCommitId` — the go-live and the rollback are one thing. */
|
|
176
|
+
async function publishCommit(api, names, siteId, commitId) {
|
|
177
|
+
try {
|
|
178
|
+
await api((0, documents_1.buildPublishMutation)(names), {
|
|
179
|
+
input: { id: siteId, [names.sitePatchField]: { activeCommitId: commitId } },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw new types_1.DeployError('PUBLISH_FAILED', `Release ${commitId} was written but publishing it failed: ${(0, cas_upload_1.errorMessage)(err)}`, err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function* iterateSource(source, ignore) {
|
|
187
|
+
if (typeof source === 'string') {
|
|
188
|
+
yield* (0, walk_1.walkDirectory)(source, ignore);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (Symbol.asyncIterator in source) {
|
|
192
|
+
yield* source;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
yield* source;
|
|
196
|
+
}
|
|
197
|
+
async function fetchExistingRelease(api, names, siteId) {
|
|
198
|
+
const data = await api((0, documents_1.buildReleaseQuery)(names), { siteId });
|
|
199
|
+
const connection = data[names.releasesQuery];
|
|
200
|
+
return connection?.nodes?.[0] ?? null;
|
|
201
|
+
}
|
|
202
|
+
async function createRelease(api, names, siteId, databaseId, manifest) {
|
|
203
|
+
if (!databaseId) {
|
|
204
|
+
throw new types_1.DeployError('MISSING_DATABASE_ID', `Site ${siteId} has no release yet, so databaseId is required to create one`);
|
|
205
|
+
}
|
|
206
|
+
const data = await api((0, documents_1.buildCreateReleaseMutation)(names), {
|
|
207
|
+
input: {
|
|
208
|
+
[names.releaseInputField]: { siteId, databaseId, manifest },
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
return readWrittenRelease(data, names);
|
|
212
|
+
}
|
|
213
|
+
async function updateRelease(api, names, releaseId, manifest) {
|
|
214
|
+
const data = await api((0, documents_1.buildUpdateReleaseMutation)(names), {
|
|
215
|
+
input: {
|
|
216
|
+
id: releaseId,
|
|
217
|
+
[names.releasePatchField]: { manifest },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return readWrittenRelease(data, names);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Read back the versioning the trigger stamped on the row.
|
|
224
|
+
*
|
|
225
|
+
* A release without a commit is not a release: it cannot be published, rolled
|
|
226
|
+
* back to, or previewed, so a missing id is a hard failure rather than a
|
|
227
|
+
* result with empty strings in it.
|
|
228
|
+
*/
|
|
229
|
+
function readWrittenRelease(data, names) {
|
|
230
|
+
const mutation = (data[names.createRelease] ?? data[names.updateRelease]);
|
|
231
|
+
const row = mutation?.[names.releasePayloadField];
|
|
232
|
+
if (!row?.id || !row.commitId || !row.storeId) {
|
|
233
|
+
throw new types_1.DeployError('RELEASE_NOT_VERSIONED', 'Release row came back without a commitId/storeId — the manifest was not committed');
|
|
234
|
+
}
|
|
235
|
+
return { id: row.id, commitId: row.commitId, storeId: row.storeId };
|
|
236
|
+
}
|
|
237
|
+
async function pointPreview(api, names, args) {
|
|
238
|
+
try {
|
|
239
|
+
// With an apex, provision so the hostname and its route exist (idempotent
|
|
240
|
+
// and it moves the ref too); without one, just move the ref.
|
|
241
|
+
if (args.apex) {
|
|
242
|
+
const data = await api((0, documents_1.buildProvisionPreviewMutation)(names), {
|
|
243
|
+
input: {
|
|
244
|
+
siteId: args.siteId,
|
|
245
|
+
name: args.name,
|
|
246
|
+
commitId: args.commitId,
|
|
247
|
+
apex: args.apex,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
const payload = data[names.provisionPreview];
|
|
251
|
+
const hostname = payload?.result?.domain?.hostname;
|
|
252
|
+
return hostname ? `https://${hostname}` : null;
|
|
253
|
+
}
|
|
254
|
+
await api((0, documents_1.buildSetPreviewMutation)(names), {
|
|
255
|
+
input: {
|
|
256
|
+
targetSiteId: args.siteId,
|
|
257
|
+
targetName: args.name,
|
|
258
|
+
targetCommitId: args.commitId,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
throw new types_1.DeployError('PREVIEW_FAILED', `Release ${args.commitId} was written but preview "${args.name}" could not be pointed at it: ${(0, cas_upload_1.errorMessage)(err)}`, err);
|
|
265
|
+
}
|
|
266
|
+
}
|
package/documents.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL documents and generated-name derivation.
|
|
3
|
+
*
|
|
4
|
+
* Plain strings, no graphql dependency: the same documents work through the
|
|
5
|
+
* generated SDK, urql, Apollo or a bare fetch. Every field name is generated
|
|
6
|
+
* per scope — unprefixed at the tenant (`database`) scope, `platform`-prefixed
|
|
7
|
+
* on the self-hosted lane — so the names are derived in one place here rather
|
|
8
|
+
* than spelled out at each call site.
|
|
9
|
+
*/
|
|
10
|
+
import type { SiteDeployNames } from './types';
|
|
11
|
+
/** Derive the deploy surface's field names for a scope. */
|
|
12
|
+
export declare function deployNames(scope?: 'database' | 'platform'): SiteDeployNames;
|
|
13
|
+
/** Bulk presigned-upload mutation for one storage plane. */
|
|
14
|
+
export declare function buildBulkUploadMutation(mutation: string): string;
|
|
15
|
+
/** The site's existing release row, if it has one (the row is UNIQUE per site). */
|
|
16
|
+
export declare function buildReleaseQuery(names: SiteDeployNames): string;
|
|
17
|
+
export declare function buildCreateReleaseMutation(names: SiteDeployNames): string;
|
|
18
|
+
export declare function buildUpdateReleaseMutation(names: SiteDeployNames): string;
|
|
19
|
+
/** Publishing and rollback are the same mutation: move the pointer. */
|
|
20
|
+
export declare function buildPublishMutation(names: SiteDeployNames): string;
|
|
21
|
+
/** Claims (or reuses) the preview hostname and points the ref at a commit. */
|
|
22
|
+
export declare function buildProvisionPreviewMutation(names: SiteDeployNames): string;
|
|
23
|
+
/** Moves an existing ref without touching routing. */
|
|
24
|
+
export declare function buildSetPreviewMutation(names: SiteDeployNames): string;
|
package/documents.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* GraphQL documents and generated-name derivation.
|
|
4
|
+
*
|
|
5
|
+
* Plain strings, no graphql dependency: the same documents work through the
|
|
6
|
+
* generated SDK, urql, Apollo or a bare fetch. Every field name is generated
|
|
7
|
+
* per scope — unprefixed at the tenant (`database`) scope, `platform`-prefixed
|
|
8
|
+
* on the self-hosted lane — so the names are derived in one place here rather
|
|
9
|
+
* than spelled out at each call site.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.deployNames = deployNames;
|
|
13
|
+
exports.buildBulkUploadMutation = buildBulkUploadMutation;
|
|
14
|
+
exports.buildReleaseQuery = buildReleaseQuery;
|
|
15
|
+
exports.buildCreateReleaseMutation = buildCreateReleaseMutation;
|
|
16
|
+
exports.buildUpdateReleaseMutation = buildUpdateReleaseMutation;
|
|
17
|
+
exports.buildPublishMutation = buildPublishMutation;
|
|
18
|
+
exports.buildProvisionPreviewMutation = buildProvisionPreviewMutation;
|
|
19
|
+
exports.buildSetPreviewMutation = buildSetPreviewMutation;
|
|
20
|
+
/** Derive the deploy surface's field names for a scope. */
|
|
21
|
+
function deployNames(scope = 'database') {
|
|
22
|
+
const Prefix = scope === 'platform' ? 'Platform' : '';
|
|
23
|
+
const prefix = scope === 'platform' ? 'platform' : '';
|
|
24
|
+
const lower = (name) => (prefix ? `${prefix}${name}` : lowerFirst(name));
|
|
25
|
+
return {
|
|
26
|
+
bulkUploadMutation: `upload${Prefix}Files`,
|
|
27
|
+
releasesQuery: `${lower('SiteReleases')}`,
|
|
28
|
+
createRelease: `create${Prefix}SiteRelease`,
|
|
29
|
+
releaseInputField: lower('SiteRelease'),
|
|
30
|
+
updateRelease: `update${Prefix}SiteRelease`,
|
|
31
|
+
releasePatchField: `${lower('SiteRelease')}Patch`,
|
|
32
|
+
releasePayloadField: lower('SiteRelease'),
|
|
33
|
+
updateSite: `update${Prefix}Site`,
|
|
34
|
+
sitePatchField: `${lower('Site')}Patch`,
|
|
35
|
+
sitePayloadField: lower('Site'),
|
|
36
|
+
// Preview procedures are exposed unprefixed; a plane that generates them
|
|
37
|
+
// per scope can override these two.
|
|
38
|
+
provisionPreview: 'provisionSitePreview',
|
|
39
|
+
setPreview: 'setSitePreview',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function lowerFirst(value) {
|
|
43
|
+
return value.charAt(0).toLowerCase() + value.slice(1);
|
|
44
|
+
}
|
|
45
|
+
/** Bulk presigned-upload mutation for one storage plane. */
|
|
46
|
+
function buildBulkUploadMutation(mutation) {
|
|
47
|
+
return `
|
|
48
|
+
mutation BulkUpload($input: ${inputTypeName(mutation)}!) {
|
|
49
|
+
${mutation}(input: $input) {
|
|
50
|
+
files {
|
|
51
|
+
uploadUrl
|
|
52
|
+
fileId
|
|
53
|
+
key
|
|
54
|
+
deduplicated
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
/** `uploadFiles` → `UploadFileBulkInput`, `uploadPlatformFiles` → … */
|
|
61
|
+
function inputTypeName(mutation) {
|
|
62
|
+
const capitalized = mutation.charAt(0).toUpperCase() + mutation.slice(1);
|
|
63
|
+
return `${capitalized.replace(/s$/, '')}BulkInput`;
|
|
64
|
+
}
|
|
65
|
+
/** The site's existing release row, if it has one (the row is UNIQUE per site). */
|
|
66
|
+
function buildReleaseQuery(names) {
|
|
67
|
+
return `
|
|
68
|
+
query SiteRelease($siteId: UUID!) {
|
|
69
|
+
${names.releasesQuery}(where: { siteId: { equalTo: $siteId } }, first: 1) {
|
|
70
|
+
nodes {
|
|
71
|
+
id
|
|
72
|
+
commitId
|
|
73
|
+
storeId
|
|
74
|
+
manifest
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
function buildCreateReleaseMutation(names) {
|
|
81
|
+
return `
|
|
82
|
+
mutation CreateSiteRelease($input: ${typeName(names.createRelease)}Input!) {
|
|
83
|
+
${names.createRelease}(input: $input) {
|
|
84
|
+
${names.releasePayloadField} {
|
|
85
|
+
id
|
|
86
|
+
commitId
|
|
87
|
+
storeId
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
function buildUpdateReleaseMutation(names) {
|
|
94
|
+
return `
|
|
95
|
+
mutation UpdateSiteRelease($input: ${typeName(names.updateRelease)}Input!) {
|
|
96
|
+
${names.updateRelease}(input: $input) {
|
|
97
|
+
${names.releasePayloadField} {
|
|
98
|
+
id
|
|
99
|
+
commitId
|
|
100
|
+
storeId
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
/** Publishing and rollback are the same mutation: move the pointer. */
|
|
107
|
+
function buildPublishMutation(names) {
|
|
108
|
+
return `
|
|
109
|
+
mutation PublishSiteRelease($input: ${typeName(names.updateSite)}Input!) {
|
|
110
|
+
${names.updateSite}(input: $input) {
|
|
111
|
+
${names.sitePayloadField} {
|
|
112
|
+
id
|
|
113
|
+
activeCommitId
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
/** Claims (or reuses) the preview hostname and points the ref at a commit. */
|
|
120
|
+
function buildProvisionPreviewMutation(names) {
|
|
121
|
+
return `
|
|
122
|
+
mutation ProvisionSitePreview($input: ${typeName(names.provisionPreview)}Input!) {
|
|
123
|
+
${names.provisionPreview}(input: $input) {
|
|
124
|
+
result {
|
|
125
|
+
id
|
|
126
|
+
previewRef
|
|
127
|
+
domain {
|
|
128
|
+
hostname
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
/** Moves an existing ref without touching routing. */
|
|
136
|
+
function buildSetPreviewMutation(names) {
|
|
137
|
+
return `
|
|
138
|
+
mutation SetSitePreview($input: ${typeName(names.setPreview)}Input!) {
|
|
139
|
+
${names.setPreview}(input: $input) {
|
|
140
|
+
result
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
function typeName(field) {
|
|
146
|
+
return field.charAt(0).toUpperCase() + field.slice(1);
|
|
147
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed bulk upload.
|
|
3
|
+
*
|
|
4
|
+
* The generic pieces come from `@constructive-io/upload-client` (a presigned
|
|
5
|
+
* PUT); what is added here is the part a static release needs: many files in
|
|
6
|
+
* one round trip, explicit `cas/sha256/<hash>` keys, and skipping every file
|
|
7
|
+
* the server already has. That skip is the whole incremental-deploy win — a
|
|
8
|
+
* one-file change re-uploads one file however large the site is.
|
|
9
|
+
*/
|
|
10
|
+
import { putToPresignedUrl } from '@constructive-io/upload-client';
|
|
11
|
+
import { buildBulkUploadMutation } from './documents';
|
|
12
|
+
import { CAS_KEY_PATTERN } from './manifest';
|
|
13
|
+
import { DeployError } from './types';
|
|
14
|
+
/**
|
|
15
|
+
* Register every file and learn which bytes are missing.
|
|
16
|
+
*
|
|
17
|
+
* Batched because the server caps files per call; one failed batch fails the
|
|
18
|
+
* whole diff rather than leaving a half-registered release.
|
|
19
|
+
*/
|
|
20
|
+
export async function diffCas(files, options) {
|
|
21
|
+
const mutation = buildBulkUploadMutation(options.mutation);
|
|
22
|
+
const missing = [];
|
|
23
|
+
const deduplicated = [];
|
|
24
|
+
for (let offset = 0; offset < files.length; offset += options.batchSize) {
|
|
25
|
+
throwIfAborted(options.signal);
|
|
26
|
+
const batch = files.slice(offset, offset + options.batchSize);
|
|
27
|
+
const data = await options.storage(mutation, {
|
|
28
|
+
input: {
|
|
29
|
+
bucketKey: options.bucketKey,
|
|
30
|
+
files: batch.map((file) => ({
|
|
31
|
+
contentHash: file.hash,
|
|
32
|
+
contentType: file.contentType,
|
|
33
|
+
size: file.size,
|
|
34
|
+
filename: file.path.slice(file.path.lastIndexOf('/') + 1),
|
|
35
|
+
key: file.key,
|
|
36
|
+
})),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const payload = (data[options.mutation] ?? null);
|
|
40
|
+
const results = payload?.files;
|
|
41
|
+
if (!results || results.length !== batch.length) {
|
|
42
|
+
throw new DeployError('UPLOAD_FAILED', `${options.mutation} returned ${results?.length ?? 0} results for ${batch.length} files`);
|
|
43
|
+
}
|
|
44
|
+
results.forEach((result, index) => {
|
|
45
|
+
const file = batch[index];
|
|
46
|
+
// The server does not enforce the CAS key form yet, so a mismatch here
|
|
47
|
+
// would publish a manifest pointing at bytes nobody can serve.
|
|
48
|
+
if (result.key !== file.key || !CAS_KEY_PATTERN.test(result.key)) {
|
|
49
|
+
throw new DeployError('KEY_MISMATCH', `Expected key "${file.key}" for "${file.path}", server returned "${result.key}"`);
|
|
50
|
+
}
|
|
51
|
+
if (result.deduplicated) {
|
|
52
|
+
deduplicated.push(file);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!result.uploadUrl) {
|
|
56
|
+
throw new DeployError('UPLOAD_FAILED', `No upload URL for "${file.path}" and deduplicated=false`);
|
|
57
|
+
}
|
|
58
|
+
missing.push({ file, uploadUrl: result.uploadUrl });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { missing, deduplicated };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* PUT the missing bytes.
|
|
65
|
+
*
|
|
66
|
+
* Any file that cannot be uploaded after its retries aborts the deploy: a
|
|
67
|
+
* manifest written over a partial upload is a site with holes in it.
|
|
68
|
+
*/
|
|
69
|
+
export async function uploadMissing(diff, options) {
|
|
70
|
+
const put = options.putObject ?? defaultPut;
|
|
71
|
+
let bytesUploaded = 0;
|
|
72
|
+
let next = 0;
|
|
73
|
+
const worker = async () => {
|
|
74
|
+
for (;;) {
|
|
75
|
+
throwIfAborted(options.signal);
|
|
76
|
+
const index = next++;
|
|
77
|
+
if (index >= diff.missing.length)
|
|
78
|
+
return;
|
|
79
|
+
const { file, uploadUrl } = diff.missing[index];
|
|
80
|
+
await withRetries(() => put(uploadUrl, file.bytes, file.contentType, options.signal), options.retries, file.path, options.signal);
|
|
81
|
+
bytesUploaded += file.size;
|
|
82
|
+
options.onUploaded?.(file);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const workers = Math.max(1, Math.min(options.concurrency, diff.missing.length));
|
|
86
|
+
await Promise.all(Array.from({ length: workers }, worker));
|
|
87
|
+
return {
|
|
88
|
+
uploaded: diff.missing.length,
|
|
89
|
+
skipped: diff.deduplicated.length,
|
|
90
|
+
bytesUploaded,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const defaultPut = async (url, body, contentType, signal) => {
|
|
94
|
+
// A fresh copy: putToPresignedUrl takes a BodyInit, and a Uint8Array view
|
|
95
|
+
// over a pooled Buffer would send the whole pool.
|
|
96
|
+
const buffer = body.slice().buffer;
|
|
97
|
+
await putToPresignedUrl(url, buffer, contentType, signal);
|
|
98
|
+
};
|
|
99
|
+
async function withRetries(attempt, retries, path, signal) {
|
|
100
|
+
let lastError;
|
|
101
|
+
for (let tries = 0; tries <= retries; tries++) {
|
|
102
|
+
throwIfAborted(signal);
|
|
103
|
+
try {
|
|
104
|
+
await attempt();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
lastError = err;
|
|
109
|
+
if (tries === retries)
|
|
110
|
+
break;
|
|
111
|
+
await delay(2 ** tries * 250);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new DeployError('UPLOAD_FAILED', `Upload of "${path}" failed after ${retries + 1} attempt(s): ${errorMessage(lastError)}`, lastError);
|
|
115
|
+
}
|
|
116
|
+
function delay(ms) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
export function throwIfAborted(signal) {
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
throw new DeployError('ABORTED', 'Deploy was cancelled');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function errorMessage(err) {
|
|
125
|
+
return err instanceof Error ? err.message : String(err);
|
|
126
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension → MIME mapping for static builds.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small: it covers what a site generator emits. Anything else
|
|
5
|
+
* falls back to `application/octet-stream` rather than being guessed — the
|
|
6
|
+
* manifest's `content_type` is what the edge serves verbatim.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
9
|
+
const CONTENT_TYPES = {
|
|
10
|
+
avif: 'image/avif',
|
|
11
|
+
css: 'text/css; charset=utf-8',
|
|
12
|
+
csv: 'text/csv; charset=utf-8',
|
|
13
|
+
eot: 'application/vnd.ms-fontobject',
|
|
14
|
+
gif: 'image/gif',
|
|
15
|
+
htm: 'text/html; charset=utf-8',
|
|
16
|
+
html: 'text/html; charset=utf-8',
|
|
17
|
+
ico: 'image/x-icon',
|
|
18
|
+
jpeg: 'image/jpeg',
|
|
19
|
+
jpg: 'image/jpeg',
|
|
20
|
+
js: 'text/javascript; charset=utf-8',
|
|
21
|
+
json: 'application/json; charset=utf-8',
|
|
22
|
+
map: 'application/json; charset=utf-8',
|
|
23
|
+
md: 'text/markdown; charset=utf-8',
|
|
24
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
25
|
+
mp3: 'audio/mpeg',
|
|
26
|
+
mp4: 'video/mp4',
|
|
27
|
+
otf: 'font/otf',
|
|
28
|
+
pdf: 'application/pdf',
|
|
29
|
+
png: 'image/png',
|
|
30
|
+
svg: 'image/svg+xml',
|
|
31
|
+
ttf: 'font/ttf',
|
|
32
|
+
txt: 'text/plain; charset=utf-8',
|
|
33
|
+
wasm: 'application/wasm',
|
|
34
|
+
webm: 'video/webm',
|
|
35
|
+
webmanifest: 'application/manifest+json',
|
|
36
|
+
webp: 'image/webp',
|
|
37
|
+
woff: 'font/woff',
|
|
38
|
+
woff2: 'font/woff2',
|
|
39
|
+
xml: 'application/xml',
|
|
40
|
+
yaml: 'text/yaml; charset=utf-8',
|
|
41
|
+
yml: 'text/yaml; charset=utf-8',
|
|
42
|
+
zip: 'application/zip',
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the content type of a logical path.
|
|
46
|
+
*
|
|
47
|
+
* @param path - Logical path (`assets/app.js`)
|
|
48
|
+
* @param overrides - Extra or overriding mappings, keyed without the dot
|
|
49
|
+
*/
|
|
50
|
+
export function contentTypeFor(path, overrides) {
|
|
51
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
52
|
+
const dot = base.lastIndexOf('.');
|
|
53
|
+
if (dot <= 0)
|
|
54
|
+
return DEFAULT_CONTENT_TYPE;
|
|
55
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
56
|
+
return overrides?.[ext] ?? CONTENT_TYPES[ext] ?? DEFAULT_CONTENT_TYPE;
|
|
57
|
+
}
|