diff --git a/README.md b/README.md index c3791b2..d1a6c70 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ npm install npm run dev # http://localhost:3000 ``` -No environment variables are required to boot. The app calls `/api/*` on its -own origin. +No environment variables are required to boot. Where the app sends API calls +is `api.baseUrl` in `config.json`, an absolute path on the app's own origin. +The default, `/experience/api`, is the reference deployment's path (see +*Serving from a sub-path*). There is no Experience API yet, so the client ships with a stub layer turned on (`stubs.enabled` in `config.json`). With it on, every endpoint returns canned @@ -131,6 +133,24 @@ location = /experience { } ``` +The Experience API sits behind the same proxy, one level down. `api.baseUrl` +in `config.json` defaults to `/experience/api` to match this layout, so +nothing is mounted over the shipped config. The proxy routes that path to the +API and strips the prefix: + +```nginx +location /experience/api/ { + proxy_pass http://experience-api:8000/; # your API's address; the slash strips the prefix + proxy_buffering off; # or the answer arrives all at once + proxy_read_timeout 120s; # the whole-turn timeout; nginx's default is 60s + client_max_body_size 2m; # above the DSS's 1 MB, so the DSS decides "too long" +} +``` + +A deployment at another path changes `api.baseUrl` to match. Nothing derives +it from `VITE_BASE_PATH`: the two can differ, and an API call that quietly +went to the wrong place would be hard to trace. + Leading and trailing slashes on `VITE_BASE_PATH` both matter. The path is baked into the bundle, so **an image built for a sub-path serves only from that path** — a deployment that needs two paths builds two images. In CI it comes diff --git a/docs/ADR/0000-fork-oan-ui.md b/docs/ADR/0000-fork-oan-ui.md index 90c604f..1d32223 100644 --- a/docs/ADR/0000-fork-oan-ui.md +++ b/docs/ADR/0000-fork-oan-ui.md @@ -200,4 +200,6 @@ Not debt. Recorded so they are not "cleaned up" by someone who assumes otherwise - **`react-markdown` + `remark-gfm`** for answer rendering, with links forced to `target="_blank" rel="noopener noreferrer"`. Means the Experience API can append provenance as markdown and have it render safely today. -- **Same-origin `/api/*`** — no CORS, no preflight, by construction. +- **Same-origin API** — `api.baseUrl` in `config.json` is an absolute path + on the app's own origin by default, so no CORS and no preflight. Another + origin is possible, but needs CORS on the API and a wider CSP. diff --git a/src/__tests__/sse.test.ts b/src/__tests__/sse.test.ts new file mode 100644 index 0000000..59069c1 --- /dev/null +++ b/src/__tests__/sse.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { readSseEvents } from "@/lib/sse"; + +/** + * The parser sits between the network and everything that reads an answer. + * Each case here is one rule of the SSE format, plus the ways a network chunk + * can cut a frame that the rules do not mention. + */ +const streamOf = (...chunks: (string | Uint8Array)[]): ReadableStream => + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const chunk of chunks) { + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk); + } + controller.close(); + } + }); + +const collect = async (body: ReadableStream) => { + const events = []; + for await (const event of readSseEvents(body)) events.push(event); + return events; +}; + +describe("readSseEvents", () => { + it("frames event and data lines ended by a blank line", async () => { + const events = await collect(streamOf('event: delta\ndata: {"text":"hi"}\n\n')); + + expect(events).toEqual([{ event: "delta", data: '{"text":"hi"}' }]); + }); + + it("joins several data lines with a newline", async () => { + const events = await collect(streamOf("data: one\ndata: two\n\n")); + + expect(events).toEqual([{ event: "message", data: "one\ntwo" }]); + }); + + it("names a frame without an event line 'message'", async () => { + const events = await collect(streamOf("data: x\n\n")); + + expect(events[0]?.event).toBe("message"); + }); + + it("ignores comment lines, unknown fields and empty frames", async () => { + const events = await collect( + streamOf(": ping\n\n", "id: 7\nretry: 1000\n\n", ": keep-alive\nevent: delta\ndata: a\n\n") + ); + + expect(events).toEqual([{ event: "delta", data: "a" }]); + }); + + it("accepts CRLF and lone CR line endings", async () => { + const crlf = await collect(streamOf("event: a\r\ndata: 1\r\n\r\n")); + const cr = await collect(streamOf("event: b\rdata: 2\r\r")); + + expect(crlf).toEqual([{ event: "a", data: "1" }]); + expect(cr).toEqual([{ event: "b", data: "2" }]); + }); + + it("reassembles a frame cut across chunks, including inside a CRLF", async () => { + const events = await collect( + streamOf("eve", "nt: delta\nda", "ta: hel", "lo\r", "\n\r\nevent: done\ndata: ok\n\n") + ); + + expect(events).toEqual([ + { event: "delta", data: "hello" }, + { event: "done", data: "ok" } + ]); + }); + + it("reassembles a multi-byte character cut across chunks", async () => { + const bytes = new TextEncoder().encode("data: 31°C\n\n"); + const cut = bytes.indexOf(0xc2) + 1; // between the two bytes of ° + + const events = await collect(streamOf(bytes.slice(0, cut), bytes.slice(cut))); + + expect(events).toEqual([{ event: "message", data: "31°C" }]); + }); + + it("delivers a final frame the stream closes without a blank line after", async () => { + const events = await collect(streamOf("event: completed\ndata: last")); + + expect(events).toEqual([{ event: "completed", data: "last" }]); + }); + + it("keeps a value's own colons and strips one leading space only", async () => { + const events = await collect(streamOf("data: a: b\n\n", "data:nospace\n\n")); + + expect(events.map((e) => e.data)).toEqual([" a: b", "nospace"]); + }); + + it("cancels the body when the reader stops early", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: 1\n\ndata: 2\n\n")); + }, + cancel() { + cancelled = true; + } + }); + + for await (const event of readSseEvents(body)) { + expect(event.data).toBe("1"); + break; + } + + expect(cancelled).toBe(true); + }); +}); diff --git a/src/config/app-config.json b/src/config/app-config.json index 2866f49..ad3a75b 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -2,6 +2,9 @@ "stubs": { "enabled": true }, + "api": { + "baseUrl": "/experience/api" + }, "theme": { "fonts": { "family": "Poppins" diff --git a/src/lib/api-service.ts b/src/lib/api-service.ts index f48e98a..d067820 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import { environment } from '@/lib/config/environment'; +import { getConfig } from '@/lib/config/runtime-config'; import { stubsEnabled, stubGetSuggestions, @@ -55,29 +55,32 @@ const stripSseKeepAliveMarkers = (chunk: string): string => /** - * Where the API lives. `environment.apiUrl` is empty, so it is same-origin; - * BASE_URL carries the sub-path the app was built for, if any. + * Where the API lives: `api.baseUrl` from config.json, used as written. An + * absolute path such as `/api` or `/exp/api` keeps every call on the app's + * own origin. A full URL works too, but needs CORS on the API and the CSP's + * connect-src widened. + * + * Read on each call rather than at import. Configuration exists only once + * loadRuntimeConfig() has resolved, and this module is imported by tests. */ -const apiBase = `${import.meta.env.BASE_URL}api/`; +const apiBaseUrl = (): string => getConfig().api.baseUrl; class ApiService { - private apiUrl: string = environment.apiUrl; private locationData: LocationData | null = null; private currentSessionId: string | null = null; - private axiosInstance: AxiosInstance; + private axios: AxiosInstance | null = null; - constructor() { - this.axiosInstance = axios.create({ - baseURL: this.apiUrl || apiBase, + /** Built on first use, for the same reason apiBaseUrl() is a function. */ + private get axiosInstance(): AxiosInstance { + this.axios ??= axios.create({ + baseURL: `${apiBaseUrl()}/`, headers: { 'Content-Type': 'application/json' } }); - + return this.axios; } - - async sendUserQuery( msg: string, session: string, @@ -105,7 +108,7 @@ class ApiService { if (onStreamData) { // Handle streaming response - const response = await fetch(`${this.apiUrl || apiBase}chat/?${new URLSearchParams(params)}`, { + const response = await fetch(`${apiBaseUrl()}/chat/?${new URLSearchParams(params)}`, { method: 'GET' }); @@ -199,7 +202,7 @@ class ApiService { const formData = new FormData(); formData.append('image', imageFile); - const response = await fetch(`${this.apiUrl || apiBase}image/upload`, { + const response = await fetch(`${apiBaseUrl()}/image/upload`, { method: 'POST', body: formData }); diff --git a/src/lib/config/environment.ts b/src/lib/config/environment.ts index 513afb9..5447a8c 100644 --- a/src/lib/config/environment.ts +++ b/src/lib/config/environment.ts @@ -1,4 +1,3 @@ export const environment = { - apiUrl: '', chatMessageMaxLength: 1000, }; diff --git a/src/lib/sse.ts b/src/lib/sse.ts new file mode 100644 index 0000000..7f90e27 --- /dev/null +++ b/src/lib/sse.ts @@ -0,0 +1,99 @@ +/** + * Reads a `text/event-stream` body as events. + * + * The Experience API streams its answer as server-sent events, but the + * browser's own `EventSource` cannot POST, so the body is read from `fetch` + * and framed here. This follows the SSE format as browsers implement it: + * + * - a frame is a run of `field: value` lines ended by a blank line; + * - `event:` names the frame, `data:` lines join with `\n`; + * - lines starting with `:` are comments, kept alive on, and ignored; + * - other fields (`id:`, `retry:`) are accepted and ignored; + * - line ends may be `\n`, `\r\n` or `\r`. + * + * Network chunks do not respect any of those boundaries, so lines and + * multi-byte characters are reassembled across them. A last frame that the + * stream closes without a blank line after is still delivered. + */ + +export interface SseEvent { + /** The `event:` field, or `message` when the frame had none. */ + event: string; + /** The `data:` lines, joined with `\n`. */ + data: string; +} + +const LINE_END = /\r\n|\n|\r/; + +/** + * Takes one complete line off the front of `buffer`, or returns null if none + * is there yet. A `\r` at the very end is not a complete line: the `\n` that + * may follow it could be in the next chunk. + */ +const takeLine = (buffer: string, streamEnded: boolean): { line: string; rest: string } | null => { + const match = LINE_END.exec(buffer); + if (!match) return null; + if (match[0] === "\r" && match.index === buffer.length - 1 && !streamEnded) return null; + return { line: buffer.slice(0, match.index), rest: buffer.slice(match.index + match[0].length) }; +}; + +export async function* readSseEvents(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + + let buffer = ""; + let eventName = ""; + let dataLines: string[] = []; + + // Returns the frame to emit for a blank line, or null if there is none. + // Per the SSE format, a frame with no data is dropped, not dispatched. + const endFrame = (): SseEvent | null => { + const frame = dataLines.length ? { event: eventName || "message", data: dataLines.join("\n") } : null; + eventName = ""; + dataLines = []; + return frame; + }; + + const readField = (line: string): void => { + if (line.startsWith(":")) return; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + + if (field === "event") eventName = value; + else if (field === "data") dataLines.push(value); + }; + + function* drain(streamEnded: boolean): Generator { + let next: ReturnType; + while ((next = takeLine(buffer, streamEnded)) !== null) { + buffer = next.rest; + if (next.line === "") { + const frame = endFrame(); + if (frame) yield frame; + } else { + readField(next.line); + } + } + } + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + yield* drain(false); + } + + buffer += decoder.decode(); + yield* drain(true); + if (buffer) readField(buffer); + const last = endFrame(); + if (last) yield last; + } finally { + // Reached on normal completion and when the consumer stops early. On a + // finished stream this is a no-op; on a live one it closes the connection. + await reader.cancel().catch(() => undefined); + } +}