@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { resolveProjectResource, type ResolveRuntime, type ResourceKind } from "./resolve.js";
|
|
8
|
+
import { webFileHeaders, webFileResponse } from "./web.js";
|
|
9
|
+
|
|
10
|
+
/** A Fetch server on a free loopback port, and requests against it in an inject-like shape. */
|
|
11
|
+
function serve(handler: (request: Request) => Promise<Response>) {
|
|
12
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler });
|
|
13
|
+
return {
|
|
14
|
+
async inject(input: { method: string; url: string; headers?: Record<string, string> }) {
|
|
15
|
+
const response = await fetch(new URL(input.url, server.url), {
|
|
16
|
+
method: input.method,
|
|
17
|
+
headers: input.headers,
|
|
18
|
+
});
|
|
19
|
+
const rawPayload = Buffer.from(await response.arrayBuffer());
|
|
20
|
+
return {
|
|
21
|
+
statusCode: response.status,
|
|
22
|
+
headers: Object.fromEntries(response.headers) as Record<string, string | undefined>,
|
|
23
|
+
rawPayload,
|
|
24
|
+
body: rawPayload.toString("utf8"),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
close: () => server.stop(true),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `rel` from `root` as the tenant core serves it, or null when the file is absent. */
|
|
32
|
+
async function serveFile(
|
|
33
|
+
root: string,
|
|
34
|
+
rel: string,
|
|
35
|
+
kind: ResourceKind,
|
|
36
|
+
request: Request,
|
|
37
|
+
): Promise<Response | null> {
|
|
38
|
+
const sent = await webFileResponse(
|
|
39
|
+
root,
|
|
40
|
+
rel,
|
|
41
|
+
kind,
|
|
42
|
+
{ method: request.method, range: request.headers.get("range") ?? undefined },
|
|
43
|
+
"frame-ancestors 'self'",
|
|
44
|
+
);
|
|
45
|
+
return sent?.response ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
|
|
49
|
+
|
|
50
|
+
const AASA =
|
|
51
|
+
'{"applinks":{"details":[{"appIDs":["7G58AP3AJX.com.povio.graize"],"components":[{"/":"/tip/*"}]}]}}';
|
|
52
|
+
|
|
53
|
+
function spaRuntime(has: (rel: string) => boolean): ResolveRuntime {
|
|
54
|
+
return { routes: [], frontendKind: "react", webHas: has };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Same static / SPA / 404 branches as the tenant core, over a temp web root. */
|
|
58
|
+
function hostOver(root: string) {
|
|
59
|
+
return serve(async (request) => {
|
|
60
|
+
const resolved = resolveProjectResource(
|
|
61
|
+
new URL(request.url).pathname,
|
|
62
|
+
request.method,
|
|
63
|
+
spaRuntime((rel) => existsSync(join(root, rel))),
|
|
64
|
+
);
|
|
65
|
+
if (resolved.kind === "static" || resolved.kind === "spa") {
|
|
66
|
+
const sent = await serveFile(root, resolved.rel ?? "index.html", resolved.kind, request);
|
|
67
|
+
if (sent) return sent;
|
|
68
|
+
}
|
|
69
|
+
return notFound();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("app association paths resolve to static files, never the SPA fallback", () => {
|
|
74
|
+
const withFiles = spaRuntime((rel) =>
|
|
75
|
+
[
|
|
76
|
+
"index.html",
|
|
77
|
+
".well-known/apple-app-site-association",
|
|
78
|
+
".well-known/assetlinks.json",
|
|
79
|
+
"apple-app-site-association",
|
|
80
|
+
].includes(rel),
|
|
81
|
+
);
|
|
82
|
+
for (const [pathname, rel] of [
|
|
83
|
+
["/.well-known/apple-app-site-association", ".well-known/apple-app-site-association"],
|
|
84
|
+
["/.well-known/apple-app-site-association/", ".well-known/apple-app-site-association"],
|
|
85
|
+
["/apple-app-site-association", "apple-app-site-association"],
|
|
86
|
+
["/.well-known/assetlinks.json", ".well-known/assetlinks.json"],
|
|
87
|
+
] as const) {
|
|
88
|
+
assert.deepEqual(resolveProjectResource(pathname, "GET", withFiles), { kind: "static", rel });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const spaOnly = spaRuntime((rel) => rel === "index.html");
|
|
92
|
+
for (const pathname of [
|
|
93
|
+
"/.well-known/apple-app-site-association",
|
|
94
|
+
"/apple-app-site-association",
|
|
95
|
+
"/.well-known/assetlinks.json",
|
|
96
|
+
"/.well-known/anything",
|
|
97
|
+
]) {
|
|
98
|
+
assert.equal(resolveProjectResource(pathname, "GET", spaOnly).kind, "not_found", pathname);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(resolveProjectResource("/tip/abc", "GET", spaOnly).kind, "spa");
|
|
101
|
+
// The project's MCP OAuth discovery still wins over static files.
|
|
102
|
+
assert.equal(
|
|
103
|
+
resolveProjectResource("/.well-known/oauth-protected-resource", "GET", spaOnly).kind,
|
|
104
|
+
"oauth",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("app association files are JSON with a one-hour cache", () => {
|
|
109
|
+
for (const rel of [
|
|
110
|
+
".well-known/apple-app-site-association",
|
|
111
|
+
"apple-app-site-association",
|
|
112
|
+
".well-known/assetlinks.json",
|
|
113
|
+
]) {
|
|
114
|
+
assert.deepEqual(webFileHeaders(rel, "static"), {
|
|
115
|
+
type: "application/json",
|
|
116
|
+
cache: "public, max-age=3600",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
assert.equal(
|
|
120
|
+
webFileHeaders("assets/apple-app-site-association", "static").type,
|
|
121
|
+
"application/octet-stream",
|
|
122
|
+
);
|
|
123
|
+
assert.equal(webFileHeaders("index.html", "spa").cache, "no-store");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("serves apple-app-site-association as 200 JSON without a redirect, 404 when absent", async () => {
|
|
127
|
+
const root = await mkdtemp(join(tmpdir(), "rd-web-"));
|
|
128
|
+
const server = hostOver(root);
|
|
129
|
+
try {
|
|
130
|
+
await writeFile(join(root, "index.html"), "<html>spa</html>");
|
|
131
|
+
|
|
132
|
+
const missing = await server.inject({
|
|
133
|
+
method: "GET",
|
|
134
|
+
url: "/.well-known/apple-app-site-association",
|
|
135
|
+
});
|
|
136
|
+
assert.equal(missing.statusCode, 404);
|
|
137
|
+
assert.match(String(missing.headers["content-type"]), /^application\/json/);
|
|
138
|
+
assert.doesNotMatch(missing.body, /<html/);
|
|
139
|
+
|
|
140
|
+
await mkdir(join(root, ".well-known"), { recursive: true });
|
|
141
|
+
await writeFile(join(root, ".well-known/apple-app-site-association"), AASA);
|
|
142
|
+
await writeFile(join(root, ".well-known/assetlinks.json"), "[]");
|
|
143
|
+
await writeFile(join(root, "apple-app-site-association"), AASA);
|
|
144
|
+
|
|
145
|
+
for (const url of [
|
|
146
|
+
"/.well-known/apple-app-site-association",
|
|
147
|
+
"/.well-known/apple-app-site-association/",
|
|
148
|
+
"/apple-app-site-association",
|
|
149
|
+
]) {
|
|
150
|
+
const response = await server.inject({ method: "GET", url });
|
|
151
|
+
assert.equal(response.statusCode, 200, url);
|
|
152
|
+
assert.equal(response.headers["content-type"], "application/json", url);
|
|
153
|
+
assert.equal(response.headers["cache-control"], "public, max-age=3600", url);
|
|
154
|
+
assert.equal(response.headers.location, undefined, url);
|
|
155
|
+
assert.equal(response.body, AASA, url);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const assetlinks = await server.inject({ method: "GET", url: "/.well-known/assetlinks.json" });
|
|
159
|
+
assert.equal(assetlinks.statusCode, 200);
|
|
160
|
+
assert.equal(assetlinks.headers["content-type"], "application/json");
|
|
161
|
+
assert.equal(assetlinks.body, "[]");
|
|
162
|
+
} finally {
|
|
163
|
+
await server.close();
|
|
164
|
+
await rm(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { extname, relative, resolve } from "node:path";
|
|
4
|
+
import { parseByteRange } from "@robodev-ai/runtime";
|
|
5
|
+
import { makeResponse } from "./response.js";
|
|
6
|
+
import type { ResourceKind } from "./resolve.js";
|
|
7
|
+
|
|
8
|
+
/** App-association files fetched by exact path: iOS Universal Links and Android App Links. */
|
|
9
|
+
const APP_ASSOCIATION_RELS = new Set([
|
|
10
|
+
".well-known/apple-app-site-association",
|
|
11
|
+
"apple-app-site-association",
|
|
12
|
+
".well-known/assetlinks.json",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const MIME: Record<string, string> = {
|
|
16
|
+
".html": "text/html; charset=utf-8",
|
|
17
|
+
".js": "text/javascript; charset=utf-8",
|
|
18
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
19
|
+
".css": "text/css; charset=utf-8",
|
|
20
|
+
".json": "application/json",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
".txt": "text/plain; charset=utf-8",
|
|
23
|
+
".md": "text/plain; charset=utf-8",
|
|
24
|
+
".map": "application/json",
|
|
25
|
+
".woff2": "font/woff2",
|
|
26
|
+
".woff": "font/woff",
|
|
27
|
+
".ttf": "font/ttf",
|
|
28
|
+
".otf": "font/otf",
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".gif": "image/gif",
|
|
33
|
+
".webp": "image/webp",
|
|
34
|
+
".ico": "image/x-icon",
|
|
35
|
+
".mp4": "video/mp4",
|
|
36
|
+
".webm": "video/webm",
|
|
37
|
+
".mov": "video/quicktime",
|
|
38
|
+
".pdf": "application/pdf",
|
|
39
|
+
".mp3": "audio/mpeg",
|
|
40
|
+
".wav": "audio/wav",
|
|
41
|
+
".ogg": "audio/ogg",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function mimeFor(rel: string): string {
|
|
45
|
+
// Apple requires JSON for the extensionless apple-app-site-association file.
|
|
46
|
+
if (APP_ASSOCIATION_RELS.has(rel)) return "application/json";
|
|
47
|
+
return MIME[extname(rel).toLowerCase()] ?? "application/octet-stream";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cacheFor(kind: ResourceKind, rel: string): string {
|
|
51
|
+
if (kind === "asset") return "public, max-age=31536000, immutable";
|
|
52
|
+
if (APP_ASSOCIATION_RELS.has(rel)) return "public, max-age=3600";
|
|
53
|
+
if (kind === "spa" || rel === "index.html") return "no-store";
|
|
54
|
+
return "no-cache";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Content-Type and Cache-Control for a file served from a project's web root. */
|
|
58
|
+
export function webFileHeaders(rel: string, kind: ResourceKind): { type: string; cache: string } {
|
|
59
|
+
return { type: mimeFor(rel), cache: cacheFor(kind, rel) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `relPath` inside `root`, or null when it escapes the root or does not exist. */
|
|
63
|
+
export function safeFileIn(root: string, relPath: string): string | null {
|
|
64
|
+
if (!relPath || relPath.includes("\0")) return null;
|
|
65
|
+
const abs = resolve(root, relPath);
|
|
66
|
+
const inside = relative(root, abs);
|
|
67
|
+
if (inside.startsWith("..") || inside.includes("\0")) return null;
|
|
68
|
+
return existsSync(abs) ? abs : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `rel` from `root` as a response, or null when it is absent so the caller can 404. Sent
|
|
73
|
+
* from disk with single-range support, so a 31MB video is never buffered and Safari (which
|
|
74
|
+
* refuses to play mp4 without Range) can seek. `frameAncestors` goes on HTML as the CSP.
|
|
75
|
+
*/
|
|
76
|
+
export async function webFileResponse(
|
|
77
|
+
root: string,
|
|
78
|
+
rel: string,
|
|
79
|
+
kind: ResourceKind,
|
|
80
|
+
request: { method: string; range?: string },
|
|
81
|
+
frameAncestors: string,
|
|
82
|
+
): Promise<{ response: Response; bytes: number } | null> {
|
|
83
|
+
const abs = safeFileIn(root, rel);
|
|
84
|
+
if (!abs) return null;
|
|
85
|
+
const info = await stat(abs);
|
|
86
|
+
if (!info.isFile()) return null;
|
|
87
|
+
const size = info.size;
|
|
88
|
+
const { type, cache } = webFileHeaders(rel, kind);
|
|
89
|
+
const headers = new Headers({
|
|
90
|
+
"content-type": type,
|
|
91
|
+
"cache-control": cache,
|
|
92
|
+
"accept-ranges": "bytes",
|
|
93
|
+
});
|
|
94
|
+
if (type.startsWith("text/html")) headers.set("content-security-policy", frameAncestors);
|
|
95
|
+
const range = parseByteRange(request.range, size);
|
|
96
|
+
if (range === "unsatisfiable") {
|
|
97
|
+
headers.set("content-range", `bytes */${size}`);
|
|
98
|
+
return { response: makeResponse(416, headers, undefined), bytes: 0 };
|
|
99
|
+
}
|
|
100
|
+
const start = range ? range.start : 0;
|
|
101
|
+
const end = range ? range.end : size - 1;
|
|
102
|
+
const length = size === 0 ? 0 : end - start + 1;
|
|
103
|
+
const status = range ? 206 : 200;
|
|
104
|
+
if (range) headers.set("content-range", `bytes ${start}-${end}/${size}`);
|
|
105
|
+
headers.set("content-length", String(length));
|
|
106
|
+
if (length === 0 || request.method === "HEAD") {
|
|
107
|
+
return { response: makeResponse(status, headers, length === 0 ? "" : undefined), bytes: 0 };
|
|
108
|
+
}
|
|
109
|
+
return { response: fileSliceResponse(abs, start, end, status, headers), bytes: length };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type BunFile = { file(path: string): Blob };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Bytes `start..end` of `abs`. On Bun the body is a file slice, which Bun.serve sends with this
|
|
116
|
+
* Content-Length (a stream body would go out chunked, and Safari will not seek a video without
|
|
117
|
+
* a length); elsewhere it is a read stream.
|
|
118
|
+
*/
|
|
119
|
+
function fileSliceResponse(
|
|
120
|
+
abs: string,
|
|
121
|
+
start: number,
|
|
122
|
+
end: number,
|
|
123
|
+
status: number,
|
|
124
|
+
headers: Headers,
|
|
125
|
+
): Response {
|
|
126
|
+
const bun = (globalThis as { Bun?: BunFile }).Bun;
|
|
127
|
+
if (bun) return new Response(bun.file(abs).slice(start, end + 1), { status, headers });
|
|
128
|
+
return makeResponse(status, headers, createReadStream(abs, { start, end }));
|
|
129
|
+
}
|
package/README.md
DELETED