onelobby 0.4.1 → 0.6.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/README.md +2 -2
- package/blueprint.schema.json +244 -0
- package/dist/blueprint.d.ts +170 -0
- package/dist/blueprint.js +286 -0
- package/dist/blueprint.js.map +1 -0
- package/dist/codec.d.ts +23 -0
- package/dist/codec.js +28 -0
- package/dist/codec.js.map +1 -1
- package/dist/direct/browser-host.d.ts +46 -0
- package/dist/direct/browser-host.js +93 -0
- package/dist/direct/browser-host.js.map +1 -0
- package/dist/direct/hub-host.d.ts +34 -23
- package/dist/direct/hub-host.js +75 -59
- package/dist/direct/hub-host.js.map +1 -1
- package/dist/direct/hub-local.d.ts +22 -0
- package/dist/direct/hub-local.js +46 -0
- package/dist/direct/hub-local.js.map +1 -0
- package/dist/direct/hub.d.ts +37 -10
- package/dist/direct/hub.js +114 -23
- package/dist/direct/hub.js.map +1 -1
- package/dist/direct/index.d.ts +37 -4
- package/dist/direct/index.js +162 -21
- package/dist/direct/index.js.map +1 -1
- package/dist/direct/metrics.d.ts +12 -0
- package/dist/direct/metrics.js +45 -0
- package/dist/direct/metrics.js.map +1 -0
- package/dist/direct/portmap.d.ts +9 -2
- package/dist/direct/portmap.js +7 -2
- package/dist/direct/portmap.js.map +1 -1
- package/dist/direct/sha1.d.ts +16 -0
- package/dist/direct/sha1.js +111 -0
- package/dist/direct/sha1.js.map +1 -0
- package/dist/direct-address.d.ts +2 -2
- package/dist/direct-address.js.map +1 -1
- package/dist/friendly.d.ts +28 -0
- package/dist/friendly.js +40 -0
- package/dist/friendly.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/internals.d.ts +9 -0
- package/dist/internals.js +9 -0
- package/dist/internals.js.map +1 -1
- package/dist/join-code-kind.d.ts +10 -0
- package/dist/join-code-kind.js +10 -0
- package/dist/join-code-kind.js.map +1 -0
- package/dist/joincode.d.ts +10 -1
- package/dist/joincode.js +23 -3
- package/dist/joincode.js.map +1 -1
- package/dist/lobby.d.ts +65 -4
- package/dist/lobby.js +151 -12
- package/dist/lobby.js.map +1 -1
- package/dist/lockstep.d.ts +8 -0
- package/dist/lockstep.js +23 -2
- package/dist/lockstep.js.map +1 -1
- package/dist/nostr/crypto.d.ts +20 -0
- package/dist/nostr/crypto.js +78 -0
- package/dist/nostr/crypto.js.map +1 -0
- package/dist/nostr/host.d.ts +66 -0
- package/dist/nostr/host.js +196 -0
- package/dist/nostr/host.js.map +1 -0
- package/dist/nostr/limits.d.ts +31 -0
- package/dist/nostr/limits.js +36 -0
- package/dist/nostr/limits.js.map +1 -0
- package/dist/nostr/link.d.ts +70 -0
- package/dist/nostr/link.js +180 -0
- package/dist/nostr/link.js.map +1 -0
- package/dist/nostr/relays.d.ts +79 -0
- package/dist/nostr/relays.js +315 -0
- package/dist/nostr/relays.js.map +1 -0
- package/dist/nostr/room.d.ts +43 -0
- package/dist/nostr/room.js +104 -0
- package/dist/nostr/room.js.map +1 -0
- package/dist/nostr/secp256k1.d.ts +10 -0
- package/dist/nostr/secp256k1.js +171 -0
- package/dist/nostr/secp256k1.js.map +1 -0
- package/dist/nostr/socket.d.ts +48 -0
- package/dist/nostr/socket.js +141 -0
- package/dist/nostr/socket.js.map +1 -0
- package/dist/onelobby.bundle.js +146 -0
- package/dist/onelobby.bundle.js.map +7 -0
- package/dist/onelobby.bundle.mjs +11 -5
- package/dist/onelobby.bundle.mjs.map +4 -4
- package/dist/onelobby.d.ts +54 -4
- package/dist/onelobby.global.js +2932 -49
- package/dist/onelobby.global.js.map +4 -4
- package/dist/onelobby.global.min.js +11 -5
- package/dist/onelobby.global.min.js.map +4 -4
- package/dist/onelobby.js +158 -15
- package/dist/onelobby.js.map +1 -1
- package/dist/pause.d.ts +85 -0
- package/dist/pause.js +160 -0
- package/dist/pause.js.map +1 -0
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js.map +1 -1
- package/dist/webrtc.d.ts +1 -0
- package/dist/webrtc.js +6 -1
- package/dist/webrtc.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { encodeRoomCode } from "../joincode.js";
|
|
2
|
+
import { NostrHostBridge } from "../nostr/host.js";
|
|
3
|
+
import { HOST_NOSTR_FIRST_RELAY_MS } from "../nostr/limits.js";
|
|
4
|
+
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_STUN } from "../nostr/relays.js";
|
|
5
|
+
import { OneLobbyError } from "../signaling.js";
|
|
6
|
+
import { SignalingHub, directHubOptions } from "./hub.js";
|
|
7
|
+
import { LocalHubSocket } from "./hub-local.js";
|
|
8
|
+
/**
|
|
9
|
+
* The startup line of a browser host, same family as `formatHostLog`:
|
|
10
|
+
* `OneLobby: browser host (no port); code 7HQ-4K2-... (Nostr); nostr 4/5 relays`.
|
|
11
|
+
*/
|
|
12
|
+
export function formatBrowserHostLog(code, relays) {
|
|
13
|
+
return `OneLobby: browser host (no port); code ${code} (Nostr); nostr ${relays} relays`;
|
|
14
|
+
}
|
|
15
|
+
export async function hostNostr(kit, opts = {}) {
|
|
16
|
+
const bp = kit.blueprint;
|
|
17
|
+
if (bp && !bp.hosting.nostr) {
|
|
18
|
+
throw new OneLobbyError("nostr_disabled", "this game has Nostr signaling turned off (hosting.nostr is false), and a browser host has no other way to be reached");
|
|
19
|
+
}
|
|
20
|
+
const defined = Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined));
|
|
21
|
+
const o = bp
|
|
22
|
+
? {
|
|
23
|
+
maxPlayers: bp.lobby.maxPlayers,
|
|
24
|
+
lateJoin: bp.lobby.lateJoin,
|
|
25
|
+
meta: { ...bp.lobby.meta },
|
|
26
|
+
...(bp.session.rateLimit ? { rateLimit: { ...bp.session.rateLimit } } : {}),
|
|
27
|
+
...defined,
|
|
28
|
+
nostr: { ...defined.nostr, relays: defined.nostr?.relays ?? [...bp.hosting.nostrRelays] },
|
|
29
|
+
}
|
|
30
|
+
: defined;
|
|
31
|
+
const maxPlayers = o.maxPlayers ?? 4;
|
|
32
|
+
const relays = o.nostr?.relays && o.nostr.relays.length > 0 ? o.nostr.relays : [...DEFAULT_NOSTR_RELAYS];
|
|
33
|
+
const stunUrls = o.nostr?.stun ?? [...DEFAULT_NOSTR_STUN];
|
|
34
|
+
const hub = new SignalingHub({ ...directHubOptions(maxPlayers, o.password), stunUrls });
|
|
35
|
+
const ticker = setInterval(() => hub.tick(), 1000);
|
|
36
|
+
ticker.unref?.();
|
|
37
|
+
const code = encodeRoomCode();
|
|
38
|
+
const env = kit.nostrHostEnv();
|
|
39
|
+
let bridge;
|
|
40
|
+
const teardown = () => {
|
|
41
|
+
clearInterval(ticker);
|
|
42
|
+
bridge?.close();
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
try {
|
|
46
|
+
bridge = await NostrHostBridge.start(hub, { code, relays, connect: env.connect, clock: env.clock, wall: env.wall, maxPlayers });
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new OneLobbyError("direct_unreachable", `cannot reach any Nostr relay (${e instanceof Error ? e.message : String(e)}); a browser host has no other way to be reached`);
|
|
50
|
+
}
|
|
51
|
+
if (!(await bridge.settle(o.relayTimeoutMs ?? HOST_NOSTR_FIRST_RELAY_MS))) {
|
|
52
|
+
throw new OneLobbyError("direct_unreachable", `no Nostr relay reachable (tried ${bridge.relaysTotal}); a browser host has no IP fallback, check the connection or pass other relays`);
|
|
53
|
+
}
|
|
54
|
+
const name = o.name ?? `${kit.options.displayName || "Player"}'s game`;
|
|
55
|
+
const h = { name, maxPlayers, lateJoin: o.lateJoin ?? true, meta: o.meta, rateLimit: o.rateLimit };
|
|
56
|
+
// joiners have no address to dial: links use the hub's STUN list, no port pin
|
|
57
|
+
const lobby = await kit.hostDirectWith(h, { socketFactory: () => new LocalHubSocket(hub), wrapLinks: (inner) => inner });
|
|
58
|
+
lobby.linkAddressSink = (peerId, ip) => hub.reportLinkAddress(peerId, ip);
|
|
59
|
+
lobby.on("sessionEnded", teardown);
|
|
60
|
+
lobby.joinCode = code;
|
|
61
|
+
lobby.joinCodeKind = "nostr";
|
|
62
|
+
lobby.directCode = "";
|
|
63
|
+
lobby.directCodeKind = "none";
|
|
64
|
+
lobby.portMapping = "off";
|
|
65
|
+
lobby.directHostLog = formatBrowserHostLog(code, `${bridge.relaysConnected}/${bridge.relaysTotal}`);
|
|
66
|
+
watchVisibility(lobby, o);
|
|
67
|
+
if (o.log !== false)
|
|
68
|
+
(o.log ?? console.log)(lobby.directHostLog);
|
|
69
|
+
return lobby;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
teardown();
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Sets `lobby.hostHidden` and warns when the tab goes hidden: browsers throttle timers there (about 1/s, ~1/min after 5 min). */
|
|
77
|
+
function watchVisibility(lobby, o) {
|
|
78
|
+
const doc = o.document !== undefined ? o.document : globalThis.document;
|
|
79
|
+
if (!doc || typeof doc.addEventListener !== "function")
|
|
80
|
+
return;
|
|
81
|
+
const warn = o.warn === false ? null : (o.warn ?? ((s) => console.warn(s)));
|
|
82
|
+
const check = () => {
|
|
83
|
+
const hidden = doc.visibilityState === "hidden";
|
|
84
|
+
const changed = hidden !== lobby.hostHidden;
|
|
85
|
+
lobby.setHostHidden(hidden);
|
|
86
|
+
if (changed && hidden)
|
|
87
|
+
warn?.("OneLobby: the host tab is hidden; the browser slows its timers, so players may lag or drop. Keep this tab visible while hosting.");
|
|
88
|
+
};
|
|
89
|
+
doc.addEventListener("visibilitychange", check);
|
|
90
|
+
lobby.on("sessionEnded", () => doc.removeEventListener("visibilitychange", check));
|
|
91
|
+
check();
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=browser-host.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-host.js","sourceRoot":"","sources":["../../src/direct/browser-host.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `kit.hostNostr()`: host a direct lobby from a browser tab (or any runtime with WebSocket + WebRTC), with no Node process\n * and no listening port. The signaling hub runs inside the page, the host's own socket is wired to it in memory, and\n * joiners reach it through public Nostr relays with a normal dc1 version 1 code (spec/nostr-signaling.md). There is no IP\n * fallback: with no relay reachable it rejects with `direct_unreachable`. Nothing here imports a Node module.\n */\nimport type { HostOptions, OneLobby } from \"../onelobby.js\";\nimport type { Lobby, RateLimit } from \"../lobby.js\";\nimport { encodeRoomCode } from \"../joincode.js\";\nimport { NostrHostBridge } from \"../nostr/host.js\";\nimport { HOST_NOSTR_FIRST_RELAY_MS } from \"../nostr/limits.js\";\nimport { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_STUN } from \"../nostr/relays.js\";\nimport { OneLobbyError } from \"../signaling.js\";\nimport { SignalingHub, directHubOptions } from \"./hub.js\";\nimport { LocalHubSocket } from \"./hub-local.js\";\n\n/** The slice of `document` the visibility watcher uses (a test seam; default `globalThis.document`). */\nexport interface VisibilityDoc {\n visibilityState?: string;\n addEventListener(type: \"visibilitychange\", listener: () => void): void;\n removeEventListener(type: \"visibilitychange\", listener: () => void): void;\n}\n\nexport interface BrowserHostOptions {\n /** Optional; joiners must send the same one or get `bad_password`. */\n password?: string;\n maxPlayers?: number;\n /** Lobby name; default \"<displayName>'s game\". */\n name?: string;\n lateJoin?: boolean;\n meta?: Record<string, string>;\n /** Per-joiner cap on incoming raw messages, e.g. `{ messages: 60, perMs: 1000 }`; the excess is dropped. */\n rateLimit?: RateLimit;\n /** Relays (default: the blueprint's `hosting.nostrRelays`, else the library list) and STUN servers (default Google's and Cloudflare's). */\n nostr?: { relays?: string[]; stun?: string[] };\n /** Receives the startup line (`lobby.directHostLog`). Default `console.log`; `false` prints nothing. */\n log?: ((line: string) => void) | false;\n /** Receives the warning printed when the host tab goes hidden. Default `console.warn`; `false` prints nothing. */\n warn?: ((line: string) => void) | false;\n /** How long to wait for a first relay before rejecting with `direct_unreachable` (default 10000). */\n relayTimeoutMs?: number;\n /** Test seam: the document whose `visibilityState` is watched. Default `globalThis.document`. */\n document?: VisibilityDoc | null;\n}\n\n/** A browser host lobby: the normal Lobby (`hostHidden` / `hostHiddenChanged` tell when the tab is throttled). */\nexport type BrowserHostLobby = Lobby;\n\n/**\n * The startup line of a browser host, same family as `formatHostLog`:\n * `OneLobby: browser host (no port); code 7HQ-4K2-... (Nostr); nostr 4/5 relays`.\n */\nexport function formatBrowserHostLog(code: string, relays: string): string {\n return `OneLobby: browser host (no port); code ${code} (Nostr); nostr ${relays} relays`;\n}\n\nexport async function hostNostr(kit: OneLobby, opts: BrowserHostOptions = {}): Promise<BrowserHostLobby> {\n const bp = kit.blueprint;\n if (bp && !bp.hosting.nostr) {\n throw new OneLobbyError(\"nostr_disabled\", \"this game has Nostr signaling turned off (hosting.nostr is false), and a browser host has no other way to be reached\");\n }\n const defined = Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined)) as BrowserHostOptions;\n const o: BrowserHostOptions = bp\n ? {\n maxPlayers: bp.lobby.maxPlayers,\n lateJoin: bp.lobby.lateJoin,\n meta: { ...bp.lobby.meta },\n ...(bp.session.rateLimit ? { rateLimit: { ...bp.session.rateLimit } } : {}),\n ...defined,\n nostr: { ...defined.nostr, relays: defined.nostr?.relays ?? [...bp.hosting.nostrRelays] },\n }\n : defined;\n const maxPlayers = o.maxPlayers ?? 4;\n const relays = o.nostr?.relays && o.nostr.relays.length > 0 ? o.nostr.relays : [...DEFAULT_NOSTR_RELAYS];\n const stunUrls = o.nostr?.stun ?? [...DEFAULT_NOSTR_STUN];\n\n const hub = new SignalingHub({ ...directHubOptions(maxPlayers, o.password), stunUrls });\n const ticker = setInterval(() => hub.tick(), 1000);\n (ticker as { unref?: () => void }).unref?.();\n const code = encodeRoomCode();\n const env = kit.nostrHostEnv();\n let bridge: NostrHostBridge | undefined;\n const teardown = () => {\n clearInterval(ticker);\n bridge?.close();\n };\n try {\n try {\n bridge = await NostrHostBridge.start(hub, { code, relays, connect: env.connect, clock: env.clock, wall: env.wall, maxPlayers });\n } catch (e) {\n throw new OneLobbyError(\"direct_unreachable\", `cannot reach any Nostr relay (${e instanceof Error ? e.message : String(e)}); a browser host has no other way to be reached`);\n }\n if (!(await bridge.settle(o.relayTimeoutMs ?? HOST_NOSTR_FIRST_RELAY_MS))) {\n throw new OneLobbyError(\"direct_unreachable\", `no Nostr relay reachable (tried ${bridge.relaysTotal}); a browser host has no IP fallback, check the connection or pass other relays`);\n }\n\n const name = o.name ?? `${kit.options.displayName || \"Player\"}'s game`;\n const h: HostOptions = { name, maxPlayers, lateJoin: o.lateJoin ?? true, meta: o.meta, rateLimit: o.rateLimit };\n // joiners have no address to dial: links use the hub's STUN list, no port pin\n const lobby = await kit.hostDirectWith(h, { socketFactory: () => new LocalHubSocket(hub), wrapLinks: (inner) => inner });\n lobby.linkAddressSink = (peerId, ip) => hub.reportLinkAddress(peerId, ip);\n lobby.on(\"sessionEnded\", teardown);\n\n lobby.joinCode = code;\n lobby.joinCodeKind = \"nostr\";\n lobby.directCode = \"\";\n lobby.directCodeKind = \"none\";\n lobby.portMapping = \"off\";\n lobby.directHostLog = formatBrowserHostLog(code, `${bridge.relaysConnected}/${bridge.relaysTotal}`);\n watchVisibility(lobby, o);\n if (o.log !== false) (o.log ?? console.log)(lobby.directHostLog);\n return lobby;\n } catch (e) {\n teardown();\n throw e;\n }\n}\n\n/** Sets `lobby.hostHidden` and warns when the tab goes hidden: browsers throttle timers there (about 1/s, ~1/min after 5 min). */\nfunction watchVisibility(lobby: Lobby, o: BrowserHostOptions): void {\n const doc: VisibilityDoc | null | undefined = o.document !== undefined ? o.document : (globalThis as { document?: VisibilityDoc }).document;\n if (!doc || typeof doc.addEventListener !== \"function\") return;\n const warn = o.warn === false ? null : (o.warn ?? ((s: string) => console.warn(s)));\n const check = () => {\n const hidden = doc.visibilityState === \"hidden\";\n const changed = hidden !== lobby.hostHidden;\n lobby.setHostHidden(hidden);\n if (changed && hidden) warn?.(\"OneLobby: the host tab is hidden; the browser slows its timers, so players may lag or drop. Keep this tab visible while hosting.\");\n };\n doc.addEventListener(\"visibilitychange\", check);\n lobby.on(\"sessionEnded\", () => doc.removeEventListener(\"visibilitychange\", check));\n check();\n}\n"]}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { SocketLike } from "../transport.js";
|
|
2
|
+
import type { IpCodeKind, JoinCodeKind } from "../join-code-kind.js";
|
|
2
3
|
import { SignalingHub, type HubDeps, type HubOptions } from "./hub.js";
|
|
4
|
+
import { LocalHubSocket } from "./hub-local.js";
|
|
3
5
|
import { PortMapper, type PortMapperOptions, type PortMapResult } from "./portmap.js";
|
|
6
|
+
import type { NostrHostBridge } from "../nostr/host.js";
|
|
7
|
+
/** A joiner socket with more than this much unsent data is closed (1008 "backpressure"); spec/signaling.md. */
|
|
8
|
+
export declare const MAX_PENDING_WRITE_BYTES: number;
|
|
4
9
|
/**
|
|
5
10
|
* Whether a WebSocket handshake carrying `origin` is accepted. A web page can open a WebSocket to any address it can
|
|
6
11
|
* reach, so a browser's Origin must be one we expect: in `allowed` ("*" = all), the same host:port as the request's
|
|
@@ -8,6 +13,8 @@ import { PortMapper, type PortMapperOptions, type PortMapResult } from "./portma
|
|
|
8
13
|
* no Origin header is a native client and never gets here.
|
|
9
14
|
*/
|
|
10
15
|
export declare function originAllowed(origin: string, host: string | undefined, allowed: readonly string[]): boolean;
|
|
16
|
+
/** Whether `ip` (as the socket reports it) is 127.0.0.0/8, ::1 or an IPv4-mapped loopback address. */
|
|
17
|
+
export declare function isLoopback(ip: string): boolean;
|
|
11
18
|
export declare class PortInUseError extends Error {
|
|
12
19
|
readonly code = "port_in_use";
|
|
13
20
|
/** Windows reserved the port (EACCES: an excluded port range, often from Hyper-V, WSL or Docker); not another program. */
|
|
@@ -18,9 +25,10 @@ export declare class PortInUseError extends Error {
|
|
|
18
25
|
export declare function portFailureHint(anyBusy: boolean, anyReserved: boolean): string;
|
|
19
26
|
/**
|
|
20
27
|
* Runs a {@link SignalingHub} inside a Node process and listens for joiners' signaling WebSockets on TCP `port`: the
|
|
21
|
-
* Node twin of C#'s `DirectHubHost` (docs/direct-ip.md s.2, s.5 step 3). A plain `net` server with a minimal HTTP/1.1
|
|
28
|
+
* Node twin of C#'s `DirectHubHost` (docs/design/direct-ip.md s.2, s.5 step 3). A plain `net` server with a minimal HTTP/1.1
|
|
22
29
|
* upgrade and RFC 6455 framing (text, close, ping/pong, fragments; client frames must be masked), so the package keeps
|
|
23
|
-
* zero runtime dependencies. Any path upgrades; `GET /healthz` answers `ok
|
|
30
|
+
* zero runtime dependencies. Any path upgrades; `GET /healthz` answers `ok` and `GET /metrics` the hub's Prometheus text
|
|
31
|
+
* (loopback callers only), as onelobby-server does, ahead of the `http` callback. The host's own client talks to the hub in
|
|
24
32
|
* memory through {@link connectLocal}. `hub.tick()` runs once a second on an unref'd timer.
|
|
25
33
|
*/
|
|
26
34
|
/** One plain-HTTP answer from the host's port, e.g. a browser join page. */
|
|
@@ -30,6 +38,23 @@ export interface HttpReply {
|
|
|
30
38
|
/** Content-Type, e.g. "text/html; charset=utf-8". */
|
|
31
39
|
type: string;
|
|
32
40
|
body: string | Uint8Array;
|
|
41
|
+
/**
|
|
42
|
+
* Extra response headers, e.g. `{ "Cache-Control": "max-age=3600" }` for a static asset (default `no-store`). The
|
|
43
|
+
* hub owns Content-Type, Content-Length and Connection; a name or value with a line break answers 500.
|
|
44
|
+
*/
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/** `GET /joininfo` on a direct host: what a page served from the host shows as its share link (`directJoinInfo()`). */
|
|
48
|
+
export interface DirectJoinInfo {
|
|
49
|
+
/** What to hand out: the Nostr code (version 1) by default, else the IP code. */
|
|
50
|
+
joinCode: string;
|
|
51
|
+
joinCodeKind: JoinCodeKind;
|
|
52
|
+
/** The IP code (address and port); equals `joinCode` when Nostr is off. */
|
|
53
|
+
directCode: string;
|
|
54
|
+
directCodeKind: IpCodeKind;
|
|
55
|
+
/** With `http`: `http://<the code's address>:<port>/`; else null. */
|
|
56
|
+
joinUrl: string | null;
|
|
57
|
+
port: number;
|
|
33
58
|
}
|
|
34
59
|
/** Serves plain `GET` requests on the direct port (path without the query); null answers 404. */
|
|
35
60
|
export type HttpHandler = (path: string) => HttpReply | null;
|
|
@@ -45,9 +70,13 @@ export declare class DirectHubHost {
|
|
|
45
70
|
* clients), the host's own page (Origin equal to the request's Host) and localhost pages are always allowed.
|
|
46
71
|
*/
|
|
47
72
|
allowedOrigins: string[];
|
|
73
|
+
/** Served as JSON at `GET /joininfo` (404 while null); `hostDirect` keeps it current as the join code changes. */
|
|
74
|
+
joinInfo: DirectJoinInfo | null;
|
|
48
75
|
/** The bound TCP port (the requested one, or the one the OS picked for 0). */
|
|
49
76
|
readonly port: number;
|
|
50
77
|
portMapper: PortMapper | null;
|
|
78
|
+
/** Nostr signaling for joiners that cannot reach the port (spec/nostr-signaling.md); set by `hostDirect`. */
|
|
79
|
+
nostr: NostrHostBridge | null;
|
|
51
80
|
private readonly server;
|
|
52
81
|
private readonly sockets;
|
|
53
82
|
private readonly ticker;
|
|
@@ -68,33 +97,15 @@ export declare class DirectHubHost {
|
|
|
68
97
|
* kit as `advanced.socketFactory: () => host.connectLocal()`.
|
|
69
98
|
*/
|
|
70
99
|
connectLocal(): SocketLike;
|
|
71
|
-
/** Asks the home router to forward TCP and UDP `port` here (UPnP, then NAT-PMP; docs/direct-ip.md s.2a). */
|
|
100
|
+
/** Asks the home router to forward TCP and UDP `port` here (UPnP, then NAT-PMP; docs/design/direct-ip.md s.2a). */
|
|
72
101
|
openPort(options?: PortMapperOptions): Promise<PortMapResult>;
|
|
73
102
|
/** Stops listening, closes every joiner socket and removes the router mapping. Every call gets the same promise. */
|
|
74
103
|
close(): Promise<void>;
|
|
75
104
|
private closing?;
|
|
76
105
|
private closeOnce;
|
|
77
106
|
private accept;
|
|
78
|
-
/** RFC 6455 s.4.2: answers 101 with Sec-WebSocket-Accept, or 200 for /healthz, or 400/426. */
|
|
107
|
+
/** RFC 6455 s.4.2: answers 101 with Sec-WebSocket-Accept, or 200 for /healthz and /metrics (loopback only), or 400/426. */
|
|
79
108
|
private handshake;
|
|
80
109
|
private run;
|
|
81
110
|
}
|
|
82
|
-
|
|
83
|
-
* The direct-IP host's own signaling socket (C#'s `HubSignalingSocket`): sends go straight into the hub; the hub's
|
|
84
|
-
* replies are delivered on a microtask, so callbacks never run inside the hub.
|
|
85
|
-
*/
|
|
86
|
-
export declare class LocalHubSocket implements SocketLike {
|
|
87
|
-
private readonly hub;
|
|
88
|
-
/** Address the hub sees for the host itself (rate limits and kicks are per address). */
|
|
89
|
-
static readonly ADDRESS = "local";
|
|
90
|
-
readyState: number;
|
|
91
|
-
onopen: SocketLike["onopen"];
|
|
92
|
-
onmessage: SocketLike["onmessage"];
|
|
93
|
-
onclose: SocketLike["onclose"];
|
|
94
|
-
onerror: SocketLike["onerror"];
|
|
95
|
-
private session;
|
|
96
|
-
constructor(hub: SignalingHub);
|
|
97
|
-
send(data: string): void;
|
|
98
|
-
close(): void;
|
|
99
|
-
private finish;
|
|
100
|
-
}
|
|
111
|
+
export { LocalHubSocket };
|
package/dist/direct/hub-host.js
CHANGED
|
@@ -3,10 +3,14 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { createSocket } from "node:dgram";
|
|
4
4
|
import { createServer } from "node:net";
|
|
5
5
|
import { SignalingHub, directHubOptions } from "./hub.js";
|
|
6
|
+
import { LocalHubSocket } from "./hub-local.js";
|
|
6
7
|
import { PortMapper } from "./portmap.js";
|
|
7
8
|
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
8
9
|
const MAX_HEADER_BYTES = 8192;
|
|
9
10
|
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
11
|
+
/** A joiner socket with more than this much unsent data is closed (1008 "backpressure"); spec/signaling.md. */
|
|
12
|
+
export const MAX_PENDING_WRITE_BYTES = 1024 * 1024;
|
|
13
|
+
const BACKPRESSURE_GRACE_MS = 3000;
|
|
10
14
|
const STATUS_TEXT = { 200: "OK", 301: "Moved Permanently", 302: "Found", 404: "Not Found", 500: "Internal Server Error" };
|
|
11
15
|
/**
|
|
12
16
|
* Whether a WebSocket handshake carrying `origin` is accepted. A web page can open a WebSocket to any address it can
|
|
@@ -30,6 +34,11 @@ export function originAllowed(origin, host, allowed) {
|
|
|
30
34
|
return true;
|
|
31
35
|
return host !== undefined && u.host.toLowerCase() === host.trim().toLowerCase();
|
|
32
36
|
}
|
|
37
|
+
/** Whether `ip` (as the socket reports it) is 127.0.0.0/8, ::1 or an IPv4-mapped loopback address. */
|
|
38
|
+
export function isLoopback(ip) {
|
|
39
|
+
const a = ip.replace(/^::ffff:/i, "");
|
|
40
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a) || a === "::1";
|
|
41
|
+
}
|
|
33
42
|
/**
|
|
34
43
|
* Rejects (EADDRINUSE) when another program holds IPv4 TCP or UDP `port`. Windows lets the dual-stack listener bind
|
|
35
44
|
* over a program on 0.0.0.0 (every IPv4 joiner would then reach that program), and the peer links need UDP `port`
|
|
@@ -68,6 +77,20 @@ export function portFailureHint(anyBusy, anyReserved) {
|
|
|
68
77
|
return "Windows has reserved it (an excluded port range, often from Hyper-V, WSL or Docker); pick another port";
|
|
69
78
|
return "each one is busy or reserved by Windows (an excluded port range); is another copy of the game hosting?";
|
|
70
79
|
}
|
|
80
|
+
const HUB_OWNED_HEADERS = new Set(["content-type", "content-length", "connection", "transfer-encoding"]);
|
|
81
|
+
function replyHeaders(extra) {
|
|
82
|
+
const h = new Map([
|
|
83
|
+
["cache-control", ["Cache-Control", "no-store"]],
|
|
84
|
+
["x-content-type-options", ["X-Content-Type-Options", "nosniff"]],
|
|
85
|
+
]);
|
|
86
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
87
|
+
if (/[\r\n:]/.test(k) || /[\r\n]/.test(String(v)) || !k)
|
|
88
|
+
throw new Error("bad header");
|
|
89
|
+
if (!HUB_OWNED_HEADERS.has(k.toLowerCase()))
|
|
90
|
+
h.set(k.toLowerCase(), [k, String(v)]);
|
|
91
|
+
}
|
|
92
|
+
return [...h.values()].map(([k, v]) => `${k}: ${v}\r\n`).join("");
|
|
93
|
+
}
|
|
71
94
|
export class DirectHubHost {
|
|
72
95
|
hub;
|
|
73
96
|
/**
|
|
@@ -80,9 +103,13 @@ export class DirectHubHost {
|
|
|
80
103
|
* clients), the host's own page (Origin equal to the request's Host) and localhost pages are always allowed.
|
|
81
104
|
*/
|
|
82
105
|
allowedOrigins = [];
|
|
106
|
+
/** Served as JSON at `GET /joininfo` (404 while null); `hostDirect` keeps it current as the join code changes. */
|
|
107
|
+
joinInfo = null;
|
|
83
108
|
/** The bound TCP port (the requested one, or the one the OS picked for 0). */
|
|
84
109
|
port;
|
|
85
110
|
portMapper = null;
|
|
111
|
+
/** Nostr signaling for joiners that cannot reach the port (spec/nostr-signaling.md); set by `hostDirect`. */
|
|
112
|
+
nostr = null;
|
|
86
113
|
server;
|
|
87
114
|
sockets = new Set();
|
|
88
115
|
ticker;
|
|
@@ -146,7 +173,7 @@ export class DirectHubHost {
|
|
|
146
173
|
connectLocal() {
|
|
147
174
|
return new LocalHubSocket(this.hub);
|
|
148
175
|
}
|
|
149
|
-
/** Asks the home router to forward TCP and UDP `port` here (UPnP, then NAT-PMP; docs/direct-ip.md s.2a). */
|
|
176
|
+
/** Asks the home router to forward TCP and UDP `port` here (UPnP, then NAT-PMP; docs/design/direct-ip.md s.2a). */
|
|
150
177
|
openPort(options) {
|
|
151
178
|
if (this.portMapper)
|
|
152
179
|
throw new Error("the port is already being opened");
|
|
@@ -161,6 +188,7 @@ export class DirectHubHost {
|
|
|
161
188
|
async closeOnce() {
|
|
162
189
|
this.closed = true;
|
|
163
190
|
clearInterval(this.ticker);
|
|
191
|
+
this.nostr?.close();
|
|
164
192
|
const unmap = this.portMapper?.close();
|
|
165
193
|
for (const s of this.sockets)
|
|
166
194
|
s.end(closeFrame(1001, "host left"));
|
|
@@ -192,14 +220,14 @@ export class DirectHubHost {
|
|
|
192
220
|
clearTimeout(timer);
|
|
193
221
|
sock.off("data", onData);
|
|
194
222
|
const rest = head.subarray(end + 4);
|
|
195
|
-
if (!this.handshake(sock, head.subarray(0, end).toString("latin1")))
|
|
223
|
+
if (!this.handshake(sock, ip, head.subarray(0, end).toString("latin1")))
|
|
196
224
|
return;
|
|
197
225
|
this.run(sock, ip, rest);
|
|
198
226
|
};
|
|
199
227
|
sock.on("data", onData);
|
|
200
228
|
}
|
|
201
|
-
/** RFC 6455 s.4.2: answers 101 with Sec-WebSocket-Accept, or 200 for /healthz, or 400/426. */
|
|
202
|
-
handshake(sock, head) {
|
|
229
|
+
/** RFC 6455 s.4.2: answers 101 with Sec-WebSocket-Accept, or 200 for /healthz and /metrics (loopback only), or 400/426. */
|
|
230
|
+
handshake(sock, ip, head) {
|
|
203
231
|
const [requestLine, ...lines] = head.split("\r\n");
|
|
204
232
|
const headers = new Map();
|
|
205
233
|
for (const l of lines) {
|
|
@@ -208,26 +236,45 @@ export class DirectHubHost {
|
|
|
208
236
|
headers.set(l.slice(0, i).trim().toLowerCase(), l.slice(i + 1).trim());
|
|
209
237
|
}
|
|
210
238
|
const [method, path] = requestLine.split(" ");
|
|
211
|
-
const reply = (status, body) => sock.end(`HTTP/1.1 ${status}\r\nContent-Type:
|
|
239
|
+
const reply = (status, body, type = "text/plain") => sock.end(`HTTP/1.1 ${status}\r\nContent-Type: ${type}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
|
|
240
|
+
// The same two endpoints as onelobby-server; /metrics stays local because this port faces the internet.
|
|
212
241
|
if (method === "GET" && path === "/healthz") {
|
|
213
|
-
reply("200 OK", "ok");
|
|
242
|
+
reply("200 OK", "ok\n");
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (method === "GET" && path === "/metrics") {
|
|
246
|
+
if (isLoopback(ip))
|
|
247
|
+
reply("200 OK", this.hub.metrics.render(), "text/plain; version=0.0.4");
|
|
248
|
+
else
|
|
249
|
+
reply("403 Forbidden", "metrics are local only (OneLobby direct host)");
|
|
214
250
|
return false;
|
|
215
251
|
}
|
|
216
252
|
const key = headers.get("sec-websocket-key");
|
|
217
253
|
const upgrade = /websocket/i.test(headers.get("upgrade") ?? "");
|
|
254
|
+
// The share link for a page the host serves: a player who opened it at a LAN address still shares the public one.
|
|
255
|
+
if (method === "GET" && !upgrade && path?.split("?")[0] === "/joininfo") {
|
|
256
|
+
if (this.joinInfo)
|
|
257
|
+
reply("200 OK", JSON.stringify(this.joinInfo), "application/json");
|
|
258
|
+
else
|
|
259
|
+
reply("404 Not Found", "no join code yet");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
218
262
|
if (method === "GET" && !upgrade && this.http && path) {
|
|
219
263
|
let page = null;
|
|
264
|
+
let extra = "";
|
|
220
265
|
try {
|
|
221
266
|
page = this.http(path.split("?")[0]);
|
|
267
|
+
extra = replyHeaders(page?.headers);
|
|
222
268
|
}
|
|
223
269
|
catch {
|
|
224
270
|
page = { status: 500, type: "text/plain", body: "error" };
|
|
271
|
+
extra = replyHeaders(undefined);
|
|
225
272
|
}
|
|
226
273
|
const p = page ?? { status: 404, type: "text/plain", body: "not found" };
|
|
227
274
|
const body = typeof p.body === "string" ? Buffer.from(p.body, "utf8") : Buffer.from(p.body);
|
|
228
275
|
const status = p.status ?? 200;
|
|
229
276
|
sock.write(`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? "OK"}\r\nContent-Type: ${p.type}\r\n` +
|
|
230
|
-
`Content-Length: ${body.length}\r\
|
|
277
|
+
`Content-Length: ${body.length}\r\n${extra}Connection: close\r\n\r\n`);
|
|
231
278
|
sock.end(body);
|
|
232
279
|
return false;
|
|
233
280
|
}
|
|
@@ -246,19 +293,32 @@ export class DirectHubHost {
|
|
|
246
293
|
}
|
|
247
294
|
run(sock, ip, initial) {
|
|
248
295
|
let open = true;
|
|
249
|
-
|
|
250
|
-
if (open && !sock.destroyed)
|
|
251
|
-
sock.write(frame(0x1, Buffer.from(text, "utf8")));
|
|
252
|
-
};
|
|
296
|
+
let session = null;
|
|
253
297
|
const shut = (code, reason) => {
|
|
254
298
|
if (!open)
|
|
255
299
|
return;
|
|
256
300
|
open = false;
|
|
257
|
-
if (
|
|
301
|
+
if (sock.destroyed)
|
|
302
|
+
return;
|
|
303
|
+
// a reader that stopped reading never drains its buffer: give the close frame a moment, then cut it off
|
|
304
|
+
if (code === 1008 && reason === "backpressure") {
|
|
305
|
+
sock.write(closeFrame(code, reason));
|
|
306
|
+
setTimeout(() => sock.destroy(), BACKPRESSURE_GRACE_MS).unref?.();
|
|
307
|
+
if (session)
|
|
308
|
+
this.hub.disconnect(session);
|
|
309
|
+
}
|
|
310
|
+
else
|
|
258
311
|
sock.end(closeFrame(code, reason));
|
|
259
312
|
};
|
|
260
|
-
|
|
261
|
-
|
|
313
|
+
const sendText = (text) => {
|
|
314
|
+
if (!open || sock.destroyed)
|
|
315
|
+
return;
|
|
316
|
+
sock.write(frame(0x1, Buffer.from(text, "utf8")));
|
|
317
|
+
// a slow or malicious reader must not grow this process's memory without bound
|
|
318
|
+
if (sock.writableLength > MAX_PENDING_WRITE_BYTES)
|
|
319
|
+
shut(1008, "backpressure");
|
|
320
|
+
};
|
|
321
|
+
session = this.hub.connect(ip, sendText, (code, reason) => shut(code ?? 1000, reason ?? "closed by the hub"));
|
|
262
322
|
if (!session)
|
|
263
323
|
return;
|
|
264
324
|
const s = session;
|
|
@@ -414,49 +474,5 @@ class FrameReader {
|
|
|
414
474
|
this.sink.fail(code, why);
|
|
415
475
|
}
|
|
416
476
|
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* The direct-IP host's own signaling socket (C#'s `HubSignalingSocket`): sends go straight into the hub; the hub's
|
|
420
|
-
* replies are delivered on a microtask, so callbacks never run inside the hub.
|
|
421
|
-
*/
|
|
422
|
-
export class LocalHubSocket {
|
|
423
|
-
hub;
|
|
424
|
-
/** Address the hub sees for the host itself (rate limits and kicks are per address). */
|
|
425
|
-
static ADDRESS = "local";
|
|
426
|
-
readyState = 0;
|
|
427
|
-
onopen = null;
|
|
428
|
-
onmessage = null;
|
|
429
|
-
onclose = null;
|
|
430
|
-
onerror = null;
|
|
431
|
-
session;
|
|
432
|
-
constructor(hub) {
|
|
433
|
-
this.hub = hub;
|
|
434
|
-
this.session = hub.connect(LocalHubSocket.ADDRESS, (text) => queueMicrotask(() => this.readyState === 1 && this.onmessage?.({ data: text })), () => this.finish());
|
|
435
|
-
queueMicrotask(() => {
|
|
436
|
-
if (!this.session) {
|
|
437
|
-
this.finish();
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
this.readyState = 1;
|
|
441
|
-
this.onopen?.();
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
send(data) {
|
|
445
|
-
if (this.readyState === 1 && this.session)
|
|
446
|
-
this.hub.receive(this.session, data);
|
|
447
|
-
}
|
|
448
|
-
close() {
|
|
449
|
-
const s = this.session;
|
|
450
|
-
const wasOpen = this.readyState === 1;
|
|
451
|
-
this.finish();
|
|
452
|
-
if (wasOpen && s)
|
|
453
|
-
this.hub.disconnect(s);
|
|
454
|
-
}
|
|
455
|
-
finish() {
|
|
456
|
-
if (this.readyState === 3)
|
|
457
|
-
return;
|
|
458
|
-
this.readyState = 3;
|
|
459
|
-
queueMicrotask(() => this.onclose?.());
|
|
460
|
-
}
|
|
461
|
-
}
|
|
477
|
+
export { LocalHubSocket };
|
|
462
478
|
//# sourceMappingURL=hub-host.js.map
|