@springbrand/space 0.1.0-alpha.2 → 0.1.0-alpha.4
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/package.json +1 -1
- package/src/space/deploy-engine.ts +257 -157
- package/src/space/durable-object.ts +79 -13
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Git } from "@cloudflare/shell/git"
|
|
2
2
|
import type { FileSystem } from "@cloudflare/shell"
|
|
3
3
|
import {
|
|
4
|
-
createApp,
|
|
5
4
|
createWorker,
|
|
6
5
|
inferContentType,
|
|
7
6
|
isTextContentType,
|
|
@@ -29,6 +28,8 @@ export interface DeployContext {
|
|
|
29
28
|
sql: SqlStorage
|
|
30
29
|
git: Git
|
|
31
30
|
fs: FileSystem
|
|
31
|
+
assetBucket?: R2Bucket
|
|
32
|
+
assetPrefix?: string
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface BranchDeploymentBundle {
|
|
@@ -36,15 +37,25 @@ export interface BranchDeploymentBundle {
|
|
|
36
37
|
commitHash: string
|
|
37
38
|
mainModule: string
|
|
38
39
|
modules: Record<string, string | Record<string, unknown>>
|
|
39
|
-
assets:
|
|
40
|
+
assets: DeploymentAsset[]
|
|
40
41
|
assetConfig: AssetConfig | undefined
|
|
41
42
|
compatibilityDate: string
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export
|
|
45
|
-
|
|
45
|
+
export interface DeploymentAsset {
|
|
46
|
+
path: string
|
|
47
|
+
contentType: string | undefined
|
|
48
|
+
etag: string
|
|
49
|
+
size: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type AssetWriter = (
|
|
53
|
+
asset: Omit<DeploymentAsset, "etag">,
|
|
54
|
+
bytes: Uint8Array,
|
|
55
|
+
) => Promise<string>
|
|
46
56
|
|
|
47
57
|
const DEFAULT_COMPATIBILITY_DATE = "2025-04-01"
|
|
58
|
+
export const MAX_STATIC_ASSET_BYTES = 25 * 1024 * 1024
|
|
48
59
|
const STATIC_APP_MAIN = ".springbrand/static-app.ts"
|
|
49
60
|
const STATIC_APP_SOURCE = `import { DurableObject } from "cloudflare:workers"
|
|
50
61
|
|
|
@@ -101,10 +112,12 @@ async function readBranchFiles(
|
|
|
101
112
|
branch: string,
|
|
102
113
|
appRoot = "",
|
|
103
114
|
revision?: string,
|
|
115
|
+
writeAsset?: AssetWriter,
|
|
104
116
|
): Promise<{
|
|
105
117
|
commitHash: string
|
|
106
118
|
files: Record<string, string>
|
|
107
|
-
assets:
|
|
119
|
+
assets: DeploymentAsset[]
|
|
120
|
+
config: ParsedWranglerConfig
|
|
108
121
|
}> {
|
|
109
122
|
const log = await ctx.git.log({ ref: branch, depth: revision ? 1000 : 1 })
|
|
110
123
|
if (log.length === 0) {
|
|
@@ -119,36 +132,81 @@ async function readBranchFiles(
|
|
|
119
132
|
|
|
120
133
|
await ctx.git.checkout({ ref: revision ? commitHash : branch, force: true })
|
|
121
134
|
|
|
122
|
-
const files: Record<string, string> = {}
|
|
123
|
-
const assets: WebsiteAssets = {}
|
|
124
135
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
let bytes: Uint8Array
|
|
133
|
-
try {
|
|
134
|
-
bytes = await ctx.fs.readFileBytes(fileInfo.path)
|
|
135
|
-
} catch {
|
|
136
|
-
continue
|
|
137
|
-
}
|
|
138
|
-
const path = fileInfo.path.startsWith("/") ? fileInfo.path.slice(1) : fileInfo.path
|
|
136
|
+
const entries: Array<{ sourcePath: string; path: string; size: number }> = []
|
|
137
|
+
for (const info of await globInfos(ctx.fs, "**/*")) {
|
|
138
|
+
if (info.type !== "file") continue
|
|
139
|
+
if (info.path.startsWith("/.git/") || info.path === "/.git") continue
|
|
140
|
+
if (info.path.startsWith("/.afs/") || info.path === "/.afs") continue
|
|
141
|
+
|
|
142
|
+
const path = info.path.startsWith("/") ? info.path.slice(1) : info.path
|
|
139
143
|
if (appRoot) {
|
|
140
144
|
if (path !== appRoot && !path.startsWith(`${appRoot}/`)) continue
|
|
141
145
|
const relativePath = path.slice(appRoot.length + 1)
|
|
142
|
-
|
|
146
|
+
entries.push({ sourcePath: info.path, path: relativePath, size: info.size })
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
entries.push({ sourcePath: info.path, path, size: info.size })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const configFiles: Record<string, string> = {}
|
|
153
|
+
for (const name of ["wrangler.json", "wrangler.jsonc", "wrangler.toml"]) {
|
|
154
|
+
const entry = entries.find((candidate) => candidate.path === name)
|
|
155
|
+
if (entry) configFiles[name] = await ctx.fs.readFile(entry.sourcePath)
|
|
156
|
+
}
|
|
157
|
+
let config = parseWebsiteConfig(configFiles)
|
|
158
|
+
const assetPaths = entries.map((entry) => entry.path)
|
|
159
|
+
const assetsDirectory = inferAssetsDirectory(config, configFiles, assetPaths)
|
|
160
|
+
if (!hasWrangler(configFiles) && assetsDirectory !== null) {
|
|
161
|
+
config = staticWebsiteConfig(assetsDirectory)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const files: Record<string, string> = { ...configFiles }
|
|
165
|
+
const assets: DeploymentAsset[] = []
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (isAssetPath(entry.path, assetsDirectory)) {
|
|
168
|
+
if (entry.size > MAX_STATIC_ASSET_BYTES) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Static asset "${entry.path}" is ${entry.size} bytes; the limit is ${MAX_STATIC_ASSET_BYTES} bytes`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
const bytes = await ctx.fs.readFileBytes(entry.sourcePath)
|
|
174
|
+
const asset: Omit<DeploymentAsset, "etag"> = {
|
|
175
|
+
path: toAssetPath(entry.path, assetsDirectory!),
|
|
176
|
+
contentType: inferContentType(entry.path),
|
|
177
|
+
size: bytes.byteLength,
|
|
178
|
+
}
|
|
179
|
+
const etag = writeAsset
|
|
180
|
+
? await writeAsset(asset, bytes)
|
|
181
|
+
: await computeAssetEtag(bytes)
|
|
182
|
+
assets.push({ ...asset, etag })
|
|
143
183
|
continue
|
|
144
184
|
}
|
|
145
|
-
|
|
185
|
+
|
|
186
|
+
const contentType = inferContentType(entry.path)
|
|
187
|
+
if (contentType !== undefined && !isTextContentType(contentType)) continue
|
|
188
|
+
if (files[entry.path] === undefined) {
|
|
189
|
+
files[entry.path] = await ctx.fs.readFile(entry.sourcePath)
|
|
190
|
+
}
|
|
146
191
|
}
|
|
192
|
+
|
|
193
|
+
if (!hasWrangler(configFiles) && assetsDirectory !== null) {
|
|
194
|
+
files["wrangler.json"] = JSON.stringify({
|
|
195
|
+
main: config.main,
|
|
196
|
+
compatibility_date: config.compatibilityDate,
|
|
197
|
+
assets: {
|
|
198
|
+
directory: config.assets!.directory,
|
|
199
|
+
html_handling: config.assets!.htmlHandling,
|
|
200
|
+
not_found_handling: config.assets!.notFoundHandling,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
files[STATIC_APP_MAIN] = STATIC_APP_SOURCE
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { commitHash, files, assets, config }
|
|
147
207
|
} finally {
|
|
148
208
|
if (revision) await ctx.git.checkout({ ref: branch, force: true })
|
|
149
209
|
}
|
|
150
|
-
|
|
151
|
-
return { commitHash, files, assets }
|
|
152
210
|
}
|
|
153
211
|
|
|
154
212
|
// ─── Deploy a branch ────────────────────────────────────────────────────────
|
|
@@ -167,10 +225,43 @@ async function deployBranch(
|
|
|
167
225
|
return jsonResponse({ error: "branch is required" }, 400)
|
|
168
226
|
}
|
|
169
227
|
|
|
228
|
+
const assetDeploymentId = crypto.randomUUID()
|
|
229
|
+
const assetRoot = ctx.assetPrefix
|
|
230
|
+
? `${ctx.assetPrefix}/deployments/${assetDeploymentId}`
|
|
231
|
+
: undefined
|
|
170
232
|
let bundle: BranchDeploymentBundle
|
|
171
233
|
try {
|
|
172
|
-
bundle = await buildBranchDeployment(
|
|
234
|
+
bundle = await buildBranchDeployment(
|
|
235
|
+
ctx,
|
|
236
|
+
branch,
|
|
237
|
+
body.appRoot,
|
|
238
|
+
body.revision,
|
|
239
|
+
async (asset, bytes) => {
|
|
240
|
+
if (!ctx.assetBucket || !assetRoot) {
|
|
241
|
+
throw new Error("Static preview deployment requires the WORKSPACE_R2 binding")
|
|
242
|
+
}
|
|
243
|
+
const key = `${assetRoot}${asset.path}`
|
|
244
|
+
const uploaded = await ctx.assetBucket.put(key, bytes, {
|
|
245
|
+
...(asset.contentType ? { httpMetadata: { contentType: asset.contentType } } : {}),
|
|
246
|
+
})
|
|
247
|
+
ctx.sql.exec(
|
|
248
|
+
`INSERT INTO deployment_assets
|
|
249
|
+
(deployment_id, path, r2_key, content_type, etag, size)
|
|
250
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
251
|
+
assetDeploymentId,
|
|
252
|
+
asset.path,
|
|
253
|
+
key,
|
|
254
|
+
asset.contentType ?? null,
|
|
255
|
+
uploaded.etag,
|
|
256
|
+
asset.size,
|
|
257
|
+
)
|
|
258
|
+
return uploaded.etag
|
|
259
|
+
},
|
|
260
|
+
)
|
|
173
261
|
} catch (e) {
|
|
262
|
+
await discardAssetDeployment(ctx, assetDeploymentId, assetRoot).catch((cleanupError) => {
|
|
263
|
+
console.warn("Failed to clean incomplete static preview deployment", cleanupError)
|
|
264
|
+
})
|
|
174
265
|
const message = e instanceof Error ? e.message : String(e)
|
|
175
266
|
const separator = message.indexOf(": ")
|
|
176
267
|
return jsonResponse({
|
|
@@ -184,31 +275,62 @@ async function deployBranch(
|
|
|
184
275
|
commitHash,
|
|
185
276
|
mainModule,
|
|
186
277
|
modules: serializedModules,
|
|
187
|
-
assets
|
|
278
|
+
assets,
|
|
188
279
|
assetConfig,
|
|
189
280
|
compatibilityDate: compatDate,
|
|
190
281
|
} = bundle
|
|
191
282
|
|
|
192
283
|
const now = Date.now()
|
|
193
|
-
ctx.sql
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
284
|
+
const oldDeployment = ctx.sql
|
|
285
|
+
.exec(
|
|
286
|
+
"SELECT asset_deployment_id FROM deployments WHERE branch = ?",
|
|
287
|
+
deploymentId,
|
|
288
|
+
)
|
|
289
|
+
.toArray()[0]
|
|
290
|
+
const oldAssetDeploymentId = oldDeployment?.asset_deployment_id as string | undefined
|
|
291
|
+
try {
|
|
292
|
+
ctx.sql.exec(
|
|
293
|
+
`INSERT OR REPLACE INTO deployments
|
|
294
|
+
(branch, commit_hash, main_module, modules, assets, asset_deployment_id, asset_count,
|
|
295
|
+
asset_config, compatibility_date, deployed_at)
|
|
296
|
+
VALUES (?, ?, ?, ?, '{}', ?, ?, ?, ?, ?)`,
|
|
297
|
+
deploymentId,
|
|
298
|
+
commitHash,
|
|
299
|
+
mainModule,
|
|
300
|
+
JSON.stringify(serializedModules),
|
|
301
|
+
assets.length > 0 ? assetDeploymentId : "",
|
|
302
|
+
assets.length,
|
|
303
|
+
assetConfig ? JSON.stringify(assetConfig) : "{}",
|
|
304
|
+
compatDate,
|
|
305
|
+
now
|
|
306
|
+
)
|
|
307
|
+
} catch (error) {
|
|
308
|
+
await discardAssetDeployment(ctx, assetDeploymentId, assetRoot).catch((cleanupError) => {
|
|
309
|
+
console.warn("Failed to clean unactivated static preview deployment", cleanupError)
|
|
310
|
+
})
|
|
311
|
+
throw error
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (assets.length === 0) {
|
|
315
|
+
await discardAssetDeployment(ctx, assetDeploymentId, assetRoot).catch((error) => {
|
|
316
|
+
console.warn("Failed to clean empty static preview deployment", error)
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
if (oldAssetDeploymentId && oldAssetDeploymentId !== assetDeploymentId) {
|
|
320
|
+
await discardAssetDeployment(
|
|
321
|
+
ctx,
|
|
322
|
+
oldAssetDeploymentId,
|
|
323
|
+
ctx.assetPrefix ? `${ctx.assetPrefix}/deployments/${oldAssetDeploymentId}` : undefined,
|
|
324
|
+
).catch((error) => {
|
|
325
|
+
console.warn("Failed to clean replaced static preview deployment", error)
|
|
326
|
+
})
|
|
327
|
+
}
|
|
206
328
|
|
|
207
329
|
return jsonResponse({
|
|
208
330
|
branch: deploymentId,
|
|
209
331
|
commit_hash: commitHash,
|
|
210
332
|
main_module: mainModule,
|
|
211
|
-
has_assets:
|
|
333
|
+
has_assets: assets.length > 0,
|
|
212
334
|
compatibility_date: compatDate,
|
|
213
335
|
deployed_at: new Date(now).toISOString(),
|
|
214
336
|
})
|
|
@@ -219,9 +341,16 @@ export async function buildBranchDeployment(
|
|
|
219
341
|
branch: string,
|
|
220
342
|
appRoot?: string | null,
|
|
221
343
|
revision?: string,
|
|
344
|
+
writeAsset?: AssetWriter,
|
|
222
345
|
): Promise<BranchDeploymentBundle> {
|
|
223
346
|
const root = normalizeAppRoot(appRoot)
|
|
224
|
-
const { commitHash, files, assets } = await readBranchFiles(
|
|
347
|
+
const { commitHash, files, assets, config } = await readBranchFiles(
|
|
348
|
+
ctx,
|
|
349
|
+
branch,
|
|
350
|
+
root,
|
|
351
|
+
revision,
|
|
352
|
+
writeAsset,
|
|
353
|
+
)
|
|
225
354
|
if (Object.keys(files).length === 0) {
|
|
226
355
|
throw new Error(
|
|
227
356
|
root
|
|
@@ -231,13 +360,13 @@ export async function buildBranchDeployment(
|
|
|
231
360
|
}
|
|
232
361
|
|
|
233
362
|
try {
|
|
234
|
-
const result = await buildWebsite(files,
|
|
363
|
+
const result = await buildWebsite(files, config)
|
|
235
364
|
return {
|
|
236
365
|
branch: revision ?? branch,
|
|
237
366
|
commitHash,
|
|
238
367
|
mainModule: result.mainModule,
|
|
239
368
|
modules: result.modules,
|
|
240
|
-
assets
|
|
369
|
+
assets,
|
|
241
370
|
assetConfig: result.assetConfig,
|
|
242
371
|
compatibilityDate: result.compatibilityDate,
|
|
243
372
|
}
|
|
@@ -248,20 +377,15 @@ export async function buildBranchDeployment(
|
|
|
248
377
|
|
|
249
378
|
async function buildWebsite(
|
|
250
379
|
sourceFiles: Record<string, string>,
|
|
251
|
-
|
|
380
|
+
config: ParsedWranglerConfig,
|
|
252
381
|
) {
|
|
253
|
-
const { files, config } = prepareWebsite(sourceFiles, sourceAssets)
|
|
254
382
|
if (config.durableObjects?.length) {
|
|
255
383
|
throw new Error(
|
|
256
384
|
"Durable Object bindings are not allowed in wrangler.json: The platform runs your app as a single Durable Object (`export class App extends DurableObject` from your main module). Do not declare `durable_objects.bindings`."
|
|
257
385
|
)
|
|
258
386
|
}
|
|
259
387
|
|
|
260
|
-
const
|
|
261
|
-
const assets = assetsDirectory === null
|
|
262
|
-
? {}
|
|
263
|
-
: collectAssets(sourceAssets, assetsDirectory)
|
|
264
|
-
const assetConfig: AssetConfig | undefined = assetsDirectory === null
|
|
388
|
+
const assetConfig: AssetConfig | undefined = config.assets?.directory === undefined
|
|
265
389
|
? undefined
|
|
266
390
|
: {
|
|
267
391
|
...(config.assets?.notFoundHandling && {
|
|
@@ -272,81 +396,48 @@ async function buildWebsite(
|
|
|
272
396
|
}),
|
|
273
397
|
}
|
|
274
398
|
|
|
275
|
-
|
|
276
|
-
const result = await createApp({
|
|
277
|
-
files,
|
|
278
|
-
assets,
|
|
279
|
-
assetConfig,
|
|
280
|
-
server: config.main,
|
|
281
|
-
})
|
|
282
|
-
return {
|
|
283
|
-
mainModule: result.mainModule,
|
|
284
|
-
modules: serializeModules(result.modules),
|
|
285
|
-
assets: result.assets,
|
|
286
|
-
assetConfig: result.assetConfig,
|
|
287
|
-
compatibilityDate: config.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const result = await createWorker({ files, entryPoint: config.main })
|
|
399
|
+
const result = await createWorker({ files: sourceFiles, entryPoint: config.main })
|
|
292
400
|
const modules = serializeModules(result.modules)
|
|
293
401
|
modules["__STATIC_CONTENT_MANIFEST"] ??= { text: "{}" }
|
|
294
402
|
return {
|
|
295
403
|
mainModule: result.mainModule,
|
|
296
404
|
modules,
|
|
297
|
-
assets: {},
|
|
298
405
|
assetConfig,
|
|
299
406
|
compatibilityDate: config.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
|
|
300
407
|
}
|
|
301
408
|
}
|
|
302
409
|
|
|
303
|
-
function
|
|
304
|
-
files: Record<string, string>,
|
|
305
|
-
assets: WebsiteAssets,
|
|
306
|
-
): { files: Record<string, string>; config: ParsedWranglerConfig } {
|
|
307
|
-
let config: ParsedWranglerConfig
|
|
410
|
+
function parseWebsiteConfig(files: Record<string, string>): ParsedWranglerConfig {
|
|
308
411
|
try {
|
|
309
|
-
|
|
412
|
+
return parseWranglerConfig(files)
|
|
310
413
|
} catch (error) {
|
|
311
414
|
if (error instanceof WranglerConfigError) {
|
|
312
415
|
throw new Error(`Invalid wrangler.json: ${error.message}`)
|
|
313
416
|
}
|
|
314
417
|
throw error
|
|
315
418
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function inferAssetsDirectory(
|
|
422
|
+
config: ParsedWranglerConfig,
|
|
423
|
+
configFiles: Record<string, string>,
|
|
424
|
+
paths: string[],
|
|
425
|
+
): string | null {
|
|
426
|
+
if (hasWrangler(configFiles)) return normalizeAssetsDirectory(config.assets?.directory)
|
|
427
|
+
if (paths.includes("index.html")) return ""
|
|
428
|
+
if (paths.includes("public/index.html")) return "public"
|
|
429
|
+
return null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function staticWebsiteConfig(directory: string): ParsedWranglerConfig {
|
|
433
|
+
return {
|
|
331
434
|
main: STATIC_APP_MAIN,
|
|
332
435
|
compatibilityDate: DEFAULT_COMPATIBILITY_DATE,
|
|
333
|
-
assets:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
...files,
|
|
338
|
-
"wrangler.json": JSON.stringify({
|
|
339
|
-
main: config.main,
|
|
340
|
-
compatibility_date: config.compatibilityDate,
|
|
341
|
-
assets: {
|
|
342
|
-
directory: generatedAssets.directory,
|
|
343
|
-
html_handling: generatedAssets.htmlHandling,
|
|
344
|
-
not_found_handling: generatedAssets.notFoundHandling,
|
|
345
|
-
},
|
|
346
|
-
}),
|
|
347
|
-
[STATIC_APP_MAIN]: STATIC_APP_SOURCE,
|
|
436
|
+
assets: {
|
|
437
|
+
directory: directory || ".",
|
|
438
|
+
htmlHandling: "auto-trailing-slash",
|
|
439
|
+
notFoundHandling: "404-page",
|
|
348
440
|
},
|
|
349
|
-
config,
|
|
350
441
|
}
|
|
351
442
|
}
|
|
352
443
|
|
|
@@ -361,15 +452,20 @@ function normalizeAssetsDirectory(directory?: string): string | null {
|
|
|
361
452
|
return normalized === "." ? "" : normalized
|
|
362
453
|
}
|
|
363
454
|
|
|
364
|
-
function
|
|
365
|
-
return
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
455
|
+
function isAssetPath(path: string, directory: string | null): boolean {
|
|
456
|
+
if (directory === null) return false
|
|
457
|
+
return directory === "" || path.startsWith(`${directory}/`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function toAssetPath(path: string, directory: string): string {
|
|
461
|
+
return directory ? `/${path.slice(directory.length + 1)}` : `/${path}`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function computeAssetEtag(bytes: Uint8Array): Promise<string> {
|
|
465
|
+
const digest = await crypto.subtle.digest("SHA-256", exactBuffer(bytes))
|
|
466
|
+
return [...new Uint8Array(digest).slice(0, 8)]
|
|
467
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
468
|
+
.join("")
|
|
373
469
|
}
|
|
374
470
|
|
|
375
471
|
function serializeModules(modules: Modules): Record<string, string | Record<string, unknown>> {
|
|
@@ -394,7 +490,9 @@ async function getDeployment(
|
|
|
394
490
|
|
|
395
491
|
const row = ctx.sql
|
|
396
492
|
.exec(
|
|
397
|
-
|
|
493
|
+
`SELECT branch, commit_hash, main_module, modules, assets, asset_count,
|
|
494
|
+
asset_config, deployed_at
|
|
495
|
+
FROM deployments WHERE branch = ?`,
|
|
398
496
|
branch
|
|
399
497
|
)
|
|
400
498
|
.toArray()
|
|
@@ -404,13 +502,13 @@ async function getDeployment(
|
|
|
404
502
|
}
|
|
405
503
|
|
|
406
504
|
const r = row[0]
|
|
407
|
-
const
|
|
505
|
+
const legacyAssets = JSON.parse((r.assets as string) || "{}")
|
|
408
506
|
return jsonResponse({
|
|
409
507
|
branch: r.branch as string,
|
|
410
508
|
commit_hash: r.commit_hash as string,
|
|
411
509
|
main_module: r.main_module as string,
|
|
412
510
|
modules: JSON.parse(r.modules as string),
|
|
413
|
-
has_assets: Object.keys(
|
|
511
|
+
has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
|
|
414
512
|
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
415
513
|
})
|
|
416
514
|
}
|
|
@@ -419,16 +517,19 @@ async function getDeployment(
|
|
|
419
517
|
|
|
420
518
|
async function listDeployments(ctx: DeployContext): Promise<Response> {
|
|
421
519
|
const rows = ctx.sql
|
|
422
|
-
.exec(
|
|
520
|
+
.exec(
|
|
521
|
+
`SELECT branch, commit_hash, main_module, assets, asset_count, deployed_at
|
|
522
|
+
FROM deployments ORDER BY deployed_at DESC`,
|
|
523
|
+
)
|
|
423
524
|
.toArray()
|
|
424
525
|
|
|
425
526
|
const deployments = rows.map((r) => {
|
|
426
|
-
const
|
|
527
|
+
const legacyAssets = JSON.parse((r.assets as string) || "{}")
|
|
427
528
|
return {
|
|
428
529
|
branch: r.branch as string,
|
|
429
530
|
commit_hash: r.commit_hash as string,
|
|
430
531
|
main_module: r.main_module as string,
|
|
431
|
-
has_assets: Object.keys(
|
|
532
|
+
has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
|
|
432
533
|
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
433
534
|
}
|
|
434
535
|
})
|
|
@@ -448,6 +549,9 @@ async function undeployBranch(
|
|
|
448
549
|
return jsonResponse({ error: "branch is required" }, 400)
|
|
449
550
|
}
|
|
450
551
|
|
|
552
|
+
const row = ctx.sql
|
|
553
|
+
.exec("SELECT asset_deployment_id FROM deployments WHERE branch = ?", branch)
|
|
554
|
+
.toArray()[0]
|
|
451
555
|
const result = ctx.sql.exec(
|
|
452
556
|
"DELETE FROM deployments WHERE branch = ?",
|
|
453
557
|
branch
|
|
@@ -457,48 +561,44 @@ async function undeployBranch(
|
|
|
457
561
|
return jsonResponse({ error: `No deployment found for branch "${branch}"` }, 404)
|
|
458
562
|
}
|
|
459
563
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
for (const [path, content] of Object.entries(assets)) {
|
|
470
|
-
out[path] = typeof content === "string"
|
|
471
|
-
? content
|
|
472
|
-
: { base64: bytesToBase64(new Uint8Array(content)) }
|
|
564
|
+
const assetDeploymentId = row?.asset_deployment_id as string | undefined
|
|
565
|
+
if (assetDeploymentId) {
|
|
566
|
+
await discardAssetDeployment(
|
|
567
|
+
ctx,
|
|
568
|
+
assetDeploymentId,
|
|
569
|
+
ctx.assetPrefix ? `${ctx.assetPrefix}/deployments/${assetDeploymentId}` : undefined,
|
|
570
|
+
).catch((error) => {
|
|
571
|
+
console.warn("Failed to clean undeployed static preview assets", error)
|
|
572
|
+
})
|
|
473
573
|
}
|
|
474
|
-
|
|
574
|
+
|
|
575
|
+
return jsonResponse({ ok: true, branch })
|
|
475
576
|
}
|
|
476
577
|
|
|
477
|
-
function
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
578
|
+
async function discardAssetDeployment(
|
|
579
|
+
ctx: DeployContext,
|
|
580
|
+
deploymentId: string,
|
|
581
|
+
root: string | undefined,
|
|
582
|
+
): Promise<void> {
|
|
583
|
+
try {
|
|
584
|
+
if (ctx.assetBucket && root) {
|
|
585
|
+
let cursor: string | undefined
|
|
586
|
+
do {
|
|
587
|
+
const page = await ctx.assetBucket.list({
|
|
588
|
+
prefix: `${root}/`,
|
|
589
|
+
limit: 1000,
|
|
590
|
+
...(cursor ? { cursor } : {}),
|
|
591
|
+
})
|
|
592
|
+
const keys = page.objects.map((object) => object.key)
|
|
593
|
+
if (keys.length > 0) await ctx.assetBucket.delete(keys)
|
|
594
|
+
cursor = page.truncated ? page.cursor : undefined
|
|
595
|
+
} while (cursor)
|
|
596
|
+
}
|
|
597
|
+
} finally {
|
|
598
|
+
ctx.sql.exec("DELETE FROM deployment_assets WHERE deployment_id = ?", deploymentId)
|
|
488
599
|
}
|
|
489
|
-
const text = new TextDecoder().decode(bytes)
|
|
490
|
-
files[path] = text
|
|
491
|
-
assets[path] = text
|
|
492
600
|
}
|
|
493
601
|
|
|
494
602
|
function exactBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
495
603
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
496
604
|
}
|
|
497
|
-
|
|
498
|
-
function bytesToBase64(bytes: Uint8Array): string {
|
|
499
|
-
let binary = ""
|
|
500
|
-
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
501
|
-
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
|
|
502
|
-
}
|
|
503
|
-
return btoa(binary)
|
|
504
|
-
}
|
|
@@ -7,10 +7,16 @@ import {
|
|
|
7
7
|
handleDeployCommand,
|
|
8
8
|
type BranchDeploymentBundle,
|
|
9
9
|
type DeployContext,
|
|
10
|
-
type SerializedAsset,
|
|
11
10
|
} from "./deploy-engine"
|
|
12
11
|
import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
|
|
13
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
handleAssetRequest,
|
|
14
|
+
buildAssetManifest,
|
|
15
|
+
createMemoryStorage,
|
|
16
|
+
type AssetConfig,
|
|
17
|
+
type AssetManifest,
|
|
18
|
+
type AssetStorage,
|
|
19
|
+
} from "@cloudflare/worker-bundler"
|
|
14
20
|
import {
|
|
15
21
|
buildInspectorWrapperSource,
|
|
16
22
|
VIBE_APP_MODULE,
|
|
@@ -94,11 +100,15 @@ interface DeploymentRow {
|
|
|
94
100
|
commitHash: string
|
|
95
101
|
mainModule: string
|
|
96
102
|
modules: Record<string, string | Record<string, unknown>>
|
|
97
|
-
|
|
103
|
+
legacyAssets: Record<string, string | ArrayBuffer>
|
|
104
|
+
assetDeploymentId: string
|
|
105
|
+
assetCount: number
|
|
98
106
|
assetConfig: AssetConfig
|
|
99
107
|
compatibilityDate: string
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
type SerializedAsset = string | { base64: string }
|
|
111
|
+
|
|
102
112
|
function deserializeAssets(
|
|
103
113
|
assets: Record<string, SerializedAsset>,
|
|
104
114
|
): Record<string, string | ArrayBuffer> {
|
|
@@ -141,8 +151,8 @@ const isReservedPath = isReservedSpacePath
|
|
|
141
151
|
// these on every request is wasteful (CWE-770 amplification under a preview
|
|
142
152
|
// flood), so we cache them per `branch:commitHash` with an LRU + TTL bound.
|
|
143
153
|
type CachedAssets = {
|
|
144
|
-
manifest:
|
|
145
|
-
storage:
|
|
154
|
+
manifest: AssetManifest
|
|
155
|
+
storage: AssetStorage
|
|
146
156
|
expiresAt: number
|
|
147
157
|
}
|
|
148
158
|
const ASSET_CACHE_MAX_ENTRIES = 8
|
|
@@ -209,14 +219,27 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
209
219
|
main_module TEXT NOT NULL,
|
|
210
220
|
modules TEXT NOT NULL,
|
|
211
221
|
assets TEXT NOT NULL DEFAULT '{}',
|
|
222
|
+
asset_deployment_id TEXT NOT NULL DEFAULT '',
|
|
223
|
+
asset_count INTEGER NOT NULL DEFAULT 0,
|
|
212
224
|
asset_config TEXT NOT NULL DEFAULT '{}',
|
|
213
225
|
compatibility_date TEXT NOT NULL DEFAULT '',
|
|
214
226
|
deployed_at INTEGER NOT NULL
|
|
215
|
-
)
|
|
227
|
+
);
|
|
228
|
+
CREATE TABLE IF NOT EXISTS deployment_assets (
|
|
229
|
+
deployment_id TEXT NOT NULL,
|
|
230
|
+
path TEXT NOT NULL,
|
|
231
|
+
r2_key TEXT NOT NULL,
|
|
232
|
+
content_type TEXT,
|
|
233
|
+
etag TEXT NOT NULL,
|
|
234
|
+
size INTEGER NOT NULL,
|
|
235
|
+
PRIMARY KEY (deployment_id, path)
|
|
236
|
+
);
|
|
216
237
|
`)
|
|
217
238
|
|
|
218
239
|
// Migrate existing deployments tables that lack new columns
|
|
219
240
|
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN assets TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
241
|
+
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_deployment_id TEXT NOT NULL DEFAULT ''`) } catch {}
|
|
242
|
+
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_count INTEGER NOT NULL DEFAULT 0`) } catch {}
|
|
220
243
|
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_config TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
221
244
|
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN compatibility_date TEXT NOT NULL DEFAULT ''`) } catch {}
|
|
222
245
|
|
|
@@ -634,7 +657,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
634
657
|
const prefix = `${spaceR2Prefix(this.spaceName)}/`
|
|
635
658
|
let cursor: string | undefined
|
|
636
659
|
do {
|
|
637
|
-
const page = await bucket.list({ prefix, ...(cursor ? { cursor } : {}) })
|
|
660
|
+
const page = await bucket.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) })
|
|
638
661
|
const keys = page.objects.map((object) => object.key)
|
|
639
662
|
if (keys.length > 0) await bucket.delete(keys)
|
|
640
663
|
cursor = page.truncated ? page.cursor : undefined
|
|
@@ -1106,10 +1129,13 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1106
1129
|
sql: this.ctx.storage.sql,
|
|
1107
1130
|
git: this.git,
|
|
1108
1131
|
fs: this.fs,
|
|
1132
|
+
assetBucket: this.env.WORKSPACE_R2,
|
|
1133
|
+
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1109
1134
|
}
|
|
1110
1135
|
const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
|
|
1111
1136
|
const data = await res.json() as Record<string, unknown>
|
|
1112
1137
|
if (typeof data.error === "string") throw new Error(data.error)
|
|
1138
|
+
this.assetCache.clear()
|
|
1113
1139
|
const deploymentId = data.branch as string
|
|
1114
1140
|
data.preview_url =
|
|
1115
1141
|
`/space/${this.spaceName}/preview/${encodeURIComponent(deploymentId)}/`
|
|
@@ -1150,8 +1176,11 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1150
1176
|
sql: this.ctx.storage.sql,
|
|
1151
1177
|
git: this.git,
|
|
1152
1178
|
fs: this.overlay,
|
|
1179
|
+
assetBucket: this.env.WORKSPACE_R2,
|
|
1180
|
+
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1153
1181
|
}
|
|
1154
1182
|
const res = await handleDeployCommand(ctx, "undeploy", fakeRequest)
|
|
1183
|
+
this.assetCache.clear()
|
|
1155
1184
|
return res.json()
|
|
1156
1185
|
}
|
|
1157
1186
|
|
|
@@ -1204,7 +1233,9 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1204
1233
|
private readDeployment(branch: string): DeploymentRow | null {
|
|
1205
1234
|
const rows = this.ctx.storage.sql
|
|
1206
1235
|
.exec(
|
|
1207
|
-
|
|
1236
|
+
`SELECT branch, commit_hash, main_module, modules, assets,
|
|
1237
|
+
asset_deployment_id, asset_count, asset_config, compatibility_date
|
|
1238
|
+
FROM deployments WHERE branch = ?`,
|
|
1208
1239
|
branch,
|
|
1209
1240
|
)
|
|
1210
1241
|
.toArray()
|
|
@@ -1215,9 +1246,11 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1215
1246
|
commitHash: r.commit_hash as string,
|
|
1216
1247
|
mainModule: r.main_module as string,
|
|
1217
1248
|
modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
|
|
1218
|
-
|
|
1249
|
+
legacyAssets: deserializeAssets(
|
|
1219
1250
|
JSON.parse((r.assets as string) || "{}") as Record<string, SerializedAsset>,
|
|
1220
1251
|
),
|
|
1252
|
+
assetDeploymentId: (r.asset_deployment_id as string) || "",
|
|
1253
|
+
assetCount: Number(r.asset_count ?? 0),
|
|
1221
1254
|
assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
|
|
1222
1255
|
compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
|
|
1223
1256
|
}
|
|
@@ -1255,7 +1288,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1255
1288
|
// Serve static assets host-side before forwarding to the Facet. The built
|
|
1256
1289
|
// manifest/storage are cached per deployment so repeat asset reads don't
|
|
1257
1290
|
// re-spin the build on every request.
|
|
1258
|
-
if (Object.keys(dep.
|
|
1291
|
+
if (dep.assetCount > 0 || Object.keys(dep.legacyAssets).length > 0) {
|
|
1259
1292
|
const { manifest, storage } = await this.getCachedAssets(dep)
|
|
1260
1293
|
const assetResponse = await handleAssetRequest(request, manifest, storage, dep.assetConfig)
|
|
1261
1294
|
if (assetResponse) return assetResponse
|
|
@@ -1281,7 +1314,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1281
1314
|
* redeploy (new commit) transparently rebuilds. Bounded by an LRU cap + TTL.
|
|
1282
1315
|
*/
|
|
1283
1316
|
private async getCachedAssets(dep: DeploymentRow): Promise<CachedAssets> {
|
|
1284
|
-
const key = `${dep.branch}:${dep.commitHash}`
|
|
1317
|
+
const key = `${dep.branch}:${dep.assetDeploymentId || dep.commitHash}`
|
|
1285
1318
|
const now = Date.now()
|
|
1286
1319
|
|
|
1287
1320
|
const cached = this.assetCache.get(key)
|
|
@@ -1292,8 +1325,39 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1292
1325
|
return cached
|
|
1293
1326
|
}
|
|
1294
1327
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1328
|
+
let manifest: AssetManifest
|
|
1329
|
+
let storage: AssetStorage
|
|
1330
|
+
if (dep.assetDeploymentId) {
|
|
1331
|
+
const bucket = this.env.WORKSPACE_R2
|
|
1332
|
+
if (!bucket) throw new Error("Static preview requires the WORKSPACE_R2 binding")
|
|
1333
|
+
const rows = this.ctx.storage.sql
|
|
1334
|
+
.exec(
|
|
1335
|
+
`SELECT path, r2_key, content_type, etag
|
|
1336
|
+
FROM deployment_assets WHERE deployment_id = ?`,
|
|
1337
|
+
dep.assetDeploymentId,
|
|
1338
|
+
)
|
|
1339
|
+
.toArray()
|
|
1340
|
+
const keys = new Map<string, string>()
|
|
1341
|
+
manifest = new Map()
|
|
1342
|
+
for (const row of rows) {
|
|
1343
|
+
const path = row.path as string
|
|
1344
|
+
keys.set(path, row.r2_key as string)
|
|
1345
|
+
manifest.set(path, {
|
|
1346
|
+
contentType: (row.content_type as string | null) ?? undefined,
|
|
1347
|
+
etag: row.etag as string,
|
|
1348
|
+
})
|
|
1349
|
+
}
|
|
1350
|
+
storage = {
|
|
1351
|
+
async get(pathname) {
|
|
1352
|
+
const r2Key = keys.get(pathname)
|
|
1353
|
+
if (!r2Key) return null
|
|
1354
|
+
return (await bucket.get(r2Key))?.body ?? null
|
|
1355
|
+
},
|
|
1356
|
+
}
|
|
1357
|
+
} else {
|
|
1358
|
+
manifest = await buildAssetManifest(dep.legacyAssets)
|
|
1359
|
+
storage = createMemoryStorage(dep.legacyAssets)
|
|
1360
|
+
}
|
|
1297
1361
|
const entry: CachedAssets = { manifest, storage, expiresAt: now + ASSET_CACHE_TTL_MS }
|
|
1298
1362
|
|
|
1299
1363
|
this.assetCache.set(key, entry)
|
|
@@ -1449,6 +1513,8 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1449
1513
|
sql: this.ctx.storage.sql,
|
|
1450
1514
|
git: this.git,
|
|
1451
1515
|
fs: this.overlay,
|
|
1516
|
+
assetBucket: this.env.WORKSPACE_R2,
|
|
1517
|
+
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1452
1518
|
}
|
|
1453
1519
|
return handleDeployCommand(deployCtx, cmd, request)
|
|
1454
1520
|
}
|