From c07dfef7a274d91bd66704c0f51747b52177cca5 Mon Sep 17 00:00:00 2001 From: Tien Pham Date: Thu, 29 Jan 2026 17:51:38 +0200 Subject: [PATCH 1/3] feat: Quickstart and client for Plain JS Script Tags --- .../bindings-typescript/src/browser/client.ts | 552 ++++++++++++++++++ .../bindings-typescript/src/browser/index.ts | 8 + crates/bindings-typescript/tsup.config.ts | 31 + .../00200-quickstarts/00180-browser.md | 122 ++++ pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + templates/browser-ts/.template.json | 5 + templates/browser-ts/LICENSE | 1 + templates/browser-ts/index.html | 131 +++++ templates/browser-ts/package.json | 9 + templates/browser-ts/spacetimedb/package.json | 15 + templates/browser-ts/spacetimedb/src/index.ts | 33 ++ .../browser-ts/spacetimedb/tsconfig.json | 22 + 13 files changed, 932 insertions(+) create mode 100644 crates/bindings-typescript/src/browser/client.ts create mode 100644 crates/bindings-typescript/src/browser/index.ts create mode 100644 docs/docs/00100-intro/00200-quickstarts/00180-browser.md create mode 100644 templates/browser-ts/.template.json create mode 120000 templates/browser-ts/LICENSE create mode 100644 templates/browser-ts/index.html create mode 100644 templates/browser-ts/package.json create mode 100644 templates/browser-ts/spacetimedb/package.json create mode 100644 templates/browser-ts/spacetimedb/src/index.ts create mode 100644 templates/browser-ts/spacetimedb/tsconfig.json diff --git a/crates/bindings-typescript/src/browser/client.ts b/crates/bindings-typescript/src/browser/client.ts new file mode 100644 index 00000000000..34b2c3984d6 --- /dev/null +++ b/crates/bindings-typescript/src/browser/client.ts @@ -0,0 +1,552 @@ +import { Identity } from '../lib/identity'; +import { ConnectionId } from '../lib/connection_id'; +import { resolveWS } from '../sdk/ws'; + +interface JsonIdentityToken { + IdentityToken: { + identity: string; + token: string; + address: string; + }; +} + +interface JsonSubscriptionUpdate { + SubscriptionUpdate: { + table_updates?: JsonTableUpdate[]; + database_update?: { + tables: any[]; + }; + }; +} + +interface JsonTableUpdate { + table_id: number; + table_name: string; + table_row_operations: JsonTableRowOperation[]; +} + +interface JsonTableRowOperation { + op: 'insert' | 'delete'; + row: any[]; +} + +interface JsonTransactionUpdate { + TransactionUpdate: { + status: { + Committed?: { + tables: any[]; + }; + }; + database_update?: { + tables: any[]; + }; + subscription_update?: { + table_updates: JsonTableUpdate[]; + }; + }; +} + +interface JsonInitialSubscription { + InitialSubscription: { + database_update: { + tables: any[]; + }; + }; +} + +interface JsonTransactionUpdateLight { + TransactionUpdateLight: { + database_update: { + tables: any[]; + }; + }; +} + +interface JsonSubscriptionError { + SubscriptionError: { + total_host_execution_duration_micros: number; + request_id?: number; + table_id?: number; + error: string; + }; +} + +type JsonServerMessage = + | JsonIdentityToken + | JsonSubscriptionUpdate + | JsonTransactionUpdate + | JsonInitialSubscription + | JsonTransactionUpdateLight + | JsonSubscriptionError; + +interface ModuleSchema { + typespace: { + types: any[]; + }; + tables: Array<{ + name: string; + product_type_ref: number; + }>; + reducers: Array<{ + name: string; + params?: any; + }>; +} + +export interface ClientOptions { + token?: string | null; + onConnect?: (identityHex: string, token: string) => void; + onDisconnect?: () => void; + onError?: (error: Error) => void; +} + +interface Subscription { + query: string; + callback: (rows: any[]) => void; + rows: Map; +} + +const TABLE_NAME_REGEX = /FROM\s+(\w+)/i; + +export class Client { + private uri: string; + private moduleName: string; + private options: ClientOptions; + private ws: WebSocket | null = null; + private _identity: Identity | null = null; + private _identityHex: string | null = null; + private _token: string | null = null; + private _connectionId: ConnectionId | null = null; + private _isConnected = false; + private subscriptions = new Map(); + private nextQueryId = 1; + private tableNameToColumns = new Map(); + + static builder(): ClientBuilder { + return new ClientBuilder(); + } + + constructor(uri: string, moduleName: string, options: ClientOptions = {}) { + this.uri = uri; + this.moduleName = moduleName; + this.options = options; + } + + get identity(): Identity | null { + return this._identity; + } + + get identityHex(): string | null { + return this._identityHex; + } + + get token(): string | null { + return this._token; + } + + get connectionId(): ConnectionId | null { + return this._connectionId; + } + + get isConnected(): boolean { + return this._isConnected; + } + + private toHttpUrl(url: string): string { + return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + } + + private toWsUrl(url: string): string { + return url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); + } + + async fetchSchema(): Promise { + const baseUrl = this.toHttpUrl(this.uri); + const response = await fetch( + `${baseUrl}/v1/database/${this.moduleName}/schema?version=9` + ); + if (!response.ok) { + throw new Error(`Failed to fetch schema: ${response.status}`); + } + const schema: ModuleSchema = await response.json(); + + const types = schema.typespace?.types || []; + for (const table of schema.tables || []) { + const typeRef = table.product_type_ref; + const productType = types[typeRef]?.Product; + if (productType?.elements) { + const colNames = productType.elements.map( + (el: any) => el.name?.some || el.name || '' + ); + this.tableNameToColumns.set(table.name, colNames); + } + } + + return schema; + } + + async connect(): Promise { + try { + await this.fetchSchema(); + } catch (e) { + console.warn('Could not fetch schema:', e); + } + + const wsBaseUrl = this.toWsUrl(this.uri); + const wsUrl = new URL( + `v1/database/${this.moduleName}/subscribe`, + wsBaseUrl + ); + + const token = this.options.token ?? this._token; + if (token) { + wsUrl.searchParams.set('token', token); + } + + // use JSON WebSocket protocol + const WS = await resolveWS(); + this.ws = new WS(wsUrl.toString(), 'v1.json.spacetimedb') as WebSocket; + + this.ws.onopen = () => { + // connection established + }; + + this.ws.onmessage = event => { + try { + const message = JSON.parse(event.data) as JsonServerMessage; + this.handleMessage(message); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + + this.ws.onerror = event => { + console.error('WebSocket error:', event); + this.options.onError?.(new Error('WebSocket error')); + }; + + this.ws.onclose = () => { + this._isConnected = false; + this.options.onDisconnect?.(); + }; + } + + private handleMessage(message: JsonServerMessage): void { + if ('IdentityToken' in message) { + const { identity, token, address } = message.IdentityToken; + + const identityStr = + typeof identity === 'string' + ? identity + : (identity as any)?.__identity__ || String(identity); + + this._identityHex = identityStr; + + try { + this._identity = Identity.fromString(identityStr); + } catch (e) { + console.warn('Could not parse identity:', e); + this._identity = null; + } + this._token = token; + + if (address) { + try { + const addrStr = + typeof address === 'string' + ? address + : (address as any)?.__connection_id__ + ? String((address as any).__connection_id__) + : String(address); + this._connectionId = ConnectionId.fromString(addrStr); + } catch (e) { + console.warn('Could not parse address:', e); + } + } + this._isConnected = true; + if (this._identityHex) { + this.options.onConnect?.(this._identityHex, token); + } + } else if ('SubscriptionUpdate' in message) { + const subUpdate = message.SubscriptionUpdate; + const tableUpdates = + subUpdate.table_updates || subUpdate.database_update?.tables || []; + this.handleTableUpdates(tableUpdates); + } else if ('InitialSubscription' in message) { + const tables = message.InitialSubscription.database_update?.tables || []; + this.handleInitialSubscription(tables); + } else if ('TransactionUpdate' in message) { + const txUpdate = message.TransactionUpdate; + + const tables = + txUpdate.status?.Committed?.tables || + txUpdate.database_update?.tables || + txUpdate.subscription_update?.table_updates || + []; + if (tables.length > 0) { + this.handleTableUpdates(tables); + } + } else if ('TransactionUpdateLight' in message) { + const tables = + message.TransactionUpdateLight.database_update?.tables || []; + this.handleInitialSubscription(tables); + } else if ('SubscriptionError' in message) { + const err = message.SubscriptionError; + console.error('Subscription error:', err.error); + this.options.onError?.(new Error(`Subscription error: ${err.error}`)); + } + } + + private normalizeRow(row: any, tableName: string): any { + if (Array.isArray(row)) { + const colNames = this.tableNameToColumns.get(tableName) || []; + return this.rowArrayToObject(row, colNames); + } + return row; + } + + // apply JSON protocol updates to a subscription + private applyJsonUpdates( + sub: Subscription, + updates: any[], + tableName: string + ): boolean { + let hasChanges = false; + for (const update of updates) { + for (const insertJson of update.inserts || []) { + try { + const parsed = + typeof insertJson === 'string' + ? JSON.parse(insertJson) + : insertJson; + const row = this.normalizeRow(parsed, tableName); + const rowId = JSON.stringify(row); + sub.rows.set(rowId, row); + hasChanges = true; + } catch (e) { + console.warn('Failed to parse insert:', e); + } + } + for (const deleteJson of update.deletes || []) { + try { + const parsed = + typeof deleteJson === 'string' + ? JSON.parse(deleteJson) + : deleteJson; + const row = this.normalizeRow(parsed, tableName); + const rowId = JSON.stringify(row); + sub.rows.delete(rowId); + hasChanges = true; + } catch (e) { + console.warn('Failed to parse delete:', e); + } + } + } + return hasChanges; + } + + private handleInitialSubscription(tables: any[]): void { + for (const table of tables) { + const tableName = table.table_name; + const updates = table.updates || []; + + for (const sub of this.subscriptions.values()) { + const match = sub.query.match(TABLE_NAME_REGEX); + if (!match || match[1] !== tableName) continue; + + if (this.applyJsonUpdates(sub, updates, tableName)) { + sub.callback(Array.from(sub.rows.values())); + } + } + } + } + + private handleTableUpdates(tableUpdates: any[]): void { + for (const tu of tableUpdates) { + const tableName = tu.table_name; + + for (const sub of this.subscriptions.values()) { + const match = sub.query.match(TABLE_NAME_REGEX); + if (!match || match[1] !== tableName) continue; + + const colNames = this.tableNameToColumns.get(tableName) || []; + let hasChanges = false; + + if (tu.table_row_operations) { + for (const op of tu.table_row_operations) { + const row = this.rowArrayToObject(op.row, colNames); + const rowId = JSON.stringify(row); + if (op.op === 'insert') { + sub.rows.set(rowId, row); + hasChanges = true; + } else if (op.op === 'delete') { + sub.rows.delete(rowId); + hasChanges = true; + } + } + } + + if (tu.updates) { + hasChanges = + this.applyJsonUpdates(sub, tu.updates, tableName) || hasChanges; + } + + if (hasChanges) { + sub.callback(Array.from(sub.rows.values())); + } + } + } + } + + private rowArrayToObject( + row: any[], + colNames: string[] + ): Record { + const obj: Record = {}; + for (let i = 0; i < row.length && i < colNames.length; i++) { + obj[colNames[i]] = row[i]; + } + return obj; + } + + subscribe(query: string, callback: (rows: any[]) => void): () => void { + if ( + !this._isConnected || + !this.ws || + this.ws.readyState !== 1 // WebSocket.OPEN + ) { + throw new Error('Not connected. Call connect() first.'); + } + + const queryId = this.nextQueryId++; + + this.subscriptions.set(queryId, { + query, + callback, + rows: new Map(), + }); + + const subscribeMsg = { + Subscribe: { + query_strings: [query], + request_id: queryId, + }, + }; + this.ws.send(JSON.stringify(subscribeMsg)); + + return () => { + this.subscriptions.delete(queryId); + if (this.ws && this.ws.readyState === 1) { + const unsubscribeMsg = { + Unsubscribe: { + request_id: queryId, + }, + }; + this.ws.send(JSON.stringify(unsubscribeMsg)); + } + }; + } + + async call( + reducerName: string, + args: Record = {} + ): Promise { + if (!this._isConnected) { + throw new Error('Not connected. Call connect() first.'); + } + + // use HTTP endpoint for reducer calls + const baseUrl = this.toHttpUrl(this.uri); + const url = `${baseUrl}/v1/database/${this.moduleName}/call/${reducerName}`; + + const argsArray = this.argsObjectToArray(args); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this._token ? { Authorization: `Bearer ${this._token}` } : {}), + }, + body: JSON.stringify(argsArray), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Reducer call failed: ${response.status} ${text}`); + } + } + + private argsObjectToArray(args: Record): any[] { + return Object.values(args); + } + + disconnect(): void { + this.subscriptions.clear(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this._isConnected = false; + this._identity = null; + this._identityHex = null; + this._connectionId = null; + } +} + +export class ClientBuilder { + #uri?: string; + #moduleName?: string; + #token?: string; + #onConnect?: (identityHex: string, token: string) => void; + #onDisconnect?: () => void; + #onError?: (error: Error) => void; + + // set URI of the SpacetimeDB server to connect to + withUri(uri: string): this { + this.#uri = uri; + return this; + } + + withModuleName(moduleName: string): this { + this.#moduleName = moduleName; + return this; + } + + withToken(token?: string | null): this { + this.#token = token ?? undefined; + return this; + } + + onConnect(callback: (identityHex: string, token: string) => void): this { + this.#onConnect = callback; + return this; + } + + onDisconnect(callback: () => void): this { + this.#onDisconnect = callback; + return this; + } + + onError(callback: (error: Error) => void): this { + this.#onError = callback; + return this; + } + + build(): Client { + if (!this.#uri) { + throw new Error('URI is required to connect to SpacetimeDB'); + } + if (!this.#moduleName) { + throw new Error( + 'Database name or address is required to connect to SpacetimeDB' + ); + } + + return new Client(this.#uri, this.#moduleName, { + token: this.#token, + onConnect: this.#onConnect, + onDisconnect: this.#onDisconnect, + onError: this.#onError, + }); + } +} diff --git a/crates/bindings-typescript/src/browser/index.ts b/crates/bindings-typescript/src/browser/index.ts new file mode 100644 index 00000000000..77fc3cebfc9 --- /dev/null +++ b/crates/bindings-typescript/src/browser/index.ts @@ -0,0 +1,8 @@ +export * from '../sdk/index'; +export { Identity } from '../lib/identity'; +export { ConnectionId } from '../lib/connection_id'; +export { Timestamp } from '../lib/timestamp'; +export { TimeDuration } from '../lib/time_duration'; +export { ScheduleAt } from '../lib/schedule_at'; +export { AlgebraicType, ProductType, SumType } from '../lib/algebraic_type'; +export { Client, ClientBuilder, type ClientOptions } from './client'; diff --git a/crates/bindings-typescript/tsup.config.ts b/crates/bindings-typescript/tsup.config.ts index 33fa0be825f..14a1ad6a36d 100644 --- a/crates/bindings-typescript/tsup.config.ts +++ b/crates/bindings-typescript/tsup.config.ts @@ -168,6 +168,37 @@ export default defineConfig([ esbuildOptions: commonEsbuildTweaks(), }, + // Browser bundle (IIFE for script tags): dist/browser.bundle.js + { + entry: { 'browser.bundle': 'src/browser/index.ts' }, + format: ['iife'], + globalName: 'SpacetimeDB', + target: 'es2022', + outDir: 'dist', + dts: false, + sourcemap: true, + platform: 'browser', + treeshake: 'smallest', + noExternal: [/.*/], + outExtension: () => ({ js: '.js' }), + esbuildOptions: commonEsbuildTweaks(), + }, + + // Browser bundle ESM: dist/browser/index.mjs + { + entry: { index: 'src/browser/index.ts' }, + format: ['esm'], + target: 'es2022', + outDir: 'dist/browser', + dts: false, + sourcemap: true, + clean: true, + platform: 'browser', + treeshake: 'smallest', + outExtension, + esbuildOptions: commonEsbuildTweaks(), + }, + // The below minified builds are not referenced in package.json and are // just included in the build for measuring the size impact of minification. // It is expected that consumers of the library will run their own diff --git a/docs/docs/00100-intro/00200-quickstarts/00180-browser.md b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md new file mode 100644 index 00000000000..8b17d8daa6a --- /dev/null +++ b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md @@ -0,0 +1,122 @@ +--- +title: Script Tag Quickstart +sidebar_label: Script Tag +slug: /quickstarts/browser +hide_table_of_contents: true +pagination_next: intro/quickstarts/typescript +--- + +import { InstallCardLink } from "@site/src/components/InstallCardLink"; +import { StepByStep, Step, StepText, StepCode } from "@site/src/components/Steps"; + +Get a SpacetimeDB app running with script tags in under 5 minutes with no build tools required. + +## Prerequisites + +- [SpacetimeDB CLI](https://spacetimedb.com/install) installed + + + +--- + + + + + Create an `index.html` file and load the SpacetimeDB bundle from unpkg. This exposes a global `SpacetimeDB` namespace. + + +```html + + + +``` + + + + + + Every connection receives a unique identity from the server. + + +```javascript +const client = SpacetimeDB.Client.builder() + .withUri('ws://localhost:3000') + .withModuleName('my-database') + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect((identityHex, token) => { + localStorage.setItem('auth_token', token); + console.log('Connected to SpacetimeDB with identity:', identityHex); + }) + .onDisconnect(() => { + console.log('Disconnected from SpacetimeDB'); + }) + .onError(error => { + console.log('Error connecting to SpacetimeDB:', error); + }) + .build(); + +client.connect(); +``` + + + + + + Tables store your data. When you subscribe to a query, SpacetimeDB sends the matching rows immediately and pushes updates whenever they change. + + +```javascript +client.subscribe('SELECT * FROM person', (rows) => { + console.log('People:', rows); + // [{ name: "Alice" }, { name: "Bob" }] +}); +``` + + + + + + Reducers are functions that modify data — they're the only way to write to the database. + + +```javascript +await client.call('add', { name: 'Alice' }); +``` + + + + + + Use the SpacetimeDB CLI to call reducers and query your data directly. + + +```bash +# Call the add reducer to insert a person +spacetime call add Alice + +# Query the person table +spacetime sql "SELECT * FROM person" + name +--------- + "Alice" + +# Call say_hello to greet everyone +spacetime call say_hello + +# View the module logs +spacetime logs +2025-01-13T12:00:00.000000Z INFO: Hello, Alice! +2025-01-13T12:00:00.000000Z INFO: Hello, World! +``` + + + + +## Next steps + +- Use `spacetime dev --template browser-ts` for a full project template with script tags +- See the [Chat App Tutorial](/tutorials/chat-app) for a complete example +- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8346710cd07..5fab00c907d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,8 @@ importers: specifier: ^7.1.5 version: 7.1.5(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(tsx@4.20.4) + templates/browser-ts: {} + templates/chat-react-ts: dependencies: react: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9c8336df9be..afd1006dbb2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - 'templates/react-ts' - 'templates/basic-ts' - 'templates/vue-ts' + - 'templates/browser-ts' - 'modules/benchmarks-ts' - 'modules/module-test-ts' - 'templates/chat-react-ts/spacetimedb' diff --git a/templates/browser-ts/.template.json b/templates/browser-ts/.template.json new file mode 100644 index 00000000000..3cbc3d87aac --- /dev/null +++ b/templates/browser-ts/.template.json @@ -0,0 +1,5 @@ +{ + "description": "Script tag web app with TypeScript server", + "client_lang": "typescript", + "server_lang": "typescript" +} diff --git a/templates/browser-ts/LICENSE b/templates/browser-ts/LICENSE new file mode 120000 index 00000000000..039e117dde2 --- /dev/null +++ b/templates/browser-ts/LICENSE @@ -0,0 +1 @@ +../../licenses/apache2.txt \ No newline at end of file diff --git a/templates/browser-ts/index.html b/templates/browser-ts/index.html new file mode 100644 index 00000000000..db34b51582f --- /dev/null +++ b/templates/browser-ts/index.html @@ -0,0 +1,131 @@ + + + + + + SpacetimeDB Script Tag App + + +

SpacetimeDB Script Tag App

+ +
+ Status: Disconnected +
+ +
+ + +
+ +
+

People (0)

+
    +
  • No people yet. Add someone above!
  • +
+
+ + + + + + diff --git a/templates/browser-ts/package.json b/templates/browser-ts/package.json new file mode 100644 index 00000000000..dd2cb29e8eb --- /dev/null +++ b/templates/browser-ts/package.json @@ -0,0 +1,9 @@ +{ + "name": "@clockworklabs/browser-ts", + "private": true, + "version": "0.0.1", + "scripts": { + "spacetime:publish:local": "spacetime publish --project-path spacetimedb --server local", + "spacetime:publish": "spacetime publish --project-path spacetimedb --server maincloud" + } +} diff --git a/templates/browser-ts/spacetimedb/package.json b/templates/browser-ts/spacetimedb/package.json new file mode 100644 index 00000000000..214ccc569bf --- /dev/null +++ b/templates/browser-ts/spacetimedb/package.json @@ -0,0 +1,15 @@ +{ + "name": "spacetime-module", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "spacetimedb": "1.*" + } +} diff --git a/templates/browser-ts/spacetimedb/src/index.ts b/templates/browser-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..900cb1bf2e9 --- /dev/null +++ b/templates/browser-ts/spacetimedb/src/index.ts @@ -0,0 +1,33 @@ +import { schema, table, t } from 'spacetimedb/server'; + +export const spacetimedb = schema( + table( + { name: 'person', public: true }, + { + name: t.string(), + } + ) +); + +spacetimedb.init((_ctx) => { + // Called when the module is initially published +}); + +spacetimedb.clientConnected((_ctx) => { + // Called every time a new client connects +}); + +spacetimedb.clientDisconnected((_ctx) => { + // Called every time a client disconnects +}); + +spacetimedb.reducer('add', { name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ name }); +}); + +spacetimedb.reducer('say_hello', (ctx) => { + for (const person of ctx.db.person.iter()) { + console.info(`Hello, ${person.name}!`); + } + console.info('Hello, World!'); +}); diff --git a/templates/browser-ts/spacetimedb/tsconfig.json b/templates/browser-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..82afb3d2923 --- /dev/null +++ b/templates/browser-ts/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +/* + * This tsconfig is used for TypeScript projects created with `spacetimedb init + * --lang typescript`. You can modify it as needed for your project, although + * some options are required by SpacetimeDB. + */ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + + /* The following options are required by SpacetimeDB + * and should not be modified + */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +} From 55730973f395ab416369c76d786c821206aa4b8d Mon Sep 17 00:00:00 2001 From: = Date: Fri, 30 Jan 2026 09:39:20 -0500 Subject: [PATCH 2/3] fix: Use DbConnection instead of custom Client for browser template Remove the custom Client class that used JSON protocol and HTTP for reducer calls. Instead, use the standard DbConnection with code generation, which uses BSATN binary protocol over WebSocket. The browser-ts template now: - Uses spacetime generate for TypeScript bindings - Bundles bindings with vite into an IIFE - Loads the bundle in index.html with inline JavaScript --- .../bindings-typescript/src/browser/client.ts | 552 ------------------ .../bindings-typescript/src/browser/index.ts | 1 - .../00200-quickstarts/00180-browser.md | 116 ++-- templates/browser-ts/index.html | 81 ++- templates/browser-ts/package.json | 10 + templates/browser-ts/src/bindings.ts | 6 + .../src/module_bindings/add_reducer.ts | 15 + .../src/module_bindings/add_type.ts | 15 + .../browser-ts/src/module_bindings/index.ts | 151 +++++ .../src/module_bindings/init_type.ts | 13 + .../src/module_bindings/on_connect_reducer.ts | 13 + .../src/module_bindings/on_connect_type.ts | 13 + .../module_bindings/on_disconnect_reducer.ts | 13 + .../src/module_bindings/on_disconnect_type.ts | 13 + .../src/module_bindings/person_table.ts | 15 + .../src/module_bindings/person_type.ts | 15 + .../src/module_bindings/say_hello_reducer.ts | 13 + .../src/module_bindings/say_hello_type.ts | 13 + templates/browser-ts/tsconfig.json | 19 + templates/browser-ts/vite.config.ts | 14 + 20 files changed, 443 insertions(+), 658 deletions(-) delete mode 100644 crates/bindings-typescript/src/browser/client.ts create mode 100644 templates/browser-ts/src/bindings.ts create mode 100644 templates/browser-ts/src/module_bindings/add_reducer.ts create mode 100644 templates/browser-ts/src/module_bindings/add_type.ts create mode 100644 templates/browser-ts/src/module_bindings/index.ts create mode 100644 templates/browser-ts/src/module_bindings/init_type.ts create mode 100644 templates/browser-ts/src/module_bindings/on_connect_reducer.ts create mode 100644 templates/browser-ts/src/module_bindings/on_connect_type.ts create mode 100644 templates/browser-ts/src/module_bindings/on_disconnect_reducer.ts create mode 100644 templates/browser-ts/src/module_bindings/on_disconnect_type.ts create mode 100644 templates/browser-ts/src/module_bindings/person_table.ts create mode 100644 templates/browser-ts/src/module_bindings/person_type.ts create mode 100644 templates/browser-ts/src/module_bindings/say_hello_reducer.ts create mode 100644 templates/browser-ts/src/module_bindings/say_hello_type.ts create mode 100644 templates/browser-ts/tsconfig.json create mode 100644 templates/browser-ts/vite.config.ts diff --git a/crates/bindings-typescript/src/browser/client.ts b/crates/bindings-typescript/src/browser/client.ts deleted file mode 100644 index 34b2c3984d6..00000000000 --- a/crates/bindings-typescript/src/browser/client.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { Identity } from '../lib/identity'; -import { ConnectionId } from '../lib/connection_id'; -import { resolveWS } from '../sdk/ws'; - -interface JsonIdentityToken { - IdentityToken: { - identity: string; - token: string; - address: string; - }; -} - -interface JsonSubscriptionUpdate { - SubscriptionUpdate: { - table_updates?: JsonTableUpdate[]; - database_update?: { - tables: any[]; - }; - }; -} - -interface JsonTableUpdate { - table_id: number; - table_name: string; - table_row_operations: JsonTableRowOperation[]; -} - -interface JsonTableRowOperation { - op: 'insert' | 'delete'; - row: any[]; -} - -interface JsonTransactionUpdate { - TransactionUpdate: { - status: { - Committed?: { - tables: any[]; - }; - }; - database_update?: { - tables: any[]; - }; - subscription_update?: { - table_updates: JsonTableUpdate[]; - }; - }; -} - -interface JsonInitialSubscription { - InitialSubscription: { - database_update: { - tables: any[]; - }; - }; -} - -interface JsonTransactionUpdateLight { - TransactionUpdateLight: { - database_update: { - tables: any[]; - }; - }; -} - -interface JsonSubscriptionError { - SubscriptionError: { - total_host_execution_duration_micros: number; - request_id?: number; - table_id?: number; - error: string; - }; -} - -type JsonServerMessage = - | JsonIdentityToken - | JsonSubscriptionUpdate - | JsonTransactionUpdate - | JsonInitialSubscription - | JsonTransactionUpdateLight - | JsonSubscriptionError; - -interface ModuleSchema { - typespace: { - types: any[]; - }; - tables: Array<{ - name: string; - product_type_ref: number; - }>; - reducers: Array<{ - name: string; - params?: any; - }>; -} - -export interface ClientOptions { - token?: string | null; - onConnect?: (identityHex: string, token: string) => void; - onDisconnect?: () => void; - onError?: (error: Error) => void; -} - -interface Subscription { - query: string; - callback: (rows: any[]) => void; - rows: Map; -} - -const TABLE_NAME_REGEX = /FROM\s+(\w+)/i; - -export class Client { - private uri: string; - private moduleName: string; - private options: ClientOptions; - private ws: WebSocket | null = null; - private _identity: Identity | null = null; - private _identityHex: string | null = null; - private _token: string | null = null; - private _connectionId: ConnectionId | null = null; - private _isConnected = false; - private subscriptions = new Map(); - private nextQueryId = 1; - private tableNameToColumns = new Map(); - - static builder(): ClientBuilder { - return new ClientBuilder(); - } - - constructor(uri: string, moduleName: string, options: ClientOptions = {}) { - this.uri = uri; - this.moduleName = moduleName; - this.options = options; - } - - get identity(): Identity | null { - return this._identity; - } - - get identityHex(): string | null { - return this._identityHex; - } - - get token(): string | null { - return this._token; - } - - get connectionId(): ConnectionId | null { - return this._connectionId; - } - - get isConnected(): boolean { - return this._isConnected; - } - - private toHttpUrl(url: string): string { - return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); - } - - private toWsUrl(url: string): string { - return url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); - } - - async fetchSchema(): Promise { - const baseUrl = this.toHttpUrl(this.uri); - const response = await fetch( - `${baseUrl}/v1/database/${this.moduleName}/schema?version=9` - ); - if (!response.ok) { - throw new Error(`Failed to fetch schema: ${response.status}`); - } - const schema: ModuleSchema = await response.json(); - - const types = schema.typespace?.types || []; - for (const table of schema.tables || []) { - const typeRef = table.product_type_ref; - const productType = types[typeRef]?.Product; - if (productType?.elements) { - const colNames = productType.elements.map( - (el: any) => el.name?.some || el.name || '' - ); - this.tableNameToColumns.set(table.name, colNames); - } - } - - return schema; - } - - async connect(): Promise { - try { - await this.fetchSchema(); - } catch (e) { - console.warn('Could not fetch schema:', e); - } - - const wsBaseUrl = this.toWsUrl(this.uri); - const wsUrl = new URL( - `v1/database/${this.moduleName}/subscribe`, - wsBaseUrl - ); - - const token = this.options.token ?? this._token; - if (token) { - wsUrl.searchParams.set('token', token); - } - - // use JSON WebSocket protocol - const WS = await resolveWS(); - this.ws = new WS(wsUrl.toString(), 'v1.json.spacetimedb') as WebSocket; - - this.ws.onopen = () => { - // connection established - }; - - this.ws.onmessage = event => { - try { - const message = JSON.parse(event.data) as JsonServerMessage; - this.handleMessage(message); - } catch (e) { - console.error('Failed to parse message:', e); - } - }; - - this.ws.onerror = event => { - console.error('WebSocket error:', event); - this.options.onError?.(new Error('WebSocket error')); - }; - - this.ws.onclose = () => { - this._isConnected = false; - this.options.onDisconnect?.(); - }; - } - - private handleMessage(message: JsonServerMessage): void { - if ('IdentityToken' in message) { - const { identity, token, address } = message.IdentityToken; - - const identityStr = - typeof identity === 'string' - ? identity - : (identity as any)?.__identity__ || String(identity); - - this._identityHex = identityStr; - - try { - this._identity = Identity.fromString(identityStr); - } catch (e) { - console.warn('Could not parse identity:', e); - this._identity = null; - } - this._token = token; - - if (address) { - try { - const addrStr = - typeof address === 'string' - ? address - : (address as any)?.__connection_id__ - ? String((address as any).__connection_id__) - : String(address); - this._connectionId = ConnectionId.fromString(addrStr); - } catch (e) { - console.warn('Could not parse address:', e); - } - } - this._isConnected = true; - if (this._identityHex) { - this.options.onConnect?.(this._identityHex, token); - } - } else if ('SubscriptionUpdate' in message) { - const subUpdate = message.SubscriptionUpdate; - const tableUpdates = - subUpdate.table_updates || subUpdate.database_update?.tables || []; - this.handleTableUpdates(tableUpdates); - } else if ('InitialSubscription' in message) { - const tables = message.InitialSubscription.database_update?.tables || []; - this.handleInitialSubscription(tables); - } else if ('TransactionUpdate' in message) { - const txUpdate = message.TransactionUpdate; - - const tables = - txUpdate.status?.Committed?.tables || - txUpdate.database_update?.tables || - txUpdate.subscription_update?.table_updates || - []; - if (tables.length > 0) { - this.handleTableUpdates(tables); - } - } else if ('TransactionUpdateLight' in message) { - const tables = - message.TransactionUpdateLight.database_update?.tables || []; - this.handleInitialSubscription(tables); - } else if ('SubscriptionError' in message) { - const err = message.SubscriptionError; - console.error('Subscription error:', err.error); - this.options.onError?.(new Error(`Subscription error: ${err.error}`)); - } - } - - private normalizeRow(row: any, tableName: string): any { - if (Array.isArray(row)) { - const colNames = this.tableNameToColumns.get(tableName) || []; - return this.rowArrayToObject(row, colNames); - } - return row; - } - - // apply JSON protocol updates to a subscription - private applyJsonUpdates( - sub: Subscription, - updates: any[], - tableName: string - ): boolean { - let hasChanges = false; - for (const update of updates) { - for (const insertJson of update.inserts || []) { - try { - const parsed = - typeof insertJson === 'string' - ? JSON.parse(insertJson) - : insertJson; - const row = this.normalizeRow(parsed, tableName); - const rowId = JSON.stringify(row); - sub.rows.set(rowId, row); - hasChanges = true; - } catch (e) { - console.warn('Failed to parse insert:', e); - } - } - for (const deleteJson of update.deletes || []) { - try { - const parsed = - typeof deleteJson === 'string' - ? JSON.parse(deleteJson) - : deleteJson; - const row = this.normalizeRow(parsed, tableName); - const rowId = JSON.stringify(row); - sub.rows.delete(rowId); - hasChanges = true; - } catch (e) { - console.warn('Failed to parse delete:', e); - } - } - } - return hasChanges; - } - - private handleInitialSubscription(tables: any[]): void { - for (const table of tables) { - const tableName = table.table_name; - const updates = table.updates || []; - - for (const sub of this.subscriptions.values()) { - const match = sub.query.match(TABLE_NAME_REGEX); - if (!match || match[1] !== tableName) continue; - - if (this.applyJsonUpdates(sub, updates, tableName)) { - sub.callback(Array.from(sub.rows.values())); - } - } - } - } - - private handleTableUpdates(tableUpdates: any[]): void { - for (const tu of tableUpdates) { - const tableName = tu.table_name; - - for (const sub of this.subscriptions.values()) { - const match = sub.query.match(TABLE_NAME_REGEX); - if (!match || match[1] !== tableName) continue; - - const colNames = this.tableNameToColumns.get(tableName) || []; - let hasChanges = false; - - if (tu.table_row_operations) { - for (const op of tu.table_row_operations) { - const row = this.rowArrayToObject(op.row, colNames); - const rowId = JSON.stringify(row); - if (op.op === 'insert') { - sub.rows.set(rowId, row); - hasChanges = true; - } else if (op.op === 'delete') { - sub.rows.delete(rowId); - hasChanges = true; - } - } - } - - if (tu.updates) { - hasChanges = - this.applyJsonUpdates(sub, tu.updates, tableName) || hasChanges; - } - - if (hasChanges) { - sub.callback(Array.from(sub.rows.values())); - } - } - } - } - - private rowArrayToObject( - row: any[], - colNames: string[] - ): Record { - const obj: Record = {}; - for (let i = 0; i < row.length && i < colNames.length; i++) { - obj[colNames[i]] = row[i]; - } - return obj; - } - - subscribe(query: string, callback: (rows: any[]) => void): () => void { - if ( - !this._isConnected || - !this.ws || - this.ws.readyState !== 1 // WebSocket.OPEN - ) { - throw new Error('Not connected. Call connect() first.'); - } - - const queryId = this.nextQueryId++; - - this.subscriptions.set(queryId, { - query, - callback, - rows: new Map(), - }); - - const subscribeMsg = { - Subscribe: { - query_strings: [query], - request_id: queryId, - }, - }; - this.ws.send(JSON.stringify(subscribeMsg)); - - return () => { - this.subscriptions.delete(queryId); - if (this.ws && this.ws.readyState === 1) { - const unsubscribeMsg = { - Unsubscribe: { - request_id: queryId, - }, - }; - this.ws.send(JSON.stringify(unsubscribeMsg)); - } - }; - } - - async call( - reducerName: string, - args: Record = {} - ): Promise { - if (!this._isConnected) { - throw new Error('Not connected. Call connect() first.'); - } - - // use HTTP endpoint for reducer calls - const baseUrl = this.toHttpUrl(this.uri); - const url = `${baseUrl}/v1/database/${this.moduleName}/call/${reducerName}`; - - const argsArray = this.argsObjectToArray(args); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this._token ? { Authorization: `Bearer ${this._token}` } : {}), - }, - body: JSON.stringify(argsArray), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Reducer call failed: ${response.status} ${text}`); - } - } - - private argsObjectToArray(args: Record): any[] { - return Object.values(args); - } - - disconnect(): void { - this.subscriptions.clear(); - if (this.ws) { - this.ws.close(); - this.ws = null; - } - this._isConnected = false; - this._identity = null; - this._identityHex = null; - this._connectionId = null; - } -} - -export class ClientBuilder { - #uri?: string; - #moduleName?: string; - #token?: string; - #onConnect?: (identityHex: string, token: string) => void; - #onDisconnect?: () => void; - #onError?: (error: Error) => void; - - // set URI of the SpacetimeDB server to connect to - withUri(uri: string): this { - this.#uri = uri; - return this; - } - - withModuleName(moduleName: string): this { - this.#moduleName = moduleName; - return this; - } - - withToken(token?: string | null): this { - this.#token = token ?? undefined; - return this; - } - - onConnect(callback: (identityHex: string, token: string) => void): this { - this.#onConnect = callback; - return this; - } - - onDisconnect(callback: () => void): this { - this.#onDisconnect = callback; - return this; - } - - onError(callback: (error: Error) => void): this { - this.#onError = callback; - return this; - } - - build(): Client { - if (!this.#uri) { - throw new Error('URI is required to connect to SpacetimeDB'); - } - if (!this.#moduleName) { - throw new Error( - 'Database name or address is required to connect to SpacetimeDB' - ); - } - - return new Client(this.#uri, this.#moduleName, { - token: this.#token, - onConnect: this.#onConnect, - onDisconnect: this.#onDisconnect, - onError: this.#onError, - }); - } -} diff --git a/crates/bindings-typescript/src/browser/index.ts b/crates/bindings-typescript/src/browser/index.ts index 77fc3cebfc9..d3347b3b4ca 100644 --- a/crates/bindings-typescript/src/browser/index.ts +++ b/crates/bindings-typescript/src/browser/index.ts @@ -5,4 +5,3 @@ export { Timestamp } from '../lib/timestamp'; export { TimeDuration } from '../lib/time_duration'; export { ScheduleAt } from '../lib/schedule_at'; export { AlgebraicType, ProductType, SumType } from '../lib/algebraic_type'; -export { Client, ClientBuilder, type ClientOptions } from './client'; diff --git a/docs/docs/00100-intro/00200-quickstarts/00180-browser.md b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md index 8b17d8daa6a..0c47173033a 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00180-browser.md +++ b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md @@ -1,6 +1,6 @@ --- -title: Script Tag Quickstart -sidebar_label: Script Tag +title: Browser Quickstart +sidebar_label: Browser slug: /quickstarts/browser hide_table_of_contents: true pagination_next: intro/quickstarts/typescript @@ -9,10 +9,11 @@ pagination_next: intro/quickstarts/typescript import { InstallCardLink } from "@site/src/components/InstallCardLink"; import { StepByStep, Step, StepText, StepCode } from "@site/src/components/Steps"; -Get a SpacetimeDB app running with script tags in under 5 minutes with no build tools required. +Get a SpacetimeDB app running in the browser with inline JavaScript. ## Prerequisites +- [Node.js](https://nodejs.org/) 18+ installed - [SpacetimeDB CLI](https://spacetimedb.com/install) installed @@ -20,59 +21,63 @@ Get a SpacetimeDB app running with script tags in under 5 minutes with no build --- - + - Create an `index.html` file and load the SpacetimeDB bundle from unpkg. This exposes a global `SpacetimeDB` namespace. + Run the `spacetime dev` command to create a new project with a TypeScript SpacetimeDB module. + + This will start the local SpacetimeDB server, publish your module, and generate TypeScript client bindings. -```html - - - +```bash +spacetime dev --template browser-ts my-spacetime-app ``` - + - Every connection receives a unique identity from the server. + The generated bindings need to be bundled into a JavaScript file that can be loaded in the browser. -```javascript -const client = SpacetimeDB.Client.builder() - .withUri('ws://localhost:3000') - .withModuleName('my-database') - .withToken(localStorage.getItem('auth_token') || undefined) - .onConnect((identityHex, token) => { - localStorage.setItem('auth_token', token); - console.log('Connected to SpacetimeDB with identity:', identityHex); - }) - .onDisconnect(() => { - console.log('Disconnected from SpacetimeDB'); - }) - .onError(error => { - console.log('Error connecting to SpacetimeDB:', error); - }) - .build(); - -client.connect(); +```bash +cd my-spacetime-app +npm install +npm run build ``` - + - Tables store your data. When you subscribe to a query, SpacetimeDB sends the matching rows immediately and pushes updates whenever they change. + Open `index.html` directly in your browser. The app connects to SpacetimeDB and displays data in real-time. + + The JavaScript code runs inline in a script tag, using the bundled `DbConnection` class. -```javascript -client.subscribe('SELECT * FROM person', (rows) => { - console.log('People:', rows); - // [{ name: "Alice" }, { name: "Bob" }] -}); +```html + + + + ``` @@ -83,33 +88,25 @@ client.subscribe('SELECT * FROM person', (rows) => { ```javascript -await client.call('add', { name: 'Alice' }); +// Call a reducer with named arguments +conn.reducers.add({ name: 'Alice' }); ``` - + - Use the SpacetimeDB CLI to call reducers and query your data directly. + Register callbacks to update your UI when data changes. -```bash -# Call the add reducer to insert a person -spacetime call add Alice - -# Query the person table -spacetime sql "SELECT * FROM person" - name ---------- - "Alice" - -# Call say_hello to greet everyone -spacetime call say_hello - -# View the module logs -spacetime logs -2025-01-13T12:00:00.000000Z INFO: Hello, Alice! -2025-01-13T12:00:00.000000Z INFO: Hello, World! +```javascript +conn.db.person.onInsert((ctx, person) => { + console.log('New person:', person.name); +}); + +conn.db.person.onDelete((ctx, person) => { + console.log('Removed:', person.name); +}); ``` @@ -117,6 +114,5 @@ spacetime logs ## Next steps -- Use `spacetime dev --template browser-ts` for a full project template with script tags - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example - Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs diff --git a/templates/browser-ts/index.html b/templates/browser-ts/index.html index db34b51582f..24bf745878b 100644 --- a/templates/browser-ts/index.html +++ b/templates/browser-ts/index.html @@ -3,7 +3,7 @@ - SpacetimeDB Script Tag App + SpacetimeDB Browser App -

SpacetimeDB Script Tag App

+

SpacetimeDB Browser App

Status: Disconnected @@ -42,7 +42,8 @@

People (0)

- + + diff --git a/templates/browser-ts/package.json b/templates/browser-ts/package.json index dd2cb29e8eb..a7456892ceb 100644 --- a/templates/browser-ts/package.json +++ b/templates/browser-ts/package.json @@ -2,8 +2,18 @@ "name": "@clockworklabs/browser-ts", "private": true, "version": "0.0.1", + "type": "module", "scripts": { + "build": "vite build", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb", "spacetime:publish:local": "spacetime publish --project-path spacetimedb --server local", "spacetime:publish": "spacetime publish --project-path spacetimedb --server maincloud" + }, + "dependencies": { + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "typescript": "~5.6.2", + "vite": "^7.1.5" } } diff --git a/templates/browser-ts/src/bindings.ts b/templates/browser-ts/src/bindings.ts new file mode 100644 index 00000000000..bc0e0d44c6a --- /dev/null +++ b/templates/browser-ts/src/bindings.ts @@ -0,0 +1,6 @@ +// Re-export generated bindings as globals for use in script tags +export { DbConnection } from './module_bindings'; + +// Make DbConnection available globally +import { DbConnection } from './module_bindings'; +(window as any).DbConnection = DbConnection; diff --git a/templates/browser-ts/src/module_bindings/add_reducer.ts b/templates/browser-ts/src/module_bindings/add_reducer.ts new file mode 100644 index 00000000000..85081559c7d --- /dev/null +++ b/templates/browser-ts/src/module_bindings/add_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default { + name: __t.string(), +}; diff --git a/templates/browser-ts/src/module_bindings/add_type.ts b/templates/browser-ts/src/module_bindings/add_type.ts new file mode 100644 index 00000000000..638f62cea39 --- /dev/null +++ b/templates/browser-ts/src/module_bindings/add_type.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('Add', { + name: __t.string(), +}); diff --git a/templates/browser-ts/src/module_bindings/index.ts b/templates/browser-ts/src/module_bindings/index.ts new file mode 100644 index 00000000000..23ea2b9deb3 --- /dev/null +++ b/templates/browser-ts/src/module_bindings/index.ts @@ -0,0 +1,151 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 1.11.3 (commit f9bca6a8df856d950360b40cbce744fcbffc9a63). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from 'spacetimedb'; + +// Import and reexport all reducer arg types +import OnConnectReducer from './on_connect_reducer'; +export { OnConnectReducer }; +import OnDisconnectReducer from './on_disconnect_reducer'; +export { OnDisconnectReducer }; +import AddReducer from './add_reducer'; +export { AddReducer }; +import SayHelloReducer from './say_hello_reducer'; +export { SayHelloReducer }; + +// Import and reexport all procedure arg types + +// Import and reexport all table handle types +import PersonRow from './person_table'; +export { PersonRow }; + +// Import and reexport all types +import Add from './add_type'; +export { Add }; +import Init from './init_type'; +export { Init }; +import OnConnect from './on_connect_type'; +export { OnConnect }; +import OnDisconnect from './on_disconnect_type'; +export { OnDisconnect }; +import Person from './person_type'; +export { Person }; +import SayHello from './say_hello_type'; +export { SayHello }; + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema( + __table( + { + name: 'person', + indexes: [], + constraints: [], + }, + PersonRow + ) +); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema('add', AddReducer), + __reducerSchema('say_hello', SayHelloReducer) +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures(); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: '1.11.3' as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. */ +export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); + +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap( + reducersSchema.reducersType.reducers +); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl< + typeof REMOTE_MODULE +> {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder( + REMOTE_MODULE, + (config: __DbConnectionConfig) => + new DbConnection(config) + ); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} diff --git a/templates/browser-ts/src/module_bindings/init_type.ts b/templates/browser-ts/src/module_bindings/init_type.ts new file mode 100644 index 00000000000..52ed691ed94 --- /dev/null +++ b/templates/browser-ts/src/module_bindings/init_type.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('Init', {}); diff --git a/templates/browser-ts/src/module_bindings/on_connect_reducer.ts b/templates/browser-ts/src/module_bindings/on_connect_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/browser-ts/src/module_bindings/on_connect_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/browser-ts/src/module_bindings/on_connect_type.ts b/templates/browser-ts/src/module_bindings/on_connect_type.ts new file mode 100644 index 00000000000..d36362515de --- /dev/null +++ b/templates/browser-ts/src/module_bindings/on_connect_type.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('OnConnect', {}); diff --git a/templates/browser-ts/src/module_bindings/on_disconnect_reducer.ts b/templates/browser-ts/src/module_bindings/on_disconnect_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/browser-ts/src/module_bindings/on_disconnect_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/browser-ts/src/module_bindings/on_disconnect_type.ts b/templates/browser-ts/src/module_bindings/on_disconnect_type.ts new file mode 100644 index 00000000000..efda71ebcfd --- /dev/null +++ b/templates/browser-ts/src/module_bindings/on_disconnect_type.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('OnDisconnect', {}); diff --git a/templates/browser-ts/src/module_bindings/person_table.ts b/templates/browser-ts/src/module_bindings/person_table.ts new file mode 100644 index 00000000000..0f70f74f617 --- /dev/null +++ b/templates/browser-ts/src/module_bindings/person_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.row({ + name: __t.string(), +}); diff --git a/templates/browser-ts/src/module_bindings/person_type.ts b/templates/browser-ts/src/module_bindings/person_type.ts new file mode 100644 index 00000000000..1156775a3cf --- /dev/null +++ b/templates/browser-ts/src/module_bindings/person_type.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('Person', { + name: __t.string(), +}); diff --git a/templates/browser-ts/src/module_bindings/say_hello_reducer.ts b/templates/browser-ts/src/module_bindings/say_hello_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/browser-ts/src/module_bindings/say_hello_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/browser-ts/src/module_bindings/say_hello_type.ts b/templates/browser-ts/src/module_bindings/say_hello_type.ts new file mode 100644 index 00000000000..6293ca6bd09 --- /dev/null +++ b/templates/browser-ts/src/module_bindings/say_hello_type.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('SayHello', {}); diff --git a/templates/browser-ts/tsconfig.json b/templates/browser-ts/tsconfig.json new file mode 100644 index 00000000000..f51f643a575 --- /dev/null +++ b/templates/browser-ts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/templates/browser-ts/vite.config.ts b/templates/browser-ts/vite.config.ts new file mode 100644 index 00000000000..8a5cba0f670 --- /dev/null +++ b/templates/browser-ts/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/bindings.ts'), + name: 'Bindings', + fileName: 'bindings', + formats: ['iife'], + }, + outDir: 'dist', + }, +}); From 76678e1603dbe47332d9a3b58f222e608537748e Mon Sep 17 00:00:00 2001 From: Tien Pham Date: Mon, 2 Feb 2026 13:16:36 +0200 Subject: [PATCH 3/3] chore: update template description --- templates/browser-ts/.template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/browser-ts/.template.json b/templates/browser-ts/.template.json index 3cbc3d87aac..8cc8855138b 100644 --- a/templates/browser-ts/.template.json +++ b/templates/browser-ts/.template.json @@ -1,5 +1,5 @@ { - "description": "Script tag web app with TypeScript server", + "description": "Browser web app with TypeScript server", "client_lang": "typescript", "server_lang": "typescript" }