@genex-ai/multiplayer 0.17.0 → 0.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -581,7 +581,8 @@ not the page a friend should land on. Any parameter name works except the platfo
581
581
  (`embed`, `ref`, `s`, `from`, `remix`, `slug`, and everything starting `genex_`).
582
582
 
583
583
  `joinPrivate` rejects when the code does not lead to a lobby this player can reach: it has
584
- closed, or its host is on another region's relay than this player's nearest one. Catch it
584
+ closed, or (only where the regional relays do not share presence) its host is on another
585
+ region's relay than this player's nearest one. Catch it
585
586
  and show the menu. Keep the typed code as the second way in: a shell that does not pass the
586
587
  link's parameter on yet opens the game without it.
587
588
 
@@ -604,6 +605,269 @@ for (const e of JSON.parse(drainEventsJson(h))) { /* { type: 'playerJoined', id
604
605
  Pages with no bundler load `dist/bridge.global.js` (the SDK and embed-sdk bundled in), which sets
605
606
  `window.GenexMultiplayerBridge`. A JS game should use the SDK directly, not the bridge.
606
607
 
608
+ *(0.17.3, contract version 2)* A server-authoritative game (below) drives its room through the
609
+ bridge's authority calls instead — `openAuthority`, `setInput`, `predictProjectile` (your shot, drawn
610
+ the frame you fire it; `''` without a seat or while it reconnects), `setWorld` (a `firstperson` or
611
+ `vehicle` room's collision world, 0.17.4), `frame` (your predicted entity and every other one,
612
+ interpolated, as one JSON string per render frame), `authorityJson`,
613
+ `drainAuthorityEventsJson`, `closeAuthority`, `disposeAuthority` — from
614
+ `@genex-ai/multiplayer/bridge/authority`, or on a page from `dist/bridge-authority.global.js`, which is
615
+ `bridge.global.js` plus these calls (a relay game keeps loading the smaller script).
616
+
617
+ ```js
618
+ const h = GenexMultiplayerBridge.openAuthority(JSON.stringify({ gameId: 'my-game', urls: [] }));
619
+ // every render frame:
620
+ GenexMultiplayerBridge.setInput(h, JSON.stringify({ moveX: 1, moveY: 0, yaw: aim, buttons: 0 }));
621
+ const { me, entities } = JSON.parse(GenexMultiplayerBridge.frame(h));
622
+ for (const e of JSON.parse(GenexMultiplayerBridge.drainAuthorityEventsJson(h))) { /* joined, message, … */ }
623
+ ```
624
+
625
+ ## Server-authoritative rooms — `@genex-ai/multiplayer/authority` *(0.17.2; firstperson, vehicle and projectiles 0.17.4)*
626
+
627
+ Protocol 4 ([`PROTOCOL.md` §17](./PROTOCOL.md#17-authority-rooms-protocol-4)): the server runs the
628
+ game's own room module at a fixed tick, clients send typed input and render what the server writes.
629
+ Use it when cheating matters (competitive play); everything above is the relay and is unchanged by it.
630
+ The authority servers are not live yet; only a local development server serves these rooms.
631
+
632
+ ```ts
633
+ import { connectAuthority } from '@genex-ai/multiplayer/authority';
634
+ import * as genexEmbed from '@genex-ai/embed-sdk';
635
+
636
+ await genexEmbed.waitForPlayer();
637
+ const s = await connectAuthority({
638
+ urls: genexEmbed.getAuthorityUrls(), // the authority cluster, NOT getColyseusUrls()
639
+ regions: genexEmbed.getAuthorityRegions(), // its regions: the nearest is joined (PROTOCOL.md §17.1)
640
+ room: 'my-game',
641
+ auth: () => genexEmbed.getColyseusAuth(),
642
+ });
643
+
644
+ function loop(now: number) {
645
+ s.input.moveX = right - left; // [-1, 1]
646
+ s.input.moveY = up - down;
647
+ s.input.yaw = aimAngle; // radians
648
+ s.input.buttons = firing ? 1 : 0; // your bitmask, < 2^24
649
+ s.frame(now); // FIRST: sends due inputs, predicts, interpolates
650
+ const me = s.me(); // your entity, predicted (no input delay)
651
+ for (const e of s.entities().values()) draw(e.id, e.x, e.y, e.yaw); // others ~100 ms behind
652
+ requestAnimationFrame(loop);
653
+ }
654
+ requestAnimationFrame(loop);
655
+ s.on('hit', (data) => flash(data)); // events your room module emits
656
+ ```
657
+
658
+ - **Input, not state.** `s.input` is sent once per fixed step (the room's tick rate) while you call
659
+ `frame()`; nothing is sent while you do not. There is no heartbeat.
660
+ - **Prediction.** When the room runs a movement preset (`topdown`, `firstperson` or `vehicle`), your own entity
661
+ moves the instant you press, with the same step the server runs, and is corrected smoothly when the
662
+ server disagrees.
663
+ Pass `predict: { step, fields }` for your own step, or `predict: false` to render the server's state.
664
+ When your entity respawns (its `epoch` changes) it snaps to the server's new pose and keeps the
665
+ inputs still in flight, which the server applies to the new life, so moving through a death costs no
666
+ correction. `s.telemetry` counts `inputsSent`, `inputsDropped`, `reconciles`, `visibleCorrections`
667
+ (> 0.05 units), `peakCorrection` and `reseeds` (respawns of your entity and reconnects, never counted
668
+ as corrections). `s.prediction` says whether your entity is predicted right now, and why not
669
+ (`off`, `no_entity`, `no_preset`, `world_mismatch`).
670
+ - **`firstperson`** ([§17.7](./PROTOCOL.md#177-movement-presets)) is Source-engine movement through a
671
+ static world of boxes and ramps, z up: friction, ground and air acceleration (air-strafing), jump,
672
+ duck and crouch-jump, a free step up ledges, staying on the ground down slopes and stairs, sliding
673
+ along walls; a ramp too steep to stand on is a wall. Ask for `tickHz: 60`: air-strafing turns the
674
+ wish direction once per input, so at 30 Hz a perfect strafe gains less than half of what it does at
675
+ 60 (sub-steps do not change that). Send `moveX`/`moveY` in WORLD axes (turn your
676
+ camera-relative keys by your view yaw yourself; the step has no trigonometry), `yaw`/`pitch` as the
677
+ aim, and jump / duck / walk on `buttons` (1, 2, 4 by default). Give the session the module's world —
678
+ `connectAuthority({ …, world })` or `s.setWorld(world)` before the first `frame()` — the same
679
+ `{ boxes: [[minX, minY, minZ, maxX, maxY, maxZ], …], ramps: [[…, axis, lo, hi], …] }` the module
680
+ exports: it is validated (`WorldError` when §17.7 refuses it) and hashed, and your entity is
681
+ predicted only while that hash is the room's `params.world`. Otherwise it renders from the server's
682
+ state, and `s.prediction.reason` is `world_mismatch`. Entities then also carry `pitch` (within the
683
+ preset's ±`pitchLimit`), `duck` (0 standing .. 1 ducked; derive the eye height from it), `mstate`
684
+ (`MoveState` bits: 1 onGround, 2 ducking, 4 jumpHeld) and `cap` (the speed multiplier the module
685
+ writes: a ceiling on the speed, never a weaker push, so a wound cannot freeze a player), predicted
686
+ for yours and interpolated for others. `firstpersonWorld(spec)` validates and
687
+ hashes a world, and `stepFirstperson` is the step itself.
688
+ - **`vehicle`** ([§17.7](./PROTOCOL.md#177-movement-presets)) is an arcade car on Rocket League's
689
+ ground and air handling, kept level (no pitch or roll): throttle, braking and coasting, boost from a
690
+ fuel tank, grip that turns the velocity with the heading (`driftGrip` while drift is held, so the
691
+ tail slides), a turn rate that grows with speed up to `turnSpeed` and mirrors in reverse, a jump and
692
+ steering in the air. It drives the same hull through the same world as `firstperson` (walls clip
693
+ the velocity along them, nothing bounces), so it takes the same `world` / `setWorld` and predicts
694
+ only while the hashes match. Axes: x/y is the ground, z is up, and `yaw` is the heading,
695
+ counterclockwise seen from above (+yaw turns left; yaw 0 faces +x). The heading is the BODY's
696
+ state, not input: send `moveY` as the throttle (below 0 reverses) and `moveX` as the steer, where
697
+ **+1 turns right, toward −yaw** (your steer-right key is `moveX = +1`, as for any other right input),
698
+ and jump / boost / drift on `buttons` (1, 2, 4 by default); `yaw`, `pitch` and `moveZ` are unused.
699
+ Ask for `tickHz: 60`. Entities then also carry `spin` (the yaw rate, rad/s, positive toward +yaw:
700
+ for wheels and skid marks) and `fuel` (the tank, 0..1), predicted for yours and the server's latest
701
+ for others; your own heading draws along the short arc across ±π. The module writes `fuel` to
702
+ refuel (a boost pad, a respawn: `fuel: 1, spin: 0, mstate: 0` with an `epoch` bump) and `cap` to
703
+ scale both top speeds; the step only burns fuel. `stepVehicle` is the step itself. It turns the
704
+ heading with `psin` / `pcos`, §17.7's portable sine and cosine (exported here), which every engine
705
+ computes to the same bits; the platform's `Math.sin` / `Math.cos` differ in the last bit between
706
+ engines and would desynchronize prediction.
707
+ - **Entities** carry `kind`, `owner`, `x y z yaw` (rendered), `vx vy vz`, `hp`, `flags`, `epoch`,
708
+ `data` (JSON text), the projectile fields `t0`, `g`, `src`, the firstperson fields `pitch`,
709
+ `duck`, `mstate`, `cap` and the vehicle fields `spin`, `fuel`. A changed `epoch` is a teleport: it snaps, never glides. Another player's snap
710
+ lands when its render time (100 ms behind) reaches the bump, the time lag compensation judges a shot
711
+ at; until then it is drawn where it was, and `epoch` reports the life drawn.
712
+ - **Projectiles** ([§17.9](./PROTOCOL.md#179-projectiles)) are entities with a launch tick `t0 > 0`,
713
+ a gravity `g` and their shooter's seat `src` (`''` for a server one). Each is drawn from its
714
+ closed-form flight (`projectileAt`), never interpolated: another player's at the server's present,
715
+ not 100 ms behind. Fire your own with `s.predictProjectile({ kind, x, y, z, vx, vy, vz, g })` while
716
+ staging the input that fires: it is drawn that same frame under the id the room module's launch
717
+ gets (`<seat>.<seq>.<n>`), keeps that id when the server's projectile confirms it, and flies on
718
+ from where the prediction was; one the server re-launches as its own (a bounce) is drawn at the
719
+ server's present from then on. One no state ever shows (refused, or removed by a hit before a patch
720
+ carried it) is dropped as soon as the server acknowledges the input that fired it, one whose input
721
+ a drop lost at the reconnect's first state (the next shot reuses its id), and at the latest after
722
+ max(2·RTT, 600 ms). While the socket is down (`reconnecting` until `reconnected`) it predicts
723
+ nothing and answers `''`: no input goes out then. Every client in a room that launches projectiles must implement §17.9: an
724
+ older one draws each projectile frozen at its launch point.
725
+ A projectile is never `me()`. `s.telemetry` counts `projectilesPredicted`, `projectilesConfirmed`,
726
+ `projectilesRejected` and the latest `lastProjectileLeadMs`. The module side is
727
+ `ctx.projectiles.launch / at / sweep`.
728
+ - **Physics rooms** ([§17.10](./PROTOCOL.md#1710-physics)): the server simulates rigid bodies and
729
+ this session draws them; nothing on the client steps physics. Every join sends the `physics@1`
730
+ capability, which a physics room requires (426 without it). A body entity (one with a descriptor in
731
+ the state's `bodies`) comes out of `entities()` with `body: true`, its drawn orientation in
732
+ `qx qy qz qw` (z up) and the server's `sleep` flag; its `x y z` are the server's present, not
733
+ 100 ms behind: the latest state (the body at the patch's tick + 1) extrapolated on the room clock
734
+ by its velocities (at most 250 ms, back a tick at most before its tick, not at all while asleep), and a newer state eased in from where
735
+ the older one was drawn (the offset decaying as e^(−age / 100 ms)). An `epoch` bump, a correction
736
+ over one metre or a body's first state snaps. A body out of this client's view is still listed,
737
+ held at its last state, until its descriptor goes. `vx vy vz` on a body are its linear velocity in
738
+ room units/s. `s.telemetry` counts `bodyStates`, `bodySnaps` and `peakBodyCorrection`. The drawing
739
+ is `physics-draw.ts` (`bodyStateOf`, `extrapolateBody`, `receiveBodyState`, `drawBody`), held to
740
+ the netcode bench's `physics-draw.json` vectors. A body entity is never `me()`. The module side is
741
+ `config.physics` and `ctx.physics` (commands, queries answered next tick, events, `body(key)` for
742
+ subscribed kinds); the wire and the reference tables are `wire.ts` and `physics.ts`. No Genex server
743
+ runs physics until the pinned core ships. `s.setPhysics(hash)` (or `connectAuthority({ …, physics })`)
744
+ names a physics world your client runs a copy of; `s.physics.reason` is `physics_mismatch` while the
745
+ room's hash differs.
746
+ - **Physics prediction** ([§17.11](./PROTOCOL.md#1711-physics-prediction)): give the session the core,
747
+ `connectAuthority({ …, world, physicsCore: async () => { const p = await import('@genex-ai/physics');
748
+ await p.init(); return p; } })`, and in a predicting room (the default for physics rooms) it steps the
749
+ bodies near you in a world of its own and draws them at your present (`predicted: true` on their
750
+ view); the core loads only once the room sends keyframes, and `wasmSimdSupported()` says when to pass
751
+ the non-SIMD module instead. `predictPhysics: false` turns it off; `s.physicsPrediction` reports
752
+ `status` (`on`, `off`, `loading`, `unavailable`, `mismatch`, `self_test`, `diverged`, `cpu`), how
753
+ keyframes were adopted, the slowest and latest replay and how many held a 60 Hz frame back
754
+ (`peakResimMs`, `lastResimMs`, `slowResims`), and how often the replay budget fell back to `cpu` and came back
755
+ (`fallbacks`, `recoveries`: `cpu` probes a replay every 500 ms and turns prediction on again after
756
+ four calm ones). The budget is the device's (4 ms desktop, 5 mobile); `physicsBudgetMs` overrides it
757
+ when it is a finite number over 0 (benches and CI on a slow host; a game leaves it unset). Remote players move by their velocity or, in a room asking
758
+ `remotePredict: 'inputDecay'`, by their last input repeated with decay (`'hold'`: held). Module commands are not
759
+ predicted, except the rules the module writes as pure functions and hands the client too:
760
+ `contacts(a, b)`, a seat contact rule (a car-to-car bump) run in your own entity's prediction
761
+ against the other seats, and `bodyRule({ tick, seats, body })`, a body rule (a scripted hit on a
762
+ ball) run before every step of the client world, and `kinematicRule(key, tick)`, where the module
763
+ poses its kinematic bodies (a door, a moving floor) at each tick, so the client world moves them as
764
+ the room does and draws them as predicted bodies (and by the rule on the server's timeline whenever
765
+ it does not predict them: no core, `cpu`); the module must run the same functions. Held to
766
+ the netcode bench's `physics-predict.json` (tolerance 0 on the client stateHash) and
767
+ `seat-contacts.json`. With `contacts`, `seatDrawing` chooses how other seats are drawn:
768
+ `predicted` (default, the Rocket League model) on your car's clock, every other seat stepped by the
769
+ room preset under its last input (`remotePredict: 'hold'` holds it) with the module's whole contact
770
+ pass (`contactPass`, else `contacts` on every pair) run on every seat and yours, each correction
771
+ eased in as a decaying offset that keeps the drawn velocity and never pulls a seat back against its
772
+ motion (`drawTick()`, `drawLead()` say where they were drawn); `separate` on
773
+ the render timeline but moved out of your drawn entity by the rule and taken round it, never across
774
+ it (draw-only); `timeline` on the render timeline only (a seat that missed yours on the server can
775
+ be drawn through it). `predicted` and `separate` keep every seat out of yours and out of each other
776
+ ([§17.11 *Drawing other seats*](./PROTOCOL.md#1711-physics-prediction)). A room whose module
777
+ declares `config.contacts` (the universal contact policy: a genre preset such as `shooter` or
778
+ `vehicle-arena`, a contact shape and per-pair rules by tag, §17.11 *Contact policy*) needs no
779
+ `contacts` or `contactPass`: given neither, the session runs the declared policy the state carries
780
+ as both, in your prediction, the seat prediction, the physics replay and the drawing guard. The
781
+ authority host runs the same pass on the server after each tick's preset steps and before the
782
+ module's `step`, so the module writes no contact code at all (and must not run a pass of its own).
783
+ - **Input repeat** is a room setting ([§17.11 *Input pacing*](./PROTOCOL.md#1711-physics-prediction),
784
+ *The setting*): `config.inputs: { preset?, repeat? }`, `repeat` (`'on'` or `'off'`) as given, else
785
+ the genre preset's (`inputs.preset`, else `config.contacts.preset`), else on; `vehicle-arena` is off,
786
+ `shooter`, `platformer`, `party` and `physics-sandbox` on (`INPUT_PRESETS`). On, a starved seat's
787
+ held input is repeated and the session compares your prediction at
788
+ `reconcileThrough(held, ack, inRep, sent)` (`ack + inRep` once sent; past the inputs sent, a hitch,
789
+ it waits at the input it compared at last) and throttles after a starve; off, the room holds a starved seat
790
+ and the session reads the plain ack and never throttles. The session reads the room's setting from
791
+ the state's preset text (`presetInputRepeat`); a bad `config.inputs` is refused by its path.
792
+ - **Reconnection** is automatic and in place, as for `connect()`; `reconnecting`, `reconnected` and
793
+ `disconnect` fire as there. An input that came due while the socket was down is dropped, never
794
+ replayed. `leave()` during an outage still leaves: it stops reconnecting, and a retry already under
795
+ way leaves the moment it lands.
796
+ - **The fast channel** ([§17.12](./PROTOCOL.md#1712-the-fast-channel-fast1)), Node clients for now
797
+ (bots, the netcode bench, tests): `connectAuthority({ …, fast: openUdpFastSocket })` with
798
+ `openUdpFastSocket` from `@genex-ai/multiplayer/authority/fast-udp` asks for `fast@1`. In a room that
799
+ offers it your inputs go by UDP datagram, each one in several (a lost datagram costs nothing), and the
800
+ room's motion comes back as per-seat ack-delta snapshots written into the same state a patch writes,
801
+ so prediction, reconcile and interpolation run unchanged; a WebSocket held up by a lost TCP segment
802
+ no longer holds them. Join, events and the rest of the state stay on the WebSocket. With no snapshot
803
+ 1 s after the first datagram or 2 s after the last, a binding it cannot use, or the server's
804
+ `__fast:off`, the seat goes on by WebSocket for the rest of the socket, every input sent stepped
805
+ once and every motion field at the WebSocket's latest value. `s.fast` says `live`, `snapshots` and `fallback` (its reason). The browser's WebTransport
806
+ opener comes later; a room without the channel plays exactly as before.
807
+ - **Lag compensation.** Every input tells the server what you saw: other entities `renderDelayMs`
808
+ (default 100) in the past, whether or not your own entity is predicted.
809
+ - **Every server message** also reaches `onEvent(type, data)` when you pass it, in order and with no
810
+ name withheld (`on(type)` skips the session's own event names) — for a facade forwarding them all.
811
+ - **Refusals** reject `connectAuthority` with the server's error; branch on `err.code` (`JoinError`):
812
+ 402 the game's owner is not entitled to authority rooms (final; not retried), 424 no room module is
813
+ published, 429 the owner's room quota refused a new room, 426 this package is too old for the
814
+ server (update and republish). 503 (no capacity) is retried after 1, 2 and 4 s (`capacityRetryMs`).
815
+
816
+ A game's server code is one module typed by `@genex-ai/multiplayer/authority/module`, which has no
817
+ dependencies so it can be bundled alone:
818
+
819
+ ```ts
820
+ import { defineRoom } from '@genex-ai/multiplayer/authority/module';
821
+
822
+ export default defineRoom({
823
+ contract: 1,
824
+ config: { tickHz: 30, preset: { name: 'topdown' } },
825
+ init: () => ({}),
826
+ onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'player', owner: player.id }); },
827
+ onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
828
+ onInput: () => {},
829
+ step: () => {},
830
+ });
831
+ ```
832
+
833
+ A `firstperson` room exports its collision world beside its config; the server validates it at init
834
+ and writes its hash into the resolved preset's `params.world`, and the game client passes the same
835
+ world to `setWorld`:
836
+
837
+ ```ts
838
+ import { defineRoom } from '@genex-ai/multiplayer/authority/module';
839
+ import world from './world.json'; // { boxes: [[…6], …], ramps: [[…9], …] }, shared with the client
840
+
841
+ export default defineRoom({
842
+ contract: 1,
843
+ config: { tickHz: 60, preset: { name: 'firstperson' } },
844
+ world,
845
+ init: () => ({}),
846
+ onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'player', owner: player.id, z: 0 }); },
847
+ onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
848
+ onInput: () => {},
849
+ step: () => {},
850
+ });
851
+ ```
852
+
853
+ A `vehicle` room exports its world the same way, and refuels on a respawn:
854
+
855
+ ```ts
856
+ import { defineRoom } from '@genex-ai/multiplayer/authority/module';
857
+ import world from './arena.json'; // the floor, walls and ramps, shared with the client
858
+
859
+ export default defineRoom({
860
+ contract: 1,
861
+ config: { tickHz: 60, preset: { name: 'vehicle' } },
862
+ world,
863
+ init: () => ({}),
864
+ onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'car', owner: player.id, z: 0, fuel: 1 }); },
865
+ onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
866
+ onInput: () => {},
867
+ step: () => {},
868
+ });
869
+ ```
870
+
607
871
  ## Rules of thumb
608
872
 
609
873
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
@@ -0,0 +1,13 @@
1
+ import { F as FastSocketOpener } from '../fast-client-CZ41v-oB.js';
2
+ import '../module-DqtFb5wA.js';
3
+
4
+ /** The host (an IPv6 host unbracketed) and port of a `udp://host:port` url, else null: the url is
5
+ * server-supplied, so anything more than a host and a port in 1..65535 is refused. */
6
+ declare function udpEndpoint(url: string): {
7
+ host: string;
8
+ port: number;
9
+ } | null;
10
+ /** A {@link FastSocketOpener} for `udp://` urls on node:dgram; null for any other url. */
11
+ declare const openUdpFastSocket: FastSocketOpener;
12
+
13
+ export { openUdpFastSocket, udpEndpoint };
@@ -0,0 +1,52 @@
1
+ // src/authority/fast-udp.ts
2
+ import { createSocket } from "dgram";
3
+ var UDP_SCHEME = "udp:";
4
+ var PORT_MAX = 65535;
5
+ var CONNECT_QUEUE_MAX = 32;
6
+ var BARE_PATHS = ["", "/"];
7
+ var onlyHostAndPort = (u) => u.username === "" && u.password === "" && BARE_PATHS.includes(u.pathname) && u.search === "" && u.hash === "";
8
+ function udpEndpoint(url) {
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(url);
12
+ } catch {
13
+ return null;
14
+ }
15
+ const port = Number(parsed.port);
16
+ const portOk = Number.isInteger(port) && port >= 1 && port <= PORT_MAX;
17
+ if (parsed.protocol !== UDP_SCHEME || !portOk || parsed.hostname === "" || !onlyHostAndPort(parsed)) return null;
18
+ return { host: parsed.hostname.replace(/^\[(.*)\]$/, "$1"), port };
19
+ }
20
+ var openUdpFastSocket = (url, receive) => {
21
+ const to = udpEndpoint(url);
22
+ if (to === null) return null;
23
+ const socket = createSocket(to.host.includes(":") ? "udp6" : "udp4");
24
+ let connected = false;
25
+ let closed = false;
26
+ let waiting = [];
27
+ socket.on("message", (msg) => receive(new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength)));
28
+ socket.on("error", () => {
29
+ });
30
+ socket.connect(to.port, to.host, () => {
31
+ connected = true;
32
+ if (!closed) for (const bytes of waiting) socket.send(bytes);
33
+ waiting = [];
34
+ });
35
+ const fast = {
36
+ send(bytes) {
37
+ if (closed) return;
38
+ if (connected) socket.send(bytes);
39
+ else waiting = [...waiting, bytes].slice(-CONNECT_QUEUE_MAX);
40
+ },
41
+ close() {
42
+ if (closed) return;
43
+ closed = true;
44
+ socket.close();
45
+ }
46
+ };
47
+ return fast;
48
+ };
49
+ export {
50
+ openUdpFastSocket,
51
+ udpEndpoint
52
+ };
@@ -0,0 +1,45 @@
1
+ import { a as FastBindMessage, F as FastSocketOpener } from '../fast-client-CZ41v-oB.js';
2
+ import '../module-DqtFb5wA.js';
3
+
4
+ /** Hex digits of one certificate hash (SHA-256). */
5
+ declare const FAST_WT_HASH_HEX_DIGITS = 64;
6
+ /** Hashes one binding may carry (the relay sends its current and next certificate's). */
7
+ declare const FAST_WT_HASHES_MAX = 4;
8
+ /** A `wt` binding this client can open: its url and the certificate hashes as bytes. */
9
+ interface FastWebTransportTarget {
10
+ url: string;
11
+ hashes: Uint8Array[];
12
+ }
13
+ /** The WebTransport `wt` of a binding this client can use, else null: an `https://` url with a host and
14
+ * no credentials, query or fragment, and 1..{@link FAST_WT_HASHES_MAX} hashes of
15
+ * {@link FAST_WT_HASH_HEX_DIGITS} lowercase hex digits. The binding is server-supplied, so anything
16
+ * else is refused. */
17
+ declare function fastWebTransportOf(bind: Pick<FastBindMessage, 'wt'>): FastWebTransportTarget | null;
18
+ /** The part of the browser's `WebTransport` the opener uses. */
19
+ interface WebTransportLike {
20
+ readonly ready: Promise<unknown>;
21
+ readonly closed: Promise<unknown>;
22
+ /** `pending`, `reliable-only` or `supports-unreliable` where the browser exposes it. */
23
+ readonly reliability?: string;
24
+ readonly datagrams: {
25
+ readonly readable: ReadableStream<Uint8Array>;
26
+ readonly writable: WritableStream<Uint8Array>;
27
+ readonly maxDatagramSize?: number;
28
+ };
29
+ close(info?: {
30
+ closeCode?: number;
31
+ reason?: string;
32
+ }): void;
33
+ }
34
+ /** `new WebTransport(url, options)`. */
35
+ type WebTransportConstructor = new (url: string, options: Record<string, unknown>) => WebTransportLike;
36
+ /** The options a fast@1 session opens with. */
37
+ declare function webTransportOptions(target: FastWebTransportTarget): Record<string, unknown>;
38
+ /** A {@link FastSocketOpener} on `WebTransport` (the browser's, or a stand-in in tests): null when the
39
+ * binding has no usable `wt` or there is no WebTransport. */
40
+ declare function webTransportFastOpener(WebTransport: WebTransportConstructor | undefined): FastSocketOpener;
41
+ /** The browser's opener: the global `WebTransport` (null where there is none, so the seat stays on the
42
+ * WebSocket). */
43
+ declare const openWebTransportFastSocket: FastSocketOpener;
44
+
45
+ export { FAST_WT_HASHES_MAX, FAST_WT_HASH_HEX_DIGITS, type FastWebTransportTarget, type WebTransportConstructor, type WebTransportLike, fastWebTransportOf, openWebTransportFastSocket, webTransportFastOpener, webTransportOptions };
@@ -0,0 +1,124 @@
1
+ import {
2
+ fromHex
3
+ } from "../chunk-5JGRFT2N.js";
4
+ import "../chunk-K3SF5VDS.js";
5
+ import "../chunk-ZSWF4VCN.js";
6
+
7
+ // src/authority/fast-webtransport.ts
8
+ var FAST_WT_HASH_HEX_DIGITS = 64;
9
+ var FAST_WT_HASHES_MAX = 4;
10
+ var WT_SCHEME = "https:";
11
+ var CONNECT_QUEUE_MAX = 32;
12
+ var HASH_PATTERN = /^[0-9a-f]+$/;
13
+ var RELIABLE_ONLY = "reliable-only";
14
+ function fastWebTransportOf(bind) {
15
+ const wt = bind.wt;
16
+ if (typeof wt !== "object" || wt === null) return null;
17
+ const { url, hashes } = wt;
18
+ if (typeof url !== "string" || !Array.isArray(hashes)) return null;
19
+ if (hashes.length === 0 || hashes.length > FAST_WT_HASHES_MAX) return null;
20
+ const hashOk = (h) => typeof h === "string" && h.length === FAST_WT_HASH_HEX_DIGITS && HASH_PATTERN.test(h);
21
+ if (!hashes.every(hashOk)) return null;
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(url);
25
+ } catch {
26
+ return null;
27
+ }
28
+ const bare = parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "";
29
+ if (parsed.protocol !== WT_SCHEME || parsed.hostname === "" || !bare) return null;
30
+ return { url, hashes: hashes.map((h) => fromHex(h)) };
31
+ }
32
+ function webTransportOptions(target) {
33
+ return {
34
+ serverCertificateHashes: target.hashes.map((value) => ({ algorithm: "sha-256", value })),
35
+ requireUnreliable: true,
36
+ allowPooling: false,
37
+ congestionControl: "low-latency"
38
+ };
39
+ }
40
+ var carriesFast = (t) => t.reliability !== RELIABLE_ONLY;
41
+ var SocketState = { Connecting: "connecting", Open: "open", Failed: "failed", Closed: "closed" };
42
+ function webTransportFastOpener(WebTransport) {
43
+ return (_url, receive, bind) => {
44
+ const target = bind === void 0 ? null : fastWebTransportOf(bind);
45
+ if (target === null || WebTransport === void 0) return null;
46
+ let t;
47
+ try {
48
+ t = new WebTransport(target.url, webTransportOptions(target));
49
+ } catch {
50
+ return null;
51
+ }
52
+ let state = SocketState.Connecting;
53
+ let waiting = [];
54
+ let writer = null;
55
+ const fail = () => {
56
+ if (state === SocketState.Closed || state === SocketState.Failed) return;
57
+ state = SocketState.Failed;
58
+ writer = null;
59
+ waiting = [];
60
+ try {
61
+ t.close();
62
+ } catch {
63
+ }
64
+ };
65
+ const write = (w, bytes) => void w.write(bytes).catch(() => {
66
+ });
67
+ const readAll = async (reader) => {
68
+ for (; ; ) {
69
+ const r = await reader.read();
70
+ if (r.done) return;
71
+ if (state === SocketState.Open) receive(r.value);
72
+ }
73
+ };
74
+ t.ready.then(
75
+ () => {
76
+ if (state !== SocketState.Connecting) return;
77
+ if (!carriesFast(t)) return fail();
78
+ state = SocketState.Open;
79
+ const w = t.datagrams.writable.getWriter();
80
+ writer = w;
81
+ for (const bytes of waiting) write(w, bytes);
82
+ waiting = [];
83
+ readAll(t.datagrams.readable.getReader()).then(fail, fail);
84
+ },
85
+ () => fail()
86
+ );
87
+ t.closed.then(fail, fail);
88
+ const socket = {
89
+ send(bytes) {
90
+ if (state === SocketState.Open && writer !== null) write(writer, bytes);
91
+ else if (state === SocketState.Connecting) waiting = [...waiting, bytes].slice(-CONNECT_QUEUE_MAX);
92
+ },
93
+ close() {
94
+ if (state === SocketState.Closed) return;
95
+ const was = state;
96
+ state = SocketState.Closed;
97
+ writer = null;
98
+ waiting = [];
99
+ if (was === SocketState.Failed) return;
100
+ try {
101
+ t.close();
102
+ } catch {
103
+ }
104
+ },
105
+ failed: () => state === SocketState.Failed,
106
+ // The browser's outgoing limit, known once the session is open: the browser sends only uplinks,
107
+ // so the seat's largest uplink must fit (Chromium reports 1024; a 120 Hz seat's is 889).
108
+ maxDatagramBytes() {
109
+ const size = state === SocketState.Open ? t.datagrams.maxDatagramSize : void 0;
110
+ return typeof size === "number" ? size : void 0;
111
+ }
112
+ };
113
+ return socket;
114
+ };
115
+ }
116
+ var openWebTransportFastSocket = (url, receive, bind) => webTransportFastOpener(globalThis.WebTransport)(url, receive, bind);
117
+ export {
118
+ FAST_WT_HASHES_MAX,
119
+ FAST_WT_HASH_HEX_DIGITS,
120
+ fastWebTransportOf,
121
+ openWebTransportFastSocket,
122
+ webTransportFastOpener,
123
+ webTransportOptions
124
+ };