@ignex/nova 0.1.3 → 0.1.5
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/README.md +4 -1
- package/docs/ai/TREE.md +69 -9
- package/docs/architecture.md +75 -27
- package/docs/events.md +83 -1
- package/docs/generic-bindings.md +10 -0
- package/docs/wire-format.md +65 -18
- package/package.json +2 -1
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/generate.ts +97 -3
- package/public/server.ts +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +376 -17
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/codegen/constants.ts +14 -4
- package/src/codegen/direct-gen.ts +20 -6
- package/src/codegen/registry-gen.ts +10 -6
- package/src/codegen/rust-glue-gen.ts +10 -3
- package/src/codegen/schema-model.ts +28 -3
- package/src/codegen/ts-ser-gen.ts +12 -3
- package/src/core/auth.ts +65 -4
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +42 -0
- package/src/core/client-wire.ts +142 -8
- package/src/core/client.ts +72 -3
- package/src/core/groups.ts +5 -0
- package/src/core/metrics.ts +38 -21
- package/src/core/outbound.ts +50 -6
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +128 -5
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/{server.ts → server/index.ts} +112 -120
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +73 -1
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +18 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +57 -11
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/queue.ts +36 -9
- package/src/events/registry.ts +90 -54
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +146 -59
- package/src/generated/fbs/backend.fbs +23 -0
- package/src/generated/registry.ts +92 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +109 -16
- package/src/generated/wire-registry.json +7 -3
- package/src/schema/index.ts +45 -1
- package/src/transport/transport.ts +117 -77
- package/src/bridge/nats.ts +0 -309
- package/src/events/cluster.ts +0 -732
- package/src/events/hub.ts +0 -481
- package/src/events/types.ts +0 -378
- package/src/transport/stats.ts +0 -48
package/README.md
CHANGED
|
@@ -292,9 +292,12 @@ createServer({
|
|
|
292
292
|
replay: { historySize: 64 }, // per-topic last-value replay on subscribe
|
|
293
293
|
authenticate: async (req) => checkToken(req.headers.get("authorization")),
|
|
294
294
|
allowedOrigins: ["http://localhost:3000"],
|
|
295
|
-
token: "shared-secret", // or (tok) => boolean
|
|
295
|
+
token: "shared-secret", // or (tok) => boolean — constant-time compared
|
|
296
296
|
maxConnections: 10_000,
|
|
297
297
|
maxMessageSize: 64 * 1024,
|
|
298
|
+
rateLimit: { messagesPerSecond: 100, burst: 200, policy: "drop" }, // per-conn inbound bucket ("close" → 1008)
|
|
299
|
+
authorizeTopic: (topic, ws) => topic !== "admin", // gate room joins (all paths)
|
|
300
|
+
authorizeGroup: (group, ws) => ws.data.userId != null, // gate group joins
|
|
298
301
|
int64Guard: "warn", // "off" | "throw" | "warn"
|
|
299
302
|
nats: { servers: ["nats://localhost:4222"], inbound: true }, // optional NATS bridge
|
|
300
303
|
tls: { keyFile, certFile }, // enables wss://
|
package/docs/ai/TREE.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
> curated maps live in `docs/architecture.md` + `docs/wire-format.md`
|
|
6
6
|
> (and `AGENTS.md` for agents).
|
|
7
7
|
|
|
8
|
-
- package: `@ignex/nova` v0.1.
|
|
8
|
+
- package: `@ignex/nova` v0.1.3
|
|
9
9
|
- engines: {"bun":">=1.4"}
|
|
10
10
|
- rust crate: `ignex-nova-ffi` v0.1.0
|
|
11
|
-
- scripts (
|
|
11
|
+
- scripts (20): `generate`, `build:rust`, `build:client`, `build:dist`, `build`, `prebuild`, `test`, `lint`, `typecheck`, `verify`, `pack:check`, `gen:ai-map`, …
|
|
12
12
|
|
|
13
13
|
## src/
|
|
14
14
|
|
|
@@ -19,7 +19,11 @@ src/
|
|
|
19
19
|
│ ├─ default.ts
|
|
20
20
|
│ └─ types.ts
|
|
21
21
|
├─ bridge/
|
|
22
|
-
│ ├─ nats
|
|
22
|
+
│ ├─ nats/
|
|
23
|
+
│ │ ├─ inbound.ts
|
|
24
|
+
│ │ ├─ index.ts
|
|
25
|
+
│ │ ├─ real-transport.ts
|
|
26
|
+
│ │ └─ types.ts
|
|
23
27
|
│ └─ subjects.ts
|
|
24
28
|
├─ codegen/
|
|
25
29
|
│ ├─ constants.ts
|
|
@@ -32,10 +36,17 @@ src/
|
|
|
32
36
|
│ ├─ ts-ser-gen.ts
|
|
33
37
|
│ └─ typebox-to-fbs.ts
|
|
34
38
|
├─ core/
|
|
39
|
+
│ ├─ server/
|
|
40
|
+
│ │ ├─ client-info.ts
|
|
41
|
+
│ │ ├─ http-routes.ts
|
|
42
|
+
│ │ ├─ index.ts
|
|
43
|
+
│ │ ├─ metrics-view.ts
|
|
44
|
+
│ │ └─ socket-lifecycle.ts
|
|
35
45
|
│ ├─ auth.ts
|
|
36
46
|
│ ├─ backpressure.ts
|
|
37
47
|
│ ├─ client-heartbeat.ts
|
|
38
48
|
│ ├─ client-reconnect.ts
|
|
49
|
+
│ ├─ client-rpc.ts
|
|
39
50
|
│ ├─ client-state.ts
|
|
40
51
|
│ ├─ client-wire.ts
|
|
41
52
|
│ ├─ client.ts
|
|
@@ -43,24 +54,59 @@ src/
|
|
|
43
54
|
│ ├─ int64-guard.ts
|
|
44
55
|
│ ├─ metrics.ts
|
|
45
56
|
│ ├─ outbound.ts
|
|
57
|
+
│ ├─ rate-limit.ts
|
|
46
58
|
│ ├─ replay.ts
|
|
59
|
+
│ ├─ resume.ts
|
|
47
60
|
│ ├─ ring.ts
|
|
48
61
|
│ ├─ rooms.ts
|
|
49
62
|
│ ├─ routing.ts
|
|
50
|
-
│ ├─
|
|
51
|
-
│ └─
|
|
63
|
+
│ ├─ state.ts
|
|
64
|
+
│ └─ topic-log.ts
|
|
52
65
|
├─ events/
|
|
66
|
+
│ ├─ cluster/
|
|
67
|
+
│ │ ├─ dedupe.ts
|
|
68
|
+
│ │ ├─ envelope.ts
|
|
69
|
+
│ │ ├─ index.ts
|
|
70
|
+
│ │ ├─ keys.ts
|
|
71
|
+
│ │ ├─ kinds.ts
|
|
72
|
+
│ │ ├─ presence-table.ts
|
|
73
|
+
│ │ ├─ presence.ts
|
|
74
|
+
│ │ ├─ redis-client.ts
|
|
75
|
+
│ │ ├─ store-memory.ts
|
|
76
|
+
│ │ ├─ store-redis.ts
|
|
77
|
+
│ │ ├─ subjects.ts
|
|
78
|
+
│ │ ├─ sync.ts
|
|
79
|
+
│ │ ├─ transport-nats.ts
|
|
80
|
+
│ │ └─ transport-redis.ts
|
|
81
|
+
│ ├─ hub/
|
|
82
|
+
│ │ ├─ context-factory.ts
|
|
83
|
+
│ │ ├─ dispatch.ts
|
|
84
|
+
│ │ ├─ index.ts
|
|
85
|
+
│ │ ├─ internal.ts
|
|
86
|
+
│ │ ├─ metrics-snapshot.ts
|
|
87
|
+
│ │ └─ resolve-cluster.ts
|
|
88
|
+
│ ├─ types/
|
|
89
|
+
│ │ ├─ client.ts
|
|
90
|
+
│ │ ├─ cluster.ts
|
|
91
|
+
│ │ ├─ context.ts
|
|
92
|
+
│ │ ├─ emit-target.ts
|
|
93
|
+
│ │ ├─ groups.ts
|
|
94
|
+
│ │ ├─ hub.ts
|
|
95
|
+
│ │ ├─ index.ts
|
|
96
|
+
│ │ ├─ metrics.ts
|
|
97
|
+
│ │ └─ options.ts
|
|
53
98
|
│ ├─ clients.ts
|
|
54
|
-
│ ├─ cluster.ts
|
|
99
|
+
│ ├─ cluster-rpc.ts
|
|
55
100
|
│ ├─ data.ts
|
|
101
|
+
│ ├─ delivery.ts
|
|
56
102
|
│ ├─ emit.ts
|
|
57
103
|
│ ├─ global.ts
|
|
58
104
|
│ ├─ groups.ts
|
|
59
|
-
│ ├─ hub.ts
|
|
60
105
|
│ ├─ index.ts
|
|
61
106
|
│ ├─ queue.ts
|
|
62
107
|
│ ├─ registry.ts
|
|
63
|
-
│
|
|
108
|
+
│ ├─ schedule.ts
|
|
109
|
+
│ └─ trace.ts
|
|
64
110
|
├─ generated/
|
|
65
111
|
│ ├─ fbs/
|
|
66
112
|
│ │ └─ backend.fbs
|
|
@@ -82,6 +128,10 @@ src/
|
|
|
82
128
|
│ │ ├─ portfolio-position.ts
|
|
83
129
|
│ │ ├─ portfolio-snapshot.ts
|
|
84
130
|
│ │ ├─ quote.ts
|
|
131
|
+
│ │ ├─ resume.ts
|
|
132
|
+
│ │ ├─ resumed.ts
|
|
133
|
+
│ │ ├─ rpc-call.ts
|
|
134
|
+
│ │ ├─ rpc-result.ts
|
|
85
135
|
│ │ ├─ side.ts
|
|
86
136
|
│ │ ├─ snapshot-request.ts
|
|
87
137
|
│ │ ├─ subscribe.ts
|
|
@@ -102,7 +152,6 @@ src/
|
|
|
102
152
|
├─ transport/
|
|
103
153
|
│ ├─ byte-buffer-pool.ts
|
|
104
154
|
│ ├─ scratch.ts
|
|
105
|
-
│ ├─ stats.ts
|
|
106
155
|
│ └─ transport.ts
|
|
107
156
|
└─ server.ts
|
|
108
157
|
```
|
|
@@ -158,8 +207,11 @@ test/
|
|
|
158
207
|
├─ bidirectional.test.ts
|
|
159
208
|
├─ bindings-gen.test.ts
|
|
160
209
|
├─ byte-buffer-pool.test.ts
|
|
210
|
+
├─ cluster-v2.test.ts
|
|
211
|
+
├─ data-integrity-edge.test.ts
|
|
161
212
|
├─ direct.test.ts
|
|
162
213
|
├─ e2e.test.ts
|
|
214
|
+
├─ efficiency.test.ts
|
|
163
215
|
├─ events-cluster.test.ts
|
|
164
216
|
├─ events.test.ts
|
|
165
217
|
├─ ffi.test.ts
|
|
@@ -171,12 +223,19 @@ test/
|
|
|
171
223
|
├─ metrics.test.ts
|
|
172
224
|
├─ nats-bridge.test.ts
|
|
173
225
|
├─ nats-integration.test.ts
|
|
226
|
+
├─ performance.test.ts
|
|
174
227
|
├─ reconnect.test.ts
|
|
228
|
+
├─ resume.test.ts
|
|
175
229
|
├─ ring.test.ts
|
|
176
230
|
├─ rooms.test.ts
|
|
177
231
|
├─ roundtrip.test.ts
|
|
232
|
+
├─ rpc.test.ts
|
|
233
|
+
├─ security-hardening.test.ts
|
|
234
|
+
├─ security-live-fuzz.test.ts
|
|
178
235
|
├─ security.test.ts
|
|
236
|
+
├─ stability-resilience.test.ts
|
|
179
237
|
├─ targeting.test.ts
|
|
238
|
+
├─ trace.test.ts
|
|
180
239
|
└─ wire.test.ts
|
|
181
240
|
```
|
|
182
241
|
|
|
@@ -185,6 +244,7 @@ test/
|
|
|
185
244
|
```
|
|
186
245
|
bench/
|
|
187
246
|
├─ BASELINE.md
|
|
247
|
+
├─ dispatch.ts
|
|
188
248
|
├─ ffi-margin.ts
|
|
189
249
|
├─ measure.ts
|
|
190
250
|
├─ serialize.ts
|
package/docs/architecture.md
CHANGED
|
@@ -38,7 +38,7 @@ are thin re-export shims that keep the npm entrypoints (`@ignex/nova/server`,
|
|
|
38
38
|
`@ignex/nova/client`) and the `dist` build stable; the implementation lives in
|
|
39
39
|
`src/core/`.
|
|
40
40
|
|
|
41
|
-
- **Composition roots** (`src/core/server
|
|
41
|
+
- **Composition roots** (`src/core/server/`, `src/core/client.ts`) — the only
|
|
42
42
|
places that know how the pieces fit together. `createServer(options)` builds
|
|
43
43
|
the `ServerState`, wires `Bun.serve`, and returns a plain API object;
|
|
44
44
|
`createClient(url, opts)` builds the client state and returns a plain API
|
|
@@ -50,7 +50,9 @@ are thin re-export shims that keep the npm entrypoints (`@ignex/nova/server`,
|
|
|
50
50
|
the per-socket queue), `routing` (inbound dispatch), and the client's
|
|
51
51
|
`client-wire` / `client-reconnect` (pure backoff math) / `client-heartbeat`.
|
|
52
52
|
- **Factories** for encapsulated mutable state: `createMetrics()`, `createScratch()`
|
|
53
|
-
(reusable zero-alloc output buffer), `
|
|
53
|
+
(reusable zero-alloc output buffer), per-event `EncodeRecord`s (encode-path
|
|
54
|
+
counters resolved eagerly at `createTransport()` — instantiation time, not
|
|
55
|
+
first-encode).
|
|
54
56
|
`int64-guard` intentionally stays a module-global (cheap `off`-mode no-op) so
|
|
55
57
|
threading it through the generated encoders can't cost the ~200ns hot path.
|
|
56
58
|
- **Dev discipline**: `bun run lint` (oxlint with FP rules: no-var,
|
|
@@ -82,21 +84,42 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
|
|
|
82
84
|
- `public/server.ts`, `public/client.ts`, `public/nats.ts` — thin re-export shims
|
|
83
85
|
(npm entrypoints `@ignex/nova/server` / `client` / `nats` + the `dist` build).
|
|
84
86
|
Implementation is in `src/core/` + `src/bridge/`.
|
|
85
|
-
- `src/core/server.ts` — `createServer` composition root: `Bun.serve`,
|
|
86
|
-
registry (id → socket), rooms, groups, inbound routing, control
|
|
87
|
-
auth/origin/token gates, backpressure, replay history, metrics,
|
|
88
|
-
hook, graceful drain, `/health` + `/clients`.
|
|
87
|
+
- `src/core/server/index.ts` — `createServer` composition root: `Bun.serve`,
|
|
88
|
+
client registry (id → socket), rooms, groups, inbound routing, control
|
|
89
|
+
frames, auth/origin/token gates, backpressure, replay history, metrics,
|
|
90
|
+
NATS bridge hook, graceful drain, `/health` + `/clients`. Decomposed into
|
|
91
|
+
sibling modules: `client-info.ts` (pure introspection mapper),
|
|
92
|
+
`http-routes.ts` (fetch handler), `socket-lifecycle.ts` (open/close as
|
|
93
|
+
`(state, ws)` actions), `metrics-view.ts` (pure snapshot assembly).
|
|
89
94
|
- `src/core/client.ts` — `createClient` composition root: typed
|
|
90
95
|
`on`/`send`/`subscribe`/`joinGroup`, reconnect with backoff, heartbeat, status
|
|
91
|
-
events, `clientId`/`groups` (from the `welcome` control frame)
|
|
92
|
-
|
|
96
|
+
events, `clientId`/`groups` (from the `welcome` control frame); the rpc
|
|
97
|
+
plumbing (`client.request`) lives in `client-rpc.ts`.
|
|
98
|
+
- `src/core/{state,auth,rooms,groups,replay,backpressure,outbound,routing,resume,rate-limit}.ts` —
|
|
93
99
|
server action modules over the explicit `ServerState`. `groups.ts` mirrors
|
|
94
100
|
`rooms.ts` (targeting sets, no replay); `state.clients` is the id→socket
|
|
95
|
-
registry, `state.groups` the group→members index.
|
|
101
|
+
registry, `state.groups` the group→members index. `rate-limit.ts` is the
|
|
102
|
+
per-connection token bucket (`options.rateLimit`, default off); `auth.ts`
|
|
103
|
+
compares literal bearer tokens in constant time and gates the HTTP admin
|
|
104
|
+
surface; topic/group joins are authorized via `authorizeTopic` /
|
|
105
|
+
`authorizeGroup`. `resume.ts` is gap-free delivery (below). `replay.ts`
|
|
106
|
+
serves per-topic snapshots (`snapshotRequest { topic, fromSeq }`) and feeds
|
|
107
|
+
the optional durable topic log; `topic-log.ts` is the pluggable durability
|
|
108
|
+
seam (`createMemoryTopicLog()` ships in-repo).
|
|
109
|
+
- **Gap-free delivery** (`src/core/resume.ts`, envelope v2): with
|
|
110
|
+
`createServer({ resume })` every APP frame is stamped (in place, pre-`ws.send`)
|
|
111
|
+
with a per-connection delivery seq and recorded in a bounded per-connection
|
|
112
|
+
history ring. A client that detects a hole sends `resume { lastSeq }`; the
|
|
113
|
+
server replays from the ring with ORIGINAL seqs (in-order, duplicate-free).
|
|
114
|
+
On disconnect the ring parks in a bounded/TTL'd graveyard keyed by client id;
|
|
115
|
+
a reconnecting session adopts it via `hello { lastSeq }`. Control frames are
|
|
116
|
+
never stamped (no ordering obligations), and external copies (NATS bridge /
|
|
117
|
+
cluster envelope) are always taken BEFORE stamping mutates the scratch.
|
|
96
118
|
- `src/core/{client-state,client-wire,client-reconnect,client-heartbeat}.ts` —
|
|
97
119
|
client action modules over the explicit client state.
|
|
98
|
-
- `src/bridge
|
|
99
|
-
|
|
120
|
+
- `src/bridge/` — optional NATS bridge (`createNatsBridge`;
|
|
121
|
+
injectable `NatsTransport` for tests; eager non-blocking connect with retry;
|
|
122
|
+
`nats/{index,types,real-transport,inbound}.ts` + `subjects.ts`),
|
|
100
123
|
subject builders (`ignex.broadcast.*` / `ignex.topic.*` / `ignex.group.*` /
|
|
101
124
|
inbound `ignex.inbound.>`). Outbound frames are copied from the shared
|
|
102
125
|
scratch; inbound frames are decoded via `readFrameHeader`/`decodePayload` and
|
|
@@ -105,15 +128,28 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
|
|
|
105
128
|
- `src/core/metrics.ts`, `src/core/int64-guard.ts` — `createMetrics()` factory
|
|
106
129
|
+ the exact-int64 safety net.
|
|
107
130
|
- `src/events/*` — the events layer (opt-in via `createServer({ events })`,
|
|
108
|
-
public entry `@ignex/nova/events` → `public/events.ts`): `hub
|
|
109
|
-
root (`server.events`, binds the module-global `emit`/`on` singleton
|
|
110
|
-
`
|
|
111
|
-
`
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
public entry `@ignex/nova/events` → `public/events.ts`): `hub/` composition
|
|
132
|
+
root (`server.events`, binds the module-global `emit`/`on` singleton;
|
|
133
|
+
decomposed into `context-factory.ts` (cached handler contexts),
|
|
134
|
+
`dispatch.ts` (reliability-aware dispatch), `metrics-snapshot.ts` (pure
|
|
135
|
+
snapshot assembly), `resolve-cluster.ts` (transport resolution)),
|
|
136
|
+
`types/` (`EventClient` records with `userId` + per-connection `data`,
|
|
137
|
+
`EmitTarget` discriminated union, hub/options interfaces — one module per
|
|
138
|
+
concern behind a barrel), `registry.ts`
|
|
139
|
+
(multi-handler dispatch with isolation — copy-on-write lists,
|
|
140
|
+
allocation-free dispatch, plus a settling variant for retries), `trace.ts`
|
|
141
|
+
(the zero-GC event trace ring behind `server.getEventTrace()`),
|
|
142
|
+
`clients.ts`/`data.ts` (client store: byId + byUser index), `groups.ts`
|
|
143
|
+
(client groups reusing the transport registry + user groups), `emit.ts`
|
|
144
|
+
(encode-once; bridge + cluster copies FIRST — pristine frames — then the
|
|
145
|
+
stamped local fan-out), `cluster/` (v2 envelope codec in `envelope.ts`,
|
|
146
|
+
presence codec + table, dedupe window, shared-state keys, NATS/Redis
|
|
147
|
+
transports and memory/Redis state stores; ROUTED targeted delivery via
|
|
148
|
+
per-instance subjects; broker-redelivery dedupe window), `delivery.ts`
|
|
149
|
+
(opt-in handler retry/backoff + dead-letter sink via `events.handlers`),
|
|
150
|
+
`schedule.ts` (`hub.schedule(name, payload, target, delayMs)` + cancel),
|
|
151
|
+
`cluster-rpc.ts` (cross-instance request/response: `hub.call` /
|
|
152
|
+
`hub.onMethod` over the cluster transport), `queue.ts` (bounded offload
|
|
117
153
|
workers that keep all cluster/state work off the WS hot path), `global.ts`
|
|
118
154
|
(the importable `emit`/`emitToGroup`/… singleton).
|
|
119
155
|
- `src/transport/{transport,scratch,stats}.ts` — object → frame encoding:
|
|
@@ -140,11 +176,11 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
|
|
|
140
176
|
`publishToGroup(group, …)` (server-side targeting sets — from auth metadata,
|
|
141
177
|
`joinGroup(id, group)`, or client `joinGroup` control frames).
|
|
142
178
|
- **Bridge**: `createServer({ nats })` creates a `NatsBridge`. The fan-out path
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`src/bridge/subjects.ts`. Inbound NATS events are
|
|
146
|
-
`fanOutAll` (no bridge call → loop prevention). All
|
|
147
|
-
`server.getMetrics()`.
|
|
179
|
+
encodes once; the NATS copy is taken BEFORE per-socket delivery-seq stamping
|
|
180
|
+
mutates the scratch header, so external consumers see pristine frames.
|
|
181
|
+
Subjects are derived by `src/bridge/subjects.ts`. Inbound NATS events are
|
|
182
|
+
decoded and forwarded via `fanOutAll` (no bridge call → loop prevention). All
|
|
183
|
+
bridge counters fold into `server.getMetrics()`.
|
|
148
184
|
|
|
149
185
|
## Why it's fast
|
|
150
186
|
|
|
@@ -167,5 +203,17 @@ frames) are encoded by `generated/ts-ser.ts` — flatc's object API (`XxxT` +
|
|
|
167
203
|
`Type.BigInt()` fields for exact values, or enable `int64Guard`.
|
|
168
204
|
- The direct fast path covers flat types + packed vectors; nested single-object
|
|
169
205
|
tables fall back to JSON (observable via metrics).
|
|
170
|
-
-
|
|
171
|
-
|
|
206
|
+
- Resume history is bounded (`resume.historySize`, default 256 frames) and the
|
|
207
|
+
cross-session graveyard is TTL'd (`resume.ttlMs`, default 60s): a hole older
|
|
208
|
+
than what is retained is reported `resumed { ok: false }` — clients should
|
|
209
|
+
resubscribe topics for a fresh snapshot. Frames published while NO session
|
|
210
|
+
for a client id exists are not buffered per-client (that's an offline-inbox
|
|
211
|
+
feature, not resume).
|
|
212
|
+
- The durable topic log seam ships with a process-local implementation
|
|
213
|
+
(`createMemoryTopicLog`); production adapters (NATS JetStream / Redis
|
|
214
|
+
Streams / filesystem) implement the same three-method interface.
|
|
215
|
+
- Cluster envelope v2 is not understood by v1 peers (rolling upgrades count
|
|
216
|
+
decode errors on the old instances until they are replaced).
|
|
217
|
+
- Cross-instance rpc `.any` calls are delivered to every instance; the first
|
|
218
|
+
response wins and later responders still execute their handlers (keep
|
|
219
|
+
`.any` methods idempotent, or address a specific instance).
|
package/docs/events.md
CHANGED
|
@@ -142,15 +142,97 @@ deferred to a bounded offload queue (`events.queue`, drop-newest on overflow).
|
|
|
142
142
|
- Self-delivery dedupe: frames carry the origin `instanceId`
|
|
143
143
|
(`cluster.instanceId`, random by default); an instance drops its own frames,
|
|
144
144
|
so a broadcast is delivered exactly once per socket.
|
|
145
|
+
- **Broker-redelivery dedupe**: every message carries a unique id; a durable
|
|
146
|
+
broker that redelivers after reconnect gets its duplicates dropped inside the
|
|
147
|
+
receiver's window (`metrics.events.clusterDroppedDupe`).
|
|
148
|
+
- **Routed targeted delivery**: `emitToClient` / `emitToUser` consult cluster
|
|
149
|
+
presence and are published ONLY to the per-instance subject of the instance(s)
|
|
150
|
+
that hold the destination socket(s) — not to the whole mesh. Unknown targets
|
|
151
|
+
fall back to the full-mesh wildcard (visible in
|
|
152
|
+
`metrics.events.clusterRouted` vs `clusterPublished`).
|
|
145
153
|
- **Presence with no shared state**: join/leave + periodic heartbeat messages
|
|
146
|
-
— `hub.clusterClients()` lists connections on other instances
|
|
154
|
+
— `hub.clusterClients()` lists connections on other instances;
|
|
155
|
+
`hub.clusterInstances()` lists the other instances themselves.
|
|
147
156
|
- **Shared state store** (`cluster.state`, default per-instance memory;
|
|
148
157
|
production: `createRedisStateStore(...)`): user→clients index
|
|
149
158
|
(`clusterUserClients`), cluster group membership, cluster-wide client data.
|
|
159
|
+
- **Cross-instance rpc** (`hub.call` / `hub.onMethod`): request/response
|
|
160
|
+
between instances over the same transport — targeted
|
|
161
|
+
(`call(method, args, { instanceId })`) or any-instance
|
|
162
|
+
(`call(method)` — first response wins; keep `.any` handlers idempotent).
|
|
163
|
+
Timeouts bound every call; counters fold into `metrics.events.rpcSent` /
|
|
164
|
+
`rpcReceived`.
|
|
165
|
+
- **Trace propagation**: emits carry a unique trace id through the cluster
|
|
166
|
+
envelope; remote `onServerEvent` contexts expose it as `ctx.traceId` for
|
|
167
|
+
end-to-end correlation.
|
|
150
168
|
- **Server-side events**: other instances' events reach `onServerEvent`
|
|
151
169
|
handlers with `ctx.source === "remote"` (delivered to clients AND handlers);
|
|
152
170
|
NATS-inbound events reach them with `source === "bridge"`.
|
|
153
171
|
|
|
172
|
+
## Handler reliability — retries + dead letters
|
|
173
|
+
|
|
174
|
+
Opt in via `createServer({ events: { handlers: {...} } })`; without it,
|
|
175
|
+
dispatch is fire-and-forget with per-handler error isolation only.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
createServer({
|
|
179
|
+
port: 3000,
|
|
180
|
+
events: {
|
|
181
|
+
handlers: {
|
|
182
|
+
retries: 2, // extra attempts after the first try
|
|
183
|
+
backoffMs: 100, // doubling: 100ms, 200ms, …
|
|
184
|
+
dlq(info) { /* { name, payload, err, attempts } */ },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A handler that keeps failing is retried on the same event, then handed to
|
|
191
|
+
`dlq`. Counters live in `server.getMetrics().events.handlerRetries` /
|
|
192
|
+
`.dlqCount`.
|
|
193
|
+
|
|
194
|
+
## Scheduled emits (time-based events)
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const id = hub.schedule("reminder.push", payload, { type: "user", userId }, delayMs);
|
|
198
|
+
hub.cancelScheduled(id); // true if it had not fired yet
|
|
199
|
+
hub.scheduledCount; // pending count
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Scheduled emits run through the normal emit path at fire time — identical
|
|
203
|
+
targeting, bridging and cluster routing semantics. Timers are cleared on
|
|
204
|
+
`hub.close()`.
|
|
205
|
+
|
|
206
|
+
## Request/response
|
|
207
|
+
|
|
208
|
+
Two complementary layers:
|
|
209
|
+
|
|
210
|
+
- **Client ⇄ this server**: `client.request(name, payload)` sends an `rpcCall`
|
|
211
|
+
control frame (correlation id + timeout) and awaits the responder registered
|
|
212
|
+
via `server.handle(name, fn)` or `hub.onRequest(name, fn)`. The response
|
|
213
|
+
reuses the SAME event schema both directions.
|
|
214
|
+
- **Instance ⇄ instance**: `hub.call` / `hub.onMethod` (above).
|
|
215
|
+
|
|
216
|
+
## Event trace (what fired — debugger visibility)
|
|
217
|
+
|
|
218
|
+
Every server owns an **event trace ring** (`src/events/trace.ts`) that records
|
|
219
|
+
each fired event — emitted (`out.emit`), published through the server API
|
|
220
|
+
(`out.publish`), received from a client (`in.client`), from another instance
|
|
221
|
+
(`in.remote`), or from the NATS bridge (`in.bridge`) — with its wire name,
|
|
222
|
+
target kind + key (topic/group/userId/clientId), frame size and timestamp.
|
|
223
|
+
|
|
224
|
+
- **Zero-GC by construction**: scalars live in pre-allocated TypedArrays,
|
|
225
|
+
strings are held by reference in reused slots; row objects materialize only
|
|
226
|
+
when the ring is read. Recording is a handful of typed-array stores (~ns).
|
|
227
|
+
- Read it with `server.getEventTrace({ limit, direction, name })` →
|
|
228
|
+
`{ enabled, capacity, stats, recent }` (newest first) and reset it with
|
|
229
|
+
`server.clearEventTrace()`. `stats.byName` / `stats.last` power at-a-glance
|
|
230
|
+
panels (the ignex debugbar's Nova panel and MCP tool use exactly this).
|
|
231
|
+
- Configure with `createServer({ trace: { capacity, enabled, capturePayloadChars } })`.
|
|
232
|
+
Default: on, capacity 1024, no payload capture. `IGNEX_NOVA_TRACE=0`
|
|
233
|
+
disables recording globally; set `capturePayloadChars` (e.g. 512) to store
|
|
234
|
+
truncated JSON previews of each payload (opt-in — costs a stringify/event).
|
|
235
|
+
|
|
154
236
|
## Metrics & shutdown
|
|
155
237
|
|
|
156
238
|
`server.getMetrics().events` (or `hub.metrics()`) exposes emitted counts per
|
package/docs/generic-bindings.md
CHANGED
|
@@ -52,6 +52,16 @@ Rules (same as the built-in registry):
|
|
|
52
52
|
- The transport control events (hello / welcome / subscribe / unsubscribe /
|
|
53
53
|
joinGroup / leaveGroup / snapshotRequest / ping / pong) are ALWAYS included —
|
|
54
54
|
you can add your own but cannot override the standard ones.
|
|
55
|
+
- **Unsupported field types fail loudly at generate time.** `Type.Any()`,
|
|
56
|
+
`Type.Unknown()`, `Type.Date()`, `Type.Null()`, `Type.Record(...)`, and mixed
|
|
57
|
+
unions (e.g. `number | string`) have no FlatBuffers representation and are
|
|
58
|
+
rejected with a clear error — they used to be *silently coerced to `string`
|
|
59
|
+
fields*, which forced `JSON.stringify(...)` at the call site and put raw JSON
|
|
60
|
+
text inside the (still-binary) frame. If you genuinely need an opaque JSON
|
|
61
|
+
payload, model it explicitly as `Type.String()` and pass the JSON string —
|
|
62
|
+
the frame stays valid, but the JSON bytes appear verbatim in it. Prefer typed
|
|
63
|
+
fields (nested tables, vectors, scalars) so the wire carries structure
|
|
64
|
+
instead of JSON text.
|
|
55
65
|
|
|
56
66
|
## 2. Generate bindings
|
|
57
67
|
|
package/docs/wire-format.md
CHANGED
|
@@ -8,18 +8,25 @@ client from the generated FlatBuffers schema — no Bun, no Rust, no FFI needed.
|
|
|
8
8
|
Every WebSocket **binary** frame is:
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
|
|
12
|
-
│ version │ event_id │ size-prefixed FlatBuffer │
|
|
13
|
-
│ 1 byte │ u32 (LE) │ (flatc: 4-byte size prefix + buf)│
|
|
14
|
-
|
|
15
|
-
offset 0 1..5
|
|
11
|
+
┌──────────┬───────────────────┬─────────┬────────────┬──────────────────────────────────┐
|
|
12
|
+
│ version │ event_id │ flags │ seq │ size-prefixed FlatBuffer │
|
|
13
|
+
│ 1 byte │ u32 (LE) │ 1 byte │ u64 (LE) │ (flatc: 4-byte size prefix + buf)│
|
|
14
|
+
└──────────┴───────────────────┴─────────┴────────────┴──────────────────────────────────┘
|
|
15
|
+
offset 0 1..5 5 6..14 14..
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
- `version` = `WIRE_VERSION` (currently `
|
|
19
|
-
version it doesn't recognize (the decoders
|
|
18
|
+
- `version` = `WIRE_VERSION` (currently `2` — v2 added the delivery header).
|
|
19
|
+
A peer MUST drop frames whose version it doesn't recognize (the decoders
|
|
20
|
+
return `null`).
|
|
20
21
|
- `event_id` = **FNV-1a 32-bit hash of the event name** (e.g.
|
|
21
22
|
`fnv1a32("quote")`). Stable across schema reordering; collisions are rejected
|
|
22
23
|
at generate time. The id→name table is emitted in `src/generated/registry.ts`.
|
|
24
|
+
- `flags` — bit0 = "seq-valid" (see below); all other bits reserved, send 0.
|
|
25
|
+
- `seq` — per-CONNECTION delivery sequence, stamped by the SERVER on every
|
|
26
|
+
**app** frame just before `ws.send` (Bun copies synchronously). Clients use
|
|
27
|
+
it for gap detection + resume. Control frames are never stamped; frames that
|
|
28
|
+
were not per-destination stamped carry `flags=0, seq=0`. Offsets are derived
|
|
29
|
+
from `WIRE_HEADER_LEN`: flags at `len-9`, seq at `len-8..len`.
|
|
23
30
|
- The payload is a **size-prefixed FlatBuffer** (built by flatc, both Rust
|
|
24
31
|
builders and the TS object API) with the root table for that event id.
|
|
25
32
|
|
|
@@ -27,6 +34,18 @@ There is **no per-frame checksum and no authentication** — the envelope id byt
|
|
|
27
34
|
is trusted. Integrity and AuthN are the application's job (see
|
|
28
35
|
[Security](#security)).
|
|
29
36
|
|
|
37
|
+
## String fields carry raw UTF-8
|
|
38
|
+
|
|
39
|
+
A FlatBuffer `string` field stores its content as raw UTF-8 bytes in the frame,
|
|
40
|
+
so any JSON document placed in a string field appears **verbatim** inside the
|
|
41
|
+
(binary) frame — expected behavior, not corruption. If a captured frame shows
|
|
42
|
+
`{"items":[...]}` text in the payload, the event schema models that data as a
|
|
43
|
+
`string` field (a JSON-in-a-string envelope, or a field written as
|
|
44
|
+
`Type.Any()`/`Type.Unknown()`, which the codegen now rejects at generate time —
|
|
45
|
+
see docs/generic-bindings.md). Prefer typed fields — `Type.Array(Table)` for
|
|
46
|
+
lists, `Type.Integer()` for counts — so the frame carries structure instead of
|
|
47
|
+
JSON text.
|
|
48
|
+
|
|
30
49
|
## Event ids
|
|
31
50
|
|
|
32
51
|
Event ids are stable hashes, so adding events, reordering the registry, or
|
|
@@ -46,13 +65,17 @@ SAME codegen, but are routed internally (never delivered to app handlers):
|
|
|
46
65
|
|
|
47
66
|
| name | direction | payload |
|
|
48
67
|
| --- | --- | --- |
|
|
49
|
-
| `hello` | both | `{ version, caps: string[], lastSeq }` — sent on connect; version mismatch → close(1002) |
|
|
68
|
+
| `hello` | both | `{ version, caps: string[], lastSeq }` — sent on connect; version mismatch → close(1002). `lastSeq > 0` asks the server to resume this client id's delivery stream after that seq (cross-session resume) |
|
|
50
69
|
| `welcome` | server→client | `{ clientId, groups: string[] }` — identity assigned to this connection (auth metadata or UUID) + its server-side groups |
|
|
51
70
|
| `subscribe` | client→server | `{ topic }` — join a room (server replies with replay history) |
|
|
52
71
|
| `unsubscribe` | client→server | `{ topic }` |
|
|
53
72
|
| `joinGroup` | client→server | `{ group }` — join a server-side group |
|
|
54
73
|
| `leaveGroup` | client→server | `{ group }` |
|
|
55
|
-
| `snapshotRequest` | client→server | `{ topic }` —
|
|
74
|
+
| `snapshotRequest` | client→server | `{ topic, fromSeq }` — replay recorded topic history STRICTLY after `fromSeq` (0 = from the beginning of retained history); hydrates from the durable topic log when the in-memory ring has moved on |
|
|
75
|
+
| `resume` | client→server | `{ lastSeq }` — same-connection gap recovery: re-send everything after the last CONTIGUOUS delivery seq (original seqs preserved) |
|
|
76
|
+
| `resumed` | server→client | `{ ok: boolean, from: number }` — ack before the replayed frames; `ok:false` = the hole is older than the retained history (partial recovery — resubscribe topics for a fresh snapshot) |
|
|
77
|
+
| `rpcCall` | client→server | `{ id, name, payloadB64 }` — request/response: `payloadB64` is base64 of a full wire frame encoded with event `name`'s schema; the response reuses the SAME schema |
|
|
78
|
+
| `rpcResult` | server→client | `{ id, ok, err, payloadB64 }` — correlated by `id`; `ok:false` carries a plain-text `err` |
|
|
56
79
|
| `ping` | client→server | `{ ts }` — heartbeat |
|
|
57
80
|
| `pong` | server→client | `{ ts }` |
|
|
58
81
|
|
|
@@ -118,7 +141,7 @@ consumer in any language:
|
|
|
118
141
|
|
|
119
142
|
1. reads `version` (byte 0) and `event_id` (bytes 1..5) from the frame,
|
|
120
143
|
2. maps `event_id` → name via `wire-registry.json`,
|
|
121
|
-
3. decodes the size-prefixed FlatBuffer from `frame[
|
|
144
|
+
3. decodes the size-prefixed FlatBuffer from `frame[14..]` with a flatc build of
|
|
122
145
|
`src/generated/fbs/backend.fbs`.
|
|
123
146
|
|
|
124
147
|
See `examples/nats-consumer.ts` for a working reference.
|
|
@@ -137,12 +160,26 @@ Clients send `ping` every `heartbeatMs` (default 15000). The server replies
|
|
|
137
160
|
long-idle sockets alive; a client that misses `heartbeatMisses` pongs force-
|
|
138
161
|
closes and reconnects.
|
|
139
162
|
|
|
140
|
-
## Sequence /
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
163
|
+
## Sequence / resume / request-response
|
|
164
|
+
|
|
165
|
+
- **Delivery seqs (v2)**: when the server starts with `resume`, every app frame
|
|
166
|
+
it writes to a socket carries an increasing per-connection `seq` (envelope
|
|
167
|
+
flags bit0). Clients track the stream; a GAP (lost frame — backpressure drop,
|
|
168
|
+
transport hiccup) is recovered by sending `resume { lastSeq }`; the server
|
|
169
|
+
replays from the connection's bounded sent-history ring with ORIGINAL seqs so
|
|
170
|
+
redelivery is in-order and duplicate-free. On disconnect the ring parks in a
|
|
171
|
+
per-client-id graveyard (bounded, TTL'd); a reconnecting session with the
|
|
172
|
+
same auth-pinned id continues the stream via `hello { lastSeq }`.
|
|
173
|
+
- **Topic snapshots**: `snapshotRequest { topic, fromSeq }` replays recorded
|
|
174
|
+
topic history strictly after a topic seq; a durable `topicLog`
|
|
175
|
+
(`createServer({ topicLog })`, e.g. `createMemoryTopicLog()`) hydrates ranges
|
|
176
|
+
the replay ring has already forgotten. Topic-history seqs are a SEPARATE
|
|
177
|
+
counter (global replay order) from per-connection delivery seqs.
|
|
178
|
+
- **Request/response**: `client.request(name, payload)` wraps a full wire frame
|
|
179
|
+
(base64) in an `rpcCall` control frame with a correlation id; the server's
|
|
180
|
+
registered responder (via `server.handle(name, fn)` or `hub.onRequest`)
|
|
181
|
+
returns the response payload encoded with the SAME event schema. Timeouts and
|
|
182
|
+
errors are correlated client-side.
|
|
146
183
|
|
|
147
184
|
## Security
|
|
148
185
|
|
|
@@ -151,7 +188,17 @@ the envelope reserves no space for a per-frame seq today.
|
|
|
151
188
|
happily decode the payload as the claimed type. Add integrity at the
|
|
152
189
|
application layer if the wire crosses an untrusted boundary.
|
|
153
190
|
- Server-side guards (all optional): `authenticate(req)` async hook, origin
|
|
154
|
-
allowlist, bearer `token
|
|
191
|
+
allowlist, bearer `token` (literal tokens compared in constant time),
|
|
192
|
+
`maxConnections`, `maxMessageSize`, per-connection inbound rate limiting
|
|
193
|
+
(`rateLimit`: token bucket over app AND control frames — drop or close 1008,
|
|
194
|
+
counted in `metrics.rateLimited`).
|
|
195
|
+
- Join authorization: `authorizeTopic(topic, ws)` / `authorizeGroup(group, ws)`
|
|
196
|
+
gate EVERY room/group join path (control frames, programmatic joins, and
|
|
197
|
+
auth-seeded membership); rejections are counted in `metrics.rejectedJoins`.
|
|
198
|
+
Leaving is always allowed.
|
|
199
|
+
- Introspection: `GET /clients` is gated by the same token/`authenticate`
|
|
200
|
+
surface when one is configured (public only on servers with no auth at all);
|
|
201
|
+
`/health` stays public (counters only).
|
|
155
202
|
- Slow consumers: `backpressure` policy (`drop-oldest`/`drop-newest`/
|
|
156
203
|
`disconnect`) bounds per-socket buffering; without it a hot publish loop can
|
|
157
204
|
balloon memory.
|
|
@@ -168,7 +215,7 @@ the envelope reserves no space for a per-frame seq today.
|
|
|
168
215
|
3. Read `version` (byte 0) and `event_id` (bytes 1..5) from each binary frame.
|
|
169
216
|
Compute ids with FNV-1a 32 over the name (see above) or read the emitted
|
|
170
217
|
`src/generated/registry.ts` table.
|
|
171
|
-
4. `flatbuffers.ByteBuffer(frame[
|
|
218
|
+
4. `flatbuffers.ByteBuffer(frame[14..])` + `getSizePrefixedRootAs<T>` (or the
|
|
172
219
|
language equivalent) gives you the payload.
|
|
173
220
|
5. Implement the control frames (at minimum `hello` + `ping`/`pong`) to be a
|
|
174
221
|
good citizen; `subscribe` for rooms.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ignex/nova",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TypeBox-driven FlatBuffer transport: Rust FFI serializer + Bun WebSocket + typed pub/sub API for server and FE.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"gen:ai-map": "bun scripts/gen-ai-map.ts",
|
|
73
73
|
"serve": "bun run src/server.ts",
|
|
74
74
|
"bench:serialize": "bun run bench/serialize.ts",
|
|
75
|
+
"bench:dispatch": "bun run bench/dispatch.ts",
|
|
75
76
|
"bench:throughput": "bun run bench/throughput.ts",
|
|
76
77
|
"release": "bun scripts/release.ts",
|
|
77
78
|
"release:dry": "bun scripts/release.ts --dry-run",
|
|
Binary file
|