Add WebSocket subscription API and tests to JS/TS client.

This commit is contained in:
Sebastian Jeltsch
2026-01-20 14:44:31 +01:00
parent f23df128d0
commit eb97f99b1a
6 changed files with 141 additions and 125 deletions

View File

@@ -37,10 +37,10 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"execa": "^9.6.1",
"globals": "^17.0.0",
"http-status": "^2.1.0",
"jsdom": "^27.4.0",
"nano-spawn": "^2.0.0",
"oauth2-mock-server": "^8.2.0",
"prettier": "^3.8.0",
"tinybench": "^6.0.0",

View File

@@ -8,6 +8,7 @@ import type { LoginStatusResponse } from "@bindings/LoginStatusResponse";
import type { LogoutRequest } from "@bindings/LogoutRequest";
import type { RefreshRequest } from "@bindings/RefreshRequest";
import type { RefreshResponse } from "@bindings/RefreshResponse";
import type { WsProtocol } from "@bindings/WsProtocol";
export type User = {
id: string;
@@ -521,6 +522,70 @@ export class RecordApiImpl<
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(JSON.parse(event.data));
});
},
cancel: () => {
socket.close();
},
});
});
}
}
class ThinClient {
@@ -629,7 +694,8 @@ class ClientImpl implements Client {
}
public get base(): URL | undefined {
return this._client.base;
const b = this._client.base;
return b !== undefined ? new URL(b) : undefined;
}
/// Low-level access to tokens (auth, refresh, csrf) useful for persisting them.
@@ -958,6 +1024,7 @@ export const exportedForTesting = isDev
? {
base64Decode,
base64Encode,
subscribeWs: (api: RecordApiImpl, id: RecordId) => api.subscribeWs(id),
}
: undefined;

View File

@@ -1,2 +1,3 @@
export const PORT: number = 4005;
export const ADDRESS: string = `127.0.0.1:${PORT}`;
export const USE_WS: boolean = false;

View File

@@ -7,12 +7,12 @@ import {
initClient,
urlSafeBase64Encode,
} from "../../src/index";
import type { Client, Event } from "../../src/index";
import type { Client, Event, RecordApiImpl } from "../../src/index";
import { status } from "http-status";
import { v7 as uuidv7, parse as uuidParse } from "uuid";
import { ADDRESS } from "../constants";
import { ADDRESS, USE_WS } from "../constants";
const { base64Encode } = exportedForTesting!;
const { base64Encode, subscribeWs } = exportedForTesting!;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -484,6 +484,41 @@ test("Subscribe to entire table", async () => {
expect(events[2]["Delete"]["text_not_null"]).equals(updatedMessage);
});
if (USE_WS) {
test("Subscribe to entire table via WebSocket", async () => {
const client = await connect();
const api = client.records<NewSimpleStrict>("simple_strict_table");
const eventStream = await subscribeWs(api as RecordApiImpl, "*");
const now = new Date().getTime();
const createMessage = `ts client ws realtime test 0: =?&${now}`;
const id = (await api.create({
text_not_null: createMessage,
})) as string;
const updatedMessage = `ts client ws updated realtime test 0: ${now}`;
const updatedValue: Partial<SimpleStrict> = {
text_not_null: updatedMessage,
};
await api.update(id, updatedValue);
await api.delete(id);
const events: Event[] = [];
for await (const event of eventStream) {
events.push(event);
if (events.length === 3) {
break;
}
}
expect(events).toHaveLength(3);
expect(events[0]["Insert"]["text_not_null"]).equals(createMessage);
expect(events[1]["Update"]["text_not_null"]).equals(updatedMessage);
expect(events[2]["Delete"]["text_not_null"]).equals(updatedMessage);
});
}
test("Subscribe to table with record filters", async () => {
const client = await connect();
const api = client.records<NewSimpleStrict>("simple_strict_table");

View File

@@ -2,14 +2,16 @@
import { createVitest } from "vitest/node";
import { cwd } from "node:process";
import { join } from "node:path";
import { execa, type Subprocess } from "execa";
import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { join, resolve } from "node:path";
import spawn from "nano-spawn";
import { ADDRESS, PORT } from "./constants";
import { ADDRESS, PORT, USE_WS } from "./constants";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function initTrailBase(): Promise<{ subprocess: Subprocess | null }> {
async function initTrailBase(): Promise<{ subprocess: ChildProcess | null }> {
if (PORT === 4000) {
// Rely on externally bootstrapped instance.
return { subprocess: null };
@@ -20,31 +22,42 @@ async function initTrailBase(): Promise<{ subprocess: Subprocess | null }> {
throw Error(`Unexpected CWD: ${pwd}`);
}
const root = join(pwd, "..", "..", "..", "..");
const build = await execa({ cwd: root })`cargo build`;
if (build.failed) {
console.error("STDOUT:", build.stdout);
console.error("STDERR:", build.stderr);
throw Error("cargo build failed");
const root = resolve(__dirname, "../../../../..");
if (!existsSync(`${root}/Cargo.lock`)) {
throw new Error(root);
}
const subprocess = execa({
const features = USE_WS ? ["--features=ws"] : [];
await spawn("cargo", ["build", ...features], { cwd: root });
const args = [
"run",
...features,
"--",
"--data-dir=client/testfixture",
`--public-url=http://${ADDRESS}`,
"run",
`--address=${ADDRESS}`,
"--runtime-threads=1",
];
const subprocess = spawn("cargo", args, {
cwd: root,
stdout: process.stdout,
stderr: process.stdout,
})`cargo run -- --data-dir client/testfixture --public-url http://${ADDRESS} run -a ${ADDRESS} --runtime-threads 1`;
});
// NOTE: debug builds of trail loading JS-WASM can take a long time.
for (let i = 0; i < 300; ++i) {
if ((subprocess.exitCode ?? 0) > 0) {
const child = await subprocess.nodeChildProcess;
if ((child.exitCode ?? 0) > 0) {
break;
}
try {
const response = await fetch(`http://${ADDRESS}/api/healthcheck`);
if (response.ok) {
return { subprocess };
return { subprocess: child };
}
console.log(await response.text());
@@ -55,10 +68,10 @@ async function initTrailBase(): Promise<{ subprocess: Subprocess | null }> {
await sleep(500);
}
subprocess.kill();
const child = await subprocess.nodeChildProcess;
child.kill();
const result = await subprocess;
console.error("EXIT:", result.exitCode);
console.error("STDOUT:", result.stdout);
console.error("STDERR:", result.stderr);

106
pnpm-lock.yaml generated
View File

@@ -248,9 +248,6 @@ importers:
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
execa:
specifier: ^9.6.1
version: 9.6.1
globals:
specifier: ^17.0.0
version: 17.0.0
@@ -260,6 +257,9 @@ importers:
jsdom:
specifier: ^27.4.0
version: 27.4.0
nano-spawn:
specifier: ^2.0.0
version: 2.0.0
oauth2-mock-server:
specifier: ^8.2.0
version: 8.2.0
@@ -2352,9 +2352,6 @@ packages:
'@rushstack/ts-command-line@5.1.5':
resolution: {integrity: sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@3.21.0':
resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
@@ -2376,10 +2373,6 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@solid-devtools/debugger@0.23.4':
resolution: {integrity: sha512-EfTB1Eo313wztQYGJ4Ec/wE70Ay2d603VCXfT3RlyqO5QfLrQGRHX5NXC07hJpQTJJJ3tbNgzO7+ZKo76MM5uA==}
peerDependencies:
@@ -3870,10 +3863,6 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -3931,10 +3920,6 @@ packages:
picomatch:
optional: true
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -4035,10 +4020,6 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
@@ -4219,10 +4200,6 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
i18n-iso-countries@7.14.0:
resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==}
engines: {node: '>= 12'}
@@ -4349,10 +4326,6 @@ packages:
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-unicode-supported@1.3.0:
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
engines: {node: '>=12'}
@@ -4934,10 +4907,6 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -5042,10 +5011,6 @@ packages:
parse-latin@7.0.0:
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -5080,10 +5045,6 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -5221,10 +5182,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -5701,10 +5658,6 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-final-newline@4.0.0:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -6010,10 +5963,6 @@ packages:
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -8014,8 +7963,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
@@ -8049,8 +7996,6 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/merge-streams@4.0.0': {}
'@solid-devtools/debugger@0.23.4(solid-js@1.9.10)':
dependencies:
'@nothing-but/utils': 0.12.1
@@ -10158,21 +10103,6 @@ snapshots:
events@3.3.0: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
cross-spawn: 7.0.6
figures: 6.1.0
get-stream: 9.0.1
human-signals: 8.0.1
is-plain-obj: 4.1.0
is-stream: 4.0.1
npm-run-path: 6.0.0
pretty-ms: 9.3.0
signal-exit: 4.1.0
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expect-type@1.3.0: {}
express@5.2.1:
@@ -10261,10 +10191,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -10363,11 +10289,6 @@ snapshots:
dependencies:
pump: 3.0.3
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
github-slugger@2.0.0: {}
glob-parent@5.1.2:
@@ -10680,8 +10601,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-signals@8.0.1: {}
i18n-iso-countries@7.14.0:
dependencies:
diacritics: 1.3.0
@@ -10775,8 +10694,6 @@ snapshots:
is-promise@4.0.0: {}
is-stream@4.0.1: {}
is-unicode-supported@1.3.0: {}
is-unicode-supported@2.1.0: {}
@@ -11557,11 +11474,6 @@ snapshots:
normalize-path@3.0.0: {}
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
unicorn-magic: 0.3.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
@@ -11712,8 +11624,6 @@ snapshots:
unist-util-visit-children: 3.0.0
vfile: 6.0.3
parse-ms@4.0.0: {}
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
@@ -11743,8 +11653,6 @@ snapshots:
path-key@3.1.1: {}
path-key@4.0.0: {}
path-parse@1.0.7: {}
path-to-regexp@8.3.0: {}
@@ -11827,10 +11735,6 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
prismjs@1.30.0: {}
process@0.11.10: {}
@@ -12537,8 +12441,6 @@ snapshots:
strip-bom@3.0.0: {}
strip-final-newline@4.0.0: {}
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
@@ -12858,8 +12760,6 @@ snapshots:
pako: 0.2.9
tiny-inflate: 1.0.3
unicorn-magic@0.3.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3