@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 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.
@@ -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 };
@@ -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":"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","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"]}
@@ -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 };
@@ -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
+ }