@springbrand/space 0.2.0-alpha.0 → 0.2.0-alpha.10

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
@@ -25,5 +25,4 @@ whether the caller may reach it.
25
25
  Preview publication writes static assets below one immutable R2 deployment
26
26
  prefix and returns that prefix to the host. Static requests can therefore go
27
27
  straight to a Workspace CDN; `serveApp()` is the dynamic-only path into the App
28
- Facet. `servePreview()` remains available for hosts that intentionally proxy
29
- both asset and backend traffic through SpaceDO.
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.2.0-alpha.0",
3
+ "version": "0.2.0-alpha.10",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type {
9
9
  AppTableQueryOpts,
10
10
  SpaceCommit,
11
11
  SpaceConditionalWriteResult,
12
+ SpaceStreamWriteOptions,
12
13
  SpaceAppPort,
13
14
  SpaceControlPort,
14
15
  SpaceFileInfo,
@@ -304,7 +304,6 @@ async function deployBranch(
304
304
  compatibilityDate: compatDate,
305
305
  },
306
306
  staticAssets: assets,
307
- assetConfig,
308
307
  } = bundle
309
308
 
310
309
  const now = Date.now()
@@ -318,16 +317,14 @@ async function deployBranch(
318
317
  try {
319
318
  ctx.sql.exec(
320
319
  `INSERT OR REPLACE INTO deployments
321
- (branch, commit_hash, main_module, modules, assets, asset_deployment_id, asset_count,
322
- asset_config, compatibility_date, deployed_at)
323
- VALUES (?, ?, ?, ?, '{}', ?, ?, ?, ?, ?)`,
320
+ (branch, commit_hash, main_module, modules, asset_deployment_id,
321
+ compatibility_date, deployed_at)
322
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
324
323
  deploymentId,
325
324
  commitHash,
326
325
  mainModule,
327
326
  JSON.stringify(serializedModules),
328
327
  assets.length > 0 ? assetDeploymentId : "",
329
- assets.length,
330
- assetConfig ? JSON.stringify(assetConfig) : "{}",
331
328
  compatDate,
332
329
  now
333
330
  )
@@ -558,8 +555,7 @@ async function getDeployment(
558
555
 
559
556
  const row = ctx.sql
560
557
  .exec(
561
- `SELECT branch, commit_hash, main_module, modules, assets, asset_deployment_id, asset_count,
562
- asset_config, deployed_at
558
+ `SELECT branch, commit_hash, main_module, modules, asset_deployment_id, deployed_at
563
559
  FROM deployments WHERE branch = ?`,
564
560
  branch
565
561
  )
@@ -570,14 +566,13 @@ async function getDeployment(
570
566
  }
571
567
 
572
568
  const r = row[0]
573
- const legacyAssets = JSON.parse((r.assets as string) || "{}")
574
569
  const assetDeploymentId = (r.asset_deployment_id as string) || ""
575
570
  return jsonResponse({
576
571
  branch: r.branch as string,
577
572
  commit_hash: r.commit_hash as string,
578
573
  main_module: r.main_module as string,
579
574
  modules: JSON.parse(r.modules as string),
580
- has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
575
+ has_assets: Boolean(assetDeploymentId),
581
576
  deployed_at: new Date(r.deployed_at as number).toISOString(),
582
577
  asset_root: assetDeploymentId && ctx.assetPrefix
583
578
  ? `${ctx.assetPrefix}/deployments/${assetDeploymentId}`
@@ -590,21 +585,17 @@ async function getDeployment(
590
585
  async function listDeployments(ctx: DeployContext): Promise<Response> {
591
586
  const rows = ctx.sql
592
587
  .exec(
593
- `SELECT branch, commit_hash, main_module, assets, asset_count, deployed_at
588
+ `SELECT branch, commit_hash, main_module, asset_deployment_id, deployed_at
594
589
  FROM deployments ORDER BY deployed_at DESC`,
595
590
  )
596
591
  .toArray()
597
-
598
- const deployments = rows.map((r) => {
599
- const legacyAssets = JSON.parse((r.assets as string) || "{}")
600
- return {
601
- branch: r.branch as string,
602
- commit_hash: r.commit_hash as string,
603
- main_module: r.main_module as string,
604
- has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
605
- deployed_at: new Date(r.deployed_at as number).toISOString(),
606
- }
607
- })
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
+ }))
608
599
 
609
600
  return jsonResponse(deployments)
610
601
  }
@@ -11,21 +11,12 @@ import {
11
11
  type SpaceDeploymentResult,
12
12
  } from "./deploy-engine"
13
13
  import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
14
- import {
15
- handleAssetRequest,
16
- buildAssetManifest,
17
- createMemoryStorage,
18
- type AssetConfig,
19
- type AssetManifest,
20
- type AssetStorage,
21
- } from "@cloudflare/worker-bundler"
22
14
  import {
23
15
  buildInspectorWrapperSource,
24
16
  VIBE_APP_MODULE,
25
17
  } from "./inspector-wrapper"
26
18
  import { ArtifactsBackend, resolveSpaceFsBackendMode, SqlBackend, spaceR2Prefix, type SpaceFsBackend } from "./fs-backend"
27
19
  import { createGitFs, stageWorkdir } from "./git-objects"
28
- import { stripPreviewSecurityHeaders } from "./preview-headers"
29
20
  import * as git from "isomorphic-git"
30
21
  import {
31
22
  defaultSpaceQuota,
@@ -39,6 +30,7 @@ import {
39
30
  type SpaceCommit,
40
31
  type SpaceCommitManifest,
41
32
  type SpaceConditionalWriteResult,
33
+ type SpaceStreamWriteOptions,
42
34
  type SpaceAppPort,
43
35
  type SpaceControlPort,
44
36
  type SpaceFileInfo,
@@ -57,6 +49,7 @@ export type {
57
49
  SpaceCommit,
58
50
  SpaceCommitManifest,
59
51
  SpaceConditionalWriteResult,
52
+ SpaceStreamWriteOptions,
60
53
  SpaceControlPort,
61
54
  SpaceFileInfo,
62
55
  SpaceFileVersion,
@@ -102,41 +95,9 @@ interface DeploymentRow {
102
95
  commitHash: string
103
96
  mainModule: string
104
97
  modules: Record<string, string | Record<string, unknown>>
105
- legacyAssets: Record<string, string | ArrayBuffer>
106
- assetDeploymentId: string
107
- assetCount: number
108
- assetConfig: AssetConfig
109
98
  compatibilityDate: string
110
99
  }
111
100
 
112
- type SerializedAsset = string | { base64: string }
113
-
114
- function deserializeAssets(
115
- assets: Record<string, SerializedAsset>,
116
- ): Record<string, string | ArrayBuffer> {
117
- return Object.fromEntries(
118
- Object.entries(assets).map(([path, content]) => [
119
- path,
120
- typeof content === "string"
121
- ? content
122
- : exactArrayBuffer(base64ToBytes(content.base64)),
123
- ]),
124
- )
125
- }
126
-
127
- function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
128
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
129
- }
130
-
131
- function base64ToBytes(value: string): Uint8Array {
132
- const binary = atob(value)
133
- const bytes = new Uint8Array(binary.length)
134
- for (let index = 0; index < binary.length; index++) {
135
- bytes[index] = binary.charCodeAt(index)
136
- }
137
- return bytes
138
- }
139
-
140
101
  // Overlay-only paths that must never leak into a deploy, rollback tree, or any
141
102
  // file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
142
103
  const isReservedPath = isReservedSpacePath
@@ -149,22 +110,10 @@ const isReservedPath = isReservedSpacePath
149
110
  // Commits/deploys are mirrored to a per-app Cloudflare Artifacts repo
150
111
  // (see artifacts-sync.ts), which is the durable source of truth for history.
151
112
 
152
- // Built asset manifest + in-memory storage for a single deployment. Rebuilding
153
- // these on every request is wasteful (CWE-770 amplification under a preview
154
- // flood), so we cache them per `branch:commitHash` with an LRU + TTL bound.
155
- type CachedAssets = {
156
- manifest: AssetManifest
157
- storage: AssetStorage
158
- expiresAt: number
159
- }
160
- const ASSET_CACHE_MAX_ENTRIES = 8
161
- const ASSET_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
162
-
163
113
  export class SpaceDO extends DurableObject<Env>
164
114
  implements SpaceWorkspacePort, SpaceControlPort, SpaceAppPort {
165
115
  private backend!: SpaceFsBackend
166
116
  private initializationPromise: Promise<void> | null = null
167
- private assetCache = new Map<string, CachedAssets>()
168
117
  /**
169
118
  * One lane for every operation that reads or rewrites the whole tree.
170
119
  *
@@ -220,11 +169,8 @@ export class SpaceDO extends DurableObject<Env>
220
169
  commit_hash TEXT NOT NULL,
221
170
  main_module TEXT NOT NULL,
222
171
  modules TEXT NOT NULL,
223
- assets TEXT NOT NULL DEFAULT '{}',
224
172
  asset_deployment_id TEXT NOT NULL DEFAULT '',
225
- asset_count INTEGER NOT NULL DEFAULT 0,
226
- asset_config TEXT NOT NULL DEFAULT '{}',
227
- compatibility_date TEXT NOT NULL DEFAULT '',
173
+ compatibility_date TEXT NOT NULL,
228
174
  deployed_at INTEGER NOT NULL
229
175
  );
230
176
  CREATE TABLE IF NOT EXISTS deployment_assets (
@@ -238,13 +184,6 @@ export class SpaceDO extends DurableObject<Env>
238
184
  );
239
185
  `)
240
186
 
241
- // Migrate existing deployments tables that lack new columns
242
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN assets TEXT NOT NULL DEFAULT '{}'`) } catch {}
243
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_deployment_id TEXT NOT NULL DEFAULT ''`) } catch {}
244
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_count INTEGER NOT NULL DEFAULT 0`) } catch {}
245
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_config TEXT NOT NULL DEFAULT '{}'`) } catch {}
246
- try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN compatibility_date TEXT NOT NULL DEFAULT ''`) } catch {}
247
-
248
187
  // Initialize git repo if not already done
249
188
  try {
250
189
  await this.git.init({ defaultBranch: "main" })
@@ -320,6 +259,21 @@ export class SpaceDO extends DurableObject<Env>
320
259
  }
321
260
  }
322
261
 
262
+ private async streamedFileLimit(path: string): Promise<number> {
263
+ const quota = defaultSpaceQuota()
264
+ const existing = await this.statInfo(path)
265
+ const usage = await this.usageOf()
266
+ if (quota.maxFiles !== null && !existing && usage.fileCount >= quota.maxFiles) {
267
+ throw new Error("Space file count quota exceeded")
268
+ }
269
+ return Math.max(0, Math.min(
270
+ quota.maxFileBytes ?? Number.MAX_SAFE_INTEGER,
271
+ quota.maxTotalBytes === null
272
+ ? Number.MAX_SAFE_INTEGER
273
+ : quota.maxTotalBytes - usage.totalBytes + (existing?.size ?? 0),
274
+ ))
275
+ }
276
+
323
277
  private async usageOf(): Promise<SpaceUsage> {
324
278
  let fileCount = 0
325
279
  let directoryCount = 0
@@ -636,7 +590,6 @@ export class SpaceDO extends DurableObject<Env>
636
590
  }
637
591
  }
638
592
  await this.deleteSpilledObjects()
639
- this.assetCache.clear()
640
593
  await this.ctx.storage.deleteAll()
641
594
  this.initializationPromise = null
642
595
  })
@@ -727,6 +680,44 @@ export class SpaceDO extends DurableObject<Env>
727
680
  await this.fs.writeFileBytes(target, bytes)
728
681
  }
729
682
 
683
+ async writeFileStream(
684
+ path: string,
685
+ content: ReadableStream<Uint8Array>,
686
+ options?: SpaceStreamWriteOptions,
687
+ ): Promise<{ path: string; bytes: number }> {
688
+ return this.lane(async () => {
689
+ await this.ensureInit()
690
+ const target = normalizeWritableSpacePath(path)
691
+ const expected = options?.contentLength
692
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0)) {
693
+ throw new Error("Stream contentLength must be a non-negative safe integer")
694
+ }
695
+ const limit = await this.streamedFileLimit(target)
696
+ if (expected !== undefined && expected > limit) {
697
+ throw new Error(`Space file exceeds the ${limit}-byte limit: ${target}`)
698
+ }
699
+ let bytes = 0
700
+ const counted = content.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
701
+ transform(chunk, controller) {
702
+ bytes += chunk.byteLength
703
+ if (bytes > limit) throw new Error(`Space file exceeds the ${limit}-byte limit: ${target}`)
704
+ controller.enqueue(chunk)
705
+ },
706
+ flush() {
707
+ if (expected !== undefined && bytes !== expected) {
708
+ throw new Error("Stream did not match declared contentLength")
709
+ }
710
+ },
711
+ }))
712
+ await this.backend.writeFileStream(
713
+ target,
714
+ counted,
715
+ options?.mediaType ?? "application/octet-stream",
716
+ )
717
+ return { path: target, bytes }
718
+ })
719
+ }
720
+
730
721
  async appendFile(path: string, content: string, mimeType?: string): Promise<void> {
731
722
  void mimeType
732
723
  await this.ensureInit()
@@ -1143,10 +1134,6 @@ export class SpaceDO extends DurableObject<Env>
1143
1134
  const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
1144
1135
  const data = await res.json() as Record<string, unknown>
1145
1136
  if (typeof data.error === "string") throw new Error(data.error)
1146
- this.assetCache.clear()
1147
- const deploymentId = data.branch as string
1148
- data.preview_url =
1149
- `/space/${this.spaceName}/preview/${encodeURIComponent(deploymentId)}/`
1150
1137
 
1151
1138
  if (!revision) await this.backend.push(branch)
1152
1139
 
@@ -1191,7 +1178,6 @@ export class SpaceDO extends DurableObject<Env>
1191
1178
  assetPrefix: spaceR2Prefix(this.spaceName),
1192
1179
  }
1193
1180
  const res = await handleDeployCommand(ctx, "undeploy", fakeRequest)
1194
- this.assetCache.clear()
1195
1181
  return res.json()
1196
1182
  }
1197
1183
 
@@ -1240,13 +1226,12 @@ export class SpaceDO extends DurableObject<Env>
1240
1226
  return { fileCount, directoryCount, totalBytes }
1241
1227
  }
1242
1228
 
1243
- // ── Deployment row reader (shared by servePreview + DB-viewer) ──
1229
+ // ── Deployment row reader ──
1244
1230
 
1245
1231
  private readDeployment(branch: string): DeploymentRow | null {
1246
1232
  const rows = this.ctx.storage.sql
1247
1233
  .exec(
1248
- `SELECT branch, commit_hash, main_module, modules, assets,
1249
- asset_deployment_id, asset_count, asset_config, compatibility_date
1234
+ `SELECT branch, commit_hash, main_module, modules, compatibility_date
1250
1235
  FROM deployments WHERE branch = ?`,
1251
1236
  branch,
1252
1237
  )
@@ -1258,17 +1243,11 @@ export class SpaceDO extends DurableObject<Env>
1258
1243
  commitHash: r.commit_hash as string,
1259
1244
  mainModule: r.main_module as string,
1260
1245
  modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
1261
- legacyAssets: deserializeAssets(
1262
- JSON.parse((r.assets as string) || "{}") as Record<string, SerializedAsset>,
1263
- ),
1264
- assetDeploymentId: (r.asset_deployment_id as string) || "",
1265
- assetCount: Number(r.asset_count ?? 0),
1266
- assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
1267
- compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
1246
+ compatibilityDate: r.compatibility_date as string,
1268
1247
  }
1269
1248
  }
1270
1249
 
1271
- // ── Preview serving via Dynamic Workers ─────────────────────────
1250
+ // ── App serving via Dynamic Workers ─────────────────────────────
1272
1251
  //
1273
1252
  // Architecture (matches Cloudflare's Durable Object Facets docs example):
1274
1253
  //
@@ -1276,10 +1255,8 @@ export class SpaceDO extends DurableObject<Env>
1276
1255
  // module. `App.fetch(request)` is the entire backend (Hono /
1277
1256
  // itty-router / vanilla — the LLM decides).
1278
1257
  // - SpaceDO acts as the supervisor ("AppRunner" in the docs).
1279
- // `servePreview` loads the user's worker via the Worker Loader,
1280
- // extracts the App class, and hosts it as a Facet keyed
1281
- // `app:<branch>`. Static assets are served host-side; everything
1282
- // else (including WebSocket upgrades) is forwarded into the Facet.
1258
+ // `serveApp` loads the user's worker via the Worker Loader, extracts the
1259
+ // App class, and hosts it as a Facet keyed `app:<branch>`.
1283
1260
  // - State is the Facet's own `ctx.storage` (SQLite + KV). No env.DB
1284
1261
  // binding is injected.
1285
1262
  // - To make the DB-viewer work without forcing the LLM to write
@@ -1289,40 +1266,12 @@ export class SpaceDO extends DurableObject<Env>
1289
1266
  // `App` that adds `__vibeInspectListTables` / `__vibeInspectRead`
1290
1267
  // / `__vibeWipe`. The subclass shares the same `ctx.storage`.
1291
1268
 
1292
- async servePreview(branch: string, request: Request): Promise<Response> {
1293
- await this.ensureInit()
1294
-
1295
- const dep = this.readDeployment(branch)
1296
- if (!dep) {
1297
- return new Response(`No deployment found for branch "${branch}"`, { status: 404 })
1298
- }
1299
-
1300
- // Serve static assets host-side before forwarding to the Facet. The built
1301
- // manifest/storage are cached per deployment so repeat asset reads don't
1302
- // re-spin the build on every request.
1303
- if (dep.assetCount > 0 || Object.keys(dep.legacyAssets).length > 0) {
1304
- const { manifest, storage } = await this.getCachedAssets(dep)
1305
- const assetResponse = await handleAssetRequest(request, manifest, storage, dep.assetConfig)
1306
- if (assetResponse) return assetResponse
1307
- }
1308
-
1309
- return this.serveDeploymentApp(branch, dep, request)
1310
- }
1311
-
1312
1269
  async serveApp(branch: string, request: Request): Promise<Response> {
1313
1270
  await this.ensureInit()
1314
1271
  const dep = this.readDeployment(branch)
1315
1272
  if (!dep) {
1316
1273
  return new Response(`No deployment found for branch "${branch}"`, { status: 404 })
1317
1274
  }
1318
- return this.serveDeploymentApp(branch, dep, request)
1319
- }
1320
-
1321
- private serveDeploymentApp(
1322
- branch: string,
1323
- dep: DeploymentRow,
1324
- request: Request,
1325
- ): Response | Promise<Response> {
1326
1275
  let appClass: DurableObjectClass
1327
1276
  try {
1328
1277
  appClass = this.loadAppClass(dep)
@@ -1337,73 +1286,6 @@ export class SpaceDO extends DurableObject<Env>
1337
1286
  return facet.fetch(request)
1338
1287
  }
1339
1288
 
1340
- /**
1341
- * Return the built asset manifest + in-memory storage for a deployment,
1342
- * reusing a cached build when available. Keyed by `branch:commitHash` so a
1343
- * redeploy (new commit) transparently rebuilds. Bounded by an LRU cap + TTL.
1344
- */
1345
- private async getCachedAssets(dep: DeploymentRow): Promise<CachedAssets> {
1346
- const key = `${dep.branch}:${dep.assetDeploymentId || dep.commitHash}`
1347
- const now = Date.now()
1348
-
1349
- const cached = this.assetCache.get(key)
1350
- if (cached && cached.expiresAt > now) {
1351
- // Refresh LRU recency.
1352
- this.assetCache.delete(key)
1353
- this.assetCache.set(key, cached)
1354
- return cached
1355
- }
1356
-
1357
- let manifest: AssetManifest
1358
- let storage: AssetStorage
1359
- if (dep.assetDeploymentId) {
1360
- const bucket = this.env.WORKSPACE_R2
1361
- if (!bucket) throw new Error("Static preview requires the WORKSPACE_R2 binding")
1362
- const rows = this.ctx.storage.sql
1363
- .exec(
1364
- `SELECT path, r2_key, content_type, etag
1365
- FROM deployment_assets WHERE deployment_id = ?`,
1366
- dep.assetDeploymentId,
1367
- )
1368
- .toArray()
1369
- const keys = new Map<string, string>()
1370
- manifest = new Map()
1371
- for (const row of rows) {
1372
- const path = row.path as string
1373
- keys.set(path, row.r2_key as string)
1374
- manifest.set(path, {
1375
- contentType: (row.content_type as string | null) ?? undefined,
1376
- etag: row.etag as string,
1377
- })
1378
- }
1379
- storage = {
1380
- async get(pathname) {
1381
- const r2Key = keys.get(pathname)
1382
- if (!r2Key) return null
1383
- return (await bucket.get(r2Key))?.body ?? null
1384
- },
1385
- }
1386
- } else {
1387
- manifest = await buildAssetManifest(dep.legacyAssets)
1388
- storage = createMemoryStorage(dep.legacyAssets)
1389
- }
1390
- const entry: CachedAssets = { manifest, storage, expiresAt: now + ASSET_CACHE_TTL_MS }
1391
-
1392
- this.assetCache.set(key, entry)
1393
-
1394
- // Evict expired / oldest entries to keep the cache bounded.
1395
- for (const [k, v] of this.assetCache) {
1396
- if (v.expiresAt <= now) this.assetCache.delete(k)
1397
- }
1398
- while (this.assetCache.size > ASSET_CACHE_MAX_ENTRIES) {
1399
- const oldest = this.assetCache.keys().next().value
1400
- if (oldest === undefined) break
1401
- this.assetCache.delete(oldest)
1402
- }
1403
-
1404
- return entry
1405
- }
1406
-
1407
1289
  // ── App-class loader ────────────────────────────────────────────
1408
1290
  //
1409
1291
  // Loads the dynamic worker for `branch`'s latest deployment with the
@@ -1428,7 +1310,7 @@ export class SpaceDO extends DurableObject<Env>
1428
1310
  // The date the app declared, not a date frozen into this file. A worker
1429
1311
  // built against one runtime and run under another is a bug the author
1430
1312
  // cannot see or fix.
1431
- compatibilityDate: dep.compatibilityDate || FALLBACK_COMPATIBILITY_DATE,
1313
+ compatibilityDate: dep.compatibilityDate,
1432
1314
  // Generated code gets no outbound network. Omitting this inherits the
1433
1315
  // parent Worker's full internet access, which would let an app the model
1434
1316
  // wrote reach anything this Worker can — including internal services.
@@ -1503,40 +1385,12 @@ export class SpaceDO extends DurableObject<Env>
1503
1385
  return { ok: true }
1504
1386
  }
1505
1387
 
1506
- // ── HTTP handler: preview serving + internal deploy commands ────
1388
+ // ── Internal deploy commands ──
1507
1389
 
1508
1390
  async fetch(request: Request): Promise<Response> {
1509
1391
  await this.ensureInit()
1510
1392
 
1511
- const url = new URL(request.url)
1512
- const path = url.pathname
1513
-
1514
- // Preview routes: /space/:name/preview/:branch/*
1515
- const previewMatch = path.match(/\/preview\/([^/]+)(\/.*)?$/)
1516
- if (previewMatch) {
1517
- const branch = decodeURIComponent(previewMatch[1])
1518
- const spaceName = this.ctx.id.name ?? "space"
1519
- const basePath = `/space/${spaceName}/preview/${encodeURIComponent(branch)}`
1520
-
1521
- // Rewrite the URL so the dynamic worker sees a clean path
1522
- const subPath = previewMatch[2] || "/"
1523
- const previewUrl = new URL(subPath, url.origin)
1524
- previewUrl.search = url.search
1525
- const previewRequest = new Request(previewUrl.toString(), request)
1526
- const response = await this.servePreview(branch, previewRequest)
1527
-
1528
- // Strip headers a generated app must not be able to set on the shared
1529
- // preview origin (e.g. Service-Worker-Allowed scope expansion) before
1530
- // any further rewriting.
1531
- const safeResponse = stripPreviewSecurityHeaders(response)
1532
-
1533
- // Rewrite root-relative paths in HTML responses so they resolve
1534
- // correctly when the preview is mounted on a sub-path
1535
- return rewritePreviewResponse(safeResponse, basePath)
1536
- }
1537
-
1538
- // Deploy command routes
1539
- const cmd = url.searchParams.get("cmd")
1393
+ const cmd = new URL(request.url).searchParams.get("cmd")
1540
1394
  if (cmd && ["deploy", "get_deployment", "list_deployments", "undeploy"].includes(cmd)) {
1541
1395
  const deployCtx: DeployContext = {
1542
1396
  sql: this.ctx.storage.sql,
@@ -1552,39 +1406,6 @@ export class SpaceDO extends DurableObject<Env>
1552
1406
  }
1553
1407
  }
1554
1408
 
1555
- // ─── Preview Response Rewriting ──────────────────────────────────────────────
1556
- // When a preview is served on /space/:name/preview/:branch/, root-relative
1557
- // paths like /style.css in HTML would resolve to the domain root instead of
1558
- // the preview path. We rewrite them so the browser fetches the correct URL.
1559
-
1560
- function rewritePreviewResponse(response: Response, basePath: string): Response {
1561
- // Rewrite Location header on redirects
1562
- const location = response.headers.get("location")
1563
- if (location?.startsWith("/")) {
1564
- const rewritten = new Response(response.body, response)
1565
- rewritten.headers.set("location", basePath + location)
1566
- return rewritten
1567
- }
1568
-
1569
- // Only rewrite HTML responses
1570
- const ct = response.headers.get("content-type") ?? ""
1571
- if (!ct.includes("text/html")) return response
1572
-
1573
- // Use HTMLRewriter to prefix root-relative src/href/action attributes
1574
- return new HTMLRewriter()
1575
- .on("[src],[href],[action]", {
1576
- element(el) {
1577
- for (const attr of ["src", "href", "action"] as const) {
1578
- const val = el.getAttribute(attr)
1579
- if (val?.startsWith("/") && !val.startsWith("//")) {
1580
- el.setAttribute(attr, basePath + val)
1581
- }
1582
- }
1583
- },
1584
- })
1585
- .transform(response)
1586
- }
1587
-
1588
1409
  // ─── Facet naming ───────────────────────────────────────────────────────────
1589
1410
 
1590
1411
  function facetNameForApp(branch: string): string {
@@ -1596,9 +1417,6 @@ function facetNameForApp(branch: string): string {
1596
1417
  /** Git's oid for an empty tree — what a repository with no content writes. */
1597
1418
  const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
1598
1419
 
1599
- /** Used only for deployments recorded before the date was persisted. */
1600
- const FALLBACK_COMPATIBILITY_DATE = "2025-04-01"
1601
-
1602
1420
  const SPACE_AUTHOR = Object.freeze({
1603
1421
  name: "Workspace",
1604
1422
  email: "workspace@springbrand.local",
@@ -47,6 +47,7 @@ export interface SpaceFsBackend {
47
47
  ready(): Promise<void>
48
48
  hydrate(path: string): Promise<void>
49
49
  materializeAll(): Promise<void>
50
+ writeFileStream(path: string, content: ReadableStream<Uint8Array>, mediaType: string): Promise<void>
50
51
  flushCheckpoint(): Promise<void>
51
52
  push(branch: string): Promise<boolean>
52
53
  fetch(branch: string): Promise<void>
@@ -134,6 +135,10 @@ export class ArtifactsBackend implements SpaceFsBackend {
134
135
  await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
135
136
  }
136
137
 
138
+ async writeFileStream(): Promise<void> {
139
+ throw new Error("Streaming writes require the SQL Space backend")
140
+ }
141
+
137
142
  async flushCheckpoint(): Promise<void> {
138
143
  if (this.checkpointDirty.size === 0) return
139
144
  const paths = [...this.checkpointDirty]
@@ -199,6 +204,57 @@ export function spaceR2Prefix(spaceId: string): string {
199
204
  return `spaces/v1/${spaceId}`
200
205
  }
201
206
 
207
+ const R2_MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
208
+
209
+ async function putStream(
210
+ bucket: R2Bucket,
211
+ key: string,
212
+ content: ReadableStream<Uint8Array>,
213
+ mediaType: string,
214
+ ): Promise<R2Object> {
215
+ const upload = await bucket.createMultipartUpload(key, {
216
+ httpMetadata: { contentType: mediaType },
217
+ })
218
+ const uploaded: R2UploadedPart[] = []
219
+ const reader = content.getReader()
220
+ let buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
221
+ let buffered = 0
222
+ let partNumber = 1
223
+ const flush = async () => {
224
+ if (buffered === 0) return
225
+ uploaded.push(await upload.uploadPart(partNumber++, buffer.slice(0, buffered)))
226
+ buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
227
+ buffered = 0
228
+ }
229
+ try {
230
+ while (true) {
231
+ const next = await reader.read()
232
+ if (next.done) break
233
+ let offset = 0
234
+ while (offset < next.value.byteLength) {
235
+ const copied = Math.min(buffer.byteLength - buffered, next.value.byteLength - offset)
236
+ buffer.set(next.value.subarray(offset, offset + copied), buffered)
237
+ buffered += copied
238
+ offset += copied
239
+ if (buffered === buffer.byteLength) await flush()
240
+ }
241
+ }
242
+ await flush()
243
+ if (uploaded.length === 0) {
244
+ await upload.abort()
245
+ return (await bucket.put(key, new Uint8Array(), {
246
+ httpMetadata: { contentType: mediaType },
247
+ }))
248
+ }
249
+ return upload.complete(uploaded)
250
+ } catch (error) {
251
+ await upload.abort().catch(() => undefined)
252
+ throw error
253
+ } finally {
254
+ reader.releaseLock()
255
+ }
256
+ }
257
+
202
258
  export class SqlBackend implements SpaceFsBackend {
203
259
  readonly overlay: FileSystem
204
260
  readonly fs: FileSystem
@@ -212,7 +268,11 @@ export class SqlBackend implements SpaceFsBackend {
212
268
  * row will hold — and a Space that fills its DO storage stops accepting
213
269
  * writes for every other file too.
214
270
  */
215
- constructor(ctx: DurableObjectState, repoName: string, r2?: R2Bucket) {
271
+ constructor(
272
+ private readonly ctx: DurableObjectState,
273
+ private readonly repoName: string,
274
+ private readonly r2?: R2Bucket,
275
+ ) {
216
276
  const workspace = new Workspace({
217
277
  sql: ctx.storage.sql,
218
278
  name: () => repoName,
@@ -229,6 +289,63 @@ export class SqlBackend implements SpaceFsBackend {
229
289
  async ready(): Promise<void> {}
230
290
  async hydrate(_path: string): Promise<void> {}
231
291
  async materializeAll(): Promise<void> {}
292
+ async writeFileStream(
293
+ path: string,
294
+ content: ReadableStream<Uint8Array>,
295
+ mediaType: string,
296
+ ): Promise<void> {
297
+ if (!this.r2) throw new Error("Streaming writes require an R2 binding")
298
+ await this.workspace.exists("/")
299
+ const existing = await this.workspace.stat(path)
300
+ if (existing?.type === "directory") throw new Error(`EISDIR: ${path} is a directory`)
301
+ const parent = path.slice(0, path.lastIndexOf("/")) || "/"
302
+ await this.workspace.mkdir(parent, { recursive: true })
303
+
304
+ // @cloudflare/shell@0.4.3 owns this table. Its public writeFileStream buffers;
305
+ // this adapter keeps the same row contract while R2 consumes the body stream.
306
+ const table = "cf_workspace_default"
307
+ const previous = this.ctx.storage.sql.exec<{ r2Key: string | null }>(
308
+ `SELECT r2_key AS r2Key FROM ${table} WHERE path = ?`,
309
+ path,
310
+ ).toArray()[0]
311
+ const key = `${spaceR2Prefix(this.repoName)}/streams/${crypto.randomUUID()}`
312
+ try {
313
+ const object = await putStream(this.r2, key, content, mediaType)
314
+ const name = path.split("/").at(-1)!
315
+ const now = Math.floor(Date.now() / 1_000)
316
+ this.ctx.storage.sql.exec(
317
+ `INSERT INTO ${table}
318
+ (path, parent_path, name, type, mime_type, size, storage_backend,
319
+ r2_key, content_encoding, content, created_at, modified_at)
320
+ VALUES (?, ?, ?, 'file', ?, ?, 'r2', ?, 'base64', NULL, ?, ?)
321
+ ON CONFLICT(path) DO UPDATE SET
322
+ parent_path = excluded.parent_path,
323
+ name = excluded.name,
324
+ type = 'file',
325
+ mime_type = excluded.mime_type,
326
+ size = excluded.size,
327
+ storage_backend = 'r2',
328
+ r2_key = excluded.r2_key,
329
+ content_encoding = 'base64',
330
+ content = NULL,
331
+ modified_at = excluded.modified_at`,
332
+ path,
333
+ parent,
334
+ name,
335
+ mediaType,
336
+ object.size,
337
+ key,
338
+ now,
339
+ now,
340
+ )
341
+ } catch (error) {
342
+ await this.r2.delete(key).catch(() => undefined)
343
+ throw error
344
+ }
345
+ if (previous?.r2Key && previous.r2Key !== key) {
346
+ await this.r2.delete(previous.r2Key).catch(() => undefined)
347
+ }
348
+ }
232
349
  async flushCheckpoint(): Promise<void> {}
233
350
  async push(_branch: string): Promise<boolean> { return false }
234
351
  async fetch(_branch: string): Promise<void> {}
@@ -182,6 +182,11 @@ export interface SpaceWorkspacePort {
182
182
  data: Uint8Array | ArrayBuffer,
183
183
  mimeType?: string,
184
184
  ): Promise<void>;
185
+ writeFileStream(
186
+ path: string,
187
+ content: ReadableStream<Uint8Array>,
188
+ options?: SpaceStreamWriteOptions,
189
+ ): Promise<{ path: string; bytes: number }>;
185
190
  appendFile(path: string, content: string, mimeType?: string): Promise<void>;
186
191
  exists(path: string): Promise<boolean>;
187
192
  stat(path: string): Promise<SpaceFileInfo | null>;
@@ -199,6 +204,11 @@ export interface SpaceWorkspacePort {
199
204
  glob(pattern: string): Promise<SpaceFileInfo[]>;
200
205
  }
201
206
 
207
+ export interface SpaceStreamWriteOptions {
208
+ contentLength?: number;
209
+ mediaType?: string;
210
+ }
211
+
202
212
  /**
203
213
  * Everything a Space does that is not a file operation.
204
214
  *
@@ -255,7 +265,6 @@ export interface SpaceAppPort {
255
265
  revision?: string,
256
266
  runtimeConfig?: DeploymentRuntimeConfig,
257
267
  ): Promise<BranchDeploymentBundle>;
258
- servePreview(branch: string, request: Request): Promise<Response>;
259
268
  serveApp(branch: string, request: Request): Promise<Response>;
260
269
  listAppTables(branch: string): Promise<unknown>;
261
270
  queryAppTable(branch: string, table: string, opts?: unknown): Promise<unknown>;