dsh-dispatch-relay 0.1.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 +119 -0
- package/dist/index.js +601 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-dispatch 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
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# dsh-dispatch-relay
|
|
2
|
+
|
|
3
|
+
Zero-knowledge WebSocket router for [dsh-dispatch](../../README.md). It connects a dsh
|
|
4
|
+
machine (the plugin) to a phone (the PWA), forwards end-to-end encrypted frames between
|
|
5
|
+
them, and — when no phone is connected — fans the machine's notification out over Web Push.
|
|
6
|
+
|
|
7
|
+
It never sees a key or a plaintext. Implements [docs/PROTOCOL.md](../../docs/PROTOCOL.md) v1.
|
|
8
|
+
|
|
9
|
+
## Run it
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# node (from a checkout)
|
|
13
|
+
pnpm --filter dsh-dispatch-relay build
|
|
14
|
+
node packages/relay/dist/index.js
|
|
15
|
+
|
|
16
|
+
# npx (published)
|
|
17
|
+
npx dsh-dispatch-relay
|
|
18
|
+
|
|
19
|
+
# docker (build context is the repo root)
|
|
20
|
+
docker build -f packages/relay/Dockerfile -t dsh-dispatch-relay .
|
|
21
|
+
docker run -p 8787:8787 -v dsh-relay-data:/data dsh-dispatch-relay
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Behind a TLS terminator (Caddy/nginx/Cloudflare), proxy `/ws` with upgrade headers and point
|
|
25
|
+
the plugin at `wss://your-host/ws`. Phones require `wss://` — browsers refuse Web Push and
|
|
26
|
+
service workers on plaintext origins.
|
|
27
|
+
|
|
28
|
+
Health check: `GET /healthz` → `200 {"ok":true}`.
|
|
29
|
+
|
|
30
|
+
## Environment
|
|
31
|
+
|
|
32
|
+
| Variable | Default | Meaning |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `PORT` | `8787` | HTTP + WebSocket port. Invalid values abort startup rather than silently falling back. |
|
|
35
|
+
| `DATA_DIR` | `./relay-data` | Directory for `push-subs.json`, the only thing the relay persists. |
|
|
36
|
+
| `VAPID_PUBLIC_KEY` | — | Web Push VAPID public key. |
|
|
37
|
+
| `VAPID_PRIVATE_KEY` | — | Web Push VAPID private key. |
|
|
38
|
+
| `VAPID_SUBJECT` | — | `mailto:you@example.com` or an `https://` contact URL. |
|
|
39
|
+
| `LOG_LEVEL` | `info` | `silent` \| `error` \| `warn` \| `info`. |
|
|
40
|
+
|
|
41
|
+
All three VAPID values are required to enable push. With any of them missing the relay logs
|
|
42
|
+
one line — `web push disabled: VAPID env not set` — and keeps serving everything else;
|
|
43
|
+
`GET /vapid` then answers `404 {"error":"push-disabled"}` so the PWA can say "push
|
|
44
|
+
unavailable on this relay" instead of failing an opaque subscribe call.
|
|
45
|
+
|
|
46
|
+
Generate a key pair with `npx web-push generate-vapid-keys`.
|
|
47
|
+
|
|
48
|
+
## Endpoints
|
|
49
|
+
|
|
50
|
+
| Route | Response |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `GET /healthz` | `200 {"ok":true}` |
|
|
53
|
+
| `GET /vapid` | `200 {"publicKey":"…"}`, or `404 {"error":"push-disabled"}` |
|
|
54
|
+
| `GET /ws` | `426 {"error":"upgrade-required"}` — this path is the WebSocket endpoint |
|
|
55
|
+
|
|
56
|
+
## Wire behaviour
|
|
57
|
+
|
|
58
|
+
- The **first** frame on a socket must be a valid `hello`; anything else gets
|
|
59
|
+
`{"kind":"error","code":"bad-hello"}` and the socket is closed (1008).
|
|
60
|
+
- After that the socket is never closed for a bad frame. Unknown kinds and malformed frames
|
|
61
|
+
get `{"kind":"error","code":"bad-frame"}` and the connection stays up — forward
|
|
62
|
+
compatibility beats strictness here.
|
|
63
|
+
- `msg` frames are forwarded **verbatim** (the original bytes, unknown fields included) to
|
|
64
|
+
every socket of the *other* role in the room. Frames larger than 16KB are rejected with
|
|
65
|
+
`{"kind":"error","code":"too-large"}` and are not forwarded.
|
|
66
|
+
- One socket can `hello` into many rooms — that is how a phone controls N machines over a
|
|
67
|
+
single connection. Joining the same room twice with different roles is refused.
|
|
68
|
+
- `presence` goes to the other role on join, and on the leave of the *last* socket of a role
|
|
69
|
+
(a second phone disconnecting does not report the phone as offline).
|
|
70
|
+
- WebSocket ping every 25s; a socket that misses the following pong is terminated.
|
|
71
|
+
|
|
72
|
+
### Push
|
|
73
|
+
|
|
74
|
+
A machine `msg` carrying a `push` hint triggers Web Push **only when the room has zero phone
|
|
75
|
+
sockets**. The relay sends the machine-supplied ciphertext with `TTL: 60` and, when the tag
|
|
76
|
+
is a valid RFC 8030 topic (≤32 chars of `[A-Za-z0-9_-]`), collapses notifications by it.
|
|
77
|
+
Subscriptions reported as `404`/`410` are pruned from the store.
|
|
78
|
+
|
|
79
|
+
Push failures are reported back to the machine rather than swallowed, so the plugin can
|
|
80
|
+
surface them (PRODUCT.md "失败可见"):
|
|
81
|
+
|
|
82
|
+
| Code | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `push-disabled` | this relay has no VAPID keys configured |
|
|
85
|
+
| `push-no-subscribers` | no phone online and no stored subscription for the room |
|
|
86
|
+
| `push-failed` | every delivery attempt failed or was expired (counts in `message`) |
|
|
87
|
+
|
|
88
|
+
## Security note
|
|
89
|
+
|
|
90
|
+
The relay is **zero-knowledge for content**. Pairing secrets are exchanged out of band (QR /
|
|
91
|
+
pairing code) between plugin and phone; the relay never receives one, so every `payload` it
|
|
92
|
+
routes is an opaque `base64(nonce ‖ secretbox)` blob it cannot open — including push payloads,
|
|
93
|
+
which are handed to the push service still encrypted. It links no crypto library at all.
|
|
94
|
+
|
|
95
|
+
What it *does* see is metadata: room ids (derived hashes, not secrets), which role is
|
|
96
|
+
connected when, message sizes and timing, and Web Push endpoint URLs of subscribed phones.
|
|
97
|
+
That metadata is the whole threat surface of a relay compromise: an attacker learns that
|
|
98
|
+
*something* happened in a room and can withhold delivery, but cannot read or forge traffic —
|
|
99
|
+
frames fail to decrypt without the pairing secret. Logs carry only the first 8 characters of
|
|
100
|
+
a room id and never a payload, ciphertext or push body. The only persisted state is
|
|
101
|
+
`DATA_DIR/push-subs.json`; delete it to drop all push subscriptions.
|
|
102
|
+
|
|
103
|
+
Self-host it: an operator you trust running this on a $5 VPS sees strictly less than a hosted
|
|
104
|
+
service would.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pnpm --filter dsh-dispatch-relay dev # watch mode
|
|
110
|
+
pnpm --filter dsh-dispatch-relay test # vitest, real sockets on port 0
|
|
111
|
+
pnpm --filter dsh-dispatch-relay typecheck
|
|
112
|
+
pnpm --filter dsh-dispatch-relay fake-machine # simulated dsh machine, prints a pairing code
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`fake-machine` is the end-to-end harness: it generates a secret, prints a pairing code plus a
|
|
116
|
+
`http://localhost:5173/#pair=…` link for the PWA, joins the derived room as a machine, and
|
|
117
|
+
then answers `sessions.get`, accepts `dispatch.request` (idempotently), raises an approval
|
|
118
|
+
five seconds later and closes it when the phone responds. Point it elsewhere with
|
|
119
|
+
`RELAY_URL`, `PWA_URL` and `MACHINE_NAME`.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
var DEFAULT_PORT = 8787;
|
|
9
|
+
var DEFAULT_DATA_DIR = "./relay-data";
|
|
10
|
+
function parsePort(raw) {
|
|
11
|
+
if (raw === void 0 || raw.trim() === "") return DEFAULT_PORT;
|
|
12
|
+
const port = Number(raw);
|
|
13
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
14
|
+
throw new Error(`invalid PORT: ${raw} (expected an integer 0-65535)`);
|
|
15
|
+
}
|
|
16
|
+
return port;
|
|
17
|
+
}
|
|
18
|
+
function readVapid(env) {
|
|
19
|
+
const publicKey = env.VAPID_PUBLIC_KEY?.trim();
|
|
20
|
+
const privateKey = env.VAPID_PRIVATE_KEY?.trim();
|
|
21
|
+
const subject = env.VAPID_SUBJECT?.trim();
|
|
22
|
+
if (publicKey && privateKey && subject) {
|
|
23
|
+
return { vapid: { publicKey, privateKey, subject }, pushDisabledReason: null };
|
|
24
|
+
}
|
|
25
|
+
const missing = [
|
|
26
|
+
publicKey ? null : "VAPID_PUBLIC_KEY",
|
|
27
|
+
privateKey ? null : "VAPID_PRIVATE_KEY",
|
|
28
|
+
subject ? null : "VAPID_SUBJECT"
|
|
29
|
+
].filter((name) => name !== null);
|
|
30
|
+
const reason = missing.length === 3 ? "VAPID env not set" : `VAPID env incomplete (missing ${missing.join(", ")})`;
|
|
31
|
+
return { vapid: null, pushDisabledReason: reason };
|
|
32
|
+
}
|
|
33
|
+
function loadConfig(env = process.env) {
|
|
34
|
+
return {
|
|
35
|
+
port: parsePort(env.PORT),
|
|
36
|
+
dataDir: resolve(env.DATA_DIR?.trim() || DEFAULT_DATA_DIR),
|
|
37
|
+
...readVapid(env)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/log.ts
|
|
42
|
+
var RANK = { silent: 0, error: 1, warn: 2, info: 3 };
|
|
43
|
+
function initialLevel() {
|
|
44
|
+
const raw = process.env.LOG_LEVEL?.trim().toLowerCase();
|
|
45
|
+
return raw && raw in RANK ? raw : "info";
|
|
46
|
+
}
|
|
47
|
+
var level = initialLevel();
|
|
48
|
+
function emit(at, write, message) {
|
|
49
|
+
if (RANK[at] > RANK[level]) return;
|
|
50
|
+
write(`${(/* @__PURE__ */ new Date()).toISOString()} [relay] ${at} ${message}`);
|
|
51
|
+
}
|
|
52
|
+
var log = {
|
|
53
|
+
info: (message) => emit("info", (l) => console.log(l), message),
|
|
54
|
+
warn: (message) => emit("warn", (l) => console.warn(l), message),
|
|
55
|
+
error: (message) => emit("error", (l) => console.error(l), message)
|
|
56
|
+
};
|
|
57
|
+
function shortRoom(room) {
|
|
58
|
+
return `${room.slice(0, 8)}\u2026`;
|
|
59
|
+
}
|
|
60
|
+
function errorMessage(err) {
|
|
61
|
+
if (err instanceof Error) return err.message;
|
|
62
|
+
return typeof err === "string" ? err : JSON.stringify(err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/server.ts
|
|
66
|
+
import { createServer } from "http";
|
|
67
|
+
import { WebSocketServer } from "ws";
|
|
68
|
+
|
|
69
|
+
// ../shared/src/limits.ts
|
|
70
|
+
var MAX_ENVELOPE_BYTES = 16 * 1024;
|
|
71
|
+
var MAX_DETAIL_CHARS = 8 * 1024;
|
|
72
|
+
var MAX_SUMMARY_CHARS = 4 * 1024;
|
|
73
|
+
var MAX_PROMPT_CHARS = 8 * 1024;
|
|
74
|
+
var MAX_PROMPT_BYTES = 10 * 1024;
|
|
75
|
+
var MAX_FIELD_BYTES = 10 * 1024;
|
|
76
|
+
var MAX_PUSH_BYTES = 3 * 1024;
|
|
77
|
+
var byteEncoder = new TextEncoder();
|
|
78
|
+
|
|
79
|
+
// src/push-store.ts
|
|
80
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
81
|
+
import { join } from "path";
|
|
82
|
+
var STORE_FILENAME = "push-subs.json";
|
|
83
|
+
function isPushSubscription(value) {
|
|
84
|
+
if (typeof value !== "object" || value === null) return false;
|
|
85
|
+
const candidate = value;
|
|
86
|
+
return typeof candidate.endpoint === "string" && candidate.endpoint.length > 0 && candidate.endpoint.length <= 2048 && typeof candidate.keys === "object" && candidate.keys !== null && typeof candidate.keys.p256dh === "string" && typeof candidate.keys.auth === "string";
|
|
87
|
+
}
|
|
88
|
+
function parseStoreFile(raw) {
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (parsed.v !== 1 || typeof parsed.rooms !== "object" || parsed.rooms === null) {
|
|
91
|
+
throw new Error('unexpected shape (want {"v":1,"rooms":{\u2026}})');
|
|
92
|
+
}
|
|
93
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
94
|
+
for (const [room, subs] of Object.entries(parsed.rooms)) {
|
|
95
|
+
const byEndpoint = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const sub of Object.values(subs ?? {})) {
|
|
97
|
+
if (isPushSubscription(sub)) byEndpoint.set(sub.endpoint, sub);
|
|
98
|
+
}
|
|
99
|
+
if (byEndpoint.size > 0) rooms.set(room, byEndpoint);
|
|
100
|
+
}
|
|
101
|
+
return rooms;
|
|
102
|
+
}
|
|
103
|
+
var PushStore = class _PushStore {
|
|
104
|
+
constructor(file, rooms) {
|
|
105
|
+
this.file = file;
|
|
106
|
+
this.rooms = rooms;
|
|
107
|
+
}
|
|
108
|
+
file;
|
|
109
|
+
rooms;
|
|
110
|
+
writes = Promise.resolve();
|
|
111
|
+
/** Never throws: a missing or corrupt store starts empty, loudly. */
|
|
112
|
+
static async load(dataDir) {
|
|
113
|
+
const file = join(dataDir, STORE_FILENAME);
|
|
114
|
+
try {
|
|
115
|
+
const rooms = parseStoreFile(await readFile(file, "utf8"));
|
|
116
|
+
const total = [...rooms.values()].reduce((n, subs) => n + subs.size, 0);
|
|
117
|
+
log.info(`push store loaded: ${total} subscription(s) in ${rooms.size} room(s)`);
|
|
118
|
+
return new _PushStore(file, rooms);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const code = err.code;
|
|
121
|
+
if (code === "ENOENT") log.info(`push store empty: no ${file} yet`);
|
|
122
|
+
else log.warn(`push store unreadable, starting empty: ${errorMessage(err)}`);
|
|
123
|
+
return new _PushStore(file, /* @__PURE__ */ new Map());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
list(room) {
|
|
127
|
+
return [...this.rooms.get(room)?.values() ?? []];
|
|
128
|
+
}
|
|
129
|
+
count(room) {
|
|
130
|
+
if (room !== void 0) return this.rooms.get(room)?.size ?? 0;
|
|
131
|
+
return [...this.rooms.values()].reduce((n, subs) => n + subs.size, 0);
|
|
132
|
+
}
|
|
133
|
+
async add(room, subscription) {
|
|
134
|
+
const byEndpoint = this.rooms.get(room) ?? /* @__PURE__ */ new Map();
|
|
135
|
+
byEndpoint.set(subscription.endpoint, subscription);
|
|
136
|
+
this.rooms.set(room, byEndpoint);
|
|
137
|
+
log.info(`push-subscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);
|
|
138
|
+
await this.persist();
|
|
139
|
+
}
|
|
140
|
+
async remove(room, endpoint) {
|
|
141
|
+
const byEndpoint = this.rooms.get(room);
|
|
142
|
+
if (byEndpoint === void 0 || !byEndpoint.delete(endpoint)) return false;
|
|
143
|
+
if (byEndpoint.size === 0) this.rooms.delete(room);
|
|
144
|
+
log.info(`push-unsubscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);
|
|
145
|
+
await this.persist();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
snapshot() {
|
|
149
|
+
const rooms = {};
|
|
150
|
+
for (const [room, subs] of this.rooms) rooms[room] = Object.fromEntries(subs);
|
|
151
|
+
return { v: 1, rooms };
|
|
152
|
+
}
|
|
153
|
+
/** Serialized write-through: last-writer-wins on the full snapshot, atomic via rename. */
|
|
154
|
+
persist() {
|
|
155
|
+
this.writes = this.writes.then(async () => {
|
|
156
|
+
const body = JSON.stringify(this.snapshot());
|
|
157
|
+
const tmp = `${this.file}.tmp`;
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(join(this.file, ".."), { recursive: true });
|
|
160
|
+
await writeFile(tmp, body, "utf8");
|
|
161
|
+
await rename(tmp, this.file);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.error(`push store write failed (subscriptions are memory-only now): ${errorMessage(err)}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return this.writes;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/frames.ts
|
|
171
|
+
var ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
|
172
|
+
function isRecord(value) {
|
|
173
|
+
return typeof value === "object" && value !== null;
|
|
174
|
+
}
|
|
175
|
+
function isRoom(value) {
|
|
176
|
+
return typeof value === "string" && ROOM_PATTERN.test(value);
|
|
177
|
+
}
|
|
178
|
+
function isRole(value) {
|
|
179
|
+
return value === "machine" || value === "phone";
|
|
180
|
+
}
|
|
181
|
+
function isHelloFrame(frame) {
|
|
182
|
+
return isRecord(frame) && frame.kind === "hello" && isRoom(frame.room) && isRole(frame.role);
|
|
183
|
+
}
|
|
184
|
+
function isMsgFrame(frame) {
|
|
185
|
+
return isRecord(frame) && frame.kind === "msg" && isRoom(frame.room) && typeof frame.payload === "string";
|
|
186
|
+
}
|
|
187
|
+
function isPushHint(hint) {
|
|
188
|
+
return isRecord(hint) && typeof hint.payload === "string" && hint.payload.length > 0 && (hint.tag === void 0 || typeof hint.tag === "string");
|
|
189
|
+
}
|
|
190
|
+
function isPushSubscribeFrame(frame) {
|
|
191
|
+
return isRecord(frame) && frame.kind === "push-subscribe" && isRoom(frame.room) && isPushSubscription(frame.subscription);
|
|
192
|
+
}
|
|
193
|
+
function isPushUnsubscribeFrame(frame) {
|
|
194
|
+
return isRecord(frame) && frame.kind === "push-unsubscribe" && isRoom(frame.room) && typeof frame.endpoint === "string";
|
|
195
|
+
}
|
|
196
|
+
function frameSize(data) {
|
|
197
|
+
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);
|
|
198
|
+
if (Buffer.isBuffer(data)) return data.length;
|
|
199
|
+
return data.byteLength;
|
|
200
|
+
}
|
|
201
|
+
function frameText(data) {
|
|
202
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
203
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
204
|
+
return Buffer.from(data).toString("utf8");
|
|
205
|
+
}
|
|
206
|
+
var OPEN = 1;
|
|
207
|
+
function sendRaw(socket, text) {
|
|
208
|
+
if (socket.readyState !== OPEN) return;
|
|
209
|
+
socket.send(text);
|
|
210
|
+
}
|
|
211
|
+
function send(socket, frame) {
|
|
212
|
+
sendRaw(socket, JSON.stringify(frame));
|
|
213
|
+
}
|
|
214
|
+
function sendError(socket, code, message) {
|
|
215
|
+
send(socket, message === void 0 ? { kind: "error", code } : { kind: "error", code, message });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/rooms.ts
|
|
219
|
+
function otherRole(role) {
|
|
220
|
+
return role === "machine" ? "phone" : "machine";
|
|
221
|
+
}
|
|
222
|
+
var RoomRegistry = class {
|
|
223
|
+
rooms = /* @__PURE__ */ new Map();
|
|
224
|
+
/** @returns true when the socket was not already registered for (room, role). */
|
|
225
|
+
join(room, role, socket) {
|
|
226
|
+
let entry = this.rooms.get(room);
|
|
227
|
+
if (entry === void 0) {
|
|
228
|
+
entry = { machine: /* @__PURE__ */ new Set(), phone: /* @__PURE__ */ new Set() };
|
|
229
|
+
this.rooms.set(room, entry);
|
|
230
|
+
}
|
|
231
|
+
if (entry[role].has(socket)) return false;
|
|
232
|
+
entry[role].add(socket);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
/** @returns true when this was the last socket of that role in the room (role went offline). */
|
|
236
|
+
leave(room, role, socket) {
|
|
237
|
+
const entry = this.rooms.get(room);
|
|
238
|
+
if (entry === void 0) return false;
|
|
239
|
+
const removed = entry[role].delete(socket);
|
|
240
|
+
if (entry.machine.size === 0 && entry.phone.size === 0) this.rooms.delete(room);
|
|
241
|
+
return removed && entry[role].size === 0;
|
|
242
|
+
}
|
|
243
|
+
peers(room) {
|
|
244
|
+
const entry = this.rooms.get(room);
|
|
245
|
+
return { machine: entry?.machine.size ?? 0, phone: entry?.phone.size ?? 0 };
|
|
246
|
+
}
|
|
247
|
+
/** Snapshot copy: callers send to it while sockets may close mid-iteration. */
|
|
248
|
+
socketsOf(room, role) {
|
|
249
|
+
const entry = this.rooms.get(room);
|
|
250
|
+
return entry === void 0 ? [] : [...entry[role]];
|
|
251
|
+
}
|
|
252
|
+
roomCount() {
|
|
253
|
+
return this.rooms.size;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/connection.ts
|
|
258
|
+
var CLOSE_POLICY_VIOLATION = 1008;
|
|
259
|
+
function safeLabel(value) {
|
|
260
|
+
return String(value).replace(/[^\w.-]/g, "").slice(0, 32) || "none";
|
|
261
|
+
}
|
|
262
|
+
function handleConnection(socket, ctx) {
|
|
263
|
+
const connection = new Connection(socket, ctx);
|
|
264
|
+
socket.on("message", (data) => connection.onMessage(data));
|
|
265
|
+
socket.on("close", () => connection.onClose());
|
|
266
|
+
socket.on("error", (err) => {
|
|
267
|
+
log.warn(`socket error: ${errorMessage(err)}`);
|
|
268
|
+
connection.onClose();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
var Connection = class {
|
|
272
|
+
constructor(socket, ctx) {
|
|
273
|
+
this.socket = socket;
|
|
274
|
+
this.ctx = ctx;
|
|
275
|
+
}
|
|
276
|
+
socket;
|
|
277
|
+
ctx;
|
|
278
|
+
joined = /* @__PURE__ */ new Map();
|
|
279
|
+
helloDone = false;
|
|
280
|
+
onMessage(data) {
|
|
281
|
+
const size = frameSize(data);
|
|
282
|
+
if (size > MAX_ENVELOPE_BYTES) {
|
|
283
|
+
log.info(`too-large rejected: ${size}B > ${MAX_ENVELOPE_BYTES}B`);
|
|
284
|
+
if (!this.helloDone) return this.closeBadHello("first frame exceeds the envelope limit");
|
|
285
|
+
sendError(this.socket, "too-large", `frame is ${size}B, limit is ${MAX_ENVELOPE_BYTES}B`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const text = frameText(data);
|
|
289
|
+
let frame;
|
|
290
|
+
try {
|
|
291
|
+
frame = JSON.parse(text);
|
|
292
|
+
} catch {
|
|
293
|
+
if (!this.helloDone) return this.closeBadHello("first frame is not valid JSON");
|
|
294
|
+
log.info("bad-frame: invalid JSON");
|
|
295
|
+
sendError(this.socket, "bad-frame", "invalid JSON");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
void this.dispatch(frame, text).catch((err) => {
|
|
299
|
+
log.error(`frame handling failed: ${errorMessage(err)}`);
|
|
300
|
+
sendError(this.socket, "bad-frame", "relay failed to handle this frame");
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async dispatch(frame, text) {
|
|
304
|
+
if (!this.helloDone) {
|
|
305
|
+
if (!isHelloFrame(frame)) return this.closeBadHello("first frame must be a valid hello");
|
|
306
|
+
this.helloDone = true;
|
|
307
|
+
return this.handleHello(frame);
|
|
308
|
+
}
|
|
309
|
+
if (isHelloFrame(frame)) return this.handleHello(frame);
|
|
310
|
+
if (isMsgFrame(frame)) return this.handleMsg(frame, text);
|
|
311
|
+
if (isPushSubscribeFrame(frame)) return this.handleSubscribe(frame);
|
|
312
|
+
if (isPushUnsubscribeFrame(frame)) return this.handleUnsubscribe(frame);
|
|
313
|
+
this.rejectFrame(frame);
|
|
314
|
+
}
|
|
315
|
+
handleHello(frame) {
|
|
316
|
+
const existing = this.joined.get(frame.room);
|
|
317
|
+
if (existing !== void 0 && existing !== frame.role) {
|
|
318
|
+
log.info(`bad-hello room=${shortRoom(frame.room)}: role conflict`);
|
|
319
|
+
sendError(this.socket, "bad-hello", "this socket already joined that room with another role");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const added = this.ctx.rooms.join(frame.room, frame.role, this.socket);
|
|
323
|
+
this.joined.set(frame.room, frame.role);
|
|
324
|
+
const peers = this.ctx.rooms.peers(frame.room);
|
|
325
|
+
send(this.socket, { kind: "hello-ok", peers });
|
|
326
|
+
if (!added) return;
|
|
327
|
+
log.info(
|
|
328
|
+
`join room=${shortRoom(frame.room)} role=${frame.role} peers=${peers.machine}m/${peers.phone}p`
|
|
329
|
+
);
|
|
330
|
+
this.broadcastPresence(frame.room, frame.role, true);
|
|
331
|
+
}
|
|
332
|
+
handleMsg(frame, text) {
|
|
333
|
+
const role = this.joined.get(frame.room);
|
|
334
|
+
if (role === void 0) {
|
|
335
|
+
log.info(`bad-frame: msg for a room this socket never joined (${shortRoom(frame.room)})`);
|
|
336
|
+
sendError(this.socket, "bad-frame", "not joined to this room");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const targets = this.ctx.rooms.socketsOf(frame.room, otherRole(role));
|
|
340
|
+
for (const peer of targets) sendRaw(peer, text);
|
|
341
|
+
if (frame.push === void 0 || frame.push === null) return;
|
|
342
|
+
if (role !== "machine") {
|
|
343
|
+
sendError(this.socket, "bad-frame", "push hints are machine\u2192relay only");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (targets.length > 0) return;
|
|
347
|
+
return this.deliverPush(frame.room, frame.push);
|
|
348
|
+
}
|
|
349
|
+
async deliverPush(room, hint) {
|
|
350
|
+
if (!isPushHint(hint)) {
|
|
351
|
+
log.info(`bad-frame: malformed push hint room=${shortRoom(room)}`);
|
|
352
|
+
sendError(this.socket, "bad-frame", "invalid push hint");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const bytes = Buffer.byteLength(hint.payload, "utf8");
|
|
356
|
+
if (bytes > MAX_PUSH_BYTES) {
|
|
357
|
+
log.info(`push rejected room=${shortRoom(room)}: ${bytes}B > ${MAX_PUSH_BYTES}B`);
|
|
358
|
+
sendError(this.socket, "too-large", `push payload is ${bytes}B, limit is ${MAX_PUSH_BYTES}B`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!this.ctx.pusher.enabled) {
|
|
362
|
+
log.warn(`push dropped room=${shortRoom(room)}: web push is disabled on this relay`);
|
|
363
|
+
sendError(this.socket, "push-disabled", "relay has no VAPID keys configured");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const { sent, dropped, failed } = await this.ctx.pusher.deliver(room, hint.payload, hint.tag);
|
|
367
|
+
if (sent > 0) return;
|
|
368
|
+
if (failed > 0 || dropped > 0) {
|
|
369
|
+
sendError(this.socket, "push-failed", `0 delivered, ${failed} failed, ${dropped} expired`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
sendError(this.socket, "push-no-subscribers", "no phone online and no push subscription stored");
|
|
373
|
+
}
|
|
374
|
+
async handleSubscribe(frame) {
|
|
375
|
+
if (!this.requiresPhoneRole(frame.room, "push-subscribe")) return;
|
|
376
|
+
await this.ctx.store.add(frame.room, frame.subscription);
|
|
377
|
+
}
|
|
378
|
+
async handleUnsubscribe(frame) {
|
|
379
|
+
if (!this.requiresPhoneRole(frame.room, "push-unsubscribe")) return;
|
|
380
|
+
await this.ctx.store.remove(frame.room, frame.endpoint);
|
|
381
|
+
}
|
|
382
|
+
requiresPhoneRole(room, what) {
|
|
383
|
+
if (this.joined.get(room) === "phone") return true;
|
|
384
|
+
log.info(`bad-frame: ${what} without a phone hello for room=${shortRoom(room)}`);
|
|
385
|
+
sendError(this.socket, "bad-frame", `${what} requires a phone hello for this room`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
rejectFrame(frame) {
|
|
389
|
+
const kind = safeLabel(typeof frame === "object" && frame !== null ? frame.kind : frame);
|
|
390
|
+
log.info(`bad-frame kind=${kind}`);
|
|
391
|
+
sendError(this.socket, "bad-frame", `unsupported or malformed frame (kind=${kind})`);
|
|
392
|
+
}
|
|
393
|
+
broadcastPresence(room, role, online) {
|
|
394
|
+
const frame = { kind: "presence", room, role, online };
|
|
395
|
+
for (const peer of this.ctx.rooms.socketsOf(room, otherRole(role))) send(peer, frame);
|
|
396
|
+
}
|
|
397
|
+
closeBadHello(reason) {
|
|
398
|
+
log.info(`bad-hello: ${reason}`);
|
|
399
|
+
sendError(this.socket, "bad-hello", reason);
|
|
400
|
+
this.socket.close(CLOSE_POLICY_VIOLATION, "bad-hello");
|
|
401
|
+
}
|
|
402
|
+
onClose() {
|
|
403
|
+
for (const [room, role] of this.joined) {
|
|
404
|
+
const wentOffline = this.ctx.rooms.leave(room, role, this.socket);
|
|
405
|
+
log.info(`leave room=${shortRoom(room)} role=${role} last=${wentOffline}`);
|
|
406
|
+
if (wentOffline) this.broadcastPresence(room, role, false);
|
|
407
|
+
}
|
|
408
|
+
this.joined.clear();
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// src/http.ts
|
|
413
|
+
var WS_PATH = "/ws";
|
|
414
|
+
function sendJson(res, status, body) {
|
|
415
|
+
const text = JSON.stringify(body);
|
|
416
|
+
res.writeHead(status, {
|
|
417
|
+
"content-type": "application/json; charset=utf-8",
|
|
418
|
+
"content-length": Buffer.byteLength(text),
|
|
419
|
+
"cache-control": "no-store",
|
|
420
|
+
// The PWA fetches /vapid cross-origin; both endpoints expose only public data.
|
|
421
|
+
"access-control-allow-origin": "*"
|
|
422
|
+
});
|
|
423
|
+
res.end(text);
|
|
424
|
+
}
|
|
425
|
+
function createRequestHandler(vapidPublicKey) {
|
|
426
|
+
return (req, res) => {
|
|
427
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
428
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
429
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (path === "/healthz") {
|
|
433
|
+
sendJson(res, 200, { ok: true });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (path === "/vapid") {
|
|
437
|
+
if (vapidPublicKey === null) sendJson(res, 404, { error: "push-disabled" });
|
|
438
|
+
else sendJson(res, 200, { publicKey: vapidPublicKey });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (path === WS_PATH) {
|
|
442
|
+
sendJson(res, 426, { error: "upgrade-required" });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
sendJson(res, 404, { error: "not-found" });
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/push.ts
|
|
450
|
+
import webpush from "web-push";
|
|
451
|
+
var PUSH_TTL_SECONDS = 60;
|
|
452
|
+
var TOPIC_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
453
|
+
function configureWebPush(vapid, disabledReason) {
|
|
454
|
+
if (vapid === null) {
|
|
455
|
+
log.warn(`web push disabled: ${disabledReason ?? "VAPID env not set"}`);
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);
|
|
460
|
+
log.info("web push enabled");
|
|
461
|
+
return true;
|
|
462
|
+
} catch (err) {
|
|
463
|
+
log.warn(`web push disabled: invalid VAPID config: ${errorMessage(err)}`);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
var Pusher = class {
|
|
468
|
+
constructor(store, enabled) {
|
|
469
|
+
this.store = store;
|
|
470
|
+
this.enabled = enabled;
|
|
471
|
+
}
|
|
472
|
+
store;
|
|
473
|
+
enabled;
|
|
474
|
+
async deliver(room, payload, tag) {
|
|
475
|
+
const result = { sent: 0, dropped: 0, failed: 0 };
|
|
476
|
+
const options = { TTL: PUSH_TTL_SECONDS };
|
|
477
|
+
if (tag !== void 0 && TOPIC_PATTERN.test(tag)) options.topic = tag;
|
|
478
|
+
for (const subscription of this.store.list(room)) {
|
|
479
|
+
await this.send(room, subscription, payload, options, result);
|
|
480
|
+
}
|
|
481
|
+
log.info(
|
|
482
|
+
`push room=${shortRoom(room)} sent=${result.sent} dropped=${result.dropped} failed=${result.failed}`
|
|
483
|
+
);
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
async send(room, subscription, payload, options, result) {
|
|
487
|
+
try {
|
|
488
|
+
await webpush.sendNotification(subscription, payload, options);
|
|
489
|
+
result.sent += 1;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
const status = err.statusCode;
|
|
492
|
+
if (status === 404 || status === 410) {
|
|
493
|
+
result.dropped += 1;
|
|
494
|
+
await this.store.remove(room, subscription.endpoint);
|
|
495
|
+
log.info(`push subscription gone (${status}) room=${shortRoom(room)}, pruned`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
result.failed += 1;
|
|
499
|
+
log.error(`push failed room=${shortRoom(room)} status=${status ?? "none"}: ${errorMessage(err)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/server.ts
|
|
505
|
+
var PING_INTERVAL_MS = 25e3;
|
|
506
|
+
var MAX_WS_PAYLOAD_BYTES = 1024 * 1024;
|
|
507
|
+
function listen(server, port) {
|
|
508
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
509
|
+
server.once("error", rejectPromise);
|
|
510
|
+
server.listen(port, () => {
|
|
511
|
+
server.removeListener("error", rejectPromise);
|
|
512
|
+
resolvePromise();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function startHeartbeat(wss) {
|
|
517
|
+
const timer = setInterval(() => {
|
|
518
|
+
for (const client of wss.clients) {
|
|
519
|
+
if (client.isAlive === false) {
|
|
520
|
+
log.warn("terminating unresponsive socket (missed pong)");
|
|
521
|
+
client.terminate();
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
client.isAlive = false;
|
|
525
|
+
client.ping();
|
|
526
|
+
}
|
|
527
|
+
}, PING_INTERVAL_MS);
|
|
528
|
+
timer.unref();
|
|
529
|
+
return timer;
|
|
530
|
+
}
|
|
531
|
+
function shutdown(server, wss, heartbeat) {
|
|
532
|
+
clearInterval(heartbeat);
|
|
533
|
+
for (const client of wss.clients) client.terminate();
|
|
534
|
+
return new Promise((resolvePromise) => {
|
|
535
|
+
wss.close(() => server.close(() => resolvePromise()));
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async function createRelayServer(config) {
|
|
539
|
+
const store = await PushStore.load(config.dataDir);
|
|
540
|
+
const pushEnabled = configureWebPush(config.vapid, config.pushDisabledReason);
|
|
541
|
+
const ctx = {
|
|
542
|
+
rooms: new RoomRegistry(),
|
|
543
|
+
store,
|
|
544
|
+
pusher: new Pusher(store, pushEnabled)
|
|
545
|
+
};
|
|
546
|
+
const server = createServer(createRequestHandler(pushEnabled && config.vapid ? config.vapid.publicKey : null));
|
|
547
|
+
const wss = new WebSocketServer({ server, path: WS_PATH, maxPayload: MAX_WS_PAYLOAD_BYTES });
|
|
548
|
+
wss.on("connection", (socket) => {
|
|
549
|
+
socket.isAlive = true;
|
|
550
|
+
socket.on("pong", () => {
|
|
551
|
+
socket.isAlive = true;
|
|
552
|
+
});
|
|
553
|
+
handleConnection(socket, ctx);
|
|
554
|
+
});
|
|
555
|
+
wss.on("error", (err) => log.error(`websocket server error: ${errorMessage(err)}`));
|
|
556
|
+
server.on("error", (err) => log.error(`http server error: ${errorMessage(err)}`));
|
|
557
|
+
const heartbeat = startHeartbeat(wss);
|
|
558
|
+
await listen(server, config.port);
|
|
559
|
+
const address = server.address();
|
|
560
|
+
const port = typeof address === "object" && address !== null ? address.port : config.port;
|
|
561
|
+
log.info(
|
|
562
|
+
`listening on :${port} ws=${WS_PATH} dataDir=${config.dataDir} push=${pushEnabled ? "on" : "off"}`
|
|
563
|
+
);
|
|
564
|
+
return {
|
|
565
|
+
port,
|
|
566
|
+
rooms: ctx.rooms,
|
|
567
|
+
store,
|
|
568
|
+
pushEnabled,
|
|
569
|
+
close: () => shutdown(server, wss, heartbeat)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/index.ts
|
|
574
|
+
async function stop(server, signal) {
|
|
575
|
+
log.info(`${signal} received, shutting down`);
|
|
576
|
+
await server.close();
|
|
577
|
+
process.exit(0);
|
|
578
|
+
}
|
|
579
|
+
async function main() {
|
|
580
|
+
const server = await createRelayServer(loadConfig());
|
|
581
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
582
|
+
process.on(signal, () => {
|
|
583
|
+
stop(server, signal).catch((err) => {
|
|
584
|
+
log.error(`shutdown failed: ${errorMessage(err)}`);
|
|
585
|
+
process.exit(1);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
var argvPath = process.argv[1];
|
|
591
|
+
if (argvPath !== void 0 && import.meta.url === pathToFileURL(argvPath).href) {
|
|
592
|
+
main().catch((err) => {
|
|
593
|
+
log.error(`fatal: ${errorMessage(err)}`);
|
|
594
|
+
process.exitCode = 1;
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
export {
|
|
598
|
+
createRelayServer,
|
|
599
|
+
loadConfig
|
|
600
|
+
};
|
|
601
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/config.ts","../src/log.ts","../src/server.ts","../../shared/src/limits.ts","../src/push-store.ts","../src/frames.ts","../src/rooms.ts","../src/connection.ts","../src/http.ts","../src/push.ts"],"sourcesContent":["// dsh-dispatch relay entrypoint (bin: dsh-dispatch-relay).\n\nimport { pathToFileURL } from 'node:url';\nimport { loadConfig } from './config.js';\nimport { errorMessage, log } from './log.js';\nimport { createRelayServer, type RelayServer } from './server.js';\n\nexport { loadConfig } from './config.js';\nexport { createRelayServer } from './server.js';\nexport type { RelayConfig } from './config.js';\nexport type { RelayServer } from './server.js';\n\nasync function stop(server: RelayServer, signal: string): Promise<void> {\n log.info(`${signal} received, shutting down`);\n await server.close();\n process.exit(0);\n}\n\nasync function main(): Promise<void> {\n const server = await createRelayServer(loadConfig());\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.on(signal, () => {\n stop(server, signal).catch((err: unknown) => {\n log.error(`shutdown failed: ${errorMessage(err)}`);\n process.exit(1);\n });\n });\n }\n}\n\n// Run only when executed as a program, so the module stays importable (tests, embedding).\nconst argvPath = process.argv[1];\nif (argvPath !== undefined && import.meta.url === pathToFileURL(argvPath).href) {\n main().catch((err: unknown) => {\n log.error(`fatal: ${errorMessage(err)}`);\n process.exitCode = 1;\n });\n}\n","// Environment configuration. Invalid values fail loudly at startup rather than\n// being silently coerced — a relay that listens on the wrong port is worse than one that refuses to boot.\n\nimport { resolve } from 'node:path';\n\nexport const DEFAULT_PORT = 8787;\nexport const DEFAULT_DATA_DIR = './relay-data';\n\nexport interface VapidConfig {\n publicKey: string;\n privateKey: string;\n subject: string;\n}\n\nexport interface RelayConfig {\n port: number;\n dataDir: string;\n /** null = Web Push disabled; `pushDisabledReason` says why (logged once at startup). */\n vapid: VapidConfig | null;\n pushDisabledReason: string | null;\n}\n\nfunction parsePort(raw: string | undefined): number {\n if (raw === undefined || raw.trim() === '') return DEFAULT_PORT;\n const port = Number(raw);\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n throw new Error(`invalid PORT: ${raw} (expected an integer 0-65535)`);\n }\n return port;\n}\n\nfunction readVapid(env: NodeJS.ProcessEnv): Pick<RelayConfig, 'vapid' | 'pushDisabledReason'> {\n const publicKey = env.VAPID_PUBLIC_KEY?.trim();\n const privateKey = env.VAPID_PRIVATE_KEY?.trim();\n const subject = env.VAPID_SUBJECT?.trim();\n if (publicKey && privateKey && subject) {\n return { vapid: { publicKey, privateKey, subject }, pushDisabledReason: null };\n }\n const missing = [\n publicKey ? null : 'VAPID_PUBLIC_KEY',\n privateKey ? null : 'VAPID_PRIVATE_KEY',\n subject ? null : 'VAPID_SUBJECT',\n ].filter((name): name is string => name !== null);\n const reason =\n missing.length === 3 ? 'VAPID env not set' : `VAPID env incomplete (missing ${missing.join(', ')})`;\n return { vapid: null, pushDisabledReason: reason };\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): RelayConfig {\n return {\n port: parsePort(env.PORT),\n dataDir: resolve(env.DATA_DIR?.trim() || DEFAULT_DATA_DIR),\n ...readVapid(env),\n };\n}\n","// Structured-ish single-line logging.\n// INVARIANT (docs/PROTOCOL.md §Security 4): never log payloads, ciphertext or push bodies.\n// Room ids are always shortened via shortRoom() before they reach a log line.\n\nexport type LogLevel = 'silent' | 'error' | 'warn' | 'info';\n\nconst RANK: Record<LogLevel, number> = { silent: 0, error: 1, warn: 2, info: 3 };\n\nfunction initialLevel(): LogLevel {\n const raw = process.env.LOG_LEVEL?.trim().toLowerCase();\n return raw && raw in RANK ? (raw as LogLevel) : 'info';\n}\n\nlet level: LogLevel = initialLevel();\n\nexport function setLogLevel(next: LogLevel): void {\n level = next;\n}\n\nfunction emit(at: LogLevel, write: (line: string) => void, message: string): void {\n if (RANK[at] > RANK[level]) return;\n write(`${new Date().toISOString()} [relay] ${at} ${message}`);\n}\n\nexport const log = {\n info: (message: string) => emit('info', (l) => console.log(l), message),\n warn: (message: string) => emit('warn', (l) => console.warn(l), message),\n error: (message: string) => emit('error', (l) => console.error(l), message),\n};\n\n/** Room ids are opaque to the relay; only a prefix ever reaches the logs. */\nexport function shortRoom(room: string): string {\n return `${room.slice(0, 8)}…`;\n}\n\nexport function errorMessage(err: unknown): string {\n if (err instanceof Error) return err.message;\n return typeof err === 'string' ? err : JSON.stringify(err);\n}\n","// Wiring: HTTP surface + WebSocket router + heartbeat + push store.\n\nimport { createServer, type Server } from 'node:http';\nimport { WebSocketServer, type WebSocket } from 'ws';\nimport type { RelayConfig } from './config.js';\nimport { handleConnection, type ConnectionContext } from './connection.js';\nimport { createRequestHandler, WS_PATH } from './http.js';\nimport { errorMessage, log } from './log.js';\nimport { PushStore } from './push-store.js';\nimport { configureWebPush, Pusher } from './push.js';\nimport { RoomRegistry } from './rooms.js';\n\nexport const PING_INTERVAL_MS = 25_000;\n/** Well above the 16KB envelope limit: we want to answer `too-large` ourselves, not have ws drop the socket. */\nexport const MAX_WS_PAYLOAD_BYTES = 1024 * 1024;\n\ninterface HeartbeatSocket extends WebSocket {\n isAlive?: boolean;\n}\n\nexport interface RelayServer {\n /** Actual listening port (resolves `PORT=0` for tests). */\n port: number;\n rooms: RoomRegistry;\n store: PushStore;\n pushEnabled: boolean;\n close(): Promise<void>;\n}\n\nfunction listen(server: Server, port: number): Promise<void> {\n return new Promise((resolvePromise, rejectPromise) => {\n server.once('error', rejectPromise);\n server.listen(port, () => {\n server.removeListener('error', rejectPromise);\n resolvePromise();\n });\n });\n}\n\nfunction startHeartbeat(wss: WebSocketServer): NodeJS.Timeout {\n const timer = setInterval(() => {\n for (const client of wss.clients as Set<HeartbeatSocket>) {\n if (client.isAlive === false) {\n log.warn('terminating unresponsive socket (missed pong)');\n client.terminate();\n continue;\n }\n client.isAlive = false;\n client.ping();\n }\n }, PING_INTERVAL_MS);\n timer.unref();\n return timer;\n}\n\nfunction shutdown(server: Server, wss: WebSocketServer, heartbeat: NodeJS.Timeout): Promise<void> {\n clearInterval(heartbeat);\n for (const client of wss.clients) client.terminate();\n return new Promise((resolvePromise) => {\n wss.close(() => server.close(() => resolvePromise()));\n });\n}\n\nexport async function createRelayServer(config: RelayConfig): Promise<RelayServer> {\n const store = await PushStore.load(config.dataDir);\n const pushEnabled = configureWebPush(config.vapid, config.pushDisabledReason);\n const ctx: ConnectionContext = {\n rooms: new RoomRegistry(),\n store,\n pusher: new Pusher(store, pushEnabled),\n };\n\n const server = createServer(createRequestHandler(pushEnabled && config.vapid ? config.vapid.publicKey : null));\n const wss = new WebSocketServer({ server, path: WS_PATH, maxPayload: MAX_WS_PAYLOAD_BYTES });\n wss.on('connection', (socket: HeartbeatSocket) => {\n socket.isAlive = true;\n socket.on('pong', () => {\n socket.isAlive = true;\n });\n handleConnection(socket, ctx);\n });\n wss.on('error', (err) => log.error(`websocket server error: ${errorMessage(err)}`));\n server.on('error', (err) => log.error(`http server error: ${errorMessage(err)}`));\n\n const heartbeat = startHeartbeat(wss);\n await listen(server, config.port);\n const address = server.address();\n const port = typeof address === 'object' && address !== null ? address.port : config.port;\n log.info(\n `listening on :${port} ws=${WS_PATH} dataDir=${config.dataDir} push=${pushEnabled ? 'on' : 'off'}`,\n );\n\n return {\n port,\n rooms: ctx.rooms,\n store,\n pushEnabled,\n close: () => shutdown(server, wss, heartbeat),\n };\n}\n","// Payload limits from docs/PROTOCOL.md. The sender truncates BEFORE encryption.\n// The relay imports THIS FILE by subpath (src/limits.js) to keep tweetnacl out of its\n// bundle — if this package ever gains a package.json \"exports\" map, add a matching entry.\n\nexport const MAX_ENVELOPE_BYTES = 16 * 1024;\nexport const MAX_DETAIL_CHARS = 8 * 1024;\nexport const MAX_SUMMARY_CHARS = 4 * 1024;\n// Char limits alone don't bound the envelope: CJK is ~3 bytes/char in UTF-8.\n// Free-text fields are clamped by chars first, then by bytes (clampBytes) before seal.\nexport const MAX_PROMPT_CHARS = 8 * 1024;\nexport const MAX_PROMPT_BYTES = 10 * 1024;\nexport const MAX_FIELD_BYTES = 10 * 1024;\nexport const MAX_PUSH_BYTES = 3 * 1024;\nexport const TRUNCATION_SUFFIX = '…[truncated]';\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return text.slice(0, maxChars - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;\n}\n\nconst byteEncoder = new TextEncoder();\n\nexport function utf8Length(text: string): number {\n return byteEncoder.encode(text).length;\n}\n\n// Clamp to a UTF-8 byte budget without splitting a multi-byte char or surrogate pair.\nexport function clampBytes(text: string, maxBytes: number): string {\n if (utf8Length(text) <= maxBytes) return text;\n const budget = maxBytes - utf8Length(TRUNCATION_SUFFIX);\n let low = 0;\n let high = text.length;\n while (low < high) {\n const mid = Math.ceil((low + high) / 2);\n if (utf8Length(text.slice(0, mid)) <= budget) low = mid;\n else high = mid - 1;\n }\n const tail = text.charCodeAt(low - 1);\n if (tail >= 0xd800 && tail <= 0xdbff) low -= 1;\n return text.slice(0, low) + TRUNCATION_SUFFIX;\n}\n","// Web Push subscriptions, keyed by (room, endpoint), persisted write-through to\n// DATA_DIR/push-subs.json. A subscription is metadata (endpoint + browser keys), never content.\n\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport { errorMessage, log, shortRoom } from './log.js';\n\nexport const STORE_FILENAME = 'push-subs.json';\n\ninterface StoreFile {\n v: 1;\n rooms: Record<string, Record<string, PushSubscriptionJson>>;\n}\n\nexport function isPushSubscription(value: unknown): value is PushSubscriptionJson {\n if (typeof value !== 'object' || value === null) return false;\n const candidate = value as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };\n return (\n typeof candidate.endpoint === 'string' &&\n candidate.endpoint.length > 0 &&\n candidate.endpoint.length <= 2048 &&\n typeof candidate.keys === 'object' &&\n candidate.keys !== null &&\n typeof candidate.keys.p256dh === 'string' &&\n typeof candidate.keys.auth === 'string'\n );\n}\n\nfunction parseStoreFile(raw: string): Map<string, Map<string, PushSubscriptionJson>> {\n const parsed = JSON.parse(raw) as Partial<StoreFile>;\n if (parsed.v !== 1 || typeof parsed.rooms !== 'object' || parsed.rooms === null) {\n throw new Error('unexpected shape (want {\"v\":1,\"rooms\":{…}})');\n }\n const rooms = new Map<string, Map<string, PushSubscriptionJson>>();\n for (const [room, subs] of Object.entries(parsed.rooms)) {\n const byEndpoint = new Map<string, PushSubscriptionJson>();\n for (const sub of Object.values(subs ?? {})) {\n if (isPushSubscription(sub)) byEndpoint.set(sub.endpoint, sub);\n }\n if (byEndpoint.size > 0) rooms.set(room, byEndpoint);\n }\n return rooms;\n}\n\nexport class PushStore {\n private writes: Promise<void> = Promise.resolve();\n\n private constructor(\n private readonly file: string,\n private readonly rooms: Map<string, Map<string, PushSubscriptionJson>>,\n ) {}\n\n /** Never throws: a missing or corrupt store starts empty, loudly. */\n static async load(dataDir: string): Promise<PushStore> {\n const file = join(dataDir, STORE_FILENAME);\n try {\n const rooms = parseStoreFile(await readFile(file, 'utf8'));\n const total = [...rooms.values()].reduce((n, subs) => n + subs.size, 0);\n log.info(`push store loaded: ${total} subscription(s) in ${rooms.size} room(s)`);\n return new PushStore(file, rooms);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') log.info(`push store empty: no ${file} yet`);\n else log.warn(`push store unreadable, starting empty: ${errorMessage(err)}`);\n return new PushStore(file, new Map());\n }\n }\n\n list(room: string): PushSubscriptionJson[] {\n return [...(this.rooms.get(room)?.values() ?? [])];\n }\n\n count(room?: string): number {\n if (room !== undefined) return this.rooms.get(room)?.size ?? 0;\n return [...this.rooms.values()].reduce((n, subs) => n + subs.size, 0);\n }\n\n async add(room: string, subscription: PushSubscriptionJson): Promise<void> {\n const byEndpoint = this.rooms.get(room) ?? new Map<string, PushSubscriptionJson>();\n byEndpoint.set(subscription.endpoint, subscription);\n this.rooms.set(room, byEndpoint);\n log.info(`push-subscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n }\n\n async remove(room: string, endpoint: string): Promise<boolean> {\n const byEndpoint = this.rooms.get(room);\n if (byEndpoint === undefined || !byEndpoint.delete(endpoint)) return false;\n if (byEndpoint.size === 0) this.rooms.delete(room);\n log.info(`push-unsubscribe room=${shortRoom(room)} subs=${byEndpoint.size}`);\n await this.persist();\n return true;\n }\n\n private snapshot(): StoreFile {\n const rooms: StoreFile['rooms'] = {};\n for (const [room, subs] of this.rooms) rooms[room] = Object.fromEntries(subs);\n return { v: 1, rooms };\n }\n\n /** Serialized write-through: last-writer-wins on the full snapshot, atomic via rename. */\n private persist(): Promise<void> {\n this.writes = this.writes.then(async () => {\n const body = JSON.stringify(this.snapshot());\n const tmp = `${this.file}.tmp`;\n try {\n await mkdir(join(this.file, '..'), { recursive: true });\n await writeFile(tmp, body, 'utf8');\n await rename(tmp, this.file);\n } catch (err) {\n // Visible failure: subscriptions stay in memory, but survive no restart. Say so.\n log.error(`push store write failed (subscriptions are memory-only now): ${errorMessage(err)}`);\n }\n });\n return this.writes;\n }\n}\n","// Wire-frame validation and low-level send helpers. Source of truth: docs/PROTOCOL.md.\n// The relay validates only the envelope — `payload` stays an opaque base64 blob.\n\nimport type { HelloFrame, MsgFrame, PushSubscribeFrame, PushUnsubscribeFrame, RelayFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport { isPushSubscription } from './push-store.js';\n\n/** Room ids are `base64url(sha512(...)[0..16])` = 22 chars; the bound just caps abuse. */\nexport const ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport type ErrorCode =\n | 'bad-hello'\n | 'bad-frame'\n | 'too-large'\n | 'push-disabled'\n | 'push-no-subscribers'\n | 'push-failed';\n\nexport interface ValidPushHint {\n payload: string;\n tag?: string;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction isRoom(value: unknown): value is string {\n return typeof value === 'string' && ROOM_PATTERN.test(value);\n}\n\nexport function isRole(value: unknown): value is Role {\n return value === 'machine' || value === 'phone';\n}\n\nexport function isHelloFrame(frame: unknown): frame is HelloFrame {\n return isRecord(frame) && frame.kind === 'hello' && isRoom(frame.room) && isRole(frame.role);\n}\n\nexport function isMsgFrame(frame: unknown): frame is MsgFrame {\n return (\n isRecord(frame) && frame.kind === 'msg' && isRoom(frame.room) && typeof frame.payload === 'string'\n );\n}\n\nexport function isPushHint(hint: unknown): hint is ValidPushHint {\n return (\n isRecord(hint) &&\n typeof hint.payload === 'string' &&\n hint.payload.length > 0 &&\n (hint.tag === undefined || typeof hint.tag === 'string')\n );\n}\n\nexport function isPushSubscribeFrame(frame: unknown): frame is PushSubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-subscribe' &&\n isRoom(frame.room) &&\n isPushSubscription(frame.subscription)\n );\n}\n\nexport function isPushUnsubscribeFrame(frame: unknown): frame is PushUnsubscribeFrame {\n return (\n isRecord(frame) &&\n frame.kind === 'push-unsubscribe' &&\n isRoom(frame.room) &&\n typeof frame.endpoint === 'string'\n );\n}\n\nexport function frameSize(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);\n if (Buffer.isBuffer(data)) return data.length;\n return data.byteLength;\n}\n\nexport function frameText(data: RawData): string {\n if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');\n if (Buffer.isBuffer(data)) return data.toString('utf8');\n return Buffer.from(data).toString('utf8');\n}\n\nconst OPEN = 1; // ws.OPEN — imported as a value would pull the runtime class into type-only modules.\n\nexport function sendRaw(socket: WebSocket, text: string): void {\n if (socket.readyState !== OPEN) return;\n socket.send(text);\n}\n\nexport function send(socket: WebSocket, frame: RelayFrame): void {\n sendRaw(socket, JSON.stringify(frame));\n}\n\nexport function sendError(socket: WebSocket, code: ErrorCode, message?: string): void {\n send(socket, message === undefined ? { kind: 'error', code } : { kind: 'error', code, message });\n}\n","// In-memory room registry. This is the entire persistent-ish state of the relay besides\n// push subscriptions: who is currently connected, per room, per role. No content, ever.\n\nimport type { Role } from '@dsh-dispatch/shared';\nimport type { WebSocket } from 'ws';\n\nexport type PeerCounts = Record<Role, number>;\n\ninterface Room {\n machine: Set<WebSocket>;\n phone: Set<WebSocket>;\n}\n\nexport function otherRole(role: Role): Role {\n return role === 'machine' ? 'phone' : 'machine';\n}\n\nexport class RoomRegistry {\n private readonly rooms = new Map<string, Room>();\n\n /** @returns true when the socket was not already registered for (room, role). */\n join(room: string, role: Role, socket: WebSocket): boolean {\n let entry = this.rooms.get(room);\n if (entry === undefined) {\n entry = { machine: new Set(), phone: new Set() };\n this.rooms.set(room, entry);\n }\n if (entry[role].has(socket)) return false;\n entry[role].add(socket);\n return true;\n }\n\n /** @returns true when this was the last socket of that role in the room (role went offline). */\n leave(room: string, role: Role, socket: WebSocket): boolean {\n const entry = this.rooms.get(room);\n if (entry === undefined) return false;\n const removed = entry[role].delete(socket);\n if (entry.machine.size === 0 && entry.phone.size === 0) this.rooms.delete(room);\n return removed && entry[role].size === 0;\n }\n\n peers(room: string): PeerCounts {\n const entry = this.rooms.get(room);\n return { machine: entry?.machine.size ?? 0, phone: entry?.phone.size ?? 0 };\n }\n\n /** Snapshot copy: callers send to it while sockets may close mid-iteration. */\n socketsOf(room: string, role: Role): WebSocket[] {\n const entry = this.rooms.get(room);\n return entry === undefined ? [] : [...entry[role]];\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n}\n","// Per-socket state machine: hello → (msg | hello | push-subscribe | push-unsubscribe)*.\n//\n// Framing rules (docs/PROTOCOL.md + PRODUCT.md \"失败可见\"):\n// - the FIRST frame must be a valid hello, otherwise `bad-hello` and close;\n// - after that the socket is never closed for a bad frame — unknown kinds are a\n// forward-compat signal, not an attack, so they get `bad-frame` and stay connected;\n// - every rejection produces an error frame AND a log line. Nothing is dropped in silence.\n\n// Limits come from the shared package's `limits` module directly, not from its barrel:\n// the barrel re-exports tweetnacl-backed crypto, and a zero-knowledge relay must never\n// link the crypto code into its bundle. Every other shared import here is type-only (erased).\nimport { MAX_ENVELOPE_BYTES, MAX_PUSH_BYTES } from '@dsh-dispatch/shared/src/limits.js';\nimport type { HelloFrame, MsgFrame, PresenceFrame, PushSubscribeFrame, PushUnsubscribeFrame, Role } from '@dsh-dispatch/shared';\nimport type { RawData, WebSocket } from 'ws';\nimport {\n frameSize,\n frameText,\n isHelloFrame,\n isMsgFrame,\n isPushHint,\n isPushSubscribeFrame,\n isPushUnsubscribeFrame,\n send,\n sendError,\n sendRaw,\n} from './frames.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\nimport type { Pusher } from './push.js';\nimport { otherRole, type RoomRegistry } from './rooms.js';\n\nexport const CLOSE_POLICY_VIOLATION = 1008;\n\nexport interface ConnectionContext {\n rooms: RoomRegistry;\n store: PushStore;\n pusher: Pusher;\n}\n\n/** Attacker-controlled strings never reach a log line raw. */\nfunction safeLabel(value: unknown): string {\n return String(value).replace(/[^\\w.-]/g, '').slice(0, 32) || 'none';\n}\n\nexport function handleConnection(socket: WebSocket, ctx: ConnectionContext): void {\n const connection = new Connection(socket, ctx);\n socket.on('message', (data: RawData) => connection.onMessage(data));\n socket.on('close', () => connection.onClose());\n socket.on('error', (err) => {\n log.warn(`socket error: ${errorMessage(err)}`);\n connection.onClose();\n });\n}\n\nclass Connection {\n private readonly joined = new Map<string, Role>();\n private helloDone = false;\n\n constructor(\n private readonly socket: WebSocket,\n private readonly ctx: ConnectionContext,\n ) {}\n\n onMessage(data: RawData): void {\n const size = frameSize(data);\n if (size > MAX_ENVELOPE_BYTES) {\n log.info(`too-large rejected: ${size}B > ${MAX_ENVELOPE_BYTES}B`);\n if (!this.helloDone) return this.closeBadHello('first frame exceeds the envelope limit');\n sendError(this.socket, 'too-large', `frame is ${size}B, limit is ${MAX_ENVELOPE_BYTES}B`);\n return;\n }\n const text = frameText(data);\n let frame: unknown;\n try {\n frame = JSON.parse(text) as unknown;\n } catch {\n if (!this.helloDone) return this.closeBadHello('first frame is not valid JSON');\n log.info('bad-frame: invalid JSON');\n sendError(this.socket, 'bad-frame', 'invalid JSON');\n return;\n }\n void this.dispatch(frame, text).catch((err: unknown) => {\n log.error(`frame handling failed: ${errorMessage(err)}`);\n sendError(this.socket, 'bad-frame', 'relay failed to handle this frame');\n });\n }\n\n private async dispatch(frame: unknown, text: string): Promise<void> {\n if (!this.helloDone) {\n if (!isHelloFrame(frame)) return this.closeBadHello('first frame must be a valid hello');\n this.helloDone = true;\n return this.handleHello(frame);\n }\n if (isHelloFrame(frame)) return this.handleHello(frame);\n if (isMsgFrame(frame)) return this.handleMsg(frame, text);\n if (isPushSubscribeFrame(frame)) return this.handleSubscribe(frame);\n if (isPushUnsubscribeFrame(frame)) return this.handleUnsubscribe(frame);\n this.rejectFrame(frame);\n }\n\n private handleHello(frame: HelloFrame): void {\n const existing = this.joined.get(frame.room);\n if (existing !== undefined && existing !== frame.role) {\n log.info(`bad-hello room=${shortRoom(frame.room)}: role conflict`);\n sendError(this.socket, 'bad-hello', 'this socket already joined that room with another role');\n return;\n }\n const added = this.ctx.rooms.join(frame.room, frame.role, this.socket);\n this.joined.set(frame.room, frame.role);\n const peers = this.ctx.rooms.peers(frame.room);\n send(this.socket, { kind: 'hello-ok', peers });\n if (!added) return;\n log.info(\n `join room=${shortRoom(frame.room)} role=${frame.role} peers=${peers.machine}m/${peers.phone}p`,\n );\n this.broadcastPresence(frame.room, frame.role, true);\n }\n\n private handleMsg(frame: MsgFrame, text: string): Promise<void> | void {\n const role = this.joined.get(frame.room);\n if (role === undefined) {\n log.info(`bad-frame: msg for a room this socket never joined (${shortRoom(frame.room)})`);\n sendError(this.socket, 'bad-frame', 'not joined to this room');\n return;\n }\n // Verbatim forward: the original text, so unknown fields survive for forward compat.\n const targets = this.ctx.rooms.socketsOf(frame.room, otherRole(role));\n for (const peer of targets) sendRaw(peer, text);\n\n if (frame.push === undefined || frame.push === null) return;\n if (role !== 'machine') {\n sendError(this.socket, 'bad-frame', 'push hints are machine→relay only');\n return;\n }\n if (targets.length > 0) return; // a phone is live in this room; it already got the msg\n return this.deliverPush(frame.room, frame.push);\n }\n\n private async deliverPush(room: string, hint: unknown): Promise<void> {\n if (!isPushHint(hint)) {\n log.info(`bad-frame: malformed push hint room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', 'invalid push hint');\n return;\n }\n const bytes = Buffer.byteLength(hint.payload, 'utf8');\n if (bytes > MAX_PUSH_BYTES) {\n log.info(`push rejected room=${shortRoom(room)}: ${bytes}B > ${MAX_PUSH_BYTES}B`);\n sendError(this.socket, 'too-large', `push payload is ${bytes}B, limit is ${MAX_PUSH_BYTES}B`);\n return;\n }\n if (!this.ctx.pusher.enabled) {\n log.warn(`push dropped room=${shortRoom(room)}: web push is disabled on this relay`);\n sendError(this.socket, 'push-disabled', 'relay has no VAPID keys configured');\n return;\n }\n const { sent, dropped, failed } = await this.ctx.pusher.deliver(room, hint.payload, hint.tag);\n if (sent > 0) return;\n if (failed > 0 || dropped > 0) {\n sendError(this.socket, 'push-failed', `0 delivered, ${failed} failed, ${dropped} expired`);\n return;\n }\n sendError(this.socket, 'push-no-subscribers', 'no phone online and no push subscription stored');\n }\n\n private async handleSubscribe(frame: PushSubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-subscribe')) return;\n await this.ctx.store.add(frame.room, frame.subscription);\n }\n\n private async handleUnsubscribe(frame: PushUnsubscribeFrame): Promise<void> {\n if (!this.requiresPhoneRole(frame.room, 'push-unsubscribe')) return;\n await this.ctx.store.remove(frame.room, frame.endpoint);\n }\n\n private requiresPhoneRole(room: string, what: string): boolean {\n if (this.joined.get(room) === 'phone') return true;\n log.info(`bad-frame: ${what} without a phone hello for room=${shortRoom(room)}`);\n sendError(this.socket, 'bad-frame', `${what} requires a phone hello for this room`);\n return false;\n }\n\n private rejectFrame(frame: unknown): void {\n const kind = safeLabel(typeof frame === 'object' && frame !== null ? (frame as { kind?: unknown }).kind : frame);\n log.info(`bad-frame kind=${kind}`);\n sendError(this.socket, 'bad-frame', `unsupported or malformed frame (kind=${kind})`);\n }\n\n private broadcastPresence(room: string, role: Role, online: boolean): void {\n const frame: PresenceFrame = { kind: 'presence', room, role, online };\n for (const peer of this.ctx.rooms.socketsOf(room, otherRole(role))) send(peer, frame);\n }\n\n private closeBadHello(reason: string): void {\n log.info(`bad-hello: ${reason}`);\n sendError(this.socket, 'bad-hello', reason);\n this.socket.close(CLOSE_POLICY_VIOLATION, 'bad-hello');\n }\n\n onClose(): void {\n for (const [room, role] of this.joined) {\n const wentOffline = this.ctx.rooms.leave(room, role, this.socket);\n log.info(`leave room=${shortRoom(room)} role=${role} last=${wentOffline}`);\n if (wentOffline) this.broadcastPresence(room, role, false);\n }\n this.joined.clear();\n }\n}\n","// Plain HTTP surface: health probe + VAPID public key discovery. Everything else is the /ws upgrade.\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\n\nexport const WS_PATH = '/ws';\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const text = JSON.stringify(body);\n res.writeHead(status, {\n 'content-type': 'application/json; charset=utf-8',\n 'content-length': Buffer.byteLength(text),\n 'cache-control': 'no-store',\n // The PWA fetches /vapid cross-origin; both endpoints expose only public data.\n 'access-control-allow-origin': '*',\n });\n res.end(text);\n}\n\n/**\n * @param vapidPublicKey public VAPID key, or null when push is disabled — in which case\n * /vapid answers 404 {\"error\":\"push-disabled\"} so the PWA can show \"push unavailable\"\n * instead of silently failing to subscribe.\n */\nexport function createRequestHandler(\n vapidPublicKey: string | null,\n): (req: IncomingMessage, res: ServerResponse) => void {\n return (req, res) => {\n const path = (req.url ?? '/').split('?')[0];\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n sendJson(res, 405, { error: 'method-not-allowed' });\n return;\n }\n if (path === '/healthz') {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (path === '/vapid') {\n if (vapidPublicKey === null) sendJson(res, 404, { error: 'push-disabled' });\n else sendJson(res, 200, { publicKey: vapidPublicKey });\n return;\n }\n if (path === WS_PATH) {\n sendJson(res, 426, { error: 'upgrade-required' });\n return;\n }\n sendJson(res, 404, { error: 'not-found' });\n };\n}\n","// Web Push fan-out. The relay forwards an already-encrypted payload produced by the machine;\n// it never composes, inspects or logs notification content.\n\nimport webpush from 'web-push';\nimport type { PushSubscriptionJson } from '@dsh-dispatch/shared';\nimport type { VapidConfig } from './config.js';\nimport { errorMessage, log, shortRoom } from './log.js';\nimport type { PushStore } from './push-store.js';\n\nexport const PUSH_TTL_SECONDS = 60;\n\n/** RFC 8030 Topic header: ≤32 chars of the base64url alphabet. Anything else is dropped, not mangled. */\nconst TOPIC_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;\n\nexport interface PushDelivery {\n sent: number;\n /** Subscriptions the push service reported as gone (404/410); pruned from the store. */\n dropped: number;\n failed: number;\n}\n\n/**\n * Applies VAPID details to the web-push singleton.\n * @returns true when push is usable. Never throws: a bad key pair disables push with one log line.\n */\nexport function configureWebPush(vapid: VapidConfig | null, disabledReason: string | null): boolean {\n if (vapid === null) {\n log.warn(`web push disabled: ${disabledReason ?? 'VAPID env not set'}`);\n return false;\n }\n try {\n webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);\n log.info('web push enabled');\n return true;\n } catch (err) {\n log.warn(`web push disabled: invalid VAPID config: ${errorMessage(err)}`);\n return false;\n }\n}\n\nexport class Pusher {\n constructor(\n private readonly store: PushStore,\n readonly enabled: boolean,\n ) {}\n\n async deliver(room: string, payload: string, tag: string | undefined): Promise<PushDelivery> {\n const result: PushDelivery = { sent: 0, dropped: 0, failed: 0 };\n const options: webpush.RequestOptions = { TTL: PUSH_TTL_SECONDS };\n if (tag !== undefined && TOPIC_PATTERN.test(tag)) options.topic = tag;\n\n for (const subscription of this.store.list(room)) {\n await this.send(room, subscription, payload, options, result);\n }\n log.info(\n `push room=${shortRoom(room)} sent=${result.sent} dropped=${result.dropped} failed=${result.failed}`,\n );\n return result;\n }\n\n private async send(\n room: string,\n subscription: PushSubscriptionJson,\n payload: string,\n options: webpush.RequestOptions,\n result: PushDelivery,\n ): Promise<void> {\n try {\n await webpush.sendNotification(subscription, payload, options);\n result.sent += 1;\n } catch (err) {\n const status = (err as { statusCode?: number }).statusCode;\n if (status === 404 || status === 410) {\n result.dropped += 1;\n await this.store.remove(room, subscription.endpoint);\n log.info(`push subscription gone (${status}) room=${shortRoom(room)}, pruned`);\n return;\n }\n result.failed += 1;\n log.error(`push failed room=${shortRoom(room)} status=${status ?? 'none'}: ${errorMessage(err)}`);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-dispatch-relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-knowledge WebSocket relay for dsh-dispatch: routes end-to-end encrypted frames between DeepSeek Harness (dsh) machines and phones, and fans out Web Push when the phone is offline.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/alextangson/dsh-dispatch.git",
|
|
9
|
+
"directory": "packages/relay"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/alextangson/dsh-dispatch#readme",
|
|
12
|
+
"bugs": "https://github.com/alextangson/dsh-dispatch/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"dsh",
|
|
15
|
+
"dsh-plugin",
|
|
16
|
+
"deepseek-harness",
|
|
17
|
+
"deepseek",
|
|
18
|
+
"relay",
|
|
19
|
+
"e2ee",
|
|
20
|
+
"web-push",
|
|
21
|
+
"remote-control"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"dsh-dispatch-relay": "dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"web-push": "^3.6.7",
|
|
37
|
+
"ws": "^8.21.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"@types/web-push": "^3.6.4",
|
|
42
|
+
"@types/ws": "^8.18.1",
|
|
43
|
+
"tsup": "^8.5.1",
|
|
44
|
+
"tsx": "^4.20.3",
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
|
+
"vitest": "^4.1.11",
|
|
47
|
+
"@dsh-dispatch/shared": "0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsx watch src/index.ts",
|
|
52
|
+
"fake-machine": "tsx scripts/fake-machine.ts",
|
|
53
|
+
"start": "node dist/index.js",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|