Skip to content

Commit d20941a

Browse files
Round 11: Add multi-tab web infrastructure
- Add SharedWorker coordinator for tab coordination and provider election - Add provider worker for SQLite database access - Add DbClient proxy for main thread database operations - Add multi-tab Playwright tests (skipped until bundled) Multi-tab architecture: - Coordinator uses Web Locks for provider election - Provider owns SQLite WASM connection - Client tabs route requests through coordinator - Serial request queue prevents race conditions
1 parent ab03d43 commit d20941a

File tree

8 files changed

+807
-0
lines changed

8 files changed

+807
-0
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* DbClient - Browser Tab Database Client
3+
*
4+
* Provides the client-side API that browser tabs use to access the database
5+
* through the SharedWorker coordinator.
6+
*/
7+
8+
import {
9+
RpcRequest,
10+
RequestId,
11+
createRequest,
12+
RPC_TIMEOUT_MS,
13+
SHARED_WORKER_PATH,
14+
} from '../shared';
15+
16+
export interface DbClientOptions {
17+
dbName: string;
18+
coordinatorUrl?: string;
19+
}
20+
21+
export class DbClient {
22+
private worker: SharedWorker | null = null;
23+
private port: MessagePort | null = null;
24+
private clientId: string | null = null;
25+
private isProvider = false;
26+
private pendingRequests = new Map<
27+
RequestId,
28+
{
29+
resolve: (result: unknown) => void;
30+
reject: (error: Error) => void;
31+
}
32+
>();
33+
private readyPromise: Promise<void>;
34+
private resolveReady!: () => void;
35+
36+
readonly dbName: string;
37+
38+
constructor(options: DbClientOptions) {
39+
this.dbName = options.dbName;
40+
this.readyPromise = new Promise((resolve) => {
41+
this.resolveReady = resolve;
42+
});
43+
44+
this.connect(options.coordinatorUrl);
45+
}
46+
47+
private connect(coordinatorUrl?: string) {
48+
const url = coordinatorUrl || SHARED_WORKER_PATH;
49+
this.worker = new SharedWorker(url, { type: 'module' });
50+
this.port = this.worker.port;
51+
52+
this.port.onmessage = (event) => this.handleMessage(event.data);
53+
this.port.start();
54+
}
55+
56+
private handleMessage(msg: unknown) {
57+
const message = msg as {
58+
type: string;
59+
clientId?: string;
60+
isYou?: boolean;
61+
requestId?: RequestId;
62+
payload?: { result?: unknown; message?: string };
63+
};
64+
65+
switch (message.type) {
66+
case 'connected':
67+
this.clientId = message.clientId ?? null;
68+
break;
69+
70+
case 'provider-elected':
71+
this.isProvider = message.isYou ?? false;
72+
this.resolveReady();
73+
break;
74+
75+
case 'result':
76+
case 'error': {
77+
if (!message.requestId) break;
78+
const pending = this.pendingRequests.get(message.requestId);
79+
if (pending) {
80+
this.pendingRequests.delete(message.requestId);
81+
if (message.type === 'result') {
82+
pending.resolve(message.payload?.result);
83+
} else {
84+
pending.reject(new Error(message.payload?.message ?? 'Unknown error'));
85+
}
86+
}
87+
break;
88+
}
89+
}
90+
}
91+
92+
get ready(): Promise<void> {
93+
return this.readyPromise;
94+
}
95+
96+
get isDbProvider(): boolean {
97+
return this.isProvider;
98+
}
99+
100+
get id(): string | null {
101+
return this.clientId;
102+
}
103+
104+
private async sendRequest<T>(request: RpcRequest): Promise<T> {
105+
await this.ready;
106+
107+
return new Promise((resolve, reject) => {
108+
this.pendingRequests.set(request.requestId, {
109+
resolve: resolve as (result: unknown) => void,
110+
reject,
111+
});
112+
this.port?.postMessage(request);
113+
114+
// Timeout after configured duration
115+
setTimeout(() => {
116+
if (this.pendingRequests.has(request.requestId)) {
117+
this.pendingRequests.delete(request.requestId);
118+
reject(new Error('Request timeout'));
119+
}
120+
}, RPC_TIMEOUT_MS);
121+
});
122+
}
123+
124+
async open(): Promise<void> {
125+
await this.sendRequest(
126+
createRequest('open', crypto.randomUUID(), { dbName: this.dbName })
127+
);
128+
}
129+
130+
async close(): Promise<void> {
131+
await this.sendRequest(
132+
createRequest('close', crypto.randomUUID(), { dbName: this.dbName })
133+
);
134+
}
135+
136+
async exec(sql: string, bind?: unknown[]): Promise<{ changes: number }> {
137+
return this.sendRequest(
138+
createRequest('exec', crypto.randomUUID(), { sql, bind })
139+
);
140+
}
141+
142+
async query(sql: string, bind?: unknown[]): Promise<unknown[][]> {
143+
const result = await this.sendRequest<{ rows: unknown[][] }>(
144+
createRequest('query', crypto.randomUUID(), { sql, bind })
145+
);
146+
return result.rows;
147+
}
148+
149+
async ping(): Promise<{ pong: boolean; timestamp: number }> {
150+
return this.sendRequest(
151+
createRequest('ping', crypto.randomUUID(), {})
152+
);
153+
}
154+
155+
disconnect() {
156+
this.port?.close();
157+
this.worker = null;
158+
this.port = null;
159+
}
160+
}
161+
162+
export function createDbClient(options: DbClientOptions): DbClient {
163+
return new DbClient(options);
164+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Client module for browser tab database access
3+
*
4+
* @example
5+
* ```typescript
6+
* import { DbClient, createDbClient } from './client';
7+
*
8+
* const client = createDbClient({ dbName: 'mydb' });
9+
* await client.ready;
10+
* const rows = await client.query('SELECT * FROM users');
11+
* ```
12+
*/
13+
14+
export { DbClient, createDbClient } from './db-client';
15+
export type { DbClientOptions } from './db-client';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Coordinator Module
3+
*
4+
* Re-exports SharedWorker coordinator functionality for multi-tab database access.
5+
*/
6+
7+
// The SharedWorker runs in its own context, so we just re-export the path
8+
// and any utilities that might be useful for the main thread.
9+
10+
export { SHARED_WORKER_PATH } from '../shared/constants';
11+
12+
// Note: The actual SharedWorker code (shared-worker.ts) runs in a separate
13+
// worker context and cannot be directly imported into the main thread.
14+
// Use SHARED_WORKER_PATH to instantiate: new SharedWorker(SHARED_WORKER_PATH)

0 commit comments

Comments
 (0)