@dokebi/sse 0.0.0-stage → 0.4.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
+ /** `eventBus` 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,26 @@
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
+ if (options === null || typeof options !== 'object')
6
+ return false;
7
+ const values = options;
8
+ // `bus` is the deprecated name of `eventBus` (D-0071), still accepted in 0.4.0.
9
+ return typeof values.eventBus === 'string' || typeof values.bus === 'string';
10
+ }
11
+ /** `eventBus` set: publications cross processes through dokebi-event-bus, so it is required. */
12
+ export function planInstall(context) {
13
+ return { requires: usesBus(context.options) ? [EVENT_BUS] : [] };
14
+ }
15
+ export function check(context) {
16
+ const diagnostics = [];
17
+ if (usesBus(context.options) === false &&
18
+ context.options?.allowInCluster === true)
19
+ diagnostics.push({
20
+ code: 'SSE_SINGLE_PROCESS',
21
+ severity: 'warning',
22
+ optionPath: ['allowInCluster'],
23
+ hint: 'Without eventBus, publications reach one worker only; set eventBus to a nats event bus',
24
+ });
25
+ return { requires: planInstall(context).requires, diagnostics };
26
+ }
@@ -0,0 +1,42 @@
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 eventBus: 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 shutdownTimeoutMs: number;
25
+ }
26
+ export declare const options: import("@dokebi/core").OptionsDeclaration<{
27
+ readonly allowInCluster: boolean;
28
+ readonly busEvent: string;
29
+ readonly eventBus?: string;
30
+ readonly heartbeatMs: number;
31
+ readonly maxBufferedBytes: number;
32
+ readonly maxConnections: number;
33
+ readonly maxEventBytes: number;
34
+ readonly maxRooms: number;
35
+ readonly retryMs: number;
36
+ readonly shutdownTimeoutMs: number;
37
+ }>;
38
+ /**
39
+ * Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults.
40
+ * Fails with SseError `INVALID_OPTIONS` (paths in the message and `details.issues`, no values).
41
+ */
42
+ export declare function parseOptions(input: unknown): Options;
@@ -0,0 +1,51 @@
1
+ import { defineOptions, option as o } from '@dokebi/core';
2
+ import { SseError } from './errors.js';
3
+ const int = (minimum, maximum, fallback, description) => o.integer({ minimum, maximum, default: fallback, description });
4
+ /*
5
+ * Options of dokebi-sse (D-0063 §3): one declaration is the parser and, at build time,
6
+ * options.schema.json.
7
+ */
8
+ export const options = defineOptions({
9
+ eventBus: o.string({
10
+ pattern: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
11
+ 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.',
12
+ }),
13
+ bus: o.alias('eventBus'),
14
+ busEvent: o.string({
15
+ maxLength: 128,
16
+ // The event-bus event name rule (dot-separated tokens starting with a letter).
17
+ pattern: /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/,
18
+ default: 'dokebi.sse',
19
+ description: 'The event-bus event name publications travel under.',
20
+ }),
21
+ allowInCluster: o.boolean({
22
+ default: false,
23
+ description: 'Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with LOCAL_IN_CLUSTER. The bus driver is read from dokebi-event-bus.buses in the application config.',
24
+ }),
25
+ retryMs: int(100, 3_600_000, 3000, "The retry: hint (browser reconnect delay), also the browser client's minimum backoff."),
26
+ heartbeatMs: int(10, 3_600_000, 15000, "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer."),
27
+ maxConnections: int(1, 1_000_000, 1024, 'Open streams per process (per cluster worker); more get 503 CAPACITY.'),
28
+ maxBufferedBytes: int(1, 67_108_864, 1_048_576, 'Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER).'),
29
+ maxRooms: int(0, 1024, 32, 'Rooms one stream may be in.'),
30
+ maxEventBytes: int(1, 16_777_216, 65_536, 'Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes.'),
31
+ shutdownTimeoutMs: int(1, 600_000, 2000, 'How long drain waits for streams to end after dokebi-restart before cutting them.'),
32
+ closeTimeoutMs: o.alias('shutdownTimeoutMs'),
33
+ });
34
+ /**
35
+ * Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults.
36
+ * Fails with SseError `INVALID_OPTIONS` (paths in the message and `details.issues`, no values).
37
+ */
38
+ export function parseOptions(input) {
39
+ const result = options.check(input);
40
+ if (!result.ok) {
41
+ const shown = result.issues
42
+ .slice(0, 3)
43
+ .map((issue) => `${issue.path || 'options'} ${issue.message}`)
44
+ .join('; ');
45
+ throw new SseError('INVALID_OPTIONS', `Invalid SSE options: ${shown}`, {
46
+ details: { issues: result.issues },
47
+ });
48
+ }
49
+ // `eventBus` is always a key (undefined when unset), as before the declaration.
50
+ return Object.freeze({ ...result.value, eventBus: result.value.eventBus });
51
+ }
@@ -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,98 @@
1
+ import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
2
+ // Always installed with this plugin (peer, required in the manifest): the provider's own file
3
+ // holds the name, package and range (D-0063 §6).
4
+ import { dependency as web } from '@dokebi/web/dependency';
5
+ import { realtime } from '@dokebi/web/points';
6
+ import { SseError } from './errors.js';
7
+ import { Gateway } from './gateway.js';
8
+ import { options as declaration } from './options.js';
9
+ import { sseTransport } from './transport.js';
10
+ /** Only when `bus` is set; created from `app.config['dokebi-event-bus']` when not registered. */
11
+ const events = providerDependency({
12
+ name: 'dokebi-event-bus',
13
+ version: '^0.4.0',
14
+ packageName: '@dokebi/event-bus',
15
+ load: () => import('@dokebi/event-bus'),
16
+ });
17
+ const health = optionalDependency({ name: 'dokebi-health', version: '^0.4.0' });
18
+ /**
19
+ * The driver of a bus as dokebi-event-bus options in the application config name it (`driver`, or
20
+ * its deprecated name `transport`, D-0071). The event-bus API does not expose it, so options given
21
+ * to that plugin in code are unknown here.
22
+ */
23
+ function configuredDriver(config, bus) {
24
+ const options = config?.['dokebi-event-bus'];
25
+ const value = options?.buses?.[bus];
26
+ const driver = value?.driver ?? value?.transport;
27
+ return typeof driver === 'string' ? driver : 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
+ // core decides the role (D-0063 §4): a node:cluster worker is `worker`.
39
+ const worker = context.runtime.role === 'worker';
40
+ // A publication must reach the streams of every worker. Without a NATS bus each worker
41
+ // reaches only its own clients, which works by accident and fails silently in use.
42
+ if (worker && !options.allowInCluster) {
43
+ const driver = options.eventBus === undefined
44
+ ? 'none'
45
+ : (configuredDriver(context.app.config, options.eventBus) ?? 'unknown');
46
+ if (driver !== 'nats') {
47
+ context.logger.log('error', 'SSE in a cluster worker without a NATS bus', {
48
+ code: 'SSE_LOCAL_IN_CLUSTER',
49
+ bus: options.eventBus ?? null,
50
+ driver,
51
+ fix: 'Set dokebi-sse.eventBus to a dokebi-event-bus bus with driver nats (configured in ' +
52
+ 'dokebi-event-bus.buses), or allowInCluster: true for a deliberate single-worker cluster',
53
+ });
54
+ throw new SseError('LOCAL_IN_CLUSTER', 'SSE publications would reach only this worker: use a nats bus (dokebi-sse.eventBus).');
55
+ }
56
+ }
57
+ const bus = options.eventBus === undefined
58
+ ? undefined
59
+ : context.getDependency(events).bus(options.eventBus);
60
+ const gateway = new Gateway(context, context.getDependency(web), options, bus, context.getOptionalDependency(health));
61
+ context.defer(() => gateway.close());
62
+ this.#gateway = gateway;
63
+ await gateway.subscribe();
64
+ // D-0063: the `sse` transport for dokebi-app (inert until a plugin collects the point).
65
+ context.contribute(realtime, sseTransport(gateway.api, context.logger));
66
+ context.logger.log('debug', 'SSE setup finished', {
67
+ bus: options.eventBus ?? null,
68
+ busEvent: options.busEvent,
69
+ cluster: worker ? 'worker' : 'none',
70
+ ms: Math.round(performance.now() - started),
71
+ });
72
+ return gateway.api;
73
+ }
74
+ start() {
75
+ this.#gateway?.start();
76
+ }
77
+ async drain(context) {
78
+ await this.#gateway?.drain(context.drainSignal);
79
+ }
80
+ }
81
+ /** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
82
+ export const createPlugin = definePlugin({
83
+ name: 'dokebi-sse',
84
+ version: '0.4.0',
85
+ core: '^0.4.0',
86
+ // The declaration (not parseOptions) so core logs OPTION_DEPRECATED for old keys (bus, closeTimeoutMs).
87
+ options: declaration,
88
+ contributes: [realtime],
89
+ dependencies: ({ options }) => options.eventBus === undefined ? [web, health] : [web, events, health],
90
+ instance: ({ options }) => new SseRuntime(Object.freeze({ ...options, eventBus: options.eventBus })),
91
+ });
92
+ /** Declares dokebi-sse as a required provider; it is created from this package when absent. */
93
+ export const dependency = providerDependency({
94
+ name: 'dokebi-sse',
95
+ packageName: '@dokebi/sse',
96
+ version: '^0.4.0',
97
+ load: () => import('./index.js'),
98
+ });
@@ -0,0 +1,13 @@
1
+ import type { Logger } from '@dokebi/core';
2
+ import type { WebRealtimeTransport } from '@dokebi/web/points';
3
+ import type { SseApi, SseNamespaceHandle, SseRoute } from './types.js';
4
+ /** The `sse` transport contributed to `dokebi-web:realtime` (D-0063 §5). */
5
+ export type SseTransport = WebRealtimeTransport<SseRoute, SseNamespaceHandle> & {
6
+ readonly kind: 'sse';
7
+ };
8
+ /**
9
+ * What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
10
+ * built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
11
+ * route; `handle` prefixes targets with `<namespace>:`.
12
+ */
13
+ export declare function sseTransport(api: SseApi, logger: Logger): SseTransport;
@@ -0,0 +1,45 @@
1
+ import { SseError } from './errors.js';
2
+ const NAMESPACE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
3
+ /**
4
+ * What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
5
+ * built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
6
+ * route; `handle` prefixes targets with `<namespace>:`.
7
+ */
8
+ export function sseTransport(api, logger) {
9
+ return Object.freeze({
10
+ kind: 'sse',
11
+ mount(route, scope) {
12
+ if (route === null || typeof route !== 'object' || typeof route.authenticate !== 'function')
13
+ throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
14
+ api.route({
15
+ ...route,
16
+ path: scope.path,
17
+ constraints: { ...(route.constraints ?? {}), ...scope.constraints },
18
+ authenticate: async (request) => {
19
+ const identity = await route.authenticate(request);
20
+ return identity ? scope.identity(identity) : undefined;
21
+ },
22
+ });
23
+ if (logger.isEnabled('debug'))
24
+ logger.log('debug', 'SSE route mounted by the realtime point', { path: scope.path });
25
+ },
26
+ handle(namespace) {
27
+ if (typeof namespace !== 'string' || !NAMESPACE.test(namespace))
28
+ throw new SseError('INVALID_TARGET', 'A realtime namespace is a name of 1 to 64 characters.');
29
+ const prefix = `${namespace}:`;
30
+ return Object.freeze({
31
+ namespace,
32
+ publish: (target, event, data) => {
33
+ if (target === null || typeof target !== 'object')
34
+ return Promise.reject(new SseError('INVALID_TARGET', 'A namespace target is { tenant?, member?, room? }.'));
35
+ const { tenant, member, room } = target;
36
+ return api.publish({
37
+ ...(tenant === undefined ? { tenantPrefix: prefix } : { tenant: prefix + tenant }),
38
+ ...(member === undefined ? {} : { member }),
39
+ ...(room === undefined ? {} : { room }),
40
+ }, event, data);
41
+ },
42
+ });
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,144 @@
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
+ /** Whom a namespace publication is for (D-0061): a tenant of the namespace, or all of them. */
130
+ export interface SseNamespaceTarget {
131
+ readonly tenant?: string;
132
+ readonly member?: string;
133
+ readonly room?: string;
134
+ }
135
+ /** The SSE operations on one realtime namespace (the tenants `<namespace>:<tenant>`). */
136
+ export interface SseNamespaceHandle {
137
+ readonly namespace: string;
138
+ /** `publish` with the tenant (or every tenant) of this namespace. */
139
+ publish(target: SseNamespaceTarget, event: string, data?: unknown): Promise<SsePublishResult>;
140
+ }
141
+ /** The events the plugin itself sends; application event names may not start with `dokebi-`. */
142
+ export declare const RELOAD_EVENT = "dokebi-reload";
143
+ export declare const RESTART_EVENT = "dokebi-restart";
144
+ 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,86 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "eventBus": {
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
+ "bus": {
12
+ "type": "string",
13
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
14
+ "deprecated": true,
15
+ "description": "Deprecated name of eventBus; use that key."
16
+ },
17
+ "busEvent": {
18
+ "type": "string",
19
+ "maxLength": 128,
20
+ "pattern": "^[A-Za-z][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*$",
21
+ "default": "dokebi.sse",
22
+ "description": "The event-bus event name publications travel under."
23
+ },
24
+ "allowInCluster": {
25
+ "type": "boolean",
26
+ "default": false,
27
+ "description": "Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with LOCAL_IN_CLUSTER. The bus driver is read from dokebi-event-bus.buses in the application config."
28
+ },
29
+ "retryMs": {
30
+ "type": "integer",
31
+ "minimum": 100,
32
+ "maximum": 3600000,
33
+ "default": 3000,
34
+ "description": "The retry: hint (browser reconnect delay), also the browser client's minimum backoff."
35
+ },
36
+ "heartbeatMs": {
37
+ "type": "integer",
38
+ "minimum": 10,
39
+ "maximum": 3600000,
40
+ "default": 15000,
41
+ "description": "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer."
42
+ },
43
+ "maxConnections": {
44
+ "type": "integer",
45
+ "minimum": 1,
46
+ "maximum": 1000000,
47
+ "default": 1024,
48
+ "description": "Open streams per process (per cluster worker); more get 503 CAPACITY."
49
+ },
50
+ "maxBufferedBytes": {
51
+ "type": "integer",
52
+ "minimum": 1,
53
+ "maximum": 67108864,
54
+ "default": 1048576,
55
+ "description": "Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER)."
56
+ },
57
+ "maxRooms": {
58
+ "type": "integer",
59
+ "minimum": 0,
60
+ "maximum": 1024,
61
+ "default": 32,
62
+ "description": "Rooms one stream may be in."
63
+ },
64
+ "maxEventBytes": {
65
+ "type": "integer",
66
+ "minimum": 1,
67
+ "maximum": 16777216,
68
+ "default": 65536,
69
+ "description": "Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes."
70
+ },
71
+ "shutdownTimeoutMs": {
72
+ "type": "integer",
73
+ "minimum": 1,
74
+ "maximum": 600000,
75
+ "default": 2000,
76
+ "description": "How long drain waits for streams to end after dokebi-restart before cutting them."
77
+ },
78
+ "closeTimeoutMs": {
79
+ "type": "integer",
80
+ "minimum": 1,
81
+ "maximum": 600000,
82
+ "deprecated": true,
83
+ "description": "Deprecated name of shutdownTimeoutMs; use that key."
84
+ }
85
+ }
86
+ }