@iicp/web-node 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.
@@ -0,0 +1,104 @@
1
+ import { type CxPublicKey } from "./cxConfidentiality.js";
2
+ export declare const DEFAULT_DIRECTORY_URL = "https://iicp.network";
3
+ export interface ClientConfig {
4
+ /** Directory base URL. Default: https://iicp.network (CORS-enabled). */
5
+ directory_url?: string;
6
+ /** Per-request timeout (ms). Default 10000. */
7
+ timeout_ms?: number;
8
+ }
9
+ export interface DiscoverOptions {
10
+ region?: string;
11
+ min_reputation?: number;
12
+ /** Max nodes to return (directory caps at 50). */
13
+ limit?: number;
14
+ /** Browser pages should keep only HTTPS/loopback endpoints. Default: true. */
15
+ browser_usable_only?: boolean;
16
+ }
17
+ /** A discoverable provider node (public discovery view — no tokens/endpoints private). */
18
+ export interface Node {
19
+ node_id: string;
20
+ endpoint: string;
21
+ region?: string;
22
+ reputation_score?: number;
23
+ reputation_tier?: string;
24
+ models?: string[];
25
+ directory_observed_reachable?: boolean | null;
26
+ route_evidence?: string;
27
+ routing_hint?: string;
28
+ browser_usable?: boolean;
29
+ [k: string]: unknown;
30
+ }
31
+ export interface ChatMessage {
32
+ role: "system" | "user" | "assistant";
33
+ content: string;
34
+ }
35
+ export declare class IicpError extends Error {
36
+ readonly code: string;
37
+ readonly status?: number | undefined;
38
+ constructor(message: string, code: string, status?: number | undefined);
39
+ }
40
+ /** CIP consumer task envelope — extracted for testability (KAT, parity with @iicp/client). */
41
+ export interface TaskEnvelope {
42
+ task_id: string;
43
+ intent: string;
44
+ constraints: Record<string, unknown>;
45
+ payload: {
46
+ messages: ChatMessage[];
47
+ model?: string;
48
+ };
49
+ }
50
+ /**
51
+ * Build the CIP consumer task envelope. Pure, deterministic (pass task_id for KAT).
52
+ * Wire-protocol parity with the Node SDK: `POST {endpoint}/v1/task` when the caller
53
+ * deliberately builds a plaintext test envelope. Production chat() below is fail-closed
54
+ * and sends an `iicp_conf` envelope only.
55
+ */
56
+ export declare function cipConsumerEnvelope(messages: ChatMessage[], opts?: {
57
+ intent?: string;
58
+ model?: string;
59
+ task_id?: string;
60
+ }): TaskEnvelope;
61
+ /**
62
+ * Build the discover URL. Extracted so the query construction is unit-testable
63
+ * (same discipline as the federation event-log URL regression test).
64
+ */
65
+ export declare function discoverUrl(directoryUrl: string, intent: string, opts?: DiscoverOptions): string;
66
+ /** Browser-native, consumer-only IICP client. */
67
+ export declare class IicpBrowserClient {
68
+ private readonly directory;
69
+ private readonly timeout;
70
+ constructor(cfg?: ClientConfig);
71
+ private getJson;
72
+ /** Discover nodes capable of an intent (GET /v1/discover — CORS-enabled on iicp.network). */
73
+ discover(intent: string, opts?: DiscoverOptions): Promise<Node[]>;
74
+ /** Directory mesh stats (GET /v1/stats), incl. mesh_health + active_nodes. */
75
+ stats(): Promise<Record<string, unknown>>;
76
+ /** Public node registry listing (GET /v1/registry/nodes). */
77
+ registry(): Promise<Record<string, unknown>>;
78
+ /**
79
+ * Route a chat to a node over the node's **HTTP transport**: `POST {endpoint}/v1/task`
80
+ * with the real task body `{ task_id, intent, payload, constraints }` (SDK-01/02 — same
81
+ * shape the @iicp/client Node SDK sends; the node replies `{ task_id, result, status,
82
+ * metrics }`). `payload` for llm:chat is `{ messages, model }`.
83
+ *
84
+ * Transport note: nodes also expose the **native IICP binary protocol on port 9484**
85
+ * (`transport_endpoint: iicp://…`) — more efficient, used by the full SDKs. A browser
86
+ * can't open raw TCP, so this client uses the HTTP transport (the discover `endpoint`).
87
+ *
88
+ * Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
89
+ * advertise `cx_public_key` (or the temporary `public_key` alias) before this
90
+ * helper will send it a task.
91
+ *
92
+ * ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
93
+ * NOT http://localhost LLMs (mixed-content; Chrome 129+ flag only), nor a node without
94
+ * CORS, nor an IPv6-firewalled node (today's live nodes are `ipv6_direct_firewall_required`).
95
+ * Pass a reachable `endpoint`. In Node there is no CORS restriction. Returns the node JSON.
96
+ */
97
+ chat(messages: ChatMessage[], opts: {
98
+ endpoint: string;
99
+ intent?: string;
100
+ model?: string;
101
+ cxPublicKey?: CxPublicKey | null;
102
+ }): Promise<Record<string, unknown>>;
103
+ }
104
+ export declare function maskTunnelUrl(url: string): string;
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // iicpConsumer — the CONSUMER module of @iicp/web-node (discover + encrypted submit).
4
+ //
5
+ // A tiny (zero-runtime-dependency) TypeScript client for the IICP discovery mesh,
6
+ // built on the browser's native fetch / TextEncoder / SubtleCrypto so it embeds
7
+ // directly into a web page (and also runs in Node ≥18). It is the CONSUMER subset
8
+ // of the protocol only — it never registers as a provider and carries no TCP / NAT /
9
+ // relay machinery (that lives in the full @iicp/client Node SDK).
10
+ //
11
+ // Discovery (GET {directory}/v1/discover) works from any https:// page because
12
+ // iicp.network sends CORS headers. Task routing to a discovered node, or to a local
13
+ // LLM, is subject to browser CORS / mixed-content policy — see README "CORS reality".
14
+ //
15
+ // Epic: #446 · Dev: #447 · Research: research/wasm/WASM-1-feasibility.md (#292).
16
+ import { encryptPayload } from "./cxConfidentiality.js";
17
+ /** Intent URN shape — parity with @iicp/client (SDK-02). */
18
+ const INTENT_RE = /^urn:iicp:intent:[a-z0-9_:/-]+$/;
19
+ export const DEFAULT_DIRECTORY_URL = "https://iicp.network";
20
+ export class IicpError extends Error {
21
+ code;
22
+ status;
23
+ constructor(message, code, status) {
24
+ super(message);
25
+ this.code = code;
26
+ this.status = status;
27
+ this.name = "IicpError";
28
+ }
29
+ }
30
+ function validateIntent(intent) {
31
+ if (!INTENT_RE.test(intent)) {
32
+ throw new IicpError(`invalid intent URN: ${intent}`, "invalid_intent");
33
+ }
34
+ }
35
+ /**
36
+ * Build the CIP consumer task envelope. Pure, deterministic (pass task_id for KAT).
37
+ * Wire-protocol parity with the Node SDK: `POST {endpoint}/v1/task` when the caller
38
+ * deliberately builds a plaintext test envelope. Production chat() below is fail-closed
39
+ * and sends an `iicp_conf` envelope only.
40
+ */
41
+ export function cipConsumerEnvelope(messages, opts = {}) {
42
+ const intent = opts.intent ?? "urn:iicp:intent:llm:chat:v1";
43
+ validateIntent(intent);
44
+ const taskId = opts.task_id ??
45
+ (globalThis.crypto?.randomUUID?.() ?? `task-${Date.now()}-${Math.random().toString(16).slice(2)}`);
46
+ return {
47
+ task_id: taskId,
48
+ intent,
49
+ constraints: {},
50
+ payload: { messages, model: opts.model },
51
+ };
52
+ }
53
+ /**
54
+ * Build the discover URL. Extracted so the query construction is unit-testable
55
+ * (same discipline as the federation event-log URL regression test).
56
+ */
57
+ export function discoverUrl(directoryUrl, intent, opts = {}) {
58
+ const params = new URLSearchParams({ intent });
59
+ if (opts.region)
60
+ params.set("region", opts.region);
61
+ if (opts.min_reputation != null)
62
+ params.set("min_reputation", String(opts.min_reputation));
63
+ if (opts.limit != null)
64
+ params.set("limit", String(opts.limit));
65
+ // /api/v1 — the directory API prefix (verified against prod iicp.network 2026-06-04:
66
+ // /api/v1/discover → JSON {nodes,count,query_ms}; a bare /v1/discover hits the website's
67
+ // static HTML fallback, not the directory). Same /api/v1 lesson as the federation work.
68
+ return `${directoryUrl.replace(/\/+$/, "")}/api/v1/discover?${params}`;
69
+ }
70
+ function isBrowserUsableEndpoint(endpoint) {
71
+ try {
72
+ const url = new URL(endpoint);
73
+ if (url.protocol === "https:")
74
+ return true;
75
+ if (url.protocol !== "http:")
76
+ return false;
77
+ return ["localhost", "127.0.0.1", "::1"].includes(url.hostname.toLowerCase());
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ /** Browser-native, consumer-only IICP client. */
84
+ export class IicpBrowserClient {
85
+ directory;
86
+ timeout;
87
+ constructor(cfg = {}) {
88
+ this.directory = (cfg.directory_url ?? DEFAULT_DIRECTORY_URL).replace(/\/+$/, "");
89
+ this.timeout = cfg.timeout_ms ?? 10_000;
90
+ }
91
+ async getJson(url) {
92
+ const ctrl = new AbortController();
93
+ const t = setTimeout(() => ctrl.abort(), this.timeout);
94
+ try {
95
+ const resp = await fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } });
96
+ if (!resp.ok) {
97
+ throw new IicpError(`GET ${url} → ${resp.status}`, "http_error", resp.status);
98
+ }
99
+ return (await resp.json());
100
+ }
101
+ finally {
102
+ clearTimeout(t);
103
+ }
104
+ }
105
+ /** Discover nodes capable of an intent (GET /v1/discover — CORS-enabled on iicp.network). */
106
+ async discover(intent, opts = {}) {
107
+ validateIntent(intent);
108
+ const body = await this.getJson(discoverUrl(this.directory, intent, opts));
109
+ const nodes = Array.isArray(body) ? body : (body.nodes ?? []);
110
+ if (opts.browser_usable_only === false)
111
+ return nodes;
112
+ return nodes.filter((node) => {
113
+ if (typeof node.browser_usable === "boolean")
114
+ return node.browser_usable;
115
+ return isBrowserUsableEndpoint(String(node.endpoint ?? ""));
116
+ });
117
+ }
118
+ /** Directory mesh stats (GET /v1/stats), incl. mesh_health + active_nodes. */
119
+ async stats() {
120
+ return this.getJson(`${this.directory}/api/v1/stats`);
121
+ }
122
+ /** Public node registry listing (GET /v1/registry/nodes). */
123
+ async registry() {
124
+ return this.getJson(`${this.directory}/api/v1/registry/nodes`);
125
+ }
126
+ /**
127
+ * Route a chat to a node over the node's **HTTP transport**: `POST {endpoint}/v1/task`
128
+ * with the real task body `{ task_id, intent, payload, constraints }` (SDK-01/02 — same
129
+ * shape the @iicp/client Node SDK sends; the node replies `{ task_id, result, status,
130
+ * metrics }`). `payload` for llm:chat is `{ messages, model }`.
131
+ *
132
+ * Transport note: nodes also expose the **native IICP binary protocol on port 9484**
133
+ * (`transport_endpoint: iicp://…`) — more efficient, used by the full SDKs. A browser
134
+ * can't open raw TCP, so this client uses the HTTP transport (the discover `endpoint`).
135
+ *
136
+ * Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
137
+ * advertise `cx_public_key` (or the temporary `public_key` alias) before this
138
+ * helper will send it a task.
139
+ *
140
+ * ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
141
+ * NOT http://localhost LLMs (mixed-content; Chrome 129+ flag only), nor a node without
142
+ * CORS, nor an IPv6-firewalled node (today's live nodes are `ipv6_direct_firewall_required`).
143
+ * Pass a reachable `endpoint`. In Node there is no CORS restriction. Returns the node JSON.
144
+ */
145
+ async chat(messages, opts) {
146
+ const intent = opts.intent ?? "urn:iicp:intent:llm:chat:v1";
147
+ validateIntent(intent);
148
+ const taskId = globalThis.crypto?.randomUUID?.() ?? `task-${Date.now()}-${Math.random().toString(16).slice(2)}`;
149
+ const payload = { messages, model: opts.model };
150
+ // IICP-CX S.16: encryption is MANDATORY (privacy-first #360) — no opt-out.
151
+ // Refuse keyless nodes instead of silently regressing to plaintext.
152
+ if (!opts.cxPublicKey) {
153
+ throw new IicpError("IICP-CX confidentiality required: node advertises no cx_public_key/public_key", "cx_required");
154
+ }
155
+ const body = {
156
+ task_id: taskId,
157
+ intent,
158
+ constraints: {},
159
+ iicp_conf: await encryptPayload(payload, opts.cxPublicKey, taskId, intent),
160
+ };
161
+ const ctrl = new AbortController();
162
+ const t = setTimeout(() => ctrl.abort(), this.timeout);
163
+ try {
164
+ const resp = await fetch(`${opts.endpoint.replace(/\/+$/, "")}/v1/task`, {
165
+ method: "POST",
166
+ signal: ctrl.signal,
167
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
168
+ body: JSON.stringify(body),
169
+ });
170
+ if (!resp.ok) {
171
+ throw new IicpError(`task → ${resp.status}`, "node_error", resp.status);
172
+ }
173
+ return (await resp.json());
174
+ }
175
+ finally {
176
+ clearTimeout(t);
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Mask the high-entropy random subdomain of an ephemeral tunnel endpoint for
182
+ * display (#privacy — maintainer 2026-06-12: keep tunnel DNS names private,
183
+ * like node UUIDs are shown only as prefixes). The random label of a
184
+ * *.trycloudflare.com (or other known tunnel-provider) host is a capability
185
+ * secret — anyone who learns it has a direct line to the operator's machine.
186
+ * Routing code keeps the real URL; only human-facing surfaces mask it.
187
+ * Operator-chosen public domains (e.g. iicp.shaal.dev) are left intact.
188
+ */
189
+ const TUNNEL_SUFFIXES = [".trycloudflare.com", ".ngrok.io", ".ngrok-free.app", ".loca.lt"];
190
+ export function maskTunnelUrl(url) {
191
+ try {
192
+ const u = new URL(url);
193
+ const suffix = TUNNEL_SUFFIXES.find((s) => u.hostname.endsWith(s));
194
+ if (!suffix)
195
+ return url;
196
+ return `${u.protocol}//****${suffix}${u.pathname === "/" ? "" : u.pathname}`;
197
+ }
198
+ catch {
199
+ return url;
200
+ }
201
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./iicpConsumer.js";
2
+ export * from "./cxConfidentiality.js";
3
+ export * from "./browserNodeProvider.js";
4
+ export * from "./webllmRuntime.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // @iicp/web-node — browser-native IICP node (consume AND serve), ESM, zero-config.
2
+ //
3
+ // The full browser node: a discovery-mesh CONSUMER (IicpBrowserClient) with MANDATORY
4
+ // end-to-end encryption (IICP-CX S.16 — WebCrypto X25519 + AES-256-GCM, no opt-out), a
5
+ // browser PROVIDER (BrowserNodeProvider — serve a model via WebLLM behind a relay), and
6
+ // the WebLLM runtime helpers. Discovery + consume work from any https:// page; serving
7
+ // needs a relay (see README). Parity with the Python/TS/Rust SDKs on the wire.
8
+ export * from "./iicpConsumer.js";
9
+ export * from "./cxConfidentiality.js";
10
+ export * from "./browserNodeProvider.js";
11
+ export * from "./webllmRuntime.js";
@@ -0,0 +1,140 @@
1
+ import type { ChatMessage } from "./iicpConsumer.js";
2
+ export type { ChatMessage };
3
+ export interface WebLLMProgress {
4
+ /** Human-readable status description (e.g. "Loading model 23%"). */
5
+ text: string;
6
+ /** 0–1 fraction. 1 = fully loaded and ready. */
7
+ progress: number;
8
+ }
9
+ export type ProgressCallback = (p: WebLLMProgress) => void;
10
+ /**
11
+ * Supported model IDs for UI display. Lineup (maintainer 2026-06-12): the two
12
+ * lightest variants run fine on ANY computer ("CPU" tier — integrated
13
+ * graphics, no dedicated card needed), plus one quality model that wants a
14
+ * dedicated GPU. tier drives the CPU/GPU badge on the picker.
15
+ * (Technically WebLLM always executes via WebGPU; the tier expresses the
16
+ * hardware a model needs to run WELL — tooltips carry that nuance.)
17
+ */
18
+ export declare const WEBLLM_MODELS: {
19
+ readonly "Qwen2.5-0.5B-Instruct-q4f32_1-MLC": {
20
+ readonly label: "Qwen 2.5 0.5B";
21
+ readonly sizeMB: 350;
22
+ readonly tier: "cpu";
23
+ readonly description: "Lightest — runs on any computer, no graphics card needed";
24
+ };
25
+ readonly "Llama-3.2-1B-Instruct-q4f32_1-MLC": {
26
+ readonly label: "Llama 3.2 1B";
27
+ readonly sizeMB: 500;
28
+ readonly tier: "cpu";
29
+ readonly description: "Light and capable — fine on laptops with integrated graphics";
30
+ };
31
+ readonly "Llama-3.2-3B-Instruct-q4f32_1-MLC": {
32
+ readonly label: "Llama 3.2 3B";
33
+ readonly sizeMB: 1500;
34
+ readonly tier: "gpu";
35
+ readonly description: "Best quality — needs a dedicated graphics card (~4 GB VRAM)";
36
+ };
37
+ };
38
+ export type WebLLMModelId = keyof typeof WEBLLM_MODELS;
39
+ export declare const DEFAULT_MODEL: WebLLMModelId;
40
+ /**
41
+ * Coarse device-capability assessment for steering the model picker (#517 D4).
42
+ * Phones/tablets and low-memory machines frequently OOM loading even a 1B model,
43
+ * so the UI should nudge toward the lightest model or the WebGPU-free mesh path
44
+ * rather than letting a newcomer pick a model their device can't run.
45
+ *
46
+ * Signals: `navigator.deviceMemory` (GB, Chromium-only, coarse) + a UA mobile
47
+ * check. Best-effort — absence of a signal is treated as "ok".
48
+ */
49
+ export type DeviceClass = "mobile" | "low" | "ok";
50
+ export interface DeviceAssessment {
51
+ deviceClass: DeviceClass;
52
+ deviceMemoryGB: number | null;
53
+ /** Whether the GPU-tier (largest) model is likely to fail on this device. */
54
+ gpuModelRisky: boolean;
55
+ /** Plain-language steer, empty when the device looks capable. */
56
+ note: string;
57
+ }
58
+ export declare function assessDevice(): DeviceAssessment;
59
+ export interface WebGPUCheckResult {
60
+ supported: boolean;
61
+ /** Present when unsupported — human-readable reason. */
62
+ reason?: string;
63
+ }
64
+ /**
65
+ * Detect WebGPU availability. Safe to call server-side (returns unsupported).
66
+ * Chrome 113+, Firefox nightly, Safari Technology Preview.
67
+ */
68
+ export declare function detectWebGPU(): WebGPUCheckResult;
69
+ /**
70
+ * Async WebGPU probe — the REAL availability signal. `detectWebGPU()` only
71
+ * checks that `navigator.gpu` exists, which some browsers expose while still
72
+ * being unable to run a model (e.g. Firefox surfaces `navigator.gpu` but has no
73
+ * usable adapter unless `dom.webgpu.enabled` is set, producing a false-positive
74
+ * "WebGPU available" badge — #518). This calls `requestAdapter()` to confirm a
75
+ * usable GPU, so UI that gates on it reflects reality, not just API presence.
76
+ */
77
+ export declare function probeWebGPU(): Promise<WebGPUCheckResult>;
78
+ /**
79
+ * Classify a load-failure message as out-of-memory (#517 D4). OOM is the most
80
+ * common real-device failure (phones / integrated GPUs); detecting it lets the
81
+ * UI steer to a smaller model or the mesh instead of a generic error. Matches
82
+ * the strings WebGPU/WebLLM/browsers surface for memory exhaustion.
83
+ */
84
+ export declare function isOutOfMemoryError(message: string): boolean;
85
+ export declare class WebLLMError extends Error {
86
+ readonly code: "webgpu_unavailable" | "not_loaded" | "load_failed" | "out_of_memory" | "inference_failed" | "bad_response";
87
+ constructor(message: string, code: "webgpu_unavailable" | "not_loaded" | "load_failed" | "out_of_memory" | "inference_failed" | "bad_response");
88
+ }
89
+ /**
90
+ * In-browser LLM runtime wrapping @mlc-ai/web-llm.
91
+ *
92
+ * Lifecycle: construct → canRun() check → load() → chat() → unload()
93
+ *
94
+ * Model weights are downloaded once and cached in IndexedDB by the WebLLM
95
+ * engine (automatic, per-origin). Subsequent loads skip the download.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const rt = new WebLLMRuntime();
100
+ * if (!rt.canRun()) return; // WebGPU unavailable
101
+ * await rt.load('Llama-3.2-1B-Instruct-q4f32_1-MLC', p => setProgress(p));
102
+ * const reply = await rt.chat([{ role: 'user', content: 'Hello' }]);
103
+ * ```
104
+ */
105
+ export declare class WebLLMRuntime {
106
+ private _engine;
107
+ private _modelId;
108
+ /** True if WebGPU is available in this browser/environment. */
109
+ canRun(): boolean;
110
+ /** True if a model is currently loaded and ready for inference. */
111
+ isLoaded(): boolean;
112
+ /** Currently loaded model ID, or null if not loaded. */
113
+ get modelId(): WebLLMModelId | null;
114
+ /**
115
+ * Load a model. First call downloads weights (~300–1500 MB) and caches them
116
+ * in IndexedDB. Subsequent calls with the same model skip the download.
117
+ *
118
+ * @param model Model ID from WEBLLM_MODELS (default: Llama 3.2 1B q4)
119
+ * @param onProgress Optional progress callback (text, 0–1 fraction)
120
+ * @throws WebLLMError If WebGPU is unavailable or model load fails
121
+ */
122
+ load(model?: WebLLMModelId, onProgress?: ProgressCallback): Promise<void>;
123
+ /**
124
+ * CIP-compatible chat inference. Returns the assistant reply text.
125
+ * Provider glue (#452) calls this to satisfy llm:chat intent tasks.
126
+ * The model is fixed at load() time — pass a different modelId to load() to switch.
127
+ *
128
+ * @param messages Chat history (system/user/assistant turns)
129
+ * @throws WebLLMError If no model is loaded or inference fails
130
+ */
131
+ chat(messages: ChatMessage[], opts?: {
132
+ temperature?: number;
133
+ max_tokens?: number;
134
+ }): Promise<string>;
135
+ /**
136
+ * Unload the model and release GPU memory.
137
+ * Safe to call when not loaded (no-op).
138
+ */
139
+ unload(): Promise<void>;
140
+ }