@springbrand/space 0.2.0-alpha.16 → 0.2.0-alpha.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/env.ts +1 -0
- package/src/index.ts +12 -0
- package/src/space/artifacts-fs.ts +7 -0
- package/src/space/deploy-engine.ts +50 -17
- package/src/space/durable-object.ts +93 -22
- package/src/space/file-content-cache.ts +194 -0
- package/src/space/fs-backend.ts +64 -133
- package/src/space/git-objects.ts +11 -24
- package/src/space/space-files.ts +1055 -0
- package/src/space/workspace-port.ts +17 -0
- package/src/space/workspace-site.ts +116 -0
|
@@ -28,6 +28,11 @@ export interface SpaceFileInfo {
|
|
|
28
28
|
createdAt: number;
|
|
29
29
|
updatedAt: number;
|
|
30
30
|
target?: string;
|
|
31
|
+
attachmentId?: string;
|
|
32
|
+
contentVersion?: string;
|
|
33
|
+
cdnUrl?: string;
|
|
34
|
+
origin?: "user_upload" | "https_import" | "action" | "agent" | "userspace";
|
|
35
|
+
understandingStatus?: "not_requested" | "pending" | "ready" | "failed" | "unsupported";
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -202,11 +207,13 @@ export interface SpaceWorkspacePort {
|
|
|
202
207
|
symlink(target: string, linkPath: string): Promise<void>;
|
|
203
208
|
readlink(path: string): Promise<string>;
|
|
204
209
|
glob(pattern: string): Promise<SpaceFileInfo[]>;
|
|
210
|
+
inspectFile(path: string): Promise<import("./space-files").SpaceFileInspection | null>;
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
export interface SpaceStreamWriteOptions {
|
|
208
214
|
contentLength?: number;
|
|
209
215
|
mediaType?: string;
|
|
216
|
+
origin?: import("./space-files").SpaceFileOrigin;
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
/**
|
|
@@ -217,6 +224,16 @@ export interface SpaceStreamWriteOptions {
|
|
|
217
224
|
* accident, and so the runtime's contract stays exactly the file surface.
|
|
218
225
|
*/
|
|
219
226
|
export interface SpaceControlPort {
|
|
227
|
+
registerExistingContents(
|
|
228
|
+
input: import("./space-files").RegisterExistingContentsInput,
|
|
229
|
+
): Promise<import("./space-files").SpaceFileInspection[] | null>;
|
|
230
|
+
setFileUnderstanding(
|
|
231
|
+
path: string,
|
|
232
|
+
contentVersion: string,
|
|
233
|
+
status: import("./space-files").SpaceUnderstandingStatus,
|
|
234
|
+
evidence?: import("./space-files").SpaceFileEvidence,
|
|
235
|
+
): Promise<import("./space-files").SpaceFileInspection>;
|
|
236
|
+
beginFileUnderstanding(path: string, contentVersion: string, refresh?: boolean): Promise<boolean>;
|
|
220
237
|
writeFileBytesIfUnchanged(
|
|
221
238
|
path: string,
|
|
222
239
|
data: Uint8Array,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const DEPLOYMENT_PATH = new RegExp(
|
|
2
|
+
"^/(spaces/v1/[^/%]+/deployments/[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:/(.*))?$",
|
|
3
|
+
"u",
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
type WorkspaceSiteBucket = Pick<R2Bucket, "get" | "head">
|
|
7
|
+
|
|
8
|
+
/** Serve immutable Workspace Site assets without routing static bytes through SpaceDO. */
|
|
9
|
+
export async function handleWorkspaceSiteRequest(
|
|
10
|
+
request: Request,
|
|
11
|
+
input: { origin: string; bucket: WorkspaceSiteBucket },
|
|
12
|
+
): Promise<Response | null> {
|
|
13
|
+
const url = new URL(request.url)
|
|
14
|
+
if (url.origin !== new URL(input.origin).origin || !url.pathname.startsWith("/spaces/v1/")) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
if (/%2f|%5c/iu.test(url.pathname)) return notFound()
|
|
18
|
+
|
|
19
|
+
let pathname: string
|
|
20
|
+
try {
|
|
21
|
+
pathname = decodeURIComponent(url.pathname)
|
|
22
|
+
} catch {
|
|
23
|
+
return notFound()
|
|
24
|
+
}
|
|
25
|
+
const match = DEPLOYMENT_PATH.exec(pathname)
|
|
26
|
+
if (!match) return null
|
|
27
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
28
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const assetRoot = match[1]!
|
|
32
|
+
const requestedPath = match[2] ?? ""
|
|
33
|
+
if (requestedPath.split("/").some((part) => part === "." || part === ".." || /[\p{Cc}]/u.test(part))) {
|
|
34
|
+
return notFound()
|
|
35
|
+
}
|
|
36
|
+
const candidates = assetCandidates(requestedPath)
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const key = `${assetRoot}/${candidate}`
|
|
39
|
+
const rangeHeader = request.method === "GET" ? request.headers.get("Range") : null
|
|
40
|
+
const rangeRequest = rangeHeader && isSingleByteRange(rangeHeader) ? rangeHeader : null
|
|
41
|
+
const object = request.method === "HEAD"
|
|
42
|
+
? await input.bucket.head(key)
|
|
43
|
+
: await input.bucket.get(key, rangeRequest
|
|
44
|
+
? { range: request.headers }
|
|
45
|
+
: undefined)
|
|
46
|
+
if (!object) continue
|
|
47
|
+
|
|
48
|
+
const headers = new Headers()
|
|
49
|
+
object.writeHttpMetadata(headers)
|
|
50
|
+
headers.set("ETag", object.httpEtag)
|
|
51
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable")
|
|
52
|
+
headers.set("Accept-Ranges", "bytes")
|
|
53
|
+
const range = rangeRequest ? rangeBounds(rangeRequest, object.size) : null
|
|
54
|
+
headers.set("Content-Length", String(range?.length ?? object.size))
|
|
55
|
+
if (range) {
|
|
56
|
+
headers.set(
|
|
57
|
+
"Content-Range",
|
|
58
|
+
`bytes ${range.offset}-${range.offset + range.length - 1}/${object.size}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
const response = new Response(
|
|
62
|
+
request.method === "HEAD" ? null : (object as R2ObjectBody).body,
|
|
63
|
+
{ status: range ? 206 : 200, headers },
|
|
64
|
+
)
|
|
65
|
+
return request.method === "GET" && headers.get("Content-Type")?.includes("text/html")
|
|
66
|
+
? rewriteHtml(response, `/${assetRoot}`)
|
|
67
|
+
: response
|
|
68
|
+
}
|
|
69
|
+
return notFound()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSingleByteRange(value: string): boolean {
|
|
73
|
+
return /^bytes=(?:\d+-\d*|-\d+)$/u.test(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rangeBounds(value: string, size: number): { offset: number; length: number } {
|
|
77
|
+
const [startText, endText] = value.slice("bytes=".length).split("-", 2)
|
|
78
|
+
if (!startText) {
|
|
79
|
+
const length = Math.min(Number(endText), size)
|
|
80
|
+
return { offset: size - length, length }
|
|
81
|
+
}
|
|
82
|
+
const offset = Number(startText)
|
|
83
|
+
const end = endText ? Math.min(Number(endText), size - 1) : size - 1
|
|
84
|
+
return { offset, length: end - offset + 1 }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assetCandidates(path: string): string[] {
|
|
88
|
+
if (!path) return ["index.html"]
|
|
89
|
+
if (path.endsWith("/")) return [`${path}index.html`]
|
|
90
|
+
return path.split("/").at(-1)?.includes(".") ? [path] : [path, `${path}.html`]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rewriteHtml(response: Response, siteBasePath: string): Response {
|
|
94
|
+
const headers = new Headers(response.headers)
|
|
95
|
+
headers.delete("Content-Length")
|
|
96
|
+
headers.delete("ETag")
|
|
97
|
+
return new HTMLRewriter()
|
|
98
|
+
.on("[src],[href],[action]", {
|
|
99
|
+
element(element) {
|
|
100
|
+
for (const attribute of ["src", "href", "action"] as const) {
|
|
101
|
+
const value = element.getAttribute(attribute)
|
|
102
|
+
if (value?.startsWith("/") && !value.startsWith("//")) {
|
|
103
|
+
element.setAttribute(attribute, siteBasePath + value)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
.transform(new Response(response.body, { status: response.status, headers }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function notFound(): Response {
|
|
112
|
+
return new Response("Not Found", {
|
|
113
|
+
status: 404,
|
|
114
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
115
|
+
})
|
|
116
|
+
}
|