Repository navigation
fix(sdk): keep a baseURL path prefix instead of discarding it #448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
51aff78
42c996b
1c273cf
f26644d
41caedd
3d83413
5b4823a
8005cea
454d2ee
4227b6c
aea34be
9b018ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { SSETransport } from "./sse.js"; | ||
|
|
||
| /** URLs handed to `new EventSource(...)`, newest last. */ | ||
| let opened: string[] = []; | ||
|
|
||
| class FakeEventSource { | ||
| static readonly CONNECTING = 0; | ||
| static readonly OPEN = 1; | ||
| static readonly CLOSED = 2; | ||
|
|
||
| readyState = FakeEventSource.CONNECTING; | ||
| onopen: (() => void) | null = null; | ||
| onmessage: ((e: MessageEvent) => void) | null = null; | ||
| onerror: (() => void) | null = null; | ||
|
|
||
| constructor(url: string) { | ||
| opened.push(url); | ||
| } | ||
|
|
||
| close(): void { | ||
| this.readyState = FakeEventSource.CLOSED; | ||
| } | ||
| } | ||
|
|
||
| /** Let `connect()`'s async `_doConnect()` reach `new EventSource(...)`. */ | ||
| const flush = () => new Promise((r) => setTimeout(r, 0)); | ||
|
|
||
| describe("SSETransport URL construction", () => { | ||
| beforeEach(() => { | ||
| opened = []; | ||
| vi.stubGlobal("EventSource", FakeEventSource); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("connects at the origin root for a root-hosted base", async () => { | ||
| new SSETransport({ baseURL: "http://localhost:8080", table: "clicks" }).connect(); | ||
| await flush(); | ||
|
|
||
| expect(opened).toHaveLength(1); | ||
| const url = new URL(opened[0]); | ||
| expect(url.pathname).toBe("/v1/stream"); | ||
| expect(url.searchParams.get("table")).toBe("clicks"); | ||
| }); | ||
|
|
||
| it("preserves a base path prefix", async () => { | ||
| new SSETransport({ | ||
| baseURL: "https://app.example.com/api/warehouse", | ||
| table: "clicks", | ||
| }).connect(); | ||
| await flush(); | ||
|
|
||
| const url = new URL(opened[0]); | ||
| expect(url.pathname).toBe("/api/warehouse/v1/stream"); | ||
| expect(url.searchParams.get("table")).toBe("clicks"); | ||
| }); | ||
|
|
||
| it("preserves the prefix alongside since and token params", async () => { | ||
| new SSETransport({ | ||
| baseURL: "https://app.example.com/api/warehouse/", | ||
| table: "clicks", | ||
| since: "2024-01-01T00:00:00Z", | ||
| auth: async () => "my-token", | ||
| }).connect(); | ||
| await flush(); | ||
|
|
||
| const url = new URL(opened[0]); | ||
| expect(url.pathname).toBe("/api/warehouse/v1/stream"); | ||
| expect(url.searchParams.get("since")).toBe("2024-01-01T00:00:00Z"); | ||
| expect(url.searchParams.get("token")).toBe("my-token"); | ||
| }); | ||
|
|
||
| it("omits the token param when auth resolves empty", async () => { | ||
| new SSETransport({ | ||
| baseURL: "https://app.example.com/api/warehouse", | ||
| table: "clicks", | ||
| auth: async () => "", | ||
| }).connect(); | ||
| await flush(); | ||
|
|
||
| expect(new URL(opened[0]).searchParams.has("token")).toBe(false); | ||
| }); | ||
|
|
||
| it("reports a bad baseURL through onError instead of throwing", async () => { | ||
| const onError = vi.fn(); | ||
| const t = new SSETransport({ baseURL: "not-a-url", table: "clicks" }); | ||
| t.onError = onError; | ||
| t.connect(); | ||
| await flush(); | ||
|
|
||
| expect(opened).toHaveLength(0); | ||
| expect(onError).toHaveBeenCalledOnce(); | ||
| expect(onError.mock.calls[0][0].code).toBe("SSE_CONNECT_ERROR"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,9 @@ export type Database = Record<string, Record<string, unknown>>; | |
| // --- Result types --- | ||
|
|
||
| /** | ||
| * Discriminated union for all async SDK operations. Never throws. | ||
| * Discriminated union for all async SDK operations. Never throws for anything | ||
| * the server returns — caller and environment errors (a non-absolute `baseURL`, | ||
| * a missing `EventSource`, a rejecting `auth` callback) do throw. | ||
|
Comment on lines
+13
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Scope the The JSDoc describes a discriminated union for “all async SDK operations.” Proposed wording- * Discriminated union for all async SDK operations. Never throws for anything
+ * Discriminated union for async request operations. Never throws for anything |
||
| * | ||
| * Discriminated on `ok`: `if (result.ok)` narrows to the success arm (and tells | ||
| * the compiler `data` is present), while `error` is always available for | ||
|
|
@@ -58,7 +60,19 @@ export interface StreamSubscriber<T = Record<string, unknown>> { | |
| // --- Client config --- | ||
|
|
||
| export interface ClientConfig<_DB extends Database = Database> { | ||
| /** Base URL of the WaveHouse server (e.g. "http://localhost:8080"). */ | ||
| /** | ||
| * Base URL of the WaveHouse server (e.g. "http://localhost:8080"). May include | ||
| * a path prefix ("https://app.example.com/api/warehouse") for a WaveHouse | ||
| * behind a backend-for-frontend (BFF), app-server route, or path-routed | ||
| * ingress; request paths are appended to it. The proxy in front must strip | ||
| * the prefix before forwarding. | ||
| * | ||
| * Must be **absolute** — scheme and host included. The transports report a | ||
| * relative value such as "/api/warehouse" differently: a REST call rejects | ||
| * with a `TypeError` instead of returning a `Result`, while a stream reports | ||
| * `SSE_CONNECT_ERROR` to the subscriber's `error` callback. Build an absolute | ||
| * one: `` `${location.origin}/api/warehouse` ``. | ||
| */ | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| baseURL: string; | ||
| /** Auth token provider. Omit for public/unauthenticated access. */ | ||
| auth?: () => Promise<string> | string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { resolveURL } from "./url.js"; | ||
|
|
||
| describe("resolveURL", () => { | ||
| it("resolves against a root-hosted base", () => { | ||
| expect(resolveURL("http://localhost:8080", "/v1/query").toString()).toBe( | ||
| "http://localhost:8080/v1/query", | ||
| ); | ||
| }); | ||
|
|
||
| it("preserves a base path prefix", () => { | ||
| expect(resolveURL("https://app.example.com/api/warehouse", "/v1/query").toString()).toBe( | ||
| "https://app.example.com/api/warehouse/v1/query", | ||
| ); | ||
| }); | ||
|
|
||
| it("preserves a prefix whether or not the base ends in a slash", () => { | ||
| const withSlash = resolveURL("https://app.example.com/api/warehouse/", "/v1/query").toString(); | ||
| const without = resolveURL("https://app.example.com/api/warehouse", "/v1/query").toString(); | ||
| expect(withSlash).toBe(without); | ||
| expect(without).toBe("https://app.example.com/api/warehouse/v1/query"); | ||
| }); | ||
|
|
||
| it("preserves a multi-segment prefix", () => { | ||
| expect(resolveURL("https://example.com/a/b/c", "/v1/admin/pipes").toString()).toBe( | ||
| "https://example.com/a/b/c/v1/admin/pipes", | ||
| ); | ||
| }); | ||
|
|
||
| it("accepts a path with or without a leading slash", () => { | ||
| expect(resolveURL("https://example.com/api", "v1/schema").toString()).toBe( | ||
| resolveURL("https://example.com/api", "/v1/schema").toString(), | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps the query string embedded in a path", () => { | ||
| expect(resolveURL("https://example.com/api", "/v1/ingest?table=clicks").toString()).toBe( | ||
| "https://example.com/api/v1/ingest?table=clicks", | ||
| ); | ||
| }); | ||
|
|
||
| it("appends params after the prefix", () => { | ||
| const url = resolveURL("https://example.com/api", "/v1/dlq/stats", { table: "clicks" }); | ||
| expect(url.toString()).toBe("https://example.com/api/v1/dlq/stats?table=clicks"); | ||
| }); | ||
|
|
||
| it("merges params with a query string already on the path", () => { | ||
| const url = resolveURL("https://example.com/api", "/v1/query?table=clicks", { limit: "10" }); | ||
| expect(url.pathname).toBe("/api/v1/query"); | ||
| expect(url.searchParams.get("table")).toBe("clicks"); | ||
| expect(url.searchParams.get("limit")).toBe("10"); | ||
| }); | ||
|
|
||
| it("percent-encodes param values", () => { | ||
| const url = resolveURL("https://example.com", "/v1/schema", { table: "a b&c" }); | ||
| expect(url.searchParams.get("table")).toBe("a b&c"); | ||
| expect(url.toString()).toContain("table=a+b%26c"); | ||
| }); | ||
|
|
||
| it("drops a query or fragment on the base rather than letting it eat the prefix", () => { | ||
| expect(resolveURL("https://example.com/api?stale=1", "/v1/query").toString()).toBe( | ||
| "https://example.com/api/v1/query", | ||
| ); | ||
| expect(resolveURL("https://example.com/api#frag", "/v1/query").toString()).toBe( | ||
| "https://example.com/api/v1/query", | ||
| ); | ||
| }); | ||
|
|
||
| it("preserves a non-default port", () => { | ||
| expect(resolveURL("https://example.com:8443/api", "/v1/query").toString()).toBe( | ||
| "https://example.com:8443/api/v1/query", | ||
| ); | ||
| }); | ||
|
|
||
| it("throws on a base with no scheme", () => { | ||
| expect(() => resolveURL("example.com/api", "/v1/query")).toThrow(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /** | ||
| * Resolve an SDK request path against the configured `baseURL`. | ||
| * | ||
| * The single URL-construction rule for every transport (REST in `http.ts`, SSE | ||
| * in `stream/sse.ts`, the codegen CLI). Request paths are joined **onto** the | ||
| * base rather than resolved as absolute ones, so a base carrying a path prefix | ||
| * — `https://app.example.com/api/warehouse`, a WaveHouse behind a BFF, | ||
| * app-server route, or path-routed ingress — keeps that prefix. Resolving | ||
| * `/v1/query` against such a base would silently discard it (#428). | ||
| * | ||
| * @internal | ||
| */ | ||
| export function resolveURL(base: string, path: string, params?: Record<string, string>): URL { | ||
| const root = new URL(base); | ||
| // Normalize the base to a directory: a bare last segment (or a query/fragment | ||
| // shadowing one) makes relative resolution *replace* the prefix, not extend it. | ||
| root.search = ""; | ||
| root.hash = ""; | ||
| if (!root.pathname.endsWith("/")) root.pathname += "/"; | ||
|
|
||
| const url = new URL(path.replace(/^\/+/, ""), root); | ||
| if (params) { | ||
| for (const [k, v] of Object.entries(params)) { | ||
| url.searchParams.set(k, v); | ||
| } | ||
| } | ||
| return url; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Separate REST exceptions from stream error delivery.
Line [132] says that the SDK throws for the listed errors, but the stream case reports
SSE_CONNECT_ERRORthrough a callback instead of throwing. The callback is optional, so the failure can be silent when no callback is supplied. Document the REST and stream behaviors in separate sentences.As per coding guidelines,
clients/ts/README.mdmust stay in sync with SDK-facing changes. Based on learnings,StreamSubscriber.erroris optional and a failed stream connection can be silent when that callback is absent.Proposed wording
Sources: Coding guidelines, Learnings