@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.
@@ -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
+ }