@springbrand/space 0.2.0-alpha.15 → 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.
@@ -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
+ }