winden-tokens 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/cli.mjs +219 -0
- package/package.json +49 -0
- package/server.mjs +692 -0
- package/ui/index.html +226 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Winden Tokens — browser bridge relay.
|
|
3
|
+
*
|
|
4
|
+
* Pairs the Figma plugin UI with one or more browser tabs running the same UI,
|
|
5
|
+
* so the Relationships graph can be driven full-size in a browser tab against
|
|
6
|
+
* the Figma file that is currently open.
|
|
7
|
+
*
|
|
8
|
+
* Figma plugin UI ──ws──▶ relay (127.0.0.1:9337) ──ws──▶ browser tab
|
|
9
|
+
* ▲ │
|
|
10
|
+
* └──────────── commands ◀───────────────────────────┘
|
|
11
|
+
*
|
|
12
|
+
* The relay holds no application state and never looks inside `payload`.
|
|
13
|
+
* Adding a new plugin command needs no change here.
|
|
14
|
+
*
|
|
15
|
+
* The SAME server also serves the browser UI (`ui/index.html`, shipped inside
|
|
16
|
+
* the published npm package). The page and the socket are therefore
|
|
17
|
+
* same-origin, which is what lets the Origin allowlist below be as narrow as
|
|
18
|
+
* it is. `bridge/cli.mjs` is the executable that drives this module.
|
|
19
|
+
*
|
|
20
|
+
* ---------------------------------------------------------------------------
|
|
21
|
+
* PROTOCOL (the plugin side and the browser side must both match this)
|
|
22
|
+
* ---------------------------------------------------------------------------
|
|
23
|
+
*
|
|
24
|
+
* Every frame is JSON:
|
|
25
|
+
*
|
|
26
|
+
* { v: 1, role: 'plugin' | 'client', kind: 'hello' | 'message', payload }
|
|
27
|
+
*
|
|
28
|
+
* `payload` is opaque to the relay. It carries the existing plugin message
|
|
29
|
+
* types verbatim (`refresh`, `create-variable`, `bind-node-property`, …).
|
|
30
|
+
* The relay only reads `payload.type` for logging, and forwards the ORIGINAL
|
|
31
|
+
* bytes unchanged — it never re-serialises your payload.
|
|
32
|
+
*
|
|
33
|
+
* 1. hello — first frame on every socket, names the role:
|
|
34
|
+
*
|
|
35
|
+
* { v: 1, role: 'plugin', kind: 'hello', payload: {...} }
|
|
36
|
+
* { v: 1, role: 'client', kind: 'hello', payload: {...} }
|
|
37
|
+
*
|
|
38
|
+
* Before its hello a socket is unidentified: the relay sends it nothing and
|
|
39
|
+
* forwards nothing from it. A socket that has not said hello within
|
|
40
|
+
* HELLO_TIMEOUT_MS is closed.
|
|
41
|
+
*
|
|
42
|
+
* THE `v` IS CHECKED FIRST, BEFORE ANYTHING ELSE ABOUT THE HELLO.
|
|
43
|
+
* The relay now ships separately from the plugin (`npm i -g winden-tokens`),
|
|
44
|
+
* so an old global install WILL eventually meet a newer plugin. A mismatch
|
|
45
|
+
* must be legible, not a blank tab — see the version-mismatch block below.
|
|
46
|
+
*
|
|
47
|
+
* 2. Routing — plugin ──▶ every client, any client ──▶ the plugin.
|
|
48
|
+
* Clients never reach each other.
|
|
49
|
+
*
|
|
50
|
+
* 3. CLIENT HELLO IS FORWARDED TO THE PLUGIN VERBATIM.
|
|
51
|
+
* When a browser tab says hello, the plugin socket receives that exact
|
|
52
|
+
* frame — `{ v:1, role:'client', kind:'hello', payload }`. That is the
|
|
53
|
+
* plugin's cue to push a full `refresh`, so a tab opened late still gets
|
|
54
|
+
* current data. There is no special wrapper kind for this: the plugin
|
|
55
|
+
* simply listens for `kind === 'hello'` from `role === 'client'`.
|
|
56
|
+
*
|
|
57
|
+
* 4. Relay-generated status frames use a THIRD role, `'relay'`, and the kind
|
|
58
|
+
* `'status'`, so they can never be confused with app traffic:
|
|
59
|
+
*
|
|
60
|
+
* { v:1, role:'relay', kind:'status', payload:{ type:'bridge/plugin-connected' } }
|
|
61
|
+
* { v:1, role:'relay', kind:'status', payload:{ type:'bridge/plugin-disconnected' } }
|
|
62
|
+
* → sent to clients. `plugin-disconnected` is what lets the browser
|
|
63
|
+
* say "lost the document" instead of hanging. A client also gets
|
|
64
|
+
* one of these immediately after its own hello, describing the
|
|
65
|
+
* current state.
|
|
66
|
+
*
|
|
67
|
+
* { v:1, role:'relay', kind:'status', payload:{ type:'bridge/client-attached', clients:N } }
|
|
68
|
+
* { v:1, role:'relay', kind:'status', payload:{ type:'bridge/client-detached', clients:N } }
|
|
69
|
+
* → sent to the plugin, for the headless status strip. Purely
|
|
70
|
+
* informational; safe to ignore. The refresh cue is the forwarded
|
|
71
|
+
* client hello in (3), NOT these frames.
|
|
72
|
+
*
|
|
73
|
+
* { v:1, role:'relay', kind:'status', payload:{ type:'bridge/version-mismatch',
|
|
74
|
+
* relay:1, peer:2, message:'…' } }
|
|
75
|
+
* → broadcast to every attached client when a socket is turned away
|
|
76
|
+
* for speaking a different protocol version. Without it, the
|
|
77
|
+
* failure a user actually sees is "my tab is empty" — the rejected
|
|
78
|
+
* side knows why, and the side still attached knows nothing.
|
|
79
|
+
* (It is only delivered to clients on the relay's OWN version;
|
|
80
|
+
* a client on another version is itself being rejected, and learns
|
|
81
|
+
* why from its close reason instead.)
|
|
82
|
+
*
|
|
83
|
+
* Exactly one plugin socket exists at a time. Figma reloads the plugin often,
|
|
84
|
+
* so a new plugin hello REPLACES the old socket, closing it with code 4000.
|
|
85
|
+
*
|
|
86
|
+
* ---------------------------------------------------------------------------
|
|
87
|
+
* THREAT MODEL (read before changing the Origin logic)
|
|
88
|
+
* ---------------------------------------------------------------------------
|
|
89
|
+
*
|
|
90
|
+
* A WebSocket handshake is NOT subject to CORS. Without an Origin check, any
|
|
91
|
+
* web page the user happens to have open in any tab could connect to
|
|
92
|
+
* ws://localhost:9337 and issue write commands into their Figma file. The
|
|
93
|
+
* same-origin policy does not stop it, and no preflight is made.
|
|
94
|
+
*
|
|
95
|
+
* Three mitigations, all required:
|
|
96
|
+
*
|
|
97
|
+
* 1. Bind 127.0.0.1 only — never 0.0.0.0. Nothing off this machine can reach
|
|
98
|
+
* the relay, so a hostile device on the LAN is out of the picture.
|
|
99
|
+
*
|
|
100
|
+
* 2. Check the Origin header at handshake and answer 403 before the upgrade
|
|
101
|
+
* completes. A browser sets Origin itself and a page cannot forge it, so
|
|
102
|
+
* this is sufficient against a hostile page. A local process CAN forge it,
|
|
103
|
+
* but that already implies code execution on the machine — out of scope.
|
|
104
|
+
*
|
|
105
|
+
* 3. Check the Host header on every request, HTTP and upgrade alike. Only
|
|
106
|
+
* `127.0.0.1:<port>` and `localhost:<port>` are answered. This is the DNS
|
|
107
|
+
* rebinding guard: a name an attacker controls, re-pointed at 127.0.0.1,
|
|
108
|
+
* arrives with ITS name in Host and is refused before anything else runs.
|
|
109
|
+
*
|
|
110
|
+
* WHICH ORIGINS ARE ALLOWED, AND WHY THAT LIST GOT SHORTER
|
|
111
|
+
*
|
|
112
|
+
* The relay now serves the browser UI itself, so the normal client origin is
|
|
113
|
+
* the relay's OWN origin — `http://127.0.0.1:<port>` and its `localhost`
|
|
114
|
+
* spelling. Only a page this relay served can have that origin, so the
|
|
115
|
+
* allowlist and the set of pages that exist are the same set.
|
|
116
|
+
*
|
|
117
|
+
* The vite dev origin (`http://localhost:5173`) used to be allowed
|
|
118
|
+
* unconditionally. It is now allowed ONLY in dev mode — `--dev`, or
|
|
119
|
+
* `WINDEN_BRIDGE_DEV=1` — which is set by `npm run dev:bridge` in this
|
|
120
|
+
* repo and by nothing a published install does. Someone developing the UI
|
|
121
|
+
* opts in explicitly; everyone else never has 5173 on the list at all.
|
|
122
|
+
* Dev mode is an explicit flag rather than "is there a checkout next to me"
|
|
123
|
+
* precisely because it must be impossible to enter by accident.
|
|
124
|
+
*
|
|
125
|
+
* The awkward part, handled honestly rather than hand-waved: the role is
|
|
126
|
+
* claimed in the `hello`, which arrives AFTER the handshake, so the role is
|
|
127
|
+
* unknown at Origin-check time. So the check runs in two stages:
|
|
128
|
+
*
|
|
129
|
+
* Stage 1 (handshake): accept if the Origin is EITHER an allowed client
|
|
130
|
+
* origin (see above) OR null/absent. Anything else → 403. Figma's plugin
|
|
131
|
+
* iframe is sandboxed, so it sends `Origin: null` or no Origin at all —
|
|
132
|
+
* which is why null must be allowed through at all.
|
|
133
|
+
*
|
|
134
|
+
* Stage 2 (hello): bind the claim to the origin.
|
|
135
|
+
* null/absent origin → may claim ONLY 'plugin'
|
|
136
|
+
* an allowed origin → may claim ONLY 'client'
|
|
137
|
+
* A violation closes the socket with a reason.
|
|
138
|
+
*
|
|
139
|
+
* The consequence worth stating plainly: stage 1 lets any NON-BROWSER client
|
|
140
|
+
* (curl, a script) reach stage 2 and claim `plugin`, because a non-browser
|
|
141
|
+
* client can simply omit Origin. That is accepted — it is the same "local
|
|
142
|
+
* code execution" case as forging Origin. What the check does buy is the
|
|
143
|
+
* thing that actually matters: a hostile WEB PAGE is blocked, because a
|
|
144
|
+
* browser always sets Origin to that page's real origin and cannot make it
|
|
145
|
+
* null for a WebSocket.
|
|
146
|
+
*
|
|
147
|
+
* No tokens, no other auth — deliberately out of scope. Loopback, dev machine.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
import { createServer } from 'node:http';
|
|
151
|
+
import { readFile } from 'node:fs/promises';
|
|
152
|
+
import { WebSocketServer } from 'ws';
|
|
153
|
+
|
|
154
|
+
export const DEFAULT_PORT = 9337;
|
|
155
|
+
export const HOST = '127.0.0.1'; // loopback ONLY — see threat model above
|
|
156
|
+
export const PROTOCOL_VERSION = 1;
|
|
157
|
+
|
|
158
|
+
const HELLO_TIMEOUT_MS = 10_000;
|
|
159
|
+
const HEARTBEAT_MS = 30_000;
|
|
160
|
+
|
|
161
|
+
/** The vite dev server's origin. Allowed only in dev mode — see threat model. */
|
|
162
|
+
const VITE_DEV_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
|
163
|
+
|
|
164
|
+
/** Hostnames that may appear in Host / Origin. Loopback spellings only. */
|
|
165
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost'];
|
|
166
|
+
|
|
167
|
+
/** Close codes (4000-4999 is the application-private range). */
|
|
168
|
+
const CLOSE_REPLACED = 4000;
|
|
169
|
+
const CLOSE_POLICY = 4001;
|
|
170
|
+
/** Protocol version mismatch. Distinct from POLICY: retrying can never fix it. */
|
|
171
|
+
export const CLOSE_VERSION = 4002;
|
|
172
|
+
|
|
173
|
+
const BODY_403 = 'Forbidden — the Winden Tokens bridge relay answers loopback requests only.\n';
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Start the relay.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} options
|
|
179
|
+
* @param {number} [options.port] TCP port. Always bound on 127.0.0.1.
|
|
180
|
+
* @param {boolean} [options.dev] Also allow the vite dev origin as a client.
|
|
181
|
+
* @param {string|null} [options.uiFile] Absolute path to the single-file UI to
|
|
182
|
+
* serve at `/`. `null` serves a 404 with an explanation instead.
|
|
183
|
+
* @returns {Promise<{ port:number, url:string, close:() => Promise<void> }>}
|
|
184
|
+
*/
|
|
185
|
+
export function startRelay({ port = DEFAULT_PORT, dev = false, uiFile = null } = {}) {
|
|
186
|
+
/**
|
|
187
|
+
* Origins a socket may claim `role: 'client'` from.
|
|
188
|
+
* Built per-relay because the relay's own origin depends on --port.
|
|
189
|
+
*/
|
|
190
|
+
const clientOrigins = new Set([
|
|
191
|
+
...LOOPBACK_HOSTNAMES.map((h) => `http://${h}:${port}`),
|
|
192
|
+
...(dev ? VITE_DEV_ORIGINS : []),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/** Host header values this relay answers. See threat model, mitigation 3. */
|
|
196
|
+
const allowedHosts = new Set([
|
|
197
|
+
...LOOPBACK_HOSTNAMES.map((h) => `${h}:${port}`),
|
|
198
|
+
...LOOPBACK_HOSTNAMES,
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
// Logging — this is what you stare at when it does not work, so every
|
|
203
|
+
// connect, disconnect, rejected origin, role claim and forwarded frame gets
|
|
204
|
+
// exactly one line, with a timestamp and the role.
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function stamp() {
|
|
208
|
+
const d = new Date();
|
|
209
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
210
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function log(tag, msg) {
|
|
214
|
+
console.log(`[${stamp()}] ${tag.padEnd(8)} ${msg}`);
|
|
215
|
+
}
|
|
216
|
+
function warn(tag, msg) {
|
|
217
|
+
console.warn(`[${stamp()}] ${tag.padEnd(8)} ${msg}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
// State
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** @type {import('ws').WebSocket | null} */
|
|
225
|
+
let pluginSocket = null;
|
|
226
|
+
/** @type {Set<import('ws').WebSocket>} */
|
|
227
|
+
const clientSockets = new Set();
|
|
228
|
+
|
|
229
|
+
let seq = 0; // monotonic socket id, for readable logs
|
|
230
|
+
const counts = { toClients: 0, toPlugin: 0, dropped: 0 };
|
|
231
|
+
|
|
232
|
+
// -------------------------------------------------------------------------
|
|
233
|
+
// HTTP server — serves the browser UI, and `noServer` so we own the upgrade
|
|
234
|
+
// and can answer a real 403 before the WebSocket handshake completes.
|
|
235
|
+
// -------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
const httpServer = createServer((req, res) => {
|
|
238
|
+
if (!hostAllowed(req)) {
|
|
239
|
+
warn('REJECT', `403 ${req.method} ${req.url} — disallowed Host: ${req.headers.host ?? '<absent>'}`);
|
|
240
|
+
res.writeHead(403, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
241
|
+
res.end(BODY_403);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
246
|
+
res.writeHead(405, { 'Content-Type': 'text/plain', Allow: 'GET, HEAD' });
|
|
247
|
+
res.end('Method not allowed.\n');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
252
|
+
|
|
253
|
+
if (path === '/healthz') {
|
|
254
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
255
|
+
res.end(
|
|
256
|
+
JSON.stringify({
|
|
257
|
+
ok: true,
|
|
258
|
+
protocol: PROTOCOL_VERSION,
|
|
259
|
+
plugin: Boolean(pluginSocket),
|
|
260
|
+
clients: clientSockets.size,
|
|
261
|
+
}) + '\n'
|
|
262
|
+
);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (path !== '/' && path !== '/index.html') {
|
|
267
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
268
|
+
res.end('Not found. The Winden Tokens bridge serves one page, at /.\n');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!uiFile) {
|
|
273
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
274
|
+
res.end(
|
|
275
|
+
'No browser UI is bundled with this relay.\n' +
|
|
276
|
+
'In a checkout, run `npm run build` first, or open the vite dev server instead.\n'
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Read per request rather than once at boot: in a checkout the served file
|
|
282
|
+
// is `dist/index.html`, and a rebuild should show up on reload.
|
|
283
|
+
readFile(uiFile)
|
|
284
|
+
.then((html) => {
|
|
285
|
+
res.writeHead(200, {
|
|
286
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
287
|
+
'Content-Length': html.length,
|
|
288
|
+
// The relay and the UI ship as one unit. A cached page from an older
|
|
289
|
+
// relay talking to a newer one is exactly the mismatch we are trying
|
|
290
|
+
// to make impossible.
|
|
291
|
+
'Cache-Control': 'no-store',
|
|
292
|
+
'X-Content-Type-Options': 'nosniff',
|
|
293
|
+
'Referrer-Policy': 'no-referrer',
|
|
294
|
+
});
|
|
295
|
+
res.end(req.method === 'HEAD' ? undefined : html);
|
|
296
|
+
})
|
|
297
|
+
.catch((err) => {
|
|
298
|
+
warn('ERROR', `could not read the UI at ${uiFile}: ${err.message}`);
|
|
299
|
+
res.writeHead(500, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
300
|
+
res.end(`Could not read the bundled UI (${uiFile}).\n`);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const wss = new WebSocketServer({ noServer: true, clientTracking: false });
|
|
305
|
+
|
|
306
|
+
function hostAllowed(req) {
|
|
307
|
+
const host = req.headers.host;
|
|
308
|
+
if (typeof host !== 'string') return false;
|
|
309
|
+
return allowedHosts.has(host.toLowerCase());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function refuseUpgrade(socket, status, body) {
|
|
313
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
314
|
+
socket.write(
|
|
315
|
+
`HTTP/1.1 ${status}\r\n` +
|
|
316
|
+
'Connection: close\r\n' +
|
|
317
|
+
'Content-Type: text/plain\r\n' +
|
|
318
|
+
`Content-Length: ${bytes.length}\r\n` +
|
|
319
|
+
'\r\n'
|
|
320
|
+
);
|
|
321
|
+
socket.write(bytes);
|
|
322
|
+
socket.destroy();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
326
|
+
if (!hostAllowed(req)) {
|
|
327
|
+
warn('REJECT', `403 upgrade refused — disallowed Host: ${req.headers.host ?? '<absent>'}`);
|
|
328
|
+
refuseUpgrade(socket, '403 Forbidden', BODY_403);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const origin = req.headers.origin; // undefined if the header is absent
|
|
333
|
+
const originLabel = origin === undefined ? '<absent>' : origin;
|
|
334
|
+
|
|
335
|
+
// Stage 1 of the Origin check. Role is not known yet — see threat model.
|
|
336
|
+
const isNullOrigin = origin === undefined || origin === 'null';
|
|
337
|
+
const isClientOrigin = origin !== undefined && clientOrigins.has(origin);
|
|
338
|
+
|
|
339
|
+
if (!isNullOrigin && !isClientOrigin) {
|
|
340
|
+
warn('REJECT', `403 handshake refused — disallowed Origin: ${originLabel}`);
|
|
341
|
+
refuseUpgrade(socket, '403 Forbidden', 'Origin not allowed by bridge relay');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
346
|
+
ws._bridge = {
|
|
347
|
+
id: ++seq,
|
|
348
|
+
origin, // undefined | 'null' | an allowed client origin
|
|
349
|
+
originLabel,
|
|
350
|
+
role: null, // set at hello
|
|
351
|
+
alive: true,
|
|
352
|
+
};
|
|
353
|
+
log('OPEN', `socket #${ws._bridge.id} accepted (Origin: ${originLabel}) — awaiting hello`);
|
|
354
|
+
wss.emit('connection', ws, req);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// Connection handling
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
wss.on('connection', (ws) => {
|
|
363
|
+
const meta = ws._bridge;
|
|
364
|
+
|
|
365
|
+
const helloTimer = setTimeout(() => {
|
|
366
|
+
if (!meta.role) {
|
|
367
|
+
warn('TIMEOUT', `socket #${meta.id} said no hello within ${HELLO_TIMEOUT_MS}ms — closing`);
|
|
368
|
+
closeWith(ws, CLOSE_POLICY, 'no hello');
|
|
369
|
+
}
|
|
370
|
+
}, HELLO_TIMEOUT_MS);
|
|
371
|
+
|
|
372
|
+
ws.on('pong', () => {
|
|
373
|
+
meta.alive = true;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
ws.on('message', (data, isBinary) => {
|
|
377
|
+
if (isBinary) {
|
|
378
|
+
counts.dropped++;
|
|
379
|
+
warn('DROP', `socket #${meta.id} (${meta.role ?? 'unidentified'}) sent a binary frame — ignored`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const raw = data.toString();
|
|
384
|
+
let frame;
|
|
385
|
+
try {
|
|
386
|
+
frame = JSON.parse(raw);
|
|
387
|
+
} catch {
|
|
388
|
+
counts.dropped++;
|
|
389
|
+
warn('DROP', `socket #${meta.id} (${meta.role ?? 'unidentified'}) sent unparseable JSON — ignored`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!frame || typeof frame !== 'object') {
|
|
394
|
+
counts.dropped++;
|
|
395
|
+
warn('DROP', `socket #${meta.id} sent a non-object frame — ignored`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (frame.kind === 'hello') {
|
|
400
|
+
handleHello(ws, frame, raw, helloTimer);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!meta.role) {
|
|
405
|
+
counts.dropped++;
|
|
406
|
+
warn('DROP', `socket #${meta.id} sent '${frame.kind}' before hello — ignored`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Forward the ORIGINAL bytes. The relay does not re-serialise payload.
|
|
411
|
+
if (meta.role === 'plugin') {
|
|
412
|
+
forwardToClients(raw, frame);
|
|
413
|
+
} else {
|
|
414
|
+
forwardToPlugin(raw, frame, meta);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
ws.on('close', (code, reasonBuf) => {
|
|
419
|
+
clearTimeout(helloTimer);
|
|
420
|
+
const reason = reasonBuf?.toString() || 'none';
|
|
421
|
+
if (meta.role === 'plugin' && pluginSocket === ws) {
|
|
422
|
+
pluginSocket = null;
|
|
423
|
+
log('CLOSE', `plugin socket #${meta.id} disconnected (code ${code}, reason: ${reason})`);
|
|
424
|
+
log('NOTIFY', `telling ${clientSockets.size} client(s): plugin is gone`);
|
|
425
|
+
broadcastToClients(status('bridge/plugin-disconnected'));
|
|
426
|
+
} else if (meta.role === 'client') {
|
|
427
|
+
clientSockets.delete(ws);
|
|
428
|
+
log('CLOSE', `client socket #${meta.id} disconnected (code ${code}, reason: ${reason}) — ${clientSockets.size} client(s) left`);
|
|
429
|
+
sendToPlugin(status('bridge/client-detached', { clients: clientSockets.size }));
|
|
430
|
+
} else {
|
|
431
|
+
log('CLOSE', `socket #${meta.id} (${meta.role ?? 'unidentified'}) disconnected (code ${code}, reason: ${reason})`);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
ws.on('error', (err) => {
|
|
436
|
+
warn('ERROR', `socket #${meta.id} (${meta.role ?? 'unidentified'}): ${err.message}`);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Stage 2 of the Origin check plus role registration.
|
|
442
|
+
*/
|
|
443
|
+
function handleHello(ws, frame, raw, helloTimer) {
|
|
444
|
+
const meta = ws._bridge;
|
|
445
|
+
const claimed = frame.role;
|
|
446
|
+
|
|
447
|
+
if (meta.role) {
|
|
448
|
+
warn('DROP', `socket #${meta.id} (${meta.role}) said hello twice — ignored`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// VERSION FIRST. A peer from another protocol generation may not agree with
|
|
453
|
+
// us about anything else in this frame, including what the role names are,
|
|
454
|
+
// so no other rejection may pre-empt this one: the user must be told the
|
|
455
|
+
// actual problem, which is that the two halves are different ages.
|
|
456
|
+
if (frame.v !== PROTOCOL_VERSION) {
|
|
457
|
+
rejectVersion(ws, frame.v, claimed);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (claimed !== 'plugin' && claimed !== 'client') {
|
|
462
|
+
warn('REJECT', `socket #${meta.id} claimed unknown role ${JSON.stringify(claimed)} — closing`);
|
|
463
|
+
closeWith(ws, CLOSE_POLICY, 'unknown role');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Bind the role claim to the handshake Origin. See threat model, stage 2.
|
|
468
|
+
const isNullOrigin = meta.origin === undefined || meta.origin === 'null';
|
|
469
|
+
if (isNullOrigin && claimed !== 'plugin') {
|
|
470
|
+
warn('REJECT', `socket #${meta.id} with null Origin claimed '${claimed}' — only 'plugin' is allowed from a null Origin. Closing.`);
|
|
471
|
+
closeWith(ws, CLOSE_POLICY, "null Origin may only claim role 'plugin'");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!isNullOrigin && claimed !== 'client') {
|
|
475
|
+
warn('REJECT', `socket #${meta.id} from Origin ${meta.originLabel} claimed '${claimed}' — only 'client' is allowed from a browser Origin. Closing.`);
|
|
476
|
+
closeWith(ws, CLOSE_POLICY, "browser Origin may only claim role 'client'");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
clearTimeout(helloTimer);
|
|
481
|
+
meta.role = claimed;
|
|
482
|
+
|
|
483
|
+
if (claimed === 'plugin') {
|
|
484
|
+
if (pluginSocket && pluginSocket !== ws) {
|
|
485
|
+
const old = pluginSocket;
|
|
486
|
+
log('REPLACE', `plugin socket #${old._bridge.id} replaced by #${meta.id} (Figma reloaded the plugin) — closing the old one`);
|
|
487
|
+
// Clear first: the old socket's 'close' handler must not see itself as
|
|
488
|
+
// the current plugin, or it would broadcast a spurious plugin-disconnected.
|
|
489
|
+
pluginSocket = null;
|
|
490
|
+
closeWith(old, CLOSE_REPLACED, 'replaced by a newer plugin connection');
|
|
491
|
+
}
|
|
492
|
+
pluginSocket = ws;
|
|
493
|
+
log('HELLO', `plugin socket #${meta.id} identified (Origin: ${meta.originLabel}) — ${clientSockets.size} client(s) attached`);
|
|
494
|
+
log('NOTIFY', `telling ${clientSockets.size} client(s): plugin is here`);
|
|
495
|
+
broadcastToClients(status('bridge/plugin-connected'));
|
|
496
|
+
if (clientSockets.size > 0) {
|
|
497
|
+
sendToPlugin(status('bridge/client-attached', { clients: clientSockets.size }));
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
clientSockets.add(ws);
|
|
503
|
+
log('HELLO', `client socket #${meta.id} identified (Origin: ${meta.originLabel}) — ${clientSockets.size} client(s) attached`);
|
|
504
|
+
|
|
505
|
+
// (3) The client's hello is forwarded to the plugin VERBATIM. That is the
|
|
506
|
+
// plugin's cue to push a full refresh.
|
|
507
|
+
if (pluginSocket) {
|
|
508
|
+
pluginSocket.send(raw);
|
|
509
|
+
counts.toPlugin++;
|
|
510
|
+
log('FWD', `client #${meta.id} → plugin kind=hello type=${payloadType(frame)} #${counts.toPlugin}`);
|
|
511
|
+
sendToPlugin(status('bridge/client-attached', { clients: clientSockets.size }));
|
|
512
|
+
} else {
|
|
513
|
+
warn('FWD', `client #${meta.id} said hello but NO PLUGIN is connected — the tab will get no data until the Figma plugin window is open`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Tell the new client where things stand, so it can show "lost the document"
|
|
517
|
+
// rather than waiting forever.
|
|
518
|
+
send(ws, status(pluginSocket ? 'bridge/plugin-connected' : 'bridge/plugin-disconnected'));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Turn away a socket from another protocol generation, loudly.
|
|
523
|
+
*
|
|
524
|
+
* Three audiences, because all three are somewhere a user might be looking:
|
|
525
|
+
* - the terminal running the relay (this block),
|
|
526
|
+
* - the rejected peer (the close reason, which its UI surfaces),
|
|
527
|
+
* - any browser tab already attached (the broadcast), which would otherwise
|
|
528
|
+
* just sit there empty with no idea why nothing arrived.
|
|
529
|
+
*/
|
|
530
|
+
function rejectVersion(ws, peerVersion, claimed) {
|
|
531
|
+
const meta = ws._bridge;
|
|
532
|
+
const shown = Number.isInteger(peerVersion) ? `v${peerVersion}` : JSON.stringify(peerVersion);
|
|
533
|
+
const older = Number.isInteger(peerVersion) && peerVersion < PROTOCOL_VERSION ? 'peer' : 'relay';
|
|
534
|
+
const advice =
|
|
535
|
+
older === 'relay'
|
|
536
|
+
? 'This relay is the older half — update it: npm i -g winden-tokens@latest'
|
|
537
|
+
: 'The Figma plugin is the older half — rebuild/reinstall the plugin from a current source tree.';
|
|
538
|
+
|
|
539
|
+
warn('VERSION', `socket #${meta.id} (claimed role: ${JSON.stringify(claimed)}) speaks bridge protocol ${shown}, this relay speaks v${PROTOCOL_VERSION}.`);
|
|
540
|
+
warn('VERSION', advice);
|
|
541
|
+
warn('VERSION', 'Refusing the socket. Nothing will be forwarded until both halves agree.');
|
|
542
|
+
|
|
543
|
+
const message = `Bridge protocol mismatch: the relay speaks v${PROTOCOL_VERSION}, the other side speaks ${shown}. ${advice}`;
|
|
544
|
+
|
|
545
|
+
broadcastToClients(
|
|
546
|
+
status('bridge/version-mismatch', {
|
|
547
|
+
relay: PROTOCOL_VERSION,
|
|
548
|
+
peer: Number.isInteger(peerVersion) ? peerVersion : null,
|
|
549
|
+
message,
|
|
550
|
+
})
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// A close reason is capped at 123 bytes, so it says the essential thing and
|
|
554
|
+
// leaves the advice to the terminal and to the broadcast above.
|
|
555
|
+
closeWith(ws, CLOSE_VERSION, `bridge protocol mismatch: relay v${PROTOCOL_VERSION}, you sent ${shown}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// -------------------------------------------------------------------------
|
|
559
|
+
// Forwarding
|
|
560
|
+
// -------------------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
function forwardToClients(raw, frame) {
|
|
563
|
+
if (clientSockets.size === 0) {
|
|
564
|
+
counts.dropped++;
|
|
565
|
+
log('FWD', `plugin → (no clients) type=${payloadType(frame)} dropped (#${counts.dropped} dropped)`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
let sent = 0;
|
|
569
|
+
for (const client of clientSockets) {
|
|
570
|
+
if (client.readyState === client.OPEN) {
|
|
571
|
+
client.send(raw);
|
|
572
|
+
sent++;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
counts.toClients++;
|
|
576
|
+
log('FWD', `plugin → ${sent} client(s) kind=${frame.kind} type=${payloadType(frame)} #${counts.toClients}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function forwardToPlugin(raw, frame, meta) {
|
|
580
|
+
if (!pluginSocket || pluginSocket.readyState !== pluginSocket.OPEN) {
|
|
581
|
+
counts.dropped++;
|
|
582
|
+
warn('FWD', `client #${meta.id} → plugin type=${payloadType(frame)} DROPPED — no plugin connected (#${counts.dropped} dropped)`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
pluginSocket.send(raw);
|
|
586
|
+
counts.toPlugin++;
|
|
587
|
+
log('FWD', `client #${meta.id} → plugin kind=${frame.kind} type=${payloadType(frame)} #${counts.toPlugin}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// -------------------------------------------------------------------------
|
|
591
|
+
// Helpers
|
|
592
|
+
// -------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
/** Read `payload.type` for logging only. Never used for routing. */
|
|
595
|
+
function payloadType(frame) {
|
|
596
|
+
const p = frame?.payload;
|
|
597
|
+
if (p && typeof p === 'object' && typeof p.type === 'string') return p.type;
|
|
598
|
+
return '<none>';
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function status(type, extra) {
|
|
602
|
+
return {
|
|
603
|
+
v: PROTOCOL_VERSION,
|
|
604
|
+
role: 'relay',
|
|
605
|
+
kind: 'status',
|
|
606
|
+
payload: { type, ...extra },
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function send(ws, frame) {
|
|
611
|
+
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function sendToPlugin(frame) {
|
|
615
|
+
send(pluginSocket, frame);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function broadcastToClients(frame) {
|
|
619
|
+
const raw = JSON.stringify(frame);
|
|
620
|
+
for (const client of clientSockets) {
|
|
621
|
+
if (client.readyState === client.OPEN) client.send(raw);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function closeWith(ws, code, reason) {
|
|
626
|
+
if (!ws) return;
|
|
627
|
+
try {
|
|
628
|
+
ws.close(code, reason);
|
|
629
|
+
} catch {
|
|
630
|
+
/* already gone */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// -------------------------------------------------------------------------
|
|
635
|
+
// Heartbeat — reap half-open sockets (Figma reloads can leave them behind).
|
|
636
|
+
// -------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
const heartbeat = setInterval(() => {
|
|
639
|
+
const sockets = [pluginSocket, ...clientSockets].filter(Boolean);
|
|
640
|
+
for (const ws of sockets) {
|
|
641
|
+
const meta = ws._bridge;
|
|
642
|
+
if (!meta.alive) {
|
|
643
|
+
warn('STALE', `socket #${meta.id} (${meta.role}) missed a heartbeat — terminating`);
|
|
644
|
+
ws.terminate();
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
meta.alive = false;
|
|
648
|
+
try {
|
|
649
|
+
ws.ping();
|
|
650
|
+
} catch {
|
|
651
|
+
/* closing */
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}, HEARTBEAT_MS);
|
|
655
|
+
heartbeat.unref?.();
|
|
656
|
+
|
|
657
|
+
// -------------------------------------------------------------------------
|
|
658
|
+
// Boot
|
|
659
|
+
// -------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
return new Promise((resolve, reject) => {
|
|
662
|
+
httpServer.once('error', (err) => {
|
|
663
|
+
clearInterval(heartbeat);
|
|
664
|
+
reject(err);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
httpServer.listen(port, HOST, () => {
|
|
668
|
+
log('READY', `bridge relay listening on http://${HOST}:${port} (loopback only)`);
|
|
669
|
+
log('READY', uiFile ? `serving the browser UI from ${uiFile}` : 'no browser UI bundled — / returns 404');
|
|
670
|
+
log('READY', `client Origins allowed: ${[...clientOrigins].join(', ')}`);
|
|
671
|
+
if (dev) log('READY', 'DEV MODE — the vite dev origin is on the allowlist');
|
|
672
|
+
log('READY', `plugin role allowed only from a null/absent Origin (the Figma iframe)`);
|
|
673
|
+
log('READY', `bridge protocol v${PROTOCOL_VERSION}`);
|
|
674
|
+
|
|
675
|
+
resolve({
|
|
676
|
+
port,
|
|
677
|
+
url: `http://${HOST}:${port}/`,
|
|
678
|
+
close() {
|
|
679
|
+
log('BYE', `shutting down (${counts.toClients} frames to clients, ${counts.toPlugin} to plugin, ${counts.dropped} dropped)`);
|
|
680
|
+
clearInterval(heartbeat);
|
|
681
|
+
for (const ws of [pluginSocket, ...clientSockets].filter(Boolean)) {
|
|
682
|
+
closeWith(ws, 1001, 'relay shutting down');
|
|
683
|
+
}
|
|
684
|
+
return new Promise((done) => {
|
|
685
|
+
httpServer.close(() => done());
|
|
686
|
+
setTimeout(done, 500).unref();
|
|
687
|
+
});
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
}
|