@springbrand/space 0.1.0-alpha.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/space",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -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: Record<string, SerializedAsset>
40
+ assets: DeploymentAsset[]
40
41
  assetConfig: AssetConfig | undefined
41
42
  compatibilityDate: string
42
43
  }
43
44
 
44
- export type SerializedAsset = string | { base64: string }
45
- type WebsiteAssets = Record<string, string | ArrayBuffer>
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: WebsiteAssets
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
- for (const fileInfo of await globInfos(ctx.fs, "**/*")) {
126
- if (fileInfo.type !== "file") continue
127
- // Skip git's object store and the ArtifactsFileSystem bookkeeping dir —
128
- // neither is part of the app and must never ship in a deploy bundle.
129
- if (fileInfo.path.startsWith("/.git/") || fileInfo.path === "/.git") continue
130
- if (fileInfo.path.startsWith("/.afs/") || fileInfo.path === "/.afs") continue
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
- addWebsiteFile(files, assets, relativePath, bytes)
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
- addWebsiteFile(files, assets, path, bytes)
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(ctx, branch, body.appRoot, body.revision)
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: serializedAssets,
278
+ assets,
188
279
  assetConfig,
189
280
  compatibilityDate: compatDate,
190
281
  } = bundle
191
282
 
192
283
  const now = Date.now()
193
- ctx.sql.exec(
194
- `INSERT OR REPLACE INTO deployments
195
- (branch, commit_hash, main_module, modules, assets, asset_config, compatibility_date, deployed_at)
196
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
197
- deploymentId,
198
- commitHash,
199
- mainModule,
200
- JSON.stringify(serializedModules),
201
- JSON.stringify(serializedAssets),
202
- assetConfig ? JSON.stringify(assetConfig) : "{}",
203
- compatDate,
204
- now
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: Object.keys(serializedAssets).length > 0,
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(ctx, branch, root, revision)
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, assets)
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: serializeAssets(result.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
- sourceAssets: WebsiteAssets,
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 assetsDirectory = normalizeAssetsDirectory(config.assets?.directory)
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
- if (Object.keys(assets).length > 0) {
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 prepareWebsite(
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
- config = parseWranglerConfig(files)
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
- if (hasWrangler(files) || config.main || config.assets?.directory) return { files, config }
317
-
318
- const directory = assets["index.html"] !== undefined
319
- ? "."
320
- : assets["public/index.html"] !== undefined
321
- ? "public"
322
- : null
323
- if (directory === null) return { files, config }
324
-
325
- const generatedAssets = {
326
- directory,
327
- htmlHandling: "auto-trailing-slash" as const,
328
- notFoundHandling: "404-page" as const,
329
- }
330
- config = {
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: generatedAssets,
334
- }
335
- return {
336
- files: {
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 collectAssets(assets: WebsiteAssets, directory: string): WebsiteAssets {
365
- return Object.fromEntries(
366
- Object.entries(assets)
367
- .filter(([path]) => !directory || path === directory || path.startsWith(`${directory}/`))
368
- .map(([path, content]) => [
369
- directory ? `/${path.slice(directory.length + 1)}` : `/${path}`,
370
- content,
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
- "SELECT branch, commit_hash, main_module, modules, assets, asset_config, deployed_at FROM deployments WHERE branch = ?",
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 assets = JSON.parse((r.assets as string) || "{}")
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(assets).length > 0,
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("SELECT branch, commit_hash, main_module, assets, deployed_at FROM deployments ORDER BY deployed_at DESC")
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 assets = JSON.parse((r.assets as string) || "{}")
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(assets).length > 0,
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
- return jsonResponse({ ok: true, branch })
461
- }
462
-
463
- // ─── Serialization helpers ───────────────────────────────────────────────────
464
-
465
- function serializeAssets(
466
- assets: Record<string, string | ArrayBuffer>
467
- ): Record<string, SerializedAsset> {
468
- const out: Record<string, SerializedAsset> = {}
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
- return out
574
+
575
+ return jsonResponse({ ok: true, branch })
475
576
  }
476
577
 
477
- function addWebsiteFile(
478
- files: Record<string, string>,
479
- assets: WebsiteAssets,
480
- path: string,
481
- bytes: Uint8Array,
482
- ): void {
483
- const contentType = inferContentType(path)
484
- const binary = contentType !== undefined && !isTextContentType(contentType)
485
- if (binary) {
486
- assets[path] = exactBuffer(bytes)
487
- return
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 { handleAssetRequest, buildAssetManifest, createMemoryStorage, type AssetConfig } from "@cloudflare/worker-bundler"
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
- assets: Record<string, string | ArrayBuffer>
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: Awaited<ReturnType<typeof buildAssetManifest>>
145
- storage: ReturnType<typeof createMemoryStorage>
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
- "SELECT branch, commit_hash, main_module, modules, assets, asset_config, compatibility_date FROM deployments WHERE branch = ?",
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
- assets: deserializeAssets(
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.assets).length > 0) {
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
- const manifest = await buildAssetManifest(dep.assets)
1296
- const storage = createMemoryStorage(dep.assets)
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
  }