{"_id":"@birtalanrobert/realtime","_rev":"6-af674c4ff081f6328e45923ec2b9674a","name":"@birtalanrobert/realtime","dist-tags":{"latest":"2.2.0"},"versions":{"1.0.0":{"name":"@birtalanrobert/realtime","version":"1.0.0","license":"AGPL-3.0-only","_id":"@birtalanrobert/realtime@1.0.0","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"homepage":"https://github.com/birtalanrobert/mortar#readme","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"23592c699f7a943927d480417b022fd916682ebc","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-1.0.0.tgz","fileCount":40,"integrity":"sha512-4XMX/vOo4KO56lh90kpwXder0xOIuOMxE3s2iuRVDYyI3wURs7HxnKn61u94Z58G80SnGGm1Qqg4WteFnD+TYw==","signatures":[{"sig":"MEUCIQDhPFP5co0/qY86c9OKFul2HPrAKtvABXxEgPUHVD4eJQIgGKcY3Ljjft/OcYKXYiGooQZgmC2CLQ8FHRFtwFdF6LM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":175096},"main":"./dist/index.js","_from":"file:birtalanrobert-realtime-1.0.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json"},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/2346b0b4e918bba1eb0abce086296c8a/birtalanrobert-realtime-1.0.0.tgz","_integrity":"sha512-4XMX/vOo4KO56lh90kpwXder0xOIuOMxE3s2iuRVDYyI3wURs7HxnKn61u94Z58G80SnGGm1Qqg4WteFnD+TYw==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/realtime_1.0.0_1788982162082_0.9877307108637496","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@birtalanrobert/realtime","version":"1.1.0","license":"AGPL-3.0-only","_id":"@birtalanrobert/realtime@1.1.0","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"homepage":"https://github.com/birtalanrobert/mortar#readme","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"f10799845f075a69112ece5f69d748ad5610ad69","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-1.1.0.tgz","fileCount":71,"integrity":"sha512-2RcHWd6VP/WYZFnVNhYjgzAgJAm/ZQttuuoyiCr/xhvxfSNSXr6pzrBmzt8RU1FhgNJTwGNglIBya5kROIVijw==","signatures":[{"sig":"MEUCIASM7gL0abGqMVJSqxezAUqJWqDBgAEVJoPUyDFV9akEAiEAyQGHLvaMPZMF36uRBwNHDliil9eY/1oWRkpwGRnGVOk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":245532},"main":"./dist/index.js","_from":"file:birtalanrobert-realtime-1.1.0.tgz","types":"./dist/index.d.ts","mortar":{"entries":["nestjs"]},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./nestjs":{"types":"./dist/nestjs/index.d.ts","default":"./dist/nestjs/index.js"},"./package.json":"./package.json"},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/a615c737bb85b908d19f4cbc60525d7a/birtalanrobert-realtime-1.1.0.tgz","_integrity":"sha512-2RcHWd6VP/WYZFnVNhYjgzAgJAm/ZQttuuoyiCr/xhvxfSNSXr6pzrBmzt8RU1FhgNJTwGNglIBya5kROIVijw==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"@birtalanrobert/redis":"^1.0.2"},"peerDependencies":{"ws":"^8.0.0","ioredis":"^5.0.0","@nestjs/common":"^11.0.0"},"peerDependenciesMeta":{"ws":{"optional":true},"ioredis":{"optional":true},"@nestjs/common":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/realtime_1.1.0_1788982796850_0.6421637064115426","host":"s3://npm-registry-packages-npm-production"}},"2.0.0":{"name":"@birtalanrobert/realtime","version":"2.0.0","license":"AGPL-3.0-only","_id":"@birtalanrobert/realtime@2.0.0","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"homepage":"https://github.com/birtalanrobert/mortar#readme","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"9750fa3497c4194822edd0054a5b6bb95931d334","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-2.0.0.tgz","fileCount":76,"integrity":"sha512-u7S5QzUKi+4kCdQrW1o6RQuqLTojwGpQ0dDNhvEOkEXZeUfeac5OaiGt4K2ylbJv8A5in+BU5RHoqijUR8up2g==","signatures":[{"sig":"MEQCIG8q9Z/Nyw/RWeEOkeFJPkuiPMpYI9Fx5ctIOYGvoNrnAiAf1c5Z5YI4HnfD7zCxYoYrdNmSJ4rGaQJiDhT8/suekg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":268902},"main":"./dist/index.js","_from":"file:birtalanrobert-realtime-2.0.0.tgz","types":"./dist/index.d.ts","mortar":{"entries":["nestjs","nestjs/socket"]},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./nestjs":{"types":"./dist/nestjs/index.d.ts","default":"./dist/nestjs/index.js"},"./package.json":"./package.json","./nestjs/socket":{"types":"./dist/nestjs/socket/index.d.ts","default":"./dist/nestjs/socket/index.js"}},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/4d8454021e857217930c3d6b8772cbad/birtalanrobert-realtime-2.0.0.tgz","_integrity":"sha512-u7S5QzUKi+4kCdQrW1o6RQuqLTojwGpQ0dDNhvEOkEXZeUfeac5OaiGt4K2ylbJv8A5in+BU5RHoqijUR8up2g==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"@birtalanrobert/redis":"^1.0.2"},"peerDependencies":{"ws":"^8.0.0","ioredis":"^5.0.0","@nestjs/common":"^11.0.0"},"peerDependenciesMeta":{"ws":{"optional":true},"ioredis":{"optional":true},"@nestjs/common":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/realtime_2.0.0_1789024779702_0.9553030711542454","host":"s3://npm-registry-packages-npm-production"}},"2.0.1":{"name":"@birtalanrobert/realtime","version":"2.0.1","license":"AGPL-3.0-only","_id":"@birtalanrobert/realtime@2.0.1","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"homepage":"https://github.com/birtalanrobert/mortar#readme","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"3d9d77db52ae1f0fe59596f49e80aac953d6c153","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-2.0.1.tgz","fileCount":76,"integrity":"sha512-wADNHmOz2qsufgfwBNZGXTNugv+PBRS8aXjZ6++FceRxUWCL1t36Q4PyE3x6urlhOds8hOD81tWiKJWXQwHm8Q==","signatures":[{"sig":"MEQCIGM3AYtR3lnrlbjIWx/z2kvsd23Es7kGh2jwTieRQR80AiBqLh/bKrVqMsInkBm1WOsFTJJvNYfK3f5xFzhow2pSAQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIFVMVhDAp5hQj0MHnmP7FvIzmYu52qrkKeOjinM6TJMxAiBLDdY6caGfk5a84pyoqbiWRhZEgVg1DS8AT3xBCHTCGA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":307127},"main":"./dist/index.js","_from":"file:birtalanrobert-realtime-2.0.1.tgz","types":"./dist/index.d.ts","mortar":{"entries":["nestjs","nestjs/socket"]},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./nestjs":{"types":"./dist/nestjs/index.d.ts","default":"./dist/nestjs/index.js"},"./package.json":"./package.json","./nestjs/socket":{"types":"./dist/nestjs/socket/index.d.ts","default":"./dist/nestjs/socket/index.js"}},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/52f48c4b5c8f21385842a53c297ff733/birtalanrobert-realtime-2.0.1.tgz","_integrity":"sha512-wADNHmOz2qsufgfwBNZGXTNugv+PBRS8aXjZ6++FceRxUWCL1t36Q4PyE3x6urlhOds8hOD81tWiKJWXQwHm8Q==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"@birtalanrobert/redis":"^1.0.2"},"peerDependencies":{"ws":"^8.0.0","ioredis":"^5.0.0","@nestjs/common":"^11.0.0"},"peerDependenciesMeta":{"ws":{"optional":true},"ioredis":{"optional":true},"@nestjs/common":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/realtime_2.0.1_1789925526796_0.23826040623036082","host":"s3://npm-registry-packages-npm-production"}},"2.1.0":{"name":"@birtalanrobert/realtime","version":"2.1.0","license":"AGPL-3.0-only","_id":"@birtalanrobert/realtime@2.1.0","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"homepage":"https://github.com/birtalanrobert/mortar#readme","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"b2d75356084f501ddcaffdbe6fc5e5bb105b4493","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-2.1.0.tgz","fileCount":76,"integrity":"sha512-jJiUeMpKleXd0AgFHg/8Er8w1y6Cw2w2kGzFABzz1jibk8kw/cLShz2PV13V6VYYYgGpABJAkkLMBT5aNnv8Lg==","signatures":[{"sig":"MEUCIQDeSIbfQ40V0QVz4NVQMRiomZJk9VKjCDvLyMzyw7PLjAIgeeLibUocACaeHijfNPKvhrGbnXjUzovUCy2ULxzoCno=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIAL9j6AAJCL5puH7A4Idr05TFEfM0eJbbretEKQniqR4AiA5OE3ThiFov/s85M8C2XRLTj7f0kFaaEX+DNnGE1r3TA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":330216},"main":"./dist/index.js","_from":"file:birtalanrobert-realtime-2.1.0.tgz","types":"./dist/index.d.ts","mortar":{"entries":["nestjs","nestjs/socket"]},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./nestjs":{"types":"./dist/nestjs/index.d.ts","default":"./dist/nestjs/index.js"},"./package.json":"./package.json","./nestjs/socket":{"types":"./dist/nestjs/socket/index.d.ts","default":"./dist/nestjs/socket/index.js"}},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/ddcbdb446b68cce8b4912373ad8d1629/birtalanrobert-realtime-2.1.0.tgz","_integrity":"sha512-jJiUeMpKleXd0AgFHg/8Er8w1y6Cw2w2kGzFABzz1jibk8kw/cLShz2PV13V6VYYYgGpABJAkkLMBT5aNnv8Lg==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"@birtalanrobert/redis":"^1.0.2"},"peerDependencies":{"ws":"^8.0.0","ioredis":"^5.0.0","@nestjs/common":"^11.0.0"},"peerDependenciesMeta":{"ws":{"optional":true},"ioredis":{"optional":true},"@nestjs/common":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/realtime_2.1.0_1790495060294_0.042676334588495735","host":"s3://npm-registry-packages-npm-production"}},"2.2.0":{"_id":"@birtalanrobert/realtime@2.2.0","bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"dist":{"shasum":"7d8c071f91ce0376aca154279f4caf991588fe9b","tarball":"https://registry.npmjs.org/@birtalanrobert/realtime/-/realtime-2.2.0.tgz","fileCount":76,"integrity":"sha512-NUHVA0DIoLkmfWtTgPJoGKSxqnTsINfIR7/uWft4ZM9fWaw8qNIISKXCKM1lYyQ3M+s/IOld4NNrMr+FzZqHCw==","signatures":[{"sig":"MEUCIArG0fj1WL11OsZbqA7QSGcL1cBD694O1P6B8LIm8RImAiEA9oBQ315IvOZucgEsM+pyMlLKZ6kLtGXYPe50p2I0PyU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIDupXIbQ69Yz7WcVN9e+tVZyQHMap4uZe4fuOrxsv5chAiEA0OdpPV6HtDkBZ3ZDzSbDB/rf69Md86bFsGFRVQBANI4="}],"unpackedSize":349057},"main":"./dist/index.js","name":"@birtalanrobert/realtime","_from":"file:birtalanrobert-realtime-2.2.0.tgz","types":"./dist/index.d.ts","mortar":{"entries":["nestjs","nestjs/socket"]},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./nestjs":{"types":"./dist/nestjs/index.d.ts","default":"./dist/nestjs/index.js"},"./package.json":"./package.json","./nestjs/socket":{"types":"./dist/nestjs/socket/index.d.ts","default":"./dist/nestjs/socket/index.js"}},"license":"AGPL-3.0-only","scripts":{"build":"tsc -p tsconfig.build.json","clean":"rm -rf dist *.tsbuildinfo","typecheck":"tsc -p tsconfig.json --noEmit"},"version":"2.2.0","_npmUser":{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"},"homepage":"https://github.com/birtalanrobert/mortar#readme","_resolved":"/private/var/folders/zx/7dcyg3mn6kjfyzsymzgpx1jr0000gn/T/27310594cef42ab91d6118375994e6b4/birtalanrobert-realtime-2.2.0.tgz","_integrity":"sha512-NUHVA0DIoLkmfWtTgPJoGKSxqnTsINfIR7/uWft4ZM9fWaw8qNIISKXCKM1lYyQ3M+s/IOld4NNrMr+FzZqHCw==","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"_npmVersion":"11.13.0","description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","directories":{},"maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"@birtalanrobert/redis":"^1.1.1"},"peerDependencies":{"ws":"^8.0.0","ioredis":"^5.0.0","@nestjs/common":"^11.0.0"},"peerDependenciesMeta":{"ws":{"optional":true},"ioredis":{"optional":true},"@nestjs/common":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/realtime_2.2.0_1790499389137_0.060800884316574155"}}},"time":{"created":"2026-09-09T19:29:21.868Z","modified":"2026-09-27T08:56:29.409Z","1.0.0":"2026-09-09T19:29:22.224Z","1.1.0":"2026-09-09T19:39:56.990Z","2.0.0":"2026-09-10T07:19:39.853Z","2.0.1":"2026-09-20T17:32:06.906Z","2.1.0":"2026-09-27T07:44:20.465Z","2.2.0":"2026-09-27T08:56:29.258Z"},"bugs":{"url":"https://github.com/birtalanrobert/mortar/issues"},"license":"AGPL-3.0-only","homepage":"https://github.com/birtalanrobert/mortar#readme","repository":{"url":"git+https://github.com/birtalanrobert/mortar.git","type":"git","directory":"packages/realtime"},"description":"Channels with sequence numbers, gap detection, resume and a tested polling fallback","maintainers":[{"name":"birtalanrobert","email":"birtalanrobert@gmail.com"}],"readme":"# @birtalanrobert/realtime\n\nChannels with sequence numbers, gap detection, resume, bidirectional\nheartbeats, and a polling fallback that is built and tested rather than\ndescribed.\n\nFive of the seventeen products hold a live connection: a kitchen display, a\nvenue's staff app, and four game clients. What makes this a package rather than\n`new WebSocket` in each of them is not the socket handling — it is **gap\ndetection**. A client that can say _\"I last saw 412\"_ and be told what it missed\nis the difference between \"probably fine\" and \"provably complete\", and a\ndisplay that is quietly one ticket behind looks exactly like a kitchen with no\norders.\n\n## The root is pure\n\nWire format, gap logic and the browser client. No framework, no Node, no\ndependencies — four browser bundles import it. The server lives in the same\nentry today because it is equally dependency-free; anything that needs Redis or\nNest arrives behind a subpath.\n\n## The client\n\n```ts\nimport { RealtimeClient } from '@birtalanrobert/realtime';\n\nconst client = new RealtimeClient({\n  url: 'wss://api.example.com/realtime',\n  pollUrl: 'https://api.example.com/realtime/poll',\n  channels: ['station:grill'],\n  onEvent: (event) => apply(event),\n  onState: (state) => showConnection(state),\n  onResync: () => reloadEverything(),\n});\n\nclient.start();\n```\n\nThree behaviours are worth knowing before using it:\n\n- **It resumes from where it stood.** A reconnection subscribes with the last\n  sequence it saw in each channel, so the events that arrived while it was away\n  are sent. Subscribing \"from now\" loses exactly the window a reconnection\n  exists to cover. A client that joined a channel while it was empty stands at\n  **0**, and 0 is a position, not a newcomer: it is sent everything the\n  channel has carried since. Only a channel the client has no position in at\n  all is joined from now.\n- **Duplicates are normal.** A resume overlaps the live stream by design,\n  because the alternative is a race in which the gap between \"here is your\n  backlog\" and \"you are now live\" loses an event. `ChannelCursor` makes the\n  overlap harmless.\n- **`onResync` means reload.** The server could not replay far enough back and\n  said so. A partial replay that looks complete is the failure this package\n  exists to prevent, so the honest answer is for the product to fetch its own\n  state again.\n\n**Polling starts immediately** when a socket will not open, and the socket is\nretried behind it. A venue whose network eats WebSockets — a hotel, a corporate\nguest network, an ageing router — gets a working display rather than a spinner\nand an exponential backoff. The fallback speaks the same protocol and is\nanswered by the same `resume`, which is what keeps it a fallback rather than a\nsecond implementation that behaves differently on the day it is needed.\n\n## The server\n\n```ts\nimport { MemoryBacklog, RealtimePublisher, resume } from '@birtalanrobert/realtime';\n\nconst publisher = new RealtimePublisher({\n  backlog: new MemoryBacklog(500),\n  broadcast: (event) => redis.publish('realtime', JSON.stringify(event)),\n});\n\nawait publisher.publish({ channel: 'station:grill', type: 'ticket.created', data: ticket });\n```\n\n`RealtimePublisher` is **the one place a sequence number is assigned**. A number\nhanded out twice is two events a client cannot tell apart; a number skipped is a\ngap that will never be filled and makes every client reload for ever.\n\nPublishing is _append then fan out_, in that order. A subscriber told about an\nevent before it was durable would learn about something a reconnecting client\ncould not be given — the same incompleteness, arriving through the door built to\nprevent it.\n\nAn event arriving over the broadcast from another process goes through\n`receiveBroadcast`, which does **not** append it: it already has its number.\n\n`BacklogPort` is bounded on purpose. A backlog that grew for ever would be a\nsecond database nobody chose; a bounded one can fail to answer, and saying so\nout loud is what keeps a client honest. `MemoryBacklog` implements it for tests\nand single-process deployments.\n\n**Numbers never go backwards, even across a channel that was forgotten.**\n`RedisBacklog` lets a quiet channel expire, and a channel used again starts over.\nIt starts at the Redis server's clock in milliseconds rather than at 1, so its\nnext event is numbered above anything a page left open overnight has seen. The\npage sees a jump and resynchronises. Numbered from 1, the same event would have\nlooked like a duplicate and been skipped without a word. A client standing\nfurther along than a channel has ever been, such as after a `MemoryBacklog`\nrestarted with its process, is told to start again.\n\n## The server half\n\n`@birtalanrobert/realtime/nestjs` — a Redis backlog, a fan-out between gateway\nprocesses, a polling handler and a Nest module. `ioredis` and `@nestjs/common`\nare optional peers: a product that only holds a client pays for neither.\n\n`@birtalanrobert/realtime/nestjs/socket` — the WebSocket server, and the only\nthing here that needs `ws`. It is a separate entry point because importing a\nbarrel loads everything in it: a process that publishes and holds no sockets — a\nworker releasing a timed event, say — would otherwise have to install a\nWebSocket library to reach a Redis backlog.\n\n```ts\nimport { RealtimeModule, RedisBacklog, RedisBroadcast } from '@birtalanrobert/realtime/nestjs';\nimport { RealtimeSocketServer } from '@birtalanrobert/realtime/nestjs/socket';\n\n// In the module: the publisher, wired to a backlog the application built.\nRealtimeModule.forRootAsync({\n  inject: [ConfigModule.token(), RedisService],\n  useFactory: (config, redis) => ({\n    backlog: new RedisBacklog(redis.client, { prefix: config.QUEUE_PREFIX, keep: 500 }),\n    broadcast: broadcast.send,\n  }),\n});\n\n// In `main.ts`, where the HTTP server exists:\nconst server = new RealtimeSocketServer({\n  publisher,\n  backlog,\n  admit, // is this a caller at all? Asked once, before the upgrade\n  authorise, // which of the channels it asked for may it hear?\n  onError: (error) => logger.error('realtime', error),\n});\nserver.attach(app.getHttpServer());\n```\n\nThe socket server is **not** in the module on purpose: it needs the HTTP server\nthe application creates at bootstrap, and a module that tried to own that would\neither guess at the ordering or hold a reference to something that does not\nexist yet.\n\n`RedisBacklog` assigns the number and stores the event in **one Lua script**.\nTwo round trips can come apart: a process that dies between `INCR` and `ZADD`\nhas handed out 413 and stored nothing, and no later care can fill that hole.\n\n`RedisBroadcast` is fire-and-forget, which is acceptable here and nowhere else:\npub/sub does not deliver to a process that is not connected, but the event is\nalready durable, so a process that missed it serves it from the resume the\nmoment any client asks. The socket is the fast path; the backlog is the truth.\n\n`authorise` returns the **subset** a caller may have rather than a boolean, so a\ndisplay asking for two stations it may see and one it may not gets the two —\nrather than a connection that fails for a reason nobody can see.\n\n`admit` is asked **once, before the upgrade**, whether there is a caller at all.\nWithout it, a request with no credential is upgraded, granted nothing, and kept\nalive by the heartbeat for as long as it answers: a socket held for free by\nanybody who can reach the port. It is also where `Origin` is checked, because a\nbrowser sends its cookies on a WebSocket upgrade from any page. A refusal is\nanswered `403`, and an `admit` that throws `503`.\n\nA client frame may weigh **64 KiB** unless `maxPayload` says otherwise; `ws`'s\nown default is 100 MiB. A subscription that `authorise` or the backlog could not\nanswer closes the connection with `1011`, so the client polls and comes back\nfrom where it stood. The error goes to `onError`, where the product logs it.\n\nFor the polling route, `parsePollQuery` reads what the client sends and\n`pollSince` answers it through the same `resume`.\n\n## What this package does not do\n\nAuthorisation, acknowledgement, presence and moderation. Who may subscribe to\nwhich channel is the product's decision; whether a ticket was _acted on_ is a\nrow in the product's database, not a frame on a socket. A transport that\nbelieved it knew either would be wrong in a different way for each of the five\nproducts that need it.\n","readmeFilename":"README.md"}