@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileInfo adapter for the plain `FileSystem` interface.
|
|
3
|
+
*
|
|
4
|
+
* The SpaceDO overlay used to be a `Workspace`, whose listing methods returned
|
|
5
|
+
* rich `FileInfo` records (mimeType, updatedAt, size). The in-memory overlay
|
|
6
|
+
* only implements the minimal `FileSystem` contract (`glob` -> string[], `stat`
|
|
7
|
+
* -> `FsStat`), so these helpers rebuild `FileInfo` from a path + `FsStat`:
|
|
8
|
+
* MIME is inferred from the extension and timestamps come from `mtime`.
|
|
9
|
+
*/
|
|
10
|
+
import type { FileSystem, FileInfo, FsStat } from "@cloudflare/shell"
|
|
11
|
+
|
|
12
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
13
|
+
html: "text/html",
|
|
14
|
+
htm: "text/html",
|
|
15
|
+
css: "text/css",
|
|
16
|
+
js: "text/javascript",
|
|
17
|
+
mjs: "text/javascript",
|
|
18
|
+
cjs: "text/javascript",
|
|
19
|
+
jsx: "text/javascript",
|
|
20
|
+
ts: "text/typescript",
|
|
21
|
+
tsx: "text/typescript",
|
|
22
|
+
json: "application/json",
|
|
23
|
+
jsonc: "application/json",
|
|
24
|
+
map: "application/json",
|
|
25
|
+
txt: "text/plain",
|
|
26
|
+
md: "text/markdown",
|
|
27
|
+
csv: "text/csv",
|
|
28
|
+
xml: "application/xml",
|
|
29
|
+
svg: "image/svg+xml",
|
|
30
|
+
png: "image/png",
|
|
31
|
+
jpg: "image/jpeg",
|
|
32
|
+
jpeg: "image/jpeg",
|
|
33
|
+
gif: "image/gif",
|
|
34
|
+
webp: "image/webp",
|
|
35
|
+
avif: "image/avif",
|
|
36
|
+
ico: "image/x-icon",
|
|
37
|
+
bmp: "image/bmp",
|
|
38
|
+
woff: "font/woff",
|
|
39
|
+
woff2: "font/woff2",
|
|
40
|
+
ttf: "font/ttf",
|
|
41
|
+
otf: "font/otf",
|
|
42
|
+
eot: "application/vnd.ms-fontobject",
|
|
43
|
+
wasm: "application/wasm",
|
|
44
|
+
pdf: "application/pdf",
|
|
45
|
+
zip: "application/zip",
|
|
46
|
+
webmanifest: "application/manifest+json",
|
|
47
|
+
mp3: "audio/mpeg",
|
|
48
|
+
mp4: "video/mp4",
|
|
49
|
+
webm: "video/webm",
|
|
50
|
+
ogg: "audio/ogg",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MIME = "application/octet-stream"
|
|
54
|
+
const DIR_MIME = "inode/directory"
|
|
55
|
+
|
|
56
|
+
function basename(path: string): string {
|
|
57
|
+
const i = path.lastIndexOf("/")
|
|
58
|
+
return i >= 0 ? path.slice(i + 1) : path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Infer a MIME type from a path's extension, defaulting to octet-stream. */
|
|
62
|
+
export function inferMimeType(path: string): string {
|
|
63
|
+
const name = basename(path)
|
|
64
|
+
const dot = name.lastIndexOf(".")
|
|
65
|
+
if (dot <= 0) return DEFAULT_MIME
|
|
66
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? DEFAULT_MIME
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a `FileInfo` from a path and its `FsStat`. */
|
|
70
|
+
export function toFileInfo(path: string, stat: FsStat): FileInfo {
|
|
71
|
+
const ms = stat.mtime.getTime()
|
|
72
|
+
return {
|
|
73
|
+
path,
|
|
74
|
+
name: basename(path),
|
|
75
|
+
type: stat.type,
|
|
76
|
+
mimeType: stat.type === "file" ? inferMimeType(path) : DIR_MIME,
|
|
77
|
+
size: stat.size,
|
|
78
|
+
createdAt: ms,
|
|
79
|
+
updatedAt: ms,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `glob` + `stat` each match into a `FileInfo[]` (skips paths that vanish). */
|
|
84
|
+
export async function globInfos(fs: FileSystem, pattern: string): Promise<FileInfo[]> {
|
|
85
|
+
const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`
|
|
86
|
+
const paths = await fs.glob(normalizedPattern)
|
|
87
|
+
const infos: FileInfo[] = []
|
|
88
|
+
for (const p of paths) {
|
|
89
|
+
try {
|
|
90
|
+
infos.push(toFileInfo(p, await fs.stat(p)))
|
|
91
|
+
} catch {
|
|
92
|
+
// Raced against a concurrent delete, or unreadable — skip.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return infos
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** List a directory's immediate children as `FileInfo[]`, with offset/limit. */
|
|
99
|
+
export async function readDirInfos(
|
|
100
|
+
fs: FileSystem,
|
|
101
|
+
dir?: string,
|
|
102
|
+
opts?: { limit?: number; offset?: number },
|
|
103
|
+
): Promise<FileInfo[]> {
|
|
104
|
+
const base = !dir || dir === "/" ? "/" : dir.startsWith("/") ? dir : "/" + dir
|
|
105
|
+
const entries = await fs.readdirWithFileTypes(base)
|
|
106
|
+
const infos: FileInfo[] = []
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
const full = base === "/" ? "/" + e.name : base + "/" + e.name
|
|
109
|
+
try {
|
|
110
|
+
infos.push(toFileInfo(full, await fs.stat(full)))
|
|
111
|
+
} catch {
|
|
112
|
+
// skip unreadable entry
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const offset = opts?.offset ?? 0
|
|
116
|
+
return opts?.limit !== undefined ? infos.slice(offset, offset + opts.limit) : infos.slice(offset)
|
|
117
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWorkspaceStateBackend,
|
|
3
|
+
FileSystemStateBackend,
|
|
4
|
+
InMemoryFs,
|
|
5
|
+
Workspace,
|
|
6
|
+
WorkspaceFileSystem,
|
|
7
|
+
type FileSystem,
|
|
8
|
+
} from "@cloudflare/shell"
|
|
9
|
+
import { createGit, type Git } from "@cloudflare/shell/git"
|
|
10
|
+
import type { Env } from "../env"
|
|
11
|
+
import { ArtifactsFileSystem } from "./artifacts-fs"
|
|
12
|
+
import { ArtifactsSync, type ArtifactsRemoteStore } from "./artifacts-sync"
|
|
13
|
+
import { CheckpointStore } from "./checkpoint"
|
|
14
|
+
import { createArtifactsBaseSource } from "./git-objects"
|
|
15
|
+
|
|
16
|
+
export const ARTIFACTS_REMOTE_URL_KEY = "artifacts:remoteUrl"
|
|
17
|
+
export const SPACE_FS_BACKEND_KEY = "space:fsBackend"
|
|
18
|
+
const ARTIFACTS_BASE_BRANCH = "main"
|
|
19
|
+
const ARTIFACTS_INIT_TIMEOUT_MS = 10_000
|
|
20
|
+
const CHECKPOINT_DEBOUNCE_MS = 2_000
|
|
21
|
+
|
|
22
|
+
export type SpaceFsBackendMode = "artifacts" | "sql"
|
|
23
|
+
|
|
24
|
+
export async function resolveSpaceFsBackendMode(
|
|
25
|
+
storage: Pick<DurableObjectStorage, "get" | "put" | "sql">,
|
|
26
|
+
env: Pick<Env, "ENABLE_ARTIFACTS" | "ARTIFACTS">,
|
|
27
|
+
): Promise<SpaceFsBackendMode> {
|
|
28
|
+
let mode = await storage.get<SpaceFsBackendMode>(SPACE_FS_BACKEND_KEY)
|
|
29
|
+
if (mode) return mode
|
|
30
|
+
|
|
31
|
+
const priorArtifacts = (await storage.get<string>(ARTIFACTS_REMOTE_URL_KEY)) !== undefined
|
|
32
|
+
let hasCheckpoint = false
|
|
33
|
+
try {
|
|
34
|
+
hasCheckpoint = storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
|
|
35
|
+
} catch {}
|
|
36
|
+
const artifactsEnabled = env.ENABLE_ARTIFACTS === "true" && !!env.ARTIFACTS
|
|
37
|
+
mode = priorArtifacts || hasCheckpoint || artifactsEnabled ? "artifacts" : "sql"
|
|
38
|
+
await storage.put(SPACE_FS_BACKEND_KEY, mode)
|
|
39
|
+
return mode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SpaceFsBackend {
|
|
43
|
+
readonly fs: FileSystem
|
|
44
|
+
readonly overlay: FileSystem
|
|
45
|
+
readonly git: Git
|
|
46
|
+
readonly stateBackend: FileSystemStateBackend
|
|
47
|
+
ready(): Promise<void>
|
|
48
|
+
hydrate(path: string): Promise<void>
|
|
49
|
+
materializeAll(): Promise<void>
|
|
50
|
+
flushCheckpoint(): Promise<void>
|
|
51
|
+
push(branch: string): Promise<boolean>
|
|
52
|
+
fetch(branch: string): Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isReservedPath(path: string): boolean {
|
|
56
|
+
return path === "/.git" || path.startsWith("/.git/") || path === "/.afs" || path.startsWith("/.afs/")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
|
60
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([
|
|
63
|
+
operation,
|
|
64
|
+
new Promise<never>((_, reject) => {
|
|
65
|
+
timeout = setTimeout(() => reject(new Error("Operation timed out")), timeoutMs)
|
|
66
|
+
}),
|
|
67
|
+
])
|
|
68
|
+
} finally {
|
|
69
|
+
if (timeout) clearTimeout(timeout)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ArtifactsBackend implements SpaceFsBackend {
|
|
74
|
+
readonly overlay: FileSystem
|
|
75
|
+
readonly fs: FileSystem
|
|
76
|
+
readonly git: Git
|
|
77
|
+
readonly stateBackend: FileSystemStateBackend
|
|
78
|
+
private readonly remoteStore: ArtifactsRemoteStore
|
|
79
|
+
private readonly checkpointStore: CheckpointStore
|
|
80
|
+
private readonly checkpointDirty = new Set<string>()
|
|
81
|
+
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
|
|
82
|
+
private artifactsSync?: ArtifactsSync
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
ctx: DurableObjectState,
|
|
86
|
+
private readonly env: Env,
|
|
87
|
+
private readonly repoName: string,
|
|
88
|
+
) {
|
|
89
|
+
if (!env.ARTIFACTS) throw new Error("SpaceDO is pinned to Artifacts but the ARTIFACTS binding is unavailable")
|
|
90
|
+
|
|
91
|
+
const overlay = new InMemoryFs()
|
|
92
|
+
this.overlay = overlay
|
|
93
|
+
this.remoteStore = {
|
|
94
|
+
read: () => ctx.storage.get<string>(ARTIFACTS_REMOTE_URL_KEY).then((value) => value ?? null),
|
|
95
|
+
write: (url) => ctx.storage.put(ARTIFACTS_REMOTE_URL_KEY, url).then(() => undefined),
|
|
96
|
+
}
|
|
97
|
+
const fetchSync = new ArtifactsSync(env.ARTIFACTS, createGit(overlay), repoName, this.remoteStore)
|
|
98
|
+
const source = createArtifactsBaseSource({
|
|
99
|
+
overlay,
|
|
100
|
+
branch: ARTIFACTS_BASE_BRANCH,
|
|
101
|
+
hasRemote: async () => (await this.remoteStore.read()) !== null,
|
|
102
|
+
fetchBranch: async () => {
|
|
103
|
+
try {
|
|
104
|
+
return await withTimeout(fetchSync.fetch(ARTIFACTS_BASE_BRANCH), ARTIFACTS_INIT_TIMEOUT_MS)
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
this.checkpointStore = new CheckpointStore(ctx.storage)
|
|
111
|
+
this.fs = new ArtifactsFileSystem(overlay, {
|
|
112
|
+
source,
|
|
113
|
+
branch: ARTIFACTS_BASE_BRANCH,
|
|
114
|
+
checkpoint: this.checkpointStore,
|
|
115
|
+
onChange: (path) => this.noteCheckpointDirty(path),
|
|
116
|
+
})
|
|
117
|
+
this.git = createGit(this.fs)
|
|
118
|
+
this.stateBackend = new FileSystemStateBackend(overlay)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async ready(): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
await (this.fs as ArtifactsFileSystem).ready()
|
|
124
|
+
} catch {
|
|
125
|
+
// Base fetch failed this time — proceed with whatever is local.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async hydrate(path: string): Promise<void> {
|
|
130
|
+
await (this.fs as ArtifactsFileSystem).hydrate(path)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async materializeAll(): Promise<void> {
|
|
134
|
+
await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async flushCheckpoint(): Promise<void> {
|
|
138
|
+
if (this.checkpointDirty.size === 0) return
|
|
139
|
+
const paths = [...this.checkpointDirty]
|
|
140
|
+
this.checkpointDirty.clear()
|
|
141
|
+
for (const path of paths) {
|
|
142
|
+
try {
|
|
143
|
+
if (await this.overlay.exists(path)) {
|
|
144
|
+
const stat = await this.overlay.stat(path)
|
|
145
|
+
if (stat.type !== "directory") this.checkpointStore.save(path, await this.overlay.readFileBytes(path))
|
|
146
|
+
} else {
|
|
147
|
+
this.checkpointStore.save(path, null)
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.warn(`Checkpoint flush failed for ${path}`, error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async push(branch: string): Promise<boolean> {
|
|
156
|
+
const pushed = await this.getArtifactsSync().push(branch)
|
|
157
|
+
if (pushed) this.resetCheckpointAfterPush()
|
|
158
|
+
return pushed
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async fetch(branch: string): Promise<void> {
|
|
162
|
+
await this.getArtifactsSync().fetch(branch)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private noteCheckpointDirty(path: string): void {
|
|
166
|
+
if (isReservedPath(path)) return
|
|
167
|
+
this.checkpointDirty.add(path)
|
|
168
|
+
if (this.checkpointTimer !== null) return
|
|
169
|
+
this.checkpointTimer = setTimeout(() => {
|
|
170
|
+
this.checkpointTimer = null
|
|
171
|
+
void this.flushCheckpoint()
|
|
172
|
+
}, CHECKPOINT_DEBOUNCE_MS)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private resetCheckpointAfterPush(): void {
|
|
176
|
+
try {
|
|
177
|
+
this.checkpointStore.clear()
|
|
178
|
+
void this.flushCheckpoint()
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.warn("Checkpoint reset after push failed", error)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private getArtifactsSync(): ArtifactsSync {
|
|
185
|
+
if (this.artifactsSync) return this.artifactsSync
|
|
186
|
+
this.artifactsSync = new ArtifactsSync(this.requireArtifacts(), this.git, this.repoName, this.remoteStore)
|
|
187
|
+
return this.artifactsSync
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private requireArtifacts(): Artifacts {
|
|
191
|
+
const artifacts = this.env.ARTIFACTS
|
|
192
|
+
if (!artifacts) throw new Error("SpaceDO is pinned to Artifacts but the ARTIFACTS binding is unavailable")
|
|
193
|
+
return artifacts
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Where a Space's spilled bytes live, kept apart from Attachment Content. */
|
|
198
|
+
export function spaceR2Prefix(spaceId: string): string {
|
|
199
|
+
return `spaces/v1/${spaceId}`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export class SqlBackend implements SpaceFsBackend {
|
|
203
|
+
readonly overlay: FileSystem
|
|
204
|
+
readonly fs: FileSystem
|
|
205
|
+
readonly git: Git
|
|
206
|
+
readonly stateBackend: FileSystemStateBackend
|
|
207
|
+
readonly workspace: Workspace
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Large files spill to R2 rather than to the DO's own SQLite. A Space holds
|
|
211
|
+
* spreadsheets, PDFs and images, any one of which can exceed what a SQLite
|
|
212
|
+
* row will hold — and a Space that fills its DO storage stops accepting
|
|
213
|
+
* writes for every other file too.
|
|
214
|
+
*/
|
|
215
|
+
constructor(ctx: DurableObjectState, repoName: string, r2?: R2Bucket) {
|
|
216
|
+
const workspace = new Workspace({
|
|
217
|
+
sql: ctx.storage.sql,
|
|
218
|
+
name: () => repoName,
|
|
219
|
+
...(r2 ? { r2, r2Prefix: spaceR2Prefix(repoName) } : {}),
|
|
220
|
+
})
|
|
221
|
+
this.workspace = workspace
|
|
222
|
+
const fs = new WorkspaceFileSystem(workspace)
|
|
223
|
+
this.overlay = fs
|
|
224
|
+
this.fs = fs
|
|
225
|
+
this.git = createGit(fs)
|
|
226
|
+
this.stateBackend = createWorkspaceStateBackend(workspace)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async ready(): Promise<void> {}
|
|
230
|
+
async hydrate(_path: string): Promise<void> {}
|
|
231
|
+
async materializeAll(): Promise<void> {}
|
|
232
|
+
async flushCheckpoint(): Promise<void> {}
|
|
233
|
+
async push(_branch: string): Promise<boolean> { return false }
|
|
234
|
+
async fetch(_branch: string): Promise<void> {}
|
|
235
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object-level git plumbing for `ArtifactsFileSystem`.
|
|
3
|
+
*
|
|
4
|
+
* The shell's `createGit` only exposes porcelain (commit/log/checkout/…), not
|
|
5
|
+
* object reads. `ArtifactsFileSystem` needs to (a) walk a fetched commit tree
|
|
6
|
+
* to build its base snapshot and (b) read individual blobs by oid to hydrate
|
|
7
|
+
* files on demand. This module wraps isomorphic-git's object APIs over a shell
|
|
8
|
+
* `FileSystem`, reusing the same fs adapter shape the shell uses so behavior
|
|
9
|
+
* (ENOENT dispatch, utf8/binary reads) matches `createGit`.
|
|
10
|
+
*/
|
|
11
|
+
import * as git from "isomorphic-git"
|
|
12
|
+
import type { FileSystem } from "@cloudflare/shell"
|
|
13
|
+
|
|
14
|
+
/** Metadata for one file in the Artifacts base tree. */
|
|
15
|
+
export interface BaseEntry {
|
|
16
|
+
oid: string
|
|
17
|
+
mode: number
|
|
18
|
+
/** Byte size, or `undefined` until first computed (lazily, on demand). */
|
|
19
|
+
size?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A loaded base tree: the commit it came from plus its files. */
|
|
23
|
+
export interface BaseSnapshot {
|
|
24
|
+
head: string
|
|
25
|
+
files: Map<string, BaseEntry>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BaseSnapshotSource {
|
|
29
|
+
/**
|
|
30
|
+
* The base tree (head commit + absolute-path -> entry map), or `null` when no
|
|
31
|
+
* base is available (the FileSystem then behaves as a plain overlay).
|
|
32
|
+
* Implementations must ensure blobs are locally readable via `readBlob`.
|
|
33
|
+
*
|
|
34
|
+
* `null` means "legitimately empty" (no remote repo/branch yet). A transient
|
|
35
|
+
* failure (network blip, fetch timeout) must THROW instead, so callers can
|
|
36
|
+
* retry on a later access rather than caching an empty base forever.
|
|
37
|
+
*/
|
|
38
|
+
loadSnapshot(): Promise<BaseSnapshot | null>
|
|
39
|
+
/** Raw bytes of a blob by oid (from the local object store). */
|
|
40
|
+
readBlob(oid: string): Promise<Uint8Array>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Node `fs.Stats`-shaped object that isomorphic-git expects from stat/lstat. */
|
|
44
|
+
class GitStat {
|
|
45
|
+
private readonly type: "file" | "directory" | "symlink"
|
|
46
|
+
readonly size: number
|
|
47
|
+
readonly mtimeMs: number
|
|
48
|
+
readonly ctimeMs: number
|
|
49
|
+
readonly ino = 0
|
|
50
|
+
readonly uid = 0
|
|
51
|
+
readonly gid = 0
|
|
52
|
+
readonly dev = 0
|
|
53
|
+
readonly mode: number
|
|
54
|
+
constructor(stat: { type: "file" | "directory" | "symlink"; size: number; mtime: Date; mode?: number }) {
|
|
55
|
+
this.type = stat.type
|
|
56
|
+
this.size = stat.size
|
|
57
|
+
this.mtimeMs = stat.mtime.getTime()
|
|
58
|
+
this.ctimeMs = this.mtimeMs
|
|
59
|
+
this.mode =
|
|
60
|
+
stat.mode ?? (this.type === "directory" ? 16877 : this.type === "symlink" ? 40960 : 33188)
|
|
61
|
+
}
|
|
62
|
+
isFile() {
|
|
63
|
+
return this.type === "file"
|
|
64
|
+
}
|
|
65
|
+
isDirectory() {
|
|
66
|
+
return this.type === "directory"
|
|
67
|
+
}
|
|
68
|
+
isSymbolicLink() {
|
|
69
|
+
return this.type === "symlink"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface CodedError extends Error {
|
|
74
|
+
code: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ensure a thrown error carries a `.code` isomorphic-git can dispatch on. */
|
|
78
|
+
function fsError(path: string, cause: unknown): CodedError {
|
|
79
|
+
if (cause instanceof Error && "code" in cause && typeof (cause as CodedError).code === "string") {
|
|
80
|
+
return cause as CodedError
|
|
81
|
+
}
|
|
82
|
+
const err = new Error(cause instanceof Error ? cause.message : `ENOENT: ${path}`) as CodedError
|
|
83
|
+
err.code = "ENOENT"
|
|
84
|
+
return err
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build an isomorphic-git compatible fs (`{ promises: { … } }`) from a shell
|
|
89
|
+
* `FileSystem`. Mirrors `@cloudflare/shell`'s internal adapter so object reads
|
|
90
|
+
* behave exactly like `createGit`'s.
|
|
91
|
+
*/
|
|
92
|
+
export function createGitFs(fs: FileSystem): git.FsClient {
|
|
93
|
+
return {
|
|
94
|
+
promises: {
|
|
95
|
+
async readFile(path: string, options?: string | { encoding?: string }) {
|
|
96
|
+
const encoding = typeof options === "string" ? options : options?.encoding
|
|
97
|
+
try {
|
|
98
|
+
if (encoding === "utf8" || encoding === "utf-8") return await fs.readFile(path)
|
|
99
|
+
return await fs.readFileBytes(path)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw fsError(path, err)
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async writeFile(path: string, data: string | Uint8Array) {
|
|
105
|
+
const parent = path.replace(/\/[^/]+$/, "")
|
|
106
|
+
if (parent && parent !== "/" && parent !== path) {
|
|
107
|
+
try {
|
|
108
|
+
await fs.mkdir(parent, { recursive: true })
|
|
109
|
+
} catch {
|
|
110
|
+
// parent may already exist
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (typeof data === "string") await fs.writeFile(path, data)
|
|
114
|
+
else await fs.writeFileBytes(path, data)
|
|
115
|
+
},
|
|
116
|
+
async unlink(path: string) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.rm(path)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw fsError(path, err)
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async readdir(path: string) {
|
|
124
|
+
return fs.readdir(path)
|
|
125
|
+
},
|
|
126
|
+
async mkdir(path: string, mode?: { recursive?: boolean }) {
|
|
127
|
+
const recursive = typeof mode === "object" ? Boolean(mode.recursive) : false
|
|
128
|
+
await fs.mkdir(path, { recursive })
|
|
129
|
+
},
|
|
130
|
+
async rmdir(path: string) {
|
|
131
|
+
await fs.rm(path)
|
|
132
|
+
},
|
|
133
|
+
async stat(path: string) {
|
|
134
|
+
try {
|
|
135
|
+
return new GitStat(await fs.stat(path))
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw fsError(path, err)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
async lstat(path: string) {
|
|
141
|
+
try {
|
|
142
|
+
return new GitStat(await fs.lstat(path))
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw fsError(path, err)
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
async readlink(path: string) {
|
|
148
|
+
try {
|
|
149
|
+
return await fs.readlink(path)
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw fsError(path, err)
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
async symlink(target: string, path: string) {
|
|
155
|
+
await fs.symlink(target, path)
|
|
156
|
+
},
|
|
157
|
+
async chmod() {
|
|
158
|
+
// no-op: the Workspace FS does not track unix modes
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
} as git.FsClient
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Resolve a ref (branch, remote-tracking ref, or oid) to a commit oid, or null. */
|
|
165
|
+
export async function resolveHead(fs: FileSystem, ref: string): Promise<string | null> {
|
|
166
|
+
try {
|
|
167
|
+
return await git.resolveRef({ fs: createGitFs(fs), dir: "/", ref })
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Walk the tree of `commitOid`, returning every file as an absolute-path ->
|
|
175
|
+
* `BaseEntry` map. Does not read blob contents (fast, index-only); `size` is
|
|
176
|
+
* left undefined and computed lazily on demand.
|
|
177
|
+
*/
|
|
178
|
+
export async function walkTreeFiles(
|
|
179
|
+
fs: FileSystem,
|
|
180
|
+
commitOid: string,
|
|
181
|
+
): Promise<Map<string, BaseEntry>> {
|
|
182
|
+
const gitFs = createGitFs(fs)
|
|
183
|
+
const entries = (await git.walk({
|
|
184
|
+
fs: gitFs,
|
|
185
|
+
dir: "/",
|
|
186
|
+
trees: [git.TREE({ ref: commitOid })],
|
|
187
|
+
map: async (filepath, walkerEntries) => {
|
|
188
|
+
if (filepath === ".") return undefined
|
|
189
|
+
const entry = walkerEntries?.[0]
|
|
190
|
+
if (!entry) return undefined
|
|
191
|
+
if ((await entry.type()) !== "blob") return undefined
|
|
192
|
+
return {
|
|
193
|
+
path: "/" + filepath,
|
|
194
|
+
oid: await entry.oid(),
|
|
195
|
+
mode: await entry.mode(),
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
})) as Array<{ path: string; oid: string; mode: number }>
|
|
199
|
+
|
|
200
|
+
const map = new Map<string, BaseEntry>()
|
|
201
|
+
for (const e of entries) {
|
|
202
|
+
if (!e) continue
|
|
203
|
+
map.set(e.path, { oid: e.oid, mode: e.mode })
|
|
204
|
+
}
|
|
205
|
+
return map
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Read a blob's bytes by oid from the local object store. */
|
|
209
|
+
export async function readBlobBytes(fs: FileSystem, oid: string): Promise<Uint8Array> {
|
|
210
|
+
const { blob } = await git.readBlob({ fs: createGitFs(fs), dir: "/", oid })
|
|
211
|
+
return blob
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Point the local branch at the fetched head when it is unborn (the cold-start
|
|
216
|
+
* case). isomorphic-git's `fetch` only updates the remote-tracking ref, so
|
|
217
|
+
* without this the next commit after a restart would be a ROOT commit with no
|
|
218
|
+
* ancestry — and the mirrored `push(force)` would replace the remote branch,
|
|
219
|
+
* silently destroying all earlier history/restore points. An existing local
|
|
220
|
+
* branch is never clobbered: local commits must not be lost.
|
|
221
|
+
*/
|
|
222
|
+
async function reconcileLocalBranch(
|
|
223
|
+
fs: FileSystem,
|
|
224
|
+
branch: string,
|
|
225
|
+
head: string,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
if (await resolveHead(fs, `refs/heads/${branch}`)) return
|
|
228
|
+
await git.writeRef({
|
|
229
|
+
fs: createGitFs(fs),
|
|
230
|
+
dir: "/",
|
|
231
|
+
ref: `refs/heads/${branch}`,
|
|
232
|
+
value: head,
|
|
233
|
+
force: true,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Artifacts-backed `BaseSnapshotSource`: fetches the branch into the overlay's
|
|
239
|
+
* `.git` (via the injected `fetchBranch`, e.g. `ArtifactsSync.fetch`), then
|
|
240
|
+
* walks the fetched commit tree and serves blobs from the local object store.
|
|
241
|
+
*/
|
|
242
|
+
export function createArtifactsBaseSource(opts: {
|
|
243
|
+
overlay: FileSystem
|
|
244
|
+
branch: string
|
|
245
|
+
/** True when a remote repo is known to exist (e.g. a persisted remote URL). */
|
|
246
|
+
hasRemote: () => Promise<boolean>
|
|
247
|
+
/** Populate the overlay `.git` with the branch's objects. Returns success. */
|
|
248
|
+
fetchBranch: () => Promise<boolean>
|
|
249
|
+
}): BaseSnapshotSource {
|
|
250
|
+
const { overlay, branch, hasRemote, fetchBranch } = opts
|
|
251
|
+
return {
|
|
252
|
+
async loadSnapshot() {
|
|
253
|
+
// No remote repo yet — a legitimately empty base (fresh app).
|
|
254
|
+
if (!(await hasRemote())) return null
|
|
255
|
+
// The repo exists but the fetch failed — transient. Throw so the caller
|
|
256
|
+
// retries on a later access instead of caching an empty base (which
|
|
257
|
+
// would hide every previously pushed file for this DO's lifetime).
|
|
258
|
+
if (!(await fetchBranch())) {
|
|
259
|
+
throw new Error(`Artifacts base fetch failed for branch "${branch}"`)
|
|
260
|
+
}
|
|
261
|
+
const head =
|
|
262
|
+
(await resolveHead(overlay, `refs/remotes/artifacts/${branch}`)) ??
|
|
263
|
+
(await resolveHead(overlay, branch))
|
|
264
|
+
// Repo exists but this branch has no commits yet — legitimately empty.
|
|
265
|
+
if (!head) return null
|
|
266
|
+
await reconcileLocalBranch(overlay, branch, head)
|
|
267
|
+
return { head, files: await walkTreeFiles(overlay, head) }
|
|
268
|
+
},
|
|
269
|
+
readBlob(oid) {
|
|
270
|
+
return readBlobBytes(overlay, oid)
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Drop the git index so the next status walk re-reads file contents.
|
|
277
|
+
*
|
|
278
|
+
* Git decides a file is unchanged when its size and modification time still
|
|
279
|
+
* match the index, and only hashes it otherwise. On a real filesystem that is
|
|
280
|
+
* a safe shortcut; here it is not. A Space's files live in memory or SQLite,
|
|
281
|
+
* where an agent can rewrite a file to different content of the same length
|
|
282
|
+
* within the same millisecond — and git would then see nothing to commit. The
|
|
283
|
+
* work would be in the Space but absent from every version of it.
|
|
284
|
+
*
|
|
285
|
+
* The index is pure cache: deleting it costs one re-hash of the tree and
|
|
286
|
+
* cannot lose anything.
|
|
287
|
+
*/
|
|
288
|
+
async function discardIndexStatCache(fs: FileSystem): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
await fs.rm("/.git/index", { force: true })
|
|
291
|
+
} catch {
|
|
292
|
+
// No index yet, or the backend refused; the status walk still works.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Stage the whole working tree (like the shell's `git add .`, which also
|
|
298
|
+
* stages deletions via `statusMatrix`) while skipping reserved paths — the
|
|
299
|
+
* `.afs` bookkeeping file must never enter a commit, or it lands in the
|
|
300
|
+
* Artifacts base tree and pollutes every snapshot.
|
|
301
|
+
*/
|
|
302
|
+
export async function stageWorkdir(
|
|
303
|
+
fs: FileSystem,
|
|
304
|
+
skip: (absPath: string) => boolean,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
const gitFs = createGitFs(fs)
|
|
307
|
+
await discardIndexStatCache(fs)
|
|
308
|
+
const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" })
|
|
309
|
+
for (const [filepath, head, workdir, stage] of matrix) {
|
|
310
|
+
if (skip(`/${filepath}`)) continue
|
|
311
|
+
if (`${head}${workdir}${stage}` === "111") continue
|
|
312
|
+
if (workdir === 0) await git.remove({ fs: gitFs, dir: "/", filepath })
|
|
313
|
+
else await git.add({ fs: gitFs, dir: "/", filepath })
|
|
314
|
+
}
|
|
315
|
+
}
|