@monlite/realtime 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 +80 -0
- package/dist/client.cjs +110 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +45 -0
- package/dist/client.d.ts +45 -0
- package/dist/client.js +108 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +131 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emad Jumaah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @monlite/realtime
|
|
2
|
+
|
|
3
|
+
Networked realtime for [`@monlite/core`](https://www.npmjs.com/package/@monlite/core) — stream
|
|
4
|
+
live queries and documents to remote clients (browser, mobile, other services) over **Server-Sent
|
|
5
|
+
Events**, backed by the change feed. Zero extra dependencies (built on `node:http` + `fetch`).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @monlite/realtime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The database stays embedded in **your** service; this package puts a realtime API in front of it.
|
|
12
|
+
|
|
13
|
+
## Server
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createDb } from "@monlite/core";
|
|
17
|
+
import { realtime } from "@monlite/realtime";
|
|
18
|
+
|
|
19
|
+
const db = createDb("./app.db", { changefeed: true }); // change feed required
|
|
20
|
+
|
|
21
|
+
// Single database:
|
|
22
|
+
realtime({ db }).listen(8080);
|
|
23
|
+
|
|
24
|
+
// Or per-tenant + auth (resolve which db from the request):
|
|
25
|
+
realtime({
|
|
26
|
+
authorize: (req) => {
|
|
27
|
+
const tenant = verify(req.headers.authorization); // your auth
|
|
28
|
+
return tenant ? { db: dbForTenant(tenant) } : null; // null → 401
|
|
29
|
+
},
|
|
30
|
+
}).listen(8080);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Attach to an existing server/framework instead of `listen()`:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const rt = realtime({ db });
|
|
37
|
+
http.createServer((req, res) => {
|
|
38
|
+
if (req.url?.startsWith("/realtime")) return rt.handler(req, res);
|
|
39
|
+
// ... your other routes
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Client (browser or Node ≥ 18)
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { connectRealtime } from "@monlite/realtime/client";
|
|
47
|
+
|
|
48
|
+
const live = connectRealtime("https://api.example.com", { token });
|
|
49
|
+
|
|
50
|
+
// Live query — fires with the snapshot, then on every change
|
|
51
|
+
const stop = live
|
|
52
|
+
.collection("orders")
|
|
53
|
+
.where({ status: "open" })
|
|
54
|
+
.orderBy({ createdAt: "desc" })
|
|
55
|
+
.onSnapshot(({ results, added, removed, changed, moved }) => render(results));
|
|
56
|
+
|
|
57
|
+
// Single document (null on delete)
|
|
58
|
+
const stopDoc = live.doc("orders", "o-123", (doc) => render(doc));
|
|
59
|
+
|
|
60
|
+
// Only re-emit when a specific field changes
|
|
61
|
+
live.collection("orders").fields(["status"]).onSnapshot(onChange);
|
|
62
|
+
|
|
63
|
+
stop(); // unsubscribe
|
|
64
|
+
live.close(); // unsubscribe everything
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
- One SSE stream per subscription; the query travels in the URL.
|
|
70
|
+
- The server runs `collection.watch()` / `watchDoc()` on the authorized database and pushes each
|
|
71
|
+
`LiveEvent` (init snapshot, then `added`/`removed`/`changed`/`moved` deltas) down the stream.
|
|
72
|
+
- The client auto-reconnects with backoff; on reconnect it receives a fresh snapshot (no missed
|
|
73
|
+
state). Because writes flow through the [change feed](https://qataruts.github.io/monlite/docs/core/realtime),
|
|
74
|
+
changes from other processes and from `@monlite/sync` are delivered too.
|
|
75
|
+
|
|
76
|
+
## Notes
|
|
77
|
+
|
|
78
|
+
- **Auth & multi-tenancy** are your `authorize` hook's job — it maps a request to a `{ db }`.
|
|
79
|
+
- **CORS** is `*` by default; set `cors` to a specific origin (or `false`) in production.
|
|
80
|
+
- Pairs naturally with the embedded, one-`.db`-file-per-tenant model.
|
package/dist/client.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/client.ts
|
|
4
|
+
function connectRealtime(baseUrl, opts = {}) {
|
|
5
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
6
|
+
const base = (opts.path ?? "/realtime").replace(/\/$/, "");
|
|
7
|
+
const reconnectMs = opts.reconnectMs ?? 1e3;
|
|
8
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
9
|
+
const subs = /* @__PURE__ */ new Set();
|
|
10
|
+
function stream(path, onMessage) {
|
|
11
|
+
let stopped = false;
|
|
12
|
+
let controller;
|
|
13
|
+
const url = new URL(root + base + path);
|
|
14
|
+
if (opts.token) url.searchParams.set("token", opts.token);
|
|
15
|
+
(async () => {
|
|
16
|
+
while (!stopped) {
|
|
17
|
+
controller = new AbortController();
|
|
18
|
+
try {
|
|
19
|
+
const res = await doFetch(url.toString(), {
|
|
20
|
+
headers: opts.token ? { authorization: `Bearer ${opts.token}` } : {},
|
|
21
|
+
signal: controller.signal
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok || !res.body)
|
|
24
|
+
throw new Error(`realtime HTTP ${res.status}`);
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const decoder = new TextDecoder();
|
|
27
|
+
let buf = "";
|
|
28
|
+
for (; ; ) {
|
|
29
|
+
const { value, done } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
buf += decoder.decode(value, { stream: true });
|
|
32
|
+
let idx;
|
|
33
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
34
|
+
const frame = buf.slice(0, idx);
|
|
35
|
+
buf = buf.slice(idx + 2);
|
|
36
|
+
const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
|
|
37
|
+
if (data) {
|
|
38
|
+
try {
|
|
39
|
+
onMessage(JSON.parse(data));
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
if (stopped) break;
|
|
48
|
+
await new Promise((r) => setTimeout(r, reconnectMs));
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
const unsub = () => {
|
|
52
|
+
stopped = true;
|
|
53
|
+
controller?.abort();
|
|
54
|
+
subs.delete(unsub);
|
|
55
|
+
};
|
|
56
|
+
subs.add(unsub);
|
|
57
|
+
return unsub;
|
|
58
|
+
}
|
|
59
|
+
function buildQuery(name) {
|
|
60
|
+
const args = {};
|
|
61
|
+
const api = {
|
|
62
|
+
where(w) {
|
|
63
|
+
args.where = w;
|
|
64
|
+
return api;
|
|
65
|
+
},
|
|
66
|
+
orderBy(o) {
|
|
67
|
+
args.orderBy = o;
|
|
68
|
+
return api;
|
|
69
|
+
},
|
|
70
|
+
take(n) {
|
|
71
|
+
args.take = n;
|
|
72
|
+
return api;
|
|
73
|
+
},
|
|
74
|
+
skip(n) {
|
|
75
|
+
args.skip = n;
|
|
76
|
+
return api;
|
|
77
|
+
},
|
|
78
|
+
fields(f) {
|
|
79
|
+
args.fields = f;
|
|
80
|
+
return api;
|
|
81
|
+
},
|
|
82
|
+
onSnapshot(cb) {
|
|
83
|
+
const qp = `?coll=${encodeURIComponent(name)}&q=${encodeURIComponent(
|
|
84
|
+
JSON.stringify(args)
|
|
85
|
+
)}`;
|
|
86
|
+
return stream(`/query${qp}`, (data) => cb(data));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return api;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
collection(name) {
|
|
93
|
+
return buildQuery(name);
|
|
94
|
+
},
|
|
95
|
+
doc(name, id, cb) {
|
|
96
|
+
const qp = `?coll=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;
|
|
97
|
+
return stream(
|
|
98
|
+
`/doc${qp}`,
|
|
99
|
+
(data) => cb(data?.doc ?? null)
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
close() {
|
|
103
|
+
for (const unsub of [...subs]) unsub();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exports.connectRealtime = connectRealtime;
|
|
109
|
+
//# sourceMappingURL=client.cjs.map
|
|
110
|
+
//# sourceMappingURL=client.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":"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","file":"client.cjs","sourcesContent":["import type {\n Doc,\n LiveEvent,\n WatchArgs,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\nexport interface RealtimeClientOptions {\n /** Bearer token sent as `Authorization` (and `?token=` for EventSource-style auth). */\n token?: string;\n /** Base path on the server. Default `\"/realtime\"`. */\n path?: string;\n /** Reconnect backoff in ms after a dropped stream. Default `1000`. */\n reconnectMs?: number;\n /** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */\n fetch?: typeof fetch;\n}\n\n/** Unsubscribe from a live stream. */\nexport type Unsubscribe = () => void;\n\nexport interface QueryBuilder<T = Doc> {\n where(where: WhereInput<T>): QueryBuilder<T>;\n orderBy(orderBy: WatchArgs<T>[\"orderBy\"]): QueryBuilder<T>;\n take(n: number): QueryBuilder<T>;\n skip(n: number): QueryBuilder<T>;\n /** Only emit when one of these fields changes (server-side filter). */\n fields(fields: (keyof T | (string & {}))[]): QueryBuilder<T>;\n /** Subscribe. The callback fires with each {@link LiveEvent} (init, then changes). */\n onSnapshot(cb: (event: LiveEvent<T>) => void): Unsubscribe;\n}\n\nexport interface RealtimeClient {\n /** Live query over a collection. */\n collection<T = Doc>(name: string): QueryBuilder<T>;\n /** Live single-document listener (`null` while absent / on delete). */\n doc<T = Doc>(\n name: string,\n id: string,\n cb: (doc: WithId<T> | null) => void,\n ): Unsubscribe;\n /** Close all subscriptions. */\n close(): void;\n}\n\n/**\n * Connect to a `@monlite/realtime` server and subscribe to live queries and\n * documents over SSE. Works in the browser and Node ≥ 18 (uses `fetch`).\n *\n * ```ts\n * const live = connectRealtime(\"http://localhost:8080\", { token });\n * const stop = live.collection(\"orders\").where({ status: \"open\" }).onSnapshot(render);\n * const stopDoc = live.doc(\"orders\", \"o-123\", (doc) => render(doc));\n * ```\n */\nexport function connectRealtime(\n baseUrl: string,\n opts: RealtimeClientOptions = {},\n): RealtimeClient {\n const root = baseUrl.replace(/\\/$/, \"\");\n const base = (opts.path ?? \"/realtime\").replace(/\\/$/, \"\");\n const reconnectMs = opts.reconnectMs ?? 1000;\n const doFetch = opts.fetch ?? globalThis.fetch;\n const subs = new Set<Unsubscribe>();\n\n /** Open an auto-reconnecting SSE stream; returns an unsubscribe. */\n function stream(path: string, onMessage: (data: any) => void): Unsubscribe {\n let stopped = false;\n let controller: AbortController | undefined;\n\n const url = new URL(root + base + path);\n if (opts.token) url.searchParams.set(\"token\", opts.token);\n\n (async () => {\n while (!stopped) {\n controller = new AbortController();\n try {\n const res = await doFetch(url.toString(), {\n headers: opts.token\n ? { authorization: `Bearer ${opts.token}` }\n : {},\n signal: controller.signal,\n });\n if (!res.ok || !res.body)\n throw new Error(`realtime HTTP ${res.status}`);\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = \"\";\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let idx: number;\n while ((idx = buf.indexOf(\"\\n\\n\")) >= 0) {\n const frame = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const data = frame\n .split(\"\\n\")\n .filter((l) => l.startsWith(\"data:\"))\n .map((l) => l.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n if (data) {\n try {\n onMessage(JSON.parse(data));\n } catch {\n /* ignore malformed frame */\n }\n }\n }\n }\n } catch {\n /* network/abort — fall through to reconnect */\n }\n if (stopped) break;\n await new Promise((r) => setTimeout(r, reconnectMs));\n }\n })();\n\n const unsub: Unsubscribe = () => {\n stopped = true;\n controller?.abort();\n subs.delete(unsub);\n };\n subs.add(unsub);\n return unsub;\n }\n\n function buildQuery<T>(name: string): QueryBuilder<T> {\n const args: WatchArgs<T> = {};\n const api: QueryBuilder<T> = {\n where(w) {\n args.where = w;\n return api;\n },\n orderBy(o) {\n args.orderBy = o;\n return api;\n },\n take(n) {\n args.take = n;\n return api;\n },\n skip(n) {\n args.skip = n;\n return api;\n },\n fields(f) {\n args.fields = f;\n return api;\n },\n onSnapshot(cb) {\n const qp = `?coll=${encodeURIComponent(name)}&q=${encodeURIComponent(\n JSON.stringify(args),\n )}`;\n return stream(`/query${qp}`, (data) => cb(data as LiveEvent<T>));\n },\n };\n return api;\n }\n\n return {\n collection<T = Doc>(name: string) {\n return buildQuery<T>(name);\n },\n doc<T = Doc>(\n name: string,\n id: string,\n cb: (doc: WithId<T> | null) => void,\n ): Unsubscribe {\n const qp = `?coll=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;\n return stream(`/doc${qp}`, (data) =>\n cb((data?.doc ?? null) as WithId<T> | null),\n );\n },\n close() {\n for (const unsub of [...subs]) unsub();\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Doc, WhereInput, WatchArgs, LiveEvent, WithId } from '@monlite/core';
|
|
2
|
+
|
|
3
|
+
interface RealtimeClientOptions {
|
|
4
|
+
/** Bearer token sent as `Authorization` (and `?token=` for EventSource-style auth). */
|
|
5
|
+
token?: string;
|
|
6
|
+
/** Base path on the server. Default `"/realtime"`. */
|
|
7
|
+
path?: string;
|
|
8
|
+
/** Reconnect backoff in ms after a dropped stream. Default `1000`. */
|
|
9
|
+
reconnectMs?: number;
|
|
10
|
+
/** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/** Unsubscribe from a live stream. */
|
|
14
|
+
type Unsubscribe = () => void;
|
|
15
|
+
interface QueryBuilder<T = Doc> {
|
|
16
|
+
where(where: WhereInput<T>): QueryBuilder<T>;
|
|
17
|
+
orderBy(orderBy: WatchArgs<T>["orderBy"]): QueryBuilder<T>;
|
|
18
|
+
take(n: number): QueryBuilder<T>;
|
|
19
|
+
skip(n: number): QueryBuilder<T>;
|
|
20
|
+
/** Only emit when one of these fields changes (server-side filter). */
|
|
21
|
+
fields(fields: (keyof T | (string & {}))[]): QueryBuilder<T>;
|
|
22
|
+
/** Subscribe. The callback fires with each {@link LiveEvent} (init, then changes). */
|
|
23
|
+
onSnapshot(cb: (event: LiveEvent<T>) => void): Unsubscribe;
|
|
24
|
+
}
|
|
25
|
+
interface RealtimeClient {
|
|
26
|
+
/** Live query over a collection. */
|
|
27
|
+
collection<T = Doc>(name: string): QueryBuilder<T>;
|
|
28
|
+
/** Live single-document listener (`null` while absent / on delete). */
|
|
29
|
+
doc<T = Doc>(name: string, id: string, cb: (doc: WithId<T> | null) => void): Unsubscribe;
|
|
30
|
+
/** Close all subscriptions. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Connect to a `@monlite/realtime` server and subscribe to live queries and
|
|
35
|
+
* documents over SSE. Works in the browser and Node ≥ 18 (uses `fetch`).
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const live = connectRealtime("http://localhost:8080", { token });
|
|
39
|
+
* const stop = live.collection("orders").where({ status: "open" }).onSnapshot(render);
|
|
40
|
+
* const stopDoc = live.doc("orders", "o-123", (doc) => render(doc));
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function connectRealtime(baseUrl: string, opts?: RealtimeClientOptions): RealtimeClient;
|
|
44
|
+
|
|
45
|
+
export { type QueryBuilder, type RealtimeClient, type RealtimeClientOptions, type Unsubscribe, connectRealtime };
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Doc, WhereInput, WatchArgs, LiveEvent, WithId } from '@monlite/core';
|
|
2
|
+
|
|
3
|
+
interface RealtimeClientOptions {
|
|
4
|
+
/** Bearer token sent as `Authorization` (and `?token=` for EventSource-style auth). */
|
|
5
|
+
token?: string;
|
|
6
|
+
/** Base path on the server. Default `"/realtime"`. */
|
|
7
|
+
path?: string;
|
|
8
|
+
/** Reconnect backoff in ms after a dropped stream. Default `1000`. */
|
|
9
|
+
reconnectMs?: number;
|
|
10
|
+
/** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/** Unsubscribe from a live stream. */
|
|
14
|
+
type Unsubscribe = () => void;
|
|
15
|
+
interface QueryBuilder<T = Doc> {
|
|
16
|
+
where(where: WhereInput<T>): QueryBuilder<T>;
|
|
17
|
+
orderBy(orderBy: WatchArgs<T>["orderBy"]): QueryBuilder<T>;
|
|
18
|
+
take(n: number): QueryBuilder<T>;
|
|
19
|
+
skip(n: number): QueryBuilder<T>;
|
|
20
|
+
/** Only emit when one of these fields changes (server-side filter). */
|
|
21
|
+
fields(fields: (keyof T | (string & {}))[]): QueryBuilder<T>;
|
|
22
|
+
/** Subscribe. The callback fires with each {@link LiveEvent} (init, then changes). */
|
|
23
|
+
onSnapshot(cb: (event: LiveEvent<T>) => void): Unsubscribe;
|
|
24
|
+
}
|
|
25
|
+
interface RealtimeClient {
|
|
26
|
+
/** Live query over a collection. */
|
|
27
|
+
collection<T = Doc>(name: string): QueryBuilder<T>;
|
|
28
|
+
/** Live single-document listener (`null` while absent / on delete). */
|
|
29
|
+
doc<T = Doc>(name: string, id: string, cb: (doc: WithId<T> | null) => void): Unsubscribe;
|
|
30
|
+
/** Close all subscriptions. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Connect to a `@monlite/realtime` server and subscribe to live queries and
|
|
35
|
+
* documents over SSE. Works in the browser and Node ≥ 18 (uses `fetch`).
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const live = connectRealtime("http://localhost:8080", { token });
|
|
39
|
+
* const stop = live.collection("orders").where({ status: "open" }).onSnapshot(render);
|
|
40
|
+
* const stopDoc = live.doc("orders", "o-123", (doc) => render(doc));
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function connectRealtime(baseUrl: string, opts?: RealtimeClientOptions): RealtimeClient;
|
|
44
|
+
|
|
45
|
+
export { type QueryBuilder, type RealtimeClient, type RealtimeClientOptions, type Unsubscribe, connectRealtime };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
function connectRealtime(baseUrl, opts = {}) {
|
|
3
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
4
|
+
const base = (opts.path ?? "/realtime").replace(/\/$/, "");
|
|
5
|
+
const reconnectMs = opts.reconnectMs ?? 1e3;
|
|
6
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
7
|
+
const subs = /* @__PURE__ */ new Set();
|
|
8
|
+
function stream(path, onMessage) {
|
|
9
|
+
let stopped = false;
|
|
10
|
+
let controller;
|
|
11
|
+
const url = new URL(root + base + path);
|
|
12
|
+
if (opts.token) url.searchParams.set("token", opts.token);
|
|
13
|
+
(async () => {
|
|
14
|
+
while (!stopped) {
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
try {
|
|
17
|
+
const res = await doFetch(url.toString(), {
|
|
18
|
+
headers: opts.token ? { authorization: `Bearer ${opts.token}` } : {},
|
|
19
|
+
signal: controller.signal
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok || !res.body)
|
|
22
|
+
throw new Error(`realtime HTTP ${res.status}`);
|
|
23
|
+
const reader = res.body.getReader();
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
let buf = "";
|
|
26
|
+
for (; ; ) {
|
|
27
|
+
const { value, done } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
29
|
+
buf += decoder.decode(value, { stream: true });
|
|
30
|
+
let idx;
|
|
31
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
32
|
+
const frame = buf.slice(0, idx);
|
|
33
|
+
buf = buf.slice(idx + 2);
|
|
34
|
+
const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
|
|
35
|
+
if (data) {
|
|
36
|
+
try {
|
|
37
|
+
onMessage(JSON.parse(data));
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
if (stopped) break;
|
|
46
|
+
await new Promise((r) => setTimeout(r, reconnectMs));
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
const unsub = () => {
|
|
50
|
+
stopped = true;
|
|
51
|
+
controller?.abort();
|
|
52
|
+
subs.delete(unsub);
|
|
53
|
+
};
|
|
54
|
+
subs.add(unsub);
|
|
55
|
+
return unsub;
|
|
56
|
+
}
|
|
57
|
+
function buildQuery(name) {
|
|
58
|
+
const args = {};
|
|
59
|
+
const api = {
|
|
60
|
+
where(w) {
|
|
61
|
+
args.where = w;
|
|
62
|
+
return api;
|
|
63
|
+
},
|
|
64
|
+
orderBy(o) {
|
|
65
|
+
args.orderBy = o;
|
|
66
|
+
return api;
|
|
67
|
+
},
|
|
68
|
+
take(n) {
|
|
69
|
+
args.take = n;
|
|
70
|
+
return api;
|
|
71
|
+
},
|
|
72
|
+
skip(n) {
|
|
73
|
+
args.skip = n;
|
|
74
|
+
return api;
|
|
75
|
+
},
|
|
76
|
+
fields(f) {
|
|
77
|
+
args.fields = f;
|
|
78
|
+
return api;
|
|
79
|
+
},
|
|
80
|
+
onSnapshot(cb) {
|
|
81
|
+
const qp = `?coll=${encodeURIComponent(name)}&q=${encodeURIComponent(
|
|
82
|
+
JSON.stringify(args)
|
|
83
|
+
)}`;
|
|
84
|
+
return stream(`/query${qp}`, (data) => cb(data));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return api;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
collection(name) {
|
|
91
|
+
return buildQuery(name);
|
|
92
|
+
},
|
|
93
|
+
doc(name, id, cb) {
|
|
94
|
+
const qp = `?coll=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;
|
|
95
|
+
return stream(
|
|
96
|
+
`/doc${qp}`,
|
|
97
|
+
(data) => cb(data?.doc ?? null)
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
close() {
|
|
101
|
+
for (const unsub of [...subs]) unsub();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { connectRealtime };
|
|
107
|
+
//# sourceMappingURL=client.js.map
|
|
108
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":";AAwDO,SAAS,eAAA,CACd,OAAA,EACA,IAAA,GAA8B,EAAC,EACf;AAChB,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AACtC,EAAA,MAAM,QAAQ,IAAA,CAAK,IAAA,IAAQ,WAAA,EAAa,OAAA,CAAQ,OAAO,EAAE,CAAA;AACzD,EAAA,MAAM,WAAA,GAAc,KAAK,WAAA,IAAe,GAAA;AACxC,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,IAAS,UAAA,CAAW,KAAA;AACzC,EAAA,MAAM,IAAA,uBAAW,GAAA,EAAiB;AAGlC,EAAA,SAAS,MAAA,CAAO,MAAc,SAAA,EAA6C;AACzE,IAAA,IAAI,OAAA,GAAU,KAAA;AACd,IAAA,IAAI,UAAA;AAEJ,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,IAAA,GAAO,OAAO,IAAI,CAAA;AACtC,IAAA,IAAI,KAAK,KAAA,EAAO,GAAA,CAAI,aAAa,GAAA,CAAI,OAAA,EAAS,KAAK,KAAK,CAAA;AAExD,IAAA,CAAC,YAAY;AACX,MAAA,OAAO,CAAC,OAAA,EAAS;AACf,QAAA,UAAA,GAAa,IAAI,eAAA,EAAgB;AACjC,QAAA,IAAI;AACF,UAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ,GAAA,CAAI,UAAS,EAAG;AAAA,YACxC,OAAA,EAAS,IAAA,CAAK,KAAA,GACV,EAAE,aAAA,EAAe,UAAU,IAAA,CAAK,KAAK,CAAA,CAAA,EAAG,GACxC,EAAC;AAAA,YACL,QAAQ,UAAA,CAAW;AAAA,WACpB,CAAA;AACD,UAAA,IAAI,CAAC,GAAA,CAAI,EAAA,IAAM,CAAC,GAAA,CAAI,IAAA;AAClB,YAAA,MAAM,IAAI,KAAA,CAAM,CAAA,cAAA,EAAiB,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AAC/C,UAAA,MAAM,MAAA,GAAS,GAAA,CAAI,IAAA,CAAK,SAAA,EAAU;AAClC,UAAA,MAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,UAAA,IAAI,GAAA,GAAM,EAAA;AACV,UAAA,WAAS;AACP,YAAA,MAAM,EAAE,KAAA,EAAO,IAAA,EAAK,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,YAAA,IAAI,IAAA,EAAM;AACV,YAAA,GAAA,IAAO,QAAQ,MAAA,CAAO,KAAA,EAAO,EAAE,MAAA,EAAQ,MAAM,CAAA;AAC7C,YAAA,IAAI,GAAA;AACJ,YAAA,OAAA,CAAQ,GAAA,GAAM,GAAA,CAAI,OAAA,CAAQ,MAAM,MAAM,CAAA,EAAG;AACvC,cAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAC9B,cAAA,GAAA,GAAM,GAAA,CAAI,KAAA,CAAM,GAAA,GAAM,CAAC,CAAA;AACvB,cAAA,MAAM,IAAA,GAAO,KAAA,CACV,KAAA,CAAM,IAAI,CAAA,CACV,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,UAAA,CAAW,OAAO,CAAC,CAAA,CACnC,IAAI,CAAC,CAAA,KAAM,CAAA,CAAE,KAAA,CAAM,CAAC,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,EAAE,CAAC,CAAA,CACvC,IAAA,CAAK,IAAI,CAAA;AACZ,cAAA,IAAI,IAAA,EAAM;AACR,gBAAA,IAAI;AACF,kBAAA,SAAA,CAAU,IAAA,CAAK,KAAA,CAAM,IAAI,CAAC,CAAA;AAAA,gBAC5B,CAAA,CAAA,MAAQ;AAAA,gBAER;AAAA,cACF;AAAA,YACF;AAAA,UACF;AAAA,QACF,CAAA,CAAA,MAAQ;AAAA,QAER;AACA,QAAA,IAAI,OAAA,EAAS;AACb,QAAA,MAAM,IAAI,OAAA,CAAQ,CAAC,MAAM,UAAA,CAAW,CAAA,EAAG,WAAW,CAAC,CAAA;AAAA,MACrD;AAAA,IACF,CAAA,GAAG;AAEH,IAAA,MAAM,QAAqB,MAAM;AAC/B,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,UAAA,EAAY,KAAA,EAAM;AAClB,MAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AAAA,IACnB,CAAA;AACA,IAAA,IAAA,CAAK,IAAI,KAAK,CAAA;AACd,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,SAAS,WAAc,IAAA,EAA+B;AACpD,IAAA,MAAM,OAAqB,EAAC;AAC5B,IAAA,MAAM,GAAA,GAAuB;AAAA,MAC3B,MAAM,CAAA,EAAG;AACP,QAAA,IAAA,CAAK,KAAA,GAAQ,CAAA;AACb,QAAA,OAAO,GAAA;AAAA,MACT,CAAA;AAAA,MACA,QAAQ,CAAA,EAAG;AACT,QAAA,IAAA,CAAK,OAAA,GAAU,CAAA;AACf,QAAA,OAAO,GAAA;AAAA,MACT,CAAA;AAAA,MACA,KAAK,CAAA,EAAG;AACN,QAAA,IAAA,CAAK,IAAA,GAAO,CAAA;AACZ,QAAA,OAAO,GAAA;AAAA,MACT,CAAA;AAAA,MACA,KAAK,CAAA,EAAG;AACN,QAAA,IAAA,CAAK,IAAA,GAAO,CAAA;AACZ,QAAA,OAAO,GAAA;AAAA,MACT,CAAA;AAAA,MACA,OAAO,CAAA,EAAG;AACR,QAAA,IAAA,CAAK,MAAA,GAAS,CAAA;AACd,QAAA,OAAO,GAAA;AAAA,MACT,CAAA;AAAA,MACA,WAAW,EAAA,EAAI;AACb,QAAA,MAAM,EAAA,GAAK,CAAA,MAAA,EAAS,kBAAA,CAAmB,IAAI,CAAC,CAAA,GAAA,EAAM,kBAAA;AAAA,UAChD,IAAA,CAAK,UAAU,IAAI;AAAA,SACpB,CAAA,CAAA;AACD,QAAA,OAAO,MAAA,CAAO,SAAS,EAAE,CAAA,CAAA,EAAI,CAAC,IAAA,KAAS,EAAA,CAAG,IAAoB,CAAC,CAAA;AAAA,MACjE;AAAA,KACF;AACA,IAAA,OAAO,GAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,WAAoB,IAAA,EAAc;AAChC,MAAA,OAAO,WAAc,IAAI,CAAA;AAAA,IAC3B,CAAA;AAAA,IACA,GAAA,CACE,IAAA,EACA,EAAA,EACA,EAAA,EACa;AACb,MAAA,MAAM,EAAA,GAAK,SAAS,kBAAA,CAAmB,IAAI,CAAC,CAAA,IAAA,EAAO,kBAAA,CAAmB,EAAE,CAAC,CAAA,CAAA;AACzE,MAAA,OAAO,MAAA;AAAA,QAAO,OAAO,EAAE,CAAA,CAAA;AAAA,QAAI,CAAC,IAAA,KAC1B,EAAA,CAAI,IAAA,EAAM,OAAO,IAAyB;AAAA,OAC5C;AAAA,IACF,CAAA;AAAA,IACA,KAAA,GAAQ;AACN,MAAA,KAAA,MAAW,KAAA,IAAS,CAAC,GAAG,IAAI,GAAG,KAAA,EAAM;AAAA,IACvC;AAAA,GACF;AACF","file":"client.js","sourcesContent":["import type {\n Doc,\n LiveEvent,\n WatchArgs,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\nexport interface RealtimeClientOptions {\n /** Bearer token sent as `Authorization` (and `?token=` for EventSource-style auth). */\n token?: string;\n /** Base path on the server. Default `\"/realtime\"`. */\n path?: string;\n /** Reconnect backoff in ms after a dropped stream. Default `1000`. */\n reconnectMs?: number;\n /** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */\n fetch?: typeof fetch;\n}\n\n/** Unsubscribe from a live stream. */\nexport type Unsubscribe = () => void;\n\nexport interface QueryBuilder<T = Doc> {\n where(where: WhereInput<T>): QueryBuilder<T>;\n orderBy(orderBy: WatchArgs<T>[\"orderBy\"]): QueryBuilder<T>;\n take(n: number): QueryBuilder<T>;\n skip(n: number): QueryBuilder<T>;\n /** Only emit when one of these fields changes (server-side filter). */\n fields(fields: (keyof T | (string & {}))[]): QueryBuilder<T>;\n /** Subscribe. The callback fires with each {@link LiveEvent} (init, then changes). */\n onSnapshot(cb: (event: LiveEvent<T>) => void): Unsubscribe;\n}\n\nexport interface RealtimeClient {\n /** Live query over a collection. */\n collection<T = Doc>(name: string): QueryBuilder<T>;\n /** Live single-document listener (`null` while absent / on delete). */\n doc<T = Doc>(\n name: string,\n id: string,\n cb: (doc: WithId<T> | null) => void,\n ): Unsubscribe;\n /** Close all subscriptions. */\n close(): void;\n}\n\n/**\n * Connect to a `@monlite/realtime` server and subscribe to live queries and\n * documents over SSE. Works in the browser and Node ≥ 18 (uses `fetch`).\n *\n * ```ts\n * const live = connectRealtime(\"http://localhost:8080\", { token });\n * const stop = live.collection(\"orders\").where({ status: \"open\" }).onSnapshot(render);\n * const stopDoc = live.doc(\"orders\", \"o-123\", (doc) => render(doc));\n * ```\n */\nexport function connectRealtime(\n baseUrl: string,\n opts: RealtimeClientOptions = {},\n): RealtimeClient {\n const root = baseUrl.replace(/\\/$/, \"\");\n const base = (opts.path ?? \"/realtime\").replace(/\\/$/, \"\");\n const reconnectMs = opts.reconnectMs ?? 1000;\n const doFetch = opts.fetch ?? globalThis.fetch;\n const subs = new Set<Unsubscribe>();\n\n /** Open an auto-reconnecting SSE stream; returns an unsubscribe. */\n function stream(path: string, onMessage: (data: any) => void): Unsubscribe {\n let stopped = false;\n let controller: AbortController | undefined;\n\n const url = new URL(root + base + path);\n if (opts.token) url.searchParams.set(\"token\", opts.token);\n\n (async () => {\n while (!stopped) {\n controller = new AbortController();\n try {\n const res = await doFetch(url.toString(), {\n headers: opts.token\n ? { authorization: `Bearer ${opts.token}` }\n : {},\n signal: controller.signal,\n });\n if (!res.ok || !res.body)\n throw new Error(`realtime HTTP ${res.status}`);\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = \"\";\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let idx: number;\n while ((idx = buf.indexOf(\"\\n\\n\")) >= 0) {\n const frame = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const data = frame\n .split(\"\\n\")\n .filter((l) => l.startsWith(\"data:\"))\n .map((l) => l.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n if (data) {\n try {\n onMessage(JSON.parse(data));\n } catch {\n /* ignore malformed frame */\n }\n }\n }\n }\n } catch {\n /* network/abort — fall through to reconnect */\n }\n if (stopped) break;\n await new Promise((r) => setTimeout(r, reconnectMs));\n }\n })();\n\n const unsub: Unsubscribe = () => {\n stopped = true;\n controller?.abort();\n subs.delete(unsub);\n };\n subs.add(unsub);\n return unsub;\n }\n\n function buildQuery<T>(name: string): QueryBuilder<T> {\n const args: WatchArgs<T> = {};\n const api: QueryBuilder<T> = {\n where(w) {\n args.where = w;\n return api;\n },\n orderBy(o) {\n args.orderBy = o;\n return api;\n },\n take(n) {\n args.take = n;\n return api;\n },\n skip(n) {\n args.skip = n;\n return api;\n },\n fields(f) {\n args.fields = f;\n return api;\n },\n onSnapshot(cb) {\n const qp = `?coll=${encodeURIComponent(name)}&q=${encodeURIComponent(\n JSON.stringify(args),\n )}`;\n return stream(`/query${qp}`, (data) => cb(data as LiveEvent<T>));\n },\n };\n return api;\n }\n\n return {\n collection<T = Doc>(name: string) {\n return buildQuery<T>(name);\n },\n doc<T = Doc>(\n name: string,\n id: string,\n cb: (doc: WithId<T> | null) => void,\n ): Unsubscribe {\n const qp = `?coll=${encodeURIComponent(name)}&id=${encodeURIComponent(id)}`;\n return stream(`/doc${qp}`, (data) =>\n cb((data?.doc ?? null) as WithId<T> | null),\n );\n },\n close() {\n for (const unsub of [...subs]) unsub();\n },\n };\n}\n"]}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var http = require('http');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
function realtime(options) {
|
|
7
|
+
const base = (options.path ?? "/realtime").replace(/\/$/, "");
|
|
8
|
+
const cors = options.cors === void 0 ? "*" : options.cors;
|
|
9
|
+
const heartbeatMs = options.heartbeatMs ?? 25e3;
|
|
10
|
+
const open = /* @__PURE__ */ new Set();
|
|
11
|
+
const resolve = async (req) => {
|
|
12
|
+
if (options.authorize) return options.authorize(req);
|
|
13
|
+
if (options.db) return { db: options.db };
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
const setCors = (res) => {
|
|
17
|
+
if (cors === false) return;
|
|
18
|
+
res.setHeader("Access-Control-Allow-Origin", cors);
|
|
19
|
+
res.setHeader("Access-Control-Allow-Headers", "authorization,content-type");
|
|
20
|
+
};
|
|
21
|
+
const fail = (res, code, msg) => {
|
|
22
|
+
setCors(res);
|
|
23
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
24
|
+
res.end(JSON.stringify({ error: msg }));
|
|
25
|
+
};
|
|
26
|
+
const handler = (req, res) => {
|
|
27
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
28
|
+
if (cors !== false && req.method === "OPTIONS") {
|
|
29
|
+
setCors(res);
|
|
30
|
+
res.writeHead(204);
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!url.pathname.startsWith(base + "/")) {
|
|
35
|
+
fail(res, 404, "not found");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const kind = url.pathname.slice(base.length + 1);
|
|
39
|
+
if (kind !== "query" && kind !== "doc") {
|
|
40
|
+
fail(res, 404, "not found");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
void (async () => {
|
|
44
|
+
let ctx;
|
|
45
|
+
try {
|
|
46
|
+
ctx = await resolve(req);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
fail(res, 401, err?.message ?? "unauthorized");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
fail(res, 401, "unauthorized");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const coll = url.searchParams.get("coll");
|
|
56
|
+
if (!coll) {
|
|
57
|
+
fail(res, 400, "missing coll");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setCors(res);
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
"content-type": "text/event-stream",
|
|
63
|
+
"cache-control": "no-cache, no-transform",
|
|
64
|
+
connection: "keep-alive",
|
|
65
|
+
"x-accel-buffering": "no"
|
|
66
|
+
});
|
|
67
|
+
res.write(": ok\n\n");
|
|
68
|
+
let id = 0;
|
|
69
|
+
const send = (payload) => {
|
|
70
|
+
if (res.writableEnded) return;
|
|
71
|
+
res.write(`id: ${++id}
|
|
72
|
+
data: ${JSON.stringify(payload)}
|
|
73
|
+
|
|
74
|
+
`);
|
|
75
|
+
};
|
|
76
|
+
let handle;
|
|
77
|
+
try {
|
|
78
|
+
const collection = ctx.db.collection(coll);
|
|
79
|
+
if (kind === "doc") {
|
|
80
|
+
const docId = url.searchParams.get("id");
|
|
81
|
+
if (!docId) {
|
|
82
|
+
fail(res, 400, "missing id");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
handle = collection.watchDoc(docId, (doc) => send({ doc }));
|
|
86
|
+
} else {
|
|
87
|
+
const q = url.searchParams.get("q");
|
|
88
|
+
const args = q ? JSON.parse(q) : {};
|
|
89
|
+
handle = collection.watch(args, (event) => send(event));
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
send({ error: err?.message ?? "watch failed" });
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
open.add(handle);
|
|
97
|
+
const heartbeat = setInterval(() => {
|
|
98
|
+
if (!res.writableEnded) res.write(": ping\n\n");
|
|
99
|
+
}, heartbeatMs);
|
|
100
|
+
heartbeat.unref?.();
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
clearInterval(heartbeat);
|
|
103
|
+
if (handle) {
|
|
104
|
+
handle.stop();
|
|
105
|
+
open.delete(handle);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
req.on("close", cleanup);
|
|
109
|
+
res.on("close", cleanup);
|
|
110
|
+
})();
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
handler,
|
|
114
|
+
listen(port, cb) {
|
|
115
|
+
const server = http.createServer(handler);
|
|
116
|
+
server.listen(port, cb);
|
|
117
|
+
return server;
|
|
118
|
+
},
|
|
119
|
+
get subscriptions() {
|
|
120
|
+
return open.size;
|
|
121
|
+
},
|
|
122
|
+
close() {
|
|
123
|
+
for (const h of open) h.stop();
|
|
124
|
+
open.clear();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
exports.realtime = realtime;
|
|
130
|
+
//# sourceMappingURL=index.cjs.map
|
|
131
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["createServer"],"mappings":"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","file":"index.cjs","sourcesContent":["import {\n createServer,\n type IncomingMessage,\n type Server,\n type ServerResponse,\n} from \"node:http\";\nimport type { Monlite, WatchArgs, WatchHandle } from \"@monlite/core\";\n\n/** What an authorized request resolves to: the database (tenant) to serve. */\nexport interface RealtimeContext {\n db: Monlite;\n}\n\nexport interface RealtimeOptions {\n /**\n * Resolve the database (tenant) for a request and authorize it. Return a\n * context (`{ db }`), or `null`/throw to reject (`401`). Read a token from\n * `req.headers.authorization` / the `?token=` query param. For per-tenant\n * deployments, map the token → that tenant's `Monlite` instance.\n */\n authorize?: (\n req: IncomingMessage,\n ) => RealtimeContext | null | Promise<RealtimeContext | null>;\n /** Single-database shortcut — serve this db for every request (no auth). */\n db?: Monlite;\n /** Base path for the endpoints. Default `\"/realtime\"`. */\n path?: string;\n /** `Access-Control-Allow-Origin` value, or `false` to disable CORS. Default `\"*\"`. */\n cors?: string | false;\n /** Heartbeat comment interval (ms) to keep idle connections alive. Default `25000`. */\n heartbeatMs?: number;\n}\n\nexport interface RealtimeServer {\n /** Node `http` request handler — attach to your own server or framework. */\n handler: (req: IncomingMessage, res: ServerResponse) => void;\n /** Convenience: start a standalone HTTP server. */\n listen: (port: number, cb?: () => void) => Server;\n /** Active subscription count. */\n readonly subscriptions: number;\n /** Stop all subscriptions (does not close the http server). */\n close: () => void;\n}\n\n/**\n * A realtime gateway that streams live queries and documents to remote clients\n * over Server-Sent Events, backed by `@monlite/core`'s `watch()` / change feed.\n * Zero extra dependencies — built on `node:http`.\n *\n * ```ts\n * realtime({ authorize: (req) => ({ db: dbForTenant(tokenOf(req)) }) }).listen(8080);\n * ```\n */\nexport function realtime(options: RealtimeOptions): RealtimeServer {\n const base = (options.path ?? \"/realtime\").replace(/\\/$/, \"\");\n const cors = options.cors === undefined ? \"*\" : options.cors;\n const heartbeatMs = options.heartbeatMs ?? 25_000;\n const open = new Set<WatchHandle<any>>();\n\n const resolve = async (\n req: IncomingMessage,\n ): Promise<RealtimeContext | null> => {\n if (options.authorize) return options.authorize(req);\n if (options.db) return { db: options.db };\n return null;\n };\n\n const setCors = (res: ServerResponse): void => {\n if (cors === false) return;\n res.setHeader(\"Access-Control-Allow-Origin\", cors);\n res.setHeader(\"Access-Control-Allow-Headers\", \"authorization,content-type\");\n };\n\n const fail = (res: ServerResponse, code: number, msg: string): void => {\n setCors(res);\n res.writeHead(code, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify({ error: msg }));\n };\n\n const handler = (req: IncomingMessage, res: ServerResponse): void => {\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n if (cors !== false && req.method === \"OPTIONS\") {\n setCors(res);\n res.writeHead(204);\n res.end();\n return;\n }\n if (!url.pathname.startsWith(base + \"/\")) {\n fail(res, 404, \"not found\");\n return;\n }\n const kind = url.pathname.slice(base.length + 1); // \"query\" | \"doc\"\n if (kind !== \"query\" && kind !== \"doc\") {\n fail(res, 404, \"not found\");\n return;\n }\n\n void (async () => {\n let ctx: RealtimeContext | null;\n try {\n ctx = await resolve(req);\n } catch (err) {\n fail(res, 401, (err as Error)?.message ?? \"unauthorized\");\n return;\n }\n if (!ctx) {\n fail(res, 401, \"unauthorized\");\n return;\n }\n\n const coll = url.searchParams.get(\"coll\");\n if (!coll) {\n fail(res, 400, \"missing coll\");\n return;\n }\n\n // Open the SSE stream.\n setCors(res);\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache, no-transform\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n });\n res.write(\": ok\\n\\n\");\n let id = 0;\n const send = (payload: unknown): void => {\n if (res.writableEnded) return;\n res.write(`id: ${++id}\\ndata: ${JSON.stringify(payload)}\\n\\n`);\n };\n\n let handle: WatchHandle<any> | undefined;\n try {\n const collection = ctx.db.collection(coll);\n if (kind === \"doc\") {\n const docId = url.searchParams.get(\"id\");\n if (!docId) {\n fail(res, 400, \"missing id\");\n return;\n }\n handle = collection.watchDoc(docId, (doc) => send({ doc }));\n } else {\n const q = url.searchParams.get(\"q\");\n const args: WatchArgs<any> = q ? JSON.parse(q) : {};\n handle = collection.watch(args, (event) => send(event));\n }\n } catch (err) {\n // Headers already sent; surface the error in-band, then close.\n send({ error: (err as Error)?.message ?? \"watch failed\" });\n res.end();\n return;\n }\n open.add(handle);\n\n const heartbeat = setInterval(() => {\n if (!res.writableEnded) res.write(\": ping\\n\\n\");\n }, heartbeatMs);\n (heartbeat as any).unref?.();\n\n const cleanup = (): void => {\n clearInterval(heartbeat);\n if (handle) {\n handle.stop();\n open.delete(handle);\n }\n };\n req.on(\"close\", cleanup);\n res.on(\"close\", cleanup);\n })();\n };\n\n return {\n handler,\n listen(port, cb) {\n const server = createServer(handler);\n server.listen(port, cb);\n return server;\n },\n get subscriptions() {\n return open.size;\n },\n close() {\n for (const h of open) h.stop();\n open.clear();\n },\n };\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse, Server } from 'node:http';
|
|
2
|
+
import { Monlite } from '@monlite/core';
|
|
3
|
+
|
|
4
|
+
/** What an authorized request resolves to: the database (tenant) to serve. */
|
|
5
|
+
interface RealtimeContext {
|
|
6
|
+
db: Monlite;
|
|
7
|
+
}
|
|
8
|
+
interface RealtimeOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the database (tenant) for a request and authorize it. Return a
|
|
11
|
+
* context (`{ db }`), or `null`/throw to reject (`401`). Read a token from
|
|
12
|
+
* `req.headers.authorization` / the `?token=` query param. For per-tenant
|
|
13
|
+
* deployments, map the token → that tenant's `Monlite` instance.
|
|
14
|
+
*/
|
|
15
|
+
authorize?: (req: IncomingMessage) => RealtimeContext | null | Promise<RealtimeContext | null>;
|
|
16
|
+
/** Single-database shortcut — serve this db for every request (no auth). */
|
|
17
|
+
db?: Monlite;
|
|
18
|
+
/** Base path for the endpoints. Default `"/realtime"`. */
|
|
19
|
+
path?: string;
|
|
20
|
+
/** `Access-Control-Allow-Origin` value, or `false` to disable CORS. Default `"*"`. */
|
|
21
|
+
cors?: string | false;
|
|
22
|
+
/** Heartbeat comment interval (ms) to keep idle connections alive. Default `25000`. */
|
|
23
|
+
heartbeatMs?: number;
|
|
24
|
+
}
|
|
25
|
+
interface RealtimeServer {
|
|
26
|
+
/** Node `http` request handler — attach to your own server or framework. */
|
|
27
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
28
|
+
/** Convenience: start a standalone HTTP server. */
|
|
29
|
+
listen: (port: number, cb?: () => void) => Server;
|
|
30
|
+
/** Active subscription count. */
|
|
31
|
+
readonly subscriptions: number;
|
|
32
|
+
/** Stop all subscriptions (does not close the http server). */
|
|
33
|
+
close: () => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A realtime gateway that streams live queries and documents to remote clients
|
|
37
|
+
* over Server-Sent Events, backed by `@monlite/core`'s `watch()` / change feed.
|
|
38
|
+
* Zero extra dependencies — built on `node:http`.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* realtime({ authorize: (req) => ({ db: dbForTenant(tokenOf(req)) }) }).listen(8080);
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
declare function realtime(options: RealtimeOptions): RealtimeServer;
|
|
45
|
+
|
|
46
|
+
export { type RealtimeContext, type RealtimeOptions, type RealtimeServer, realtime };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse, Server } from 'node:http';
|
|
2
|
+
import { Monlite } from '@monlite/core';
|
|
3
|
+
|
|
4
|
+
/** What an authorized request resolves to: the database (tenant) to serve. */
|
|
5
|
+
interface RealtimeContext {
|
|
6
|
+
db: Monlite;
|
|
7
|
+
}
|
|
8
|
+
interface RealtimeOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the database (tenant) for a request and authorize it. Return a
|
|
11
|
+
* context (`{ db }`), or `null`/throw to reject (`401`). Read a token from
|
|
12
|
+
* `req.headers.authorization` / the `?token=` query param. For per-tenant
|
|
13
|
+
* deployments, map the token → that tenant's `Monlite` instance.
|
|
14
|
+
*/
|
|
15
|
+
authorize?: (req: IncomingMessage) => RealtimeContext | null | Promise<RealtimeContext | null>;
|
|
16
|
+
/** Single-database shortcut — serve this db for every request (no auth). */
|
|
17
|
+
db?: Monlite;
|
|
18
|
+
/** Base path for the endpoints. Default `"/realtime"`. */
|
|
19
|
+
path?: string;
|
|
20
|
+
/** `Access-Control-Allow-Origin` value, or `false` to disable CORS. Default `"*"`. */
|
|
21
|
+
cors?: string | false;
|
|
22
|
+
/** Heartbeat comment interval (ms) to keep idle connections alive. Default `25000`. */
|
|
23
|
+
heartbeatMs?: number;
|
|
24
|
+
}
|
|
25
|
+
interface RealtimeServer {
|
|
26
|
+
/** Node `http` request handler — attach to your own server or framework. */
|
|
27
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
28
|
+
/** Convenience: start a standalone HTTP server. */
|
|
29
|
+
listen: (port: number, cb?: () => void) => Server;
|
|
30
|
+
/** Active subscription count. */
|
|
31
|
+
readonly subscriptions: number;
|
|
32
|
+
/** Stop all subscriptions (does not close the http server). */
|
|
33
|
+
close: () => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A realtime gateway that streams live queries and documents to remote clients
|
|
37
|
+
* over Server-Sent Events, backed by `@monlite/core`'s `watch()` / change feed.
|
|
38
|
+
* Zero extra dependencies — built on `node:http`.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* realtime({ authorize: (req) => ({ db: dbForTenant(tokenOf(req)) }) }).listen(8080);
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
declare function realtime(options: RealtimeOptions): RealtimeServer;
|
|
45
|
+
|
|
46
|
+
export { type RealtimeContext, type RealtimeOptions, type RealtimeServer, realtime };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
function realtime(options) {
|
|
5
|
+
const base = (options.path ?? "/realtime").replace(/\/$/, "");
|
|
6
|
+
const cors = options.cors === void 0 ? "*" : options.cors;
|
|
7
|
+
const heartbeatMs = options.heartbeatMs ?? 25e3;
|
|
8
|
+
const open = /* @__PURE__ */ new Set();
|
|
9
|
+
const resolve = async (req) => {
|
|
10
|
+
if (options.authorize) return options.authorize(req);
|
|
11
|
+
if (options.db) return { db: options.db };
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
const setCors = (res) => {
|
|
15
|
+
if (cors === false) return;
|
|
16
|
+
res.setHeader("Access-Control-Allow-Origin", cors);
|
|
17
|
+
res.setHeader("Access-Control-Allow-Headers", "authorization,content-type");
|
|
18
|
+
};
|
|
19
|
+
const fail = (res, code, msg) => {
|
|
20
|
+
setCors(res);
|
|
21
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify({ error: msg }));
|
|
23
|
+
};
|
|
24
|
+
const handler = (req, res) => {
|
|
25
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
26
|
+
if (cors !== false && req.method === "OPTIONS") {
|
|
27
|
+
setCors(res);
|
|
28
|
+
res.writeHead(204);
|
|
29
|
+
res.end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!url.pathname.startsWith(base + "/")) {
|
|
33
|
+
fail(res, 404, "not found");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const kind = url.pathname.slice(base.length + 1);
|
|
37
|
+
if (kind !== "query" && kind !== "doc") {
|
|
38
|
+
fail(res, 404, "not found");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
void (async () => {
|
|
42
|
+
let ctx;
|
|
43
|
+
try {
|
|
44
|
+
ctx = await resolve(req);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
fail(res, 401, err?.message ?? "unauthorized");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!ctx) {
|
|
50
|
+
fail(res, 401, "unauthorized");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const coll = url.searchParams.get("coll");
|
|
54
|
+
if (!coll) {
|
|
55
|
+
fail(res, 400, "missing coll");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setCors(res);
|
|
59
|
+
res.writeHead(200, {
|
|
60
|
+
"content-type": "text/event-stream",
|
|
61
|
+
"cache-control": "no-cache, no-transform",
|
|
62
|
+
connection: "keep-alive",
|
|
63
|
+
"x-accel-buffering": "no"
|
|
64
|
+
});
|
|
65
|
+
res.write(": ok\n\n");
|
|
66
|
+
let id = 0;
|
|
67
|
+
const send = (payload) => {
|
|
68
|
+
if (res.writableEnded) return;
|
|
69
|
+
res.write(`id: ${++id}
|
|
70
|
+
data: ${JSON.stringify(payload)}
|
|
71
|
+
|
|
72
|
+
`);
|
|
73
|
+
};
|
|
74
|
+
let handle;
|
|
75
|
+
try {
|
|
76
|
+
const collection = ctx.db.collection(coll);
|
|
77
|
+
if (kind === "doc") {
|
|
78
|
+
const docId = url.searchParams.get("id");
|
|
79
|
+
if (!docId) {
|
|
80
|
+
fail(res, 400, "missing id");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
handle = collection.watchDoc(docId, (doc) => send({ doc }));
|
|
84
|
+
} else {
|
|
85
|
+
const q = url.searchParams.get("q");
|
|
86
|
+
const args = q ? JSON.parse(q) : {};
|
|
87
|
+
handle = collection.watch(args, (event) => send(event));
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
send({ error: err?.message ?? "watch failed" });
|
|
91
|
+
res.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
open.add(handle);
|
|
95
|
+
const heartbeat = setInterval(() => {
|
|
96
|
+
if (!res.writableEnded) res.write(": ping\n\n");
|
|
97
|
+
}, heartbeatMs);
|
|
98
|
+
heartbeat.unref?.();
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
clearInterval(heartbeat);
|
|
101
|
+
if (handle) {
|
|
102
|
+
handle.stop();
|
|
103
|
+
open.delete(handle);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
req.on("close", cleanup);
|
|
107
|
+
res.on("close", cleanup);
|
|
108
|
+
})();
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
handler,
|
|
112
|
+
listen(port, cb) {
|
|
113
|
+
const server = createServer(handler);
|
|
114
|
+
server.listen(port, cb);
|
|
115
|
+
return server;
|
|
116
|
+
},
|
|
117
|
+
get subscriptions() {
|
|
118
|
+
return open.size;
|
|
119
|
+
},
|
|
120
|
+
close() {
|
|
121
|
+
for (const h of open) h.stop();
|
|
122
|
+
open.clear();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { realtime };
|
|
128
|
+
//# sourceMappingURL=index.js.map
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import {\n createServer,\n type IncomingMessage,\n type Server,\n type ServerResponse,\n} from \"node:http\";\nimport type { Monlite, WatchArgs, WatchHandle } from \"@monlite/core\";\n\n/** What an authorized request resolves to: the database (tenant) to serve. */\nexport interface RealtimeContext {\n db: Monlite;\n}\n\nexport interface RealtimeOptions {\n /**\n * Resolve the database (tenant) for a request and authorize it. Return a\n * context (`{ db }`), or `null`/throw to reject (`401`). Read a token from\n * `req.headers.authorization` / the `?token=` query param. For per-tenant\n * deployments, map the token → that tenant's `Monlite` instance.\n */\n authorize?: (\n req: IncomingMessage,\n ) => RealtimeContext | null | Promise<RealtimeContext | null>;\n /** Single-database shortcut — serve this db for every request (no auth). */\n db?: Monlite;\n /** Base path for the endpoints. Default `\"/realtime\"`. */\n path?: string;\n /** `Access-Control-Allow-Origin` value, or `false` to disable CORS. Default `\"*\"`. */\n cors?: string | false;\n /** Heartbeat comment interval (ms) to keep idle connections alive. Default `25000`. */\n heartbeatMs?: number;\n}\n\nexport interface RealtimeServer {\n /** Node `http` request handler — attach to your own server or framework. */\n handler: (req: IncomingMessage, res: ServerResponse) => void;\n /** Convenience: start a standalone HTTP server. */\n listen: (port: number, cb?: () => void) => Server;\n /** Active subscription count. */\n readonly subscriptions: number;\n /** Stop all subscriptions (does not close the http server). */\n close: () => void;\n}\n\n/**\n * A realtime gateway that streams live queries and documents to remote clients\n * over Server-Sent Events, backed by `@monlite/core`'s `watch()` / change feed.\n * Zero extra dependencies — built on `node:http`.\n *\n * ```ts\n * realtime({ authorize: (req) => ({ db: dbForTenant(tokenOf(req)) }) }).listen(8080);\n * ```\n */\nexport function realtime(options: RealtimeOptions): RealtimeServer {\n const base = (options.path ?? \"/realtime\").replace(/\\/$/, \"\");\n const cors = options.cors === undefined ? \"*\" : options.cors;\n const heartbeatMs = options.heartbeatMs ?? 25_000;\n const open = new Set<WatchHandle<any>>();\n\n const resolve = async (\n req: IncomingMessage,\n ): Promise<RealtimeContext | null> => {\n if (options.authorize) return options.authorize(req);\n if (options.db) return { db: options.db };\n return null;\n };\n\n const setCors = (res: ServerResponse): void => {\n if (cors === false) return;\n res.setHeader(\"Access-Control-Allow-Origin\", cors);\n res.setHeader(\"Access-Control-Allow-Headers\", \"authorization,content-type\");\n };\n\n const fail = (res: ServerResponse, code: number, msg: string): void => {\n setCors(res);\n res.writeHead(code, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify({ error: msg }));\n };\n\n const handler = (req: IncomingMessage, res: ServerResponse): void => {\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n if (cors !== false && req.method === \"OPTIONS\") {\n setCors(res);\n res.writeHead(204);\n res.end();\n return;\n }\n if (!url.pathname.startsWith(base + \"/\")) {\n fail(res, 404, \"not found\");\n return;\n }\n const kind = url.pathname.slice(base.length + 1); // \"query\" | \"doc\"\n if (kind !== \"query\" && kind !== \"doc\") {\n fail(res, 404, \"not found\");\n return;\n }\n\n void (async () => {\n let ctx: RealtimeContext | null;\n try {\n ctx = await resolve(req);\n } catch (err) {\n fail(res, 401, (err as Error)?.message ?? \"unauthorized\");\n return;\n }\n if (!ctx) {\n fail(res, 401, \"unauthorized\");\n return;\n }\n\n const coll = url.searchParams.get(\"coll\");\n if (!coll) {\n fail(res, 400, \"missing coll\");\n return;\n }\n\n // Open the SSE stream.\n setCors(res);\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache, no-transform\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n });\n res.write(\": ok\\n\\n\");\n let id = 0;\n const send = (payload: unknown): void => {\n if (res.writableEnded) return;\n res.write(`id: ${++id}\\ndata: ${JSON.stringify(payload)}\\n\\n`);\n };\n\n let handle: WatchHandle<any> | undefined;\n try {\n const collection = ctx.db.collection(coll);\n if (kind === \"doc\") {\n const docId = url.searchParams.get(\"id\");\n if (!docId) {\n fail(res, 400, \"missing id\");\n return;\n }\n handle = collection.watchDoc(docId, (doc) => send({ doc }));\n } else {\n const q = url.searchParams.get(\"q\");\n const args: WatchArgs<any> = q ? JSON.parse(q) : {};\n handle = collection.watch(args, (event) => send(event));\n }\n } catch (err) {\n // Headers already sent; surface the error in-band, then close.\n send({ error: (err as Error)?.message ?? \"watch failed\" });\n res.end();\n return;\n }\n open.add(handle);\n\n const heartbeat = setInterval(() => {\n if (!res.writableEnded) res.write(\": ping\\n\\n\");\n }, heartbeatMs);\n (heartbeat as any).unref?.();\n\n const cleanup = (): void => {\n clearInterval(heartbeat);\n if (handle) {\n handle.stop();\n open.delete(handle);\n }\n };\n req.on(\"close\", cleanup);\n res.on(\"close\", cleanup);\n })();\n };\n\n return {\n handler,\n listen(port, cb) {\n const server = createServer(handler);\n server.listen(port, cb);\n return server;\n },\n get subscriptions() {\n return open.size;\n },\n close() {\n for (const h of open) h.stop();\n open.clear();\n },\n };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@monlite/realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Networked realtime for @monlite/core: stream live queries and documents to remote clients over SSE, backed by the change feed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./client": {
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/client.d.ts",
|
|
23
|
+
"default": "./dist/client.js"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/client.d.cts",
|
|
27
|
+
"default": "./dist/client.cjs"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"keywords": [
|
|
36
|
+
"monlite",
|
|
37
|
+
"realtime",
|
|
38
|
+
"live-query",
|
|
39
|
+
"sse",
|
|
40
|
+
"reactive",
|
|
41
|
+
"firebase",
|
|
42
|
+
"sqlite"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"author": "Emad Jumaah <emadjumaah@gmail.com>",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/qataruts/monlite.git",
|
|
49
|
+
"directory": "packages/realtime"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/qataruts/monlite/tree/main/packages/realtime#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/qataruts/monlite/issues"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@monlite/core": "^2.7.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^22.10.0",
|
|
66
|
+
"better-sqlite3": "^11.8.0",
|
|
67
|
+
"tsup": "^8.3.5",
|
|
68
|
+
"typescript": "^5.7.2",
|
|
69
|
+
"vitest": "^2.1.8"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsup",
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"typecheck": "tsc --noEmit"
|
|
75
|
+
}
|
|
76
|
+
}
|