Skip to content

feat(web): add ICRC-167 browser URL transport - #27

Merged
sea-snake merged 9 commits into
mainfrom
icrc-167-url-transport
Jul 23, 2026
Merged

sea-snake merged 9 commits into
mainfrom
icrc-167-url-transport

Conversation

@sea-snake

@sea-snake sea-snake commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator

Adds UrlTransport — the ICRC-167 browser URL transport — communicating with a signer via top-level browser navigation instead of a postMessage window. It works with the existing Signer through the Transport/Channel interface, 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 (sessionStorage by default) and replayed on the return load — code written as const 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 message matching the stored pending); 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.

import { Signer } from '@icp-sdk/signer';
import { UrlTransport } from '@icp-sdk/signer/web';

const transport = new UrlTransport({ url: SIGNER_URL, callbackUrl: `${origin}/connect` });
const signer = new Signer({ transport });

// on the /connect route's load (fresh visit or signer return):
const nonce = await transport.memoize(() => fetchNonce());
const [attributes, delegation] = await Promise.all([
  signer.sendRequest({ jsonrpc: '2.0', id: 1, method: 'attributes', params: { nonce } }),
  signer.requestDelegation({ publicKey, targets }),
]);
finish(attributes, delegation); // runs once, on the return load
  • Batching — requests issued concurrently (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.
  • Multiple flows — each flow uses its own callback URL, which routes its return and namespaces its journal automatically; a journal expires after flowTimeout (default 10 min).
  • Signer-initiated — needs no extra API: a signer starts a flow by navigating the user to its route (a declared callback), which is an ordinary fresh start.

The replay contract

Run the flow on every load and issue the same requests and memoize steps in the same order (branch only on values recovered from earlier responses). Route any value a request depends on — such as a nonce — through memoize so it's identical on the replay load; a value fetched with a bare await would differ on replay and mismatch the cached response. Documented on UrlFlow/UrlTransport and in the README.

Tests

npm run build, npm test (127 passing, incl. batching, replay + id re-stamping, memoize once/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, and npm audit all pass. @icp-sdk/signer/web bundle: 2.71 kB (limit 15 kB).

Companion standard: ICRC-167.

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>
@sea-snake
sea-snake requested a review from a team as a code owner July 17, 2026 13:17
sea-snake and others added 6 commits July 17, 2026 15:30
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and UrlFlow implementing 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.md to 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 on Signer in this package. Either update the example to use available methods (e.g. getAccounts/requestDelegation) or demonstrate calling the underlying JSON-RPC via signer.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.

Comment thread src/web/urlChannel.ts
Comment thread src/web/urlFlow.ts
Comment thread README.md
Comment thread src/web/urlTransport.ts
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>
@MRmarioruci

Copy link
Copy Markdown

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>
@sea-snake

sea-snake commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 #settle "quiet moment" was answering two questions at once, and that it couldn't tell "flow finished" from "flow waiting on an await we don't know about" — so a plain await between calls could wipe the journal mid-flight. That whole quiescence/completion-detection mechanism is now gone:

  • A journal is continued only on 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, an abandoned pending with no message) starts a fresh flow, ignoring the leftover.
  • There's no longer any "decide the flow is over" moment, so a plain await between calls can't clear anything. A completed journal is simply inert to the next flow, and flowTimeout is a storage backstop.

So transport.run(...) isn't needed — the transport doesn't watch the flow from the outside anymore; it just resumes on a real return. (Your instinct was right; we landed on making a completed journal inert instead of having the transport own execution.) The "await only through memoize" guidance still stands, but now purely for value stability — route any value a request depends on (e.g. a nonce) through memoize so it's the same on replay. A stray await is at worst a wasted re-run now, not a journal wipe.

Is this doable with how Signer drives the channel, or does the flow need to stay top-level for some reason?

Most code that relies on @icp-sdk/signer is build already top-level, adding something like run() would be a significant breaking change and differentiate the usage of the most common transport (PostMessage), making transports not swappable as intended. As mentioned above, the code has now been updated to avoid the "quiescence" requirement, instead memoize is only used for what it's meant for (memoizing values across reloads).

Copilot points, all addressed in the same commit:

  • id-less requests / notifications — UrlChannel.send now rejects a request with no id up front; without a response it could never complete and would redirect-loop.
  • fragment leak on a non-matching message — the fragment is now stripped whenever a message is present (even on a state mismatch / cleared storage), so a stray response can't leak via history or referrer; results are absorbed only on a match.
  • wrong Signer methods in README/JSDoc — fixed to getAccounts() / requestDelegation() / sendRequest().

@sea-snake
sea-snake merged commit 9b18c42 into main Jul 23, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants