@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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/docs/architecture.md +146 -0
  4. package/docs/publishing.md +119 -0
  5. package/docs/wire-format.md +170 -0
  6. package/index.ts +61 -0
  7. package/package.json +89 -0
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/client.ts +23 -0
  10. package/public/nats.ts +19 -0
  11. package/public/server.ts +35 -0
  12. package/rust/Cargo.toml +19 -0
  13. package/rust/src/ffi.rs +135 -0
  14. package/rust/src/generated/backend.rs +2817 -0
  15. package/rust/src/generated/mod.rs +2 -0
  16. package/rust/src/lib.rs +9 -0
  17. package/rust/src/transcode/generated.rs +1352 -0
  18. package/rust/src/transcode/mod.rs +2 -0
  19. package/src/bridge/nats.ts +269 -0
  20. package/src/bridge/subjects.ts +30 -0
  21. package/src/core/auth.ts +56 -0
  22. package/src/core/backpressure.ts +39 -0
  23. package/src/core/client-heartbeat.ts +27 -0
  24. package/src/core/client-reconnect.ts +35 -0
  25. package/src/core/client-state.ts +76 -0
  26. package/src/core/client-wire.ts +72 -0
  27. package/src/core/client.ts +176 -0
  28. package/src/core/groups.ts +52 -0
  29. package/src/core/int64-guard.ts +44 -0
  30. package/src/core/metrics.ts +105 -0
  31. package/src/core/outbound.ts +76 -0
  32. package/src/core/replay.ts +31 -0
  33. package/src/core/ring.ts +85 -0
  34. package/src/core/rooms.ts +44 -0
  35. package/src/core/routing.ts +94 -0
  36. package/src/core/server.ts +294 -0
  37. package/src/core/state.ts +179 -0
  38. package/src/generated/direct-ser.ts +495 -0
  39. package/src/generated/fbs/backend.fbs +139 -0
  40. package/src/generated/registry.ts +341 -0
  41. package/src/generated/rust/backend_generated.rs +2817 -0
  42. package/src/generated/ts/backend.ts +25 -0
  43. package/src/generated/ts/big-val.ts +106 -0
  44. package/src/generated/ts/complex.ts +303 -0
  45. package/src/generated/ts/customer.ts +137 -0
  46. package/src/generated/ts/hello.ts +123 -0
  47. package/src/generated/ts/join-group.ts +78 -0
  48. package/src/generated/ts/leave-group.ts +78 -0
  49. package/src/generated/ts/order-billing.ts +137 -0
  50. package/src/generated/ts/order-line.ts +144 -0
  51. package/src/generated/ts/order.ts +236 -0
  52. package/src/generated/ts/ping.ts +74 -0
  53. package/src/generated/ts/pong.ts +74 -0
  54. package/src/generated/ts/portfolio-position.ts +120 -0
  55. package/src/generated/ts/portfolio-snapshot.ts +170 -0
  56. package/src/generated/ts/quote.ts +148 -0
  57. package/src/generated/ts/side.ts +8 -0
  58. package/src/generated/ts/snapshot-request.ts +78 -0
  59. package/src/generated/ts/subscribe.ts +78 -0
  60. package/src/generated/ts/tags.ts +9 -0
  61. package/src/generated/ts/trade.ts +135 -0
  62. package/src/generated/ts/unsubscribe.ts +78 -0
  63. package/src/generated/ts/welcome.ts +112 -0
  64. package/src/generated/ts-ser.ts +465 -0
  65. package/src/generated/wire-registry.json +20 -0
  66. package/src/native/codec.ts +35 -0
  67. package/src/native/ffi.ts +214 -0
  68. package/src/native/loader.ts +55 -0
  69. package/src/schema/index.ts +217 -0
  70. package/src/server.ts +87 -0
  71. package/src/transport/byte-buffer-pool.ts +63 -0
  72. package/src/transport/scratch.ts +48 -0
  73. package/src/transport/stats.ts +44 -0
  74. 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
+ }
@@ -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";
@@ -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";
@@ -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
@@ -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
+