@springbrand/space 0.1.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/LICENSE +21 -0
- package/README.md +24 -0
- package/package.json +30 -0
- package/src/env.ts +30 -0
- package/src/index.ts +27 -0
- package/src/space/artifacts-fs.ts +516 -0
- package/src/space/artifacts-sync.ts +331 -0
- package/src/space/checkpoint.ts +101 -0
- package/src/space/deploy-engine.ts +363 -0
- package/src/space/durable-object.ts +1557 -0
- package/src/space/fileinfo.ts +117 -0
- package/src/space/fs-backend.ts +235 -0
- package/src/space/git-objects.ts +315 -0
- package/src/space/inspector-wrapper.ts +141 -0
- package/src/space/preview-headers.ts +38 -0
- package/src/space/workspace-port.ts +245 -0
- package/src/space/wrangler-config.ts +188 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { Git } from "@cloudflare/shell/git"
|
|
2
|
+
import type { FileSystem } from "@cloudflare/shell"
|
|
3
|
+
import { createApp, createWorker, type AssetConfig, type Modules } from "@cloudflare/worker-bundler"
|
|
4
|
+
import { parseWranglerConfig, WranglerConfigError } from "./wrangler-config"
|
|
5
|
+
import { globInfos } from "./fileinfo"
|
|
6
|
+
|
|
7
|
+
// ─── Deploy Engine ──────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/** Shared JSON response helper for the internal deploy command handlers. */
|
|
10
|
+
function jsonResponse(data: unknown, status: number = 200): Response {
|
|
11
|
+
return new Response(JSON.stringify(data), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeployContext {
|
|
18
|
+
sql: SqlStorage
|
|
19
|
+
git: Git
|
|
20
|
+
fs: FileSystem
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BranchDeploymentBundle {
|
|
24
|
+
branch: string
|
|
25
|
+
commitHash: string
|
|
26
|
+
mainModule: string
|
|
27
|
+
modules: Record<string, string | Record<string, unknown>>
|
|
28
|
+
assets: Record<string, string>
|
|
29
|
+
assetConfig: AssetConfig | undefined
|
|
30
|
+
compatibilityDate: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function handleDeployCommand(
|
|
34
|
+
ctx: DeployContext,
|
|
35
|
+
cmd: string,
|
|
36
|
+
request: Request
|
|
37
|
+
): Promise<Response> {
|
|
38
|
+
try {
|
|
39
|
+
switch (cmd) {
|
|
40
|
+
case "deploy":
|
|
41
|
+
return await deployBranch(ctx, request)
|
|
42
|
+
case "get_deployment":
|
|
43
|
+
return await getDeployment(ctx, request)
|
|
44
|
+
case "list_deployments":
|
|
45
|
+
return await listDeployments(ctx)
|
|
46
|
+
case "undeploy":
|
|
47
|
+
return await undeployBranch(ctx, request)
|
|
48
|
+
default:
|
|
49
|
+
return jsonResponse({ error: `Unknown deploy command: ${cmd}` }, 400)
|
|
50
|
+
}
|
|
51
|
+
} catch (e: any) {
|
|
52
|
+
return jsonResponse({ error: e.message ?? String(e) }, 500)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Read files from a git branch using shell's git ─────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce a caller-supplied app root to a clean relative directory.
|
|
60
|
+
*
|
|
61
|
+
* A Space is not an app. Most hold a spreadsheet and a couple of scripts, and
|
|
62
|
+
* only the directory the caller names is what gets built and shipped — nothing
|
|
63
|
+
* else in the Space is scanned, let alone deployed.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeAppRoot(appRoot?: string | null): string {
|
|
66
|
+
if (!appRoot) return ""
|
|
67
|
+
const parts = appRoot.replaceAll("\\", "/").split("/").filter(Boolean)
|
|
68
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
69
|
+
throw new Error("appRoot cannot traverse")
|
|
70
|
+
}
|
|
71
|
+
return parts.join("/")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readBranchFiles(
|
|
75
|
+
ctx: DeployContext,
|
|
76
|
+
branch: string,
|
|
77
|
+
appRoot = ""
|
|
78
|
+
): Promise<{ commitHash: string; files: Record<string, string> }> {
|
|
79
|
+
// Get commit log for the branch to find the commit hash
|
|
80
|
+
const log = await ctx.git.log({ ref: branch, depth: 1 })
|
|
81
|
+
if (log.length === 0) {
|
|
82
|
+
throw new Error(`No commits found on branch "${branch}"`)
|
|
83
|
+
}
|
|
84
|
+
const commitHash = log[0].oid
|
|
85
|
+
|
|
86
|
+
// Checkout the branch to populate working tree
|
|
87
|
+
await ctx.git.checkout({ ref: branch, force: true })
|
|
88
|
+
|
|
89
|
+
// Read all files recursively (readDir is non-recursive, glob is)
|
|
90
|
+
const allFiles = await globInfos(ctx.fs, "**/*")
|
|
91
|
+
const files: Record<string, string> = {}
|
|
92
|
+
|
|
93
|
+
for (const fileInfo of allFiles) {
|
|
94
|
+
if (fileInfo.type !== "file") continue
|
|
95
|
+
// Skip git's object store and the ArtifactsFileSystem bookkeeping dir —
|
|
96
|
+
// neither is part of the app and must never ship in a deploy bundle.
|
|
97
|
+
if (fileInfo.path.startsWith("/.git/") || fileInfo.path === "/.git") continue
|
|
98
|
+
if (fileInfo.path.startsWith("/.afs/") || fileInfo.path === "/.afs") continue
|
|
99
|
+
|
|
100
|
+
let content: string
|
|
101
|
+
try {
|
|
102
|
+
content = await ctx.fs.readFile(fileInfo.path)
|
|
103
|
+
} catch {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const path = fileInfo.path.startsWith("/") ? fileInfo.path.slice(1) : fileInfo.path
|
|
107
|
+
if (appRoot) {
|
|
108
|
+
if (path !== appRoot && !path.startsWith(`${appRoot}/`)) continue
|
|
109
|
+
files[path.slice(appRoot.length + 1)] = content
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
files[path] = content
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { commitHash, files }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Deploy a branch ────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
async function deployBranch(
|
|
121
|
+
ctx: DeployContext,
|
|
122
|
+
request: Request
|
|
123
|
+
): Promise<Response> {
|
|
124
|
+
const body = (await request.json()) as { branch: string; appRoot?: string }
|
|
125
|
+
const branch = body.branch
|
|
126
|
+
if (!branch) {
|
|
127
|
+
return jsonResponse({ error: "branch is required" }, 400)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let bundle: BranchDeploymentBundle
|
|
131
|
+
try {
|
|
132
|
+
bundle = await buildBranchDeployment(ctx, branch, body.appRoot)
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
135
|
+
const separator = message.indexOf(": ")
|
|
136
|
+
return jsonResponse({
|
|
137
|
+
error: separator > 0 ? message.slice(0, separator) : message,
|
|
138
|
+
...(separator > 0 ? { details: message.slice(separator + 2) } : {}),
|
|
139
|
+
}, 400)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const {
|
|
143
|
+
commitHash,
|
|
144
|
+
mainModule,
|
|
145
|
+
modules: serializedModules,
|
|
146
|
+
assets: serializedAssets,
|
|
147
|
+
assetConfig,
|
|
148
|
+
compatibilityDate: compatDate,
|
|
149
|
+
} = bundle
|
|
150
|
+
|
|
151
|
+
const now = Date.now()
|
|
152
|
+
ctx.sql.exec(
|
|
153
|
+
`INSERT OR REPLACE INTO deployments
|
|
154
|
+
(branch, commit_hash, main_module, modules, assets, asset_config, compatibility_date, deployed_at)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
156
|
+
branch,
|
|
157
|
+
commitHash,
|
|
158
|
+
mainModule,
|
|
159
|
+
JSON.stringify(serializedModules),
|
|
160
|
+
JSON.stringify(serializedAssets),
|
|
161
|
+
assetConfig ? JSON.stringify(assetConfig) : "{}",
|
|
162
|
+
compatDate,
|
|
163
|
+
now
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return jsonResponse({
|
|
167
|
+
branch,
|
|
168
|
+
commit_hash: commitHash,
|
|
169
|
+
main_module: mainModule,
|
|
170
|
+
has_assets: Object.keys(serializedAssets).length > 0,
|
|
171
|
+
compatibility_date: compatDate,
|
|
172
|
+
deployed_at: new Date(now).toISOString(),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function buildBranchDeployment(
|
|
177
|
+
ctx: DeployContext,
|
|
178
|
+
branch: string,
|
|
179
|
+
appRoot?: string | null
|
|
180
|
+
): Promise<BranchDeploymentBundle> {
|
|
181
|
+
const root = normalizeAppRoot(appRoot)
|
|
182
|
+
const { commitHash, files } = await readBranchFiles(ctx, branch, root)
|
|
183
|
+
if (Object.keys(files).length === 0) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
root
|
|
186
|
+
? `No files found under "${root}" on branch "${branch}"`
|
|
187
|
+
: `No files found in branch "${branch}"`
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let wranglerCfg
|
|
192
|
+
try {
|
|
193
|
+
wranglerCfg = parseWranglerConfig(files)
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (e instanceof WranglerConfigError) {
|
|
196
|
+
throw new Error(`Invalid wrangler.json: ${e.message}`)
|
|
197
|
+
}
|
|
198
|
+
throw e
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (wranglerCfg.durableObjects?.length) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
"Durable Object bindings are not allowed in wrangler.json: The platform runs your app as a single Durable Object (`export class App extends DurableObject` from your main module). Do not declare `durable_objects.bindings`."
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const assetsDir = wranglerCfg.assets?.directory?.replace(/^\.?\//, "").replace(/\/$/, "")
|
|
209
|
+
const collectedAssets = assetsDir
|
|
210
|
+
? Object.fromEntries(
|
|
211
|
+
Object.entries(files)
|
|
212
|
+
.filter(([path]) => path === assetsDir || path.startsWith(`${assetsDir}/`))
|
|
213
|
+
.map(([path, content]) => [`/${path.slice(assetsDir.length + 1)}`, content])
|
|
214
|
+
)
|
|
215
|
+
: {}
|
|
216
|
+
const assetConfig: AssetConfig | undefined = assetsDir
|
|
217
|
+
? {
|
|
218
|
+
...(wranglerCfg.assets?.notFoundHandling && {
|
|
219
|
+
not_found_handling: wranglerCfg.assets.notFoundHandling,
|
|
220
|
+
}),
|
|
221
|
+
...(wranglerCfg.assets?.htmlHandling && {
|
|
222
|
+
html_handling: wranglerCfg.assets.htmlHandling,
|
|
223
|
+
}),
|
|
224
|
+
}
|
|
225
|
+
: undefined
|
|
226
|
+
|
|
227
|
+
if (Object.keys(collectedAssets).length) {
|
|
228
|
+
const result = await createApp({
|
|
229
|
+
files,
|
|
230
|
+
assets: collectedAssets,
|
|
231
|
+
assetConfig,
|
|
232
|
+
server: wranglerCfg.main,
|
|
233
|
+
})
|
|
234
|
+
return {
|
|
235
|
+
branch,
|
|
236
|
+
commitHash,
|
|
237
|
+
mainModule: result.mainModule,
|
|
238
|
+
modules: serializeModules(result.modules),
|
|
239
|
+
assets: serializeAssets(result.assets),
|
|
240
|
+
assetConfig: result.assetConfig,
|
|
241
|
+
compatibilityDate: wranglerCfg.compatibilityDate ?? "2025-04-01",
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const result = await createWorker({ files, entryPoint: wranglerCfg.main })
|
|
246
|
+
const modules = serializeModules(result.modules)
|
|
247
|
+
modules["__STATIC_CONTENT_MANIFEST"] ??= { text: "{}" }
|
|
248
|
+
return {
|
|
249
|
+
branch,
|
|
250
|
+
commitHash,
|
|
251
|
+
mainModule: result.mainModule,
|
|
252
|
+
modules,
|
|
253
|
+
assets: {},
|
|
254
|
+
assetConfig,
|
|
255
|
+
compatibilityDate: wranglerCfg.compatibilityDate ?? "2025-04-01",
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
throw new Error(`Build failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Get a deployment ───────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
async function getDeployment(
|
|
265
|
+
ctx: DeployContext,
|
|
266
|
+
request: Request
|
|
267
|
+
): Promise<Response> {
|
|
268
|
+
const url = new URL(request.url)
|
|
269
|
+
const branch = url.searchParams.get("branch")
|
|
270
|
+
if (!branch) {
|
|
271
|
+
return jsonResponse({ error: "branch query param is required" }, 400)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const row = ctx.sql
|
|
275
|
+
.exec(
|
|
276
|
+
"SELECT branch, commit_hash, main_module, modules, assets, asset_config, deployed_at FROM deployments WHERE branch = ?",
|
|
277
|
+
branch
|
|
278
|
+
)
|
|
279
|
+
.toArray()
|
|
280
|
+
|
|
281
|
+
if (row.length === 0) {
|
|
282
|
+
return jsonResponse({ error: `No deployment found for branch "${branch}"` }, 404)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const r = row[0]
|
|
286
|
+
const assets = JSON.parse((r.assets as string) || "{}")
|
|
287
|
+
return jsonResponse({
|
|
288
|
+
branch: r.branch as string,
|
|
289
|
+
commit_hash: r.commit_hash as string,
|
|
290
|
+
main_module: r.main_module as string,
|
|
291
|
+
modules: JSON.parse(r.modules as string),
|
|
292
|
+
has_assets: Object.keys(assets).length > 0,
|
|
293
|
+
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── List deployments ───────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
async function listDeployments(ctx: DeployContext): Promise<Response> {
|
|
300
|
+
const rows = ctx.sql
|
|
301
|
+
.exec("SELECT branch, commit_hash, main_module, assets, deployed_at FROM deployments ORDER BY deployed_at DESC")
|
|
302
|
+
.toArray()
|
|
303
|
+
|
|
304
|
+
const deployments = rows.map((r) => {
|
|
305
|
+
const assets = JSON.parse((r.assets as string) || "{}")
|
|
306
|
+
return {
|
|
307
|
+
branch: r.branch as string,
|
|
308
|
+
commit_hash: r.commit_hash as string,
|
|
309
|
+
main_module: r.main_module as string,
|
|
310
|
+
has_assets: Object.keys(assets).length > 0,
|
|
311
|
+
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
return jsonResponse(deployments)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Undeploy a branch ──────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async function undeployBranch(
|
|
321
|
+
ctx: DeployContext,
|
|
322
|
+
request: Request
|
|
323
|
+
): Promise<Response> {
|
|
324
|
+
const body = (await request.json()) as { branch: string }
|
|
325
|
+
const branch = body.branch
|
|
326
|
+
if (!branch) {
|
|
327
|
+
return jsonResponse({ error: "branch is required" }, 400)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = ctx.sql.exec(
|
|
331
|
+
"DELETE FROM deployments WHERE branch = ?",
|
|
332
|
+
branch
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if (result.rowsWritten === 0) {
|
|
336
|
+
return jsonResponse({ error: `No deployment found for branch "${branch}"` }, 404)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return jsonResponse({ ok: true, branch })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Serialization helpers ───────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
function serializeModules(modules: Modules): Record<string, string | Record<string, unknown>> {
|
|
345
|
+
const out: Record<string, string | Record<string, unknown>> = {}
|
|
346
|
+
for (const [name, value] of Object.entries(modules)) {
|
|
347
|
+
out[name] = typeof value === "string" ? value : (value as Record<string, unknown>)
|
|
348
|
+
}
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function serializeAssets(
|
|
353
|
+
assets: Record<string, string | ArrayBuffer>
|
|
354
|
+
): Record<string, string> {
|
|
355
|
+
const out: Record<string, string> = {}
|
|
356
|
+
for (const [path, content] of Object.entries(assets)) {
|
|
357
|
+
// Store text as-is; encode binary as base64
|
|
358
|
+
out[path] = typeof content === "string"
|
|
359
|
+
? content
|
|
360
|
+
: btoa(String.fromCharCode(...new Uint8Array(content)))
|
|
361
|
+
}
|
|
362
|
+
return out
|
|
363
|
+
}
|