@springbrand/space 0.1.0-alpha.9 → 0.2.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/README.md +11 -7
- package/package.json +1 -1
- package/src/index.ts +11 -1
- package/src/space/deploy-engine.ts +99 -36
- package/src/space/durable-object.ts +48 -233
- package/src/space/workspace-port.ts +19 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @springbrand/space
|
|
2
2
|
|
|
3
|
-
SpaceDO — a git-backed Durable Object workspace with
|
|
4
|
-
Facet database inspection.
|
|
3
|
+
SpaceDO — a git-backed Durable Object workspace with target-neutral deployment
|
|
4
|
+
bundles, CDN-backed Preview assets, App Facet execution and database inspection.
|
|
5
5
|
|
|
6
6
|
## Provenance
|
|
7
7
|
|
|
@@ -11,14 +11,18 @@ This package is a tracked fork of the VibeSDK `space/` package.
|
|
|
11
11
|
- Frozen at: `main@a318f08625db`
|
|
12
12
|
- Licence: MIT (see `LICENSE`)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
path-level restore and `destroySpace`.
|
|
14
|
+
The fork retains VibeSDK's Workspace and App Facet model while replacing its
|
|
15
|
+
single-row Preview assets with per-file R2 objects. One `DeploymentBundle`
|
|
16
|
+
separates Worker modules, static asset metadata and deployment-level runtime
|
|
17
|
+
configuration so Preview and production publishers can consume the same build.
|
|
19
18
|
|
|
20
19
|
## Boundary
|
|
21
20
|
|
|
22
21
|
SpaceDO is infrastructure. It knows nothing about users, Chats or
|
|
23
22
|
authorisation — the host Worker decides which Space a request belongs to and
|
|
24
23
|
whether the caller may reach it.
|
|
24
|
+
|
|
25
|
+
Preview publication writes static assets below one immutable R2 deployment
|
|
26
|
+
prefix and returns that prefix to the host. Static requests can therefore go
|
|
27
|
+
straight to a Workspace CDN; `serveApp()` is the dynamic-only path into the App
|
|
28
|
+
Facet. SpaceDO never reads static assets for browser requests.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -18,7 +18,17 @@ export type {
|
|
|
18
18
|
SpaceWorkspacePort,
|
|
19
19
|
} from "./space/durable-object";
|
|
20
20
|
export { SPACE_RESERVED_PREFIXES } from "./space/durable-object";
|
|
21
|
-
export
|
|
21
|
+
export {
|
|
22
|
+
deploymentRuntimeConfigSource,
|
|
23
|
+
RUNTIME_CONFIG_ASSET_PATH,
|
|
24
|
+
} from "./space/deploy-engine";
|
|
25
|
+
export type {
|
|
26
|
+
BranchDeploymentBundle,
|
|
27
|
+
DeploymentAsset,
|
|
28
|
+
DeploymentBundle,
|
|
29
|
+
DeploymentRuntimeConfig,
|
|
30
|
+
SpaceDeploymentResult,
|
|
31
|
+
} from "./space/deploy-engine";
|
|
22
32
|
|
|
23
33
|
// ── Preview hardening ─────────────────────────────────────────────
|
|
24
34
|
export { stripPreviewSecurityHeaders, STRIPPED_PREVIEW_HEADERS } from "./space/preview-headers";
|
|
@@ -32,18 +32,40 @@ export interface DeployContext {
|
|
|
32
32
|
assetPrefix?: string
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export interface
|
|
35
|
+
export interface DeploymentBundle {
|
|
36
36
|
branch: string
|
|
37
37
|
commitHash: string
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
workerModules: {
|
|
39
|
+
mainModule: string
|
|
40
|
+
modules: Record<string, string | Record<string, unknown>>
|
|
41
|
+
compatibilityDate: string
|
|
42
|
+
}
|
|
43
|
+
staticAssets: DeploymentAsset[]
|
|
41
44
|
assetConfig: AssetConfig | undefined
|
|
42
|
-
|
|
45
|
+
runtimeConfig: DeploymentRuntimeConfig | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type BranchDeploymentBundle = DeploymentBundle
|
|
49
|
+
|
|
50
|
+
export interface DeploymentRuntimeConfig {
|
|
51
|
+
apiBaseUrl: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SpaceDeploymentResult {
|
|
55
|
+
branch: string
|
|
56
|
+
commit_hash: string
|
|
57
|
+
main_module: string
|
|
58
|
+
has_assets: boolean
|
|
59
|
+
compatibility_date: string
|
|
60
|
+
deployed_at: string
|
|
61
|
+
asset_root: string | null
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
export const RUNTIME_CONFIG_ASSET_PATH = "/.springbrand/runtime-config.js"
|
|
65
|
+
|
|
45
66
|
export interface DeploymentAsset {
|
|
46
67
|
path: string
|
|
68
|
+
sourcePath: string | null
|
|
47
69
|
contentType: string | undefined
|
|
48
70
|
etag: string
|
|
49
71
|
size: number
|
|
@@ -173,6 +195,7 @@ async function readBranchFiles(
|
|
|
173
195
|
const bytes = await ctx.fs.readFileBytes(entry.sourcePath)
|
|
174
196
|
const asset: Omit<DeploymentAsset, "etag"> = {
|
|
175
197
|
path: toAssetPath(entry.path, assetsDirectory!),
|
|
198
|
+
sourcePath: entry.sourcePath,
|
|
176
199
|
contentType: inferContentType(entry.path),
|
|
177
200
|
size: bytes.byteLength,
|
|
178
201
|
}
|
|
@@ -219,6 +242,7 @@ async function deployBranch(
|
|
|
219
242
|
branch: string
|
|
220
243
|
appRoot?: string
|
|
221
244
|
revision?: string
|
|
245
|
+
runtimeConfig?: DeploymentRuntimeConfig
|
|
222
246
|
}
|
|
223
247
|
const branch = body.branch
|
|
224
248
|
if (!branch) {
|
|
@@ -257,6 +281,7 @@ async function deployBranch(
|
|
|
257
281
|
)
|
|
258
282
|
return uploaded.etag
|
|
259
283
|
},
|
|
284
|
+
body.runtimeConfig,
|
|
260
285
|
)
|
|
261
286
|
} catch (e) {
|
|
262
287
|
await discardAssetDeployment(ctx, assetDeploymentId, assetRoot).catch((cleanupError) => {
|
|
@@ -273,11 +298,12 @@ async function deployBranch(
|
|
|
273
298
|
const {
|
|
274
299
|
branch: deploymentId,
|
|
275
300
|
commitHash,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
301
|
+
workerModules: {
|
|
302
|
+
mainModule,
|
|
303
|
+
modules: serializedModules,
|
|
304
|
+
compatibilityDate: compatDate,
|
|
305
|
+
},
|
|
306
|
+
staticAssets: assets,
|
|
281
307
|
} = bundle
|
|
282
308
|
|
|
283
309
|
const now = Date.now()
|
|
@@ -291,16 +317,14 @@ async function deployBranch(
|
|
|
291
317
|
try {
|
|
292
318
|
ctx.sql.exec(
|
|
293
319
|
`INSERT OR REPLACE INTO deployments
|
|
294
|
-
(branch, commit_hash, main_module, modules,
|
|
295
|
-
|
|
296
|
-
VALUES (?, ?, ?, ?,
|
|
320
|
+
(branch, commit_hash, main_module, modules, asset_deployment_id,
|
|
321
|
+
compatibility_date, deployed_at)
|
|
322
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
297
323
|
deploymentId,
|
|
298
324
|
commitHash,
|
|
299
325
|
mainModule,
|
|
300
326
|
JSON.stringify(serializedModules),
|
|
301
327
|
assets.length > 0 ? assetDeploymentId : "",
|
|
302
|
-
assets.length,
|
|
303
|
-
assetConfig ? JSON.stringify(assetConfig) : "{}",
|
|
304
328
|
compatDate,
|
|
305
329
|
now
|
|
306
330
|
)
|
|
@@ -333,6 +357,7 @@ async function deployBranch(
|
|
|
333
357
|
has_assets: assets.length > 0,
|
|
334
358
|
compatibility_date: compatDate,
|
|
335
359
|
deployed_at: new Date(now).toISOString(),
|
|
360
|
+
asset_root: assets.length > 0 ? assetRoot : null,
|
|
336
361
|
})
|
|
337
362
|
}
|
|
338
363
|
|
|
@@ -342,6 +367,7 @@ export async function buildBranchDeployment(
|
|
|
342
367
|
appRoot?: string | null,
|
|
343
368
|
revision?: string,
|
|
344
369
|
writeAsset?: AssetWriter,
|
|
370
|
+
runtimeConfig?: DeploymentRuntimeConfig,
|
|
345
371
|
): Promise<BranchDeploymentBundle> {
|
|
346
372
|
const root = normalizeAppRoot(appRoot)
|
|
347
373
|
const { commitHash, files, assets, config } = await readBranchFiles(
|
|
@@ -351,6 +377,22 @@ export async function buildBranchDeployment(
|
|
|
351
377
|
revision,
|
|
352
378
|
writeAsset,
|
|
353
379
|
)
|
|
380
|
+
const normalizedRuntimeConfig = normalizeRuntimeConfig(runtimeConfig)
|
|
381
|
+
if (normalizedRuntimeConfig) {
|
|
382
|
+
const bytes = new TextEncoder().encode(
|
|
383
|
+
deploymentRuntimeConfigSource(normalizedRuntimeConfig),
|
|
384
|
+
)
|
|
385
|
+
const asset: Omit<DeploymentAsset, "etag"> = {
|
|
386
|
+
path: RUNTIME_CONFIG_ASSET_PATH,
|
|
387
|
+
sourcePath: null,
|
|
388
|
+
contentType: "application/javascript; charset=utf-8",
|
|
389
|
+
size: bytes.byteLength,
|
|
390
|
+
}
|
|
391
|
+
const etag = writeAsset
|
|
392
|
+
? await writeAsset(asset, bytes)
|
|
393
|
+
: await computeAssetEtag(bytes)
|
|
394
|
+
assets.push({ ...asset, etag })
|
|
395
|
+
}
|
|
354
396
|
if (Object.keys(files).length === 0) {
|
|
355
397
|
throw new Error(
|
|
356
398
|
root
|
|
@@ -364,17 +406,40 @@ export async function buildBranchDeployment(
|
|
|
364
406
|
return {
|
|
365
407
|
branch: revision ?? branch,
|
|
366
408
|
commitHash,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
409
|
+
workerModules: {
|
|
410
|
+
mainModule: result.mainModule,
|
|
411
|
+
modules: result.modules,
|
|
412
|
+
compatibilityDate: result.compatibilityDate,
|
|
413
|
+
},
|
|
414
|
+
staticAssets: assets,
|
|
370
415
|
assetConfig: result.assetConfig,
|
|
371
|
-
|
|
416
|
+
runtimeConfig: normalizedRuntimeConfig,
|
|
372
417
|
}
|
|
373
418
|
} catch (e) {
|
|
374
419
|
throw new Error(`Build failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
375
420
|
}
|
|
376
421
|
}
|
|
377
422
|
|
|
423
|
+
function normalizeRuntimeConfig(
|
|
424
|
+
config: DeploymentRuntimeConfig | undefined,
|
|
425
|
+
): DeploymentRuntimeConfig | undefined {
|
|
426
|
+
if (!config) return undefined
|
|
427
|
+
let url: URL
|
|
428
|
+
try {
|
|
429
|
+
url = new URL(config.apiBaseUrl)
|
|
430
|
+
} catch {
|
|
431
|
+
throw new Error("runtimeConfig.apiBaseUrl must be an absolute HTTP(S) URL ending in /")
|
|
432
|
+
}
|
|
433
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.pathname.endsWith("/")) {
|
|
434
|
+
throw new Error("runtimeConfig.apiBaseUrl must be an absolute HTTP(S) URL ending in /")
|
|
435
|
+
}
|
|
436
|
+
return { apiBaseUrl: url.toString() }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function deploymentRuntimeConfigSource(config: DeploymentRuntimeConfig): string {
|
|
440
|
+
return `globalThis.__APP_CONFIG__=Object.freeze(${JSON.stringify(config)});`
|
|
441
|
+
}
|
|
442
|
+
|
|
378
443
|
async function buildWebsite(
|
|
379
444
|
sourceFiles: Record<string, string>,
|
|
380
445
|
config: ParsedWranglerConfig,
|
|
@@ -463,7 +528,7 @@ function toAssetPath(path: string, directory: string): string {
|
|
|
463
528
|
|
|
464
529
|
async function computeAssetEtag(bytes: Uint8Array): Promise<string> {
|
|
465
530
|
const digest = await crypto.subtle.digest("SHA-256", exactBuffer(bytes))
|
|
466
|
-
return [...new Uint8Array(digest).slice(0,
|
|
531
|
+
return [...new Uint8Array(digest).slice(0, 16)]
|
|
467
532
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
468
533
|
.join("")
|
|
469
534
|
}
|
|
@@ -490,8 +555,7 @@ async function getDeployment(
|
|
|
490
555
|
|
|
491
556
|
const row = ctx.sql
|
|
492
557
|
.exec(
|
|
493
|
-
`SELECT branch, commit_hash, main_module, modules,
|
|
494
|
-
asset_config, deployed_at
|
|
558
|
+
`SELECT branch, commit_hash, main_module, modules, asset_deployment_id, deployed_at
|
|
495
559
|
FROM deployments WHERE branch = ?`,
|
|
496
560
|
branch
|
|
497
561
|
)
|
|
@@ -502,14 +566,17 @@ async function getDeployment(
|
|
|
502
566
|
}
|
|
503
567
|
|
|
504
568
|
const r = row[0]
|
|
505
|
-
const
|
|
569
|
+
const assetDeploymentId = (r.asset_deployment_id as string) || ""
|
|
506
570
|
return jsonResponse({
|
|
507
571
|
branch: r.branch as string,
|
|
508
572
|
commit_hash: r.commit_hash as string,
|
|
509
573
|
main_module: r.main_module as string,
|
|
510
574
|
modules: JSON.parse(r.modules as string),
|
|
511
|
-
has_assets:
|
|
575
|
+
has_assets: Boolean(assetDeploymentId),
|
|
512
576
|
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
577
|
+
asset_root: assetDeploymentId && ctx.assetPrefix
|
|
578
|
+
? `${ctx.assetPrefix}/deployments/${assetDeploymentId}`
|
|
579
|
+
: null,
|
|
513
580
|
})
|
|
514
581
|
}
|
|
515
582
|
|
|
@@ -518,21 +585,17 @@ async function getDeployment(
|
|
|
518
585
|
async function listDeployments(ctx: DeployContext): Promise<Response> {
|
|
519
586
|
const rows = ctx.sql
|
|
520
587
|
.exec(
|
|
521
|
-
`SELECT branch, commit_hash, main_module,
|
|
588
|
+
`SELECT branch, commit_hash, main_module, asset_deployment_id, deployed_at
|
|
522
589
|
FROM deployments ORDER BY deployed_at DESC`,
|
|
523
590
|
)
|
|
524
591
|
.toArray()
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
has_assets: Number(r.asset_count ?? 0) > 0 || Object.keys(legacyAssets).length > 0,
|
|
533
|
-
deployed_at: new Date(r.deployed_at as number).toISOString(),
|
|
534
|
-
}
|
|
535
|
-
})
|
|
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
|
+
}))
|
|
536
599
|
|
|
537
600
|
return jsonResponse(deployments)
|
|
538
601
|
}
|
|
@@ -6,24 +6,17 @@ import {
|
|
|
6
6
|
buildBranchDeployment,
|
|
7
7
|
handleDeployCommand,
|
|
8
8
|
type BranchDeploymentBundle,
|
|
9
|
+
type DeploymentRuntimeConfig,
|
|
9
10
|
type DeployContext,
|
|
11
|
+
type SpaceDeploymentResult,
|
|
10
12
|
} from "./deploy-engine"
|
|
11
13
|
import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
|
|
12
|
-
import {
|
|
13
|
-
handleAssetRequest,
|
|
14
|
-
buildAssetManifest,
|
|
15
|
-
createMemoryStorage,
|
|
16
|
-
type AssetConfig,
|
|
17
|
-
type AssetManifest,
|
|
18
|
-
type AssetStorage,
|
|
19
|
-
} from "@cloudflare/worker-bundler"
|
|
20
14
|
import {
|
|
21
15
|
buildInspectorWrapperSource,
|
|
22
16
|
VIBE_APP_MODULE,
|
|
23
17
|
} from "./inspector-wrapper"
|
|
24
18
|
import { ArtifactsBackend, resolveSpaceFsBackendMode, SqlBackend, spaceR2Prefix, type SpaceFsBackend } from "./fs-backend"
|
|
25
19
|
import { createGitFs, stageWorkdir } from "./git-objects"
|
|
26
|
-
import { stripPreviewSecurityHeaders } from "./preview-headers"
|
|
27
20
|
import * as git from "isomorphic-git"
|
|
28
21
|
import {
|
|
29
22
|
defaultSpaceQuota,
|
|
@@ -100,41 +93,9 @@ interface DeploymentRow {
|
|
|
100
93
|
commitHash: string
|
|
101
94
|
mainModule: string
|
|
102
95
|
modules: Record<string, string | Record<string, unknown>>
|
|
103
|
-
legacyAssets: Record<string, string | ArrayBuffer>
|
|
104
|
-
assetDeploymentId: string
|
|
105
|
-
assetCount: number
|
|
106
|
-
assetConfig: AssetConfig
|
|
107
96
|
compatibilityDate: string
|
|
108
97
|
}
|
|
109
98
|
|
|
110
|
-
type SerializedAsset = string | { base64: string }
|
|
111
|
-
|
|
112
|
-
function deserializeAssets(
|
|
113
|
-
assets: Record<string, SerializedAsset>,
|
|
114
|
-
): Record<string, string | ArrayBuffer> {
|
|
115
|
-
return Object.fromEntries(
|
|
116
|
-
Object.entries(assets).map(([path, content]) => [
|
|
117
|
-
path,
|
|
118
|
-
typeof content === "string"
|
|
119
|
-
? content
|
|
120
|
-
: exactArrayBuffer(base64ToBytes(content.base64)),
|
|
121
|
-
]),
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
126
|
-
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function base64ToBytes(value: string): Uint8Array {
|
|
130
|
-
const binary = atob(value)
|
|
131
|
-
const bytes = new Uint8Array(binary.length)
|
|
132
|
-
for (let index = 0; index < binary.length; index++) {
|
|
133
|
-
bytes[index] = binary.charCodeAt(index)
|
|
134
|
-
}
|
|
135
|
-
return bytes
|
|
136
|
-
}
|
|
137
|
-
|
|
138
99
|
// Overlay-only paths that must never leak into a deploy, rollback tree, or any
|
|
139
100
|
// file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
|
|
140
101
|
const isReservedPath = isReservedSpacePath
|
|
@@ -147,22 +108,10 @@ const isReservedPath = isReservedSpacePath
|
|
|
147
108
|
// Commits/deploys are mirrored to a per-app Cloudflare Artifacts repo
|
|
148
109
|
// (see artifacts-sync.ts), which is the durable source of truth for history.
|
|
149
110
|
|
|
150
|
-
// Built asset manifest + in-memory storage for a single deployment. Rebuilding
|
|
151
|
-
// these on every request is wasteful (CWE-770 amplification under a preview
|
|
152
|
-
// flood), so we cache them per `branch:commitHash` with an LRU + TTL bound.
|
|
153
|
-
type CachedAssets = {
|
|
154
|
-
manifest: AssetManifest
|
|
155
|
-
storage: AssetStorage
|
|
156
|
-
expiresAt: number
|
|
157
|
-
}
|
|
158
|
-
const ASSET_CACHE_MAX_ENTRIES = 8
|
|
159
|
-
const ASSET_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
160
|
-
|
|
161
111
|
export class SpaceDO extends DurableObject<Env>
|
|
162
112
|
implements SpaceWorkspacePort, SpaceControlPort, SpaceAppPort {
|
|
163
113
|
private backend!: SpaceFsBackend
|
|
164
114
|
private initializationPromise: Promise<void> | null = null
|
|
165
|
-
private assetCache = new Map<string, CachedAssets>()
|
|
166
115
|
/**
|
|
167
116
|
* One lane for every operation that reads or rewrites the whole tree.
|
|
168
117
|
*
|
|
@@ -218,11 +167,8 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
218
167
|
commit_hash TEXT NOT NULL,
|
|
219
168
|
main_module TEXT NOT NULL,
|
|
220
169
|
modules TEXT NOT NULL,
|
|
221
|
-
assets TEXT NOT NULL DEFAULT '{}',
|
|
222
170
|
asset_deployment_id TEXT NOT NULL DEFAULT '',
|
|
223
|
-
|
|
224
|
-
asset_config TEXT NOT NULL DEFAULT '{}',
|
|
225
|
-
compatibility_date TEXT NOT NULL DEFAULT '',
|
|
171
|
+
compatibility_date TEXT NOT NULL,
|
|
226
172
|
deployed_at INTEGER NOT NULL
|
|
227
173
|
);
|
|
228
174
|
CREATE TABLE IF NOT EXISTS deployment_assets (
|
|
@@ -236,13 +182,6 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
236
182
|
);
|
|
237
183
|
`)
|
|
238
184
|
|
|
239
|
-
// Migrate existing deployments tables that lack new columns
|
|
240
|
-
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN assets TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
241
|
-
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_deployment_id TEXT NOT NULL DEFAULT ''`) } catch {}
|
|
242
|
-
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_count INTEGER NOT NULL DEFAULT 0`) } catch {}
|
|
243
|
-
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_config TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
244
|
-
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN compatibility_date TEXT NOT NULL DEFAULT ''`) } catch {}
|
|
245
|
-
|
|
246
185
|
// Initialize git repo if not already done
|
|
247
186
|
try {
|
|
248
187
|
await this.git.init({ defaultBranch: "main" })
|
|
@@ -634,7 +573,6 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
634
573
|
}
|
|
635
574
|
}
|
|
636
575
|
await this.deleteSpilledObjects()
|
|
637
|
-
this.assetCache.clear()
|
|
638
576
|
await this.ctx.storage.deleteAll()
|
|
639
577
|
this.initializationPromise = null
|
|
640
578
|
})
|
|
@@ -1109,7 +1047,12 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1109
1047
|
* analysis Space with a spreadsheet and a script deploys nothing until
|
|
1110
1048
|
* someone names the directory that holds a site.
|
|
1111
1049
|
*/
|
|
1112
|
-
async deploy(
|
|
1050
|
+
async deploy(
|
|
1051
|
+
branch: string,
|
|
1052
|
+
appRoot?: string,
|
|
1053
|
+
revision?: string,
|
|
1054
|
+
runtimeConfig?: DeploymentRuntimeConfig,
|
|
1055
|
+
): Promise<SpaceDeploymentResult> {
|
|
1113
1056
|
await this.ensureInit()
|
|
1114
1057
|
return this.lane(async () => {
|
|
1115
1058
|
// The deploy engine reads the full branch tree, so ensure the Artifacts
|
|
@@ -1123,6 +1066,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1123
1066
|
branch,
|
|
1124
1067
|
...(appRoot ? { appRoot } : {}),
|
|
1125
1068
|
...(revision ? { revision } : {}),
|
|
1069
|
+
...(runtimeConfig ? { runtimeConfig } : {}),
|
|
1126
1070
|
}),
|
|
1127
1071
|
})
|
|
1128
1072
|
const ctx: DeployContext = {
|
|
@@ -1135,14 +1079,10 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1135
1079
|
const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
|
|
1136
1080
|
const data = await res.json() as Record<string, unknown>
|
|
1137
1081
|
if (typeof data.error === "string") throw new Error(data.error)
|
|
1138
|
-
this.assetCache.clear()
|
|
1139
|
-
const deploymentId = data.branch as string
|
|
1140
|
-
data.preview_url =
|
|
1141
|
-
`/space/${this.spaceName}/preview/${encodeURIComponent(deploymentId)}/`
|
|
1142
1082
|
|
|
1143
1083
|
if (!revision) await this.backend.push(branch)
|
|
1144
1084
|
|
|
1145
|
-
return data
|
|
1085
|
+
return spaceDeploymentResult(data)
|
|
1146
1086
|
})
|
|
1147
1087
|
}
|
|
1148
1088
|
|
|
@@ -1150,6 +1090,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1150
1090
|
branch: string,
|
|
1151
1091
|
appRoot?: string,
|
|
1152
1092
|
revision?: string,
|
|
1093
|
+
runtimeConfig?: DeploymentRuntimeConfig,
|
|
1153
1094
|
): Promise<BranchDeploymentBundle> {
|
|
1154
1095
|
await this.ensureInit()
|
|
1155
1096
|
await this.materializeAll()
|
|
@@ -1162,6 +1103,8 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1162
1103
|
branch,
|
|
1163
1104
|
appRoot,
|
|
1164
1105
|
revision,
|
|
1106
|
+
undefined,
|
|
1107
|
+
runtimeConfig,
|
|
1165
1108
|
)
|
|
1166
1109
|
}
|
|
1167
1110
|
|
|
@@ -1180,7 +1123,6 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1180
1123
|
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1181
1124
|
}
|
|
1182
1125
|
const res = await handleDeployCommand(ctx, "undeploy", fakeRequest)
|
|
1183
|
-
this.assetCache.clear()
|
|
1184
1126
|
return res.json()
|
|
1185
1127
|
}
|
|
1186
1128
|
|
|
@@ -1203,6 +1145,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1203
1145
|
sql: this.ctx.storage.sql,
|
|
1204
1146
|
git: this.git,
|
|
1205
1147
|
fs: this.overlay,
|
|
1148
|
+
assetPrefix: spaceR2Prefix(this.spaceName),
|
|
1206
1149
|
}
|
|
1207
1150
|
const res = await handleDeployCommand(ctx, "get_deployment", fakeRequest)
|
|
1208
1151
|
return res.json()
|
|
@@ -1228,13 +1171,12 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1228
1171
|
return { fileCount, directoryCount, totalBytes }
|
|
1229
1172
|
}
|
|
1230
1173
|
|
|
1231
|
-
// ── Deployment row reader
|
|
1174
|
+
// ── Deployment row reader ──
|
|
1232
1175
|
|
|
1233
1176
|
private readDeployment(branch: string): DeploymentRow | null {
|
|
1234
1177
|
const rows = this.ctx.storage.sql
|
|
1235
1178
|
.exec(
|
|
1236
|
-
`SELECT branch, commit_hash, main_module, modules,
|
|
1237
|
-
asset_deployment_id, asset_count, asset_config, compatibility_date
|
|
1179
|
+
`SELECT branch, commit_hash, main_module, modules, compatibility_date
|
|
1238
1180
|
FROM deployments WHERE branch = ?`,
|
|
1239
1181
|
branch,
|
|
1240
1182
|
)
|
|
@@ -1246,17 +1188,11 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1246
1188
|
commitHash: r.commit_hash as string,
|
|
1247
1189
|
mainModule: r.main_module as string,
|
|
1248
1190
|
modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
|
|
1249
|
-
|
|
1250
|
-
JSON.parse((r.assets as string) || "{}") as Record<string, SerializedAsset>,
|
|
1251
|
-
),
|
|
1252
|
-
assetDeploymentId: (r.asset_deployment_id as string) || "",
|
|
1253
|
-
assetCount: Number(r.asset_count ?? 0),
|
|
1254
|
-
assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
|
|
1255
|
-
compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
|
|
1191
|
+
compatibilityDate: r.compatibility_date as string,
|
|
1256
1192
|
}
|
|
1257
1193
|
}
|
|
1258
1194
|
|
|
1259
|
-
// ──
|
|
1195
|
+
// ── App serving via Dynamic Workers ─────────────────────────────
|
|
1260
1196
|
//
|
|
1261
1197
|
// Architecture (matches Cloudflare's Durable Object Facets docs example):
|
|
1262
1198
|
//
|
|
@@ -1264,10 +1200,8 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1264
1200
|
// module. `App.fetch(request)` is the entire backend (Hono /
|
|
1265
1201
|
// itty-router / vanilla — the LLM decides).
|
|
1266
1202
|
// - SpaceDO acts as the supervisor ("AppRunner" in the docs).
|
|
1267
|
-
// `
|
|
1268
|
-
//
|
|
1269
|
-
// `app:<branch>`. Static assets are served host-side; everything
|
|
1270
|
-
// else (including WebSocket upgrades) is forwarded into the Facet.
|
|
1203
|
+
// `serveApp` loads the user's worker via the Worker Loader, extracts the
|
|
1204
|
+
// App class, and hosts it as a Facet keyed `app:<branch>`.
|
|
1271
1205
|
// - State is the Facet's own `ctx.storage` (SQLite + KV). No env.DB
|
|
1272
1206
|
// binding is injected.
|
|
1273
1207
|
// - To make the DB-viewer work without forcing the LLM to write
|
|
@@ -1277,23 +1211,12 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1277
1211
|
// `App` that adds `__vibeInspectListTables` / `__vibeInspectRead`
|
|
1278
1212
|
// / `__vibeWipe`. The subclass shares the same `ctx.storage`.
|
|
1279
1213
|
|
|
1280
|
-
async
|
|
1214
|
+
async serveApp(branch: string, request: Request): Promise<Response> {
|
|
1281
1215
|
await this.ensureInit()
|
|
1282
|
-
|
|
1283
1216
|
const dep = this.readDeployment(branch)
|
|
1284
1217
|
if (!dep) {
|
|
1285
1218
|
return new Response(`No deployment found for branch "${branch}"`, { status: 404 })
|
|
1286
1219
|
}
|
|
1287
|
-
|
|
1288
|
-
// Serve static assets host-side before forwarding to the Facet. The built
|
|
1289
|
-
// manifest/storage are cached per deployment so repeat asset reads don't
|
|
1290
|
-
// re-spin the build on every request.
|
|
1291
|
-
if (dep.assetCount > 0 || Object.keys(dep.legacyAssets).length > 0) {
|
|
1292
|
-
const { manifest, storage } = await this.getCachedAssets(dep)
|
|
1293
|
-
const assetResponse = await handleAssetRequest(request, manifest, storage, dep.assetConfig)
|
|
1294
|
-
if (assetResponse) return assetResponse
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
1220
|
let appClass: DurableObjectClass
|
|
1298
1221
|
try {
|
|
1299
1222
|
appClass = this.loadAppClass(dep)
|
|
@@ -1308,73 +1231,6 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1308
1231
|
return facet.fetch(request)
|
|
1309
1232
|
}
|
|
1310
1233
|
|
|
1311
|
-
/**
|
|
1312
|
-
* Return the built asset manifest + in-memory storage for a deployment,
|
|
1313
|
-
* reusing a cached build when available. Keyed by `branch:commitHash` so a
|
|
1314
|
-
* redeploy (new commit) transparently rebuilds. Bounded by an LRU cap + TTL.
|
|
1315
|
-
*/
|
|
1316
|
-
private async getCachedAssets(dep: DeploymentRow): Promise<CachedAssets> {
|
|
1317
|
-
const key = `${dep.branch}:${dep.assetDeploymentId || dep.commitHash}`
|
|
1318
|
-
const now = Date.now()
|
|
1319
|
-
|
|
1320
|
-
const cached = this.assetCache.get(key)
|
|
1321
|
-
if (cached && cached.expiresAt > now) {
|
|
1322
|
-
// Refresh LRU recency.
|
|
1323
|
-
this.assetCache.delete(key)
|
|
1324
|
-
this.assetCache.set(key, cached)
|
|
1325
|
-
return cached
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
let manifest: AssetManifest
|
|
1329
|
-
let storage: AssetStorage
|
|
1330
|
-
if (dep.assetDeploymentId) {
|
|
1331
|
-
const bucket = this.env.WORKSPACE_R2
|
|
1332
|
-
if (!bucket) throw new Error("Static preview requires the WORKSPACE_R2 binding")
|
|
1333
|
-
const rows = this.ctx.storage.sql
|
|
1334
|
-
.exec(
|
|
1335
|
-
`SELECT path, r2_key, content_type, etag
|
|
1336
|
-
FROM deployment_assets WHERE deployment_id = ?`,
|
|
1337
|
-
dep.assetDeploymentId,
|
|
1338
|
-
)
|
|
1339
|
-
.toArray()
|
|
1340
|
-
const keys = new Map<string, string>()
|
|
1341
|
-
manifest = new Map()
|
|
1342
|
-
for (const row of rows) {
|
|
1343
|
-
const path = row.path as string
|
|
1344
|
-
keys.set(path, row.r2_key as string)
|
|
1345
|
-
manifest.set(path, {
|
|
1346
|
-
contentType: (row.content_type as string | null) ?? undefined,
|
|
1347
|
-
etag: row.etag as string,
|
|
1348
|
-
})
|
|
1349
|
-
}
|
|
1350
|
-
storage = {
|
|
1351
|
-
async get(pathname) {
|
|
1352
|
-
const r2Key = keys.get(pathname)
|
|
1353
|
-
if (!r2Key) return null
|
|
1354
|
-
return (await bucket.get(r2Key))?.body ?? null
|
|
1355
|
-
},
|
|
1356
|
-
}
|
|
1357
|
-
} else {
|
|
1358
|
-
manifest = await buildAssetManifest(dep.legacyAssets)
|
|
1359
|
-
storage = createMemoryStorage(dep.legacyAssets)
|
|
1360
|
-
}
|
|
1361
|
-
const entry: CachedAssets = { manifest, storage, expiresAt: now + ASSET_CACHE_TTL_MS }
|
|
1362
|
-
|
|
1363
|
-
this.assetCache.set(key, entry)
|
|
1364
|
-
|
|
1365
|
-
// Evict expired / oldest entries to keep the cache bounded.
|
|
1366
|
-
for (const [k, v] of this.assetCache) {
|
|
1367
|
-
if (v.expiresAt <= now) this.assetCache.delete(k)
|
|
1368
|
-
}
|
|
1369
|
-
while (this.assetCache.size > ASSET_CACHE_MAX_ENTRIES) {
|
|
1370
|
-
const oldest = this.assetCache.keys().next().value
|
|
1371
|
-
if (oldest === undefined) break
|
|
1372
|
-
this.assetCache.delete(oldest)
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
return entry
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
1234
|
// ── App-class loader ────────────────────────────────────────────
|
|
1379
1235
|
//
|
|
1380
1236
|
// Loads the dynamic worker for `branch`'s latest deployment with the
|
|
@@ -1399,7 +1255,7 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1399
1255
|
// The date the app declared, not a date frozen into this file. A worker
|
|
1400
1256
|
// built against one runtime and run under another is a bug the author
|
|
1401
1257
|
// cannot see or fix.
|
|
1402
|
-
compatibilityDate: dep.compatibilityDate
|
|
1258
|
+
compatibilityDate: dep.compatibilityDate,
|
|
1403
1259
|
// Generated code gets no outbound network. Omitting this inherits the
|
|
1404
1260
|
// parent Worker's full internet access, which would let an app the model
|
|
1405
1261
|
// wrote reach anything this Worker can — including internal services.
|
|
@@ -1474,40 +1330,12 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1474
1330
|
return { ok: true }
|
|
1475
1331
|
}
|
|
1476
1332
|
|
|
1477
|
-
// ──
|
|
1333
|
+
// ── Internal deploy commands ──
|
|
1478
1334
|
|
|
1479
1335
|
async fetch(request: Request): Promise<Response> {
|
|
1480
1336
|
await this.ensureInit()
|
|
1481
1337
|
|
|
1482
|
-
const
|
|
1483
|
-
const path = url.pathname
|
|
1484
|
-
|
|
1485
|
-
// Preview routes: /space/:name/preview/:branch/*
|
|
1486
|
-
const previewMatch = path.match(/\/preview\/([^/]+)(\/.*)?$/)
|
|
1487
|
-
if (previewMatch) {
|
|
1488
|
-
const branch = decodeURIComponent(previewMatch[1])
|
|
1489
|
-
const spaceName = this.ctx.id.name ?? "space"
|
|
1490
|
-
const basePath = `/space/${spaceName}/preview/${encodeURIComponent(branch)}`
|
|
1491
|
-
|
|
1492
|
-
// Rewrite the URL so the dynamic worker sees a clean path
|
|
1493
|
-
const subPath = previewMatch[2] || "/"
|
|
1494
|
-
const previewUrl = new URL(subPath, url.origin)
|
|
1495
|
-
previewUrl.search = url.search
|
|
1496
|
-
const previewRequest = new Request(previewUrl.toString(), request)
|
|
1497
|
-
const response = await this.servePreview(branch, previewRequest)
|
|
1498
|
-
|
|
1499
|
-
// Strip headers a generated app must not be able to set on the shared
|
|
1500
|
-
// preview origin (e.g. Service-Worker-Allowed scope expansion) before
|
|
1501
|
-
// any further rewriting.
|
|
1502
|
-
const safeResponse = stripPreviewSecurityHeaders(response)
|
|
1503
|
-
|
|
1504
|
-
// Rewrite root-relative paths in HTML responses so they resolve
|
|
1505
|
-
// correctly when the preview is mounted on a sub-path
|
|
1506
|
-
return rewritePreviewResponse(safeResponse, basePath)
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Deploy command routes
|
|
1510
|
-
const cmd = url.searchParams.get("cmd")
|
|
1338
|
+
const cmd = new URL(request.url).searchParams.get("cmd")
|
|
1511
1339
|
if (cmd && ["deploy", "get_deployment", "list_deployments", "undeploy"].includes(cmd)) {
|
|
1512
1340
|
const deployCtx: DeployContext = {
|
|
1513
1341
|
sql: this.ctx.storage.sql,
|
|
@@ -1523,39 +1351,6 @@ export class SpaceDO extends DurableObject<Env>
|
|
|
1523
1351
|
}
|
|
1524
1352
|
}
|
|
1525
1353
|
|
|
1526
|
-
// ─── Preview Response Rewriting ──────────────────────────────────────────────
|
|
1527
|
-
// When a preview is served on /space/:name/preview/:branch/, root-relative
|
|
1528
|
-
// paths like /style.css in HTML would resolve to the domain root instead of
|
|
1529
|
-
// the preview path. We rewrite them so the browser fetches the correct URL.
|
|
1530
|
-
|
|
1531
|
-
function rewritePreviewResponse(response: Response, basePath: string): Response {
|
|
1532
|
-
// Rewrite Location header on redirects
|
|
1533
|
-
const location = response.headers.get("location")
|
|
1534
|
-
if (location?.startsWith("/")) {
|
|
1535
|
-
const rewritten = new Response(response.body, response)
|
|
1536
|
-
rewritten.headers.set("location", basePath + location)
|
|
1537
|
-
return rewritten
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
// Only rewrite HTML responses
|
|
1541
|
-
const ct = response.headers.get("content-type") ?? ""
|
|
1542
|
-
if (!ct.includes("text/html")) return response
|
|
1543
|
-
|
|
1544
|
-
// Use HTMLRewriter to prefix root-relative src/href/action attributes
|
|
1545
|
-
return new HTMLRewriter()
|
|
1546
|
-
.on("[src],[href],[action]", {
|
|
1547
|
-
element(el) {
|
|
1548
|
-
for (const attr of ["src", "href", "action"] as const) {
|
|
1549
|
-
const val = el.getAttribute(attr)
|
|
1550
|
-
if (val?.startsWith("/") && !val.startsWith("//")) {
|
|
1551
|
-
el.setAttribute(attr, basePath + val)
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
},
|
|
1555
|
-
})
|
|
1556
|
-
.transform(response)
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
1354
|
// ─── Facet naming ───────────────────────────────────────────────────────────
|
|
1560
1355
|
|
|
1561
1356
|
function facetNameForApp(branch: string): string {
|
|
@@ -1567,9 +1362,6 @@ function facetNameForApp(branch: string): string {
|
|
|
1567
1362
|
/** Git's oid for an empty tree — what a repository with no content writes. */
|
|
1568
1363
|
const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
1569
1364
|
|
|
1570
|
-
/** Used only for deployments recorded before the date was persisted. */
|
|
1571
|
-
const FALLBACK_COMPATIBILITY_DATE = "2025-04-01"
|
|
1572
|
-
|
|
1573
1365
|
const SPACE_AUTHOR = Object.freeze({
|
|
1574
1366
|
name: "Workspace",
|
|
1575
1367
|
email: "workspace@springbrand.local",
|
|
@@ -1610,6 +1402,29 @@ function byteLength(content: string): number {
|
|
|
1610
1402
|
return new TextEncoder().encode(content).byteLength
|
|
1611
1403
|
}
|
|
1612
1404
|
|
|
1405
|
+
function spaceDeploymentResult(data: Record<string, unknown>): SpaceDeploymentResult {
|
|
1406
|
+
if (
|
|
1407
|
+
typeof data.branch !== "string" ||
|
|
1408
|
+
typeof data.commit_hash !== "string" ||
|
|
1409
|
+
typeof data.main_module !== "string" ||
|
|
1410
|
+
typeof data.has_assets !== "boolean" ||
|
|
1411
|
+
typeof data.compatibility_date !== "string" ||
|
|
1412
|
+
typeof data.deployed_at !== "string" ||
|
|
1413
|
+
!(typeof data.asset_root === "string" || data.asset_root === null)
|
|
1414
|
+
) {
|
|
1415
|
+
throw new Error("Invalid deployment result")
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
branch: data.branch,
|
|
1419
|
+
commit_hash: data.commit_hash,
|
|
1420
|
+
main_module: data.main_module,
|
|
1421
|
+
has_assets: data.has_assets,
|
|
1422
|
+
compatibility_date: data.compatibility_date,
|
|
1423
|
+
deployed_at: data.deployed_at,
|
|
1424
|
+
asset_root: data.asset_root,
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1613
1428
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
1614
1429
|
|
|
1615
1430
|
interface PatchEdit {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BranchDeploymentBundle,
|
|
3
|
+
DeploymentRuntimeConfig,
|
|
4
|
+
SpaceDeploymentResult,
|
|
5
|
+
} from "./deploy-engine";
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* The production Workspace contract a Space must satisfy.
|
|
3
9
|
*
|
|
@@ -234,11 +240,22 @@ export interface SpaceControlPort {
|
|
|
234
240
|
* and only an explicit deploy brings an app into existence.
|
|
235
241
|
*/
|
|
236
242
|
export interface SpaceAppPort {
|
|
237
|
-
deploy(
|
|
243
|
+
deploy(
|
|
244
|
+
branch: string,
|
|
245
|
+
appRoot?: string,
|
|
246
|
+
revision?: string,
|
|
247
|
+
runtimeConfig?: DeploymentRuntimeConfig,
|
|
248
|
+
): Promise<SpaceDeploymentResult>;
|
|
238
249
|
undeploy(branch: string): Promise<unknown>;
|
|
239
250
|
listDeployments(): Promise<unknown>;
|
|
240
251
|
getDeployment(branch: string): Promise<unknown>;
|
|
241
|
-
|
|
252
|
+
getDeploymentBundle(
|
|
253
|
+
branch: string,
|
|
254
|
+
appRoot?: string,
|
|
255
|
+
revision?: string,
|
|
256
|
+
runtimeConfig?: DeploymentRuntimeConfig,
|
|
257
|
+
): Promise<BranchDeploymentBundle>;
|
|
258
|
+
serveApp(branch: string, request: Request): Promise<Response>;
|
|
242
259
|
listAppTables(branch: string): Promise<unknown>;
|
|
243
260
|
queryAppTable(branch: string, table: string, opts?: unknown): Promise<unknown>;
|
|
244
261
|
wipeAppDatabase(branch: string): Promise<{ ok: true }>;
|