From 0dd76038231cd7e73aabc0b2eb93864f7c75817c Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 00:27:29 +0530 Subject: [PATCH 1/3] feat: read the API base URL from runtime config Where the client sends API calls was `apiUrl` in environment.ts, a build-time constant that has always been empty, so every call fell through to `${BASE_URL}api/`. Changing it meant a rebuild, and the deployment behind /exp needs the API at /exp/api, which the app's own base path cannot express. It is now `api.baseUrl` in config.json, used as written, so a deployment sets it by mounting a config file. The default `/api` keeps the same-origin behaviour of the root build. The base is read per call and the axios instance built on first use, because configuration only exists after loadRuntimeConfig() resolves and this module is imported by tests. Verified: lint, typecheck, knip, tests and build pass; the dev server serves the new block in /config.json. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++-- docs/ADR/0000-fork-oan-ui.md | 4 +++- src/config/app-config.json | 3 +++ src/lib/api-service.ts | 31 +++++++++++++++++-------------- src/lib/config/environment.ts | 1 - 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c3791b2..b61e221 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ 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`: `/api` on its own origin by default. 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 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/config/app-config.json b/src/config/app-config.json index 2866f49..45b7cd4 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -2,6 +2,9 @@ "stubs": { "enabled": true }, + "api": { + "baseUrl": "/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, }; From 8e070997649271dec24a8f6b0986d87329882e3b Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 00:29:06 +0530 Subject: [PATCH 2/3] feat: add a parser for server-sent event streams The Experience API streams each answer as server-sent events over a POST, which the browser's EventSource cannot make. The client already reads the body from fetch; what it lacked was framing. Today it treats every network chunk as answer text. readSseEvents() turns a body into event/data frames by the SSE rules: blank line ends a frame, data lines join, comment lines and unknown fields are ignored, all three line endings work. Lines and multi-byte characters are reassembled across chunk boundaries, and a final frame the stream closes on is still delivered. Stopping early cancels the body so a live connection is closed. Nothing uses it yet; the next change does. Verified against the running API as well as the tests: its stream parses to started, two deltas and completed. Co-Authored-By: Claude Fable 5.1 --- src/__tests__/sse.test.ts | 112 ++++++++++++++++++++++++++++++++++++++ src/lib/sse.ts | 99 +++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/__tests__/sse.test.ts create mode 100644 src/lib/sse.ts 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/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); + } +} From 75cf7660a97051ee1eac56c5881cc399f575dc0b Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 01:12:24 +0530 Subject: [PATCH 3/3] fix: default the API base URL to the deployed path The reference deployment runs the shipped config.json as it is, with nothing mounted over it, so the bundled default has to be the real value. The UI is served at /experience and the API at /experience/api behind one front proxy; `/api` pointed at the domain root and would have missed. The value is still used as written and not derived from the build's base path. The two can differ, and an API call that quietly went to the wrong place would be hard to trace. A deployment at another path changes this one key. The README's sub-path section now shows the proxy block the API path needs: buffering off so the answer streams, a read timeout that covers a whole turn, and a body limit above the DSS's own so the DSS is the one that says a conversation is too long. Co-Authored-By: Claude Fable 5.1 --- README.md | 22 +++++++++++++++++++++- src/config/app-config.json | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b61e221..d1a6c70 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ npm run dev # http://localhost:3000 ``` No environment variables are required to boot. Where the app sends API calls -is `api.baseUrl` in `config.json`: `/api` on its own origin by default. +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/src/config/app-config.json b/src/config/app-config.json index 45b7cd4..ad3a75b 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -3,7 +3,7 @@ "enabled": true }, "api": { - "baseUrl": "/api" + "baseUrl": "/experience/api" }, "theme": { "fonts": {