@shadow-js/realtime 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/dev/adapter.d.ts +75 -0
  5. package/dist/dev/client/core.d.ts +24 -0
  6. package/dist/dev/client/core.js +845 -0
  7. package/dist/dev/client/core.js.map +1 -0
  8. package/dist/dev/client/index.d.ts +5 -0
  9. package/dist/dev/client/index.js +3 -0
  10. package/dist/dev/client/presence.js +23 -0
  11. package/dist/dev/client/presence.js.map +1 -0
  12. package/dist/dev/client/sse.js +76 -0
  13. package/dist/dev/client/sse.js.map +1 -0
  14. package/dist/dev/client/types.d.ts +256 -0
  15. package/dist/dev/client/util.js +30 -0
  16. package/dist/dev/client/util.js.map +1 -0
  17. package/dist/dev/client/ws.js +39 -0
  18. package/dist/dev/client/ws.js.map +1 -0
  19. package/dist/dev/cloudflare/adapter.d.ts +80 -0
  20. package/dist/dev/cloudflare/adapter.js +347 -0
  21. package/dist/dev/cloudflare/adapter.js.map +1 -0
  22. package/dist/dev/cloudflare/core.js +701 -0
  23. package/dist/dev/cloudflare/core.js.map +1 -0
  24. package/dist/dev/cloudflare/link.js +397 -0
  25. package/dist/dev/cloudflare/link.js.map +1 -0
  26. package/dist/dev/cloudflare/object.d.ts +82 -0
  27. package/dist/dev/cloudflare/object.js +214 -0
  28. package/dist/dev/cloudflare/object.js.map +1 -0
  29. package/dist/dev/cloudflare/types.d.ts +100 -0
  30. package/dist/dev/cloudflare/wire.js +284 -0
  31. package/dist/dev/cloudflare/wire.js.map +1 -0
  32. package/dist/dev/cloudflare.d.ts +4 -0
  33. package/dist/dev/cloudflare.js +3 -0
  34. package/dist/dev/errors.d.ts +25 -0
  35. package/dist/dev/errors.js +24 -0
  36. package/dist/dev/errors.js.map +1 -0
  37. package/dist/dev/hooks.d.ts +110 -0
  38. package/dist/dev/hooks.js +162 -0
  39. package/dist/dev/hooks.js.map +1 -0
  40. package/dist/dev/index.d.ts +4 -0
  41. package/dist/dev/index.js +3 -0
  42. package/dist/dev/memory.d.ts +33 -0
  43. package/dist/dev/memory.js +255 -0
  44. package/dist/dev/memory.js.map +1 -0
  45. package/dist/dev/protocol.d.ts +211 -0
  46. package/dist/dev/protocol.js +400 -0
  47. package/dist/dev/protocol.js.map +1 -0
  48. package/dist/dev/redis/client.d.ts +139 -0
  49. package/dist/dev/redis/client.js +146 -0
  50. package/dist/dev/redis/client.js.map +1 -0
  51. package/dist/dev/redis/scripts.js +275 -0
  52. package/dist/dev/redis/scripts.js.map +1 -0
  53. package/dist/dev/redis/topics.d.ts +8 -0
  54. package/dist/dev/redis/topics.js +377 -0
  55. package/dist/dev/redis/topics.js.map +1 -0
  56. package/dist/dev/redis.d.ts +68 -0
  57. package/dist/dev/redis.js +422 -0
  58. package/dist/dev/redis.js.map +1 -0
  59. package/dist/dev/server/http.d.ts +48 -0
  60. package/dist/dev/server/http.js +286 -0
  61. package/dist/dev/server/http.js.map +1 -0
  62. package/dist/dev/server/hub.d.ts +114 -0
  63. package/dist/dev/server/hub.js +827 -0
  64. package/dist/dev/server/hub.js.map +1 -0
  65. package/dist/dev/server/index.d.ts +47 -0
  66. package/dist/dev/server/index.js +41 -0
  67. package/dist/dev/server/index.js.map +1 -0
  68. package/dist/dev/server/limits.d.ts +54 -0
  69. package/dist/dev/server/limits.js +85 -0
  70. package/dist/dev/server/limits.js.map +1 -0
  71. package/dist/dev/server/rules.d.ts +43 -0
  72. package/dist/dev/server/rules.js +72 -0
  73. package/dist/dev/server/rules.js.map +1 -0
  74. package/dist/dev/testing.d.ts +115 -0
  75. package/dist/dev/testing.js +427 -0
  76. package/dist/dev/testing.js.map +1 -0
  77. package/dist/prod/client/core.js +2 -0
  78. package/dist/prod/client/core.js.map +1 -0
  79. package/dist/prod/client/index.js +1 -0
  80. package/dist/prod/client/presence.js +2 -0
  81. package/dist/prod/client/presence.js.map +1 -0
  82. package/dist/prod/client/sse.js +5 -0
  83. package/dist/prod/client/sse.js.map +1 -0
  84. package/dist/prod/client/util.js +2 -0
  85. package/dist/prod/client/util.js.map +1 -0
  86. package/dist/prod/client/ws.js +2 -0
  87. package/dist/prod/client/ws.js.map +1 -0
  88. package/dist/prod/cloudflare/adapter.js +3 -0
  89. package/dist/prod/cloudflare/adapter.js.map +1 -0
  90. package/dist/prod/cloudflare/core.js +2 -0
  91. package/dist/prod/cloudflare/core.js.map +1 -0
  92. package/dist/prod/cloudflare/link.js +2 -0
  93. package/dist/prod/cloudflare/link.js.map +1 -0
  94. package/dist/prod/cloudflare/object.js +2 -0
  95. package/dist/prod/cloudflare/object.js.map +1 -0
  96. package/dist/prod/cloudflare/wire.js +2 -0
  97. package/dist/prod/cloudflare/wire.js.map +1 -0
  98. package/dist/prod/cloudflare.js +1 -0
  99. package/dist/prod/errors.js +2 -0
  100. package/dist/prod/errors.js.map +1 -0
  101. package/dist/prod/hooks.js +2 -0
  102. package/dist/prod/hooks.js.map +1 -0
  103. package/dist/prod/index.js +1 -0
  104. package/dist/prod/memory.js +2 -0
  105. package/dist/prod/memory.js.map +1 -0
  106. package/dist/prod/protocol.js +2 -0
  107. package/dist/prod/protocol.js.map +1 -0
  108. package/dist/prod/redis/client.js +2 -0
  109. package/dist/prod/redis/client.js.map +1 -0
  110. package/dist/prod/redis/scripts.js +157 -0
  111. package/dist/prod/redis/scripts.js.map +1 -0
  112. package/dist/prod/redis/topics.js +2 -0
  113. package/dist/prod/redis/topics.js.map +1 -0
  114. package/dist/prod/redis.js +2 -0
  115. package/dist/prod/redis.js.map +1 -0
  116. package/dist/prod/server/http.js +2 -0
  117. package/dist/prod/server/http.js.map +1 -0
  118. package/dist/prod/server/hub.js +2 -0
  119. package/dist/prod/server/hub.js.map +1 -0
  120. package/dist/prod/server/index.js +2 -0
  121. package/dist/prod/server/index.js.map +1 -0
  122. package/dist/prod/server/limits.js +2 -0
  123. package/dist/prod/server/limits.js.map +1 -0
  124. package/dist/prod/server/rules.js +2 -0
  125. package/dist/prod/server/rules.js.map +1 -0
  126. package/dist/prod/testing.js +2 -0
  127. package/dist/prod/testing.js.map +1 -0
  128. package/package.json +105 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","names":[],"sources":["../../src/protocol.ts"],"sourcesContent":["import type { ErrorCode } from \"./errors\";\n\n/**\n * The wire protocol version this package speaks (https://realtime.shadowjs.net/guides/realtime-protocol/).\n * @example\n * import { PROTOCOL_VERSION } from \"@shadow-js/realtime\";\n *\n * const hello = JSON.stringify({ t: \"hello\", v: PROTOCOL_VERSION });\n * @since 1.0.0\n */\nexport const PROTOCOL_VERSION: 1 = 1;\n\n/**\n * Any JSON value.\n * @since 1.0.0\n */\nexport type Json =\n | null\n | boolean\n | number\n | string\n | readonly Json[]\n | { readonly [key: string]: Json };\n\n/**\n * One member of a channel's presence set: the connection that joined, its user, its state, and the version of that state.\n * @since 1.0.0\n */\nexport interface Member<State = Json> {\n /** The connection id. */\n readonly id: string;\n /** The user of the connection's identity, when it has one. */\n readonly u?: string;\n /** The member's state. */\n readonly s: State;\n /** A counter that rises with every state change of this member, from 1. */\n readonly v: number;\n}\n\n/** A message as stored by an adapter: the data is its JSON text. @since 1.0.0 */\nexport interface StoredMessage {\n readonly seq: number;\n readonly event: string;\n /** The JSON text of the data. */\n readonly data: string;\n /** The publishing connection id, when a connection published it. */\n readonly publisher?: string;\n /** The publishing user, when it has one. */\n readonly user?: string;\n /** When the adapter accepted it, in epoch milliseconds. */\n readonly at: number;\n}\n\n/** What a publisher supplies; the adapter adds `seq` and `at`. @since 1.0.0 */\nexport type StoredEntry = Omit<StoredMessage, \"seq\" | \"at\">;\n\n/** A change of a presence set, as adapters deliver it. @since 1.0.0 */\nexport interface PresenceEvent {\n readonly channel: string;\n readonly op: \"join\" | \"set\" | \"leave\";\n readonly member: Member;\n}\n\n/** Frames a client sends. @since 1.0.0 */\nexport type ClientFrame =\n | { readonly t: \"hello\"; readonly v: number }\n | {\n readonly t: \"sub\";\n readonly n: number;\n readonly c: string;\n readonly after?: number;\n readonly echo?: boolean;\n readonly presence?: Json;\n readonly hasPresence: boolean;\n readonly watch?: boolean;\n }\n | { readonly t: \"unsub\"; readonly n?: number; readonly c: string }\n | {\n readonly t: \"pub\";\n readonly c: string;\n readonly e: string;\n readonly d: string;\n readonly n?: number;\n readonly k?: string;\n }\n | {\n readonly t: \"req\";\n readonly n: number;\n readonly c: string;\n readonly e: string;\n readonly d: Json;\n readonly ms?: number;\n }\n | { readonly t: \"pres\"; readonly c: string; readonly s: Json }\n | { readonly t: \"ping\"; readonly n?: number };\n\n/** What decoding a client frame yields. @since 1.0.0 */\nexport type DecodedClientFrame =\n | { readonly ok: true; readonly frame: ClientFrame }\n | { readonly ok: false; readonly code: ErrorCode; readonly n?: number; readonly why: string };\n\nconst CHANNEL = /^[A-Za-z0-9_.:/@-]{1,128}$/;\nconst EVENT = /^[A-Za-z0-9_.:@-]{1,64}$/;\nconst KEY = /^[A-Za-z0-9_.:@-]{1,64}$/;\nconst encoder = new TextEncoder();\n\n/**\n * Whether `value` is a valid channel name: 1..128 characters of `A-Za-z0-9_.:/@-`, not starting with the reserved `$`.\n * @param value The candidate.\n * @returns True for a valid name.\n * @example\n * import { isChannelName } from \"@shadow-js/realtime\";\n *\n * isChannelName(\"chat:lobby\"); // true\n * isChannelName(\"chat lobby\"); // false\n * @since 1.0.0\n */\nexport function isChannelName(value: unknown): value is string {\n return typeof value === \"string\" && CHANNEL.test(value);\n}\n\n/**\n * Whether `value` is a valid event name: 1..64 characters of `A-Za-z0-9_.:@-`.\n * @param value The candidate.\n * @returns True for a valid name.\n * @example\n * import { isEventName } from \"@shadow-js/realtime\";\n *\n * isEventName(\"message\"); // true\n * isEventName(\"a/b\"); // false\n * @since 1.0.0\n */\nexport function isEventName(value: unknown): value is string {\n return typeof value === \"string\" && EVENT.test(value);\n}\n\n/** Whether `value` is a valid idempotency key. @internal */\nexport function isIdempotencyKey(value: unknown): value is string {\n return typeof value === \"string\" && KEY.test(value);\n}\n\n/** The number of bytes of `text` in UTF-8, without encoding the common cases. @internal */\nexport function byteLength(text: string): number {\n // Every UTF-16 unit is at most 3 bytes in UTF-8, and at least 1.\n if (text.length * 3 <= 0x7fffffff) {\n let ascii = true;\n for (let at = 0; at < text.length; at += 1) {\n if (text.charCodeAt(at) > 0x7f) {\n ascii = false;\n break;\n }\n }\n if (ascii) return text.length;\n }\n return encoder.encode(text).length;\n}\n\nfunction isCount(value: unknown): value is number {\n return typeof value === \"number\" && Number.isSafeInteger(value) && value >= 0;\n}\n\ntype Refusal = Extract<DecodedClientFrame, { ok: false }>;\n\nfunction bad(why: string, n?: unknown): Refusal {\n return isCount(n)\n ? { ok: false, code: \"bad_frame\", n, why }\n : { ok: false, code: \"bad_frame\", why };\n}\n\n/**\n * Decodes and validates one client frame (server side). Never throws: any text yields a frame or a refusal.\n *\n * @param text The frame text.\n * @param maxBytes The largest accepted frame, in UTF-8 bytes.\n * @returns The frame, or the code and reason to refuse it with.\n * @example\n * import { decodeClientFrame } from \"@shadow-js/realtime\";\n *\n * const result = decodeClientFrame('{\"t\":\"ping\"}', 65_536);\n * result.ok; // true\n * @since 1.0.0\n */\nexport function decodeClientFrame(text: string, maxBytes: number): DecodedClientFrame {\n if (byteLength(text) > maxBytes)\n return { ok: false, code: \"too_large\", why: \"the frame is larger than the limit\" };\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch {\n return bad(\"the frame is not JSON\");\n }\n if (value === null || typeof value !== \"object\" || Array.isArray(value))\n return bad(\"the frame is not an object\");\n const raw = value as Record<string, unknown>;\n const type = raw[\"t\"];\n const n = raw[\"n\"];\n if (n !== undefined && !isCount(n)) return bad(\"n must be a non-negative integer\");\n const c = raw[\"c\"];\n const needChannel = (): Refusal | undefined =>\n isChannelName(c)\n ? undefined\n : { ...bad(\"c is not a valid channel name\", n), code: \"bad_channel\" };\n switch (type) {\n case \"hello\": {\n const v = raw[\"v\"];\n if (typeof v !== \"number\" || !Number.isInteger(v) || v < 1) return bad(\"v must be a version\");\n return { ok: true, frame: { t: \"hello\", v } };\n }\n case \"ping\":\n return { ok: true, frame: n === undefined ? { t: \"ping\" } : { t: \"ping\", n } };\n case \"sub\": {\n const refusal = needChannel();\n if (refusal !== undefined) return refusal;\n if (n === undefined) return bad(\"sub needs n\");\n const after = raw[\"after\"];\n if (after !== undefined && !isCount(after))\n return bad(\"after must be a non-negative integer\", n);\n const echo = raw[\"echo\"];\n if (echo !== undefined && typeof echo !== \"boolean\") return bad(\"echo must be a boolean\", n);\n const watch = raw[\"watch\"];\n if (watch !== undefined && typeof watch !== \"boolean\")\n return bad(\"watch must be a boolean\", n);\n const hasPresence = \"presence\" in raw;\n return {\n ok: true,\n frame: {\n t: \"sub\",\n n,\n c: c as string,\n hasPresence,\n ...(after === undefined ? {} : { after }),\n ...(echo === undefined ? {} : { echo }),\n ...(watch === undefined ? {} : { watch }),\n ...(hasPresence ? { presence: raw[\"presence\"] as Json } : {}),\n },\n };\n }\n case \"unsub\": {\n const refusal = needChannel();\n if (refusal !== undefined) return refusal;\n return {\n ok: true,\n frame: n === undefined ? { t: \"unsub\", c: c as string } : { t: \"unsub\", n, c: c as string },\n };\n }\n case \"pub\": {\n const refusal = needChannel();\n if (refusal !== undefined) return refusal;\n const e = raw[\"e\"];\n if (!isEventName(e)) return bad(\"e is not a valid event name\", n);\n const k = raw[\"k\"];\n if (k !== undefined && !isIdempotencyKey(k)) return bad(\"k is not a valid key\", n);\n const data = JSON.stringify(raw[\"d\"] ?? null);\n return {\n ok: true,\n frame: {\n t: \"pub\",\n c: c as string,\n e,\n d: data,\n ...(n === undefined ? {} : { n }),\n ...(k === undefined ? {} : { k }),\n },\n };\n }\n case \"req\": {\n const refusal = needChannel();\n if (refusal !== undefined) return refusal;\n if (n === undefined) return bad(\"req needs n\");\n const e = raw[\"e\"];\n if (!isEventName(e)) return bad(\"e is not a valid event name\", n);\n const ms = raw[\"ms\"];\n if (ms !== undefined && !(typeof ms === \"number\" && Number.isFinite(ms) && ms > 0))\n return bad(\"ms must be a positive number\", n);\n return {\n ok: true,\n frame: {\n t: \"req\",\n n,\n c: c as string,\n e,\n d: (raw[\"d\"] ?? null) as Json,\n ...(ms === undefined ? {} : { ms }),\n },\n };\n }\n case \"pres\": {\n const refusal = needChannel();\n if (refusal !== undefined) return refusal;\n return { ok: true, frame: { t: \"pres\", c: c as string, s: (raw[\"s\"] ?? null) as Json } };\n }\n default:\n return bad(\"unknown frame type\", n);\n }\n}\n\n/** Frames a server sends, as a client reads them. @since 1.0.0 */\nexport type ServerFrame =\n | {\n readonly t: \"ready\";\n readonly v: number;\n readonly id: string;\n readonly hb: number;\n readonly max: number;\n }\n | {\n readonly t: \"subbed\";\n readonly n: number;\n readonly c: string;\n readonly head: number;\n readonly gap?: boolean;\n readonly reset?: boolean;\n }\n | { readonly t: \"unsubbed\"; readonly c: string; readonly why?: string }\n | {\n readonly t: \"msg\";\n readonly c: string;\n readonly s: number;\n readonly e: string;\n readonly d: Json;\n readonly p?: string;\n readonly u?: string;\n readonly r?: 1;\n }\n | { readonly t: \"ack\"; readonly n: number; readonly s: number; readonly dup?: boolean }\n | { readonly t: \"rep\"; readonly n: number; readonly d: Json }\n | {\n readonly t: \"err\";\n readonly n?: number;\n readonly c?: string;\n readonly code: ErrorCode;\n readonly msg?: string;\n }\n | {\n readonly t: \"pres\";\n readonly c: string;\n readonly op: \"sync\" | \"join\" | \"set\" | \"leave\";\n readonly m: Member | readonly Member[];\n }\n | { readonly t: \"pong\"; readonly n?: number }\n | { readonly t: \"bye\"; readonly code: number; readonly why: string };\n\n/**\n * Decodes one server frame (client side). Returns `undefined` for anything that is not a known, well-formed frame, so a\n * client ignores what a newer server may send and never acts on a malformed frame.\n *\n * @param text The frame text.\n * @returns The frame, or undefined.\n * @example\n * import { decodeServerFrame } from \"@shadow-js/realtime\";\n *\n * decodeServerFrame('{\"t\":\"pong\"}'); // { t: \"pong\" }\n * decodeServerFrame(\"not json\"); // undefined\n * @since 1.0.0\n */\nexport function decodeServerFrame(text: string): ServerFrame | undefined {\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch {\n return undefined;\n }\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n const raw = value as Record<string, unknown>;\n const str = (key: string): string | undefined => {\n const item = raw[key];\n return typeof item === \"string\" ? item : undefined;\n };\n const num = (key: string): number | undefined => {\n const item = raw[key];\n return isCount(item) ? item : undefined;\n };\n switch (raw[\"t\"]) {\n case \"ready\": {\n const v = num(\"v\");\n const id = str(\"id\");\n if (v === undefined || id === undefined) return undefined;\n return { t: \"ready\", v, id, hb: num(\"hb\") ?? 30_000, max: num(\"max\") ?? 65_536 };\n }\n case \"subbed\": {\n const n = num(\"n\");\n const c = str(\"c\");\n const head = num(\"head\");\n if (n === undefined || c === undefined || head === undefined) return undefined;\n return {\n t: \"subbed\",\n n,\n c,\n head,\n ...(raw[\"gap\"] === true ? { gap: true } : {}),\n ...(raw[\"reset\"] === true ? { reset: true } : {}),\n };\n }\n case \"unsubbed\": {\n const c = str(\"c\");\n if (c === undefined) return undefined;\n const why = str(\"why\");\n return why === undefined ? { t: \"unsubbed\", c } : { t: \"unsubbed\", c, why };\n }\n case \"msg\": {\n const c = str(\"c\");\n const s = num(\"s\");\n const e = str(\"e\");\n if (c === undefined || s === undefined || e === undefined || s < 1) return undefined;\n const p = str(\"p\");\n const u = str(\"u\");\n return {\n t: \"msg\",\n c,\n s,\n e,\n d: raw[\"d\"] as Json,\n ...(p === undefined ? {} : { p }),\n ...(u === undefined ? {} : { u }),\n ...(raw[\"r\"] === 1 ? { r: 1 as const } : {}),\n };\n }\n case \"ack\": {\n const n = num(\"n\");\n const s = num(\"s\");\n if (n === undefined || s === undefined) return undefined;\n return { t: \"ack\", n, s, ...(raw[\"dup\"] === true ? { dup: true } : {}) };\n }\n case \"rep\": {\n const n = num(\"n\");\n if (n === undefined) return undefined;\n return { t: \"rep\", n, d: raw[\"d\"] as Json };\n }\n case \"err\": {\n const code = str(\"code\");\n if (code === undefined) return undefined;\n const n = num(\"n\");\n const c = str(\"c\");\n const msg = str(\"msg\");\n return {\n t: \"err\",\n code: code as ErrorCode,\n ...(n === undefined ? {} : { n }),\n ...(c === undefined ? {} : { c }),\n ...(msg === undefined ? {} : { msg }),\n };\n }\n case \"pres\": {\n const c = str(\"c\");\n const op = raw[\"op\"];\n const m = raw[\"m\"];\n if (c === undefined || (op !== \"sync\" && op !== \"join\" && op !== \"set\" && op !== \"leave\"))\n return undefined;\n const member = (item: unknown): Member | undefined => {\n if (item === null || typeof item !== \"object\") return undefined;\n const entry = item as Record<string, unknown>;\n if (typeof entry[\"id\"] !== \"string\" || !isCount(entry[\"v\"])) return undefined;\n return {\n id: entry[\"id\"],\n v: entry[\"v\"],\n s: (entry[\"s\"] ?? null) as Json,\n ...(typeof entry[\"u\"] === \"string\" ? { u: entry[\"u\"] } : {}),\n };\n };\n if (op === \"sync\") {\n if (!Array.isArray(m)) return undefined;\n const list = m.map(member);\n if (list.some((item) => item === undefined)) return undefined;\n return { t: \"pres\", c, op, m: list as Member[] };\n }\n const one = member(m);\n return one === undefined ? undefined : { t: \"pres\", c, op, m: one };\n }\n case \"pong\": {\n const n = num(\"n\");\n return n === undefined ? { t: \"pong\" } : { t: \"pong\", n };\n }\n case \"bye\": {\n const code = num(\"code\");\n return code === undefined ? undefined : { t: \"bye\", code, why: str(\"why\") ?? \"\" };\n }\n default:\n return undefined;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,139 @@
1
+ //#region src/redis/client.d.ts
2
+ /**
3
+ * What the Redis adapter needs from a Redis library, and the two wrappers that adapt the common ones. The adapter has no
4
+ * dependency on a Redis package: it is written against these small interfaces, which ioredis and node-redis satisfy through
5
+ * {@link fromIoredis} and {@link fromNodeRedis}, and which anything else (Upstash over TCP, a mock, your own pool) can implement.
6
+ */
7
+ /**
8
+ * A connection that runs commands. Commands issued without waiting for the previous one are pipelined by the library, which the
9
+ * adapter relies on for throughput.
10
+ *
11
+ * @example
12
+ * import type { RedisClient } from "@shadow-js/realtime/redis";
13
+ *
14
+ * declare function send(args: string[]): Promise<unknown>;
15
+ * const client: RedisClient = { sendCommand: (args) => send([...args]) };
16
+ * @since 1.0.0
17
+ */
18
+ export interface RedisClient {
19
+ /**
20
+ * Runs one command (`["EVALSHA", sha, "1", key, ...]`) and resolves with its reply: a string (or bytes) for bulk and status
21
+ * replies, a number for integers, `null` for nil, an array for multi-bulk. Rejects with the server's error (its message starts
22
+ * with the error code, for example `NOSCRIPT`) or with the transport's error.
23
+ */
24
+ sendCommand(args: readonly string[]): Promise<unknown>;
25
+ /**
26
+ * Opens a dedicated connection for pub/sub, used when {@link RedisAdapterOptions.subscriber} is not given. The adapter owns it
27
+ * and closes it with {@link RedisSubscriber.close}.
28
+ */
29
+ createSubscriber?(): Promise<RedisSubscriber> | RedisSubscriber;
30
+ }
31
+ /**
32
+ * A connection in subscriber mode.
33
+ *
34
+ * @example
35
+ * import type { RedisSubscriber } from "@shadow-js/realtime/redis";
36
+ *
37
+ * declare const subscriber: RedisSubscriber;
38
+ * await subscriber.subscribe("rt:{room}:c", (message) => message.length);
39
+ * await subscriber.unsubscribe("rt:{room}:c");
40
+ * @since 1.0.0
41
+ */
42
+ export interface RedisSubscriber {
43
+ /**
44
+ * Subscribes to a topic. Resolves once the server confirmed. Calling it again for a topic already subscribed replaces the
45
+ * listener and does not deliver twice.
46
+ */
47
+ subscribe(topic: string, listener: (message: string) => void): Promise<void>;
48
+ /** Stops listening to a topic. The listener is removed before the returned promise settles. */
49
+ unsubscribe(topic: string): Promise<void>;
50
+ /**
51
+ * Registers a function to call whenever the connection was lost and came back (not for the first connection). The adapter
52
+ * subscribes its topics again and recovers what was published in between from history. Returns a function that removes the
53
+ * registration.
54
+ */
55
+ onReconnect(handler: () => void): () => void;
56
+ /** Registers a function that receives the connection's errors, so none goes unhandled. Returns a function that removes it. */
57
+ onError?(handler: (error: unknown) => void): () => void;
58
+ /** Closes the connection. Called only for a subscriber the adapter created itself. */
59
+ close?(): Promise<void>;
60
+ }
61
+ /**
62
+ * The part of an ioredis connection that {@link fromIoredis} uses. The real `Redis` and `Cluster` classes fit.
63
+ * @since 1.0.0
64
+ */
65
+ export interface IoredisLike {
66
+ call(command: string, ...args: string[]): Promise<unknown>;
67
+ duplicate(): IoredisSubscriberLike;
68
+ }
69
+ /**
70
+ * The part of a duplicated ioredis connection that {@link fromIoredis} uses in subscriber mode.
71
+ * @since 1.0.0
72
+ */
73
+ export interface IoredisSubscriberLike {
74
+ subscribe(...topics: string[]): Promise<unknown>;
75
+ unsubscribe(...topics: string[]): Promise<unknown>;
76
+ on(event: string, listener: (...args: never[]) => void): unknown;
77
+ off?(event: string, listener: (...args: never[]) => void): unknown;
78
+ removeListener?(event: string, listener: (...args: never[]) => void): unknown;
79
+ quit(): Promise<unknown>;
80
+ disconnect?(): void;
81
+ }
82
+ /**
83
+ * The part of a node-redis (v4 or v5) client that {@link fromNodeRedis} uses.
84
+ * @since 1.0.0
85
+ */
86
+ export interface NodeRedisLike {
87
+ sendCommand(args: string[]): Promise<unknown>;
88
+ duplicate(): NodeRedisSubscriberLike;
89
+ }
90
+ /**
91
+ * The part of a duplicated node-redis client that {@link fromNodeRedis} uses in subscriber mode.
92
+ * @since 1.0.0
93
+ */
94
+ export interface NodeRedisSubscriberLike {
95
+ connect(): Promise<unknown>;
96
+ subscribe(topic: string, listener: (message: string, topic: string) => void): Promise<unknown>;
97
+ unsubscribe(topic: string): Promise<unknown>;
98
+ on(event: string, listener: (...args: never[]) => void): unknown;
99
+ off?(event: string, listener: (...args: never[]) => void): unknown;
100
+ removeListener?(event: string, listener: (...args: never[]) => void): unknown;
101
+ close?(): Promise<unknown>;
102
+ quit?(): Promise<unknown>;
103
+ disconnect?(): Promise<unknown>;
104
+ }
105
+ /**
106
+ * Adapts an ioredis connection (`Redis` or `Cluster`). Commands go through `call`; the subscriber is a `duplicate()` of the
107
+ * connection, which ioredis re-subscribes by itself after a reconnect (the adapter then recovers what was missed).
108
+ *
109
+ * @param client The ioredis connection.
110
+ * @returns A {@link RedisClient} for `redisAdapter`.
111
+ * @example
112
+ * import { Redis } from "ioredis";
113
+ * import { fromIoredis, redisAdapter } from "@shadow-js/realtime/redis";
114
+ *
115
+ * const adapter = redisAdapter({ client: fromIoredis(new Redis(process.env.REDIS_URL ?? "")) });
116
+ * await adapter.close();
117
+ * @since 1.0.0
118
+ */
119
+ export declare function fromIoredis(client: IoredisLike): RedisClient;
120
+ /**
121
+ * Adapts a node-redis (v4 or v5) client. Commands go through `sendCommand`; the subscriber is a `duplicate()` that is connected
122
+ * here and that node-redis re-subscribes by itself after a reconnect (the adapter then recovers what was missed). Pass a
123
+ * standalone client; node-redis cluster clients route by key and need their own wrapper.
124
+ *
125
+ * @param client The node-redis client (already connected).
126
+ * @returns A {@link RedisClient} for `redisAdapter`.
127
+ * @example
128
+ * import { createClient } from "redis";
129
+ * import { fromNodeRedis, redisAdapter } from "@shadow-js/realtime/redis";
130
+ *
131
+ * const client = createClient({ url: process.env.REDIS_URL ?? "" });
132
+ * await client.connect();
133
+ * const adapter = redisAdapter({ client: fromNodeRedis(client) });
134
+ * await adapter.close();
135
+ * @since 1.0.0
136
+ */
137
+ export declare function fromNodeRedis(client: NodeRedisLike): RedisClient;
138
+ //#endregion
139
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,146 @@
1
+ //#region src/redis/client.ts
2
+ /** A set of handlers with add-and-remove, shared by the two wrappers. */
3
+ function handlers() {
4
+ const set = /* @__PURE__ */ new Set();
5
+ return {
6
+ add(handler) {
7
+ set.add(handler);
8
+ return () => {
9
+ set.delete(handler);
10
+ };
11
+ },
12
+ fire(...args) {
13
+ for (const handler of [...set]) try {
14
+ handler(...args);
15
+ } catch {}
16
+ }
17
+ };
18
+ }
19
+ /**
20
+ * Adapts an ioredis connection (`Redis` or `Cluster`). Commands go through `call`; the subscriber is a `duplicate()` of the
21
+ * connection, which ioredis re-subscribes by itself after a reconnect (the adapter then recovers what was missed).
22
+ *
23
+ * @param client The ioredis connection.
24
+ * @returns A {@link RedisClient} for `redisAdapter`.
25
+ * @example
26
+ * import { Redis } from "ioredis";
27
+ * import { fromIoredis, redisAdapter } from "@shadow-js/realtime/redis";
28
+ *
29
+ * const adapter = redisAdapter({ client: fromIoredis(new Redis(process.env.REDIS_URL ?? "")) });
30
+ * await adapter.close();
31
+ * @since 1.0.0
32
+ */
33
+ function fromIoredis(client) {
34
+ return {
35
+ sendCommand: (args) => {
36
+ const [command, ...rest] = args;
37
+ return client.call(command ?? "", ...rest);
38
+ },
39
+ createSubscriber: () => {
40
+ const connection = client.duplicate();
41
+ const topics = /* @__PURE__ */ new Map();
42
+ const reconnects = handlers();
43
+ const errors = handlers();
44
+ let ready = 0;
45
+ connection.on("message", ((topic, message) => {
46
+ topics.get(topic)?.(message);
47
+ }));
48
+ connection.on("ready", (() => {
49
+ ready += 1;
50
+ if (ready > 1) reconnects.fire();
51
+ }));
52
+ connection.on("error", ((error) => errors.fire(error)));
53
+ return {
54
+ async subscribe(topic, listener) {
55
+ topics.set(topic, listener);
56
+ await connection.subscribe(topic);
57
+ },
58
+ async unsubscribe(topic) {
59
+ topics.delete(topic);
60
+ await connection.unsubscribe(topic);
61
+ },
62
+ onReconnect: (handler) => reconnects.add(handler),
63
+ onError: (handler) => errors.add(handler),
64
+ async close() {
65
+ topics.clear();
66
+ try {
67
+ await connection.quit();
68
+ } catch {
69
+ connection.disconnect?.();
70
+ }
71
+ }
72
+ };
73
+ }
74
+ };
75
+ }
76
+ /**
77
+ * Adapts a node-redis (v4 or v5) client. Commands go through `sendCommand`; the subscriber is a `duplicate()` that is connected
78
+ * here and that node-redis re-subscribes by itself after a reconnect (the adapter then recovers what was missed). Pass a
79
+ * standalone client; node-redis cluster clients route by key and need their own wrapper.
80
+ *
81
+ * @param client The node-redis client (already connected).
82
+ * @returns A {@link RedisClient} for `redisAdapter`.
83
+ * @example
84
+ * import { createClient } from "redis";
85
+ * import { fromNodeRedis, redisAdapter } from "@shadow-js/realtime/redis";
86
+ *
87
+ * const client = createClient({ url: process.env.REDIS_URL ?? "" });
88
+ * await client.connect();
89
+ * const adapter = redisAdapter({ client: fromNodeRedis(client) });
90
+ * await adapter.close();
91
+ * @since 1.0.0
92
+ */
93
+ function fromNodeRedis(client) {
94
+ return {
95
+ sendCommand: (args) => client.sendCommand([...args]),
96
+ createSubscriber: async () => {
97
+ const connection = client.duplicate();
98
+ const topics = /* @__PURE__ */ new Map();
99
+ const dispatch = (message, topic) => {
100
+ topics.get(topic)?.(message);
101
+ };
102
+ const reconnects = handlers();
103
+ const errors = handlers();
104
+ let ready = 0;
105
+ connection.on("ready", (() => {
106
+ ready += 1;
107
+ if (ready > 1) reconnects.fire();
108
+ }));
109
+ connection.on("error", ((error) => errors.fire(error)));
110
+ await connection.connect();
111
+ const registered = /* @__PURE__ */ new Set();
112
+ return {
113
+ async subscribe(topic, listener) {
114
+ topics.set(topic, listener);
115
+ if (registered.has(topic)) return;
116
+ registered.add(topic);
117
+ try {
118
+ await connection.subscribe(topic, dispatch);
119
+ } catch (error) {
120
+ registered.delete(topic);
121
+ topics.delete(topic);
122
+ throw error;
123
+ }
124
+ },
125
+ async unsubscribe(topic) {
126
+ topics.delete(topic);
127
+ if (!registered.delete(topic)) return;
128
+ await connection.unsubscribe(topic);
129
+ },
130
+ onReconnect: (handler) => reconnects.add(handler),
131
+ onError: (handler) => errors.add(handler),
132
+ async close() {
133
+ topics.clear();
134
+ registered.clear();
135
+ if (connection.close !== void 0) await connection.close();
136
+ else if (connection.quit !== void 0) await connection.quit();
137
+ else await connection.disconnect?.();
138
+ }
139
+ };
140
+ }
141
+ };
142
+ }
143
+ //#endregion
144
+ export { fromIoredis, fromNodeRedis };
145
+
146
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":[],"sources":["../../../src/redis/client.ts"],"sourcesContent":["/**\n * What the Redis adapter needs from a Redis library, and the two wrappers that adapt the common ones. The adapter has no\n * dependency on a Redis package: it is written against these small interfaces, which ioredis and node-redis satisfy through\n * {@link fromIoredis} and {@link fromNodeRedis}, and which anything else (Upstash over TCP, a mock, your own pool) can implement.\n */\n\n/**\n * A connection that runs commands. Commands issued without waiting for the previous one are pipelined by the library, which the\n * adapter relies on for throughput.\n *\n * @example\n * import type { RedisClient } from \"@shadow-js/realtime/redis\";\n *\n * declare function send(args: string[]): Promise<unknown>;\n * const client: RedisClient = { sendCommand: (args) => send([...args]) };\n * @since 1.0.0\n */\nexport interface RedisClient {\n /**\n * Runs one command (`[\"EVALSHA\", sha, \"1\", key, ...]`) and resolves with its reply: a string (or bytes) for bulk and status\n * replies, a number for integers, `null` for nil, an array for multi-bulk. Rejects with the server's error (its message starts\n * with the error code, for example `NOSCRIPT`) or with the transport's error.\n */\n sendCommand(args: readonly string[]): Promise<unknown>;\n /**\n * Opens a dedicated connection for pub/sub, used when {@link RedisAdapterOptions.subscriber} is not given. The adapter owns it\n * and closes it with {@link RedisSubscriber.close}.\n */\n createSubscriber?(): Promise<RedisSubscriber> | RedisSubscriber;\n}\n\n/**\n * A connection in subscriber mode.\n *\n * @example\n * import type { RedisSubscriber } from \"@shadow-js/realtime/redis\";\n *\n * declare const subscriber: RedisSubscriber;\n * await subscriber.subscribe(\"rt:{room}:c\", (message) => message.length);\n * await subscriber.unsubscribe(\"rt:{room}:c\");\n * @since 1.0.0\n */\nexport interface RedisSubscriber {\n /**\n * Subscribes to a topic. Resolves once the server confirmed. Calling it again for a topic already subscribed replaces the\n * listener and does not deliver twice.\n */\n subscribe(topic: string, listener: (message: string) => void): Promise<void>;\n /** Stops listening to a topic. The listener is removed before the returned promise settles. */\n unsubscribe(topic: string): Promise<void>;\n /**\n * Registers a function to call whenever the connection was lost and came back (not for the first connection). The adapter\n * subscribes its topics again and recovers what was published in between from history. Returns a function that removes the\n * registration.\n */\n onReconnect(handler: () => void): () => void;\n /** Registers a function that receives the connection's errors, so none goes unhandled. Returns a function that removes it. */\n onError?(handler: (error: unknown) => void): () => void;\n /** Closes the connection. Called only for a subscriber the adapter created itself. */\n close?(): Promise<void>;\n}\n\n/**\n * The part of an ioredis connection that {@link fromIoredis} uses. The real `Redis` and `Cluster` classes fit.\n * @since 1.0.0\n */\nexport interface IoredisLike {\n call(command: string, ...args: string[]): Promise<unknown>;\n duplicate(): IoredisSubscriberLike;\n}\n\n/**\n * The part of a duplicated ioredis connection that {@link fromIoredis} uses in subscriber mode.\n * @since 1.0.0\n */\nexport interface IoredisSubscriberLike {\n subscribe(...topics: string[]): Promise<unknown>;\n unsubscribe(...topics: string[]): Promise<unknown>;\n on(event: string, listener: (...args: never[]) => void): unknown;\n off?(event: string, listener: (...args: never[]) => void): unknown;\n removeListener?(event: string, listener: (...args: never[]) => void): unknown;\n quit(): Promise<unknown>;\n disconnect?(): void;\n}\n\n/**\n * The part of a node-redis (v4 or v5) client that {@link fromNodeRedis} uses.\n * @since 1.0.0\n */\nexport interface NodeRedisLike {\n sendCommand(args: string[]): Promise<unknown>;\n duplicate(): NodeRedisSubscriberLike;\n}\n\n/**\n * The part of a duplicated node-redis client that {@link fromNodeRedis} uses in subscriber mode.\n * @since 1.0.0\n */\nexport interface NodeRedisSubscriberLike {\n connect(): Promise<unknown>;\n subscribe(topic: string, listener: (message: string, topic: string) => void): Promise<unknown>;\n unsubscribe(topic: string): Promise<unknown>;\n on(event: string, listener: (...args: never[]) => void): unknown;\n off?(event: string, listener: (...args: never[]) => void): unknown;\n removeListener?(event: string, listener: (...args: never[]) => void): unknown;\n close?(): Promise<unknown>;\n quit?(): Promise<unknown>;\n disconnect?(): Promise<unknown>;\n}\n\n/** A set of handlers with add-and-remove, shared by the two wrappers. */\nfunction handlers<A extends unknown[]>(): {\n add(handler: (...args: A) => void): () => void;\n fire(...args: A): void;\n} {\n const set = new Set<(...args: A) => void>();\n return {\n add(handler) {\n set.add(handler);\n return () => {\n set.delete(handler);\n };\n },\n fire(...args) {\n for (const handler of [...set]) {\n try {\n handler(...args);\n } catch {\n // A handler's failure is its own.\n }\n }\n },\n };\n}\n\n/**\n * Adapts an ioredis connection (`Redis` or `Cluster`). Commands go through `call`; the subscriber is a `duplicate()` of the\n * connection, which ioredis re-subscribes by itself after a reconnect (the adapter then recovers what was missed).\n *\n * @param client The ioredis connection.\n * @returns A {@link RedisClient} for `redisAdapter`.\n * @example\n * import { Redis } from \"ioredis\";\n * import { fromIoredis, redisAdapter } from \"@shadow-js/realtime/redis\";\n *\n * const adapter = redisAdapter({ client: fromIoredis(new Redis(process.env.REDIS_URL ?? \"\")) });\n * await adapter.close();\n * @since 1.0.0\n */\nexport function fromIoredis(client: IoredisLike): RedisClient {\n return {\n sendCommand: (args) => {\n const [command, ...rest] = args;\n return client.call(command ?? \"\", ...rest);\n },\n createSubscriber: (): RedisSubscriber => {\n const connection = client.duplicate();\n const topics = new Map<string, (message: string) => void>();\n const reconnects = handlers<[]>();\n const errors = handlers<[unknown]>();\n let ready = 0;\n connection.on(\"message\", ((topic: string, message: string) => {\n topics.get(topic)?.(message);\n }) as (...args: never[]) => void);\n connection.on(\"ready\", (() => {\n ready += 1;\n if (ready > 1) reconnects.fire();\n }) as (...args: never[]) => void);\n connection.on(\"error\", ((error: unknown) => errors.fire(error)) as (\n ...args: never[]\n ) => void);\n return {\n async subscribe(topic, listener) {\n topics.set(topic, listener);\n await connection.subscribe(topic);\n },\n async unsubscribe(topic) {\n topics.delete(topic);\n await connection.unsubscribe(topic);\n },\n onReconnect: (handler) => reconnects.add(handler),\n onError: (handler) => errors.add(handler),\n async close() {\n topics.clear();\n try {\n await connection.quit();\n } catch {\n connection.disconnect?.();\n }\n },\n };\n },\n };\n}\n\n/**\n * Adapts a node-redis (v4 or v5) client. Commands go through `sendCommand`; the subscriber is a `duplicate()` that is connected\n * here and that node-redis re-subscribes by itself after a reconnect (the adapter then recovers what was missed). Pass a\n * standalone client; node-redis cluster clients route by key and need their own wrapper.\n *\n * @param client The node-redis client (already connected).\n * @returns A {@link RedisClient} for `redisAdapter`.\n * @example\n * import { createClient } from \"redis\";\n * import { fromNodeRedis, redisAdapter } from \"@shadow-js/realtime/redis\";\n *\n * const client = createClient({ url: process.env.REDIS_URL ?? \"\" });\n * await client.connect();\n * const adapter = redisAdapter({ client: fromNodeRedis(client) });\n * await adapter.close();\n * @since 1.0.0\n */\nexport function fromNodeRedis(client: NodeRedisLike): RedisClient {\n return {\n sendCommand: (args) => client.sendCommand([...args]),\n createSubscriber: async (): Promise<RedisSubscriber> => {\n const connection = client.duplicate();\n const topics = new Map<string, (message: string) => void>();\n const dispatch = (message: string, topic: string): void => {\n topics.get(topic)?.(message);\n };\n const reconnects = handlers<[]>();\n const errors = handlers<[unknown]>();\n let ready = 0;\n connection.on(\"ready\", (() => {\n ready += 1;\n if (ready > 1) reconnects.fire();\n }) as (...args: never[]) => void);\n // node-redis throws an unhandled \"error\" event as an exception: it is always handled here.\n connection.on(\"error\", ((error: unknown) => errors.fire(error)) as (\n ...args: never[]\n ) => void);\n await connection.connect();\n const registered = new Set<string>();\n return {\n async subscribe(topic, listener) {\n topics.set(topic, listener);\n if (registered.has(topic)) return;\n registered.add(topic);\n try {\n await connection.subscribe(topic, dispatch);\n } catch (error) {\n registered.delete(topic);\n topics.delete(topic);\n throw error;\n }\n },\n async unsubscribe(topic) {\n topics.delete(topic);\n if (!registered.delete(topic)) return;\n await connection.unsubscribe(topic);\n },\n onReconnect: (handler) => reconnects.add(handler),\n onError: (handler) => errors.add(handler),\n async close() {\n topics.clear();\n registered.clear();\n if (connection.close !== undefined) await connection.close();\n else if (connection.quit !== undefined) await connection.quit();\n else await connection.disconnect?.();\n },\n };\n },\n };\n}\n"],"mappings":";;AA+GA,SAAS,WAGP;CACA,MAAM,sBAAM,IAAI,IAA0B;CAC1C,OAAO;EACL,IAAI,SAAS;GACX,IAAI,IAAI,OAAO;GACf,aAAa;IACX,IAAI,OAAO,OAAO;GACpB;EACF;EACA,KAAK,GAAG,MAAM;GACZ,KAAK,MAAM,WAAW,CAAC,GAAG,GAAG,GAC3B,IAAI;IACF,QAAQ,GAAG,IAAI;GACjB,QAAQ,CAER;EAEJ;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,YAAY,QAAkC;CAC5D,OAAO;EACL,cAAc,SAAS;GACrB,MAAM,CAAC,SAAS,GAAG,QAAQ;GAC3B,OAAO,OAAO,KAAK,WAAW,IAAI,GAAG,IAAI;EAC3C;EACA,wBAAyC;GACvC,MAAM,aAAa,OAAO,UAAU;GACpC,MAAM,yBAAS,IAAI,IAAuC;GAC1D,MAAM,aAAa,SAAa;GAChC,MAAM,SAAS,SAAoB;GACnC,IAAI,QAAQ;GACZ,WAAW,GAAG,aAAa,OAAe,YAAoB;IAC5D,OAAO,IAAI,KAAK,CAAC,GAAG,OAAO;GAC7B,EAAgC;GAChC,WAAW,GAAG,gBAAgB;IAC5B,SAAS;IACT,IAAI,QAAQ,GAAG,WAAW,KAAK;GACjC,EAAgC;GAChC,WAAW,GAAG,WAAW,UAAmB,OAAO,KAAK,KAAK,EAEpD;GACT,OAAO;IACL,MAAM,UAAU,OAAO,UAAU;KAC/B,OAAO,IAAI,OAAO,QAAQ;KAC1B,MAAM,WAAW,UAAU,KAAK;IAClC;IACA,MAAM,YAAY,OAAO;KACvB,OAAO,OAAO,KAAK;KACnB,MAAM,WAAW,YAAY,KAAK;IACpC;IACA,cAAc,YAAY,WAAW,IAAI,OAAO;IAChD,UAAU,YAAY,OAAO,IAAI,OAAO;IACxC,MAAM,QAAQ;KACZ,OAAO,MAAM;KACb,IAAI;MACF,MAAM,WAAW,KAAK;KACxB,QAAQ;MACN,WAAW,aAAa;KAC1B;IACF;GACF;EACF;CACF;AACF;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,cAAc,QAAoC;CAChE,OAAO;EACL,cAAc,SAAS,OAAO,YAAY,CAAC,GAAG,IAAI,CAAC;EACnD,kBAAkB,YAAsC;GACtD,MAAM,aAAa,OAAO,UAAU;GACpC,MAAM,yBAAS,IAAI,IAAuC;GAC1D,MAAM,YAAY,SAAiB,UAAwB;IACzD,OAAO,IAAI,KAAK,CAAC,GAAG,OAAO;GAC7B;GACA,MAAM,aAAa,SAAa;GAChC,MAAM,SAAS,SAAoB;GACnC,IAAI,QAAQ;GACZ,WAAW,GAAG,gBAAgB;IAC5B,SAAS;IACT,IAAI,QAAQ,GAAG,WAAW,KAAK;GACjC,EAAgC;GAEhC,WAAW,GAAG,WAAW,UAAmB,OAAO,KAAK,KAAK,EAEpD;GACT,MAAM,WAAW,QAAQ;GACzB,MAAM,6BAAa,IAAI,IAAY;GACnC,OAAO;IACL,MAAM,UAAU,OAAO,UAAU;KAC/B,OAAO,IAAI,OAAO,QAAQ;KAC1B,IAAI,WAAW,IAAI,KAAK,GAAG;KAC3B,WAAW,IAAI,KAAK;KACpB,IAAI;MACF,MAAM,WAAW,UAAU,OAAO,QAAQ;KAC5C,SAAS,OAAO;MACd,WAAW,OAAO,KAAK;MACvB,OAAO,OAAO,KAAK;MACnB,MAAM;KACR;IACF;IACA,MAAM,YAAY,OAAO;KACvB,OAAO,OAAO,KAAK;KACnB,IAAI,CAAC,WAAW,OAAO,KAAK,GAAG;KAC/B,MAAM,WAAW,YAAY,KAAK;IACpC;IACA,cAAc,YAAY,WAAW,IAAI,OAAO;IAChD,UAAU,YAAY,OAAO,IAAI,OAAO;IACxC,MAAM,QAAQ;KACZ,OAAO,MAAM;KACb,WAAW,MAAM;KACjB,IAAI,WAAW,UAAU,KAAA,GAAW,MAAM,WAAW,MAAM;UACtD,IAAI,WAAW,SAAS,KAAA,GAAW,MAAM,WAAW,KAAK;UACzD,MAAM,WAAW,aAAa;IACrC;GACF;EACF;CACF;AACF"}
@@ -0,0 +1,275 @@
1
+ //#region src/redis/scripts.ts
2
+ function rotateLeft(value, bits) {
3
+ return (value << bits | value >>> 32 - bits) >>> 0;
4
+ }
5
+ /**
6
+ * SHA-1 of an ASCII or UTF-8 text, as lowercase hex. Pure TypeScript, so the adapter needs neither `node:crypto` nor a
7
+ * round trip to `SCRIPT LOAD`.
8
+ */
9
+ function sha1(text) {
10
+ const input = new TextEncoder().encode(text);
11
+ const total = input.length + 9 + 63 >> 6 << 6;
12
+ const padded = new Uint8Array(total);
13
+ padded.set(input);
14
+ padded[input.length] = 128;
15
+ const view = new DataView(padded.buffer);
16
+ const bits = input.length * 8;
17
+ view.setUint32(total - 8, Math.floor(bits / 2 ** 32));
18
+ view.setUint32(total - 4, bits >>> 0);
19
+ let h0 = 1732584193;
20
+ let h1 = 4023233417;
21
+ let h2 = 2562383102;
22
+ let h3 = 271733878;
23
+ let h4 = 3285377520;
24
+ const w = /* @__PURE__ */ new Uint32Array(80);
25
+ for (let offset = 0; offset < total; offset += 64) {
26
+ for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4);
27
+ for (let i = 16; i < 80; i += 1) w[i] = rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
28
+ let a = h0;
29
+ let b = h1;
30
+ let c = h2;
31
+ let d = h3;
32
+ let e = h4;
33
+ for (let i = 0; i < 80; i += 1) {
34
+ let f;
35
+ let k;
36
+ if (i < 20) {
37
+ f = b & c | ~b & d;
38
+ k = 1518500249;
39
+ } else if (i < 40) {
40
+ f = b ^ c ^ d;
41
+ k = 1859775393;
42
+ } else if (i < 60) {
43
+ f = b & c | b & d | c & d;
44
+ k = 2400959708;
45
+ } else {
46
+ f = b ^ c ^ d;
47
+ k = 3395469782;
48
+ }
49
+ const next = rotateLeft(a, 5) + (f >>> 0) + e + k + w[i] >>> 0;
50
+ e = d;
51
+ d = c;
52
+ c = rotateLeft(b, 30);
53
+ b = a;
54
+ a = next;
55
+ }
56
+ h0 = h0 + a >>> 0;
57
+ h1 = h1 + b >>> 0;
58
+ h2 = h2 + c >>> 0;
59
+ h3 = h3 + d >>> 0;
60
+ h4 = h4 + e >>> 0;
61
+ }
62
+ return [
63
+ h0,
64
+ h1,
65
+ h2,
66
+ h3,
67
+ h4
68
+ ].map((word) => word.toString(16).padStart(8, "0")).join("");
69
+ }
70
+ function script(source) {
71
+ return {
72
+ source,
73
+ sha: sha1(source)
74
+ };
75
+ }
76
+ /** Lua helpers shared by the scripts that read the clock. */
77
+ const CLOCK = `
78
+ local function now_ms()
79
+ local t = redis.call('TIME')
80
+ return tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
81
+ end
82
+ local function num(n) return string.format('%.0f', n) end
83
+ `;
84
+ /**
85
+ * Publish. KEYS: meta hash, log zset (score = seq, member = the message), time zset (score = accepted-at, member = zero-padded
86
+ * seq), optional idempotency key. ARGV: history, historyTtlMs, idempotencyTtlMs, channelTtlMs, topic, message tail.
87
+ * Returns `{seq, duplicate}`.
88
+ */
89
+ const PUBLISH = script(`${CLOCK}
90
+ local meta, log, tix, idem = KEYS[1], KEYS[2], KEYS[3], KEYS[4]
91
+ if idem then
92
+ local prev = redis.call('GET', idem)
93
+ if prev then return {tonumber(prev), 1} end
94
+ end
95
+ local history = tonumber(ARGV[1])
96
+ local histTtl = tonumber(ARGV[2])
97
+ local idemTtl = tonumber(ARGV[3])
98
+ local chanTtl = tonumber(ARGV[4])
99
+ local at = now_ms()
100
+ local seq = redis.call('HINCRBY', meta, 's', 1)
101
+ if history > 0 then
102
+ local newest = redis.call('ZREVRANGE', tix, 0, 0, 'WITHSCORES')
103
+ if newest[2] and tonumber(newest[2]) > at then at = tonumber(newest[2]) end
104
+ end
105
+ local msg = '{"s":' .. num(seq) .. ',"a":' .. num(at) .. ',' .. ARGV[6]
106
+ if history > 0 then
107
+ local pad = string.format('%016.0f', seq)
108
+ redis.call('ZADD', log, seq, msg)
109
+ redis.call('ZADD', tix, at, pad)
110
+ local n = redis.call('ZCARD', log)
111
+ if n ~= redis.call('ZCARD', tix) then
112
+ redis.call('DEL', log, tix)
113
+ redis.call('ZADD', log, seq, msg)
114
+ redis.call('ZADD', tix, at, pad)
115
+ else
116
+ local drop = n - history
117
+ local old = redis.call('ZCOUNT', tix, '-inf', '(' .. num(at - histTtl))
118
+ if old > drop then drop = old end
119
+ if drop > 0 then
120
+ redis.call('ZREMRANGEBYRANK', log, 0, drop - 1)
121
+ redis.call('ZREMRANGEBYRANK', tix, 0, drop - 1)
122
+ end
123
+ end
124
+ redis.call('PEXPIRE', log, histTtl)
125
+ redis.call('PEXPIRE', tix, histTtl)
126
+ else
127
+ redis.call('DEL', log, tix)
128
+ end
129
+ redis.call('HSET', meta, 't', ARGV[2])
130
+ local keep = chanTtl
131
+ if histTtl > keep then keep = histTtl end
132
+ if idem and idemTtl > keep then keep = idemTtl end
133
+ redis.call('PEXPIRE', meta, keep)
134
+ if idem then redis.call('SET', idem, num(seq), 'PX', idemTtl) end
135
+ redis.call('PUBLISH', ARGV[5], msg)
136
+ return {seq, 0}
137
+ `);
138
+ /**
139
+ * History. KEYS: meta, log, time. ARGV: after, limit. Returns `{head, oldest, message...}`; a message older than the channel's
140
+ * history lifetime is not returned even when no publish has trimmed it yet.
141
+ */
142
+ const HISTORY = script(`${CLOCK}
143
+ local meta, log, tix = KEYS[1], KEYS[2], KEYS[3]
144
+ local head = tonumber(redis.call('HGET', meta, 's') or '0')
145
+ local ttl = redis.call('HGET', meta, 't')
146
+ local low = tonumber(ARGV[1])
147
+ local limit = tonumber(ARGV[2])
148
+ local skip = 0
149
+ if ttl then
150
+ skip = redis.call('ZCOUNT', tix, '-inf', '(' .. num(now_ms() - tonumber(ttl)))
151
+ end
152
+ local oldest = 0
153
+ local first = redis.call('ZRANGE', tix, skip, skip)
154
+ if first[1] then oldest = tonumber(first[1]) end
155
+ if skip > 0 then
156
+ local expired = tonumber(redis.call('ZRANGE', tix, skip - 1, skip - 1)[1])
157
+ if expired and expired > low then low = expired end
158
+ end
159
+ local out = {head, oldest}
160
+ if limit > 0 and oldest > 0 then
161
+ local rows = redis.call('ZRANGEBYSCORE', log, '(' .. num(low), '+inf', 'LIMIT', 0, limit)
162
+ for i = 1, #rows do out[#out + 1] = rows[i] end
163
+ end
164
+ return out
165
+ `);
166
+ /**
167
+ * Keeps the numbering of a channel that has listeners alive. KEYS: meta. ARGV: channelTtlMs. Only ever extends the lifetime.
168
+ */
169
+ const KEEPALIVE = script(`
170
+ local ms = tonumber(ARGV[1])
171
+ local left = redis.call('PTTL', KEYS[1])
172
+ if left >= 0 and left < ms then redis.call('PEXPIRE', KEYS[1], ms) end
173
+ return 1
174
+ `);
175
+ /** Lua: extends the lifetime of both presence keys to `keep` milliseconds, never shortens it. */
176
+ const EXTEND = `
177
+ local function extend(keep)
178
+ for i = 1, 2 do
179
+ local left = redis.call('PTTL', KEYS[i])
180
+ if left == -1 or (left >= 0 and left < keep) then redis.call('PEXPIRE', KEYS[i], keep) end
181
+ end
182
+ end
183
+ `;
184
+ /**
185
+ * Presence set. KEYS: members hash (id -> stored), expiry zset (id -> expires). ARGV: id, version, ttlMs, topic, stored, member
186
+ * JSON, graceMs. Returns 0 for an ignored older version, else 1. Publishes join (new) or set (higher version).
187
+ */
188
+ const PRESENCE_SET = script(`${CLOCK}${EXTEND}
189
+ local id, version, ttl = ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
190
+ local cur = redis.call('HGET', KEYS[1], id)
191
+ local op = nil
192
+ if cur then
193
+ local known = tonumber(string.match(cur, '^{"v":(%d+),'))
194
+ if known and known > version then return 0 end
195
+ if known == nil or known < version then op = 'set' end
196
+ else
197
+ op = 'join'
198
+ end
199
+ redis.call('HSET', KEYS[1], id, ARGV[5])
200
+ redis.call('ZADD', KEYS[2], num(now_ms() + ttl), id)
201
+ extend(ttl + tonumber(ARGV[7]))
202
+ if op then redis.call('PUBLISH', ARGV[4], '{"o":"' .. op .. '","m":' .. ARGV[6] .. '}') end
203
+ return 1
204
+ `);
205
+ /**
206
+ * Presence remove. KEYS: hash, zset. ARGV: id, topic. Returns 1 only for the call that removed the member (which alone
207
+ * publishes the leave), else 0.
208
+ */
209
+ const PRESENCE_REMOVE = script(`
210
+ local cur = redis.call('HGET', KEYS[1], ARGV[1])
211
+ redis.call('ZREM', KEYS[2], ARGV[1])
212
+ if not cur then return 0 end
213
+ redis.call('HDEL', KEYS[1], ARGV[1])
214
+ local member = string.match(cur, '^{"v":%d+,"m":(.*)}$')
215
+ if member then redis.call('PUBLISH', ARGV[2], '{"o":"leave","m":' .. member .. '}') end
216
+ return 1
217
+ `);
218
+ /**
219
+ * Presence touch. KEYS: hash, zset. ARGV: ttlMs, graceMs, id... Refreshes the expiry of the members that exist and returns the
220
+ * ids that do not.
221
+ */
222
+ const PRESENCE_TOUCH = script(`${CLOCK}${EXTEND}
223
+ local ttl = tonumber(ARGV[1])
224
+ local expires = num(now_ms() + ttl)
225
+ local missing = {}
226
+ local found = false
227
+ for i = 3, #ARGV do
228
+ if redis.call('HEXISTS', KEYS[1], ARGV[i]) == 1 then
229
+ redis.call('ZADD', KEYS[2], expires, ARGV[i])
230
+ found = true
231
+ else
232
+ missing[#missing + 1] = ARGV[i]
233
+ end
234
+ end
235
+ if found then extend(ttl + tonumber(ARGV[2])) end
236
+ return missing
237
+ `);
238
+ /**
239
+ * Presence reap. KEYS: hash, zset. ARGV: topic, limit. Removes up to `limit` expired members, publishing one leave for each,
240
+ * and returns their stored values.
241
+ */
242
+ const PRESENCE_REAP = script(`${CLOCK}
243
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', num(now_ms()), 'LIMIT', 0, tonumber(ARGV[2]))
244
+ local out = {}
245
+ for i = 1, #ids do
246
+ local cur = redis.call('HGET', KEYS[1], ids[i])
247
+ redis.call('ZREM', KEYS[2], ids[i])
248
+ if cur then
249
+ redis.call('HDEL', KEYS[1], ids[i])
250
+ out[#out + 1] = cur
251
+ local member = string.match(cur, '^{"v":%d+,"m":(.*)}$')
252
+ if member then redis.call('PUBLISH', ARGV[1], '{"o":"leave","m":' .. member .. '}') end
253
+ end
254
+ end
255
+ return out
256
+ `);
257
+ /** Presence list. KEYS: hash, zset. Returns the stored values of the members that have not expired. */
258
+ const PRESENCE_LIST = script(`${CLOCK}
259
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[2], '(' .. num(now_ms()), '+inf')
260
+ local out = {}
261
+ local step = 400
262
+ for from = 1, #ids, step do
263
+ local to = from + step - 1
264
+ if to > #ids then to = #ids end
265
+ local values = redis.call('HMGET', KEYS[1], unpack(ids, from, to))
266
+ for i = 1, #values do
267
+ if values[i] then out[#out + 1] = values[i] end
268
+ end
269
+ end
270
+ return out
271
+ `);
272
+ //#endregion
273
+ export { HISTORY, KEEPALIVE, PRESENCE_LIST, PRESENCE_REAP, PRESENCE_REMOVE, PRESENCE_SET, PRESENCE_TOUCH, PUBLISH, sha1 };
274
+
275
+ //# sourceMappingURL=scripts.js.map