import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, RebaseApiError } from "@rebasepro/types";
import { serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
import { rebaseReviver } from "./reviver";
// The canonical client error now lives in `@rebasepro/types` so every package
// (client, auth, …) throws one type. Re-exported here to preserve the historical
// `import { RebaseApiError } from ".../transport"` path used across the SDK.
export { RebaseApiError } from "@rebasepro/types";
export type { RebaseErrorInit } from "@rebasepro/types";
import { RebaseClientError } from "@rebasepro/types";
export interface RebaseClientConfig {
/**
* Origin of the Rebase server — scheme, host and port **only**.
*
* {@link apiPath} is appended to this, so do not include it here:
* `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
* silently builds `/api/api/…` and every request 404s. Omit entirely for
* same-origin requests from the browser.
*/
baseUrl?: string;
/**
* Bearer token sent as `Authorization` on every request.
*
* In the browser this is the signed-in user's access token, so row-level
* security applies. Server-side callers — scripts, cron jobs, ETL — pass the
* service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
* and **bypasses RLS**: there is no user to constrain those queries, so scope
* them explicitly.
*/
token?: string;
/**
* Path the API is mounted under, appended to {@link baseUrl}.
* Defaults to `"/api"`; override only if the server mounts it elsewhere.
*/
apiPath?: string;
/**
* Origin to use instead of {@link baseUrl} for URLs that are handed to the
* browser to fetch on its own — storage file downloads and previews.
*
* API *requests* always go to `baseUrl`; this only changes URLs the SDK
* *returns* (e.g. `storage.getSignedUrl`). It exists for proxied setups:
* when `baseUrl` routes through an authenticated middleman (the Rebase
* console's Studio proxy), a plain `
` or a copied link cannot
* satisfy the middleman's auth — but the file route itself is reachable
* directly at the origin server and secured by its own scoped `?token=`.
* Set this to that server's public origin (no path; {@link apiPath} is
* appended) and returned file URLs point straight at it.
*/
storageUrlOrigin?: string;
fetch?: typeof globalThis.fetch;
onUnauthorized?: () => Promise;
websocketUrl?: string; // Optional real-time WebSocket connection
/**
* Open the realtime WebSocket. **Defaults to `true`.**
*
* The socket connects as soon as the client is constructed and keeps the
* Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
* exit on its own. Set this to `false` for any process that reads or writes
* and then terminates — `.listen()` and `.listenById()` then throw instead
* of silently doing nothing.
*
* Long-lived processes that do want realtime can instead call
* `client.close()` when shutting down.
*/
realtime?: boolean;
/**
* "Yes, I meant to be anonymous."
*
* Off-browser, a client with no credential can only ever call as an
* anonymous user, and row-level security answers it with whatever is
* public — usually nothing. That is almost always a mistake in a script or
* cron job, so the SDK warns once on the first request (see
* {@link ANONYMOUS_SERVER_CLIENT_WARNING}). Anonymous is a legitimate
* choice for public reads, though; set this to `true` to say so and
* silence the warning.
*
* Has no effect in the browser, where anonymous-before-sign-in is normal
* and nothing is ever warned about.
*/
anonymous?: boolean;
}
/**
* Facts about the surrounding client that the transport cannot read off its own
* config, but needs in order to decide whether a request is *meaningfully*
* credential-less.
*/
export interface TransportEnvironment {
/**
* The credential reaches the server without an `Authorization` header —
* i.e. `auth.authFlowMode: "cookie"`, where the refresh token lives in an
* httpOnly cookie. Such a client looks tokenless to the transport but is
* not anonymous, so it must never trip the guard.
*/
credentialOutOfBand?: boolean;
}
/**
* True when there is no browser to have signed a user in — a Node script, a
* cron job, an edge worker.
*
* Anonymous is an ordinary, correct state in a browser: before sign-in, on a
* marketing page, for public reads. Warning there would be noise that teaches
* people to ignore warnings, so the guard is off entirely. This uses the same
* `typeof window` test as {@link resolveBaseUrl}, and additionally treats a
* defined `document` as a browser so an SSR shim or test harness that installs
* only one of the two is still excluded.
*/
function isServerLikeEnvironment(): boolean {
return typeof window === "undefined" && typeof document === "undefined";
}
/**
* Emitted once per client. Kept as a constant so the wording is testable and
* greppable — this is the string a user will paste into a search.
*/
export const ANONYMOUS_SERVER_CLIENT_WARNING =
"[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, "
+ "and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only "
+ "publicly readable rows, which is usually nothing and occasionally the wrong thing. "
+ "Inside a cron or function handler, use the `client` you were handed instead of building a new one: its data "
+ "plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. "
+ "If you really do want anonymous access, pass `anonymous: true` to silence this.";
/**
* Re-export from `@rebasepro/types` for backward compatibility.
*
* Forwards the row type: without the parameter this alias flattened
* `FindParams` back to its `Record` default, and `where` /
* `orderBy` went back to accepting any column name — the alias, not the
* definition, was where the typing was lost.
*/
export type FindParams = Record> = TypesFindParams;
export type FindResponse = TypesFindResponse ? T : Record>;
/**
* Refuse a filter whose *value* is missing.
*
* `where: { status: ["==", undefined] }` used to serialize to the literal
* string, so `status=eq.undefined` went out on the wire and the server dutifully
* looked for rows whose status is the four-letter word "undefined". The caller
* saw an empty page, not an error — the classic shape of a variable that was
* never set.
*
* Dropping the condition instead would be worse than sending it: the query
* would come back *unfiltered*, which for an ownership or tenant filter means
* returning rows the caller never asked to see. So this is a hard error, and
* both correct spellings are named in the message: omit the key to skip the
* filter, or use `["is-null", null]` to match SQL NULL (which still
* serializes — `null` is a value, `undefined` is the absence of one).
*/
function assertNoUndefinedFilterValues(where: Record): void {
const reject = (field: string, op: unknown): never => {
throw new RebaseClientError(
`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). `
+ `Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`
);
};
for (const [field, condition] of Object.entries(where)) {
// An entirely absent condition is the documented way to skip a filter.
if (condition === undefined) continue;
if (!Array.isArray(condition)) continue;
// Either one `[op, value]` tuple or an array of them.
const tuples = Array.isArray(condition[0]) ? condition as unknown[][] : [condition as unknown[]];
for (const tuple of tuples) {
if (!Array.isArray(tuple) || tuple.length !== 2) continue;
const [op, value] = tuple;
if (value === undefined) reject(field, op);
// `["in", [...]]` — a hole in the list is the same mistake.
if (Array.isArray(value) && value.some(v => v === undefined)) reject(field, op);
}
}
}
export function buildQueryString(params?: FindParams): string {
if (!params) return "";
const parts: string[] = [];
if (params.limit != null) parts.push(`limit=${params.limit}`);
if (params.offset != null) parts.push(`offset=${params.offset}`);
if (params.page != null) parts.push(`page=${params.page}`);
if (params.orderBy) {
const wire = serializeOrderBy(params.orderBy);
if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
}
if (params.searchString) {
parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
if (params.searchExplain) parts.push("searchExplain=true");
}
// The server keys vector search off `vector_search` naming the property and
// `vector` carrying the embedding as a JSON array; both must be present or
// it ignores the pair entirely.
if (params.vectorSearch) {
const vs = params.vectorSearch;
parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
if (vs.threshold !== undefined) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
}
if (params.include && params.include.length > 0) {
parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
}
if (params.logical) {
const root = params.logical;
const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
}
if (params.where) {
assertNoUndefinedFilterValues(params.where);
const serialized = serializeFilter(params.where);
for (const [field, value] of Object.entries(serialized)) {
if (Array.isArray(value)) {
for (const v of value) {
parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
}
} else {
parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
}
}
}
return parts.length > 0 ? "?" + parts.join("&") : "";
}
export interface Transport {
request: (path: string, init?: RequestInit) => Promise;
setToken: (newToken: string | null) => void;
setAuthTokenGetter: (getter: () => Promise) => void;
setOnUnauthorized: (handler: () => Promise) => void;
readonly baseUrl: string;
readonly apiPath: string;
/** See {@link RebaseClientConfig.storageUrlOrigin}. Undefined = use `baseUrl`. */
readonly storageUrlOrigin?: string;
readonly fetchFn: typeof globalThis.fetch;
getHeaders: (init?: RequestInit) => Record;
resolveToken: () => Promise;
}
/**
* The base every request and every caller-built URL resolves against.
*
* `baseUrl` is optional because the common production shape is a Rebase
* backend serving its own SPA, where the API is simply the page's origin.
* Leaving it unset is therefore the *correct* configuration there — and the
* one that keeps working when a second hostname (a custom domain) points at
* the same app.
*
* When unset in a browser this resolves to the page origin rather than "".
* Requests behave identically either way, but the empty string is a trap for
* anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
* throws, so apps "fixed" it by baking an absolute host into their bundle —
* which is exactly what breaks the day a custom domain is added, and which no
* amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
* not sent cross-site either.
*/
function resolveBaseUrl(configured?: string): string {
if (configured) return configured.replace(/\/$/, "");
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
return "";
}
export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
const fetchFn = config.fetch || globalThis.fetch;
const apiPath = config.apiPath || "/api";
// `apiPath` is appended to `baseUrl`, so a `baseUrl` that already ends in it
// builds `/api/api/…` and every request 404s. That was documented on
// `baseUrl` and left to be discovered at runtime — including by this
// package's own tests, which configured it that way a dozen times. A 404 on
// every call looks like a server that is down, not like a doubled path.
// `storageUrlOrigin` is checked alongside it because `storage.ts` composes
// it the same way — `${storageUrlOrigin ?? baseUrl}${apiPath}` — and its own
// docblock carries the same "no path" caveat.
for (const field of ["baseUrl", "storageUrlOrigin"] as const) {
const value = config[field];
if (!value || !apiPath) continue;
const trimmed = value.replace(/\/+$/, "");
if (!trimmed.endsWith(apiPath)) continue;
console.warn(
`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ` +
`${JSON.stringify(apiPath)}, which is appended to it — requests will go to ` +
`${trimmed}${apiPath}/… and 404. Pass the origin only ` +
`(${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set ` +
"`apiPath` if the server really does mount the API one level deeper."
);
}
let token = config.token;
let tokenGetter: (() => Promise) | undefined;
let onUnauthorizedHandler = config.onUnauthorized;
/** Once per client, never per request — log spam is its own bug. */
let anonymousWarningIssued = false;
/**
* Warn a server-side caller that it built a client that can only ever be
* anonymous. Deliberately checked at the *first request* rather than at
* construction: `setToken()` / `setAuthTokenGetter()` and a server-side
* `auth.signIn…()` (which calls `transport.setToken`) all land after the
* constructor, and warning at construction would fire on every one of them.
*/
function warnIfAnonymousServerClient(activeToken: string | undefined): void {
if (anonymousWarningIssued) return;
if (activeToken) return; // a credential is being sent
if (tokenGetter) return; // a credential is being fetched per request
if (config.anonymous) return; // "yes, I meant this"
if (environment?.credentialOutOfBand) return; // cookie auth flow — credential is not a header
if (!isServerLikeEnvironment()) return; // browsers are legitimately anonymous
anonymousWarningIssued = true;
console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
}
function getHeaders(activeToken: string | undefined, init?: RequestInit) {
return {
"Content-Type": "application/json",
...(activeToken ? { Authorization: `Bearer ${activeToken}` } : {}),
...((init?.headers as Record) || {})
};
}
/**
* The refusal for a success status carrying a body this client cannot read.
*
* The first 120 characters go in the message because they identify the
* sender at a glance: `` says "you are talking to a web
* server, not to this API" faster than any wording here could.
*
* One function for both the first attempt and the post-refresh retry — the
* retry is a second copy of this whole response-reading path, and copies
* are how one of them ends up fixed and the other not.
*/
function unreadableResponse(status: number, text: string): RebaseApiError {
return new RebaseApiError(
`The server answered ${status} with a body that is not JSON, so there is nothing to return. ` +
"This usually means the request reached something other than the Rebase API — a single-page-app " +
"fallback serving index.html, or a proxy error page — so check the API URL configuration " +
`(e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`,
{ status, code: "INVALID_JSON_RESPONSE" }
);
}
async function request(path: string, init?: RequestInit): Promise {
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
let activeToken = token;
if (tokenGetter) {
try {
const fetched = await tokenGetter();
if (fetched !== null && fetched !== undefined) {
activeToken = fetched;
}
} catch (e) {
// Ignore error, fallback to static token if any
}
}
warnIfAnonymousServerClient(activeToken);
const headers = getHeaders(activeToken, init);
// If passing FormData, we MUST let fetch set the boundary, so remove Content-Type
if (init?.body instanceof FormData) {
delete (headers as Record)["Content-Type"];
}
const res = await fetchFn(url, { ...init,
headers });
if (res.status === 204) return undefined as T; // SAFETY: HTTP 204 No Content has no body
const text = await res.text().catch(() => "");
let body: Record = {};
/**
* Whether the body was there and could not be read as JSON.
*
* On an error status this does not matter — the status is the answer
* and the message falls back to `statusText`. On a *success* status it
* is the whole answer, and `{}` was being returned as though the server
* had sent it: `find()` answered `{}` instead of an array, `getOne()`
* an empty object, with nothing thrown.
*
* The case that produces it is not exotic. Point `VITE_API_URL` at the
* frontend's own host and `/api/data/posts` lands on the SPA fallback,
* which answers `200` with `index.html` — so the misconfiguration the
* 404 branch below spends four lines explaining reaches the caller, in
* its most common form, as an empty success.
*/
let unreadableBody = false;
if (text) {
try {
body = JSON.parse(text, rebaseReviver) as Record;
} catch (e) {
unreadableBody = true;
}
}
// The server always emits the canonical `{ error: { message, code, details? } }`
// envelope (formatted by the central errorHandler), so we read strictly
// from `body.error.*`.
const getErrorField = (obj: Record, field: string): unknown => {
const err = obj?.error;
if (err && typeof err === "object" && err !== null) {
return (err as Record)[field];
}
return undefined;
};
if (res.status === 401 && onUnauthorizedHandler) {
const retried = await onUnauthorizedHandler();
if (retried) {
let retryToken = token;
if (tokenGetter) {
try {
const fetched = await tokenGetter();
if (fetched !== null && fetched !== undefined) {
retryToken = fetched;
}
} catch (e) { /* ignore */ }
}
const retryHeaders = getHeaders(retryToken, init) as Record;
const retryRes = await fetchFn(url, { ...init,
headers: retryHeaders });
if (retryRes.status === 204) return undefined as T; // SAFETY: HTTP 204 No Content has no body
const retryText = await retryRes.text().catch(() => "");
let retryBody: Record = {};
let retryUnreadable = false;
if (retryText) {
try {
retryBody = JSON.parse(retryText, rebaseReviver);
} catch (e) {
retryUnreadable = true;
}
}
if (!retryRes.ok) {
let fallbackMessage = retryRes.statusText;
if (retryRes.status === 404 && !fallbackMessage) {
const method = init?.method || "GET";
fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
}
throw new RebaseApiError(
String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`),
{
status: retryRes.status,
code: getErrorField(retryBody, "code") as string | undefined,
details: getErrorField(retryBody, "details")
}
);
}
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
return retryBody as T;
}
}
if (!res.ok) {
let fallbackMessage = res.statusText;
if (res.status === 404 && !fallbackMessage) {
const method = init?.method || "GET";
fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
}
throw new RebaseApiError(
String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`),
{
status: res.status,
code: getErrorField(body, "code") as string | undefined,
details: getErrorField(body, "details")
}
);
}
if (unreadableBody) throw unreadableResponse(res.status, text);
return body as T;
}
return {
request,
setToken(newToken: string | null) { token = newToken || undefined; },
setAuthTokenGetter(getter: () => Promise) { tokenGetter = getter; },
setOnUnauthorized(handler: () => Promise) { onUnauthorizedHandler = handler; },
get baseUrl() { return resolveBaseUrl(config.baseUrl); },
get apiPath() { return apiPath; },
get storageUrlOrigin() { return config.storageUrlOrigin?.replace(/\/$/, "") || undefined; },
get fetchFn() { return fetchFn; },
getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record,
resolveToken: async () => {
if (tokenGetter) {
try {
const fetched = await tokenGetter();
if (fetched !== null && fetched !== undefined) {
return fetched;
}
} catch (e) { /* ignore */ }
}
return token || null;
}
};
}