@springbrand/space 0.1.0-alpha.9 → 0.2.0-alpha.1

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @springbrand/space
2
2
 
3
- SpaceDO — a git-backed Durable Object workspace with deploy, preview and App
4
- Facet database inspection.
3
+ SpaceDO — a git-backed Durable Object workspace with target-neutral deployment
4
+ bundles, CDN-backed Preview assets, App Facet execution and database inspection.
5
5
 
6
6
  ## Provenance
7
7
 
@@ -11,14 +11,18 @@ This package is a tracked fork of the VibeSDK `space/` package.
11
11
  - Frozen at: `main@a318f08625db`
12
12
  - Licence: MIT (see `LICENSE`)
13
13
 
14
- Files under `src/space/` are the upstream implementation. Local additions are
15
- confined to the `WorkspacePort` compatibility surface on `SpaceDO`
16
- (`durable-object.ts`), which Agent Runtime hosts require: binary
17
- writes, append, conditional write, quota, full file operations, R2 spill,
18
- path-level restore and `destroySpace`.
14
+ The fork retains VibeSDK's Workspace and App Facet model while replacing its
15
+ single-row Preview assets with per-file R2 objects. One `DeploymentBundle`
16
+ separates Worker modules, static asset metadata and deployment-level runtime
17
+ configuration so Preview and production publishers can consume the same build.
19
18
 
20
19
  ## Boundary
21
20
 
22
21
  SpaceDO is infrastructure. It knows nothing about users, Chats or
23
22
  authorisation — the host Worker decides which Space a request belongs to and
24
23
  whether the caller may reach it.
24
+
25
+ Preview publication writes static assets below one immutable R2 deployment
26
+ prefix and returns that prefix to the host. Static requests can therefore go
27
+ straight to a Workspace CDN; `serveApp()` is the dynamic-only path into the App
28
+ Facet. SpaceDO never reads static assets for browser requests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/space",
3
- "version": "0.1.0-alpha.9",
3
+ "version": "0.2.0-alpha.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/index.ts CHANGED
@@ -18,7 +18,17 @@ export type {
18
18
  SpaceWorkspacePort,
19
19
  } from "./space/durable-object";
20
20
  export { SPACE_RESERVED_PREFIXES } from "./space/durable-object";
21
- export type { BranchDeploymentBundle } from "./space/deploy-engine";
21
+ export {
22
+ deploymentRuntimeConfigSource,
23
+ RUNTIME_CONFIG_ASSET_PATH,
24
+ } from "./space/deploy-engine";
25
+ export type {
26
+ BranchDeploymentBundle,
27
+ DeploymentAsset,
28
+ DeploymentBundle,
29
+ DeploymentRuntimeConfig,
30
+ SpaceDeploymentResult,
31
+ } from "./space/deploy-engine";
22
32
 
23
33
  // ── Preview hardening ─────────────────────────────────────────────
24
34
  export { stripPreviewSecurityHeaders, STRIPPED_PREVIEW_HEADERS } from "./space/preview-headers";
@@ -32,18 +32,40 @@ export interface DeployContext {
32
32
  assetPrefix?: string
33
33
  }
34
34
 
35
- export interface BranchDeploymentBundle {
35
+ export interface DeploymentBundle {
36
36
  branch: string
37
37
  commitHash: string
38
- mainModule: string
39
- modules: Record<string, string | Record<string, unknown>>
40
- assets: DeploymentAsset[]
38
+ workerModules: {
39
+ mainModule: string
40
+ modules: Record<string, string | Record<string, unknown>>
41
+ compatibilityDate: string
42
+ }
43
+ staticAssets: DeploymentAsset[]
41
44
  assetConfig: AssetConfig | undefined
42
- compatibilityDate: string
45
+ runtimeConfig: DeploymentRuntimeConfig | undefined
46
+ }
47
+
48
+ export type BranchDeploymentBundle = DeploymentBundle
49
+
50
+ export interface DeploymentRuntimeConfig {
51
+ apiBaseUrl: string
52
+ }
53
+
54
+ export interface SpaceDeploymentResult {
55
+ branch: string
56
+ commit_hash: string
57
+ main_module: string
58
+ has_assets: boolean
59
+ compatibility_date: string
60
+ deployed_at: string
61
+ asset_root: string | null
43
62
  }
44
63
 
64
+ export const RUNTIME_CONFIG_ASSET_PATH = "/.springbrand/runtime-config.js"
65
+
45
66
  export interface DeploymentAsset {
46
67
  path: string
68
+ sourcePath: string | null
47
69
  contentType: string | undefined
48
70
  etag: string
49
71
  size: number
@@ -173,6 +195,7 @@ async function readBranchFiles(
173
195
  const bytes = await ctx.fs.readFileBytes(entry.sourcePath)
174
196
  const asset: Omit<DeploymentAsset, "etag"> = {
175
197
  path: toAssetPath(entry.path, assetsDirectory!),
198
+ sourcePath: entry.sourcePath,
176
199
  contentType: inferContentType(entry.path),
177
200
  size: bytes.byteLength,
178
201
  }
@@ -219,6 +242,7 @@ async function deployBranch(
219
242
  branch: string
220
243
  appRoot?: string
221
244
  revision?: string
245
+ runtimeConfig?: DeploymentRuntimeConfig
222
246
  }
223
247
  const branch = body.branch
224
248
  if (!branch) {
@@ -257,6 +281,7 @@ async function deployBranch(
257
281
  )
258
282
  return uploaded.etag
259
283
  },
284
+ body.runtimeConfig,
260
285
  )
261
286
  } catch (e) {
262
287
  await discardAssetDeployment(ctx, assetDeploymentId, assetRoot).catch((cleanupError) => {
@@ -273,11 +298,12 @@ async function deployBranch(
273
298
  const {
274
299
  branch: deploymentId,
275
300
  commitHash,
276
- mainModule,
277
- modules: serializedModules,
278
- assets,
279
- assetConfig,
280
- compatibilityDate: compatDate,
301
+ workerModules: {
302
+ mainModule,
303
+ modules: serializedModules,
304
+ compatibilityDate: compatDate,
305
+ },
306
+ staticAssets: assets,
281
307
  } = bundle
282
308
 
283
309
  const now = Date.now()
@@ -291,16 +317,14 @@ async function deployBranch(
291
317
  try {
292
318
  ctx.sql.exec(
293
319
  `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 (?, ?, ?, ?, '{}', ?, ?, ?, ?, ?)`,
320
+ (branch, commit_hash, main_module, modules, asset_deployment_id,
321
+ compatibility_date, deployed_at)
322
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
297
323
  deploymentId,
298
324
  commitHash,
299
325
  mainModule,
300
326
  JSON.stringify(serializedModules),
301
327
  assets.length > 0 ? assetDeploymentId : "",
302
- assets.length,
303
- assetConfig ? JSON.stringify(assetConfig) : "{}",
304
328
  compatDate,
305
329
  now
306
330
  )
@@ -333,6 +357,7 @@ async function deployBranch(
333
357
  has_assets: assets.length > 0,
334
358
  compatibility_date: compatDate,
335
359
  deployed_at: new Date(now).toISOString(),
360
+ asset_root: assets.length > 0 ? assetRoot : null,
336
361
  })
337
362
  }
338
363
 
@@ -342,6 +367,7 @@ export async function buildBranchDeployment(
342
367
  appRoot?: string | null,
343
368
  revision?: string,
344
369
  writeAsset?: AssetWriter,
370
+ runtimeConfig?: DeploymentRuntimeConfig,
345
371
  ): Promise<BranchDeploymentBundle> {
346
372
  const root = normalizeAppRoot(appRoot)
347
373
  const { commitHash, files, assets, config } = await readBranchFiles(
@@ -351,6 +377,22 @@ export async function buildBranchDeployment(
351
377
  revision,
352
378
  writeAsset,
353
379
  )
380
+ const normalizedRuntimeConfig = normalizeRuntimeConfig(runtimeConfig)
381
+ if (normalizedRuntimeConfig) {
382
+ const bytes = new TextEncoder().encode(
383
+ deploymentRuntimeConfigSource(normalizedRuntimeConfig),
384
+ )
385
+ const asset: Omit<DeploymentAsset, "etag"> = {
386
+ path: RUNTIME_CONFIG_ASSET_PATH,
387
+ sourcePath: null,
388
+ contentType: "application/javascript; charset=utf-8",
389
+ size: bytes.byteLength,
390
+ }
391
+ const etag = writeAsset
392
+ ? await writeAsset(asset, bytes)
393
+ : await computeAssetEtag(bytes)
394
+ assets.push({ ...asset, etag })
395
+ }
354
396
  if (Object.keys(files).length === 0) {
355
397
  throw new Error(
356
398
  root
@@ -364,17 +406,40 @@ export async function buildBranchDeployment(
364
406
  return {
365
407
  branch: revision ?? branch,
366
408
  commitHash,
367
- mainModule: result.mainModule,
368
- modules: result.modules,
369
- assets,
409
+ workerModules: {
410
+ mainModule: result.mainModule,
411
+ modules: result.modules,
412
+ compatibilityDate: result.compatibilityDate,
413
+ },
414
+ staticAssets: assets,
370
415
  assetConfig: result.assetConfig,
371
- compatibilityDate: result.compatibilityDate,
416
+ runtimeConfig: normalizedRuntimeConfig,
372
417
  }
373
418
  } catch (e) {
374
419
  throw new Error(`Build failed: ${e instanceof Error ? e.message : String(e)}`)
375
420
  }
376
421
  }
377
422
 
423
+ function normalizeRuntimeConfig(
424
+ config: DeploymentRuntimeConfig | undefined,
425
+ ): DeploymentRuntimeConfig | undefined {
426
+ if (!config) return undefined
427
+ let url: URL
428
+ try {
429
+ url = new URL(config.apiBaseUrl)
430
+ } catch {
431
+ throw new Error("runtimeConfig.apiBaseUrl must be an absolute HTTP(S) URL ending in /")
432
+ }
433
+ if (!["http:", "https:"].includes(url.protocol) || !url.pathname.endsWith("/")) {
434
+ throw new Error("runtimeConfig.apiBaseUrl must be an absolute HTTP(S) URL ending in /")
435
+ }
436
+ return { apiBaseUrl: url.toString() }
437
+ }
438
+
439
+ export function deploymentRuntimeConfigSource(config: DeploymentRuntimeConfig): string {
440
+ return `globalThis.__APP_CONFIG__=Object.freeze(${JSON.stringify(config)});`
441
+ }
442
+
378
443
  async function buildWebsite(
379
444
  sourceFiles: Record<string, string>,
380
445
  config: ParsedWranglerConfig,
@@ -463,7 +528,7 @@ function toAssetPath(path: string, directory: string): string {
463
528
 
464
529
  async function computeAssetEtag(bytes: Uint8Array): Promise<string> {
465
530
  const digest = await crypto.subtle.digest("SHA-256", exactBuffer(bytes))
466
- return [...new Uint8Array(digest).slice(0, 8)]
531
+ return [...new Uint8Array(digest).slice(0, 16)]
467
532
  .map((byte) => byte.toString(16).padStart(2, "0"))
468
533
  .join("")
469
534
  }
@@ -490,8 +555,7 @@ async function getDeployment(
490
555
 
491
556
  const row = ctx.sql
492
557
  .exec(
493
- `SELECT branch, commit_hash, main_module, modules, assets, asset_count,
494
- asset_config, deployed_at
558
+ `SELECT branch, commit_hash, main_module, modules, asset_deployment_id, deployed_at
495
559
  FROM deployments WHERE branch = ?`,
496
560
  branch
497
561
  )
@@ -502,14 +566,17 @@ async function getDeployment(
502
566
  }
503
567
 
504
568
  const r = row[0]
505
- const legacyAssets = JSON.parse((r.assets as string) || "{}")
569
+ const assetDeploymentId = (r.asset_deployment_id as string) || ""
506
570
  return jsonResponse({
507
571
  branch: r.branch as string,
508
572
  commit_hash: r.commit_hash as string,
509
573
  main_module: r.main_module as string,
510
574
  modules: JSON.parse(r.modules as string),
511
- has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
575
+ has_assets: Boolean(assetDeploymentId),
512
576
  deployed_at: new Date(r.deployed_at as number).toISOString(),
577
+ asset_root: assetDeploymentId && ctx.assetPrefix
578
+ ? `${ctx.assetPrefix}/deployments/${assetDeploymentId}`
579
+ : null,
513
580
  })
514
581
  }
515
582
 
@@ -518,21 +585,17 @@ async function getDeployment(
518
585
  async function listDeployments(ctx: DeployContext): Promise<Response> {
519
586
  const rows = ctx.sql
520
587
  .exec(
521
- `SELECT branch, commit_hash, main_module, assets, asset_count, deployed_at
588
+ `SELECT branch, commit_hash, main_module, asset_deployment_id, deployed_at
522
589
  FROM deployments ORDER BY deployed_at DESC`,
523
590
  )
524
591
  .toArray()
525
-
526
- const deployments = rows.map((r) => {
527
- const legacyAssets = JSON.parse((r.assets as string) || "{}")
528
- return {
529
- branch: r.branch as string,
530
- commit_hash: r.commit_hash as string,
531
- main_module: r.main_module as string,
532
- has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
533
- deployed_at: new Date(r.deployed_at as number).toISOString(),
534
- }
535
- })
592
+ const deployments = rows.map((r) => ({
593
+ branch: r.branch as string,
594
+ commit_hash: r.commit_hash as string,
595
+ main_module: r.main_module as string,
596
+ has_assets: Boolean(r.asset_deployment_id),
597
+ deployed_at: new Date(r.deployed_at as number).toISOString(),
598
+ }))
536
599
 
537
600
  return jsonResponse(deployments)
538
601
  }
@@ -6,24 +6,17 @@ import {
6
6
  buildBranchDeployment,
7
7
  handleDeployCommand,
8
8
  type BranchDeploymentBundle,
9
+ type DeploymentRuntimeConfig,
9
10
  type DeployContext,
11
+ type SpaceDeploymentResult,
10
12
  } from "./deploy-engine"
11
13
  import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
12
- import {
13
- handleAssetRequest,
14
- buildAssetManifest,
15
- createMemoryStorage,
16
- type AssetConfig,
17
- type AssetManifest,
18
- type AssetStorage,
19
- } from "@cloudflare/worker-bundler"
20
14
  import {
21
15
  buildInspectorWrapperSource,
22
16
  VIBE_APP_MODULE,
23
17
  } from "./inspector-wrapper"
24
18
  import { ArtifactsBackend, resolveSpaceFsBackendMode, SqlBackend, spaceR2Prefix, type SpaceFsBackend } from "./fs-backend"
25
19
  import { createGitFs, stageWorkdir } from "./git-objects"
26
- import { stripPreviewSecurityHeaders } from "./preview-headers"
27
20
  import * as git from "isomorphic-git"
28
21
  import {
29
22
  defaultSpaceQuota,
@@ -100,41 +93,9 @@ interface DeploymentRow {
100
93
  commitHash: string
101
94
  mainModule: string
102
95
  modules: Record<string, string | Record<string, unknown>>
103
- legacyAssets: Record<string, string | ArrayBuffer>
104
- assetDeploymentId: string
105
- assetCount: number
106
- assetConfig: AssetConfig
107
96
  compatibilityDate: string
108
97
  }
109
98
 
110
- type SerializedAsset = string | { base64: string }
111
-
112
- function deserializeAssets(
113
- assets: Record<string, SerializedAsset>,
114
- ): Record<string, string | ArrayBuffer> {
115
- return Object.fromEntries(
116
- Object.entries(assets).map(([path, content]) => [
117
- path,
118
- typeof content === "string"
119
- ? content
120
- : exactArrayBuffer(base64ToBytes(content.base64)),
121
- ]),
122
- )
123
- }
124
-
125
- function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
126
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
127
- }
128
-
129
- function base64ToBytes(value: string): Uint8Array {
130
- const binary = atob(value)
131
- const bytes = new Uint8Array(binary.length)
132
- for (let index = 0; index < binary.length; index++) {
133
- bytes[index] = binary.charCodeAt(index)
134
- }
135
- return bytes
136
- }
137
-
138
99
  // Overlay-only paths that must never leak into a deploy, rollback tree, or any
139
100
  // file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
140
101
  const isReservedPath = isReservedSpacePath
@@ -147,22 +108,10 @@ const isReservedPath = isReservedSpacePath
147
108
  // Commits/deploys are mirrored to a per-app Cloudflare Artifacts repo
148
109
  // (see artifacts-sync.ts), which is the durable source of truth for history.
149
110
 
150
- // Built asset manifest + in-memory storage for a single deployment. Rebuilding
151
- // these on every request is wasteful (CWE-770 amplification under a preview
152
- // flood), so we cache them per `branch:commitHash` with an LRU + TTL bound.
153
- type CachedAssets = {
154
- manifest: AssetManifest
155
- storage: AssetStorage
156
- expiresAt: number
157
- }
158
- const ASSET_CACHE_MAX_ENTRIES = 8
159
- const ASSET_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
160
-
161
111
  export class SpaceDO extends DurableObject<Env>
162
112
  implements SpaceWorkspacePort, SpaceControlPort, SpaceAppPort {
163
113
  private backend!: SpaceFsBackend
164
114
  private initializationPromise: Promise<void> | null = null
165
- private assetCache = new Map<string, CachedAssets>()
166
115
  /**
167
116
  * One lane for every operation that reads or rewrites the whole tree.
168
117
  *
@@ -218,11 +167,8 @@ export class SpaceDO extends DurableObject<Env>
218
167
  commit_hash TEXT NOT NULL,
219
168
  main_module TEXT NOT NULL,
220
169
  modules TEXT NOT NULL,
221
- assets TEXT NOT NULL DEFAULT '{}',
222
170
  asset_deployment_id TEXT NOT NULL DEFAULT '',
223
- asset_count INTEGER NOT NULL DEFAULT 0,
224
- asset_config TEXT NOT NULL DEFAULT '{}',
225
- compatibility_date TEXT NOT NULL DEFAULT '',
171
+ compatibility_date TEXT NOT NULL,
226
172
  deployed_at INTEGER NOT NULL
227
173
  );
228
174
  CREATE TABLE IF NOT EXISTS deployment_assets (
@@ -236,13 +182,6 @@ export class SpaceDO extends DurableObject<Env>
236
182
  );
237
183
  `)
238
184
 
239
- // Migrate existing deployments tables that lack new columns
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 {}
243
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_config TEXT NOT NULL DEFAULT '{}'`) } catch {}
244
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN compatibility_date TEXT NOT NULL DEFAULT ''`) } catch {}
245
-
246
185
  // Initialize git repo if not already done
247
186
  try {
248
187
  await this.git.init({ defaultBranch: "main" })
@@ -634,7 +573,6 @@ export class SpaceDO extends DurableObject<Env>
634
573
  }
635
574
  }
636
575
  await this.deleteSpilledObjects()
637
- this.assetCache.clear()
638
576
  await this.ctx.storage.deleteAll()
639
577
  this.initializationPromise = null
640
578
  })
@@ -1109,7 +1047,12 @@ export class SpaceDO extends DurableObject<Env>
1109
1047
  * analysis Space with a spreadsheet and a script deploys nothing until
1110
1048
  * someone names the directory that holds a site.
1111
1049
  */
1112
- async deploy(branch: string, appRoot?: string, revision?: string): Promise<unknown> {
1050
+ async deploy(
1051
+ branch: string,
1052
+ appRoot?: string,
1053
+ revision?: string,
1054
+ runtimeConfig?: DeploymentRuntimeConfig,
1055
+ ): Promise<SpaceDeploymentResult> {
1113
1056
  await this.ensureInit()
1114
1057
  return this.lane(async () => {
1115
1058
  // The deploy engine reads the full branch tree, so ensure the Artifacts
@@ -1123,6 +1066,7 @@ export class SpaceDO extends DurableObject<Env>
1123
1066
  branch,
1124
1067
  ...(appRoot ? { appRoot } : {}),
1125
1068
  ...(revision ? { revision } : {}),
1069
+ ...(runtimeConfig ? { runtimeConfig } : {}),
1126
1070
  }),
1127
1071
  })
1128
1072
  const ctx: DeployContext = {
@@ -1135,14 +1079,10 @@ export class SpaceDO extends DurableObject<Env>
1135
1079
  const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
1136
1080
  const data = await res.json() as Record<string, unknown>
1137
1081
  if (typeof data.error === "string") throw new Error(data.error)
1138
- this.assetCache.clear()
1139
- const deploymentId = data.branch as string
1140
- data.preview_url =
1141
- `/space/${this.spaceName}/preview/${encodeURIComponent(deploymentId)}/`
1142
1082
 
1143
1083
  if (!revision) await this.backend.push(branch)
1144
1084
 
1145
- return data
1085
+ return spaceDeploymentResult(data)
1146
1086
  })
1147
1087
  }
1148
1088
 
@@ -1150,6 +1090,7 @@ export class SpaceDO extends DurableObject<Env>
1150
1090
  branch: string,
1151
1091
  appRoot?: string,
1152
1092
  revision?: string,
1093
+ runtimeConfig?: DeploymentRuntimeConfig,
1153
1094
  ): Promise<BranchDeploymentBundle> {
1154
1095
  await this.ensureInit()
1155
1096
  await this.materializeAll()
@@ -1162,6 +1103,8 @@ export class SpaceDO extends DurableObject<Env>
1162
1103
  branch,
1163
1104
  appRoot,
1164
1105
  revision,
1106
+ undefined,
1107
+ runtimeConfig,
1165
1108
  )
1166
1109
  }
1167
1110
 
@@ -1180,7 +1123,6 @@ export class SpaceDO extends DurableObject<Env>
1180
1123
  assetPrefix: spaceR2Prefix(this.spaceName),
1181
1124
  }
1182
1125
  const res = await handleDeployCommand(ctx, "undeploy", fakeRequest)
1183
- this.assetCache.clear()
1184
1126
  return res.json()
1185
1127
  }
1186
1128
 
@@ -1203,6 +1145,7 @@ export class SpaceDO extends DurableObject<Env>
1203
1145
  sql: this.ctx.storage.sql,
1204
1146
  git: this.git,
1205
1147
  fs: this.overlay,
1148
+ assetPrefix: spaceR2Prefix(this.spaceName),
1206
1149
  }
1207
1150
  const res = await handleDeployCommand(ctx, "get_deployment", fakeRequest)
1208
1151
  return res.json()
@@ -1228,13 +1171,12 @@ export class SpaceDO extends DurableObject<Env>
1228
1171
  return { fileCount, directoryCount, totalBytes }
1229
1172
  }
1230
1173
 
1231
- // ── Deployment row reader (shared by servePreview + DB-viewer) ──
1174
+ // ── Deployment row reader ──
1232
1175
 
1233
1176
  private readDeployment(branch: string): DeploymentRow | null {
1234
1177
  const rows = this.ctx.storage.sql
1235
1178
  .exec(
1236
- `SELECT branch, commit_hash, main_module, modules, assets,
1237
- asset_deployment_id, asset_count, asset_config, compatibility_date
1179
+ `SELECT branch, commit_hash, main_module, modules, compatibility_date
1238
1180
  FROM deployments WHERE branch = ?`,
1239
1181
  branch,
1240
1182
  )
@@ -1246,17 +1188,11 @@ export class SpaceDO extends DurableObject<Env>
1246
1188
  commitHash: r.commit_hash as string,
1247
1189
  mainModule: r.main_module as string,
1248
1190
  modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
1249
- legacyAssets: deserializeAssets(
1250
- JSON.parse((r.assets as string) || "{}") as Record<string, SerializedAsset>,
1251
- ),
1252
- assetDeploymentId: (r.asset_deployment_id as string) || "",
1253
- assetCount: Number(r.asset_count ?? 0),
1254
- assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
1255
- compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
1191
+ compatibilityDate: r.compatibility_date as string,
1256
1192
  }
1257
1193
  }
1258
1194
 
1259
- // ── Preview serving via Dynamic Workers ─────────────────────────
1195
+ // ── App serving via Dynamic Workers ─────────────────────────────
1260
1196
  //
1261
1197
  // Architecture (matches Cloudflare's Durable Object Facets docs example):
1262
1198
  //
@@ -1264,10 +1200,8 @@ export class SpaceDO extends DurableObject<Env>
1264
1200
  // module. `App.fetch(request)` is the entire backend (Hono /
1265
1201
  // itty-router / vanilla — the LLM decides).
1266
1202
  // - SpaceDO acts as the supervisor ("AppRunner" in the docs).
1267
- // `servePreview` loads the user's worker via the Worker Loader,
1268
- // extracts the App class, and hosts it as a Facet keyed
1269
- // `app:<branch>`. Static assets are served host-side; everything
1270
- // else (including WebSocket upgrades) is forwarded into the Facet.
1203
+ // `serveApp` loads the user's worker via the Worker Loader, extracts the
1204
+ // App class, and hosts it as a Facet keyed `app:<branch>`.
1271
1205
  // - State is the Facet's own `ctx.storage` (SQLite + KV). No env.DB
1272
1206
  // binding is injected.
1273
1207
  // - To make the DB-viewer work without forcing the LLM to write
@@ -1277,23 +1211,12 @@ export class SpaceDO extends DurableObject<Env>
1277
1211
  // `App` that adds `__vibeInspectListTables` / `__vibeInspectRead`
1278
1212
  // / `__vibeWipe`. The subclass shares the same `ctx.storage`.
1279
1213
 
1280
- async servePreview(branch: string, request: Request): Promise<Response> {
1214
+ async serveApp(branch: string, request: Request): Promise<Response> {
1281
1215
  await this.ensureInit()
1282
-
1283
1216
  const dep = this.readDeployment(branch)
1284
1217
  if (!dep) {
1285
1218
  return new Response(`No deployment found for branch "${branch}"`, { status: 404 })
1286
1219
  }
1287
-
1288
- // Serve static assets host-side before forwarding to the Facet. The built
1289
- // manifest/storage are cached per deployment so repeat asset reads don't
1290
- // re-spin the build on every request.
1291
- if (dep.assetCount > 0 || Object.keys(dep.legacyAssets).length > 0) {
1292
- const { manifest, storage } = await this.getCachedAssets(dep)
1293
- const assetResponse = await handleAssetRequest(request, manifest, storage, dep.assetConfig)
1294
- if (assetResponse) return assetResponse
1295
- }
1296
-
1297
1220
  let appClass: DurableObjectClass
1298
1221
  try {
1299
1222
  appClass = this.loadAppClass(dep)
@@ -1308,73 +1231,6 @@ export class SpaceDO extends DurableObject<Env>
1308
1231
  return facet.fetch(request)
1309
1232
  }
1310
1233
 
1311
- /**
1312
- * Return the built asset manifest + in-memory storage for a deployment,
1313
- * reusing a cached build when available. Keyed by `branch:commitHash` so a
1314
- * redeploy (new commit) transparently rebuilds. Bounded by an LRU cap + TTL.
1315
- */
1316
- private async getCachedAssets(dep: DeploymentRow): Promise<CachedAssets> {
1317
- const key = `${dep.branch}:${dep.assetDeploymentId || dep.commitHash}`
1318
- const now = Date.now()
1319
-
1320
- const cached = this.assetCache.get(key)
1321
- if (cached && cached.expiresAt > now) {
1322
- // Refresh LRU recency.
1323
- this.assetCache.delete(key)
1324
- this.assetCache.set(key, cached)
1325
- return cached
1326
- }
1327
-
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
- }
1361
- const entry: CachedAssets = { manifest, storage, expiresAt: now + ASSET_CACHE_TTL_MS }
1362
-
1363
- this.assetCache.set(key, entry)
1364
-
1365
- // Evict expired / oldest entries to keep the cache bounded.
1366
- for (const [k, v] of this.assetCache) {
1367
- if (v.expiresAt <= now) this.assetCache.delete(k)
1368
- }
1369
- while (this.assetCache.size > ASSET_CACHE_MAX_ENTRIES) {
1370
- const oldest = this.assetCache.keys().next().value
1371
- if (oldest === undefined) break
1372
- this.assetCache.delete(oldest)
1373
- }
1374
-
1375
- return entry
1376
- }
1377
-
1378
1234
  // ── App-class loader ────────────────────────────────────────────
1379
1235
  //
1380
1236
  // Loads the dynamic worker for `branch`'s latest deployment with the
@@ -1399,7 +1255,7 @@ export class SpaceDO extends DurableObject<Env>
1399
1255
  // The date the app declared, not a date frozen into this file. A worker
1400
1256
  // built against one runtime and run under another is a bug the author
1401
1257
  // cannot see or fix.
1402
- compatibilityDate: dep.compatibilityDate || FALLBACK_COMPATIBILITY_DATE,
1258
+ compatibilityDate: dep.compatibilityDate,
1403
1259
  // Generated code gets no outbound network. Omitting this inherits the
1404
1260
  // parent Worker's full internet access, which would let an app the model
1405
1261
  // wrote reach anything this Worker can — including internal services.
@@ -1474,40 +1330,12 @@ export class SpaceDO extends DurableObject<Env>
1474
1330
  return { ok: true }
1475
1331
  }
1476
1332
 
1477
- // ── HTTP handler: preview serving + internal deploy commands ────
1333
+ // ── Internal deploy commands ──
1478
1334
 
1479
1335
  async fetch(request: Request): Promise<Response> {
1480
1336
  await this.ensureInit()
1481
1337
 
1482
- const url = new URL(request.url)
1483
- const path = url.pathname
1484
-
1485
- // Preview routes: /space/:name/preview/:branch/*
1486
- const previewMatch = path.match(/\/preview\/([^/]+)(\/.*)?$/)
1487
- if (previewMatch) {
1488
- const branch = decodeURIComponent(previewMatch[1])
1489
- const spaceName = this.ctx.id.name ?? "space"
1490
- const basePath = `/space/${spaceName}/preview/${encodeURIComponent(branch)}`
1491
-
1492
- // Rewrite the URL so the dynamic worker sees a clean path
1493
- const subPath = previewMatch[2] || "/"
1494
- const previewUrl = new URL(subPath, url.origin)
1495
- previewUrl.search = url.search
1496
- const previewRequest = new Request(previewUrl.toString(), request)
1497
- const response = await this.servePreview(branch, previewRequest)
1498
-
1499
- // Strip headers a generated app must not be able to set on the shared
1500
- // preview origin (e.g. Service-Worker-Allowed scope expansion) before
1501
- // any further rewriting.
1502
- const safeResponse = stripPreviewSecurityHeaders(response)
1503
-
1504
- // Rewrite root-relative paths in HTML responses so they resolve
1505
- // correctly when the preview is mounted on a sub-path
1506
- return rewritePreviewResponse(safeResponse, basePath)
1507
- }
1508
-
1509
- // Deploy command routes
1510
- const cmd = url.searchParams.get("cmd")
1338
+ const cmd = new URL(request.url).searchParams.get("cmd")
1511
1339
  if (cmd && ["deploy", "get_deployment", "list_deployments", "undeploy"].includes(cmd)) {
1512
1340
  const deployCtx: DeployContext = {
1513
1341
  sql: this.ctx.storage.sql,
@@ -1523,39 +1351,6 @@ export class SpaceDO extends DurableObject<Env>
1523
1351
  }
1524
1352
  }
1525
1353
 
1526
- // ─── Preview Response Rewriting ──────────────────────────────────────────────
1527
- // When a preview is served on /space/:name/preview/:branch/, root-relative
1528
- // paths like /style.css in HTML would resolve to the domain root instead of
1529
- // the preview path. We rewrite them so the browser fetches the correct URL.
1530
-
1531
- function rewritePreviewResponse(response: Response, basePath: string): Response {
1532
- // Rewrite Location header on redirects
1533
- const location = response.headers.get("location")
1534
- if (location?.startsWith("/")) {
1535
- const rewritten = new Response(response.body, response)
1536
- rewritten.headers.set("location", basePath + location)
1537
- return rewritten
1538
- }
1539
-
1540
- // Only rewrite HTML responses
1541
- const ct = response.headers.get("content-type") ?? ""
1542
- if (!ct.includes("text/html")) return response
1543
-
1544
- // Use HTMLRewriter to prefix root-relative src/href/action attributes
1545
- return new HTMLRewriter()
1546
- .on("[src],[href],[action]", {
1547
- element(el) {
1548
- for (const attr of ["src", "href", "action"] as const) {
1549
- const val = el.getAttribute(attr)
1550
- if (val?.startsWith("/") && !val.startsWith("//")) {
1551
- el.setAttribute(attr, basePath + val)
1552
- }
1553
- }
1554
- },
1555
- })
1556
- .transform(response)
1557
- }
1558
-
1559
1354
  // ─── Facet naming ───────────────────────────────────────────────────────────
1560
1355
 
1561
1356
  function facetNameForApp(branch: string): string {
@@ -1567,9 +1362,6 @@ function facetNameForApp(branch: string): string {
1567
1362
  /** Git's oid for an empty tree — what a repository with no content writes. */
1568
1363
  const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
1569
1364
 
1570
- /** Used only for deployments recorded before the date was persisted. */
1571
- const FALLBACK_COMPATIBILITY_DATE = "2025-04-01"
1572
-
1573
1365
  const SPACE_AUTHOR = Object.freeze({
1574
1366
  name: "Workspace",
1575
1367
  email: "workspace@springbrand.local",
@@ -1610,6 +1402,29 @@ function byteLength(content: string): number {
1610
1402
  return new TextEncoder().encode(content).byteLength
1611
1403
  }
1612
1404
 
1405
+ function spaceDeploymentResult(data: Record<string, unknown>): SpaceDeploymentResult {
1406
+ if (
1407
+ typeof data.branch !== "string" ||
1408
+ typeof data.commit_hash !== "string" ||
1409
+ typeof data.main_module !== "string" ||
1410
+ typeof data.has_assets !== "boolean" ||
1411
+ typeof data.compatibility_date !== "string" ||
1412
+ typeof data.deployed_at !== "string" ||
1413
+ !(typeof data.asset_root === "string" || data.asset_root === null)
1414
+ ) {
1415
+ throw new Error("Invalid deployment result")
1416
+ }
1417
+ return {
1418
+ branch: data.branch,
1419
+ commit_hash: data.commit_hash,
1420
+ main_module: data.main_module,
1421
+ has_assets: data.has_assets,
1422
+ compatibility_date: data.compatibility_date,
1423
+ deployed_at: data.deployed_at,
1424
+ asset_root: data.asset_root,
1425
+ }
1426
+ }
1427
+
1613
1428
  // ─── Helpers ────────────────────────────────────────────────────────────────
1614
1429
 
1615
1430
  interface PatchEdit {
@@ -1,3 +1,9 @@
1
+ import type {
2
+ BranchDeploymentBundle,
3
+ DeploymentRuntimeConfig,
4
+ SpaceDeploymentResult,
5
+ } from "./deploy-engine";
6
+
1
7
  /**
2
8
  * The production Workspace contract a Space must satisfy.
3
9
  *
@@ -234,11 +240,22 @@ export interface SpaceControlPort {
234
240
  * and only an explicit deploy brings an app into existence.
235
241
  */
236
242
  export interface SpaceAppPort {
237
- deploy(branch: string, appRoot?: string, revision?: string): Promise<unknown>;
243
+ deploy(
244
+ branch: string,
245
+ appRoot?: string,
246
+ revision?: string,
247
+ runtimeConfig?: DeploymentRuntimeConfig,
248
+ ): Promise<SpaceDeploymentResult>;
238
249
  undeploy(branch: string): Promise<unknown>;
239
250
  listDeployments(): Promise<unknown>;
240
251
  getDeployment(branch: string): Promise<unknown>;
241
- servePreview(branch: string, request: Request): Promise<Response>;
252
+ getDeploymentBundle(
253
+ branch: string,
254
+ appRoot?: string,
255
+ revision?: string,
256
+ runtimeConfig?: DeploymentRuntimeConfig,
257
+ ): Promise<BranchDeploymentBundle>;
258
+ serveApp(branch: string, request: Request): Promise<Response>;
242
259
  listAppTables(branch: string): Promise<unknown>;
243
260
  queryAppTable(branch: string, table: string, opts?: unknown): Promise<unknown>;
244
261
  wipeAppDatabase(branch: string): Promise<{ ok: true }>;