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,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TransportSendError
|
|
3
|
+
} from "../shared/esm-3tkwvysa.js";
|
|
4
|
+
import"../shared/esm-b7xs9cde.js";
|
|
5
|
+
|
|
6
|
+
// src/transport/sse.ts
|
|
7
|
+
function createSseServerTransport(cfg = {}) {
|
|
8
|
+
const controllers = new Map;
|
|
9
|
+
const buffers = new Map;
|
|
10
|
+
const counters = new Map;
|
|
11
|
+
const replayBufferSize = cfg.replayBufferSize ?? 256;
|
|
12
|
+
const maxMessageBytes = cfg.maxMessageBytes ?? 1e6;
|
|
13
|
+
let messageHandler = null;
|
|
14
|
+
let connectHandler = null;
|
|
15
|
+
let disconnectHandler = null;
|
|
16
|
+
const encoder = new TextEncoder;
|
|
17
|
+
function encode(id, message) {
|
|
18
|
+
const data = JSON.stringify(message);
|
|
19
|
+
return encoder.encode(`id: ${id}
|
|
20
|
+
data: ${data}
|
|
21
|
+
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
function buffer(clientId, id, encoded, delivered) {
|
|
25
|
+
let buf = buffers.get(clientId);
|
|
26
|
+
if (!buf) {
|
|
27
|
+
buf = [];
|
|
28
|
+
buffers.set(clientId, buf);
|
|
29
|
+
}
|
|
30
|
+
buf.push({ id, encoded, delivered });
|
|
31
|
+
let lostUndelivered = false;
|
|
32
|
+
while (buf.length > replayBufferSize) {
|
|
33
|
+
const dropped = buf.shift();
|
|
34
|
+
if (dropped && !dropped.delivered)
|
|
35
|
+
lostUndelivered = true;
|
|
36
|
+
}
|
|
37
|
+
return lostUndelivered;
|
|
38
|
+
}
|
|
39
|
+
function nextId(clientId) {
|
|
40
|
+
const id = (counters.get(clientId) ?? 0) + 1;
|
|
41
|
+
counters.set(clientId, id);
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
handleSubscribe(clientId, controller, lastEventId) {
|
|
46
|
+
controllers.set(clientId, controller);
|
|
47
|
+
if (lastEventId) {
|
|
48
|
+
const lastId = Number(lastEventId);
|
|
49
|
+
const buf = buffers.get(clientId);
|
|
50
|
+
if (buf && Number.isFinite(lastId)) {
|
|
51
|
+
for (const entry of buf) {
|
|
52
|
+
if (entry.id > lastId) {
|
|
53
|
+
controller.enqueue(entry.encoded);
|
|
54
|
+
entry.delivered = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
connectHandler?.(clientId, new Request("https://sse-connect"));
|
|
60
|
+
},
|
|
61
|
+
handleMessage(clientId, message) {
|
|
62
|
+
let byteLen;
|
|
63
|
+
try {
|
|
64
|
+
const stringified = JSON.stringify(message);
|
|
65
|
+
byteLen = typeof Buffer !== "undefined" ? Buffer.byteLength(stringified, "utf8") : new Blob([stringified]).size;
|
|
66
|
+
} catch {
|
|
67
|
+
byteLen = 0;
|
|
68
|
+
}
|
|
69
|
+
if (byteLen > maxMessageBytes) {
|
|
70
|
+
console.warn(`[reflectdb] SSE: message from ${clientId} exceeds ${maxMessageBytes} bytes (${byteLen}), dropping`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
messageHandler?.(clientId, message);
|
|
74
|
+
},
|
|
75
|
+
handleDisconnect(clientId) {
|
|
76
|
+
controllers.delete(clientId);
|
|
77
|
+
disconnectHandler?.(clientId);
|
|
78
|
+
},
|
|
79
|
+
createEventStream(clientId, lastEventId) {
|
|
80
|
+
return new ReadableStream({
|
|
81
|
+
start: (controller) => {
|
|
82
|
+
this.handleSubscribe(clientId, controller, lastEventId);
|
|
83
|
+
},
|
|
84
|
+
cancel: () => {
|
|
85
|
+
this.handleDisconnect(clientId);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
async send(clientId, message) {
|
|
90
|
+
const id = nextId(clientId);
|
|
91
|
+
const encoded = encode(id, message);
|
|
92
|
+
const controller = controllers.get(clientId);
|
|
93
|
+
let sendError;
|
|
94
|
+
let delivered = false;
|
|
95
|
+
if (controller) {
|
|
96
|
+
try {
|
|
97
|
+
controller.enqueue(encoded);
|
|
98
|
+
delivered = true;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
sendError = err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const lostUndelivered = buffer(clientId, id, encoded, delivered);
|
|
104
|
+
if (sendError) {
|
|
105
|
+
throw new TransportSendError(clientId, `sse enqueue failed: ${sendError instanceof Error ? sendError.message : String(sendError)}`, sendError);
|
|
106
|
+
}
|
|
107
|
+
if (lostUndelivered) {
|
|
108
|
+
throw new TransportSendError(clientId, `sse replay buffer overflow (size ${replayBufferSize}); undelivered frames dropped`);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
async broadcast(_roomId, message, exclude) {
|
|
112
|
+
for (const [clientId, controller] of controllers) {
|
|
113
|
+
if (clientId === exclude)
|
|
114
|
+
continue;
|
|
115
|
+
const id = nextId(clientId);
|
|
116
|
+
const encoded = encode(id, message);
|
|
117
|
+
let delivered = false;
|
|
118
|
+
try {
|
|
119
|
+
controller.enqueue(encoded);
|
|
120
|
+
delivered = true;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.warn("[reflectdb] sse broadcast skipped a client:", err instanceof Error ? err.message : err);
|
|
123
|
+
}
|
|
124
|
+
buffer(clientId, id, encoded, delivered);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
onMessage(handler) {
|
|
128
|
+
messageHandler = handler;
|
|
129
|
+
},
|
|
130
|
+
onConnect(handler) {
|
|
131
|
+
connectHandler = handler;
|
|
132
|
+
},
|
|
133
|
+
onDisconnect(handler) {
|
|
134
|
+
disconnectHandler = handler;
|
|
135
|
+
},
|
|
136
|
+
async close() {
|
|
137
|
+
for (const controller of controllers.values()) {
|
|
138
|
+
try {
|
|
139
|
+
controller.close();
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
controllers.clear();
|
|
143
|
+
buffers.clear();
|
|
144
|
+
counters.clear();
|
|
145
|
+
},
|
|
146
|
+
disconnect(clientId) {
|
|
147
|
+
const controller = controllers.get(clientId);
|
|
148
|
+
if (controller) {
|
|
149
|
+
try {
|
|
150
|
+
controller.close();
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
controllers.delete(clientId);
|
|
154
|
+
buffers.delete(clientId);
|
|
155
|
+
counters.delete(clientId);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function createSseClientTransport(config) {
|
|
160
|
+
let eventSource = null;
|
|
161
|
+
let handler = null;
|
|
162
|
+
let intentionalClose = false;
|
|
163
|
+
return {
|
|
164
|
+
async send(message) {
|
|
165
|
+
const res = await fetch(config.messageUrl, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
...config.headers
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify(message)
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
throw new Error(`SSE send failed: ${res.status} ${res.statusText}`);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
subscribe(h) {
|
|
178
|
+
handler = h;
|
|
179
|
+
if (eventSource)
|
|
180
|
+
return;
|
|
181
|
+
eventSource = new EventSource(config.eventUrl);
|
|
182
|
+
eventSource.onmessage = (event) => {
|
|
183
|
+
try {
|
|
184
|
+
const message = JSON.parse(event.data);
|
|
185
|
+
handler?.(message);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.warn("[reflectdb] SSE: malformed message ignored:", err instanceof Error ? err.message : String(err));
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
eventSource.onerror = () => {
|
|
191
|
+
if (!intentionalClose) {
|
|
192
|
+
handler?.({ type: "disconnect", reason: "sse_error" });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
async close() {
|
|
197
|
+
intentionalClose = true;
|
|
198
|
+
if (eventSource) {
|
|
199
|
+
eventSource.close();
|
|
200
|
+
eventSource = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export {
|
|
206
|
+
createSseServerTransport,
|
|
207
|
+
createSseClientTransport
|
|
208
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
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 ClientTransport {
|
|
178
|
+
send(message: ClientMessage): Promise<void>;
|
|
179
|
+
subscribe(handler: (message: ServerMessage) => void): void;
|
|
180
|
+
close(): Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
interface WsServerConfig {
|
|
183
|
+
server: {
|
|
184
|
+
upgrade(req: Request, options: {
|
|
185
|
+
data: {
|
|
186
|
+
clientId: string;
|
|
187
|
+
};
|
|
188
|
+
}): boolean;
|
|
189
|
+
};
|
|
190
|
+
path?: string;
|
|
191
|
+
}
|
|
192
|
+
interface WsServerTransportConfig {
|
|
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 pong is seen in this window. Default: 2× pingInterval. */
|
|
198
|
+
pongTimeoutMs?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Outbound backpressure ceiling. When the socket's `bufferedAmount` exceeds
|
|
201
|
+
* this, `send` rejects instead of queueing more — a slow reader can't make
|
|
202
|
+
* the server buffer without bound. 0 disables. Default: 8 MB.
|
|
203
|
+
*/
|
|
204
|
+
maxBufferedBytes?: number;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Origin-check helper for WS/SSE/polling upgrade paths. Callers invoke this
|
|
208
|
+
* before accepting a connection. The default browser WebSocket/EventSource
|
|
209
|
+
* clients always send an Origin header, so a `null` return usually means
|
|
210
|
+
* a non-browser peer — treat accordingly for your threat model.
|
|
211
|
+
*
|
|
212
|
+
* if (!isOriginAllowed(req, ["https://app.example.com"])) return new Response("Forbidden", { status: 403 });
|
|
213
|
+
*/
|
|
214
|
+
declare function isOriginAllowed(req: Request, allowed: readonly string[] | ((origin: string | null) => boolean)): boolean;
|
|
215
|
+
declare function createWsServerTransport(cfg?: WsServerTransportConfig): ServerTransport & {
|
|
216
|
+
handleOpen(clientId: string, ws: WebSocketLike): void;
|
|
217
|
+
handleMessage(clientId: string, data: string): void;
|
|
218
|
+
handleClose(clientId: string): void;
|
|
219
|
+
handlePong(clientId: string): void;
|
|
220
|
+
};
|
|
221
|
+
interface WsClientConfig {
|
|
222
|
+
url: string;
|
|
223
|
+
protocols?: string[];
|
|
224
|
+
}
|
|
225
|
+
declare function createWsClientTransport(config: WsClientConfig): ClientTransport & {
|
|
226
|
+
getSocket(): WebSocket | null;
|
|
227
|
+
};
|
|
228
|
+
interface WebSocketLike {
|
|
229
|
+
send(data: string): void;
|
|
230
|
+
close(): void;
|
|
231
|
+
/** WebSocket readyState. When present, only 1 (OPEN) accepts sends. */
|
|
232
|
+
readyState?: number;
|
|
233
|
+
/** Bytes queued but not yet flushed. Used for outbound backpressure. */
|
|
234
|
+
bufferedAmount?: number;
|
|
235
|
+
}
|
|
236
|
+
export { isOriginAllowed, createWsServerTransport, createWsClientTransport, WsServerTransportConfig, WsServerConfig, WsClientConfig, WebSocketLike };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TransportSendError
|
|
3
|
+
} from "../shared/esm-3tkwvysa.js";
|
|
4
|
+
import"../shared/esm-b7xs9cde.js";
|
|
5
|
+
|
|
6
|
+
// src/transport/ws.ts
|
|
7
|
+
function trySend(clientId, ws, data, maxBufferedBytes) {
|
|
8
|
+
const sock = ws;
|
|
9
|
+
const rs = sock.readyState;
|
|
10
|
+
if (rs !== undefined && rs !== 1) {
|
|
11
|
+
throw new TransportSendError(clientId, `ws not open (readyState=${rs})`);
|
|
12
|
+
}
|
|
13
|
+
if (maxBufferedBytes > 0 && (sock.bufferedAmount ?? 0) > maxBufferedBytes) {
|
|
14
|
+
throw new TransportSendError(clientId, `ws outbound buffer over limit (${sock.bufferedAmount} > ${maxBufferedBytes})`);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
ws.send(data);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new TransportSendError(clientId, `ws send failed: ${err instanceof Error ? err.message : String(err)}`, err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isOriginAllowed(req, allowed) {
|
|
23
|
+
const origin = req.headers.get("origin");
|
|
24
|
+
if (typeof allowed === "function")
|
|
25
|
+
return allowed(origin);
|
|
26
|
+
if (allowed.includes("*"))
|
|
27
|
+
return true;
|
|
28
|
+
if (!origin)
|
|
29
|
+
return false;
|
|
30
|
+
return allowed.includes(origin);
|
|
31
|
+
}
|
|
32
|
+
function createWsServerTransport(cfg = {}) {
|
|
33
|
+
const clients = new Map;
|
|
34
|
+
const rooms = new Map;
|
|
35
|
+
const lastSeen = new Map;
|
|
36
|
+
const maxMessageBytes = cfg.maxMessageBytes ?? 1e6;
|
|
37
|
+
const pingIntervalMs = cfg.pingIntervalMs ?? 30000;
|
|
38
|
+
const pongTimeoutMs = cfg.pongTimeoutMs ?? pingIntervalMs * 2;
|
|
39
|
+
const maxBufferedBytes = cfg.maxBufferedBytes ?? 8000000;
|
|
40
|
+
let heartbeatTimer = null;
|
|
41
|
+
if (pingIntervalMs > 0) {
|
|
42
|
+
heartbeatTimer = setInterval(() => {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
for (const [clientId, ws] of clients) {
|
|
45
|
+
const last = lastSeen.get(clientId) ?? now;
|
|
46
|
+
if (now - last > pongTimeoutMs) {
|
|
47
|
+
try {
|
|
48
|
+
ws.close();
|
|
49
|
+
} catch {}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
ws.ping?.();
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}, pingIntervalMs);
|
|
57
|
+
if (typeof heartbeatTimer.unref === "function") {
|
|
58
|
+
heartbeatTimer.unref();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let messageHandler = null;
|
|
62
|
+
let connectHandler = null;
|
|
63
|
+
let disconnectHandler = null;
|
|
64
|
+
return {
|
|
65
|
+
handleOpen(clientId, ws) {
|
|
66
|
+
clients.set(clientId, ws);
|
|
67
|
+
lastSeen.set(clientId, Date.now());
|
|
68
|
+
connectHandler?.(clientId, new Request("https://ws-connect"));
|
|
69
|
+
},
|
|
70
|
+
handleMessage(clientId, data) {
|
|
71
|
+
lastSeen.set(clientId, Date.now());
|
|
72
|
+
const byteLen = typeof Buffer !== "undefined" ? Buffer.byteLength(data, "utf8") : new Blob([data]).size;
|
|
73
|
+
if (byteLen > maxMessageBytes) {
|
|
74
|
+
console.warn(`[reflectdb] WS server: message from ${clientId} exceeds ${maxMessageBytes} bytes (${byteLen}), closing`);
|
|
75
|
+
const ws = clients.get(clientId);
|
|
76
|
+
try {
|
|
77
|
+
ws?.close();
|
|
78
|
+
} catch {}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const message = JSON.parse(data);
|
|
83
|
+
messageHandler?.(clientId, message);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.warn(`[reflectdb] WS server: malformed message from ${clientId}:`, err instanceof Error ? err.message : String(err));
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
handleClose(clientId) {
|
|
89
|
+
clients.delete(clientId);
|
|
90
|
+
lastSeen.delete(clientId);
|
|
91
|
+
for (const members of rooms.values()) {
|
|
92
|
+
members.delete(clientId);
|
|
93
|
+
}
|
|
94
|
+
disconnectHandler?.(clientId);
|
|
95
|
+
},
|
|
96
|
+
handlePong(clientId) {
|
|
97
|
+
lastSeen.set(clientId, Date.now());
|
|
98
|
+
},
|
|
99
|
+
async send(clientId, message) {
|
|
100
|
+
const ws = clients.get(clientId);
|
|
101
|
+
if (!ws) {
|
|
102
|
+
throw new TransportSendError(clientId, "no socket registered for client");
|
|
103
|
+
}
|
|
104
|
+
trySend(clientId, ws, JSON.stringify(message), maxBufferedBytes);
|
|
105
|
+
},
|
|
106
|
+
async broadcast(roomId, message, exclude) {
|
|
107
|
+
const members = rooms.get(roomId);
|
|
108
|
+
if (!members)
|
|
109
|
+
return;
|
|
110
|
+
const data = JSON.stringify(message);
|
|
111
|
+
for (const clientId of members) {
|
|
112
|
+
if (clientId === exclude)
|
|
113
|
+
continue;
|
|
114
|
+
const ws = clients.get(clientId);
|
|
115
|
+
if (!ws)
|
|
116
|
+
continue;
|
|
117
|
+
try {
|
|
118
|
+
trySend(clientId, ws, data, maxBufferedBytes);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn("[reflectdb] ws broadcast skipped a client:", err instanceof Error ? err.message : err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
disconnect(clientId) {
|
|
125
|
+
const ws = clients.get(clientId);
|
|
126
|
+
if (!ws)
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
ws.close();
|
|
130
|
+
} catch {}
|
|
131
|
+
clients.delete(clientId);
|
|
132
|
+
},
|
|
133
|
+
onMessage(handler) {
|
|
134
|
+
messageHandler = handler;
|
|
135
|
+
},
|
|
136
|
+
onConnect(handler) {
|
|
137
|
+
connectHandler = handler;
|
|
138
|
+
},
|
|
139
|
+
onDisconnect(handler) {
|
|
140
|
+
disconnectHandler = handler;
|
|
141
|
+
},
|
|
142
|
+
async close() {
|
|
143
|
+
if (heartbeatTimer)
|
|
144
|
+
clearInterval(heartbeatTimer);
|
|
145
|
+
for (const ws of clients.values()) {
|
|
146
|
+
ws.close();
|
|
147
|
+
}
|
|
148
|
+
clients.clear();
|
|
149
|
+
lastSeen.clear();
|
|
150
|
+
rooms.clear();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function createWsClientTransport(config) {
|
|
155
|
+
let ws = null;
|
|
156
|
+
let handler = null;
|
|
157
|
+
let intentionalClose = false;
|
|
158
|
+
const pending = [];
|
|
159
|
+
const MAX_PENDING = 1000;
|
|
160
|
+
function connect() {
|
|
161
|
+
const socket = new WebSocket(config.url, config.protocols);
|
|
162
|
+
socket.addEventListener("open", () => {
|
|
163
|
+
for (const msg of pending) {
|
|
164
|
+
socket.send(msg);
|
|
165
|
+
}
|
|
166
|
+
pending.length = 0;
|
|
167
|
+
});
|
|
168
|
+
socket.addEventListener("message", (event) => {
|
|
169
|
+
try {
|
|
170
|
+
const message = JSON.parse(event.data);
|
|
171
|
+
handler?.(message);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.warn("[reflectdb] WS client: malformed message ignored:", err instanceof Error ? err.message : String(err));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
socket.addEventListener("close", () => {
|
|
177
|
+
if (ws === socket)
|
|
178
|
+
ws = null;
|
|
179
|
+
if (!intentionalClose) {
|
|
180
|
+
handler?.({ type: "disconnect", reason: "ws_closed" });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
socket.addEventListener("error", () => {
|
|
184
|
+
if (!intentionalClose) {
|
|
185
|
+
handler?.({ type: "disconnect", reason: "ws_error" });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
return socket;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
async send(message) {
|
|
192
|
+
if (!ws) {
|
|
193
|
+
ws = connect();
|
|
194
|
+
}
|
|
195
|
+
const data = JSON.stringify(message);
|
|
196
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
197
|
+
ws.send(data);
|
|
198
|
+
} else {
|
|
199
|
+
pending.push(data);
|
|
200
|
+
while (pending.length > MAX_PENDING)
|
|
201
|
+
pending.shift();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
subscribe(h) {
|
|
205
|
+
handler = h;
|
|
206
|
+
if (!ws) {
|
|
207
|
+
ws = connect();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
async close() {
|
|
211
|
+
intentionalClose = true;
|
|
212
|
+
if (ws) {
|
|
213
|
+
ws.close();
|
|
214
|
+
ws = null;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
getSocket() {
|
|
218
|
+
return ws;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export {
|
|
223
|
+
isOriginAllowed,
|
|
224
|
+
createWsServerTransport,
|
|
225
|
+
createWsClientTransport
|
|
226
|
+
};
|