opinionated-machine 11.1.0 → 11.2.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.
- package/CHANGELOG.md +6 -0
- package/README.md +98 -0
- package/dist/lib/sse/index.d.ts +1 -1
- package/dist/lib/sse/index.js +1 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/rooms/SSERoomEventPublisher.d.ts +121 -0
- package/dist/lib/sse/rooms/SSERoomEventPublisher.js +119 -0
- package/dist/lib/sse/rooms/SSERoomEventPublisher.js.map +1 -0
- package/dist/lib/sse/rooms/index.d.ts +1 -0
- package/dist/lib/sse/rooms/index.js +1 -0
- package/dist/lib/sse/rooms/index.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# opinionated-machine
|
|
2
2
|
|
|
3
|
+
## 11.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9637451: Add `SSERoomEventPublisher`: fire-and-forget room broadcasting for domain code. It validates a payload against the event's own schema before broadcasting, and broadcasts the parsed value so schema defaults reach the wire. `publish` throws on a payload that violates its schema, since nobody receives that event and a producer should not believe otherwise; `safePublish` returns `Either<InternalError, true>` instead, for a caller that cannot absorb a throw. A failed broadcast is logged by both, as it happens after the call returns. Accepts the caller's context (anything with a `logger`, such as a `RequestContext`) so failures carry a correlation id.
|
|
8
|
+
|
|
3
9
|
## 11.1.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
56
56
|
- [Session Room Operations](#session-room-operations)
|
|
57
57
|
- [Broadcasting to Rooms](#broadcasting-to-rooms)
|
|
58
58
|
- [Room Broadcaster (Decoupled Broadcasting)](#room-broadcaster-decoupled-broadcasting)
|
|
59
|
+
- [Room Event Publisher (Fire-and-Forget)](#room-event-publisher-fire-and-forget)
|
|
59
60
|
- [Room Name Helpers](#room-name-helpers)
|
|
60
61
|
- [Room Query Methods](#room-query-methods)
|
|
61
62
|
- [Auto-Leave on Disconnect](#auto-leave-on-disconnect)
|
|
@@ -2026,6 +2027,103 @@ class MetricsService {
|
|
|
2026
2027
|
|
|
2027
2028
|
The broadcaster provides `broadcastToRoom()` (with `defineEvent()`-based type safety), `broadcastMessage()` (raw SSEMessage), plus room query methods (`getConnectionsInRoom`, `getConnectionCountInRoom`). Multiple controllers register their `sendEvent` with the same broadcaster — the first to recognize a connection handles delivery.
|
|
2028
2029
|
|
|
2030
|
+
#### Room Event Publisher (Fire-and-Forget)
|
|
2031
|
+
|
|
2032
|
+
`broadcastToRoom()` returns a promise, and most producers of a room event have nothing to do with
|
|
2033
|
+
it. An event listener or message queue handler has already committed its primary work by the time
|
|
2034
|
+
it broadcasts: it cannot retry a dropped hint, has nowhere to report one, and awaiting the fan-out
|
|
2035
|
+
would tie its latency to the number of open connections. `SSERoomEventPublisher` is the broadcaster
|
|
2036
|
+
without the promise.
|
|
2037
|
+
|
|
2038
|
+
```ts
|
|
2039
|
+
import { defineEvent, SSERoomEventPublisher } from 'opinionated-machine'
|
|
2040
|
+
import { z } from 'zod'
|
|
2041
|
+
|
|
2042
|
+
const metricsUpdateEvent = defineEvent(
|
|
2043
|
+
'metricsUpdate',
|
|
2044
|
+
z.object({ cpu: z.number(), memory: z.number() }),
|
|
2045
|
+
)
|
|
2046
|
+
|
|
2047
|
+
// Register alongside the broadcaster it wraps; it expects 'sseRoomBroadcaster' and 'logger'
|
|
2048
|
+
// in the cradle, so the names must match exactly.
|
|
2049
|
+
class DashboardModule extends AbstractModule {
|
|
2050
|
+
resolveDependencies() {
|
|
2051
|
+
return {
|
|
2052
|
+
sseRoomManager: asValue(new SSERoomManager()),
|
|
2053
|
+
sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster),
|
|
2054
|
+
sseRoomEventPublisher: asSingletonClass(SSERoomEventPublisher),
|
|
2055
|
+
metricsService: asSingletonClass(MetricsService),
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
class MetricsService {
|
|
2061
|
+
private publisher: SSERoomEventPublisher
|
|
2062
|
+
|
|
2063
|
+
constructor(deps: { sseRoomEventPublisher: SSERoomEventPublisher }) {
|
|
2064
|
+
this.publisher = deps.sseRoomEventPublisher
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
onMetricsUpdate(
|
|
2068
|
+
dashboardId: string,
|
|
2069
|
+
metrics: { cpu: number; memory: number },
|
|
2070
|
+
requestContext: RequestContext,
|
|
2071
|
+
) {
|
|
2072
|
+
// No await: a failure is logged, not returned. The context is passed whole; only its
|
|
2073
|
+
// logger is read, so a dropped event carries the correlation id of whatever produced it.
|
|
2074
|
+
this.publisher.publish(
|
|
2075
|
+
`dashboard:${dashboardId}`,
|
|
2076
|
+
metricsUpdateEvent,
|
|
2077
|
+
metrics,
|
|
2078
|
+
requestContext,
|
|
2079
|
+
)
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
The context parameter is typed as `SSELogContext` (`{ logger: SSELogger }`) rather than any
|
|
2085
|
+
concrete context type, so `@lokalise/fastify-extras`' `RequestContext` satisfies it structurally
|
|
2086
|
+
and this package needs no dependency on it. A job or consumer context of your own works the same
|
|
2087
|
+
way, and a caller that has none omits the argument and falls back to the injected logger.
|
|
2088
|
+
|
|
2089
|
+
Two things it does beyond hiding the promise:
|
|
2090
|
+
|
|
2091
|
+
- **Validates before broadcasting, and throws.** A payload that violates its own event schema is
|
|
2092
|
+
a bug in the producer, and nobody receives the event, so dropping it quietly means believing
|
|
2093
|
+
you published something you did not. Delivery-time validation cannot give you this: it runs
|
|
2094
|
+
once per connection, so it reports the mismatch once per open connection on every node, names
|
|
2095
|
+
the event but not the code that produced it, does not run at all when nobody has joined the
|
|
2096
|
+
room, and by then the call has long returned.
|
|
2097
|
+
- **Puts the parsed value on the wire,** so a schema default is filled in once here rather than
|
|
2098
|
+
left to every client. Delivery-time validation discards its own result and serializes what it
|
|
2099
|
+
was handed, so `broadcastToRoom()` sends the unparsed input.
|
|
2100
|
+
|
|
2101
|
+
#### `publish` vs `safePublish`
|
|
2102
|
+
|
|
2103
|
+
They differ in one thing: what a malformed payload does.
|
|
2104
|
+
|
|
2105
|
+
| | malformed payload | failed broadcast |
|
|
2106
|
+
| --- | --- | --- |
|
|
2107
|
+
| `publish` | throws `InternalError` | logged |
|
|
2108
|
+
| `safePublish` | returns `{ error }`, and logs | logged |
|
|
2109
|
+
|
|
2110
|
+
Reach for `safePublish` in a producer that cannot absorb a throw: a message handler whose primary
|
|
2111
|
+
work has already committed would be retried in full and redo it, and the retry cannot succeed
|
|
2112
|
+
anyway, since a malformed payload fails the same way every time. Prefer `publish` everywhere
|
|
2113
|
+
else.
|
|
2114
|
+
|
|
2115
|
+
```ts
|
|
2116
|
+
const outcome = this.publisher.safePublish(room, event, payload, requestContext)
|
|
2117
|
+
if (outcome.error) {
|
|
2118
|
+
// decide for yourself: metric, Bugsnag, a compensating write
|
|
2119
|
+
}
|
|
2120
|
+
```
|
|
2121
|
+
|
|
2122
|
+
`{ result: true }` means the payload was validated and handed to the broadcaster. That is
|
|
2123
|
+
acceptance, not delivery: the fan-out has not run yet, and neither method reports its outcome,
|
|
2124
|
+
because it happens after the call has returned. Use the broadcaster directly when the delivered
|
|
2125
|
+
count matters, or when a failed delivery is something the caller can act on.
|
|
2126
|
+
|
|
2029
2127
|
#### Room Name Helpers
|
|
2030
2128
|
|
|
2031
2129
|
Room names are plain strings (like Socket.IO), but `defineRoom()` adds type-safe resolvers that ensure consistent naming across controllers and domain services:
|
package/dist/lib/sse/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { type BuildFastifySSERoutesReturnType, buildFastifyRoute, buildHandler,
|
|
|
4
4
|
export { AbstractSSEController, type SSEControllerConfig, type SSEEventSender, type SSELogger, type SSEMessage, } from './AbstractSSEController.js';
|
|
5
5
|
export { defineEvent, type SSEEventDefinition } from './defineEvent.js';
|
|
6
6
|
export { type AsyncEventIdSequence, type CreateEventIdSequenceOptions, compareEventIds, createEventIdSequence, type EventIdSequence, formatEventId, MAX_EVENT_ID_COUNTER, } from './eventIds.js';
|
|
7
|
-
export { defineRoom, InMemoryAdapter, type PreDeliveryFilter, type RoomBroadcastOptions, type RoomNameResolver, type SSERoomAdapter, SSERoomBroadcaster, SSERoomManager, type SSERoomManagerConfig, type SSERoomMessageHandler, type SSERoomOperations, } from './rooms/index.js';
|
|
7
|
+
export { defineRoom, InMemoryAdapter, type PreDeliveryFilter, type RoomBroadcastOptions, type RoomNameResolver, type SSELogContext, type SSERoomAdapter, SSERoomBroadcaster, SSERoomEventPublisher, type SSERoomEventPublisherDependencies, type SSERoomEventPublishOptions, SSERoomManager, type SSERoomManagerConfig, type SSERoomMessageHandler, type SSERoomOperations, } from './rooms/index.js';
|
|
8
8
|
export { type SpiedSSESession, type SSESessionEvent, SSESessionSpy } from './SSESessionSpy.js';
|
|
9
9
|
export { SSE_DIAGNOSTICS_HEADER, type SSEDiagnosticsScope, type SSESendFailure, } from './sseSendDiagnostics.js';
|
|
10
10
|
export { defineEventMetadata, type ExtractMetadata, type FilterVerdict, type IncomingEvent, type MetadataGuard, type MetadataGuards, type PublishResult, type ResolverResult, SSESubscriptionManager, type SSESubscriptionManagerConfig, type SubscriptionContext, type SubscriptionPolicy, type SubscriptionResolver, } from './subscriptions/index.js';
|
package/dist/lib/sse/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { AbstractSSEController, } from './AbstractSSEController.js';
|
|
|
8
8
|
export { defineEvent } from './defineEvent.js';
|
|
9
9
|
export { compareEventIds, createEventIdSequence, formatEventId, MAX_EVENT_ID_COUNTER, } from './eventIds.js';
|
|
10
10
|
// Re-export room types and classes
|
|
11
|
-
export { defineRoom, InMemoryAdapter, SSERoomBroadcaster, SSERoomManager, } from './rooms/index.js';
|
|
11
|
+
export { defineRoom, InMemoryAdapter, SSERoomBroadcaster, SSERoomEventPublisher, SSERoomManager, } from './rooms/index.js';
|
|
12
12
|
export { SSESessionSpy } from './SSESessionSpy.js';
|
|
13
13
|
export { SSE_DIAGNOSTICS_HEADER, } from './sseSendDiagnostics.js';
|
|
14
14
|
// SSE Subscriptions
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/sse/index.ts"],"names":[],"mappings":"AAUA,6EAA6E;AAC7E,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,EACL,qBAAqB,EAIrB,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,cAAc,GAIf,MAAM,iCAAiC,CAAA;AACxC,2CAA2C;AAC3C,OAAO,EAEL,iBAAiB,EACjB,YAAY,GAUb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,qBAAqB,GAKtB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAA2B,MAAM,kBAAkB,CAAA;AACvE,OAAO,EAGL,eAAe,EACf,qBAAqB,EAErB,aAAa,EACb,oBAAoB,GACrB,MAAM,eAAe,CAAA;AACtB,mCAAmC;AACnC,OAAO,EACL,UAAU,EACV,eAAe,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/sse/index.ts"],"names":[],"mappings":"AAUA,6EAA6E;AAC7E,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,EACL,qBAAqB,EAIrB,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,cAAc,GAIf,MAAM,iCAAiC,CAAA;AACxC,2CAA2C;AAC3C,OAAO,EAEL,iBAAiB,EACjB,YAAY,GAUb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,qBAAqB,GAKtB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAA2B,MAAM,kBAAkB,CAAA;AACvE,OAAO,EAGL,eAAe,EACf,qBAAqB,EAErB,aAAa,EACb,oBAAoB,GACrB,MAAM,eAAe,CAAA;AACtB,mCAAmC;AACnC,OAAO,EACL,UAAU,EACV,eAAe,EAMf,kBAAkB,EAClB,qBAAqB,EAGrB,cAAc,GAIf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAA8C,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAC9F,OAAO,EACL,sBAAsB,GAGvB,MAAM,yBAAyB,CAAA;AAChC,oBAAoB;AACpB,OAAO,EACL,mBAAmB,EAQnB,sBAAsB,GAKvB,MAAM,0BAA0B,CAAA"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type Either, InternalError } from '@lokalise/node-core';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { SSEEventDefinition } from '../defineEvent.js';
|
|
4
|
+
import type { SSELogger } from '../sseTypes.js';
|
|
5
|
+
import type { SSERoomBroadcaster } from './SSERoomBroadcaster.js';
|
|
6
|
+
import type { RoomBroadcastOptions } from './types.js';
|
|
7
|
+
export type SSERoomEventPublisherDependencies = {
|
|
8
|
+
sseRoomBroadcaster: SSERoomBroadcaster;
|
|
9
|
+
logger: SSELogger;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Whatever the caller is working on behalf of, narrowed to the one thing the publisher needs
|
|
13
|
+
* from it. `@lokalise/fastify-extras`' `RequestContext` satisfies this structurally, as does a
|
|
14
|
+
* job or consumer context of your own, so neither this package nor its callers need an adapter.
|
|
15
|
+
*/
|
|
16
|
+
export type SSELogContext = {
|
|
17
|
+
logger: SSELogger;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Everything {@link SSERoomBroadcaster.broadcastToRoom} accepts, so the publisher is not a lossy
|
|
21
|
+
* wrapper over it.
|
|
22
|
+
*/
|
|
23
|
+
export type SSERoomEventPublishOptions = RoomBroadcastOptions & {
|
|
24
|
+
/**
|
|
25
|
+
* The SSE `id:` put on the wire. Defaults to a random UUID. Set it to a value a client can
|
|
26
|
+
* order, such as the sequence from `createEventIdSequence()`, when consumers deduplicate or
|
|
27
|
+
* resume by event id.
|
|
28
|
+
*/
|
|
29
|
+
id?: string;
|
|
30
|
+
/** The SSE `retry:` hint, in milliseconds: how long a client waits before reconnecting. */
|
|
31
|
+
retry?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Per-broadcast context handed to the pre-delivery filter, and to other nodes alongside the
|
|
34
|
+
* message. Only a filter reads it (`SSESubscriptionManager` installs one to evaluate its
|
|
35
|
+
* resolver pipeline), so it is redundant unless one is installed.
|
|
36
|
+
*/
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Fire-and-forget broadcasting for domain code: a room event goes out and the caller does not
|
|
41
|
+
* await the fan-out.
|
|
42
|
+
*
|
|
43
|
+
* The typical producer is an event listener or a message handler whose primary work has already
|
|
44
|
+
* committed. It cannot retry a dropped hint, and awaiting the fan-out would tie its latency to
|
|
45
|
+
* the number of open connections. Code that needs the delivered count, or that can act on a
|
|
46
|
+
* delivery failure, should use {@link SSERoomBroadcaster} directly.
|
|
47
|
+
*
|
|
48
|
+
* The two ways publishing fails are treated differently, because they are different kinds of
|
|
49
|
+
* problem:
|
|
50
|
+
*
|
|
51
|
+
* - **A payload that violates its own event schema is a bug in the producer.** Nobody receives
|
|
52
|
+
* the event, so swallowing it means believing you published something you did not.
|
|
53
|
+
* {@link publish} throws. {@link safePublish} returns the error instead, for a caller that
|
|
54
|
+
* cannot absorb a throw.
|
|
55
|
+
* - **A failed broadcast is the world's problem**, and no caller here can retry it, so both
|
|
56
|
+
* methods log it. It also happens after the call has returned, which is why neither method can
|
|
57
|
+
* report it in a return value.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* // In your DI module
|
|
62
|
+
* sseRoomEventPublisher: asSingletonClass(SSERoomEventPublisher),
|
|
63
|
+
*
|
|
64
|
+
* // In a listener. `requestContext` is passed straight through: the publisher reads its
|
|
65
|
+
* // logger, so a dropped event carries the correlation id of whatever produced it.
|
|
66
|
+
* this.publisher.publish(
|
|
67
|
+
* ProjectSse.roomResolver(projectId),
|
|
68
|
+
* ProjectSse.events.updated,
|
|
69
|
+
* payload,
|
|
70
|
+
* requestContext,
|
|
71
|
+
* )
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare class SSERoomEventPublisher {
|
|
75
|
+
private readonly sseRoomBroadcaster;
|
|
76
|
+
private readonly logger;
|
|
77
|
+
constructor({ sseRoomBroadcaster, logger }: SSERoomEventPublisherDependencies);
|
|
78
|
+
/**
|
|
79
|
+
* Validate `data` against the event's own schema and broadcast it to `room`, throwing if it
|
|
80
|
+
* fails.
|
|
81
|
+
*
|
|
82
|
+
* Validating here rather than leaving it to delivery is what makes the throw possible at all.
|
|
83
|
+
* Delivery-time validation runs once per connection, so it reports a mismatch once per open
|
|
84
|
+
* connection on every node, names the event but not the code that produced it, and does not
|
|
85
|
+
* run at all when nobody has joined the room. By then the call has long returned and there is
|
|
86
|
+
* nothing left to throw to.
|
|
87
|
+
*
|
|
88
|
+
* What goes on the wire is the parsed value, so a schema default is filled in once here
|
|
89
|
+
* instead of being left to every client. Delivery-time validation discards its own result and
|
|
90
|
+
* serializes what it was handed, so calling {@link SSERoomBroadcaster.broadcastToRoom}
|
|
91
|
+
* directly sends the unparsed input.
|
|
92
|
+
*
|
|
93
|
+
* `context` is whatever the caller is acting on behalf of; only its logger is read, so a
|
|
94
|
+
* request context goes in as-is. Omit it in a caller that has none and the injected logger is
|
|
95
|
+
* used, which costs a logged failure its correlation id and nothing else.
|
|
96
|
+
*
|
|
97
|
+
* @throws {InternalError} if `data` does not satisfy the event's schema.
|
|
98
|
+
*/
|
|
99
|
+
publish<T extends z.ZodType>(room: string | string[], event: SSEEventDefinition<string, T>, data: z.input<T>, context?: SSELogContext, options?: SSERoomEventPublishOptions): void;
|
|
100
|
+
/**
|
|
101
|
+
* {@link publish}, but a payload that fails its schema comes back as an error instead of being
|
|
102
|
+
* thrown.
|
|
103
|
+
*
|
|
104
|
+
* For a producer that cannot absorb a throw: a message handler whose primary work has already
|
|
105
|
+
* committed would be retried in full and redo it, and the retry cannot succeed anyway, since a
|
|
106
|
+
* malformed payload fails the same way every time. Prefer {@link publish} everywhere else.
|
|
107
|
+
*
|
|
108
|
+
* The failure is logged as well as returned, so a caller that ignores the result still leaves
|
|
109
|
+
* a trace rather than silence.
|
|
110
|
+
*
|
|
111
|
+
* @returns `{ result: true }` once the payload has been validated and handed to the
|
|
112
|
+
* broadcaster. That is acceptance, not delivery: the fan-out has not happened yet, and its
|
|
113
|
+
* own failure is logged rather than returned.
|
|
114
|
+
*/
|
|
115
|
+
safePublish<T extends z.ZodType>(room: string | string[], event: SSEEventDefinition<string, T>, data: z.input<T>, context?: SSELogContext, options?: SSERoomEventPublishOptions): Either<InternalError, true>;
|
|
116
|
+
/**
|
|
117
|
+
* The shared tail of both methods. A failed broadcast is logged rather than surfaced: it
|
|
118
|
+
* happens after the caller has returned, and none of these callers could retry it anyway.
|
|
119
|
+
*/
|
|
120
|
+
private broadcast;
|
|
121
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { InternalError } from '@lokalise/node-core';
|
|
2
|
+
/** The same error either way, so a caller switching between the two methods sees one shape. */
|
|
3
|
+
const validationError = (room, event, error) => new InternalError({
|
|
4
|
+
message: `SSE event validation failed for event "${event}": ${error.message}`,
|
|
5
|
+
errorCode: 'SSE_EVENT_VALIDATION_FAILED',
|
|
6
|
+
details: { room: Array.isArray(room) ? room.join(',') : room, event },
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Fire-and-forget broadcasting for domain code: a room event goes out and the caller does not
|
|
10
|
+
* await the fan-out.
|
|
11
|
+
*
|
|
12
|
+
* The typical producer is an event listener or a message handler whose primary work has already
|
|
13
|
+
* committed. It cannot retry a dropped hint, and awaiting the fan-out would tie its latency to
|
|
14
|
+
* the number of open connections. Code that needs the delivered count, or that can act on a
|
|
15
|
+
* delivery failure, should use {@link SSERoomBroadcaster} directly.
|
|
16
|
+
*
|
|
17
|
+
* The two ways publishing fails are treated differently, because they are different kinds of
|
|
18
|
+
* problem:
|
|
19
|
+
*
|
|
20
|
+
* - **A payload that violates its own event schema is a bug in the producer.** Nobody receives
|
|
21
|
+
* the event, so swallowing it means believing you published something you did not.
|
|
22
|
+
* {@link publish} throws. {@link safePublish} returns the error instead, for a caller that
|
|
23
|
+
* cannot absorb a throw.
|
|
24
|
+
* - **A failed broadcast is the world's problem**, and no caller here can retry it, so both
|
|
25
|
+
* methods log it. It also happens after the call has returned, which is why neither method can
|
|
26
|
+
* report it in a return value.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* // In your DI module
|
|
31
|
+
* sseRoomEventPublisher: asSingletonClass(SSERoomEventPublisher),
|
|
32
|
+
*
|
|
33
|
+
* // In a listener. `requestContext` is passed straight through: the publisher reads its
|
|
34
|
+
* // logger, so a dropped event carries the correlation id of whatever produced it.
|
|
35
|
+
* this.publisher.publish(
|
|
36
|
+
* ProjectSse.roomResolver(projectId),
|
|
37
|
+
* ProjectSse.events.updated,
|
|
38
|
+
* payload,
|
|
39
|
+
* requestContext,
|
|
40
|
+
* )
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export class SSERoomEventPublisher {
|
|
44
|
+
sseRoomBroadcaster;
|
|
45
|
+
logger;
|
|
46
|
+
constructor({ sseRoomBroadcaster, logger }) {
|
|
47
|
+
this.sseRoomBroadcaster = sseRoomBroadcaster;
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate `data` against the event's own schema and broadcast it to `room`, throwing if it
|
|
52
|
+
* fails.
|
|
53
|
+
*
|
|
54
|
+
* Validating here rather than leaving it to delivery is what makes the throw possible at all.
|
|
55
|
+
* Delivery-time validation runs once per connection, so it reports a mismatch once per open
|
|
56
|
+
* connection on every node, names the event but not the code that produced it, and does not
|
|
57
|
+
* run at all when nobody has joined the room. By then the call has long returned and there is
|
|
58
|
+
* nothing left to throw to.
|
|
59
|
+
*
|
|
60
|
+
* What goes on the wire is the parsed value, so a schema default is filled in once here
|
|
61
|
+
* instead of being left to every client. Delivery-time validation discards its own result and
|
|
62
|
+
* serializes what it was handed, so calling {@link SSERoomBroadcaster.broadcastToRoom}
|
|
63
|
+
* directly sends the unparsed input.
|
|
64
|
+
*
|
|
65
|
+
* `context` is whatever the caller is acting on behalf of; only its logger is read, so a
|
|
66
|
+
* request context goes in as-is. Omit it in a caller that has none and the injected logger is
|
|
67
|
+
* used, which costs a logged failure its correlation id and nothing else.
|
|
68
|
+
*
|
|
69
|
+
* @throws {InternalError} if `data` does not satisfy the event's schema.
|
|
70
|
+
*/
|
|
71
|
+
publish(room, event, data, context, options) {
|
|
72
|
+
const validation = event.schema.safeParse(data);
|
|
73
|
+
if (!validation.success) {
|
|
74
|
+
throw validationError(room, event.event, validation.error);
|
|
75
|
+
}
|
|
76
|
+
this.broadcast(room, event, validation.data, context, options);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* {@link publish}, but a payload that fails its schema comes back as an error instead of being
|
|
80
|
+
* thrown.
|
|
81
|
+
*
|
|
82
|
+
* For a producer that cannot absorb a throw: a message handler whose primary work has already
|
|
83
|
+
* committed would be retried in full and redo it, and the retry cannot succeed anyway, since a
|
|
84
|
+
* malformed payload fails the same way every time. Prefer {@link publish} everywhere else.
|
|
85
|
+
*
|
|
86
|
+
* The failure is logged as well as returned, so a caller that ignores the result still leaves
|
|
87
|
+
* a trace rather than silence.
|
|
88
|
+
*
|
|
89
|
+
* @returns `{ result: true }` once the payload has been validated and handed to the
|
|
90
|
+
* broadcaster. That is acceptance, not delivery: the fan-out has not happened yet, and its
|
|
91
|
+
* own failure is logged rather than returned.
|
|
92
|
+
*/
|
|
93
|
+
safePublish(room, event, data, context, options) {
|
|
94
|
+
const validation = event.schema.safeParse(data);
|
|
95
|
+
if (!validation.success) {
|
|
96
|
+
const logger = context?.logger ?? this.logger;
|
|
97
|
+
logger.error({ room, event: event.event, issues: validation.error.issues }, 'Refusing to broadcast an SSE event that fails its own schema');
|
|
98
|
+
return { error: validationError(room, event.event, validation.error) };
|
|
99
|
+
}
|
|
100
|
+
this.broadcast(room, event, validation.data, context, options);
|
|
101
|
+
return { result: true };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The shared tail of both methods. A failed broadcast is logged rather than surfaced: it
|
|
105
|
+
* happens after the caller has returned, and none of these callers could retry it anyway.
|
|
106
|
+
*/
|
|
107
|
+
broadcast(room, event,
|
|
108
|
+
// Already schema-valid, so the re-parse at delivery accepts it. The cast below is only
|
|
109
|
+
// needed because `z.output` is not `z.input` for a schema that defaults or transforms.
|
|
110
|
+
parsed, context, options) {
|
|
111
|
+
const logger = context?.logger ?? this.logger;
|
|
112
|
+
this.sseRoomBroadcaster
|
|
113
|
+
.broadcastToRoom(room, event, parsed, options)
|
|
114
|
+
.catch((error) => {
|
|
115
|
+
logger.error({ error, room, event: event.event }, 'Failed to broadcast SSE event');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=SSERoomEventPublisher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SSERoomEventPublisher.js","sourceRoot":"","sources":["../../../../lib/sse/rooms/SSERoomEventPublisher.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { InMemoryAdapter } from './adapters/InMemoryAdapter.js';
|
|
2
2
|
export { defineRoom } from './defineRoom.js';
|
|
3
3
|
export { SSERoomBroadcaster } from './SSERoomBroadcaster.js';
|
|
4
|
+
export { type SSELogContext, SSERoomEventPublisher, type SSERoomEventPublisherDependencies, type SSERoomEventPublishOptions, } from './SSERoomEventPublisher.js';
|
|
4
5
|
export { SSERoomManager } from './SSERoomManager.js';
|
|
5
6
|
export type { PreDeliveryFilter, RoomBroadcastOptions, RoomNameResolver, SSERoomAdapter, SSERoomManagerConfig, SSERoomMessageHandler, SSERoomOperations, } from './types.js';
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
export { InMemoryAdapter } from './adapters/InMemoryAdapter.js';
|
|
4
4
|
export { defineRoom } from './defineRoom.js';
|
|
5
5
|
export { SSERoomBroadcaster } from './SSERoomBroadcaster.js';
|
|
6
|
+
export { SSERoomEventPublisher, } from './SSERoomEventPublisher.js';
|
|
6
7
|
export { SSERoomManager } from './SSERoomManager.js';
|
|
7
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../lib/sse/rooms/index.ts"],"names":[],"mappings":"AAAA,oBAAoB;AAEpB,WAAW;AACX,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../lib/sse/rooms/index.ts"],"names":[],"mappings":"AAAA,oBAAoB;AAEpB,WAAW;AACX,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAC5D,OAAO,EAEL,qBAAqB,GAGtB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opinionated-machine",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Very opinionated DI framework for fastify, built on top of awilix ",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
"@types/node": "^22.19.7",
|
|
49
49
|
"@vitest/coverage-v8": "^4.1.11",
|
|
50
50
|
"awilix": "^13.0.0",
|
|
51
|
-
"awilix-manager": "^7.0.
|
|
52
|
-
"fastify": "^5.
|
|
51
|
+
"awilix-manager": "^7.0.1",
|
|
52
|
+
"fastify": "^5.12.4",
|
|
53
53
|
"fastify-type-provider-zod": "^7.0.0",
|
|
54
54
|
"rimraf": "^6.1.2",
|
|
55
55
|
"typescript": "^5.9.3",
|
|
56
56
|
"vitest": "^4.1.11",
|
|
57
|
-
"zod": "^4.
|
|
57
|
+
"zod": "^4.6.2",
|
|
58
58
|
"@opinionated-machine/sse-fallback": "0.1.1"
|
|
59
59
|
},
|
|
60
60
|
"private": false,
|