@shadow-js/realtime 0.0.0-stage → 1.0.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/dev/adapter.d.ts +75 -0
- package/dist/dev/client/core.d.ts +24 -0
- package/dist/dev/client/core.js +845 -0
- package/dist/dev/client/core.js.map +1 -0
- package/dist/dev/client/index.d.ts +5 -0
- package/dist/dev/client/index.js +3 -0
- package/dist/dev/client/presence.js +23 -0
- package/dist/dev/client/presence.js.map +1 -0
- package/dist/dev/client/sse.js +76 -0
- package/dist/dev/client/sse.js.map +1 -0
- package/dist/dev/client/types.d.ts +256 -0
- package/dist/dev/client/util.js +30 -0
- package/dist/dev/client/util.js.map +1 -0
- package/dist/dev/client/ws.js +39 -0
- package/dist/dev/client/ws.js.map +1 -0
- package/dist/dev/cloudflare/adapter.d.ts +80 -0
- package/dist/dev/cloudflare/adapter.js +347 -0
- package/dist/dev/cloudflare/adapter.js.map +1 -0
- package/dist/dev/cloudflare/core.js +701 -0
- package/dist/dev/cloudflare/core.js.map +1 -0
- package/dist/dev/cloudflare/link.js +397 -0
- package/dist/dev/cloudflare/link.js.map +1 -0
- package/dist/dev/cloudflare/object.d.ts +82 -0
- package/dist/dev/cloudflare/object.js +214 -0
- package/dist/dev/cloudflare/object.js.map +1 -0
- package/dist/dev/cloudflare/types.d.ts +100 -0
- package/dist/dev/cloudflare/wire.js +284 -0
- package/dist/dev/cloudflare/wire.js.map +1 -0
- package/dist/dev/cloudflare.d.ts +4 -0
- package/dist/dev/cloudflare.js +3 -0
- package/dist/dev/errors.d.ts +25 -0
- package/dist/dev/errors.js +24 -0
- package/dist/dev/errors.js.map +1 -0
- package/dist/dev/hooks.d.ts +110 -0
- package/dist/dev/hooks.js +162 -0
- package/dist/dev/hooks.js.map +1 -0
- package/dist/dev/index.d.ts +4 -0
- package/dist/dev/index.js +3 -0
- package/dist/dev/memory.d.ts +33 -0
- package/dist/dev/memory.js +255 -0
- package/dist/dev/memory.js.map +1 -0
- package/dist/dev/protocol.d.ts +211 -0
- package/dist/dev/protocol.js +400 -0
- package/dist/dev/protocol.js.map +1 -0
- package/dist/dev/redis/client.d.ts +139 -0
- package/dist/dev/redis/client.js +146 -0
- package/dist/dev/redis/client.js.map +1 -0
- package/dist/dev/redis/scripts.js +275 -0
- package/dist/dev/redis/scripts.js.map +1 -0
- package/dist/dev/redis/topics.d.ts +8 -0
- package/dist/dev/redis/topics.js +377 -0
- package/dist/dev/redis/topics.js.map +1 -0
- package/dist/dev/redis.d.ts +68 -0
- package/dist/dev/redis.js +422 -0
- package/dist/dev/redis.js.map +1 -0
- package/dist/dev/server/http.d.ts +48 -0
- package/dist/dev/server/http.js +286 -0
- package/dist/dev/server/http.js.map +1 -0
- package/dist/dev/server/hub.d.ts +114 -0
- package/dist/dev/server/hub.js +827 -0
- package/dist/dev/server/hub.js.map +1 -0
- package/dist/dev/server/index.d.ts +47 -0
- package/dist/dev/server/index.js +41 -0
- package/dist/dev/server/index.js.map +1 -0
- package/dist/dev/server/limits.d.ts +54 -0
- package/dist/dev/server/limits.js +85 -0
- package/dist/dev/server/limits.js.map +1 -0
- package/dist/dev/server/rules.d.ts +43 -0
- package/dist/dev/server/rules.js +72 -0
- package/dist/dev/server/rules.js.map +1 -0
- package/dist/dev/testing.d.ts +115 -0
- package/dist/dev/testing.js +427 -0
- package/dist/dev/testing.js.map +1 -0
- package/dist/prod/client/core.js +2 -0
- package/dist/prod/client/core.js.map +1 -0
- package/dist/prod/client/index.js +1 -0
- package/dist/prod/client/presence.js +2 -0
- package/dist/prod/client/presence.js.map +1 -0
- package/dist/prod/client/sse.js +5 -0
- package/dist/prod/client/sse.js.map +1 -0
- package/dist/prod/client/util.js +2 -0
- package/dist/prod/client/util.js.map +1 -0
- package/dist/prod/client/ws.js +2 -0
- package/dist/prod/client/ws.js.map +1 -0
- package/dist/prod/cloudflare/adapter.js +3 -0
- package/dist/prod/cloudflare/adapter.js.map +1 -0
- package/dist/prod/cloudflare/core.js +2 -0
- package/dist/prod/cloudflare/core.js.map +1 -0
- package/dist/prod/cloudflare/link.js +2 -0
- package/dist/prod/cloudflare/link.js.map +1 -0
- package/dist/prod/cloudflare/object.js +2 -0
- package/dist/prod/cloudflare/object.js.map +1 -0
- package/dist/prod/cloudflare/wire.js +2 -0
- package/dist/prod/cloudflare/wire.js.map +1 -0
- package/dist/prod/cloudflare.js +1 -0
- package/dist/prod/errors.js +2 -0
- package/dist/prod/errors.js.map +1 -0
- package/dist/prod/hooks.js +2 -0
- package/dist/prod/hooks.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/memory.js +2 -0
- package/dist/prod/memory.js.map +1 -0
- package/dist/prod/protocol.js +2 -0
- package/dist/prod/protocol.js.map +1 -0
- package/dist/prod/redis/client.js +2 -0
- package/dist/prod/redis/client.js.map +1 -0
- package/dist/prod/redis/scripts.js +157 -0
- package/dist/prod/redis/scripts.js.map +1 -0
- package/dist/prod/redis/topics.js +2 -0
- package/dist/prod/redis/topics.js.map +1 -0
- package/dist/prod/redis.js +2 -0
- package/dist/prod/redis.js.map +1 -0
- package/dist/prod/server/http.js +2 -0
- package/dist/prod/server/http.js.map +1 -0
- package/dist/prod/server/hub.js +2 -0
- package/dist/prod/server/hub.js.map +1 -0
- package/dist/prod/server/index.js +2 -0
- package/dist/prod/server/index.js.map +1 -0
- package/dist/prod/server/limits.js +2 -0
- package/dist/prod/server/limits.js.map +1 -0
- package/dist/prod/server/rules.js +2 -0
- package/dist/prod/server/rules.js.map +1 -0
- package/dist/prod/testing.js +2 -0
- package/dist/prod/testing.js.map +1 -0
- package/package.json +105 -4
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { RealtimeError } from "../errors.js";
|
|
2
|
+
import { byteLength } from "../protocol.js";
|
|
3
|
+
import { TokenBucket } from "./limits.js";
|
|
4
|
+
//#region src/server/http.ts
|
|
5
|
+
const KEY = "$realtime";
|
|
6
|
+
const MAX_FRAMES_PER_POST = 64;
|
|
7
|
+
const MAX_SUBSCRIPTIONS_IN_QUERY = 64;
|
|
8
|
+
const MAX_BUCKETS = 1e4;
|
|
9
|
+
function refuse(status, code, message) {
|
|
10
|
+
return new Response(JSON.stringify({ error: {
|
|
11
|
+
code,
|
|
12
|
+
message
|
|
13
|
+
} }), {
|
|
14
|
+
status,
|
|
15
|
+
headers: {
|
|
16
|
+
"content-type": "application/json",
|
|
17
|
+
"cache-control": "no-store"
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** Whether the request's `Origin` header is acceptable. A missing header is allowed (see `HubRouteOptions.allowedOrigins`). */
|
|
22
|
+
function originAllowed(request, allowed) {
|
|
23
|
+
const origin = request.headers.get("origin");
|
|
24
|
+
if (origin === null) return true;
|
|
25
|
+
if (allowed !== void 0) return allowed.includes(origin);
|
|
26
|
+
try {
|
|
27
|
+
return origin === new URL(request.url).origin;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function identify(options, event) {
|
|
33
|
+
const result = await options.authenticate?.(event);
|
|
34
|
+
if (result instanceof Response) return { refusal: result };
|
|
35
|
+
return { identity: result ?? {} };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The WebSocket route of a hub: `export const WS = hub.websocket({ authenticate })` in a Dusk stream file. The upgrade is decided
|
|
39
|
+
* by `authenticate` (a `Response` refuses it) and Dusk's own checks (origin, caps, size); each text frame goes to the hub.
|
|
40
|
+
* Binary frames close the connection with 1003.
|
|
41
|
+
*
|
|
42
|
+
* @param hub The hub.
|
|
43
|
+
* @param options How to identify the connection.
|
|
44
|
+
* @returns Handlers shaped like Dusk's `WS` export.
|
|
45
|
+
* @example
|
|
46
|
+
* import { createHub } from "@shadow-js/realtime/server";
|
|
47
|
+
*
|
|
48
|
+
* const hub = createHub({ channels: { "chat:{room}": { subscribe: true, publish: true } } });
|
|
49
|
+
* export const WS = hub.websocket();
|
|
50
|
+
* @since 1.0.0
|
|
51
|
+
*/
|
|
52
|
+
function websocketRoute(hub, options = {}) {
|
|
53
|
+
return {
|
|
54
|
+
async upgrade(event) {
|
|
55
|
+
const result = await identify(options, event);
|
|
56
|
+
return "refusal" in result ? result.refusal : void 0;
|
|
57
|
+
},
|
|
58
|
+
async open(ws, event) {
|
|
59
|
+
const result = await identify(options, event);
|
|
60
|
+
if ("refusal" in result) {
|
|
61
|
+
ws.close(1008, "unauthorized");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const transport = {
|
|
65
|
+
send: (frame) => ws.send(frame),
|
|
66
|
+
close: (code, reason) => ws.close(code, reason)
|
|
67
|
+
};
|
|
68
|
+
ws.data[KEY] = hub.connect(transport, result.identity);
|
|
69
|
+
},
|
|
70
|
+
message(ws, message) {
|
|
71
|
+
const connection = ws.data[KEY];
|
|
72
|
+
if (connection === void 0) return;
|
|
73
|
+
if (typeof message !== "string") {
|
|
74
|
+
ws.close(1003, "binary frames are not supported");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
connection.receive(message);
|
|
78
|
+
},
|
|
79
|
+
close(ws) {
|
|
80
|
+
ws.data[KEY]?.closed();
|
|
81
|
+
},
|
|
82
|
+
error(ws, error) {
|
|
83
|
+
hub.report(error, { where: "websocket" });
|
|
84
|
+
ws.data[KEY]?.closed();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Refuses a stream that has already started (the status 200 is sent before the handler runs): a `bye` with the policy code the
|
|
90
|
+
* client treats as fatal, so it gives up instead of reconnecting for ever, then the end of the stream.
|
|
91
|
+
*/
|
|
92
|
+
async function refuseStream(stream, why) {
|
|
93
|
+
try {
|
|
94
|
+
await stream.send({
|
|
95
|
+
event: "rt",
|
|
96
|
+
data: JSON.stringify({
|
|
97
|
+
t: "bye",
|
|
98
|
+
code: 1008,
|
|
99
|
+
why
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
} catch {}
|
|
103
|
+
stream.close();
|
|
104
|
+
}
|
|
105
|
+
/** Reads the subscriptions a stream request declares: `?s=[{"c":"chat:lobby","after":12}]`. */
|
|
106
|
+
function declared(url) {
|
|
107
|
+
const text = url.searchParams.get("s");
|
|
108
|
+
if (text === null) return [];
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(text);
|
|
111
|
+
if (!Array.isArray(value) || value.length > MAX_SUBSCRIPTIONS_IN_QUERY) return void 0;
|
|
112
|
+
return value.map((item) => item !== null && typeof item === "object" ? item : {});
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The server-sent events route of a hub, for hosts that cannot hold a WebSocket: `export const SSE = hub.sse()`. The channels
|
|
119
|
+
* are declared in the request (`?s=[{"c":"chat:lobby","after":12,"echo":false,"presence":{"typing":false}}]`); to change them the
|
|
120
|
+
* client opens a new stream. Frames go out as events named `rt`; everything the client sends goes to {@link postRoute}.
|
|
121
|
+
*
|
|
122
|
+
* @param hub The hub.
|
|
123
|
+
* @param options How to identify the connection and which origins may connect.
|
|
124
|
+
* @returns A handler shaped like Dusk's `SSE` export.
|
|
125
|
+
* @example
|
|
126
|
+
* import { createHub } from "@shadow-js/realtime/server";
|
|
127
|
+
*
|
|
128
|
+
* const hub = createHub({ channels: { "chat:{room}": { subscribe: true, publish: true } } });
|
|
129
|
+
* export const SSE = hub.sse();
|
|
130
|
+
* @since 1.0.0
|
|
131
|
+
*/
|
|
132
|
+
function sseRoute(hub, options = {}) {
|
|
133
|
+
return async (event, stream) => {
|
|
134
|
+
if (!originAllowed(event.request, options.allowedOrigins)) {
|
|
135
|
+
hub.report(new RealtimeError("forbidden", "origin refused"), { where: "sse.origin" });
|
|
136
|
+
await refuseStream(stream, "origin not allowed");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const result = await identify(options, event);
|
|
140
|
+
if ("refusal" in result) {
|
|
141
|
+
await refuseStream(stream, "unauthorized");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const entries = declared(new URL(event.request.url));
|
|
145
|
+
if (entries === void 0) {
|
|
146
|
+
await refuseStream(stream, "bad subscription list");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let pendingBytes = 0;
|
|
150
|
+
const connection = hub.connect({
|
|
151
|
+
send(frame) {
|
|
152
|
+
if (stream.closed) return false;
|
|
153
|
+
pendingBytes += frame.length;
|
|
154
|
+
stream.send({
|
|
155
|
+
event: "rt",
|
|
156
|
+
data: frame
|
|
157
|
+
}).then(() => {
|
|
158
|
+
pendingBytes -= frame.length;
|
|
159
|
+
}, () => {
|
|
160
|
+
pendingBytes = Number.MAX_SAFE_INTEGER;
|
|
161
|
+
connection.closed();
|
|
162
|
+
});
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
close: () => stream.close(),
|
|
166
|
+
buffered: () => pendingBytes
|
|
167
|
+
}, result.identity);
|
|
168
|
+
entries.forEach((entry, at) => {
|
|
169
|
+
connection.receive(JSON.stringify({
|
|
170
|
+
t: "sub",
|
|
171
|
+
n: at + 1,
|
|
172
|
+
...entry
|
|
173
|
+
}));
|
|
174
|
+
});
|
|
175
|
+
if (!stream.signal.aborted) await new Promise((resolve) => {
|
|
176
|
+
stream.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
177
|
+
});
|
|
178
|
+
connection.closed();
|
|
179
|
+
await connection.done;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The HTTP route that carries what an SSE client sends: `export const POST = hub.post()`. The body is
|
|
184
|
+
* `{"cid": "<connection id>", "frames": [ ... ]}` (at most 64 frames of the types `pub`, `req`, `pres`); the answer is
|
|
185
|
+
* `{"frames": [ ... ]}` with the acks, replies and errors. It keeps nothing between requests, so any server can answer it.
|
|
186
|
+
*
|
|
187
|
+
* The request rate of one caller is limited by `key` (default: the user, else all anonymous callers together) with the hub's
|
|
188
|
+
* publish limits; put Dusk's rate limiting in front of the route for per-address limits.
|
|
189
|
+
*
|
|
190
|
+
* @param hub The hub.
|
|
191
|
+
* @param options Identity, origins and the rate-limit key.
|
|
192
|
+
* @returns A handler for a Dusk API route.
|
|
193
|
+
* @example
|
|
194
|
+
* import { createHub } from "@shadow-js/realtime/server";
|
|
195
|
+
*
|
|
196
|
+
* const hub = createHub({ channels: { "chat:{room}": { subscribe: true, publish: true } } });
|
|
197
|
+
* export const POST = hub.post();
|
|
198
|
+
* @since 1.0.0
|
|
199
|
+
*/
|
|
200
|
+
function postRoute(hub, options = {}) {
|
|
201
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
202
|
+
const bucketFor = (key) => {
|
|
203
|
+
let bucket = buckets.get(key);
|
|
204
|
+
if (bucket === void 0) {
|
|
205
|
+
if (buckets.size >= MAX_BUCKETS) {
|
|
206
|
+
const oldest = buckets.keys().next().value;
|
|
207
|
+
if (oldest !== void 0) buckets.delete(oldest);
|
|
208
|
+
}
|
|
209
|
+
bucket = new TokenBucket(hub.limits.publishesPerSecond, hub.limits.publishBurst, Date.now());
|
|
210
|
+
buckets.set(key, bucket);
|
|
211
|
+
}
|
|
212
|
+
return bucket;
|
|
213
|
+
};
|
|
214
|
+
return async (event) => {
|
|
215
|
+
const { request } = event;
|
|
216
|
+
if (!originAllowed(request, options.allowedOrigins)) return refuse(403, "forbidden", "origin not allowed");
|
|
217
|
+
const result = await identify(options, event);
|
|
218
|
+
if ("refusal" in result) return result.refusal;
|
|
219
|
+
const text = await request.text();
|
|
220
|
+
if (byteLength(text) > hub.limits.maxMessageBytes * 8) return refuse(413, "too_large", "the request is too large");
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = JSON.parse(text);
|
|
224
|
+
} catch {
|
|
225
|
+
return refuse(400, "bad_frame", "the body is not JSON");
|
|
226
|
+
}
|
|
227
|
+
const frames = body?.frames;
|
|
228
|
+
const cid = body?.cid;
|
|
229
|
+
if (!Array.isArray(frames) || frames.length === 0 || frames.length > MAX_FRAMES_PER_POST) return refuse(400, "bad_frame", `frames must hold 1..${MAX_FRAMES_PER_POST} frames`);
|
|
230
|
+
const key = options.key?.(event, result.identity) ?? result.identity.user ?? "anonymous";
|
|
231
|
+
const answers = [];
|
|
232
|
+
const connection = hub.connect({
|
|
233
|
+
send(frame) {
|
|
234
|
+
if (!frame.startsWith("{\"t\":\"ready\"")) answers.push(frame);
|
|
235
|
+
return true;
|
|
236
|
+
},
|
|
237
|
+
close: () => void 0
|
|
238
|
+
}, result.identity, {
|
|
239
|
+
stateless: true,
|
|
240
|
+
publishBucket: bucketFor(key),
|
|
241
|
+
...typeof cid === "string" ? { actingFor: cid } : {}
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
for (const frame of frames) {
|
|
245
|
+
const type = frame?.t;
|
|
246
|
+
if (type === "pres") {
|
|
247
|
+
const item = frame;
|
|
248
|
+
try {
|
|
249
|
+
if (typeof item.c !== "string" || typeof cid !== "string") throw new RealtimeError("bad_frame", "pres needs c and the connection id");
|
|
250
|
+
await hub.setMemberState(result.identity, item.c, cid, item.s ?? null);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const code = error instanceof RealtimeError ? error.code : "unavailable";
|
|
253
|
+
answers.push(JSON.stringify({
|
|
254
|
+
t: "err",
|
|
255
|
+
code
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (type !== "pub" && type !== "req") {
|
|
261
|
+
answers.push(JSON.stringify({
|
|
262
|
+
t: "err",
|
|
263
|
+
code: "bad_frame",
|
|
264
|
+
msg: "only pub, req and pres"
|
|
265
|
+
}));
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
connection.receive(JSON.stringify(frame));
|
|
269
|
+
}
|
|
270
|
+
await connection.settled();
|
|
271
|
+
} finally {
|
|
272
|
+
connection.closed();
|
|
273
|
+
}
|
|
274
|
+
return new Response(`{"frames":[${answers.join(",")}]}`, {
|
|
275
|
+
status: 200,
|
|
276
|
+
headers: {
|
|
277
|
+
"content-type": "application/json",
|
|
278
|
+
"cache-control": "no-store"
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
export { postRoute, sseRoute, websocketRoute };
|
|
285
|
+
|
|
286
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","names":[],"sources":["../../../src/server/http.ts"],"sourcesContent":["import { RealtimeError } from \"../errors\";\nimport { byteLength, type Json } from \"../protocol\";\nimport type { HubConnection, HubCore, Transport } from \"./hub\";\nimport { TokenBucket } from \"./limits\";\nimport type { Identity } from \"./rules\";\n\n/** What a hub needs from the framework's request event. @since 1.0.0 */\nexport interface RequestLike {\n readonly request: Request;\n}\n\n/** How a connection is identified: an identity, or a response that refuses it. @since 1.0.0 */\nexport type AuthenticateResult = Identity | undefined | Response;\n\n/** Options shared by the transport routes. @since 1.0.0 */\nexport interface HubRouteOptions<E extends RequestLike> {\n /**\n * Reads the identity from the request (the session cookie, a bearer token). Return a `Response` to refuse (a 401). Return\n * nothing for an anonymous connection. Default: everyone is anonymous, and channel rules decide.\n */\n readonly authenticate?: (event: E) => AuthenticateResult | Promise<AuthenticateResult>;\n /**\n * Origins allowed to open a stream or post (full origins such as `https://app.example.com`). Default: the request's own\n * origin only. A request with no `Origin` header (same-origin GET, a non-browser client) is allowed.\n */\n readonly allowedOrigins?: readonly string[];\n}\n\n/** The WebSocket connection the framework hands to handlers (the part this package uses). @since 1.0.0 */\nexport interface WebSocketLike {\n readonly data: Record<string, unknown>;\n readonly signal: AbortSignal;\n send(message: string | Uint8Array): boolean;\n close(code?: number, reason?: string): void;\n}\n\n/** The handlers of a WebSocket route, shaped like Dusk's `WS` export. @since 1.0.0 */\nexport interface WebSocketRoute<E extends RequestLike> {\n upgrade(event: E): Promise<Response | undefined>;\n open(ws: WebSocketLike, event: E): Promise<void>;\n message(ws: WebSocketLike, message: string | Uint8Array, event: E): void;\n close(ws: WebSocketLike): void;\n error(ws: WebSocketLike, error: unknown, event: E): void;\n}\n\n/** The stream a server-sent events route writes to (the part this package uses). @since 1.0.0 */\nexport interface SseLike {\n send(message: { readonly event: string; readonly data: string }): Promise<void>;\n close(): void;\n readonly signal: AbortSignal;\n readonly closed: boolean;\n}\n\nconst KEY = \"$realtime\";\nconst MAX_FRAMES_PER_POST = 64;\nconst MAX_SUBSCRIPTIONS_IN_QUERY = 64;\nconst MAX_BUCKETS = 10_000;\n\nfunction refuse(status: number, code: RealtimeError[\"code\"], message: string): Response {\n return new Response(JSON.stringify({ error: { code, message } }), {\n status,\n headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" },\n });\n}\n\n/** Whether the request's `Origin` header is acceptable. A missing header is allowed (see `HubRouteOptions.allowedOrigins`). */\nfunction originAllowed(request: Request, allowed: readonly string[] | undefined): boolean {\n const origin = request.headers.get(\"origin\");\n if (origin === null) return true;\n if (allowed !== undefined) return allowed.includes(origin);\n try {\n return origin === new URL(request.url).origin;\n } catch {\n return false;\n }\n}\n\nasync function identify<E extends RequestLike>(\n options: HubRouteOptions<E>,\n event: E,\n): Promise<{ readonly identity: Identity } | { readonly refusal: Response }> {\n const result = await options.authenticate?.(event);\n if (result instanceof Response) return { refusal: result };\n return { identity: result ?? {} };\n}\n\n/**\n * The WebSocket route of a hub: `export const WS = hub.websocket({ authenticate })` in a Dusk stream file. The upgrade is decided\n * by `authenticate` (a `Response` refuses it) and Dusk's own checks (origin, caps, size); each text frame goes to the hub.\n * Binary frames close the connection with 1003.\n *\n * @param hub The hub.\n * @param options How to identify the connection.\n * @returns Handlers shaped like Dusk's `WS` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const WS = hub.websocket();\n * @since 1.0.0\n */\nexport function websocketRoute<E extends RequestLike>(\n hub: HubCore,\n options: Pick<HubRouteOptions<E>, \"authenticate\"> = {},\n): WebSocketRoute<E> {\n return {\n async upgrade(event) {\n const result = await identify(options, event);\n return \"refusal\" in result ? result.refusal : undefined;\n },\n async open(ws, event) {\n const result = await identify(options, event);\n if (\"refusal\" in result) {\n ws.close(1008, \"unauthorized\");\n return;\n }\n const transport: Transport = {\n send: (frame) => ws.send(frame),\n close: (code, reason) => ws.close(code, reason),\n };\n ws.data[KEY] = hub.connect(transport, result.identity);\n },\n message(ws, message) {\n const connection = ws.data[KEY] as HubConnection | undefined;\n if (connection === undefined) return;\n if (typeof message !== \"string\") {\n ws.close(1003, \"binary frames are not supported\");\n return;\n }\n connection.receive(message);\n },\n close(ws) {\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n error(ws, error) {\n hub.report(error, { where: \"websocket\" });\n (ws.data[KEY] as HubConnection | undefined)?.closed();\n },\n };\n}\n\n/**\n * Refuses a stream that has already started (the status 200 is sent before the handler runs): a `bye` with the policy code the\n * client treats as fatal, so it gives up instead of reconnecting for ever, then the end of the stream.\n */\nasync function refuseStream(stream: SseLike, why: string): Promise<void> {\n try {\n await stream.send({ event: \"rt\", data: JSON.stringify({ t: \"bye\", code: 1008, why }) });\n } catch {\n // A client that already left needs no answer.\n }\n stream.close();\n}\n\n/** Reads the subscriptions a stream request declares: `?s=[{\"c\":\"chat:lobby\",\"after\":12}]`. */\nfunction declared(url: URL): readonly Record<string, unknown>[] | undefined {\n const text = url.searchParams.get(\"s\");\n if (text === null) return [];\n try {\n const value: unknown = JSON.parse(text);\n if (!Array.isArray(value) || value.length > MAX_SUBSCRIPTIONS_IN_QUERY) return undefined;\n return value.map((item) =>\n item !== null && typeof item === \"object\" ? (item as Record<string, unknown>) : {},\n );\n } catch {\n return undefined;\n }\n}\n\n/**\n * The server-sent events route of a hub, for hosts that cannot hold a WebSocket: `export const SSE = hub.sse()`. The channels\n * are declared in the request (`?s=[{\"c\":\"chat:lobby\",\"after\":12,\"echo\":false,\"presence\":{\"typing\":false}}]`); to change them the\n * client opens a new stream. Frames go out as events named `rt`; everything the client sends goes to {@link postRoute}.\n *\n * @param hub The hub.\n * @param options How to identify the connection and which origins may connect.\n * @returns A handler shaped like Dusk's `SSE` export.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const SSE = hub.sse();\n * @since 1.0.0\n */\nexport function sseRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> = {},\n): (event: E, stream: SseLike) => Promise<void> {\n return async (event, stream) => {\n if (!originAllowed(event.request, options.allowedOrigins)) {\n hub.report(new RealtimeError(\"forbidden\", \"origin refused\"), { where: \"sse.origin\" });\n await refuseStream(stream, \"origin not allowed\");\n return;\n }\n const result = await identify(options, event);\n if (\"refusal\" in result) {\n await refuseStream(stream, \"unauthorized\");\n return;\n }\n const entries = declared(new URL(event.request.url));\n if (entries === undefined) {\n await refuseStream(stream, \"bad subscription list\");\n return;\n }\n let pendingBytes = 0;\n const transport: Transport = {\n send(frame) {\n if (stream.closed) return false;\n pendingBytes += frame.length;\n stream.send({ event: \"rt\", data: frame }).then(\n () => {\n pendingBytes -= frame.length;\n },\n () => {\n pendingBytes = Number.MAX_SAFE_INTEGER;\n connection.closed();\n },\n );\n return true;\n },\n close: () => stream.close(),\n buffered: () => pendingBytes,\n };\n const connection = hub.connect(transport, result.identity);\n entries.forEach((entry, at) => {\n connection.receive(JSON.stringify({ t: \"sub\", n: at + 1, ...entry }));\n });\n if (!stream.signal.aborted)\n await new Promise<void>((resolve) => {\n stream.signal.addEventListener(\"abort\", () => resolve(), { once: true });\n });\n connection.closed();\n await connection.done;\n };\n}\n\n/**\n * The HTTP route that carries what an SSE client sends: `export const POST = hub.post()`. The body is\n * `{\"cid\": \"<connection id>\", \"frames\": [ ... ]}` (at most 64 frames of the types `pub`, `req`, `pres`); the answer is\n * `{\"frames\": [ ... ]}` with the acks, replies and errors. It keeps nothing between requests, so any server can answer it.\n *\n * The request rate of one caller is limited by `key` (default: the user, else all anonymous callers together) with the hub's\n * publish limits; put Dusk's rate limiting in front of the route for per-address limits.\n *\n * @param hub The hub.\n * @param options Identity, origins and the rate-limit key.\n * @returns A handler for a Dusk API route.\n * @example\n * import { createHub } from \"@shadow-js/realtime/server\";\n *\n * const hub = createHub({ channels: { \"chat:{room}\": { subscribe: true, publish: true } } });\n * export const POST = hub.post();\n * @since 1.0.0\n */\nexport function postRoute<E extends RequestLike>(\n hub: HubCore,\n options: HubRouteOptions<E> & { readonly key?: (event: E, identity: Identity) => string } = {},\n): (event: E) => Promise<Response> {\n const buckets = new Map<string, TokenBucket>();\n const bucketFor = (key: string): TokenBucket => {\n let bucket = buckets.get(key);\n if (bucket === undefined) {\n if (buckets.size >= MAX_BUCKETS) {\n const oldest = buckets.keys().next().value;\n if (oldest !== undefined) buckets.delete(oldest);\n }\n bucket = new TokenBucket(hub.limits.publishesPerSecond, hub.limits.publishBurst, Date.now());\n buckets.set(key, bucket);\n }\n return bucket;\n };\n return async (event) => {\n const { request } = event;\n if (!originAllowed(request, options.allowedOrigins))\n return refuse(403, \"forbidden\", \"origin not allowed\");\n const result = await identify(options, event);\n if (\"refusal\" in result) return result.refusal;\n const text = await request.text();\n if (byteLength(text) > hub.limits.maxMessageBytes * 8)\n return refuse(413, \"too_large\", \"the request is too large\");\n let body: unknown;\n try {\n body = JSON.parse(text);\n } catch {\n return refuse(400, \"bad_frame\", \"the body is not JSON\");\n }\n const frames = (body as { frames?: unknown } | null)?.frames;\n const cid = (body as { cid?: unknown } | null)?.cid;\n if (!Array.isArray(frames) || frames.length === 0 || frames.length > MAX_FRAMES_PER_POST)\n return refuse(400, \"bad_frame\", `frames must hold 1..${MAX_FRAMES_PER_POST} frames`);\n const key = options.key?.(event, result.identity) ?? result.identity.user ?? \"anonymous\";\n const answers: string[] = [];\n const transport: Transport = {\n send(frame) {\n if (!frame.startsWith('{\"t\":\"ready\"')) answers.push(frame);\n return true;\n },\n close: () => undefined,\n };\n const connection = hub.connect(transport, result.identity, {\n stateless: true,\n publishBucket: bucketFor(key),\n ...(typeof cid === \"string\" ? { actingFor: cid } : {}),\n });\n try {\n for (const frame of frames) {\n const type = (frame as { t?: unknown } | null)?.t;\n if (type === \"pres\") {\n const item = frame as { c?: unknown; s?: unknown };\n try {\n if (typeof item.c !== \"string\" || typeof cid !== \"string\")\n throw new RealtimeError(\"bad_frame\", \"pres needs c and the connection id\");\n await hub.setMemberState(result.identity, item.c, cid, (item.s ?? null) as Json);\n } catch (error) {\n const code = error instanceof RealtimeError ? error.code : \"unavailable\";\n answers.push(JSON.stringify({ t: \"err\", code }));\n }\n continue;\n }\n if (type !== \"pub\" && type !== \"req\") {\n answers.push(\n JSON.stringify({ t: \"err\", code: \"bad_frame\", msg: \"only pub, req and pres\" }),\n );\n continue;\n }\n connection.receive(JSON.stringify(frame));\n }\n await connection.settled();\n } finally {\n connection.closed();\n }\n return new Response(`{\"frames\":[${answers.join(\",\")}]}`, {\n status: 200,\n headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" },\n });\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Json } from "../protocol.js";
|
|
2
|
+
import { Adapter, PublishResult } from "../adapter.js";
|
|
3
|
+
import { ChannelRule, Identity, RuleContext } from "./rules.js";
|
|
4
|
+
import { HubLimits, TokenBucket } from "./limits.js";
|
|
5
|
+
//#region src/server/hub.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* What a hub needs from a connection's transport. A WebSocket, an SSE stream and an in-memory test client all fit.
|
|
8
|
+
* @since 1.0.0
|
|
9
|
+
*/
|
|
10
|
+
export interface Transport {
|
|
11
|
+
/** Sends one frame (text). False when the transport is closed. */
|
|
12
|
+
send(frame: string): boolean;
|
|
13
|
+
/** Closes the transport with a WebSocket close code and a reason of at most 123 bytes. */
|
|
14
|
+
close(code: number, reason: string): void;
|
|
15
|
+
/** Bytes accepted by `send` that the peer has not yet taken. When present, a connection over the limit is closed. */
|
|
16
|
+
buffered?(): number;
|
|
17
|
+
}
|
|
18
|
+
/** Where an error reported to `onError` came from. @since 1.0.0 */
|
|
19
|
+
export interface ErrorContext {
|
|
20
|
+
readonly where: string;
|
|
21
|
+
readonly channel?: string;
|
|
22
|
+
readonly connection?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Options of {@link createHub}. @since 1.0.0 */
|
|
25
|
+
export interface HubOptions {
|
|
26
|
+
/** Storage and fan-out. Default: a fresh `memoryAdapter()`, closed with the hub. */
|
|
27
|
+
readonly adapter?: Adapter;
|
|
28
|
+
/** Channel rules by pattern, in order. The first matching pattern wins. */
|
|
29
|
+
readonly channels?: Readonly<Record<string, ChannelRule>>;
|
|
30
|
+
/** Limits (see {@link HubLimits}). */
|
|
31
|
+
readonly limits?: Partial<HubLimits>;
|
|
32
|
+
/** Receives failures the hub handled (an adapter outage, a rule predicate that threw). Default: `console.error`. */
|
|
33
|
+
readonly onError?: (error: unknown, context: ErrorContext) => void;
|
|
34
|
+
/** The clock, in epoch milliseconds. Default `Date.now`. */
|
|
35
|
+
readonly now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
/** Counters of a running hub. @since 1.0.0 */
|
|
38
|
+
export interface HubStats {
|
|
39
|
+
readonly connections: number;
|
|
40
|
+
readonly channels: number;
|
|
41
|
+
readonly subscriptions: number;
|
|
42
|
+
readonly published: number;
|
|
43
|
+
readonly delivered: number;
|
|
44
|
+
readonly refused: number;
|
|
45
|
+
readonly slowConsumers: number;
|
|
46
|
+
}
|
|
47
|
+
/** One connection, as the transport glue drives it. @since 1.0.0 */
|
|
48
|
+
export interface HubConnection {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly identity: Identity;
|
|
51
|
+
/** Hands the hub one text frame from the peer. Frames are handled in order. */
|
|
52
|
+
receive(text: string): void;
|
|
53
|
+
/** Tells the hub the transport closed. Safe to call twice. */
|
|
54
|
+
closed(): void;
|
|
55
|
+
/** Resolves once every frame received so far was handled and every request answered. */
|
|
56
|
+
settled(): Promise<void>;
|
|
57
|
+
/** Resolves when the hub finished releasing what the connection held. */
|
|
58
|
+
readonly done: Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/** How a connection differs from a long-lived one. @since 1.0.0 */
|
|
61
|
+
export interface ConnectOptions {
|
|
62
|
+
/**
|
|
63
|
+
* A connection that exists for one HTTP request: a request is answered for any identity the channel's `subscribe` rule
|
|
64
|
+
* accepts (there is no subscription to look up), and nothing is kept after it closes.
|
|
65
|
+
*/
|
|
66
|
+
readonly stateless?: boolean;
|
|
67
|
+
/** Replaces the connection's own publish bucket, so a caller can share one across requests. */
|
|
68
|
+
readonly publishBucket?: TokenBucket;
|
|
69
|
+
/**
|
|
70
|
+
* The id of a long-lived connection of the same user this request acts for (SSE clients post under their stream's id, so
|
|
71
|
+
* `echo:false` and `publisher` work). Honored only when that connection is open on this server and has the same user.
|
|
72
|
+
*/
|
|
73
|
+
readonly actingFor?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Who to remove subscriptions of. At least one field. @since 1.0.0 */
|
|
76
|
+
export interface RevokeTarget {
|
|
77
|
+
readonly user?: string;
|
|
78
|
+
readonly connection?: string;
|
|
79
|
+
readonly channel?: string;
|
|
80
|
+
}
|
|
81
|
+
/** The server object. @since 1.0.0 */
|
|
82
|
+
export interface HubCore {
|
|
83
|
+
/** Adds a channel rule after the ones already registered. */
|
|
84
|
+
channel(pattern: string, rule: ChannelRule): HubCore;
|
|
85
|
+
/** Starts handling a connection. Sends `ready`. */
|
|
86
|
+
connect(transport: Transport, identity?: Identity, options?: ConnectOptions): HubConnection;
|
|
87
|
+
/** Publishes from the server (no rule applies). */
|
|
88
|
+
publish(channel: string, event: string, data: unknown, options?: {
|
|
89
|
+
readonly key?: string;
|
|
90
|
+
}): Promise<PublishResult>;
|
|
91
|
+
/** Removes matching subscriptions; each subscriber gets `unsubbed why:"revoked"`. Returns how many were removed. */
|
|
92
|
+
revoke(target: RevokeTarget): number;
|
|
93
|
+
/** Counters. */
|
|
94
|
+
stats(): HubStats;
|
|
95
|
+
/** Closes every connection with 1001, stops timers, removes presence, settles requests. */
|
|
96
|
+
close(): Promise<void>;
|
|
97
|
+
/** @internal Changes the state of a presence member from outside its connection (SSE clients). */
|
|
98
|
+
setMemberState(identity: Identity, channel: string, connectionId: string, state: Json): Promise<void>;
|
|
99
|
+
/** @internal The resolved limits. */
|
|
100
|
+
readonly limits: HubLimits;
|
|
101
|
+
/** @internal The adapter. */
|
|
102
|
+
readonly adapter: Adapter;
|
|
103
|
+
/** @internal Reports an error. */
|
|
104
|
+
report(error: unknown, context: ErrorContext): void;
|
|
105
|
+
/** @internal Matches a channel against the rules. */
|
|
106
|
+
match(channel: string): {
|
|
107
|
+
readonly rule: ChannelRule;
|
|
108
|
+
readonly params: Record<string, string>;
|
|
109
|
+
} | undefined;
|
|
110
|
+
/** @internal Evaluates a rule permission for an identity. */
|
|
111
|
+
allowed(rule: ChannelRule, op: RuleContext["op"], channel: string, params: Record<string, string>, identity: Identity): Promise<boolean>;
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//# sourceMappingURL=hub.d.ts.map
|