From a06b7d57432fe7586885d2d29b3e682f32a54b18 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Thu, 19 Mar 2026 11:15:50 +0100 Subject: [PATCH 1/5] chore: initial review --- .github/workflows/ci-module.yml | 6 +- package.json | 24 +- src/event-buffer.ts | 1 + src/index.ts | 14 +- src/replayer.ts | 10 + src/session.ts | 31 +- src/sse.ts | 8 +- src/subscription.ts | 6 +- {src => test}/event-buffer.test.ts | 4 +- test/index.test.ts | 12 + {src => test}/replayer.test.ts | 75 ++- test/session.test.ts | 203 ++++++ {src => test}/sse.test.ts | 1010 ++++++++++++++++------------ test/subscription.test.ts | 109 +++ tsconfig.build.json | 12 - tsconfig.json | 9 +- tsdown.config.ts | 6 +- vitest.config.ts | 24 +- 18 files changed, 1046 insertions(+), 518 deletions(-) rename {src => test}/event-buffer.test.ts (98%) create mode 100644 test/index.test.ts rename {src => test}/replayer.test.ts (70%) create mode 100644 test/session.test.ts rename {src => test}/sse.test.ts (75%) create mode 100644 test/subscription.test.ts delete mode 100644 tsconfig.build.json diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml index c2d5fef..4dea4d1 100644 --- a/.github/workflows/ci-module.yml +++ b/.github/workflows/ci-module.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22] + node-version: [22, 24, latest] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/package.json b/package.json index f69ecb9..5f46789 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,9 @@ "name": "@hapi/sse", "version": "0.0.1", "description": "", - "main": "./dist/index.js", - "types": "./dist/index-BZ6I-Suu.d.ts", - "module": "./dist/index.js", + "types": "./dist/index.d.mts", "exports": { - ".": "./dist/index.js", + ".": "./dist/index.mjs", "./package.json": "./package.json" }, "type": "module", @@ -16,8 +14,8 @@ "LICENSE.md" ], "scripts": { - "test": "vitest run --coverage", - "lint": "tsc --noEmit && eslint .", + "test": "vitest run", + "lint": "eslint .", "build": "tsdown" }, "keywords": [], @@ -29,12 +27,12 @@ }, "devDependencies": { "@hapi/boom": "^10.0.1", - "@hapi/hapi": "^21.4.6", - "@types/node": "^22", - "@vitest/coverage-v8": "^3.2.4", - "tsdown": "^0.13.3", - "typescript": "^5.8.2", - "typescript-eslint": "^8.11.0", - "vitest": "^3.2.4" + "@hapi/hapi": "^21.4.7", + "@types/node": "^22.19.15", + "@vitest/coverage-v8": "^4.1.0", + "tsdown": "^0.21.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1", + "vitest": "^4.1.0" } } diff --git a/src/event-buffer.ts b/src/event-buffer.ts index 34ee9a2..8028b9f 100644 --- a/src/event-buffer.ts +++ b/src/event-buffer.ts @@ -1,4 +1,5 @@ export class EventBuffer { + /** @internal */ #buffer = ''; data(value: unknown): this { diff --git a/src/index.ts b/src/index.ts index f531077..37d2c05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -export { EventBuffer } from './event-buffer.ts'; -export { Session } from './session.ts'; -export type { BackpressureOptions } from './session.ts'; -export { SsePlugin } from './sse.ts'; +export { EventBuffer } from './event-buffer.js'; +export { Session } from './session.js'; +export type { BackpressureOptions } from './session.js'; +export { SsePlugin } from './sse.js'; export type { SsePluginOptions, SseApi, @@ -11,6 +11,6 @@ export type { SubscriptionConfig, SubscriptionInfo, FilterOptions, -} from './sse.ts'; -export type { Replayer, ReplayEntry } from './replayer.ts'; -export { FiniteReplayer, ValidReplayer } from './replayer.ts'; +} from './sse.js'; +export type { Replayer, ReplayEntry } from './replayer.js'; +export { FiniteReplayer, ValidReplayer } from './replayer.js'; diff --git a/src/replayer.ts b/src/replayer.ts index 0da6d88..00ebd78 100644 --- a/src/replayer.ts +++ b/src/replayer.ts @@ -11,9 +11,13 @@ export interface Replayer { } export class FiniteReplayer implements Replayer { + /** @internal */ readonly #size: number; + /** @internal */ readonly #autoId: boolean; + /** @internal */ readonly #buffer: ReplayEntry[] = []; + /** @internal */ #counter = 0; constructor(opts: { size: number; autoId?: boolean }) { @@ -51,10 +55,15 @@ interface TimedEntry extends ReplayEntry { } export class ValidReplayer implements Replayer { + /** @internal */ readonly #ttl: number; + /** @internal */ readonly #autoId: boolean; + /** @internal */ readonly #buffer: TimedEntry[] = []; + /** @internal */ #timer: ReturnType | null = null; + /** @internal */ #counter = 0; constructor(opts: { ttl: number; autoId?: boolean }) { @@ -94,6 +103,7 @@ export class ValidReplayer implements Replayer { } } + /** @internal */ #gc(): void { const now = Date.now(); diff --git a/src/session.ts b/src/session.ts index 85affcd..7caa16e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,7 +1,7 @@ import type { Request } from '@hapi/hapi'; import type { ServerResponse } from 'node:http'; -import { EventBuffer } from './event-buffer.ts'; +import { EventBuffer } from './event-buffer.js'; export interface BackpressureOptions { maxBytes: number; @@ -20,14 +20,23 @@ export class Session { readonly request: Request; readonly lastEventId: string; readonly connectedAt: number; + /** @internal */ readonly #res: ServerResponse; + /** @internal */ readonly #buffer: EventBuffer; + /** @internal */ readonly #retry: number | null; + /** @internal */ readonly #keepAlive: { interval: number } | false; + /** @internal */ readonly #headers: Record; + /** @internal */ readonly #backpressure: BackpressureOptions | undefined; + /** @internal */ readonly #metadata = new Map(); + /** @internal */ #keepAliveTimer: ReturnType | null = null; + /** @internal */ #closed = false; constructor(options: SessionOptions) { @@ -89,16 +98,19 @@ export class Session { this.#flush(); if (this.#keepAlive) { - this.#keepAliveTimer = setInterval(() => { - if (this.#closed) { - return; - } + this.#keepAliveTimer = setInterval(() => this.#onKeepAlive(), this.#keepAlive.interval); + } + } - this.#buffer.comment(); - this.#buffer.dispatch(); - this.#flush(); - }, this.#keepAlive.interval); + /** @internal */ + #onKeepAlive(): void { + if (this.#closed) { + return; } + + this.#buffer.comment(); + this.#buffer.dispatch(); + this.#flush(); } push(data: unknown, event?: string, id?: string): boolean { @@ -153,6 +165,7 @@ export class Session { this.#res.end(); } + /** @internal */ #flush(): boolean { const data = this.#buffer.read(); diff --git a/src/sse.ts b/src/sse.ts index 5387577..85230a4 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -1,12 +1,12 @@ import type { NamedPlugin, Request, ResponseToolkit, RouteOptions, Lifecycle } from '@hapi/hapi'; import { createRequire } from 'node:module'; -import { Session } from './session.ts'; -import type { BackpressureOptions } from './session.ts'; -import { SubscriptionRegistry } from './subscription.ts'; +import { Session } from './session.js'; +import type { BackpressureOptions } from './session.js'; +import { SubscriptionRegistry } from './subscription.js'; const { version } = createRequire(import.meta.url)('../package.json') as { version: string }; -import type { SubscriptionConfig, SubscriptionInfo, FilterOptions } from './subscription.ts'; +import type { SubscriptionConfig, SubscriptionInfo, FilterOptions } from './subscription.js'; export type { SubscriptionConfig, SubscriptionInfo, FilterOptions }; diff --git a/src/subscription.ts b/src/subscription.ts index 2f5ac75..24da043 100644 --- a/src/subscription.ts +++ b/src/subscription.ts @@ -1,7 +1,7 @@ import type { RouteOptions } from '@hapi/hapi'; -import { Session } from './session.ts'; -import type { Replayer } from './replayer.ts'; +import { Session } from './session.js'; +import type { Replayer } from './replayer.js'; export interface FilterOptions { credentials: unknown; @@ -44,7 +44,9 @@ interface SessionInfo { } export class SubscriptionRegistry { + /** @internal */ readonly #subscriptions = new Map(); + /** @internal */ readonly #sessionInfo = new Map(); register(pattern: string, config: SubscriptionConfig): void { diff --git a/src/event-buffer.test.ts b/test/event-buffer.test.ts similarity index 98% rename from src/event-buffer.test.ts rename to test/event-buffer.test.ts index 1c30667..eba4aa5 100644 --- a/src/event-buffer.test.ts +++ b/test/event-buffer.test.ts @@ -1,8 +1,8 @@ import { expect, describe, it } from 'vitest'; -import { EventBuffer } from './event-buffer.ts'; +import { EventBuffer } from '../src/event-buffer.js'; -describe('EventBuffer', () => { +describe.concurrent('EventBuffer', () => { it('serializes string data', () => { const buf = new EventBuffer(); diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..09f6556 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import * as SSE from '../src/index.js'; + +describe.concurrent('SSE', () => { + it('index exports', () => { + expect(SSE.EventBuffer).toBeDefined(); + expect(SSE.Session).toBeDefined(); + expect(SSE.SsePlugin).toBeDefined(); + expect(SSE.FiniteReplayer).toBeDefined(); + expect(SSE.ValidReplayer).toBeDefined(); + }); +}); diff --git a/src/replayer.test.ts b/test/replayer.test.ts similarity index 70% rename from src/replayer.test.ts rename to test/replayer.test.ts index 47fd9c1..0a83e84 100644 --- a/src/replayer.test.ts +++ b/test/replayer.test.ts @@ -1,8 +1,9 @@ -import { expect, describe, it, afterEach } from 'vitest'; +import * as timers from 'node:timers/promises'; +import { expect, describe, it } from 'vitest'; -import { FiniteReplayer, ValidReplayer } from './replayer.ts'; +import { FiniteReplayer, ValidReplayer } from '../src/replayer.js'; -describe('FiniteReplayer', () => { +describe.concurrent('FiniteReplayer', () => { it('records and replays entries after lastEventId', () => { const replayer = new FiniteReplayer({ size: 10 }); @@ -75,6 +76,16 @@ describe('FiniteReplayer', () => { expect(entries[1].id).toBe('2'); }); + it('id is not generated when autoId is false and id is missing', () => { + const replayer = new FiniteReplayer({ size: 10, autoId: false }); + + replayer.record({ data: 'a' } as any); + + const entries = replayer.replay('0'); + + expect(entries[0].id).toBeUndefined(); + }); + it('autoId does not overwrite explicit ids', () => { const replayer = new FiniteReplayer({ size: 10, autoId: true }); @@ -98,17 +109,10 @@ describe('FiniteReplayer', () => { }); }); -describe('ValidReplayer', () => { - let replayer: ValidReplayer; - - afterEach(() => { - if (replayer) { - replayer.stop(); - } - }); - - it('records and replays entries after lastEventId', () => { - replayer = new ValidReplayer({ ttl: 60_000 }); +describe.concurrent('ValidReplayer', () => { + it('records and replays entries after lastEventId', ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 60_000 }); + onTestFinished(() => replayer.stop()); replayer.record({ data: 'a', id: '1' }); replayer.record({ data: 'b', id: '2' }); @@ -121,8 +125,9 @@ describe('ValidReplayer', () => { expect(entries[1]).toEqual({ data: 'c', event: undefined, id: '3' }); }); - it('returns all entries when lastEventId is not found', () => { - replayer = new ValidReplayer({ ttl: 60_000 }); + it('returns all entries when lastEventId is not found', ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 60_000 }); + onTestFinished(() => replayer.stop()); replayer.record({ data: 'a', id: '1' }); replayer.record({ data: 'b', id: '2' }); @@ -132,20 +137,22 @@ describe('ValidReplayer', () => { expect(entries.length).toBe(2); }); - it('expires entries after ttl', async () => { - replayer = new ValidReplayer({ ttl: 50 }); + it('expires entries after ttl', async ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 50 }); + onTestFinished(() => replayer.stop()); replayer.record({ data: 'a', id: '1' }); - await new Promise((r) => setTimeout(r, 100)); + await timers.setTimeout(100); const entries = replayer.replay('0'); expect(entries.length).toBe(0); }); - it('preserves non-expired entries', async () => { - replayer = new ValidReplayer({ ttl: 5000 }); + it('preserves non-expired entries', async ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 5000 }); + onTestFinished(() => replayer.stop()); replayer.record({ data: 'a', id: '1' }); @@ -154,8 +161,9 @@ describe('ValidReplayer', () => { expect(entries.length).toBe(1); }); - it('autoId generates sequential ids when entry id is empty', () => { - replayer = new ValidReplayer({ ttl: 60_000, autoId: true }); + it('autoId generates sequential ids when entry id is empty', ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 60_000, autoId: true }); + onTestFinished(() => replayer.stop()); replayer.record({ data: 'a', id: '' }); replayer.record({ data: 'b', id: '' }); @@ -166,20 +174,21 @@ describe('ValidReplayer', () => { expect(entries[1].id).toBe('2'); }); - it('stop() clears the timer', () => { - replayer = new ValidReplayer({ ttl: 1000 }); + it('id is not generated when autoId is false and id is missing', ({ onTestFinished }) => { + const replayer = new ValidReplayer({ ttl: 60_000, autoId: false }); + onTestFinished(() => replayer.stop()); - replayer.stop(); - replayer.stop(); - }); + replayer.record({ data: 'a' } as any); - it('replay strips internal expiresAt field', () => { - replayer = new ValidReplayer({ ttl: 60_000 }); + const entries = replayer.replay('0'); - replayer.record({ data: 'a', id: '1' }); + expect(entries[0].id).toBeUndefined(); + }); - const entries = replayer.replay('0'); + it('stop() clears the timer', () => { + const replayer = new ValidReplayer({ ttl: 1000 }); - expect(Object.keys(entries[0])).toEqual(['data', 'event', 'id']); + replayer.stop(); + replayer.stop(); }); }); diff --git a/test/session.test.ts b/test/session.test.ts new file mode 100644 index 0000000..1c8e79a --- /dev/null +++ b/test/session.test.ts @@ -0,0 +1,203 @@ +import * as timers from 'node:timers/promises'; +import { expect, describe, it } from 'vitest'; +import { Session } from '../src/session.js'; + +describe.concurrent('Session', () => { + it('does not initialize or start keep-alive when the session is already closed', async () => { + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: { writeHead: () => {}, end: () => {} }, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: 1000, + keepAlive: { interval: 1 }, // very short interval + headers: {}, + }); + + session.close(); + + // Should return early + session.initialize(); + await timers.setTimeout(20); + }); + + it('cleans up keep-alive timer when the session is closed', async () => { + const mockRes = { + writeHead: () => {}, + write: () => { + return true; + }, + end: () => {}, + } as any; + + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: mockRes, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: 1000, + keepAlive: { interval: 5 }, + headers: {}, + }); + + session.initialize(); + // Force closed + session.close(); + }); + + it('closes the session when a flush error occurs during a push', async () => { + const mockRes = { + writeHead: () => {}, + write: () => { + throw new Error('write error'); + }, + end: () => {}, + } as any; + + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: mockRes, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: null, + keepAlive: false, + headers: {}, + }); + + session.initialize(); + // push will trigger flush which will throw + const result = session.push({ data: 1 }); + + expect(result).toBe(false); + expect(session.isOpen).toBe(false); + }); + + it('uses the first event ID when the header is an array of IDs', async () => { + const mockRequest = { + headers: { 'last-event-id': ['id1', 'id2'] }, + raw: { + req: { socket: {} }, + res: { writeHead: () => {}, end: () => {} }, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: 1000, + keepAlive: false, + headers: {}, + }); + + expect(session.lastEventId).toBe('id1'); + }); + + it('does not exceed backpressure maxBytes when within limit', async () => { + const mockRes = { + writeHead: () => {}, + write: () => true, + end: () => {}, + writableLength: 0, + } as any; + + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: mockRes, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: null, + keepAlive: false, + headers: {}, + backpressure: { maxBytes: 1000, strategy: 'close' }, + }); + + session.initialize(); + const result = session.push('small data'); + expect(result).toBe(true); + expect(session.isOpen).toBe(true); + }); + it('can be closed when not initialized', async () => { + const mockRes = { + end: () => {}, + } as any; + + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: mockRes, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: null, + keepAlive: false, + headers: {}, + }); + + session.close(); + expect(session.isOpen).toBe(false); + }); + + it('does not write to the response when flushing an empty buffer', async () => { + const mockRes = { + writeHead: () => {}, + write: () => true, + end: () => {}, + } as any; + + const mockRequest = { + headers: {}, + raw: { + req: { socket: {} }, + res: mockRes, + }, + } as any; + + const session = new Session({ + request: mockRequest, + retry: null, + keepAlive: { interval: 5 }, + headers: {}, + }); + + // To reach the 'if (data)' false branch in #flush(), we need data to be falsy. + // Since all public methods add data before flushing, we use a mock on EventBuffer.read. + const { EventBuffer } = await import('../src/event-buffer.js'); + const readSpy = (await import('vitest')).vi.spyOn(EventBuffer.prototype, 'read') + .mockReturnValueOnce('ok') // for initialize + .mockReturnValueOnce(''); // for keep-alive + + session.initialize(); + + // Wait for keep-alive to trigger #onKeepAlive which calls #flush + await timers.setTimeout(15); + + session.close(); + + expect(readSpy).toHaveBeenCalled(); + // The second call (keep-alive) should have returned '' and thus not called write + // But initialize called write. + readSpy.mockRestore(); + }); +}); diff --git a/src/sse.test.ts b/test/sse.test.ts similarity index 75% rename from src/sse.test.ts rename to test/sse.test.ts index 896b59f..f2bd92f 100644 --- a/src/sse.test.ts +++ b/test/sse.test.ts @@ -1,13 +1,12 @@ -import { expect, describe, it, afterEach } from 'vitest'; +import * as timers from 'node:timers/promises'; +import { expect, describe, it } from 'vitest'; import http from 'node:http'; import Hapi from '@hapi/hapi'; import Boom from '@hapi/boom'; -import { SsePlugin } from './sse.ts'; -import { FiniteReplayer } from './replayer.ts'; - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); +import { SsePlugin } from '../src/sse.js'; +import { FiniteReplayer, ValidReplayer } from '../src/replayer.js'; interface SseOptions { maxEvents?: number; @@ -78,17 +77,10 @@ const collectSse = (url: string, opts: SseOptions = {}): Promise => { }); }; -describe('SSE Plugin', () => { - let server: Hapi.Server; - - afterEach(async () => { - if (server) { - await server.stop({ timeout: 500 }); - } - }); - - it('registers without error', async () => { - server = Hapi.server(); +describe.concurrent('SSE Plugin', () => { + it('registers without error', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); expect(server.sse).toBeDefined(); @@ -98,8 +90,9 @@ describe('SSE Plugin', () => { expect(typeof server.sse.eachSession).toBe('function'); }); - it('subscription creates a GET route', async () => { - server = Hapi.server(); + it('subscription creates a GET route', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); server.sse.subscription('/events'); @@ -111,13 +104,14 @@ describe('SSE Plugin', () => { expect(route!.method).toBe('get'); }); - it('returns correct SSE headers', async () => { - server = Hapi.server({ port: 0 }); + it('returns correct SSE headers', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const headers = await new Promise((resolve, _reject) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -134,8 +128,9 @@ describe('SSE Plugin', () => { expect(headers['x-accel-buffering']).toBe('no'); }); - it('onSubscribe throwing Boom returns error without SSE headers', async () => { - server = Hapi.server({ port: 0 }); + it('onSubscribe throwing Boom returns error without SSE headers', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); server.sse.subscription('/events', { onSubscribe: () => { @@ -146,7 +141,7 @@ describe('SSE Plugin', () => { await server.start(); const result = await collectSse( - `http://localhost:${(server.listener.address() as { port: number }).port}/events`, + `http://localhost:${server.info.port}/events`, { timeout: 500 }, ); @@ -154,17 +149,18 @@ describe('SSE Plugin', () => { expect(result.headers['content-type']).toContain('application/json'); }); - it('publish delivers to subscribers', async () => { - server = Hapi.server({ port: 0 }); + it('publish delivers to subscribers', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'hello' }, { event: 'test' }); @@ -175,8 +171,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"hello"}'); }); - it('filter excludes non-matching sessions', async () => { - server = Hapi.server({ port: 0 }); + it('filter excludes non-matching sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: (_path, _message, options) => { @@ -186,11 +183,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { timeout: 300 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'blocked' }, { internal: { allow: false } }); @@ -199,8 +196,9 @@ describe('SSE Plugin', () => { expect(events.length).toBe(0); }); - it('filter override sends modified data', async () => { - server = Hapi.server({ port: 0 }); + it('filter override sends modified data', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => ({ override: { redacted: true } }), @@ -208,11 +206,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { secret: 'data' }); @@ -221,11 +219,12 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"redacted":true}'); }); - it('onReconnect fires when Last-Event-ID present', async () => { + it('onReconnect fires when Last-Event-ID present', async ({ onTestFinished }) => { let reconnectCalled = false; let receivedLastId = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onReconnect: (session) => { @@ -237,7 +236,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { events } = await collectSse(`http://localhost:${port}/events`, { maxEvents: 1, @@ -249,10 +248,11 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"replayed":true}'); }); - it('onUnsubscribe fires on disconnect', async () => { + it('onUnsubscribe fires on disconnect', async ({ onTestFinished }) => { let unsubscribeCalled = false; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onUnsubscribe: () => { @@ -262,7 +262,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -276,13 +276,14 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(300); + await timers.setTimeout(300); expect(unsubscribeCalled).toBe(true); }); - it('custom handler mode works', async () => { - server = Hapi.server({ port: 0 }); + it('custom handler mode works', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -301,7 +302,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { events } = await collectSse(`http://localhost:${port}/stream`, { maxEvents: 2 }); @@ -310,20 +311,21 @@ describe('SSE Plugin', () => { expect(events[1]).toContain('data: {"chunk":2}'); }); - it('broadcast reaches all sessions', async () => { - server = Hapi.server({ port: 0 }); + it('broadcast reaches all sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/a'); server.sse.subscription('/b'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/a`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/b`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.broadcast({ system: true }, { event: 'announce' }); @@ -333,18 +335,19 @@ describe('SSE Plugin', () => { expect(r2.events[0]).toContain('data: {"system":true}'); }); - it('eachSession iterates correctly', async () => { - server = Hapi.server({ port: 0 }); + it('eachSession iterates correctly', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); let count = 0; @@ -363,18 +366,19 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"direct":true}'); }); - it('graceful shutdown closes all sessions', async () => { - server = Hapi.server({ port: 0 }); + it('graceful shutdown closes all sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { timeout: 2000 }); - await wait(50); + await timers.setTimeout(50); await server.stop(); @@ -383,18 +387,19 @@ describe('SSE Plugin', () => { expect(events).toBeDefined(); }); - it('multiple concurrent subscribers receive published events', async () => { - server = Hapi.server({ port: 0 }); + it('multiple concurrent subscribers receive published events', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'all' }); @@ -404,8 +409,9 @@ describe('SSE Plugin', () => { expect(r2.events[0]).toContain('data: {"msg":"all"}'); }); - it('publish to unmatched path is a silent no-op', async () => { - server = Hapi.server({ port: 0 }); + it('publish to unmatched path is a silent no-op', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); @@ -413,10 +419,11 @@ describe('SSE Plugin', () => { await server.sse.publish('/nonexistent', { msg: 'lost' }); }); - it('filter receives correct params for parameterized path', async () => { + it('filter receives correct params for parameterized path', async ({ onTestFinished }) => { let receivedParams: Record = {}; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events/{channel}', { filter: (_path, _message, options) => { @@ -428,11 +435,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events/news`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events/news', { msg: 'hi' }); @@ -441,20 +448,21 @@ describe('SSE Plugin', () => { expect(receivedParams.channel).toBe('news'); }); - it('eachSession without subscription filter iterates all sessions', async () => { - server = Hapi.server({ port: 0 }); + it('eachSession without subscription filter iterates all sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/a'); server.sse.subscription('/b'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/a`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/b`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); let count = 0; @@ -471,10 +479,11 @@ describe('SSE Plugin', () => { expect(r2.events[0]).toContain('data: {"ping":true}'); }); - it('custom handler receives lastEventId', async () => { + it('custom handler receives lastEventId', async ({ onTestFinished }) => { let receivedId = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -493,7 +502,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { maxEvents: 1, @@ -503,10 +512,11 @@ describe('SSE Plugin', () => { expect(receivedId).toBe('99'); }); - it('custom handler disconnect fires cleanup', async () => { + it('custom handler disconnect fires cleanup', async ({ onTestFinished }) => { let closed = false; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -528,7 +538,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/stream`, (res) => { @@ -542,18 +552,19 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(300); + await timers.setTimeout(300); expect(closed).toBe(true); }); - it('retry field is sent on connection when configured', async () => { - server = Hapi.server({ port: 0 }); + it('retry field is sent on connection when configured', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: 5000, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -574,8 +585,9 @@ describe('SSE Plugin', () => { expect(raw).toContain('retry: 5000'); }); - it('session comment sends through the wire', async () => { - server = Hapi.server({ port: 0 }); + it('session comment sends through the wire', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -593,7 +605,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -612,8 +624,9 @@ describe('SSE Plugin', () => { expect(raw).toContain(': ping'); }); - it('double close does not throw', async () => { - server = Hapi.server({ port: 0 }); + it('double close does not throw', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -631,15 +644,16 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { status } = await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); expect(status).toBe(200); }); - it('push after close is silently ignored', async () => { - server = Hapi.server({ port: 0 }); + it('push after close is silently ignored', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -658,7 +672,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { events } = await collectSse(`http://localhost:${port}/stream`, { maxEvents: 2, timeout: 500 }); @@ -666,8 +680,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"before":true}'); }); - it('plugin custom headers propagate to response', async () => { - server = Hapi.server({ port: 0 }); + it('plugin custom headers propagate to response', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false, headers: { 'X-Custom': 'test' } }, @@ -675,7 +690,7 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const headers = await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -690,17 +705,18 @@ describe('SSE Plugin', () => { expect(headers['x-custom']).toBe('test'); }); - it('publish with id passes id to session', async () => { - server = Hapi.server({ port: 0 }); + it('publish with id passes id to session', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'hi' }, { event: 'test', id: 'evt-42' }); @@ -710,13 +726,14 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('event: test'); }); - it('subscription-level retry override is respected', async () => { - server = Hapi.server({ port: 0 }); + it('subscription-level retry override is respected', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: 1000, keepAlive: false } }); server.sse.subscription('/events', { retry: 9999 }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -737,17 +754,18 @@ describe('SSE Plugin', () => { expect(raw).toContain('retry: 9999'); }); - it('removeSession is idempotent', async () => { - server = Hapi.server({ port: 0 }); + it('removeSession is idempotent', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); - await wait(50); + await timers.setTimeout(50); let count = 0; @@ -759,7 +777,7 @@ describe('SSE Plugin', () => { await promise; - await wait(100); + await timers.setTimeout(100); count = 0; await server.sse.eachSession(() => { @@ -769,13 +787,14 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('publish to disconnected session is safely skipped', async () => { - server = Hapi.server({ port: 0 }); + it('publish to disconnected session is safely skipped', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -789,13 +808,14 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(100); + await timers.setTimeout(100); await server.sse.publish('/events', { msg: 'to ghost' }); }); - it('custom handler stream() error closes session gracefully', async () => { - server = Hapi.server({ port: 0 }); + it('custom handler stream() error closes session gracefully', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -814,7 +834,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { events } = await collectSse(`http://localhost:${port}/stream`, { maxEvents: 1, timeout: 500 }); @@ -822,10 +842,11 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"before":true}'); }); - it('filter error does not block delivery to other sessions', async () => { + it('filter error does not block delivery to other sessions', async ({ onTestFinished }) => { let callCount = 0; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => { @@ -841,12 +862,12 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1, timeout: 500 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1, timeout: 500 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'hi' }); @@ -857,8 +878,9 @@ describe('SSE Plugin', () => { expect(received).toContain(1); }); - it('onUnsubscribe throwing does not break cleanup', async () => { - server = Hapi.server({ port: 0 }); + it('onUnsubscribe throwing does not break cleanup', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onUnsubscribe: () => { @@ -868,7 +890,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -882,7 +904,7 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(300); + await timers.setTimeout(300); let count = 0; @@ -893,17 +915,18 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('multiple publishes deliver in order', async () => { - server = Hapi.server({ port: 0 }); + it('multiple publishes deliver in order', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 3 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { event: 'msg' }); await server.sse.publish('/events', { n: 2 }, { event: 'msg' }); @@ -917,17 +940,18 @@ describe('SSE Plugin', () => { expect(events[2]).toContain('data: {"n":3}'); }); - it('publish with multi-line string data delivers correctly', async () => { - server = Hapi.server({ port: 0 }); + it('publish with multi-line string data delivers correctly', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', 'line1\nline2'); @@ -937,17 +961,18 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: line2'); }); - it('publish with empty id sends id field to reset client lastEventId', async () => { - server = Hapi.server({ port: 0 }); + it('publish with empty id sends id field to reset client lastEventId', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'reset' }, { event: 'test', id: '' }); @@ -957,8 +982,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"reset"}'); }); - it('parameterized subscriptions extract params', async () => { - server = Hapi.server({ port: 0 }); + it('parameterized subscriptions extract params', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); let capturedParams: Record = {}; @@ -971,11 +997,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events/general`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(capturedParams.channel).toBe('general'); @@ -988,11 +1014,12 @@ describe('SSE Plugin', () => { // Feature 1: session.isOpen getter - it('session.isOpen returns true when open, false after close', async () => { + it('session.isOpen returns true when open, false after close', async ({ onTestFinished }) => { let openBefore = false; let openAfter = true; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -1011,7 +1038,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); @@ -1021,13 +1048,14 @@ describe('SSE Plugin', () => { // Feature 2: Session metadata - it('session metadata set/get/has/delete', async () => { + it('session metadata set/get/has/delete', async ({ onTestFinished }) => { let hasKey = false; let getValue: unknown; let deleted = false; let hasAfterDelete = true; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -1049,7 +1077,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); @@ -1059,10 +1087,11 @@ describe('SSE Plugin', () => { expect(hasAfterDelete).toBe(false); }); - it('session metadata persists across operations in subscription mode', async () => { + it('session metadata persists across operations in subscription mode', async ({ onTestFinished }) => { let metaValue: unknown; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onSubscribe: (session) => { @@ -1072,11 +1101,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.eachSession((session) => { metaValue = session.get('role'); @@ -1090,18 +1119,19 @@ describe('SSE Plugin', () => { // Feature 3: Publish returns delivery count - it('publish returns delivery count', async () => { - server = Hapi.server({ port: 0 }); + it('publish returns delivery count', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.publish('/events', { msg: 'hi' }); @@ -1110,8 +1140,9 @@ describe('SSE Plugin', () => { expect(count).toBe(2); }); - it('publish returns 0 for unmatched path', async () => { - server = Hapi.server({ port: 0 }); + it('publish returns 0 for unmatched path', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); @@ -1121,8 +1152,9 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('publish does not count filtered-out sessions', async () => { - server = Hapi.server({ port: 0 }); + it('publish does not count filtered-out sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => false, @@ -1130,11 +1162,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { timeout: 300 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.publish('/events', { msg: 'blocked' }); @@ -1145,8 +1177,9 @@ describe('SSE Plugin', () => { // Feature 4: server.sse.subscriptions() - it('subscriptions() returns registered subscription info', async () => { - server = Hapi.server({ port: 0 }); + it('subscriptions() returns registered subscription info', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); server.sse.subscription('/chat/{room}'); @@ -1158,18 +1191,19 @@ describe('SSE Plugin', () => { expect(subs[1]).toEqual({ pattern: '/chat/{room}', activeSessions: 0 }); }); - it('subscriptions() reflects active session count', async () => { - server = Hapi.server({ port: 0 }); + it('subscriptions() reflects active session count', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const subs = server.sse.subscriptions(); @@ -1182,18 +1216,19 @@ describe('SSE Plugin', () => { // Feature 5: Path-literal publish - it('literal matchMode only delivers to exact path match', async () => { - server = Hapi.server({ port: 0 }); + it('literal matchMode only delivers to exact path match', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events/{channel}'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const pNews = collectSse(`http://localhost:${port}/events/news`, { maxEvents: 1, timeout: 500 }); const pSport = collectSse(`http://localhost:${port}/events/sport`, { timeout: 500 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events/news', { msg: 'breaking' }, { matchMode: 'literal' }); @@ -1204,18 +1239,19 @@ describe('SSE Plugin', () => { expect(rSport.events.length).toBe(0); }); - it('pattern matchMode (default) delivers to all matching sessions', async () => { - server = Hapi.server({ port: 0 }); + it('pattern matchMode (default) delivers to all matching sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events/{channel}'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const pNews = collectSse(`http://localhost:${port}/events/news`, { maxEvents: 1 }); const pSport = collectSse(`http://localhost:${port}/events/sport`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events/news', { msg: 'all' }); @@ -1232,20 +1268,21 @@ describe('SSE Plugin', () => { // Feature 6: Event replay integration - it('replayer replays events on reconnect via Last-Event-ID', async () => { + it('replayer replays events on reconnect via Last-Event-ID', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100 }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Connect first client to register the subscription route const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 3 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { event: 'msg', id: '1' }); await server.sse.publish('/events', { n: 2 }, { event: 'msg', id: '2' }); @@ -1264,19 +1301,20 @@ describe('SSE Plugin', () => { expect(events[1]).toContain('data: {"n":3}'); }); - it('replayer replays all when lastEventId not found', async () => { + it('replayer replays all when lastEventId not found', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100 }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 2 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { event: 'msg', id: '1' }); await server.sse.publish('/events', { n: 2 }, { event: 'msg', id: '2' }); @@ -1291,11 +1329,12 @@ describe('SSE Plugin', () => { expect(events.length).toBe(2); }); - it('replay fires before onReconnect', async () => { + it('replay fires before onReconnect', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100 }); const order: string[] = []; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer, @@ -1309,12 +1348,12 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Publish first to populate the replayer const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { id: '1' }); @@ -1334,11 +1373,12 @@ describe('SSE Plugin', () => { // Feature 8: Metrics hooks - it('onSession metric fires on new subscription', async () => { + it('onSession metric fires on new subscription', async ({ onTestFinished }) => { let metricPath = ''; let metricSession: unknown; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1356,11 +1396,11 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(metricSession).toBeDefined(); expect(metricPath).toBe('/events'); @@ -1370,10 +1410,11 @@ describe('SSE Plugin', () => { await promise; }); - it('onSessionClose metric fires on disconnect', async () => { + it('onSessionClose metric fires on disconnect', async ({ onTestFinished }) => { let closedPath = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1390,7 +1431,7 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -1404,16 +1445,17 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(300); + await timers.setTimeout(300); expect(closedPath).toBe('/events'); }); - it('onPublish metric fires with delivery count', async () => { + it('onPublish metric fires with delivery count', async ({ onTestFinished }) => { let metricCount = -1; let metricPublishPath = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1431,11 +1473,11 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'hi' }); @@ -1445,8 +1487,9 @@ describe('SSE Plugin', () => { expect(metricCount).toBe(1); }); - it('metrics hook error does not break SSE', async () => { - server = Hapi.server({ port: 0 }); + it('metrics hook error does not break SSE', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1466,11 +1509,11 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'ok' }); @@ -1482,8 +1525,9 @@ describe('SSE Plugin', () => { // Feature 9: Backpressure - it('backpressure close strategy closes session when maxBytes exceeded', async () => { - server = Hapi.server({ port: 0 }); + it('backpressure close strategy closes session when maxBytes exceeded', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1516,7 +1560,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); @@ -1524,8 +1568,9 @@ describe('SSE Plugin', () => { expect(sessionRef!.isOpen).toBe(false); }); - it('backpressure drop strategy drops event but keeps session open', async () => { - server = Hapi.server({ port: 0 }); + it('backpressure drop strategy drops event but keeps session open', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1559,7 +1604,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); @@ -1567,8 +1612,9 @@ describe('SSE Plugin', () => { expect(pushResults).toContain(false); }); - it('subscriptions api is available on registration', async () => { - server = Hapi.server(); + it('subscriptions api is available on registration', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); expect(typeof server.sse.subscriptions).toBe('function'); @@ -1580,20 +1626,21 @@ describe('SSE Plugin', () => { // Broadcast returns delivery count - it('broadcast returns delivery count', async () => { - server = Hapi.server({ port: 0 }); + it('broadcast returns delivery count', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/a'); server.sse.subscription('/b'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/a`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/b`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.broadcast({ system: true }); @@ -1604,26 +1651,27 @@ describe('SSE Plugin', () => { // closeSessions - it('closeSessions closes sessions for a specific subscription', async () => { - server = Hapi.server({ port: 0 }); + it('closeSessions closes sessions for a specific subscription', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/a'); server.sse.subscription('/b'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const pA = collectSse(`http://localhost:${port}/a`, { timeout: 500 }); const pB = collectSse(`http://localhost:${port}/b`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(server.sse.sessionCount).toBe(2); server.sse.closeSessions('/a'); - await wait(50); + await timers.setTimeout(50); expect(server.sse.sessionCount).toBe(1); @@ -1635,8 +1683,9 @@ describe('SSE Plugin', () => { expect(rB.events[0]).toContain('data: {"msg":"still here"}'); }); - it('closeSessions on unknown pattern is a no-op', async () => { - server = Hapi.server(); + it('closeSessions on unknown pattern is a no-op', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); server.sse.closeSessions('/nonexistent'); @@ -1644,13 +1693,14 @@ describe('SSE Plugin', () => { // --- Rigorous tests inspired by better-sse and go-sse --- - it('keep-alive sends periodic comments', async () => { - server = Hapi.server({ port: 0 }); + it('keep-alive sends periodic comments', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: { interval: 100 } } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -1662,10 +1712,10 @@ describe('SSE Plugin', () => { }); // Wait for 350ms — should get initial comment + at least 2 keep-alive comments - setTimeout(() => { + timers.setTimeout(350).then(() => { req.destroy(); resolve(data); - }, 350); + }); req.on('error', () => {}); }); @@ -1677,19 +1727,20 @@ describe('SSE Plugin', () => { expect(commentLines.length).toBeGreaterThanOrEqual(3); }); - it('3+ concurrent clients all receive published events', async () => { - server = Hapi.server({ port: 0 }); + it('3+ concurrent clients all receive published events', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); const p3 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(server.sse.sessionCount).toBe(3); @@ -1702,8 +1753,9 @@ describe('SSE Plugin', () => { expect(r3.events[0]).toContain('data: {"msg":"all3"}'); }); - it('onReconnect throwing closes session and cleans up', async () => { - server = Hapi.server({ port: 0 }); + it('onReconnect throwing closes session and cleans up', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onReconnect: () => { @@ -1713,7 +1765,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // onReconnect fires after initialize() (headers already sent), so error // closes the session rather than returning an HTTP error @@ -1722,18 +1774,19 @@ describe('SSE Plugin', () => { headers: { 'Last-Event-ID': '1' }, }); - await wait(100); + await timers.setTimeout(100); // Session should be cleaned up expect(server.sse.sessionCount).toBe(0); }); - it('async filter function works correctly', async () => { - server = Hapi.server({ port: 0 }); + it('async filter function works correctly', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: async (_path, _message, options) => { - await new Promise((r) => setTimeout(r, 10)); + await timers.setTimeout(10); return (options.internal as { allow: boolean }).allow; }, @@ -1741,11 +1794,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'allowed' }, { internal: { allow: true } }); @@ -1755,17 +1808,18 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"allowed"}'); }); - it('handles large payloads', async () => { - server = Hapi.server({ port: 0 }); + it('handles large payloads', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const largeData = { payload: 'x'.repeat(50_000) }; @@ -1777,17 +1831,18 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('x'.repeat(100)); }); - it('publish with \\r\\n data normalizes to multiple data fields', async () => { - server = Hapi.server({ port: 0 }); + it('publish with \\r\\n data normalizes to multiple data fields', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', 'line1\r\nline2\rline3'); @@ -1798,8 +1853,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: line3'); }); - it('broadcast with 0 subscribers returns 0', async () => { - server = Hapi.server({ port: 0 }); + it('broadcast with 0 subscribers returns 0', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); @@ -1809,8 +1865,9 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('comment after close is silently ignored', async () => { - server = Hapi.server({ port: 0 }); + it('comment after close is silently ignored', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -1828,15 +1885,16 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const { status } = await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); expect(status).toBe(200); }); - it('filter override preserves event and id from publish opts', async () => { - server = Hapi.server({ port: 0 }); + it('filter override preserves event and id from publish opts', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => ({ override: { transformed: true } }), @@ -1844,11 +1902,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { original: true }, { event: 'custom', id: 'evt-99' }); @@ -1859,8 +1917,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"transformed":true}'); }); - it('backpressure works in subscription mode (not just handler mode)', async () => { - server = Hapi.server({ port: 0 }); + it('backpressure works in subscription mode (not just handler mode)', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -1873,11 +1932,11 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); - await wait(50); + await timers.setTimeout(50); // Publish large events — some may be dropped const bigData = 'x'.repeat(200); @@ -1894,10 +1953,11 @@ describe('SSE Plugin', () => { await promise; }); - it('onSubscribe sets metadata accessible during publish filter', async () => { + it('onSubscribe sets metadata accessible during publish filter', async ({ onTestFinished }) => { let filterSawRole = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onSubscribe: (session) => { @@ -1914,11 +1974,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); // Verify metadata set in onSubscribe is accessible later let metaValue: unknown; @@ -1936,11 +1996,12 @@ describe('SSE Plugin', () => { expect(filterSawRole).toBe('checked'); }); - it('session.request provides access to the original request object', async () => { + it('session.request provides access to the original request object', async ({ onTestFinished }) => { let requestPath = ''; let hasAuth = false; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -1960,7 +2021,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { maxEvents: 1 }); @@ -1968,13 +2029,14 @@ describe('SSE Plugin', () => { expect(hasAuth).toBe(true); }); - it('Connection: keep-alive header is set', async () => { - server = Hapi.server({ port: 0 }); + it('Connection: keep-alive header is set', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const headers = await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -1989,10 +2051,11 @@ describe('SSE Plugin', () => { expect(headers['connection']).toBe('keep-alive'); }); - it('multiple parameterized path segments work', async () => { + it('multiple parameterized path segments work', async ({ onTestFinished }) => { let capturedParams: Record = {}; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/org/{org}/channel/{channel}', { onSubscribe: (_session, _path, params) => { @@ -2002,11 +2065,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/org/acme/channel/general`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(capturedParams.org).toBe('acme'); expect(capturedParams.channel).toBe('general'); @@ -2018,29 +2081,28 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"hi"}'); }); - it('ValidReplayer integration — expired events are not replayed', async () => { - const { ValidReplayer } = await import('./replayer.ts'); - + it('ValidReplayer integration — expired events are not replayed', async ({ onTestFinished }) => { const replayer = new ValidReplayer({ ttl: 100 }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Publish events via first client const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { id: '1' }); await p1; // Wait for TTL to expire - await wait(200); + await timers.setTimeout(200); // Reconnect — events should be expired const { events } = await collectSse(`http://localhost:${port}/events`, { @@ -2052,22 +2114,23 @@ describe('SSE Plugin', () => { expect(events.length).toBe(0); }); - it('FiniteReplayer with autoId generates IDs in integration', async () => { + it('FiniteReplayer with autoId generates IDs in integration', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100, autoId: true }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Note: autoId only applies to replayer.record() which requires opts.id to be truthy // In current implementation, publish without id won't call record() // So autoId is mainly useful when manually calling replayer.record() const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }, { id: 'a' }); @@ -2083,13 +2146,14 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"n":1}'); }); - it('stats tracks multiple connect/disconnect cycles', async () => { - server = Hapi.server({ port: 0 }); + it('stats tracks multiple connect/disconnect cycles', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // First connection cycle await new Promise((resolve) => { @@ -2104,7 +2168,7 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(100); + await timers.setTimeout(100); // Second connection cycle await new Promise((resolve) => { @@ -2119,7 +2183,7 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(100); + await timers.setTimeout(100); const stats = server.sse.stats(); @@ -2128,18 +2192,19 @@ describe('SSE Plugin', () => { expect(stats.activeSessions).toBe(0); }); - it('closeSessions allows new connections after closing', async () => { - server = Hapi.server({ port: 0 }); + it('closeSessions allows new connections after closing', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Connect, then close all const p1 = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); - await wait(50); + await timers.setTimeout(50); server.sse.closeSessions('/events'); @@ -2148,7 +2213,7 @@ describe('SSE Plugin', () => { // New connection should still work const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(server.sse.sessionCount).toBe(1); @@ -2159,18 +2224,19 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"new"}'); }); - it('rapid publish/disconnect does not crash', async () => { - server = Hapi.server({ port: 0 }); + it('rapid publish/disconnect does not crash', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Connect and immediately start publishing const promise = collectSse(`http://localhost:${port}/events`, { timeout: 200 }); - await wait(20); + await timers.setTimeout(20); // Fire multiple publishes rapidly — some may hit disconnected sessions const results = await Promise.all([ @@ -2191,17 +2257,18 @@ describe('SSE Plugin', () => { } }); - it('literal publish returns 0 for non-matching literal path', async () => { - server = Hapi.server({ port: 0 }); + it('literal publish returns 0 for non-matching literal path', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events/{channel}'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events/news`, { timeout: 300 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.publish('/events/sport', { msg: 'hi' }, { matchMode: 'literal' }); @@ -2210,8 +2277,9 @@ describe('SSE Plugin', () => { await promise; }); - it('publish delivery count reflects filter override', async () => { - server = Hapi.server({ port: 0 }); + it('publish delivery count reflects filter override', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => ({ override: { replaced: true } }), @@ -2219,11 +2287,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.publish('/events', { original: true }); @@ -2232,10 +2300,11 @@ describe('SSE Plugin', () => { await promise; }); - it('replay + onReconnect ordering — replay events arrive before onReconnect events', async () => { + it('replay + onReconnect ordering — replay events arrive before onReconnect events', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100 }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer, @@ -2246,12 +2315,12 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; // Populate replayer const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { source: 'replay' }, { event: 'msg', id: '1' }); @@ -2268,8 +2337,9 @@ describe('SSE Plugin', () => { expect(events[1]).toContain('data: {"source":"onReconnect"}'); }); - it('handler-level backpressure overrides plugin-level', async () => { - server = Hapi.server({ port: 0 }); + it('handler-level backpressure overrides plugin-level', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -2302,17 +2372,18 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); expect(sessionClosed).toBe(true); }); - it('multiple filter errors do not prevent delivery to remaining sessions', async () => { + it('multiple filter errors do not prevent delivery to remaining sessions', async ({ onTestFinished }) => { let filterCallCount = 0; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => { @@ -2329,13 +2400,13 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1, timeout: 500 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1, timeout: 500 }); const p3 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1, timeout: 500 }); - await wait(50); + await timers.setTimeout(50); const count = await server.sse.publish('/events', { msg: 'partial' }); @@ -2348,8 +2419,9 @@ describe('SSE Plugin', () => { expect(totalReceived).toBe(1); }); - it('hooks onSession error does not prevent session from working', async () => { - server = Hapi.server({ port: 0 }); + it('hooks onSession error does not prevent session from working', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -2366,11 +2438,11 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'despite hook error' }); @@ -2380,8 +2452,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"despite hook error"}'); }); - it('hooks onSessionClose error does not prevent cleanup', async () => { - server = Hapi.server({ port: 0 }); + it('hooks onSessionClose error does not prevent cleanup', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { @@ -2398,7 +2471,7 @@ describe('SSE Plugin', () => { server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/events`, (res) => { @@ -2412,18 +2485,19 @@ describe('SSE Plugin', () => { req.on('error', () => {}); }); - await wait(200); + await timers.setTimeout(200); expect(server.sse.sessionCount).toBe(0); }); - it('retry: null disables retry field in SSE stream', async () => { - server = Hapi.server({ port: 0 }); + it('retry: null disables retry field in SSE stream', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -2446,8 +2520,9 @@ describe('SSE Plugin', () => { // sessionCount - it('sessionCount reflects connected sessions', async () => { - server = Hapi.server({ port: 0 }); + it('sessionCount reflects connected sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); @@ -2455,11 +2530,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); expect(server.sse.sessionCount).toBe(1); @@ -2470,10 +2545,11 @@ describe('SSE Plugin', () => { // connectedAt - it('session.connectedAt is set to a recent timestamp', async () => { + it('session.connectedAt is set to a recent timestamp', async ({ onTestFinished }) => { let timestamp = 0; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -2491,7 +2567,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const before = Date.now(); @@ -2505,8 +2581,9 @@ describe('SSE Plugin', () => { // stats() - it('stats() tracks connection and publish metrics', async () => { - server = Hapi.server({ port: 0 }); + it('stats() tracks connection and publish metrics', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); @@ -2519,11 +2596,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 2 }); - await wait(50); + await timers.setTimeout(50); const afterConnect = server.sse.stats(); @@ -2540,24 +2617,25 @@ describe('SSE Plugin', () => { await promise; - await wait(100); + await timers.setTimeout(100); const afterDisconnect = server.sse.stats(); expect(afterDisconnect.totalDisconnections).toBe(1); }); - it('stats() tracks broadcast metrics separately', async () => { - server = Hapi.server({ port: 0 }); + it('stats() tracks broadcast metrics separately', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.broadcast({ msg: 'hi' }); @@ -2574,23 +2652,24 @@ describe('SSE Plugin', () => { // --- Additional rigorous tests (pass 3) --- - it('eachSession with async callback processes all sessions', async () => { - server = Hapi.server({ port: 0 }); + it('eachSession with async callback processes all sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); const visited: string[] = []; await server.sse.eachSession(async (session) => { - await new Promise((r) => setTimeout(r, 10)); + await timers.setTimeout(10); visited.push('visited'); session.push({ done: true }); }); @@ -2600,8 +2679,9 @@ describe('SSE Plugin', () => { await Promise.all([p1, p2]); }); - it('eachSession on non-existent subscription is a no-op', async () => { - server = Hapi.server({ port: 0 }); + it('eachSession on non-existent subscription is a no-op', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); @@ -2618,8 +2698,9 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('custom handler headers override plugin-level headers', async () => { - server = Hapi.server({ port: 0 }); + it('custom handler headers override plugin-level headers', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false, headers: { 'X-Plugin': 'yes' } }, @@ -2641,7 +2722,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const headers = await new Promise((resolve) => { const req = http.get(`http://localhost:${port}/stream`, (res) => { @@ -2658,8 +2739,9 @@ describe('SSE Plugin', () => { expect(headers['x-plugin']).toBeUndefined(); }); - it('custom handler retry override is respected', async () => { - server = Hapi.server({ port: 0 }); + it('custom handler retry override is respected', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: 1000, keepAlive: false } }); server.route({ @@ -2678,7 +2760,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -2696,8 +2778,9 @@ describe('SSE Plugin', () => { expect(raw).toContain('retry: 7777'); }); - it('custom handler keepAlive override sends periodic comments', async () => { - server = Hapi.server({ port: 0 }); + it('custom handler keepAlive override sends periodic comments', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -2715,7 +2798,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const raw = await new Promise((resolve) => { let data = ''; @@ -2740,9 +2823,7 @@ describe('SSE Plugin', () => { expect(commentLines.length).toBeGreaterThanOrEqual(3); }); - it('server.stop() calls replayer.stop() for ValidReplayer cleanup', async () => { - const { ValidReplayer } = await import('./replayer.ts'); - + it('server.stop() calls replayer.stop() for ValidReplayer cleanup', async ({ onTestFinished }) => { const replayer = new ValidReplayer({ ttl: 60_000 }); let stopCalled = false; const originalStop = replayer.stop.bind(replayer); @@ -2752,7 +2833,8 @@ describe('SSE Plugin', () => { originalStop(); }; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); @@ -2762,17 +2844,18 @@ describe('SSE Plugin', () => { expect(stopCalled).toBe(true); }); - it('broadcast with id field sends id to all sessions', async () => { - server = Hapi.server({ port: 0 }); + it('broadcast with id field sends id to all sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.broadcast({ msg: 'hi' }, { event: 'sys', id: 'b-1' }); @@ -2783,8 +2866,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"hi"}'); }); - it('publish to matched pattern with 0 connected sessions returns 0', async () => { - server = Hapi.server({ port: 0 }); + it('publish to matched pattern with 0 connected sessions returns 0', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); @@ -2795,17 +2879,18 @@ describe('SSE Plugin', () => { expect(count).toBe(0); }); - it('push with id but no event sends id field without event field', async () => { - server = Hapi.server({ port: 0 }); + it('push with id but no event sends id field without event field', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { msg: 'hi' }, { id: 'only-id' }); @@ -2816,19 +2901,20 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"hi"}'); }); - it('concurrent publishes to different subscriptions are isolated', async () => { - server = Hapi.server({ port: 0 }); + it('concurrent publishes to different subscriptions are isolated', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/a'); server.sse.subscription('/b'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const pA = collectSse(`http://localhost:${port}/a`, { maxEvents: 1 }); const pB = collectSse(`http://localhost:${port}/b`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); // Publish to both concurrently await Promise.all([server.sse.publish('/a', { target: 'a' }), server.sse.publish('/b', { target: 'b' })]); @@ -2839,14 +2925,15 @@ describe('SSE Plugin', () => { expect(rB.events[0]).toContain('data: {"target":"b"}'); }); - it('async onSubscribe is awaited before session is active', async () => { + it('async onSubscribe is awaited before session is active', async ({ onTestFinished }) => { let subscribeFinished = false; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { onSubscribe: async (session) => { - await new Promise((r) => setTimeout(r, 50)); + await timers.setTimeout(50); session.set('ready', true); subscribeFinished = true; }, @@ -2854,11 +2941,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(100); + await timers.setTimeout(100); expect(subscribeFinished).toBe(true); @@ -2874,8 +2961,9 @@ describe('SSE Plugin', () => { await promise; }); - it('filter returning truthy non-boolean non-override object delivers original data', async () => { - server = Hapi.server({ port: 0 }); + it('filter returning truthy non-boolean non-override object delivers original data', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { filter: () => true as unknown as boolean, @@ -2883,11 +2971,11 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { original: true }); @@ -2896,10 +2984,11 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: {"original":true}'); }); - it('session.get() returns undefined for non-existent key', async () => { + it('session.get() returns undefined for non-existent key', async ({ onTestFinished }) => { let value: unknown = 'sentinel'; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -2917,17 +3006,18 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); expect(value).toBeUndefined(); }); - it('session.delete() returns false for non-existent key', async () => { + it('session.delete() returns false for non-existent key', async ({ onTestFinished }) => { let result = true; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -2945,24 +3035,25 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); expect(result).toBe(false); }); - it('publish with null data serializes as "null"', async () => { - server = Hapi.server({ port: 0 }); + it('publish with null data serializes as "null"', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', null); @@ -2971,17 +3062,18 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: null'); }); - it('publish with numeric data serializes correctly', async () => { - server = Hapi.server({ port: 0 }); + it('publish with numeric data serializes correctly', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', 42); @@ -2990,8 +3082,9 @@ describe('SSE Plugin', () => { expect(events[0]).toContain('data: 42'); }); - it('onSubscribe throwing non-Boom error returns 500', async () => { - server = Hapi.server({ port: 0 }); + it('onSubscribe throwing non-Boom error returns 500', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); server.sse.subscription('/events', { onSubscribe: () => { @@ -3001,18 +3094,19 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const result = await collectSse(`http://localhost:${port}/events`, { timeout: 500 }); expect(result.status).toBe(500); }); - it('handler stream receives the request object', async () => { + it('handler stream receives the request object', async ({ onTestFinished }) => { let receivedPath = ''; let receivedMethod = ''; - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.route({ @@ -3031,7 +3125,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; await collectSse(`http://localhost:${port}/stream`, { timeout: 500 }); @@ -3039,17 +3133,18 @@ describe('SSE Plugin', () => { expect(receivedMethod).toBe('get'); }); - it('multiple sequential publishes update stats cumulatively', async () => { - server = Hapi.server({ port: 0 }); + it('multiple sequential publishes update stats cumulatively', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events'); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const promise = collectSse(`http://localhost:${port}/events`, { maxEvents: 5 }); - await wait(50); + await timers.setTimeout(50); for (let i = 0; i < 5; i++) { await server.sse.publish('/events', { n: i }); @@ -3063,19 +3158,20 @@ describe('SSE Plugin', () => { expect(stats.totalEventsDelivered).toBe(5); }); - it('replayer does not record events without an id', async () => { + it('replayer does not record events without an id', async ({ onTestFinished }) => { const replayer = new FiniteReplayer({ size: 100 }); - server = Hapi.server({ port: 0 }); + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); server.sse.subscription('/events', { replay: replayer }); await server.start(); - const port = (server.listener.address() as { port: number }).port; + const port = server.info.port; const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 2 }); - await wait(50); + await timers.setTimeout(50); await server.sse.publish('/events', { n: 1 }); // no id — should NOT be recorded await server.sse.publish('/events', { n: 2 }, { id: '1' }); // has id — should be recorded @@ -3095,8 +3191,9 @@ describe('SSE Plugin', () => { // ── Handler decorator ── - it('supports sse handler decorator on routes', async () => { - server = Hapi.server({ port: 0 }); + it('supports sse handler decorator on routes', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin }); server.route({ @@ -3113,7 +3210,7 @@ describe('SSE Plugin', () => { await server.start(); - const port = (server.info as any).port; + const port = server.info.port; const { events, status } = await collectSse(`http://localhost:${port}/stream`, { maxEvents: 1, @@ -3124,4 +3221,85 @@ describe('SSE Plugin', () => { expect(events.length).toBe(1); expect(events[0]).toContain('data: {"msg":"hello from decorator"}'); }); + + describe.concurrent('Edge Cases', () => { + it('handles auth configurations in subscription()', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register(SsePlugin); + + // Mock auth strategy + server.auth.scheme('mock', () => ({ + authenticate: (_request, h) => h.authenticated({ credentials: { user: 'test' } }), + })); + server.auth.strategy('test', 'mock'); + + server.sse.subscription('/events', { auth: 'test' }); + + const route = server.table().find((r) => r.path === '/events'); + + expect(route?.settings.auth?.strategies).toContain('test'); + }); + + it('gracefully handles errors in onSession hook', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ + plugin: SsePlugin, + options: { + hooks: { + onSession: () => { + throw new Error('hook error'); + }, + }, + }, + }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + const req = http.get(`http://localhost:${port}/events`); + + await timers.setTimeout(100); + req.destroy(); + }); + + it('gracefully handles errors in onPublish hook', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ + plugin: SsePlugin, + options: { + hooks: { + onPublish: () => { + throw new Error('hook error'); + }, + }, + }, + }); + server.sse.subscription('/events'); + + // Should not throw + await server.sse.publish('/events', { test: true }); + }); + + it('gracefully handles errors in onUnsubscribe callback', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register(SsePlugin); + server.sse.subscription('/events', { + onUnsubscribe: () => { + throw new Error('unsub error'); + }, + }); + await server.start(); + + const port = server.info.port; + const req = http.get(`http://localhost:${port}/events`); + + await timers.setTimeout(100); + req.destroy(); + await timers.setTimeout(100); + }); + }); }); diff --git a/test/subscription.test.ts b/test/subscription.test.ts new file mode 100644 index 0000000..2bc33db --- /dev/null +++ b/test/subscription.test.ts @@ -0,0 +1,109 @@ +import { expect, describe, it } from 'vitest'; +import { SubscriptionRegistry } from '../src/subscription.js'; +import { Session } from '../src/session.js'; + +describe.concurrent('SubscriptionRegistry', () => { + it('getSessionInfo returns session info', () => { + const registry = new SubscriptionRegistry(); + const session = { request: { headers: {} } } as any; + registry.register('/events', {}); + registry.addSession('/events', session, { id: '1' }, '/events/1'); + + const info = registry.getSessionInfo(session); + expect(info).toEqual({ + pattern: '/events', + params: { id: '1' }, + resolvedPath: '/events/1' + }); + }); + + it('getConfig returns config', () => { + const registry = new SubscriptionRegistry(); + const config = {}; + registry.register('/events', config); + + expect(registry.getConfig('/events')).toBe(config); + }); + + it('removeSession is idempotent for non-existent session', () => { + const registry = new SubscriptionRegistry(); + const session = new Session({ + request: { + headers: {}, + raw: { req: { socket: {} }, res: { writeHead: () => {}, end: () => {} } } + } as any, + retry: null, + keepAlive: false, + headers: {}, + }); + + // Should not throw + registry.removeSession(session); + }); + + it('matchPath returns null for no match', () => { + const registry = new SubscriptionRegistry(); + expect(registry.matchPath('/nonexistent')).toBeNull(); + }); + + it('publish continues if session info is missing', async () => { + const registry = new SubscriptionRegistry(); + const session = { request: { headers: {} }, push: () => true } as any; + registry.register('/a', {}); + registry.register('/b', {}); + + // Add session to both patterns + registry.addSession('/a', session, {}, '/a'); + // This sets #sessionInfo.get(session).pattern = '/a' + + registry.addSession('/b', session, {}, '/b'); + // This overwrites #sessionInfo.get(session).pattern = '/b' + // But session is still in /a's sub.sessions! + + // Now remove session + registry.removeSession(session); + // This looks up info (pattern='/b'), deletes from /b's sub.sessions, and deletes from #sessionInfo. + // session is STILL in /a's sub.sessions, but NOT in #sessionInfo! + + const delivered = await registry.publish('/a', 'data'); + expect(delivered).toBe(0); + }); + + it('addSession does nothing for a non-existent subscription', () => { + const registry = new SubscriptionRegistry(); + const session = { request: { headers: {} } } as any; + + // No subscription registered for '/events' + registry.addSession('/events', session, {}, '/events'); + expect(registry.getSessionInfo(session)).toBeUndefined(); + }); + + it('publish correctly handles failed pushes with filter override', async () => { + const registry = new SubscriptionRegistry(); + const session = { + request: { auth: { credentials: {} } }, + push: () => false // Simulate closed session or error during push + } as any; + + registry.register('/events', { + filter: () => ({ override: 'new-data' }) + }); + registry.addSession('/events', session, {}, '/events'); + + const delivered = await registry.publish('/events', 'data'); + expect(delivered).toBe(0); + }); + + it('broadcast handles failed pushes to a session', async () => { + const registry = new SubscriptionRegistry(); + const session = { + push: () => false // Simulate closed session or error + } as any; + + registry.register('/events', {}); + registry.addSession('/events', session, {}, '/events'); + + const delivered = await registry.broadcast('data'); + expect(delivered).toBe(0); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index b8b1ec6..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.test.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json index 5e34184..2898a06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,14 @@ { "compilerOptions": { - "allowImportingTsExtensions": true, - "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": false, "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, // "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "inlineSources": true, + "isolatedDeclarations": true, "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, @@ -18,13 +18,14 @@ // "noUncheckedIndexedAccess": true, // "noUnusedLocals": true, // "noUnusedParameters": true, - "rewriteRelativeImportExtensions": true, "skipLibCheck": true, "sourceMap": true, + "stripInternal": true, "strict": true, "target": "ESNext" }, "include": [ - "src" + "src", + "test" ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index fd136f5..c6c1c08 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'tsdown'; +import type { UserConfig } from 'tsdown'; -export default defineConfig({ +const config: UserConfig = defineConfig({ entry: ['./src/index.ts'], outDir: './dist', exports: true, + dts: true, format: 'esm', target: 'node22' }); + +export default config; diff --git a/vitest.config.ts b/vitest.config.ts index b867460..630b9f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,23 +3,23 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['test/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['**/*.ts'], + }, coverage: { enabled: true, provider: 'v8', - all: true, + include: ['src/**/*.ts'], reportsDirectory: './coverage', reporter: ['text', 'lcov'], - exclude: [ - 'eslint.config.cjs', - 'tsdown.config.ts', - 'vitest.config.ts', - 'src/**/*.test.ts', - 'dist/**', - 'tmp/**', - 'node_modules/**', - '**/*.d.ts' - ] + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100 + }, } } }); From 487ee9161184fe2bfcd3d77a04551b1f7b7276a9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 1 Apr 2026 01:26:41 -0400 Subject: [PATCH 2/5] feat(sse): add security hardening against known SSE attack vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce retry floor (min 1000ms) to prevent reconnection storm DoS - Sanitize Last-Event-ID header by stripping control characters (\x00-\x1f) - Add maxSessions per-subscription connection limit with 503 rejection - Add maxDuration connection TTL with ±10% jitter to prevent thundering herd - Add 29 security tests covering CRLF injection, retry abuse, cross-client isolation, connection exhaustion, and post-stop safety - Document new options and security properties in API.md --- API.md | 93 ++++-- src/session.ts | 22 +- src/sse.ts | 20 +- src/subscription.ts | 22 +- test/event-buffer.test.ts | 72 +++++ test/sse.test.ts | 650 ++++++++++++++++++++++++++++++++++++++ test/subscription.test.ts | 23 +- 7 files changed, 858 insertions(+), 44 deletions(-) diff --git a/API.md b/API.md index 82a2f58..dcee662 100644 --- a/API.md +++ b/API.md @@ -20,12 +20,12 @@ const server = Hapi.server({ port: 3000 }); await server.register({ plugin: SsePlugin }); -server.sse.subscription('/events'); +server.sse.subscription('/chat/{room}'); await server.start(); // Publish from anywhere -await server.sse.publish('/events', { msg: 'hello' }, { event: 'chat' }); +await server.sse.publish('/chat/general', { text: 'hello', user: 'alice' }, { event: 'message' }); ``` ## Plugin Options @@ -50,12 +50,12 @@ await server.register({ Registers a subscription route. Clients connect via `GET `. ```typescript -server.sse.subscription('/events/{channel}', { +server.sse.subscription('/chat/{room}', { auth: 'jwt', retry: 5000, keepAlive: { interval: 10_000 }, filter: async (path, message, { credentials, params, internal }) => { - if (params.channel !== internal.targetChannel) { + if (params.room !== internal.targetRoom) { return false; // don't deliver } return { override: { ...message, filtered: true } }; // or transform @@ -79,6 +79,8 @@ server.sse.subscription('/events/{channel}', { | `onUnsubscribe` | `(session, path, params) => void` | Fires on client disconnect | | `onReconnect` | `(session, path, params) => void \| Promise` | Fires when `Last-Event-ID` is present (after replay). Errors close the session gracefully. | | `replay` | `Replayer` | Replay provider for automatic reconnection replay | +| `maxSessions` | `number` | Maximum concurrent sessions for this subscription. Excess connections receive a 503 response. | +| `maxDuration` | `number` | Maximum connection lifetime in ms. Sessions are closed after this duration (with ±10% jitter to prevent thundering herd reconnections). A `: session expired` comment is sent before closing. | ### `server.sse.publish(path, data, opts?)` @@ -86,12 +88,12 @@ Publishes an event to all matching subscribers. Returns the number of sessions t ```typescript const delivered = await server.sse.publish( - '/events/news', - { headline: '...' }, + '/chat/general', + { text: 'hello everyone', user: 'alice' }, { - event: 'breaking', - id: 'evt-42', - internal: { targetChannel: 'news' }, // passed to filter + event: 'message', + id: 'msg-42', + internal: { targetRoom: 'general' }, // passed to filter matchMode: 'literal', // 'pattern' (default) or 'literal' }, ); @@ -101,8 +103,8 @@ console.log(`Delivered to ${delivered} sessions`); **`matchMode`:** -- `'pattern'` (default) — delivers to all sessions on a matching subscription pattern (e.g. `/events/{channel}`) -- `'literal'` — only delivers to sessions whose actual connected path equals `path` exactly. Useful for parameterized subscriptions where you want to target `/events/news` but not `/events/sport`. +- `'pattern'` (default) — delivers to all sessions on a matching subscription pattern (e.g. `/chat/{room}`) +- `'literal'` — only delivers to sessions whose actual connected path equals `path` exactly. Useful for parameterized subscriptions where you want to target `/chat/general` but not `/chat/random`. **Note:** Only events published with an explicit `id` are recorded by the replayer. Events without an `id` are delivered but not stored for replay. @@ -111,7 +113,10 @@ console.log(`Delivered to ${delivered} sessions`); Sends an event to every connected session across all subscriptions. Returns the delivery count. ```typescript -const count = await server.sse.broadcast({ type: 'maintenance' }, { event: 'system' }); +const count = await server.sse.broadcast( + { text: 'Server restarting in 5 minutes', user: 'system' }, + { event: 'system' }, +); ``` ### `server.sse.eachSession(fn, opts?)` @@ -121,9 +126,9 @@ Iterates over connected sessions. Optionally filter by subscription pattern. ```typescript await server.sse.eachSession( async (session) => { - session.push({ ping: true }); + session.push({ text: 'ping', user: 'system' }); }, - { subscription: '/events' }, + { subscription: '/chat/{room}' }, ); ``` @@ -133,7 +138,7 @@ Returns a snapshot of all registered subscriptions with active session counts. ```typescript const subs = server.sse.subscriptions(); -// [{ pattern: '/events', activeSessions: 3 }, { pattern: '/chat/{room}', activeSessions: 12 }] +// [{ pattern: '/chat/{room}', activeSessions: 12 }] ``` ### `server.sse.closeSessions(pattern)` @@ -141,8 +146,7 @@ const subs = server.sse.subscriptions(); Closes all sessions for a specific subscription pattern. ```typescript -server.sse.closeSessions('/events'); // close all /events sessions -server.sse.closeSessions('/chat/{room}'); // close all chat sessions +server.sse.closeSessions('/chat/{room}'); ``` ### `server.sse.sessionCount` @@ -195,8 +199,8 @@ session.request // The original hapi Request object **Metadata** — attach arbitrary key-value data to a session: ```typescript -session.set('userId', 42); -session.get('userId'); // 42 +session.set('userId', 'alice'); +session.get('userId'); // 'alice' session.has('userId'); // true session.delete('userId'); // true ``` @@ -205,23 +209,23 @@ Metadata persists for the lifetime of the session. Useful for tagging sessions i ## Custom Handler Mode -For full control over the stream (e.g. AI token streaming), use the handler decorator instead of subscriptions: +For full control over the stream (e.g. AI-assisted chat responses), use the handler decorator instead of subscriptions: ```typescript server.route({ method: 'GET', - path: '/stream', + path: '/chat/{room}/ai', handler: { sse: { stream: async (request, session) => { for (const token of tokens) { - session.push({ token }, 'token'); + session.push({ token, user: 'assistant' }, 'token'); } session.close(); }, retry: 3000, // override plugin-level retry keepAlive: { interval: 10_000 }, // override plugin-level keep-alive - headers: { 'X-Stream': 'true' }, // override plugin-level headers + headers: { 'X-Chat-Bot': 'true' }, // override plugin-level headers backpressure: { maxBytes: 32768, strategy: 'close' }, }, }, @@ -237,10 +241,11 @@ server.route({ | `keepAlive` | `{ interval: number } \| false` | Override plugin-level keep-alive (default: inherits from plugin) | | `headers` | `Record` | Override plugin-level headers (default: inherits from plugin) | | `backpressure` | `BackpressureOptions` | Override plugin-level backpressure (default: inherits from plugin) | +| `maxDuration` | `number` | Maximum connection lifetime in ms (with ±10% jitter). Sends a comment before closing. | ## Event Replay -Automatic replay of missed events on client reconnection. When a client sends `Last-Event-ID`, the replayer pushes missed events before `onReconnect` fires. +SSE clients automatically send a `Last-Event-ID` header when reconnecting after a dropped connection. When a replayer is configured, the plugin uses that ID to find where the client left off and pushes any events published after it — so the client catches up on what it missed while disconnected. Only events published with an explicit `id` are recorded. Events without an `id` are delivered but not stored for replay — this prevents the buffer from filling with unaddressable entries. @@ -248,29 +253,29 @@ Two built-in replayers: ### FiniteReplayer -Fixed-size ring buffer. O(1) append, linear scan for replay. +Keeps the last N events in a fixed-size ring buffer. When full, the oldest entry is dropped to make room. Memory usage is predictable — bounded by `size`. ```typescript import { FiniteReplayer } from '@hapi/sse'; const replayer = new FiniteReplayer({ size: 100, autoId: true }); -server.sse.subscription('/events', { replay: replayer }); +server.sse.subscription('/chat/{room}', { replay: replayer }); ``` ### ValidReplayer -Time-based expiry with periodic garbage collection. +Keeps events for a fixed duration. A periodic cleanup timer removes expired entries, so memory usage varies with publish rate but replayed events are never older than `ttl`. ```typescript import { ValidReplayer } from '@hapi/sse'; const replayer = new ValidReplayer({ ttl: 60_000, autoId: true }); -server.sse.subscription('/events', { replay: replayer }); +server.sse.subscription('/chat/{room}', { replay: replayer }); ``` -Call `replayer.stop()` to clear the GC timer (handled automatically on server stop). +Call `replayer.stop()` to clear the cleanup timer (handled automatically on server stop). **Options:** @@ -314,7 +319,7 @@ await server.register({ // Handler level — overrides plugin level server.route({ method: 'GET', - path: '/stream', + path: '/chat/{room}/ai', handler: { sse: { stream: async (req, session) => { ... }, @@ -343,13 +348,13 @@ await server.register({ options: { hooks: { onSession: (session, path, params) => { - console.log(`New connection: ${path}`); + console.log(`Joined: ${path}`); }, onSessionClose: (session, path, params) => { - console.log(`Disconnected: ${path}`); + console.log(`Left: ${path}`); }, onPublish: (path, data, deliveryCount) => { - console.log(`Published to ${path}: ${deliveryCount} recipients`); + console.log(`Message in ${path}: ${deliveryCount} recipients`); }, }, }, @@ -368,16 +373,34 @@ interface ChatMessage { user: string; } -server.sse.subscription('/chat', { +server.sse.subscription('/chat/{room}', { filter: (path, message) => { // message is typed as ChatMessage return message.user !== 'blocked'; }, }); -await server.sse.publish('/chat', { text: 'hi', user: 'alice' }); +await server.sse.publish('/chat/general', { text: 'hello', user: 'alice' }); ``` +## Security + +The plugin includes several built-in defenses against known SSE attack vectors: + +**Retry floor** — The `retry` value is silently clamped to a minimum of 1000ms. This prevents reconnection storm attacks where a malicious or misconfigured `retry: 0` causes clients to reconnect thousands of times per second. Setting `retry: null` disables the retry field entirely (no clamping). + +**Last-Event-ID sanitization** — Control characters (`\x00`–`\x1f`) are stripped from the incoming `Last-Event-ID` header. This prevents null byte injection and CRLF attacks via the reconnection header. + +**Connection limiting** — Use `maxSessions` on subscriptions to cap concurrent connections. Excess connections receive an HTTP 503 response before SSE headers are sent, preventing connection exhaustion. + +**Connection TTL** — Use `maxDuration` to enforce a maximum connection lifetime. A ±10% jitter is applied to prevent thundering herd reconnections when many clients connect at the same time. Clients automatically reconnect via the standard SSE reconnection mechanism. + +**CRLF injection protection** — The `EventBuffer` serializer strips or splits newlines in `event` and `id` fields, and splits `data` fields on line terminators. This prevents SSE event injection attacks (CVE-2026-33128, CVE-2026-22735, CVE-2026-29085 pattern). + +**Backpressure** — Slow consumers are handled via configurable backpressure strategies (`drop` or `close`), preventing unbounded memory growth from write buffer accumulation. + +**Not in scope** — Origin header validation, CSRF protection, and authentication are handled by hapi's auth system and middleware (`onPreAuth` extensions or reverse proxy configuration), not by the SSE plugin. + ## Exports ```typescript diff --git a/src/session.ts b/src/session.ts index 7caa16e..6f92bf6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,6 +14,7 @@ export interface SessionOptions { keepAlive: { interval: number } | false; headers: Record; backpressure?: BackpressureOptions; + maxDuration?: number; } export class Session { @@ -33,10 +34,14 @@ export class Session { /** @internal */ readonly #backpressure: BackpressureOptions | undefined; /** @internal */ + readonly #maxDuration: number | undefined; + /** @internal */ readonly #metadata = new Map(); /** @internal */ #keepAliveTimer: ReturnType | null = null; /** @internal */ + #maxDurationTimer: ReturnType | null = null; + /** @internal */ #closed = false; constructor(options: SessionOptions) { @@ -45,13 +50,14 @@ export class Session { const rawId = options.request.headers['last-event-id']; - this.lastEventId = (Array.isArray(rawId) ? rawId[0] : rawId) ?? ''; + this.lastEventId = ((Array.isArray(rawId) ? rawId[0] : rawId) ?? '').replace(/[\x00-\x1f]/g, ''); this.#res = options.request.raw.res; this.#buffer = new EventBuffer(); this.#retry = options.retry; this.#keepAlive = options.keepAlive; this.#headers = options.headers; this.#backpressure = options.backpressure; + this.#maxDuration = options.maxDuration; } get isOpen(): boolean { @@ -100,6 +106,15 @@ export class Session { if (this.#keepAlive) { this.#keepAliveTimer = setInterval(() => this.#onKeepAlive(), this.#keepAlive.interval); } + + if (this.#maxDuration) { + const jitter = this.#maxDuration * 0.1 * (2 * Math.random() - 1); + + this.#maxDurationTimer = setTimeout(() => { + this.comment('session expired'); + this.close(); + }, this.#maxDuration + jitter); + } } /** @internal */ @@ -162,6 +177,11 @@ export class Session { this.#keepAliveTimer = null; } + if (this.#maxDurationTimer) { + clearTimeout(this.#maxDurationTimer); + this.#maxDurationTimer = null; + } + this.#res.end(); } diff --git a/src/sse.ts b/src/sse.ts index 85230a4..f7fe308 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -1,4 +1,5 @@ import type { NamedPlugin, Request, ResponseToolkit, RouteOptions, Lifecycle } from '@hapi/hapi'; +import Boom from '@hapi/boom'; import { createRequire } from 'node:module'; import { Session } from './session.js'; @@ -30,6 +31,7 @@ export interface SseHandlerOptions { keepAlive?: { interval: number } | false; headers?: Record; backpressure?: BackpressureOptions; + maxDuration?: number; } export interface SseStats { @@ -66,6 +68,12 @@ declare module '@hapi/hapi' { } } +const RETRY_FLOOR = 1000; + +const clampRetry = (value: number | null): number | null => { + return value === null ? null : Math.max(value, RETRY_FLOOR); +}; + const defaults: Required> = { keepAlive: { interval: 15_000 }, retry: 2000, @@ -103,12 +111,19 @@ export const SsePlugin: NamedPlugin = { handler: async (request: Request, h: ResponseToolkit) => { const matched = registry.matchPath(request.path)!; + const maxSessions = (subConfig as SubscriptionConfig).maxSessions; + + if (maxSessions && registry.subscriptionSessionCount(matched.pattern) >= maxSessions) { + return Boom.serverUnavailable('Too many connections'); + } + const session = new Session({ request, - retry: subConfig.retry ?? config.retry, + retry: clampRetry(subConfig.retry ?? config.retry), keepAlive: subConfig.keepAlive ?? config.keepAlive, headers: config.headers, backpressure: options.backpressure, + maxDuration: (subConfig as SubscriptionConfig).maxDuration, }); if (subConfig.onSubscribe) { @@ -219,10 +234,11 @@ export const SsePlugin: NamedPlugin = { return async (request: Request, h: ResponseToolkit): Promise => { const session = new Session({ request, - retry: handlerOptions.retry ?? config.retry, + retry: clampRetry(handlerOptions.retry ?? config.retry), keepAlive: handlerOptions.keepAlive ?? config.keepAlive, headers: handlerOptions.headers ?? config.headers, backpressure: handlerOptions.backpressure ?? options.backpressure, + maxDuration: handlerOptions.maxDuration, }); session.initialize(); diff --git a/src/subscription.ts b/src/subscription.ts index 24da043..98466d8 100644 --- a/src/subscription.ts +++ b/src/subscription.ts @@ -22,6 +22,8 @@ export interface SubscriptionConfig { retry?: number | null; keepAlive?: { interval: number } | false; replay?: Replayer; + maxSessions?: number; + maxDuration?: number; } export interface SubscriptionInfo { @@ -89,13 +91,21 @@ export class SubscriptionRegistry { return null; } - addSession(pattern: string, session: Session, params: Record, resolvedPath: string): void { + addSession(pattern: string, session: Session, params: Record, resolvedPath: string): boolean { const sub = this.#subscriptions.get(pattern); - if (sub) { - sub.sessions.add(session); - this.#sessionInfo.set(session, { pattern, params, resolvedPath }); + if (!sub) { + return false; + } + + if (sub.config.maxSessions && sub.sessions.size >= sub.config.maxSessions) { + return false; } + + sub.sessions.add(session); + this.#sessionInfo.set(session, { pattern, params, resolvedPath }); + + return true; } removeSession(session: Session): void { @@ -115,6 +125,10 @@ export class SubscriptionRegistry { return this.#sessionInfo.size; } + subscriptionSessionCount(pattern: string): number { + return this.#subscriptions.get(pattern)?.sessions.size ?? 0; + } + listSubscriptions(): SubscriptionInfo[] { const result: SubscriptionInfo[] = []; diff --git a/test/event-buffer.test.ts b/test/event-buffer.test.ts index eba4aa5..5d8875a 100644 --- a/test/event-buffer.test.ts +++ b/test/event-buffer.test.ts @@ -291,4 +291,76 @@ describe.concurrent('EventBuffer', () => { expect(buf.read()).toBe('event: testinjection\n'); }); + + // Security: retry field CRLF injection (CVE-2026-33128 pattern) + + it('retry rejects string coercion with CRLF injection payload', () => { + const buf = new EventBuffer(); + + // @ts-expect-error — runtime safety for non-TS callers + expect(() => buf.retry('3000\ndata: injected')).toThrow('non-negative integer'); + }); + + it('retry rejects string-number coercion', () => { + const buf = new EventBuffer(); + + // @ts-expect-error — runtime safety for non-TS callers + expect(() => buf.retry('1000')).toThrow('non-negative integer'); + }); + + // Security: combined injection via push() + + it('push() with CRLF in event name does not create extra fields', () => { + const buf = new EventBuffer(); + + buf.push('safe', 'msg\nevent: spoofed', '1'); + + const output = buf.read(); + const eventLines = output.split('\n').filter((l) => l.startsWith('event:')); + + expect(eventLines.length).toBe(1); + expect(eventLines[0]).toBe('event: msgevent: spoofed'); + }); + + it('push() with CRLF in id does not create extra fields', () => { + const buf = new EventBuffer(); + + buf.push('safe', 'msg', '1\nid: spoofed'); + + const output = buf.read(); + const idLines = output.split('\n').filter((l) => l.startsWith('id:')); + + expect(idLines.length).toBe(1); + expect(idLines[0]).toBe('id: 1id: spoofed'); + }); + + it('push() with CRLF in data splits into safe data fields (no field injection)', () => { + const buf = new EventBuffer(); + + buf.push('line1\nevent: spoofed\ndata: injected'); + + const output = buf.read(); + const lines = output.split('\n').filter((l) => l.length > 0); + + // Every non-empty line must be a data: field — no standalone event:/id:/retry: lines + for (const line of lines) { + expect(line.startsWith('data:')).toBe(true); + } + + // The "event: spoofed" text is safely wrapped inside a data: field + expect(output).toContain('data: event: spoofed'); + expect(output).not.toMatch(/^event:/m); + }); + + it('comment() with injection payload splits safely', () => { + const buf = new EventBuffer(); + + buf.comment('keepalive\ndata: injected\nevent: spoofed'); + + const output = buf.read(); + + for (const line of output.split('\n').filter((l) => l.length > 0)) { + expect(line.startsWith(':')).toBe(true); + } + }); }); diff --git a/test/sse.test.ts b/test/sse.test.ts index f2bd92f..72df2ed 100644 --- a/test/sse.test.ts +++ b/test/sse.test.ts @@ -1,6 +1,7 @@ import * as timers from 'node:timers/promises'; import { expect, describe, it } from 'vitest'; import http from 'node:http'; +import net from 'node:net'; import Hapi from '@hapi/hapi'; import Boom from '@hapi/boom'; @@ -3222,6 +3223,655 @@ describe.concurrent('SSE Plugin', () => { expect(events[0]).toContain('data: {"msg":"hello from decorator"}'); }); + // ======================================================================== + // Security Tests — SSE Security Research Gap Coverage + // Ref: sse-security/research/conclusion.md + // ======================================================================== + + // --- Injection: Last-Event-ID CRLF (CWE-93) --- + + it('Last-Event-ID with CRLF via raw TCP is split by HTTP parser (value truncated at newline)', async ({ + onTestFinished, + }) => { + let capturedId = ''; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + onReconnect: (session) => { + capturedId = session.lastEventId; + }, + }); + + await server.start(); + + const port = server.info.port as number; + + const response = await new Promise((resolve) => { + const socket = net.createConnection({ port }, () => { + socket.write( + 'GET /events HTTP/1.1\r\n' + + `Host: localhost:${port}\r\n` + + 'Last-Event-ID: 123\r\nX-Injected: evil\r\n' + + 'Connection: close\r\n' + + '\r\n', + ); + }); + + let data = ''; + + socket.on('data', (chunk) => { + data += chunk.toString(); + }); + + socket.on('end', () => resolve(data)); + socket.on('error', () => resolve(data)); + + setTimeout(() => { + socket.destroy(); + resolve(data); + }, 500); + }); + + expect(response).toContain('HTTP/1.1 200'); + expect(capturedId).toBe('123'); + }); + + it('Last-Event-ID value is not echoed back into SSE stream', async ({ onTestFinished }) => { + let capturedId = ''; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + onReconnect: (session) => { + capturedId = session.lastEventId; + session.push({ reconnected: true }, 'msg', '2'); + }, + }); + + await server.start(); + + const port = server.info.port; + + const maliciousId = ''; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get( + `http://localhost:${port}/events`, + { headers: { 'Last-Event-ID': maliciousId } }, + (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 100); + }); + }, + ); + + req.on('error', () => {}); + }); + + expect(capturedId).toBe(maliciousId); + expect(raw).not.toContain(maliciousId); + }); + + it('Last-Event-ID with null character via raw TCP does not crash server', async ({ onTestFinished }) => { + let capturedId = ''; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + onReconnect: (session) => { + capturedId = session.lastEventId; + }, + }); + + await server.start(); + + const port = server.info.port as number; + + const response = await new Promise((resolve) => { + const socket = net.createConnection({ port }, () => { + socket.write( + 'GET /events HTTP/1.1\r\n' + + `Host: localhost:${port}\r\n` + + 'Last-Event-ID: abc\0def\r\n' + + 'Connection: close\r\n' + + '\r\n', + ); + }); + + let data = ''; + + socket.on('data', (chunk) => { + data += chunk.toString(); + }); + + socket.on('end', () => resolve(data)); + socket.on('error', () => resolve(data)); + + setTimeout(() => { + socket.destroy(); + resolve(data); + }, 500); + }); + + expect(response).toContain('HTTP/1.1'); + }); + + // --- DoS: Retry Floor Enforcement (reconnection storm prevention) --- + + it('retry: 0 is clamped to 1000ms floor', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: 0, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 50); + }); + }); + + req.on('error', () => {}); + }); + + expect(raw).not.toContain('retry: 0'); + expect(raw).toContain('retry: 1000'); + }); + + it('retry: 500 is clamped to 1000ms floor', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: 500, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 50); + }); + }); + + req.on('error', () => {}); + }); + + expect(raw).not.toContain('retry: 500'); + expect(raw).toContain('retry: 1000'); + }); + + it('retry: 2000 is not clamped (above floor)', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: 2000, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 50); + }); + }); + + req.on('error', () => {}); + }); + + expect(raw).toContain('retry: 2000'); + }); + + it('retry: null still disables retry field entirely', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 50); + }); + }); + + req.on('error', () => {}); + }); + + expect(raw).not.toContain('retry:'); + }); + + it('subscription-level retry below floor is clamped', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: 2000, keepAlive: false } }); + server.sse.subscription('/events', { retry: 100 }); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + setTimeout(() => { + req.destroy(); + resolve(data); + }, 50); + }); + }); + + req.on('error', () => {}); + }); + + expect(raw).not.toMatch(/retry: 100\n/); + expect(raw).toContain('retry: 1000'); + }); + + // --- Session Security: Cross-Client Data Isolation --- + + it('concurrent clients on same subscription receive only their own replay data', async ({ onTestFinished }) => { + const replayer = new FiniteReplayer({ size: 100, autoId: true }); + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { replay: replayer }); + await server.start(); + + const port = server.info.port; + + const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); + + await timers.setTimeout(50); + + await server.sse.publish('/events', { n: 1 }, { event: 'msg', id: '1' }); + await p1; + + await server.sse.publish('/events', { n: 2 }, { event: 'msg', id: '2' }); + await server.sse.publish('/events', { n: 3 }, { event: 'msg', id: '3' }); + + const [clientB, clientC] = await Promise.all([ + collectSse(`http://localhost:${port}/events`, { + maxEvents: 2, + headers: { 'Last-Event-ID': '1' }, + }), + collectSse(`http://localhost:${port}/events`, { + maxEvents: 1, + timeout: 300, + }), + ]); + + expect(clientB.events.length).toBe(2); + expect(clientB.events[0]).toContain('data: {"n":2}'); + expect(clientB.events[1]).toContain('data: {"n":3}'); + + expect(clientC.events.length).toBe(0); + }); + + it('session metadata is isolated between concurrent clients', async ({ onTestFinished }) => { + const metadata: Array<{ id: string; peer: unknown }> = []; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + onSubscribe: (session) => { + const id = Math.random().toString(36).slice(2); + + session.set('clientId', id); + }, + }); + + await server.start(); + + const port = server.info.port; + + const p1 = collectSse(`http://localhost:${port}/events`, { timeout: 300 }); + const p2 = collectSse(`http://localhost:${port}/events`, { timeout: 300 }); + + await timers.setTimeout(50); + + await server.sse.eachSession((session) => { + const id = session.get('clientId') as string; + + metadata.push({ id, peer: session.get('peerSecret') }); + }); + + await Promise.all([p1, p2]); + + expect(metadata.length).toBe(2); + expect(metadata[0].id).not.toBe(metadata[1].id); + expect(metadata[0].peer).toBeUndefined(); + expect(metadata[1].peer).toBeUndefined(); + }); + + it('filter receives per-session credentials without cross-leak', async ({ onTestFinished }) => { + const credentialsSeen: unknown[] = []; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + filter: (_path, _message, options) => { + credentialsSeen.push(options.credentials); + + return true; + }, + }); + + await server.start(); + + const port = server.info.port; + + const p1 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); + const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); + + await timers.setTimeout(100); + + await server.sse.publish('/events', { data: 'test' }, { event: 'msg' }); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.events.length).toBe(1); + expect(r2.events.length).toBe(1); + + expect(credentialsSeen.length).toBe(2); + }); + + // --- DoS: Graceful handling under connection pressure --- + + it('rapid subscribe/unsubscribe does not leak sessions', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + const port = server.info.port; + + const connections = Array.from({ length: 10 }, () => + collectSse(`http://localhost:${port}/events`, { timeout: 100 }), + ); + + await Promise.all(connections); + + await timers.setTimeout(200); + + expect(server.sse.sessionCount).toBe(0); + }); + + it('publish after server.stop() does not crash', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + await server.stop({ timeout: 100 }); + + const count = await server.sse.publish('/events', { msg: 'after stop' }); + + expect(count).toBe(0); + }); + + it('broadcast after server.stop() does not crash', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events'); + await server.start(); + + await server.stop({ timeout: 100 }); + + const count = await server.sse.broadcast({ msg: 'after stop' }); + + expect(count).toBe(0); + }); + + // --- Data Leakage: Cross-subscription isolation --- + + it('subscription A publish does not leak to subscription B listeners', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/private'); + server.sse.subscription('/public'); + await server.start(); + + const port = server.info.port; + + const publicPromise = collectSse(`http://localhost:${port}/public`, { timeout: 300 }); + + await timers.setTimeout(50); + + await server.sse.publish('/private', { secret: 'classified' }, { event: 'leak' }); + + const publicResult = await publicPromise; + + expect(publicResult.events.length).toBe(0); + }); + + // --- Connection Security: Kill switch --- + + it('closed session does not receive subsequently published events', async ({ onTestFinished }) => { + let sessionRef: any; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + onSubscribe: (session) => { + sessionRef = session; + }, + }); + + await server.start(); + + const port = server.info.port; + + const promise = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); + + await timers.setTimeout(50); + + sessionRef.close(); + + const count = await server.sse.publish('/events', { msg: 'post-kill' }, { event: 'msg' }); + + await promise; + + expect(count).toBe(0); + }); + + // --- maxSessions: Per-subscription connection limiting --- + + it('maxSessions rejects connections exceeding threshold with 503', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { maxSessions: 2 }); + await server.start(); + + const port = server.info.port; + + const p1 = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); + const p2 = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); + + await timers.setTimeout(100); + + expect(server.sse.sessionCount).toBe(2); + + const rejected = await collectSse(`http://localhost:${port}/events`, { timeout: 500 }); + + expect(rejected.status).toBe(503); + + await Promise.all([p1, p2]); + }); + + it('maxSessions allows new connections after existing ones disconnect', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { maxSessions: 1 }); + await server.start(); + + const port = server.info.port; + + const p1 = collectSse(`http://localhost:${port}/events`, { timeout: 200 }); + + await timers.setTimeout(50); + + expect(server.sse.sessionCount).toBe(1); + + await p1; + await timers.setTimeout(100); + + const p2 = collectSse(`http://localhost:${port}/events`, { maxEvents: 1 }); + + await timers.setTimeout(50); + + await server.sse.publish('/events', { ok: true }); + + const result = await p2; + + expect(result.status).toBe(200); + expect(result.events.length).toBe(1); + }); + + it('maxSessions is per-subscription, not global', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/a', { maxSessions: 1 }); + server.sse.subscription('/b', { maxSessions: 1 }); + await server.start(); + + const port = server.info.port; + + const pa = collectSse(`http://localhost:${port}/a`, { timeout: 300 }); + const pb = collectSse(`http://localhost:${port}/b`, { timeout: 300 }); + + await timers.setTimeout(50); + + expect(server.sse.sessionCount).toBe(2); + + await Promise.all([pa, pb]); + }); + + // --- maxDuration: Connection TTL with forced expiry --- + + it('maxDuration closes session after expiry', async ({ onTestFinished }) => { + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { maxDuration: 200 }); + await server.start(); + + const port = server.info.port; + + const raw = await new Promise((resolve) => { + let data = ''; + const req = http.get(`http://localhost:${port}/events`, (res) => { + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + }); + + res.on('end', () => resolve(data)); + }); + + req.on('error', () => {}); + + setTimeout(() => { + req.destroy(); + resolve(data); + }, 1000); + }); + + expect(raw).toContain(': session expired'); + }); + + it('maxDuration timer is cleared on early close', async ({ onTestFinished }) => { + let sessionRef: any; + + const server = Hapi.server({ port: 0 }); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); + server.sse.subscription('/events', { + maxDuration: 60_000, + onSubscribe: (session) => { + sessionRef = session; + }, + }); + + await server.start(); + + const port = server.info.port; + + const promise = collectSse(`http://localhost:${port}/events`, { timeout: 500 }); + + await timers.setTimeout(50); + + sessionRef.close(); + + await promise; + + expect(() => sessionRef.close()).not.toThrow(); + }); + + // --- Remaining gaps (external layer) --- + + it.todo( + 'EXTERNAL: Origin header validation — implement via Hapi onPreAuth extension or reverse proxy', + ); + + it.todo( + 'EXTERNAL: stream replacement guard — implement via session-aware auth middleware', + ); + describe.concurrent('Edge Cases', () => { it('handles auth configurations in subscription()', async ({ onTestFinished }) => { const server = Hapi.server(); diff --git a/test/subscription.test.ts b/test/subscription.test.ts index 2bc33db..cb4bd87 100644 --- a/test/subscription.test.ts +++ b/test/subscription.test.ts @@ -69,15 +69,34 @@ describe.concurrent('SubscriptionRegistry', () => { expect(delivered).toBe(0); }); - it('addSession does nothing for a non-existent subscription', () => { + it('addSession returns false for a non-existent subscription', () => { const registry = new SubscriptionRegistry(); const session = { request: { headers: {} } } as any; // No subscription registered for '/events' - registry.addSession('/events', session, {}, '/events'); + expect(registry.addSession('/events', session, {}, '/events')).toBe(false); expect(registry.getSessionInfo(session)).toBeUndefined(); }); + it('addSession returns false when maxSessions is reached', () => { + const registry = new SubscriptionRegistry(); + const s1 = { request: { headers: {} } } as any; + const s2 = { request: { headers: {} } } as any; + + registry.register('/events', { maxSessions: 1 }); + + expect(registry.addSession('/events', s1, {}, '/events')).toBe(true); + expect(registry.addSession('/events', s2, {}, '/events')).toBe(false); + expect(registry.getSessionInfo(s2)).toBeUndefined(); + expect(registry.subscriptionSessionCount('/events')).toBe(1); + }); + + it('subscriptionSessionCount returns 0 for non-existent pattern', () => { + const registry = new SubscriptionRegistry(); + + expect(registry.subscriptionSessionCount('/nope')).toBe(0); + }); + it('publish correctly handles failed pushes with filter override', async () => { const registry = new SubscriptionRegistry(); const session = { From 555afcb1b74a86173d30429492b29eb85a6905b0 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Fri, 3 Apr 2026 12:27:04 +0200 Subject: [PATCH 3/5] chore: fix linting issue --- test/sse.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/sse.test.ts b/test/sse.test.ts index 72df2ed..b4c9206 100644 --- a/test/sse.test.ts +++ b/test/sse.test.ts @@ -3322,16 +3322,10 @@ describe.concurrent('SSE Plugin', () => { }); it('Last-Event-ID with null character via raw TCP does not crash server', async ({ onTestFinished }) => { - let capturedId = ''; - const server = Hapi.server({ port: 0 }); onTestFinished(() => server.stop()); await server.register({ plugin: SsePlugin, options: { retry: null, keepAlive: false } }); - server.sse.subscription('/events', { - onReconnect: (session) => { - capturedId = session.lastEventId; - }, - }); + server.sse.subscription('/events'); await server.start(); From 68fd5a26aa6bcca34e2320c45fe62076d72d8a35 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 7 Apr 2026 12:56:21 -0400 Subject: [PATCH 4/5] feat(sse): validate plugin and route configuration with joi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface mistakes in configuration up-front (at register, subscription declaration, route build, and replayer construction) instead of letting them fail silently or surface deep inside request handling. - Plugin options validated against a labeled Joi schema at register() covering keepAlive, retry, headers, hooks, and backpressure. - subscription() asserts the path via Hoek.assert (non-empty, starts with "/") and validates SubscriptionConfig — filter/onSubscribe/ onUnsubscribe/onReconnect, retry, keepAlive, replay (duck-typed Replayer), maxSessions, maxDuration. - The sse handler decoration validates SseHandlerOptions once when the route builds the handler, embedding "METHOD path" in the error so the failing route is obvious. - FiniteReplayer and ValidReplayer constructors validate their own option objects. Hot paths (publish, broadcast, eachSession, Session.push) are deliberately untouched — validation only runs at configuration boundaries that are exercised once per server lifecycle. Adds joi and @hapi/hoek as direct dependencies. --- package.json | 4 + src/replayer.ts | 16 ++++ src/sse.ts | 93 ++++++++++++++++++-- test/sse.test.ts | 217 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5f46789..f1c4499 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,9 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vitest": "^4.1.0" + }, + "dependencies": { + "@hapi/hoek": "^11.0.7", + "joi": "^18.1.2" } } diff --git a/src/replayer.ts b/src/replayer.ts index 00ebd78..1c23e37 100644 --- a/src/replayer.ts +++ b/src/replayer.ts @@ -1,3 +1,5 @@ +import Joi from 'joi'; + export interface ReplayEntry { data: unknown; event?: string; @@ -10,6 +12,16 @@ export interface Replayer { stop?(): void; } +const finiteReplayerOptionsSchema = Joi.object({ + size: Joi.number().integer().positive().required(), + autoId: Joi.boolean(), +}).label('FiniteReplayer options'); + +const validReplayerOptionsSchema = Joi.object({ + ttl: Joi.number().integer().positive().required(), + autoId: Joi.boolean(), +}).label('ValidReplayer options'); + export class FiniteReplayer implements Replayer { /** @internal */ readonly #size: number; @@ -21,6 +33,8 @@ export class FiniteReplayer implements Replayer { #counter = 0; constructor(opts: { size: number; autoId?: boolean }) { + Joi.attempt(opts, finiteReplayerOptionsSchema, 'Invalid FiniteReplayer options:'); + this.#size = opts.size; this.#autoId = opts.autoId ?? false; } @@ -67,6 +81,8 @@ export class ValidReplayer implements Replayer { #counter = 0; constructor(opts: { ttl: number; autoId?: boolean }) { + Joi.attempt(opts, validReplayerOptionsSchema, 'Invalid ValidReplayer options:'); + this.#ttl = opts.ttl; this.#autoId = opts.autoId ?? false; diff --git a/src/sse.ts b/src/sse.ts index f7fe308..1f20d34 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -1,5 +1,7 @@ -import type { NamedPlugin, Request, ResponseToolkit, RouteOptions, Lifecycle } from '@hapi/hapi'; +import type { NamedPlugin, Request, RequestRoute, ResponseToolkit, RouteOptions, Lifecycle } from '@hapi/hapi'; import Boom from '@hapi/boom'; +import * as Hoek from '@hapi/hoek'; +import Joi from 'joi'; import { createRequire } from 'node:module'; import { Session } from './session.js'; @@ -80,10 +82,70 @@ const defaults: Required> = { headers: {}, }; +const keepAliveSchema = Joi.alternatives().try( + Joi.object({ interval: Joi.number().integer().positive().required() }), + Joi.valid(false), +); + +const retrySchema = Joi.number().integer().min(0).allow(null); + +const headersSchema = Joi.object().pattern(Joi.string(), Joi.string()); + +const backpressureSchema = Joi.object({ + maxBytes: Joi.number().integer().positive().required(), + strategy: Joi.string().valid('close', 'drop').required(), +}); + +const hooksSchema = Joi.object({ + onSession: Joi.function(), + onSessionClose: Joi.function(), + onPublish: Joi.function(), +}); + +const pluginOptionsSchema = Joi.object({ + keepAlive: keepAliveSchema, + retry: retrySchema, + headers: headersSchema, + hooks: hooksSchema, + backpressure: backpressureSchema, +}).label('SsePluginOptions'); + +const replayerSchema = Joi.object({ + record: Joi.function().required(), + replay: Joi.function().required(), + stop: Joi.function(), +}) + .unknown(true) + .label('Replayer'); + +const subscriptionConfigSchema = Joi.object({ + auth: Joi.any(), + filter: Joi.function(), + onSubscribe: Joi.function(), + onUnsubscribe: Joi.function(), + onReconnect: Joi.function(), + retry: retrySchema, + keepAlive: keepAliveSchema, + replay: replayerSchema, + maxSessions: Joi.number().integer().positive(), + maxDuration: Joi.number().integer().positive(), +}).label('SubscriptionConfig'); + +const handlerOptionsSchema = Joi.object({ + stream: Joi.function().required(), + retry: retrySchema, + keepAlive: keepAliveSchema, + headers: headersSchema, + backpressure: backpressureSchema, + maxDuration: Joi.number().integer().positive(), +}).label('SseHandlerOptions'); + export const SsePlugin: NamedPlugin = { name: '@hapi/sse', version, register: (server, options) => { + Joi.attempt(options, pluginOptionsSchema, 'Invalid @hapi/sse plugin options:'); + const config = { ...defaults, ...options }; const registry = new SubscriptionRegistry(); const hooks = options.hooks; @@ -96,6 +158,21 @@ export const SsePlugin: NamedPlugin = { const api: SseApi = { subscription: (path, subConfig = {}) => { + Hoek.assert( + typeof path === 'string' && path.length > 0, + 'sse.subscription(path): path must be a non-empty string', + ); + Hoek.assert( + path.startsWith('/'), + `sse.subscription(path): path must start with "/" (got ${JSON.stringify(path)})`, + ); + + Joi.attempt( + subConfig, + subscriptionConfigSchema, + `Invalid @hapi/sse subscription config for "${path}":`, + ); + registry.register(path, subConfig as SubscriptionConfig); const routeConfig: RouteOptions = {}; @@ -111,7 +188,7 @@ export const SsePlugin: NamedPlugin = { handler: async (request: Request, h: ResponseToolkit) => { const matched = registry.matchPath(request.path)!; - const maxSessions = (subConfig as SubscriptionConfig).maxSessions; + const maxSessions = subConfig.maxSessions; if (maxSessions && registry.subscriptionSessionCount(matched.pattern) >= maxSessions) { return Boom.serverUnavailable('Too many connections'); @@ -123,7 +200,7 @@ export const SsePlugin: NamedPlugin = { keepAlive: subConfig.keepAlive ?? config.keepAlive, headers: config.headers, backpressure: options.backpressure, - maxDuration: (subConfig as SubscriptionConfig).maxDuration, + maxDuration: subConfig.maxDuration, }); if (subConfig.onSubscribe) { @@ -142,7 +219,7 @@ export const SsePlugin: NamedPlugin = { } } - const replayer = (subConfig as SubscriptionConfig).replay; + const replayer = subConfig.replay; if (session.lastEventId && replayer) { const entries = replayer.replay(session.lastEventId); @@ -230,7 +307,13 @@ export const SsePlugin: NamedPlugin = { server.decorate('server', 'sse', api); - server.decorate('handler', 'sse', (_route: unknown, handlerOptions: SseHandlerOptions) => { + server.decorate('handler', 'sse', (route: RequestRoute, handlerOptions: SseHandlerOptions) => { + Joi.attempt( + handlerOptions, + handlerOptionsSchema, + `Invalid @hapi/sse handler options for ${route.method.toUpperCase()} ${route.path}:`, + ); + return async (request: Request, h: ResponseToolkit): Promise => { const session = new Session({ request, diff --git a/test/sse.test.ts b/test/sse.test.ts index b4c9206..dda285f 100644 --- a/test/sse.test.ts +++ b/test/sse.test.ts @@ -3946,4 +3946,221 @@ describe.concurrent('SSE Plugin', () => { await timers.setTimeout(100); }); }); + + // --- Runtime validation: catches developer mistakes early --- + + describe.concurrent('Runtime validation', () => { + it('rejects non-positive keepAlive interval at register()', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { keepAlive: { interval: 0 } }, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*keepAlive/i); + }); + + it('rejects negative retry at register()', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { retry: -100 }, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*retry/i); + }); + + it('rejects unknown plugin option keys', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { keepAlve: false } as never, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*keepAlve/i); + }); + + it('rejects non-function hook in plugin options', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { hooks: { onSession: 'not-a-function' as never } }, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*onSession/i); + }); + + it('rejects backpressure with invalid strategy', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { + backpressure: { maxBytes: 1000, strategy: 'kaboom' as never }, + }, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*strategy/i); + }); + + it('rejects backpressure with non-integer maxBytes', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + + await expect( + server.register({ + plugin: SsePlugin, + options: { + backpressure: { maxBytes: 1.5, strategy: 'drop' }, + }, + }), + ).rejects.toThrow(/Invalid @hapi\/sse plugin options.*maxBytes/i); + }); + + it('rejects subscription path that is not a string', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => server.sse.subscription(123 as never)).toThrow(/sse\.subscription\(path\)/); + }); + + it('rejects subscription path missing leading slash', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => server.sse.subscription('events')).toThrow(/must start with "\/"/); + }); + + it('rejects empty subscription path', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => server.sse.subscription('')).toThrow(/non-empty string/); + }); + + it('rejects subscription with non-function filter', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.sse.subscription('/events', { filter: 'nope' as never }), + ).toThrow(/Invalid @hapi\/sse subscription config for "\/events".*filter/i); + }); + + it('rejects subscription with non-positive maxSessions', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.sse.subscription('/events', { maxSessions: 0 }), + ).toThrow(/maxSessions/); + }); + + it('rejects subscription with non-positive maxDuration', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.sse.subscription('/events', { maxDuration: -1 }), + ).toThrow(/maxDuration/); + }); + + it('rejects subscription with replayer missing record/replay', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.sse.subscription('/events', { replay: { record: () => {} } as never }), + ).toThrow(/replay/i); + }); + + it('rejects unknown subscription config key', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.sse.subscription('/events', { onUnsubcsribe: () => {} } as never), + ).toThrow(/onUnsubcsribe/); + }); + + it('rejects sse handler decoration missing stream', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.route({ + method: 'GET', + path: '/stream', + handler: { sse: {} as never }, + }), + ).toThrow(/Invalid @hapi\/sse handler options for GET \/stream.*stream/i); + }); + + it('rejects sse handler decoration with non-function stream', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.route({ + method: 'GET', + path: '/stream', + handler: { sse: { stream: 'nope' as never } }, + }), + ).toThrow(/stream/); + }); + + it('rejects sse handler decoration with bad backpressure', async ({ onTestFinished }) => { + const server = Hapi.server(); + onTestFinished(() => server.stop()); + await server.register({ plugin: SsePlugin }); + + expect(() => + server.route({ + method: 'GET', + path: '/stream', + handler: { + sse: { + stream: () => {}, + backpressure: { maxBytes: -1, strategy: 'drop' }, + }, + }, + }), + ).toThrow(/maxBytes/); + }); + + it('FiniteReplayer rejects non-positive size', () => { + expect(() => new FiniteReplayer({ size: 0 })).toThrow(/Invalid FiniteReplayer options.*size/i); + }); + + it('FiniteReplayer rejects missing size', () => { + expect(() => new FiniteReplayer({} as never)).toThrow(/Invalid FiniteReplayer options.*size/i); + }); + + it('ValidReplayer rejects non-positive ttl', () => { + expect(() => new ValidReplayer({ ttl: 0 })).toThrow(/Invalid ValidReplayer options.*ttl/i); + }); + + it('ValidReplayer rejects non-integer ttl', () => { + expect(() => new ValidReplayer({ ttl: 50.5 })).toThrow(/Invalid ValidReplayer options.*ttl/i); + }); + }); }); From 8b6224bc8639aaad74a74279087bb9ccc2a4966d Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Tue, 7 Apr 2026 19:25:27 +0200 Subject: [PATCH 5/5] chore: add release script --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f1c4499..fa85a8c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "scripts": { "test": "vitest run", "lint": "eslint .", - "build": "tsdown" + "build": "tsdown", + "prepublishOnly": "node --run build" }, "keywords": [], "author": "Danilo Alonso",