@robodev-ai/tenant-host 0.0.0-stage → 0.1.0
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 +35 -4
- package/src/host.test.ts +258 -0
- package/src/host.ts +316 -0
- package/src/index.ts +38 -0
- package/src/request.ts +132 -0
- package/src/resolve.ts +107 -0
- package/src/response.ts +175 -0
- package/src/web-media.test.ts +137 -0
- package/src/web-well-known.test.ts +166 -0
- package/src/web.ts +129 -0
- package/README.md +0 -3
package/src/request.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request a tenant host serves, after the server has read and parsed the body. Each host
|
|
3
|
+
* builds one with its own body rules and limits (hosted Starbase and `robodev dev` both on
|
|
4
|
+
* `Bun.serve`); `fromFetchRequest` is the plain Fetch version.
|
|
5
|
+
*/
|
|
6
|
+
export type TenantHeaders = Record<string, string | string[] | undefined>;
|
|
7
|
+
|
|
8
|
+
export type TenantRequest = {
|
|
9
|
+
method: string;
|
|
10
|
+
/** Path and query as received, e.g. `/api/notes?limit=5`. */
|
|
11
|
+
url: string;
|
|
12
|
+
/** Decoded path without a trailing slash (`/` for the root). */
|
|
13
|
+
pathname: string;
|
|
14
|
+
/** The raw query string including `?`, or "". */
|
|
15
|
+
search: string;
|
|
16
|
+
/** Repeated keys become arrays, like Fastify's default query parser. */
|
|
17
|
+
query: Record<string, string | string[]>;
|
|
18
|
+
headers: TenantHeaders;
|
|
19
|
+
/** `Host` as the client sent it (may include a port). */
|
|
20
|
+
host: string;
|
|
21
|
+
/** Parsed by content type: JSON, a form as an object, or the bytes for binary and multipart. */
|
|
22
|
+
body: unknown;
|
|
23
|
+
/** The untouched request bytes, for `rawBody` handlers and multipart parsing. */
|
|
24
|
+
rawBody?: Buffer;
|
|
25
|
+
ip: string;
|
|
26
|
+
protocol: string;
|
|
27
|
+
/** The server's request id, for logs. */
|
|
28
|
+
id: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A malformed request body. Hosts answer it with `status` and `{ error: code, message }`. */
|
|
32
|
+
export class TenantRequestError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly statusCode: number,
|
|
35
|
+
readonly code: string,
|
|
36
|
+
message: string,
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeDecode(value: string): string {
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(value);
|
|
45
|
+
} catch {
|
|
46
|
+
// Malformed escapes: classify on the raw path; the route decides whether that is a 400.
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `/a/b/?x` becomes `/a/b`. Backslashes count as slashes, like the hosted project host. */
|
|
52
|
+
export function tenantPathname(url: string): string {
|
|
53
|
+
const raw = safeDecode(url.split("?")[0] ?? "/").replace(/\\/g, "/");
|
|
54
|
+
return raw.replace(/\/$/, "") || "/";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseQuery(search: string): Record<string, string | string[]> {
|
|
58
|
+
const query: Record<string, string | string[]> = {};
|
|
59
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
60
|
+
const existing = query[key];
|
|
61
|
+
if (existing === undefined) query[key] = value;
|
|
62
|
+
else if (Array.isArray(existing)) existing.push(value);
|
|
63
|
+
else query[key] = [existing, value];
|
|
64
|
+
}
|
|
65
|
+
return query;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mediaType(headers: TenantHeaders): string {
|
|
69
|
+
const raw = headers["content-type"];
|
|
70
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
71
|
+
return (value ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The body as the hosts have always parsed it: JSON (empty is `{}`), urlencoded forms as a flat
|
|
76
|
+
* object, and binary or multipart as the bytes. Any other type keeps the bytes too.
|
|
77
|
+
*/
|
|
78
|
+
export function parseTenantBody(raw: Buffer, headers: TenantHeaders): unknown {
|
|
79
|
+
const type = mediaType(headers);
|
|
80
|
+
if (type === "application/json") {
|
|
81
|
+
const text = raw.toString("utf8");
|
|
82
|
+
if (!text) return {};
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new TenantRequestError(400, "invalid_json", "Request body is not valid JSON");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (type === "application/x-www-form-urlencoded") {
|
|
90
|
+
return Object.fromEntries(new URLSearchParams(raw.toString("utf8")).entries());
|
|
91
|
+
}
|
|
92
|
+
return raw.length > 0 ? raw : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function headerRecord(headers: Headers): TenantHeaders {
|
|
96
|
+
const out: TenantHeaders = {};
|
|
97
|
+
for (const [name, value] of headers) out[name] = value;
|
|
98
|
+
const cookies = headers.getSetCookie?.() ?? [];
|
|
99
|
+
if (cookies.length > 1) out["set-cookie"] = cookies;
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reads a Fetch `Request` into a `TenantRequest`. The body is read once, here. */
|
|
104
|
+
export async function fromFetchRequest(
|
|
105
|
+
request: Request,
|
|
106
|
+
info: { ip: string; id: string },
|
|
107
|
+
): Promise<TenantRequest> {
|
|
108
|
+
const url = new URL(request.url);
|
|
109
|
+
const headers = headerRecord(request.headers);
|
|
110
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD" && request.body !== null;
|
|
111
|
+
const rawBody = hasBody ? Buffer.from(await request.arrayBuffer()) : undefined;
|
|
112
|
+
return {
|
|
113
|
+
method: request.method.toUpperCase(),
|
|
114
|
+
url: `${url.pathname}${url.search}`,
|
|
115
|
+
pathname: tenantPathname(url.pathname),
|
|
116
|
+
search: url.search,
|
|
117
|
+
query: parseQuery(url.search),
|
|
118
|
+
headers,
|
|
119
|
+
host: String(headers.host ?? url.host),
|
|
120
|
+
body: rawBody && rawBody.length > 0 ? parseTenantBody(rawBody, headers) : undefined,
|
|
121
|
+
rawBody,
|
|
122
|
+
ip: info.ip,
|
|
123
|
+
protocol: url.protocol.replace(/:$/, ""),
|
|
124
|
+
id: info.id,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The first value of a header, or undefined. */
|
|
129
|
+
export function headerValue(headers: TenantHeaders, name: string): string | undefined {
|
|
130
|
+
const value = headers[name];
|
|
131
|
+
return Array.isArray(value) ? value[0] : value;
|
|
132
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENTERPRISE_SPEC_PATH,
|
|
3
|
+
isProjectMcpRequest,
|
|
4
|
+
isProjectOAuthRequestPath,
|
|
5
|
+
isReservedAuthPath,
|
|
6
|
+
isReservedRobodevPath,
|
|
7
|
+
isReservedStoragePath,
|
|
8
|
+
isReservedStorageUploadPath,
|
|
9
|
+
matchProjectRoute,
|
|
10
|
+
} from "@robodev-ai/runtime";
|
|
11
|
+
import type { TenantHeaders } from "./request.js";
|
|
12
|
+
|
|
13
|
+
export type ResourceKind =
|
|
14
|
+
| "reserved"
|
|
15
|
+
| "mcp"
|
|
16
|
+
| "oauth"
|
|
17
|
+
| "api"
|
|
18
|
+
| "swagger"
|
|
19
|
+
| "scalar"
|
|
20
|
+
| "asset"
|
|
21
|
+
| "static"
|
|
22
|
+
| "spa"
|
|
23
|
+
| "not_found";
|
|
24
|
+
|
|
25
|
+
/** Which reserved endpoint a `reserved` resource is. */
|
|
26
|
+
export type ReservedKind = "auth" | "storage" | "ready" | "openapi" | "enterprise_spec" | "other";
|
|
27
|
+
|
|
28
|
+
export type ResolveResult = {
|
|
29
|
+
kind: ResourceKind;
|
|
30
|
+
rel?: string;
|
|
31
|
+
reserved?: ReservedKind;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type FrontendKind = "none" | "static" | "react" | (string & {});
|
|
35
|
+
|
|
36
|
+
export type ResolveRuntime = {
|
|
37
|
+
routes: { method: string; path: string }[];
|
|
38
|
+
frontendKind: FrontendKind;
|
|
39
|
+
webHas: (relPath: string) => boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Apple and Google fetch these by exact path; the SPA's HTML must never answer them. */
|
|
43
|
+
export function isWellKnownLookupPath(path: string): boolean {
|
|
44
|
+
return path.startsWith("/.well-known/") || path === "/apple-app-site-association";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a project path serves, in priority order: Robodev's reserved endpoints, the MCP endpoint
|
|
49
|
+
* and its OAuth server, docs, hashed assets, the project's routes, then web files and the SPA.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveProjectResource(
|
|
52
|
+
pathname: string,
|
|
53
|
+
method: string,
|
|
54
|
+
runtime: ResolveRuntime,
|
|
55
|
+
headers: TenantHeaders = {},
|
|
56
|
+
): ResolveResult {
|
|
57
|
+
const verb = method.toLowerCase();
|
|
58
|
+
const path = pathname.replace(/\/$/, "") || "/";
|
|
59
|
+
|
|
60
|
+
if (isReservedAuthPath(path)) return { kind: "reserved", reserved: "auth" };
|
|
61
|
+
if (isReservedStoragePath(path) || isReservedStorageUploadPath(path)) {
|
|
62
|
+
return { kind: "reserved", reserved: "storage" };
|
|
63
|
+
}
|
|
64
|
+
if (path === "/_robodev/ready") return { kind: "reserved", reserved: "ready" };
|
|
65
|
+
if (path === "/openapi.json") return { kind: "reserved", reserved: "openapi" };
|
|
66
|
+
if (path === ENTERPRISE_SPEC_PATH) return { kind: "reserved", reserved: "enterprise_spec" };
|
|
67
|
+
// The project's MCP endpoint and its OAuth server win over routes and static files. A
|
|
68
|
+
// browser opening `/mcp` as a page falls through, so a tenant SPA route there still loads.
|
|
69
|
+
if (isProjectMcpRequest(path, method, headers)) return { kind: "mcp" };
|
|
70
|
+
if (isProjectOAuthRequestPath(path)) return { kind: "oauth" };
|
|
71
|
+
if (path === "/_robodev/docs" || path === "/_robodev/swagger") {
|
|
72
|
+
return { kind: "swagger" };
|
|
73
|
+
}
|
|
74
|
+
if (path === "/_robodev/scalar") {
|
|
75
|
+
return { kind: "scalar" };
|
|
76
|
+
}
|
|
77
|
+
if (path === "/docs" || path === "/swagger") {
|
|
78
|
+
if (runtime.frontendKind === "none") return { kind: "swagger" };
|
|
79
|
+
}
|
|
80
|
+
if (path === "/scalar") {
|
|
81
|
+
if (runtime.frontendKind === "none") return { kind: "scalar" };
|
|
82
|
+
}
|
|
83
|
+
if (path === "/_robodev/assets" || path.startsWith("/_robodev/assets/")) {
|
|
84
|
+
const rel = path === "/_robodev/assets" ? "" : path.slice("/_robodev/assets/".length);
|
|
85
|
+
return { kind: "asset", rel };
|
|
86
|
+
}
|
|
87
|
+
if (isReservedRobodevPath(path)) {
|
|
88
|
+
return { kind: "reserved", reserved: "other" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const matched = matchProjectRoute(path, verb, runtime.routes);
|
|
92
|
+
if (matched) return { kind: "api" };
|
|
93
|
+
|
|
94
|
+
const staticRel = path === "/" ? "index.html" : path.slice(1);
|
|
95
|
+
if (runtime.webHas(staticRel)) return { kind: "static", rel: staticRel };
|
|
96
|
+
if (isWellKnownLookupPath(path)) return { kind: "not_found" };
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
runtime.frontendKind !== "none" &&
|
|
100
|
+
(verb === "get" || verb === "head") &&
|
|
101
|
+
runtime.webHas("index.html")
|
|
102
|
+
) {
|
|
103
|
+
return { kind: "spa", rel: "index.html" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { kind: "not_found" };
|
|
107
|
+
}
|
package/src/response.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Responses whose body is a stream (storage objects, files off Bun). Every other response is
|
|
5
|
+
* small and sent as one buffer.
|
|
6
|
+
*/
|
|
7
|
+
const streamed = new WeakSet<Response>();
|
|
8
|
+
|
|
9
|
+
export function isStreamedResponse(response: Response): boolean {
|
|
10
|
+
return streamed.has(response);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The body each buffered response was built from. A server adapter sends these bytes as they
|
|
15
|
+
* are instead of reading the body stream back, which on Node (undici) costs more than the
|
|
16
|
+
* request itself. The body stream stays readable for anyone else.
|
|
17
|
+
*/
|
|
18
|
+
const builtBodies = new WeakMap<Response, string | Uint8Array>();
|
|
19
|
+
|
|
20
|
+
export function builtBodyOf(response: Response): string | Uint8Array | undefined {
|
|
21
|
+
return response.bodyUsed ? undefined : builtBodies.get(response);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Body = string | Buffer | Uint8Array | null;
|
|
25
|
+
|
|
26
|
+
function isNodeReadable(value: unknown): value is Readable {
|
|
27
|
+
return value instanceof Readable;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How a value goes on the wire, matching Fastify's `reply.send`: strings as text, bytes as
|
|
32
|
+
* bytes, streams piped, `undefined` as no body, anything else as JSON.
|
|
33
|
+
*/
|
|
34
|
+
export function encodeBody(value: unknown): {
|
|
35
|
+
body: Body | ReadableStream<Uint8Array>;
|
|
36
|
+
defaultType: string | null;
|
|
37
|
+
} {
|
|
38
|
+
if (value === undefined) return { body: null, defaultType: null };
|
|
39
|
+
if (typeof value === "string") return { body: value, defaultType: "text/plain; charset=utf-8" };
|
|
40
|
+
if (value instanceof Uint8Array) return { body: value, defaultType: "application/octet-stream" };
|
|
41
|
+
if (value instanceof ReadableStream) {
|
|
42
|
+
return { body: value as ReadableStream<Uint8Array>, defaultType: "application/octet-stream" };
|
|
43
|
+
}
|
|
44
|
+
if (isNodeReadable(value)) {
|
|
45
|
+
return {
|
|
46
|
+
body: Readable.toWeb(value) as unknown as ReadableStream<Uint8Array>,
|
|
47
|
+
defaultType: "application/octet-stream",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { body: JSON.stringify(value), defaultType: "application/json; charset=utf-8" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Builds a `Response`, recording whether its body streams. */
|
|
54
|
+
export function makeResponse(
|
|
55
|
+
status: number,
|
|
56
|
+
headers: Headers,
|
|
57
|
+
value: unknown,
|
|
58
|
+
method = "GET",
|
|
59
|
+
): Response {
|
|
60
|
+
const { body, defaultType } = encodeBody(value);
|
|
61
|
+
if (defaultType && !headers.has("content-type")) headers.set("content-type", defaultType);
|
|
62
|
+
const noBody = status === 204 || status === 304 || method === "HEAD";
|
|
63
|
+
if (status === 204) {
|
|
64
|
+
// A 204 has no content, so it names no type or length (RFC 9110 §8.6).
|
|
65
|
+
headers.delete("content-type");
|
|
66
|
+
headers.delete("content-length");
|
|
67
|
+
}
|
|
68
|
+
if (body instanceof ReadableStream) {
|
|
69
|
+
if (noBody) {
|
|
70
|
+
void body.cancel().catch(() => {});
|
|
71
|
+
return new Response(null, { status, headers });
|
|
72
|
+
}
|
|
73
|
+
const response = new Response(body, { status, headers });
|
|
74
|
+
streamed.add(response);
|
|
75
|
+
return response;
|
|
76
|
+
}
|
|
77
|
+
if (body !== null && !headers.has("content-length")) {
|
|
78
|
+
const length = typeof body === "string" ? Buffer.byteLength(body) : body.byteLength;
|
|
79
|
+
headers.set("content-length", String(length));
|
|
80
|
+
}
|
|
81
|
+
const response = new Response(noBody ? null : (body as BodyInit | null), { status, headers });
|
|
82
|
+
if (!noBody && body !== null) builtBodies.set(response, body);
|
|
83
|
+
return response;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A JSON response. */
|
|
87
|
+
export function json(body: unknown, status = 200, headers?: Record<string, string>): Response {
|
|
88
|
+
return makeResponse(status, new Headers(headers), body ?? null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A reply builder with the slice of Fastify's `reply` API the tenant handlers use, so a
|
|
93
|
+
* handler reads the same as it did on Fastify and returns a `Response` instead of writing one.
|
|
94
|
+
*/
|
|
95
|
+
export class TenantReply {
|
|
96
|
+
statusCode = 200;
|
|
97
|
+
readonly headers = new Headers();
|
|
98
|
+
private result: Response | null = null;
|
|
99
|
+
|
|
100
|
+
constructor(private readonly method = "GET") {}
|
|
101
|
+
|
|
102
|
+
code(status: number): this {
|
|
103
|
+
this.statusCode = status;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
status(status: number): this {
|
|
108
|
+
return this.code(status);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `Set-Cookie` accumulates, like Fastify; any other header is replaced. */
|
|
112
|
+
header(name: string, value: string | number | string[]): this {
|
|
113
|
+
const values = Array.isArray(value) ? value : [String(value)];
|
|
114
|
+
if (name.toLowerCase() === "set-cookie") {
|
|
115
|
+
for (const item of values) this.headers.append(name, item);
|
|
116
|
+
} else {
|
|
117
|
+
this.headers.set(name, values.join(", "));
|
|
118
|
+
}
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type(contentType: string): this {
|
|
123
|
+
return this.header("content-type", contentType);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getHeader(name: string): string | null {
|
|
127
|
+
return this.headers.get(name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get sent(): boolean {
|
|
131
|
+
return this.result !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
send(value?: unknown): this {
|
|
135
|
+
if (this.result) throw new Error("TenantReply already sent");
|
|
136
|
+
this.result = makeResponse(this.statusCode, this.headers, value, this.method);
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fastify's rule: an explicit code wins, then a non-200 code set earlier, then 302. */
|
|
141
|
+
redirect(location: string, code?: number): this {
|
|
142
|
+
const status = code ?? (this.statusCode === 200 ? 302 : this.statusCode);
|
|
143
|
+
this.code(status).header("location", location);
|
|
144
|
+
return this.send();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The finished response. A handler that never sent answers 204, like an empty Fastify reply. */
|
|
148
|
+
response(): Response {
|
|
149
|
+
return (
|
|
150
|
+
this.result ??
|
|
151
|
+
makeResponse(this.statusCode === 200 ? 204 : this.statusCode, this.headers, undefined)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A runtime-shaped answer (`{ status, headers, body }`) as a `Response`. */
|
|
157
|
+
export function runtimeResponse(input: {
|
|
158
|
+
status: number;
|
|
159
|
+
headers?: Record<string, string>;
|
|
160
|
+
body?: unknown;
|
|
161
|
+
}): Response {
|
|
162
|
+
return makeResponse(input.status, new Headers(input.headers), input.body);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A handler's encoded result (status, headers, content type, payload) as a `Response`. */
|
|
166
|
+
export function encodedResponse(encoded: {
|
|
167
|
+
status: number;
|
|
168
|
+
headers: Record<string, string>;
|
|
169
|
+
contentType: string;
|
|
170
|
+
payload: unknown;
|
|
171
|
+
}): Response {
|
|
172
|
+
const headers = new Headers(encoded.headers);
|
|
173
|
+
headers.set("content-type", encoded.contentType);
|
|
174
|
+
return makeResponse(encoded.status, headers, encoded.payload);
|
|
175
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, test } from "node:test";
|
|
6
|
+
import type { ResourceKind } from "./resolve.js";
|
|
7
|
+
import { webFileHeaders, webFileResponse } from "./web.js";
|
|
8
|
+
|
|
9
|
+
/** A Fetch server on a free loopback port, and requests against it in an inject-like shape. */
|
|
10
|
+
function serve(handler: (request: Request) => Promise<Response>) {
|
|
11
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler });
|
|
12
|
+
return {
|
|
13
|
+
async inject(input: { method: string; url: string; headers?: Record<string, string> }) {
|
|
14
|
+
const response = await fetch(new URL(input.url, server.url), {
|
|
15
|
+
method: input.method,
|
|
16
|
+
headers: input.headers,
|
|
17
|
+
});
|
|
18
|
+
const rawPayload = Buffer.from(await response.arrayBuffer());
|
|
19
|
+
return {
|
|
20
|
+
statusCode: response.status,
|
|
21
|
+
headers: Object.fromEntries(response.headers) as Record<string, string | undefined>,
|
|
22
|
+
rawPayload,
|
|
23
|
+
body: rawPayload.toString("utf8"),
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
close: () => server.stop(true),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `rel` from `root` as the tenant core serves it, or null when the file is absent. */
|
|
31
|
+
async function serveFile(
|
|
32
|
+
root: string,
|
|
33
|
+
rel: string,
|
|
34
|
+
kind: ResourceKind,
|
|
35
|
+
request: Request,
|
|
36
|
+
): Promise<Response | null> {
|
|
37
|
+
const sent = await webFileResponse(
|
|
38
|
+
root,
|
|
39
|
+
rel,
|
|
40
|
+
kind,
|
|
41
|
+
{ method: request.method, range: request.headers.get("range") ?? undefined },
|
|
42
|
+
"frame-ancestors 'self'",
|
|
43
|
+
);
|
|
44
|
+
return sent?.response ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
|
|
48
|
+
|
|
49
|
+
let root = "";
|
|
50
|
+
let server: ReturnType<typeof serve>;
|
|
51
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg">${"<path d='M0 0'/>".repeat(200_000)}</svg>`;
|
|
52
|
+
const video = Buffer.from(Array.from({ length: 64 * 1024 }, (_, i) => i % 251));
|
|
53
|
+
|
|
54
|
+
before(async () => {
|
|
55
|
+
root = await mkdtemp(join(tmpdir(), "rd-host-media-"));
|
|
56
|
+
await writeFile(join(root, "index.html"), "<html></html>");
|
|
57
|
+
await writeFile(join(root, "clip.mp4"), video);
|
|
58
|
+
await writeFile(join(root, "big.svg"), svg);
|
|
59
|
+
server = serve(async (request) => {
|
|
60
|
+
const rel = new URL(request.url).pathname.slice(1) || "index.html";
|
|
61
|
+
return (await serveFile(root, rel, "static", request)) ?? notFound();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
after(async () => {
|
|
66
|
+
await server.close();
|
|
67
|
+
await rm(root, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("project host: media MIME types", () => {
|
|
71
|
+
const types: Record<string, string> = {
|
|
72
|
+
mp4: "video/mp4",
|
|
73
|
+
webm: "video/webm",
|
|
74
|
+
mov: "video/quicktime",
|
|
75
|
+
pdf: "application/pdf",
|
|
76
|
+
mp3: "audio/mpeg",
|
|
77
|
+
wav: "audio/wav",
|
|
78
|
+
ogg: "audio/ogg",
|
|
79
|
+
};
|
|
80
|
+
for (const [ext, type] of Object.entries(types)) {
|
|
81
|
+
assert.equal(webFileHeaders(`media/a.${ext}`, "static").type, type, ext);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("project host: full GET streams the file with Accept-Ranges and Content-Length", async () => {
|
|
86
|
+
const response = await server.inject({ method: "GET", url: "/clip.mp4" });
|
|
87
|
+
assert.equal(response.statusCode, 200);
|
|
88
|
+
assert.equal(response.headers["accept-ranges"], "bytes");
|
|
89
|
+
assert.equal(response.headers["content-length"], String(video.length));
|
|
90
|
+
assert.equal(response.headers["content-type"], "video/mp4");
|
|
91
|
+
assert.deepEqual(response.rawPayload, video);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("project host: Range gives 206 with Content-Range; out of range is 416", async () => {
|
|
95
|
+
const part = await server.inject({
|
|
96
|
+
method: "GET",
|
|
97
|
+
url: "/clip.mp4",
|
|
98
|
+
headers: { range: "bytes=0-1023" },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(part.statusCode, 206);
|
|
101
|
+
assert.equal(part.headers["content-range"], `bytes 0-1023/${video.length}`);
|
|
102
|
+
assert.equal(part.headers["content-length"], "1024");
|
|
103
|
+
assert.equal(part.headers["content-type"], "video/mp4");
|
|
104
|
+
assert.equal(part.headers["accept-ranges"], "bytes");
|
|
105
|
+
assert.deepEqual(part.rawPayload, video.subarray(0, 1024));
|
|
106
|
+
|
|
107
|
+
const open = await server.inject({
|
|
108
|
+
method: "GET",
|
|
109
|
+
url: "/clip.mp4",
|
|
110
|
+
headers: { range: "bytes=65000-" },
|
|
111
|
+
});
|
|
112
|
+
assert.equal(open.statusCode, 206);
|
|
113
|
+
assert.deepEqual(open.rawPayload, video.subarray(65000));
|
|
114
|
+
|
|
115
|
+
const bad = await server.inject({
|
|
116
|
+
method: "GET",
|
|
117
|
+
url: "/clip.mp4",
|
|
118
|
+
headers: { range: `bytes=${video.length}-` },
|
|
119
|
+
});
|
|
120
|
+
assert.equal(bad.statusCode, 416);
|
|
121
|
+
assert.equal(bad.headers["content-range"], `bytes */${video.length}`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("project host: HEAD sends headers only", async () => {
|
|
125
|
+
const head = await server.inject({ method: "HEAD", url: "/clip.mp4" });
|
|
126
|
+
assert.equal(head.statusCode, 200);
|
|
127
|
+
assert.equal(head.headers["content-length"], String(video.length));
|
|
128
|
+
assert.equal(head.rawPayload.length, 0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("project host: a 3MB SVG (materialized from a blob) is served as image/svg+xml", async () => {
|
|
132
|
+
const response = await server.inject({ method: "GET", url: "/big.svg" });
|
|
133
|
+
assert.equal(response.statusCode, 200);
|
|
134
|
+
assert.equal(response.headers["content-type"], "image/svg+xml");
|
|
135
|
+
assert.equal(response.headers["content-length"], String(Buffer.byteLength(svg)));
|
|
136
|
+
assert.equal(response.body, svg);
|
|
137
|
+
});
|