@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 +1 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/space/deploy-engine.ts +13 -22
- package/src/space/durable-object.ts +65 -247
- package/src/space/fs-backend.ts +118 -1
- package/src/space/workspace-port.ts +10 -1
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.
|
|
29
|
-
both asset and backend traffic through SpaceDO.
|
|
28
|
+
Facet. SpaceDO never reads static assets for browser requests.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -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,
|
|
322
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
// `
|
|
1280
|
-
//
|
|
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
|
|
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
|
-
// ──
|
|
1388
|
+
// ── Internal deploy commands ──
|
|
1507
1389
|
|
|
1508
1390
|
async fetch(request: Request): Promise<Response> {
|
|
1509
1391
|
await this.ensureInit()
|
|
1510
1392
|
|
|
1511
|
-
const
|
|
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",
|
package/src/space/fs-backend.ts
CHANGED
|
@@ -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(
|
|
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>;
|