TS client: Break up files, add a vite build step and allow injecting a custom Transport interface. #222

This commit is contained in:
Sebastian Jeltsch
2026-03-20 12:24:31 +01:00
parent 2599c8c0ce
commit 3f52065452
16 changed files with 1325 additions and 1246 deletions
@@ -30,7 +30,7 @@ export function TotpToggleButton(props: { client: Client; user: User }) {
try {
const res = await props.client.registerTOTP({ png: true });
setTotp({
url: res.totp_url,
url: res.url,
png: res.png ?? "",
});
} catch (err) {
+1
View File
@@ -0,0 +1 @@
../bindings
+16 -15
View File
@@ -2,18 +2,18 @@
"name": "trailbase",
"description": "Official TrailBase Client",
"homepage": "https://trailbase.io",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0 OR OSL-3.0",
"type": "module",
"main": "./src/index.ts",
"publishConfig": {
"access": "public",
"main": "./dist/client/src/index.js",
"types": "/dist/client/src/index.d.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"default": "./dist/client/src/index.js",
"types": "./dist/client/src/index.d.ts"
"default": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
},
@@ -27,13 +27,19 @@
"directory": "crates/assets/js/client"
},
"scripts": {
"build": "tsc",
"build": "vite build",
"check": "tsc --noEmit --skipLibCheck && eslint",
"format": "prettier -w src tests",
"prepack": "rm -rf ./dist && pnpm build && test -e ./dist/client/src/index.js",
"start": "tsc && node dist/client/src/index.js",
"prepack": "rm -rf ./dist && pnpm build && test -e ./dist/index.js",
"test": "vitest run && vite-node tests/integration_test_runner.ts"
},
"dependencies": {
"@types/geojson": "^7946.0.16",
"@ungap/raw-json": "0.4.4",
"geojson": "^0.5.0",
"jwt-decode": "^4.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.0.3",
@@ -47,14 +53,9 @@
"tinybench": "^6.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1",
"vite-node": "^6.0.0",
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.1.0"
},
"dependencies": {
"@types/geojson": "^7946.0.16",
"@ungap/raw-json": "0.4.4",
"geojson": "^0.5.0",
"jwt-decode": "^4.0.0",
"uuid": "^13.0.0"
}
}
+606
View File
@@ -0,0 +1,606 @@
import { jwtDecode } from "jwt-decode";
import * as JSON from "@ungap/raw-json";
import { isDev } from "./constants";
import { jsonContentTypeHeader } from "./constants";
import { parseJSON } from "./json";
import type {
RecordApi,
RecordId,
CreateOperation,
UpdateOperation,
DeleteOperation,
} from "./record_api";
import { RecordApiImpl } from "./record_api";
import { ThinClient, Transport } from "./transport";
export type { Transport } from "./transport";
import type { ChangeEmailRequest } from "@bindings/ChangeEmailRequest";
import type { RequestOtpRequest } from "@bindings/RequestOtpRequest";
import type { LoginOtpRequest } from "@bindings/LoginOtpRequest";
import type { RegisterTotpResponse } from "@bindings/RegisterTotpResponse";
import type { ConfirmRegisterTotpRequest } from "@bindings/ConfirmRegisterTotpRequest";
import type { DisableTotpRequest } from "@bindings/DisableTotpRequest";
import type { MfaTokenResponse } from "@bindings/MfaTokenResponse";
import type { LoginRequest } from "@bindings/LoginRequest";
import type { LoginMfaRequest } from "@bindings/LoginMfaRequest";
import type { LoginResponse } from "@bindings/LoginResponse";
import type { LoginStatusResponse } from "@bindings/LoginStatusResponse";
import type { LogoutRequest } from "@bindings/LogoutRequest";
import type { RefreshRequest } from "@bindings/RefreshRequest";
import type { RefreshResponse } from "@bindings/RefreshResponse";
export type User = {
id: string;
email: string;
admin?: boolean;
mfa?: boolean;
};
export interface MultiFactorAuthToken {
token: string;
}
export type RegisterTotp = { url: string; png: string | null };
export type Tokens = {
auth_token: string;
refresh_token: string | null;
csrf_token: string | null;
};
type TokenClaims = {
sub: string;
iat: number;
exp: number;
email: string;
csrf_token: string;
admin?: boolean;
mfa?: boolean;
};
type TokenState = {
state?: {
tokens: Tokens;
claims: TokenClaims;
};
headers: HeadersInit;
};
export type Event =
| { Insert: object }
| { Update: object }
| { Delete: object }
| { Error: string };
function buildTokenState(tokens?: Tokens): TokenState {
return {
state: tokens && {
tokens,
claims: jwtDecode(tokens.auth_token),
},
headers: headers(tokens),
};
}
function buildUser(state: TokenState): User | undefined {
const claims = state.state?.claims;
if (claims) {
return {
id: claims.sub,
email: claims.email,
admin: claims.admin,
mfa: claims.mfa,
};
}
}
function isExpired(state: TokenState): boolean {
const claims = state.state?.claims;
if (claims) {
const now = Date.now() / 1000;
if (claims.exp < now) {
return true;
}
}
return false;
}
/// Returns the refresh token if should refresh.
function shouldRefresh(tokenState: TokenState): string | undefined {
const state = tokenState.state;
if (state && state.claims.exp - 60 < Date.now() / 1000) {
return state.tokens?.refresh_token ?? undefined;
}
}
export type FetchOptions = RequestInit & {
throwOnError?: boolean;
};
export class FetchError extends Error {
public status: number;
public url: string | URL | undefined;
constructor(status: number, msg: string, url?: string | URL) {
super(msg);
this.status = status;
this.url = url;
}
static async from(
response: Response,
url?: string | URL,
): Promise<FetchError> {
// Some IntoResponse implementations return a body, e.g. RecordError::BadRequest.
const msg: string = await response.text().then(
(b) => (b !== "" ? b : response.statusText),
(_err) => response.statusText,
);
return new FetchError(response.status, msg, url);
}
public isClient(): boolean {
return this.status >= 400 && this.status < 500;
}
public isServer(): boolean {
return this.status >= 500;
}
public toString(): string {
return `FetchError(${[this.status, this.message, this.url].filter((e) => e !== undefined).join(", ")})`;
}
}
export interface ClientOptions {
tokens?: Tokens;
onAuthChange?: (client: Client, user?: User) => void;
transport?: Transport;
}
export interface Client {
get base(): URL | undefined;
/// Low-level access to tokens (auth, refresh, csrf) useful for persisting them.
tokens(): Tokens | undefined;
/// Provides current user.
user(): User | undefined;
/// Provides current user.
headers(): HeadersInit;
/// Construct accessor for Record API with given name.
records<T = Record<string, unknown>>(name: string): RecordApi<T>;
avatarUrl(userId?: string): string | undefined;
login(
email: string,
password: string,
): Promise<MultiFactorAuthToken | undefined>;
loginSecond(opts: {
mfaToken: MultiFactorAuthToken;
totpCode: string;
}): Promise<void>;
requestOtp(email: string, opts?: { redirectUri?: string }): Promise<void>;
loginOtp(email: string, code: string): Promise<void>;
logout(): Promise<boolean>;
registerTOTP(opts?: { png: boolean }): Promise<RegisterTotp>;
confirmTOTP(totpUrl: string, totp: string): Promise<void>;
unregisterTOTP(totp: string): Promise<void>;
deleteUser(): Promise<void>;
checkCookies(): Promise<Tokens | undefined>;
refreshAuthToken(): Promise<void>;
/// Fetches data from TrailBase endpoints, e.g.:
/// const response = await client.fetch("/api/auth/v1/status");
///
/// Unlike native fetch, will throw in case !response.ok.
fetch(path: string, init?: FetchOptions): Promise<Response>;
/// Execute a batch query.
execute(
operations: (CreateOperation | UpdateOperation | DeleteOperation)[],
transaction?: boolean,
): Promise<RecordId[]>;
}
/// Client for interacting with TrailBase auth and record APIs.
class ClientImpl implements Client {
private readonly _base: URL | undefined;
private readonly _client: Transport;
private readonly _authChange:
| undefined
| ((client: Client, user?: User) => void);
private _tokenState: TokenState;
constructor(baseUrl: URL | string | undefined, opts?: ClientOptions) {
this._base = baseUrl ? new URL(baseUrl) : undefined;
this._client = opts?.transport ?? new ThinClient(this._base);
this._authChange = opts?.onAuthChange;
const tokens = opts?.tokens;
// Note: this is a double assignment to _tokenState to ensure the linter
// that it's really initialized in the constructor.
this._tokenState = this.setTokenState(buildTokenState(tokens), true);
if (tokens?.refresh_token !== undefined) {
// Validate session. This is currently async, which allows to initialize
// a Client synchronously from invalid tokens. We may want to consider
// offering a safer async initializer to avoid "racy" behavior. Especially,
// when the auth token is valid while the session has already been closed.
this.checkAuthStatus()
.then((tokens) => {
if (tokens === undefined) {
// In this case, the auth state has changed, so we should invoke the callback.
this.setTokenState(buildTokenState(undefined), false);
} else {
// In this case, the auth state has remained the same, we're merely
// updating the reminted auth token.
this.setTokenState(buildTokenState(tokens), true);
}
})
.catch(console.error);
}
}
public get base(): URL | undefined {
return this._base;
}
/// Low-level access to tokens (auth, refresh, csrf) useful for persisting them.
public tokens = (): Tokens | undefined => this._tokenState?.state?.tokens;
/// Provides current user.
public user = (): User | undefined => buildUser(this._tokenState);
/// Provides current user.
public headers = (): HeadersInit => this._tokenState.headers;
/// Construct accessor for Record API with given name.
public records<T = Record<string, unknown>>(name: string): RecordApi<T> {
return new RecordApiImpl<T>(this, name);
}
/// Execute a batch query.
async execute(
operations: (CreateOperation | UpdateOperation | DeleteOperation)[],
transaction: boolean = true,
): Promise<RecordId[]> {
const response = await this.fetch(transactionApiBasePath, {
method: "POST",
body: JSON.stringify({ operations, transaction }),
headers: jsonContentTypeHeader,
});
return parseJSON(await response.text()).ids;
}
public avatarUrl(userId?: string): string | undefined {
const id = userId ?? this.user()?.id;
if (id) {
return `${authApiBasePath}/avatar/${id}`;
}
return undefined;
}
public async login(
email: string,
password: string,
): Promise<MultiFactorAuthToken | undefined> {
try {
const response = await this.fetch(`${authApiBasePath}/login`, {
method: "POST",
body: JSON.stringify({
email,
password,
} as LoginRequest),
headers: jsonContentTypeHeader,
});
this.setTokenState(
buildTokenState((await response.json()) as LoginResponse),
);
} catch (err) {
if (err instanceof FetchError && err.status === 403) {
const mfaTokenResponse = JSON.parse(err.message) as MfaTokenResponse;
return {
token: mfaTokenResponse.mfa_token,
};
}
throw err;
}
}
public async loginSecond(opts: {
mfaToken: MultiFactorAuthToken;
totpCode: string;
}): Promise<void> {
const response = await this.fetch(`${authApiBasePath}/login_mfa`, {
method: "POST",
body: JSON.stringify({
mfa_token: opts.mfaToken.token,
totp: opts.totpCode,
} as LoginMfaRequest),
headers: jsonContentTypeHeader,
});
this.setTokenState(
buildTokenState((await response.json()) as LoginResponse),
);
}
public async requestOtp(
email: string,
opts?: { redirectUri?: string },
): Promise<void> {
const redirect = opts?.redirectUri;
const params = redirect ? `?redirect_uri=${redirect}` : "";
await this.fetch(`${authApiBasePath}/otp/request${params}`, {
method: "POST",
body: JSON.stringify({
email,
} as RequestOtpRequest),
headers: jsonContentTypeHeader,
});
}
public async loginOtp(email: string, code: string): Promise<void> {
const response = await this.fetch(`${authApiBasePath}/otp/login`, {
method: "POST",
body: JSON.stringify({
email,
code,
} as LoginOtpRequest),
headers: jsonContentTypeHeader,
});
this.setTokenState(
buildTokenState((await response.json()) as LoginResponse),
);
}
public async logout(): Promise<boolean> {
try {
const refresh_token = this._tokenState.state?.tokens.refresh_token;
if (refresh_token) {
await this.fetch(`${authApiBasePath}/logout`, {
method: "POST",
body: JSON.stringify({
refresh_token,
} as LogoutRequest),
headers: jsonContentTypeHeader,
});
} else {
await this.fetch(`${authApiBasePath}/logout`);
}
} catch (err) {
console.debug(err);
}
this.setTokenState(buildTokenState(undefined));
return true;
}
public async deleteUser(): Promise<void> {
await this.fetch(`${authApiBasePath}/delete`);
this.setTokenState(buildTokenState(undefined));
}
public async changeEmail(email: string): Promise<void> {
await this.fetch(`${authApiBasePath}/change_email`, {
method: "POST",
body: JSON.stringify({
new_email: email,
} as ChangeEmailRequest),
headers: jsonContentTypeHeader,
});
}
public async registerTOTP(opts?: { png: boolean }): Promise<RegisterTotp> {
const response = await this.fetch(
`${authApiBasePath}/totp/register?png=${opts?.png ?? false}`,
{
method: "GET",
headers: jsonContentTypeHeader,
},
);
const parsed: RegisterTotpResponse = parseJSON(await response.text());
return {
url: parsed.totp_url,
png: parsed.png,
};
}
public async confirmTOTP(totpUrl: string, totp: string): Promise<void> {
await this.fetch(`${authApiBasePath}/totp/confirm`, {
method: "POST",
body: JSON.stringify({
totp_url: totpUrl,
totp,
} as ConfirmRegisterTotpRequest),
headers: jsonContentTypeHeader,
});
await this.refreshAuthToken({ force: true });
}
public async unregisterTOTP(totp: string): Promise<void> {
await this.fetch(`${authApiBasePath}/totp/unregister`, {
method: "POST",
body: JSON.stringify({
totp,
} as DisableTotpRequest),
headers: jsonContentTypeHeader,
});
await this.refreshAuthToken({ force: true });
}
/// This will call the status endpoint, which validates any provided tokens
/// but also hoists any tokens provided as cookies into a JSON response.
private async checkAuthStatus(): Promise<Tokens | undefined> {
const response = await this.fetch(`${authApiBasePath}/status`, {
throwOnError: false,
});
if (response.ok) {
const status: LoginStatusResponse = await response.json();
const auth_token = status.auth_token;
if (auth_token) {
return {
auth_token,
refresh_token: status.refresh_token,
csrf_token: status.csrf_token,
};
}
}
return undefined;
}
public async checkCookies(): Promise<Tokens | undefined> {
const tokens = await this.checkAuthStatus();
if (tokens) {
const newState = buildTokenState(tokens);
this.setTokenState(newState);
return newState.state?.tokens;
}
}
public async refreshAuthToken(opts?: { force?: boolean }): Promise<void> {
const force = opts?.force ?? false;
const refreshToken = force
? this._tokenState.state?.tokens.refresh_token
: shouldRefresh(this._tokenState);
if (refreshToken) {
// Note: refreshTokenImpl will auto-logout on 401.
this.setTokenState(await this.refreshTokensImpl(refreshToken));
}
}
private async refreshTokensImpl(refreshToken: string): Promise<TokenState> {
const path = `${authApiBasePath}/refresh`;
const response = await this._client.fetch(path, this._tokenState.headers, {
method: "POST",
body: JSON.stringify({
refresh_token: refreshToken,
} as RefreshRequest),
headers: jsonContentTypeHeader,
});
if (!response.ok) {
if (response.status === 401) {
this.logout();
}
throw await FetchError.from(
response,
isDev ? new URL(path, this.base) : undefined,
);
}
return buildTokenState({
...((await response.json()) as RefreshResponse),
refresh_token: refreshToken,
});
}
private setTokenState(
state: TokenState,
skipCb: boolean = false,
): TokenState {
this._tokenState = state;
if (!skipCb) {
this._authChange?.(this, buildUser(state));
}
if (isExpired(state)) {
// This can happen on initial construction, i.e. if a client is
// constructed from older, persisted tokens.
console.debug(`Set token state (expired)`);
}
return this._tokenState;
}
/// Fetches data from TrailBase endpoints, e.g.:
/// const response = await client.fetch("/api/auth/v1/status");
///
/// Unlike native fetch, will throw in case !response.ok.
public async fetch(path: string, init?: FetchOptions): Promise<Response> {
let tokenState = this._tokenState;
const refreshToken = shouldRefresh(tokenState);
if (refreshToken) {
tokenState = this.setTokenState(
await this.refreshTokensImpl(refreshToken),
);
}
try {
const response = await this._client.fetch(path, tokenState.headers, init);
if (!response.ok && (init?.throwOnError ?? true)) {
throw await FetchError.from(
response,
isDev ? new URL(path, this.base) : undefined,
);
}
return response;
} catch (err) {
if (err instanceof TypeError) {
console.debug(`Connection refused ${err}. TrailBase down or CORS?`);
}
throw err;
}
}
}
/// Initialize a new TrailBase client.
export function initClient(site?: URL | string, opts?: ClientOptions): Client {
return new ClientImpl(site, opts);
}
/// Asynchronously initialize a new TrailBase client trying to convert any
/// potentially existing cookies into an authenticated client.
export async function initClientFromCookies(
site?: URL | string,
opts?: ClientOptions,
): Promise<Client> {
const client = new ClientImpl(site, opts);
// Prefer explicit tokens. When given, do not update/refresh infinite recursion
// with `($token) => Client` factories.
if (!client.tokens()) {
try {
await client.checkCookies();
} catch (err) {
console.debug("No valid cookies found: ", err);
}
}
return client;
}
const authApiBasePath = "/api/auth/v1";
const transactionApiBasePath = "/api/transaction/v1/execute";
function headers(tokens?: Tokens): HeadersInit {
if (tokens) {
const { auth_token, refresh_token, csrf_token } = tokens;
return {
...(auth_token && {
Authorization: `Bearer ${auth_token}`,
}),
...(refresh_token && {
"Refresh-Token": refresh_token,
}),
...(csrf_token && {
"CSRF-Token": csrf_token,
}),
};
}
return {};
}
+16
View File
@@ -0,0 +1,16 @@
export const jsonContentTypeHeader = {
"Content-Type": "application/json",
};
function _isDev(): boolean {
type ImportMeta = {
env: object | undefined;
};
const env = (import.meta as unknown as ImportMeta).env;
const key = "DEV" as keyof typeof env;
const isDev = env?.[key] ?? false;
return isDev;
}
export const isDev = _isDev();
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
import * as JSON from "@ungap/raw-json";
// BigInt JSON stringify/parse shenanigans.
declare global {
interface BigInt {
toJSON(): unknown;
}
}
BigInt.prototype.toJSON = function () {
return JSON.rawJSON(this.toString());
};
export function parseJSON(text: string) {
function reviver(_key: string, value: unknown, context: { source: string }) {
if (
typeof value === "number" &&
Number.isInteger(value) &&
!Number.isSafeInteger(value)
) {
// Ignore the value because it has already lost precision
return BigInt(context.source);
}
return value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return JSON.parse(text, reviver as any);
}
+552
View File
@@ -0,0 +1,552 @@
import * as JSON from "@ungap/raw-json";
import { FeatureCollection } from "geojson";
import { isDev, jsonContentTypeHeader } from "./constants";
import { parseJSON } from "./json";
import { Client } from "./client";
import type { WsProtocol } from "@bindings/WsProtocol";
export interface FileUpload {
content_type?: null | string;
filename?: null | string;
mime_type?: null | string;
objectstore_path: string;
}
export type CompareOp =
| "equal"
| "notEqual"
| "lessThan"
| "lessThanEqual"
| "greaterThan"
| "greaterThanEqual"
| "like"
| "regexp"
| "@within"
| "@intersects"
| "@contains";
function formatCompareOp(op: CompareOp): string {
switch (op) {
case "equal":
return "$eq";
case "notEqual":
return "$ne";
case "lessThan":
return "$lt";
case "lessThanEqual":
return "$lte";
case "greaterThan":
return "$gt";
case "greaterThanEqual":
return "$gte";
case "like":
return "$like";
case "regexp":
return "$re";
// Geospatials:
case "@within":
case "@intersects":
case "@contains":
return op;
}
}
export type Filter = {
column: string;
op?: CompareOp;
value: string;
};
export type And = {
and: FilterOrComposite[];
};
export type Or = {
or: FilterOrComposite[];
};
export type FilterOrComposite = Filter | And | Or;
export type RecordId = string | number;
// TODO: Use `ts-rs` generated types.
interface CreateOp {
Create: {
api_name: string;
value: Record<string, unknown>;
};
}
interface UpdateOp {
Update: {
api_name: string;
record_id: RecordId;
value: Record<string, unknown>;
};
}
interface DeleteOp {
Delete: {
api_name: string;
record_id: RecordId;
};
}
export interface DeferredOperation<ResponseType> {
query(): Promise<ResponseType>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DeferredMutation<
ResponseType,
> extends DeferredOperation<ResponseType> {}
export class CreateOperation<
T = Record<string, unknown>,
> implements DeferredMutation<RecordId> {
constructor(
private readonly client: Client,
private readonly apiName: string,
private readonly record: Partial<T>,
) {}
async query(): Promise<RecordId> {
const response = await this.client.fetch(
`${recordApiBasePath}/${this.apiName}`,
{
method: "POST",
body: JSON.stringify(this.record),
headers: jsonContentTypeHeader,
},
);
return parseJSON(await response.text()).ids[0];
}
protected toJSON(): CreateOp {
return {
Create: {
api_name: this.apiName,
value: this.record,
},
};
}
}
export class UpdateOperation<
T = Record<string, unknown>,
> implements DeferredMutation<void> {
constructor(
private readonly client: Client,
private readonly apiName: string,
private readonly id: RecordId,
private readonly record: Partial<T>,
) {}
async query(): Promise<void> {
await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, {
method: "PATCH",
body: JSON.stringify(this.record),
headers: jsonContentTypeHeader,
});
}
protected toJSON(): UpdateOp {
return {
Update: {
api_name: this.apiName,
record_id: this.id,
value: this.record,
},
};
}
}
export class DeleteOperation implements DeferredMutation<void> {
constructor(
private readonly client: Client,
private readonly apiName: string,
private readonly id: RecordId,
) {}
async query(): Promise<void> {
await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, {
method: "DELETE",
});
}
protected toJSON(): DeleteOp {
return {
Delete: {
api_name: this.apiName,
record_id: this.id,
},
};
}
}
export interface ReadOpts {
expand?: string[];
}
export class ReadOperation<
T = Record<string, unknown>,
> implements DeferredOperation<T> {
constructor(
private readonly client: Client,
private readonly apiName: string,
private readonly id: RecordId,
private readonly opt?: ReadOpts,
) {}
async query(): Promise<T> {
const expand = this.opt?.expand;
const response = await this.client.fetch(
expand
? `${recordApiBasePath}/${this.apiName}/${this.id}?expand=${expand.join(",")}`
: `${recordApiBasePath}/${this.apiName}/${this.id}`,
);
return parseJSON(await response.text()) as T;
}
}
export type Pagination = {
cursor?: string;
limit?: number;
offset?: number;
};
export type ListResponse<T> = {
cursor?: string;
records: T[];
total_count?: number;
};
export interface ListOpts {
pagination?: Pagination;
order?: string[];
filters?: FilterOrComposite[];
count?: boolean;
expand?: string[];
}
export class ListOperation<
T = Record<string, unknown>,
R = ListResponse<T>,
> implements DeferredOperation<R> {
constructor(
private readonly client: Client,
private readonly apiName: string,
private readonly opts?: ListOpts,
private readonly geojson?: string,
) {}
async query(): Promise<R> {
const params = new URLSearchParams();
const pagination = this.opts?.pagination;
if (pagination) {
const cursor = pagination.cursor;
if (cursor) params.append("cursor", cursor);
const limit = pagination.limit;
if (limit) params.append("limit", limit.toString());
const offset = pagination.offset;
if (offset) params.append("offset", offset.toString());
}
const order = this.opts?.order;
if (order) params.append("order", order.join(","));
if (this.opts?.count) params.append("count", "true");
const expand = this.opts?.expand;
if (expand) params.append("expand", expand.join(","));
const filters = this.opts?.filters;
if (filters) {
for (const filter of filters) {
addFiltersToParams(params, "filter", filter);
}
}
if (this.geojson) params.append("geojson", this.geojson);
const response = await this.client.fetch(
`${recordApiBasePath}/${this.apiName}?${params}`,
);
return parseJSON(await response.text()) as R;
}
}
export interface SubscribeOpts {
filters?: FilterOrComposite[];
}
export interface RecordApi<T = Record<string, unknown>> {
list(opts?: ListOpts): Promise<ListResponse<T>>;
listOp(opts?: ListOpts): ListOperation<T>;
// For queries on TABLE/VIEWs with geometry columns wantin to return GeoJSON.
listGeoOp(
geometryColumn: string,
opts?: ListOpts,
): ListOperation<T, FeatureCollection>;
read(id: RecordId, opt?: ReadOpts): Promise<T>;
readOp(id: RecordId, opt?: ReadOpts): ReadOperation<T>;
create(record: T): Promise<RecordId>;
createOp(record: T): CreateOperation<T>;
// TODO: Retire in favor of `client.execute`.
createBulk(records: T[]): Promise<RecordId[]>;
update(id: RecordId, record: Partial<T>): Promise<void>;
updateOp(id: RecordId, record: Partial<T>): UpdateOperation;
delete(id: RecordId): Promise<void>;
deleteOp(id: RecordId): DeleteOperation;
subscribe(id: RecordId): Promise<ReadableStream<Event>>;
subscribeAll(opts?: SubscribeOpts): Promise<ReadableStream<Event>>;
}
/// Provides CRUD access to records through TrailBase's record API.
export class RecordApiImpl<
T = Record<string, unknown>,
> implements RecordApi<T> {
constructor(
private readonly client: Client,
private readonly name: string,
) {}
public async list(opts?: ListOpts): Promise<ListResponse<T>> {
return new ListOperation<T>(this.client, this.name, opts).query();
}
public listOp(opts?: ListOpts): ListOperation<T> {
return new ListOperation<T>(this.client, this.name, opts);
}
public listGeoOp(
geometryColumn: string,
opts?: ListOpts,
): ListOperation<T, FeatureCollection> {
return new ListOperation<T, FeatureCollection>(
this.client,
this.name,
opts,
geometryColumn,
);
}
public async read<T = Record<string, unknown>>(
id: RecordId,
opt?: ReadOpts,
): Promise<T> {
return new ReadOperation<T>(this.client, this.name, id, opt).query();
}
public readOp(id: RecordId, opt?: ReadOpts): ReadOperation<T> {
return new ReadOperation<T>(this.client, this.name, id, opt);
}
public async create(record: T): Promise<RecordId> {
return new CreateOperation<T>(this.client, this.name, record).query();
}
public createOp(record: T): CreateOperation<T> {
return new CreateOperation<T>(this.client, this.name, record);
}
public async createBulk<T = Record<string, unknown>>(
records: T[],
): Promise<RecordId[]> {
const response = await this.client.fetch(
`${recordApiBasePath}/${this.name}`,
{
method: "POST",
body: JSON.stringify(records),
headers: jsonContentTypeHeader,
},
);
return parseJSON(await response.text()).ids;
}
public async update(id: RecordId, record: Partial<T>): Promise<void> {
return new UpdateOperation<T>(this.client, this.name, id, record).query();
}
public updateOp(id: RecordId, record: Partial<T>): UpdateOperation<T> {
return new UpdateOperation<T>(this.client, this.name, id, record);
}
public async delete(id: RecordId): Promise<void> {
return new DeleteOperation(this.client, this.name, id).query();
}
public deleteOp(id: RecordId): DeleteOperation {
return new DeleteOperation(this.client, this.name, id);
}
public async subscribe(id: RecordId): Promise<ReadableStream<Event>> {
return await this.subscribeImpl(id);
}
public async subscribeAll(
opts?: SubscribeOpts,
): Promise<ReadableStream<Event>> {
return await this.subscribeImpl("*", opts);
}
private async subscribeImpl(
id: RecordId,
opts?: SubscribeOpts,
): Promise<ReadableStream<Event>> {
const params = new URLSearchParams();
const filters = opts?.filters ?? [];
if (filters.length > 0) {
for (const filter of filters) {
addFiltersToParams(params, "filter", filter);
}
}
const response = await this.client.fetch(
filters.length > 0
? `${recordApiBasePath}/${this.name}/subscribe/${id}?${params}`
: `${recordApiBasePath}/${this.name}/subscribe/${id}`,
);
const body = response.body;
if (!body) {
throw Error("Subscription reader is null.");
}
const decoder = new TextDecoder();
const transformStream = new TransformStream<Uint8Array, Event>({
transform(chunk: Uint8Array, controller) {
const messages = decoder.decode(chunk).trimEnd().split("\n\n");
for (const msg of messages) {
if (msg.startsWith("data: ")) {
controller.enqueue(parseJSON(msg.substring(6)));
}
}
},
flush(controller) {
controller.terminate();
},
});
return body.pipeThrough(transformStream);
}
async subscribeWs(
id: RecordId,
opts?: SubscribeOpts,
): Promise<ReadableStream<Event>> {
const params = new URLSearchParams();
params.append("ws", "true");
const filters = opts?.filters ?? [];
if (filters.length > 0) {
for (const filter of filters) {
addFiltersToParams(params, "filter", filter);
}
}
return new Promise<ReadableStream<Event>>((resolve, reject) => {
const host = this.client.base?.host ?? "";
const protocol = this.client.base?.protocol === "https" ? "wss" : "ws";
const url = `${protocol}://${host}${recordApiBasePath}/${this.name}/subscribe/${id}?${params}`;
const socket = new WebSocket(url);
const timeout = setTimeout(() => {
reject("WS connection timeout");
}, 5000);
const readable = new ReadableStream({
start: (controller) => {
socket.addEventListener("open", (_openEvent) => {
// Initialize connection and authenticate.
socket.send(
JSON.stringify({
Init: {
auth_token: this.client.tokens()?.auth_token ?? null,
},
} as WsProtocol),
);
clearTimeout(timeout);
resolve(readable);
});
socket.addEventListener("close", () => {
controller.close();
});
socket.addEventListener("error", (err) => {
controller.error(err);
});
// Listen for messages
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") {
new Error("expected JSON string");
}
controller.enqueue(parseJSON(event.data));
});
},
cancel: () => {
socket.close();
},
});
});
}
}
export function filePath(
apiName: string,
recordId: RecordId,
columnName: string,
): string {
return `${recordApiBasePath}/${apiName}/${recordId}/file/${columnName}`;
}
export function filesPath(
apiName: string,
recordId: RecordId,
columnName: string,
fileName: string,
): string {
return `${recordApiBasePath}/${apiName}/${recordId}/files/${columnName}/${fileName}`;
}
function addFiltersToParams(
params: URLSearchParams,
path: string,
filter: FilterOrComposite,
) {
if ("and" in filter) {
for (const [i, f] of (filter as And).and.entries()) {
addFiltersToParams(params, `${path}[$and][${i}]`, f);
}
} else if ("or" in filter) {
for (const [i, f] of (filter as Or).or.entries()) {
addFiltersToParams(params, `${path}[$or][${i}]`, f);
}
} else {
const f = filter as Filter;
const op = f.op;
if (op) {
params.append(`${path}[${f.column}][${formatCompareOp(op)}]`, f.value);
} else {
params.append(`${path}[${f.column}]`, f.value);
}
}
}
const recordApiBasePath = "/api/records/v1";
export const exportedForTesting = isDev
? {
subscribeWs: (api: RecordApiImpl, id: RecordId) => api.subscribeWs(id),
}
: undefined;
+35
View File
@@ -0,0 +1,35 @@
import { isDev } from "./constants";
export interface Transport {
fetch: (
path: string,
headers: HeadersInit,
init?: RequestInit,
) => Promise<Response>;
}
export class ThinClient implements Transport {
constructor(private readonly base: URL | undefined) {}
async fetch(
path: string,
headers: HeadersInit,
init?: RequestInit,
): Promise<Response> {
// NOTE: We need to merge the headers in such a complicated fashion
// to avoid user-provided `init` with headers unintentionally suppressing
// the credentials.
const response = await fetch(this.base ? new URL(path, this.base) : path, {
credentials: isDev ? "include" : "same-origin",
...init,
headers: init
? {
...headers,
...init?.headers,
}
: headers,
});
return response;
}
}
@@ -6,7 +6,7 @@ import { FeatureCollection } from "geojson";
import { generate } from "otplib";
import {
exportedForTesting,
exportedForTesting as indexExportForTesting,
FetchError,
filePath,
filesPath,
@@ -14,9 +14,12 @@ import {
urlSafeBase64Encode,
} from "../../src/index";
import type { Client, Event, RecordApiImpl } from "../../src/index";
import { exportedForTesting as recordExportForTesting } from "../../src/record_api";
import { ADDRESS, USE_WS } from "../constants";
const { base64Encode, subscribeWs } = exportedForTesting!;
const { base64Encode } = indexExportForTesting!;
const { subscribeWs } = recordExportForTesting!;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -214,7 +217,7 @@ test("Record integration tests", async () => {
await api.delete(ids[1]);
await expect(async () => await api.read(ids[1])).rejects.toThrowError(
await expect(async () => await api.read(ids[1])).rejects.toThrow(
expect.objectContaining({
status: status.NOT_FOUND,
}),
@@ -223,7 +226,7 @@ test("Record integration tests", async () => {
expect(await client.logout()).toBe(true);
expect(client.user()).toBe(undefined);
await expect(async () => await api.read(ids[0])).rejects.toThrowError(
await expect(async () => await api.read(ids[0])).rejects.toThrow(
expect.objectContaining({
status: status.FORBIDDEN,
}),
@@ -375,7 +378,7 @@ test("API Errors", async () => {
await expect(
async () => await client.fetch("/nonexistent/api/path"),
).rejects.toThrowError(
).rejects.toThrow(
new FetchError(
status.NOT_FOUND,
"Not Found",
@@ -387,7 +390,7 @@ test("API Errors", async () => {
const nonExistentApi = client.records("non-existent");
await expect(
async () => await nonExistentApi.read(nonExistentId),
).rejects.toThrowError(
).rejects.toThrow(
new FetchError(
status.METHOD_NOT_ALLOWED,
"Method Not Allowed",
@@ -397,7 +400,7 @@ test("API Errors", async () => {
const api = client.records("simple_strict_table");
const invalidId = "InvalidId0123";
await expect(async () => await api.read(invalidId)).rejects.toThrowError(
await expect(async () => await api.read(invalidId)).rejects.toThrow(
new FetchError(
status.BAD_REQUEST,
// Custom error reply by RecordError's IntoResponse impl.
@@ -408,7 +411,7 @@ test("API Errors", async () => {
),
);
await expect(async () => await api.read(nonExistentId)).rejects.toThrowError(
await expect(async () => await api.read(nonExistentId)).rejects.toThrow(
new FetchError(
status.NOT_FOUND,
"Not Found",
@@ -724,7 +727,6 @@ test("GeoJson access", async ({ expect }) => {
{
const json: FeatureCollection = await api.listGeoOp("geom").query();
console.log("HERE", json);
expect(json.features).toHaveLength(4);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from "vitest";
import { FetchError, initClient, exportedForTesting } from "../src/index";
const { parseJSON } = exportedForTesting!;
import { FetchError, initClient } from "../src/index";
import { parseJSON } from "../src/json";
test("error-handling", async ({ expect }) => {
expect(new FetchError(404, "test", "url").toString()).toEqual(
+1 -2
View File
@@ -10,8 +10,7 @@
"declaration": true,
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"],
"@bindings/*": ["../bindings/*"]
"@bindings/*": ["./bindings/*"]
}
},
"include": [
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import { resolve } from 'path'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function external(source: string, _importer: string | undefined, _isResolved: boolean): boolean {
console.log(source)
return source.startsWith("../bindings");
}
export default defineConfig({
build: {
outDir: "./dist",
minify: false,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: "trailbase",
fileName: "index",
formats: ["es"],
},
// rollupOptions: {
// external: external,
// plugins: [
// dts({
// copyDtsFiles: true,
// staticImport: true,
// insertTypesEntry: true,
// rollupTypes: true,
// }),
// ],
// },
},
plugins: [
dts({
strictOutput: true,
// copyDtsFiles: true,
// staticImport: true,
// insertTypesEntry: true,
rollupTypes: true,
}),
],
})
+3
View File
@@ -1,6 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
test: {
globals: true,
environment: "jsdom",
+1 -1
View File
@@ -30,7 +30,7 @@ export function TotpToggleButton(props: { client: Client; user: User }) {
try {
const res = await props.client.registerTOTP({ png: true });
setTotp({
url: res.totp_url,
url: res.url,
png: res.png ?? "",
});
} catch (err) {
+6
View File
@@ -287,9 +287,15 @@ importers:
typescript-eslint:
specifier: ^8.57.1
version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^8.0.1
version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2)
vite-node:
specifier: ^6.0.0
version: 6.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2)
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4(@types/node@25.5.0)(rollup@4.59.0)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2))
vitest:
specifier: ^4.1.0
version: 4.1.0(@types/node@25.5.0)(happy-dom@15.11.7)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2))