mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-05-19 07:49:57 -05:00
TS client: Break up files, add a vite build step and allow injecting a custom Transport interface. #222
This commit is contained in:
@@ -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) {
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../bindings
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@bindings/*": ["../bindings/*"]
|
||||
"@bindings/*": ["./bindings/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+6
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user