@springbrand/space 0.1.0-alpha.1 → 0.1.0-alpha.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/space",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -1,8 +1,19 @@
1
1
  import type { Git } from "@cloudflare/shell/git"
2
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"
3
+ import {
4
+ createApp,
5
+ createWorker,
6
+ inferContentType,
7
+ isTextContentType,
8
+ type AssetConfig,
9
+ type Modules,
10
+ } from "@cloudflare/worker-bundler"
5
11
  import { globInfos } from "./fileinfo"
12
+ import {
13
+ parseWranglerConfig,
14
+ type ParsedWranglerConfig,
15
+ WranglerConfigError,
16
+ } from "./wrangler-config"
6
17
 
7
18
  // ─── Deploy Engine ──────────────────────────────────────────────────────────
8
19
 
@@ -25,11 +36,25 @@ export interface BranchDeploymentBundle {
25
36
  commitHash: string
26
37
  mainModule: string
27
38
  modules: Record<string, string | Record<string, unknown>>
28
- assets: Record<string, string>
39
+ assets: Record<string, SerializedAsset>
29
40
  assetConfig: AssetConfig | undefined
30
41
  compatibilityDate: string
31
42
  }
32
43
 
44
+ export type SerializedAsset = string | { base64: string }
45
+ type WebsiteAssets = Record<string, string | ArrayBuffer>
46
+
47
+ const DEFAULT_COMPATIBILITY_DATE = "2025-04-01"
48
+ const STATIC_APP_MAIN = ".springbrand/static-app.ts"
49
+ const STATIC_APP_SOURCE = `import { DurableObject } from "cloudflare:workers"
50
+
51
+ export class App extends DurableObject {
52
+ fetch() {
53
+ return new Response("Not Found", { status: 404 })
54
+ }
55
+ }
56
+ `
57
+
33
58
  export async function handleDeployCommand(
34
59
  ctx: DeployContext,
35
60
  cmd: string,
@@ -74,45 +99,56 @@ export function normalizeAppRoot(appRoot?: string | null): string {
74
99
  async function readBranchFiles(
75
100
  ctx: DeployContext,
76
101
  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 })
102
+ appRoot = "",
103
+ revision?: string,
104
+ ): Promise<{
105
+ commitHash: string
106
+ files: Record<string, string>
107
+ assets: WebsiteAssets
108
+ }> {
109
+ const log = await ctx.git.log({ ref: branch, depth: revision ? 1000 : 1 })
81
110
  if (log.length === 0) {
82
111
  throw new Error(`No commits found on branch "${branch}"`)
83
112
  }
84
- const commitHash = log[0].oid
113
+ const commitHash = revision
114
+ ? log.find((entry) => entry.oid === revision)?.oid
115
+ : log[0].oid
116
+ if (!commitHash) {
117
+ throw new Error(`Revision "${revision}" was not found on branch "${branch}"`)
118
+ }
85
119
 
86
- // Checkout the branch to populate working tree
87
- await ctx.git.checkout({ ref: branch, force: true })
120
+ await ctx.git.checkout({ ref: revision ? commitHash : branch, force: true })
88
121
 
89
- // Read all files recursively (readDir is non-recursive, glob is)
90
- const allFiles = await globInfos(ctx.fs, "**/*")
91
122
  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
123
+ const assets: WebsiteAssets = {}
124
+ try {
125
+ for (const fileInfo of await globInfos(ctx.fs, "**/*")) {
126
+ if (fileInfo.type !== "file") continue
127
+ // Skip git's object store and the ArtifactsFileSystem bookkeeping dir
128
+ // neither is part of the app and must never ship in a deploy bundle.
129
+ if (fileInfo.path.startsWith("/.git/") || fileInfo.path === "/.git") continue
130
+ if (fileInfo.path.startsWith("/.afs/") || fileInfo.path === "/.afs") continue
131
+
132
+ let bytes: Uint8Array
133
+ try {
134
+ bytes = await ctx.fs.readFileBytes(fileInfo.path)
135
+ } catch {
136
+ continue
137
+ }
138
+ const path = fileInfo.path.startsWith("/") ? fileInfo.path.slice(1) : fileInfo.path
139
+ if (appRoot) {
140
+ if (path !== appRoot && !path.startsWith(`${appRoot}/`)) continue
141
+ const relativePath = path.slice(appRoot.length + 1)
142
+ addWebsiteFile(files, assets, relativePath, bytes)
143
+ continue
144
+ }
145
+ addWebsiteFile(files, assets, path, bytes)
111
146
  }
112
- files[path] = content
147
+ } finally {
148
+ if (revision) await ctx.git.checkout({ ref: branch, force: true })
113
149
  }
114
150
 
115
- return { commitHash, files }
151
+ return { commitHash, files, assets }
116
152
  }
117
153
 
118
154
  // ─── Deploy a branch ────────────────────────────────────────────────────────
@@ -121,7 +157,11 @@ async function deployBranch(
121
157
  ctx: DeployContext,
122
158
  request: Request
123
159
  ): Promise<Response> {
124
- const body = (await request.json()) as { branch: string; appRoot?: string }
160
+ const body = (await request.json()) as {
161
+ branch: string
162
+ appRoot?: string
163
+ revision?: string
164
+ }
125
165
  const branch = body.branch
126
166
  if (!branch) {
127
167
  return jsonResponse({ error: "branch is required" }, 400)
@@ -129,7 +169,7 @@ async function deployBranch(
129
169
 
130
170
  let bundle: BranchDeploymentBundle
131
171
  try {
132
- bundle = await buildBranchDeployment(ctx, branch, body.appRoot)
172
+ bundle = await buildBranchDeployment(ctx, branch, body.appRoot, body.revision)
133
173
  } catch (e) {
134
174
  const message = e instanceof Error ? e.message : String(e)
135
175
  const separator = message.indexOf(": ")
@@ -140,6 +180,7 @@ async function deployBranch(
140
180
  }
141
181
 
142
182
  const {
183
+ branch: deploymentId,
143
184
  commitHash,
144
185
  mainModule,
145
186
  modules: serializedModules,
@@ -153,7 +194,7 @@ async function deployBranch(
153
194
  `INSERT OR REPLACE INTO deployments
154
195
  (branch, commit_hash, main_module, modules, assets, asset_config, compatibility_date, deployed_at)
155
196
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
156
- branch,
197
+ deploymentId,
157
198
  commitHash,
158
199
  mainModule,
159
200
  JSON.stringify(serializedModules),
@@ -164,7 +205,7 @@ async function deployBranch(
164
205
  )
165
206
 
166
207
  return jsonResponse({
167
- branch,
208
+ branch: deploymentId,
168
209
  commit_hash: commitHash,
169
210
  main_module: mainModule,
170
211
  has_assets: Object.keys(serializedAssets).length > 0,
@@ -176,10 +217,11 @@ async function deployBranch(
176
217
  export async function buildBranchDeployment(
177
218
  ctx: DeployContext,
178
219
  branch: string,
179
- appRoot?: string | null
220
+ appRoot?: string | null,
221
+ revision?: string,
180
222
  ): Promise<BranchDeploymentBundle> {
181
223
  const root = normalizeAppRoot(appRoot)
182
- const { commitHash, files } = await readBranchFiles(ctx, branch, root)
224
+ const { commitHash, files, assets } = await readBranchFiles(ctx, branch, root, revision)
183
225
  if (Object.keys(files).length === 0) {
184
226
  throw new Error(
185
227
  root
@@ -188,77 +230,156 @@ export async function buildBranchDeployment(
188
230
  )
189
231
  }
190
232
 
191
- let wranglerCfg
192
233
  try {
193
- wranglerCfg = parseWranglerConfig(files)
194
- } catch (e) {
195
- if (e instanceof WranglerConfigError) {
196
- throw new Error(`Invalid wrangler.json: ${e.message}`)
234
+ const result = await buildWebsite(files, assets)
235
+ return {
236
+ branch: revision ?? branch,
237
+ commitHash,
238
+ mainModule: result.mainModule,
239
+ modules: result.modules,
240
+ assets: serializeAssets(result.assets),
241
+ assetConfig: result.assetConfig,
242
+ compatibilityDate: result.compatibilityDate,
197
243
  }
198
- throw e
244
+ } catch (e) {
245
+ throw new Error(`Build failed: ${e instanceof Error ? e.message : String(e)}`)
199
246
  }
247
+ }
200
248
 
201
- if (wranglerCfg.durableObjects?.length) {
249
+ async function buildWebsite(
250
+ sourceFiles: Record<string, string>,
251
+ sourceAssets: WebsiteAssets,
252
+ ) {
253
+ const { files, config } = prepareWebsite(sourceFiles, sourceAssets)
254
+ if (config.durableObjects?.length) {
202
255
  throw new Error(
203
256
  "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
257
  )
205
258
  }
206
259
 
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",
260
+ const assetsDirectory = normalizeAssetsDirectory(config.assets?.directory)
261
+ const assets = assetsDirectory === null
262
+ ? {}
263
+ : collectAssets(sourceAssets, assetsDirectory)
264
+ const assetConfig: AssetConfig | undefined = assetsDirectory === null
265
+ ? undefined
266
+ : {
267
+ ...(config.assets?.notFoundHandling && {
268
+ not_found_handling: config.assets.notFoundHandling,
269
+ }),
270
+ ...(config.assets?.htmlHandling && {
271
+ html_handling: config.assets.htmlHandling,
272
+ }),
242
273
  }
243
- }
244
274
 
245
- const result = await createWorker({ files, entryPoint: wranglerCfg.main })
246
- const modules = serializeModules(result.modules)
247
- modules["__STATIC_CONTENT_MANIFEST"] ??= { text: "{}" }
275
+ if (Object.keys(assets).length > 0) {
276
+ const result = await createApp({
277
+ files,
278
+ assets,
279
+ assetConfig,
280
+ server: config.main,
281
+ })
248
282
  return {
249
- branch,
250
- commitHash,
251
283
  mainModule: result.mainModule,
252
- modules,
253
- assets: {},
254
- assetConfig,
255
- compatibilityDate: wranglerCfg.compatibilityDate ?? "2025-04-01",
284
+ modules: serializeModules(result.modules),
285
+ assets: result.assets,
286
+ assetConfig: result.assetConfig,
287
+ compatibilityDate: config.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
256
288
  }
257
- } catch (e) {
258
- throw new Error(`Build failed: ${e instanceof Error ? e.message : String(e)}`)
289
+ }
290
+
291
+ const result = await createWorker({ files, entryPoint: config.main })
292
+ const modules = serializeModules(result.modules)
293
+ modules["__STATIC_CONTENT_MANIFEST"] ??= { text: "{}" }
294
+ return {
295
+ mainModule: result.mainModule,
296
+ modules,
297
+ assets: {},
298
+ assetConfig,
299
+ compatibilityDate: config.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
259
300
  }
260
301
  }
261
302
 
303
+ function prepareWebsite(
304
+ files: Record<string, string>,
305
+ assets: WebsiteAssets,
306
+ ): { files: Record<string, string>; config: ParsedWranglerConfig } {
307
+ let config: ParsedWranglerConfig
308
+ try {
309
+ config = parseWranglerConfig(files)
310
+ } catch (error) {
311
+ if (error instanceof WranglerConfigError) {
312
+ throw new Error(`Invalid wrangler.json: ${error.message}`)
313
+ }
314
+ throw error
315
+ }
316
+ if (hasWrangler(files) || config.main || config.assets?.directory) return { files, config }
317
+
318
+ const directory = assets["index.html"] !== undefined
319
+ ? "."
320
+ : assets["public/index.html"] !== undefined
321
+ ? "public"
322
+ : null
323
+ if (directory === null) return { files, config }
324
+
325
+ const generatedAssets = {
326
+ directory,
327
+ htmlHandling: "auto-trailing-slash" as const,
328
+ notFoundHandling: "404-page" as const,
329
+ }
330
+ config = {
331
+ main: STATIC_APP_MAIN,
332
+ compatibilityDate: DEFAULT_COMPATIBILITY_DATE,
333
+ assets: generatedAssets,
334
+ }
335
+ return {
336
+ files: {
337
+ ...files,
338
+ "wrangler.json": JSON.stringify({
339
+ main: config.main,
340
+ compatibility_date: config.compatibilityDate,
341
+ assets: {
342
+ directory: generatedAssets.directory,
343
+ html_handling: generatedAssets.htmlHandling,
344
+ not_found_handling: generatedAssets.notFoundHandling,
345
+ },
346
+ }),
347
+ [STATIC_APP_MAIN]: STATIC_APP_SOURCE,
348
+ },
349
+ config,
350
+ }
351
+ }
352
+
353
+ function hasWrangler(files: Record<string, string>): boolean {
354
+ return ["wrangler.json", "wrangler.jsonc", "wrangler.toml"]
355
+ .some((path) => files[path] !== undefined)
356
+ }
357
+
358
+ function normalizeAssetsDirectory(directory?: string): string | null {
359
+ if (directory === undefined) return null
360
+ const normalized = directory.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/$/u, "")
361
+ return normalized === "." ? "" : normalized
362
+ }
363
+
364
+ function collectAssets(assets: WebsiteAssets, directory: string): WebsiteAssets {
365
+ return Object.fromEntries(
366
+ Object.entries(assets)
367
+ .filter(([path]) => !directory || path === directory || path.startsWith(`${directory}/`))
368
+ .map(([path, content]) => [
369
+ directory ? `/${path.slice(directory.length + 1)}` : `/${path}`,
370
+ content,
371
+ ]),
372
+ )
373
+ }
374
+
375
+ function serializeModules(modules: Modules): Record<string, string | Record<string, unknown>> {
376
+ const out: Record<string, string | Record<string, unknown>> = {}
377
+ for (const [name, value] of Object.entries(modules)) {
378
+ out[name] = typeof value === "string" ? value : (value as Record<string, unknown>)
379
+ }
380
+ return out
381
+ }
382
+
262
383
  // ─── Get a deployment ───────────────────────────────────────────────────────
263
384
 
264
385
  async function getDeployment(
@@ -341,23 +462,43 @@ async function undeployBranch(
341
462
 
342
463
  // ─── Serialization helpers ───────────────────────────────────────────────────
343
464
 
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
465
  function serializeAssets(
353
466
  assets: Record<string, string | ArrayBuffer>
354
- ): Record<string, string> {
355
- const out: Record<string, string> = {}
467
+ ): Record<string, SerializedAsset> {
468
+ const out: Record<string, SerializedAsset> = {}
356
469
  for (const [path, content] of Object.entries(assets)) {
357
- // Store text as-is; encode binary as base64
358
470
  out[path] = typeof content === "string"
359
471
  ? content
360
- : btoa(String.fromCharCode(...new Uint8Array(content)))
472
+ : { base64: bytesToBase64(new Uint8Array(content)) }
361
473
  }
362
474
  return out
363
475
  }
476
+
477
+ function addWebsiteFile(
478
+ files: Record<string, string>,
479
+ assets: WebsiteAssets,
480
+ path: string,
481
+ bytes: Uint8Array,
482
+ ): void {
483
+ const contentType = inferContentType(path)
484
+ const binary = contentType !== undefined && !isTextContentType(contentType)
485
+ if (binary) {
486
+ assets[path] = exactBuffer(bytes)
487
+ return
488
+ }
489
+ const text = new TextDecoder().decode(bytes)
490
+ files[path] = text
491
+ assets[path] = text
492
+ }
493
+
494
+ function exactBuffer(bytes: Uint8Array): ArrayBuffer {
495
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
496
+ }
497
+
498
+ function bytesToBase64(bytes: Uint8Array): string {
499
+ let binary = ""
500
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
501
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
502
+ }
503
+ return btoa(binary)
504
+ }
@@ -7,6 +7,7 @@ import {
7
7
  handleDeployCommand,
8
8
  type BranchDeploymentBundle,
9
9
  type DeployContext,
10
+ type SerializedAsset,
10
11
  } from "./deploy-engine"
11
12
  import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
12
13
  import { handleAssetRequest, buildAssetManifest, createMemoryStorage, type AssetConfig } from "@cloudflare/worker-bundler"
@@ -93,11 +94,37 @@ interface DeploymentRow {
93
94
  commitHash: string
94
95
  mainModule: string
95
96
  modules: Record<string, string | Record<string, unknown>>
96
- assets: Record<string, string>
97
+ assets: Record<string, string | ArrayBuffer>
97
98
  assetConfig: AssetConfig
98
99
  compatibilityDate: string
99
100
  }
100
101
 
102
+ function deserializeAssets(
103
+ assets: Record<string, SerializedAsset>,
104
+ ): Record<string, string | ArrayBuffer> {
105
+ return Object.fromEntries(
106
+ Object.entries(assets).map(([path, content]) => [
107
+ path,
108
+ typeof content === "string"
109
+ ? content
110
+ : exactArrayBuffer(base64ToBytes(content.base64)),
111
+ ]),
112
+ )
113
+ }
114
+
115
+ function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
116
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
117
+ }
118
+
119
+ function base64ToBytes(value: string): Uint8Array {
120
+ const binary = atob(value)
121
+ const bytes = new Uint8Array(binary.length)
122
+ for (let index = 0; index < binary.length; index++) {
123
+ bytes[index] = binary.charCodeAt(index)
124
+ }
125
+ return bytes
126
+ }
127
+
101
128
  // Overlay-only paths that must never leak into a deploy, rollback tree, or any
102
129
  // file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
103
130
  const isReservedPath = isReservedSpacePath
@@ -1059,7 +1086,7 @@ export class SpaceDO extends DurableObject<Env>
1059
1086
  * analysis Space with a spreadsheet and a script deploys nothing until
1060
1087
  * someone names the directory that holds a site.
1061
1088
  */
1062
- async deploy(branch: string, appRoot?: string): Promise<unknown> {
1089
+ async deploy(branch: string, appRoot?: string, revision?: string): Promise<unknown> {
1063
1090
  await this.ensureInit()
1064
1091
  return this.lane(async () => {
1065
1092
  // The deploy engine reads the full branch tree, so ensure the Artifacts
@@ -1069,7 +1096,11 @@ export class SpaceDO extends DurableObject<Env>
1069
1096
  const fakeRequest = new Request("http://internal/?cmd=deploy", {
1070
1097
  method: "POST",
1071
1098
  headers: { "Content-Type": "application/json" },
1072
- body: JSON.stringify({ branch, ...(appRoot ? { appRoot } : {}) }),
1099
+ body: JSON.stringify({
1100
+ branch,
1101
+ ...(appRoot ? { appRoot } : {}),
1102
+ ...(revision ? { revision } : {}),
1103
+ }),
1073
1104
  })
1074
1105
  const ctx: DeployContext = {
1075
1106
  sql: this.ctx.storage.sql,
@@ -1078,9 +1109,12 @@ export class SpaceDO extends DurableObject<Env>
1078
1109
  }
1079
1110
  const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
1080
1111
  const data = await res.json() as Record<string, unknown>
1081
- data.preview_url = `/space/${this.spaceName}/preview/${encodeURIComponent(branch)}/`
1112
+ if (typeof data.error === "string") throw new Error(data.error)
1113
+ const deploymentId = data.branch as string
1114
+ data.preview_url =
1115
+ `/space/${this.spaceName}/preview/${encodeURIComponent(deploymentId)}/`
1082
1116
 
1083
- if (!data.error) await this.backend.push(branch)
1117
+ if (!revision) await this.backend.push(branch)
1084
1118
 
1085
1119
  return data
1086
1120
  })
@@ -1089,6 +1123,7 @@ export class SpaceDO extends DurableObject<Env>
1089
1123
  async getDeploymentBundle(
1090
1124
  branch: string,
1091
1125
  appRoot?: string,
1126
+ revision?: string,
1092
1127
  ): Promise<BranchDeploymentBundle> {
1093
1128
  await this.ensureInit()
1094
1129
  await this.materializeAll()
@@ -1100,6 +1135,7 @@ export class SpaceDO extends DurableObject<Env>
1100
1135
  },
1101
1136
  branch,
1102
1137
  appRoot,
1138
+ revision,
1103
1139
  )
1104
1140
  }
1105
1141
 
@@ -1179,7 +1215,9 @@ export class SpaceDO extends DurableObject<Env>
1179
1215
  commitHash: r.commit_hash as string,
1180
1216
  mainModule: r.main_module as string,
1181
1217
  modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
1182
- assets: JSON.parse((r.assets as string) || "{}") as Record<string, string>,
1218
+ assets: deserializeAssets(
1219
+ JSON.parse((r.assets as string) || "{}") as Record<string, SerializedAsset>,
1220
+ ),
1183
1221
  assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
1184
1222
  compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
1185
1223
  }
@@ -234,7 +234,7 @@ export interface SpaceControlPort {
234
234
  * and only an explicit deploy brings an app into existence.
235
235
  */
236
236
  export interface SpaceAppPort {
237
- deploy(branch: string, appRoot?: string): Promise<unknown>;
237
+ deploy(branch: string, appRoot?: string, revision?: string): Promise<unknown>;
238
238
  undeploy(branch: string): Promise<unknown>;
239
239
  listDeployments(): Promise<unknown>;
240
240
  getDeployment(branch: string): Promise<unknown>;