@ignex/nova 0.1.1
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/LICENSE +21 -0
- package/README.md +313 -0
- package/docs/architecture.md +146 -0
- package/docs/publishing.md +119 -0
- package/docs/wire-format.md +170 -0
- package/index.ts +61 -0
- package/package.json +89 -0
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/client.ts +23 -0
- package/public/nats.ts +19 -0
- package/public/server.ts +35 -0
- package/rust/Cargo.toml +19 -0
- package/rust/src/ffi.rs +135 -0
- package/rust/src/generated/backend.rs +2817 -0
- package/rust/src/generated/mod.rs +2 -0
- package/rust/src/lib.rs +9 -0
- package/rust/src/transcode/generated.rs +1352 -0
- package/rust/src/transcode/mod.rs +2 -0
- package/src/bridge/nats.ts +269 -0
- package/src/bridge/subjects.ts +30 -0
- package/src/core/auth.ts +56 -0
- package/src/core/backpressure.ts +39 -0
- package/src/core/client-heartbeat.ts +27 -0
- package/src/core/client-reconnect.ts +35 -0
- package/src/core/client-state.ts +76 -0
- package/src/core/client-wire.ts +72 -0
- package/src/core/client.ts +176 -0
- package/src/core/groups.ts +52 -0
- package/src/core/int64-guard.ts +44 -0
- package/src/core/metrics.ts +105 -0
- package/src/core/outbound.ts +76 -0
- package/src/core/replay.ts +31 -0
- package/src/core/ring.ts +85 -0
- package/src/core/rooms.ts +44 -0
- package/src/core/routing.ts +94 -0
- package/src/core/server.ts +294 -0
- package/src/core/state.ts +179 -0
- package/src/generated/direct-ser.ts +495 -0
- package/src/generated/fbs/backend.fbs +139 -0
- package/src/generated/registry.ts +341 -0
- package/src/generated/rust/backend_generated.rs +2817 -0
- package/src/generated/ts/backend.ts +25 -0
- package/src/generated/ts/big-val.ts +106 -0
- package/src/generated/ts/complex.ts +303 -0
- package/src/generated/ts/customer.ts +137 -0
- package/src/generated/ts/hello.ts +123 -0
- package/src/generated/ts/join-group.ts +78 -0
- package/src/generated/ts/leave-group.ts +78 -0
- package/src/generated/ts/order-billing.ts +137 -0
- package/src/generated/ts/order-line.ts +144 -0
- package/src/generated/ts/order.ts +236 -0
- package/src/generated/ts/ping.ts +74 -0
- package/src/generated/ts/pong.ts +74 -0
- package/src/generated/ts/portfolio-position.ts +120 -0
- package/src/generated/ts/portfolio-snapshot.ts +170 -0
- package/src/generated/ts/quote.ts +148 -0
- package/src/generated/ts/side.ts +8 -0
- package/src/generated/ts/snapshot-request.ts +78 -0
- package/src/generated/ts/subscribe.ts +78 -0
- package/src/generated/ts/tags.ts +9 -0
- package/src/generated/ts/trade.ts +135 -0
- package/src/generated/ts/unsubscribe.ts +78 -0
- package/src/generated/ts/welcome.ts +112 -0
- package/src/generated/ts-ser.ts +465 -0
- package/src/generated/wire-registry.json +20 -0
- package/src/native/codec.ts +35 -0
- package/src/native/ffi.ts +214 -0
- package/src/native/loader.ts +55 -0
- package/src/schema/index.ts +217 -0
- package/src/server.ts +87 -0
- package/src/transport/byte-buffer-pool.ts +63 -0
- package/src/transport/scratch.ts +48 -0
- package/src/transport/stats.ts +44 -0
- package/src/transport/transport.ts +106 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Wire format
|
|
2
|
+
|
|
3
|
+
This document pins the on-the-wire contract so anyone can build an independent
|
|
4
|
+
client from the generated FlatBuffers schema — no Bun, no Rust, no FFI needed.
|
|
5
|
+
|
|
6
|
+
## Frame
|
|
7
|
+
|
|
8
|
+
Every WebSocket **binary** frame is:
|
|
9
|
+
|
|
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 5..
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `version` = `WIRE_VERSION` (currently `1`). A peer MUST drop frames whose
|
|
19
|
+
version it doesn't recognize (the decoders return `null`).
|
|
20
|
+
- `event_id` = **FNV-1a 32-bit hash of the event name** (e.g.
|
|
21
|
+
`fnv1a32("quote")`). Stable across schema reordering; collisions are rejected
|
|
22
|
+
at generate time. The id→name table is emitted in `src/generated/registry.ts`.
|
|
23
|
+
- The payload is a **size-prefixed FlatBuffer** (built by flatc, both Rust
|
|
24
|
+
builders and the TS object API) with the root table for that event id.
|
|
25
|
+
|
|
26
|
+
There is **no per-frame checksum and no authentication** — the envelope id byte
|
|
27
|
+
is trusted. Integrity and AuthN are the application's job (see
|
|
28
|
+
[Security](#security)).
|
|
29
|
+
|
|
30
|
+
## Event ids
|
|
31
|
+
|
|
32
|
+
Event ids are stable hashes, so adding events, reordering the registry, or
|
|
33
|
+
adding fields does not renumber the wire format. Compute them with FNV-1a 32:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
h = 2166136261
|
|
37
|
+
for each byte b of the UTF-8 event name:
|
|
38
|
+
h ^= b
|
|
39
|
+
h = (h * 16777619) mod 2^32
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Control frames
|
|
43
|
+
|
|
44
|
+
A fixed set of transport-internal events share the SAME frame format and the
|
|
45
|
+
SAME codegen, but are routed internally (never delivered to app handlers):
|
|
46
|
+
|
|
47
|
+
| name | direction | payload |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `hello` | both | `{ version, caps: string[], lastSeq }` — sent on connect; version mismatch → close(1002) |
|
|
50
|
+
| `welcome` | server→client | `{ clientId, groups: string[] }` — identity assigned to this connection (auth metadata or UUID) + its server-side groups |
|
|
51
|
+
| `subscribe` | client→server | `{ topic }` — join a room (server replies with replay history) |
|
|
52
|
+
| `unsubscribe` | client→server | `{ topic }` |
|
|
53
|
+
| `joinGroup` | client→server | `{ group }` — join a server-side group |
|
|
54
|
+
| `leaveGroup` | client→server | `{ group }` |
|
|
55
|
+
| `snapshotRequest` | client→server | `{ topic }` — reserved for future replay control |
|
|
56
|
+
| `ping` | client→server | `{ ts }` — heartbeat |
|
|
57
|
+
| `pong` | server→client | `{ ts }` |
|
|
58
|
+
|
|
59
|
+
Control ids are `fnv1a32` of the control name, dispatched via
|
|
60
|
+
`isControlId(id)` before user handlers.
|
|
61
|
+
|
|
62
|
+
## Rooms / topics
|
|
63
|
+
|
|
64
|
+
- `subscribe`/`unsubscribe` control frames manage server-side room membership.
|
|
65
|
+
- The server keeps an optional per-topic history (`replay: { historySize }`).
|
|
66
|
+
On join it sends the recorded frames **oldest → newest** as a last-value
|
|
67
|
+
snapshot, then live traffic follows. History is bounded (`historySize`),
|
|
68
|
+
older frames are dropped.
|
|
69
|
+
- `publish` = global broadcast; `publishToTopic` = room only.
|
|
70
|
+
|
|
71
|
+
## Targeted delivery / groups
|
|
72
|
+
|
|
73
|
+
Every connection gets a stable **client id**: an explicit id from the
|
|
74
|
+
`authenticate` hook (`{ id, groups, meta }`) or an auto-generated UUID. The
|
|
75
|
+
server assigns it during the upgrade and delivers it in the `welcome` control
|
|
76
|
+
frame, so the client knows its own identity (`client.clientId`).
|
|
77
|
+
|
|
78
|
+
- `publishToClient(id, name, payload)` — send to ONE client by id (not bridged
|
|
79
|
+
to NATS).
|
|
80
|
+
- **Groups** are a server-side targeting dimension (no replay):
|
|
81
|
+
- seeded from `authenticate` metadata (`{ groups: [...] }`),
|
|
82
|
+
- managed programmatically (`joinGroup(id, group)` / `leaveGroup(id, group)`),
|
|
83
|
+
- or joined by the client via the `joinGroup`/`leaveGroup` control frames.
|
|
84
|
+
- `publishToGroup(group, name, payload)` fans out to every member.
|
|
85
|
+
- Introspection: `getClient(id)` / `getClients()` / `groupMembers(group)` /
|
|
86
|
+
`groups()` — also exposed as `GET /clients` on the server.
|
|
87
|
+
|
|
88
|
+
Rooms are client-joinable *subscriptions with optional replay*; groups are
|
|
89
|
+
server-side *targeting sets without replay*. Both are independent dimensions.
|
|
90
|
+
|
|
91
|
+
## NATS bridge
|
|
92
|
+
|
|
93
|
+
When `createServer({ nats: {...} })` is set, every broadcast / topic / group
|
|
94
|
+
publish is ALSO published to NATS as the **same wire frame** the WS clients
|
|
95
|
+
receive (encoded once, copied for NATS). Best-effort: if NATS is down the
|
|
96
|
+
frame is dropped and counted in `bridgeErrors` — the WS hot path never blocks.
|
|
97
|
+
|
|
98
|
+
| publish API | NATS subject |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `publish(name, …)` | `{prefix}.broadcast.{name}` |
|
|
101
|
+
| `publishToTopic(topic, name, …)` | `{prefix}.topic.{topic}.{name}` |
|
|
102
|
+
| `publishToGroup(group, name, …)` | `{prefix}.group.{group}.{name}` |
|
|
103
|
+
|
|
104
|
+
`{prefix}` defaults to `ignex`. External apps push events INTO the hub by
|
|
105
|
+
publishing on `{prefix}.inbound.>` (default; configurable via
|
|
106
|
+
`inboundSubjects`); the server decodes them and forwards to all clients
|
|
107
|
+
(allowlisted by `inboundEvents`, control frames dropped, and never re-bridged —
|
|
108
|
+
no loops). `publishToClient` (single-socket) is intentionally not bridged.
|
|
109
|
+
|
|
110
|
+
Bridge state is observable via `server.getMetrics()`
|
|
111
|
+
(`natsStatus`, `bridged`, `bridgedBytes`, `bridgeErrors`, `bridgeInbound`).
|
|
112
|
+
|
|
113
|
+
### Decoding bridged frames (external consumers)
|
|
114
|
+
|
|
115
|
+
`bun run generate` emits `src/generated/wire-registry.json` — a machine-readable
|
|
116
|
+
`{ version, events: { name: id } }` map of the FNV-1a ids. A consumer in any
|
|
117
|
+
language:
|
|
118
|
+
|
|
119
|
+
1. reads `version` (byte 0) and `event_id` (bytes 1..5) from the frame,
|
|
120
|
+
2. maps `event_id` → name via `wire-registry.json`,
|
|
121
|
+
3. decodes the size-prefixed FlatBuffer from `frame[5..]` with a flatc build of
|
|
122
|
+
`src/generated/fbs/backend.fbs`.
|
|
123
|
+
|
|
124
|
+
See `examples/nats-consumer.ts` for a working reference.
|
|
125
|
+
|
|
126
|
+
## Heartbeat
|
|
127
|
+
|
|
128
|
+
Clients send `ping` every `heartbeatMs` (default 15000). The server replies
|
|
129
|
+
`pong`. Bun's `idleTimeout` closes connections with no traffic, so pings keep
|
|
130
|
+
long-idle sockets alive; a client that misses `heartbeatMisses` pongs force-
|
|
131
|
+
closes and reconnects.
|
|
132
|
+
|
|
133
|
+
## Sequence / replay
|
|
134
|
+
|
|
135
|
+
The current model replays **recent history on subscribe** (bounded ring per
|
|
136
|
+
topic, last-value snapshot). Full gap-based replay (per-frame sequence numbers
|
|
137
|
+
in the envelope + `hello.lastSeq` negotiation) is a documented future extension;
|
|
138
|
+
the envelope reserves no space for a per-frame seq today.
|
|
139
|
+
|
|
140
|
+
## Security
|
|
141
|
+
|
|
142
|
+
- **Trust model**: the envelope has no checksum/AuthN. A hostile peer can lie
|
|
143
|
+
about the event id; the decoder is fuzz-safe (never throws / OOB) but will
|
|
144
|
+
happily decode the payload as the claimed type. Add integrity at the
|
|
145
|
+
application layer if the wire crosses an untrusted boundary.
|
|
146
|
+
- Server-side guards (all optional): `authenticate(req)` async hook, origin
|
|
147
|
+
allowlist, bearer `token`, `maxConnections`, `maxMessageSize`.
|
|
148
|
+
- Slow consumers: `backpressure` policy (`drop-oldest`/`drop-newest`/
|
|
149
|
+
`disconnect`) bounds per-socket buffering; without it a hot publish loop can
|
|
150
|
+
balloon memory.
|
|
151
|
+
- Injection: unknown object keys and prototype-pollution keys are dropped on
|
|
152
|
+
both encode paths; embedded NULs route to the JSON path so they round-trip
|
|
153
|
+
exactly (never silently truncated).
|
|
154
|
+
|
|
155
|
+
## Building an independent client
|
|
156
|
+
|
|
157
|
+
1. `bun run generate` produces `generated/fbs/backend.fbs` — the FlatBuffers
|
|
158
|
+
schema for every table (app + control).
|
|
159
|
+
2. Compile that `.fbs` with flatc for your language: `flatc --python
|
|
160
|
+
--gen-object-api ...`, `--cpp`, `--go`, etc.
|
|
161
|
+
3. Read `version` (byte 0) and `event_id` (bytes 1..5) from each binary frame.
|
|
162
|
+
Compute ids with FNV-1a 32 over the name (see above) or read the emitted
|
|
163
|
+
`src/generated/registry.ts` table.
|
|
164
|
+
4. `flatbuffers.ByteBuffer(frame[5..])` + `getSizePrefixedRootAs<T>` (or the
|
|
165
|
+
language equivalent) gives you the payload.
|
|
166
|
+
5. Implement the control frames (at minimum `hello` + `ping`/`pong`) to be a
|
|
167
|
+
good citizen; `subscribe` for rooms.
|
|
168
|
+
|
|
169
|
+
Wire compatibility is guaranteed by flatc: the Rust builders (server) and the
|
|
170
|
+
generated TS object API (browser client) both derive from the same `.fbs`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ignex-nova — public package root.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the full typed pub/sub API (server + client + NATS bridge +
|
|
5
|
+
* schema types) so a single `import ... from "ignex-nova"` works in Bun
|
|
6
|
+
* projects.
|
|
7
|
+
*
|
|
8
|
+
* For leaner / target-specific imports use the subpath entrypoints — each
|
|
9
|
+
* resolves to its own source file and tree-shakes independently:
|
|
10
|
+
*
|
|
11
|
+
* import { createServer } from "ignex-nova/server"; // Bun-only (Rust FFI)
|
|
12
|
+
* import { createClient } from "ignex-nova/client"; // browser + Bun
|
|
13
|
+
* import { createNatsBridge } from "ignex-nova/nats"; // standalone bridge
|
|
14
|
+
*
|
|
15
|
+
* Note: the root entry also pulls in the Bun-only server + FFI path, so
|
|
16
|
+
* browser bundles should import "ignex-nova/client" instead.
|
|
17
|
+
*
|
|
18
|
+
* The README (and docs/publishing.md) covers consuming this from an npm
|
|
19
|
+
* package: `bun add ignex-nova`, then use the subpaths above.
|
|
20
|
+
*/
|
|
21
|
+
export { createServer } from "./public/server";
|
|
22
|
+
export type {
|
|
23
|
+
IgnServer,
|
|
24
|
+
ClientInfo,
|
|
25
|
+
IgnServerOptions,
|
|
26
|
+
IgnBackpressureOptions,
|
|
27
|
+
BackpressurePolicy,
|
|
28
|
+
WsData,
|
|
29
|
+
ClientMeta,
|
|
30
|
+
AuthResult,
|
|
31
|
+
MetricsSnapshot,
|
|
32
|
+
Int64GuardMode,
|
|
33
|
+
} from "./public/server";
|
|
34
|
+
|
|
35
|
+
export { createClient } from "./public/client";
|
|
36
|
+
export type {
|
|
37
|
+
IgnClient,
|
|
38
|
+
IgnClientOptions,
|
|
39
|
+
IgnReconnectOptions,
|
|
40
|
+
ClientStatus,
|
|
41
|
+
} from "./public/client";
|
|
42
|
+
|
|
43
|
+
export { createNatsBridge, createSubjectBuilder } from "./public/nats";
|
|
44
|
+
export type {
|
|
45
|
+
NatsBridge,
|
|
46
|
+
NatsBridgeOptions,
|
|
47
|
+
NatsBridgeStats,
|
|
48
|
+
NatsBridgeStatus,
|
|
49
|
+
NatsTransport,
|
|
50
|
+
SubjectBuilder,
|
|
51
|
+
} from "./public/nats";
|
|
52
|
+
|
|
53
|
+
// Plain-object payload types consumers type against (e.g. `Events["quote"]`).
|
|
54
|
+
// Import as types only — the runtime `events` registry is transport-internal.
|
|
55
|
+
export type {
|
|
56
|
+
Events,
|
|
57
|
+
EventName,
|
|
58
|
+
AnyEventName,
|
|
59
|
+
ControlEvents,
|
|
60
|
+
ControlEventName,
|
|
61
|
+
} from "./src/schema";
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ignex/nova",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "TypeBox-driven FlatBuffer transport: Rust FFI serializer + Bun WebSocket + typed pub/sub API for server and FE.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ignex-nova contributors",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"flatbuffers",
|
|
10
|
+
"websocket",
|
|
11
|
+
"pubsub",
|
|
12
|
+
"realtime",
|
|
13
|
+
"typebox",
|
|
14
|
+
"bun",
|
|
15
|
+
"ffi",
|
|
16
|
+
"rust",
|
|
17
|
+
"nats",
|
|
18
|
+
"typed"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/ignex-nova/ignex-nova.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/ignex-nova/ignex-nova#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/ignex-nova/ignex-nova/issues"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.4"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"public",
|
|
35
|
+
"src",
|
|
36
|
+
"rust",
|
|
37
|
+
"prebuilds",
|
|
38
|
+
"docs",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"main": "./index.ts",
|
|
43
|
+
"module": "./index.ts",
|
|
44
|
+
"types": "./index.ts",
|
|
45
|
+
"browser": "./public/client.ts",
|
|
46
|
+
"exports": {
|
|
47
|
+
".": "./index.ts",
|
|
48
|
+
"./server": "./public/server.ts",
|
|
49
|
+
"./client": "./public/client.ts",
|
|
50
|
+
"./nats": "./public/nats.ts",
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"generate": "bun scripts/generate.ts",
|
|
58
|
+
"build:rust": "cargo build --release --manifest-path rust/Cargo.toml",
|
|
59
|
+
"build:client": "bun build ./client/main.ts --outdir ./client-dist --target=browser --splitting",
|
|
60
|
+
"build:dist": "bun build ./index.ts ./public/server.ts ./public/client.ts ./public/nats.ts --outdir ./dist --target=bun --splitting && bunx tsc --emitDeclarationOnly -p tsconfig.dist.json",
|
|
61
|
+
"build": "bun run generate && bun run build:rust && bun run build:client",
|
|
62
|
+
"prebuild": "bun scripts/build-prebuild.ts",
|
|
63
|
+
"test": "bun test",
|
|
64
|
+
"lint": "bunx oxlint",
|
|
65
|
+
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
|
|
66
|
+
"verify": "bun run typecheck && bun run lint && bun test",
|
|
67
|
+
"pack:check": "bun scripts/check-pack.ts",
|
|
68
|
+
"serve": "bun run src/server.ts",
|
|
69
|
+
"bench:serialize": "bun run bench/serialize.ts",
|
|
70
|
+
"bench:throughput": "bun run bench/throughput.ts",
|
|
71
|
+
"release": "bun scripts/release.ts",
|
|
72
|
+
"release:dry": "bun scripts/release.ts --dry-run",
|
|
73
|
+
"prepublishOnly": "bun run generate && bun run verify",
|
|
74
|
+
"prepack": "bun run prebuild"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@sinclair/typebox": "^0.34.0",
|
|
78
|
+
"flatbuffers": "^25.9.23",
|
|
79
|
+
"nats": "^2.29.3"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@types/bun": "latest",
|
|
83
|
+
"lefthook": "^2.1.10",
|
|
84
|
+
"oxlint": "^1.78.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"typescript": "^7"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
Binary file
|
package/public/client.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public client API — thin re-export shim (keeps the npm entrypoint + `dist`
|
|
3
|
+
* build stable). The implementation lives in the functional modules under
|
|
4
|
+
* `src/core/`; this file just exposes the public surface.
|
|
5
|
+
*
|
|
6
|
+
* import { createClient } from "ignex-nova/client";
|
|
7
|
+
*
|
|
8
|
+
* const client = createClient("ws://localhost:3000/ws", { reconnect: true });
|
|
9
|
+
* client.on("quote", (q) => { /* q: Events["quote"] — a plain object *\/ });
|
|
10
|
+
* client.subscribe("equities"); // server-side room membership (+ last-value replay)
|
|
11
|
+
* client.send("chat", {...}); // typed client→server (server must allow it)
|
|
12
|
+
* client.connect();
|
|
13
|
+
*
|
|
14
|
+
* Works in the browser (bundle with `bun build --target=browser`) AND in Bun.
|
|
15
|
+
* Outgoing frames are encoded by the generated PURE-JS encoder — no Rust FFI
|
|
16
|
+
* needed, so the browser can send too.
|
|
17
|
+
*/
|
|
18
|
+
export { createClient, type IgnClient } from "../src/core/client";
|
|
19
|
+
export type {
|
|
20
|
+
IgnClientOptions,
|
|
21
|
+
IgnReconnectOptions,
|
|
22
|
+
ClientStatus,
|
|
23
|
+
} from "../src/core/client-state";
|
package/public/nats.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public NATS bridge API — standalone entrypoint (`ignex-nova/nats`).
|
|
3
|
+
*
|
|
4
|
+
* import { createNatsBridge } from "ignex-nova/nats";
|
|
5
|
+
* const bridge = createNatsBridge({ servers: ["nats://localhost:4222"] });
|
|
6
|
+
* bridge.publish("ignex.broadcast.quote", frame); // frame = wire bytes
|
|
7
|
+
*
|
|
8
|
+
* Most apps don't need this directly — pass `nats` to `createServer` and the
|
|
9
|
+
* server bridges broadcast / topic / group publishes automatically.
|
|
10
|
+
*/
|
|
11
|
+
export { createNatsBridge } from "../src/bridge/nats";
|
|
12
|
+
export type {
|
|
13
|
+
NatsBridgeOptions,
|
|
14
|
+
NatsBridge,
|
|
15
|
+
NatsBridgeStats,
|
|
16
|
+
NatsBridgeStatus,
|
|
17
|
+
NatsTransport,
|
|
18
|
+
} from "../src/bridge/nats";
|
|
19
|
+
export { createSubjectBuilder, type SubjectBuilder } from "../src/bridge/subjects";
|
package/public/server.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public server API — thin re-export shim (keeps the npm entrypoint + `dist`
|
|
3
|
+
* build stable). The implementation lives in the functional modules under
|
|
4
|
+
* `src/core/`; this file just exposes the public surface.
|
|
5
|
+
*
|
|
6
|
+
* import { createServer } from "ignex-nova/server";
|
|
7
|
+
*
|
|
8
|
+
* const server = createServer({ port: 3000 });
|
|
9
|
+
* server.publish("quote", { symbol: "AAPL", bid: 180.1, ask: 180.2, ... });
|
|
10
|
+
* server.publishTo(ws, "trade", { ... });
|
|
11
|
+
* server.join("equities", ws); server.publishToTopic("equities", "quote", {...});
|
|
12
|
+
*
|
|
13
|
+
* Bun-only (bun:ffi + Bun.serve).
|
|
14
|
+
*/
|
|
15
|
+
export { createServer, type IgnServer, type ClientInfo } from "../src/core/server";
|
|
16
|
+
export type {
|
|
17
|
+
IgnServerOptions,
|
|
18
|
+
IgnBackpressureOptions,
|
|
19
|
+
BackpressurePolicy,
|
|
20
|
+
WsData,
|
|
21
|
+
ClientMeta,
|
|
22
|
+
AuthResult,
|
|
23
|
+
} from "../src/core/state";
|
|
24
|
+
export type { MetricsSnapshot } from "../src/core/metrics";
|
|
25
|
+
export type { Int64GuardMode } from "../src/core/int64-guard";
|
|
26
|
+
// NATS bridge — re-exported so `nats` options on `createServer` are typed
|
|
27
|
+
// without a separate import (a standalone entrypoint is `ignex-nova/nats`).
|
|
28
|
+
export { createNatsBridge } from "../src/bridge/nats";
|
|
29
|
+
export type {
|
|
30
|
+
NatsBridgeOptions,
|
|
31
|
+
NatsBridge,
|
|
32
|
+
NatsBridgeStats,
|
|
33
|
+
NatsBridgeStatus,
|
|
34
|
+
NatsTransport,
|
|
35
|
+
} from "../src/bridge/nats";
|
package/rust/Cargo.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "ignex-nova-ffi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
|
|
6
|
+
[lib]
|
|
7
|
+
name = "ignex_ffi"
|
|
8
|
+
crate-type = ["cdylib", "rlib"]
|
|
9
|
+
path = "src/lib.rs"
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
flatbuffers = "25"
|
|
13
|
+
serde = { version = "1", features = ["derive"] }
|
|
14
|
+
serde_json = "1"
|
|
15
|
+
|
|
16
|
+
[profile.release]
|
|
17
|
+
opt-level = 3
|
|
18
|
+
lto = true
|
|
19
|
+
codegen-units = 1
|
package/rust/src/ffi.rs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//! C-ABI surface for Bun `dlopen`. Conventions follow the castrum FFI guide:
|
|
2
|
+
//! - `panic_guard` every call (a panic unwinding through `extern "C"` kills Bun)
|
|
3
|
+
//! - null-check every pointer
|
|
4
|
+
//! - `cstring` ARG → `CStr::from_ptr` (Bun's engine transcodes the JS string;
|
|
5
|
+
//! zero JS-side text encoding — the "cstring zero text encoding" pattern)
|
|
6
|
+
//! - needed-size convention: `0` = error, `w > out_cap` = exact size required
|
|
7
|
+
//! - `thread_local` scratch only (no global `static mut`)
|
|
8
|
+
//!
|
|
9
|
+
//! Frame envelope (written by both `fb_serialize` and the generated direct
|
|
10
|
+
//! `fb_*_serialize` exports):
|
|
11
|
+
//! `[WIRE_VERSION:1][event_id:u32 LE][size-prefixed FlatBuffer]`
|
|
12
|
+
//!
|
|
13
|
+
//! Bun binding (`src/native/ffi.ts`):
|
|
14
|
+
//! fb_serialize: { args: ['u32','cstring','buffer','buffer_length'], returns: 'u64_fast' }
|
|
15
|
+
//! fb_probe: { args: [], returns: 'u32' }
|
|
16
|
+
//! fb_wire_version: { args: [], returns: 'u32' }
|
|
17
|
+
use std::ffi::CStr;
|
|
18
|
+
use std::os::raw::c_char;
|
|
19
|
+
|
|
20
|
+
use crate::transcode::generated::{self, WIRE_HEADER_LEN, WIRE_VERSION};
|
|
21
|
+
|
|
22
|
+
/// Bind-time self-test / capability probe magic ("IGNX").
|
|
23
|
+
pub const FB_PROBE_MAGIC: u32 = 0x4947_4e58;
|
|
24
|
+
|
|
25
|
+
/// Capability probe used by the Bun bind-time self-test.
|
|
26
|
+
#[no_mangle]
|
|
27
|
+
pub unsafe extern "C" fn fb_probe() -> u32 {
|
|
28
|
+
FB_PROBE_MAGIC
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Wire-format version. Checked against the TS `WIRE_VERSION` at bind time so a
|
|
32
|
+
/// stale cdylib (built from an older schema/envelope) fails loudly instead of
|
|
33
|
+
/// silently producing frames the client can't decode.
|
|
34
|
+
#[no_mangle]
|
|
35
|
+
pub extern "C" fn fb_wire_version() -> u32 {
|
|
36
|
+
WIRE_VERSION as u32
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Diagnostic C-ABI probes (bench-only, NOT in the ffi.ts dlopen map) ─────
|
|
40
|
+
//
|
|
41
|
+
// Used ONLY by bench/ffi-margin.ts to isolate the fixed per-call FFI cost into
|
|
42
|
+
// its components (mirrors castrum's `ffi_probe_*` set): the bare trampoline
|
|
43
|
+
// (noop), scalar-arg/return conversion (echo_usize), TypedArray-view→pointer
|
|
44
|
+
// resolution (echo_view), and `cstring`-ARG transcoding (echo_cstr — the engine
|
|
45
|
+
// encodes the JS string to a call-scoped NUL-terminated buffer; the callee
|
|
46
|
+
// borrows via `CStr::from_ptr`, never dereferencing beyond NUL). They are
|
|
47
|
+
// trivial, non-fallible, allocate nothing, and are NOT part of the shipped
|
|
48
|
+
// `src/native/ffi.ts` dlopen map.
|
|
49
|
+
|
|
50
|
+
/// Bare C-ABI trampoline floor: does nothing, returns nothing.
|
|
51
|
+
#[no_mangle]
|
|
52
|
+
pub extern "C" fn ffi_probe_noop() {}
|
|
53
|
+
|
|
54
|
+
/// Scalar pass-through: returns `v` unchanged. Measures scalar-arg + return
|
|
55
|
+
/// conversion only (bind with `usize` vs `u64_fast` to isolate BigInt boxing).
|
|
56
|
+
#[no_mangle]
|
|
57
|
+
pub extern "C" fn ffi_probe_echo_usize(v: usize) -> usize {
|
|
58
|
+
v
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// View pass-through: returns the byte length of the `(ptr, len)` pair.
|
|
62
|
+
/// Measures TypedArray-view→pointer resolution + (ptr,len) arg conversion.
|
|
63
|
+
///
|
|
64
|
+
/// # Safety
|
|
65
|
+
/// `data` must be valid for reads of `len` bytes (the pointer is only used to
|
|
66
|
+
/// form a slice whose length we return — never dereferenced).
|
|
67
|
+
#[no_mangle]
|
|
68
|
+
pub unsafe extern "C" fn ffi_probe_echo_view(data: *const u8, len: usize) -> usize {
|
|
69
|
+
if data.is_null() && len != 0 {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
let _ = std::slice::from_raw_parts(data, len);
|
|
73
|
+
len
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// `cstring`-ARG pass-through: returns the byte length of the NUL-terminated
|
|
77
|
+
/// C string the engine produced by transcoding a JS string arg. Measures the
|
|
78
|
+
/// engine-side JS-string→UTF-8 transcode + call-scoped buffer + callee
|
|
79
|
+
/// `CStr::from_ptr` borrow — the cost a `'cstring'` input arg adds compared
|
|
80
|
+
/// with a JS-side encode + `(ptr,len)` pair. The pointer is only used to
|
|
81
|
+
/// form a slice whose length we return — never dereferenced beyond NUL.
|
|
82
|
+
///
|
|
83
|
+
/// # Safety
|
|
84
|
+
/// `data` must be a valid NUL-terminated C string for reads up to its terminator.
|
|
85
|
+
#[no_mangle]
|
|
86
|
+
pub unsafe extern "C" fn ffi_probe_echo_cstr(data: *const c_char) -> usize {
|
|
87
|
+
if data.is_null() {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
CStr::from_ptr(data).to_bytes().len()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Serialize a JS object (JSON text) into the transport frame
|
|
94
|
+
/// `[WIRE_VERSION][event_id:u32][size-prefixed FlatBuffer]`, writing into `out`.
|
|
95
|
+
///
|
|
96
|
+
/// Returns (`u64_fast` on the Bun side):
|
|
97
|
+
/// - `0` → hard error (bad JSON / unknown event id / panic)
|
|
98
|
+
/// - `w <= out_cap` → `w` bytes written (envelope + flatbuffer)
|
|
99
|
+
/// - `w > out_cap` → exactly `w` bytes required; nothing written
|
|
100
|
+
///
|
|
101
|
+
/// `json` is a NUL-terminated UTF-8 C string (Bun `cstring` arg).
|
|
102
|
+
///
|
|
103
|
+
/// # Safety
|
|
104
|
+
/// `json` must point to a NUL-terminated UTF-8 buffer valid for the call.
|
|
105
|
+
/// `out` must point to `out_cap` writable bytes (or be null when `out_cap == 0`).
|
|
106
|
+
#[no_mangle]
|
|
107
|
+
pub unsafe extern "C" fn fb_serialize(event_id: u32, json: *const c_char, out: *mut u8, out_cap: usize) -> usize {
|
|
108
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
109
|
+
if json.is_null() || (out.is_null() && out_cap != 0) {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
let json_bytes = CStr::from_ptr(json).to_bytes();
|
|
113
|
+
// envelope = [WIRE_VERSION:1][event_id:u32 LE]; flatbuffer payload follows
|
|
114
|
+
let payload: &mut [u8] = if out_cap < WIRE_HEADER_LEN {
|
|
115
|
+
&mut []
|
|
116
|
+
} else {
|
|
117
|
+
std::slice::from_raw_parts_mut(out.add(WIRE_HEADER_LEN), out_cap - WIRE_HEADER_LEN)
|
|
118
|
+
};
|
|
119
|
+
match generated::serialize_event(event_id, json_bytes, payload) {
|
|
120
|
+
Ok(written) => {
|
|
121
|
+
let needed = written + WIRE_HEADER_LEN;
|
|
122
|
+
if needed <= out_cap {
|
|
123
|
+
*out.add(0) = WIRE_VERSION;
|
|
124
|
+
let id_bytes = event_id.to_le_bytes();
|
|
125
|
+
std::ptr::copy_nonoverlapping(id_bytes.as_ptr(), out.add(1), 4);
|
|
126
|
+
}
|
|
127
|
+
needed
|
|
128
|
+
}
|
|
129
|
+
Err(_) => 0,
|
|
130
|
+
}
|
|
131
|
+
}))
|
|
132
|
+
.unwrap_or(0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|