reflectdb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1260 -0
- package/dist/cjs/client/index.cjs +1252 -0
- package/dist/cjs/client/index.d.cts +629 -0
- package/dist/cjs/client/storage/indexeddb.cjs +253 -0
- package/dist/cjs/client/storage/indexeddb.d.cts +85 -0
- package/dist/cjs/core/index.cjs +202 -0
- package/dist/cjs/core/index.d.cts +473 -0
- package/dist/cjs/react/index.cjs +1512 -0
- package/dist/cjs/react/index.d.cts +748 -0
- package/dist/cjs/server/drizzle.cjs +413 -0
- package/dist/cjs/server/drizzle.d.cts +233 -0
- package/dist/cjs/server/index.cjs +4486 -0
- package/dist/cjs/server/index.d.cts +1988 -0
- package/dist/cjs/svelte/index.cjs +1414 -0
- package/dist/cjs/svelte/index.d.cts +645 -0
- package/dist/cjs/transport/bun-ws.cjs +244 -0
- package/dist/cjs/transport/bun-ws.d.cts +215 -0
- package/dist/cjs/transport/polling.cjs +285 -0
- package/dist/cjs/transport/polling.d.cts +210 -0
- package/dist/cjs/transport/sse.cjs +312 -0
- package/dist/cjs/transport/sse.d.cts +205 -0
- package/dist/cjs/transport/ws.cjs +330 -0
- package/dist/cjs/transport/ws.d.cts +236 -0
- package/dist/cjs/vanilla/index.cjs +1430 -0
- package/dist/cjs/vanilla/index.d.cts +657 -0
- package/dist/client/index.d.ts +629 -0
- package/dist/client/index.js +106 -0
- package/dist/client/storage/indexeddb.d.ts +85 -0
- package/dist/client/storage/indexeddb.js +213 -0
- package/dist/core/index.d.ts +473 -0
- package/dist/core/index.js +56 -0
- package/dist/react/index.d.ts +748 -0
- package/dist/react/index.js +366 -0
- package/dist/server/drizzle.d.ts +233 -0
- package/dist/server/drizzle.js +9 -0
- package/dist/server/index.d.ts +1988 -0
- package/dist/server/index.js +3959 -0
- package/dist/shared/esm-3tkwvysa.js +54 -0
- package/dist/shared/esm-b7xs9cde.js +4 -0
- package/dist/shared/esm-rw7jjtrv.js +58 -0
- package/dist/shared/esm-wkwx6bd9.js +25 -0
- package/dist/shared/esm-ytrd3hbq.js +1007 -0
- package/dist/shared/esm-z1xse19c.js +369 -0
- package/dist/svelte/index.d.ts +645 -0
- package/dist/svelte/index.js +262 -0
- package/dist/transport/bun-ws.d.ts +215 -0
- package/dist/transport/bun-ws.js +140 -0
- package/dist/transport/polling.d.ts +210 -0
- package/dist/transport/polling.js +181 -0
- package/dist/transport/sse.d.ts +205 -0
- package/dist/transport/sse.js +208 -0
- package/dist/transport/ws.d.ts +236 -0
- package/dist/transport/ws.js +226 -0
- package/dist/vanilla/index.d.ts +657 -0
- package/dist/vanilla/index.js +278 -0
- package/package.json +253 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SyncClient,
|
|
3
|
+
pushSafely
|
|
4
|
+
} from "../shared/esm-ytrd3hbq.js";
|
|
5
|
+
import"../shared/esm-rw7jjtrv.js";
|
|
6
|
+
import"../shared/esm-3tkwvysa.js";
|
|
7
|
+
import"../shared/esm-b7xs9cde.js";
|
|
8
|
+
|
|
9
|
+
// src/svelte/stores.ts
|
|
10
|
+
function createBrowserWsTransport(url) {
|
|
11
|
+
let ws = null;
|
|
12
|
+
let handler = null;
|
|
13
|
+
let intentionalClose = false;
|
|
14
|
+
const sendQueue = [];
|
|
15
|
+
function ensureConnected() {
|
|
16
|
+
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
|
17
|
+
return ws;
|
|
18
|
+
}
|
|
19
|
+
const socket = new WebSocket(url);
|
|
20
|
+
socket.onopen = () => {
|
|
21
|
+
for (const msg of sendQueue) {
|
|
22
|
+
socket.send(JSON.stringify(msg));
|
|
23
|
+
}
|
|
24
|
+
sendQueue.length = 0;
|
|
25
|
+
};
|
|
26
|
+
socket.onmessage = (event) => {
|
|
27
|
+
try {
|
|
28
|
+
handler?.(JSON.parse(event.data));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn("[reflectdb] svelte WS: malformed message ignored:", err instanceof Error ? err.message : String(err));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
socket.onclose = () => {
|
|
34
|
+
ws = null;
|
|
35
|
+
if (!intentionalClose) {
|
|
36
|
+
handler?.({ type: "disconnect", reason: "transport_closed" });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
ws = socket;
|
|
40
|
+
return socket;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
async send(message) {
|
|
44
|
+
const socket = ensureConnected();
|
|
45
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
46
|
+
socket.send(JSON.stringify(message));
|
|
47
|
+
} else {
|
|
48
|
+
sendQueue.push(message);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
subscribe(h) {
|
|
52
|
+
handler = h;
|
|
53
|
+
},
|
|
54
|
+
async close() {
|
|
55
|
+
intentionalClose = true;
|
|
56
|
+
if (ws) {
|
|
57
|
+
ws.close();
|
|
58
|
+
ws = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function stableKey(params) {
|
|
64
|
+
if (!params)
|
|
65
|
+
return "";
|
|
66
|
+
return JSON.stringify(params, Object.keys(params).sort());
|
|
67
|
+
}
|
|
68
|
+
function createSyncStore(config) {
|
|
69
|
+
const managedTables = new Set(config.tables ?? []);
|
|
70
|
+
const clientId = config.clientId ?? `browser-${crypto.randomUUID().slice(0, 8)}`;
|
|
71
|
+
const transport = createBrowserWsTransport(config.url);
|
|
72
|
+
const client = new SyncClient({
|
|
73
|
+
clientId,
|
|
74
|
+
transport,
|
|
75
|
+
token: config.token,
|
|
76
|
+
storage: config.storage,
|
|
77
|
+
onReauth: () => config.onReauth?.() ?? Promise.reject(new Error("No onReauth handler")),
|
|
78
|
+
onError: (err) => config.onError?.(err)
|
|
79
|
+
});
|
|
80
|
+
const unmanagedRefCounts = new Map;
|
|
81
|
+
const unmanagedParamKeys = new Map;
|
|
82
|
+
const status = {
|
|
83
|
+
subscribe(cb) {
|
|
84
|
+
cb(client.getState());
|
|
85
|
+
return client.subscribe(() => {
|
|
86
|
+
cb(client.getState());
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const pendingCount = {
|
|
91
|
+
subscribe(cb) {
|
|
92
|
+
cb(client.getPendingCount());
|
|
93
|
+
return client.subscribe(() => {
|
|
94
|
+
cb(client.getPendingCount());
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function sync(table, options) {
|
|
99
|
+
const isManaged = managedTables.has(table);
|
|
100
|
+
const paramKey = stableKey(options?.params);
|
|
101
|
+
const rows = {
|
|
102
|
+
subscribe(cb) {
|
|
103
|
+
if (!isManaged) {
|
|
104
|
+
const currentParamKey = unmanagedParamKeys.get(table);
|
|
105
|
+
const count = unmanagedRefCounts.get(table) ?? 0;
|
|
106
|
+
if (count === 0 || currentParamKey !== paramKey) {
|
|
107
|
+
if (currentParamKey !== undefined && currentParamKey !== paramKey) {
|
|
108
|
+
client.unsync(table);
|
|
109
|
+
}
|
|
110
|
+
client.sync(table, options?.params, options?.window ? { window: options.window } : undefined);
|
|
111
|
+
client.scheduleBootstrap();
|
|
112
|
+
unmanagedParamKeys.set(table, paramKey);
|
|
113
|
+
}
|
|
114
|
+
unmanagedRefCounts.set(table, count + 1);
|
|
115
|
+
}
|
|
116
|
+
cb(client.getRows(table, { includeDeleted: options?.includeDeleted }));
|
|
117
|
+
const unsub = client.subscribeTable(table, () => {
|
|
118
|
+
cb(client.getRows(table, { includeDeleted: options?.includeDeleted }));
|
|
119
|
+
});
|
|
120
|
+
return () => {
|
|
121
|
+
unsub();
|
|
122
|
+
if (!isManaged) {
|
|
123
|
+
const count = (unmanagedRefCounts.get(table) ?? 1) - 1;
|
|
124
|
+
if (count <= 0) {
|
|
125
|
+
unmanagedRefCounts.delete(table);
|
|
126
|
+
unmanagedParamKeys.delete(table);
|
|
127
|
+
client.unsync(table);
|
|
128
|
+
} else {
|
|
129
|
+
unmanagedRefCounts.set(table, count);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const insert = (rowId, payload) => {
|
|
136
|
+
client.insert(table, rowId, payload);
|
|
137
|
+
pushSafely(client);
|
|
138
|
+
};
|
|
139
|
+
const update = (rowId, payload) => {
|
|
140
|
+
client.update(table, rowId, payload);
|
|
141
|
+
pushSafely(client);
|
|
142
|
+
};
|
|
143
|
+
const remove = (rowId) => {
|
|
144
|
+
client.delete(table, rowId);
|
|
145
|
+
pushSafely(client);
|
|
146
|
+
};
|
|
147
|
+
return { rows, insert, update, remove };
|
|
148
|
+
}
|
|
149
|
+
function row(table, rowId) {
|
|
150
|
+
return {
|
|
151
|
+
subscribe(cb) {
|
|
152
|
+
cb(client.getRow(table, rowId));
|
|
153
|
+
return client.subscribeTable(table, () => {
|
|
154
|
+
cb(client.getRow(table, rowId));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function ephemeral(cfg) {
|
|
160
|
+
let current = {};
|
|
161
|
+
const timers = new Map;
|
|
162
|
+
const subscribers = new Set;
|
|
163
|
+
function notifySubscribers() {
|
|
164
|
+
for (const sub of subscribers) {
|
|
165
|
+
sub(current);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const unsub = client.subscribeEphemeral(cfg.key, (event) => {
|
|
169
|
+
current = { ...current, [event.userId]: event.data };
|
|
170
|
+
notifySubscribers();
|
|
171
|
+
if (cfg.ttlMs) {
|
|
172
|
+
const existing = timers.get(event.userId);
|
|
173
|
+
if (existing) {
|
|
174
|
+
clearTimeout(existing);
|
|
175
|
+
}
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
const next = { ...current };
|
|
178
|
+
delete next[event.userId];
|
|
179
|
+
current = next;
|
|
180
|
+
timers.delete(event.userId);
|
|
181
|
+
notifySubscribers();
|
|
182
|
+
}, cfg.ttlMs);
|
|
183
|
+
timers.set(event.userId, timer);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
const events = {
|
|
187
|
+
subscribe(cb) {
|
|
188
|
+
subscribers.add(cb);
|
|
189
|
+
cb(current);
|
|
190
|
+
return () => {
|
|
191
|
+
subscribers.delete(cb);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const broadcast = (data) => client.sendEphemeral({
|
|
196
|
+
key: cfg.key,
|
|
197
|
+
userId: cfg.userId,
|
|
198
|
+
data,
|
|
199
|
+
ttlMs: cfg.ttlMs
|
|
200
|
+
});
|
|
201
|
+
const destroy = () => {
|
|
202
|
+
unsub();
|
|
203
|
+
for (const timer of timers.values()) {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
timers.clear();
|
|
207
|
+
subscribers.clear();
|
|
208
|
+
};
|
|
209
|
+
return { events, broadcast, destroy };
|
|
210
|
+
}
|
|
211
|
+
function totalCount(table) {
|
|
212
|
+
return {
|
|
213
|
+
subscribe(cb) {
|
|
214
|
+
cb(client.getTotalCount(table));
|
|
215
|
+
return client.subscribeTable(table, () => {
|
|
216
|
+
cb(client.getTotalCount(table));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function loadMore(table, count) {
|
|
222
|
+
client.loadMore(table, count);
|
|
223
|
+
}
|
|
224
|
+
async function connectLifecycle() {
|
|
225
|
+
if (config.storage) {
|
|
226
|
+
await client.init();
|
|
227
|
+
}
|
|
228
|
+
await client.connect();
|
|
229
|
+
for (const table of managedTables) {
|
|
230
|
+
await client.sync(table);
|
|
231
|
+
}
|
|
232
|
+
await client.resume();
|
|
233
|
+
}
|
|
234
|
+
async function close() {
|
|
235
|
+
await client.close();
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
client,
|
|
239
|
+
status,
|
|
240
|
+
pendingCount,
|
|
241
|
+
sync,
|
|
242
|
+
row,
|
|
243
|
+
ephemeral,
|
|
244
|
+
totalCount,
|
|
245
|
+
loadMore,
|
|
246
|
+
connect: connectLifecycle,
|
|
247
|
+
close
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// src/svelte/typed.ts
|
|
251
|
+
function createSyncSvelte() {
|
|
252
|
+
function createStore(config) {
|
|
253
|
+
const store = createSyncStore(config);
|
|
254
|
+
return store;
|
|
255
|
+
}
|
|
256
|
+
return { createStore };
|
|
257
|
+
}
|
|
258
|
+
export {
|
|
259
|
+
createSyncSvelte,
|
|
260
|
+
createSyncStore,
|
|
261
|
+
createBrowserWsTransport
|
|
262
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
type OpType = "insert" | "update" | "delete";
|
|
2
|
+
type ErrorReason = "outside_shape" | "readonly_query" | "rate_limited" | "buffer_full" | "server_conflict" | "custom_conflict" | "merge_stale" | "clock_drift" | "replay" | "batch_too_large" | "schema_outdated" | "auth_revoked" | "compacted" | "mutation_rejected" | "server_error" | "unknown_query";
|
|
3
|
+
interface HelloMessage {
|
|
4
|
+
type: "hello";
|
|
5
|
+
/** Highest protocol version the client prefers. Retained for back-compat. */
|
|
6
|
+
protocolVersion: number;
|
|
7
|
+
/** All versions the client can speak. Omit for legacy single-version clients. */
|
|
8
|
+
supportedVersions?: readonly number[];
|
|
9
|
+
clientId: string;
|
|
10
|
+
token: string;
|
|
11
|
+
}
|
|
12
|
+
interface BootstrapMessage {
|
|
13
|
+
type: "bootstrap";
|
|
14
|
+
syncParams?: Record<string, Record<string, unknown>>;
|
|
15
|
+
}
|
|
16
|
+
interface ResumeMessage {
|
|
17
|
+
type: "resume";
|
|
18
|
+
since: string;
|
|
19
|
+
}
|
|
20
|
+
interface OpsMessage {
|
|
21
|
+
type: "ops";
|
|
22
|
+
ops: ClientOp[];
|
|
23
|
+
token: string;
|
|
24
|
+
}
|
|
25
|
+
interface ClientOp {
|
|
26
|
+
id: string;
|
|
27
|
+
table: string;
|
|
28
|
+
op: OpType;
|
|
29
|
+
rowId: string;
|
|
30
|
+
payload: Record<string, unknown> | null;
|
|
31
|
+
hlc: string;
|
|
32
|
+
batchId?: string;
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
batchSeq?: number;
|
|
35
|
+
}
|
|
36
|
+
interface SyncDeclareMessage {
|
|
37
|
+
type: "sync_declare";
|
|
38
|
+
table: string;
|
|
39
|
+
params?: Record<string, unknown>;
|
|
40
|
+
window?: number;
|
|
41
|
+
}
|
|
42
|
+
interface LoadMoreMessage {
|
|
43
|
+
type: "load_more";
|
|
44
|
+
table: string;
|
|
45
|
+
count: number;
|
|
46
|
+
}
|
|
47
|
+
interface UnsyncMessage {
|
|
48
|
+
type: "unsync";
|
|
49
|
+
table: string;
|
|
50
|
+
}
|
|
51
|
+
interface AuthMessage {
|
|
52
|
+
type: "auth";
|
|
53
|
+
token: string;
|
|
54
|
+
}
|
|
55
|
+
interface EphemeralMessage {
|
|
56
|
+
type: "ephemeral";
|
|
57
|
+
key: string;
|
|
58
|
+
userId: string;
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
ttlMs?: number;
|
|
61
|
+
}
|
|
62
|
+
interface HelloAckMessage {
|
|
63
|
+
type: "hello_ack";
|
|
64
|
+
protocolVersion: number;
|
|
65
|
+
serverId: string;
|
|
66
|
+
}
|
|
67
|
+
interface HelloRejectMessage {
|
|
68
|
+
type: "hello_reject";
|
|
69
|
+
reason: string;
|
|
70
|
+
supported: number[];
|
|
71
|
+
}
|
|
72
|
+
interface SnapshotMessage {
|
|
73
|
+
type: "snapshot";
|
|
74
|
+
table: string;
|
|
75
|
+
rows: Record<string, unknown>[];
|
|
76
|
+
colClocks: Record<string, Record<string, string>>;
|
|
77
|
+
append?: boolean;
|
|
78
|
+
totalCount?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Primary key column name for this table. Self-contained per message so the
|
|
81
|
+
* client doesn't depend on `bootstrap_complete.tableMeta` arriving first.
|
|
82
|
+
* When omitted (older servers), the client falls back to tableMeta, then "id".
|
|
83
|
+
*/
|
|
84
|
+
pk?: string;
|
|
85
|
+
}
|
|
86
|
+
interface BootstrapCompleteMessage {
|
|
87
|
+
type: "bootstrap_complete";
|
|
88
|
+
serverHlc: string;
|
|
89
|
+
tableMeta: Record<string, TableMeta>;
|
|
90
|
+
}
|
|
91
|
+
interface TableMeta {
|
|
92
|
+
serverSet: string[];
|
|
93
|
+
readonly: string[];
|
|
94
|
+
broadcast: "consistent" | "eager" | "eager-durable";
|
|
95
|
+
/** Primary key column name. Defaults to "id" when omitted (older servers). */
|
|
96
|
+
pk?: string;
|
|
97
|
+
}
|
|
98
|
+
interface DeltaMessage {
|
|
99
|
+
type: "delta";
|
|
100
|
+
table: string;
|
|
101
|
+
op: OpType;
|
|
102
|
+
rowId: string;
|
|
103
|
+
payload: Record<string, unknown> | null;
|
|
104
|
+
hlc: string;
|
|
105
|
+
colClocks?: Record<string, string>;
|
|
106
|
+
}
|
|
107
|
+
interface AckMessage {
|
|
108
|
+
type: "ack";
|
|
109
|
+
opIds: string[];
|
|
110
|
+
}
|
|
111
|
+
interface RejectMessage {
|
|
112
|
+
type: "reject";
|
|
113
|
+
opId?: string;
|
|
114
|
+
batchId?: string;
|
|
115
|
+
reason: ErrorReason;
|
|
116
|
+
serverRow?: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
interface ResumeCompleteMessage {
|
|
119
|
+
type: "resume_complete";
|
|
120
|
+
serverHlc: string;
|
|
121
|
+
}
|
|
122
|
+
interface ResumeRejectedMessage {
|
|
123
|
+
type: "resume_rejected";
|
|
124
|
+
reason: string;
|
|
125
|
+
serverHlc: string;
|
|
126
|
+
}
|
|
127
|
+
interface ShapeChangedMessage {
|
|
128
|
+
type: "shape_changed";
|
|
129
|
+
table: string;
|
|
130
|
+
reason: "auth_changed" | "params_changed" | "server_policy";
|
|
131
|
+
}
|
|
132
|
+
interface DisconnectMessage {
|
|
133
|
+
type: "disconnect";
|
|
134
|
+
reason: string;
|
|
135
|
+
}
|
|
136
|
+
interface ReauthMessage {
|
|
137
|
+
type: "reauth";
|
|
138
|
+
}
|
|
139
|
+
interface CountChangedMessage {
|
|
140
|
+
type: "count_changed";
|
|
141
|
+
table: string;
|
|
142
|
+
totalCount: number;
|
|
143
|
+
}
|
|
144
|
+
interface EphemeralEvent {
|
|
145
|
+
type: "ephemeral";
|
|
146
|
+
key: string;
|
|
147
|
+
clientId: string;
|
|
148
|
+
userId: string;
|
|
149
|
+
data: Record<string, unknown>;
|
|
150
|
+
ttlMs?: number;
|
|
151
|
+
}
|
|
152
|
+
type ClientMessage = HelloMessage | BootstrapMessage | ResumeMessage | OpsMessage | SyncDeclareMessage | LoadMoreMessage | UnsyncMessage | AuthMessage | EphemeralMessage;
|
|
153
|
+
type ServerMessage = HelloAckMessage | HelloRejectMessage | SnapshotMessage | BootstrapCompleteMessage | DeltaMessage | AckMessage | RejectMessage | ResumeCompleteMessage | ResumeRejectedMessage | ShapeChangedMessage | DisconnectMessage | ReauthMessage | EphemeralEvent | CountChangedMessage;
|
|
154
|
+
interface ServerTransport {
|
|
155
|
+
/**
|
|
156
|
+
* Deliver a message to one client.
|
|
157
|
+
*
|
|
158
|
+
* MUST reject (ideally with `TransportSendError`) when the frame could not
|
|
159
|
+
* be handed to the peer — unknown/closed socket, full outbound queue, or a
|
|
160
|
+
* throw from the underlying socket. Resolving on a dropped frame makes the
|
|
161
|
+
* broadcast engine commit result-cache state the client never received.
|
|
162
|
+
*/
|
|
163
|
+
send(clientId: string, message: ServerMessage): Promise<void>;
|
|
164
|
+
broadcast(roomId: string, message: ServerMessage, exclude?: string): Promise<void>;
|
|
165
|
+
onMessage(handler: (clientId: string, message: ClientMessage) => void): void;
|
|
166
|
+
onConnect(handler: (clientId: string, req: Request) => void): void;
|
|
167
|
+
onDisconnect(handler: (clientId: string) => void): void;
|
|
168
|
+
close(): Promise<void>;
|
|
169
|
+
/**
|
|
170
|
+
* Force-disconnect a single client. Optional — if absent, the handler relies
|
|
171
|
+
* on the client honoring a `disconnect` server-message. Required for any
|
|
172
|
+
* transport that carries authentication state, since an adversarial client
|
|
173
|
+
* can otherwise ignore the message and continue using a revoked session.
|
|
174
|
+
*/
|
|
175
|
+
disconnect?(clientId: string): Promise<void> | void;
|
|
176
|
+
}
|
|
177
|
+
interface BunWsTransportData {
|
|
178
|
+
id: string;
|
|
179
|
+
/** Original upgrade request — forwarded to the onConnect handler if provided */
|
|
180
|
+
req?: Request;
|
|
181
|
+
}
|
|
182
|
+
/** Bun's ServerWebSocket shape (subset used by this transport) */
|
|
183
|
+
interface BunServerWebSocket<T> {
|
|
184
|
+
data: T;
|
|
185
|
+
send(data: string): void;
|
|
186
|
+
ping?: (data?: string) => void;
|
|
187
|
+
close(): void;
|
|
188
|
+
readyState?: number;
|
|
189
|
+
/** Bun exposes queued-but-unflushed bytes here; used for backpressure. */
|
|
190
|
+
getBufferedAmount?: () => number;
|
|
191
|
+
}
|
|
192
|
+
interface BunWsServerConfig {
|
|
193
|
+
/** Max inbound message bytes before the connection is closed. Default: 1 MB. */
|
|
194
|
+
maxMessageBytes?: number;
|
|
195
|
+
/** Ping interval in ms. 0 disables. Default: 30_000. */
|
|
196
|
+
pingIntervalMs?: number;
|
|
197
|
+
/** Close a connection if no message/pong seen in this window. Default: 2× pingInterval. */
|
|
198
|
+
pongTimeoutMs?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Outbound backpressure ceiling in bytes. `send` rejects once the socket has
|
|
201
|
+
* more than this queued, so a slow reader can't grow server memory without
|
|
202
|
+
* bound. 0 disables. Default: 8 MB.
|
|
203
|
+
*/
|
|
204
|
+
maxBufferedBytes?: number;
|
|
205
|
+
}
|
|
206
|
+
declare function createBunWsServerTransport(cfg?: BunWsServerConfig): {
|
|
207
|
+
transport: ServerTransport;
|
|
208
|
+
websocket: {
|
|
209
|
+
open(ws: BunServerWebSocket<BunWsTransportData>): void;
|
|
210
|
+
message(ws: BunServerWebSocket<BunWsTransportData>, data: string | Uint8Array): void;
|
|
211
|
+
close(ws: BunServerWebSocket<BunWsTransportData>): void;
|
|
212
|
+
pong?(ws: BunServerWebSocket<BunWsTransportData>): void;
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
export { createBunWsServerTransport, BunWsTransportData, BunWsServerConfig };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TransportSendError
|
|
3
|
+
} from "../shared/esm-3tkwvysa.js";
|
|
4
|
+
import"../shared/esm-b7xs9cde.js";
|
|
5
|
+
|
|
6
|
+
// src/transport/bun-ws.ts
|
|
7
|
+
function trySend(clientId, ws, data, maxBufferedBytes) {
|
|
8
|
+
if (ws.readyState !== undefined && ws.readyState !== 1) {
|
|
9
|
+
throw new TransportSendError(clientId, `bun-ws not open (readyState=${ws.readyState})`);
|
|
10
|
+
}
|
|
11
|
+
if (maxBufferedBytes > 0 && typeof ws.getBufferedAmount === "function") {
|
|
12
|
+
const buffered = ws.getBufferedAmount();
|
|
13
|
+
if (buffered > maxBufferedBytes) {
|
|
14
|
+
throw new TransportSendError(clientId, `bun-ws outbound buffer over limit (${buffered} > ${maxBufferedBytes})`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
ws.send(data);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new TransportSendError(clientId, `bun-ws send failed: ${err instanceof Error ? err.message : String(err)}`, err);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function createBunWsServerTransport(cfg = {}) {
|
|
24
|
+
const clients = new Map;
|
|
25
|
+
const lastSeen = new Map;
|
|
26
|
+
const maxMessageBytes = cfg.maxMessageBytes ?? 1e6;
|
|
27
|
+
const pingIntervalMs = cfg.pingIntervalMs ?? 30000;
|
|
28
|
+
const pongTimeoutMs = cfg.pongTimeoutMs ?? pingIntervalMs * 2;
|
|
29
|
+
const maxBufferedBytes = cfg.maxBufferedBytes ?? 8000000;
|
|
30
|
+
let messageHandler = null;
|
|
31
|
+
let connectHandler = null;
|
|
32
|
+
let disconnectHandler = null;
|
|
33
|
+
let heartbeatTimer = null;
|
|
34
|
+
if (pingIntervalMs > 0) {
|
|
35
|
+
heartbeatTimer = setInterval(() => {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [id, ws] of clients) {
|
|
38
|
+
const last = lastSeen.get(id) ?? now;
|
|
39
|
+
if (now - last > pongTimeoutMs) {
|
|
40
|
+
try {
|
|
41
|
+
ws.close();
|
|
42
|
+
} catch {}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
ws.ping?.();
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
}, pingIntervalMs);
|
|
50
|
+
if (typeof heartbeatTimer.unref === "function") {
|
|
51
|
+
heartbeatTimer.unref();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const websocket = {
|
|
55
|
+
open(ws) {
|
|
56
|
+
const { id, req } = ws.data;
|
|
57
|
+
clients.set(id, ws);
|
|
58
|
+
lastSeen.set(id, Date.now());
|
|
59
|
+
connectHandler?.(id, req ?? new Request("https://ws-connect"));
|
|
60
|
+
},
|
|
61
|
+
message(ws, data) {
|
|
62
|
+
const { id } = ws.data;
|
|
63
|
+
lastSeen.set(id, Date.now());
|
|
64
|
+
const byteLen = typeof data === "string" ? typeof Buffer !== "undefined" ? Buffer.byteLength(data, "utf8") : new Blob([data]).size : data.length;
|
|
65
|
+
if (byteLen > maxMessageBytes) {
|
|
66
|
+
console.warn(`[reflectdb] bun-ws: message from ${id} exceeds ${maxMessageBytes} bytes (${byteLen}), closing`);
|
|
67
|
+
try {
|
|
68
|
+
ws.close();
|
|
69
|
+
} catch {}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const raw = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
73
|
+
try {
|
|
74
|
+
messageHandler?.(id, JSON.parse(raw));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn(`[reflectdb] bun-ws: malformed message from ${id}:`, err instanceof Error ? err.message : String(err));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
close(ws) {
|
|
80
|
+
const { id } = ws.data;
|
|
81
|
+
clients.delete(id);
|
|
82
|
+
lastSeen.delete(id);
|
|
83
|
+
disconnectHandler?.(id);
|
|
84
|
+
},
|
|
85
|
+
pong(ws) {
|
|
86
|
+
lastSeen.set(ws.data.id, Date.now());
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const transport = {
|
|
90
|
+
async send(clientId, message) {
|
|
91
|
+
const ws = clients.get(clientId);
|
|
92
|
+
if (!ws) {
|
|
93
|
+
throw new TransportSendError(clientId, "no socket registered for client");
|
|
94
|
+
}
|
|
95
|
+
trySend(clientId, ws, JSON.stringify(message), maxBufferedBytes);
|
|
96
|
+
},
|
|
97
|
+
async broadcast(_roomId, message, exclude) {
|
|
98
|
+
const data = JSON.stringify(message);
|
|
99
|
+
for (const [id, ws] of clients) {
|
|
100
|
+
if (id === exclude)
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
trySend(id, ws, data, maxBufferedBytes);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn("[reflectdb] bun-ws broadcast skipped a client:", err instanceof Error ? err.message : err);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
disconnect(clientId) {
|
|
110
|
+
const ws = clients.get(clientId);
|
|
111
|
+
if (!ws)
|
|
112
|
+
return;
|
|
113
|
+
try {
|
|
114
|
+
ws.close();
|
|
115
|
+
} catch {}
|
|
116
|
+
clients.delete(clientId);
|
|
117
|
+
},
|
|
118
|
+
onMessage(handler) {
|
|
119
|
+
messageHandler = handler;
|
|
120
|
+
},
|
|
121
|
+
onConnect(handler) {
|
|
122
|
+
connectHandler = handler;
|
|
123
|
+
},
|
|
124
|
+
onDisconnect(handler) {
|
|
125
|
+
disconnectHandler = handler;
|
|
126
|
+
},
|
|
127
|
+
async close() {
|
|
128
|
+
if (heartbeatTimer)
|
|
129
|
+
clearInterval(heartbeatTimer);
|
|
130
|
+
for (const ws of clients.values())
|
|
131
|
+
ws.close();
|
|
132
|
+
clients.clear();
|
|
133
|
+
lastSeen.clear();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return { transport, websocket };
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
createBunWsServerTransport
|
|
140
|
+
};
|