@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,141 @@
|
|
|
1
|
+
// ─── Inspector wrapper module ──────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The DB-viewer tab needs to read SQL from the user's App Durable Object,
|
|
4
|
+
// but the Facet's storage is fully isolated from the supervisor (SpaceDO).
|
|
5
|
+
// The only way to read it is via methods on the App class itself.
|
|
6
|
+
//
|
|
7
|
+
// Rather than asking the LLM to paste ~60 lines of inspector boilerplate
|
|
8
|
+
// into every app, we inject a small wrapper module into the dynamic
|
|
9
|
+
// worker. The wrapper:
|
|
10
|
+
// 1. imports the user's App class,
|
|
11
|
+
// 2. re-exports every other named export unchanged,
|
|
12
|
+
// 3. exports a subclass `App` (shadowing the user's) that extends the
|
|
13
|
+
// user's App and adds `__vibeInspectListTables` / `__vibeInspectRead`.
|
|
14
|
+
//
|
|
15
|
+
// SpaceDO loads this wrapper as the main module and `getDurableObjectClass
|
|
16
|
+
// ("App")` returns the subclass. The subclass shares the user's `ctx`,
|
|
17
|
+
// `ctx.storage`, and any state — so the inspector reads the same SQLite
|
|
18
|
+
// database the user's App writes to.
|
|
19
|
+
|
|
20
|
+
export const VIBE_APP_MODULE = "__vibe_app__.js"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the wrapper source. `userMainModule` is the key in `modules`
|
|
24
|
+
* holding the LLM's compiled main (which must `export class App
|
|
25
|
+
* extends DurableObject`).
|
|
26
|
+
*/
|
|
27
|
+
export function buildInspectorWrapperSource(userMainModule: string): string {
|
|
28
|
+
return `import * as __vibeUserModule from ${JSON.stringify(userMainModule)}
|
|
29
|
+
|
|
30
|
+
// Re-export everything from the user's module first. We then shadow the
|
|
31
|
+
// \`App\` export below with the subclass.
|
|
32
|
+
export * from ${JSON.stringify(userMainModule)}
|
|
33
|
+
|
|
34
|
+
const __VIBE_UserApp = __vibeUserModule.App
|
|
35
|
+
if (typeof __VIBE_UserApp !== "function") {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Your main module must export a class named \`App\` extending DurableObject. " +
|
|
38
|
+
"See the cloudflare-bundler-apps skill for the required app structure."
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const __VIBE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
43
|
+
const __VIBE_MAX_PAGE_SIZE = 200
|
|
44
|
+
|
|
45
|
+
function __vibeIsSafeIdentifier(name) {
|
|
46
|
+
return typeof name === "string" && __VIBE_IDENT_RE.test(name)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function __vibeQuote(name) {
|
|
50
|
+
return '"' + String(name).replace(/"/g, '""') + '"'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class App extends __VIBE_UserApp {
|
|
54
|
+
async __vibeInspectListTables() {
|
|
55
|
+
const sql = this.ctx.storage.sql
|
|
56
|
+
const tableRows = sql.exec(
|
|
57
|
+
"SELECT name FROM sqlite_master " +
|
|
58
|
+
"WHERE type = 'table' " +
|
|
59
|
+
" AND name NOT LIKE 'sqlite_%' " +
|
|
60
|
+
" AND name NOT LIKE '_cf_%' " +
|
|
61
|
+
"ORDER BY name"
|
|
62
|
+
).toArray()
|
|
63
|
+
const out = []
|
|
64
|
+
for (const row of tableRows) {
|
|
65
|
+
const name = row.name
|
|
66
|
+
if (!__vibeIsSafeIdentifier(name)) continue
|
|
67
|
+
const columns = sql.exec(
|
|
68
|
+
"PRAGMA table_info(" + __vibeQuote(name) + ")"
|
|
69
|
+
).toArray().map((c) => ({
|
|
70
|
+
name: c.name, type: c.type, notnull: c.notnull, pk: c.pk,
|
|
71
|
+
}))
|
|
72
|
+
let rowCount = 0
|
|
73
|
+
try {
|
|
74
|
+
const cnt = sql.exec(
|
|
75
|
+
"SELECT COUNT(*) AS c FROM " + __vibeQuote(name)
|
|
76
|
+
).one()
|
|
77
|
+
rowCount = Number(cnt.c)
|
|
78
|
+
} catch {
|
|
79
|
+
rowCount = 0
|
|
80
|
+
}
|
|
81
|
+
out.push({ name, rowCount, columns })
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async __vibeInspectRead(table, opts) {
|
|
87
|
+
opts = opts || {}
|
|
88
|
+
if (!__vibeIsSafeIdentifier(table)) {
|
|
89
|
+
throw new Error("Invalid table name: " + table)
|
|
90
|
+
}
|
|
91
|
+
const sql = this.ctx.storage.sql
|
|
92
|
+
const exists = sql.exec(
|
|
93
|
+
"SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name = ?",
|
|
94
|
+
table
|
|
95
|
+
).one().c
|
|
96
|
+
if (!exists) throw new Error("Unknown table: " + table)
|
|
97
|
+
const columns = sql.exec(
|
|
98
|
+
"PRAGMA table_info(" + __vibeQuote(table) + ")"
|
|
99
|
+
).toArray().map((c) => c.name)
|
|
100
|
+
const limit = Math.max(1, Math.min(__VIBE_MAX_PAGE_SIZE, opts.limit || 50))
|
|
101
|
+
const offset = Math.max(0, opts.offset || 0)
|
|
102
|
+
let orderClause = ""
|
|
103
|
+
if (opts.orderBy) {
|
|
104
|
+
if (!columns.includes(opts.orderBy)) {
|
|
105
|
+
throw new Error("Unknown column: " + opts.orderBy)
|
|
106
|
+
}
|
|
107
|
+
const dir = opts.orderDir === "desc" ? "DESC" : "ASC"
|
|
108
|
+
orderClause = " ORDER BY " + __vibeQuote(opts.orderBy) + " " + dir
|
|
109
|
+
}
|
|
110
|
+
const totalCount = Number(sql.exec(
|
|
111
|
+
"SELECT COUNT(*) AS c FROM " + __vibeQuote(table)
|
|
112
|
+
).one().c)
|
|
113
|
+
const rows = sql.exec(
|
|
114
|
+
"SELECT * FROM " + __vibeQuote(table) + orderClause + " LIMIT ? OFFSET ?",
|
|
115
|
+
limit, offset
|
|
116
|
+
).toArray()
|
|
117
|
+
return { columns, rows, totalCount }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async __vibeWipe() {
|
|
121
|
+
// Drop every user table. The next request recreates whatever
|
|
122
|
+
// CREATE TABLE IF NOT EXISTS the App runs on startup.
|
|
123
|
+
const sql = this.ctx.storage.sql
|
|
124
|
+
const tableRows = sql.exec(
|
|
125
|
+
"SELECT name FROM sqlite_master " +
|
|
126
|
+
"WHERE type = 'table' " +
|
|
127
|
+
" AND name NOT LIKE 'sqlite_%' " +
|
|
128
|
+
" AND name NOT LIKE '_cf_%'"
|
|
129
|
+
).toArray()
|
|
130
|
+
this.ctx.storage.transactionSync(() => {
|
|
131
|
+
for (const row of tableRows) {
|
|
132
|
+
const name = row.name
|
|
133
|
+
if (!__vibeIsSafeIdentifier(name)) continue
|
|
134
|
+
sql.exec("DROP TABLE IF EXISTS " + __vibeQuote(name))
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
return { ok: true }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Preview response security.
|
|
2
|
+
//
|
|
3
|
+
// All Think previews share one browser origin (path-based on the preview
|
|
4
|
+
// domain). A generated app must not be able to emit response headers that grant
|
|
5
|
+
// it authority over that shared origin. Most importantly Service-Worker-Allowed
|
|
6
|
+
// lets a service worker widen its scope to "/" - allowing one app's SW to
|
|
7
|
+
// intercept every other user's previews. We also drop Clear-Site-Data (could
|
|
8
|
+
// wipe other users' preview cookies) and Service-Worker-Navigation-Preload.
|
|
9
|
+
|
|
10
|
+
export const STRIPPED_PREVIEW_HEADERS = [
|
|
11
|
+
"Service-Worker-Allowed",
|
|
12
|
+
"Service-Worker-Navigation-Preload",
|
|
13
|
+
"Clear-Site-Data",
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
export function stripPreviewSecurityHeaders(response: Response): Response {
|
|
17
|
+
// WebSocket upgrade responses cannot be reconstructed (no body, immutable
|
|
18
|
+
// headers); they never carry these headers, so pass them through untouched.
|
|
19
|
+
if (response.status === 101 || response.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
20
|
+
return response
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const headers = new Headers(response.headers)
|
|
24
|
+
let changed = false
|
|
25
|
+
for (const name of STRIPPED_PREVIEW_HEADERS) {
|
|
26
|
+
if (headers.has(name)) {
|
|
27
|
+
headers.delete(name)
|
|
28
|
+
changed = true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!changed) return response
|
|
32
|
+
|
|
33
|
+
return new Response(response.body, {
|
|
34
|
+
status: response.status,
|
|
35
|
+
statusText: response.statusText,
|
|
36
|
+
headers,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production Workspace contract a Space must satisfy.
|
|
3
|
+
*
|
|
4
|
+
* Upstream SpaceDO shipped the file surface VibeSDK's Think tools needed:
|
|
5
|
+
* text reads that throw, string-only writes and unpaginated listings. A complete
|
|
6
|
+
* Agent Runtime needs binary writes for XLSX and images, append,
|
|
7
|
+
* conditional writes for Sandbox publishing, quota, and the full set of file
|
|
8
|
+
* operations — so this module states the contract once and the Durable Object
|
|
9
|
+
* implements it verbatim.
|
|
10
|
+
*
|
|
11
|
+
* The names and semantics deliberately match `WorkspacePort` in
|
|
12
|
+
* `@springbrand/agent-runtime`: one file capability, not two competing ones.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Metadata for a single Space directory entry. */
|
|
16
|
+
export interface SpaceFileInfo {
|
|
17
|
+
path: string;
|
|
18
|
+
name: string;
|
|
19
|
+
type: "file" | "directory" | "symlink";
|
|
20
|
+
mimeType: string;
|
|
21
|
+
size: number;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
target?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A file's identity at a point in time.
|
|
29
|
+
*
|
|
30
|
+
* Size plus modification time, because that is what both the Space backends and
|
|
31
|
+
* the Sandbox adapter can observe without hashing every published byte.
|
|
32
|
+
*/
|
|
33
|
+
export interface SpaceFileVersion {
|
|
34
|
+
updatedAt: number;
|
|
35
|
+
size: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SpaceConditionalWriteResult =
|
|
39
|
+
| { written: true; version: SpaceFileVersion }
|
|
40
|
+
| { written: false; reason: "conflict"; current: SpaceFileVersion | null };
|
|
41
|
+
|
|
42
|
+
export interface SpaceUsage {
|
|
43
|
+
fileCount: number;
|
|
44
|
+
directoryCount: number;
|
|
45
|
+
totalBytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SpaceQuota {
|
|
49
|
+
maxFiles: number | null;
|
|
50
|
+
maxTotalBytes: number | null;
|
|
51
|
+
maxFileBytes: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One Space Git commit, as the host's revision history reads it. */
|
|
55
|
+
export interface SpaceCommit {
|
|
56
|
+
revision: string;
|
|
57
|
+
treeHash: string;
|
|
58
|
+
reason: string;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SpaceCommitManifest {
|
|
63
|
+
revision: string;
|
|
64
|
+
treeHash: string;
|
|
65
|
+
files: readonly { path: string; size: number }[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Paths a Space owns and callers may never write.
|
|
70
|
+
*
|
|
71
|
+
* `.git` and `.afs` are the Git object store and the Artifacts overlay's
|
|
72
|
+
* bookkeeping; a caller that could write them could rewrite history.
|
|
73
|
+
*/
|
|
74
|
+
export const SPACE_RESERVED_PREFIXES = ["/.git", "/.afs"] as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Paths that persist in the Space but never enter a Git commit.
|
|
78
|
+
*
|
|
79
|
+
* A Space holds working data as well as deliverables: scratch files, installed
|
|
80
|
+
* dependencies and hydrated attachments are large, regenerable and worthless in
|
|
81
|
+
* history. Committing them would put multi-megabyte blobs into every revision.
|
|
82
|
+
*/
|
|
83
|
+
export const SPACE_UNVERSIONED_PREFIXES = [
|
|
84
|
+
"/.git",
|
|
85
|
+
"/.afs",
|
|
86
|
+
"/tmp",
|
|
87
|
+
"/node_modules",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
const DEFAULT_QUOTA: SpaceQuota = Object.freeze({
|
|
91
|
+
maxFiles: 20_000,
|
|
92
|
+
maxTotalBytes: 2 * 1024 * 1024 * 1024,
|
|
93
|
+
maxFileBytes: 256 * 1024 * 1024,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export function defaultSpaceQuota(): SpaceQuota {
|
|
97
|
+
return { ...DEFAULT_QUOTA };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isReservedSpacePath(path: string): boolean {
|
|
101
|
+
return SPACE_RESERVED_PREFIXES.some(
|
|
102
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isUnversionedSpacePath(path: string): boolean {
|
|
107
|
+
return SPACE_UNVERSIONED_PREFIXES.some(
|
|
108
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reduce a caller-supplied path to a canonical absolute Space path.
|
|
114
|
+
*
|
|
115
|
+
* Traversal is rejected rather than resolved: a caller writing `../` means
|
|
116
|
+
* either a bug or an escape attempt, and silently clamping it to the root turns
|
|
117
|
+
* both into a file quietly landing somewhere the caller did not name.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizeSpacePath(path: string): string {
|
|
120
|
+
if (typeof path !== "string") throw new Error("Space path must be a string");
|
|
121
|
+
const value = path.trim().replaceAll("\\", "/");
|
|
122
|
+
const parts = value.split("/").filter(Boolean);
|
|
123
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
124
|
+
throw new Error("Space path traversal is not allowed");
|
|
125
|
+
}
|
|
126
|
+
return `/${parts.join("/")}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** As `normalizeSpacePath`, and refuses paths the Space owns. */
|
|
130
|
+
export function normalizeWritableSpacePath(path: string): string {
|
|
131
|
+
const normalized = normalizeSpacePath(path);
|
|
132
|
+
if (isReservedSpacePath(normalized)) {
|
|
133
|
+
throw new Error("Space metadata is not writable");
|
|
134
|
+
}
|
|
135
|
+
if (normalized === "/") throw new Error("The Space root is not a file");
|
|
136
|
+
return normalized;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Normalize a glob pattern without destroying its wildcards.
|
|
141
|
+
*
|
|
142
|
+
* `normalizeSpacePath` would reject `**` never, but it also collapses the empty
|
|
143
|
+
* segments a caller may rely on; patterns are therefore only anchored, and the
|
|
144
|
+
* traversal check is kept.
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeSpacePattern(pattern: string): string {
|
|
147
|
+
if (typeof pattern !== "string") throw new Error("Space pattern must be a string");
|
|
148
|
+
const value = pattern.trim().replaceAll("\\", "/");
|
|
149
|
+
const parts = value.split("/").filter(Boolean);
|
|
150
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
151
|
+
throw new Error("Space path traversal is not allowed");
|
|
152
|
+
}
|
|
153
|
+
return `/${parts.join("/")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function toBytes(data: Uint8Array | ArrayBuffer): Uint8Array {
|
|
157
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function versionOf(info: SpaceFileInfo | null): SpaceFileVersion | null {
|
|
161
|
+
return info?.type === "file" ? { updatedAt: info.updatedAt, size: info.size } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The file capability the Agent Runtime consumes, method-for-method.
|
|
166
|
+
*
|
|
167
|
+
* Declared here so a compile error — not a production 500 — is what happens
|
|
168
|
+
* when the Durable Object and the runtime contract drift apart.
|
|
169
|
+
*/
|
|
170
|
+
export interface SpaceWorkspacePort {
|
|
171
|
+
readFile(path: string): Promise<string | null>;
|
|
172
|
+
readFileBytes(path: string): Promise<Uint8Array | null>;
|
|
173
|
+
writeFile(path: string, content: string, mimeType?: string): Promise<void>;
|
|
174
|
+
writeFileBytes(
|
|
175
|
+
path: string,
|
|
176
|
+
data: Uint8Array | ArrayBuffer,
|
|
177
|
+
mimeType?: string,
|
|
178
|
+
): Promise<void>;
|
|
179
|
+
appendFile(path: string, content: string, mimeType?: string): Promise<void>;
|
|
180
|
+
exists(path: string): Promise<boolean>;
|
|
181
|
+
stat(path: string): Promise<SpaceFileInfo | null>;
|
|
182
|
+
lstat(path: string): Promise<SpaceFileInfo | null>;
|
|
183
|
+
mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>;
|
|
184
|
+
readDir(
|
|
185
|
+
dir?: string,
|
|
186
|
+
opts?: { limit?: number; offset?: number },
|
|
187
|
+
): Promise<SpaceFileInfo[]>;
|
|
188
|
+
rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
|
189
|
+
cp(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void>;
|
|
190
|
+
mv(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void>;
|
|
191
|
+
symlink(target: string, linkPath: string): Promise<void>;
|
|
192
|
+
readlink(path: string): Promise<string>;
|
|
193
|
+
glob(pattern: string): Promise<SpaceFileInfo[]>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Everything a Space does that is not a file operation.
|
|
198
|
+
*
|
|
199
|
+
* Git, deployment, preview and the App database live here rather than beside
|
|
200
|
+
* the file methods so a caller holding a Workspace cannot reach them by
|
|
201
|
+
* accident, and so the runtime's contract stays exactly the file surface.
|
|
202
|
+
*/
|
|
203
|
+
export interface SpaceControlPort {
|
|
204
|
+
writeFileBytesIfUnchanged(
|
|
205
|
+
path: string,
|
|
206
|
+
data: Uint8Array,
|
|
207
|
+
mimeType: string,
|
|
208
|
+
expected: SpaceFileVersion | null,
|
|
209
|
+
): Promise<SpaceConditionalWriteResult>;
|
|
210
|
+
getUsage(): Promise<SpaceUsage>;
|
|
211
|
+
getQuota(): Promise<SpaceQuota>;
|
|
212
|
+
commit(reason: string, prefix?: string): Promise<{
|
|
213
|
+
revision: string | null;
|
|
214
|
+
treeHash: string | null;
|
|
215
|
+
changed: boolean;
|
|
216
|
+
}>;
|
|
217
|
+
listCommits(prefix: string, limit?: number): Promise<readonly SpaceCommit[]>;
|
|
218
|
+
currentCommit(prefix: string): Promise<SpaceCommit | null>;
|
|
219
|
+
commitManifest(prefix: string, revision: string): Promise<SpaceCommitManifest>;
|
|
220
|
+
readCommitFile(prefix: string, revision: string, path: string): Promise<Uint8Array>;
|
|
221
|
+
restorePathsFromCommit(
|
|
222
|
+
prefix: string,
|
|
223
|
+
revision: string,
|
|
224
|
+
reason: string,
|
|
225
|
+
): Promise<{ revision: string; treeHash: string }>;
|
|
226
|
+
destroySpace(): Promise<void>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Turning a Space into a running application.
|
|
231
|
+
*
|
|
232
|
+
* Separate from the file and history surface because most Spaces never use it:
|
|
233
|
+
* a Space holding a spreadsheet and a script is complete without any of this,
|
|
234
|
+
* and only an explicit deploy brings an app into existence.
|
|
235
|
+
*/
|
|
236
|
+
export interface SpaceAppPort {
|
|
237
|
+
deploy(branch: string, appRoot?: string): Promise<unknown>;
|
|
238
|
+
undeploy(branch: string): Promise<unknown>;
|
|
239
|
+
listDeployments(): Promise<unknown>;
|
|
240
|
+
getDeployment(branch: string): Promise<unknown>;
|
|
241
|
+
servePreview(branch: string, request: Request): Promise<Response>;
|
|
242
|
+
listAppTables(branch: string): Promise<unknown>;
|
|
243
|
+
queryAppTable(branch: string, table: string, opts?: unknown): Promise<unknown>;
|
|
244
|
+
wipeAppDatabase(branch: string): Promise<{ ok: true }>;
|
|
245
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ─── Wrangler Config Parser ──────────────────────────────────────────────────
|
|
2
|
+
// Parses the child project's wrangler.toml / wrangler.json / wrangler.jsonc
|
|
3
|
+
// to extract deployment-relevant configuration.
|
|
4
|
+
|
|
5
|
+
export interface ParsedDurableObjectBinding {
|
|
6
|
+
name: string
|
|
7
|
+
className: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParsedWranglerConfig {
|
|
11
|
+
main?: string
|
|
12
|
+
compatibilityDate?: string
|
|
13
|
+
compatibilityFlags?: string[]
|
|
14
|
+
assets?: {
|
|
15
|
+
directory?: string
|
|
16
|
+
binding?: string
|
|
17
|
+
htmlHandling?: "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"
|
|
18
|
+
notFoundHandling?: "single-page-application" | "404-page" | "none"
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Durable Object bindings declared by the child project (Tier-2 apps).
|
|
22
|
+
* Each binding must reference a SQLite-backed class exported from `main`.
|
|
23
|
+
* `script_name`-style externals and KV-backed (`new_classes`) DOs are
|
|
24
|
+
* rejected upstream of the parser so we keep this shape minimal.
|
|
25
|
+
*/
|
|
26
|
+
durableObjects?: ParsedDurableObjectBinding[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class WranglerConfigError extends Error {
|
|
30
|
+
constructor(message: string) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = "WranglerConfigError"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Try to parse wrangler config from a set of project files.
|
|
38
|
+
* Checks wrangler.json, wrangler.jsonc, then wrangler.toml.
|
|
39
|
+
*/
|
|
40
|
+
export function parseWranglerConfig(files: Record<string, string>): ParsedWranglerConfig {
|
|
41
|
+
const jsonContent = files["wrangler.json"] ?? files["wrangler.jsonc"]
|
|
42
|
+
if (jsonContent) return parseJsonConfig(jsonContent)
|
|
43
|
+
|
|
44
|
+
const tomlContent = files["wrangler.toml"]
|
|
45
|
+
if (tomlContent) return parseTomlConfig(tomlContent)
|
|
46
|
+
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseJsonConfig(content: string): ParsedWranglerConfig {
|
|
51
|
+
// Strip single-line comments for jsonc support
|
|
52
|
+
const stripped = content.replace(/^\s*\/\/.*$/gm, "")
|
|
53
|
+
const raw = JSON.parse(stripped)
|
|
54
|
+
|
|
55
|
+
const cfg: ParsedWranglerConfig = {}
|
|
56
|
+
if (typeof raw.main === "string") cfg.main = raw.main
|
|
57
|
+
if (typeof raw.compatibility_date === "string") cfg.compatibilityDate = raw.compatibility_date
|
|
58
|
+
if (Array.isArray(raw.compatibility_flags)) cfg.compatibilityFlags = raw.compatibility_flags
|
|
59
|
+
|
|
60
|
+
if (raw.assets && typeof raw.assets === "object") {
|
|
61
|
+
cfg.assets = {}
|
|
62
|
+
if (typeof raw.assets.directory === "string") cfg.assets.directory = raw.assets.directory
|
|
63
|
+
if (typeof raw.assets.binding === "string") cfg.assets.binding = raw.assets.binding
|
|
64
|
+
if (typeof raw.assets.html_handling === "string") cfg.assets.htmlHandling = raw.assets.html_handling
|
|
65
|
+
if (typeof raw.assets.not_found_handling === "string") cfg.assets.notFoundHandling = raw.assets.not_found_handling
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Durable Objects (Tier 2) ───────────────────────────────────
|
|
69
|
+
const dos = parseDurableObjectsJson(raw)
|
|
70
|
+
if (dos && dos.length > 0) cfg.durableObjects = dos
|
|
71
|
+
|
|
72
|
+
return cfg
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseDurableObjectsJson(raw: any): ParsedDurableObjectBinding[] | undefined {
|
|
76
|
+
const block = raw?.durable_objects
|
|
77
|
+
if (!block || typeof block !== "object") return undefined
|
|
78
|
+
const bindings = block.bindings
|
|
79
|
+
if (!Array.isArray(bindings) || bindings.length === 0) return undefined
|
|
80
|
+
|
|
81
|
+
// Collect the set of class names allowed by migrations.
|
|
82
|
+
const sqliteClasses = new Set<string>()
|
|
83
|
+
const nonSqliteClasses = new Set<string>()
|
|
84
|
+
const migrations = Array.isArray(raw.migrations) ? raw.migrations : []
|
|
85
|
+
for (const m of migrations) {
|
|
86
|
+
if (!m || typeof m !== "object") continue
|
|
87
|
+
if (Array.isArray(m.new_sqlite_classes)) {
|
|
88
|
+
for (const c of m.new_sqlite_classes) {
|
|
89
|
+
if (typeof c === "string") sqliteClasses.add(c)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(m.new_classes)) {
|
|
93
|
+
for (const c of m.new_classes) {
|
|
94
|
+
if (typeof c === "string") nonSqliteClasses.add(c)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result: ParsedDurableObjectBinding[] = []
|
|
100
|
+
for (const b of bindings) {
|
|
101
|
+
if (!b || typeof b !== "object") continue
|
|
102
|
+
if (typeof b.script_name === "string") {
|
|
103
|
+
throw new WranglerConfigError(
|
|
104
|
+
`Durable Object binding "${b.name ?? "?"}" uses script_name. ` +
|
|
105
|
+
`Generated apps cannot reference Durable Objects from other scripts.`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
const name = typeof b.name === "string" ? b.name : null
|
|
109
|
+
const className = typeof b.class_name === "string" ? b.class_name : null
|
|
110
|
+
if (!name || !className) {
|
|
111
|
+
throw new WranglerConfigError(
|
|
112
|
+
`Durable Object binding must have both "name" and "class_name".`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (nonSqliteClasses.has(className)) {
|
|
116
|
+
throw new WranglerConfigError(
|
|
117
|
+
`Durable Object class "${className}" uses new_classes (KV storage). ` +
|
|
118
|
+
`Generated apps must declare it under new_sqlite_classes instead.`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
if (!sqliteClasses.has(className)) {
|
|
122
|
+
throw new WranglerConfigError(
|
|
123
|
+
`Durable Object class "${className}" is not declared in migrations.new_sqlite_classes. ` +
|
|
124
|
+
`Add a migration tag with new_sqlite_classes: ["${className}"].`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
result.push({ name, className })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseTomlConfig(content: string): ParsedWranglerConfig {
|
|
134
|
+
const cfg: ParsedWranglerConfig = {}
|
|
135
|
+
|
|
136
|
+
// Top-level fields (before any [section])
|
|
137
|
+
cfg.main = extractTomlString(content, "main", true)
|
|
138
|
+
cfg.compatibilityDate = extractTomlString(content, "compatibility_date", true)
|
|
139
|
+
|
|
140
|
+
const flags = extractTomlArray(content, "compatibility_flags", true)
|
|
141
|
+
if (flags) cfg.compatibilityFlags = flags
|
|
142
|
+
|
|
143
|
+
// [assets] section
|
|
144
|
+
const assetsSection = extractTomlSection(content, "assets")
|
|
145
|
+
if (assetsSection) {
|
|
146
|
+
cfg.assets = {}
|
|
147
|
+
cfg.assets.directory = extractTomlString(assetsSection, "directory")
|
|
148
|
+
cfg.assets.binding = extractTomlString(assetsSection, "binding")
|
|
149
|
+
cfg.assets.htmlHandling = extractTomlString(assetsSection, "html_handling") as ParsedWranglerConfig["assets"] extends { htmlHandling?: infer T } ? T : never
|
|
150
|
+
cfg.assets.notFoundHandling = extractTomlString(assetsSection, "not_found_handling") as ParsedWranglerConfig["assets"] extends { notFoundHandling?: infer T } ? T : never
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// TOML DO parsing is intentionally not supported; the space skill
|
|
154
|
+
// tells the LLM to use wrangler.json (the file the bundler emits). If
|
|
155
|
+
// a TOML config declares DOs, we surface a clear error so the user
|
|
156
|
+
// knows to switch to JSON.
|
|
157
|
+
if (/\[\[durable_objects\.bindings\]\]/.test(content)) {
|
|
158
|
+
throw new WranglerConfigError(
|
|
159
|
+
"Durable Object bindings in wrangler.toml are not supported. " +
|
|
160
|
+
"Use wrangler.json instead.",
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return cfg
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── TOML Helpers (minimal, field-specific) ──────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function extractTomlSection(content: string, name: string): string | undefined {
|
|
170
|
+
const pattern = new RegExp(`^\\[${name}\\]\\s*\\n((?:(?!^\\[)[^\\n]*\\n?)*)`, "m")
|
|
171
|
+
const match = content.match(pattern)
|
|
172
|
+
return match?.[1]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractTomlString(content: string, key: string, topLevelOnly?: boolean): string | undefined {
|
|
176
|
+
// If topLevelOnly, only match before the first [section]
|
|
177
|
+
const scope = topLevelOnly ? content.split(/^\[/m)[0] : content
|
|
178
|
+
const pattern = new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m")
|
|
179
|
+
return scope.match(pattern)?.[1]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractTomlArray(content: string, key: string, topLevelOnly?: boolean): string[] | undefined {
|
|
183
|
+
const scope = topLevelOnly ? content.split(/^\[/m)[0] : content
|
|
184
|
+
const pattern = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m")
|
|
185
|
+
const match = scope.match(pattern)?.[1]
|
|
186
|
+
if (!match) return undefined
|
|
187
|
+
return match.split(",").map(s => s.trim().replace(/^"|"$/g, "")).filter(Boolean)
|
|
188
|
+
}
|