castrum 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +563 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 castrum contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -14,26 +14,32 @@ A high-performance Bun backend framework with Rust-accelerated FFI functions, pr
|
|
|
14
14
|
│ │
|
|
15
15
|
│ index.ts (entry point) │
|
|
16
16
|
│ ├── src/rust-ffi/ Raw Rust FFI bindings │
|
|
17
|
-
│ ├── src/native/
|
|
17
|
+
│ ├── src/native/ bun:ffi (primary on Bun) + NAPI loader │
|
|
18
18
|
│ ├── src/ingress/ High-performance HTTP ingress pipeline │
|
|
19
|
+
│ ├── src/integration/ Framework-agnostic pipeline/WS/SSE │
|
|
20
|
+
│ ├── src/loader/ Higher-order loader (HFC) │
|
|
19
21
|
│ ├── src/baseline/ JS baseline (benchmark reference) │
|
|
20
|
-
│ ├── src/data/ Data serialization utilities │
|
|
21
22
|
│ └── src/shared/ Shared constants and helpers │
|
|
22
23
|
│ │
|
|
23
|
-
|
|
24
|
+
├──────── Native Bridge: bun:ffi (Bun, primary) + NAPI (fallback) ────────┤
|
|
24
25
|
│ │
|
|
25
26
|
│ Rust (cdylib) Layer │
|
|
26
27
|
│ │
|
|
27
28
|
│ rust/lib.rs │
|
|
28
|
-
│ ├──
|
|
29
|
-
│
|
|
30
|
-
│ ├──
|
|
31
|
-
│
|
|
32
|
-
│ │
|
|
33
|
-
│ ├──
|
|
34
|
-
│
|
|
35
|
-
│ ├──
|
|
36
|
-
│
|
|
29
|
+
│ ├── util/ bytes, packed, batch, batch_core, threadpool, │
|
|
30
|
+
│ │ validation │
|
|
31
|
+
│ ├── http/ headers, method, http_parser, cookie/query │
|
|
32
|
+
│ │ parsers, form, media_type, url_codec/join, │
|
|
33
|
+
│ │ etag, accept, mime_lookup, multipart │
|
|
34
|
+
│ ├── crypto/ hmac_sha256, cookie_sign, csrf, jwt, aead, │
|
|
35
|
+
│ │ argon2, base64, hashing, random_token │
|
|
36
|
+
│ ├── json/ json_ops, json_ser, json_patch_ops, │
|
|
37
|
+
│ │ json_schema, fast_schema/ (zero-DOM) │
|
|
38
|
+
│ ├── payload/ compress, sse, ws_frames, websocket, template │
|
|
39
|
+
│ ├── ingress/ mod.rs (napi boundary), pipeline.rs (core), │
|
|
40
|
+
│ │ options/time/packed, cors, proxy, ip_trust, │
|
|
41
|
+
│ │ rate_limit, terminal, output, constants │
|
|
42
|
+
│ └── ffi.rs extern "C" exports (castrum_*) for bun:ffi │
|
|
37
43
|
└─────────────────────────────────────────────────────────────────┘
|
|
38
44
|
```
|
|
39
45
|
|
|
@@ -46,21 +52,64 @@ bun install
|
|
|
46
52
|
# Build Rust native addon (release)
|
|
47
53
|
bun run build
|
|
48
54
|
|
|
55
|
+
# Build the compiled ESM entry for Node.js consumers (dist/)
|
|
56
|
+
bun run build:js
|
|
57
|
+
|
|
49
58
|
# Run benchmarks
|
|
50
59
|
bun bench.ts
|
|
51
60
|
|
|
52
61
|
# Run tests
|
|
53
62
|
bun test
|
|
63
|
+
|
|
64
|
+
# Run the Node.js smoke suite (requires `bun run build:js` first)
|
|
65
|
+
bun run test:node
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Node.js Support
|
|
69
|
+
|
|
70
|
+
**Bun is the primary target** (raw TypeScript, zero startup cost). The package is
|
|
71
|
+
*also* consumable from Node.js via a compiled ESM entry:
|
|
72
|
+
|
|
73
|
+
- Bun resolves the `bun` exports condition → `index.ts` (native TS execution).
|
|
74
|
+
- Node.js resolves the `node`/`default` condition → `dist/index.js` (compiled ESM,
|
|
75
|
+
requires Node.js >= 20.3). The bundled `dist/index.d.ts` covers the full API.
|
|
76
|
+
- **One cdylib, two transports.** The addon is built with napi-rs (a standard N-API
|
|
77
|
+
binary) but ALSO exports `extern "C"` symbols (`rust/ffi.rs`) that Bun loads directly:
|
|
78
|
+
- **Bun** — `bun:ffi` is the PRIMARY transport. `src/native/ffi.ts` `dlopen`s the same
|
|
79
|
+
`.node` file and JIT-calls the C ABI exports (~10-20ns crossing), covering the whole
|
|
80
|
+
stateless scalar API + the ingress per-request pipeline + the ingress layout
|
|
81
|
+
constants. NAPI is the fallback (Node, `CASTRUM_FFI_MODE=napi`, or a failed bind-time
|
|
82
|
+
self-test).
|
|
83
|
+
- **Node.js** — the N-API addon (`src/native/loader.ts`) is the fallback transport.
|
|
84
|
+
- `rust.transport()` returns `"ffi"` / `"napi"` (the resolved transport) and
|
|
85
|
+
`rust.ffiActive()` reports whether the bun:ffi transport is live; `CASTRUM_FFI_MODE`
|
|
86
|
+
overrides the selection (see docs/ENVIRONMENT.md).
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// Node.js (ESM)
|
|
90
|
+
import { rust, createIngressHandler, readHandler, createIngressServerNode } from "castrum";
|
|
91
|
+
|
|
92
|
+
rust.crc32(new Uint8Array([104, 105])); // native FFI works identically (napi fallback)
|
|
93
|
+
|
|
94
|
+
// Serve the SAME pre-baked ingress handlers over node:http
|
|
95
|
+
const srv = createIngressServerNode({ port: 3000, routes: { "/health": { read: handler } } });
|
|
96
|
+
await srv.ready; // resolves once the server is listening
|
|
54
97
|
```
|
|
55
98
|
|
|
99
|
+
`createIngressServer` (Bun.serve) is Bun-only; Node users use `createIngressServerNode`
|
|
100
|
+
with the same route handlers.
|
|
101
|
+
|
|
56
102
|
## Build
|
|
57
103
|
|
|
58
104
|
```bash
|
|
59
|
-
# Production build
|
|
105
|
+
# Production build (native addon)
|
|
60
106
|
bun run build
|
|
61
107
|
|
|
62
108
|
# Debug build
|
|
63
109
|
bun run build:debug
|
|
110
|
+
|
|
111
|
+
# Compiled JS entry for Node (bundle + bundled types -> dist/)
|
|
112
|
+
bun run build:js
|
|
64
113
|
```
|
|
65
114
|
|
|
66
115
|
## Benchmarks
|
|
@@ -78,27 +127,121 @@ bun run bench:http:ingress # vs Ingress (native)
|
|
|
78
127
|
bun run bench:http:bun-only # vs raw Bun
|
|
79
128
|
|
|
80
129
|
# Load patterns
|
|
81
|
-
bun run bench:http:smoke # smoke test
|
|
130
|
+
bun run bench:http:smoke # smoke test (CI wire-format guard)
|
|
82
131
|
bun run bench:http:stress # stress test
|
|
83
132
|
bun run bench:http:heavy # heavy JSON
|
|
84
133
|
bun run bench:http:crud # CRUD operations
|
|
134
|
+
bun run bench:http:spike # spike test
|
|
85
135
|
bun run bench:http:soak # soak (endurance) test
|
|
86
136
|
bun run bench:http:storm # storm (burst) test
|
|
87
137
|
bun run bench:http:boundary # boundary conditions
|
|
138
|
+
bun run bench:http:all-heavy # heavy-JSON scenarios (substring filter "1": 01-smoke, 10-12, 13-20)
|
|
88
139
|
```
|
|
89
140
|
|
|
141
|
+
### Bun built-ins comparison ("don't reinvent the wheel")
|
|
142
|
+
|
|
143
|
+
`bun run check` also races castrum ops against Bun's native built-ins
|
|
144
|
+
(`Bun.hash`, `Bun.password`, `Bun.CryptoHasher`, `Bun.gzipSync`,
|
|
145
|
+
`Bun.randomUUIDv7`) as a **diagnostic** set (`diag:` task names — never
|
|
146
|
+
audited by `check:proven`). Findings on Bun 1.4 / release addon:
|
|
147
|
+
|
|
148
|
+
- **Bun wins → `@flux/native` should prefer Bun**: non-crypto hashing
|
|
149
|
+
(`Bun.hash.crc32` ~3x, `xxHash3` ~4x, wyhash ~1-2x), gzip
|
|
150
|
+
(`Bun.gzipSync`/`gunzipSync` ~1.3-2x), `Bun.randomUUIDv7` (~2x), HMAC
|
|
151
|
+
(`Bun.CryptoHasher` ~1.2x).
|
|
152
|
+
- **Rust wins → keep**: argon2id password hashing/verify (~1.8-2x vs
|
|
153
|
+
`Bun.password`), bcrypt verify (~1.5x), and everything with no sync Bun
|
|
154
|
+
equivalent (brotli, PBKDF2, zero-DOM JSON/parsers).
|
|
155
|
+
|
|
156
|
+
Full table + per-op decisions: [`docs/bun-builtins-decision-matrix.md`](docs/bun-builtins-decision-matrix.md).
|
|
157
|
+
|
|
158
|
+
### New primitives
|
|
159
|
+
|
|
160
|
+
| `rust.*` | Description | Benchmark vs |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `xxh3` | XXH3-64 (non-crypto hash) | `Bun.hash.xxHash3` (~4x Bun — prefer Bun under Bun) |
|
|
163
|
+
| `passwordHashBcrypt` / `passwordVerifyBcrypt` | bcrypt `$2b$` PHC | `Bun.password` bcrypt (hash parity, verify rust ~1.5x) |
|
|
164
|
+
| `pbkdf2Sha256` | PBKDF2-HMAC-SHA256 | `node:crypto.pbkdf2Sync` (parity; only sync option in Bun) |
|
|
165
|
+
| `uuidv7()` (TS) | UUIDv7 — delegates to `Bun.randomUUIDv7`, `crypto.randomUUID` on Node | `Bun.randomUUIDv7` |
|
|
166
|
+
|
|
90
167
|
## API Reference
|
|
91
168
|
|
|
92
169
|
### Core Exports
|
|
93
170
|
|
|
94
171
|
```ts
|
|
95
|
-
import { rust,
|
|
172
|
+
import { rust, proven } from "castrum";
|
|
173
|
+
|
|
174
|
+
// `rust` — All Rust FFI native implementations (flat, complete API)
|
|
175
|
+
// `proven` — The same full `rust.*` surface, with performance annotations
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Performance-annotated surface
|
|
179
|
+
|
|
180
|
+
Not every `rust.*` function beats its JavaScript baseline — e.g. `rust.jsonParse`
|
|
181
|
+
loses ~5x to Bun's `JSON.parse` (DOM + napi marshaling), and the native schema
|
|
182
|
+
validator is slower than `ajv` for small documents. `proven` exposes the **full
|
|
183
|
+
`rust.*` surface** (it is literally the same object as `rust`); performance is
|
|
184
|
+
communicated by `@performance` / `@deprecated` JSDoc annotations rather than by
|
|
185
|
+
filtering, so a function's classification is visible in your editor and in
|
|
186
|
+
`PROVEN_SURFACE`:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { proven, PROVEN_SURFACE } from "castrum";
|
|
190
|
+
|
|
191
|
+
proven.jsonValid(bytes); // ✓ proven — Rust wins vs the JS baseline
|
|
192
|
+
proven.fnv1a64(bytes); // ✓ proven — Rust wins ~9-16x
|
|
193
|
+
proven.jsonParse(bytes); // ✗ @deprecated — loses ~5x to JSON.parse
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `proven` is the full `rust.*` client (`export const proven = rust`). Each
|
|
197
|
+
exported function's JSDoc carries its measured performance vs the JS baseline
|
|
198
|
+
(`@performance`) and is marked `@deprecated` when it loses — so the editor
|
|
199
|
+
steers you to the JS/Bun baseline for those. The single source of truth for the
|
|
200
|
+
classifications is `PROVEN_SURFACE` (`src/shared/proven.ts`). Statuses:
|
|
201
|
+
`proven` / `parity` / `not-competitive` / `unmeasured`.
|
|
202
|
+
- The full surface is unchanged — `rust.jsonParse`, `rust.createSchemaValidator`,
|
|
203
|
+
etc. remain available for completeness; they just aren't advertised as
|
|
204
|
+
performance wins.
|
|
205
|
+
- Classifications are based on the **release build on the shipped baseline CPU**
|
|
206
|
+
(published artifacts are baseline — not the local SIMD `build:perf`).
|
|
207
|
+
|
|
208
|
+
Keep the registry honest with the audit:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
bun run check # writes bench/results/cpu/latest.json (release build)
|
|
212
|
+
bun run check:proven # report-only: proves vs loses per function
|
|
213
|
+
bun run check:proven:fail # CI gate: exits 1 if a "proven" function regresses
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The audit compares each registry entry against the latest benchmark report and
|
|
217
|
+
fails (with `--fail`) when a `proven` function loses to its baseline by more
|
|
218
|
+
than the tolerance — catching real performance regressions (and wrong
|
|
219
|
+
classifications) before they ship.
|
|
220
|
+
|
|
221
|
+
### Native-vs-JS selection surface
|
|
222
|
+
|
|
223
|
+
For framework consumers that want to bind each operation to a fixed
|
|
224
|
+
implementation at load time (instead of calling `rust.*` directly), castrum
|
|
225
|
+
exposes an auto-selected decision surface:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { opImpl, isNativeOp, opDecision } from "castrum";
|
|
96
229
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
230
|
+
opImpl("gzipCompress"); // "native" | "js" | null
|
|
231
|
+
isNativeOp("crc32"); // boolean
|
|
232
|
+
opDecision("fnv1a64"); // { impl, note? } | null
|
|
100
233
|
```
|
|
101
234
|
|
|
235
|
+
- The single source of truth is `rust/selection.rs`, which embeds the
|
|
236
|
+
benchmark-generated `src/selection.json` (produced by
|
|
237
|
+
`scripts/select-native.ts --write`, audited by `--check` in CI). Under Bun,
|
|
238
|
+
ops where the Bun built-in beats the Rust addon (gzip, crc32, xxh3, HMAC,
|
|
239
|
+
random tokens — see `docs/bun-builtins-decision-matrix.md`) are selected as
|
|
240
|
+
`"js"` (a JS path that delegates to the Bun built-in); under Node the base
|
|
241
|
+
benchmark decision stands.
|
|
242
|
+
- Consumers read `opImpl(op)` **once at startup** and bind each op to a fixed
|
|
243
|
+
implementation — they do not swap native↔js per call.
|
|
244
|
+
|
|
102
245
|
### Rust Utility API
|
|
103
246
|
|
|
104
247
|
`rust` is a single flat object exposing **every** native Rust utility with
|
|
@@ -122,8 +265,10 @@ string variants under `rust.text`.
|
|
|
122
265
|
| `hmacSha256(key, data)` | `Uint8Array` — HMAC-SHA256 signature |
|
|
123
266
|
| `hmacSha256Verify(key, data, sig)` | `boolean` |
|
|
124
267
|
| `jsonValid(input)` | `boolean` — JSON validity check |
|
|
268
|
+
| `jsonParse(input)` | `unknown` — JSON → JS value (native sonic-rs DOM; throws on invalid) |
|
|
125
269
|
| `jsonSumIds(input)` | `bigint` — sum of numeric `id` fields |
|
|
126
270
|
| `jsonPatch(doc, patch)` | `Uint8Array` — RFC 6902 result |
|
|
271
|
+
| `createSchemaValidator(schema)` | `SchemaValidator` — compile a JSON Schema (draft-07); `.validate(doc): boolean`, `.validateDetailed(doc): SchemaError[]`, `.validateFirstError(doc): SchemaError | null`, `.validateBatchPackedCount(packed): number`, … |
|
|
127
272
|
| `mimeFromExtension(ext)` | `Uint8Array` — MIME type |
|
|
128
273
|
| `randomToken(byteLen)` | `Uint8Array` — CSPRNG token |
|
|
129
274
|
| `urlEncode(input)` / `urlDecode(input)` | `Uint8Array` — percent encode/decode |
|
|
@@ -131,8 +276,36 @@ string variants under `rust.text`.
|
|
|
131
276
|
| `validateEmail/Uuid/Ipv4/Ipv6(input)` | `boolean` |
|
|
132
277
|
| `wsAcceptKey(key)` | `Uint8Array` — WebSocket accept key |
|
|
133
278
|
|
|
279
|
+
#### Framework actions (HTTP semantics + security)
|
|
280
|
+
|
|
281
|
+
| Function | Returns |
|
|
282
|
+
|----------|---------|
|
|
283
|
+
| `formParsePacked(body)` | `Uint8Array` — packed x-www-form-urlencoded pairs |
|
|
284
|
+
| `createFormParser(capacity?)` | `FormParser` — reusable-buffer form parser (`.parse`, `.parseInto`) |
|
|
285
|
+
| `parseMediaType(header)` | `{ mediaType, charset, boundary, params }` — Content-Type parse |
|
|
286
|
+
| `createMediaTypeParser()` | `MediaTypeParser` — `.parse`, `.matches(actual, expected)` (wildcards) |
|
|
287
|
+
| `etag(data, weak?)` | `Uint8Array` — strong/weak ETag (crc32-based) |
|
|
288
|
+
| `httpDate(secs?)` / `parseHttpDate(input)` | `Uint8Array` / `bigint | null` — IMF-fixdate format/parse |
|
|
289
|
+
| `createConditionalRequest(etag, lastModifiedSecs?)` | `ConditionalRequest` — `.isNotModified(ifNoneMatch, ifModifiedSince)` → 304 |
|
|
290
|
+
| `parseAcceptEncoding(header)` | `{ encoding, q, order }[]` |
|
|
291
|
+
| `createAcceptNegotiator(supported[])` | `AcceptNegotiator` — `.negotiate(header)` → best encoding |
|
|
292
|
+
| `base64Encode/Decode(input, urlSafe?, padding?)`, `base64UrlEncode/Decode(input)` | `Uint8Array` |
|
|
293
|
+
| `createBase64Codec(urlSafe?, padding?)` | `Base64Codec` — `.encode`, `.decode` |
|
|
294
|
+
| `hexEncode(input)` / `hexDecode(input)` | `Uint8Array` — lowercase hex |
|
|
295
|
+
| `signCookie(value, secret)` / `verifyCookie(signed, secret)` | `Uint8Array` / `Uint8Array | null` — signed cookies |
|
|
296
|
+
| `createCookieSigner(secret)` | `CookieSigner` — `.sign`, `.verify` (HMAC key compiled once) |
|
|
297
|
+
| `csrfToken(secret)` / `csrfVerify(token, secret)` | `Uint8Array` / `boolean` |
|
|
298
|
+
| `createCsrfProtector(secret)` | `CsrfProtector` — `.create`, `.verify` |
|
|
299
|
+
| `urlResolve(base, reference)` | `Uint8Array` — RFC 3986 resolution |
|
|
300
|
+
| `urlEncodeQuery(params)` | `Uint8Array` — percent-encoded query (sorted keys) |
|
|
301
|
+
| `createUrlBuilder(base)` | `UrlBuilder` — `.resolve(reference)` (base parsed once) |
|
|
302
|
+
| `createJwtSigner(secret, ttlSeconds?)` | `JwtSigner` — `.sign(claims, now)`, `.verify(token, now)` (HS256 key + ttl compiled once) |
|
|
303
|
+
| `createAeadCipher(key, algorithm?)` | `AeadCipher` — `.encrypt(nonce, pt)`, `.decrypt(nonce, ct)` (key compiled once) |
|
|
304
|
+
| `createArgon2Hasher(options?)` | `Argon2Hasher` — `.hash(password, salt)`, `.verify(password, phc)` (params compiled once) |
|
|
305
|
+
| `createMediaTypeMatcher(expected)` | `MediaTypeMatcher` — `.matches(actual)` (expected precompiled once) |
|
|
306
|
+
|
|
134
307
|
Low-level / packed variants: `rust.httpParseRequestPacked`, `rust.queryParsePacked`,
|
|
135
|
-
`rust.cookieParsePacked`.
|
|
308
|
+
`rust.cookieParsePacked`, `rust.formParsePacked`.
|
|
136
309
|
|
|
137
310
|
#### String ergonomics (`rust.text`)
|
|
138
311
|
|
|
@@ -156,13 +329,80 @@ rust.batch.jsonValid(docs); // Uint8Array bitset (1 per doc)
|
|
|
156
329
|
rust.batch.crc32(docs); // Uint32Array
|
|
157
330
|
rust.batch.jsonSumIds(docs); // BigInt64Array
|
|
158
331
|
rust.batch.queryParse(docs); // Uint8Array[]
|
|
332
|
+
rust.batch.formParse(docs); // Uint8Array[] (packed pairs per doc)
|
|
333
|
+
rust.batch.signCookie(docs, secret); // Uint8Array[] (signed)
|
|
334
|
+
rust.batch.verifyCookie(docs, secret); // Uint8Array bitset (valid?)
|
|
335
|
+
rust.batch.csrfVerify(tokens, secret); // Uint8Array bitset (valid?)
|
|
159
336
|
rust.batch.schemaValidate(validator, docs); // bitset
|
|
337
|
+
|
|
338
|
+
// Hashing / url / mime / ws / base64url
|
|
339
|
+
rust.batch.fnv1a64(docs); // BigUint64Array (unsigned hashes)
|
|
340
|
+
rust.batch.etag(docs); // Uint8Array[]
|
|
341
|
+
rust.batch.urlEncode(docs); // Uint8Array[]
|
|
342
|
+
rust.batch.urlDecode(docs); // Uint8Array[]
|
|
343
|
+
rust.batch.base64UrlEncode(docs); // Uint8Array[]
|
|
344
|
+
rust.batch.wsAcceptKey(keys); // Uint8Array[]
|
|
345
|
+
rust.batch.mimeFromExtension(exts); // Uint8Array[]
|
|
346
|
+
|
|
347
|
+
// Backend-framework batches (zipped lists / shared args)
|
|
348
|
+
rust.batch.passwordVerify(pwds, phcs); // Uint8Array bitset (zipped)
|
|
349
|
+
rust.batch.urlResolve(bases, refs); // Uint8Array[] (zipped)
|
|
350
|
+
rust.batch.jwtSign(claimDocs, secret); // Uint8Array[] (sign N JSON claim docs)
|
|
351
|
+
rust.batch.jwtVerify(tokens, secret, now); // Uint8Array bitset
|
|
352
|
+
rust.batch.sseEncode(items, "event"); // Uint8Array[]
|
|
353
|
+
rust.batch.wsFrameEncode(items, 1, false, true); // Uint8Array[]
|
|
354
|
+
rust.batch.wsFrameDecode(frames); // Uint8Array[] (payloads)
|
|
355
|
+
rust.batch.multipartParse(bodies, boundary); // MultipartPart[][]
|
|
160
356
|
```
|
|
161
357
|
|
|
162
358
|
Raw packed + metadata variants (advanced): `rust.packed.*` mirrors the native
|
|
163
359
|
functions 1:1 (`jsonValidBatchPacked`, `jsonValidBatchCountPacked`,
|
|
164
360
|
`queryParseBatchTotalLenPacked`, …).
|
|
165
361
|
|
|
362
|
+
#### Higher-order data loader (`castrum.loader`)
|
|
363
|
+
|
|
364
|
+
`loader` is a callable higher-order function over the whole batch surface. It
|
|
365
|
+
pre-binds an op so repeated calls skip registry dispatch, and it routes small
|
|
366
|
+
vs. large workloads automatically: a single item → one scalar native call, a
|
|
367
|
+
bulk → ONE packed batch call, and `load()` coalesces N same-tick calls into one
|
|
368
|
+
packed call (DataLoader-style) with a bounded LRU cache.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { loader } from "castrum";
|
|
372
|
+
|
|
373
|
+
const isEmail = loader("validateEmail");
|
|
374
|
+
isEmail(emailBytes); // scalar → boolean
|
|
375
|
+
isEmail([a, b, c]); // bulk → Uint8Array bitset (one packed call)
|
|
376
|
+
await isEmail.load(a); // coalesced + cached (auto batch/strategy)
|
|
377
|
+
|
|
378
|
+
// Covered op families (44 ops):
|
|
379
|
+
// hash crc32, fnv1a64
|
|
380
|
+
// json jsonValid, jsonSumIds, jsonPatch*, schemaValidate
|
|
381
|
+
// validate email, uuid, ipv4, ipv6
|
|
382
|
+
// parse query, cookie, form, httpParseRequest, urlResolve*
|
|
383
|
+
// encode hex, base64, base64Url, urlEncode, urlDecode, urlDecodeBytes, etag
|
|
384
|
+
// compress gzip, brotli
|
|
385
|
+
// crypto hmacSha256, hmacSha256Verify*, signCookie, verifyCookie, csrfVerify,
|
|
386
|
+
// passwordHash, passwordVerify*, aeadEncrypt, aeadDecrypt, jwtSign, jwtVerify
|
|
387
|
+
// web wsAcceptKey, wsFrameEncode, sseEncode, mimeFromExtension
|
|
388
|
+
// (* = paired: per-item companion array, e.g. loader.run("jsonPatch", docs, patches))
|
|
389
|
+
|
|
390
|
+
// Paired ops take a companion array:
|
|
391
|
+
loader.run("jsonPatch", docs, patches); // bulk
|
|
392
|
+
loader.run("jsonPatch", doc, patch); // single
|
|
393
|
+
|
|
394
|
+
// verifyCookie / jwtVerify are boolean-validity ops (valid/invalid);
|
|
395
|
+
// use rust.verifyCookie / rust.jwtVerify for the decoded value.
|
|
396
|
+
|
|
397
|
+
// Bind a schema validator for repeated single/bulk validation + count:
|
|
398
|
+
const schema = loader.schema(rust.createSchemaValidator(schemaBytes));
|
|
399
|
+
schema(doc); // boolean
|
|
400
|
+
schema(docs); // Uint8Array bitset
|
|
401
|
+
schema.count(docs); // number of valid docs
|
|
402
|
+
|
|
403
|
+
// Fine-grained control: createLoader({ adaptive, batchMin, maxCacheKeys, ... })
|
|
404
|
+
```
|
|
405
|
+
|
|
166
406
|
#### Configuring defaults
|
|
167
407
|
|
|
168
408
|
Defaults are selected automatically, and can be overridden:
|
|
@@ -197,8 +437,7 @@ const myRust = createRust({ mimeCache: false });
|
|
|
197
437
|
| `import { encoder, decoder } from "castrum"` | Text encode/decode utilities |
|
|
198
438
|
| `import { packBatch, packPairs, unpackBitset, unpackByteResults, unpackI64ArrayAsBigInt, unpackU32Array } from "castrum"` | Batch/pair packing + unpacking utilities |
|
|
199
439
|
| `import { readPairsPacked, pairsToObject, readHttpPacked } from "castrum"` | Decode packed pairs / parsed HTTP buffers |
|
|
200
|
-
| `import { parseQueryString, parseCookieHeader } from "castrum"` | High-level
|
|
201
|
-
| `import { jsonRowsBytes, createJsonRows } from "castrum"` | JSON row serialization |
|
|
440
|
+
| `import { parseQueryString, parseCookieHeader, parseFormBody } from "castrum"` | High-level parsers (no hand-packing) |
|
|
202
441
|
| `import { createIngress, createIngressSync, createIngressFast } from "castrum"` | HTTP ingress pipeline (low-level) |
|
|
203
442
|
| `import { createIngressHandler, createIngressServer, readHandler, jsonWriteHandler, echoHandler, fallbackHandler } from "castrum"` | **Pre-baked** ingress handlers — route factories + Bun.serve builder |
|
|
204
443
|
|
|
@@ -299,46 +538,60 @@ castrum/
|
|
|
299
538
|
├── package.json # Node/Bun package config
|
|
300
539
|
├── tsconfig.json # TypeScript config
|
|
301
540
|
│
|
|
302
|
-
├── rust/ # Rust source (cdylib)
|
|
303
|
-
│ ├── lib.rs # Crate root
|
|
304
|
-
│ ├──
|
|
305
|
-
│ ├──
|
|
306
|
-
│
|
|
307
|
-
│ ├──
|
|
308
|
-
│
|
|
309
|
-
│ ├──
|
|
310
|
-
│
|
|
311
|
-
│
|
|
312
|
-
│
|
|
313
|
-
│ ├──
|
|
314
|
-
│ ├──
|
|
315
|
-
│
|
|
316
|
-
│
|
|
317
|
-
│ ├──
|
|
318
|
-
│
|
|
319
|
-
│ ├── mime_lookup.rs # MIME type lookup
|
|
320
|
-
│ ├── websocket.rs # WebSocket utilities
|
|
321
|
-
│ ├── random_token.rs # Secure random token generation
|
|
322
|
-
│ ├── batch.rs # Batch processing
|
|
323
|
-
│ ├── method.rs # HTTP method enum
|
|
324
|
-
│ ├── headers.rs # Header packing
|
|
325
|
-
│ ├── output.rs # Output buffer layout
|
|
326
|
-
│ ├── terminal.rs # Terminal response writers
|
|
327
|
-
│ ├── proxy.rs # Proxy detection utilities
|
|
328
|
-
│ ├── util.rs # Core utilities
|
|
329
|
-
│ └── ingress_constants.rs # NAPI-exported constants
|
|
541
|
+
├── rust/ # Rust source (one cdylib, domain folders)
|
|
542
|
+
│ ├── lib.rs # Crate root: folder declarations + module map
|
|
543
|
+
│ ├── util/ # Shared infra: bytes, packed, batch(+core), threadpool, validation
|
|
544
|
+
│ ├── http/ # Wire formats: headers, method, http/query/cookie parsers,
|
|
545
|
+
│ │ # form, media_type, url_codec/join, etag, accept, mime_lookup, multipart
|
|
546
|
+
│ ├── crypto/ # Auth & hashing: hmac, cookie_sign, csrf, jwt, aead, argon2,
|
|
547
|
+
│ │ # base64, hashing, random_token
|
|
548
|
+
│ ├── json/ # JSON & schema: json_ops, json_ser, json_patch_ops, json_schema,
|
|
549
|
+
│ │ # fast_schema/ (zero-DOM draft-07 engine: types/cursor/compile/
|
|
550
|
+
│ │ # validate/errors — incl. pattern, enum/const, multipleOf,
|
|
551
|
+
│ │ # combinators, $ref, uniqueItems, detailed errors)
|
|
552
|
+
│ ├── payload/ # Output & streaming: compress, sse, ws_frames, websocket, template
|
|
553
|
+
│ ├── ingress/ # The ingress pipeline: mod.rs (napi boundary), pipeline.rs (core),
|
|
554
|
+
│ │ # tests.rs, options/time/packed, cors, proxy, ip_trust,
|
|
555
|
+
│ │ # rate_limit, terminal, output, ingress_constants
|
|
556
|
+
│ ├── test_support.rs # Shared test helpers
|
|
557
|
+
│ └── unit_tests.rs # Cross-module test suite
|
|
330
558
|
│
|
|
331
559
|
├── src/ # TypeScript source
|
|
332
|
-
│ ├── ingress/ # Ingress pipeline (TS layer)
|
|
333
|
-
│ │ ├── index.ts # Public API + async factory
|
|
334
|
-
│ │ ├── fast.ts #
|
|
335
|
-
│ │ ├── handlers.ts #
|
|
560
|
+
│ ├── ingress/ # Ingress pipeline (TS layer), decomposed by task
|
|
561
|
+
│ │ ├── index.ts # Public API barrel + async factory
|
|
562
|
+
│ │ ├── fast.ts # Thin: createIngressFast (packed input)
|
|
563
|
+
│ │ ├── handlers.ts # Thin: createIngressHandler (JS-packs frame → handleRequestPacked, pre-baked)
|
|
564
|
+
│ │ ├── server.ts # createIngressServer (Bun.serve builder)
|
|
565
|
+
│ │ ├── server-node.ts # createIngressServerNode (node:http adapter)
|
|
336
566
|
│ │ ├── constants.ts # Layout constants (from Rust)
|
|
337
|
-
│ │
|
|
338
|
-
│ ├──
|
|
339
|
-
│ │
|
|
567
|
+
│ │ ├── shared.ts # JS constants shared by both paths
|
|
568
|
+
│ │ ├── status.ts # Status normalization helpers
|
|
569
|
+
│ │ ├── errors.ts # Fast-path error-code mapping
|
|
570
|
+
│ │ ├── options.ts # IngressFast option types + validation
|
|
571
|
+
│ │ ├── types.ts # Public API types
|
|
572
|
+
│ │ ├── body.ts # Streaming request-body reader
|
|
573
|
+
│ │ ├── context.ts # Result snapshot + synthetic contexts
|
|
574
|
+
│ │ ├── packing/ # header-packing, input-packer, gather-raw-headers
|
|
575
|
+
│ │ ├── headers/ # cors, hsts, fast-templates, baked-templates
|
|
576
|
+
│ │ ├── decode/ # fast-result, baked-result
|
|
577
|
+
│ │ ├── response/ # terminal, error-bodies
|
|
578
|
+
│ │ └── routes/ # read/head/json-write/echo/delete/options/fallback factories
|
|
579
|
+
│ ├── native/ # Native transport: bun:ffi (primary on Bun) + NAPI fallback
|
|
580
|
+
│ │ ├── types.ts # NativeAddon interface + instance types
|
|
581
|
+
│ │ ├── loader.ts # getAddon/lazyAddon path resolution
|
|
582
|
+
│ │ ├── ffi.ts # bun:ffi C-ABI bindings (Bun-only; bind-time self-test)
|
|
583
|
+
│ │ └── index.ts # Barrel
|
|
340
584
|
│ ├── rust-ffi/ # Rust FFI bindings
|
|
341
|
-
│ │
|
|
585
|
+
│ │ ├── options.ts # RustOptions + input-normalization helpers
|
|
586
|
+
│ │ ├── addon.ts # shared lazy addon proxy
|
|
587
|
+
│ │ ├── context.ts # per-instance state + namespace helpers
|
|
588
|
+
│ │ ├── text.ts # string namespace
|
|
589
|
+
│ │ ├── batch.ts # array-of-bytes namespace
|
|
590
|
+
│ │ ├── packed.ts # raw packed-wire namespace
|
|
591
|
+
│ │ ├── scalar/ # scalar/feature methods (interface + per-domain builders)
|
|
592
|
+
│ │ ├── client.ts # createRust factory + default `rust`
|
|
593
|
+
│ │ ├── proven.ts # `proven` client (full surface, annotated)
|
|
594
|
+
│ │ └── index.ts # Barrel
|
|
342
595
|
│ ├── baseline/ # JS baseline implementations
|
|
343
596
|
│ │ ├── index.ts # Aggregator
|
|
344
597
|
│ │ └── tasks/ # Individual baseline implementations
|
|
@@ -353,6 +606,12 @@ castrum/
|
|
|
353
606
|
│
|
|
354
607
|
├── test/ # Tests (see CONTRIBUTING.md → Testing Requirements)
|
|
355
608
|
├── docs/ # Documentation
|
|
609
|
+
│ ├── REPO_MAP.md # ← START HERE: what is where & why
|
|
610
|
+
│ ├── GETTING_STARTED.md # Intern-friendly tutorial
|
|
611
|
+
│ ├── ARCHITECTURE.md # Deep-dive internals
|
|
612
|
+
│ ├── INGRESS.md # Pre-baked ingress API
|
|
613
|
+
│ ├── BENCHMARKS.md # Benchmark scenarios
|
|
614
|
+
│ └── ENVIRONMENT.md # Env vars
|
|
356
615
|
└── scripts/ # Build/utility scripts
|
|
357
616
|
```
|
|
358
617
|
|
|
@@ -365,7 +624,8 @@ All Rust FFI functions are designed for **zero-allocation** paths where possible
|
|
|
365
624
|
- **Ingress pipeline**: Single output buffer written by Rust, read by JS — minimal copies
|
|
366
625
|
- **Validation functions**: Pure CPU-bound, 10-100x faster than JS equivalents
|
|
367
626
|
- **Hashing**: SIMD-optimized crates under the hood
|
|
368
|
-
- **Batch operations**: Packed binary format minimizes
|
|
627
|
+
- **Batch operations**: Packed binary format minimizes boundary crossings
|
|
628
|
+
(the batch/packed namespaces use the NAPI fallback — no C-ABI batch symbols)
|
|
369
629
|
- **JSON parsing**: Uses `sonic-rs` (Rust) — one of the fastest JSON parsers available
|
|
370
630
|
|
|
371
631
|
---
|
|
@@ -397,9 +657,12 @@ Bump `version` in `package.json` (and `Cargo.toml`), add a `CHANGELOG.md` entry,
|
|
|
397
657
|
then:
|
|
398
658
|
|
|
399
659
|
```bash
|
|
400
|
-
git tag
|
|
660
|
+
git tag v<version> && git push origin v<version>
|
|
401
661
|
```
|
|
402
662
|
|
|
663
|
+
> Run `bun run check:version` first — it verifies `package.json`, `Cargo.toml`,
|
|
664
|
+
> and `CHANGELOG.md` agree. See [docs/REPO_MAP.md](./docs/REPO_MAP.md) §6.
|
|
665
|
+
|
|
403
666
|
### Manual
|
|
404
667
|
|
|
405
668
|
To build and publish a release by hand (no CI needed; npm logged in or `NPM_TOKEN`
|
|
Binary file
|