From 6b6167b1eba2d38ec4d2e23c1de421b4ec85572f Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 9 Oct 2025 09:48:01 +0700 Subject: [PATCH 01/30] init --- packages/publisher/.gitignore | 26 ++++++++++ packages/publisher/README.md | 81 ++++++++++++++++++++++++++++++++ packages/publisher/package.json | 39 +++++++++++++++ packages/publisher/src/index.ts | 0 packages/publisher/tsconfig.json | 15 ++++++ 5 files changed, 161 insertions(+) create mode 100644 packages/publisher/.gitignore create mode 100644 packages/publisher/README.md create mode 100644 packages/publisher/package.json create mode 100644 packages/publisher/src/index.ts create mode 100644 packages/publisher/tsconfig.json diff --git a/packages/publisher/.gitignore b/packages/publisher/.gitignore new file mode 100644 index 000000000..f3620b55e --- /dev/null +++ b/packages/publisher/.gitignore @@ -0,0 +1,26 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem \ No newline at end of file diff --git a/packages/publisher/README.md b/packages/publisher/README.md new file mode 100644 index 000000000..54af19622 --- /dev/null +++ b/packages/publisher/README.md @@ -0,0 +1,81 @@ +
+ oRPC logo +
+ +

+ +
+ + codecov + + + weekly downloads + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +

Typesafe APIs Made Simple 🪄

+ +**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards + +--- + +## Highlights + +- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. +- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. +- **📝 Contract-First Development**: Optionally define your API contract before implementation. +- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. +- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. +- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. +- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. +- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. +- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. +- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. +- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. +- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. + +## Documentation + +You can find the full documentation [here](https://orpc.unnoq.com). + +## Packages + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. +- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. +- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). +- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. +- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. +- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). + +## `@orpc/experimental-publisher` + +Event Publisher with multiple adapter support, resume support, and more. + +## Sponsors + +

+ + + +

+ +## License + +Distributed under the MIT License. See [LICENSE](https://github.com/unnoq/orpc/blob/main/LICENSE) for more information. diff --git a/packages/publisher/package.json b/packages/publisher/package.json new file mode 100644 index 000000000..635b61175 --- /dev/null +++ b/packages/publisher/package.json @@ -0,0 +1,39 @@ +{ + "name": "@orpc/experimental-publisher", + "type": "module", + "version": "1.9.3", + "license": "MIT", + "homepage": "https://orpc.unnoq.com", + "repository": { + "type": "git", + "url": "git+https://github.com/unnoq/orpc.git", + "directory": "packages/publisher" + }, + "keywords": [ + "unnoq", + "orpc" + ], + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + } + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "build:watch": "pnpm run build --watch", + "type:check": "tsc -b" + }, + "dependencies": { + "@orpc/shared": "workspace:*" + } +} diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json new file mode 100644 index 000000000..0cfaeeeb0 --- /dev/null +++ b/packages/publisher/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../shared" } + ], + "include": ["src"], + "exclude": [ + "**/*.bench.*", + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} From 766ab34d98cb43d4ea47cf05ac4dcb734f6d2d89 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 10 Oct 2025 16:33:57 +0700 Subject: [PATCH 02/30] publisher & memory publisher --- packages/publisher/package.json | 11 +- .../publisher/src/adapters/memory.test.ts | 179 +++++++++++ packages/publisher/src/adapters/memory.ts | 117 +++++++ packages/publisher/src/index.ts | 1 + packages/publisher/src/publisher.test.ts | 301 ++++++++++++++++++ packages/publisher/src/publisher.ts | 152 +++++++++ packages/publisher/tsconfig.json | 3 +- packages/shared/src/id.test.ts | 48 ++- packages/shared/src/id.ts | 19 +- pnpm-lock.yaml | 126 ++++---- 10 files changed, 889 insertions(+), 68 deletions(-) create mode 100644 packages/publisher/src/adapters/memory.test.ts create mode 100644 packages/publisher/src/adapters/memory.ts create mode 100644 packages/publisher/src/publisher.test.ts create mode 100644 packages/publisher/src/publisher.ts diff --git a/packages/publisher/package.json b/packages/publisher/package.json index 635b61175..d680c692b 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -19,11 +19,17 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" + }, + "./memory": { + "types": "./dist/adapters/memory.d.mts", + "import": "./dist/adapters/memory.mjs", + "default": "./dist/adapters/memory.mjs" } } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./memory": "./src/adapters/memory.ts" }, "files": [ "dist" @@ -34,6 +40,7 @@ "type:check": "tsc -b" }, "dependencies": { - "@orpc/shared": "workspace:*" + "@orpc/shared": "workspace:*", + "@orpc/standard-server": "workspace:*" } } diff --git a/packages/publisher/src/adapters/memory.test.ts b/packages/publisher/src/adapters/memory.test.ts new file mode 100644 index 000000000..6aa3b0eb6 --- /dev/null +++ b/packages/publisher/src/adapters/memory.test.ts @@ -0,0 +1,179 @@ +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { MemoryPublisher } from './memory' + +describe('memoryPublisher', () => { + it('without resume: can pub/sub but not resume', async () => { + const publisher = new MemoryPublisher() // resume is disabled by default + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + publisher.publish('event1', payload1) + publisher.publish('event3', payload2) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1.mock.calls[0]![0]).toBe(payload1) // ensure without proxy + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + publisher.publish('event1', payload2) + publisher.publish('event2', payload2) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2.mock.calls[0]![0]).toBe(payload2) // ensure without proxy + + await unsub2() + + const unsub11 = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) + expect(listener1).toHaveBeenCalledTimes(1) // resume not happens + await unsub11() + + expect(publisher.size).toEqual(0) // ensure no memory leak + }) + + describe('with resume', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('can pub/sub and resume', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + publisher.publish('event1', payload1) + publisher.publish('event3', payload2) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(payload1) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + publisher.publish('event1', payload2) + publisher.publish('event2', payload2) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledWith(payload2) + + await unsub2() + + const listener3 = vi.fn() + const unsub3 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) + expect(listener3).toHaveBeenCalledTimes(2) // resume happens + expect(listener3).toHaveBeenNthCalledWith(1, payload1) + expect(listener3).toHaveBeenNthCalledWith(2, payload2) + + await unsub3() + expect(publisher.size).toEqual(4) // 4 events are stored + }) + + it('control event.id', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload1 = { order: 1 } + const payload2 = withEventMeta({ order: 2 }, { id: 'some-id', comments: ['hello'] }) + + publisher.publish('event1', payload1) + publisher.publish('event1', payload2) + + expect(listener1).toHaveBeenCalledTimes(2) + expect(listener1).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload1) + expect(p).toEqual(payload1) + expect(getEventMeta(p)).toEqual({ id: '1' }) + return true + })) + expect(listener1).toHaveBeenNthCalledWith(2, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + expect(getEventMeta(p)).toEqual({ id: '2', comments: ['hello'] }) // id is overridden + return true + })) + + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2, { lastEventId: '0' }) + expect(listener2).toHaveBeenCalledTimes(2) + expect(listener2).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload1) + expect(p).toEqual(payload1) + expect(getEventMeta(p)).toEqual({ id: '1' }) + return true + })) + expect(listener2).toHaveBeenNthCalledWith(2, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + expect(getEventMeta(p)).toEqual({ id: '2', comments: ['hello'] }) // id is overridden + return true + })) + + await unsub1() + await unsub2() + expect(publisher.size).toEqual(2) // 2 events are stored + }) + + it('resume event.id > lastEventId and in order', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) + + for (let i = 1; i <= 1000; i++) { + publisher.publish('event', { order: i }) + } + + for (let i = 0; i < 10; i++) { + const lastEventId = Math.floor(Math.random() * 990) + const listener1 = vi.fn() + const unsub = await publisher.subscribe('event', listener1, { lastEventId: lastEventId.toString(36) }) + expect(listener1).toHaveBeenCalledTimes(1000 - lastEventId) + expect(listener1).toHaveBeenNthCalledWith(1, { order: lastEventId + 1 }) + expect(listener1).toHaveBeenNthCalledWith(2, { order: lastEventId + 2 }) + expect(listener1).toHaveBeenNthCalledWith(10, { order: lastEventId + 10 }) + await unsub() + } + }) + + it('remove expired events on publish', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) + + publisher.publish('event1', { order: 1 }) + publisher.publish('event2', { order: 2 }) + publisher.publish('event3', { order: 3 }) + publisher.publish('event1', { order: 4 }) + expect(publisher.size).toEqual(4) // 4 events are stored + + vi.advanceTimersByTime(500) // not expired yet + publisher.publish('event1', { order: 5 }) + expect(publisher.size).toEqual(5) // 5 events are stored + + vi.advanceTimersByTime(500) // expired + publisher.publish('event2', { order: 6 }) + expect(publisher.size).toEqual(2) // 2 events are stored + + vi.advanceTimersByTime(1000) // expired + publisher.publish('event10', { order: 7 }) + expect(publisher.size).toEqual(1) // 1 event is stored + }) + }) +}) diff --git a/packages/publisher/src/adapters/memory.ts b/packages/publisher/src/adapters/memory.ts new file mode 100644 index 000000000..0216a396f --- /dev/null +++ b/packages/publisher/src/adapters/memory.ts @@ -0,0 +1,117 @@ +import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' +import { compareSequentialIds, EventPublisher, SequentialIdGenerator } from '@orpc/shared' +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { Publisher } from '../publisher' + +export interface MemoryPublisherOptions extends PublisherOptions { + /** + * How long (in seconds) to retain events for replay. + * + * @remark + * This allows new subscribers to "catch up" on missed events using `lastEventId`. + * Note that event cleanup is deferred for performance reasons — meaning some + * expired events may still be available for a short period of time, and listeners + * might still receive them. + * + * @default NaN (disabled) + */ + resumeRetentionSeconds?: number +} + +export class MemoryPublisher> extends Publisher { + private readonly eventPublisher = new EventPublisher() + private readonly idGenerator = new SequentialIdGenerator() + private readonly retentionSeconds: number + private readonly events: Map = new Map() + + /** + * Useful for measuring memory usage. + * + * @internal + * + */ + get size(): number { + let size = this.eventPublisher.size + for (const events of this.events) { + /* v8 ignore next 1 */ + size += events[1].length || 1 // empty array should never happen so we treat it as a single event + } + + return size + } + + private get isResumeEnabled(): boolean { + return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 + } + + constructor({ resumeRetentionSeconds, ...options }: MemoryPublisherOptions = {}) { + super(options) + + this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN + } + + async publish(event: K, payload: T[K]): Promise { + this.cleanup() + + if (this.isResumeEnabled) { + const now = Date.now() + const expiresAt = now + this.retentionSeconds * 1000 + + let events = this.events.get(event) + if (!events) { + this.events.set(event, events = []) + } + + payload = withEventMeta(payload, { ...getEventMeta(payload), id: this.idGenerator.generate() }) + events.push({ expiresAt, payload }) + } + + this.eventPublisher.publish(event, payload) + } + + protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + if (this.isResumeEnabled && typeof options?.lastEventId === 'string') { + const events = this.events.get(event) + if (events) { + for (const { payload } of events) { + const id = getEventMeta(payload)?.id + if (typeof id === 'string' && compareSequentialIds(id, options.lastEventId) > 0) { + listener(payload as T[K]) + } + } + } + } + + const syncUnsub = this.eventPublisher.subscribe(event, listener) + + return async () => { + syncUnsub() + } + } + + private lastCleanupTime: number | null = null + private cleanup(): void { + if (!this.isResumeEnabled) { + return + } + + const now = Date.now() + + if (this.lastCleanupTime !== null && this.lastCleanupTime + this.retentionSeconds * 1000 > now) { + return + } + + this.lastCleanupTime = now + + for (const [event, events] of this.events) { + const validEvents = events.filter(event => event.expiresAt > now) + + if (validEvents.length > 0) { + this.events.set(event, validEvents) + } + else { + this.events.delete(event) + } + } + } +} diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index e69de29bb..912da4aac 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -0,0 +1 @@ +export * from './publisher' diff --git a/packages/publisher/src/publisher.test.ts b/packages/publisher/src/publisher.test.ts new file mode 100644 index 000000000..30f5e9694 --- /dev/null +++ b/packages/publisher/src/publisher.test.ts @@ -0,0 +1,301 @@ +import type { PublisherSubscribeListenerOptions } from './publisher' +import { Publisher } from './publisher' + +// Concrete implementation for testing +class TestPublisher> extends Publisher { + listeners = new Map void>>() + + async publish(event: K, payload: T[K]): Promise { + const eventListeners = this.listeners.get(event) + if (eventListeners) { + eventListeners.forEach(listener => listener(payload)) + } + } + + protected async subscribeListener( + event: K, + listener: (payload: T[K]) => void, + options?: PublisherSubscribeListenerOptions, + ): Promise<() => Promise> { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)!.add(listener) + + return async () => { + this.listeners.get(event)?.delete(listener) + } + } +} + +type TestEvents = { + message: { text: string } + count: { value: number } + user: { id: string, name: string } +} + +describe('publisher', () => { + let publisher: TestPublisher + + beforeEach(() => { + publisher = new TestPublisher() + }) + + afterEach(() => { + let size = 0 + for (const listeners of publisher.listeners.values()) { + size += listeners.size + } + expect(size).toBe(0) // ensure all listeners are unsubscribed correctly + }) + + describe('subscribe with callback', () => { + it('should subscribe and receive events', async () => { + const listener = vi.fn() + const unsub = await publisher.subscribe('message', listener) + + await publisher.publish('message', { text: 'hello' }) + + expect(listener).toHaveBeenCalledWith({ text: 'hello' }) + expect(listener).toHaveBeenCalledTimes(1) + + await unsub() + }) + + it('should handle multiple subscribers', async () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('message', listener1) + const unsub2 = await publisher.subscribe('message', listener2) + + await publisher.publish('message', { text: 'hello' }) + + expect(listener1).toHaveBeenCalledWith({ text: 'hello' }) + expect(listener2).toHaveBeenCalledWith({ text: 'hello' }) + + await unsub1() + await unsub2() + }) + + it('should unsubscribe correctly', async () => { + const listener = vi.fn() + const unsubscribe = await publisher.subscribe('message', listener) + + await publisher.publish('message', { text: 'first' }) + await unsubscribe() + await publisher.publish('message', { text: 'second' }) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ text: 'first' }) + }) + }) + + describe('subscribe with async iterator', () => { + it('should iterate over events', async () => { + const events: string[] = [] + const iterator = publisher.subscribe('message') + + setTimeout(() => { + publisher.publish('message', { text: 'first' }) + publisher.publish('message', { text: 'second' }) + publisher.publish('message', { text: 'third' }) + setTimeout(() => iterator.return(), 50) + }, 10) + + for await (const payload of iterator) { + events.push(payload.text) + } + + expect(events).toEqual(['first', 'second', 'third']) + }) + + it('should buffer events when consumer is slow', async () => { + const iterator = publisher.subscribe('message', { maxBufferedEvents: 3 }) + + // Publish events before consuming + await publisher.publish('message', { text: 'first' }) + await publisher.publish('message', { text: 'second' }) + await publisher.publish('message', { text: 'third' }) + + const result1 = await iterator.next() + const result2 = await iterator.next() + const result3 = await iterator.next() + + expect(result1.value?.text).toBe('first') + expect(result2.value?.text).toBe('second') + expect(result3.value?.text).toBe('third') + + await iterator.return() + }) + + it('should drop oldest events when buffer exceeds maxBufferedEvents', async () => { + const iterator = publisher.subscribe('message', { maxBufferedEvents: 2 }) + + // Publish 4 events, buffer can only hold 2 + await publisher.publish('message', { text: 'first' }) + await publisher.publish('message', { text: 'second' }) + await publisher.publish('message', { text: 'third' }) + await publisher.publish('message', { text: 'fourth' }) + + const result1 = await iterator.next() + const result2 = await iterator.next() + + // First two should be dropped, we get third and fourth + expect(result1.value?.text).toBe('third') + expect(result2.value?.text).toBe('fourth') + + await iterator.return() + }) + + it('should handle maxBufferedEvents of 0', async () => { + const iterator = publisher.subscribe('message', { maxBufferedEvents: 0 }) + + // Publish event before consuming - should be dropped + await publisher.publish('message', { text: 'dropped' }) + + // Start consuming + const nextPromise = iterator.next() + await new Promise(resolve => setTimeout(resolve, 1)) + + // Publish while waiting + await publisher.publish('message', { text: 'received' }) + + const result = await nextPromise + expect(result.value?.text).toBe('received') + + await iterator.return() + }) + + it('should handle maxBufferedEvents of 1', async () => { + const iterator = publisher.subscribe('message', { maxBufferedEvents: 1 }) + + await publisher.publish('message', { text: 'first' }) + await publisher.publish('message', { text: 'second' }) + + const result = await iterator.next() + // Only the latest event is kept + expect(result.value?.text).toBe('second') + + await iterator.return() + }) + + it('should abort with signal', async () => { + const controller = new AbortController() + const iterator = publisher.subscribe('message', { signal: controller.signal }) + + const nextPromise = iterator.next() + controller.abort(new Error('Aborted')) + + await expect(nextPromise).rejects.toThrow('Aborted') + }) + + it('should throw if signal is already aborted', () => { + const controller = new AbortController() + controller.abort(new Error('Already aborted')) + + expect(() => { + publisher.subscribe('message', { signal: controller.signal }) + }).toThrow('Already aborted') + }) + + it('should cleanup on abort', async () => { + const controller = new AbortController() + const iterator = publisher.subscribe('message', { signal: controller.signal }) + + const nextPromise = iterator.next() + controller.abort() + await expect(nextPromise).rejects.toThrow('This operation was aborted') + + // Publishing after abort should not affect the iterator + await publisher.publish('message', { text: 'after abort' }) + + const result = await iterator.next() + expect(result.done).toBe(true) + }) + + it('should cleanup on return', async () => { + const iterator = publisher.subscribe('message') + + await publisher.publish('message', { text: 'first' }) + await iterator.next() + + const returnResult = await iterator.return() + expect(returnResult.done).toBe(true) + + // Further iterations should be done + const result = await iterator.next() + expect(result.done).toBe(true) + }) + + it('should handle concurrent consumers', async () => { + const iterator1 = publisher.subscribe('message') + const iterator2 = publisher.subscribe('message') + + await publisher.publish('message', { text: 'concurrent' }) + + const [result1, result2] = await Promise.all([ + iterator1.next(), + iterator2.next(), + ]) + + expect(result1.value?.text).toBe('concurrent') + expect(result2.value?.text).toBe('concurrent') + + await iterator1.return() + await iterator2.return() + }) + + it('should use instance maxBufferedEvents by default', async () => { + const pub = new TestPublisher({ maxBufferedEvents: 1 }) + const iterator = pub.subscribe('message') + + await pub.publish('message', { text: 'first' }) + await pub.publish('message', { text: 'second' }) + + const result = await iterator.next() + expect(result.value?.text).toBe('second') + + await iterator.return() + }) + + it('should override instance maxBufferedEvents with options', async () => { + const pub = new TestPublisher({ maxBufferedEvents: 1 }) + const iterator = pub.subscribe('message', { maxBufferedEvents: 3 }) + + await pub.publish('message', { text: 'first' }) + await pub.publish('message', { text: 'second' }) + + const result1 = await iterator.next() + const result2 = await iterator.next() + + expect(result1.value?.text).toBe('first') + expect(result2.value?.text).toBe('second') + + await iterator.return() + }) + + it('should handle rapid publishing and consuming', async () => { + const iterator = publisher.subscribe('count') + const received: number[] = [] + + const publishPromise = (async () => { + for (let i = 0; i < 100; i++) { + await publisher.publish('count', { value: i }) + } + })() + + for (let i = 0; i < 100; i++) { + const result = await iterator.next() + if (!result.done) { + received.push(result.value.value) + } + } + + await publishPromise + await iterator.return() + + expect(received).toHaveLength(100) + }) + }) +}) diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts new file mode 100644 index 000000000..a7f29bb8c --- /dev/null +++ b/packages/publisher/src/publisher.ts @@ -0,0 +1,152 @@ +import { AsyncIteratorClass } from '@orpc/shared' + +export interface PublisherOptions { + /** + * Maximum number of events to buffer for async iterator subscribers. + * + * If the buffer exceeds this limit, the oldest event is dropped. + * This prevents unbounded memory growth if consumers process events slowly. + * + * Set to: + * - `0`: Disable buffering. Events must be consumed before the next one arrives. + * - `1`: Only keep the latest event. Useful for real-time updates where only the most recent value matters. + * - `Infinity`: Keep all events. Ensures no data loss, but may lead to high memory usage. + * + * @default 100 + */ + maxBufferedEvents?: number +} + +export interface PublisherSubscribeListenerOptions { + /** + * Resume from a specific event ID + */ + lastEventId?: string | undefined +} + +export interface PublisherSubscribeIteratorOptions extends PublisherSubscribeListenerOptions, PublisherOptions { + /** + * Abort signal, automatically unsubscribes on abort + */ + signal?: AbortSignal | undefined | null +} + +export abstract class Publisher> { + private readonly maxBufferedEvents: Exclude + + constructor( + options: PublisherOptions = {}, + ) { + this.maxBufferedEvents = options.maxBufferedEvents ?? 100 + } + + /** + * Publish an event to subscribers + */ + abstract publish(event: K, payload: T[K]): Promise + + /** + * Subscribes to a specific event using a callback function. + * Returns an unsubscribe function to remove the listener. + * + * @remarks + * This method should be protected to avoid conflicts with `subscribe` method + */ + protected abstract subscribeListener( + event: K, + listener: (payload: T[K]) => void, + options?: PublisherSubscribeListenerOptions + ): Promise<() => Promise> + + /** + * Subscribes to a specific event using a callback function. + * Returns an unsubscribe function to remove the listener. + * + * @example + * ```ts + * const unsubscribe = publisher.subscribe('event', (payload) => { + * console.log(payload) + * }) + * + * // Later + * unsubscribe() + * ``` + */ + subscribe(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> + /** + * Subscribes to a specific event using an async iterator. + * Useful for `for await...of` loops with optional buffering and abort support. + * + * @example + * ```ts + * for await (const payload of publisher.subscribe('event', { signal })) { + * console.log(payload) + * } + * ``` + */ + subscribe(event: K, options?: PublisherSubscribeIteratorOptions): AsyncIteratorClass + subscribe( + event: K, + listenerOrOptions?: ((payload: T[K]) => void) | PublisherSubscribeIteratorOptions, + listenerOptions?: PublisherSubscribeListenerOptions, + ): Promise<() => Promise> | AsyncIteratorClass { + if (typeof listenerOrOptions === 'function') { + return this.subscribeListener(event, listenerOrOptions, listenerOptions) + } + + const signal = listenerOrOptions?.signal + const maxBufferedEvents = listenerOrOptions?.maxBufferedEvents ?? this.maxBufferedEvents + + signal?.throwIfAborted() + + const bufferedEvents: T[K][] = [] + const pullResolvers: [(result: IteratorResult) => void, (error: Error) => void][] = [] + + const unsubscribePromise = this.subscribe(event, (payload) => { + const resolver = pullResolvers.shift() + + if (resolver) { + resolver[0]({ done: false, value: payload }) + } + else { + bufferedEvents.push(payload) + + if (bufferedEvents.length > maxBufferedEvents) { + bufferedEvents.shift() + } + } + }) + + const abortListener = (event: any) => { + pullResolvers.forEach(resolver => resolver[1](event.target.reason)) + pullResolvers.length = 0 + bufferedEvents.length = 0 + unsubscribePromise.then(unsubscribe => unsubscribe()).catch(() => {}) // ignore error + } + + signal?.addEventListener('abort', abortListener, { once: true }) + + return new AsyncIteratorClass(async () => { + await unsubscribePromise // make sure subscription is ready + + if (signal?.aborted) { + throw signal.reason + } + + if (bufferedEvents.length > 0) { + return { done: false, value: bufferedEvents.shift()! } + } + + return new Promise((resolve, reject) => { + pullResolvers.push([resolve, reject]) + }) + }, async () => { + signal?.removeEventListener('abort', abortListener) + pullResolvers.forEach(resolver => resolver[0]({ done: true, value: undefined })) + pullResolvers.length = 0 + bufferedEvents.length = 0 + + await unsubscribePromise.then(unsubscribe => unsubscribe()) + }) + } +} diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json index 0cfaeeeb0..15db1f79b 100644 --- a/packages/publisher/tsconfig.json +++ b/packages/publisher/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.lib.json", "references": [ - { "path": "../shared" } + { "path": "../shared" }, + { "path": "../standard-server" } ], "include": ["src"], "exclude": [ diff --git a/packages/shared/src/id.test.ts b/packages/shared/src/id.test.ts index f96b78c19..214ee2c6f 100644 --- a/packages/shared/src/id.test.ts +++ b/packages/shared/src/id.test.ts @@ -1,4 +1,4 @@ -import { SequentialIdGenerator } from './id' +import { compareSequentialIds, SequentialIdGenerator } from './id' describe('sequentialIdGenerator', () => { it('unique and increase', () => { @@ -10,7 +10,7 @@ describe('sequentialIdGenerator', () => { expect(idGenerator.generate()).toBe('3') for (let i = 4; i < 1000; i++) { - expect(idGenerator.generate()).toBe(i.toString(32)) + expect(idGenerator.generate()).toBe(i.toString(36)) } }) @@ -28,3 +28,47 @@ describe('sequentialIdGenerator', () => { expect(generatedIds.size).toBe(size) }) }) + +describe('compareSequentialIds', () => { + it('should return 0 when ids are equal', () => { + expect(compareSequentialIds('a', 'a')).toBe(0) + expect(compareSequentialIds('10', '10')).toBe(0) + }) + + it('should return negative when a < b (same length)', () => { + expect(compareSequentialIds('a', 'b')).toBeLessThan(0) + expect(compareSequentialIds('09', '0a')).toBeLessThan(0) + }) + + it('should return positive when a > b (same length)', () => { + expect(compareSequentialIds('b', 'a')).toBeGreaterThan(0) + expect(compareSequentialIds('0b', '0a')).toBeGreaterThan(0) + }) + + it('should return negative when a is shorter (length difference)', () => { + expect(compareSequentialIds('z', '10')).toBeLessThan(0) + expect(compareSequentialIds('zz', '100')).toBeLessThan(0) + }) + + it('should return positive when a is longer (length difference)', () => { + expect(compareSequentialIds('10', 'z')).toBeGreaterThan(0) + expect(compareSequentialIds('100', 'zz')).toBeGreaterThan(0) + }) + + it('random check', { repeats: 1000 }, () => { + const bigInt1 = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)) + const bigInt2 = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)) + + const result = compareSequentialIds(bigInt1.toString(36), bigInt2.toString(36)) + + if (bigInt1 > bigInt2) { + expect(result).toBeGreaterThan(0) + } + else if (bigInt1 < bigInt2) { + expect(result).toBeLessThan(0) + } + else { + expect(result).toBe(0) + } + }) +}) diff --git a/packages/shared/src/id.ts b/packages/shared/src/id.ts index d2d4ad64d..c778c350f 100644 --- a/packages/shared/src/id.ts +++ b/packages/shared/src/id.ts @@ -1,9 +1,24 @@ export class SequentialIdGenerator { - private index = BigInt(0) + private index = BigInt(1) generate(): string { - const id = this.index.toString(32) + const id = this.index.toString(36) this.index++ return id } } + +/** + * Compares two sequential IDs. + * Returns: + * - negative if `a` < `b` + * - positive if `a` > `b` + * - 0 if equal + */ +export function compareSequentialIds(a: string, b: string): number { + if (a.length !== b.length) { + return a.length - b.length + } + + return a < b ? -1 : a > b ? 1 : 0 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf4f4ebda..271429985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,6 +492,15 @@ importers: specifier: ^0.205.0 version: 0.205.0(@opentelemetry/api@1.9.0) + packages/publisher: + dependencies: + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@orpc/standard-server': + specifier: workspace:* + version: link:../standard-server + packages/react: dependencies: '@orpc/client': @@ -926,7 +935,7 @@ importers: version: 19.2.0(@types/react@19.2.0) astro: specifier: ^5.14.1 - version: 5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1) + version: 5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1) react: specifier: ^19.2.0 version: 19.2.0 @@ -1376,7 +1385,7 @@ importers: version: 5.90.2(vue@3.5.22(typescript@5.8.3)) nuxt: specifier: ^4.1.2 - version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) vue: specifier: latest version: 3.5.22(typescript@5.8.3) @@ -1412,7 +1421,7 @@ importers: version: 0.15.3(solid-js@1.9.9) '@solidjs/start': specifier: ^1.2.0 - version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) '@tanstack/solid-query': specifier: ^5.90.3 version: 5.90.3(solid-js@1.9.9) @@ -1421,7 +1430,7 @@ importers: version: 1.9.9 vinxi: specifier: ^0.5.8 - version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-top-level-await: specifier: ^1.6.0 version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.52.4)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -3891,9 +3900,6 @@ packages: '@internationalized/number@3.6.5': resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} - '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} @@ -7094,9 +7100,6 @@ packages: '@types/node@22.18.8': resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==} - '@types/node@24.5.2': - resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} - '@types/node@24.7.0': resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} @@ -7282,6 +7285,9 @@ packages: peerDependencies: vue: '>=3.5.18' + '@upstash/redis@1.35.5': + resolution: {integrity: sha512-KdLdNAspQGOTGeC++o2LDBzNbMXrfInnmW5nUJfNXabnVh8X4NPrlJ0X4j75cBUShiMpXB3uI1ql4KpFQeqrHQ==} + '@valibot/to-json-schema@1.3.0': resolution: {integrity: sha512-82Vv6x7sOYhv5YmTRgSppSqj1nn2pMCk5BqCMGWYp0V/fq+qirrbGncqZAtZ09/lrO40ne/7z8ejwE728aVreg==} peerDependencies: @@ -10837,14 +10843,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ioredis@5.7.0: - resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} - engines: {node: '>=12.22.0'} - ioredis@5.8.0: resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} engines: {node: '>=12.22.0'} + ioredis@5.8.1: + resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==} + engines: {node: '>=12.22.0'} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -14597,9 +14603,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.12.0: - resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} - undici-types@7.14.0: resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} @@ -18098,8 +18101,6 @@ snapshots: dependencies: '@swc/helpers': 0.5.17 - '@ioredis/commands@1.3.0': {} - '@ioredis/commands@1.4.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -21139,11 +21140,11 @@ snapshots: dependencies: solid-js: 1.9.9 - '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tanstack/server-functions-plugin': 1.121.21(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) cookie-es: 2.0.0 defu: 6.1.4 error-stack-parser: 2.1.4 @@ -21155,7 +21156,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.9) tinyglobby: 0.2.15 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.9(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -22076,10 +22077,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.5.2': - dependencies: - undici-types: 7.12.0 - '@types/node@24.7.0': dependencies: undici-types: 7.14.0 @@ -22336,6 +22333,11 @@ snapshots: unhead: 2.0.17 vue: 3.5.22(typescript@5.8.3) + '@upstash/redis@1.35.5': + dependencies: + uncrypto: 0.1.3 + optional: true + '@valibot/to-json-schema@1.3.0(valibot@1.1.0(typescript@5.8.3))': dependencies: valibot: 1.1.0(typescript@5.8.3) @@ -22400,7 +22402,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.28.4 acorn: 8.15.0 @@ -22411,18 +22413,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.15.0 acorn-loose: 8.5.2 acorn-typescript: 1.4.13(acorn@8.15.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: @@ -23426,7 +23428,7 @@ snapshots: astring@1.9.0: {} - astro@5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1): + astro@5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.3 @@ -23480,7 +23482,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.5.2 unist-util-visit: 5.0.0 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) vfile: 6.0.3 vite: 6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) vitefu: 1.1.1(vite@6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -23664,7 +23666,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1 + debug: 4.4.3 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -23797,7 +23799,7 @@ snapshots: bun-types@1.2.23(@types/react@19.2.0): dependencies: - '@types/node': 24.5.2 + '@types/node': 22.18.8 '@types/react': 19.2.0 bundle-name@4.1.0: @@ -26103,7 +26105,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -26952,9 +26954,9 @@ snapshots: internmap@2.0.3: {} - ioredis@5.7.0: + ioredis@5.8.0: dependencies: - '@ioredis/commands': 1.3.0 + '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 debug: 4.4.3 denque: 2.1.0 @@ -26966,7 +26968,7 @@ snapshots: transitivePeerDependencies: - supports-color - ioredis@5.8.0: + ioredis@5.8.1: dependencies: '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 @@ -26979,6 +26981,7 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color + optional: true ip-address@10.0.1: {} @@ -28389,7 +28392,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nitropack@2.12.4(@netlify/blobs@9.1.2)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): + nitropack@2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.10(encoding@0.1.13)(rollup@4.52.4) @@ -28424,7 +28427,7 @@ snapshots: h3: 1.15.4 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.7.0 + ioredis: 5.8.0 jiti: 2.6.1 klona: 2.0.6 knitwork: 1.2.0 @@ -28457,7 +28460,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.2.5 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.7.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) untyped: 2.0.0 unwasm: 0.3.9 youch: 4.1.0-beta.8 @@ -28493,7 +28496,7 @@ snapshots: - supports-color - uploadthing - nitropack@2.12.6(@netlify/blobs@9.1.2)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): + nitropack@2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@rollup/plugin-alias': 5.1.1(rollup@4.52.4) @@ -28560,7 +28563,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.3.0 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) untyped: 2.0.0 unwasm: 0.3.11 youch: 4.1.0-beta.11 @@ -28710,7 +28713,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): + nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@nuxt/cli': 3.28.0(magicast@0.3.5) '@nuxt/devalue': 2.0.2 @@ -28745,7 +28748,7 @@ snapshots: mlly: 1.8.0 mocked-exports: 0.1.1 nanotar: 0.2.0 - nitropack: 2.12.6(@netlify/blobs@9.1.2)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) + nitropack: 2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) nypm: 0.6.2 ofetch: 1.4.1 ohash: 2.0.11 @@ -28769,7 +28772,7 @@ snapshots: unimport: 5.4.0 unplugin: 2.3.10 unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.5.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) - unstorage: 1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) untyped: 2.0.0 vue: 3.5.22(typescript@5.8.3) vue-bundle-renderer: 2.1.2 @@ -30354,7 +30357,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -30477,7 +30480,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -31590,8 +31593,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.12.0: {} - undici-types@7.14.0: optional: true @@ -31824,7 +31825,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.16.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.7.0): + unstorage@1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31836,10 +31837,11 @@ snapshots: ufo: 1.6.1 optionalDependencies: '@netlify/blobs': 9.1.2 + '@upstash/redis': 1.35.5 db0: 0.3.2(better-sqlite3@12.4.1) - ioredis: 5.7.0 + ioredis: 5.8.0 - unstorage@1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.7.0): + unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31851,10 +31853,11 @@ snapshots: ufo: 1.6.1 optionalDependencies: '@netlify/blobs': 9.1.2 + '@upstash/redis': 1.35.5 db0: 0.3.2(better-sqlite3@12.4.1) - ioredis: 5.7.0 + ioredis: 5.8.0 - unstorage@1.17.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0): + unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31866,8 +31869,9 @@ snapshots: ufo: 1.6.1 optionalDependencies: '@netlify/blobs': 9.1.2 + '@upstash/redis': 1.35.5 db0: 0.3.2(better-sqlite3@12.4.1) - ioredis: 5.8.0 + ioredis: 5.8.1 until-async@3.0.2: {} @@ -32005,7 +32009,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) @@ -32026,7 +32030,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.12.4(@netlify/blobs@9.1.2)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) + nitropack: 2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -32038,7 +32042,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.16.1(@netlify/blobs@9.1.2)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.7.0) + unstorage: 1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) vite: 6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) zod: 3.25.76 transitivePeerDependencies: From 688bff71bcf2d7e74ec1100097eab2a691dff2ec Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 13 Oct 2025 09:43:35 +0700 Subject: [PATCH 03/30] improve --- packages/publisher/src/adapters/memory.ts | 10 +++++----- packages/publisher/src/publisher.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/publisher/src/adapters/memory.ts b/packages/publisher/src/adapters/memory.ts index 0216a396f..8a4736bf6 100644 --- a/packages/publisher/src/adapters/memory.ts +++ b/packages/publisher/src/adapters/memory.ts @@ -40,7 +40,7 @@ export class MemoryPublisher> extends Publisher return size } - private get isResumeEnabled(): boolean { + protected get isResumeEnabled(): boolean { return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 } @@ -50,7 +50,7 @@ export class MemoryPublisher> extends Publisher this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN } - async publish(event: K, payload: T[K]): Promise { + async publish(event: K, payload: T[K]): Promise { this.cleanup() if (this.isResumeEnabled) { @@ -69,7 +69,7 @@ export class MemoryPublisher> extends Publisher this.eventPublisher.publish(event, payload) } - protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { if (this.isResumeEnabled && typeof options?.lastEventId === 'string') { const events = this.events.get(event) if (events) { @@ -89,8 +89,8 @@ export class MemoryPublisher> extends Publisher } } - private lastCleanupTime: number | null = null - private cleanup(): void { + protected lastCleanupTime: number | null = null + protected cleanup(): void { if (!this.isResumeEnabled) { return } diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts index a7f29bb8c..b480a67b1 100644 --- a/packages/publisher/src/publisher.ts +++ b/packages/publisher/src/publisher.ts @@ -43,7 +43,7 @@ export abstract class Publisher> { /** * Publish an event to subscribers */ - abstract publish(event: K, payload: T[K]): Promise + abstract publish(event: K, payload: T[K]): Promise /** * Subscribes to a specific event using a callback function. @@ -52,7 +52,7 @@ export abstract class Publisher> { * @remarks * This method should be protected to avoid conflicts with `subscribe` method */ - protected abstract subscribeListener( + protected abstract subscribeListener( event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions @@ -72,7 +72,7 @@ export abstract class Publisher> { * unsubscribe() * ``` */ - subscribe(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> + subscribe(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> /** * Subscribes to a specific event using an async iterator. * Useful for `for await...of` loops with optional buffering and abort support. @@ -84,8 +84,8 @@ export abstract class Publisher> { * } * ``` */ - subscribe(event: K, options?: PublisherSubscribeIteratorOptions): AsyncIteratorClass - subscribe( + subscribe(event: K, options?: PublisherSubscribeIteratorOptions): AsyncIteratorClass + subscribe( event: K, listenerOrOptions?: ((payload: T[K]) => void) | PublisherSubscribeIteratorOptions, listenerOptions?: PublisherSubscribeListenerOptions, From 346f80bfb4b14e8d189d030a74f7c3bc84414eb5 Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 13 Oct 2025 16:01:37 +0700 Subject: [PATCH 04/30] redis - wip --- packages/publisher/package.json | 12 ++ packages/publisher/src/adapters/ioredis.ts | 194 +++++++++++++++++++++ pnpm-lock.yaml | 102 ++++------- 3 files changed, 243 insertions(+), 65 deletions(-) create mode 100644 packages/publisher/src/adapters/ioredis.ts diff --git a/packages/publisher/package.json b/packages/publisher/package.json index d680c692b..d48400cf2 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -39,8 +39,20 @@ "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, + "peerDependencies": { + "ioredis": ">=5.8.1" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } + }, "dependencies": { + "@orpc/client": "workspace:*", "@orpc/shared": "workspace:*", "@orpc/standard-server": "workspace:*" + }, + "devDependencies": { + "ioredis": "^5.8.1" } } diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts new file mode 100644 index 000000000..5d2cae05f --- /dev/null +++ b/packages/publisher/src/adapters/ioredis.ts @@ -0,0 +1,194 @@ +import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOptions } from '@orpc/client/standard' +import type Redis from 'ioredis' +import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' +import { StandardRPCJsonSerializer } from '@orpc/client/standard' +import { stringifyJSON } from '@orpc/shared' +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { Publisher } from '../publisher' + +type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } + +export interface IORedisPublisherOptions extends PublisherOptions, StandardRPCJsonSerializerOptions { + /** + * How long (in seconds) to retain events for replay. + * + * @remark + * This allows new subscribers to "catch up" on missed events using `lastEventId`. + * Note that event cleanup is deferred for performance reasons — meaning some + * expired events may still be available for a short period of time, and listeners + * might still receive them. + * + * @default NaN (disabled) + */ + resumeRetentionSeconds?: number + + /** + * The prefix to use for Redis keys. + * + * @default orpc:publisher: + */ + prefix?: string +} + +export class IORedisPublisher> extends Publisher { + protected readonly prefix: string + protected readonly serializer: StandardRPCJsonSerializer + protected readonly retentionSeconds: number + protected readonly listeners = new Map void>>() + protected redisListener: ((channel: string, message: string) => void) | undefined + + protected get isResumeEnabled(): boolean { + return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 + } + + constructor( + private readonly redis: Redis, + { resumeRetentionSeconds, prefix, ...options }: IORedisPublisherOptions = {}, + ) { + super(options) + + this.prefix = prefix ?? 'orpc:publisher:' + this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN + this.serializer = new StandardRPCJsonSerializer(options) + } + + protected lastCleanupTime: number | undefined + override async publish(event: K, payload: T[K]): Promise { + const key = this.prefixKey(event) + + const serialized = this.serializePayload(payload) + + let id: string | undefined + if (this.isResumeEnabled) { + const now = Date.now() + if (this.lastCleanupTime === undefined || this.lastCleanupTime + this.retentionSeconds * 1000 < now) { + this.lastCleanupTime = now + const result = await this.redis.multi() + .expire(key, this.retentionSeconds * 2) // double to avoid expires new events + .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) + .xadd(key, '*', stringifyJSON(serialized)) + .exec() + + if (result) { + for (const [error] of result) { + if (error) { + throw error + } + } + } + + id = (result![2]![1] as string | null) ?? undefined + } + else { + const result = await this.redis.xadd(key, '*', stringifyJSON(serialized)) + id = result ?? undefined + } + } + + await this.redis.publish(key, stringifyJSON({ ...serialized, id })) + } + + protected override async subscribeListener(event: K, _listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + const key = this.prefixKey(event) + + const lastEventId = options?.lastEventId + let pendingPayloads: T[K][] | undefined = [] + + const listener = (payload: T[K]) => { + if (pendingPayloads) { + pendingPayloads.push(payload) + } + else { + _listener(payload) + } + } + + if (!this.redisListener) { + this.redisListener = (channel: string, message: string) => { + const listeners = this.listeners.get(channel) + + if (listeners) { + const { id, ...rest } = JSON.parse(message) + const payload = this.deserializePayload(id, rest) + + for (const listener of listeners) { + listener(payload) + } + } + } + + this.redis.on('message', this.redisListener) + } + + let listeners = this.listeners.get(key) + if (!listeners) { + await this.redis.subscribe(key) + this.listeners.set(key, listeners = new Set()) // only set after subscribe successfully + } + + listeners.add(listener) + + void (async () => { + try { + if (typeof lastEventId === 'string') { + const results = await this.redis.xread('STREAMS', key, lastEventId) + + if (results && results[0]) { + const [_, items] = results[0] + const firstPendingId = getEventMeta(pendingPayloads[0])?.id + for (const [id, fields] of items) { + if (id === firstPendingId) { + break + } + + const serialized = fields[0]! + const payload = this.deserializePayload(id, JSON.parse(serialized)) + listener(payload) + } + } + } + } + catch { + // TODO: log error + } + finally { + for (const payload of pendingPayloads) { + listener(payload) + } + + pendingPayloads = undefined // disable pending + } + })() + + return async () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.listeners.delete(key) // should execute before async to avoid throw + + if (this.redisListener && this.listeners.size === 0) { + this.redis.off('message', this.redisListener) + this.redisListener = undefined + } + + await this.redis.unsubscribe(key) + } + } + } + + protected prefixKey(key: string): string { + return `${this.prefix}${key}` + } + + protected serializePayload(payload: object): SerializedPayload { + const eventMeta = getEventMeta(payload) + const [json, meta] = this.serializer.serialize(payload) + return { json: json as object, meta, eventMeta } + } + + protected deserializePayload(id: string | undefined, { json, meta, eventMeta }: SerializedPayload): any { + return withEventMeta( + this.serializer.deserialize(json, meta) as object, + id === undefined ? { ...eventMeta } : { ...eventMeta, id }, + ) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 271429985..6ecc3d31f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,12 +494,19 @@ importers: packages/publisher: dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client '@orpc/shared': specifier: workspace:* version: link:../shared '@orpc/standard-server': specifier: workspace:* version: link:../standard-server + devDependencies: + ioredis: + specifier: ^5.8.1 + version: 5.8.1 packages/react: dependencies: @@ -1043,7 +1050,7 @@ importers: version: 5.90.2(react@19.2.0) '@types/bun': specifier: latest - version: 1.2.23(@types/react@19.2.0) + version: 1.3.0(@types/react@19.2.0) '@types/react': specifier: ^19.2.0 version: 19.2.0 @@ -1385,7 +1392,7 @@ importers: version: 5.90.2(vue@3.5.22(typescript@5.8.3)) nuxt: specifier: ^4.1.2 - version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) vue: specifier: latest version: 3.5.22(typescript@5.8.3) @@ -1421,7 +1428,7 @@ importers: version: 0.15.3(solid-js@1.9.9) '@solidjs/start': specifier: ^1.2.0 - version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) '@tanstack/solid-query': specifier: ^5.90.3 version: 5.90.3(solid-js@1.9.9) @@ -1430,7 +1437,7 @@ importers: version: 1.9.9 vinxi: specifier: ^0.5.8 - version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-top-level-await: specifier: ^1.6.0 version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.52.4)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -6884,8 +6891,8 @@ packages: '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - '@types/bun@1.2.23': - resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==} + '@types/bun@1.3.0': + resolution: {integrity: sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA==} '@types/bunyan@1.8.11': resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} @@ -8323,8 +8330,8 @@ packages: engines: {node: '>=18'} hasBin: true - bun-types@1.2.23: - resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==} + bun-types@1.3.0: + resolution: {integrity: sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ==} peerDependencies: '@types/react': ^19 @@ -10843,10 +10850,6 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ioredis@5.8.0: - resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} - engines: {node: '>=12.22.0'} - ioredis@5.8.1: resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==} engines: {node: '>=12.22.0'} @@ -21140,11 +21143,11 @@ snapshots: dependencies: solid-js: 1.9.9 - '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tanstack/server-functions-plugin': 1.121.21(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) cookie-es: 2.0.0 defu: 6.1.4 error-stack-parser: 2.1.4 @@ -21156,7 +21159,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.9) tinyglobby: 0.2.15 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.9(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -21814,9 +21817,9 @@ snapshots: '@types/braces@3.0.5': {} - '@types/bun@1.2.23(@types/react@19.2.0)': + '@types/bun@1.3.0(@types/react@19.2.0)': dependencies: - bun-types: 1.2.23(@types/react@19.2.0) + bun-types: 1.3.0(@types/react@19.2.0) transitivePeerDependencies: - '@types/react' @@ -22402,7 +22405,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.28.4 acorn: 8.15.0 @@ -22413,18 +22416,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.15.0 acorn-loose: 8.5.2 acorn-typescript: 1.4.13(acorn@8.15.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: @@ -23797,7 +23800,7 @@ snapshots: transitivePeerDependencies: - magicast - bun-types@1.2.23(@types/react@19.2.0): + bun-types@1.3.0(@types/react@19.2.0): dependencies: '@types/node': 22.18.8 '@types/react': 19.2.0 @@ -26954,20 +26957,6 @@ snapshots: internmap@2.0.3: {} - ioredis@5.8.0: - dependencies: - '@ioredis/commands': 1.4.0 - cluster-key-slot: 1.1.2 - debug: 4.4.3 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ioredis@5.8.1: dependencies: '@ioredis/commands': 1.4.0 @@ -26981,7 +26970,6 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - optional: true ip-address@10.0.1: {} @@ -28427,7 +28415,7 @@ snapshots: h3: 1.15.4 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.8.0 + ioredis: 5.8.1 jiti: 2.6.1 klona: 2.0.6 knitwork: 1.2.0 @@ -28460,7 +28448,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.2.5 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 unwasm: 0.3.9 youch: 4.1.0-beta.8 @@ -28530,7 +28518,7 @@ snapshots: h3: 1.15.4 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.8.0 + ioredis: 5.8.1 jiti: 2.6.0 klona: 2.0.6 knitwork: 1.2.0 @@ -28563,7 +28551,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.3.0 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 unwasm: 0.3.11 youch: 4.1.0-beta.11 @@ -28713,7 +28701,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): + nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@nuxt/cli': 3.28.0(magicast@0.3.5) '@nuxt/devalue': 2.0.2 @@ -28772,7 +28760,7 @@ snapshots: unimport: 5.4.0 unplugin: 2.3.10 unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.5.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 vue: 3.5.22(typescript@5.8.3) vue-bundle-renderer: 2.1.2 @@ -31825,23 +31813,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0): - dependencies: - anymatch: 3.1.3 - chokidar: 4.0.3 - destr: 2.0.5 - h3: 1.15.4 - lru-cache: 10.4.3 - node-fetch-native: 1.6.7 - ofetch: 1.4.1 - ufo: 1.6.1 - optionalDependencies: - '@netlify/blobs': 9.1.2 - '@upstash/redis': 1.35.5 - db0: 0.3.2(better-sqlite3@12.4.1) - ioredis: 5.8.0 - - unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0): + unstorage@1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31855,7 +31827,7 @@ snapshots: '@netlify/blobs': 9.1.2 '@upstash/redis': 1.35.5 db0: 0.3.2(better-sqlite3@12.4.1) - ioredis: 5.8.0 + ioredis: 5.8.1 unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): dependencies: @@ -32009,7 +31981,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) @@ -32042,7 +32014,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.0) + unstorage: 1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) vite: 6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) zod: 3.25.76 transitivePeerDependencies: From ee2fb1829bbd65408982029fc60b2cf0c15dbc48 Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 13 Oct 2025 20:42:15 +0700 Subject: [PATCH 05/30] improve --- packages/publisher/src/adapters/ioredis.ts | 37 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 5d2cae05f..d43f4fc23 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -41,6 +41,20 @@ export class IORedisPublisher> extends Publishe return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 } + /** + * Useful for measuring memory usage. + * + * @internal + * + */ + get size(): number { + let size = this.redisListener ? 1 : 0 + for (const listeners of this.listeners) { + size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event + } + return size + } + constructor( private readonly redis: Redis, { resumeRetentionSeconds, prefix, ...options }: IORedisPublisherOptions = {}, @@ -52,7 +66,7 @@ export class IORedisPublisher> extends Publishe this.serializer = new StandardRPCJsonSerializer(options) } - protected lastCleanupTime: number | undefined + protected lastCleanupTimes: Map = new Map() override async publish(event: K, payload: T[K]): Promise { const key = this.prefixKey(event) @@ -61,12 +75,21 @@ export class IORedisPublisher> extends Publishe let id: string | undefined if (this.isResumeEnabled) { const now = Date.now() - if (this.lastCleanupTime === undefined || this.lastCleanupTime + this.retentionSeconds * 1000 < now) { - this.lastCleanupTime = now + + // cleanup for more efficiency memory + for (const [key, lastCleanupTime] of this.lastCleanupTimes) { + if (lastCleanupTime + this.retentionSeconds * 1000 < now) { + this.lastCleanupTimes.delete(key) + } + } + + if (!this.lastCleanupTimes.has(key)) { + this.lastCleanupTimes.set(key, now) + const result = await this.redis.multi() - .expire(key, this.retentionSeconds * 2) // double to avoid expires new events - .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) .xadd(key, '*', stringifyJSON(serialized)) + .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) + .expire(key, this.retentionSeconds * 2) // double to avoid expires new events .exec() if (result) { @@ -77,7 +100,7 @@ export class IORedisPublisher> extends Publishe } } - id = (result![2]![1] as string | null) ?? undefined + id = (result![0]![1] as string | null) ?? undefined } else { const result = await this.redis.xadd(key, '*', stringifyJSON(serialized)) @@ -130,7 +153,7 @@ export class IORedisPublisher> extends Publishe void (async () => { try { - if (typeof lastEventId === 'string') { + if (this.isResumeEnabled && typeof lastEventId === 'string') { const results = await this.redis.xread('STREAMS', key, lastEventId) if (results && results[0]) { From a70f9b49a0d32025ea60e0d48f65372a319236ae Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 14 Oct 2025 09:04:30 +0700 Subject: [PATCH 06/30] wip --- .../publisher/src/adapters/ioredis.test.ts | 291 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 2 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 packages/publisher/src/adapters/ioredis.test.ts diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts new file mode 100644 index 000000000..76dc5a7c5 --- /dev/null +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -0,0 +1,291 @@ +import type Redis from 'ioredis' +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IORedisPublisher } from './ioredis' + +describe('iORedisPublisher', () => { + let mockRedis: { + publish: ReturnType + subscribe: ReturnType + unsubscribe: ReturnType + on: ReturnType + off: ReturnType + xadd: ReturnType + xread: ReturnType + xtrim: ReturnType + expire: ReturnType + multi: ReturnType + } + + beforeEach(() => { + mockRedis = { + publish: vi.fn().mockResolvedValue(1), + subscribe: vi.fn().mockResolvedValue(undefined), + unsubscribe: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + xadd: vi.fn().mockResolvedValue('1-0'), + xread: vi.fn().mockResolvedValue(null), + xtrim: vi.fn().mockResolvedValue(0), + expire: vi.fn().mockResolvedValue(1), + multi: vi.fn().mockReturnValue({ + xadd: vi.fn().mockReturnThis(), + xtrim: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([ + [null, '1-0'], + [null, 0], + [null, 1], + ]), + }), + } + }) + + it('without resume: can pub/sub but not resume', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis) // resume is disabled by default + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event1') + expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event2') + expect(mockRedis.on).toHaveBeenCalledWith('message', expect.any(Function)) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + await publisher.publish('event1', payload1) + await publisher.publish('event3', payload2) + + expect(mockRedis.publish).toHaveBeenCalledTimes(2) + expect(mockRedis.xadd).not.toHaveBeenCalled() // resume disabled + + // Simulate Redis message callback + const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void + messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload1, meta: [], eventMeta: undefined })) + messageHandler('orpc:publisher:event3', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(payload1) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) + messageHandler('orpc:publisher:event2', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledWith(payload2) + + await unsub2() + + const listener3 = vi.fn() + const unsub11 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) + + // Wait a bit for potential async operations + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(listener3).toHaveBeenCalledTimes(0) // resume not happens (no xread called) + expect(mockRedis.xread).not.toHaveBeenCalled() // resume disabled + await unsub11() + + expect(publisher.size).toEqual(0) // ensure no memory leak + }) + + describe('with resume', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('publishes with xadd when resume enabled', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + await publisher.publish('event1', { order: 1 }) + await publisher.publish('event2', { order: 2 }) + + // First publish for each event triggers multi with xtrim + expect(mockRedis.multi).toHaveBeenCalledTimes(2) + expect(mockRedis.publish).toHaveBeenCalledTimes(2) + }) + + it('uses xadd for subsequent publishes to same event', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + await publisher.publish('event1', { order: 1 }) + await publisher.publish('event1', { order: 2 }) + + // First publish uses multi, second uses xadd + expect(mockRedis.multi).toHaveBeenCalledTimes(1) + expect(mockRedis.xadd).toHaveBeenCalledTimes(1) + }) + + it('cleanup expired events tracking on publish', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + await publisher.publish('event1', { order: 1 }) + await publisher.publish('event2', { order: 2 }) + await publisher.publish('event3', { order: 3 }) + + // First publish for each event triggers multi + expect(mockRedis.multi).toHaveBeenCalledTimes(3) + + vi.advanceTimersByTime(1100) // expired (1 second + buffer) + await publisher.publish('event1', { order: 4 }) + + // event1's lastCleanupTime has expired, so it triggers multi again + expect(mockRedis.multi).toHaveBeenCalledTimes(4) + }) + + it('calls xread when subscribing with lastEventId', async () => { + vi.useRealTimers() // Use real timers for this test + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + mockRedis.xread.mockResolvedValueOnce(null) + + const listener = vi.fn() + await publisher.subscribe('event1', listener, { lastEventId: '0' }) + + // Wait for async xread to be called + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(mockRedis.xread).toHaveBeenCalledWith('STREAMS', 'orpc:publisher:event1', '0') + vi.useFakeTimers() // Restore fake timers + }) + + it('handles xread errors gracefully', async () => { + vi.useRealTimers() // Use real timers for this test + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + // Mock xread to throw error + mockRedis.xread.mockRejectedValueOnce(new Error('Redis connection error')) + + const listener = vi.fn() + await publisher.subscribe('event1', listener, { lastEventId: '0' }) + + // Wait for async xread to complete (and fail) + await new Promise(resolve => setTimeout(resolve, 50)) + + // Simulate message arriving after error + const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void + const payload = { order: 1 } + messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload, meta: [], eventMeta: undefined, id: '1-0' })) + + // Should still receive new messages despite xread error + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)) + vi.useFakeTimers() // Restore fake timers + }) + }) + + it('uses custom prefix', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { prefix: 'custom:prefix:' }) + + const listener = vi.fn() + await publisher.subscribe('event1', listener) + + expect(mockRedis.subscribe).toHaveBeenCalledWith('custom:prefix:event1') + + await publisher.publish('event1', { order: 1 }) + + expect(mockRedis.publish).toHaveBeenCalledWith('custom:prefix:event1', expect.any(String)) + }) + + it('handles multiple listeners for same event', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis) + + const listener1 = vi.fn() + const listener2 = vi.fn() + const listener3 = vi.fn() + + await publisher.subscribe('event1', listener1) + await publisher.subscribe('event1', listener2) + await publisher.subscribe('event1', listener3) + + // Should only subscribe once to Redis + expect(mockRedis.subscribe).toHaveBeenCalledTimes(1) + expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event1') + + const payload = { order: 1 } + const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void + messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload, meta: [], eventMeta: undefined })) + + expect(listener1).toHaveBeenCalledWith(payload) + expect(listener2).toHaveBeenCalledWith(payload) + expect(listener3).toHaveBeenCalledWith(payload) + + expect(publisher.size).toEqual(4) // 1 redisListener + 3 listeners + }) + + it('cleans up Redis listener when all subscriptions removed', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis) + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + expect(mockRedis.on).toHaveBeenCalledTimes(1) + expect(publisher.size).toEqual(3) // 1 redisListener + 2 listeners + + await unsub1() + expect(mockRedis.off).not.toHaveBeenCalled() + expect(publisher.size).toEqual(2) + + await unsub2() + expect(mockRedis.off).toHaveBeenCalledWith('message', expect.any(Function)) + expect(publisher.size).toEqual(0) + }) + + it('handles multi exec errors', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + // Mock multi to return error + mockRedis.multi.mockReturnValueOnce({ + xadd: vi.fn().mockReturnThis(), + xtrim: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([ + [new Error('Redis error'), null], + [null, 0], + [null, 1], + ]), + }) + + await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Redis error') + }) + + it('deserializes payloads with event metadata', async () => { + const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) + + const listener = vi.fn() + await publisher.subscribe('event1', listener) + + const payload = withEventMeta({ order: 1 }, { comments: ['test'] }) + await publisher.publish('event1', payload) + + // Simulate Redis message with id + const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void + messageHandler('orpc:publisher:event1', JSON.stringify({ + json: { order: 1 }, + meta: [], + eventMeta: { comments: ['test'] }, + id: '1-0', + })) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(expect.toSatisfy((p) => { + expect(p).toEqual({ order: 1 }) + expect(getEventMeta(p)).toEqual({ id: '1-0', comments: ['test'] }) + return true + })) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ecc3d31f..1735cce57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32074,7 +32074,7 @@ snapshots: vite-node@3.2.4(@types/node@22.17.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.9(@types/node@22.17.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) @@ -32095,7 +32095,7 @@ snapshots: vite-node@3.2.4(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) @@ -32116,7 +32116,7 @@ snapshots: vite-node@3.2.4(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) From 46731a208f3bf347ba571b20918ee94f7afd786b Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 15 Oct 2025 21:40:01 +0700 Subject: [PATCH 07/30] wip --- .env.example | 4 + packages/arktype/package.json | 3 + .../publisher/src/adapters/ioredis.test.ts | 296 +----------------- packages/publisher/src/adapters/ioredis.ts | 37 ++- packages/server/package.json | 3 +- packages/shared/src/id.test.ts | 4 +- .../standard-server-peer/src/client.test.ts | 88 +++--- packages/valibot/package.json | 3 +- .../tanstack-start/src/routeTree.gen.ts | 1 + pnpm-lock.yaml | 30 +- vitest.config.ts | 70 +++++ vitest.workspace.ts | 63 ---- 12 files changed, 191 insertions(+), 411 deletions(-) create mode 100644 .env.example create mode 100644 vitest.config.ts delete mode 100644 vitest.workspace.ts diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..ebe311084 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Some tests depend on redis +REDIS_URL= +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file diff --git a/packages/arktype/package.json b/packages/arktype/package.json index 798c5b7ea..a3050a3e1 100644 --- a/packages/arktype/package.json +++ b/packages/arktype/package.json @@ -40,5 +40,8 @@ }, "dependencies": { "@orpc/openapi": "workspace:*" + }, + "devDependencies": { + "zod": "^4.1.11" } } diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 76dc5a7c5..aa9d5eb97 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -1,291 +1,27 @@ -import type Redis from 'ioredis' -import { getEventMeta, withEventMeta } from '@orpc/standard-server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Redis } from 'ioredis' import { IORedisPublisher } from './ioredis' -describe('iORedisPublisher', () => { - let mockRedis: { - publish: ReturnType - subscribe: ReturnType - unsubscribe: ReturnType - on: ReturnType - off: ReturnType - xadd: ReturnType - xread: ReturnType - xtrim: ReturnType - expire: ReturnType - multi: ReturnType +describe('ioRedisPublisher', () => { + const REDIS_URL = process.env.REDIS_URL + if (!REDIS_URL) { + throw new Error('There tests requires REDIS_URL env variable') } - beforeEach(() => { - mockRedis = { - publish: vi.fn().mockResolvedValue(1), - subscribe: vi.fn().mockResolvedValue(undefined), - unsubscribe: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - off: vi.fn(), - xadd: vi.fn().mockResolvedValue('1-0'), - xread: vi.fn().mockResolvedValue(null), - xtrim: vi.fn().mockResolvedValue(0), - expire: vi.fn().mockResolvedValue(1), - multi: vi.fn().mockReturnValue({ - xadd: vi.fn().mockReturnThis(), - xtrim: vi.fn().mockReturnThis(), - expire: vi.fn().mockReturnThis(), - exec: vi.fn().mockResolvedValue([ - [null, '1-0'], - [null, 0], - [null, 1], - ]), - }), - } - }) - - it('without resume: can pub/sub but not resume', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis) // resume is disabled by default - - const listener1 = vi.fn() - const listener2 = vi.fn() - - const unsub1 = await publisher.subscribe('event1', listener1) - const unsub2 = await publisher.subscribe('event2', listener2) - - expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event1') - expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event2') - expect(mockRedis.on).toHaveBeenCalledWith('message', expect.any(Function)) - - const payload1 = { order: 1 } - const payload2 = { order: 2 } - - await publisher.publish('event1', payload1) - await publisher.publish('event3', payload2) - - expect(mockRedis.publish).toHaveBeenCalledTimes(2) - expect(mockRedis.xadd).not.toHaveBeenCalled() // resume disabled - - // Simulate Redis message callback - const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void - messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload1, meta: [], eventMeta: undefined })) - messageHandler('orpc:publisher:event3', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) + const commander = new Redis(REDIS_URL) + const listener = new Redis(REDIS_URL) - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener1).toHaveBeenCalledWith(payload1) - expect(listener2).toHaveBeenCalledTimes(0) - - await unsub1() - - messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) - messageHandler('orpc:publisher:event2', JSON.stringify({ json: payload2, meta: [], eventMeta: undefined })) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledWith(payload2) - - await unsub2() - - const listener3 = vi.fn() - const unsub11 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) - - // Wait a bit for potential async operations - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(listener3).toHaveBeenCalledTimes(0) // resume not happens (no xread called) - expect(mockRedis.xread).not.toHaveBeenCalled() // resume disabled - await unsub11() - - expect(publisher.size).toEqual(0) // ensure no memory leak + const publisher = new IORedisPublisher({ + commander, + listener, }) - describe('with resume', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('publishes with xadd when resume enabled', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - await publisher.publish('event1', { order: 1 }) - await publisher.publish('event2', { order: 2 }) - - // First publish for each event triggers multi with xtrim - expect(mockRedis.multi).toHaveBeenCalledTimes(2) - expect(mockRedis.publish).toHaveBeenCalledTimes(2) - }) - - it('uses xadd for subsequent publishes to same event', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - await publisher.publish('event1', { order: 1 }) - await publisher.publish('event1', { order: 2 }) - - // First publish uses multi, second uses xadd - expect(mockRedis.multi).toHaveBeenCalledTimes(1) - expect(mockRedis.xadd).toHaveBeenCalledTimes(1) - }) - - it('cleanup expired events tracking on publish', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - await publisher.publish('event1', { order: 1 }) - await publisher.publish('event2', { order: 2 }) - await publisher.publish('event3', { order: 3 }) - - // First publish for each event triggers multi - expect(mockRedis.multi).toHaveBeenCalledTimes(3) - - vi.advanceTimersByTime(1100) // expired (1 second + buffer) - await publisher.publish('event1', { order: 4 }) - - // event1's lastCleanupTime has expired, so it triggers multi again - expect(mockRedis.multi).toHaveBeenCalledTimes(4) - }) - - it('calls xread when subscribing with lastEventId', async () => { - vi.useRealTimers() // Use real timers for this test - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - mockRedis.xread.mockResolvedValueOnce(null) - - const listener = vi.fn() - await publisher.subscribe('event1', listener, { lastEventId: '0' }) - - // Wait for async xread to be called - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockRedis.xread).toHaveBeenCalledWith('STREAMS', 'orpc:publisher:event1', '0') - vi.useFakeTimers() // Restore fake timers - }) - - it('handles xread errors gracefully', async () => { - vi.useRealTimers() // Use real timers for this test - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - // Mock xread to throw error - mockRedis.xread.mockRejectedValueOnce(new Error('Redis connection error')) - - const listener = vi.fn() - await publisher.subscribe('event1', listener, { lastEventId: '0' }) - - // Wait for async xread to complete (and fail) - await new Promise(resolve => setTimeout(resolve, 50)) - - // Simulate message arriving after error - const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void - const payload = { order: 1 } - messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload, meta: [], eventMeta: undefined, id: '1-0' })) - - // Should still receive new messages despite xread error - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)) - vi.useFakeTimers() // Restore fake timers - }) - }) - - it('uses custom prefix', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { prefix: 'custom:prefix:' }) - - const listener = vi.fn() - await publisher.subscribe('event1', listener) - - expect(mockRedis.subscribe).toHaveBeenCalledWith('custom:prefix:event1') - - await publisher.publish('event1', { order: 1 }) - - expect(mockRedis.publish).toHaveBeenCalledWith('custom:prefix:event1', expect.any(String)) - }) - - it('handles multiple listeners for same event', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis) - - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - await publisher.subscribe('event1', listener1) - await publisher.subscribe('event1', listener2) - await publisher.subscribe('event1', listener3) - - // Should only subscribe once to Redis - expect(mockRedis.subscribe).toHaveBeenCalledTimes(1) - expect(mockRedis.subscribe).toHaveBeenCalledWith('orpc:publisher:event1') - - const payload = { order: 1 } - const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void - messageHandler('orpc:publisher:event1', JSON.stringify({ json: payload, meta: [], eventMeta: undefined })) - - expect(listener1).toHaveBeenCalledWith(payload) - expect(listener2).toHaveBeenCalledWith(payload) - expect(listener3).toHaveBeenCalledWith(payload) - - expect(publisher.size).toEqual(4) // 1 redisListener + 3 listeners - }) - - it('cleans up Redis listener when all subscriptions removed', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis) - - const listener1 = vi.fn() - const listener2 = vi.fn() - - const unsub1 = await publisher.subscribe('event1', listener1) - const unsub2 = await publisher.subscribe('event2', listener2) - - expect(mockRedis.on).toHaveBeenCalledTimes(1) - expect(publisher.size).toEqual(3) // 1 redisListener + 2 listeners - - await unsub1() - expect(mockRedis.off).not.toHaveBeenCalled() - expect(publisher.size).toEqual(2) - - await unsub2() - expect(mockRedis.off).toHaveBeenCalledWith('message', expect.any(Function)) - expect(publisher.size).toEqual(0) + afterEach(async () => { + await commander.flushall() + await listener.flushall() }) - it('handles multi exec errors', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - // Mock multi to return error - mockRedis.multi.mockReturnValueOnce({ - xadd: vi.fn().mockReturnThis(), - xtrim: vi.fn().mockReturnThis(), - expire: vi.fn().mockReturnThis(), - exec: vi.fn().mockResolvedValue([ - [new Error('Redis error'), null], - [null, 0], - [null, 1], - ]), - }) - - await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Redis error') - }) - - it('deserializes payloads with event metadata', async () => { - const publisher = new IORedisPublisher(mockRedis as unknown as Redis, { resumeRetentionSeconds: 1 }) - - const listener = vi.fn() - await publisher.subscribe('event1', listener) - - const payload = withEventMeta({ order: 1 }, { comments: ['test'] }) - await publisher.publish('event1', payload) - - // Simulate Redis message with id - const messageHandler = mockRedis.on.mock.calls[0]![1] as (channel: string, message: string) => void - messageHandler('orpc:publisher:event1', JSON.stringify({ - json: { order: 1 }, - meta: [], - eventMeta: { comments: ['test'] }, - id: '1-0', - })) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(expect.toSatisfy((p) => { - expect(p).toEqual({ order: 1 }) - expect(getEventMeta(p)).toEqual({ id: '1-0', comments: ['test'] }) - return true - })) + afterAll(async () => { + commander.disconnect() + listener.disconnect() }) }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index d43f4fc23..9afcb8948 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -9,6 +9,19 @@ import { Publisher } from '../publisher' type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } export interface IORedisPublisherOptions extends PublisherOptions, StandardRPCJsonSerializerOptions { + /** + * Redis commander instance (used for execute short-lived commands) + */ + commander: Redis + + /** + * redis listener instance (used for listening to events) + * + * @remark + * - `lazyConnect: true` option is supported + */ + listener: Redis + /** * How long (in seconds) to retain events for replay. * @@ -31,6 +44,9 @@ export interface IORedisPublisherOptions extends PublisherOptions, StandardRPCJs } export class IORedisPublisher> extends Publisher { + protected readonly commander: Redis + protected readonly listener: Redis + protected readonly prefix: string protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number @@ -56,11 +72,12 @@ export class IORedisPublisher> extends Publishe } constructor( - private readonly redis: Redis, - { resumeRetentionSeconds, prefix, ...options }: IORedisPublisherOptions = {}, + { commander, listener, resumeRetentionSeconds, prefix, ...options }: IORedisPublisherOptions, ) { super(options) + this.commander = commander + this.listener = listener this.prefix = prefix ?? 'orpc:publisher:' this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN this.serializer = new StandardRPCJsonSerializer(options) @@ -86,7 +103,7 @@ export class IORedisPublisher> extends Publishe if (!this.lastCleanupTimes.has(key)) { this.lastCleanupTimes.set(key, now) - const result = await this.redis.multi() + const result = await this.commander.multi() .xadd(key, '*', stringifyJSON(serialized)) .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) .expire(key, this.retentionSeconds * 2) // double to avoid expires new events @@ -103,12 +120,12 @@ export class IORedisPublisher> extends Publishe id = (result![0]![1] as string | null) ?? undefined } else { - const result = await this.redis.xadd(key, '*', stringifyJSON(serialized)) + const result = await this.commander.xadd(key, '*', stringifyJSON(serialized)) id = result ?? undefined } } - await this.redis.publish(key, stringifyJSON({ ...serialized, id })) + await this.commander.publish(key, stringifyJSON({ ...serialized, id })) } protected override async subscribeListener(event: K, _listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { @@ -140,12 +157,12 @@ export class IORedisPublisher> extends Publishe } } - this.redis.on('message', this.redisListener) + this.listener.on('message', this.redisListener) } let listeners = this.listeners.get(key) if (!listeners) { - await this.redis.subscribe(key) + await this.listener.subscribe(key) this.listeners.set(key, listeners = new Set()) // only set after subscribe successfully } @@ -154,7 +171,7 @@ export class IORedisPublisher> extends Publishe void (async () => { try { if (this.isResumeEnabled && typeof lastEventId === 'string') { - const results = await this.redis.xread('STREAMS', key, lastEventId) + const results = await this.commander.xread('STREAMS', key, lastEventId) if (results && results[0]) { const [_, items] = results[0] @@ -189,11 +206,11 @@ export class IORedisPublisher> extends Publishe this.listeners.delete(key) // should execute before async to avoid throw if (this.redisListener && this.listeners.size === 0) { - this.redis.off('message', this.redisListener) + this.listener.off('message', this.redisListener) this.redisListener = undefined } - await this.redis.unsubscribe(key) + await this.listener.unsubscribe(key) } } } diff --git a/packages/server/package.json b/packages/server/package.json index 65588d514..682bd9800 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -141,6 +141,7 @@ "crossws": "^0.4.1", "next": "^15.5.4", "supertest": "^7.1.4", - "ws": "^8.18.3" + "ws": "^8.18.3", + "zod": "^4.1.11" } } diff --git a/packages/shared/src/id.test.ts b/packages/shared/src/id.test.ts index 214ee2c6f..6dc5b06f9 100644 --- a/packages/shared/src/id.test.ts +++ b/packages/shared/src/id.test.ts @@ -4,12 +4,12 @@ describe('sequentialIdGenerator', () => { it('unique and increase', () => { const idGenerator = new SequentialIdGenerator() - expect(idGenerator.generate()).toBe('0') expect(idGenerator.generate()).toBe('1') expect(idGenerator.generate()).toBe('2') expect(idGenerator.generate()).toBe('3') + expect(idGenerator.generate()).toBe('4') - for (let i = 4; i < 1000; i++) { + for (let i = 5; i < 1000; i++) { expect(idGenerator.generate()).toBe(i.toString(36)) } }) diff --git a/packages/standard-server-peer/src/client.test.ts b/packages/standard-server-peer/src/client.test.ts index 389aa04b8..d5c91b8b2 100644 --- a/packages/standard-server-peer/src/client.test.ts +++ b/packages/standard-server-peer/src/client.test.ts @@ -20,7 +20,7 @@ describe('clientPeer', () => { url: new URL('https://example.com'), method: 'POST', headers: { - 'x-request': '1', + 'x-request': '2', }, body: { hello: 'world' }, signal: undefined, @@ -29,7 +29,7 @@ describe('clientPeer', () => { const baseResponse = { status: 200, headers: { - 'x-response': '1', + 'x-response': '2', }, body: { hello: 'world_2' }, } @@ -38,9 +38,9 @@ describe('clientPeer', () => { expect(peer.request(baseRequest)).resolves.toEqual(baseResponse) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) - expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['0', MessageType.REQUEST, baseRequest]) + expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['1', MessageType.REQUEST, baseRequest]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('multiple simple request/response', async () => { @@ -48,11 +48,11 @@ describe('clientPeer', () => { expect(peer.request({ ...baseRequest, body: '__SECOND__' })).resolves.toEqual({ ...baseResponse, body: '__SECOND__' }) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)) - expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['0', MessageType.REQUEST, baseRequest]) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.REQUEST, { ...baseRequest, body: '__SECOND__' }]) + expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['1', MessageType.REQUEST, baseRequest]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['2', MessageType.REQUEST, { ...baseRequest, body: '__SECOND__' }]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) - peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, { ...baseResponse, body: '__SECOND__' })) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('2', MessageType.RESPONSE, { ...baseResponse, body: '__SECOND__' })) }) describe('request', () => { @@ -69,7 +69,7 @@ describe('clientPeer', () => { await expect(peer.request(request)).rejects.toThrow('This operation was aborted') expect(send).toHaveBeenCalledTimes(0) - await peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + await peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('signal', async () => { @@ -90,10 +90,10 @@ describe('clientPeer', () => { controller.abort() await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)) - expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['0', MessageType.REQUEST, baseRequest]) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['0', MessageType.ABORT_SIGNAL, undefined]) + expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['1', MessageType.REQUEST, baseRequest]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.ABORT_SIGNAL, undefined]) - await peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + await peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('signal 2', async () => { @@ -112,13 +112,13 @@ describe('clientPeer', () => { expect(peer.request(request)).rejects.toThrow('This operation was aborted') await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) - expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['0', MessageType.REQUEST, baseRequest]) + expect(await decodeRequestMessage(send.mock.calls[0]![0])).toEqual(['1', MessageType.REQUEST, baseRequest]) controller.abort() await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['0', MessageType.ABORT_SIGNAL, undefined]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.ABORT_SIGNAL, undefined]) - await peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + await peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('signal - remove abort listener before signal is garbage collected', async () => { @@ -150,12 +150,12 @@ describe('clientPeer', () => { await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(4)) expect(await decodeRequestMessage(send.mock.calls[0]![0])) - .toEqual(['0', MessageType.REQUEST, { ...request, body: undefined, headers: { ...request.headers, 'content-type': 'text/event-stream' } }]) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['0', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' }]) - expect(await decodeRequestMessage(send.mock.calls[2]![0])).toEqual(['0', MessageType.EVENT_ITERATOR, { event: 'message', data: { hello2: true }, meta: { id: 'id-1' } }]) - expect(await decodeRequestMessage(send.mock.calls[3]![0])).toEqual(['0', MessageType.EVENT_ITERATOR, { event: 'done' }]) + .toEqual(['1', MessageType.REQUEST, { ...request, body: undefined, headers: { ...request.headers, 'content-type': 'text/event-stream' } }]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' }]) + expect(await decodeRequestMessage(send.mock.calls[2]![0])).toEqual(['1', MessageType.EVENT_ITERATOR, { event: 'message', data: { hello2: true }, meta: { id: 'id-1' } }]) + expect(await decodeRequestMessage(send.mock.calls[3]![0])).toEqual(['1', MessageType.EVENT_ITERATOR, { event: 'done' }]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('iterator and server abort while sending', async () => { @@ -185,14 +185,14 @@ describe('clientPeer', () => { expect(yieldFn).toHaveBeenCalledTimes(1) expect(isFinallyCalled).toBe(false) - await peer.message(await encodeResponseMessage('0', MessageType.ABORT_SIGNAL, undefined)) + await peer.message(await encodeResponseMessage('1', MessageType.ABORT_SIGNAL, undefined)) await new Promise(resolve => setTimeout(resolve, 20)) expect(send).toHaveBeenCalledTimes(2) expect(yieldFn).toHaveBeenCalledTimes(2) expect(isFinallyCalled).toBe(true) - await peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + await peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) await new Promise(resolve => setTimeout(resolve, 20)) expect(send).toHaveBeenCalledTimes(2) @@ -220,14 +220,14 @@ describe('clientPeer', () => { await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)) expect(await decodeRequestMessage(send.mock.calls[0]![0])) - .toEqual(['0', MessageType.REQUEST, { ...request, body: undefined, headers: { ...request.headers, 'content-type': 'text/event-stream' } }]) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['0', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' }]) + .toEqual(['1', MessageType.REQUEST, { ...request, body: undefined, headers: { ...request.headers, 'content-type': 'text/event-stream' } }]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' }]) /** * Should send an error event even when the error is not an instance of ErrorEvent. */ - expect(await decodeRequestMessage(send.mock.calls[2]![0])).toEqual(['0', MessageType.EVENT_ITERATOR, { event: 'error' }]) + expect(await decodeRequestMessage(send.mock.calls[2]![0])).toEqual(['1', MessageType.EVENT_ITERATOR, { event: 'error' }]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) await promise @@ -244,7 +244,7 @@ describe('clientPeer', () => { await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) expect(await decodeRequestMessage(send.mock.calls[0]![0])) - .toEqual(['0', MessageType.REQUEST, { + .toEqual(['1', MessageType.REQUEST, { ...request, headers: { ...request.headers, @@ -253,7 +253,7 @@ describe('clientPeer', () => { }, }]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('form data', async () => { @@ -270,7 +270,7 @@ describe('clientPeer', () => { await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) expect(await decodeRequestMessage(send.mock.calls[0]![0])) - .toEqual(['0', MessageType.REQUEST, { + .toEqual(['1', MessageType.REQUEST, { ...request, headers: { ...request.headers, @@ -278,7 +278,7 @@ describe('clientPeer', () => { }, }]) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) }) it('throw if can not send', async () => { @@ -332,7 +332,7 @@ describe('clientPeer', () => { await new Promise(resolve => setTimeout(resolve, 0)) expect(send).toHaveBeenCalledTimes(2) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) await promise await new Promise(resolve => setTimeout(resolve, 100)) expect(send).toHaveBeenCalledTimes(2) @@ -354,10 +354,10 @@ describe('clientPeer', () => { await new Promise(resolve => setTimeout(resolve, 0)) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, response)) - peer.message(await encodeResponseMessage('0', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' })) - peer.message(await encodeResponseMessage('0', MessageType.EVENT_ITERATOR, { event: 'message', data: { hello2: true }, meta: { id: 'id-1' } })) - peer.message(await encodeResponseMessage('0', MessageType.EVENT_ITERATOR, { event: 'done', data: 'hello3' })) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, response)) + peer.message(await encodeResponseMessage('1', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' })) + peer.message(await encodeResponseMessage('1', MessageType.EVENT_ITERATOR, { event: 'message', data: { hello2: true }, meta: { id: 'id-1' } })) + peer.message(await encodeResponseMessage('1', MessageType.EVENT_ITERATOR, { event: 'done', data: 'hello3' })) const result = await responsePromise @@ -406,8 +406,8 @@ describe('clientPeer', () => { await new Promise(resolve => setTimeout(resolve, 0)) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, response)) - peer.message(await encodeResponseMessage('0', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' })) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, response)) + peer.message(await encodeResponseMessage('1', MessageType.EVENT_ITERATOR, { event: 'message', data: 'hello' })) const result = await responsePromise @@ -437,7 +437,7 @@ describe('clientPeer', () => { expect(await iterator.next()).toEqual({ done: true, value: undefined }) expect(send).toHaveBeenCalledTimes(2) - expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['0', MessageType.ABORT_SIGNAL, undefined]) + expect(await decodeRequestMessage(send.mock.calls[1]![0])).toEqual(['1', MessageType.ABORT_SIGNAL, undefined]) }) it('iterator and server success response while sending', async () => { @@ -467,7 +467,7 @@ describe('clientPeer', () => { expect(yieldFn).toHaveBeenCalledTimes(1) expect(isFinallyCalled).toBe(false) - await peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, baseResponse)) + await peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, baseResponse)) await new Promise(resolve => setTimeout(resolve, 20)) expect(send).toHaveBeenCalledTimes(2) @@ -503,7 +503,7 @@ describe('clientPeer', () => { const [response] = await Promise.all([ peer.request(request), new Promise(resolve => setTimeout(resolve, 0)) - .then(async () => peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, { ...baseResponse, body: (async function* () { })() }))), + .then(async () => peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, { ...baseResponse, body: (async function* () { })() }))), ]) const iterator = response.body as AsyncGenerator @@ -518,7 +518,7 @@ describe('clientPeer', () => { expect(yieldFn).toHaveBeenCalledTimes(2) expect(isFinallyCalled).toBe(false) - await peer.message(await encodeResponseMessage('0', MessageType.EVENT_ITERATOR, { event: 'done', data: 'hello' })) + await peer.message(await encodeResponseMessage('1', MessageType.EVENT_ITERATOR, { event: 'done', data: 'hello' })) await iterator.next() await new Promise(resolve => setTimeout(resolve, 10)) @@ -548,7 +548,7 @@ describe('clientPeer', () => { }, }) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, response)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, response)) }) it('form data', async () => { @@ -569,15 +569,15 @@ describe('clientPeer', () => { }, }) - peer.message(await encodeResponseMessage('0', MessageType.RESPONSE, response)) + peer.message(await encodeResponseMessage('1', MessageType.RESPONSE, response)) }) }) it('close all', async () => { const promise = Promise.all([ - expect(peer.request(baseRequest)).rejects.toThrow('[AsyncIdQueue] Queue[0] was closed or aborted while waiting for pulling.'), expect(peer.request(baseRequest)).rejects.toThrow('[AsyncIdQueue] Queue[1] was closed or aborted while waiting for pulling.'), expect(peer.request(baseRequest)).rejects.toThrow('[AsyncIdQueue] Queue[2] was closed or aborted while waiting for pulling.'), + expect(peer.request(baseRequest)).rejects.toThrow('[AsyncIdQueue] Queue[3] was closed or aborted while waiting for pulling.'), ]) await new Promise(resolve => setTimeout(resolve, 1)) diff --git a/packages/valibot/package.json b/packages/valibot/package.json index dd644a755..272ba9af9 100644 --- a/packages/valibot/package.json +++ b/packages/valibot/package.json @@ -43,6 +43,7 @@ "@valibot/to-json-schema": "^1.3.0" }, "devDependencies": { - "valibot": "^1.1.0" + "valibot": "^1.1.0", + "zod": "^4.1.11" } } diff --git a/playgrounds/tanstack-start/src/routeTree.gen.ts b/playgrounds/tanstack-start/src/routeTree.gen.ts index 635dc45f9..98e1a7dbd 100644 --- a/playgrounds/tanstack-start/src/routeTree.gen.ts +++ b/playgrounds/tanstack-start/src/routeTree.gen.ts @@ -98,6 +98,7 @@ import type { getRouter } from './router.tsx' import type { createStart } from '@tanstack/react-start' declare module '@tanstack/react-start' { interface Register { + ssr: true router: Awaited> } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1735cce57..ac2052509 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,10 @@ importers: arktype: specifier: '*' version: 2.1.20 + devDependencies: + zod: + specifier: ^4.1.11 + version: 4.1.11 packages/client: dependencies: @@ -619,6 +623,9 @@ importers: ws: specifier: ^8.18.3 version: 8.18.3 + zod: + specifier: ^4.1.11 + version: 4.1.11 packages/shared: dependencies: @@ -835,6 +842,9 @@ importers: valibot: specifier: ^1.1.0 version: 1.1.0(typescript@5.8.3) + zod: + specifier: ^4.1.11 + version: 4.1.11 packages/vue-colada: dependencies: @@ -1398,7 +1408,7 @@ importers: version: 3.5.22(typescript@5.8.3) vue-router: specifier: latest - version: 4.5.1(vue@3.5.22(typescript@5.8.3)) + version: 4.6.1(vue@3.5.22(typescript@5.8.3)) zod: specifier: ^4.1.11 version: 4.1.11 @@ -15450,10 +15460,10 @@ packages: peerDependencies: vue: ^3.0.0 - vue-router@4.5.1: - resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==} + vue-router@4.6.1: + resolution: {integrity: sha512-m6bVZMXKP4qPB+e2pU6Ptgfy58PDSI3Tf7bNCWqVAf0cIGe+9zR3qCu2nRmFO+CysfUxIfI+1uzD7zWVQ7zwtQ==} peerDependencies: - vue: ^3.2.0 + vue: ^3.5.0 vue-sonner@1.3.2: resolution: {integrity: sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==} @@ -20628,7 +20638,7 @@ snapshots: shell-quote: 1.8.3 type-fest: 4.41.0 vue: 3.5.22(typescript@5.8.3) - vue-router: 4.5.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) whatwg-mimetype: 4.0.0 yaml: 2.8.0 zod: 4.1.11 @@ -28759,13 +28769,13 @@ snapshots: unctx: 2.4.1 unimport: 5.4.0 unplugin: 2.3.10 - unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.5.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) + unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 vue: 3.5.22(typescript@5.8.3) vue-bundle-renderer: 2.1.2 vue-devtools-stub: 0.1.0 - vue-router: 4.5.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) optionalDependencies: '@parcel/watcher': 2.5.1 '@types/node': 24.7.0 @@ -31776,7 +31786,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.5.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)): + unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)): dependencies: '@vue-macros/common': 3.0.0-beta.16(vue@3.5.22(typescript@5.8.3)) '@vue/compiler-sfc': 3.5.22 @@ -31796,7 +31806,7 @@ snapshots: unplugin-utils: 0.2.5 yaml: 2.8.1 optionalDependencies: - vue-router: 4.5.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) transitivePeerDependencies: - typescript - vue @@ -32615,7 +32625,7 @@ snapshots: dependencies: vue: 3.5.22(typescript@5.8.3) - vue-router@4.5.1(vue@3.5.22(typescript@5.8.3)): + vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)): dependencies: '@vue/devtools-api': 6.6.4 vue: 3.5.22(typescript@5.8.3) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..325f62ea6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,70 @@ +import process from 'node:process' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' +import { loadEnv } from 'vite' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +export default defineConfig(({ mode }) => ({ + test: { + env: loadEnv(mode, process.cwd(), ''), + projects: [ + { + test: { + globals: true, + include: ['**/*.test.ts'], + setupFiles: ['./vitest.javascript.ts'], + }, + }, + { + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], + include: [ + './packages/react/**/*.test.tsx', + './packages/react-query/**/*.test.tsx', + './packages/react-swr/**/*.test.tsx', + './packages/tanstack-query/**/*.test.tsx', + './packages/vue-colada/**/*.test.tsx', + './packages/vue-query/**/*.test.tsx', + ], + }, + }, + { + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], + include: [ + './packages/solid-query/**/*.test.tsx', + ], + deps: { + inline: [/solid-js/, /@solidjs\/testing-library/], + }, + }, + plugins: [solid()], + resolve: { + conditions: ['development', 'browser'], + }, + }, + { + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], + include: [ + './packages/svelte-query/**/*.test.tsx', + ], + deps: { + inline: [/svelte/, /@testing-library\/svelte /], + }, + }, + plugins: [svelte(), svelteTesting()], + resolve: { + conditions: ['development', 'browser'], + }, + }, + ], + }, +})) diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index d4fb1bd86..000000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { svelte } from '@sveltejs/vite-plugin-svelte' -import { svelteTesting } from '@testing-library/svelte/vite' -import solid from 'vite-plugin-solid' -import { defineWorkspace } from 'vitest/config' - -export default defineWorkspace([ - { - test: { - globals: true, - include: ['**/*.test.ts'], - setupFiles: ['./vitest.javascript.ts'], - }, - }, - { - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], - include: [ - './packages/react/**/*.test.tsx', - './packages/react-query/**/*.test.tsx', - './packages/react-swr/**/*.test.tsx', - './packages/tanstack-query/**/*.test.tsx', - './packages/vue-colada/**/*.test.tsx', - './packages/vue-query/**/*.test.tsx', - ], - }, - }, - { - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], - include: [ - './packages/solid-query/**/*.test.tsx', - ], - deps: { - inline: [/solid-js/, /@solidjs\/testing-library/], - }, - }, - plugins: [solid()], - resolve: { - conditions: ['development', 'browser'], - }, - }, - { - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./vitest.javascript.ts', './vitest.jsdom.ts'], - include: [ - './packages/svelte-query/**/*.test.tsx', - ], - deps: { - inline: [/svelte/, /@testing-library\/svelte /], - }, - }, - plugins: [svelte(), svelteTesting()], - resolve: { - conditions: ['development', 'browser'], - }, - }, -]) From e2ac5d2d1680d3ff12b14c97450fe16c9bd109ba Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 16 Oct 2025 16:52:40 +0700 Subject: [PATCH 08/30] ioredis --- .../publisher/src/adapters/ioredis.test.ts | 541 +++++++++++++++++- packages/publisher/src/adapters/ioredis.ts | 39 +- 2 files changed, 559 insertions(+), 21 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index aa9d5eb97..c39dd00fe 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -1,27 +1,552 @@ +import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from 'ioredis' import { IORedisPublisher } from './ioredis' -describe('ioRedisPublisher', () => { +describe('ioredis publisher', () => { const REDIS_URL = process.env.REDIS_URL if (!REDIS_URL) { - throw new Error('There tests requires REDIS_URL env variable') + throw new Error('These tests require REDIS_URL env variable') } - const commander = new Redis(REDIS_URL) - const listener = new Redis(REDIS_URL) + let publisher: IORedisPublisher + let commander: Redis + let listener: Redis - const publisher = new IORedisPublisher({ - commander, - listener, + beforeAll(() => { + commander = new Redis(REDIS_URL) + listener = new Redis(REDIS_URL) }) afterEach(async () => { + // Use a separate commander for cleanup since listener might be in subscriber mode await commander.flushall() - await listener.flushall() + expect(publisher.size).toEqual(0) // ensure cleanup correctly }) afterAll(async () => { commander.disconnect() listener.disconnect() }) + + it('without resume: can pub/sub but not resume', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + }) // resume is disabled by default + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + await publisher.publish('event1', payload1) + await publisher.publish('event3', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1.mock.calls[0]![0]).toEqual(payload1) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + await publisher.publish('event1', payload2) + await publisher.publish('event2', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2.mock.calls[0]![0]).toEqual(payload2) + + await unsub2() + + const unsub11 = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) + + // Wait a bit to ensure no resume happens + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) // resume not happens + await unsub11() + }) + + describe('with resume', () => { + it('basic pub/sub', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload1 = { order: 1 } + await publisher.publish('event1', payload1) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) + + await unsub1() + }) + + it('can pub/sub and resume', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + await publisher.publish('event1', payload1) + await publisher.publish('event3', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + await publisher.publish('event1', payload2) + await publisher.publish('event2', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload2)) + + await unsub2() + + const listener3 = vi.fn() + const unsub3 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener3).toHaveBeenCalledTimes(2) // resume happens + expect(listener3).toHaveBeenNthCalledWith(1, expect.objectContaining(payload1)) + expect(listener3).toHaveBeenNthCalledWith(2, expect.objectContaining(payload2)) + + await unsub3() + expect(publisher.size).toEqual(0) // all listeners unsubscribed + }) + + it('control event.id', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload1 = { order: 1 } + const payload2 = withEventMeta({ order: 2 }, { id: 'some-id', comments: ['hello'] }) + + await publisher.publish('event1', payload1) + await publisher.publish('event1', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(2) + expect(listener1).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload1) + expect(p).toEqual(payload1) + const meta = getEventMeta(p) + expect(meta?.id).toBeDefined() + expect(typeof meta?.id).toBe('string') + return true + })) + expect(listener1).toHaveBeenNthCalledWith(2, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + const meta = getEventMeta(p) + expect(meta?.id).toBeDefined() + expect(typeof meta?.id).toBe('string') + expect(meta?.comments).toEqual(['hello']) + return true + })) + + const firstEventId = getEventMeta(listener1.mock.calls[0]![0])?.id + + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2, { lastEventId: firstEventId }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener2).toHaveBeenCalledTimes(1) // only second event + expect(listener2).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + const meta = getEventMeta(p) + expect(meta?.id).toEqual(getEventMeta(listener1.mock.calls[1]![0])?.id) + expect(meta?.comments).toEqual(['hello']) + return true + })) + + await unsub1() + await unsub2() + expect(publisher.size).toEqual(0) // ensure no memory leak + }) + + it('resume event.id > lastEventId and in order', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event', listener1) + + // Publish 10 events + for (let i = 1; i <= 10; i++) { + await publisher.publish('event', { order: i }) + } + + // Wait for all events to be received + await new Promise(resolve => setTimeout(resolve, 200)) + + expect(listener1).toHaveBeenCalledTimes(10) + + // Get the ID of the 5th event + const fifthEventId = getEventMeta(listener1.mock.calls[4]![0])?.id + + if (!fifthEventId) { + throw new Error('No event ID found') + } + + await unsub1() + + // Now subscribe with lastEventId set to the 5th event + // Should receive events 6-10 + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event', listener2, { lastEventId: fifthEventId }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 300)) + + // Should have received events 6-10 (5 events) + expect(listener2).toHaveBeenCalledTimes(5) + expect(listener2).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 6 })) + expect(listener2).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 7 })) + expect(listener2).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 10 })) + + // Verify order + for (let i = 0; i < listener2.mock.calls.length - 1; i++) { + const current = listener2.mock.calls[i]![0].order + const next = listener2.mock.calls[i + 1]![0].order + expect(next).toBeGreaterThan(current) + } + + await unsub2() + }, 10000) // Increase timeout to 10 seconds + + it('handles multiple subscribers on same event', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + const listener3 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event1', listener2) + const unsub3 = await publisher.subscribe('event1', listener3) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener3).toHaveBeenCalledWith(expect.objectContaining(payload)) + + await unsub1() + await unsub2() + await unsub3() + + expect(publisher.size).toEqual(0) + }) + + it('handles custom prefix', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + prefix: 'custom:prefix:', + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + + // Verify the key uses custom prefix + const keys = await commander.keys('custom:prefix:*') + expect(keys.length).toBeGreaterThan(0) + expect(keys.some(k => k.includes('custom:prefix:event1'))).toBe(true) + + await unsub1() + }) + + it('handles serialization with complex objects and custom serializers', async () => { + class Person { + constructor( + public name: string, + public date: Date, + ) {} + } + + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + customJsonSerializers: [ + { + condition: data => data instanceof Person, + type: 20, + serialize: person => ({ name: person.name, date: person.date }), + deserialize: data => new Person(data.name, data.date), + }, + ], + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { + order: 1, + nested: { + value: 'test', + array: [1, 2, 3], + }, + date: new Date('2024-01-01'), + person: new Person('John Doe', new Date('2023-01-01')), + } + + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(listener1).toHaveBeenCalledTimes(1) + const received = listener1.mock.calls[0]![0] + expect(received.order).toBe(1) + expect(received.nested.value).toBe('test') + expect(received.nested.array).toEqual([1, 2, 3]) + expect(received.date).toEqual(new Date('2024-01-01')) + expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) + + await unsub1() + }) + + it('handles errors during resume gracefully', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + + // Subscribe with an invalid lastEventId to trigger error in xread + const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format' }) + + // Publish an event + await publisher.publish('event1', { order: 1 }) + + // Wait for message to be received (should still work despite resume error) + await new Promise(resolve => setTimeout(resolve, 200)) + + // Should have received the new event even though resume failed + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) + + await unsub1() + }) + + it('handles race condition where events published during resume', { repeats: 5 }, async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + prefix: 'race:test:', + }) + + await publisher.publish('event1', { order: 1 }) + await new Promise(resolve => setTimeout(resolve, 150)) // wait for publish to finish + await publisher.publish('event1', { order: 2 }) + + publisher.publish('event1', { order: 3 }) + publisher.publish('event1', { order: 4 }) + const listener1 = vi.fn() + const unsub = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) + + await publisher.publish('event1', { order: 5 }) + await publisher.publish('event1', { order: 6 }) + + // Wait for publish to finish + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + expect(listener1).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 1 })) + expect(listener1).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 2 })) + expect(listener1).toHaveBeenNthCalledWith(3, expect.objectContaining({ order: 3 })) + expect(listener1).toHaveBeenNthCalledWith(4, expect.objectContaining({ order: 4 })) + expect(listener1).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 5 })) + expect(listener1).toHaveBeenNthCalledWith(6, expect.objectContaining({ order: 6 })) + + await unsub() + }) + + describe('cleanup retention', () => { + it('handles cleanup of expired events on publish', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 1, // 1 second retention + prefix: 'cleanup:test:', + }) + + const key1 = 'cleanup:test:event1' + + // Publish events to event1 + await publisher.publish('event1', { order: 1 }) + await publisher.publish('event1', { order: 2 }) + await publisher.publish('event1', { order: 3 }) + + // Verify events are stored using xread + const beforeCleanup = await commander.xread('STREAMS', key1, '0') + + expect(beforeCleanup![0]![1].length).toBe(3) // 3 events for event1 + + // Wait for retention to expire + await new Promise(resolve => setTimeout(resolve, 1100)) + + // Trigger cleanup by publishing a new event to event1 + await publisher.publish('event1', { order: 4 }) + + // Verify cleanup happened using xread - old events should be trimmed + const afterCleanup = await commander.xread('STREAMS', key1, '0') + + // event1 should only have the new event (order: 4), old ones trimmed + expect(afterCleanup![0]![1].length).toBe(1) + }) + + it('verifies Redis auto-expires keys after retention period * 2', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 1, + prefix: 'test:expire:', + }) + + const key = 'test:expire:event1' + + // Publish an event + await publisher.publish('event1', { order: 1 }) + + // Verify key exists + const ttl1 = await commander.ttl(key) + expect(ttl1).toBeGreaterThan(0) + expect(ttl1).toBeLessThanOrEqual(2) // (2 * retentionSeconds) + + // Wait for key to expire (2 * retentionSeconds = 2 seconds) + await new Promise(resolve => setTimeout(resolve, 2500)) + + // Verify key has been auto-expired by Redis + const exists = await commander.exists(key) + expect(exists).toBe(0) + }) + }) + + describe('edge cases', () => { + it('handles transaction errors during publish', async () => { + // Create a mock commander that will fail on multi + const mockCommander = { + ...commander, + multi: () => ({ + xadd: () => ({ xtrim: () => ({ expire: () => ({ exec: async () => [[new Error('Transaction failed')]] }) }) }), + }), + publish: commander.publish.bind(commander), + } as any + + publisher = new IORedisPublisher({ + commander: mockCommander, + listener, + resumeRetentionSeconds: 10, + }) + + // This should throw the transaction error + await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') + }) + + it('only subscribe to redis-listener when needed', async () => { + publisher = new IORedisPublisher({ + commander, + listener, + resumeRetentionSeconds: 10, + }) + + expect(listener.listenerCount('message')).toBe(0) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + expect(listener.listenerCount('message')).toBe(1) + + const unsub2 = await publisher.subscribe('event1', listener1) + + expect(listener.listenerCount('message')).toBe(1) // reuse listener + + await unsub1() + await unsub2() + + expect(listener.listenerCount('message')).toBe(0) + expect(publisher.size).toBe(0) + }) + }) + }) }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 9afcb8948..e58ca8638 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -64,6 +64,7 @@ export class IORedisPublisher> extends Publishe * */ get size(): number { + /* v8 ignore next 5 */ let size = this.redisListener ? 1 : 0 for (const listeners of this.listeners) { size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event @@ -104,7 +105,7 @@ export class IORedisPublisher> extends Publishe this.lastCleanupTimes.set(key, now) const result = await this.commander.multi() - .xadd(key, '*', stringifyJSON(serialized)) + .xadd(key, '*', 'data', stringifyJSON(serialized)) .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) .expire(key, this.retentionSeconds * 2) // double to avoid expires new events .exec() @@ -117,30 +118,42 @@ export class IORedisPublisher> extends Publishe } } - id = (result![0]![1] as string | null) ?? undefined + id = (result![0]![1] as string) } else { - const result = await this.commander.xadd(key, '*', stringifyJSON(serialized)) - id = result ?? undefined + const result = await this.commander.xadd(key, '*', 'data', stringifyJSON(serialized)) + id = result! } } await this.commander.publish(key, stringifyJSON({ ...serialized, id })) } - protected override async subscribeListener(event: K, _listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + protected override async subscribeListener(event: K, originalListener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { const key = this.prefixKey(event) const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] + let resumePayloadIds: (string | undefined)[] | undefined = [] const listener = (payload: T[K]) => { if (pendingPayloads) { pendingPayloads.push(payload) + return } - else { - _listener(payload) + + if (resumePayloadIds) { + const payloadId = getEventMeta(payload)?.id + for (const resumePayloadId of resumePayloadIds) { + if (payloadId === resumePayloadId) { // duplicate happen + return + } + } + + resumePayloadIds = undefined } + + originalListener(payload) } if (!this.redisListener) { @@ -177,13 +190,14 @@ export class IORedisPublisher> extends Publishe const [_, items] = results[0] const firstPendingId = getEventMeta(pendingPayloads[0])?.id for (const [id, fields] of items) { - if (id === firstPendingId) { + if (id === firstPendingId) { // duplicate happen break } - const serialized = fields[0]! + const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') const payload = this.deserializePayload(id, JSON.parse(serialized)) - listener(payload) + resumePayloadIds.push(id) + originalListener(payload) } } } @@ -193,10 +207,9 @@ export class IORedisPublisher> extends Publishe } finally { for (const payload of pendingPayloads) { - listener(payload) + originalListener(payload) } - - pendingPayloads = undefined // disable pending + pendingPayloads = undefined } })() From 171bb6dfe9d80d1844d740cb7c4d2de9192f103b Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 16 Oct 2025 20:36:54 +0700 Subject: [PATCH 09/30] ci: tests with real redis --- .env.example | 13 +++++++++---- .github/workflows/ci.yaml | 16 ++++++++++++++++ docker-compose.yaml | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 docker-compose.yaml diff --git a/.env.example b/.env.example index ebe311084..7f82e30fd 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ -# Some tests depend on redis -REDIS_URL= -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file +# copy this file and rename it to .env +# then fill in the appropriate values + +# Some tests in the project depend on Redis or Upstash Redis +# You can quickly start Redis locally with `docker compose up -d` +# See docker-compose.yaml for more details about the Redis setup +REDIS_URL=redis://localhost:6379 +UPSTASH_REDIS_REST_URL=http://localhost:8079 +UPSTASH_REDIS_REST_TOKEN=default \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d5fac87e..4a5917b3c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +env: + SRH_TOKEN: default + jobs: lint: runs-on: ubuntu-latest @@ -27,6 +30,15 @@ jobs: test: runs-on: ubuntu-latest + services: + redis: + image: redis/redis-stack-server:6.2.6-v6 # 6.2 is the Upstash compatible Redis version + srh: + image: hiett/serverless-redis-http:latest + env: + SRH_MODE: env # We are using env mode because we are only connecting to one server. + SRH_TOKEN: ${{ env.SRH_TOKEN }} + SRH_CONNECTION_STRING: redis://redis:6379 steps: - uses: actions/checkout@v4 @@ -40,6 +52,10 @@ jobs: - run: pnpm i - run: pnpm run test:coverage + env: + REDIS_URL: redis://redis:6379 + UPSTASH_REDIS_REST_URL: http://srh:80 + UPSTASH_REDIS_REST_TOKEN: ${{ env.SRH_TOKEN }} - uses: codecov/codecov-action@v5 with: diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..af9bf2f65 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +version: '3' +services: + redis: + image: redis/redis-stack-server:6.2.6-v6 # 6.2 is the Upstash compatible Redis version + ports: + - '6379:6379' + serverless-redis-http: + ports: + - '8079:80' + image: hiett/serverless-redis-http:latest + environment: + SRH_MODE: env + SRH_TOKEN: default + SRH_CONNECTION_STRING: 'redis://redis:6379' From 0cc63c2577cb207e9ca8484bf0237acb850a34a2 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 17 Oct 2025 11:11:24 +0700 Subject: [PATCH 10/30] upstash redis --- packages/publisher/package.json | 19 +- .../publisher/src/adapters/ioredis.test.ts | 86 +-- packages/publisher/src/adapters/ioredis.ts | 27 +- .../src/adapters/upstash-redis.test.ts | 530 ++++++++++++++++++ .../publisher/src/adapters/upstash-redis.ts | 254 +++++++++ pnpm-lock.yaml | 86 +-- 6 files changed, 916 insertions(+), 86 deletions(-) create mode 100644 packages/publisher/src/adapters/upstash-redis.test.ts create mode 100644 packages/publisher/src/adapters/upstash-redis.ts diff --git a/packages/publisher/package.json b/packages/publisher/package.json index d48400cf2..727efd689 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -24,12 +24,24 @@ "types": "./dist/adapters/memory.d.mts", "import": "./dist/adapters/memory.mjs", "default": "./dist/adapters/memory.mjs" + }, + "./ioredis": { + "types": "./dist/adapters/ioredis.d.mts", + "import": "./dist/adapters/ioredis.mjs", + "default": "./dist/adapters/ioredis.mjs" + }, + "./upstash-redis": { + "types": "./dist/adapters/upstash-redis.d.mts", + "import": "./dist/adapters/upstash-redis.mjs", + "default": "./dist/adapters/upstash-redis.mjs" } } }, "exports": { ".": "./src/index.ts", - "./memory": "./src/adapters/memory.ts" + "./memory": "./src/adapters/memory.ts", + "./ioredis": "./src/adapters/ioredis.ts", + "./upstash-redis": "./src/adapters/upstash-redis.ts" }, "files": [ "dist" @@ -40,9 +52,13 @@ "type:check": "tsc -b" }, "peerDependencies": { + "@upstash/redis": ">=1.35.6", "ioredis": ">=5.8.1" }, "peerDependenciesMeta": { + "@upstash/redis": { + "optional": true + }, "ioredis": { "optional": true } @@ -53,6 +69,7 @@ "@orpc/standard-server": "workspace:*" }, "devDependencies": { + "@upstash/redis": "^1.35.6", "ioredis": "^5.8.1" } } diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index c39dd00fe..7810f3cf3 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -407,7 +407,7 @@ describe('ioredis publisher', () => { await unsub1() }) - it('handles race condition where events published during resume', { repeats: 5 }, async () => { + it('handles race condition where events published during resume', { repeats: 10 }, async () => { publisher = new IORedisPublisher({ commander, listener, @@ -450,6 +450,8 @@ describe('ioredis publisher', () => { prefix: 'cleanup:test:', }) + publisher.xtrimExactness = '=' // for easier testing + const key1 = 'cleanup:test:event1' // Publish events to event1 @@ -457,7 +459,6 @@ describe('ioredis publisher', () => { await publisher.publish('event1', { order: 2 }) await publisher.publish('event1', { order: 3 }) - // Verify events are stored using xread const beforeCleanup = await commander.xread('STREAMS', key1, '0') expect(beforeCleanup![0]![1].length).toBe(3) // 3 events for event1 @@ -501,52 +502,65 @@ describe('ioredis publisher', () => { expect(exists).toBe(0) }) }) + }) - describe('edge cases', () => { - it('handles transaction errors during publish', async () => { - // Create a mock commander that will fail on multi - const mockCommander = { - ...commander, - multi: () => ({ - xadd: () => ({ xtrim: () => ({ expire: () => ({ exec: async () => [[new Error('Transaction failed')]] }) }) }), - }), - publish: commander.publish.bind(commander), - } as any + describe('edge cases', () => { + it('handles transaction errors during publish', async () => { + // Create a mock commander that will fail on multi + const mockCommander = { + ...commander, + multi: () => ({ + xadd: () => ({ xtrim: () => ({ expire: () => ({ exec: async () => [[new Error('Transaction failed')]] }) }) }), + }), + publish: commander.publish.bind(commander), + } as any - publisher = new IORedisPublisher({ - commander: mockCommander, - listener, - resumeRetentionSeconds: 10, - }) + publisher = new IORedisPublisher({ + commander: mockCommander, + listener, + resumeRetentionSeconds: 10, + }) + + // This should throw the transaction error + await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') + }) - // This should throw the transaction error - await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') + it('only subscribe to redis-listener when needed', async () => { + publisher = new IORedisPublisher({ + commander, + listener, }) - it('only subscribe to redis-listener when needed', async () => { - publisher = new IORedisPublisher({ - commander, - listener, - resumeRetentionSeconds: 10, - }) + expect(listener.listenerCount('message')).toBe(0) - expect(listener.listenerCount('message')).toBe(0) + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) + expect(listener.listenerCount('message')).toBe(1) - expect(listener.listenerCount('message')).toBe(1) + const unsub2 = await publisher.subscribe('event1', listener1) - const unsub2 = await publisher.subscribe('event1', listener1) + expect(listener.listenerCount('message')).toBe(1) // reuse listener - expect(listener.listenerCount('message')).toBe(1) // reuse listener + await unsub1() + await unsub2() - await unsub1() - await unsub2() + expect(listener.listenerCount('message')).toBe(0) + expect(publisher.size).toBe(0) + }) - expect(listener.listenerCount('message')).toBe(0) - expect(publisher.size).toBe(0) - }) + it('gracefully handles invalid subscription message', async () => { + publisher = new IORedisPublisher({ commander, listener }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + await commander.publish('orpc:publisher:event1', 'invalid message') + + // Wait for message to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + await unsub1() }) }) }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index e58ca8638..2f950b717 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -57,6 +57,13 @@ export class IORedisPublisher> extends Publishe return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 } + /** + * The exactness of the `XTRIM` command. + * + * @internal + */ + xtrimExactness: '~' | '=' = '~' + /** * Useful for measuring memory usage. * @@ -106,7 +113,7 @@ export class IORedisPublisher> extends Publishe const result = await this.commander.multi() .xadd(key, '*', 'data', stringifyJSON(serialized)) - .xtrim(key, 'MINID', `${now - this.retentionSeconds * 1000}-0`) + .xtrim(key, 'MINID', this.xtrimExactness as '~', `${now - this.retentionSeconds * 1000}-0`) .expire(key, this.retentionSeconds * 2) // double to avoid expires new events .exec() @@ -158,16 +165,22 @@ export class IORedisPublisher> extends Publishe if (!this.redisListener) { this.redisListener = (channel: string, message: string) => { - const listeners = this.listeners.get(channel) + try { + const listeners = this.listeners.get(channel) - if (listeners) { - const { id, ...rest } = JSON.parse(message) - const payload = this.deserializePayload(id, rest) + if (listeners) { + const { id, ...rest } = JSON.parse(message) + const payload = this.deserializePayload(id, rest) - for (const listener of listeners) { - listener(payload) + for (const listener of listeners) { + listener(payload) + } } } + catch { + // error can happen when message is invalid + // TODO: log error + } } this.listener.on('message', this.redisListener) diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts new file mode 100644 index 000000000..bbeb433c3 --- /dev/null +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -0,0 +1,530 @@ +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { Redis } from '@upstash/redis' +import { UpstashRedisPublisher } from './upstash-redis' + +describe('upstash redis publisher', { concurrent: false }, () => { + const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL + const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN + + if (!UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN) { + throw new Error('These tests require UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN env variables') + } + + let publisher: UpstashRedisPublisher + let redis: Redis + + beforeAll(() => { + redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + }) + + beforeEach(async () => { + await redis.flushall() + }) + + afterEach(async () => { + expect(publisher.size).toEqual(0) // ensure unsubscribed correctly + }) + + it('without resume: can pub/sub but not resume', async () => { + publisher = new UpstashRedisPublisher(redis) // resume is disabled by default + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + await publisher.publish('event1', payload1) + await publisher.publish('event3', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1.mock.calls[0]![0]).toEqual(payload1) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + await publisher.publish('event1', payload2) + await publisher.publish('event2', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2.mock.calls[0]![0]).toEqual(payload2) + + await unsub2() + + const unsub11 = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) + + // Wait a bit to ensure no resume happens + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) // resume not happens + await unsub11() + }) + + describe('with resume', () => { + it('basic pub/sub', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload1 = { order: 1 } + await publisher.publish('event1', payload1) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) + + await unsub1() + }) + + it('can pub/sub and resume', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event2', listener2) + + const payload1 = { order: 1 } + const payload2 = { order: 2 } + + await publisher.publish('event1', payload1) + await publisher.publish('event3', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) + expect(listener2).toHaveBeenCalledTimes(0) + + await unsub1() + + await publisher.publish('event1', payload2) + await publisher.publish('event2', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload2)) + + await unsub2() + + const listener3 = vi.fn() + const unsub3 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener3).toHaveBeenCalledTimes(2) // resume happens + expect(listener3).toHaveBeenNthCalledWith(1, expect.objectContaining(payload1)) + expect(listener3).toHaveBeenNthCalledWith(2, expect.objectContaining(payload2)) + + await unsub3() + }) + + it('control event.id', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload1 = { order: 1 } + const payload2 = withEventMeta({ order: 2 }, { id: 'some-id', comments: ['hello'] }) + + await publisher.publish('event1', payload1) + await publisher.publish('event1', payload2) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(2) + expect(listener1).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload1) + expect(p).toEqual(payload1) + const meta = getEventMeta(p) + expect(meta?.id).toBeDefined() + expect(typeof meta?.id).toBe('string') + return true + })) + expect(listener1).toHaveBeenNthCalledWith(2, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + const meta = getEventMeta(p) + expect(meta?.id).toBeDefined() + expect(typeof meta?.id).toBe('string') + expect(meta?.comments).toEqual(['hello']) + return true + })) + + const firstEventId = getEventMeta(listener1.mock.calls[0]![0])?.id + + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2, { lastEventId: firstEventId }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener2).toHaveBeenCalledTimes(1) // only second event + expect(listener2).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { + expect(p).not.toBe(payload2) + expect(p).toEqual(payload2) + const meta = getEventMeta(p) + expect(meta?.id).toEqual(getEventMeta(listener1.mock.calls[1]![0])?.id) + expect(meta?.comments).toEqual(['hello']) + return true + })) + + await unsub1() + await unsub2() + }) + + it('resume event.id > lastEventId and in order', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event', listener1) + + for (let i = 1; i <= 10; i++) { + await publisher.publish('event', { order: i }) + } + + // Wait for all events to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(10) + + // Get the ID of the 5th event + const fifthEventId = getEventMeta(listener1.mock.calls[4]![0])?.id + + if (!fifthEventId) { + throw new Error('No event ID found') + } + + await unsub1() + + // Now subscribe with lastEventId set to the 5th event + // Should receive events 6-10 + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event', listener2, { lastEventId: fifthEventId }) + + // Wait for resume to complete + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should have received events 6-10 (5 events) + expect(listener2).toHaveBeenCalledTimes(5) + expect(listener2).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 6 })) + expect(listener2).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 7 })) + expect(listener2).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 10 })) + + // Verify order + for (let i = 0; i < listener2.mock.calls.length - 1; i++) { + const current = listener2.mock.calls[i]![0].order + const next = listener2.mock.calls[i + 1]![0].order + expect(next).toBeGreaterThan(current) + } + + await unsub2() + }) + + it('handles multiple subscribers on same event', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + const listener3 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event1', listener2) + const unsub3 = await publisher.subscribe('event1', listener3) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener3).toHaveBeenCalledWith(expect.objectContaining(payload)) + + await unsub1() + await unsub2() + await unsub3() + }) + + it('handles custom prefix', async () => { + publisher = new UpstashRedisPublisher(redis, { + prefix: 'custom:prefix:', + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + + // Verify the key uses custom prefix + const keys = await redis.keys('custom:prefix:*') + expect(keys.length).toBeGreaterThan(0) + expect(keys.some(k => k.includes('custom:prefix:event1'))).toBe(true) + + await unsub1() + }) + + it('handles serialization with complex objects and custom serializers', async () => { + class Person { + constructor( + public name: string, + public date: Date, + ) {} + } + + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + customJsonSerializers: [ + { + condition: data => data instanceof Person, + type: 20, + serialize: person => ({ name: person.name, date: person.date }), + deserialize: data => new Person(data.name, data.date), + }, + ], + }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { + order: 1, + nested: { + value: 'test', + array: [1, 2, 3], + }, + date: new Date('2024-01-01'), + person: new Person('John Doe', new Date('2023-01-01')), + } + + await publisher.publish('event1', payload) + + // Wait for messages to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(1) + const received = listener1.mock.calls[0]![0] + expect(received.order).toBe(1) + expect(received.nested.value).toBe('test') + expect(received.nested.array).toEqual([1, 2, 3]) + expect(received.date).toEqual(new Date('2024-01-01')) + expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) + + await unsub1() + }) + + it('handles errors during resume gracefully', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + + // Subscribe with an invalid lastEventId to trigger error in xread + const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format' }) + + // Publish an event + await publisher.publish('event1', { order: 1 }) + + // Wait for message to be received (should still work despite resume error) + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should have received the new event even though resume failed + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) + + await unsub1() + }) + + it('handles race condition where events published during resume', { repeats: 10 }, async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 10, + }) + + await publisher.publish('event1', { order: 1 }) + await new Promise(resolve => setTimeout(resolve, 150)) // wait for publish to finish + await publisher.publish('event1', { order: 2 }) + + publisher.publish('event1', { order: 3 }) + publisher.publish('event1', { order: 4 }) + const listener1 = vi.fn() + const unsub = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) + + await publisher.publish('event1', { order: 5 }) + await publisher.publish('event1', { order: 6 }) + + // Wait for publish to finish + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + expect(listener1).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 1 })) + expect(listener1).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 2 })) + expect(listener1).toHaveBeenNthCalledWith(3, expect.objectContaining({ order: 3 })) + expect(listener1).toHaveBeenNthCalledWith(4, expect.objectContaining({ order: 4 })) + expect(listener1).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 5 })) + expect(listener1).toHaveBeenNthCalledWith(6, expect.objectContaining({ order: 6 })) + + await unsub() + }) + + describe('cleanup retention', () => { + it('handles cleanup of expired events on publish', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 1, // 1 second retention + prefix: 'cleanup:test:', + }) + + publisher.xtrimExactness = '=' // for easier testing + + const key1 = 'cleanup:test:event1' + + // Publish events to event1 + await publisher.publish('event1', { order: 1 }) + await publisher.publish('event1', { order: 2 }) + await publisher.publish('event1', { order: 3 }) + + // Verify events are stored using xread + const beforeCleanup = await redis.xread(key1, '0') as any + + expect(beforeCleanup[0][1].length).toBe(3) // 3 events for event1 + + // Wait for retention to expire + await new Promise(resolve => setTimeout(resolve, 1100)) + + // Trigger cleanup by publishing a new event to event1 + await publisher.publish('event1', { order: 4 }) + + // Verify cleanup happened using xread - old events should be trimmed + const afterCleanup = await redis.xread(key1, '0') as any + + // event1 should only have the new event (order: 4), old ones trimmed + expect(afterCleanup[0][1].length).toBe(1) + }) + + it('verifies Redis auto-expires keys after retention period * 2', async () => { + publisher = new UpstashRedisPublisher(redis, { + resumeRetentionSeconds: 1, + prefix: 'test:expire:', + }) + + const key = 'test:expire:event1' + + // Publish an event + await publisher.publish('event1', { order: 1 }) + + // Verify key exists + const ttl1 = await redis.ttl(key) + expect(ttl1).toBeGreaterThan(0) + expect(ttl1).toBeLessThanOrEqual(2) // (2 * retentionSeconds) + + // Wait for key to expire (2 * retentionSeconds = 2 seconds) + await new Promise(resolve => setTimeout(resolve, 2500)) + + // Verify key has been auto-expired by Redis + const exists = await redis.exists(key) + expect(exists).toBe(0) + }) + }) + }) + + describe('edge cases', () => { + it('only subscribe to redis-listener when needed', async () => { + const originalSubscribe = redis.subscribe.bind(redis) + const unsubscribeSpys: any[] = [] + const subscribeSpy = vi.spyOn(redis, 'subscribe') + subscribeSpy.mockImplementation((...args) => { + const subscription = originalSubscribe(...args) + unsubscribeSpys.push(vi.spyOn(subscription, 'unsubscribe')) + return subscription + }) + publisher = new UpstashRedisPublisher(redis) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + const unsub2 = await publisher.subscribe('event1', listener1) + + expect(subscribeSpy).toHaveBeenCalledTimes(1) + expect(unsubscribeSpys[0]).toBeCalledTimes(0) + + await unsub1() + expect(unsubscribeSpys[0]).toBeCalledTimes(0) + + await unsub2() + expect(unsubscribeSpys[0]).toBeCalledTimes(1) // unsubscribed in redis + + expect(unsubscribeSpys.length).toBe(1) // ensure only subscribe once + }) + + it('subscribe should throw & on connection error', async () => { + const redis = new Redis({ + url: 'http://invalid:6379', + token: 'invalid', + }) + + publisher = new UpstashRedisPublisher(redis) + + await expect(publisher.subscribe('event1', () => { })).rejects.toThrow() + }) + + it('gracefully handles invalid subscription message', async () => { + publisher = new UpstashRedisPublisher(redis) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + await redis.publish('orpc:publisher:event1', 'invalid message') + + // Wait for message to be received + await new Promise(resolve => setTimeout(resolve, 150)) + + await unsub1() + }) + }) +}) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts new file mode 100644 index 000000000..69ec27414 --- /dev/null +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -0,0 +1,254 @@ +import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOptions } from '@orpc/client/standard' +import type { Redis } from '@upstash/redis' +import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' +import { StandardRPCJsonSerializer } from '@orpc/client/standard' +import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { Publisher } from '../publisher' + +type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } + +export interface UpstashRedisPublisherOptions extends PublisherOptions, StandardRPCJsonSerializerOptions { + /** + * How long (in seconds) to retain events for replay. + * + * @remark + * This allows new subscribers to "catch up" on missed events using `lastEventId`. + * Note that event cleanup is deferred for performance reasons — meaning some + * expired events may still be available for a short period of time, and listeners + * might still receive them. + * + * @default NaN (disabled) + */ + resumeRetentionSeconds?: number + + /** + * The prefix to use for Redis keys. + * + * @default orpc:publisher: + */ + prefix?: string +} + +export class UpstashRedisPublisher> extends Publisher { + protected readonly prefix: string + protected readonly serializer: StandardRPCJsonSerializer + protected readonly retentionSeconds: number + protected readonly listenersMap = new Map void>>() + protected readonly subscriptionsMap = new Map() // Upstash subscription objects + + protected get isResumeEnabled(): boolean { + return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 + } + + /** + * The exactness of the `XTRIM` command. + * + * @internal + */ + xtrimExactness: '~' | '=' = '~' + + /** + * Useful for measuring memory usage. + * + * @internal + * + */ + get size(): number { + /* v8 ignore next 5 */ + let size = 0 + for (const listeners of this.listenersMap) { + size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event + } + return size + } + + constructor( + protected readonly redis: Redis, + { resumeRetentionSeconds, prefix, ...options }: UpstashRedisPublisherOptions = {}, + ) { + super(options) + + this.prefix = prefix ?? 'orpc:publisher:' + this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN + this.serializer = new StandardRPCJsonSerializer(options) + } + + protected lastCleanupTimes: Map = new Map() + override async publish(event: K, payload: T[K]): Promise { + const key = this.prefixKey(event) + + const serialized = this.serializePayload(payload) + + let id: string | undefined + if (this.isResumeEnabled) { + const now = Date.now() + + // cleanup for more efficiency memory + for (const [key, lastCleanupTime] of this.lastCleanupTimes) { + if (lastCleanupTime + this.retentionSeconds * 1000 < now) { + this.lastCleanupTimes.delete(key) + } + } + + if (!this.lastCleanupTimes.has(key)) { + this.lastCleanupTimes.set(key, now) + + const results = await this.redis.multi() + .xadd(key, '*', { data: serialized }) + .xtrim(key, { strategy: 'MINID', exactness: this.xtrimExactness, threshold: `${now - this.retentionSeconds * 1000}-0` }) + .expire(key, this.retentionSeconds * 2) + .exec() + + id = results[0] + } + else { + const result = await this.redis.xadd(key, '*', { data: serialized }) + id = result + } + } + + await this.redis.publish(key, { ...serialized, id }) + } + + protected override async subscribeListener(event: K, originalListener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + const key = this.prefixKey(event) + + const lastEventId = options?.lastEventId + let pendingPayloads: T[K][] | undefined = [] + let resumePayloadIds: (string | undefined)[] | undefined = [] + + const listener = (payload: T[K]) => { + if (pendingPayloads) { + pendingPayloads.push(payload) + return + } + + if (resumePayloadIds) { + const payloadId = getEventMeta(payload)?.id + for (const resumePayloadId of resumePayloadIds) { + if (payloadId === resumePayloadId) { // duplicate happen + return + } + } + + resumePayloadIds = undefined + } + + originalListener(payload) + } + + // Get or create subscription for this channel + let subscription = this.subscriptionsMap.get(key) as ReturnType | undefined + if (!subscription) { + subscription = this.redis.subscribe(key) + subscription.on('message', (event) => { + try { + const listeners = this.listenersMap.get(event.channel) + + if (listeners) { + const { id, ...rest } = event.message as any + const payload = this.deserializePayload(id, rest) + + for (const listener of listeners) { + listener(payload) + } + } + } + catch { + // there error can happen when event.message is invalid + // TODO: log error + } + }) + + let resolvePromise: (value?: unknown) => void + let rejectPromise: (error: Error) => void + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + + subscription.on('error', (error) => { + rejectPromise(error) + }) + + subscription.on('subscribe', () => { + resolvePromise() + }) + + await promise + + this.subscriptionsMap.set(key, subscription) // only set after subscription is ready + } + + let listeners = this.listenersMap.get(key) + if (!listeners) { + this.listenersMap.set(key, listeners = new Set()) + } + + listeners.add(listener) + + void (async () => { + try { + if (this.isResumeEnabled && typeof lastEventId === 'string') { + const results = await this.redis.xread(key, lastEventId) + + if (results && results[0]) { + const [_, items] = results[0] as any + const firstPendingId = getEventMeta(pendingPayloads[0])?.id + for (const [id, fields] of items) { + if (id === firstPendingId) { // duplicate happen + break + } + + const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') + const payload = this.deserializePayload(id, serialized) + resumePayloadIds.push(id) + originalListener(payload) + } + } + } + } + catch { + // error can happen when result from xread is invalid + // TODO: log error + } + finally { + for (const payload of pendingPayloads) { + originalListener(payload) + } + pendingPayloads = undefined + } + })() + + return async () => { + listeners.delete(listener) + + if (listeners.size === 0) { + this.listenersMap.delete(key) // should execute before async to avoid throw + const subscription = this.subscriptionsMap.get(key) + + if (subscription) { + this.subscriptionsMap.delete(key) + await subscription.unsubscribe() + } + } + } + } + + protected prefixKey(key: string): string { + return `${this.prefix}${key}` + } + + protected serializePayload(payload: object): SerializedPayload { + const eventMeta = getEventMeta(payload) + const [json, meta] = this.serializer.serialize(payload) + return { json: json as object, meta, eventMeta } + } + + protected deserializePayload(id: string | undefined, { json, meta, eventMeta }: SerializedPayload): any { + return withEventMeta( + this.serializer.deserialize(json, meta) as object, + id === undefined ? { ...eventMeta } : { ...eventMeta, id }, + ) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac2052509..a3231c37c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,6 +508,9 @@ importers: specifier: workspace:* version: link:../standard-server devDependencies: + '@upstash/redis': + specifier: ^1.35.6 + version: 1.35.6 ioredis: specifier: ^5.8.1 version: 5.8.1 @@ -952,7 +955,7 @@ importers: version: 19.2.0(@types/react@19.2.0) astro: specifier: ^5.14.1 - version: 5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1) + version: 5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1) react: specifier: ^19.2.0 version: 19.2.0 @@ -1402,13 +1405,13 @@ importers: version: 5.90.2(vue@3.5.22(typescript@5.8.3)) nuxt: specifier: ^4.1.2 - version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + version: 4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) vue: specifier: latest version: 3.5.22(typescript@5.8.3) vue-router: specifier: latest - version: 4.6.1(vue@3.5.22(typescript@5.8.3)) + version: 4.6.3(vue@3.5.22(typescript@5.8.3)) zod: specifier: ^4.1.11 version: 4.1.11 @@ -1438,7 +1441,7 @@ importers: version: 0.15.3(solid-js@1.9.9) '@solidjs/start': specifier: ^1.2.0 - version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) '@tanstack/solid-query': specifier: ^5.90.3 version: 5.90.3(solid-js@1.9.9) @@ -1447,7 +1450,7 @@ importers: version: 1.9.9 vinxi: specifier: ^0.5.8 - version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-top-level-await: specifier: ^1.6.0 version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.52.4)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -7302,8 +7305,8 @@ packages: peerDependencies: vue: '>=3.5.18' - '@upstash/redis@1.35.5': - resolution: {integrity: sha512-KdLdNAspQGOTGeC++o2LDBzNbMXrfInnmW5nUJfNXabnVh8X4NPrlJ0X4j75cBUShiMpXB3uI1ql4KpFQeqrHQ==} + '@upstash/redis@1.35.6': + resolution: {integrity: sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==} '@valibot/to-json-schema@1.3.0': resolution: {integrity: sha512-82Vv6x7sOYhv5YmTRgSppSqj1nn2pMCk5BqCMGWYp0V/fq+qirrbGncqZAtZ09/lrO40ne/7z8ejwE728aVreg==} @@ -15460,8 +15463,8 @@ packages: peerDependencies: vue: ^3.0.0 - vue-router@4.6.1: - resolution: {integrity: sha512-m6bVZMXKP4qPB+e2pU6Ptgfy58PDSI3Tf7bNCWqVAf0cIGe+9zR3qCu2nRmFO+CysfUxIfI+1uzD7zWVQ7zwtQ==} + vue-router@4.6.3: + resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==} peerDependencies: vue: ^3.5.0 @@ -20638,7 +20641,7 @@ snapshots: shell-quote: 1.8.3 type-fest: 4.41.0 vue: 3.5.22(typescript@5.8.3) - vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.3(vue@3.5.22(typescript@5.8.3)) whatwg-mimetype: 4.0.0 yaml: 2.8.0 zod: 4.1.11 @@ -21153,11 +21156,11 @@ snapshots: dependencies: solid-js: 1.9.9 - '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@solidjs/start@1.2.0(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tanstack/server-functions-plugin': 1.121.21(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) cookie-es: 2.0.0 defu: 6.1.4 error-stack-parser: 2.1.4 @@ -21169,7 +21172,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.9) tinyglobby: 0.2.15 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.9(@testing-library/jest-dom@6.9.1)(solid-js@1.9.9)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -22346,10 +22349,9 @@ snapshots: unhead: 2.0.17 vue: 3.5.22(typescript@5.8.3) - '@upstash/redis@1.35.5': + '@upstash/redis@1.35.6': dependencies: uncrypto: 0.1.3 - optional: true '@valibot/to-json-schema@1.3.0(valibot@1.1.0(typescript@5.8.3))': dependencies: @@ -22415,7 +22417,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.28.4 acorn: 8.15.0 @@ -22426,18 +22428,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.15.0 acorn-loose: 8.5.2 acorn-typescript: 1.4.13(acorn@8.15.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1) '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: @@ -23441,7 +23443,7 @@ snapshots: astring@1.9.0: {} - astro@5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1): + astro@5.14.1(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.3 @@ -23495,7 +23497,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.5.2 unist-util-visit: 5.0.0 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) vfile: 6.0.3 vite: 6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) vitefu: 1.1.1(vite@6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -28390,7 +28392,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nitropack@2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): + nitropack@2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.10(encoding@0.1.13)(rollup@4.52.4) @@ -28458,7 +28460,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.2.5 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 unwasm: 0.3.9 youch: 4.1.0-beta.8 @@ -28494,7 +28496,7 @@ snapshots: - supports-color - uploadthing - nitropack@2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): + nitropack@2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@rollup/plugin-alias': 5.1.1(rollup@4.52.4) @@ -28561,7 +28563,7 @@ snapshots: unenv: 2.0.0-rc.21 unimport: 5.4.0 unplugin-utils: 0.3.0 - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 unwasm: 0.3.11 youch: 4.1.0-beta.11 @@ -28711,7 +28713,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.5)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): + nuxt@4.1.2(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.7.0)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.0))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.4)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@nuxt/cli': 3.28.0(magicast@0.3.5) '@nuxt/devalue': 2.0.2 @@ -28746,7 +28748,7 @@ snapshots: mlly: 1.8.0 mocked-exports: 0.1.1 nanotar: 0.2.0 - nitropack: 2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) + nitropack: 2.12.6(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) nypm: 0.6.2 ofetch: 1.4.1 ohash: 2.0.11 @@ -28769,13 +28771,13 @@ snapshots: unctx: 2.4.1 unimport: 5.4.0 unplugin: 2.3.10 - unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) - unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) + unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)) + unstorage: 1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) untyped: 2.0.0 vue: 3.5.22(typescript@5.8.3) vue-bundle-renderer: 2.1.2 vue-devtools-stub: 0.1.0 - vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.3(vue@3.5.22(typescript@5.8.3)) optionalDependencies: '@parcel/watcher': 2.5.1 '@types/node': 24.7.0 @@ -31786,7 +31788,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)): + unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.22)(typescript@5.8.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3)): dependencies: '@vue-macros/common': 3.0.0-beta.16(vue@3.5.22(typescript@5.8.3)) '@vue/compiler-sfc': 3.5.22 @@ -31806,7 +31808,7 @@ snapshots: unplugin-utils: 0.2.5 yaml: 2.8.1 optionalDependencies: - vue-router: 4.6.1(vue@3.5.22(typescript@5.8.3)) + vue-router: 4.6.3(vue@3.5.22(typescript@5.8.3)) transitivePeerDependencies: - typescript - vue @@ -31823,7 +31825,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): + unstorage@1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31835,11 +31837,11 @@ snapshots: ufo: 1.6.1 optionalDependencies: '@netlify/blobs': 9.1.2 - '@upstash/redis': 1.35.5 + '@upstash/redis': 1.35.6 db0: 0.3.2(better-sqlite3@12.4.1) ioredis: 5.8.1 - unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): + unstorage@1.17.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -31851,7 +31853,7 @@ snapshots: ufo: 1.6.1 optionalDependencies: '@netlify/blobs': 9.1.2 - '@upstash/redis': 1.35.5 + '@upstash/redis': 1.35.6 db0: 0.3.2(better-sqlite3@12.4.1) ioredis: 5.8.1 @@ -31991,7 +31993,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.8(@netlify/blobs@9.1.2)(@types/node@24.7.0)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.2(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) @@ -32012,7 +32014,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) + nitropack: 2.12.4(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -32024,7 +32026,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.5)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) + unstorage: 1.16.1(@netlify/blobs@9.1.2)(@upstash/redis@1.35.6)(db0@0.3.2(better-sqlite3@12.4.1))(ioredis@5.8.1) vite: 6.3.6(@types/node@24.7.0)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) zod: 3.25.76 transitivePeerDependencies: @@ -32625,7 +32627,7 @@ snapshots: dependencies: vue: 3.5.22(typescript@5.8.3) - vue-router@4.6.1(vue@3.5.22(typescript@5.8.3)): + vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)): dependencies: '@vue/devtools-api': 6.6.4 vue: 3.5.22(typescript@5.8.3) From 6eae7c06990abb568bf29cf132f8d3311a4a21c3 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 17 Oct 2025 15:37:16 +0700 Subject: [PATCH 11/30] docs --- apps/content/.vitepress/config.ts | 1 + apps/content/docs/event-iterator.md | 48 ++--- apps/content/docs/helpers/publisher.md | 221 +++++++++++++++++++++ packages/publisher/src/publisher.test-d.ts | 75 +++++++ 4 files changed, 309 insertions(+), 36 deletions(-) create mode 100644 apps/content/docs/helpers/publisher.md create mode 100644 packages/publisher/src/publisher.test-d.ts diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index c51f3714e..41f219e3a 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -160,6 +160,7 @@ export default withMermaid(defineConfig({ { text: 'Cookie', link: '/docs/helpers/cookie' }, { text: 'Encryption', link: '/docs/helpers/encryption' }, { text: 'Form Data', link: '/docs/helpers/form-data' }, + { text: 'Publisher', link: '/docs/helpers/publisher' }, { text: 'Signing', link: '/docs/helpers/signing' }, ], }, diff --git a/apps/content/docs/event-iterator.md b/apps/content/docs/event-iterator.md index 7acd3169f..1e59f1032 100644 --- a/apps/content/docs/event-iterator.md +++ b/apps/content/docs/event-iterator.md @@ -106,53 +106,29 @@ const example = os }) ``` -## Event Publisher +## Publisher Combination -oRPC includes a built-in `EventPublisher` for real-time features like chat, notifications, or live updates. It supports broadcasting and subscribing to named events. +You can combine the event iterator with [Publisher Helper](/docs/helpers/publisher) to build real-time features like chat, notifications, or live updates. -::: code-group - -```ts [Static Events] -import { EventPublisher } from '@orpc/server' - -const publisher = new EventPublisher<{ +```ts +const publisher = new MemoryPublisher<{ 'something-updated': { id: string } }>() -const livePlanet = os +const live = os .handler(async function* ({ input, signal }) { - for await (const payload of publisher.subscribe('something-updated', { signal })) { // [!code highlight] - // handle payload here and yield something to client + const iterator = publisher.subscribe('something-updated', { signal }) + for await (const payload of iterator) { + // Handle payload here or yield directly to client + yield payload } }) -const update = os +const publish = os .input(z.object({ id: z.string() })) - .handler(({ input }) => { - publisher.publish('something-updated', { id: input.id }) // [!code highlight] - }) -``` - -```ts [Dynamic Events] -import { EventPublisher } from '@orpc/server' - -const publisher = new EventPublisher>() - -const onMessage = os - .input(z.object({ channel: z.string() })) - .handler(async function* ({ input, signal }) { - for await (const payload of publisher.subscribe(input.channel, { signal })) { // [!code highlight] - yield payload.message - } - }) - -const sendMessage = os - .input(z.object({ channel: z.string(), message: z.string() })) - .handler(({ input }) => { - publisher.publish(input.channel, { message: input.message }) // [!code highlight] + .handler(async ({ input }) => { + await publisher.publish('something-updated', { id: input.id }) }) ``` - -::: diff --git a/apps/content/docs/helpers/publisher.md b/apps/content/docs/helpers/publisher.md new file mode 100644 index 000000000..c3762f5d3 --- /dev/null +++ b/apps/content/docs/helpers/publisher.md @@ -0,0 +1,221 @@ +--- +title: Publisher +description: Listen and publish events with resuming support in oRPC +--- + +# Publisher + +The Publisher is a helper that enables you to listen to and publish events to subscribers. Combined with the [Event Iterator](/docs/client/event-iterator), it allows you to build streaming responses, real-time updates, and server-sent events with minimal requirements. + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/experimental-publisher@latest +``` + +```sh [yarn] +yarn add @orpc/experimental-publisher@latest +``` + +```sh [pnpm] +pnpm add @orpc/experimental-publisher@latest +``` + +```sh [bun] +bun add @orpc/experimental-publisher@latest +``` + +```sh [deno] +deno add npm:@orpc/experimental-publisher@latest +``` + +::: + +## Basic Usage + +```ts twoslash +import { MemoryPublisher } from '@orpc/experimental-publisher/memory' +import { os } from '@orpc/server' +import * as z from 'zod' +// ---cut--- +const publisher = new MemoryPublisher<{ + 'something-updated': { + id: string + } +}>() + +const live = os + .handler(async function* ({ input, signal }) { + const iterator = publisher.subscribe('something-updated', { signal }) + for await (const payload of iterator) { + // Handle payload here or yield directly to client + yield payload + } + }) + +const publish = os + .input(z.object({ id: z.string() })) + .handler(async ({ input }) => { + await publisher.publish('something-updated', { id: input.id }) + }) +``` + +::: tip +The publisher supports both static and dynamic event names. + +```ts +const publisher = new MemoryPublisher>() +``` + +::: + +## Resume Feature + +The resume feature uses `lastEventId` to determine where to resume from after a disconnection. + +::: warning +By default, most adapters have this feature disabled. +::: + +### Server Implementation + +When subscribing, you must forward the `lastEventId` to the publisher to enable resuming: + +```ts +const live = os + .handler(async function* ({ input, signal, lastEventId }) { + const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) + for await (const payload of iterator) { + yield payload + } + }) +``` + +::: warning Event ID Management +The publisher automatically manages event ids when resume is enabled. This means: + +- Event ids you provide when publishing will be ignored +- When subscribing, you must forward the event id when yielding custom payloads + +```ts +import { getEventMeta, withEventMeta } from '@orpc/server' + +const live = os + .handler(async function* ({ input, signal, lastEventId }) { + const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) + for await (const payload of iterator) { + // Preserve event id when yielding custom data + yield withEventMeta({ custom: 'value' }, { ...getEventMeta(payload) }) + } + }) + +const publish = os + .input(z.object({ id: z.string() })) + .handler(async ({ input }) => { + // The event id 'this-will-be-ignored' will be replaced by the publisher + await publisher.publish('something-updated', withEventMeta({ id: input.id }, { id: 'this-will-be-ignored' })) + }) +``` + +::: + +### Client Implementation + +On the client, you can use the [Client Retry Plugin](/docs/plugins/client-retry), which automatically controls and passes `lastEventId` to the server when reconnecting. Alternatively, you can manage `lastEventId` manually: + +```ts +import { getEventMeta } from '@orpc/client' + +let lastEventId: string | undefined + +while (true) { + try { + const iterator = await client.live('input', { lastEventId }) + + for await (const payload of iterator) { + lastEventId = getEventMeta(payload)?.id // Update lastEventId + + console.log(payload) + } + } + catch { + await new Promise(resolve => setTimeout(resolve, 1000)) // Wait 1 second before retrying + } +} +``` + +## Available Adapters + +| Name | Resume Support | Description | +| ----------------------- | -------------- | ---------------------------------------------------------------- | +| `MemoryPublisher` | ✅ | A simple in-memory publisher | +| `IORedisPublisher` | ✅ | Adapter for [ioredis](https://github.com/redis/ioredis) | +| `UpstashRedisPublisher` | ✅ | Adapter for [Upstash Redis](https://github.com/upstash/redis-js) | + +::: info +If you'd like to add a new publisher adapter, please open an issue. +::: + +### Memory Publisher + +```ts +import { MemoryPublisher } from '@orpc/experimental-publisher/memory' + +const publisher = new MemoryPublisher<{ + 'something-updated': { + id: string + } +}>({ + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes +}) +``` + +::: info +Resume support is disabled by default in `MemoryPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. +::: + +### IORedis Publisher + +```ts +import { Redis } from 'ioredis' +import { IORedisPublisher } from '@orpc/experimental-publisher/ioredis' + +const publisher = new IORedisPublisher<{ + 'something-updated': { + id: string + } +}>({ + commander: new Redis(), // For executing short-lived commands + subscriber: new Redis(), // For subscribing to events + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes +}) +``` + +This adapter requires two Redis instances: one for executing short-lived commands and another for subscribing to events. + +::: info +Resume support is disabled by default in `IORedisPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. +::: + +### Upstash Redis Publisher + +```ts +import { Redis } from '@upstash/redis' +import { UpstashRedisPublisher } from '@orpc/experimental-publisher/upstash-redis' + +const redis = Redis.fromEnv() + +const publisher = new UpstashRedisPublisher<{ + 'something-updated': { + id: string + } +}>(redis, { + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes +}) +``` + +::: info +Resume support is disabled by default in `UpstashRedisPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. +::: diff --git a/packages/publisher/src/publisher.test-d.ts b/packages/publisher/src/publisher.test-d.ts new file mode 100644 index 000000000..7ea6b43f2 --- /dev/null +++ b/packages/publisher/src/publisher.test-d.ts @@ -0,0 +1,75 @@ +import type { Publisher } from './publisher' + +describe('publisher', () => { + it('key-value types', async () => { + const pub = {} as Publisher<{ + 'event-1': { + id: string + } + 'event-2': { + name: string + } + }> + + pub.publish('event-1', { id: '1' }) + pub.publish('event-2', { name: '1' }) + // @ts-expect-error - wrong event + pub.publish('event-3', { name: '1' }) + // @ts-expect-error - wrong payload + pub.publish('event-2', { name: 123 }) + + pub.subscribe('event-1', (payload) => { + expectTypeOf(payload).toEqualTypeOf<{ + id: string + }>() + }) + + pub.subscribe('event-2', (payload) => { + expectTypeOf(payload).toEqualTypeOf<{ + name: string + }>() + }) + + // @ts-expect-error - wrong event + pub.subscribe('event-3', (payload) => {}) + + for await (const payload of pub.subscribe('event-1')) { + expectTypeOf(payload).toEqualTypeOf<{ + id: string + }>() + } + + for await (const payload of pub.subscribe('event-2')) { + expectTypeOf(payload).toEqualTypeOf<{ + name: string + }>() + } + + // @ts-expect-error - wrong event + for await (const payload of pub.subscribe('event-3')) { + // empty + } + }) + + it('record types', async () => { + const pub = {} as Publisher> + + pub.publish('event-1', { id: '1' }) + pub.publish('event-2', { id: '1' }) + pub.publish('event-100', { id: '1' }) + // @ts-expect-error - invalid payload + pub.publish('event-100', { id: 123 }) + + pub.subscribe('event-933', (payload) => { + expectTypeOf(payload).toEqualTypeOf<{ + id: string + }>() + }) + + for await (const payload of pub.subscribe('event-3439')) { + expectTypeOf(payload).toEqualTypeOf<{ + id: string + }>() + } + }) +}) From d8a69f0bcefbe74e08c1e3d945488489c675b3c4 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 17 Oct 2025 16:49:33 +0700 Subject: [PATCH 12/30] fix & improve --- .env.example | 9 ++++----- .github/workflows/ci.yaml | 15 +++------------ apps/content/package.json | 1 + docker-compose.yaml | 14 -------------- packages/publisher/src/adapters/ioredis.ts | 8 +++++--- packages/publisher/src/adapters/upstash-redis.ts | 8 +++++--- pnpm-lock.yaml | 3 +++ vitest.config.ts | 16 +++++++++++++++- 8 files changed, 36 insertions(+), 38 deletions(-) delete mode 100644 docker-compose.yaml diff --git a/.env.example b/.env.example index 7f82e30fd..c0baf4787 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,7 @@ # then fill in the appropriate values # Some tests in the project depend on Redis or Upstash Redis -# You can quickly start Redis locally with `docker compose up -d` -# See docker-compose.yaml for more details about the Redis setup -REDIS_URL=redis://localhost:6379 -UPSTASH_REDIS_REST_URL=http://localhost:8079 -UPSTASH_REDIS_REST_TOKEN=default \ No newline at end of file +# You can create a free redis instance at upstash and copy the connection details here +REDIS_URL= +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4a5917b3c..cf81d425b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,9 +6,6 @@ on: pull_request: branches: [main] -env: - SRH_TOKEN: default - jobs: lint: runs-on: ubuntu-latest @@ -32,13 +29,7 @@ jobs: runs-on: ubuntu-latest services: redis: - image: redis/redis-stack-server:6.2.6-v6 # 6.2 is the Upstash compatible Redis version - srh: - image: hiett/serverless-redis-http:latest - env: - SRH_MODE: env # We are using env mode because we are only connecting to one server. - SRH_TOKEN: ${{ env.SRH_TOKEN }} - SRH_CONNECTION_STRING: redis://redis:6379 + image: redis:alpine steps: - uses: actions/checkout@v4 @@ -54,8 +45,8 @@ jobs: - run: pnpm run test:coverage env: REDIS_URL: redis://redis:6379 - UPSTASH_REDIS_REST_URL: http://srh:80 - UPSTASH_REDIS_REST_TOKEN: ${{ env.SRH_TOKEN }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} - uses: codecov/codecov-action@v5 with: diff --git a/apps/content/package.json b/apps/content/package.json index d489f8702..a63810f59 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -16,6 +16,7 @@ "@orpc/arktype": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", + "@orpc/experimental-publisher": "workspace:*", "@orpc/experimental-react-swr": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/openapi-client": "workspace:*", diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index af9bf2f65..000000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: '3' -services: - redis: - image: redis/redis-stack-server:6.2.6-v6 # 6.2 is the Upstash compatible Redis version - ports: - - '6379:6379' - serverless-redis-http: - ports: - - '8079:80' - image: hiett/serverless-redis-http:latest - environment: - SRH_MODE: env - SRH_TOKEN: default - SRH_CONNECTION_STRING: 'redis://redis:6379' diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 2f950b717..23ba66a1c 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -219,10 +219,12 @@ export class IORedisPublisher> extends Publishe // TODO: log error } finally { - for (const payload of pendingPayloads) { - originalListener(payload) - } + const pending = pendingPayloads pendingPayloads = undefined + + for (const payload of pending) { + listener(payload) // listener instead of originalListener for deduplication + } } })() diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 69ec27414..11d65d447 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -213,10 +213,12 @@ export class UpstashRedisPublisher> extends Pub // TODO: log error } finally { - for (const payload of pendingPayloads) { - originalListener(payload) - } + const pending = pendingPayloads pendingPayloads = undefined + + for (const payload of pending) { + listener(payload) // listener instead of originalListener for deduplication + } } })() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3231c37c..7d25459cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@orpc/contract': specifier: workspace:* version: link:../../packages/contract + '@orpc/experimental-publisher': + specifier: workspace:* + version: link:../../packages/publisher '@orpc/experimental-react-swr': specifier: workspace:* version: link:../../packages/react-swr diff --git a/vitest.config.ts b/vitest.config.ts index 325f62ea6..343f0205d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelteTesting } from '@testing-library/svelte/vite' import { loadEnv } from 'vite' import solid from 'vite-plugin-solid' -import { defineConfig } from 'vitest/config' +import { defaultExclude, defineConfig } from 'vitest/config' export default defineConfig(({ mode }) => ({ test: { @@ -12,8 +12,22 @@ export default defineConfig(({ mode }) => ({ { test: { globals: true, + setupFiles: ['./vitest.javascript.ts'], include: ['**/*.test.ts'], + exclude: [...defaultExclude, './packages/publisher/src/adapters/**/*'], + }, + }, + { + test: { + globals: true, setupFiles: ['./vitest.javascript.ts'], + include: ['./packages/publisher/src/adapters/**/*.test.ts'], + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + }, + }, }, }, { From e85a5f582b621df8c88f00fd045395d37236cf79 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 17 Oct 2025 21:24:48 +0700 Subject: [PATCH 13/30] fix & improve --- .../publisher/src/adapters/ioredis.test.ts | 139 ++++++++--------- packages/publisher/src/adapters/ioredis.ts | 28 ++-- .../src/adapters/upstash-redis.test.ts | 142 ++++++++---------- .../publisher/src/adapters/upstash-redis.ts | 27 ++-- packages/publisher/src/index.ts | 1 + packages/publisher/src/utils.test.ts | 16 ++ packages/publisher/src/utils.ts | 21 +++ vitest.config.ts | 7 +- 8 files changed, 187 insertions(+), 194 deletions(-) create mode 100644 packages/publisher/src/utils.test.ts create mode 100644 packages/publisher/src/utils.ts diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 7810f3cf3..0a3f1c2f7 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -46,10 +46,9 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload1) await publisher.publish('event3', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1.mock.calls[0]![0]).toEqual(payload1) expect(listener2).toHaveBeenCalledTimes(0) @@ -58,19 +57,18 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload2) await publisher.publish('event2', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) + }) expect(listener2.mock.calls[0]![0]).toEqual(payload2) + expect(listener1).toHaveBeenCalledTimes(1) await unsub2() const unsub11 = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) // Wait a bit to ensure no resume happens - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise(resolve => setTimeout(resolve, 1000)) expect(listener1).toHaveBeenCalledTimes(1) // resume not happens await unsub11() @@ -90,10 +88,9 @@ describe('ioredis publisher', () => { const payload1 = { order: 1 } await publisher.publish('event1', payload1) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) await unsub1() @@ -118,10 +115,9 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload1) await publisher.publish('event3', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) expect(listener2).toHaveBeenCalledTimes(0) @@ -130,22 +126,20 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload2) await publisher.publish('event2', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) + }) expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload2)) + expect(listener1).toHaveBeenCalledTimes(1) await unsub2() const listener3 = vi.fn() const unsub3 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) - // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener3).toHaveBeenCalledTimes(2) // resume happens + await vi.waitFor(() => { + expect(listener3).toHaveBeenCalledTimes(2) + }) expect(listener3).toHaveBeenNthCalledWith(1, expect.objectContaining(payload1)) expect(listener3).toHaveBeenNthCalledWith(2, expect.objectContaining(payload2)) @@ -169,10 +163,9 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload1) await publisher.publish('event1', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(2) + }) expect(listener1).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { expect(p).not.toBe(payload1) expect(p).toEqual(payload1) @@ -196,10 +189,9 @@ describe('ioredis publisher', () => { const listener2 = vi.fn() const unsub2 = await publisher.subscribe('event1', listener2, { lastEventId: firstEventId }) - // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener2).toHaveBeenCalledTimes(1) // only second event + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) // resume + }) expect(listener2).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { expect(p).not.toBe(payload2) expect(p).toEqual(payload2) @@ -229,10 +221,9 @@ describe('ioredis publisher', () => { await publisher.publish('event', { order: i }) } - // Wait for all events to be received - await new Promise(resolve => setTimeout(resolve, 200)) - - expect(listener1).toHaveBeenCalledTimes(10) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(10) + }) // Get the ID of the 5th event const fifthEventId = getEventMeta(listener1.mock.calls[4]![0])?.id @@ -249,10 +240,9 @@ describe('ioredis publisher', () => { const unsub2 = await publisher.subscribe('event', listener2, { lastEventId: fifthEventId }) // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 300)) - - // Should have received events 6-10 (5 events) - expect(listener2).toHaveBeenCalledTimes(5) + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(5) + }) expect(listener2).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 6 })) expect(listener2).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 7 })) expect(listener2).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 10 })) @@ -265,7 +255,7 @@ describe('ioredis publisher', () => { } await unsub2() - }, 10000) // Increase timeout to 10 seconds + }) it('handles multiple subscribers on same event', async () => { publisher = new IORedisPublisher({ @@ -285,12 +275,11 @@ describe('ioredis publisher', () => { const payload = { order: 1 } await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) @@ -317,10 +306,9 @@ describe('ioredis publisher', () => { const payload = { order: 1 } await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) // Verify the key uses custom prefix @@ -368,10 +356,9 @@ describe('ioredis publisher', () => { await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) const received = listener1.mock.calls[0]![0] expect(received.order).toBe(1) expect(received.nested.value).toBe('test') @@ -397,11 +384,12 @@ describe('ioredis publisher', () => { // Publish an event await publisher.publish('event1', { order: 1 }) - // Wait for message to be received (should still work despite resume error) - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise(resolve => setTimeout(resolve, 1000)) // wait until resume is finished - // Should have received the new event even though resume failed - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + // Should have received the new event even though resume failed + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) await unsub1() @@ -416,7 +404,7 @@ describe('ioredis publisher', () => { }) await publisher.publish('event1', { order: 1 }) - await new Promise(resolve => setTimeout(resolve, 150)) // wait for publish to finish + await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit await publisher.publish('event1', { order: 2 }) publisher.publish('event1', { order: 3 }) @@ -427,10 +415,9 @@ describe('ioredis publisher', () => { await publisher.publish('event1', { order: 5 }) await publisher.publish('event1', { order: 6 }) - // Wait for publish to finish - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + }) expect(listener1).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 1 })) expect(listener1).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 2 })) expect(listener1).toHaveBeenNthCalledWith(3, expect.objectContaining({ order: 3 })) @@ -455,13 +442,14 @@ describe('ioredis publisher', () => { const key1 = 'cleanup:test:event1' // Publish events to event1 - await publisher.publish('event1', { order: 1 }) - await publisher.publish('event1', { order: 2 }) - await publisher.publish('event1', { order: 3 }) + await Promise.all([ + publisher.publish('event1', { order: 1 }), + publisher.publish('event1', { order: 2 }), + publisher.publish('event1', { order: 3 }), + ]) const beforeCleanup = await commander.xread('STREAMS', key1, '0') - - expect(beforeCleanup![0]![1].length).toBe(3) // 3 events for event1 + expect(beforeCleanup![0]![1].length).toBe(3) // Wait for retention to expire await new Promise(resolve => setTimeout(resolve, 1100)) @@ -469,11 +457,8 @@ describe('ioredis publisher', () => { // Trigger cleanup by publishing a new event to event1 await publisher.publish('event1', { order: 4 }) - // Verify cleanup happened using xread - old events should be trimmed const afterCleanup = await commander.xread('STREAMS', key1, '0') - - // event1 should only have the new event (order: 4), old ones trimmed - expect(afterCleanup![0]![1].length).toBe(1) + expect(afterCleanup![0]![1].length).toBe(1) // old events should be trimmed }) it('verifies Redis auto-expires keys after retention period * 2', async () => { @@ -558,7 +543,7 @@ describe('ioredis publisher', () => { await commander.publish('orpc:publisher:event1', 'invalid message') // Wait for message to be received - await new Promise(resolve => setTimeout(resolve, 150)) + await new Promise(resolve => setTimeout(resolve, 1000)) await unsub1() }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 23ba66a1c..774cb4b04 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -5,6 +5,7 @@ import { StandardRPCJsonSerializer } from '@orpc/client/standard' import { stringifyJSON } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' +import { compareRedisStreamIds } from '../utils' type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } @@ -141,7 +142,7 @@ export class IORedisPublisher> extends Publishe const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] - let resumePayloadIds: (string | undefined)[] | undefined = [] + let lastResumePayloadId: string | undefined const listener = (payload: T[K]) => { if (pendingPayloads) { @@ -149,15 +150,13 @@ export class IORedisPublisher> extends Publishe return } - if (resumePayloadIds) { - const payloadId = getEventMeta(payload)?.id - for (const resumePayloadId of resumePayloadIds) { - if (payloadId === resumePayloadId) { // duplicate happen - return - } - } - - resumePayloadIds = undefined + const payloadId = getEventMeta(payload)?.id + if ( + payloadId !== undefined // if resume is enabled payloadId will be defined + && lastResumePayloadId !== undefined // when resume happen + && compareRedisStreamIds(payloadId, lastResumePayloadId) <= 0 // when duplicate happen + ) { + return } originalListener(payload) @@ -201,21 +200,18 @@ export class IORedisPublisher> extends Publishe if (results && results[0]) { const [_, items] = results[0] - const firstPendingId = getEventMeta(pendingPayloads[0])?.id - for (const [id, fields] of items) { - if (id === firstPendingId) { // duplicate happen - break - } + for (const [id, fields] of items) { const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') const payload = this.deserializePayload(id, JSON.parse(serialized)) - resumePayloadIds.push(id) + lastResumePayloadId = id originalListener(payload) } } } } catch { + // error happen when message is invalid // TODO: log error } finally { diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index bbeb433c3..480fddeb8 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -2,7 +2,7 @@ import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from '@upstash/redis' import { UpstashRedisPublisher } from './upstash-redis' -describe('upstash redis publisher', { concurrent: false }, () => { +describe('upstash redis publisher', () => { const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN @@ -43,10 +43,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload1) await publisher.publish('event3', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1.mock.calls[0]![0]).toEqual(payload1) expect(listener2).toHaveBeenCalledTimes(0) @@ -55,19 +54,18 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload2) await publisher.publish('event2', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) + }) expect(listener2.mock.calls[0]![0]).toEqual(payload2) + expect(listener1).toHaveBeenCalledTimes(1) await unsub2() const unsub11 = await publisher.subscribe('event1', listener1, { lastEventId: '0' }) // Wait a bit to ensure no resume happens - await new Promise(resolve => setTimeout(resolve, 150)) + await new Promise(resolve => setTimeout(resolve, 1000)) expect(listener1).toHaveBeenCalledTimes(1) // resume not happens await unsub11() @@ -85,10 +83,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { const payload1 = { order: 1 } await publisher.publish('event1', payload1) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) await unsub1() @@ -111,10 +108,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload1) await publisher.publish('event3', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload1)) expect(listener2).toHaveBeenCalledTimes(0) @@ -123,11 +119,10 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload2) await publisher.publish('event2', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload2)) await unsub2() @@ -135,10 +130,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { const listener3 = vi.fn() const unsub3 = await publisher.subscribe('event1', listener3, { lastEventId: '0' }) - // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener3).toHaveBeenCalledTimes(2) // resume happens + await vi.waitFor(() => { + expect(listener3).toHaveBeenCalledTimes(2) // resume happens + }) expect(listener3).toHaveBeenNthCalledWith(1, expect.objectContaining(payload1)) expect(listener3).toHaveBeenNthCalledWith(2, expect.objectContaining(payload2)) @@ -159,10 +153,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload1) await publisher.publish('event1', payload2) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(2) + }) expect(listener1).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { expect(p).not.toBe(payload1) expect(p).toEqual(payload1) @@ -186,10 +179,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { const listener2 = vi.fn() const unsub2 = await publisher.subscribe('event1', listener2, { lastEventId: firstEventId }) - // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener2).toHaveBeenCalledTimes(1) // only second event + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(1) // only second event + }) expect(listener2).toHaveBeenNthCalledWith(1, expect.toSatisfy((p) => { expect(p).not.toBe(payload2) expect(p).toEqual(payload2) @@ -205,7 +197,7 @@ describe('upstash redis publisher', { concurrent: false }, () => { it('resume event.id > lastEventId and in order', async () => { publisher = new UpstashRedisPublisher(redis, { - resumeRetentionSeconds: 10, + resumeRetentionSeconds: 60, }) const listener1 = vi.fn() @@ -215,12 +207,10 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event', { order: i }) } - // Wait for all events to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(10) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(10) + }) - // Get the ID of the 5th event const fifthEventId = getEventMeta(listener1.mock.calls[4]![0])?.id if (!fifthEventId) { @@ -229,16 +219,12 @@ describe('upstash redis publisher', { concurrent: false }, () => { await unsub1() - // Now subscribe with lastEventId set to the 5th event - // Should receive events 6-10 const listener2 = vi.fn() const unsub2 = await publisher.subscribe('event', listener2, { lastEventId: fifthEventId }) - // Wait for resume to complete - await new Promise(resolve => setTimeout(resolve, 150)) - - // Should have received events 6-10 (5 events) - expect(listener2).toHaveBeenCalledTimes(5) + await vi.waitFor(() => { + expect(listener2).toHaveBeenCalledTimes(5) + }) expect(listener2).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 6 })) expect(listener2).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 7 })) expect(listener2).toHaveBeenNthCalledWith(5, expect.objectContaining({ order: 10 })) @@ -269,12 +255,11 @@ describe('upstash redis publisher', { concurrent: false }, () => { const payload = { order: 1 } await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) @@ -297,10 +282,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { const payload = { order: 1 } await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) // Verify the key uses custom prefix @@ -346,10 +330,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', payload) - // Wait for messages to be received - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) const received = listener1.mock.calls[0]![0] expect(received.order).toBe(1) expect(received.nested.value).toBe('test') @@ -373,11 +356,11 @@ describe('upstash redis publisher', { concurrent: false }, () => { // Publish an event await publisher.publish('event1', { order: 1 }) - // Wait for message to be received (should still work despite resume error) - await new Promise(resolve => setTimeout(resolve, 150)) + await new Promise(resolve => setTimeout(resolve, 1000)) // wait until resume is finished - // Should have received the new event even though resume failed - expect(listener1).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) await unsub1() @@ -389,7 +372,7 @@ describe('upstash redis publisher', { concurrent: false }, () => { }) await publisher.publish('event1', { order: 1 }) - await new Promise(resolve => setTimeout(resolve, 150)) // wait for publish to finish + await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit await publisher.publish('event1', { order: 2 }) publisher.publish('event1', { order: 3 }) @@ -400,10 +383,9 @@ describe('upstash redis publisher', { concurrent: false }, () => { await publisher.publish('event1', { order: 5 }) await publisher.publish('event1', { order: 6 }) - // Wait for publish to finish - await new Promise(resolve => setTimeout(resolve, 150)) - - expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(6) // no duplicates + }) expect(listener1).toHaveBeenNthCalledWith(1, expect.objectContaining({ order: 1 })) expect(listener1).toHaveBeenNthCalledWith(2, expect.objectContaining({ order: 2 })) expect(listener1).toHaveBeenNthCalledWith(3, expect.objectContaining({ order: 3 })) @@ -426,13 +408,13 @@ describe('upstash redis publisher', { concurrent: false }, () => { const key1 = 'cleanup:test:event1' // Publish events to event1 - await publisher.publish('event1', { order: 1 }) - await publisher.publish('event1', { order: 2 }) - await publisher.publish('event1', { order: 3 }) + await Promise.all([ + publisher.publish('event1', { order: 1 }), + publisher.publish('event1', { order: 2 }), + publisher.publish('event1', { order: 3 }), + ]) - // Verify events are stored using xread const beforeCleanup = await redis.xread(key1, '0') as any - expect(beforeCleanup[0][1].length).toBe(3) // 3 events for event1 // Wait for retention to expire @@ -441,11 +423,8 @@ describe('upstash redis publisher', { concurrent: false }, () => { // Trigger cleanup by publishing a new event to event1 await publisher.publish('event1', { order: 4 }) - // Verify cleanup happened using xread - old events should be trimmed const afterCleanup = await redis.xread(key1, '0') as any - - // event1 should only have the new event (order: 4), old ones trimmed - expect(afterCleanup[0][1].length).toBe(1) + expect(afterCleanup[0][1].length).toBe(1) // old events should be trimmed }) it('verifies Redis auto-expires keys after retention period * 2', async () => { @@ -521,8 +500,7 @@ describe('upstash redis publisher', { concurrent: false }, () => { await redis.publish('orpc:publisher:event1', 'invalid message') - // Wait for message to be received - await new Promise(resolve => setTimeout(resolve, 150)) + await new Promise(resolve => setTimeout(resolve, 1000)) // ensure message received await unsub1() }) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 11d65d447..09a55d5d5 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -4,6 +4,7 @@ import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../pub import { StandardRPCJsonSerializer } from '@orpc/client/standard' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' +import { compareRedisStreamIds } from '../utils' type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } @@ -115,7 +116,7 @@ export class UpstashRedisPublisher> extends Pub const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] - let resumePayloadIds: (string | undefined)[] | undefined = [] + let lastResumePayloadId: string | undefined const listener = (payload: T[K]) => { if (pendingPayloads) { @@ -123,15 +124,13 @@ export class UpstashRedisPublisher> extends Pub return } - if (resumePayloadIds) { - const payloadId = getEventMeta(payload)?.id - for (const resumePayloadId of resumePayloadIds) { - if (payloadId === resumePayloadId) { // duplicate happen - return - } - } - - resumePayloadIds = undefined + const payloadId = getEventMeta(payload)?.id + if ( + payloadId !== undefined // if resume is enabled payloadId will be defined + && lastResumePayloadId !== undefined // when resume happen + && compareRedisStreamIds(payloadId, lastResumePayloadId) <= 0 // when duplicate happen + ) { + return } originalListener(payload) @@ -194,15 +193,11 @@ export class UpstashRedisPublisher> extends Pub if (results && results[0]) { const [_, items] = results[0] as any - const firstPendingId = getEventMeta(pendingPayloads[0])?.id - for (const [id, fields] of items) { - if (id === firstPendingId) { // duplicate happen - break - } + for (const [id, fields] of items) { const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') const payload = this.deserializePayload(id, serialized) - resumePayloadIds.push(id) + lastResumePayloadId = id originalListener(payload) } } diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 912da4aac..204dc4a15 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -1 +1,2 @@ export * from './publisher' +export * from './utils' diff --git a/packages/publisher/src/utils.test.ts b/packages/publisher/src/utils.test.ts new file mode 100644 index 000000000..99ee62cb7 --- /dev/null +++ b/packages/publisher/src/utils.test.ts @@ -0,0 +1,16 @@ +import { compareRedisStreamIds } from './utils' + +it('compareRedisStreamIds', () => { + // throw on invalid format + expect(() => compareRedisStreamIds('123', '222')).toThrow(TypeError) + + expect(compareRedisStreamIds('1-1', '1-2')).toBeLessThan(0) + expect(compareRedisStreamIds('1-0', '2-0')).toBeLessThan(0) + + expect(compareRedisStreamIds('1-1', '1-1')).toBe(0) + expect(compareRedisStreamIds('73892329-0', '73892329-0')).toBe(0) + + expect(compareRedisStreamIds('1-2', '1-1')).toBeGreaterThan(0) + expect(compareRedisStreamIds('2-0', '1-0')).toBeGreaterThan(0) + expect(compareRedisStreamIds('100034343-20', '100034343-1')).toBeGreaterThan(0) +}) diff --git a/packages/publisher/src/utils.ts b/packages/publisher/src/utils.ts new file mode 100644 index 000000000..77233be15 --- /dev/null +++ b/packages/publisher/src/utils.ts @@ -0,0 +1,21 @@ +import { compareSequentialIds } from '@orpc/shared' + +/** + * Compare two redis stream ids + * Returns: + * - negative if `a` < `b` + * - positive if `a` > `b` + * - 0 if equal + */ +export function compareRedisStreamIds(a: string, b: string): number { + const [timeA, seqA] = a.split('-') + const [timeB, seqB] = b.split('-') + + if (timeA === undefined || timeB === undefined || seqA === undefined || seqB === undefined) { + throw new TypeError('Invalid redis stream id format') + } + + return timeA !== timeB + ? compareSequentialIds(timeA, timeB) + : compareSequentialIds(seqA, seqB) +} diff --git a/vitest.config.ts b/vitest.config.ts index 343f0205d..cc54d6915 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,14 +20,15 @@ export default defineConfig(({ mode }) => ({ { test: { globals: true, - setupFiles: ['./vitest.javascript.ts'], - include: ['./packages/publisher/src/adapters/**/*.test.ts'], + testTimeout: 20000, pool: 'threads', poolOptions: { threads: { - singleThread: true, + singleThread: true, // disable fileParallelism }, }, + setupFiles: ['./vitest.javascript.ts'], + include: ['./packages/publisher/src/adapters/**/*.test.ts'], }, }, { From 48002b5207ed61945445c98ae0c52c4f59023fb7 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 10:03:19 +0700 Subject: [PATCH 14/30] fix and improve race condition --- .github/workflows/ci.yaml | 7 +++- apps/content/docs/helpers/publisher.md | 2 ++ .../publisher/src/adapters/ioredis.test.ts | 14 +++----- packages/publisher/src/adapters/ioredis.ts | 35 ++++++++++++------- .../publisher/src/adapters/memory.test.ts | 2 +- .../src/adapters/upstash-redis.test.ts | 12 +++---- .../publisher/src/adapters/upstash-redis.ts | 27 ++++++++------ packages/publisher/src/index.test.ts | 3 ++ packages/publisher/src/index.ts | 1 - packages/publisher/src/utils.test.ts | 16 --------- packages/publisher/src/utils.ts | 21 ----------- 11 files changed, 60 insertions(+), 80 deletions(-) create mode 100644 packages/publisher/src/index.test.ts delete mode 100644 packages/publisher/src/utils.test.ts delete mode 100644 packages/publisher/src/utils.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf81d425b..5b713067c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,7 +29,12 @@ jobs: runs-on: ubuntu-latest services: redis: - image: redis:alpine + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 diff --git a/apps/content/docs/helpers/publisher.md b/apps/content/docs/helpers/publisher.md index c3762f5d3..982ad7881 100644 --- a/apps/content/docs/helpers/publisher.md +++ b/apps/content/docs/helpers/publisher.md @@ -190,6 +190,7 @@ const publisher = new IORedisPublisher<{ commander: new Redis(), // For executing short-lived commands subscriber: new Redis(), // For subscribing to events resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes + prefix: 'orpc:publisher:', // avoid conflict with other keys }) ``` @@ -213,6 +214,7 @@ const publisher = new UpstashRedisPublisher<{ } }>(redis, { resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes + prefix: 'orpc:publisher:', // avoid conflict with other keys }) ``` diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 0a3f1c2f7..81bc87d6a 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -2,23 +2,19 @@ import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from 'ioredis' import { IORedisPublisher } from './ioredis' -describe('ioredis publisher', () => { - const REDIS_URL = process.env.REDIS_URL - if (!REDIS_URL) { - throw new Error('These tests require REDIS_URL env variable') - } +const REDIS_URL = process.env.REDIS_URL +describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { let publisher: IORedisPublisher let commander: Redis let listener: Redis beforeAll(() => { - commander = new Redis(REDIS_URL) - listener = new Redis(REDIS_URL) + commander = new Redis(REDIS_URL!) + listener = new Redis(REDIS_URL!) }) afterEach(async () => { - // Use a separate commander for cleanup since listener might be in subscriber mode await commander.flushall() expect(publisher.size).toEqual(0) // ensure cleanup correctly }) @@ -395,7 +391,7 @@ describe('ioredis publisher', () => { await unsub1() }) - it('handles race condition where events published during resume', { repeats: 10 }, async () => { + it('handles race condition where events published during resume', { repeats: 3 }, async () => { publisher = new IORedisPublisher({ commander, listener, diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 774cb4b04..bca6a20b4 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -5,7 +5,6 @@ import { StandardRPCJsonSerializer } from '@orpc/client/standard' import { stringifyJSON } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' -import { compareRedisStreamIds } from '../utils' type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } @@ -51,7 +50,8 @@ export class IORedisPublisher> extends Publishe protected readonly prefix: string protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number - protected readonly listeners = new Map void>>() + protected readonly listenerPromiseMap = new Map>() + protected readonly listenersMap = new Map void>>() protected redisListener: ((channel: string, message: string) => void) | undefined protected get isResumeEnabled(): boolean { @@ -74,7 +74,7 @@ export class IORedisPublisher> extends Publishe get size(): number { /* v8 ignore next 5 */ let size = this.redisListener ? 1 : 0 - for (const listeners of this.listeners) { + for (const listeners of this.listenersMap) { size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event } return size @@ -142,7 +142,7 @@ export class IORedisPublisher> extends Publishe const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] - let lastResumePayloadId: string | undefined + const resumePayloadIds = new Set() const listener = (payload: T[K]) => { if (pendingPayloads) { @@ -153,8 +153,7 @@ export class IORedisPublisher> extends Publishe const payloadId = getEventMeta(payload)?.id if ( payloadId !== undefined // if resume is enabled payloadId will be defined - && lastResumePayloadId !== undefined // when resume happen - && compareRedisStreamIds(payloadId, lastResumePayloadId) <= 0 // when duplicate happen + && resumePayloadIds.has(payloadId) // duplicate happen ) { return } @@ -165,7 +164,7 @@ export class IORedisPublisher> extends Publishe if (!this.redisListener) { this.redisListener = (channel: string, message: string) => { try { - const listeners = this.listeners.get(channel) + const listeners = this.listenersMap.get(channel) if (listeners) { const { id, ...rest } = JSON.parse(message) @@ -185,10 +184,20 @@ export class IORedisPublisher> extends Publishe this.listener.on('message', this.redisListener) } - let listeners = this.listeners.get(key) + // avoid race condition when multiple listeners subscribe to the same channel on first time + await this.listenerPromiseMap.get(key) + + let listeners = this.listenersMap.get(key) if (!listeners) { - await this.listener.subscribe(key) - this.listeners.set(key, listeners = new Set()) // only set after subscribe successfully + try { + const promise = this.listener.subscribe(key) + this.listenerPromiseMap.set(key, promise) + await promise + this.listenersMap.set(key, listeners = new Set()) // only set after subscribe successfully + } + finally { + this.listenerPromiseMap.delete(key) + } } listeners.add(listener) @@ -204,7 +213,7 @@ export class IORedisPublisher> extends Publishe for (const [id, fields] of items) { const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') const payload = this.deserializePayload(id, JSON.parse(serialized)) - lastResumePayloadId = id + resumePayloadIds.add(id) originalListener(payload) } } @@ -227,9 +236,9 @@ export class IORedisPublisher> extends Publishe return async () => { listeners.delete(listener) if (listeners.size === 0) { - this.listeners.delete(key) // should execute before async to avoid throw + this.listenersMap.delete(key) // should execute before async to avoid throw - if (this.redisListener && this.listeners.size === 0) { + if (this.redisListener && this.listenersMap.size === 0) { this.listener.off('message', this.redisListener) this.redisListener = undefined } diff --git a/packages/publisher/src/adapters/memory.test.ts b/packages/publisher/src/adapters/memory.test.ts index 6aa3b0eb6..7d4b5f538 100644 --- a/packages/publisher/src/adapters/memory.test.ts +++ b/packages/publisher/src/adapters/memory.test.ts @@ -45,7 +45,7 @@ describe('memoryPublisher', () => { }) afterEach(() => { - vi.restoreAllMocks() + vi.useRealTimers() }) it('can pub/sub and resume', async () => { diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index 480fddeb8..c746ff366 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -2,14 +2,10 @@ import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from '@upstash/redis' import { UpstashRedisPublisher } from './upstash-redis' -describe('upstash redis publisher', () => { - const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL - const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN - - if (!UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN) { - throw new Error('These tests require UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN env variables') - } +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN +describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, timeout: 20000 }, () => { let publisher: UpstashRedisPublisher let redis: Redis @@ -366,7 +362,7 @@ describe('upstash redis publisher', () => { await unsub1() }) - it('handles race condition where events published during resume', { repeats: 10 }, async () => { + it('handles race condition where events published during resume', { repeats: 3 }, async () => { publisher = new UpstashRedisPublisher(redis, { resumeRetentionSeconds: 10, }) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 09a55d5d5..7b35b2f90 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -4,7 +4,6 @@ import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../pub import { StandardRPCJsonSerializer } from '@orpc/client/standard' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' -import { compareRedisStreamIds } from '../utils' type SerializedPayload = { json: object, meta: StandardRPCJsonSerializedMetaItem[], eventMeta: ReturnType } @@ -35,6 +34,7 @@ export class UpstashRedisPublisher> extends Pub protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number protected readonly listenersMap = new Map void>>() + protected readonly subscriptionPromiseMap = new Map>() protected readonly subscriptionsMap = new Map() // Upstash subscription objects protected get isResumeEnabled(): boolean { @@ -116,7 +116,7 @@ export class UpstashRedisPublisher> extends Pub const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] - let lastResumePayloadId: string | undefined + const resumePayloadIds = new Set() const listener = (payload: T[K]) => { if (pendingPayloads) { @@ -127,8 +127,7 @@ export class UpstashRedisPublisher> extends Pub const payloadId = getEventMeta(payload)?.id if ( payloadId !== undefined // if resume is enabled payloadId will be defined - && lastResumePayloadId !== undefined // when resume happen - && compareRedisStreamIds(payloadId, lastResumePayloadId) <= 0 // when duplicate happen + && resumePayloadIds.has(payloadId) // duplicate happen ) { return } @@ -136,6 +135,9 @@ export class UpstashRedisPublisher> extends Pub originalListener(payload) } + // avoid race condition when multiple listeners subscribe to the same channel on first time + await this.subscriptionPromiseMap.get(key) + // Get or create subscription for this channel let subscription = this.subscriptionsMap.get(key) as ReturnType | undefined if (!subscription) { @@ -159,9 +161,9 @@ export class UpstashRedisPublisher> extends Pub } }) - let resolvePromise: (value?: unknown) => void + let resolvePromise: () => void let rejectPromise: (error: Error) => void - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { resolvePromise = resolve rejectPromise = reject }) @@ -174,9 +176,14 @@ export class UpstashRedisPublisher> extends Pub resolvePromise() }) - await promise - - this.subscriptionsMap.set(key, subscription) // only set after subscription is ready + try { + this.subscriptionPromiseMap.set(key, promise) + await promise + this.subscriptionsMap.set(key, subscription) // set after subscription is ready + } + finally { + this.subscriptionPromiseMap.delete(key) + } } let listeners = this.listenersMap.get(key) @@ -197,7 +204,7 @@ export class UpstashRedisPublisher> extends Pub for (const [id, fields] of items) { const serialized = fields[1]! // field value is at index 1 (index 0 is field name 'data') const payload = this.deserializePayload(id, serialized) - lastResumePayloadId = id + resumePayloadIds.add(id) originalListener(payload) } } diff --git a/packages/publisher/src/index.test.ts b/packages/publisher/src/index.test.ts new file mode 100644 index 000000000..8297b9d7e --- /dev/null +++ b/packages/publisher/src/index.test.ts @@ -0,0 +1,3 @@ +it('exports Publisher', async () => { + expect(Object.keys(await import('./index'))).toContain('Publisher') +}) diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 204dc4a15..912da4aac 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -1,2 +1 @@ export * from './publisher' -export * from './utils' diff --git a/packages/publisher/src/utils.test.ts b/packages/publisher/src/utils.test.ts deleted file mode 100644 index 99ee62cb7..000000000 --- a/packages/publisher/src/utils.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { compareRedisStreamIds } from './utils' - -it('compareRedisStreamIds', () => { - // throw on invalid format - expect(() => compareRedisStreamIds('123', '222')).toThrow(TypeError) - - expect(compareRedisStreamIds('1-1', '1-2')).toBeLessThan(0) - expect(compareRedisStreamIds('1-0', '2-0')).toBeLessThan(0) - - expect(compareRedisStreamIds('1-1', '1-1')).toBe(0) - expect(compareRedisStreamIds('73892329-0', '73892329-0')).toBe(0) - - expect(compareRedisStreamIds('1-2', '1-1')).toBeGreaterThan(0) - expect(compareRedisStreamIds('2-0', '1-0')).toBeGreaterThan(0) - expect(compareRedisStreamIds('100034343-20', '100034343-1')).toBeGreaterThan(0) -}) diff --git a/packages/publisher/src/utils.ts b/packages/publisher/src/utils.ts deleted file mode 100644 index 77233be15..000000000 --- a/packages/publisher/src/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { compareSequentialIds } from '@orpc/shared' - -/** - * Compare two redis stream ids - * Returns: - * - negative if `a` < `b` - * - positive if `a` > `b` - * - 0 if equal - */ -export function compareRedisStreamIds(a: string, b: string): number { - const [timeA, seqA] = a.split('-') - const [timeB, seqB] = b.split('-') - - if (timeA === undefined || timeB === undefined || seqA === undefined || seqB === undefined) { - throw new TypeError('Invalid redis stream id format') - } - - return timeA !== timeB - ? compareSequentialIds(timeA, timeB) - : compareSequentialIds(seqA, seqB) -} From 7ca7912ea19373c579e5ca37fe02e8430b2630b3 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 10:04:45 +0700 Subject: [PATCH 15/30] version --- packages/publisher/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/publisher/package.json b/packages/publisher/package.json index 727efd689..bd2650d2c 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/experimental-publisher", "type": "module", - "version": "1.9.3", + "version": "0.0.0", "license": "MIT", "homepage": "https://orpc.unnoq.com", "repository": { From a971e9e5412c564e1f92c68e7f3890987fae3beb Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 14:55:49 +0700 Subject: [PATCH 16/30] fix and improve test speed --- .github/workflows/ci.yaml | 4 +- .../publisher/src/adapters/ioredis.test.ts | 267 +++++++++--------- .../src/adapters/upstash-redis.test.ts | 242 +++++++++------- .../publisher/src/adapters/upstash-redis.ts | 3 +- vitest.config.ts | 17 +- 5 files changed, 279 insertions(+), 254 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5b713067c..51fb225f1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,8 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + ports: + - 6379:6379 steps: - uses: actions/checkout@v4 @@ -49,7 +51,7 @@ jobs: - run: pnpm run test:coverage env: - REDIS_URL: redis://redis:6379 + REDIS_URL: redis://localhost:6379 UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 81bc87d6a..c942213bc 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -1,34 +1,45 @@ +import type { IORedisPublisherOptions } from './ioredis' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from 'ioredis' import { IORedisPublisher } from './ioredis' const REDIS_URL = process.env.REDIS_URL -describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { - let publisher: IORedisPublisher +describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { let commander: Redis let listener: Redis + function createPublisher(options: Partial = {}) { + const publisher = new IORedisPublisher({ + commander, + listener, + prefix: crypto.randomUUID(), // isolated from other tests + ...options, + }) + + publisher.xtrimExactness = '=' // for easier testing + + return { + publisher, + [Symbol.dispose]: () => { + expect(publisher.size).toEqual(0) // ensure cleanup correctly + }, + } + } + beforeAll(() => { commander = new Redis(REDIS_URL!) listener = new Redis(REDIS_URL!) }) - afterEach(async () => { - await commander.flushall() - expect(publisher.size).toEqual(0) // ensure cleanup correctly - }) - afterAll(async () => { commander.disconnect() listener.disconnect() }) it('without resume: can pub/sub but not resume', async () => { - publisher = new IORedisPublisher({ - commander, - listener, - }) // resume is disabled by default + using resource = createPublisher() // resume is disabled by default + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -72,11 +83,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => describe('with resume', () => { it('basic pub/sub', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -93,11 +103,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('can pub/sub and resume', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -144,11 +153,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('control event.id', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -203,11 +211,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('resume event.id > lastEventId and in order', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event', listener1) @@ -254,11 +261,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('handles multiple subscribers on same event', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -288,89 +294,11 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => expect(publisher.size).toEqual(0) }) - it('handles custom prefix', async () => { - publisher = new IORedisPublisher({ - commander, - listener, - prefix: 'custom:prefix:', - resumeRetentionSeconds: 10, - }) - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - - const payload = { order: 1 } - await publisher.publish('event1', payload) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - }) - expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) - - // Verify the key uses custom prefix - const keys = await commander.keys('custom:prefix:*') - expect(keys.length).toBeGreaterThan(0) - expect(keys.some(k => k.includes('custom:prefix:event1'))).toBe(true) - - await unsub1() - }) - - it('handles serialization with complex objects and custom serializers', async () => { - class Person { - constructor( - public name: string, - public date: Date, - ) {} - } - - publisher = new IORedisPublisher({ - commander, - listener, - resumeRetentionSeconds: 10, - customJsonSerializers: [ - { - condition: data => data instanceof Person, - type: 20, - serialize: person => ({ name: person.name, date: person.date }), - deserialize: data => new Person(data.name, data.date), - }, - ], - }) - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - - const payload = { - order: 1, - nested: { - value: 'test', - array: [1, 2, 3], - }, - date: new Date('2024-01-01'), - person: new Person('John Doe', new Date('2023-01-01')), - } - - await publisher.publish('event1', payload) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - }) - const received = listener1.mock.calls[0]![0] - expect(received.order).toBe(1) - expect(received.nested.value).toBe('test') - expect(received.nested.array).toEqual([1, 2, 3]) - expect(received.date).toEqual(new Date('2024-01-01')) - expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) - - await unsub1() - }) - it('handles errors during resume gracefully', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() @@ -392,12 +320,10 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('handles race condition where events published during resume', { repeats: 3 }, async () => { - publisher = new IORedisPublisher({ - commander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, - prefix: 'race:test:', }) + const { publisher } = resource await publisher.publish('event1', { order: 1 }) await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit @@ -426,16 +352,14 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => describe('cleanup retention', () => { it('handles cleanup of expired events on publish', async () => { - publisher = new IORedisPublisher({ - commander, - listener, - resumeRetentionSeconds: 1, // 1 second retention - prefix: 'cleanup:test:', + const prefix = `cleanup:${crypto.randomUUID()}:` + using resource = createPublisher({ + resumeRetentionSeconds: 1, + prefix, }) + const { publisher } = resource - publisher.xtrimExactness = '=' // for easier testing - - const key1 = 'cleanup:test:event1' + const key1 = `${prefix}event1` // Publish events to event1 await Promise.all([ @@ -458,14 +382,14 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('verifies Redis auto-expires keys after retention period * 2', async () => { - publisher = new IORedisPublisher({ - commander, - listener, + const prefix = `expire:${crypto.randomUUID()}:` + using resource = createPublisher({ resumeRetentionSeconds: 1, - prefix: 'test:expire:', + prefix, }) + const { publisher } = resource - const key = 'test:expire:event1' + const key = `${prefix}event1` // Publish an event await publisher.publish('event1', { order: 1 }) @@ -485,6 +409,84 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) }) + it('handles prefix correctly', async () => { + const prefix = `custom:${crypto.randomUUID()}:` + using resource = createPublisher({ + resumeRetentionSeconds: 10, + prefix, + }) + const { publisher } = resource + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + // verify channel use prefix + const numSub = await commander.pubsub('NUMSUB', `${prefix}event1`) + expect(numSub[1]).toBe(1) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + + // Verify the key uses prefix + const keys = await commander.keys(`${prefix}*`) + expect(keys.some(k => k.includes(`${prefix}event1`))).toBe(true) + + await unsub1() + }) + + it('handles serialization with complex objects and custom serializers', async () => { + class Person { + constructor( + public name: string, + public date: Date, + ) { } + } + + using resource = createPublisher({ + resumeRetentionSeconds: 10, + customJsonSerializers: [ + { + condition: data => data instanceof Person, + type: 20, + serialize: person => ({ name: person.name, date: person.date }), + deserialize: data => new Person(data.name, data.date), + }, + ], + }) + const { publisher } = resource + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { + order: 1, + nested: { + value: 'test', + array: [1, 2, 3], + }, + date: new Date('2024-01-01'), + person: new Person('John Doe', new Date('2023-01-01')), + } + + await publisher.publish('event1', payload) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) + const received = listener1.mock.calls[0]![0] + expect(received.order).toBe(1) + expect(received.nested.value).toBe('test') + expect(received.nested.array).toEqual([1, 2, 3]) + expect(received.date).toEqual(new Date('2024-01-01')) + expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) + + await unsub1() + }) + describe('edge cases', () => { it('handles transaction errors during publish', async () => { // Create a mock commander that will fail on multi @@ -496,21 +498,23 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => publish: commander.publish.bind(commander), } as any - publisher = new IORedisPublisher({ - commander: mockCommander, - listener, + using resource = createPublisher({ resumeRetentionSeconds: 10, + commander: mockCommander, }) + const { publisher } = resource // This should throw the transaction error await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') }) it('only subscribe to redis-listener when needed', async () => { - publisher = new IORedisPublisher({ - commander, - listener, - }) + // use dedicated listener + const listener = new Redis(REDIS_URL!) + onTestFinished(() => listener.disconnect()) + + using resource = createPublisher({ listener }) + const { publisher } = resource expect(listener.listenerCount('message')).toBe(0) @@ -531,7 +535,8 @@ describe('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => }) it('gracefully handles invalid subscription message', async () => { - publisher = new IORedisPublisher({ commander, listener }) + using resource = createPublisher() + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index c746ff366..18a7d4cd0 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -1,3 +1,4 @@ +import type { UpstashRedisPublisherOptions } from './upstash-redis' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Redis } from '@upstash/redis' import { UpstashRedisPublisher } from './upstash-redis' @@ -5,10 +6,25 @@ import { UpstashRedisPublisher } from './upstash-redis' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN -describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, timeout: 20000 }, () => { - let publisher: UpstashRedisPublisher +describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, timeout: 20000 }, () => { let redis: Redis + function createPublisher(options: UpstashRedisPublisherOptions = {}, useRedis = redis) { + const publisher = new UpstashRedisPublisher(useRedis, { + prefix: crypto.randomUUID(), // isolated from other tests + ...options, + }) + + publisher.xtrimExactness = '=' // for easier testing + + return { + publisher, + [Symbol.dispose]: () => { + expect(publisher.size).toEqual(0) // ensure cleanup correctly + }, + } + } + beforeAll(() => { redis = new Redis({ url: UPSTASH_REDIS_REST_URL, @@ -16,16 +32,9 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) }) - beforeEach(async () => { - await redis.flushall() - }) - - afterEach(async () => { - expect(publisher.size).toEqual(0) // ensure unsubscribed correctly - }) - it('without resume: can pub/sub but not resume', async () => { - publisher = new UpstashRedisPublisher(redis) // resume is disabled by default + using resource = createPublisher() // resume is disabled by default + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -69,9 +78,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ describe('with resume', () => { it('basic pub/sub', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -88,9 +98,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('can pub/sub and resume', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -136,9 +147,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('control event.id', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -192,9 +204,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('resume event.id > lastEventId and in order', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 60, }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event', listener1) @@ -236,9 +249,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('handles multiple subscribers on same event', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -266,83 +280,11 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ await unsub3() }) - it('handles custom prefix', async () => { - publisher = new UpstashRedisPublisher(redis, { - prefix: 'custom:prefix:', - resumeRetentionSeconds: 10, - }) - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - - const payload = { order: 1 } - await publisher.publish('event1', payload) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - }) - expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) - - // Verify the key uses custom prefix - const keys = await redis.keys('custom:prefix:*') - expect(keys.length).toBeGreaterThan(0) - expect(keys.some(k => k.includes('custom:prefix:event1'))).toBe(true) - - await unsub1() - }) - - it('handles serialization with complex objects and custom serializers', async () => { - class Person { - constructor( - public name: string, - public date: Date, - ) {} - } - - publisher = new UpstashRedisPublisher(redis, { - resumeRetentionSeconds: 10, - customJsonSerializers: [ - { - condition: data => data instanceof Person, - type: 20, - serialize: person => ({ name: person.name, date: person.date }), - deserialize: data => new Person(data.name, data.date), - }, - ], - }) - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - - const payload = { - order: 1, - nested: { - value: 'test', - array: [1, 2, 3], - }, - date: new Date('2024-01-01'), - person: new Person('John Doe', new Date('2023-01-01')), - } - - await publisher.publish('event1', payload) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - }) - const received = listener1.mock.calls[0]![0] - expect(received.order).toBe(1) - expect(received.nested.value).toBe('test') - expect(received.nested.array).toEqual([1, 2, 3]) - expect(received.date).toEqual(new Date('2024-01-01')) - expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) - - await unsub1() - }) - it('handles errors during resume gracefully', async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource const listener1 = vi.fn() @@ -363,9 +305,10 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('handles race condition where events published during resume', { repeats: 3 }, async () => { - publisher = new UpstashRedisPublisher(redis, { + using resource = createPublisher({ resumeRetentionSeconds: 10, }) + const { publisher } = resource await publisher.publish('event1', { order: 1 }) await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit @@ -394,14 +337,14 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ describe('cleanup retention', () => { it('handles cleanup of expired events on publish', async () => { - publisher = new UpstashRedisPublisher(redis, { - resumeRetentionSeconds: 1, // 1 second retention - prefix: 'cleanup:test:', + const prefix = `cleanup:${crypto.randomUUID()}:` + using resource = createPublisher({ + resumeRetentionSeconds: 1, + prefix, }) + const { publisher } = resource - publisher.xtrimExactness = '=' // for easier testing - - const key1 = 'cleanup:test:event1' + const key1 = `${prefix}event1` // Publish events to event1 await Promise.all([ @@ -424,12 +367,14 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('verifies Redis auto-expires keys after retention period * 2', async () => { - publisher = new UpstashRedisPublisher(redis, { + const prefix = `expire:${crypto.randomUUID()}:` + using resource = createPublisher({ resumeRetentionSeconds: 1, - prefix: 'test:expire:', + prefix, }) + const { publisher } = resource - const key = 'test:expire:event1' + const key = `${prefix}event1` // Publish an event await publisher.publish('event1', { order: 1 }) @@ -449,8 +394,89 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) }) + it('handles prefix correctly', async () => { + const prefix = `custom:${crypto.randomUUID()}:` + using resource = createPublisher({ + resumeRetentionSeconds: 10, + prefix, + }) + const { publisher } = resource + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + // verify channel use prefix + const numSub: any = await redis.exec(['PUBSUB', 'NUMSUB', `${prefix}event1`]) + expect(numSub[1]).toBe(1) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) + expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) + + // veryfy key use prefix + const keys = await redis.keys(`${prefix}*`) + expect(keys.some(k => k.includes(`${prefix}event1`))).toBe(true) + + await unsub1() + }) + + it('handles serialization with complex objects and custom serializers', async () => { + class Person { + constructor( + public name: string, + public date: Date, + ) { } + } + + using resource = createPublisher({ + resumeRetentionSeconds: 10, + customJsonSerializers: [ + { + condition: data => data instanceof Person, + type: 20, + serialize: person => ({ name: person.name, date: person.date }), + deserialize: data => new Person(data.name, data.date), + }, + ], + }) + const { publisher } = resource + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + + const payload = { + order: 1, + nested: { + value: 'test', + array: [1, 2, 3], + }, + date: new Date('2024-01-01'), + person: new Person('John Doe', new Date('2023-01-01')), + } + + await publisher.publish('event1', payload) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + }) + const received = listener1.mock.calls[0]![0] + expect(received.order).toBe(1) + expect(received.nested.value).toBe('test') + expect(received.nested.array).toEqual([1, 2, 3]) + expect(received.date).toEqual(new Date('2024-01-01')) + expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) + + await unsub1() + }) + describe('edge cases', () => { it('only subscribe to redis-listener when needed', async () => { + // use dedicated redis instance + const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }) + const originalSubscribe = redis.subscribe.bind(redis) const unsubscribeSpys: any[] = [] const subscribeSpy = vi.spyOn(redis, 'subscribe') @@ -459,7 +485,8 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ unsubscribeSpys.push(vi.spyOn(subscription, 'unsubscribe')) return subscription }) - publisher = new UpstashRedisPublisher(redis) + using resource = createPublisher({}, redis) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -478,23 +505,28 @@ describe('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_ }) it('subscribe should throw & on connection error', async () => { - const redis = new Redis({ + const invalidRedis = new Redis({ url: 'http://invalid:6379', token: 'invalid', }) - publisher = new UpstashRedisPublisher(redis) + using resource = createPublisher({}, invalidRedis) + const { publisher } = resource await expect(publisher.subscribe('event1', () => { })).rejects.toThrow() }) it('gracefully handles invalid subscription message', async () => { - publisher = new UpstashRedisPublisher(redis) + const prefix = `invalid:${crypto.randomUUID()}:` + using resource = createPublisher({ + prefix, + }) + const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - await redis.publish('orpc:publisher:event1', 'invalid message') + await redis.publish(`${prefix}event1`, 'invalid message') await new Promise(resolve => setTimeout(resolve, 1000)) // ensure message received diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 7b35b2f90..f5924c6fc 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -2,6 +2,7 @@ import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOption import type { Redis } from '@upstash/redis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' +import { fallback } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -69,7 +70,7 @@ export class UpstashRedisPublisher> extends Pub ) { super(options) - this.prefix = prefix ?? 'orpc:publisher:' + this.prefix = fallback(prefix, 'orpc:publisher:') // use fallback to improve test-coverage this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN this.serializer = new StandardRPCJsonSerializer(options) } diff --git a/vitest.config.ts b/vitest.config.ts index cc54d6915..f9d0b1487 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelteTesting } from '@testing-library/svelte/vite' import { loadEnv } from 'vite' import solid from 'vite-plugin-solid' -import { defaultExclude, defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config' export default defineConfig(({ mode }) => ({ test: { @@ -14,21 +14,6 @@ export default defineConfig(({ mode }) => ({ globals: true, setupFiles: ['./vitest.javascript.ts'], include: ['**/*.test.ts'], - exclude: [...defaultExclude, './packages/publisher/src/adapters/**/*'], - }, - }, - { - test: { - globals: true, - testTimeout: 20000, - pool: 'threads', - poolOptions: { - threads: { - singleThread: true, // disable fileParallelism - }, - }, - setupFiles: ['./vitest.javascript.ts'], - include: ['./packages/publisher/src/adapters/**/*.test.ts'], }, }, { From 0881c35087afac8ac7f9a3405856fc03174332e8 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 14:58:09 +0700 Subject: [PATCH 17/30] comment --- packages/publisher/src/adapters/ioredis.test.ts | 4 ++++ packages/publisher/src/adapters/upstash-redis.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index c942213bc..b39b2f402 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -5,6 +5,10 @@ import { IORedisPublisher } from './ioredis' const REDIS_URL = process.env.REDIS_URL +/** + * These tests depend on a real Redis server — make sure to set the `REDIS_URL` env. + * When writing new tests, always use unique keys to avoid conflicts with other test cases. + */ describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { let commander: Redis let listener: Redis diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index 18a7d4cd0..fe1e1748a 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -6,6 +6,10 @@ import { UpstashRedisPublisher } from './upstash-redis' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN +/** + * These tests depend on a real Upstash redis server — make sure to set the `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` envs. + * When writing new tests, always use unique keys to avoid conflicts with other test cases. + */ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, timeout: 20000 }, () => { let redis: Redis From 0e5fbfe58a4e01a39079fbe83561fbb055860745 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 14:59:07 +0700 Subject: [PATCH 18/30] improve tests coverage --- packages/publisher/src/adapters/ioredis.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index bca6a20b4..ef64cd00e 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -2,7 +2,7 @@ import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOption import type Redis from 'ioredis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' -import { stringifyJSON } from '@orpc/shared' +import { fallback, stringifyJSON } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -87,7 +87,7 @@ export class IORedisPublisher> extends Publishe this.commander = commander this.listener = listener - this.prefix = prefix ?? 'orpc:publisher:' + this.prefix = fallback(prefix, 'orpc:publisher:') // use fallback to improve test-coverage this.retentionSeconds = resumeRetentionSeconds ?? Number.NaN this.serializer = new StandardRPCJsonSerializer(options) } From 8c3a72fe946ce271e4e7e032881ff62ddec03004 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 15:01:28 +0700 Subject: [PATCH 19/30] fix a test not cover as expected --- packages/publisher/src/adapters/ioredis.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index b39b2f402..699ffa13b 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -539,13 +539,16 @@ describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 2000 }) it('gracefully handles invalid subscription message', async () => { - using resource = createPublisher() + const prefix = `invalid:${crypto.randomUUID()}:` + using resource = createPublisher({ + prefix, + }) const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - await commander.publish('orpc:publisher:event1', 'invalid message') + await commander.publish(`${prefix}event1`, 'invalid message') // Wait for message to be received await new Promise(resolve => setTimeout(resolve, 1000)) From 4d2db9c3164f1d07f7be7144e88403ba1862acad Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 15:12:27 +0700 Subject: [PATCH 20/30] fix --- packages/publisher/src/adapters/ioredis.test.ts | 10 ++++++---- packages/publisher/src/adapters/ioredis.ts | 10 +++++----- packages/publisher/src/adapters/upstash-redis.ts | 10 +++++----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 699ffa13b..1ea4841bb 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -9,7 +9,7 @@ const REDIS_URL = process.env.REDIS_URL * These tests depend on a real Redis server — make sure to set the `REDIS_URL` env. * When writing new tests, always use unique keys to avoid conflicts with other test cases. */ -describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { +describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { let commander: Redis let listener: Redis @@ -37,8 +37,8 @@ describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 2000 }) afterAll(async () => { - commander.disconnect() - listener.disconnect() + await commander.quit() + await listener.quit() }) it('without resume: can pub/sub but not resume', async () => { @@ -515,7 +515,9 @@ describe.concurrent('upstash redis publisher', { skip: !REDIS_URL, timeout: 2000 it('only subscribe to redis-listener when needed', async () => { // use dedicated listener const listener = new Redis(REDIS_URL!) - onTestFinished(() => listener.disconnect()) + onTestFinished(async () => { + await listener.quit() + }) using resource = createPublisher({ listener }) const { publisher } = resource diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index ef64cd00e..182505408 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -92,7 +92,7 @@ export class IORedisPublisher> extends Publishe this.serializer = new StandardRPCJsonSerializer(options) } - protected lastCleanupTimes: Map = new Map() + protected lastCleanupTimeMap: Map = new Map() override async publish(event: K, payload: T[K]): Promise { const key = this.prefixKey(event) @@ -103,14 +103,14 @@ export class IORedisPublisher> extends Publishe const now = Date.now() // cleanup for more efficiency memory - for (const [key, lastCleanupTime] of this.lastCleanupTimes) { + for (const [mapKey, lastCleanupTime] of this.lastCleanupTimeMap) { if (lastCleanupTime + this.retentionSeconds * 1000 < now) { - this.lastCleanupTimes.delete(key) + this.lastCleanupTimeMap.delete(mapKey) } } - if (!this.lastCleanupTimes.has(key)) { - this.lastCleanupTimes.set(key, now) + if (!this.lastCleanupTimeMap.has(key)) { + this.lastCleanupTimeMap.set(key, now) const result = await this.commander.multi() .xadd(key, '*', 'data', stringifyJSON(serialized)) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index f5924c6fc..d8e3db553 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -75,7 +75,7 @@ export class UpstashRedisPublisher> extends Pub this.serializer = new StandardRPCJsonSerializer(options) } - protected lastCleanupTimes: Map = new Map() + protected lastCleanupTimeMap: Map = new Map() override async publish(event: K, payload: T[K]): Promise { const key = this.prefixKey(event) @@ -86,14 +86,14 @@ export class UpstashRedisPublisher> extends Pub const now = Date.now() // cleanup for more efficiency memory - for (const [key, lastCleanupTime] of this.lastCleanupTimes) { + for (const [mapKey, lastCleanupTime] of this.lastCleanupTimeMap) { if (lastCleanupTime + this.retentionSeconds * 1000 < now) { - this.lastCleanupTimes.delete(key) + this.lastCleanupTimeMap.delete(mapKey) } } - if (!this.lastCleanupTimes.has(key)) { - this.lastCleanupTimes.set(key, now) + if (!this.lastCleanupTimeMap.has(key)) { + this.lastCleanupTimeMap.set(key, now) const results = await this.redis.multi() .xadd(key, '*', { data: serialized }) From eb574ba133e8a69278ef0f891e51d234ab0e819b Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 15:56:54 +0700 Subject: [PATCH 21/30] fix concurrent tests --- .../publisher/src/adapters/ioredis.test.ts | 69 ++++++++---------- packages/publisher/src/adapters/ioredis.ts | 2 +- .../src/adapters/upstash-redis.test.ts | 71 ++++++++----------- .../publisher/src/adapters/upstash-redis.ts | 4 +- 4 files changed, 63 insertions(+), 83 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 1ea4841bb..83518b59d 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -12,8 +12,9 @@ const REDIS_URL = process.env.REDIS_URL describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, () => { let commander: Redis let listener: Redis + const createdPublishers: IORedisPublisher[] = [] - function createPublisher(options: Partial = {}) { + function createTestingPublisher(options: Partial = {}) { const publisher = new IORedisPublisher({ commander, listener, @@ -23,12 +24,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( publisher.xtrimExactness = '=' // for easier testing - return { - publisher, - [Symbol.dispose]: () => { - expect(publisher.size).toEqual(0) // ensure cleanup correctly - }, - } + createdPublishers.push(publisher) + + return publisher } beforeAll(() => { @@ -39,11 +37,14 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( afterAll(async () => { await commander.quit() await listener.quit() + + for (const publisher of createdPublishers) { + expect(publisher.size).toEqual(0) // ensure cleanup correctly + } }) it('without resume: can pub/sub but not resume', async () => { - using resource = createPublisher() // resume is disabled by default - const { publisher } = resource + const publisher = createTestingPublisher() // resume is disabled by default const listener1 = vi.fn() const listener2 = vi.fn() @@ -87,10 +88,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( describe('with resume', () => { it('basic pub/sub', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -107,10 +107,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('can pub/sub and resume', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -157,10 +156,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('control event.id', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -215,10 +213,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('resume event.id > lastEventId and in order', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event', listener1) @@ -265,10 +262,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('handles multiple subscribers on same event', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -299,10 +295,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('handles errors during resume gracefully', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() @@ -324,10 +319,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('handles race condition where events published during resume', { repeats: 3 }, async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource await publisher.publish('event1', { order: 1 }) await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit @@ -357,11 +351,10 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( describe('cleanup retention', () => { it('handles cleanup of expired events on publish', async () => { const prefix = `cleanup:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 1, prefix, }) - const { publisher } = resource const key1 = `${prefix}event1` @@ -387,11 +380,10 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( it('verifies Redis auto-expires keys after retention period * 2', async () => { const prefix = `expire:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 1, prefix, }) - const { publisher } = resource const key = `${prefix}event1` @@ -415,18 +407,19 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( it('handles prefix correctly', async () => { const prefix = `custom:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, prefix, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - // verify channel use prefix - const numSub = await commander.pubsub('NUMSUB', `${prefix}event1`) - expect(numSub[1]).toBe(1) + // verify channel use prefix (NUMSUB can be delayed) + await vi.waitFor(async () => { + const numSub = await commander.pubsub('NUMSUB', `${prefix}event1`) + expect(numSub[1]).toBe(1) + }) const payload = { order: 1 } await publisher.publish('event1', payload) @@ -450,7 +443,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( ) { } } - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, customJsonSerializers: [ { @@ -461,7 +454,6 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }, ], }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -502,11 +494,10 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( publish: commander.publish.bind(commander), } as any - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, commander: mockCommander, }) - const { publisher } = resource // This should throw the transaction error await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') @@ -519,8 +510,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await listener.quit() }) - using resource = createPublisher({ listener }) - const { publisher } = resource + const publisher = createTestingPublisher({ listener }) expect(listener.listenerCount('message')).toBe(0) @@ -542,10 +532,9 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( it('gracefully handles invalid subscription message', async () => { const prefix = `invalid:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ prefix, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 182505408..b98e354dd 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -236,7 +236,7 @@ export class IORedisPublisher> extends Publishe return async () => { listeners.delete(listener) if (listeners.size === 0) { - this.listenersMap.delete(key) // should execute before async to avoid throw + this.listenersMap.delete(key) // should execute before async to avoid race condition if (this.redisListener && this.listenersMap.size === 0) { this.listener.off('message', this.redisListener) diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index fe1e1748a..b454ce506 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -12,8 +12,9 @@ const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN */ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, timeout: 20000 }, () => { let redis: Redis + const createdPublishers: UpstashRedisPublisher[] = [] - function createPublisher(options: UpstashRedisPublisherOptions = {}, useRedis = redis) { + function createTestingPublisher(options: UpstashRedisPublisherOptions = {}, useRedis = redis) { const publisher = new UpstashRedisPublisher(useRedis, { prefix: crypto.randomUUID(), // isolated from other tests ...options, @@ -21,12 +22,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | publisher.xtrimExactness = '=' // for easier testing - return { - publisher, - [Symbol.dispose]: () => { - expect(publisher.size).toEqual(0) // ensure cleanup correctly - }, - } + createdPublishers.push(publisher) + + return publisher } beforeAll(() => { @@ -36,9 +34,14 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) }) + afterAll(async () => { + for (const publisher of createdPublishers) { + expect(publisher.size).toEqual(0) // ensure cleanup correctly + } + }) + it('without resume: can pub/sub but not resume', async () => { - using resource = createPublisher() // resume is disabled by default - const { publisher } = resource + const publisher = createTestingPublisher() // resume is disabled by default const listener1 = vi.fn() const listener2 = vi.fn() @@ -82,10 +85,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | describe('with resume', () => { it('basic pub/sub', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -102,10 +104,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('can pub/sub and resume', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -151,10 +152,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('control event.id', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -208,10 +208,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('resume event.id > lastEventId and in order', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 60, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event', listener1) @@ -253,10 +252,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('handles multiple subscribers on same event', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() const listener2 = vi.fn() @@ -285,10 +283,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('handles errors during resume gracefully', async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource const listener1 = vi.fn() @@ -309,10 +306,9 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('handles race condition where events published during resume', { repeats: 3 }, async () => { - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) - const { publisher } = resource await publisher.publish('event1', { order: 1 }) await new Promise(resolve => setTimeout(resolve, 150)) // wait a bit @@ -342,11 +338,10 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | describe('cleanup retention', () => { it('handles cleanup of expired events on publish', async () => { const prefix = `cleanup:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 1, prefix, }) - const { publisher } = resource const key1 = `${prefix}event1` @@ -372,11 +367,10 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | it('verifies Redis auto-expires keys after retention period * 2', async () => { const prefix = `expire:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 1, prefix, }) - const { publisher } = resource const key = `${prefix}event1` @@ -400,18 +394,19 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | it('handles prefix correctly', async () => { const prefix = `custom:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, prefix, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - // verify channel use prefix - const numSub: any = await redis.exec(['PUBSUB', 'NUMSUB', `${prefix}event1`]) - expect(numSub[1]).toBe(1) + // verify channel use prefix (NUMSUB can be delayed) + await vi.waitFor(async () => { + const numSub: any = await redis.exec(['PUBSUB', 'NUMSUB', `${prefix}event1`]) + expect(numSub[1]).toBe(1) + }) const payload = { order: 1 } await publisher.publish('event1', payload) @@ -435,7 +430,7 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | ) { } } - using resource = createPublisher({ + const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, customJsonSerializers: [ { @@ -446,7 +441,6 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }, ], }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -489,8 +483,7 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | unsubscribeSpys.push(vi.spyOn(subscription, 'unsubscribe')) return subscription }) - using resource = createPublisher({}, redis) - const { publisher } = resource + const publisher = createTestingPublisher({}, redis) const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) @@ -514,18 +507,16 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | token: 'invalid', }) - using resource = createPublisher({}, invalidRedis) - const { publisher } = resource + const publisher = createTestingPublisher({}, invalidRedis) await expect(publisher.subscribe('event1', () => { })).rejects.toThrow() }) it('gracefully handles invalid subscription message', async () => { const prefix = `invalid:${crypto.randomUUID()}:` - using resource = createPublisher({ + const publisher = createTestingPublisher({ prefix, }) - const { publisher } = resource const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index d8e3db553..a5bdcaa69 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -229,11 +229,11 @@ export class UpstashRedisPublisher> extends Pub listeners.delete(listener) if (listeners.size === 0) { - this.listenersMap.delete(key) // should execute before async to avoid throw + this.listenersMap.delete(key) const subscription = this.subscriptionsMap.get(key) if (subscription) { - this.subscriptionsMap.delete(key) + this.subscriptionsMap.delete(key) // should execute before async to avoid race condition await subscription.unsubscribe() } } From 5b281c152efab4fd171b83f16f3784f28623191c Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 17:16:18 +0700 Subject: [PATCH 22/30] handle error --- .../publisher/src/adapters/ioredis.test.ts | 54 +++++++++++++------ packages/publisher/src/adapters/ioredis.ts | 19 +++++-- .../src/adapters/upstash-redis.test.ts | 32 ++++++----- .../publisher/src/adapters/upstash-redis.ts | 10 ++-- packages/publisher/src/publisher.test.ts | 50 ++++++++++++++--- packages/publisher/src/publisher.ts | 36 +++++++++++-- 6 files changed, 154 insertions(+), 47 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 83518b59d..7a4a5bde2 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -300,16 +300,16 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) const listener1 = vi.fn() + const onError1 = vi.fn() // Subscribe with an invalid lastEventId to trigger error in xread - const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format' }) + const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format', onError: onError1 }) // Publish an event await publisher.publish('event1', { order: 1 }) - await new Promise(resolve => setTimeout(resolve, 1000)) // wait until resume is finished - await vi.waitFor(() => { + expect(onError1).toHaveBeenCalledTimes(1) // Should have received the new event even though resume failed expect(listener1).toHaveBeenCalledTimes(1) }) @@ -318,7 +318,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub1() }) - it('handles race condition where events published during resume', { repeats: 3 }, async () => { + it('handles race condition where events published during resume', { repeats: 5 }, async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) @@ -415,11 +415,11 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - // verify channel use prefix (NUMSUB can be delayed) - await vi.waitFor(async () => { - const numSub = await commander.pubsub('NUMSUB', `${prefix}event1`) - expect(numSub[1]).toBe(1) - }) + // verify channel use prefix (NUMSUB is not reliable) + // await vi.waitFor(async () => { + // const numSub = await commander.pubsub('NUMSUB', `${prefix}event1`) + // expect(numSub[1]).toBe(1) + // }) const payload = { order: 1 } await publisher.publish('event1', payload) @@ -515,21 +515,43 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( expect(listener.listenerCount('message')).toBe(0) const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) + const onError1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1, { onError: onError1 }) expect(listener.listenerCount('message')).toBe(1) + expect(listener.listenerCount('error')).toBe(1) - const unsub2 = await publisher.subscribe('event1', listener1) + const onError2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener1, { onError: onError2 }) expect(listener.listenerCount('message')).toBe(1) // reuse listener + expect(listener.listenerCount('error')).toBe(2) // not reuse error listener await unsub1() - await unsub2() + expect(listener.listenerCount('message')).toBe(1) + expect(listener.listenerCount('error')).toBe(1) + await unsub2() expect(listener.listenerCount('message')).toBe(0) + expect(listener.listenerCount('error')).toBe(0) + expect(publisher.size).toBe(0) }) + it('subscribe should throw & on connection error', async () => { + const invalidListener = new Redis('redis://invalid', { + maxRetriesPerRequest: 0, + }) + + const publisher = createTestingPublisher({ listener: invalidListener }) + + const listener1 = vi.fn() + const onError = vi.fn() + await expect(publisher.subscribe('event1', listener1, { onError })).rejects.toThrow() + expect(listener1).toHaveBeenCalledTimes(0) + expect(onError).toHaveBeenCalledTimes(1) + }) + it('gracefully handles invalid subscription message', async () => { const prefix = `invalid:${crypto.randomUUID()}:` const publisher = createTestingPublisher({ @@ -537,12 +559,14 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) + const onError1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1, { onError: onError1 }) await commander.publish(`${prefix}event1`, 'invalid message') - // Wait for message to be received - await new Promise(resolve => setTimeout(resolve, 1000)) + await vi.waitFor(() => { + expect(onError1).toHaveBeenCalledTimes(1) + }) await unsub1() }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index b98e354dd..dc7d926d5 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -1,4 +1,5 @@ import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOptions } from '@orpc/client/standard' +import type { ThrowableError } from '@orpc/shared' import type Redis from 'ioredis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' @@ -141,6 +142,7 @@ export class IORedisPublisher> extends Publishe const key = this.prefixKey(event) const lastEventId = options?.lastEventId + const onError = options?.onError let pendingPayloads: T[K][] | undefined = [] const resumePayloadIds = new Set() @@ -175,15 +177,19 @@ export class IORedisPublisher> extends Publishe } } } - catch { + catch (error) { // error can happen when message is invalid - // TODO: log error + onError?.(error as ThrowableError) } } this.listener.on('message', this.redisListener) } + if (onError) { + this.listener.on('error', onError) + } + // avoid race condition when multiple listeners subscribe to the same channel on first time await this.listenerPromiseMap.get(key) @@ -219,9 +225,9 @@ export class IORedisPublisher> extends Publishe } } } - catch { + catch (error) { // error happen when message is invalid - // TODO: log error + onError?.(error as ThrowableError) } finally { const pending = pendingPayloads @@ -235,6 +241,11 @@ export class IORedisPublisher> extends Publishe return async () => { listeners.delete(listener) + + if (onError) { + this.listener.off('error', onError) + } + if (listeners.size === 0) { this.listenersMap.delete(key) // should execute before async to avoid race condition diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index b454ce506..b677ff0f7 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -288,16 +288,16 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) const listener1 = vi.fn() + const onError = vi.fn() // Subscribe with an invalid lastEventId to trigger error in xread - const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format' }) + const unsub1 = await publisher.subscribe('event1', listener1, { lastEventId: 'invalid-id-format', onError }) // Publish an event await publisher.publish('event1', { order: 1 }) - await new Promise(resolve => setTimeout(resolve, 1000)) // wait until resume is finished - await vi.waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) expect(listener1).toHaveBeenCalledTimes(1) }) expect(listener1).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) @@ -305,7 +305,7 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub1() }) - it('handles race condition where events published during resume', { repeats: 3 }, async () => { + it('handles race condition where events published during resume', { repeats: 5 }, async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) @@ -402,11 +402,11 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | const listener1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1) - // verify channel use prefix (NUMSUB can be delayed) - await vi.waitFor(async () => { - const numSub: any = await redis.exec(['PUBSUB', 'NUMSUB', `${prefix}event1`]) - expect(numSub[1]).toBe(1) - }) + // verify channel use prefix (NUMSUB is not reliable) + // await vi.waitFor(async () => { + // const numSub: any = await redis.exec(['PUBSUB', 'NUMSUB', `${prefix}event1`]) + // expect(numSub[1]).toBe(1) + // }) const payload = { order: 1 } await publisher.publish('event1', payload) @@ -509,7 +509,11 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | const publisher = createTestingPublisher({}, invalidRedis) - await expect(publisher.subscribe('event1', () => { })).rejects.toThrow() + const listener1 = vi.fn() + const onError = vi.fn() + await expect(publisher.subscribe('event1', listener1, { onError })).rejects.toThrow() + expect(listener1).toHaveBeenCalledTimes(0) + expect(onError).toHaveBeenCalledTimes(1) }) it('gracefully handles invalid subscription message', async () => { @@ -519,11 +523,15 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) + const onError = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1, { onError }) await redis.publish(`${prefix}event1`, 'invalid message') - await new Promise(resolve => setTimeout(resolve, 1000)) // ensure message received + await vi.waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + expect(listener1).toHaveBeenCalledTimes(0) await unsub1() }) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index a5bdcaa69..009b682c0 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -1,4 +1,5 @@ import type { StandardRPCJsonSerializedMetaItem, StandardRPCJsonSerializerOptions } from '@orpc/client/standard' +import type { ThrowableError } from '@orpc/shared' import type { Redis } from '@upstash/redis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' @@ -156,9 +157,9 @@ export class UpstashRedisPublisher> extends Pub } } } - catch { + catch (error) { // there error can happen when event.message is invalid - // TODO: log error + options?.onError?.(error as ThrowableError) } }) @@ -171,6 +172,7 @@ export class UpstashRedisPublisher> extends Pub subscription.on('error', (error) => { rejectPromise(error) + options?.onError?.(error) }) subscription.on('subscribe', () => { @@ -211,9 +213,9 @@ export class UpstashRedisPublisher> extends Pub } } } - catch { + catch (error) { // error can happen when result from xread is invalid - // TODO: log error + options?.onError?.(error as ThrowableError) } finally { const pending = pendingPayloads diff --git a/packages/publisher/src/publisher.test.ts b/packages/publisher/src/publisher.test.ts index 30f5e9694..d0ffb0b13 100644 --- a/packages/publisher/src/publisher.test.ts +++ b/packages/publisher/src/publisher.test.ts @@ -3,10 +3,11 @@ import { Publisher } from './publisher' // Concrete implementation for testing class TestPublisher> extends Publisher { - listeners = new Map void>>() + optionsMap = new Map() + listenersMap = new Map void>>() async publish(event: K, payload: T[K]): Promise { - const eventListeners = this.listeners.get(event) + const eventListeners = this.listenersMap.get(event) if (eventListeners) { eventListeners.forEach(listener => listener(payload)) } @@ -17,13 +18,18 @@ class TestPublisher> extends Publisher { listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions, ): Promise<() => Promise> { - if (!this.listeners.has(event)) { - this.listeners.set(event, new Set()) + if (!this.optionsMap.has(event)) { + this.optionsMap.set(event, []) } - this.listeners.get(event)!.add(listener) + this.optionsMap.get(event)!.push(options) + + if (!this.listenersMap.has(event)) { + this.listenersMap.set(event, new Set()) + } + this.listenersMap.get(event)!.add(listener) return async () => { - this.listeners.get(event)?.delete(listener) + this.listenersMap.get(event)?.delete(listener) } } } @@ -43,7 +49,7 @@ describe('publisher', () => { afterEach(() => { let size = 0 - for (const listeners of publisher.listeners.values()) { + for (const listeners of publisher.listenersMap.values()) { size += listeners.size } expect(size).toBe(0) // ensure all listeners are unsubscribed correctly @@ -89,6 +95,15 @@ describe('publisher', () => { expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith({ text: 'first' }) }) + + it('should forward options to subscribeListener', async () => { + const listener = vi.fn() + const options = { lastEventId: '123' } + const unsubscribe = await publisher.subscribe('message', listener, options) + expect(publisher.optionsMap.get('message')![0]).toBe(options) + + await unsubscribe() + }) }) describe('subscribe with async iterator', () => { @@ -297,5 +312,26 @@ describe('publisher', () => { expect(received).toHaveLength(100) }) + + it('should forward lastEventId to subscribeListener', async () => { + const unsub = publisher.subscribe('message', { lastEventId: '__test__' }) + expect(publisher.optionsMap.get('message')?.[0]?.lastEventId).toBe('__test__') + await unsub.return() + }) + + describe('should stop onError trigger', async () => { + it('error happen before pull', async () => { + const iterator = publisher.subscribe('message') + publisher.optionsMap.get('message')?.[0]?.onError?.(new Error('Test error')) + await expect(iterator.next()).rejects.toThrow('Test error') + }) + + it('error happen after pull', async () => { + const iterator = publisher.subscribe('message', { signal: AbortSignal.timeout(100) }) + const promise = expect(iterator.next()).rejects.toThrow('Test error') + publisher.optionsMap.get('message')?.[0]?.onError?.(new Error('Test error')) + await promise + }) + }) }) }) diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts index b480a67b1..84f956010 100644 --- a/packages/publisher/src/publisher.ts +++ b/packages/publisher/src/publisher.ts @@ -1,3 +1,4 @@ +import type { ThrowableError } from '@orpc/shared' import { AsyncIteratorClass } from '@orpc/shared' export interface PublisherOptions { @@ -22,9 +23,15 @@ export interface PublisherSubscribeListenerOptions { * Resume from a specific event ID */ lastEventId?: string | undefined + + /** + * Triggered when an error occur + */ + onError?: (error: ThrowableError) => void } -export interface PublisherSubscribeIteratorOptions extends PublisherSubscribeListenerOptions, PublisherOptions { +export interface PublisherSubscribeIteratorOptions + extends Pick, Pick { /** * Abort signal, automatically unsubscribes on abort */ @@ -101,6 +108,7 @@ export abstract class Publisher> { const bufferedEvents: T[K][] = [] const pullResolvers: [(result: IteratorResult) => void, (error: Error) => void][] = [] + let subscriptionError: { error: ThrowableError } | undefined const unsubscribePromise = this.subscribe(event, (payload) => { const resolver = pullResolvers.shift() @@ -115,24 +123,42 @@ export abstract class Publisher> { bufferedEvents.shift() } } + }, { + lastEventId: listenerOrOptions?.lastEventId, + onError: (error) => { + subscriptionError = { error } + pullResolvers.forEach(resolver => resolver[1](error)) + signal?.removeEventListener('abort', abortListener) + pullResolvers.length = 0 + bufferedEvents.length = 0 + unsubscribePromise.then(unsubscribe => unsubscribe()).catch(() => { + // TODO: log error + }) + }, }) - const abortListener = (event: any) => { + function abortListener(event: any) { pullResolvers.forEach(resolver => resolver[1](event.target.reason)) pullResolvers.length = 0 bufferedEvents.length = 0 - unsubscribePromise.then(unsubscribe => unsubscribe()).catch(() => {}) // ignore error + unsubscribePromise.then(unsubscribe => unsubscribe()).catch(() => { + // TODO: log error + }) } signal?.addEventListener('abort', abortListener, { once: true }) return new AsyncIteratorClass(async () => { - await unsubscribePromise // make sure subscription is ready + if (subscriptionError) { + throw subscriptionError.error + } if (signal?.aborted) { throw signal.reason } + await unsubscribePromise // make sure subscription is ready + if (bufferedEvents.length > 0) { return { done: false, value: bufferedEvents.shift()! } } @@ -141,8 +167,8 @@ export abstract class Publisher> { pullResolvers.push([resolve, reject]) }) }, async () => { - signal?.removeEventListener('abort', abortListener) pullResolvers.forEach(resolver => resolver[0]({ done: true, value: undefined })) + signal?.removeEventListener('abort', abortListener) pullResolvers.length = 0 bufferedEvents.length = 0 From 26c5f191f64bec4aa7de1fad9760398cd4e5437e Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 17:18:17 +0700 Subject: [PATCH 23/30] comment --- packages/publisher/src/publisher.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts index 84f956010..af9a920a7 100644 --- a/packages/publisher/src/publisher.ts +++ b/packages/publisher/src/publisher.ts @@ -73,6 +73,11 @@ export abstract class Publisher> { * ```ts * const unsubscribe = publisher.subscribe('event', (payload) => { * console.log(payload) + * }, { + * lastEventId, + * onError: (error) => { + * // handle error (consider unsubscribe if error can't be recovered) + * } * }) * * // Later @@ -86,7 +91,7 @@ export abstract class Publisher> { * * @example * ```ts - * for await (const payload of publisher.subscribe('event', { signal })) { + * for await (const payload of publisher.subscribe('event', { signal, lastEventId })) { * console.log(payload) * } * ``` From e76d1e21de4d3b7159b8115e2017e16d017ab267 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 18:24:14 +0700 Subject: [PATCH 24/30] fix ioredis cleanup --- packages/publisher/src/adapters/ioredis.test.ts | 6 +++++- packages/publisher/src/adapters/ioredis.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 7a4a5bde2..e548442b2 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -38,6 +38,10 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await commander.quit() await listener.quit() + expect(commander.listenerCount('message')).toEqual(0) + expect(commander.listenerCount('error')).toEqual(0) + expect(listener.listenerCount('message')).toEqual(0) + expect(listener.listenerCount('error')).toEqual(0) for (const publisher of createdPublishers) { expect(publisher.size).toEqual(0) // ensure cleanup correctly } @@ -549,7 +553,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const onError = vi.fn() await expect(publisher.subscribe('event1', listener1, { onError })).rejects.toThrow() expect(listener1).toHaveBeenCalledTimes(0) - expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(0) // error happen before register listener }) it('gracefully handles invalid subscription message', async () => { diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index dc7d926d5..2ec9949a5 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -186,10 +186,6 @@ export class IORedisPublisher> extends Publishe this.listener.on('message', this.redisListener) } - if (onError) { - this.listener.on('error', onError) - } - // avoid race condition when multiple listeners subscribe to the same channel on first time await this.listenerPromiseMap.get(key) @@ -203,6 +199,11 @@ export class IORedisPublisher> extends Publishe } finally { this.listenerPromiseMap.delete(key) + + if (this.listenersMap.size === 0) { // error happen + no listener + this.listener.off('message', this.redisListener) + this.redisListener = undefined + } } } @@ -239,6 +240,11 @@ export class IORedisPublisher> extends Publishe } })() + // listen error after async to avoid throw -> can't off + if (onError) { + this.listener.on('error', onError) + } + return async () => { listeners.delete(listener) From bc506f7eae542f1ab77763ce526de920582f278f Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 19:23:03 +0700 Subject: [PATCH 25/30] use array instead of set - because listener can duplicate + fix onError --- .../publisher/src/adapters/ioredis.test.ts | 83 +++++++++++++---- packages/publisher/src/adapters/ioredis.ts | 92 +++++++++++++------ .../src/adapters/upstash-redis.test.ts | 47 +++++++--- .../publisher/src/adapters/upstash-redis.ts | 57 +++++++++--- 4 files changed, 208 insertions(+), 71 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index e548442b2..3407b5bfd 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -487,6 +487,61 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub1() }) + it('subscribe should throw & on connection error', async () => { + const invalidListener = new Redis('redis://invalid', { + maxRetriesPerRequest: 0, + }) + + const publisher = createTestingPublisher({ listener: invalidListener }) + + const listener1 = vi.fn() + const onError1 = vi.fn() + const listener2 = vi.fn() + const onError2 = vi.fn() + + await Promise.all([ // race condition + expect(publisher.subscribe('event1', listener1, { onError: onError1 })).rejects.toThrow(), + expect(publisher.subscribe('event1', listener1, { onError: onError1 })).rejects.toThrow(), + expect(publisher.subscribe('event2', listener2, { onError: onError2 })).rejects.toThrow(), + ]) + + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(0) + expect(onError1).toHaveBeenCalledTimes(0) // error happen before register listener + expect(onError2).toHaveBeenCalledTimes(0) // error happen before register listener + }) + + it('onError should trigger on connection error', async ({ onTestFinished }) => { + const listener = new Redis(REDIS_URL!, { + maxRetriesPerRequest: 0, + }) + onTestFinished(async () => { + await listener.quit() + }) + + const publisher = createTestingPublisher({ listener }) + + const listener1 = vi.fn() + const onError1 = vi.fn() + const listener2 = vi.fn() + const onError2 = vi.fn() + + const unsub1 = await publisher.subscribe('event1', listener1, { onError: onError1 }) + const unsub11 = await publisher.subscribe('event1', listener1, { onError: onError1 }) + const unsub2 = await publisher.subscribe('event2', listener2, { onError: onError2 }) + + ;(listener as any).connector.stream.destroy(new Error('Simulated network failure')) + + await vi.waitFor(() => { + expect(onError1).toHaveBeenCalledTimes(2) + expect(onError2).toHaveBeenCalledTimes(1) + }) + + await unsub1() + await unsub11() + await unsub2() + }) + describe('edge cases', () => { it('handles transaction errors during publish', async () => { // Create a mock commander that will fail on multi @@ -507,7 +562,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await expect(publisher.publish('event1', { order: 1 })).rejects.toThrow('Transaction failed') }) - it('only subscribe to redis-listener when needed', async () => { + it('only subscribe to redis-listener when needed', async ({ onTestFinished }) => { // use dedicated listener const listener = new Redis(REDIS_URL!) onTestFinished(async () => { @@ -517,6 +572,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const publisher = createTestingPublisher({ listener }) expect(listener.listenerCount('message')).toBe(0) + expect(listener.listenerCount('error')).toBe(0) const listener1 = vi.fn() const onError1 = vi.fn() @@ -529,7 +585,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const unsub2 = await publisher.subscribe('event1', listener1, { onError: onError2 }) expect(listener.listenerCount('message')).toBe(1) // reuse listener - expect(listener.listenerCount('error')).toBe(2) // not reuse error listener + expect(listener.listenerCount('error')).toBe(1) // reuse onError await unsub1() expect(listener.listenerCount('message')).toBe(1) @@ -542,20 +598,6 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( expect(publisher.size).toBe(0) }) - it('subscribe should throw & on connection error', async () => { - const invalidListener = new Redis('redis://invalid', { - maxRetriesPerRequest: 0, - }) - - const publisher = createTestingPublisher({ listener: invalidListener }) - - const listener1 = vi.fn() - const onError = vi.fn() - await expect(publisher.subscribe('event1', listener1, { onError })).rejects.toThrow() - expect(listener1).toHaveBeenCalledTimes(0) - expect(onError).toHaveBeenCalledTimes(0) // error happen before register listener - }) - it('gracefully handles invalid subscription message', async () => { const prefix = `invalid:${crypto.randomUUID()}:` const publisher = createTestingPublisher({ @@ -566,13 +608,22 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const onError1 = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1, { onError: onError1 }) + // use two onError to ensure redis-onError handle correctly to populate to all onError + const listener2 = vi.fn() + const onError2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2, { onError: onError2 }) + await commander.publish(`${prefix}event1`, 'invalid message') await vi.waitFor(() => { expect(onError1).toHaveBeenCalledTimes(1) + expect(onError2).toHaveBeenCalledTimes(1) }) + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(0) await unsub1() + await unsub2() }) }) }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 2ec9949a5..54098350f 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -52,8 +52,12 @@ export class IORedisPublisher> extends Publishe protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number protected readonly listenerPromiseMap = new Map>() - protected readonly listenersMap = new Map void>>() - protected redisListener: ((channel: string, message: string) => void) | undefined + protected readonly listenersMap = new Map void)[]>() + protected readonly onErrorsMap = new Map void)[]>() + protected redisListenerAndOnError: undefined | { + listener: (channel: string, message: string) => void + onError: (error: ThrowableError) => void + } protected get isResumeEnabled(): boolean { return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 @@ -73,10 +77,13 @@ export class IORedisPublisher> extends Publishe * */ get size(): number { - /* v8 ignore next 5 */ - let size = this.redisListener ? 1 : 0 + /* v8 ignore next 8 */ + let size = this.redisListenerAndOnError ? 1 : 0 for (const listeners of this.listenersMap) { - size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event + size += listeners[1].length || 1 // empty array should never happen so we treat it as a single event + } + for (const onErrors of this.onErrorsMap) { + size += onErrors[1].length || 1 // empty array should never happen so we treat it as a single event } return size } @@ -138,11 +145,13 @@ export class IORedisPublisher> extends Publishe await this.commander.publish(key, stringifyJSON({ ...serialized, id })) } - protected override async subscribeListener(event: K, originalListener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + protected override async subscribeListener( + event: K, + originalListener: (payload: T[K]) => void, + { lastEventId, onError }: PublisherSubscribeListenerOptions = {}, + ): Promise<() => Promise> { const key = this.prefixKey(event) - const lastEventId = options?.lastEventId - const onError = options?.onError let pendingPayloads: T[K][] | undefined = [] const resumePayloadIds = new Set() @@ -163,8 +172,16 @@ export class IORedisPublisher> extends Publishe originalListener(payload) } - if (!this.redisListener) { - this.redisListener = (channel: string, message: string) => { + if (!this.redisListenerAndOnError) { + const redisOnError = (error: ThrowableError) => { + for (const [_, onErrors] of this.onErrorsMap) { + for (const onError of onErrors) { + onError(error) + } + } + } + + const redisListener = (channel: string, message: string) => { try { const listeners = this.listenersMap.get(channel) @@ -179,11 +196,19 @@ export class IORedisPublisher> extends Publishe } catch (error) { // error can happen when message is invalid - onError?.(error as ThrowableError) + const onErrors = this.onErrorsMap.get(channel) + if (onErrors) { + for (const onError of onErrors) { + onError(error as ThrowableError) + } + } } } - this.listener.on('message', this.redisListener) + this.redisListenerAndOnError = { listener: redisListener, onError: redisOnError } + + this.listener.on('message', redisListener) + this.listener.on('error', redisOnError) } // avoid race condition when multiple listeners subscribe to the same channel on first time @@ -195,19 +220,29 @@ export class IORedisPublisher> extends Publishe const promise = this.listener.subscribe(key) this.listenerPromiseMap.set(key, promise) await promise - this.listenersMap.set(key, listeners = new Set()) // only set after subscribe successfully + this.listenersMap.set(key, listeners = []) // only set after subscribe successfully } finally { this.listenerPromiseMap.delete(key) if (this.listenersMap.size === 0) { // error happen + no listener - this.listener.off('message', this.redisListener) - this.redisListener = undefined + this.listener.off('message', this.redisListenerAndOnError.listener) + this.listener.off('error', this.redisListenerAndOnError.onError) + this.redisListenerAndOnError = undefined } } } - listeners.add(listener) + listeners.push(listener) + + if (onError) { // add onError after subscribe success + let onErrors = this.onErrorsMap.get(key) + if (!onErrors) { + this.onErrorsMap.set(key, onErrors = []) + } + + onErrors.push(onError) + } void (async () => { try { @@ -240,24 +275,25 @@ export class IORedisPublisher> extends Publishe } })() - // listen error after async to avoid throw -> can't off - if (onError) { - this.listener.on('error', onError) - } - return async () => { - listeners.delete(listener) + listeners.splice(listeners.indexOf(listener), 1) if (onError) { - this.listener.off('error', onError) + const onErrors = this.onErrorsMap.get(key) + if (onErrors) { + onErrors.splice(onErrors.indexOf(onError), 1) + } } - if (listeners.size === 0) { - this.listenersMap.delete(key) // should execute before async to avoid race condition + if (listeners.length === 0) { // onErrors always has lower length than listeners + // should execute before async to avoid race condition + this.listenersMap.delete(key) + this.onErrorsMap.delete(key) - if (this.redisListener && this.listenersMap.size === 0) { - this.listener.off('message', this.redisListener) - this.redisListener = undefined + if (this.redisListenerAndOnError && this.listenersMap.size === 0) { + this.listener.off('message', this.redisListenerAndOnError.listener) + this.listener.off('error', this.redisListenerAndOnError.onError) + this.redisListenerAndOnError = undefined } await this.listener.unsubscribe(key) diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index b677ff0f7..1ffb4076d 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -470,6 +470,30 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub1() }) + it('subscribe should throw & on connection error', async () => { + const invalidRedis = new Redis({ + url: 'http://invalid:6379', + token: 'invalid', + }) + + const publisher = createTestingPublisher({}, invalidRedis) + + const listener1 = vi.fn() + const onError1 = vi.fn() + const listener2 = vi.fn() + const onError2 = vi.fn() + + await Promise.all([ // race condition + expect(publisher.subscribe('event1', listener1, { onError: onError1 })).rejects.toThrow(), + expect(publisher.subscribe('event1', listener1, { onError: onError1 })).rejects.toThrow(), + expect(publisher.subscribe('event2', listener2, { onError: onError2 })).rejects.toThrow(), + ]) + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(0) + expect(onError1).toHaveBeenCalledTimes(0) // error happen before register listener + expect(onError2).toHaveBeenCalledTimes(0) // error happen before register listener + }) + describe('edge cases', () => { it('only subscribe to redis-listener when needed', async () => { // use dedicated redis instance @@ -501,21 +525,6 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | expect(unsubscribeSpys.length).toBe(1) // ensure only subscribe once }) - it('subscribe should throw & on connection error', async () => { - const invalidRedis = new Redis({ - url: 'http://invalid:6379', - token: 'invalid', - }) - - const publisher = createTestingPublisher({}, invalidRedis) - - const listener1 = vi.fn() - const onError = vi.fn() - await expect(publisher.subscribe('event1', listener1, { onError })).rejects.toThrow() - expect(listener1).toHaveBeenCalledTimes(0) - expect(onError).toHaveBeenCalledTimes(1) - }) - it('gracefully handles invalid subscription message', async () => { const prefix = `invalid:${crypto.randomUUID()}:` const publisher = createTestingPublisher({ @@ -526,14 +535,22 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | const onError = vi.fn() const unsub1 = await publisher.subscribe('event1', listener1, { onError }) + // use two onError to ensure redis-onError handle correctly to populate to all onError + const listener2 = vi.fn() + const onError2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2, { onError: onError2 }) + await redis.publish(`${prefix}event1`, 'invalid message') await vi.waitFor(() => { expect(onError).toHaveBeenCalledTimes(1) + expect(onError2).toHaveBeenCalledTimes(1) }) expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(0) await unsub1() + await unsub2() }) }) }) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 009b682c0..4c516b74b 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -35,7 +35,8 @@ export class UpstashRedisPublisher> extends Pub protected readonly prefix: string protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number - protected readonly listenersMap = new Map void>>() + protected readonly listenersMap = new Map void)[]>() + protected readonly onErrorsMap = new Map void)[]>() protected readonly subscriptionPromiseMap = new Map>() protected readonly subscriptionsMap = new Map() // Upstash subscription objects @@ -57,10 +58,13 @@ export class UpstashRedisPublisher> extends Pub * */ get size(): number { - /* v8 ignore next 5 */ + /* v8 ignore next 8 */ let size = 0 for (const listeners of this.listenersMap) { - size += listeners[1].size || 1 // empty set should never happen so we treat it as a single event + size += listeners[1].length || 1 // empty array should never happen so we treat it as a single event + } + for (const onErrors of this.onErrorsMap) { + size += onErrors[1].length || 1 // empty array should never happen so we treat it as a single event } return size } @@ -113,10 +117,13 @@ export class UpstashRedisPublisher> extends Pub await this.redis.publish(key, { ...serialized, id }) } - protected override async subscribeListener(event: K, originalListener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + protected override async subscribeListener( + event: K, + originalListener: (payload: T[K]) => void, + { lastEventId, onError }: PublisherSubscribeListenerOptions = {}, + ): Promise<() => Promise> { const key = this.prefixKey(event) - const lastEventId = options?.lastEventId let pendingPayloads: T[K][] | undefined = [] const resumePayloadIds = new Set() @@ -143,6 +150,15 @@ export class UpstashRedisPublisher> extends Pub // Get or create subscription for this channel let subscription = this.subscriptionsMap.get(key) as ReturnType | undefined if (!subscription) { + const dispatchErrorForKey = (error: ThrowableError) => { + const onErrors = this.onErrorsMap.get(key) + if (onErrors) { + for (const onError of onErrors) { + onError(error) + } + } + } + subscription = this.redis.subscribe(key) subscription.on('message', (event) => { try { @@ -159,7 +175,7 @@ export class UpstashRedisPublisher> extends Pub } catch (error) { // there error can happen when event.message is invalid - options?.onError?.(error as ThrowableError) + dispatchErrorForKey(error as ThrowableError) } }) @@ -172,7 +188,7 @@ export class UpstashRedisPublisher> extends Pub subscription.on('error', (error) => { rejectPromise(error) - options?.onError?.(error) + dispatchErrorForKey(error) }) subscription.on('subscribe', () => { @@ -191,10 +207,18 @@ export class UpstashRedisPublisher> extends Pub let listeners = this.listenersMap.get(key) if (!listeners) { - this.listenersMap.set(key, listeners = new Set()) + this.listenersMap.set(key, listeners = []) } - listeners.add(listener) + listeners.push(listener) + + if (onError) { + let onErrors = this.onErrorsMap.get(key) + if (!onErrors) { + this.onErrorsMap.set(key, onErrors = []) + } + onErrors.push(onError) + } void (async () => { try { @@ -215,7 +239,7 @@ export class UpstashRedisPublisher> extends Pub } catch (error) { // error can happen when result from xread is invalid - options?.onError?.(error as ThrowableError) + onError?.(error as ThrowableError) } finally { const pending = pendingPayloads @@ -228,10 +252,19 @@ export class UpstashRedisPublisher> extends Pub })() return async () => { - listeners.delete(listener) + listeners.splice(listeners.indexOf(listener), 1) + + if (onError) { + const onErrors = this.onErrorsMap.get(key) + if (onErrors) { + onErrors.splice(onErrors.indexOf(onError), 1) + } + } - if (listeners.size === 0) { + if (listeners.length === 0) { // onErrors always has lower length than listeners this.listenersMap.delete(key) + this.onErrorsMap.delete(key) + const subscription = this.subscriptionsMap.get(key) if (subscription) { From 7e4012bace794dafc12cfe60e89e666c9b9e78fe Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 21:46:05 +0700 Subject: [PATCH 26/30] cover case unsub use multiple time + same lister --- .../publisher/src/adapters/ioredis.test.ts | 47 +++++++++++++++++++ packages/publisher/src/adapters/ioredis.ts | 15 +++--- .../publisher/src/adapters/memory.test.ts | 47 +++++++++++++++++++ packages/publisher/src/adapters/memory.ts | 22 ++++----- .../src/adapters/upstash-redis.test.ts | 47 +++++++++++++++++++ .../publisher/src/adapters/upstash-redis.ts | 15 +++--- packages/shared/src/event-publisher.test.ts | 34 ++++++++++++++ packages/shared/src/event-publisher.ts | 16 +++---- 8 files changed, 208 insertions(+), 35 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 3407b5bfd..4e1b24179 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -625,5 +625,52 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub1() await unsub2() }) + + it('support use same listener multiple times', async () => { + const publisher = createTestingPublisher() + + const listener = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener) + const unsub2 = await publisher.subscribe('event1', listener) + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + + await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(3) + }) + + await unsub2() + }) + + it('safely to unsub multiple times', async () => { + const publisher = createTestingPublisher() + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2) + + // ensure unsub multiple times not effect other listener + await unsub1() + await unsub1() + await unsub1() + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(1) + }) + + await unsub2() + }) }) }) diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index 54098350f..bd4f5dc12 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -3,7 +3,7 @@ import type { ThrowableError } from '@orpc/shared' import type Redis from 'ioredis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' -import { fallback, stringifyJSON } from '@orpc/shared' +import { fallback, once, stringifyJSON } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -52,8 +52,8 @@ export class IORedisPublisher> extends Publishe protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number protected readonly listenerPromiseMap = new Map>() - protected readonly listenersMap = new Map void)[]>() - protected readonly onErrorsMap = new Map void)[]>() + protected readonly listenersMap = new Map void>>() + protected readonly onErrorsMap = new Map void>>() protected redisListenerAndOnError: undefined | { listener: (channel: string, message: string) => void onError: (error: ThrowableError) => void @@ -206,7 +206,6 @@ export class IORedisPublisher> extends Publishe } this.redisListenerAndOnError = { listener: redisListener, onError: redisOnError } - this.listener.on('message', redisListener) this.listener.on('error', redisOnError) } @@ -232,7 +231,6 @@ export class IORedisPublisher> extends Publishe } } } - listeners.push(listener) if (onError) { // add onError after subscribe success @@ -240,7 +238,6 @@ export class IORedisPublisher> extends Publishe if (!onErrors) { this.onErrorsMap.set(key, onErrors = []) } - onErrors.push(onError) } @@ -275,7 +272,7 @@ export class IORedisPublisher> extends Publishe } })() - return async () => { + return once(async () => { listeners.splice(listeners.indexOf(listener), 1) if (onError) { @@ -286,7 +283,6 @@ export class IORedisPublisher> extends Publishe } if (listeners.length === 0) { // onErrors always has lower length than listeners - // should execute before async to avoid race condition this.listenersMap.delete(key) this.onErrorsMap.delete(key) @@ -296,9 +292,10 @@ export class IORedisPublisher> extends Publishe this.redisListenerAndOnError = undefined } + // should execute all logic before async to avoid race condition problem await this.listener.unsubscribe(key) } - } + }) } protected prefixKey(key: string): string { diff --git a/packages/publisher/src/adapters/memory.test.ts b/packages/publisher/src/adapters/memory.test.ts index 7d4b5f538..ff118287e 100644 --- a/packages/publisher/src/adapters/memory.test.ts +++ b/packages/publisher/src/adapters/memory.test.ts @@ -175,5 +175,52 @@ describe('memoryPublisher', () => { publisher.publish('event10', { order: 7 }) expect(publisher.size).toEqual(1) // 1 event is stored }) + + it('support use same listener multiple times', async () => { + const publisher = new MemoryPublisher() + + const listener = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener) + const unsub2 = await publisher.subscribe('event1', listener) + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + + await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(3) + }) + + await unsub2() + + expect(publisher.size).toEqual(0) + }) + + it('safely to unsub multiple times', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2) + + // ensure unsub not accidentally effect other listener + await unsub1() + await unsub1() + await unsub1() + + await publisher.publish('event1', { order: 1 }) + + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(1) + + await unsub2() + }) }) }) diff --git a/packages/publisher/src/adapters/memory.ts b/packages/publisher/src/adapters/memory.ts index 8a4736bf6..3dc47a1c6 100644 --- a/packages/publisher/src/adapters/memory.ts +++ b/packages/publisher/src/adapters/memory.ts @@ -1,5 +1,5 @@ import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' -import { compareSequentialIds, EventPublisher, SequentialIdGenerator } from '@orpc/shared' +import { compareSequentialIds, EventPublisher, once, SequentialIdGenerator } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -22,7 +22,7 @@ export class MemoryPublisher> extends Publisher private readonly eventPublisher = new EventPublisher() private readonly idGenerator = new SequentialIdGenerator() private readonly retentionSeconds: number - private readonly events: Map = new Map() + private readonly eventsMap: Map> = new Map() /** * Useful for measuring memory usage. @@ -32,7 +32,7 @@ export class MemoryPublisher> extends Publisher */ get size(): number { let size = this.eventPublisher.size - for (const events of this.events) { + for (const events of this.eventsMap) { /* v8 ignore next 1 */ size += events[1].length || 1 // empty array should never happen so we treat it as a single event } @@ -57,9 +57,9 @@ export class MemoryPublisher> extends Publisher const now = Date.now() const expiresAt = now + this.retentionSeconds * 1000 - let events = this.events.get(event) + let events = this.eventsMap.get(event) if (!events) { - this.events.set(event, events = []) + this.eventsMap.set(event, events = []) } payload = withEventMeta(payload, { ...getEventMeta(payload), id: this.idGenerator.generate() }) @@ -71,7 +71,7 @@ export class MemoryPublisher> extends Publisher protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { if (this.isResumeEnabled && typeof options?.lastEventId === 'string') { - const events = this.events.get(event) + const events = this.eventsMap.get(event) if (events) { for (const { payload } of events) { const id = getEventMeta(payload)?.id @@ -84,9 +84,9 @@ export class MemoryPublisher> extends Publisher const syncUnsub = this.eventPublisher.subscribe(event, listener) - return async () => { + return once(async () => { syncUnsub() - } + }) } protected lastCleanupTime: number | null = null @@ -103,14 +103,14 @@ export class MemoryPublisher> extends Publisher this.lastCleanupTime = now - for (const [event, events] of this.events) { + for (const [event, events] of this.eventsMap) { const validEvents = events.filter(event => event.expiresAt > now) if (validEvents.length > 0) { - this.events.set(event, validEvents) + this.eventsMap.set(event, validEvents) } else { - this.events.delete(event) + this.eventsMap.delete(event) } } } diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index 1ffb4076d..7351decbc 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -552,5 +552,52 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub1() await unsub2() }) + + it('support use same listener multiple times', async () => { + const publisher = createTestingPublisher() + + const listener = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener) + const unsub2 = await publisher.subscribe('event1', listener) + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + + await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(3) + }) + + await unsub2() + }) + + it('safely to unsub multiple times', async () => { + const publisher = createTestingPublisher() + + const listener1 = vi.fn() + const unsub1 = await publisher.subscribe('event1', listener1) + const listener2 = vi.fn() + const unsub2 = await publisher.subscribe('event1', listener2) + + // ensure unsub multiple times not effect other listener + await unsub1() + await unsub1() + await unsub1() + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(1) + }) + + await unsub2() + }) }) }) diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 4c516b74b..5ae3b0608 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -3,7 +3,7 @@ import type { ThrowableError } from '@orpc/shared' import type { Redis } from '@upstash/redis' import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' import { StandardRPCJsonSerializer } from '@orpc/client/standard' -import { fallback } from '@orpc/shared' +import { fallback, once } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -35,8 +35,8 @@ export class UpstashRedisPublisher> extends Pub protected readonly prefix: string protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number - protected readonly listenersMap = new Map void)[]>() - protected readonly onErrorsMap = new Map void)[]>() + protected readonly listenersMap = new Map void>>() + protected readonly onErrorsMap = new Map void>>() protected readonly subscriptionPromiseMap = new Map>() protected readonly subscriptionsMap = new Map() // Upstash subscription objects @@ -209,7 +209,6 @@ export class UpstashRedisPublisher> extends Pub if (!listeners) { this.listenersMap.set(key, listeners = []) } - listeners.push(listener) if (onError) { @@ -251,7 +250,7 @@ export class UpstashRedisPublisher> extends Pub } })() - return async () => { + return once(async () => { listeners.splice(listeners.indexOf(listener), 1) if (onError) { @@ -268,11 +267,13 @@ export class UpstashRedisPublisher> extends Pub const subscription = this.subscriptionsMap.get(key) if (subscription) { - this.subscriptionsMap.delete(key) // should execute before async to avoid race condition + this.subscriptionsMap.delete(key) + + // should execute all logic before async to avoid race condition problem await subscription.unsubscribe() } } - } + }) } protected prefixKey(key: string): string { diff --git a/packages/shared/src/event-publisher.test.ts b/packages/shared/src/event-publisher.test.ts index e74ab583c..b77966818 100644 --- a/packages/shared/src/event-publisher.test.ts +++ b/packages/shared/src/event-publisher.test.ts @@ -265,5 +265,39 @@ describe('eventPublisher', () => { await promise }) + + it('support reuse lister for multiple time', async () => { + const listener = vi.fn() + + const unsub1 = pub.subscribe('event', listener) + const unsub2 = pub.subscribe('event', listener) + + pub.publish('event', 'payload1') + expect(listener).toHaveBeenCalledTimes(2) + + unsub1() + pub.publish('event', 'payload2') + expect(listener).toHaveBeenCalledTimes(3) + + unsub2() + }) + + it('safely unsub multiple times', async () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + + const unsub1 = pub.subscribe('event', listener1) + const unsub2 = pub.subscribe('event', listener2) + + unsub1() + unsub1() + unsub1() + + pub.publish('event', 'payload1') + expect(listener1).toHaveBeenCalledTimes(0) + expect(listener2).toHaveBeenCalledTimes(1) + + unsub2() + }) }) }) diff --git a/packages/shared/src/event-publisher.ts b/packages/shared/src/event-publisher.ts index 7fa6f9bae..b5d84fecc 100644 --- a/packages/shared/src/event-publisher.ts +++ b/packages/shared/src/event-publisher.ts @@ -1,3 +1,4 @@ +import { once } from './function' import { AsyncIteratorClass } from './iterator' export interface EventPublisherOptions { @@ -25,7 +26,7 @@ export interface EventPublisherSubscribeIteratorOptions extends EventPublisherOp } export class EventPublisher> { - #listenersMap = new Map void>>() + #listenersMap = new Map void>>() #maxBufferedEvents: number constructor(options: EventPublisherOptions = {}) { @@ -86,18 +87,17 @@ export class EventPublisher> { let listeners = this.#listenersMap.get(event) if (!listeners) { - this.#listenersMap.set(event, listeners = new Set()) + this.#listenersMap.set(event, listeners = []) } + listeners.push(listenerOrOptions) - listeners.add(listenerOrOptions) + return once(() => { + listeners.splice(listeners.indexOf(listenerOrOptions), 1) - return () => { - listeners.delete(listenerOrOptions) - - if (listeners.size === 0) { + if (listeners.length === 0) { this.#listenersMap.delete(event) } - } + }) } const signal = listenerOrOptions?.signal From c89872d7daf92ae0ad27fddad47e8b1ee183da34 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 18 Oct 2025 22:29:20 +0700 Subject: [PATCH 27/30] improve unsub --- .../publisher/src/adapters/ioredis.test.ts | 27 ++--------------- packages/publisher/src/adapters/ioredis.ts | 13 +++++++-- .../publisher/src/adapters/memory.test.ts | 29 +++---------------- packages/publisher/src/adapters/memory.ts | 6 ++-- .../src/adapters/upstash-redis.test.ts | 27 ++--------------- .../publisher/src/adapters/upstash-redis.ts | 8 +++-- 6 files changed, 29 insertions(+), 81 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index 4e1b24179..bb9417589 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -626,7 +626,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub2() }) - it('support use same listener multiple times', async () => { + it('support reuse same listener and unsub multiple times', async () => { const publisher = createTestingPublisher() const listener = vi.fn() @@ -639,35 +639,14 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( expect(listener).toHaveBeenCalledTimes(2) }) - await unsub1() - - await publisher.publish('event1', { order: 2 }) - - await vi.waitFor(() => { - expect(listener).toHaveBeenCalledTimes(3) - }) - - await unsub2() - }) - - it('safely to unsub multiple times', async () => { - const publisher = createTestingPublisher() - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - const listener2 = vi.fn() - const unsub2 = await publisher.subscribe('event1', listener2) - - // ensure unsub multiple times not effect other listener await unsub1() await unsub1() await unsub1() - await publisher.publish('event1', { order: 1 }) + await publisher.publish('event1', { order: 2 }) await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(0) - expect(listener2).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledTimes(3) }) await unsub2() diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index bd4f5dc12..f14914667 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -272,15 +272,22 @@ export class IORedisPublisher> extends Publishe } })() - return once(async () => { + const cleanupListeners = once(() => { listeners.splice(listeners.indexOf(listener), 1) if (onError) { const onErrors = this.onErrorsMap.get(key) if (onErrors) { - onErrors.splice(onErrors.indexOf(onError), 1) + const index = onErrors.indexOf(onError) + if (index !== -1) { + onErrors.splice(index, 1) + } } } + }) + + return async () => { + cleanupListeners() if (listeners.length === 0) { // onErrors always has lower length than listeners this.listenersMap.delete(key) @@ -295,7 +302,7 @@ export class IORedisPublisher> extends Publishe // should execute all logic before async to avoid race condition problem await this.listener.unsubscribe(key) } - }) + } } protected prefixKey(key: string): string { diff --git a/packages/publisher/src/adapters/memory.test.ts b/packages/publisher/src/adapters/memory.test.ts index ff118287e..5e05573c0 100644 --- a/packages/publisher/src/adapters/memory.test.ts +++ b/packages/publisher/src/adapters/memory.test.ts @@ -176,8 +176,8 @@ describe('memoryPublisher', () => { expect(publisher.size).toEqual(1) // 1 event is stored }) - it('support use same listener multiple times', async () => { - const publisher = new MemoryPublisher() + it('support reuse same listener and unsub multiple times', async () => { + const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) const listener = vi.fn() const unsub1 = await publisher.subscribe('event1', listener) @@ -189,6 +189,8 @@ describe('memoryPublisher', () => { expect(listener).toHaveBeenCalledTimes(2) }) + await unsub1() + await unsub1() await unsub1() await publisher.publish('event1', { order: 2 }) @@ -198,29 +200,6 @@ describe('memoryPublisher', () => { }) await unsub2() - - expect(publisher.size).toEqual(0) - }) - - it('safely to unsub multiple times', async () => { - const publisher = new MemoryPublisher({ resumeRetentionSeconds: 1 }) - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - const listener2 = vi.fn() - const unsub2 = await publisher.subscribe('event1', listener2) - - // ensure unsub not accidentally effect other listener - await unsub1() - await unsub1() - await unsub1() - - await publisher.publish('event1', { order: 1 }) - - expect(listener1).toHaveBeenCalledTimes(0) - expect(listener2).toHaveBeenCalledTimes(1) - - await unsub2() }) }) }) diff --git a/packages/publisher/src/adapters/memory.ts b/packages/publisher/src/adapters/memory.ts index 3dc47a1c6..8a0ab7763 100644 --- a/packages/publisher/src/adapters/memory.ts +++ b/packages/publisher/src/adapters/memory.ts @@ -1,5 +1,5 @@ import type { PublisherOptions, PublisherSubscribeListenerOptions } from '../publisher' -import { compareSequentialIds, EventPublisher, once, SequentialIdGenerator } from '@orpc/shared' +import { compareSequentialIds, EventPublisher, SequentialIdGenerator } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@orpc/standard-server' import { Publisher } from '../publisher' @@ -84,9 +84,9 @@ export class MemoryPublisher> extends Publisher const syncUnsub = this.eventPublisher.subscribe(event, listener) - return once(async () => { + return async () => { syncUnsub() - }) + } } protected lastCleanupTime: number | null = null diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index 7351decbc..02ead4e52 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -553,7 +553,7 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub2() }) - it('support use same listener multiple times', async () => { + it('support reuse same listener and unsub multiple times', async () => { const publisher = createTestingPublisher() const listener = vi.fn() @@ -566,35 +566,14 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | expect(listener).toHaveBeenCalledTimes(2) }) - await unsub1() - - await publisher.publish('event1', { order: 2 }) - - await vi.waitFor(() => { - expect(listener).toHaveBeenCalledTimes(3) - }) - - await unsub2() - }) - - it('safely to unsub multiple times', async () => { - const publisher = createTestingPublisher() - - const listener1 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - const listener2 = vi.fn() - const unsub2 = await publisher.subscribe('event1', listener2) - - // ensure unsub multiple times not effect other listener await unsub1() await unsub1() await unsub1() - await publisher.publish('event1', { order: 1 }) + await publisher.publish('event1', { order: 2 }) await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(0) - expect(listener2).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledTimes(3) }) await unsub2() diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index 5ae3b0608..c1a391db1 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -250,7 +250,7 @@ export class UpstashRedisPublisher> extends Pub } })() - return once(async () => { + const cleanupListener = once(() => { listeners.splice(listeners.indexOf(listener), 1) if (onError) { @@ -259,6 +259,10 @@ export class UpstashRedisPublisher> extends Pub onErrors.splice(onErrors.indexOf(onError), 1) } } + }) + + return async () => { + cleanupListener() if (listeners.length === 0) { // onErrors always has lower length than listeners this.listenersMap.delete(key) @@ -273,7 +277,7 @@ export class UpstashRedisPublisher> extends Pub await subscription.unsubscribe() } } - }) + } } protected prefixKey(key: string): string { From 5d9b55c2fe2f47cc10afca40892e01e1dc30a043 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 19 Oct 2025 09:26:44 +0700 Subject: [PATCH 28/30] handles multiple subscribers on same event with race condition --- apps/content/docs/event-iterator.md | 55 ++++++++++++++++++- apps/content/docs/helpers/publisher.md | 6 +- .../publisher/src/adapters/ioredis.test.ts | 49 ++++++++++++----- packages/publisher/src/adapters/ioredis.ts | 15 +++-- .../src/adapters/upstash-redis.test.ts | 50 ++++++++++++----- .../publisher/src/adapters/upstash-redis.ts | 14 +++-- 6 files changed, 146 insertions(+), 43 deletions(-) diff --git a/apps/content/docs/event-iterator.md b/apps/content/docs/event-iterator.md index 1e59f1032..3a01e4687 100644 --- a/apps/content/docs/event-iterator.md +++ b/apps/content/docs/event-iterator.md @@ -106,9 +106,9 @@ const example = os }) ``` -## Publisher Combination +## Publisher Helper -You can combine the event iterator with [Publisher Helper](/docs/helpers/publisher) to build real-time features like chat, notifications, or live updates. +You can combine the event iterator with the [Publisher Helper](/docs/helpers/publisher) to build real-time features like chat, notifications, or live updates with resume support. ```ts const publisher = new MemoryPublisher<{ @@ -132,3 +132,54 @@ const publish = os await publisher.publish('something-updated', { id: input.id }) }) ``` + +## Event Publisher + +Unlike the [Publisher Helper](/docs/helpers/publisher), the `EventPublisher` is more lightweight with synchronous publishing and no resume support. + +::: code-group + +```ts [Static Events] +import { EventPublisher } from '@orpc/server' + +const publisher = new EventPublisher<{ + 'something-updated': { + id: string + } +}>() + +const livePlanet = os + .handler(async function* ({ input, signal }) { + for await (const payload of publisher.subscribe('something-updated', { signal })) { // [!code highlight] + // handle payload here and yield something to client + } + }) + +const update = os + .input(z.object({ id: z.string() })) + .handler(({ input }) => { + publisher.publish('something-updated', { id: input.id }) // [!code highlight] + }) +``` + +```ts [Dynamic Events] +import { EventPublisher } from '@orpc/server' + +const publisher = new EventPublisher>() + +const onMessage = os + .input(z.object({ channel: z.string() })) + .handler(async function* ({ input, signal }) { + for await (const payload of publisher.subscribe(input.channel, { signal })) { // [!code highlight] + yield payload.message + } + }) + +const sendMessage = os + .input(z.object({ channel: z.string(), message: z.string() })) + .handler(({ input }) => { + publisher.publish(input.channel, { message: input.message }) // [!code highlight] + }) +``` + +::: diff --git a/apps/content/docs/helpers/publisher.md b/apps/content/docs/helpers/publisher.md index 982ad7881..6be17edac 100644 --- a/apps/content/docs/helpers/publisher.md +++ b/apps/content/docs/helpers/publisher.md @@ -168,7 +168,7 @@ const publisher = new MemoryPublisher<{ id: string } }>({ - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume }) ``` @@ -189,7 +189,7 @@ const publisher = new IORedisPublisher<{ }>({ commander: new Redis(), // For executing short-lived commands subscriber: new Redis(), // For subscribing to events - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume prefix: 'orpc:publisher:', // avoid conflict with other keys }) ``` @@ -213,7 +213,7 @@ const publisher = new UpstashRedisPublisher<{ id: string } }>(redis, { - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume prefix: 'orpc:publisher:', // avoid conflict with other keys }) ``` diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index bb9417589..a1c4790d1 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -265,7 +265,7 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub2() }) - it('handles multiple subscribers on same event', async () => { + it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) @@ -274,8 +274,10 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( const listener2 = vi.fn() const listener3 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - const unsub2 = await publisher.subscribe('event1', listener2) + const [unsub1, unsub2] = await Promise.all([ // race condition + publisher.subscribe('event1', listener1), + publisher.subscribe('event1', listener2), + ]) const unsub3 = await publisher.subscribe('event1', listener3) const payload = { order: 1 } @@ -287,15 +289,25 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( expect(listener3).toHaveBeenCalledTimes(1) }) - expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) - expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) - expect(listener3).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener1).toHaveBeenCalledWith({ order: 1 }) + expect(listener2).toHaveBeenCalledWith({ order: 1 }) + expect(listener3).toHaveBeenCalledWith({ order: 1 }) await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(2) + expect(listener3).toHaveBeenCalledTimes(2) + }) + + expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) + expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) + await unsub2() await unsub3() - - expect(publisher.size).toEqual(0) }) it('handles errors during resume gracefully', async () => { @@ -627,26 +639,37 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) it('support reuse same listener and unsub multiple times', async () => { - const publisher = createTestingPublisher() + const prefix = `invalid:${crypto.randomUUID()}:` + const publisher = createTestingPublisher({ prefix }) const listener = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener) - const unsub2 = await publisher.subscribe('event1', listener) + const onError = vi.fn() - await publisher.publish('event1', { order: 1 }) + const unsub1 = await publisher.subscribe('event1', listener, { onError }) + const unsub2 = await publisher.subscribe('event1', listener, { onError }) + + await Promise.all([ + publisher.publish('event1', { order: 1 }), + commander.publish(`${prefix}event1`, 'invalid message'), + ]) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(2) }) await unsub1() await unsub1() await unsub1() - await publisher.publish('event1', { order: 2 }) + await Promise.all([ + publisher.publish('event1', { order: 2 }), + commander.publish(`${prefix}event1`, 'invalid message'), + ]) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(3) + expect(onError).toHaveBeenCalledTimes(3) }) await unsub2() diff --git a/packages/publisher/src/adapters/ioredis.ts b/packages/publisher/src/adapters/ioredis.ts index f14914667..7406b727c 100644 --- a/packages/publisher/src/adapters/ioredis.ts +++ b/packages/publisher/src/adapters/ioredis.ts @@ -51,7 +51,7 @@ export class IORedisPublisher> extends Publishe protected readonly prefix: string protected readonly serializer: StandardRPCJsonSerializer protected readonly retentionSeconds: number - protected readonly listenerPromiseMap = new Map>() + protected readonly subscriptionPromiseMap = new Map>() protected readonly listenersMap = new Map void>>() protected readonly onErrorsMap = new Map void>>() protected redisListenerAndOnError: undefined | { @@ -210,19 +210,22 @@ export class IORedisPublisher> extends Publishe this.listener.on('error', redisOnError) } - // avoid race condition when multiple listeners subscribe to the same channel on first time - await this.listenerPromiseMap.get(key) - + const subscriptionPromise = this.subscriptionPromiseMap.get(key) + if (subscriptionPromise) { + // Avoid race conditions when multiple listeners subscribe to the same channel at once. + // Await only if subscriptionPromise exists, and ensure no other `await` occurs between its set and await. + await subscriptionPromise + } let listeners = this.listenersMap.get(key) if (!listeners) { try { const promise = this.listener.subscribe(key) - this.listenerPromiseMap.set(key, promise) + this.subscriptionPromiseMap.set(key, promise) await promise this.listenersMap.set(key, listeners = []) // only set after subscribe successfully } finally { - this.listenerPromiseMap.delete(key) + this.subscriptionPromiseMap.delete(key) if (this.listenersMap.size === 0) { // error happen + no listener this.listener.off('message', this.redisListenerAndOnError.listener) diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index 02ead4e52..c79d98f23 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -251,7 +251,7 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub2() }) - it('handles multiple subscribers on same event', async () => { + it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, }) @@ -260,12 +260,13 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | const listener2 = vi.fn() const listener3 = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener1) - const unsub2 = await publisher.subscribe('event1', listener2) + const [unsub1, unsub2] = await Promise.all([ // race condition + publisher.subscribe('event1', listener1), + publisher.subscribe('event1', listener2), + ]) const unsub3 = await publisher.subscribe('event1', listener3) - const payload = { order: 1 } - await publisher.publish('event1', payload) + await publisher.publish('event1', { order: 1 }) await vi.waitFor(() => { expect(listener1).toHaveBeenCalledTimes(1) @@ -273,11 +274,23 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | expect(listener3).toHaveBeenCalledTimes(1) }) - expect(listener1).toHaveBeenCalledWith(expect.objectContaining(payload)) - expect(listener2).toHaveBeenCalledWith(expect.objectContaining(payload)) - expect(listener3).toHaveBeenCalledWith(expect.objectContaining(payload)) + expect(listener1).toHaveBeenCalledWith({ order: 1 }) + expect(listener2).toHaveBeenCalledWith({ order: 1 }) + expect(listener3).toHaveBeenCalledWith({ order: 1 }) await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(2) + expect(listener3).toHaveBeenCalledTimes(2) + }) + + expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) + expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) + await unsub2() await unsub3() }) @@ -554,26 +567,37 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) it('support reuse same listener and unsub multiple times', async () => { - const publisher = createTestingPublisher() + const prefix = `invalid:${crypto.randomUUID()}:` + const publisher = createTestingPublisher({ prefix }) const listener = vi.fn() - const unsub1 = await publisher.subscribe('event1', listener) - const unsub2 = await publisher.subscribe('event1', listener) + const onError = vi.fn() - await publisher.publish('event1', { order: 1 }) + const unsub1 = await publisher.subscribe('event1', listener, { onError }) + const unsub2 = await publisher.subscribe('event1', listener, { onError }) + + await Promise.all([ + publisher.publish('event1', { order: 1 }), + redis.publish(`${prefix}event1`, 'invalid message'), + ]) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(2) }) await unsub1() await unsub1() await unsub1() - await publisher.publish('event1', { order: 2 }) + await Promise.all([ + publisher.publish('event1', { order: 2 }), + redis.publish(`${prefix}event1`, 'invalid message'), + ]) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(3) + expect(onError).toHaveBeenCalledTimes(3) }) await unsub2() diff --git a/packages/publisher/src/adapters/upstash-redis.ts b/packages/publisher/src/adapters/upstash-redis.ts index c1a391db1..0512fbf27 100644 --- a/packages/publisher/src/adapters/upstash-redis.ts +++ b/packages/publisher/src/adapters/upstash-redis.ts @@ -144,10 +144,12 @@ export class UpstashRedisPublisher> extends Pub originalListener(payload) } - // avoid race condition when multiple listeners subscribe to the same channel on first time - await this.subscriptionPromiseMap.get(key) - - // Get or create subscription for this channel + const subscriptionPromise = this.subscriptionPromiseMap.get(key) + if (subscriptionPromise) { + // Avoid race conditions when multiple listeners subscribe to the same channel at once. + // Await only if subscriptionPromise exists, and ensure no other `await` occurs between its set and await. + await subscriptionPromise + } let subscription = this.subscriptionsMap.get(key) as ReturnType | undefined if (!subscription) { const dispatchErrorForKey = (error: ThrowableError) => { @@ -250,7 +252,7 @@ export class UpstashRedisPublisher> extends Pub } })() - const cleanupListener = once(() => { + const cleanupListeners = once(() => { listeners.splice(listeners.indexOf(listener), 1) if (onError) { @@ -262,7 +264,7 @@ export class UpstashRedisPublisher> extends Pub }) return async () => { - cleanupListener() + cleanupListeners() if (listeners.length === 0) { // onErrors always has lower length than listeners this.listenersMap.delete(key) From 9cfe34379444ddc6a4b8e370541be5062507a4bc Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 19 Oct 2025 09:28:32 +0700 Subject: [PATCH 29/30] reorganize --- .../publisher/src/adapters/ioredis.test.ts | 90 +++++++++---------- .../src/adapters/upstash-redis.test.ts | 88 +++++++++--------- 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/packages/publisher/src/adapters/ioredis.test.ts b/packages/publisher/src/adapters/ioredis.test.ts index a1c4790d1..371c62bd0 100644 --- a/packages/publisher/src/adapters/ioredis.test.ts +++ b/packages/publisher/src/adapters/ioredis.test.ts @@ -265,51 +265,6 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( await unsub2() }) - it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { - const publisher = createTestingPublisher({ - resumeRetentionSeconds: 10, - }) - - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const [unsub1, unsub2] = await Promise.all([ // race condition - publisher.subscribe('event1', listener1), - publisher.subscribe('event1', listener2), - ]) - const unsub3 = await publisher.subscribe('event1', listener3) - - const payload = { order: 1 } - await publisher.publish('event1', payload) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) - }) - - expect(listener1).toHaveBeenCalledWith({ order: 1 }) - expect(listener2).toHaveBeenCalledWith({ order: 1 }) - expect(listener3).toHaveBeenCalledWith({ order: 1 }) - - await unsub1() - - await publisher.publish('event1', { order: 2 }) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(2) - expect(listener3).toHaveBeenCalledTimes(2) - }) - - expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) - expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) - - await unsub2() - await unsub3() - }) - it('handles errors during resume gracefully', async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, @@ -421,6 +376,51 @@ describe.concurrent('ioredis publisher', { skip: !REDIS_URL, timeout: 20000 }, ( }) }) + it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { + const publisher = createTestingPublisher({ + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + const listener3 = vi.fn() + + const [unsub1, unsub2] = await Promise.all([ // race condition + publisher.subscribe('event1', listener1), + publisher.subscribe('event1', listener2), + ]) + const unsub3 = await publisher.subscribe('event1', listener3) + + const payload = { order: 1 } + await publisher.publish('event1', payload) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + }) + + expect(listener1).toHaveBeenCalledWith({ order: 1 }) + expect(listener2).toHaveBeenCalledWith({ order: 1 }) + expect(listener3).toHaveBeenCalledWith({ order: 1 }) + + await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(2) + expect(listener3).toHaveBeenCalledTimes(2) + }) + + expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) + expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) + + await unsub2() + await unsub3() + }) + it('handles prefix correctly', async () => { const prefix = `custom:${crypto.randomUUID()}:` const publisher = createTestingPublisher({ diff --git a/packages/publisher/src/adapters/upstash-redis.test.ts b/packages/publisher/src/adapters/upstash-redis.test.ts index c79d98f23..9e8965092 100644 --- a/packages/publisher/src/adapters/upstash-redis.test.ts +++ b/packages/publisher/src/adapters/upstash-redis.test.ts @@ -251,50 +251,6 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | await unsub2() }) - it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { - const publisher = createTestingPublisher({ - resumeRetentionSeconds: 10, - }) - - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const [unsub1, unsub2] = await Promise.all([ // race condition - publisher.subscribe('event1', listener1), - publisher.subscribe('event1', listener2), - ]) - const unsub3 = await publisher.subscribe('event1', listener3) - - await publisher.publish('event1', { order: 1 }) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) - }) - - expect(listener1).toHaveBeenCalledWith({ order: 1 }) - expect(listener2).toHaveBeenCalledWith({ order: 1 }) - expect(listener3).toHaveBeenCalledWith({ order: 1 }) - - await unsub1() - - await publisher.publish('event1', { order: 2 }) - - await vi.waitFor(() => { - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(2) - expect(listener3).toHaveBeenCalledTimes(2) - }) - - expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) - expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) - - await unsub2() - await unsub3() - }) - it('handles errors during resume gracefully', async () => { const publisher = createTestingPublisher({ resumeRetentionSeconds: 10, @@ -405,6 +361,50 @@ describe.concurrent('upstash redis publisher', { skip: !UPSTASH_REDIS_REST_URL | }) }) + it('handles multiple subscribers on same event with race condition', { repeats: 3 }, async () => { + const publisher = createTestingPublisher({ + resumeRetentionSeconds: 10, + }) + + const listener1 = vi.fn() + const listener2 = vi.fn() + const listener3 = vi.fn() + + const [unsub1, unsub2] = await Promise.all([ // race condition + publisher.subscribe('event1', listener1), + publisher.subscribe('event1', listener2), + ]) + const unsub3 = await publisher.subscribe('event1', listener3) + + await publisher.publish('event1', { order: 1 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + expect(listener3).toHaveBeenCalledTimes(1) + }) + + expect(listener1).toHaveBeenCalledWith({ order: 1 }) + expect(listener2).toHaveBeenCalledWith({ order: 1 }) + expect(listener3).toHaveBeenCalledWith({ order: 1 }) + + await unsub1() + + await publisher.publish('event1', { order: 2 }) + + await vi.waitFor(() => { + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(2) + expect(listener3).toHaveBeenCalledTimes(2) + }) + + expect(listener2).toHaveBeenNthCalledWith(2, { order: 2 }) + expect(listener3).toHaveBeenNthCalledWith(2, { order: 2 }) + + await unsub2() + await unsub3() + }) + it('handles prefix correctly', async () => { const prefix = `custom:${crypto.randomUUID()}:` const publisher = createTestingPublisher({ From 70f2209ab9e882c932e8460cd2aba7ffa75ff3ea Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 19 Oct 2025 14:50:02 +0700 Subject: [PATCH 30/30] bump version --- packages/publisher/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/publisher/package.json b/packages/publisher/package.json index bd2650d2c..5f00b78d5 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/experimental-publisher", "type": "module", - "version": "0.0.0", + "version": "0.0.1", "license": "MIT", "homepage": "https://orpc.unnoq.com", "repository": {