opinionated-machine 10.5.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 +154 -0
- package/README.md +513 -52
- package/dist/lib/DIContext.d.ts +13 -1
- package/dist/lib/DIContext.js +28 -8
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +47 -5
- package/dist/lib/api-contracts/apiRouteBuilder.js +57 -8
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
- package/dist/lib/api-contracts/apiSseConnectionRegistry.d.ts +182 -0
- package/dist/lib/api-contracts/apiSseConnectionRegistry.js +332 -0
- package/dist/lib/api-contracts/apiSseConnectionRegistry.js.map +1 -0
- package/dist/lib/api-contracts/index.d.ts +1 -0
- package/dist/lib/api-contracts/index.js +1 -0
- package/dist/lib/api-contracts/index.js.map +1 -1
- package/dist/lib/gateway/gatewayMetadata.d.ts +1 -0
- package/dist/lib/gateway/gatewayMetadata.js +11 -0
- package/dist/lib/gateway/gatewayMetadata.js.map +1 -1
- package/dist/lib/gateway/index.d.ts +1 -0
- package/dist/lib/gateway/index.js +1 -0
- package/dist/lib/gateway/index.js.map +1 -1
- package/dist/lib/gateway/manifest/buildManifest.d.ts +18 -0
- package/dist/lib/gateway/manifest/buildManifest.js +31 -0
- package/dist/lib/gateway/manifest/buildManifest.js.map +1 -1
- package/dist/lib/gateway/manifest/manifestSchema.d.ts +18 -0
- package/dist/lib/gateway/manifest/manifestSchema.js +27 -0
- package/dist/lib/gateway/manifest/manifestSchema.js.map +1 -1
- package/dist/lib/gateway/routeStreaming.d.ts +79 -0
- package/dist/lib/gateway/routeStreaming.js +70 -0
- package/dist/lib/gateway/routeStreaming.js.map +1 -0
- package/dist/lib/resolverFunctions.d.ts +7 -0
- package/dist/lib/resolverFunctions.js +13 -0
- package/dist/lib/resolverFunctions.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.js +9 -2
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +10 -0
- package/dist/lib/sse/AbstractSSEController.js +9 -0
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/eventIds.d.ts +138 -0
- package/dist/lib/sse/eventIds.js +155 -0
- package/dist/lib/sse/eventIds.js.map +1 -0
- package/dist/lib/sse/index.d.ts +3 -2
- package/dist/lib/sse/index.js +6 -2
- 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/dist/lib/testing/apiSseEventValidation.d.ts +1 -1
- package/dist/lib/testing/apiSseInjectHelpers.js +9 -10
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +10 -2
- package/dist/lib/testing/sseHttpClient.js +13 -6
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectClient.d.ts +1 -1
- package/dist/lib/testing/sseInjectClient.js +1 -1
- package/dist/lib/testing/sseInjectClient.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +1 -1
- package/package.json +13 -13
- package/dist/lib/sse/sseParser.d.ts +0 -167
- package/dist/lib/sse/sseParser.js +0 -225
- package/dist/lib/sse/sseParser.js.map +0 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
43
43
|
- [Error Handling](#error-handling)
|
|
44
44
|
- [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
|
|
45
45
|
- [SSE Parsing Utilities](#sse-parsing-utilities)
|
|
46
|
+
- [parseSSEResponse](#parsesseresponse)
|
|
47
|
+
- [createSSEStreamParser and parseSSEStream](#createssestreamparser-and-parsessestream)
|
|
46
48
|
- [parseSSEEvents](#parsesseevents)
|
|
47
49
|
- [parseSSEBuffer](#parsessebuffer)
|
|
48
50
|
- [ParsedSSEEvent Type](#parsedsseevent-type)
|
|
@@ -54,6 +56,7 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
54
56
|
- [Session Room Operations](#session-room-operations)
|
|
55
57
|
- [Broadcasting to Rooms](#broadcasting-to-rooms)
|
|
56
58
|
- [Room Broadcaster (Decoupled Broadcasting)](#room-broadcaster-decoupled-broadcasting)
|
|
59
|
+
- [Room Event Publisher (Fire-and-Forget)](#room-event-publisher-fire-and-forget)
|
|
57
60
|
- [Room Name Helpers](#room-name-helpers)
|
|
58
61
|
- [Room Query Methods](#room-query-methods)
|
|
59
62
|
- [Auto-Leave on Disconnect](#auto-leave-on-disconnect)
|
|
@@ -94,7 +97,13 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
94
97
|
- [Field Reference](#field-reference)
|
|
95
98
|
- [Generating Gateway Configs](#generating-gateway-configs)
|
|
96
99
|
- [Inspecting the Manifest at Runtime](#inspecting-the-manifest-at-runtime)
|
|
100
|
+
- [Streaming Routes](#streaming-routes)
|
|
97
101
|
- [What's Not Covered](#whats-not-covered)
|
|
102
|
+
- [Polling Fallback for SSE](#polling-fallback-for-sse)
|
|
103
|
+
- [Serving the Pattern](#serving-the-pattern)
|
|
104
|
+
- [Monotonic Event IDs](#monotonic-event-ids)
|
|
105
|
+
- [Server-Side Guarantees Checklist](#server-side-guarantees-checklist)
|
|
106
|
+
- [Development](#development)
|
|
98
107
|
|
|
99
108
|
## Basic usage
|
|
100
109
|
|
|
@@ -1380,21 +1389,86 @@ private handleStream = buildHandler(streamContract, {
|
|
|
1380
1389
|
// Connection closes automatically when handler returns
|
|
1381
1390
|
},
|
|
1382
1391
|
})
|
|
1392
|
+
```
|
|
1383
1393
|
|
|
1384
1394
|
### SSE Parsing Utilities
|
|
1385
1395
|
|
|
1386
|
-
|
|
1396
|
+
Wire-format parsing lives in
|
|
1397
|
+
[`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
|
|
1398
|
+
re-exported here, so the server's test helpers and the browser client
|
|
1399
|
+
(`@opinionated-machine/sse-fallback`) frame a stream with the same code.
|
|
1387
1400
|
|
|
1388
|
-
| Function | Use
|
|
1401
|
+
| Function | Use case |
|
|
1389
1402
|
|----------|----------|
|
|
1390
|
-
| `
|
|
1391
|
-
| `
|
|
1403
|
+
| `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
|
|
1404
|
+
| `parseSSEStream` | An async iterable of already-decoded text chunks |
|
|
1405
|
+
| `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
|
|
1406
|
+
| `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
|
|
1407
|
+
| `parseSSEBuffer` | The primitive the others are built on |
|
|
1408
|
+
|
|
1409
|
+
#### parseSSEResponse
|
|
1410
|
+
|
|
1411
|
+
Consume a live SSE stream from `fetch`. Multi-byte characters split across
|
|
1412
|
+
network chunks are held back, and breaking out of the loop cancels the
|
|
1413
|
+
response body.
|
|
1414
|
+
|
|
1415
|
+
```ts
|
|
1416
|
+
import { parseSSEResponse } from 'opinionated-machine'
|
|
1417
|
+
|
|
1418
|
+
const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
|
|
1419
|
+
|
|
1420
|
+
for await (const event of parseSSEResponse(response)) {
|
|
1421
|
+
console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
|
|
1422
|
+
if (event.event === 'done') break
|
|
1423
|
+
}
|
|
1424
|
+
```
|
|
1425
|
+
|
|
1426
|
+
Unlike `EventSource` the request is yours: custom headers, a POST body, an
|
|
1427
|
+
`AbortSignal`, your own reconnect policy.
|
|
1428
|
+
|
|
1429
|
+
#### createSSEStreamParser and parseSSEStream
|
|
1430
|
+
|
|
1431
|
+
When the transport hands you decoded text rather than a `Response`, or when you
|
|
1432
|
+
need the reconnect cursor after the stream ends.
|
|
1433
|
+
|
|
1434
|
+
```ts
|
|
1435
|
+
import { createSSEStreamParser } from 'opinionated-machine'
|
|
1436
|
+
|
|
1437
|
+
// One per connection: it holds the partial frame, the Last-Event-ID cursor and
|
|
1438
|
+
// the BOM that may open the stream.
|
|
1439
|
+
const parser = createSSEStreamParser({ lastEventId: resumeFrom })
|
|
1440
|
+
|
|
1441
|
+
for await (const chunk of chunks) {
|
|
1442
|
+
for (const event of parser.push(chunk)) {
|
|
1443
|
+
console.log('Received:', event.event ?? 'message', event.data)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
reconnectWith(parser.lastEventId)
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
`parseSSEStream` wraps that loop when you only want the events:
|
|
1451
|
+
|
|
1452
|
+
```ts
|
|
1453
|
+
import { parseSSEStream } from 'opinionated-machine'
|
|
1454
|
+
|
|
1455
|
+
for await (const event of parseSSEStream(chunks, {
|
|
1456
|
+
onChunk: () => resetStaleConnectionTimer(),
|
|
1457
|
+
})) {
|
|
1458
|
+
handle(event)
|
|
1459
|
+
}
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
`onChunk` fires for every chunk before it is framed, comment frames included.
|
|
1463
|
+
Framing consumes `: heartbeat` comments, so a consumer watching only events
|
|
1464
|
+
cannot tell an idle-but-healthy connection from a dead one.
|
|
1392
1465
|
|
|
1393
1466
|
#### parseSSEEvents
|
|
1394
1467
|
|
|
1395
1468
|
Parse a complete SSE response body into an array of events.
|
|
1396
1469
|
|
|
1397
|
-
**When to use:**
|
|
1470
|
+
**When to use:** testing with Fastify's `inject()`, or when the full response is
|
|
1471
|
+
available (request-response style SSE such as OpenAI completions):
|
|
1398
1472
|
|
|
1399
1473
|
```ts
|
|
1400
1474
|
import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
|
|
@@ -1418,67 +1492,58 @@ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
|
|
|
1418
1492
|
const notifications = events.map(e => JSON.parse(e.data))
|
|
1419
1493
|
```
|
|
1420
1494
|
|
|
1421
|
-
|
|
1495
|
+
A trailing frame with no blank line after it is discarded, which is what the
|
|
1496
|
+
spec requires at the end of a stream: a body cut mid-frame must not surface its
|
|
1497
|
+
truncated payload as a delivered event. Reach for `parseSSEBuffer` when you want
|
|
1498
|
+
to inspect that leftover.
|
|
1422
1499
|
|
|
1423
|
-
|
|
1500
|
+
#### parseSSEBuffer
|
|
1424
1501
|
|
|
1425
|
-
|
|
1502
|
+
One pass over a buffer: the events it completed, the bytes it could not, and the
|
|
1503
|
+
reconnect cursor. Prefer `createSSEStreamParser` for a live stream, which keeps
|
|
1504
|
+
all three across chunks for you.
|
|
1426
1505
|
|
|
1427
1506
|
```ts
|
|
1428
1507
|
import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
|
|
1429
1508
|
|
|
1430
1509
|
let buffer = ''
|
|
1510
|
+
let cursor: string | undefined
|
|
1431
1511
|
|
|
1432
|
-
// As chunks arrive from a stream...
|
|
1433
1512
|
for await (const chunk of stream) {
|
|
1434
1513
|
buffer += chunk
|
|
1435
|
-
|
|
1514
|
+
// Feeding the cursor back is what makes Last-Event-ID survive: an event with
|
|
1515
|
+
// no `id:` of its own inherits the previous one, and an `id:` frame carrying
|
|
1516
|
+
// no data still moves it.
|
|
1517
|
+
const result: ParseSSEBufferResult = parseSSEBuffer(buffer, cursor)
|
|
1518
|
+
buffer = result.remaining
|
|
1519
|
+
cursor = result.lastEventId
|
|
1436
1520
|
|
|
1437
|
-
// Process complete events
|
|
1438
1521
|
for (const event of result.events) {
|
|
1439
1522
|
console.log('Received:', event.event, event.data)
|
|
1440
1523
|
}
|
|
1441
|
-
|
|
1442
|
-
// Keep incomplete data for next chunk
|
|
1443
|
-
buffer = result.remaining
|
|
1444
|
-
}
|
|
1445
|
-
```
|
|
1446
|
-
|
|
1447
|
-
**Production example with fetch:**
|
|
1448
|
-
|
|
1449
|
-
```ts
|
|
1450
|
-
const response = await fetch(url)
|
|
1451
|
-
const reader = response.body!.getReader()
|
|
1452
|
-
const decoder = new TextDecoder()
|
|
1453
|
-
let buffer = ''
|
|
1454
|
-
|
|
1455
|
-
while (true) {
|
|
1456
|
-
const { done, value } = await reader.read()
|
|
1457
|
-
if (done) break
|
|
1458
|
-
|
|
1459
|
-
buffer += decoder.decode(value, { stream: true })
|
|
1460
|
-
const { events, remaining } = parseSSEBuffer(buffer)
|
|
1461
|
-
buffer = remaining
|
|
1462
|
-
|
|
1463
|
-
for (const event of events) {
|
|
1464
|
-
console.log('Received:', event.event, JSON.parse(event.data))
|
|
1465
|
-
}
|
|
1466
1524
|
}
|
|
1467
1525
|
```
|
|
1468
1526
|
|
|
1469
1527
|
#### ParsedSSEEvent Type
|
|
1470
1528
|
|
|
1471
|
-
|
|
1529
|
+
Every entry point returns events with this structure:
|
|
1472
1530
|
|
|
1473
1531
|
```ts
|
|
1474
1532
|
type ParsedSSEEvent = {
|
|
1475
|
-
id?: string
|
|
1476
|
-
event?: string
|
|
1477
|
-
data: string
|
|
1478
|
-
retry?: number
|
|
1533
|
+
id?: string // The "id:" this event carried, if any
|
|
1534
|
+
event?: string // Event type from "event:"; absent means 'message'
|
|
1535
|
+
data: string // Event data from "data:", always present
|
|
1536
|
+
retry?: number // Reconnection interval from "retry:"
|
|
1537
|
+
lastEventId?: string // The reconnect cursor as of this event's dispatch
|
|
1479
1538
|
}
|
|
1480
1539
|
```
|
|
1481
1540
|
|
|
1541
|
+
`id` and `lastEventId` are separate on purpose. The cursor persists across
|
|
1542
|
+
events that carry no `id:` of their own, so it is what you reconnect with;
|
|
1543
|
+
`id` is what the event itself carried, so it is what you order and deduplicate
|
|
1544
|
+
on. Ordering on the cursor instead makes every inheriting event look like a
|
|
1545
|
+
duplicate of the last id-bearing one.
|
|
1546
|
+
|
|
1482
1547
|
### Testing SSE Controllers
|
|
1483
1548
|
|
|
1484
1549
|
The test client depends on the session mode:
|
|
@@ -1962,6 +2027,103 @@ class MetricsService {
|
|
|
1962
2027
|
|
|
1963
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.
|
|
1964
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
|
+
|
|
1965
2127
|
#### Room Name Helpers
|
|
1966
2128
|
|
|
1967
2129
|
Room names are plain strings (like Socket.IO), but `defineRoom()` adds type-safe resolvers that ensure consistent naming across controllers and domain services:
|
|
@@ -2062,7 +2224,7 @@ class DashboardModule extends AbstractModule {
|
|
|
2062
2224
|
|
|
2063
2225
|
The Redis adapter uses Pub/Sub for cross-node message propagation. When you call `broadcastToRoom()`, the message is published to Redis and delivered to all nodes that have connections in that room.
|
|
2064
2226
|
|
|
2065
|
-
See the [@opinionated-machine/sse-rooms-redis](
|
|
2227
|
+
See the [@opinionated-machine/sse-rooms-redis](../sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
|
|
2066
2228
|
|
|
2067
2229
|
### SSE Subscriptions
|
|
2068
2230
|
|
|
@@ -3017,6 +3179,13 @@ await app.ready()
|
|
|
3017
3179
|
|
|
3018
3180
|
### Accept Header Routing
|
|
3019
3181
|
|
|
3182
|
+
Dual-mode routes are registered with @fastify/sse kind `'manual'`, so the
|
|
3183
|
+
framework's `determineMode()` is the single Accept negotiator: q-value aware,
|
|
3184
|
+
`defaultMode` applies for `*/*` or a missing `Accept` header (including
|
|
3185
|
+
`defaultMode: 'sse'`). SSE-only routes use kind `'only'` — a missing header or
|
|
3186
|
+
`*/*` streams, and a client that explicitly refuses `text/event-stream`
|
|
3187
|
+
(e.g. `Accept: application/json`) receives a clean 406.
|
|
3188
|
+
|
|
3020
3189
|
The `Accept` header determines response mode:
|
|
3021
3190
|
|
|
3022
3191
|
```bash
|
|
@@ -3253,9 +3422,9 @@ them in:
|
|
|
3253
3422
|
|
|
3254
3423
|
| Gateway | Package | Output |
|
|
3255
3424
|
| ------- | ------- | ------ |
|
|
3256
|
-
| Envoy | [`@opinionated-machine/gateway-envoy`](
|
|
3257
|
-
| KrakenD | [`@opinionated-machine/gateway-krakend`](
|
|
3258
|
-
| Kong | [`@opinionated-machine/gateway-kong`](
|
|
3425
|
+
| Envoy | [`@opinionated-machine/gateway-envoy`](../gateway-envoy) | static v3 YAML/JSON |
|
|
3426
|
+
| KrakenD | [`@opinionated-machine/gateway-krakend`](../gateway-krakend) | declarative v3 JSON |
|
|
3427
|
+
| Kong | [`@opinionated-machine/gateway-kong`](../gateway-kong) | DB-less declarative YAML/JSON |
|
|
3259
3428
|
|
|
3260
3429
|
### Quick Start
|
|
3261
3430
|
|
|
@@ -3478,7 +3647,7 @@ time.
|
|
|
3478
3647
|
| Field | Example | Notes |
|
|
3479
3648
|
| ----- | ------- | ----- |
|
|
3480
3649
|
| `upstream` | `'users-service'` | Logical cluster name; resolved to a host by the generator |
|
|
3481
|
-
| `timeouts` | `{ request: '5s', idle: '60s', connect: '1s' }` | Duration units: `ms` / `s` / `m` / `h` |
|
|
3650
|
+
| `timeouts` | `{ request: '5s', idle: '60s', connect: '1s' }` | Duration units: `ms` / `s` / `m` / `h`. `idle` maps to Envoy route `idle_timeout`, joins Kong's loosest-wins `read_timeout`, and raises KrakenD's endpoint `timeout` — declare it on streaming routes to bound liveness (pair with heartbeats) |
|
|
3482
3651
|
| `retry` | `{ attempts: 2, on: ['5xx', 'connect-failure'], perTryTimeout: '2s' }` | |
|
|
3483
3652
|
| `rateLimit` | `{ requests: 100, per: '1m', key: 'ip' }` | `key`: `'ip'`, `{ header }`, `{ customHeader }`, `{ query }`, `{ customQuery }` |
|
|
3484
3653
|
| `cache` | `{ ttl: '60s', methods: ['GET'], vary: ['Accept-Language'] }` | |
|
|
@@ -3532,9 +3701,9 @@ generators merge that block onto the rendered route last.
|
|
|
3532
3701
|
|
|
3533
3702
|
For each gateway's full mapping table and quirks:
|
|
3534
3703
|
|
|
3535
|
-
- [`@opinionated-machine/gateway-envoy`](
|
|
3536
|
-
- [`@opinionated-machine/gateway-krakend`](
|
|
3537
|
-
- [`@opinionated-machine/gateway-kong`](
|
|
3704
|
+
- [`@opinionated-machine/gateway-envoy`](../gateway-envoy/README.md)
|
|
3705
|
+
- [`@opinionated-machine/gateway-krakend`](../gateway-krakend/README.md)
|
|
3706
|
+
- [`@opinionated-machine/gateway-kong`](../gateway-kong/README.md)
|
|
3538
3707
|
|
|
3539
3708
|
### Inspecting the Manifest at Runtime
|
|
3540
3709
|
|
|
@@ -3565,11 +3734,82 @@ const manifest = app.buildGatewayManifest()
|
|
|
3565
3734
|
The manifest is rebuilt on every call, so it always reflects the current set
|
|
3566
3735
|
of registered controllers.
|
|
3567
3736
|
|
|
3737
|
+
### Streaming Routes
|
|
3738
|
+
|
|
3739
|
+
SSE and dual-mode routes need gateway treatment that request-response routes
|
|
3740
|
+
must not get: Envoy's defaults (15s route timeout, 5-minute stream idle
|
|
3741
|
+
timeout) reset long-lived streams, and buffering proxies hold SSE frames until
|
|
3742
|
+
the response completes. Routes built from SSE-capable contracts are therefore
|
|
3743
|
+
stamped with a streaming mode, and the manifest carries it as
|
|
3744
|
+
`streaming: 'sse' | 'dual'`.
|
|
3745
|
+
|
|
3746
|
+
The marker describes the **success path**. An error status answers with a JSON
|
|
3747
|
+
body on a streaming route too (including the early-return `sse.respond(404,
|
|
3748
|
+
...)` path), so generators size timeouts and buffering from it but must not
|
|
3749
|
+
assume the content type of a failure.
|
|
3750
|
+
|
|
3751
|
+
- **Envoy** — streaming routes default to `timeout: 0s` and `idle_timeout: 0s`
|
|
3752
|
+
(declare `timeouts.idle` to reinstate a liveness bound; heartbeats are the
|
|
3753
|
+
intended keep-alive). `EnvoyOptions.streamIdleTimeout` sets the listener-wide
|
|
3754
|
+
HCM `stream_idle_timeout` for everything else. Declaring `timeouts.request`
|
|
3755
|
+
on an SSE-only route warns — it bounds the stream's total lifetime.
|
|
3756
|
+
|
|
3757
|
+
With both of those timeouts off, a streaming route would otherwise have an
|
|
3758
|
+
**unbounded** lifetime, and the authorization checked when the stream opened
|
|
3759
|
+
would stay in force for as long as the connection lives — a principal removed
|
|
3760
|
+
from a scope keeps receiving events until they close the tab. Streaming
|
|
3761
|
+
routes therefore emit a route-level `max_stream_duration`, defaulting to
|
|
3762
|
+
30 minutes. Configure it with `EnvoyOptions.maxStreamDuration` (`'off'` for
|
|
3763
|
+
the old unbounded behaviour) or per route with `timeouts.maxDuration`
|
|
3764
|
+
(`'0s'` to opt out). The ceiling is invisible to users when the client
|
|
3765
|
+
treats a server close as a routine reconnect, which
|
|
3766
|
+
`@opinionated-machine/sse-fallback` does.
|
|
3767
|
+
|
|
3768
|
+
A **dual-mode** route is emitted as *two* Envoy routes, because one route
|
|
3769
|
+
cannot be both: `<id>__sse`, matched on `Accept: text/event-stream`, and
|
|
3770
|
+
`<id>`, the catch-all. The declared timeouts are split between them rather
|
|
3771
|
+
than applied to both — `timeouts.idle` goes to the stream branch,
|
|
3772
|
+
`timeouts.request` to the JSON branch, which is the fallback poll path and
|
|
3773
|
+
the one that most needs a bound. The split keys off the `Accept` header, the
|
|
3774
|
+
same predicate `determineMode()` uses server-side, quality values included:
|
|
3775
|
+
`text/event-stream;q=0` is a refusal, so it takes the JSON branch.
|
|
3776
|
+
|
|
3777
|
+
A route declaring `defaultMode: 'sse'` inverts the split, because there the
|
|
3778
|
+
server streams for a missing or wildcard `Accept` header. The manifest
|
|
3779
|
+
carries the fallback branch as `streamingDefaultMode` (`'non-sse' | 'sse'`,
|
|
3780
|
+
the `@lokalise/api-contracts` vocabulary), and Envoy makes the
|
|
3781
|
+
stream the catch-all with `<id>__json` as the narrow branch, so an
|
|
3782
|
+
unspecific request cannot land on the JSON branch's request timeout while the
|
|
3783
|
+
server is streaming. A request listing both media types resolves to JSON on
|
|
3784
|
+
the server but takes the stream branch at the gateway; the renderer warns
|
|
3785
|
+
about that residual ambiguity.
|
|
3786
|
+
- **Kong** — streaming routes emit `response_buffering: false` (Kong ≥ 2.3);
|
|
3787
|
+
`timeouts.idle` joins the loosest-wins service `read_timeout`. Streaming
|
|
3788
|
+
routes without a declared idle warn: heartbeats must arrive within the
|
|
3789
|
+
effective `read_timeout` (Kong default 60s) or the stream is reset.
|
|
3790
|
+
|
|
3791
|
+
Kong CE's `read_timeout` is **service-level**, so a long streaming idle
|
|
3792
|
+
window loosens every route sharing that upstream. Each co-located
|
|
3793
|
+
non-streaming route that inherits a raised timeout is warned about by name;
|
|
3794
|
+
give streaming routes their own `metadata.upstream` when the plain routes
|
|
3795
|
+
beside them need to stay tightly bounded.
|
|
3796
|
+
- **KrakenD** — the endpoint `timeout` uses the looser of `timeouts.request` /
|
|
3797
|
+
`timeouts.idle`; streaming routes with neither warn about KrakenD's 2s
|
|
3798
|
+
default endpoint timeout.
|
|
3799
|
+
|
|
3800
|
+
Routes declared through `AbstractApiController` are always included in the
|
|
3801
|
+
manifest. Legacy `AbstractSSEController` / `AbstractDualModeController` routes
|
|
3802
|
+
are included when you opt in:
|
|
3803
|
+
|
|
3804
|
+
```ts
|
|
3805
|
+
const manifest = context.buildGatewayManifest({
|
|
3806
|
+
service: 'users-api',
|
|
3807
|
+
includeStreamingControllers: true, // default false — existing manifests don't silently grow
|
|
3808
|
+
})
|
|
3809
|
+
```
|
|
3810
|
+
|
|
3568
3811
|
### What's Not Covered
|
|
3569
3812
|
|
|
3570
|
-
- **SSE and dual-mode controllers.** Only routes from `AbstractController` and
|
|
3571
|
-
`AbstractApiController` appear in the manifest today. Streaming routes still
|
|
3572
|
-
proxy through every gateway, but they aren't listed.
|
|
3573
3813
|
- **Fields a particular gateway can't natively express.** They show up in
|
|
3574
3814
|
`result.warnings` rather than disappearing. Reach for `extensions.<vendor>`
|
|
3575
3815
|
to hand-write the missing piece on a per-route basis.
|
|
@@ -3577,3 +3817,224 @@ of registered controllers.
|
|
|
3577
3817
|
gateway runs separately. The generators don't compare deployed gateway
|
|
3578
3818
|
state against the manifest.
|
|
3579
3819
|
|
|
3820
|
+
|
|
3821
|
+
## Polling Fallback for SSE
|
|
3822
|
+
|
|
3823
|
+
Push channels fail silently: connections die without an error event, proxies
|
|
3824
|
+
kill idle streams, a broadcast misses a rebalancing room. When the missed
|
|
3825
|
+
notification gates workflow progress ("upload finished"), the user is stuck.
|
|
3826
|
+
|
|
3827
|
+
[`@opinionated-machine/sse-fallback`](../sse-fallback/README.md) is a
|
|
3828
|
+
browser-safe, zero-dependency client core that makes **polling the correctness
|
|
3829
|
+
backbone** and SSE the latency optimization: the client subscribes to the SSE
|
|
3830
|
+
branch of a dual-mode route and keeps a deadman timer — when no data event
|
|
3831
|
+
arrives within the window, it polls the JSON branch of the same route. A
|
|
3832
|
+
version gate reconciles the two channels so app code sees exactly one uniform
|
|
3833
|
+
event stream:
|
|
3834
|
+
|
|
3835
|
+
```ts
|
|
3836
|
+
// Shared contracts module — the binding is the reconciliation declaration
|
|
3837
|
+
export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
|
|
3838
|
+
snapshotToEvents: (s) =>
|
|
3839
|
+
s.status === 'completed' ? [{ event: 'uploadFinished', data: { result: s.result } }] : [],
|
|
3840
|
+
version: { ofSnapshot: (s) => s.version },
|
|
3841
|
+
terminalEvents: ['uploadFinished', 'uploadFailed'],
|
|
3842
|
+
})
|
|
3843
|
+
|
|
3844
|
+
// Client — identical result whether it traveled over SSE, replay, or a poll
|
|
3845
|
+
const sub = createResilientSubscription(uploadStatusBinding, { transport, params })
|
|
3846
|
+
const { result } = await sub.waitFor('uploadFinished')
|
|
3847
|
+
```
|
|
3848
|
+
|
|
3849
|
+
See the [package README](../sse-fallback/README.md) for the state
|
|
3850
|
+
machine, reconciliation semantics, hydration (initial load + live updates),
|
|
3851
|
+
and the transport interface.
|
|
3852
|
+
|
|
3853
|
+
### Serving the Pattern
|
|
3854
|
+
|
|
3855
|
+
One dual-mode `AbstractApiController` route serves both channels — the sync
|
|
3856
|
+
branch answers the fallback polls, the SSE branch joins a room that the domain
|
|
3857
|
+
service broadcasts into:
|
|
3858
|
+
|
|
3859
|
+
```ts
|
|
3860
|
+
readonly routes = {
|
|
3861
|
+
jobStatus: buildApiRoute(
|
|
3862
|
+
jobStatusContract,
|
|
3863
|
+
(request, _reply, { expectedContentType, sse }) => {
|
|
3864
|
+
// The push channel: join the job's room and stay open
|
|
3865
|
+
if (expectedContentType === 'text/event-stream') {
|
|
3866
|
+
const session = sse.start('keepAlive')
|
|
3867
|
+
getSessionRooms(session).join(`job:${request.params.jobId}`)
|
|
3868
|
+
return
|
|
3869
|
+
}
|
|
3870
|
+
// The fallback poll: return the current snapshot with its version
|
|
3871
|
+
return { status: 200, body: this.jobs.get(request.params.jobId) }
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
// enables room membership + broadcast delivery for this route's sessions
|
|
3875
|
+
sseRooms: this.sseRoomBroadcaster,
|
|
3876
|
+
},
|
|
3877
|
+
),
|
|
3878
|
+
}
|
|
3879
|
+
```
|
|
3880
|
+
|
|
3881
|
+
```ts
|
|
3882
|
+
// Domain service — broadcast with a monotonic id so clients can order events
|
|
3883
|
+
await this.sseRoomBroadcaster.broadcastToRoom(`job:${jobId}`, doneEvent, { result }, {
|
|
3884
|
+
id: String(job.version),
|
|
3885
|
+
})
|
|
3886
|
+
```
|
|
3887
|
+
|
|
3888
|
+
### SSE Rooms Authorization
|
|
3889
|
+
|
|
3890
|
+
Room membership decides who receives a broadcast, so it is an authorization
|
|
3891
|
+
boundary. The handler above names the room from a path param; nothing in that
|
|
3892
|
+
line checks that the authenticated principal belongs to the job's scope. Pass
|
|
3893
|
+
an options object instead of the bare broadcaster to declare the check once
|
|
3894
|
+
per route:
|
|
3895
|
+
|
|
3896
|
+
```ts
|
|
3897
|
+
{
|
|
3898
|
+
sseRooms: {
|
|
3899
|
+
broadcaster: this.sseRoomBroadcaster,
|
|
3900
|
+
// Refused joins are logged and dropped; the stream itself stays open.
|
|
3901
|
+
authorizeJoin: (session, room) => this.membership.canRead(session.request.user, room),
|
|
3902
|
+
// Close the session after 30 minutes, forcing a re-authorized reconnect.
|
|
3903
|
+
maxSessionLifetimeMs: 30 * 60_000,
|
|
3904
|
+
},
|
|
3905
|
+
}
|
|
3906
|
+
```
|
|
3907
|
+
|
|
3908
|
+
A synchronous verdict is applied before `join()` returns; an async one is
|
|
3909
|
+
applied when it resolves, so the session joins a moment later and the client's
|
|
3910
|
+
reconciliation poll covers anything broadcast in between.
|
|
3911
|
+
|
|
3912
|
+
Authorization checked at connect goes stale, so revocation needs a termination
|
|
3913
|
+
path of its own:
|
|
3914
|
+
|
|
3915
|
+
```ts
|
|
3916
|
+
const registry = getApiSseConnectionRegistry(this.sseRoomBroadcaster)
|
|
3917
|
+
|
|
3918
|
+
registry.evict(connectionId) // end one stream
|
|
3919
|
+
registry.evictFromRoom(room, connectionId) // drop one scope, keep the stream
|
|
3920
|
+
registry.closeRoom(`project:${projectId}`) // end every stream in a scope
|
|
3921
|
+
```
|
|
3922
|
+
|
|
3923
|
+
Only connections on the current node are closed, so a revocation event has to
|
|
3924
|
+
reach every node. A client that reconnects (as
|
|
3925
|
+
`@opinionated-machine/sse-fallback` does) comes back through the route's own
|
|
3926
|
+
authorization, so evicting a still-authorized principal costs a reconnect
|
|
3927
|
+
rather than a broken surface — which is also why `maxSessionLifetimeMs` is
|
|
3928
|
+
cheap: it doubles as the token-refresh mechanism and the backstop for a
|
|
3929
|
+
revocation that never reached `evict()`.
|
|
3930
|
+
|
|
3931
|
+
`test/api-contracts/api.rooms.security.e2e.spec.ts` is the pattern to copy per
|
|
3932
|
+
endpoint: a negative cross-tenant join test, and a mid-stream revocation test.
|
|
3933
|
+
|
|
3934
|
+
### Monotonic Event IDs
|
|
3935
|
+
|
|
3936
|
+
`Last-Event-ID` replay, client-side ordering, and the fallback version gate
|
|
3937
|
+
all need event ids a client can ORDER, not just deduplicate. Use
|
|
3938
|
+
`createEventIdSequence()` (one sequence per ordering scope — per room, per
|
|
3939
|
+
resource):
|
|
3940
|
+
|
|
3941
|
+
```ts
|
|
3942
|
+
import { compareEventIds, createEventIdSequence } from 'opinionated-machine'
|
|
3943
|
+
|
|
3944
|
+
const seq = createEventIdSequence()
|
|
3945
|
+
await broadcaster.broadcastToRoom(room, statusEvent, data, { id: seq.next() })
|
|
3946
|
+
|
|
3947
|
+
// Ids order lexicographically within an epoch; across epochs (e.g. after a
|
|
3948
|
+
// process restart) compareEventIds returns undefined — clients resync via poll
|
|
3949
|
+
compareEventIds('e1-000000000001', 'e1-000000000002') // -1
|
|
3950
|
+
```
|
|
3951
|
+
|
|
3952
|
+
**Prefer a domain version** (`job.version`, a revision column) over a generated
|
|
3953
|
+
sequence whenever the resource has one: it is per-scope and writer-independent
|
|
3954
|
+
for free, and the snapshot body has to carry it anyway for the client's version
|
|
3955
|
+
gate.
|
|
3956
|
+
|
|
3957
|
+
`createEventIdSequence()` is in-memory and per-process, which makes it safe
|
|
3958
|
+
only for a **single-writer** ordering scope. Its epoch defaults to the process
|
|
3959
|
+
start time, and the client's default extractor orders by epoch first. If two
|
|
3960
|
+
pods of the same service broadcast into the same room, each with its own
|
|
3961
|
+
sequence, their epochs differ: the events interleave, the client's watermark
|
|
3962
|
+
lands on the newer epoch, and every subsequent event from the older-epoch pod
|
|
3963
|
+
compares as stale and is **silently dropped**. The failure only shows up under
|
|
3964
|
+
horizontal scale, so it reaches production.
|
|
3965
|
+
|
|
3966
|
+
For a multi-writer scope use a domain version, a fixed shared `epoch` with a
|
|
3967
|
+
`start` handed out from shared storage, or the Redis-backed sequence:
|
|
3968
|
+
|
|
3969
|
+
```ts
|
|
3970
|
+
import { createRedisEventIdSequence } from '@opinionated-machine/sse-rooms-redis'
|
|
3971
|
+
|
|
3972
|
+
// One counter per ordering scope, shared by every pod — one INCR per id.
|
|
3973
|
+
const seq = createRedisEventIdSequence({ client: redis, key: `sse:seq:job:${jobId}` })
|
|
3974
|
+
await broadcaster.broadcastToRoom(room, statusEvent, data, { id: await seq.next() })
|
|
3975
|
+
```
|
|
3976
|
+
|
|
3977
|
+
A shared counter orders **allocation**, not delivery. Between `next()` and the
|
|
3978
|
+
broadcast a writer can be descheduled while another pod allocates the next id
|
|
3979
|
+
and publishes first, so the client sees the higher id and drops the lower one
|
|
3980
|
+
as stale. What to do about it depends on what the events carry:
|
|
3981
|
+
|
|
3982
|
+
- **Replacement-safe events** (the payload describes the state of the scope,
|
|
3983
|
+
or the id is a domain version read in the same transaction that wrote it):
|
|
3984
|
+
nothing. The dropped event is superseded by the one that overtook it, which
|
|
3985
|
+
is what the version gate is for.
|
|
3986
|
+
- **Delta events applied to client state** (`state.apply`): the drop is a real
|
|
3987
|
+
loss. Serialize allocation and publication per ordering scope — one writer
|
|
3988
|
+
per scope, or a per-scope lock or outbox that publishes in id order.
|
|
3989
|
+
|
|
3990
|
+
Either way, call `next()` immediately before the broadcast with nothing awaited
|
|
3991
|
+
in between: the window that reorders is exactly that gap.
|
|
3992
|
+
|
|
3993
|
+
### Server-Side Guarantees Checklist
|
|
3994
|
+
|
|
3995
|
+
For a resource to participate in the fallback pattern:
|
|
3996
|
+
|
|
3997
|
+
1. **Required** — a monotonic version per resource, present in both the
|
|
3998
|
+
snapshot body and each event, and truthful: a snapshot at version *v*
|
|
3999
|
+
reflects every event ≤ *v* (publish events after commit; read committed
|
|
4000
|
+
state in the poll handler). Snapshots must **subsume** prior events.
|
|
4001
|
+
2. **Recommended** — stamp the SSE `id:` with that version; the client's
|
|
4002
|
+
default version extraction (bare integers and `createEventIdSequence()`
|
|
4003
|
+
ids alike) and `Last-Event-ID` replay then compose free. Make sure the id
|
|
4004
|
+
source is safe for the number of writers the scope has — see
|
|
4005
|
+
[Monotonic Event IDs](#monotonic-event-ids).
|
|
4006
|
+
3. **Recommended** — a short heartbeat interval (~15s, configured once via
|
|
4007
|
+
`app.register(fastifySSE, { heartbeatInterval })`) so clients detect
|
|
4008
|
+
silently dead connections fast; correctness holds without heartbeats
|
|
4009
|
+
(polls bound staleness), detection latency improves with them.
|
|
4010
|
+
4. Optional — dense (consecutive) versions enable client gap detection;
|
|
4011
|
+
`onReconnect` replay lets clients skip the post-reconnect poll
|
|
4012
|
+
(`replay: 'trusted'` in the binding).
|
|
4013
|
+
5. Gateway — declare `timeouts.idle` on streaming routes (or rely on the
|
|
4014
|
+
streaming-route defaults) so proxies don't reset quiet streams; see
|
|
4015
|
+
[Streaming Routes](#streaming-routes).
|
|
4016
|
+
6. Authorization — room membership decides who receives a broadcast, so it is
|
|
4017
|
+
an authorization boundary. Declare the scope check once per route with
|
|
4018
|
+
`sseRooms.authorizeJoin` rather than trusting every handler body, give
|
|
4019
|
+
sessions a `maxSessionLifetimeMs` so a check made at connect cannot stay in
|
|
4020
|
+
force forever, and call `getApiSseConnectionRegistry(broadcaster).evict()` /
|
|
4021
|
+
`.closeRoom()` when access is revoked mid-stream. See
|
|
4022
|
+
[SSE Rooms Authorization](#sse-rooms-authorization).
|
|
4023
|
+
|
|
4024
|
+
## Development
|
|
4025
|
+
|
|
4026
|
+
Tasks are orchestrated by [Turborepo](https://turborepo.dev), which reads the workspace graph from
|
|
4027
|
+
`turbo.jsonc`:
|
|
4028
|
+
|
|
4029
|
+
```bash
|
|
4030
|
+
pnpm run build:all # build every package in dependency order
|
|
4031
|
+
pnpm run lint:all # biome + tsc in every package
|
|
4032
|
+
pnpm exec turbo run test:ci # this package's suite with coverage
|
|
4033
|
+
```
|
|
4034
|
+
|
|
4035
|
+
Run these from the workspace root. Start with `build:all` on a fresh clone: the per-package
|
|
4036
|
+
`build`, `lint` and `test` scripts each do one package's work and assume their workspace
|
|
4037
|
+
dependencies are already built, so `pnpm run build` on its own fails until the graph has been
|
|
4038
|
+
built once.
|
|
4039
|
+
|
|
4040
|
+
See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full task table and caching notes.
|