@nekuda/webmcp-sdk 0.4.0-dev.7.3

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,208 @@
1
+ /**
2
+ * Anonymous visitor/session tracking for WebMCP tool calls. Opt-in via the
3
+ * `tracking` option on `registerTools`.
4
+ *
5
+ * This module owns three things: anonymous identity, event payload assembly
6
+ * (`buildEventPayload` + `boundEventPayload`), and emission (`track`).
7
+ *
8
+ * Identity is intentionally minimal and PII-free: random UUIDs only, namespaced
9
+ * per `apiKey` (a non-reversible hash — the raw key never lands in storage) so
10
+ * two tenants sharing an origin never mix identity. Event *payloads* are a
11
+ * separate matter — page URL, tool `input`, and `response` are captured verbatim
12
+ * and may carry PII; redaction is a downstream/backend concern.
13
+ *
14
+ * - `visitorId` lives in `localStorage` and is stable across visits.
15
+ * - `sessionId` lives in `sessionStorage` and is refreshed by a `last_seen`
16
+ * timestamp; after `SESSION_TIMEOUT_MS` of inactivity a new session is minted.
17
+ *
18
+ * Browser storage can be absent (non-DOM env) or throw (privacy mode). Every
19
+ * access is guarded, and on failure the module falls back to per-page-load
20
+ * in-memory IDs so identity resolution never throws.
21
+ *
22
+ * Consent + outputs: *this channel* is silent by default and `disabled: true` is
23
+ * its hard consent gate — but it is not an SDK-wide one. The default-on usage
24
+ * telemetry channel (`src/telemetry.ts`) emits derived page and tool signals with
25
+ * no `apiKey`, has its own opt-outs (`telemetry: false`, `__WEBMCP_TELEMETRY__`,
26
+ * and GPC), and touches no browser storage at all — so `disabled` does not narrow
27
+ * it in any way. The identity below is this channel's alone. Otherwise the
28
+ * two outputs here are independent and composable —
29
+ * `apiKey` enables the backend transport (`sendToCollect`) and `otel: true`
30
+ * enables OTEL LogRecords (`emitOtelLog`), both in `src/transport.ts`. With
31
+ * neither configured `track()` builds nothing at all: no identity resolution,
32
+ * no storage access, no output. Emission is wrapped end-to-end so telemetry can
33
+ * never throw into the caller.
34
+ */
35
+ import { type CollectConfig } from "./transport.js";
36
+ /**
37
+ * 32-bit FNV-1a over UTF-16 code units, rendered as 8 lowercase hex chars. Not
38
+ * a security primitive — a cheap, dependency-free way to derive a stable,
39
+ * non-reversible key from a caller-supplied string (storage namespaces here,
40
+ * tool schema hashes in the telemetry channel). Collisions are possible and
41
+ * acceptable for both uses.
42
+ */
43
+ export declare function fnv1a(input: string): string;
44
+ /**
45
+ * Stable, non-reversible storage namespace derived from the `apiKey` so one key
46
+ * ≈ one site keeps its own visitor/session identity without the customer ever
47
+ * knowing their `org_id` (server-injected) and without exposing the key itself
48
+ * in `localStorage`. Not for security, only to separate tenants sharing an
49
+ * origin. An absent/empty key falls back to a shared constant namespace.
50
+ */
51
+ export declare function storageNamespace(apiKey?: string): string;
52
+ /**
53
+ * A v4 UUID that resolves in every context. `crypto.randomUUID` is
54
+ * secure-context-only — `undefined` on plain `http://` pages — so we fall back
55
+ * to `crypto.getRandomValues` (available in insecure contexts) and finally
56
+ * {@link fillPseudoRandom}, preserving this module's "identity resolution never
57
+ * throws" guarantee even on non-HTTPS sites.
58
+ */
59
+ export declare function randomId(): string;
60
+ /** Stable anonymous visitor ID, persisted in `localStorage` across visits. */
61
+ export declare function getOrCreateVisitorId(namespace: string): string;
62
+ /**
63
+ * Journey session ID in `sessionStorage`. Reuses the existing session while
64
+ * `last_seen` is within the timeout, otherwise mints a new one. Every call
65
+ * refreshes `last_seen`.
66
+ */
67
+ export declare function getOrCreateSessionId(namespace: string): string;
68
+ /**
69
+ * Configures tool-call tracking for a `registerTools` batch.
70
+ *
71
+ * `apiKey` is the sole tenant credential: it enables the backend transport
72
+ * (POST to the collect endpoint, authenticated via the `x-api-key` header) and
73
+ * namespaces identity storage; the backend resolves it to the `org_id`, which
74
+ * the client never sends or knows. `otel: true` emits each event as an OTEL
75
+ * LogRecord via the global `LoggerProvider`. These two outputs are independent
76
+ * and composable. `endpoint` overrides the backend URL. `disabled: true` is the
77
+ * hard consent gate for both of them — it does not affect the default-on
78
+ * telemetry channel at all, see the module comment. With neither `apiKey` nor
79
+ * `otel` set, no event is built at all.
80
+ */
81
+ export interface TrackingOptions {
82
+ /** Presence (non-empty) enables the backend transport and namespaces storage. */
83
+ apiKey?: string;
84
+ /** Override the collect endpoint; ignored without `apiKey`. */
85
+ endpoint?: string;
86
+ /** Emit each event as an OTEL LogRecord via the global `LoggerProvider`. */
87
+ otel?: boolean;
88
+ /**
89
+ * Consent gate for this channel: nothing is built or emitted here. It does not
90
+ * reach the default-on telemetry channel, which resolves no identity and touches
91
+ * no storage — `telemetry: false` on `registerTools` is that channel's opt-out.
92
+ */
93
+ disabled?: boolean;
94
+ }
95
+ /**
96
+ * Which of this channel's two outputs these options actually enable, after the
97
+ * consent gate. The single source of truth for "is this channel live", because
98
+ * three call sites need exactly this answer and must not drift: {@link track}
99
+ * fans out to the enabled outputs, `register.ts` skips building a per-call
100
+ * emitter (and the `callId` behind it) when neither is live, and telemetry's
101
+ * `config.*` reports what this channel is *doing*.
102
+ *
103
+ * Reads a caller-supplied object, so it is guarded: a hostile `apiKey` getter
104
+ * means "no outputs", never a throw into tool execution or page load.
105
+ */
106
+ export declare function trackingOutputs(options?: TrackingOptions): {
107
+ toBackend: boolean;
108
+ toOtel: boolean;
109
+ };
110
+ /** Best-effort page context on a tracking event; this channel's alone. */
111
+ export interface PageFields {
112
+ siteOrigin?: string;
113
+ url?: string;
114
+ path?: string;
115
+ referrer?: string;
116
+ title?: string;
117
+ }
118
+ /**
119
+ * A single tracking event. Identity + timing fields are always present; page
120
+ * fields are best-effort (absent in a non-DOM env); event-specific data (e.g.
121
+ * `toolStableKey`, `input`, `response`) is spread in by the caller.
122
+ */
123
+ export interface TrackingEvent extends PageFields {
124
+ /** Dedup key, unique per event. */
125
+ eventId: string;
126
+ visitorId: string;
127
+ sessionId: string;
128
+ eventName: string;
129
+ /** Event time as an ISO-8601 string. */
130
+ ts: string;
131
+ [key: string]: unknown;
132
+ }
133
+ /**
134
+ * The error's message, or its string form for a non-`Error` throw. Both a throwing
135
+ * `message` getter and a throwing `toString` are possible on a value the page threw
136
+ * at us, so both are guarded; an unreadable value reports as empty rather than
137
+ * breaking event assembly — or, worse, replacing the tool's own error with ours.
138
+ *
139
+ * Shared by both channels, but only one sends it raw: the authenticated one reports
140
+ * it as `error` on the response event, sliced by the 64 KB bound downstream. The
141
+ * default-on one carries no raw message at all — this is only the input to
142
+ * `errorSignature`, which templates and caps it where it is read.
143
+ */
144
+ export declare function errorMessage(error: unknown): string;
145
+ /** Assemble a tracking event from resolved identity, an event name, and extra data. */
146
+ export declare function buildEventPayload(params: {
147
+ visitorId: string;
148
+ sessionId: string;
149
+ eventName: string;
150
+ data?: Record<string, unknown>;
151
+ }): TrackingEvent;
152
+ /**
153
+ * Truncate a string so its JSON-escaped form fits in `maxBytes` UTF-8 bytes,
154
+ * without splitting a code point. The event size bound (schema §1.5) is on the
155
+ * *serialized* event, so a raw byte slice is not enough: `"`/`\`/control chars
156
+ * expand under escaping, so a 16 KB raw slice of escapable text can serialize
157
+ * to ~96 KB and blow the bound on its own. Measure the escaped length and
158
+ * binary-search the longest byte-prefix that still fits.
159
+ *
160
+ * Exported because the default-on telemetry channel (`src/telemetry.ts`) caps every
161
+ * string at its source — brands, error signatures, per-tool identities — since the
162
+ * ladder below reaches none of them.
163
+ */
164
+ export declare function sliceToBytes(value: string, maxBytes: number): string;
165
+ /**
166
+ * Serialized UTF-8 byte size of a value, or `undefined` when it cannot be
167
+ * serialized (a circular structure). Exported because the default-on telemetry
168
+ * channel reports a tool result's size as `response.bytes` instead of its content
169
+ * — the same measurement the truncation ladder below makes its decisions on, so
170
+ * one implementation keeps the reported size and the enforced bound in agreement.
171
+ */
172
+ export declare function serializedBytes(value: unknown): number | undefined;
173
+ /**
174
+ * Keep a serialized event under the schema §1.5 64 KB bound. Fields are dropped
175
+ * in order — `response`, then `input`, then `error` (a string sliced to 16 KB,
176
+ * anything else replaced) — each replaced by a `{ __truncated: true,
177
+ * originalBytes }` marker, and only as far as needed to fit. Unserializable
178
+ * (circular) payload fields are always replaced with a marker. Then the telemetry
179
+ * channel's `tools[]` degrades in two steps of its own ({@link boundTools}). As a
180
+ * final guarantee, every caller-uncontrolled page string ({@link pageFields}) is
181
+ * sliced to a byte cap so a giant URL alone can't exceed the bound. Never
182
+ * throws: on any failure the original event is returned unchanged.
183
+ *
184
+ * Generic over the event shape so one bound serves both channels and the returned
185
+ * event keeps its type. The rungs are per-field, and a rung whose field the event
186
+ * does not carry is a no-op: `tool_registration` reaches only the `tools` one, and
187
+ * `tool_call` reaches none of them — every string it carries is capped where it is
188
+ * read, and its `response` is metrics rather than the result itself.
189
+ */
190
+ export declare function boundEventPayload<T extends Record<string, unknown>>(event: T): T;
191
+ /**
192
+ * The two independent tracking outputs. Injectable so orchestration can be
193
+ * tested without touching the network or the OTEL packages (spec.ts pattern).
194
+ */
195
+ export interface TrackingSinks {
196
+ sendToCollect: (event: TrackingEvent, config: CollectConfig) => void;
197
+ emitOtelLog: (event: TrackingEvent) => void;
198
+ }
199
+ /**
200
+ * Emit a tracking event. Silent by default: a no-op when `disabled`, and when
201
+ * neither `apiKey` nor `otel` is configured it builds nothing at all — no
202
+ * identity resolution, no storage access, no output. Otherwise it resolves
203
+ * identity, builds the payload, bounds it to the 64 KB schema limit, and fans
204
+ * it out to each enabled output (backend transport and/or OTEL), both of which
205
+ * are themselves best-effort. Wrapped end-to-end in try/catch so tracking can
206
+ * never break the caller (tool execution owns correctness, not telemetry).
207
+ */
208
+ export declare function track(options: TrackingOptions, eventName: string, data?: Record<string, unknown>, sinks?: TrackingSinks): void;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Backend transport for tracking events (schema §1). Off unless an `apiKey` is
3
+ * configured; then `sendToCollect` POSTs each bounded event to the collect
4
+ * endpoint, fire-and-forget, one request per event — no batching or retries in
5
+ * v0.
6
+ *
7
+ * Transport is `fetch(..., { keepalive: true })` — unload-safe and, unlike
8
+ * `sendBeacon`, able to set custom headers. Auth travels as the `x-api-key`
9
+ * **header** (never a query param or body field): the deployed API Gateway
10
+ * authorizer's identity source is that header, so a request without it is
11
+ * rejected 401 before the authorizer runs, and keeping the key out of the URL
12
+ * keeps it out of referrer logs and out of the canonical `payload` column. The
13
+ * event JSON is the bare POST body; `org_id` is server-injected from the key.
14
+ *
15
+ * The scope is injectable for tests (spec.ts pattern). Every browser global is
16
+ * guarded and the whole function is wrapped so telemetry never throws into the
17
+ * caller.
18
+ *
19
+ * `sendTelemetry` is the sibling sender for the default-on usage-telemetry
20
+ * channel (`src/telemetry.ts`): same keepalive/fire-and-forget discipline and the
21
+ * same `x-api-key` header *when the page configured a key*, on a distinct
22
+ * `/v1/telemetry` path that accepts the request either way. That channel runs on
23
+ * pages that never key the authenticated one, so a keyless send is the normal
24
+ * case, not a failure — the backend attributes those by CORS `Origin` instead.
25
+ *
26
+ * The second, independent output is OTEL (`emitOtelLog`): each event becomes a
27
+ * LogRecord on the global `LoggerProvider` via the optional peer dep
28
+ * `@opentelemetry/api-logs`. The host app owns exporters and processing; a
29
+ * missing package or unregistered provider is a permanent silent no-op.
30
+ */
31
+ import type { TrackingEvent } from "./tracking.js";
32
+ /** Default ingest endpoint; the base host is baked per flavor (see {@link INGEST_BASE}). */
33
+ export declare const DEFAULT_COLLECT_ENDPOINT: string;
34
+ /**
35
+ * Default ingest endpoint for the default-on usage-telemetry channel — a
36
+ * **separate path** from {@link DEFAULT_COLLECT_ENDPOINT} so the backend can route
37
+ * the two channels independently: one authorizer-protected, this one unauthenticated
38
+ * by default and accepting the same request with an optional `x-api-key` for tenant
39
+ * attribution. Same per-flavor base host (see {@link INGEST_BASE}).
40
+ */
41
+ export declare const DEFAULT_TELEMETRY_ENDPOINT: string;
42
+ /** Backend transport config; `apiKey` presence is what enables this output. */
43
+ export interface CollectConfig {
44
+ apiKey: string;
45
+ /** Override the ingest endpoint; defaults to {@link DEFAULT_COLLECT_ENDPOINT}. */
46
+ endpoint?: string;
47
+ }
48
+ /**
49
+ * POST a tracking event to the collect endpoint via `fetch(keepalive)`, sending
50
+ * the key as the `x-api-key` header. Never throws: any failure is swallowed and
51
+ * a no-`fetch` environment is a silent no-op.
52
+ */
53
+ export declare function sendToCollect(event: TrackingEvent, config: CollectConfig, scope?: object): void;
54
+ /**
55
+ * POST a usage-telemetry event to the telemetry endpoint via `fetch(keepalive)`.
56
+ * The path is the same whether or not `apiKey` is given: the header only *adds*
57
+ * tenant attribution to an event the backend would accept anyway, so a page that
58
+ * never configures the authenticated channel keeps sending, and the backend falls
59
+ * back to the CORS `Origin` header. Never throws: any failure is swallowed and a
60
+ * no-`fetch` environment is a silent no-op.
61
+ *
62
+ * `apiKey` is `unknown` because it originates in caller-supplied options that no
63
+ * compiler checked; anything that is not a non-blank string sends unauthenticated
64
+ * rather than putting `"undefined"` (or a hostile object's `toString`) on the
65
+ * wire. Note that adding the header makes the request non-simple under CORS, so
66
+ * an authenticated beacon costs a preflight the anonymous one does not.
67
+ *
68
+ * The event stays a bare `object` here so this module never has to import the
69
+ * assembled shape from `telemetry.ts` (which imports this function).
70
+ */
71
+ export declare function sendTelemetry(event: object, scope?: object, endpoint?: string, apiKey?: unknown): void;
72
+ /** The `Logger.emit` slice we use — structural, no hard OTEL type coupling. */
73
+ interface LoggerLike {
74
+ emit(record: {
75
+ severityNumber?: number;
76
+ body?: unknown;
77
+ attributes?: Record<string, unknown>;
78
+ }): void;
79
+ }
80
+ /** The `logs` singleton slice we use from `@opentelemetry/api-logs`. */
81
+ interface LogsApiLike {
82
+ getLogger(name: string): LoggerLike;
83
+ }
84
+ /** Resolves the logs API, or a nullish value when OTEL is unavailable. */
85
+ type OtelLoader = () => LogsApiLike | null | undefined | Promise<LogsApiLike | null | undefined>;
86
+ /**
87
+ * Emit a bounded tracking event as an OTEL LogRecord through the global
88
+ * `LoggerProvider`. Off unless `otel` is configured. Fire-and-forget: the
89
+ * returned promise always resolves (never rejects) so callers can ignore it.
90
+ * No provider registered / package absent → silent no-op; `emit` throwing is
91
+ * swallowed. The host app owns all downstream exporting.
92
+ */
93
+ export declare function emitOtelLog(event: TrackingEvent, loadLogs?: OtelLoader): Promise<void>;
94
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@nekuda/webmcp-sdk",
3
+ "version": "0.4.0-dev.7.3",
4
+ "type": "module",
5
+ "description": "Phase-1 WebMCP SDK: a thin wrapper over document.modelContext that plugin-generated code targets — defineTool + register/unregister lifecycle. This package pins the plugin↔SDK seam; anonymous tool-call tracking (backend transport via apiKey, OTEL LogRecords via otel) is opt-in through registerTools and default-silent, while anonymous usage telemetry is a separate unauthenticated channel that is on by default (opt out with telemetry: false).",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "test": "bun test",
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "biome check .",
25
+ "build": "bun build src/index.ts --outdir dist --target browser --format esm --external @opentelemetry/api-logs",
26
+ "build:types": "tsc -p tsconfig.build.json"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "1.9.4",
30
+ "@opentelemetry/api-logs": "^0.205.0",
31
+ "@types/bun": "^1.3.14",
32
+ "typescript": "^7.0.2"
33
+ },
34
+ "peerDependencies": {
35
+ "@opentelemetry/api-logs": ">=0.50.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@opentelemetry/api-logs": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "trustedDependencies": [
43
+ "@biomejs/biome"
44
+ ]
45
+ }