Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/ADR/0000-fork-oan-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
112 changes: 112 additions & 0 deletions src/__tests__/sse.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> =>
new ReadableStream<Uint8Array>({
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<Uint8Array>) => {
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<Uint8Array>({
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);
});
});
3 changes: 3 additions & 0 deletions src/config/app-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"stubs": {
"enabled": true
},
"api": {
"baseUrl": "/experience/api"
},
"theme": {
"fonts": {
"family": "Poppins"
Expand Down
31 changes: 17 additions & 14 deletions src/lib/api-service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'
});

Expand Down Expand Up @@ -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
});
Expand Down
1 change: 0 additions & 1 deletion src/lib/config/environment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export const environment = {
apiUrl: '',
chatMessageMaxLength: 1000,
};
99 changes: 99 additions & 0 deletions src/lib/sse.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>): AsyncGenerator<SseEvent> {
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<SseEvent> {
let next: ReturnType<typeof takeLine>;
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);
}
}
Loading