@iicp/web-node 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/browserNodeProvider.d.ts +7 -2
- package/dist/browserNodeProvider.js +88 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,12 @@ Browser providers advertise a generated `cx_public_key`, decrypt incoming `iicp_
|
|
|
47
47
|
payloads locally, report task success/failure/latency in heartbeats, and use a relay path
|
|
48
48
|
because browser tabs cannot accept raw inbound TCP.
|
|
49
49
|
|
|
50
|
+
Startup is intentionally ticketed: the provider registers, requests a short-lived relay
|
|
51
|
+
bind ticket scoped to its worker and selected relay, and presents that ticket when it binds.
|
|
52
|
+
Authentication or validation failures stop serving and clean up the temporary directory
|
|
53
|
+
registration instead of silently falling back to an unsigned bind. Compatibility fallback
|
|
54
|
+
is limited to directories that explicitly report that the ticket service is unavailable.
|
|
55
|
+
|
|
50
56
|
Provider state includes deterministic recovery diagnostics (`stable`, `tunnel_starting`,
|
|
51
57
|
`route_mismatch`, `operator_action_needed`, or `unavailable`) so a host page can explain
|
|
52
58
|
whether it is directory-listed and relay-bound. Browser lifetime still matters: closing or
|
|
@@ -8,6 +8,8 @@ export interface BrowserProviderRuntime {
|
|
|
8
8
|
export interface BrowserProviderConfig {
|
|
9
9
|
/** Relay node base URL, e.g. "http://127.0.0.1:9484". Required. */
|
|
10
10
|
relayUrl: string;
|
|
11
|
+
/** Auto-discovered relay node id. Used to audience-scope relay bind tickets. */
|
|
12
|
+
relayNodeId?: string;
|
|
11
13
|
/** Directory API base. Default: "https://iicp.network/api". */
|
|
12
14
|
directoryUrl?: string;
|
|
13
15
|
/** Model name advertised to the directory (the loaded WebLLM model id). */
|
|
@@ -29,7 +31,7 @@ export interface BrowserProviderDiagnostic {
|
|
|
29
31
|
relay_bound: boolean;
|
|
30
32
|
tasks_served: number;
|
|
31
33
|
}
|
|
32
|
-
export declare const BROWSER_NODE_SDK_VERSION = "0.7.
|
|
34
|
+
export declare const BROWSER_NODE_SDK_VERSION = "0.7.86-browser";
|
|
33
35
|
/**
|
|
34
36
|
* Coarse region autodetect from the browser's timezone (no network, no
|
|
35
37
|
* geolocation permission). Matches the mesh's region convention
|
|
@@ -76,10 +78,13 @@ export declare class BrowserNodeProvider {
|
|
|
76
78
|
private log;
|
|
77
79
|
private setState;
|
|
78
80
|
private setRecovery;
|
|
79
|
-
/** Register → bind →
|
|
81
|
+
/** Register → obtain bind ticket → bind → serve. */
|
|
80
82
|
start(): Promise<void>;
|
|
83
|
+
private register;
|
|
84
|
+
private fetchBindTicket;
|
|
81
85
|
/** Unbind from the relay and deregister from the directory. */
|
|
82
86
|
stop(): Promise<void>;
|
|
87
|
+
private deregisterQuietly;
|
|
83
88
|
private unbindQuietly;
|
|
84
89
|
private heartbeat;
|
|
85
90
|
private pollLoop;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { maskTunnelUrl } from "./iicpConsumer.js";
|
|
20
20
|
import { createCxKeyPair, decryptPayload } from "./cxConfidentiality.js";
|
|
21
21
|
const CHAT_INTENT = "urn:iicp:intent:llm:chat:v1";
|
|
22
|
-
export const BROWSER_NODE_SDK_VERSION = "0.7.
|
|
22
|
+
export const BROWSER_NODE_SDK_VERSION = "0.7.86-browser";
|
|
23
23
|
/**
|
|
24
24
|
* Coarse region autodetect from the browser's timezone (no network, no
|
|
25
25
|
* geolocation permission). Matches the mesh's region convention
|
|
@@ -193,15 +193,25 @@ export class BrowserNodeProvider {
|
|
|
193
193
|
this._recoveryAction = action;
|
|
194
194
|
this.cfg.onRecoveryChange?.(this.diagnostic);
|
|
195
195
|
}
|
|
196
|
-
/** Register → bind →
|
|
196
|
+
/** Register → obtain bind ticket → bind → serve. */
|
|
197
197
|
async start() {
|
|
198
198
|
if (this._state === "serving" || this._state === "starting")
|
|
199
199
|
return;
|
|
200
200
|
this._stopRequested = false;
|
|
201
201
|
this.setState("starting");
|
|
202
202
|
this.setRecovery("tunnel_starting", "wait_cooldown");
|
|
203
|
-
|
|
204
|
-
//
|
|
203
|
+
const endpoint = `${this.relayBase}/v1/relay-for/${this.nodeId}`;
|
|
204
|
+
// 1. Register first. Strict relays require a directory-issued bind ticket,
|
|
205
|
+
// which in turn requires the worker's node token. A failed later bind is
|
|
206
|
+
// immediately cleaned up so this ordering does not leave stale listings.
|
|
207
|
+
await this.register(endpoint);
|
|
208
|
+
// 2. Obtain a short-lived worker/audience-scoped bind ticket. Only an
|
|
209
|
+
// explicit older-directory response may use the legacy soft-bind path.
|
|
210
|
+
let bindTicket = "";
|
|
211
|
+
if (this._nodeToken) {
|
|
212
|
+
bindTicket = await this.fetchBindTicket();
|
|
213
|
+
}
|
|
214
|
+
// 3. Bind to the relay, presenting the ticket whenever one was issued.
|
|
205
215
|
let bindResp;
|
|
206
216
|
try {
|
|
207
217
|
bindResp = await fetch(`${this.relayBase}/v1/relay/bind`, {
|
|
@@ -211,25 +221,37 @@ export class BrowserNodeProvider {
|
|
|
211
221
|
worker_id: this.nodeId,
|
|
212
222
|
intent: CHAT_INTENT,
|
|
213
223
|
models: [this.cfg.model],
|
|
224
|
+
...(bindTicket ? { bind_ticket: bindTicket } : {}),
|
|
214
225
|
}),
|
|
215
226
|
});
|
|
216
227
|
}
|
|
217
228
|
catch (err) {
|
|
218
229
|
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
219
230
|
this.setState("error");
|
|
231
|
+
await this.deregisterQuietly();
|
|
220
232
|
throw new BrowserProviderError(`relay unreachable at ${maskTunnelUrl(this.relayBase)}: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
221
233
|
}
|
|
222
234
|
if (!bindResp.ok) {
|
|
223
235
|
this.setState("error");
|
|
224
236
|
const detail = await bindResp.text().catch(() => "");
|
|
237
|
+
await this.deregisterQuietly();
|
|
225
238
|
throw new BrowserProviderError(`relay bind failed: HTTP ${bindResp.status} ${detail.slice(0, 200)}`, "bind");
|
|
226
239
|
}
|
|
227
240
|
const bind = await bindResp.json();
|
|
228
241
|
this._sessionToken = bind.session_token;
|
|
229
242
|
this.setRecovery("route_mismatch", "reregister");
|
|
230
243
|
this.log(`relay bound — worker ${this.nodeId}`);
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
if (this.directoryListed)
|
|
245
|
+
this.setRecovery("stable", "none");
|
|
246
|
+
// 4. Heartbeat (only when directory-listed) + poll loop.
|
|
247
|
+
if (this.directoryListed) {
|
|
248
|
+
void this.heartbeat();
|
|
249
|
+
this._heartbeatTimer = setInterval(() => void this.heartbeat(), HEARTBEAT_MS);
|
|
250
|
+
}
|
|
251
|
+
this.setState("serving");
|
|
252
|
+
void this.pollLoop();
|
|
253
|
+
}
|
|
254
|
+
async register(endpoint) {
|
|
233
255
|
try {
|
|
234
256
|
const regResp = await fetch(`${this.directoryBase}/v1/register`, {
|
|
235
257
|
method: "POST",
|
|
@@ -274,7 +296,6 @@ export class BrowserNodeProvider {
|
|
|
274
296
|
if (!this._nodeToken)
|
|
275
297
|
throw new Error("directory returned no node_token");
|
|
276
298
|
this.directoryListed = true;
|
|
277
|
-
this.setRecovery("stable", "none");
|
|
278
299
|
this.log(`registered with directory as ${this.nodeId}`);
|
|
279
300
|
}
|
|
280
301
|
catch (err) {
|
|
@@ -286,16 +307,45 @@ export class BrowserNodeProvider {
|
|
|
286
307
|
this.setRecovery("route_mismatch", "reregister");
|
|
287
308
|
this.log(`directory registration rejected — serving relay-only (not discoverable): ${err instanceof Error ? err.message : String(err)}`);
|
|
288
309
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
310
|
+
}
|
|
311
|
+
async fetchBindTicket() {
|
|
312
|
+
let resp;
|
|
313
|
+
try {
|
|
314
|
+
resp = await fetch(`${this.directoryBase}/v1/relay/ticket`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Bearer ${this._nodeToken}`,
|
|
318
|
+
"Content-Type": "application/json",
|
|
319
|
+
// Opaque node tokens need an explicit subject hint; JWT-backed
|
|
320
|
+
// clients can be resolved from their claims, but browser providers
|
|
321
|
+
// intentionally use the portable opaque registration credential.
|
|
322
|
+
"X-Node-Id": this.nodeId,
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({ relay_node_id: this.cfg.relayNodeId ?? "*" }),
|
|
325
|
+
});
|
|
296
326
|
}
|
|
297
|
-
|
|
298
|
-
|
|
327
|
+
catch (err) {
|
|
328
|
+
await this.deregisterQuietly();
|
|
329
|
+
this.setState("error");
|
|
330
|
+
throw new BrowserProviderError(`relay bind ticket request failed: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
331
|
+
}
|
|
332
|
+
if (resp.ok) {
|
|
333
|
+
const body = await resp.json();
|
|
334
|
+
if (typeof body.ticket !== "string" || !body.ticket) {
|
|
335
|
+
await this.deregisterQuietly();
|
|
336
|
+
throw new BrowserProviderError("directory returned no relay bind ticket", "bind");
|
|
337
|
+
}
|
|
338
|
+
return body.ticket;
|
|
339
|
+
}
|
|
340
|
+
const detail = await resp.text().catch(() => "");
|
|
341
|
+
const legacy = resp.status === 404 || (resp.status === 503 && detail.includes("not_configured"));
|
|
342
|
+
if (legacy) {
|
|
343
|
+
this.log("directory has no relay bind-ticket service — trying legacy soft bind");
|
|
344
|
+
return "";
|
|
345
|
+
}
|
|
346
|
+
await this.deregisterQuietly();
|
|
347
|
+
this.setState("error");
|
|
348
|
+
throw new BrowserProviderError(`relay bind ticket refused: HTTP ${resp.status} ${detail.slice(0, 200)}`, "bind");
|
|
299
349
|
}
|
|
300
350
|
/** Unbind from the relay and deregister from the directory. */
|
|
301
351
|
async stop() {
|
|
@@ -305,22 +355,30 @@ export class BrowserNodeProvider {
|
|
|
305
355
|
this._heartbeatTimer = null;
|
|
306
356
|
}
|
|
307
357
|
await this.unbindQuietly();
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
await fetch(`${this.directoryBase}/v1/register`, {
|
|
311
|
-
method: "DELETE",
|
|
312
|
-
headers: { Authorization: `Bearer ${this._nodeToken}` },
|
|
313
|
-
keepalive: true,
|
|
314
|
-
});
|
|
315
|
-
this.log("deregistered from directory");
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
// best-effort — heartbeat expiry cleans up server-side
|
|
319
|
-
}
|
|
320
|
-
this._nodeToken = "";
|
|
321
|
-
}
|
|
358
|
+
await this.deregisterQuietly();
|
|
322
359
|
this.setState("stopped");
|
|
323
360
|
}
|
|
361
|
+
async deregisterQuietly() {
|
|
362
|
+
if (!this._nodeToken)
|
|
363
|
+
return;
|
|
364
|
+
try {
|
|
365
|
+
await fetch(`${this.directoryBase}/v1/register`, {
|
|
366
|
+
method: "DELETE",
|
|
367
|
+
headers: {
|
|
368
|
+
Authorization: `Bearer ${this._nodeToken}`,
|
|
369
|
+
// Opaque registration credentials carry no subject claim.
|
|
370
|
+
"X-Node-Id": this.nodeId,
|
|
371
|
+
},
|
|
372
|
+
keepalive: true,
|
|
373
|
+
});
|
|
374
|
+
this.log("deregistered from directory");
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// best-effort — heartbeat expiry cleans up server-side
|
|
378
|
+
}
|
|
379
|
+
this._nodeToken = "";
|
|
380
|
+
this.directoryListed = false;
|
|
381
|
+
}
|
|
324
382
|
async unbindQuietly() {
|
|
325
383
|
if (!this._sessionToken)
|
|
326
384
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iicp/web-node",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Browser-native IICP node (consume + serve): discovery-mesh client with mandatory E2E encryption (IICP-CX) + WebLLM provider. Zero-config, ESM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|