@dokebi/sse 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ export type { SseErrorCode } from './errors.js';
2
+ export { SseError } from './errors.js';
3
+ export type { Options as SseOptions } from './options.js';
4
+ export { parseOptions } from './options.js';
5
+ export { createPlugin, dependency } from './plugin.js';
6
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { SseError } from './errors.js';
2
+ export { parseOptions } from './options.js';
3
+ export { createPlugin, dependency } from './plugin.js';
4
+ export * from './types.js';
@@ -0,0 +1,18 @@
1
+ interface InstallContext {
2
+ readonly options: Readonly<Record<string, unknown>>;
3
+ }
4
+ interface Diagnostic {
5
+ readonly code: string;
6
+ readonly severity: 'error' | 'warning';
7
+ readonly optionPath?: readonly string[];
8
+ readonly hint?: string;
9
+ }
10
+ /** `bus` set: publications cross processes through dokebi-event-bus, so it is required. */
11
+ export declare function planInstall(context: InstallContext): {
12
+ requires: string[];
13
+ };
14
+ export declare function check(context: InstallContext): {
15
+ requires: string[];
16
+ diagnostics: Diagnostic[];
17
+ };
18
+ export {};
@@ -0,0 +1,24 @@
1
+ // Install lifecycle module (`dokebi.installModule`). It runs isolated in the CLI, so it imports
2
+ // nothing and only reads the plain options it is given. Invalid options are left to the schema.
3
+ const EVENT_BUS = 'dokebi-event-bus';
4
+ function usesBus(options) {
5
+ return (options !== null &&
6
+ typeof options === 'object' &&
7
+ typeof options.bus === 'string');
8
+ }
9
+ /** `bus` set: publications cross processes through dokebi-event-bus, so it is required. */
10
+ export function planInstall(context) {
11
+ return { requires: usesBus(context.options) ? [EVENT_BUS] : [] };
12
+ }
13
+ export function check(context) {
14
+ const diagnostics = [];
15
+ if (usesBus(context.options) === false &&
16
+ context.options?.allowInCluster === true)
17
+ diagnostics.push({
18
+ code: 'SSE_SINGLE_PROCESS',
19
+ severity: 'warning',
20
+ optionPath: ['allowInCluster'],
21
+ hint: 'Without bus, publications reach one worker only; set bus to a nats event bus',
22
+ });
23
+ return { requires: planInstall(context).requires, diagnostics };
24
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { LogFields, Logger, LogLevel } from '@dokebi/core';
2
+ /**
3
+ * The full text of a caught value for the `error` log field (D-0047): name, whole message and
4
+ * stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
5
+ * removed; a text over 16 KB is cut and marked.
6
+ */
7
+ export declare function errorText(error: unknown): string;
8
+ /** A stable code of an error (hub, ws or errno code), or `fallback`. */
9
+ export declare function errorCode(error: unknown, fallback: string): string;
10
+ /**
11
+ * Logs a repeating event once per interval (D-0047): the first occurrence at once, the following
12
+ * ones of the same message and code (and `group`, when given) are counted and reported together
13
+ * (`repeated`) when the interval ends. `group` must come from a bounded set (e.g. registered
14
+ * handler types), never from client input, since each value keeps an entry for an interval. Hot paths (per message, per frame) can report every failure without a burst
15
+ * flooding the log. Field and error text are built only for lines that are written.
16
+ */
17
+ export declare class LogThrottle {
18
+ #private;
19
+ readonly logger: Logger;
20
+ readonly intervalMs: number;
21
+ constructor(logger: Logger, intervalMs?: number);
22
+ report(level: LogLevel, message: string, code: string, error?: unknown, fields?: LogFields, group?: string): void;
23
+ /** Writes the counts still pending (on close), so a burst at shutdown is not lost. */
24
+ flush(): void;
25
+ }
package/dist/log.js ADDED
@@ -0,0 +1,102 @@
1
+ const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
2
+ /**
3
+ * The full text of a caught value for the `error` log field (D-0047): name, whole message and
4
+ * stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
5
+ * removed; a text over 16 KB is cut and marked.
6
+ */
7
+ export function errorText(error) {
8
+ const parts = [];
9
+ let current = error;
10
+ for (let depth = 0; current !== undefined && depth < 5; depth++) {
11
+ let text;
12
+ if (current instanceof Error)
13
+ text = current.stack ?? `${current.name}: ${current.message}`;
14
+ else if (typeof current === 'string')
15
+ text = current;
16
+ else {
17
+ try {
18
+ text = `NonError: ${JSON.stringify(current)}`;
19
+ }
20
+ catch {
21
+ text = `NonError: ${String(current)}`; // not serialisable (cycle, BigInt): its string form
22
+ }
23
+ }
24
+ parts.push(depth ? `Caused by: ${text}` : text);
25
+ current = current instanceof Error ? current.cause : undefined;
26
+ }
27
+ const text = parts.join('\n').replace(/\/\/[^\s/@]*@/g, '//***@');
28
+ return text.length > 16_384
29
+ ? `${text.slice(0, 16_384)}… [truncated ${text.length - 16_384} characters]`
30
+ : text;
31
+ }
32
+ /** A stable code of an error (hub, ws or errno code), or `fallback`. */
33
+ export function errorCode(error, fallback) {
34
+ const code = error?.code;
35
+ return typeof code === 'string' && CODE.test(code) ? code : fallback;
36
+ }
37
+ /**
38
+ * Logs a repeating event once per interval (D-0047): the first occurrence at once, the following
39
+ * ones of the same message and code (and `group`, when given) are counted and reported together
40
+ * (`repeated`) when the interval ends. `group` must come from a bounded set (e.g. registered
41
+ * handler types), never from client input, since each value keeps an entry for an interval. Hot paths (per message, per frame) can report every failure without a burst
42
+ * flooding the log. Field and error text are built only for lines that are written.
43
+ */
44
+ export class LogThrottle {
45
+ logger;
46
+ intervalMs;
47
+ #entries = new Map();
48
+ constructor(logger, intervalMs = 10_000) {
49
+ this.logger = logger;
50
+ this.intervalMs = intervalMs;
51
+ }
52
+ report(level, message, code, error, fields, group) {
53
+ if (!this.logger.isEnabled(level))
54
+ return;
55
+ const key = `${level}\u0000${code}\u0000${message}${group === undefined ? '' : `\u0000${group}`}`;
56
+ const entry = this.#entries.get(key);
57
+ if (entry) {
58
+ entry.count++;
59
+ entry.error = error;
60
+ entry.fields = fields;
61
+ return;
62
+ }
63
+ this.#write(level, message, code, error, fields, 0);
64
+ const value = {
65
+ count: 0,
66
+ error: undefined,
67
+ fields: undefined,
68
+ timer: setTimeout(() => this.#elapsed(key, value, level, message, code), this.intervalMs),
69
+ };
70
+ value.timer.unref();
71
+ this.#entries.set(key, value);
72
+ }
73
+ #elapsed(key, entry, level, message, code) {
74
+ if (!entry.count) {
75
+ this.#entries.delete(key);
76
+ return;
77
+ }
78
+ this.#write(level, message, code, entry.error, entry.fields, entry.count);
79
+ entry.count = 0;
80
+ entry.timer = setTimeout(() => this.#elapsed(key, entry, level, message, code), this.intervalMs);
81
+ entry.timer.unref();
82
+ }
83
+ #write(level, message, code, error, fields, repeated) {
84
+ this.logger.log(level, message, {
85
+ ...fields,
86
+ code,
87
+ ...(error === undefined ? {} : { error: errorText(error) }),
88
+ ...(repeated ? { repeated, intervalMs: this.intervalMs } : {}),
89
+ });
90
+ }
91
+ /** Writes the counts still pending (on close), so a burst at shutdown is not lost. */
92
+ flush() {
93
+ for (const [key, entry] of this.#entries) {
94
+ clearTimeout(entry.timer);
95
+ if (entry.count) {
96
+ const [level, code, message] = key.split('\u0000');
97
+ this.#write(level, message, code, entry.error, entry.fields, entry.count);
98
+ }
99
+ }
100
+ this.#entries.clear();
101
+ }
102
+ }
@@ -0,0 +1,27 @@
1
+ export interface Options {
2
+ /**
3
+ * The dokebi-event-bus bus (`buses.<name>`) that carries publications to the other workers and
4
+ * servers. Unset: publish reaches the streams of this process only.
5
+ */
6
+ readonly bus: string | undefined;
7
+ /** The bus event name publications travel under. */
8
+ readonly busEvent: string;
9
+ /** Lets a node:cluster worker start without a NATS bus (a deliberate single-worker cluster). */
10
+ readonly allowInCluster: boolean;
11
+ /** The `retry:` hint sent first on every stream (how long a browser waits before reconnecting). */
12
+ readonly retryMs: number;
13
+ /** How often a `: ping` comment is written to every stream (keeps proxies from closing it). */
14
+ readonly heartbeatMs: number;
15
+ /** Open streams per process (per worker); more are refused with 503 CAPACITY. */
16
+ readonly maxConnections: number;
17
+ /** Bytes a stream may have waiting to be sent; past it the stream is ended (SLOW_CONSUMER). */
18
+ readonly maxBufferedBytes: number;
19
+ /** Rooms one stream may be in. */
20
+ readonly maxRooms: number;
21
+ /** Largest JSON `data` of one event, in bytes. */
22
+ readonly maxEventBytes: number;
23
+ /** How long drain waits for streams to end after `dokebi-restart` before cutting them. */
24
+ readonly closeTimeoutMs: number;
25
+ }
26
+ /** Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults. */
27
+ export declare function parseOptions(input: unknown): Options;
@@ -0,0 +1,63 @@
1
+ import { types } from 'node:util';
2
+ import { SseError } from './errors.js';
3
+ const BUS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
4
+ /** The event-bus event name rule (dot-separated tokens starting with a letter). */
5
+ const BUS_EVENT = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/;
6
+ const LIMITS = {
7
+ retryMs: [3000, 100, 3_600_000],
8
+ heartbeatMs: [15000, 10, 3_600_000],
9
+ maxConnections: [1024, 1, 1_000_000],
10
+ maxBufferedBytes: [1_048_576, 1, 67_108_864],
11
+ maxRooms: [32, 0, 1024],
12
+ maxEventBytes: [65_536, 1, 16_777_216],
13
+ closeTimeoutMs: [2000, 1, 600_000],
14
+ };
15
+ const KEYS = new Set(['bus', 'busEvent', 'allowInCluster', ...Object.keys(LIMITS)]);
16
+ function invalid(message) {
17
+ throw new SseError('INVALID_OPTIONS', message);
18
+ }
19
+ /** Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults. */
20
+ export function parseOptions(input) {
21
+ if (input === undefined)
22
+ input = {};
23
+ if (!input ||
24
+ typeof input !== 'object' ||
25
+ Array.isArray(input) ||
26
+ types.isProxy(input) ||
27
+ ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
28
+ invalid('SSE options must be a plain object.');
29
+ const value = Object.create(null);
30
+ for (const key of Reflect.ownKeys(input)) {
31
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
32
+ if (typeof key !== 'string' ||
33
+ !descriptor ||
34
+ !('value' in descriptor) ||
35
+ !descriptor.enumerable)
36
+ invalid('SSE options must hold plain data properties.');
37
+ if (!KEYS.has(key))
38
+ invalid(`Unknown SSE option: ${key}`);
39
+ value[key] = descriptor.value;
40
+ }
41
+ const bus = value.bus;
42
+ if (bus !== undefined && (typeof bus !== 'string' || !BUS.test(bus)))
43
+ invalid('bus must be the name of a dokebi-event-bus bus.');
44
+ const busEvent = value.busEvent ?? 'dokebi.sse';
45
+ if (typeof busEvent !== 'string' || busEvent.length > 128 || !BUS_EVENT.test(busEvent))
46
+ invalid('busEvent must be an event-bus event name such as dokebi.sse.');
47
+ const allowInCluster = value.allowInCluster ?? false;
48
+ if (typeof allowInCluster !== 'boolean')
49
+ invalid('allowInCluster must be a boolean.');
50
+ const limits = {};
51
+ for (const [key, [fallback, min, max]] of Object.entries(LIMITS)) {
52
+ const number = value[key] ?? fallback;
53
+ if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min || number > max)
54
+ invalid(`${key} must be an integer from ${min} to ${max}.`);
55
+ limits[key] = number;
56
+ }
57
+ return Object.freeze({
58
+ bus,
59
+ busEvent,
60
+ allowInCluster,
61
+ ...limits,
62
+ });
63
+ }
@@ -0,0 +1,6 @@
1
+ import { type PluginDependency } from '@dokebi/core';
2
+ import type { SseApi } from './types.js';
3
+ /** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
4
+ export declare const createPlugin: import("@dokebi/core").PluginFactory<SseApi>;
5
+ /** Declares dokebi-sse as a required provider; it is created from this package when absent. */
6
+ export declare const dependency: PluginDependency<SseApi>;
package/dist/plugin.js ADDED
@@ -0,0 +1,90 @@
1
+ import cluster from 'node:cluster';
2
+ import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
3
+ import { SseError } from './errors.js';
4
+ import { Gateway } from './gateway.js';
5
+ import { parseOptions } from './options.js';
6
+ const web = providerDependency({
7
+ name: 'dokebi-web',
8
+ version: '^0.2.0',
9
+ packageName: '@dokebi/web',
10
+ load: () => import('@dokebi/web'),
11
+ });
12
+ /** Only when `bus` is set; created from `app.config['dokebi-event-bus']` when not registered. */
13
+ const events = providerDependency({
14
+ name: 'dokebi-event-bus',
15
+ version: '^0.1.0',
16
+ packageName: '@dokebi/event-bus',
17
+ load: () => import('@dokebi/event-bus'),
18
+ });
19
+ const health = optionalDependency({ name: 'dokebi-health', version: '^0.1.0' });
20
+ /**
21
+ * The transport of a bus as dokebi-event-bus options in the application config name it.
22
+ * The event-bus API does not expose it, so options given to that plugin in code are unknown here.
23
+ */
24
+ function configuredTransport(config, bus) {
25
+ const options = config?.['dokebi-event-bus'];
26
+ const transport = options?.buses?.[bus]?.transport;
27
+ return typeof transport === 'string' ? transport : undefined;
28
+ }
29
+ class SseRuntime {
30
+ options;
31
+ #gateway;
32
+ constructor(options) {
33
+ this.options = options;
34
+ }
35
+ async setup(context) {
36
+ const started = performance.now();
37
+ const { options } = this;
38
+ // A publication must reach the streams of every worker. Without a NATS bus each worker
39
+ // reaches only its own clients, which works by accident and fails silently in use.
40
+ if (cluster.isWorker && !options.allowInCluster) {
41
+ const transport = options.bus === undefined
42
+ ? 'none'
43
+ : (configuredTransport(context.app.config, options.bus) ?? 'unknown');
44
+ if (transport !== 'nats') {
45
+ context.logger.log('error', 'SSE in a cluster worker without a NATS bus', {
46
+ code: 'SSE_LOCAL_IN_CLUSTER',
47
+ bus: options.bus ?? null,
48
+ transport,
49
+ fix: 'Set dokebi-sse.bus to a dokebi-event-bus bus with transport nats (configured in ' +
50
+ 'dokebi-event-bus.buses), or allowInCluster: true for a deliberate single-worker cluster',
51
+ });
52
+ throw new SseError('SSE_LOCAL_IN_CLUSTER', 'SSE publications would reach only this worker: use a nats bus (dokebi-sse.bus).');
53
+ }
54
+ }
55
+ const bus = options.bus === undefined ? undefined : context.getDependency(events).bus(options.bus);
56
+ const gateway = new Gateway(context, context.getDependency(web), options, bus, context.getOptionalDependency(health));
57
+ context.defer(() => gateway.close());
58
+ this.#gateway = gateway;
59
+ await gateway.subscribe();
60
+ context.logger.log('debug', 'SSE setup finished', {
61
+ bus: options.bus ?? null,
62
+ busEvent: options.busEvent,
63
+ cluster: cluster.isWorker ? 'worker' : 'none',
64
+ ms: Math.round(performance.now() - started),
65
+ });
66
+ return gateway.api;
67
+ }
68
+ start() {
69
+ this.#gateway?.start();
70
+ }
71
+ async drain(context) {
72
+ await this.#gateway?.drain(context.drainSignal);
73
+ }
74
+ }
75
+ /** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
76
+ export const createPlugin = definePlugin({
77
+ name: 'dokebi-sse',
78
+ version: '0.1.0',
79
+ core: '^0.2.0',
80
+ options: parseOptions,
81
+ dependencies: ({ options }) => options.bus === undefined ? [web, health] : [web, events, health],
82
+ instance: ({ options }) => new SseRuntime(options),
83
+ });
84
+ /** Declares dokebi-sse as a required provider; it is created from this package when absent. */
85
+ export const dependency = providerDependency({
86
+ name: 'dokebi-sse',
87
+ packageName: '@dokebi/sse',
88
+ version: '^0.1.0',
89
+ load: () => import('./index.js'),
90
+ });
@@ -0,0 +1,132 @@
1
+ import type { Awaitable } from '@dokebi/core';
2
+ import type { FastifyRequest } from 'fastify';
3
+ /** Who a stream belongs to, as `authenticate` decided. Extra fields are kept on `stream.identity`. */
4
+ export interface SseIdentity {
5
+ readonly tenant: string;
6
+ readonly member: string;
7
+ /** Rooms the stream starts in (counted against maxRooms). */
8
+ readonly rooms?: readonly string[];
9
+ /**
10
+ * Which realtime namespace of the app the stream joins (D-0061). Read and removed by
11
+ * @dokebi/app, which prefixes the tenant with it; this package ignores it.
12
+ */
13
+ readonly namespace?: string;
14
+ }
15
+ /** Why a stream ended, as `onClose` and the debug log report it. */
16
+ export type SseCloseReason =
17
+ /** The client went away (tab closed, network lost, EventSource.close()). */
18
+ 'CLIENT_CLOSED'
19
+ /** `stream.close()`. */
20
+ | 'SERVER_CLOSED'
21
+ /** More than maxBufferedBytes waited to be sent. */
22
+ | 'SLOW_CONSUMER'
23
+ /** The application is stopping; the stream got `dokebi-restart` first. */
24
+ | 'DRAIN'
25
+ /** `onOpen` threw. */
26
+ | 'ON_OPEN_FAILED'
27
+ /** The response failed (socket error). */
28
+ | 'WRITE_FAILED';
29
+ /** One open event stream of this process. */
30
+ export interface SseStream<TIdentity extends SseIdentity = SseIdentity> {
31
+ /** A random id of this stream (not an event id). */
32
+ readonly id: string;
33
+ readonly tenant: string;
34
+ readonly member: string;
35
+ /** The object `authenticate` returned. */
36
+ readonly identity: TIdentity;
37
+ /** The route path. */
38
+ readonly path: string;
39
+ /**
40
+ * The `Last-Event-ID` the client sent (EventSource does on its own reconnects), or the
41
+ * `lastEventId` query of a client that reopened by itself. The server keeps no history
42
+ * (at most once): this is for the application to resend what it can.
43
+ */
44
+ readonly lastEventId: string | undefined;
45
+ /** The rooms this stream is in now. */
46
+ readonly rooms: readonly string[];
47
+ readonly closed: boolean;
48
+ /**
49
+ * Sends one event to this client only. False when the stream is closed (or was just ended as a
50
+ * slow consumer). Throws INVALID_EVENT, INVALID_DATA or EVENT_TOO_LARGE.
51
+ */
52
+ send(event: string, data?: unknown): boolean;
53
+ /** Joins a room of this stream's tenant (this connection only). Throws ROOM_LIMIT, INVALID_ROOM. */
54
+ join(room: string): void;
55
+ leave(room: string): void;
56
+ /** Ends the stream (`SERVER_CLOSED`). The browser's EventSource reconnects after `retry`. */
57
+ close(): void;
58
+ }
59
+ export interface SseRoute<TIdentity extends SseIdentity = SseIdentity> {
60
+ /** The GET path of the stream. */
61
+ readonly path: string;
62
+ /** Fastify route constraints, for example `{ dokebiApp: 'shop' }` of @dokebi/app. */
63
+ readonly constraints?: Readonly<Record<string, unknown>>;
64
+ /**
65
+ * Browser origins allowed besides the page's own host. EventSource sends `Origin` on
66
+ * cross-origin requests; a request whose Origin host differs from the request host and is not
67
+ * listed gets 403. Listed origins get CORS headers with credentials. `'*'` allows every origin
68
+ * without credentials (cookies are not sent then).
69
+ */
70
+ readonly origins?: readonly string[] | '*';
71
+ /**
72
+ * The client code version this route accepts (D-0033). A client whose `dokebiVersion` query
73
+ * (or `x-dokebi-version` header) differs gets `event: dokebi-reload` and the stream ends.
74
+ */
75
+ readonly version?: string;
76
+ /** Who is asking; undefined answers 401. Runs on every (re)connect. */
77
+ authenticate(request: FastifyRequest): Awaitable<TIdentity | undefined>;
78
+ /**
79
+ * Called once the stream is open and already receives publications. Rooms joined here (or in
80
+ * `authenticate`) are this connection's only: a reconnect starts without them.
81
+ */
82
+ onOpen?(stream: SseStream<TIdentity>): Awaitable<void>;
83
+ onClose?(stream: SseStream<TIdentity>, reason: SseCloseReason): Awaitable<void>;
84
+ }
85
+ /** Whom a publication is for: a tenant, one member of it, a room of it, or a member in a room. */
86
+ export interface SseTarget {
87
+ readonly tenant: string;
88
+ readonly member?: string;
89
+ readonly room?: string;
90
+ }
91
+ /**
92
+ * Every tenant whose name starts with `tenantPrefix` (D-0061: a realtime namespace of @dokebi/app
93
+ * is the prefix `<namespace>:`), or one member and/or room of each of them. Each process matches
94
+ * it against its own streams, so it costs one pass over that process's tenants.
95
+ */
96
+ export interface SsePrefixTarget {
97
+ readonly tenantPrefix: string;
98
+ readonly member?: string;
99
+ readonly room?: string;
100
+ }
101
+ export interface SsePublishResult {
102
+ /** The event id every recipient sees as `lastEventId`. */
103
+ readonly id: string;
104
+ /** Streams of this process the event was written to. */
105
+ readonly local: number;
106
+ }
107
+ /** Narrows `connections()`; every field is optional. */
108
+ export interface SseFilter {
109
+ readonly tenant?: string;
110
+ readonly member?: string;
111
+ readonly room?: string;
112
+ readonly path?: string;
113
+ }
114
+ export interface SseApi {
115
+ /** Adds an event-stream route. Setup only. */
116
+ route<TIdentity extends SseIdentity = SseIdentity>(route: SseRoute<TIdentity>): void;
117
+ /**
118
+ * Sends one event to the matching streams of every process on the bus (this one included,
119
+ * once). At most once: no acknowledgement, no replay, nothing is resent. Fails with
120
+ * INVALID_TARGET, INVALID_EVENT, INVALID_DATA, EVENT_TOO_LARGE, BUS_UNAVAILABLE (nothing sent)
121
+ * or BUS_PUBLISH_FAILED (sent here, other processes unknown; the error has `id` and `local`).
122
+ */
123
+ publish(target: SseTarget | SsePrefixTarget, event: string, data?: unknown): Promise<SsePublishResult>;
124
+ /** Open streams of this process (worker) that match. */
125
+ connections(filter?: SseFilter): number;
126
+ /** The bus name, when publications cross processes. */
127
+ readonly bus: string | undefined;
128
+ }
129
+ /** The events the plugin itself sends; application event names may not start with `dokebi-`. */
130
+ export declare const RELOAD_EVENT = "dokebi-reload";
131
+ export declare const RESTART_EVENT = "dokebi-restart";
132
+ export declare const OPEN_EVENT = "dokebi-open";
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /** The events the plugin itself sends; application event names may not start with `dokebi-`. */
2
+ export const RELOAD_EVENT = 'dokebi-reload';
3
+ export const RESTART_EVENT = 'dokebi-restart';
4
+ export const OPEN_EVENT = 'dokebi-open';
package/dist/wire.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Event names: like WebSocket message types (D-0033 router), `dokebi-` kept for the plugin. */
2
+ export declare const EVENT: RegExp;
3
+ /** Event ids: the publishing process prefix, `-`, a base-36 counter. */
4
+ export declare const ID: RegExp;
5
+ export declare function checkEvent(event: unknown): string;
6
+ /** The JSON text of `data` on one line, within maxEventBytes. `undefined` is sent as null. */
7
+ export declare function encodeData(data: unknown, maxBytes: number): string;
8
+ /** True when `json` (from the bus) can be written as a `data:` line as it is. */
9
+ export declare function isLine(text: string): boolean;
10
+ export declare function frame(id: string | undefined, event: string, json: string): string;
11
+ export declare const PING: Buffer<ArrayBuffer>;
12
+ export declare const HEADERS: Readonly<{
13
+ 'content-type': "text/event-stream; charset=utf-8";
14
+ 'cache-control': "no-cache, no-transform";
15
+ 'x-accel-buffering': "no";
16
+ connection: "keep-alive";
17
+ }>;
package/dist/wire.js ADDED
@@ -0,0 +1,44 @@
1
+ import { SseError } from './errors.js';
2
+ /** Event names: like WebSocket message types (D-0033 router), `dokebi-` kept for the plugin. */
3
+ export const EVENT = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/;
4
+ /** Event ids: the publishing process prefix, `-`, a base-36 counter. */
5
+ export const ID = /^[A-Za-z0-9-]{1,64}$/;
6
+ /** Text that may sit on one SSE field line (no line breaks, no NUL). */
7
+ const LINE = /^[^\r\n\0]*$/;
8
+ export function checkEvent(event) {
9
+ if (typeof event !== 'string' || !EVENT.test(event) || event.startsWith('dokebi-'))
10
+ throw new SseError('INVALID_EVENT', 'Event names start with a letter, hold up to 64 of A-Z a-z 0-9 . _ : - and do not start with dokebi-.');
11
+ return event;
12
+ }
13
+ /** The JSON text of `data` on one line, within maxEventBytes. `undefined` is sent as null. */
14
+ export function encodeData(data, maxBytes) {
15
+ let json;
16
+ try {
17
+ json = data === undefined ? 'null' : JSON.stringify(data);
18
+ }
19
+ catch (cause) {
20
+ throw new SseError('INVALID_DATA', 'Event data is not JSON (a cycle or a BigInt).', { cause });
21
+ }
22
+ if (json === undefined)
23
+ throw new SseError('INVALID_DATA', 'Event data is not JSON (a function or a symbol).');
24
+ // JSON.stringify escapes CR and LF, so the text always fits on one `data:` line.
25
+ if (json.length > maxBytes || Buffer.byteLength(json) > maxBytes)
26
+ throw new SseError('EVENT_TOO_LARGE', `Event data is larger than maxEventBytes (${maxBytes}).`);
27
+ return json;
28
+ }
29
+ /** True when `json` (from the bus) can be written as a `data:` line as it is. */
30
+ export function isLine(text) {
31
+ return LINE.test(text);
32
+ }
33
+ export function frame(id, event, json) {
34
+ return `${id === undefined ? '' : `id: ${id}\n`}event: ${event}\ndata: ${json}\n\n`;
35
+ }
36
+ export const PING = Buffer.from(': ping\n\n');
37
+ export const HEADERS = Object.freeze({
38
+ 'content-type': 'text/event-stream; charset=utf-8',
39
+ // no-transform: proxies and CDNs must not compress or rewrite the stream (compression buffers).
40
+ 'cache-control': 'no-cache, no-transform',
41
+ // nginx: do not buffer this response.
42
+ 'x-accel-buffering': 'no',
43
+ connection: 'keep-alive',
44
+ });
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "bus": {
7
+ "type": "string",
8
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
9
+ "description": "A dokebi-event-bus bus (buses.<name>) that carries publications to every worker and server. Use a nats bus for more than one process. Unset: publish reaches this process only."
10
+ },
11
+ "busEvent": {
12
+ "type": "string",
13
+ "maxLength": 128,
14
+ "pattern": "^[A-Za-z][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*$",
15
+ "default": "dokebi.sse",
16
+ "description": "The event-bus event name publications travel under."
17
+ },
18
+ "allowInCluster": {
19
+ "type": "boolean",
20
+ "default": false,
21
+ "description": "Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with SSE_LOCAL_IN_CLUSTER. The bus transport is read from dokebi-event-bus.buses in the application config."
22
+ },
23
+ "retryMs": {
24
+ "type": "integer",
25
+ "minimum": 100,
26
+ "maximum": 3600000,
27
+ "default": 3000,
28
+ "description": "The retry: hint (browser reconnect delay), also the browser client's minimum backoff."
29
+ },
30
+ "heartbeatMs": {
31
+ "type": "integer",
32
+ "minimum": 10,
33
+ "maximum": 3600000,
34
+ "default": 15000,
35
+ "description": "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer."
36
+ },
37
+ "maxConnections": {
38
+ "type": "integer",
39
+ "minimum": 1,
40
+ "maximum": 1000000,
41
+ "default": 1024,
42
+ "description": "Open streams per process (per cluster worker); more get 503 CAPACITY."
43
+ },
44
+ "maxBufferedBytes": {
45
+ "type": "integer",
46
+ "minimum": 1,
47
+ "maximum": 67108864,
48
+ "default": 1048576,
49
+ "description": "Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER)."
50
+ },
51
+ "maxRooms": {
52
+ "type": "integer",
53
+ "minimum": 0,
54
+ "maximum": 1024,
55
+ "default": 32,
56
+ "description": "Rooms one stream may be in."
57
+ },
58
+ "maxEventBytes": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "maximum": 16777216,
62
+ "default": 65536,
63
+ "description": "Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes."
64
+ },
65
+ "closeTimeoutMs": {
66
+ "type": "integer",
67
+ "minimum": 1,
68
+ "maximum": 600000,
69
+ "default": 2000,
70
+ "description": "How long drain waits for streams to end after dokebi-restart before cutting them."
71
+ }
72
+ }
73
+ }