@monlite/realtime 0.1.0 → 0.2.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/README.md CHANGED
@@ -64,6 +64,10 @@ stop(); // unsubscribe
64
64
  live.close(); // unsubscribe everything
65
65
  ```
66
66
 
67
+ Client options: `{ token, path, reconnectMs, fetch, onError }`. `onError` (default `console.error`)
68
+ receives any server-sent `{ error }` frame — e.g. a watch that failed server-side — so it is never
69
+ mis-delivered as a snapshot or a `null` document.
70
+
67
71
  ## How it works
68
72
 
69
73
  - One SSE stream per subscription; the query travels in the URL.
package/dist/client.cjs CHANGED
@@ -25,20 +25,27 @@ function connectRealtime(baseUrl, opts = {}) {
25
25
  const reader = res.body.getReader();
26
26
  const decoder = new TextDecoder();
27
27
  let buf = "";
28
+ const FRAME_SEP = /\r\n\r\n|\n\n|\r\r/;
28
29
  for (; ; ) {
29
30
  const { value, done } = await reader.read();
30
31
  if (done) break;
31
32
  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
- }
33
+ let m;
34
+ while (m = FRAME_SEP.exec(buf)) {
35
+ const frame = buf.slice(0, m.index);
36
+ buf = buf.slice(m.index + m[0].length);
37
+ const data = frame.split(/\r\n|\n|\r/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
38
+ if (!data) continue;
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(data);
42
+ } catch {
43
+ continue;
44
+ }
45
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
46
+ (opts.onError ?? ((e) => console.error("realtime:", e)))(parsed.error);
47
+ } else {
48
+ onMessage(parsed);
42
49
  }
43
50
  }
44
51
  }
@@ -1 +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.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"]}
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 /** Called for a server-sent `{ error }` frame. Defaults to `console.error`. */\n onError?: (error: unknown) => void;\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 // Tolerate LF, CRLF and CR frame/line separators (SSE permits all).\n const FRAME_SEP = /\\r\\n\\r\\n|\\n\\n|\\r\\r/;\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let m: RegExpExecArray | null;\n while ((m = FRAME_SEP.exec(buf))) {\n const frame = buf.slice(0, m.index);\n buf = buf.slice(m.index + m[0].length);\n const data = frame\n .split(/\\r\\n|\\n|\\r/)\n .filter((l) => l.startsWith(\"data:\"))\n .map((l) => l.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n if (!data) continue; // comment/heartbeat (\":\" lines) → ignore\n let parsed: any;\n try {\n parsed = JSON.parse(data);\n } catch {\n continue; // malformed frame\n }\n if (parsed && typeof parsed === \"object\" && \"error\" in parsed) {\n (\n opts.onError ??\n ((e: unknown) => console.error(\"realtime:\", e))\n )(parsed.error);\n } else {\n onMessage(parsed);\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/client.d.cts CHANGED
@@ -9,6 +9,8 @@ interface RealtimeClientOptions {
9
9
  reconnectMs?: number;
10
10
  /** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */
11
11
  fetch?: typeof fetch;
12
+ /** Called for a server-sent `{ error }` frame. Defaults to `console.error`. */
13
+ onError?: (error: unknown) => void;
12
14
  }
13
15
  /** Unsubscribe from a live stream. */
14
16
  type Unsubscribe = () => void;
package/dist/client.d.ts CHANGED
@@ -9,6 +9,8 @@ interface RealtimeClientOptions {
9
9
  reconnectMs?: number;
10
10
  /** Custom `fetch` (e.g. for Node < 18 or a proxy). Defaults to global `fetch`. */
11
11
  fetch?: typeof fetch;
12
+ /** Called for a server-sent `{ error }` frame. Defaults to `console.error`. */
13
+ onError?: (error: unknown) => void;
12
14
  }
13
15
  /** Unsubscribe from a live stream. */
14
16
  type Unsubscribe = () => void;
package/dist/client.js CHANGED
@@ -23,20 +23,27 @@ function connectRealtime(baseUrl, opts = {}) {
23
23
  const reader = res.body.getReader();
24
24
  const decoder = new TextDecoder();
25
25
  let buf = "";
26
+ const FRAME_SEP = /\r\n\r\n|\n\n|\r\r/;
26
27
  for (; ; ) {
27
28
  const { value, done } = await reader.read();
28
29
  if (done) break;
29
30
  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
- }
31
+ let m;
32
+ while (m = FRAME_SEP.exec(buf)) {
33
+ const frame = buf.slice(0, m.index);
34
+ buf = buf.slice(m.index + m[0].length);
35
+ const data = frame.split(/\r\n|\n|\r/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
36
+ if (!data) continue;
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(data);
40
+ } catch {
41
+ continue;
42
+ }
43
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
44
+ (opts.onError ?? ((e) => console.error("realtime:", e)))(parsed.error);
45
+ } else {
46
+ onMessage(parsed);
40
47
  }
41
48
  }
42
49
  }
@@ -1 +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"]}
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 /** Called for a server-sent `{ error }` frame. Defaults to `console.error`. */\n onError?: (error: unknown) => void;\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 // Tolerate LF, CRLF and CR frame/line separators (SSE permits all).\n const FRAME_SEP = /\\r\\n\\r\\n|\\n\\n|\\r\\r/;\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let m: RegExpExecArray | null;\n while ((m = FRAME_SEP.exec(buf))) {\n const frame = buf.slice(0, m.index);\n buf = buf.slice(m.index + m[0].length);\n const data = frame\n .split(/\\r\\n|\\n|\\r/)\n .filter((l) => l.startsWith(\"data:\"))\n .map((l) => l.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n if (!data) continue; // comment/heartbeat (\":\" lines) → ignore\n let parsed: any;\n try {\n parsed = JSON.parse(data);\n } catch {\n continue; // malformed frame\n }\n if (parsed && typeof parsed === \"object\" && \"error\" in parsed) {\n (\n opts.onError ??\n ((e: unknown) => console.error(\"realtime:\", e))\n )(parsed.error);\n } else {\n onMessage(parsed);\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 CHANGED
@@ -41,20 +41,57 @@ function realtime(options) {
41
41
  return;
42
42
  }
43
43
  void (async () => {
44
+ const coll = url.searchParams.get("coll");
45
+ if (!coll) {
46
+ fail(res, 400, "missing coll");
47
+ return;
48
+ }
49
+ const docId = kind === "doc" ? url.searchParams.get("id") : null;
50
+ if (kind === "doc" && !docId) {
51
+ fail(res, 400, "missing id");
52
+ return;
53
+ }
54
+ let args = {};
55
+ if (kind === "query") {
56
+ const q = url.searchParams.get("q");
57
+ if (q) {
58
+ try {
59
+ args = JSON.parse(q);
60
+ } catch {
61
+ fail(res, 400, "invalid q");
62
+ return;
63
+ }
64
+ }
65
+ }
66
+ let handle;
67
+ let heartbeat = void 0;
68
+ let cleaned = false;
69
+ const cleanup = () => {
70
+ if (cleaned) return;
71
+ cleaned = true;
72
+ if (heartbeat) clearInterval(heartbeat);
73
+ if (handle) {
74
+ handle.stop();
75
+ open.delete(handle);
76
+ }
77
+ };
78
+ req.on("close", cleanup);
79
+ res.on("close", cleanup);
44
80
  let ctx;
45
81
  try {
46
82
  ctx = await resolve(req);
47
83
  } catch (err) {
84
+ cleanup();
48
85
  fail(res, 401, err?.message ?? "unauthorized");
49
86
  return;
50
87
  }
51
88
  if (!ctx) {
89
+ cleanup();
52
90
  fail(res, 401, "unauthorized");
53
91
  return;
54
92
  }
55
- const coll = url.searchParams.get("coll");
56
- if (!coll) {
57
- fail(res, 400, "missing coll");
93
+ if (cleaned || req.destroyed || res.destroyed) {
94
+ cleanup();
58
95
  return;
59
96
  }
60
97
  setCors(res);
@@ -67,46 +104,30 @@ function realtime(options) {
67
104
  res.write(": ok\n\n");
68
105
  let id = 0;
69
106
  const send = (payload) => {
70
- if (res.writableEnded) return;
107
+ if (res.writableEnded || res.destroyed) return;
71
108
  res.write(`id: ${++id}
72
109
  data: ${JSON.stringify(payload)}
73
110
 
74
111
  `);
75
112
  };
76
- let handle;
77
113
  try {
78
114
  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
- }
115
+ handle = kind === "doc" ? collection.watchDoc(docId, (doc) => send({ doc })) : collection.watch(args, (event) => send(event));
91
116
  } catch (err) {
92
117
  send({ error: err?.message ?? "watch failed" });
93
118
  res.end();
119
+ cleanup();
120
+ return;
121
+ }
122
+ if (cleaned) {
123
+ handle.stop();
94
124
  return;
95
125
  }
96
126
  open.add(handle);
97
- const heartbeat = setInterval(() => {
98
- if (!res.writableEnded) res.write(": ping\n\n");
127
+ heartbeat = setInterval(() => {
128
+ if (!res.writableEnded && !res.destroyed) res.write(": ping\n\n");
99
129
  }, heartbeatMs);
100
130
  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
131
  })();
111
132
  };
112
133
  return {
@@ -1 +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"]}
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 // Validate params BEFORE auth/stream — a bad request is then a clean 400,\n // not an in-band error written onto an already-opened SSE stream.\n const coll = url.searchParams.get(\"coll\");\n if (!coll) {\n fail(res, 400, \"missing coll\");\n return;\n }\n const docId = kind === \"doc\" ? url.searchParams.get(\"id\") : null;\n if (kind === \"doc\" && !docId) {\n fail(res, 400, \"missing id\");\n return;\n }\n let args: WatchArgs<any> = {};\n if (kind === \"query\") {\n const q = url.searchParams.get(\"q\");\n if (q) {\n try {\n args = JSON.parse(q);\n } catch {\n fail(res, 400, \"invalid q\");\n return;\n }\n }\n }\n\n // Attach an idempotent cleanup BEFORE the (possibly async) authorize, so a\n // client that disconnects during auth still tears the subscription down —\n // otherwise the watch handle + heartbeat leak forever (unauthenticated DoS).\n let handle: WatchHandle<any> | undefined;\n // `cleanup` (attached below, before `await`) may read this while it's still\n // unset, so it must be a hoisted let seeded to undefined — not a const.\n let heartbeat: ReturnType<typeof setInterval> | undefined = undefined;\n let cleaned = false;\n const cleanup = (): void => {\n if (cleaned) return;\n cleaned = true;\n if (heartbeat) 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 let ctx: RealtimeContext | null;\n try {\n ctx = await resolve(req);\n } catch (err) {\n cleanup();\n fail(res, 401, (err as Error)?.message ?? \"unauthorized\");\n return;\n }\n if (!ctx) {\n cleanup();\n fail(res, 401, \"unauthorized\");\n return;\n }\n // Client may have disconnected during the await — bail before opening.\n if (cleaned || req.destroyed || res.destroyed) {\n cleanup();\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 || res.destroyed) return;\n res.write(`id: ${++id}\\ndata: ${JSON.stringify(payload)}\\n\\n`);\n };\n\n try {\n const collection = ctx.db.collection(coll);\n handle =\n kind === \"doc\"\n ? collection.watchDoc(docId as string, (doc) => send({ doc }))\n : collection.watch(args, (event) => send(event));\n } catch (err) {\n send({ error: (err as Error)?.message ?? \"watch failed\" });\n res.end();\n cleanup();\n return;\n }\n if (cleaned) {\n // Disconnected during setup — don't leave the handle registered.\n handle.stop();\n return;\n }\n open.add(handle);\n heartbeat = setInterval(() => {\n if (!res.writableEnded && !res.destroyed) res.write(\": ping\\n\\n\");\n }, heartbeatMs);\n (heartbeat as any).unref?.();\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.js CHANGED
@@ -39,20 +39,57 @@ function realtime(options) {
39
39
  return;
40
40
  }
41
41
  void (async () => {
42
+ const coll = url.searchParams.get("coll");
43
+ if (!coll) {
44
+ fail(res, 400, "missing coll");
45
+ return;
46
+ }
47
+ const docId = kind === "doc" ? url.searchParams.get("id") : null;
48
+ if (kind === "doc" && !docId) {
49
+ fail(res, 400, "missing id");
50
+ return;
51
+ }
52
+ let args = {};
53
+ if (kind === "query") {
54
+ const q = url.searchParams.get("q");
55
+ if (q) {
56
+ try {
57
+ args = JSON.parse(q);
58
+ } catch {
59
+ fail(res, 400, "invalid q");
60
+ return;
61
+ }
62
+ }
63
+ }
64
+ let handle;
65
+ let heartbeat = void 0;
66
+ let cleaned = false;
67
+ const cleanup = () => {
68
+ if (cleaned) return;
69
+ cleaned = true;
70
+ if (heartbeat) clearInterval(heartbeat);
71
+ if (handle) {
72
+ handle.stop();
73
+ open.delete(handle);
74
+ }
75
+ };
76
+ req.on("close", cleanup);
77
+ res.on("close", cleanup);
42
78
  let ctx;
43
79
  try {
44
80
  ctx = await resolve(req);
45
81
  } catch (err) {
82
+ cleanup();
46
83
  fail(res, 401, err?.message ?? "unauthorized");
47
84
  return;
48
85
  }
49
86
  if (!ctx) {
87
+ cleanup();
50
88
  fail(res, 401, "unauthorized");
51
89
  return;
52
90
  }
53
- const coll = url.searchParams.get("coll");
54
- if (!coll) {
55
- fail(res, 400, "missing coll");
91
+ if (cleaned || req.destroyed || res.destroyed) {
92
+ cleanup();
56
93
  return;
57
94
  }
58
95
  setCors(res);
@@ -65,46 +102,30 @@ function realtime(options) {
65
102
  res.write(": ok\n\n");
66
103
  let id = 0;
67
104
  const send = (payload) => {
68
- if (res.writableEnded) return;
105
+ if (res.writableEnded || res.destroyed) return;
69
106
  res.write(`id: ${++id}
70
107
  data: ${JSON.stringify(payload)}
71
108
 
72
109
  `);
73
110
  };
74
- let handle;
75
111
  try {
76
112
  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
- }
113
+ handle = kind === "doc" ? collection.watchDoc(docId, (doc) => send({ doc })) : collection.watch(args, (event) => send(event));
89
114
  } catch (err) {
90
115
  send({ error: err?.message ?? "watch failed" });
91
116
  res.end();
117
+ cleanup();
118
+ return;
119
+ }
120
+ if (cleaned) {
121
+ handle.stop();
92
122
  return;
93
123
  }
94
124
  open.add(handle);
95
- const heartbeat = setInterval(() => {
96
- if (!res.writableEnded) res.write(": ping\n\n");
125
+ heartbeat = setInterval(() => {
126
+ if (!res.writableEnded && !res.destroyed) res.write(": ping\n\n");
97
127
  }, heartbeatMs);
98
128
  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
129
  })();
109
130
  };
110
131
  return {
package/dist/index.js.map CHANGED
@@ -1 +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"]}
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 // Validate params BEFORE auth/stream — a bad request is then a clean 400,\n // not an in-band error written onto an already-opened SSE stream.\n const coll = url.searchParams.get(\"coll\");\n if (!coll) {\n fail(res, 400, \"missing coll\");\n return;\n }\n const docId = kind === \"doc\" ? url.searchParams.get(\"id\") : null;\n if (kind === \"doc\" && !docId) {\n fail(res, 400, \"missing id\");\n return;\n }\n let args: WatchArgs<any> = {};\n if (kind === \"query\") {\n const q = url.searchParams.get(\"q\");\n if (q) {\n try {\n args = JSON.parse(q);\n } catch {\n fail(res, 400, \"invalid q\");\n return;\n }\n }\n }\n\n // Attach an idempotent cleanup BEFORE the (possibly async) authorize, so a\n // client that disconnects during auth still tears the subscription down —\n // otherwise the watch handle + heartbeat leak forever (unauthenticated DoS).\n let handle: WatchHandle<any> | undefined;\n // `cleanup` (attached below, before `await`) may read this while it's still\n // unset, so it must be a hoisted let seeded to undefined — not a const.\n let heartbeat: ReturnType<typeof setInterval> | undefined = undefined;\n let cleaned = false;\n const cleanup = (): void => {\n if (cleaned) return;\n cleaned = true;\n if (heartbeat) 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 let ctx: RealtimeContext | null;\n try {\n ctx = await resolve(req);\n } catch (err) {\n cleanup();\n fail(res, 401, (err as Error)?.message ?? \"unauthorized\");\n return;\n }\n if (!ctx) {\n cleanup();\n fail(res, 401, \"unauthorized\");\n return;\n }\n // Client may have disconnected during the await — bail before opening.\n if (cleaned || req.destroyed || res.destroyed) {\n cleanup();\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 || res.destroyed) return;\n res.write(`id: ${++id}\\ndata: ${JSON.stringify(payload)}\\n\\n`);\n };\n\n try {\n const collection = ctx.db.collection(coll);\n handle =\n kind === \"doc\"\n ? collection.watchDoc(docId as string, (doc) => send({ doc }))\n : collection.watch(args, (event) => send(event));\n } catch (err) {\n send({ error: (err as Error)?.message ?? \"watch failed\" });\n res.end();\n cleanup();\n return;\n }\n if (cleaned) {\n // Disconnected during setup — don't leave the handle registered.\n handle.stop();\n return;\n }\n open.add(handle);\n heartbeat = setInterval(() => {\n if (!res.writableEnded && !res.destroyed) res.write(\": ping\\n\\n\");\n }, heartbeatMs);\n (heartbeat as any).unref?.();\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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monlite/realtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Networked realtime for @monlite/core: stream live queries and documents to remote clients over SSE, backed by the change feed.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -59,7 +59,7 @@
59
59
  "node": ">=18"
60
60
  },
61
61
  "dependencies": {
62
- "@monlite/core": "^2.7.0"
62
+ "@monlite/core": "^2.8.1"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^22.10.0",