@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 CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@robodev-ai/tenant-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Robodev tenant host — the framework-free request core (Fetch Request/Response) that serves a project's routes, Auth, storage, MCP, docs and web files, shared by hosted Starbase and `robodev dev`.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/robodev-com/robodev-starbase.git",
10
+ "directory": "packages/tenant-host"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "import": "./src/index.ts",
16
+ "default": "./src/index.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "bun test --timeout=60000",
27
+ "typecheck": "bun check --no-pretty"
28
+ },
29
+ "dependencies": {
30
+ "@robodev-ai/runtime": "0.32.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "1.4.4",
34
+ "@types/node": "^24.3.0",
35
+ "typescript": "^5.9.2"
36
+ }
37
+ }
@@ -0,0 +1,258 @@
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 { handleTenantRequest, type TenantHost, type TenantRuntime } from "./host.js";
7
+ import { fromFetchRequest, TenantRequestError, type TenantRequest } from "./request.js";
8
+ import { json, TenantReply } from "./response.js";
9
+
10
+ let webRoot = "";
11
+
12
+ before(async () => {
13
+ webRoot = await mkdtemp(join(tmpdir(), "tenant-host-"));
14
+ await writeFile(join(webRoot, "index.html"), "<html>app</html>");
15
+ await writeFile(join(webRoot, "logo.svg"), "<svg/>");
16
+ });
17
+
18
+ after(() => rm(webRoot, { recursive: true, force: true }));
19
+
20
+ const runtime: TenantRuntime = {
21
+ routes: [
22
+ { method: "get", path: "/api/notes" },
23
+ { method: "post", path: "/api/notes" },
24
+ ],
25
+ frontendKind: "react",
26
+ openapi: { openapi: "3.0.3" },
27
+ };
28
+
29
+ function host(overrides: Partial<TenantHost<TenantRuntime>> = {}): TenantHost<TenantRuntime> {
30
+ return {
31
+ storage: async () => json({ storage: true }),
32
+ runtime: async () => runtime,
33
+ unavailable: () => json({ error: "project_not_deployed" }, 404),
34
+ ready: async () => json({ ok: true }),
35
+ auth: async () => json({ auth: true }),
36
+ mcp: async () => json({ mcp: true }),
37
+ oauth: async () => null,
38
+ invoke: async (request) => ({
39
+ kind: "ok",
40
+ encoded: {
41
+ status: 200,
42
+ headers: { "x-route": "1" },
43
+ contentType: "application/json; charset=utf-8",
44
+ payload: { method: request.method, body: request.body ?? null },
45
+ },
46
+ }),
47
+ webRoot: () => webRoot,
48
+ frameAncestors: "frame-ancestors 'self'",
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ function req(method: string, url: string, init: RequestInit = {}): Promise<TenantRequest> {
54
+ return fromFetchRequest(new Request(`http://prj-x.localhost${url}`, { method, ...init }), {
55
+ ip: "127.0.0.1",
56
+ id: "req_1",
57
+ });
58
+ }
59
+
60
+ async function body(response: Response) {
61
+ const text = await response.text();
62
+ return response.headers.get("content-type")?.includes("json") ? JSON.parse(text) : text;
63
+ }
64
+
65
+ test("routes reach the invoker with the parsed body; misses list the deployed routes", async () => {
66
+ const ok = await handleTenantRequest(
67
+ await req("POST", "/api/notes", {
68
+ body: JSON.stringify({ text: "a" }),
69
+ headers: { "content-type": "application/json" },
70
+ }),
71
+ host(),
72
+ );
73
+ assert.equal(ok.kind, "api");
74
+ assert.equal(ok.route, "/api/notes");
75
+ assert.equal(ok.response.headers.get("x-route"), "1");
76
+ assert.deepEqual(await body(ok.response), { method: "POST", body: { text: "a" } });
77
+
78
+ const miss = await handleTenantRequest(await req("PUT", "/api/notes"), host());
79
+ assert.equal(miss.response.status, 404);
80
+ assert.equal(miss.error, "not_found");
81
+ assert.deepEqual(await body(miss.response), {
82
+ error: "not_found",
83
+ message: "PUT /api/notes is not deployed",
84
+ routes: ["GET /api/notes", "POST /api/notes"],
85
+ });
86
+ });
87
+
88
+ test("a handler error keeps its status and body and is logged", async () => {
89
+ const result = await handleTenantRequest(
90
+ await req("GET", "/api/notes"),
91
+ host({
92
+ invoke: async () => ({ kind: "error", status: 400, body: { error: "bad", message: "no" } }),
93
+ }),
94
+ );
95
+ assert.equal(result.response.status, 400);
96
+ assert.equal(result.error, "bad");
97
+ assert.equal(result.errorMessage, "no");
98
+ });
99
+
100
+ test("the guard runs first and its cookie goes before the endpoint's", async () => {
101
+ let woke = false;
102
+ const refused = await handleTenantRequest(
103
+ await req("GET", "/api/notes"),
104
+ host({
105
+ guard: async () => ({
106
+ ok: false,
107
+ response: new TenantReply().code(401).send("Authentication required").response(),
108
+ error: "gate_unauthorized",
109
+ }),
110
+ runtime: async () => {
111
+ woke = true;
112
+ return runtime;
113
+ },
114
+ }),
115
+ );
116
+ assert.equal(refused.response.status, 401);
117
+ assert.equal(refused.error, "gate_unauthorized");
118
+ assert.equal(woke, false, "a refused request never loads the runtime");
119
+
120
+ const passed = await handleTenantRequest(
121
+ await req("GET", "/api/user/me"),
122
+ host({
123
+ guard: async () => ({ ok: true, headers: { "Set-Cookie": "gate=1; Path=/" } }),
124
+ auth: async () => new TenantReply().header("Set-Cookie", "tenant=1").send({}).response(),
125
+ }),
126
+ );
127
+ assert.deepEqual(passed.response.headers.getSetCookie(), ["gate=1; Path=/", "tenant=1"]);
128
+ });
129
+
130
+ test("storage reads skip the runtime; a missing runtime answers unavailable", async () => {
131
+ const storage = await handleTenantRequest(
132
+ await req("GET", "/storage/objects/a.png"),
133
+ host({ runtime: async () => assert.fail("storage must not load the runtime") }),
134
+ );
135
+ assert.deepEqual(await body(storage.response), { storage: true });
136
+
137
+ const none = await handleTenantRequest(
138
+ await req("GET", "/api/notes"),
139
+ host({ runtime: async () => null }),
140
+ );
141
+ assert.equal(none.response.status, 404);
142
+ assert.equal(none.error, "project_not_deployed");
143
+ });
144
+
145
+ test("reserved endpoints: ready, OpenAPI, Auth, MCP, OAuth miss, docs", async () => {
146
+ const ready = await handleTenantRequest(await req("GET", "/_robodev/ready"), host());
147
+ assert.deepEqual(await body(ready.response), { ok: true });
148
+ const openapi = await handleTenantRequest(await req("GET", "/openapi.json"), host());
149
+ assert.deepEqual(await body(openapi.response), { openapi: "3.0.3" });
150
+ const auth = await handleTenantRequest(await req("POST", "/api/user/auth/login"), host());
151
+ assert.deepEqual(await body(auth.response), { auth: true });
152
+ const mcp = await handleTenantRequest(
153
+ await req("POST", "/mcp", {
154
+ body: "{}",
155
+ headers: {
156
+ "content-type": "application/json",
157
+ accept: "application/json, text/event-stream",
158
+ },
159
+ }),
160
+ host(),
161
+ );
162
+ assert.equal(mcp.kind, "mcp");
163
+ const oauth = await handleTenantRequest(
164
+ await req("GET", "/.well-known/oauth-authorization-server"),
165
+ host(),
166
+ );
167
+ assert.equal(oauth.response.status, 404);
168
+ const docs = await handleTenantRequest(await req("GET", "/_robodev/docs"), host());
169
+ assert.equal(docs.response.headers.get("content-type"), "text/html; charset=utf-8");
170
+ assert.equal(docs.response.headers.get("content-security-policy"), "frame-ancestors 'self'");
171
+ const other = await handleTenantRequest(await req("GET", "/_robodev/nope"), host());
172
+ assert.equal(other.response.status, 404);
173
+ });
174
+
175
+ test("web files, the SPA fallback, HEAD, and no web root", async () => {
176
+ const file = await handleTenantRequest(await req("GET", "/logo.svg"), host());
177
+ assert.equal(file.kind, "static");
178
+ assert.equal(file.response.headers.get("content-type"), "image/svg+xml");
179
+ assert.equal(await file.response.text(), "<svg/>");
180
+
181
+ const spa = await handleTenantRequest(await req("GET", "/settings/profile"), host());
182
+ assert.equal(spa.kind, "spa");
183
+ assert.equal(spa.response.headers.get("cache-control"), "no-store");
184
+ assert.equal(await spa.response.text(), "<html>app</html>");
185
+
186
+ const head = await handleTenantRequest(await req("HEAD", "/logo.svg"), host());
187
+ assert.equal(head.response.headers.get("content-length"), "6");
188
+ assert.equal(head.response.body, null);
189
+
190
+ const none = await handleTenantRequest(
191
+ await req("GET", "/logo.svg"),
192
+ host({ webRoot: () => null }),
193
+ );
194
+ assert.equal(none.response.status, 404);
195
+ });
196
+
197
+ test("opening an HTML page reports a page view; assets, files and API calls do not", async () => {
198
+ const views: string[] = [];
199
+ const watching = host({ onPageView: (request) => views.push(request.pathname) });
200
+ await handleTenantRequest(await req("GET", "/"), watching);
201
+ await handleTenantRequest(await req("GET", "/settings/profile"), watching);
202
+ await handleTenantRequest(await req("HEAD", "/"), watching);
203
+ await handleTenantRequest(await req("GET", "/logo.svg"), watching);
204
+ await handleTenantRequest(await req("GET", "/api/notes"), watching);
205
+ assert.deepEqual(views, ["/", "/settings/profile"]);
206
+ });
207
+
208
+ test("a cold project serves its built frontend at once; API calls still wait for the runtime", async () => {
209
+ let runtimeCalls = 0;
210
+ const cold = host({
211
+ coldWeb: async () => ({ webRoot, frontendKind: "react" }),
212
+ runtime: async () => {
213
+ runtimeCalls += 1;
214
+ return runtime;
215
+ },
216
+ });
217
+ const html = { headers: { accept: "text/html,application/xhtml+xml" } };
218
+
219
+ const page = await handleTenantRequest(await req("GET", "/settings", html), cold);
220
+ assert.equal(page.kind, "spa");
221
+ assert.equal(await page.response.text(), "<html>app</html>");
222
+ const file = await handleTenantRequest(await req("GET", "/logo.svg"), cold);
223
+ assert.equal(file.kind, "static");
224
+ assert.equal(runtimeCalls, 0);
225
+
226
+ // Routes are not known yet: a fetch that is not a navigation goes to the runtime.
227
+ const api = await handleTenantRequest(await req("GET", "/api/notes"), cold);
228
+ assert.equal(api.kind, "api");
229
+ assert.equal(runtimeCalls, 1);
230
+
231
+ // No built frontend: the normal path.
232
+ const none = host({ coldWeb: async () => null });
233
+ const loaded = await handleTenantRequest(await req("GET", "/", html), none);
234
+ assert.equal(loaded.kind, "static");
235
+ });
236
+
237
+ test("fromFetchRequest parses JSON, forms and bytes, and rejects bad JSON", async () => {
238
+ const form = await req("POST", "/x?a=1&a=2&b=3", {
239
+ body: "k=v",
240
+ headers: { "content-type": "application/x-www-form-urlencoded" },
241
+ });
242
+ assert.deepEqual(form.body, { k: "v" });
243
+ assert.deepEqual(form.query, { a: ["1", "2"], b: "3" });
244
+ assert.equal(form.pathname, "/x");
245
+ assert.equal(form.search, "?a=1&a=2&b=3");
246
+
247
+ const bytes = await req("POST", "/x", {
248
+ body: new Uint8Array([1, 2, 3]),
249
+ headers: { "content-type": "application/octet-stream" },
250
+ });
251
+ assert.deepEqual(bytes.body, Buffer.from([1, 2, 3]));
252
+ assert.deepEqual(bytes.rawBody, Buffer.from([1, 2, 3]));
253
+
254
+ await assert.rejects(
255
+ req("POST", "/x", { body: "{", headers: { "content-type": "application/json" } }),
256
+ (error) => error instanceof TenantRequestError && error.code === "invalid_json",
257
+ );
258
+ });
package/src/host.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ buildEnterpriseSpec,
4
+ isReservedStoragePath,
5
+ isReservedStorageUploadPath,
6
+ matchProjectRoute,
7
+ scalarHtml,
8
+ swaggerHtml,
9
+ type RouteOutcome,
10
+ } from "@robodev-ai/runtime";
11
+ import type { TenantRequest } from "./request.js";
12
+ import { headerValue } from "./request.js";
13
+ import { builtBodyOf, encodedResponse, json, makeResponse } from "./response.js";
14
+ import { resolveProjectResource, type FrontendKind, type ResourceKind } from "./resolve.js";
15
+ import { safeFileIn, webFileResponse } from "./web.js";
16
+
17
+ /** What the core needs from one deployed generation of a project. */
18
+ export type TenantRuntime = {
19
+ routes: { method: string; path: string }[];
20
+ frontendKind: FrontendKind;
21
+ openapi: Record<string, unknown>;
22
+ };
23
+
24
+ export type GuardResult =
25
+ | { ok: true; headers?: Record<string, string> }
26
+ | { ok: false; response: Response; error: string; message?: string };
27
+
28
+ /**
29
+ * Everything that differs between hosted Starbase and `robodev dev`. The core decides which
30
+ * endpoint a request is for and how errors look; the host supplies the runtime and the
31
+ * endpoints that need its own clients (Auth, storage, MCP, the handler invoker).
32
+ */
33
+ export type TenantHost<R extends TenantRuntime> = {
34
+ /** Runs first (hosted: password protection), so a refused request never wakes the project. */
35
+ guard?: (request: TenantRequest) => Promise<GuardResult>;
36
+ /**
37
+ * `GET /storage/objects/*` and `PUT`/`POST /storage/uploads/{id}`. Answered before the runtime
38
+ * loads: storage needs no worker.
39
+ */
40
+ storage: (request: TenantRequest) => Promise<Response>;
41
+ /** The project's current runtime, or null when there is none to serve. */
42
+ runtime: (request: TenantRequest) => Promise<R | null>;
43
+ /** The answer when `runtime` is null (hosted: not deployed; dev: still compiling). */
44
+ unavailable: (request: TenantRequest) => Response;
45
+ ready: (request: TenantRequest, runtime: R) => Promise<Response>;
46
+ auth: (request: TenantRequest, runtime: R) => Promise<Response>;
47
+ mcp: (request: TenantRequest, runtime: R) => Promise<Response>;
48
+ /** Null when the path is not an OAuth endpoint after all. */
49
+ oauth: (request: TenantRequest, runtime: R) => Promise<Response | null>;
50
+ invoke: (request: TenantRequest, runtime: R) => Promise<RouteOutcome>;
51
+ /** The deployed web root, or null when this host serves no web files (dev: Vite does). */
52
+ webRoot?: (runtime: R) => string | null;
53
+ /**
54
+ * A browser opened one of the project's HTML pages. Not awaited: hosted uses it to start the
55
+ * worker in the background, so the page's first API call does not wait for a boot.
56
+ */
57
+ onPageView?: (request: TenantRequest, runtime: R) => void;
58
+ /**
59
+ * A frontend already built on disk for a project whose runtime is not loaded yet (hosted:
60
+ * after a restart), or null. Page loads and web files are then served from it at once,
61
+ * while the host loads the runtime in the background; API calls still wait for `runtime`.
62
+ */
63
+ coldWeb?: (
64
+ request: TenantRequest,
65
+ ) => Promise<{ webRoot: string; frontendKind: FrontendKind } | null>;
66
+ /** `frame-ancestors` CSP for HTML this host serves, or null to send none. */
67
+ frameAncestors?: string | null;
68
+ };
69
+
70
+ /** What happened, for the request log. */
71
+ export type TenantResult = {
72
+ response: Response;
73
+ kind: ResourceKind | null;
74
+ /** The matched route pattern, for API requests. */
75
+ route: string | null;
76
+ error: string | null;
77
+ errorMessage: string | null;
78
+ };
79
+
80
+ function errorFields(body: unknown): { error: string | null; message: string | null } {
81
+ if (!body || typeof body !== "object") return { error: null, message: null };
82
+ const record = body as { error?: unknown; message?: unknown };
83
+ return {
84
+ error: typeof record.error === "string" ? record.error : null,
85
+ message: typeof record.message === "string" ? record.message : null,
86
+ };
87
+ }
88
+
89
+ export function notDeployedBody(request: TenantRequest, routes: TenantRuntime["routes"]) {
90
+ return {
91
+ error: "not_found",
92
+ message: `${request.method} ${request.pathname} is not deployed`,
93
+ routes: routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
94
+ };
95
+ }
96
+
97
+ /** A JSON body this package built, for the request log; null for anything else. */
98
+ function jsonBodyOf(response: Response): unknown {
99
+ const built = builtBodyOf(response);
100
+ if (typeof built !== "string" || !response.headers.get("content-type")?.includes("json")) {
101
+ return null;
102
+ }
103
+ try {
104
+ return JSON.parse(built);
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function htmlPage(body: string, frameAncestors: string | null | undefined): Response {
111
+ const headers = new Headers({
112
+ "content-type": "text/html; charset=utf-8",
113
+ "cache-control": "no-store",
114
+ });
115
+ if (frameAncestors) headers.set("content-security-policy", frameAncestors);
116
+ return makeResponse(200, headers, body);
117
+ }
118
+
119
+ function acceptsHtml(request: TenantRequest): boolean {
120
+ return (headerValue(request.headers, "accept") ?? "").includes("text/html");
121
+ }
122
+
123
+ /**
124
+ * A web file or page from a frontend built before the runtime loaded. Routes are not known
125
+ * yet, so the SPA fallback answers only a browser navigation (`Accept: text/html`): a fetch to
126
+ * an API path must wait for the runtime rather than get the app's HTML.
127
+ */
128
+ async function serveColdWeb(
129
+ request: TenantRequest,
130
+ cold: { webRoot: string; frontendKind: FrontendKind },
131
+ frameAncestors: string,
132
+ ): Promise<{ kind: ResourceKind; response: Response } | null> {
133
+ const { webRoot } = cold;
134
+ const resolved = resolveProjectResource(
135
+ request.pathname,
136
+ request.method,
137
+ {
138
+ routes: [],
139
+ frontendKind: cold.frontendKind,
140
+ webHas: (rel) => Boolean(safeFileIn(webRoot, rel)),
141
+ },
142
+ request.headers,
143
+ );
144
+ const options = { method: request.method, range: headerValue(request.headers, "range") };
145
+ if (resolved.kind === "asset") {
146
+ const sent = await webFileResponse(
147
+ webRoot,
148
+ join("assets", resolved.rel ?? ""),
149
+ "asset",
150
+ options,
151
+ frameAncestors,
152
+ );
153
+ return sent ? { kind: "asset", response: sent.response } : null;
154
+ }
155
+ const page = resolved.kind === "static" || (resolved.kind === "spa" && acceptsHtml(request));
156
+ if (!page) return null;
157
+ const sent = await webFileResponse(
158
+ webRoot,
159
+ resolved.rel ?? "index.html",
160
+ resolved.kind,
161
+ options,
162
+ frameAncestors,
163
+ );
164
+ return sent ? { kind: resolved.kind, response: sent.response } : null;
165
+ }
166
+
167
+ /** Serves one project request. Thrown errors are the host's to map (validation, 500s). */
168
+ export async function handleTenantRequest<R extends TenantRuntime>(
169
+ request: TenantRequest,
170
+ host: TenantHost<R>,
171
+ ): Promise<TenantResult> {
172
+ const { pathname, method } = request;
173
+ let kind: ResourceKind | null = null;
174
+ let route: string | null = null;
175
+ let errorBody: unknown = null;
176
+ let extraHeaders: Record<string, string> | undefined;
177
+
178
+ const finish = (response: Response): TenantResult => {
179
+ for (const [name, value] of Object.entries(extraHeaders ?? {})) {
180
+ if (name.toLowerCase() !== "set-cookie") {
181
+ response.headers.set(name, value);
182
+ continue;
183
+ }
184
+ // The guard's cookie goes first; a cookie the endpoint set is appended after it.
185
+ const own = response.headers.getSetCookie();
186
+ response.headers.delete("set-cookie");
187
+ response.headers.append("set-cookie", value);
188
+ for (const cookie of own) response.headers.append("set-cookie", cookie);
189
+ }
190
+ const fields = errorFields(errorBody);
191
+ return { response, kind, route, error: fields.error, errorMessage: fields.message };
192
+ };
193
+ const fail = (status: number, body: unknown): TenantResult => {
194
+ errorBody = body;
195
+ return finish(json(body, status));
196
+ };
197
+
198
+ if (host.guard) {
199
+ const gate = await host.guard(request);
200
+ if (!gate.ok) {
201
+ kind = "reserved";
202
+ errorBody = { error: gate.error, message: gate.message };
203
+ return finish(gate.response);
204
+ }
205
+ extraHeaders = gate.headers;
206
+ }
207
+
208
+ // Storage objects (GET) and upload URLs (PUT, POST) run without the project runtime: they
209
+ // never wake the worker or call a handler.
210
+ if (
211
+ (method === "GET" && isReservedStoragePath(pathname)) ||
212
+ ((method === "PUT" || method === "POST") && isReservedStorageUploadPath(pathname))
213
+ ) {
214
+ kind = "reserved";
215
+ return finish(await host.storage(request));
216
+ }
217
+
218
+ if (host.coldWeb && (method === "GET" || method === "HEAD")) {
219
+ const cold = await host.coldWeb(request);
220
+ const sent = cold ? await serveColdWeb(request, cold, host.frameAncestors ?? "") : null;
221
+ if (sent) {
222
+ kind = sent.kind;
223
+ return finish(sent.response);
224
+ }
225
+ }
226
+
227
+ const runtime = await host.runtime(request);
228
+ if (!runtime) {
229
+ const response = host.unavailable(request);
230
+ errorBody = jsonBodyOf(response);
231
+ return finish(response);
232
+ }
233
+
234
+ const webRoot = host.webRoot?.(runtime) ?? null;
235
+ const resolved = resolveProjectResource(
236
+ pathname,
237
+ method,
238
+ {
239
+ routes: runtime.routes,
240
+ frontendKind: runtime.frontendKind,
241
+ webHas: (rel) => (webRoot ? Boolean(safeFileIn(webRoot, rel)) : false),
242
+ },
243
+ request.headers,
244
+ );
245
+ kind = resolved.kind;
246
+
247
+ switch (resolved.kind) {
248
+ case "reserved":
249
+ switch (resolved.reserved) {
250
+ case "ready": {
251
+ const response = await host.ready(request, runtime);
252
+ errorBody = jsonBodyOf(response);
253
+ return finish(response);
254
+ }
255
+ case "openapi":
256
+ return finish(json(runtime.openapi));
257
+ case "enterprise_spec":
258
+ return finish(json(buildEnterpriseSpec(runtime.openapi)));
259
+ case "auth":
260
+ return finish(await host.auth(request, runtime));
261
+ default:
262
+ return fail(404, notDeployedBody(request, runtime.routes));
263
+ }
264
+ case "mcp":
265
+ return finish(await host.mcp(request, runtime));
266
+ case "oauth": {
267
+ const response = await host.oauth(request, runtime);
268
+ return response ? finish(response) : fail(404, { error: "not_found" });
269
+ }
270
+ case "swagger":
271
+ return finish(htmlPage(swaggerHtml("/openapi.json"), host.frameAncestors));
272
+ case "scalar":
273
+ return finish(htmlPage(scalarHtml("/openapi.json"), host.frameAncestors));
274
+ case "asset": {
275
+ const sent = webRoot
276
+ ? await webFileResponse(
277
+ webRoot,
278
+ join("assets", resolved.rel ?? ""),
279
+ "asset",
280
+ { method, range: headerValue(request.headers, "range") },
281
+ host.frameAncestors ?? "",
282
+ )
283
+ : null;
284
+ return sent ? finish(sent.response) : fail(404, { error: "not_found" });
285
+ }
286
+ case "static":
287
+ case "spa": {
288
+ const rel = resolved.rel ?? "index.html";
289
+ if (method === "GET" && (resolved.kind === "spa" || rel.endsWith(".html"))) {
290
+ host.onPageView?.(request, runtime);
291
+ }
292
+ const sent = webRoot
293
+ ? await webFileResponse(
294
+ webRoot,
295
+ rel,
296
+ resolved.kind,
297
+ { method, range: headerValue(request.headers, "range") },
298
+ host.frameAncestors ?? "",
299
+ )
300
+ : null;
301
+ return sent ? finish(sent.response) : fail(404, notDeployedBody(request, runtime.routes));
302
+ }
303
+ case "api":
304
+ break;
305
+ default:
306
+ return fail(404, notDeployedBody(request, runtime.routes));
307
+ }
308
+
309
+ const matched = matchProjectRoute(pathname, method.toLowerCase(), runtime.routes);
310
+ if (!matched) return fail(404, notDeployedBody(request, runtime.routes));
311
+ route = matched.route.path;
312
+
313
+ const outcome = await host.invoke(request, runtime);
314
+ if (outcome.kind === "error") return fail(outcome.status, outcome.body);
315
+ return finish(encodedResponse(outcome.encoded));
316
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export {
2
+ handleTenantRequest,
3
+ notDeployedBody,
4
+ type GuardResult,
5
+ type TenantHost,
6
+ type TenantResult,
7
+ type TenantRuntime,
8
+ } from "./host.js";
9
+ export {
10
+ fromFetchRequest,
11
+ headerValue,
12
+ parseQuery,
13
+ parseTenantBody,
14
+ tenantPathname,
15
+ TenantRequestError,
16
+ type TenantHeaders,
17
+ type TenantRequest,
18
+ } from "./request.js";
19
+ export {
20
+ encodeBody,
21
+ encodedResponse,
22
+ builtBodyOf,
23
+ isStreamedResponse,
24
+ json,
25
+ makeResponse,
26
+ runtimeResponse,
27
+ TenantReply,
28
+ } from "./response.js";
29
+ export {
30
+ isWellKnownLookupPath,
31
+ resolveProjectResource,
32
+ type FrontendKind,
33
+ type ReservedKind,
34
+ type ResolveResult,
35
+ type ResolveRuntime,
36
+ type ResourceKind,
37
+ } from "./resolve.js";
38
+ export { safeFileIn, webFileHeaders, webFileResponse } from "./web.js";