UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

171 lines • 8.14 kB
/** * hosting/browserSession — the client half of a session, for a page. * * A standing agent remembers a conversation per `sessionId`. Something has to * decide what that id IS, and in a browser that is one line: * * const sessionId = browserSessionId(); * await fetch('/invoke', { * method: 'POST', * headers: { 'content-type': 'application/json', 'x-session-id': sessionId }, * body: JSON.stringify({ input: 'hello' }), * }); * * The server half is `nodeHost({ sessionHeader })` (default `'x-session-id'`, * so the line above needs no server option at all) or * `nodeHost({ sessionCookie })`, which does the same job without any client * code — see those options for the trade. * * ── Why this file imports NOTHING ──────────────────────────────────────────── * It is the one piece of `hosting/` a browser bundle is meant to contain, and * the rest of that folder is Node: `httpHost` dynamically imports `node:http`, * `sqliteSessions` reaches `node:sqlite` through `createRequire`. So this file * has no imports at all and is exported from the MAIN barrel * (`import { browserSessionId } from 'agentfootprint'`) rather than from * `agentfootprint/hosting`, which no browser bundle should have to pull in. It * lives here, beside the server half it pairs with, so the two cannot drift * about which header carries what. * * Pattern: a plain function. There is no state to own beyond the storage key. */ /** The default `localStorage` key. Named, so a page can clear it deliberately. */ export const DEFAULT_SESSION_STORAGE_KEY = 'agentfootprint.sessionId'; /** * Storage for the runs where `localStorage` is not there — a private-mode * browser that throws on access, an SSR pass, a test. One `Map` for the module, * so a second call in the same page still gets the same id back. */ const fallbackStore = new Map(); /** * The conversation this browser is having with your agent: minted once, kept, * and handed back on every later call. * * Pass it as `x-session-id` (or in the JSON body as `sessionId`) and a * `standingAgent` will hydrate the same conversation on every turn — and, since * 9.10.0, scope the agent's memory to it too, with no configuration. * * ── What it is, and the thing it is NOT ────────────────────────────────────── * **A session id is not authentication.** It is a handle the browser carries, * and anyone who can reach your host can send any string in its place. Nothing * here signs it, and nothing on the server side checks it: authenticate the * caller by your own means, then check that the authenticated principal is * allowed the session they claimed. Storing anything sensitive under a session * id alone is storing it under a value the client controls. * * ── Where it is kept, and what that costs ──────────────────────────────────── * `localStorage`, under {@link BrowserSessionIdOptions.storageKey}. That means * it survives a reload and a closed tab, is scoped to the ORIGIN, and is * readable by any script running on that origin — including one you did not * write. It is per browser profile, so the same person on a phone and a laptop * is two conversations, and a shared machine is one. * * When `localStorage` cannot be reached — private mode in some browsers throws * on access, and there is no `window` at all during SSR — the id is kept in a * module-level `Map` instead. Same id for the life of the page, gone on reload. * This is a fallback and it is stated rather than hidden: a page that must * survive a reload in private mode needs its own storage. * * ── How it is minted ───────────────────────────────────────────────────────── * `crypto.randomUUID()` where it exists (every current browser, in a secure * context), then `crypto.getRandomValues`. Both are unguessable. On a runtime * that has NEITHER — an insecure-context page in an older browser — it falls * back to `Math.random`, which is **not** unguessable, and that is survivable * only because of the paragraph above: this id is a handle, never a credential. * * @example A chat page that remembers across reloads * ```ts * import { browserSessionId } from 'agentfootprint'; * * const sessionId = browserSessionId(); * const reply = await fetch('/invoke', { * method: 'POST', * headers: { 'content-type': 'application/json', 'x-session-id': sessionId }, * body: JSON.stringify({ input: message }), * }).then((r) => r.json()); * ``` * * @example Two agents on one origin, two conversations * ```ts * const support = browserSessionId({ storageKey: 'support.sessionId' }); * const billing = browserSessionId({ storageKey: 'billing.sessionId' }); * ``` */ export function browserSessionId(options = {}) { const key = options.storageKey ?? DEFAULT_SESSION_STORAGE_KEY; const existing = readStored(key); if (existing !== undefined && existing.length > 0) return existing; const minted = mintId(); writeStored(key, minted); return minted; } /** * `localStorage` if it is both present and usable, otherwise `undefined`. * * Presence is not usability: Safari's private mode has historically THROWN on * `localStorage` access rather than being absent, so the only honest test is to * touch it inside a try. */ function storage() { try { const candidate = globalThis.localStorage; if (candidate === undefined || candidate === null) return undefined; // A round trip, because a quota-exhausted or disabled store can be present // and still refuse every write. const probe = '__agentfootprint_probe__'; candidate.setItem(probe, '1'); candidate.removeItem(probe); return candidate; } catch { return undefined; } } function readStored(key) { const store = storage(); if (store === undefined) return fallbackStore.get(key); try { return store.getItem(key) ?? undefined; } catch { return fallbackStore.get(key); } } function writeStored(key, value) { // The in-memory copy is kept EITHER WAY. A `localStorage` that accepted the // probe and then refused this write (quota, a policy change mid-page) would // otherwise mint a new id on every call, and a conversation that starts over // on every message is worse than one that ends at the reload. fallbackStore.set(key, value); const store = storage(); if (store === undefined) return; try { store.setItem(key, value); } catch { // Already kept above. Nothing here is worth failing a page over. } } /** An unguessable id where the platform offers one; see the caveat on the door. */ function mintId() { const webcrypto = globalThis.crypto; if (typeof webcrypto?.randomUUID === 'function') return webcrypto.randomUUID(); if (typeof webcrypto?.getRandomValues === 'function') { const bytes = webcrypto.getRandomValues(new Uint8Array(16)); // RFC 4122 version 4, laid out by hand because `randomUUID` is what is // missing here, not the randomness. bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } // No crypto at all. Not unguessable, and labelled so in the returned value // itself rather than only in a doc nobody reads at three in the morning. const random = () => Math.random().toString(36).slice(2, 10); return `weak-${Date.now().toString(36)}-${random()}${random()}`; } //# sourceMappingURL=browserSession.js.map