Repository navigation
feat(web): add ICRC-167 browser URL transport - #27
Conversation
Add UrlTransport / UrlChannel implementing the ICRC-167 browser URL transport over top-level navigation, usable with the existing Signer. Each request navigates the current window to the signer with the request in the URL hash fragment; the signer returns the response in the fragment of the relying party's callbackUrl. Because a top-level redirect unloads the page, completed request results are persisted in storage (keyed by call order) and replayed on the return load, so code written as `const x = await a(); const y = await b(x)` resumes across the redirect. The signer's per-call id is re-stamped onto the replayed response so the Signer's id correlation works unchanged. Also add readSignerInitiation for ICRC-167 signer-initiated interactions, and hasPendingFlow / clearFlow on the transport for the resume-on-load and cleanup lifecycle. README code blocks were normalized by prettier as a side effect of editing the file (the repo's pre-commit hook formats any touched file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defer the redirect by a macrotask and buffer requests that arrive first, so requests issued concurrently — e.g. a delegation and certified attributes via Promise.all — are coalesced into a single JSON-RPC batch and answered in one round-trip instead of one redirect each. The returned batch is absorbed per response by matching JSON-RPC id to the buffered request. Sequential requests, where a later one cannot be issued until an earlier await resolves, remain one redirect each. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the URL-transport journal into a shared, load-scoped UrlFlow that both channel sends and memoized steps drive through one call-order counter. Add UrlFlow.memoize as the core hit-or-produce step — run the callback once, journal the result, replay it on the return load — and expose it as UrlTransport.memoize. This lets an async pre-step whose result must survive the redirect (e.g. fetching a single-use certified-attributes nonce the signer signs against) run once and replay, instead of being re-fetched on the return load and breaking correlation. Absorbing a signer return now preserves memoized results alongside request responses in the same journal. Moving the counter/results to the shared flow also hardens against the signer recreating the channel within a load (auto-close), which would otherwise reset a per-channel counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stale flows Derive the journal storage key from callbackUrl by default, so a relying party with several flows (each with its own callback) gets an isolated journal per flow without configuring storage keys. Stamp each journal with a creation time and ignore it once older than flowTimeout (default 10 min), so an abandoned flow — and any single-use value it captured, such as a nonce — is not resumed later. hasPendingFlow now reflects non-expired resumable state. storageKey and now remain injectable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detect completion by quiescence so the transport cleans up itself, and remove the explicit lifecycle methods. The shared flow reschedules a settle task on every call; once the calls settle it navigates the batch (if any request missed) or, if nothing navigated, clears its own journal. An in-flight memoize counter holds settle off, so it can't fire mid-fetch and a concurrent memoized value is recorded before any redirect. Usage becomes: run the flow code on its callback route (fresh visit and signer return both land there) — no resume or cleanup call. This is sound only when the flow awaits nothing but memoize and signer requests between calls; documented as the replay contract, with flowTimeout as a backstop. The channel is now a thin adapter over the shared UrlFlow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visiting a flow's callback route with no response is already a fresh start, so a signer initiates simply by navigating the user there — the `init` marker, `signer` hint, and `readSignerInitiation` helper are redundant. Remove them. Also clarify that `memoize` is for any async work in a flow other than a signer request, not only pre-steps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`npm update` refreshes the lockfile within existing ranges (fixing the brace-expansion, fast-uri, vite, and size-limit-nested esbuild advisories), and the direct `esbuild` devDependency is bumped from ^0.27.4 to ^0.28.1 to pick up the fix outside the old range. All advisories were in build/docs tooling (devDependencies), never shipped. `npm audit` now reports 0 vulnerabilities; build, tests, lint, size-limit, and the esbuild e2e bundling all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an ICRC-167 “URL (redirect)” transport to @icp-sdk/signer/web, enabling signer interaction via top-level navigation with URL-fragment request/response passing and a sessionStorage-backed journal to replay results across page reloads.
Changes:
- Introduces
UrlTransport,UrlChannel, andUrlFlowimplementing redirect-based JSON-RPC with batching and call-order journaling. - Adds comprehensive Vitest coverage for flow replay, batching, memoization, expiry, and auto-clear behavior.
- Updates
README.mdto document the new URL transport and bumps build/tooling dependencies (e.g. esbuild) with lockfile updates.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/web/urlTransport.ts | New transport wrapper around UrlFlow, plus memoize API and option validation. |
| src/web/urlTransport.test.ts | Tests for UrlTransport construction, namespacing/expiry/cleanup, and memoize behavior. |
| src/web/urlFlow.ts | Core redirect/journal implementation: batching, settle detection, persistence, and return absorption. |
| src/web/urlFlow.test.ts | Tests for slot ordering, batching, replay, fragment stripping, and expiry behavior. |
| src/web/urlChannel.ts | Channel implementation that buffers requests and replays responses from the journal. |
| src/web/urlChannel.test.ts | Tests for buffering/navigation, batching, replay id restamping, close semantics. |
| src/web/index.ts | Exports the new URL transport/channel/flow APIs from the web entrypoint. |
| README.md | Documents URL transport usage patterns and replay contract. |
| package.json | Tooling dependency bump (esbuild). |
| package-lock.json | Lockfile updates corresponding to dependency bumps/version. |
Comments suppressed due to low confidence (2)
README.md:71
- This sentence references non-existent Signer methods (
signer.delegation(...),signer.accounts(...)). The README should use the actual public API (requestDelegation,getAccounts).
Requests issued concurrently are coalesced into a single JSON-RPC batch and answered in one round-trip. For example, requesting certified attributes together with a delegation via `Promise.all([signer.delegation(...), signer.accounts(...)])` performs one redirect, not two. Sequential requests — where a later one depends on an earlier response — remain one redirect each.
README.md:82
- This example calls
signer.requestAttributes/signer.delegation, but those methods do not exist onSignerin this package. Either update the example to use available methods (e.g.getAccounts/requestDelegation) or demonstrate calling the underlying JSON-RPC viasigner.sendRequest(...)for the attributes method.
const [attributes, delegation] = await Promise.all([
// one batched redirect
signer.requestAttributes({ nonce }),
signer.delegation({ publicKey, targets }),
]);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A journal is continued only when the load is a signer return — the URL carries a `message` matching the stored `pending`. Any other load (a bare visit to the callback, a finished flow's leftover journal, or an abandoned pending with no message) starts a fresh flow: the stored journal is ignored and overwritten by the first request. This makes navigating to the callback always start the flow and removes the stale-replay hazard (a completed journal is inert to the next flow), so the quiescence-based completion detection and its journal clearing are no longer needed and are removed. No public API change — no clearFlow, no pending flags; consumers that navigate away on completion (e.g. an auth client) need no explicit clear. What remains is the batch-flush macrotask (coalesce concurrent requests) guarded by the in-flight memoize check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
If I'm reading it right, the flow is considered finished when things go quiet, #settle runs on a setTimeout(0), and if nothing's buffered and we haven't navigated, it clears the journal. I like that this means no cleanup call. My worry: that one "quiet" moment is used to answer two different things, "time to redirect the batch?" and "is the flow over?". It's a good signal for the first, but for the second it can't tell "the flow finished" apart from "the flow is waiting on an await we don't know about." So if someone writes a plain await between calls: const del = await signer.delegation({ publicKey, targets });
await new Promise(r => setTimeout(r, 800)); // plain await not memoiz
const attrs = await signer.requestAttributes({ nonce });
finish(del, attrs);settle can fire during that pause, decide the flow is done, and wipe the journal halfway through, and the breakage (re-auth loop / re-fetched nonce) shows up far from the await that caused it. What if the transport ran the flow itself instead of watching it from the outside? Somethin like: transport.run(async () => {
const nonce = await transport.memoize(() => fetchNonce());
const [attrs, del] = await Promise.all([...]);
finish(attrs, del);
});Is this doable with how Signer drives the channel, or does the flow need to stay top-level for some reason? Might be missing something. Also this is kind of far-fetched and the dev basically not writing the code as specified... |
…onse fragment, correct docs - Reject a request without an id in UrlChannel.send: it gets no response, so over a redirect it would re-navigate on every replay (infinite loop). Notifications are inherently unsupported by this transport. - Strip the URL fragment whenever a `message` is present, not only on a state match, so a stray/mismatched response (e.g. storage cleared) can't leak a delegation via history or the referrer. Absorb only on a match. - Fix README and JSDoc examples to use real Signer methods (getAccounts / requestDelegation / sendRequest) instead of the non-existent accounts() / delegation() / requestAttributes(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The above has been addressed in 8e87ad8. @MRmarioruci great catch, and it's exactly the concern that pushed the redesign. You're right that the
So
Most code that relies on Copilot points, all addressed in the same commit:
|
Adds
UrlTransport— the ICRC-167 browser URL transport — communicating with a signer via top-level browser navigation instead of apostMessagewindow. It works with the existingSignerthrough theTransport/Channelinterface, under@icp-sdk/signer/web, for flows where a popup isn't available (full-page redirects, native/mobile deep links).How it works
Each request navigates the current page to the signer with the request in the URL hash fragment; the signer returns the response in the fragment of the relying party's
callbackUrl(so message contents never reach a backend). A top-level redirect unloads the page, so completed results are persisted in a call-order-keyed journal (sessionStorageby default) and replayed on the return load — code written asconst x = await a(); const y = await b(x)continues where it left off.Usage
Run the flow's code on its callback route; a fresh visit starts it and the signer's return replays it. There is no resume or cleanup call: a load is continued only when it's a signer return (the URL carries a
messagematching the storedpending); any other load — a bare visit, a finished flow's leftover journal, an abandoned pending — starts a fresh flow, ignoring the leftover. So navigating to the callback always starts the flow, and a completed journal is simply inert to the next one.Promise.all) are coalesced into one JSON-RPC batch and one redirect; sequential requests remain one redirect each.memoize— the sole place a flow does async work other than a signer request; it runs once and replays its result across the redirect, so a value like a single-use nonce the signer signs against stays stable instead of being re-fetched.flowTimeout(default 10 min).The replay contract
Run the flow on every load and issue the same requests and
memoizesteps in the same order (branch only on values recovered from earlier responses). Route any value a request depends on — such as a nonce — throughmemoizeso it's identical on the replay load; a value fetched with a bareawaitwould differ on replay and mismatch the cached response. Documented onUrlFlow/UrlTransportand in the README.Tests
npm run build,npm test(127 passing, incl. batching, replay + id re-stamping,memoizeonce/replay-on-return, in-flight-memoize-holds-the-redirect, resume-only-on-a-return / bare-load-starts-fresh, id-less-request rejection, fragment stripping on state mismatch, callback-namespacing, expiry, and an end-to-end nonce-plus-batch across loads),npm run lint,npm run prettier:check, andnpm auditall pass.@icp-sdk/signer/webbundle: 2.71 kB (limit 15 kB).Companion standard: ICRC-167.