@saeris/hanko 0.2.0 โ†’ 0.2.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## 0.2.1
5
+ <sub>2026-09-02</sub>
6
+
7
+ - Refresh the README, and give the example real navigation.
8
+
3
9
  ## 0.2.0
4
10
  <sub>2026-09-02</sub>
5
11
 
package/README.md CHANGED
@@ -1,605 +1,346 @@
1
- # hanko
1
+ <div align="center">
2
2
 
3
- QR-assisted device sign-in for screens without a keyboard โ€” TVs, kiosks, set-top
4
- boxes. An implementation of [RFC 8628][rfc], the OAuth 2.0 Device Authorization
5
- Grant, which is the flow behind Plex, Steam, and Discord's TV sign-in.
3
+ # ๐Ÿ’ฎ hankoใ€Œๅˆคๅญใ€
6
4
 
7
- The device shows a short code and a QR. The user authorizes on their phone. The
8
- device polls until it hears back, then signs in.
5
+ [![CI status][ci_badge]][ci] [![npm][npm_badge]][npm] [![License][license_badge]][license]
9
6
 
10
- ```
11
- โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
12
- โ”‚ Sign in โ”‚
13
- โ”‚ โ”‚
14
- โ”‚ Visit example.com/link โ”‚ โ–Œโ–Œโ–Œ โ–Œ โ–Œโ–Œโ–Œ
15
- โ”‚ and enter this code โ”‚ OR โ–Œ โ–Œโ–Œโ–Œโ–Œ โ–Œโ–Œ
16
- โ”‚ โ”‚ โ–Œโ–Œ โ–Œ โ–Œโ–Œโ–Œโ–Œ
17
- โ”‚ W D J B - M J H T โ”‚ โ–Œโ–Œโ–Œโ–Œ โ–Œ โ–Œโ–Œ
18
- โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
19
- ```
7
+ QR-assisted device sign-in for screens without a keyboard, with a QR decoder of its own.
20
8
 
21
- Zero dependencies beyond [`etiket`][etiket] for QR rendering. Runs anywhere with
22
- WinterTC primitives: Node, browsers, Deno, Bun, Cloudflare Workers.
9
+ </div>
23
10
 
24
- ## Three places this flow lives
11
+ ---
25
12
 
26
- hanko ships a baseline for each, and no UI components for any of them โ€” state
27
- and lifecycle hooks instead, so React, Vue, Svelte, Solid, Angular, and React
28
- Native each bind it with their own conventions.
13
+ **ๅˆคๅญ** (_hanko_) is the personal seal used in Japan in place of a signature โ€”
14
+ pressed once to authorize something on your behalf. Which is the whole flow: a
15
+ TV asks, you approve it from your phone, the TV is signed in.
29
16
 
30
- | Entry | Runs on | Gives you |
31
- | ----------------------------- | ---------------------- | ------------------------------------------ |
32
- | `@saeris/hanko` + `/handlers` | your API | grant lifecycle, `Request`โ†’`Response` glue |
33
- | `@saeris/hanko/client` | the device signing in | poll loop, QR rendering |
34
- | `@saeris/hanko/approve` | the device granting it | QR reading, confirmation challenges |
17
+ ## ๐ŸŽฏ What it does
35
18
 
36
- ## Install
19
+ An implementation of [RFC 8628][rfc], the OAuth 2.0 Device Authorization Grant
20
+ โ€” the flow behind Plex and Steam's TV sign-in, and Discord's device
21
+ authorization. A device that is awkward to type on shows a short code and a QR;
22
+ the user authorizes on a phone they are already signed in on; the device polls
23
+ until it hears back.
37
24
 
38
- ```sh
39
- yarn add @saeris/hanko
40
- ```
41
-
42
- hanko has **no runtime dependencies**. Its job is RFC 8628 โ€” the grant
43
- lifecycle โ€” and that is all it ships.
44
-
45
- Drawing a QR is a different class of problem, so it lives behind
46
- `@saeris/hanko/qr` and defers to [`etiket`](https://www.npmjs.com/package/etiket),
47
- declared as an optional peer. Add it only if you render the device screen:
25
+ ```mermaid
26
+ sequenceDiagram
27
+ participant TV as ๐Ÿ“บ Device
28
+ participant API as โš™๏ธ Your API
29
+ participant Phone as ๐Ÿ“ฑ Phone
48
30
 
49
- ```sh
50
- yarn add etiket
51
- ```
31
+ TV->>API: POST /device/authorize
32
+ API-->>TV: user_code, device_code, verification_uri
33
+ Note over TV: shows WDJB-MJHT<br/>and a QR of the link
52
34
 
53
- ## Server
35
+ loop until approved or expired
36
+ TV->>API: POST /device/token
37
+ API-->>TV: authorization_pending
38
+ end
54
39
 
55
- hanko owns the grant lifecycle and nothing else. It never mints sessions or
56
- tokens: `approve()` records an opaque `subject`, a successful poll hands it
57
- back, and your app issues whatever credential it already knows how to issue.
58
- That is what lets it sit alongside Better-Auth or Supabase rather than competing
59
- with them.
40
+ Phone->>Phone: scans the QR
41
+ Phone->>API: POST /link (approve)
42
+ API-->>Phone: approved
60
43
 
61
- ```ts
62
- import { HankoServer } from "@saeris/hanko";
63
- import { MemoryDeviceGrantStore } from "@saeris/hanko/stores/memory";
64
-
65
- const hanko = new HankoServer({
66
- store: new MemoryDeviceGrantStore(),
67
- verificationUri: `https://example.com/link`
68
- });
69
-
70
- // POST /device/authorize โ€” the device starts here
71
- const grant = await hanko.requestAuthorization();
72
- // โ†’ { device_code, user_code, verification_uri, verification_uri_complete, ... }
73
-
74
- // POST /device/token โ€” the device polls here
75
- const result = await hanko.poll(deviceCode);
76
- if (result.status === `approved`) {
77
- const session = await createSession(result.subject);
78
- }
79
-
80
- // POST /link โ€” the phone approves here, after the user confirms the code
81
- await hanko.approve(userCode, session.userId);
44
+ TV->>API: POST /device/token
45
+ API-->>TV: access_token
82
46
  ```
83
47
 
84
- ### Rendering the screen
48
+ Three entry points, one per device in the flow. No UI components for any of
49
+ them โ€” state and lifecycle hooks instead, so React, Vue, Svelte, Solid, Angular
50
+ and React Native each bind it with their own conventions.
85
51
 
86
- ```ts
87
- // From the /qr subpath, not the barrel: this is the one entry point that
88
- // needs a dependency, and a server that only issues grants never pays for it.
89
- import { renderDeviceQr } from "@saeris/hanko/qr";
90
-
91
- const svg = renderDeviceQr(grant.verification_uri_complete, { size: 512 });
92
- ```
52
+ | Entry | Runs on | Gives you |
53
+ | ----------------------------- | ---------------------- | ------------------------------------------ |
54
+ | `@saeris/hanko` + `/handlers` | your API | grant lifecycle, `Request`โ†’`Response` glue |
55
+ | `@saeris/hanko/client` | the device signing in | poll loop, QR rendering |
56
+ | `@saeris/hanko/approve` | the device granting it | QR reading, confirmation challenges |
93
57
 
94
- Defaults are tuned for a TV viewed from across a room. Error correction is `M`,
95
- not `H`: higher correction needs more modules to encode the same URL, so at a
96
- fixed size each module gets smaller โ€” and on a clean screen, module size matters
97
- more than damage tolerance.
58
+ ## ๐Ÿ“ท The QR decoder
98
59
 
99
- **Show the code on both screens, and let the user compare.**
100
- [RFC 8628 ยง3.3.1][rfc] is explicit about the mitigation:
60
+ `@saeris/hanko/scan` reads QR codes. Pixels in, string out โ€” no camera, no DOM,
61
+ no dependencies โ€” so the same code runs in a browser, in a worker, on a server,
62
+ or in a test.
101
63
 
102
- > The server SHOULD display the `user_code` to the user and ask them to verify
103
- > that it matches the `user_code` being displayed on the device to confirm they
104
- > are authorizing the correct device.
64
+ It exists because every JavaScript alternative is unmaintained: jsQR's last code
65
+ commit was August 2021, qr-scanner's November 2022, and `BarcodeDetector` is
66
+ still a WICG incubation that Safari has never shipped.
105
67
 
106
- So `createApprovalHandler` returns the code, and the approval screen shows it
107
- next to which client is asking. The check is a **visual comparison against a
108
- physically separate screen** โ€” not a memory test. Making someone re-type a code
109
- they can see proves nothing extra and is friction no production implementation
110
- of this flow imposes.
68
+ Measured against the 718-image [BoofCV benchmark][boofcv] โ€” photographs, not
69
+ renders:
111
70
 
112
- `device_code` is never returned. That one is the bearer credential.
71
+ | Decoder | Recognition |
72
+ | --------- | ----------- |
73
+ | **hanko** | **74.4%** |
74
+ | BoofCV | 60.69% |
75
+ | ZBar | 38.95% |
76
+ | ZXing | 31.87% |
77
+ | jsQR | 24.8% |
113
78
 
114
- ## Device
79
+ ### Decoding one image
115
80
 
116
81
  ```ts
117
- import { DeviceAuthClient } from "@saeris/hanko/client";
118
-
119
- const client = new DeviceAuthClient({
120
- tokenUrl: `https://example.com/device/token`,
121
- deviceCode: grant.device_code,
122
- interval: grant.interval,
123
- expiresIn: grant.expires_in,
124
- hooks: {
125
- onTransition: (from, to) => render(to),
126
- onSlowDown: (seconds) => console.log(`slowing to ${seconds}s`)
127
- }
128
- });
129
-
130
- const outcome = await client.run(signal);
131
- // โ†’ { status: "authorized", tokens } | "denied" | "expired" | "aborted"
132
- ```
82
+ import { createQrDecoder, toGray } from "@saeris/hanko/scan";
133
83
 
134
- Polling only โ€” no SSE or WebSocket. A sign-in screen may stay powered on for
135
- days, and a persistent connection is one more thing to leak and reconnect on
136
- hardware you cannot attach a profiler to.
84
+ const decoder = createQrDecoder();
85
+ const symbol = decoder.decode(toGray(rgba, width, height));
137
86
 
138
- For the common case where you only want the outcome:
139
-
140
- ```ts
141
- import { pollUntilAuthorized } from "@saeris/hanko/client";
142
-
143
- const outcome = await pollUntilAuthorized({
144
- tokenUrl,
145
- deviceCode,
146
- interval,
147
- expiresIn
148
- });
87
+ symbol?.value; // the payload, or null if nothing was found
149
88
  ```
150
89
 
151
- ## Approving device
90
+ ### Scanning with a camera
152
91
 
153
- The phone that is already signed in. Two topologies exist in the wild and hanko
154
- supports both through one flow:
92
+ Three pieces: a worker holding the decoder, something to turn a camera into
93
+ greyscale frames, and a loop between them.
155
94
 
156
- - **Plex**: the OS camera opens `verification_uri_complete` in a browser. The
157
- code arrives in the query string โ€” no scanning step.
158
- - **Discord / Steam**: the app scans in-place and the user never leaves it.
95
+ The worker is not optional. The retry ladder deliberately outruns its own time
96
+ budget โ€” the budget is checked between attempts, not inside them โ€” so a
97
+ synchronous decode stalls the preview on exactly the frames someone is lining
98
+ up.
159
99
 
160
100
  ```ts
161
- import { ApprovalClient, codeEntryChallenge } from "@saeris/hanko/approve";
162
-
163
- const client = new ApprovalClient({
164
- resolve: async (code) =>
165
- await fetch(`/link?user_code=${code}`).then((r) =>
166
- r.ok ? r.json() : null
167
- ),
168
- submit: async (code, approved) => {
169
- await fetch(`/link`, {
170
- method: `POST`,
171
- body: new URLSearchParams({ user_code: code, approved: String(approved) })
172
- });
173
- },
174
- challenge: codeEntryChallenge(),
175
- hooks: { onTransition: (from, to) => render(to) }
176
- });
177
-
178
- // Plex path โ€” code came from the URL
179
- await client.submitCode(new URL(location.href).searchParams.get(`user_code`));
180
-
181
- // Discord/Steam path โ€” scan frames until one carries a code
182
- client.startScanning();
183
- await client.scan(videoElement);
184
-
185
- // Then, once the user answers the challenge
186
- await client.confirm(typedCode);
187
- await client.approve();
188
- ```
189
-
190
- `approve()` refuses until the challenge passes. `deny()` never does โ€” a user who
191
- cannot confirm a code is the one most likely to be looking at a phishing
192
- attempt, and they must always be able to say no.
193
-
194
- ### Reading QR codes
195
-
196
- hanko defines a two-method `QrScanner` interface and lets you bring a decoder.
197
- It deliberately does not pick one for you.
198
-
199
- **`BarcodeDetector` is not a web standard.** It is a WICG incubation that MDN
200
- flags as outside Baseline; Safari has never shipped it, and no vendor has
201
- committed to it. Building against it directly means the scanner silently does
202
- nothing on an iPhone โ€” the device most people approve from.
101
+ // decoder.worker.ts
102
+ import { serveDecoder } from "@saeris/hanko/scan/worker";
203
103
 
204
- The recommendation is [`qr-scanner`][qr-scanner]: ~6 kB gzipped,
205
- self-contained, and it uses a native `BarcodeDetector` where one exists and its
206
- own worker otherwise. It owns the camera too, which is most of the work.
207
-
208
- ```ts
209
- import QrScanner from "qr-scanner";
210
- import { parseApprovalLink } from "@saeris/hanko/approve";
211
-
212
- const scanner = new QrScanner(
213
- videoElement,
214
- ({ data }) => {
215
- const link = parseApprovalLink(data);
216
- if (link) void client.submitCode(link.userCode);
217
- },
218
- { preferredCamera: `environment`, maxScansPerSecond: 10 }
219
- );
220
- await scanner.start();
104
+ // No time budget: a worker has no preview to block, so the ladder may run to
105
+ // exhaustion. Worth roughly twenty points of recognition over the 120ms a
106
+ // synchronous decode has to respect.
107
+ serveDecoder(self, { timeBudgetMs: 0 });
221
108
  ```
222
109
 
223
- Alternatives, and what they cost:
224
-
225
- | Decoder | Size | Note |
226
- | ------------------------ | ------------ | -------------------------------------------------------- |
227
- | `qr-scanner` | ~6 kB gz | self-contained, manages the camera |
228
- | `barcode-detector` | ~1.5 MB WASM | more formats, but fetches its WASM from a CDN at runtime |
229
- | native `BarcodeDetector` | 0 | absent on Safari; use `createBarcodeDetectorScanner()` |
230
- | `expo-camera` | โ€” | React Native, where no web API exists |
231
-
232
- Anything satisfying `QrScanner` works, including a React Native camera:
233
-
234
110
  ```ts
235
- const scanner: QrScanner = {
236
- detect: async (frame) => [{ rawValue: await scanWithExpoCamera(frame) }]
111
+ // camera.ts
112
+ export const openCamera = async (video: HTMLVideoElement) => {
113
+ const stream = await navigator.mediaDevices.getUserMedia({
114
+ // A preference, not a guarantee โ€” a laptop with one camera ignores it.
115
+ video: { facingMode: { ideal: `environment` } },
116
+ audio: false
117
+ });
118
+
119
+ video.srcObject = stream;
120
+ // Without this iOS opens the system player full-screen instead of playing
121
+ // inline, and there is no preview to aim with.
122
+ video.setAttribute(`playsinline`, ``);
123
+ await video.play();
124
+
125
+ const canvas = document.createElement(`canvas`);
126
+ // `willReadFrequently` matters: without it the browser keeps the canvas on
127
+ // the GPU and every `getImageData` is a synchronous readback, which is the
128
+ // most expensive thing in this loop.
129
+ const context = canvas.getContext(`2d`, { willReadFrequently: true })!;
130
+
131
+ return {
132
+ grab: () => {
133
+ const width = video.videoWidth;
134
+ const height = video.videoHeight;
135
+ // Zero before the first paint, and on iOS while the element is paused.
136
+ if (width === 0 || height === 0) return null;
137
+
138
+ canvas.width = width;
139
+ canvas.height = height;
140
+ context.drawImage(video, 0, 0);
141
+ const { data } = context.getImageData(0, 0, width, height);
142
+
143
+ // Converted here rather than in the worker: it quarters the bytes
144
+ // transferred per frame.
145
+ const grey = new Uint8ClampedArray(width * height);
146
+ for (let i = 0, p = 0; i < grey.length; i++, p += 4) {
147
+ grey[i] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
148
+ }
149
+
150
+ return { data: grey, width, height };
151
+ },
152
+
153
+ stop: () => {
154
+ for (const track of stream.getTracks()) track.stop();
155
+ video.srcObject = null;
156
+ }
157
+ };
237
158
  };
238
159
  ```
239
160
 
240
- `createBarcodeDetectorScanner` restricts detection to `qr_code`. An EAN-13 in
241
- the same frame would otherwise be posted to your approval endpoint as though it
242
- were a user code.
243
-
244
- ### Confirmation challenges
245
-
246
- Scanning a QR removes the moment where the user would have noticed the code was
247
- wrong. [RFC 8628 ยง5.4][rfc-security] asks you to put it back. How much friction
248
- that deserves is a product decision, so it is pluggable:
249
-
250
- | Strategy | Pattern | Friction |
251
- | ------------------------------------- | -------------------------- | ------------------- |
252
- | `noChallenge()` | Discord, Steam | one tap |
253
- | `tripletChallenge({ generate })` | Google mobile approval | one tap, real check |
254
- | `codeEntryChallenge()` | GitHub sudo, Plex | types the code |
255
- | `platformChallenge({ authenticate })` | FaceID, WebAuthn, passcode | biometric |
256
-
257
- `allOf([...])` composes them. The pairing worth knowing: a biometric proves
258
- possession of the phone, a code check proves the user is looking at the screen
259
- being authorized. Neither covers both.
260
-
261
- ```ts
262
- import {
263
- allOf,
264
- codeEntryChallenge,
265
- platformChallenge
266
- } from "@saeris/hanko/approve";
267
-
268
- const challenge = allOf([
269
- platformChallenge({
270
- authenticate: () => LocalAuthentication.authenticateAsync()
271
- }),
272
- codeEntryChallenge()
273
- ]);
274
- ```
275
-
276
- For a public screen โ€” a taproom TV anyone can walk up to โ€” do not use
277
- `noChallenge()`.
278
-
279
- ## Opening the app from a scanned code
280
-
281
- One QR should open the native app when it is installed, and the web page when it
282
- is not โ€” without the device ever showing an error.
283
-
284
- That rules out custom schemes as the QR payload. A `beerjournal://` code read by
285
- the OS camera on a phone without the app fails silently and unrecoverably: the
286
- user sees "cannot open" with nowhere to go. **Universal Links (iOS) and App
287
- Links (Android)** solve this by making the payload an ordinary `https://` URL
288
- that the OS _routes_ to the app when the domain and app are associated.
289
-
290
- So the QR keeps encoding `verification_uri_complete` unchanged. The routing
291
- lives in association files served from the same origin:
292
-
293
161
  ```ts
294
- import { createWellKnownHandler } from "@saeris/hanko/handlers";
295
-
296
- const wellKnown = createWellKnownHandler({
297
- appleAppIds: [`QQ57RJ5UTD.gg.saeris.beerjournal`],
298
- androidPackageName: `gg.saeris.beerjournal`,
299
- // SHA-256 of the PLAY-signed cert, not your local keystore.
300
- androidFingerprints: [`AA:BB:...`]
301
- });
302
- ```
303
-
304
- Both files must be served from the **same origin** as the approval page, over
305
- HTTPS, **with no redirects**. A redirect or a wrong content-type makes the
306
- association fail silently, with nothing in any log โ€” the usual reason universal
307
- links "just don't work".
308
-
309
- ### Expo
310
-
311
- ```ts
312
- import { expoLinkingConfig } from "@saeris/hanko";
313
-
314
- // Merge into app.json
315
- expoLinkingConfig({ origin: `https://example.com`, scheme: `beerjournal` });
316
- // โ†’ { scheme, ios: { associatedDomains: ["applinks:example.com"] },
317
- // android: { intentFilters: [{ autoVerify: true, ... }] } }
318
- ```
319
-
320
- Two things that silently break this:
321
-
322
- - `associatedDomains` takes **no protocol** โ€” `applinks:example.com`, never
323
- `applinks:https://example.com`.
324
- - `autoVerify: true` is what makes Android fetch `assetlinks.json` and open the
325
- app without a chooser dialog. Without it the link is registered and
326
- practically useless.
327
-
328
- **Universal links do not work in Expo Go.** The entitlement is registered at
329
- build time, so this needs a development or production build. A project pinned to
330
- Expo Go uses the web fallback until it moves to dev builds โ€” which is a
331
- sequencing constraint, not a blocker: the same QR already works.
332
-
333
- Receiving the link:
334
-
335
- ```ts
336
- import * as Linking from "expo-linking";
337
- import { parseApprovalLink } from "@saeris/hanko/approve";
338
-
339
- const initial = await Linking.getInitialURL();
340
- const link = initial && parseApprovalLink(initial, { scheme: `beerjournal` });
341
- if (link) await client.submitCode(link.userCode);
342
- ```
343
-
344
- ### PWAs on both ends
345
-
346
- The whole flow works PWA-to-PWA, with one caveat worth knowing up front.
347
-
348
- **The signing-in device** (the TV) is the easy half: it renders a code and an
349
- SVG QR, then polls. No camera, no install, no platform APIs. A Fire Stick or Pi
350
- browser runs it as-is.
351
-
352
- **The approving device** is where PWAs get thin:
353
-
354
- | Capability | Status |
355
- | ----------------------------------- | ------------------------------------- |
356
- | Receive an https link | works everywhere |
357
- | `launch_handler: navigate-existing` | Chromium only; falls back cleanly |
358
- | Camera scanning (`getUserMedia`) | works, but see below |
359
- | Being the OS camera's target | **installed PWAs cannot claim links** |
360
-
361
- The last row is the real constraint: a PWA cannot register for Universal Links.
362
- The OS camera opens the _browser_, not your installed PWA. `launch_handler` only
363
- controls what happens once the link reaches your origin.
364
-
365
- Camera access inside an installed iOS PWA was broken from iOS 18 until 18.4, and
366
- permission still is not persisted the way it is in Safari proper. So on the
367
- approving side, **the typed-code path is the reliability floor, not a nicety** โ€”
368
- build the scanner as an enhancement over it, never the only way in.
369
-
370
- ```ts
371
- import { consumeLaunchTarget, parseApprovalLink } from "@saeris/hanko/approve";
372
-
373
- // Reads launchQueue where supported. Safari and Firefox have none, and the
374
- // `location.href` fallback is opt-in (`fallbackToLocation: true`) because it
375
- // cannot tell a launch from an ordinary page load โ€” it fires on every visit.
376
- // Turn it on only for a page reached exclusively by launch; otherwise a plain
377
- // visit submits its own URL as a code and fails.
378
- consumeLaunchTarget((href) => {
379
- const link = parseApprovalLink(href);
380
- if (link) void client.submitCode(link.userCode);
381
- });
382
- ```
383
-
384
- Add `pwaLaunchHandler()` to your manifest so a scanned link reuses the open
385
- window rather than stacking a second one behind it.
386
-
387
- ### What each device actually gets
388
-
389
- | Approving device | Scanned QR opens | Notes |
390
- | ------------------------------------- | --------------------------- | ------------------------- |
391
- | Native app installed (dev/prod build) | the app, directly | best case |
392
- | Native app absent | the web page | same QR, no error |
393
- | Installed PWA | the browser, then your page | PWA cannot claim the link |
394
- | Desktop browser | the web page | typed code only |
162
+ // the loop
163
+ import { createWorkerScanner } from "@saeris/hanko/scan";
164
+ import { openCamera } from "./camera";
165
+
166
+ const camera = await openCamera(document.querySelector(`video`)!);
167
+ const scanner = createWorkerScanner(
168
+ new Worker(new URL("./decoder.worker.ts", import.meta.url), {
169
+ type: `module`
170
+ })
171
+ );
395
172
 
396
- Every row reaches a working approval screen. That is the property worth
397
- protecting โ€” and the reason the payload stays an `https://` URL.
173
+ let scanning = true;
398
174
 
399
- ## Edge runtimes
175
+ while (scanning) {
176
+ const frame = camera.grab();
400
177
 
401
- The server half is stateless by construction: every request loads its grant,
402
- applies one transition, and writes it back. Nothing is held between
403
- invocations, so a flow survives its requests landing on different workers, or
404
- on an instance frozen mid-flow.
178
+ if (frame !== null) {
179
+ // Awaited one at a time on purpose. A camera produces frames faster than
180
+ // they decode, and `scan` drops anything offered while a decode is in
181
+ // flight rather than queueing it โ€” the next frame is always a better
182
+ // input than a backlogged one.
183
+ const symbol = await scanner.scan(frame);
405
184
 
406
- ```ts
407
- // Cloudflare Workers
408
- import { createHandlers } from "@saeris/hanko/handlers";
409
- import { HankoServer } from "@saeris/hanko";
410
- import { KvDeviceGrantStore, kvFromOptionsApi } from "@saeris/hanko/stores/kv";
411
-
412
- export default {
413
- async fetch(request: Request, env: Env): Promise<Response> {
414
- const handlers = createHandlers({
415
- server: new HankoServer({
416
- store: new KvDeviceGrantStore({
417
- kv: kvFromOptionsApi({
418
- get: (k) => env.GRANTS.get(k),
419
- set: (k, v, o) => env.GRANTS.put(k, v, o),
420
- remove: (k) => env.GRANTS.delete(k)
421
- })
422
- }),
423
- verificationUri: `https://example.com/link`
424
- }),
425
- // Read from YOUR session โ€” never from the request body.
426
- authenticate: (req) => getSession(req)?.userId ?? null,
427
- createSession: (subject) => mintToken(subject),
428
- rateLimit: (req, code) =>
429
- limiter.check(req.headers.get(`CF-Connecting-IP`), code)
430
- });
431
- return handlers.fetch(request);
185
+ if (symbol !== null) {
186
+ console.log(symbol.value);
187
+ scanning = false;
188
+ break;
189
+ }
432
190
  }
433
- };
434
- ```
435
-
436
- The individual handlers โ€” `authorize`, `token`, `approval` โ€” are exported
437
- separately for file-based routing (Astro, Next, SvelteKit, Vercel Functions).
438
-
439
- ### Multiple hostnames, one deployment
440
191
 
441
- The QR has to encode the host the device is actually talking to. One deployment
442
- is commonly reachable through several โ€” a preview URL, a custom domain, a
443
- tunnel during development โ€” and a `verificationUri` fixed at construction time
444
- points at only one of them. The failure is silent: the code renders correctly
445
- and the phone lands nowhere.
446
-
447
- `createAuthorizationHandler` therefore derives the origin per request from
448
- `x-forwarded-host` and `x-forwarded-proto`, which every common proxy sets โ€”
449
- ngrok, Cloudflare, Vercel, nginx. The configured `verificationUri` remains the
450
- fallback for direct requests that carry no forwarded headers.
192
+ // Yield to the compositor. The decode happens on another thread, but this
193
+ // loop still runs on the main one.
194
+ await new Promise((resolve) => requestAnimationFrame(resolve));
195
+ }
451
196
 
452
- ```ts
453
- createAuthorizationHandler({
454
- server,
455
- verificationPath: `/link`, // appended to the detected origin
456
- trustForwardedHost: true // the default
457
- });
197
+ scanner.close();
198
+ camera.stop();
458
199
  ```
459
200
 
460
- Set `trustForwardedHost: false` to always use the configured value. Worth doing
461
- if your platform does not strip client-sent `x-forwarded-*` headers and you
462
- would rather pin the origin: those headers are client-controllable in that
463
- case. They are safe for building a URL the same client will visit โ€” which is
464
- all this does โ€” but never use them for an authorization decision.
201
+ A scanned payload is untrusted input. If you turn one into a link, restrict it
202
+ to `http:` and `https:` and show the raw text beside it โ€” `new URL()` happily
203
+ accepts `javascript:`.
465
204
 
466
- Calling the server directly takes the same override:
205
+ For a camera that should keep trying across frames rather than exhausting the
206
+ ladder on one, `createProgressiveScanner` spends a small budget per frame and
207
+ raises it while a symbol is in view.
467
208
 
468
- ```ts
469
- await server.requestAuthorization({
470
- verificationUri: `https://${request.headers.get("x-forwarded-host")}/link`
471
- });
472
- ```
473
-
474
- ### Persistence
475
-
476
- | Layer | Adapter |
477
- | ----------------------------------- | ----------------------------------------------------------- |
478
- | Upstash Redis, Vercel KV, `ioredis` | `KvDeviceGrantStore` + `kvFromOptionsApi({ ttlKey: "ex" })` |
479
- | Cloudflare Workers KV | `kvFromOptionsApi({ ttlKey: "expirationTtl" })` |
480
- | Deno KV | `KeyValueAdapter` directly |
481
- | Supabase / Postgres | implement `DeviceGrantStore` (four methods) |
482
- | Durable Objects | implement `DeviceGrantStore` over `ctx.storage` |
209
+ Coverage per condition, and the negative results behind it, live in
210
+ [plan/qr-coverage.md](plan/qr-coverage.md). Both move often.
483
211
 
484
- TTL does the pruning, with a grace window past the deadline so the server can
485
- answer `expired_token` honestly instead of "unknown code" โ€” which a client
486
- cannot distinguish from a typo.
212
+ ## ๐Ÿ—๏ธ How it works
487
213
 
488
- **Rate limiting is your job.** [ยง5.1][rfc-security] requires it, and hanko
489
- cannot do it portably: an effective limiter needs the client IP, which lives in
490
- a platform-specific header. The `rateLimit` seam exists so the requirement is
491
- not silently skipped.
214
+ hanko owns the **grant lifecycle** and nothing else. It never mints sessions or
215
+ touches your user table: it tells you a grant was approved and by whom, and
216
+ issuing a token from that is your application's decision.
492
217
 
493
- ## State machines
218
+ ```mermaid
219
+ flowchart LR
220
+ subgraph device["๐Ÿ“บ Device"]
221
+ client["@saeris/hanko/client"]
222
+ end
494
223
 
495
- Both halves are explicit state machines with declarative transition tables. The
496
- transitions are data, so they can be read against the RFC side by side โ€” and
497
- illegal moves are impossible by construction rather than guarded against.
224
+ subgraph api["โš™๏ธ Your API"]
225
+ server["@saeris/hanko + /handlers"]
226
+ store[("DeviceGrantStore")]
227
+ server <--> store
228
+ end
498
229
 
499
- **Grant** (server):
230
+ subgraph phone["๐Ÿ“ฑ Phone"]
231
+ approve["@saeris/hanko/approve"]
232
+ scan["@saeris/hanko/scan"]
233
+ approve --> scan
234
+ end
500
235
 
236
+ client -->|authorize, then poll| server
237
+ approve -->|approve| server
501
238
  ```
502
- pending โ”€โ”€APPROVEโ”€โ”€โ–ถ approved โ”€โ”€REDEEMโ”€โ”€โ–ถ consumed
503
- โ”‚ โ”‚
504
- โ”œโ”€โ”€DENYโ”€โ”€โ”€โ”€โ–ถ denied โ””โ”€โ”€EXPIREโ”€โ”€โ–ถ expired
505
- โ””โ”€โ”€EXPIREโ”€โ”€โ–ถ expired
506
- ```
507
-
508
- Redemption, not approval, is what ends the flow โ€” a `device_code` that stayed
509
- redeemable after approval would be a replayable bearer credential. And an
510
- approval nobody collected still expires.
511
239
 
512
- **Poll** (device):
240
+ ### Decisions worth knowing
241
+
242
+ - **Zero runtime dependencies.** The grant lifecycle is the product; rendering a
243
+ QR is a different class of problem, so it sits behind `@saeris/hanko/qr` and
244
+ defers to [`etiket`][etiket] as an optional peer. Reading one had no
245
+ maintained option at all, which is why `/scan` exists.
246
+ - **WinterTC primitives only.** `Request`, `Response`, `crypto.getRandomValues` โ€”
247
+ so one build runs on Node, Deno, Bun, Cloudflare Workers and in a browser,
248
+ with no adapter per runtime.
249
+ - **A short code is a small keyspace.** That is the deliberate trade for
250
+ readability, and [RFC 8628 ยง5.1][rfc-security] **requires you to rate-limit
251
+ attempts**. hanko does not do it for you: that belongs at your HTTP boundary,
252
+ where you can see IPs.
253
+ - **Approval is two steps, not one.** Scanning a code that says "approve this"
254
+ and approving it are separate, because a QR is a link anyone can point a
255
+ camera at. `/approve` ships the confirmation challenge that closes that gap.
256
+ - **The store is four methods.** `create`, `findByDeviceCode`, `findByUserCode`
257
+ and `update`, plus an optional `prune` โ€” small enough that an adapter for your
258
+ database is a short file. The bundled memory store is for development only.
259
+
260
+ ## ๐Ÿ“ฆ Install
513
261
 
514
- ```
515
- idle โ”€โ”€STARTโ”€โ”€โ–ถ waiting โ”€โ”€TICKโ”€โ”€โ–ถ polling
516
- โ–ฒ โ”‚
517
- โ””โ”€โ”€ pending โ”€โ”€โ”€โ”€โ”€โ”€โ”คโ”€โ”€ SUCCESS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ authorized
518
- slow_down โ”œโ”€โ”€ ACCESS_DENIED โ”€โ”€โ–ถ denied
519
- network error โ””โ”€โ”€ EXPIRED_TOKEN โ”€โ”€โ–ถ expired
262
+ ```sh
263
+ yarn add @saeris/hanko
520
264
  ```
521
265
 
522
- The interval lives in context, not state: `waiting` at 5s and `waiting` at 20s
523
- are the same state. That separation keeps the two pacing rules distinct โ€”
524
- `slow_down` adds a fixed 5s permanently ([ยง3.5][rfc-token]), while a network
525
- failure doubles with a cap. Conflating them either hammers a struggling server
526
- or crawls when it only asked for a small delay.
266
+ Add `etiket` only if you render the device screen:
527
267
 
528
- **Approval** (the phone):
529
-
530
- ```
531
- idle โ”€โ”€SCANโ”€โ”€โ–ถ scanning โ”€โ”€CODEโ”€โ”€โ–ถ resolving โ”€โ”€RESOLVEDโ”€โ”€โ–ถ confirming
532
- โ”‚ โ”‚ โ”‚
533
- โ””โ”€โ”€CODE (from URL)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
534
- โ””โ”€โ”€REJECTEDโ”€โ”€โ–ถ invalid โ”‚
535
- โ–ผ
536
- approved โ—€โ”€โ”€SUBMITTEDโ”€โ”€ submitting โ”€โ”˜
537
- denied โ—€โ”€โ”€SUBMITTEDโ”€โ”€
268
+ ```sh
269
+ yarn add etiket
538
270
  ```
539
271
 
540
- `confirming` accepts `CHALLENGE_FAILED` back into itself: a mistyped code is a
541
- retry, not a dead end. Both entry paths โ€” scanned in-app or arriving by URL โ€”
542
- converge on the same confirmation, so the security-sensitive half is written
543
- once.
544
-
545
- All three machines are exported if you want to drive them yourself โ€” persisting
546
- grant state in a Durable Object, say, or rendering a screen straight from the
547
- state:
548
-
549
- ```ts
550
- import {
551
- grantTransition,
552
- pollTransition,
553
- approvalTransition,
554
- canTransitionGrant
555
- } from "@saeris/hanko";
272
+ Codes follow the spec's worked example: 8 characters from a 20-consonant
273
+ alphabet, shown as `WDJB-MJHT`. No vowels, so a code cannot spell a word; no
274
+ digits, so there is no `0`/`O` or `1`/`l`/`I` to misread.
275
+
276
+ ## ๐Ÿ”ง Examples
277
+
278
+ The whole sign-in flow, wired end to end, lives in
279
+ [`examples/astro`](examples/astro) โ€” three surfaces, a store, rate limiting and
280
+ the confirmation challenge, with the framework-specific parts explained in
281
+ [its README](examples/astro/README.md). That is the reference for building the
282
+ flow; this README covers the pieces rather than the assembly.
283
+
284
+ More are planned โ€” Next.js first, mirroring the Astro one closely enough to
285
+ diff, then other frameworks and UI stacks.
286
+
287
+ | Route | What it is |
288
+ | ---------------- | -------------------------------------------------------------------- |
289
+ | `/` | the map โ€” what to open where, and whether the URL reaches a phone |
290
+ | `/tv` | the device screen โ€” a short code and a QR for a pending grant |
291
+ | `/signin` | the phone's own sign-in; this identity is what the device inherits |
292
+ | `/account` | approved devices, and revoking them |
293
+ | `/link` | the approval page โ€” scans that QR, or takes a typed code |
294
+ | `/scanner` | a bare scanner, showing the payload raw and as a link |
295
+ | `/scanner-debug` | the same, reporting each stage on screen for a phone with no console |
296
+
297
+ ```bash
298
+ yarn demo # builds the library, then serves the example
299
+ yarn demo:share # the same, over TLS so a phone can reach it
556
300
  ```
557
301
 
558
- ## Codes
302
+ A camera needs a secure context, so a bare LAN IP will not do โ€” `demo:share`
303
+ puts a TLS proxy in front, which is what makes the phone half testable at all.
559
304
 
560
- Defaults follow the spec's worked example: 8 characters from a 20-consonant
561
- alphabet, displayed as `WDJB-MJHT`. No vowels, so codes cannot spell words; no
562
- digits, so there is no `0`/`O` or `1`/`l`/`I` confusion.
305
+ ## ๐Ÿงช Checks
563
306
 
564
- ```ts
565
- import { generateUserCode, NUMERIC_ALPHABET } from "@saeris/hanko";
307
+ | What | Command | Notes |
308
+ | -------------- | ------------------- | ----------------------------------------------- |
309
+ | Everything | `vp run ci` | `vp pack && vp check && vp test`, what CI runs. |
310
+ | Tests | `vp test` | Pure TypeScript; never imports react-native. |
311
+ | Recognition | `yarn bench:corpus` | The 718-image corpus, sharded across workers. |
312
+ | Decode profile | `yarn bench` | A still image, for `deoptkit`. |
566
313
 
567
- generateUserCode(); // "WDJB-MJHT"
568
- generateUserCode({ alphabet: NUMERIC_ALPHABET, length: 9 }); // "0194-5073-0"
569
- ```
314
+ `vp pack` has to run before `vp check`: the example resolves `@saeris/hanko`
315
+ through the `exports` map, which points at `dist/`, so a clean checkout cannot
316
+ typecheck it until the library is built.
570
317
 
571
- A short code is a small keyspace, which is the deliberate trade for
572
- readability. **[RFC 8628 ยง5.1][rfc-security] requires you to rate-limit
573
- attempts** โ€” the code alone is not brute-force resistant, and hanko does not
574
- rate-limit for you. That belongs at your HTTP boundary, where you can see IPs.
318
+ ## ๐Ÿš€ Releasing
575
319
 
576
- ## Storage
320
+ Driven by [bumpy][bumpy]. Every change carries a **bump file** in `.bumpy/`
321
+ saying what moved and how far, so the changelog cannot fall behind the code.
577
322
 
578
- `DeviceGrantStore` is four methods, so adapters are small:
579
-
580
- ```ts
581
- interface DeviceGrantStore {
582
- create(grant: DeviceGrant): Promise<void> | void;
583
- findByDeviceCode(
584
- deviceCode: string
585
- ): Promise<DeviceGrant | null> | DeviceGrant | null;
586
- findByUserCode(
587
- userCode: string
588
- ): Promise<DeviceGrant | null> | DeviceGrant | null;
589
- update(grant: DeviceGrant): Promise<void> | void;
590
- prune?(now: number): Promise<void> | void;
591
- }
323
+ ```bash
324
+ yarn bumpy add
592
325
  ```
593
326
 
594
- The bundled `MemoryDeviceGrantStore` is for development only โ€” state dies with
595
- the process and is not shared across instances.
327
+ Merging that opens a **Version PR**; merging _that_ tags the release and
328
+ publishes to npm over OIDC trusted publishing, so no token is stored. The
329
+ example deploys to Vercel on push, independently of releases.
596
330
 
597
- ## License
331
+ ## ๐Ÿฅ‚ License
598
332
 
599
- MIT ยฉ [Drake Costa](https://saeris.gg)
333
+ [MIT][license] ยฉ [Drake Costa][personal-website]
600
334
 
601
335
  [rfc]: https://datatracker.ietf.org/doc/html/rfc8628
602
- [rfc-token]: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
603
336
  [rfc-security]: https://datatracker.ietf.org/doc/html/rfc8628#section-5
604
337
  [etiket]: https://github.com/productdevbook/etiket
605
- [qr-scanner]: https://github.com/nimiq/qr-scanner
338
+ [boofcv]: https://boofcv.org/index.php?title=Performance:QrCode
339
+ [bumpy]: https://github.com/dmno-dev/bumpy
340
+ [ci_badge]: https://github.com/Saeris/hanko/actions/workflows/ci.yml/badge.svg
341
+ [ci]: https://github.com/Saeris/hanko/actions/workflows/ci.yml
342
+ [npm_badge]: https://img.shields.io/npm/v/@saeris/hanko.svg
343
+ [npm]: https://www.npmjs.com/package/@saeris/hanko
344
+ [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
345
+ [license]: https://github.com/Saeris/hanko/blob/main/LICENSE.md
346
+ [personal-website]: https://saeris.gg
@@ -88,7 +88,10 @@ interface WorkerScanner {
88
88
  * would be wrong for most consumers.
89
89
  */
90
90
  declare const createWorkerScanner: (worker: {
91
- postMessage: (message: unknown, transfer?: ArrayBufferLike[]) => void;
91
+ postMessage: {
92
+ (message: unknown, transfer: never[]): void;
93
+ (message: unknown, options?: unknown): void;
94
+ };
92
95
  addEventListener?: (type: string, listener: (event: {
93
96
  data: unknown;
94
97
  }) => void) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/scan/qr/progressive.ts","../../src/scan/qr/locate.ts","../../src/scan/binarize.ts","../../src/scan/qr/format.ts","../../src/scan/qr/decode-matrix.ts","../../src/scan/qr/sample.ts","../../src/scan/gpu.ts","../../src/scan/machine.ts"],"mappings":";;;UA6BiB;;;;;;;EAOf,KAAK,OAAO,YAAY;;;;;;;;EASxB;;WAGS;;;UAIM,2BAA2B,KAC1C;;;;;;;EASA;;;;;;;;EASA;;;;;;;;EASA;;;;;;;;;;cAWW,6BAA4B,iBAAA,aAAA,WAAA,mBAKtC,uBAA0B;;;;;;;;;;;;;;;UA2DZ;;EAEf,KAAK,OAAO,YAAY,QAAQ;;EAEhC;;EAEA;;WAES;;;;;;;;;;cAWE,sBACX;EAIE,cAAc,kBAAkB,WAAW;EAC3C,oBACE,cACA,WAAW;IAAS;;EAEtB,MAAM,cAAc,WAAW;EAC/B;KAEF,iBAAA,aAAA,WAIG,uBACF;;;;UC7Kc;WACN,QAAQ;;WAER;;;;;;;;;cAynBE,gBACX,QAAQ,WACR,mBAAmB,oBAClB;;;;cCpmBU,SACX,MAAM,mBACN,eACA,mBACC;;;;;;;;;;;;;;;;;;;;;;cAiNU,iBACX,OAAO,aACP;EAAsB;MACrB;;;;;;;;;;;;;;;;;;cAmEU,QAAS,QAAQ,cAAY;;;;;;;;;;;;;;;;;;;cAkF7B,YAAa,OAAO,WAAW,mBAAiB;;;;;;;;;;;;;;;;;;;;cAgEhD,OAAQ,OAAO,WAAW,mBAAiB;;;;;;;;;;;;;cAmL3C,WACX,OAAO,aACP;EAAsB;MACrB;;;;KC5nBS;;;;UCKK;WACN;WACA;WACA,sBAAsB;WACtB;;;;;;;;;;cAWE,eACX,QAAQ,WAgBR;;;;;;;;;;;;;;;;AAAA,aAAa,eACZ;;;;;;;;;;;UC9Bc;WACN;WACA;WACA;WACA;WACA;WACA;WACA;WACA;WACA;;;;;UC6HM;EACf,MACE,OAAO,WACP,qBAAqB,aACrB,cACA,sCACC,QAAQ;EACX;;;cAIW;;;;;;;cASA,uBAA4B,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KCxJrC;;KAWA;;;;;;;;;;KAWA;;;EAEN;;;;EAEA;;;;EAEA;EAAgB;;;;EAEhB;;;;EAEA;EAAc,QAAQ;;;;EAEtB;;;;EAEA;;;;EAEA;;;cAyCO,oBACX,OAAO,WACP,OAAO;;cAII,iBAAkB,OAAO,WAAW,OAAO,cAAY;;;;;;;cASvD,gBAAiB,OAAO"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/scan/qr/progressive.ts","../../src/scan/qr/locate.ts","../../src/scan/binarize.ts","../../src/scan/qr/format.ts","../../src/scan/qr/decode-matrix.ts","../../src/scan/qr/sample.ts","../../src/scan/gpu.ts","../../src/scan/machine.ts"],"mappings":";;;UA6BiB;;;;;;;EAOf,KAAK,OAAO,YAAY;;;;;;;;EASxB;;WAGS;;;UAIM,2BAA2B,KAC1C;;;;;;;EASA;;;;;;;;EASA;;;;;;;;EASA;;;;;;;;;;cAWW,6BAA4B,iBAAA,aAAA,WAAA,mBAKtC,uBAA0B;;;;;;;;;;;;;;;UA2DZ;;EAEf,KAAK,OAAO,YAAY,QAAQ;;EAEhC;;EAEA;;WAES;;;;;;;;;;cAWE,sBACX;EAUE;KACG,kBAAkB;KAClB,kBAAkB;;EAErB,oBACE,cACA,WAAW;IAAS;;EAEtB,MAAM,cAAc,WAAW;EAC/B;KAEF,iBAAA,aAAA,WAIG,uBACF;;;;UCtLc;WACN,QAAQ;;WAER;;;;;;;;;cAynBE,gBACX,QAAQ,WACR,mBAAmB,oBAClB;;;;cCpmBU,SACX,MAAM,mBACN,eACA,mBACC;;;;;;;;;;;;;;;;;;;;;;cAiNU,iBACX,OAAO,aACP;EAAsB;MACrB;;;;;;;;;;;;;;;;;;cAmEU,QAAS,QAAQ,cAAY;;;;;;;;;;;;;;;;;;;cAkF7B,YAAa,OAAO,WAAW,mBAAiB;;;;;;;;;;;;;;;;;;;;cAgEhD,OAAQ,OAAO,WAAW,mBAAiB;;;;;;;;;;;;;cAmL3C,WACX,OAAO,aACP;EAAsB;MACrB;;;;KC5nBS;;;;UCKK;WACN;WACA;WACA,sBAAsB;WACtB;;;;;;;;;;cAWE,eACX,QAAQ,WAgBR;;;;;;;;;;;;;;;;AAAA,aAAa,eACZ;;;;;;;;;;;UC9Bc;WACN;WACA;WACA;WACA;WACA;WACA;WACA;WACA;WACA;;;;;UC6HM;EACf,MACE,OAAO,WACP,qBAAqB,aACrB,cACA,sCACC,QAAQ;EACX;;;cAIW;;;;;;;cASA,uBAA4B,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KCxJrC;;KAWA;;;;;;;;;;KAWA;;;EAEN;;;;EAEA;;;;EAEA;EAAgB;;;;EAEhB;;;;EAEA;EAAc,QAAQ;;;;EAEtB;;;;EAEA;;;;EAEA;;;cAyCO,oBACX,OAAO,WACP,OAAO;;cAII,iBAAkB,OAAO,WAAW,OAAO,cAAY;;;;;;;cASvD,gBAAiB,OAAO"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/scan/qr/progressive.ts","../../src/scan/gpu.ts","../../src/scan/machine.ts"],"sourcesContent":["/**\n * Scanning across frames rather than within one.\n *\n * The retry ladder is what makes difficult symbols readable, and it costs far\n * more than one frame can afford. Measured on the benchmark corpus, the\n * decoder reads **61.6%** with no time limit and **41.9%** at the 120ms\n * default a viewfinder needs โ€” so a third of its capability is unreachable in\n * the configuration people actually run, and no budget recovers it: a sweep\n * showed recognition still climbing at 700ms.\n *\n * That is a false choice, because a camera is not a still image. It supplies\n * thirty frames a second, each nearly free and each slightly different. Ten\n * frames at 120ms is 1.2 seconds of decoding work at full frame rate, without\n * ever stalling the preview.\n *\n * So this spends a small budget per frame and ADVANCES through the ladder\n * across successive frames: cheap rungs on every frame, expensive ones only\n * once the cheap ones have failed repeatedly. A code held steadily in view\n * gets the whole ladder within a second; a frame with nothing in it costs one\n * cheap pass.\n *\n * The decoder itself stays pure and synchronous โ€” this wraps it rather than\n * changing it, so a still image can still run the whole ladder at once.\n */\n\nimport type { DecodedSymbol, GrayImage } from \"../types.js\";\nimport { createQrDecoder, type QrDecoderOptions } from \"./decoder.js\";\n\n/** A scanner that improves its effort as frames arrive. */\nexport interface ProgressiveScanner {\n /**\n * Offer one frame.\n *\n * Returns a symbol as soon as one is read, and `null` otherwise โ€” including\n * while effort is still ramping up.\n */\n scan(image: GrayImage): DecodedSymbol | null;\n\n /**\n * Reset the effort ramp.\n *\n * Call when the scene changes โ€” a new code presented, the camera moved\n * somewhere else โ€” so the next frame starts cheap again rather than\n * inheriting effort earned by a symbol that is no longer there.\n */\n reset(): void;\n\n /** How much effort the next frame will receive, in milliseconds. */\n readonly budgetMs: number;\n}\n\n/** Options for {@link createProgressiveScanner}. */\nexport interface ProgressiveOptions extends Omit<\n QrDecoderOptions,\n `timeBudgetMs`\n> {\n /**\n * Budget for the first frame, in milliseconds.\n *\n * Deliberately small. Most frames a camera delivers contain no code at all,\n * and this is what every one of them costs.\n */\n initialBudgetMs?: number;\n\n /**\n * Ceiling on the per-frame budget.\n *\n * 400ms by default: beyond that a single frame stalls the preview\n * noticeably, and at 30fps the ladder has had a dozen frames to work with\n * by the time the ramp reaches it.\n */\n maxBudgetMs?: number;\n\n /**\n * How much the budget grows per consecutive frame that shows a symbol.\n *\n * Growth is conditional on there being something to find: a frame with no\n * finder candidate at all should not earn the next frame more time, or a\n * camera pointed at a wall would ramp to its ceiling and stay there.\n */\n growth?: number;\n}\n\n/**\n * Create a scanner that spreads decoding effort across frames.\n *\n * The ramp is driven by evidence rather than by a timer. A frame in which the\n * decoder finds nothing resets it, because nothing is there to spend effort\n * on; a frame that finds a symbol but cannot read it raises the budget,\n * because that is exactly the case where more effort pays.\n */\nexport const createProgressiveScanner = ({\n initialBudgetMs = 40,\n maxBudgetMs = 400,\n growth = 1.6,\n ...decoderOptions\n}: ProgressiveOptions = {}): ProgressiveScanner => {\n let budget = initialBudgetMs;\n\n // One decoder per budget level, cached: constructing one is cheap, but a\n // camera loop calls this thirty times a second and there are only a handful\n // of distinct budgets in the ramp.\n const decoders = new Map<number, ReturnType<typeof createQrDecoder>>();\n const decoderFor = (ms: number): ReturnType<typeof createQrDecoder> => {\n const existing = decoders.get(ms);\n if (existing !== undefined) return existing;\n\n const created = createQrDecoder({ ...decoderOptions, timeBudgetMs: ms });\n decoders.set(ms, created);\n return created;\n };\n\n return {\n get budgetMs(): number {\n return budget;\n },\n\n scan: (image: GrayImage): DecodedSymbol | null => {\n const rounded = Math.round(budget);\n const result = decoderFor(rounded).decode(image);\n\n if (result !== null) {\n // Read it. Drop back to the cheap budget: whatever comes next is a\n // different scan, and starting expensive would waste effort on the\n // frames after a successful read.\n budget = initialBudgetMs;\n return result;\n }\n\n // Nothing read. Spend more next time, up to the ceiling โ€” the symbol may\n // simply need more of the ladder than this frame could afford.\n budget = Math.min(maxBudgetMs, budget * growth);\n return null;\n },\n\n reset: (): void => {\n budget = initialBudgetMs;\n }\n };\n};\n\n/**\n * Drive a decoder running in a worker.\n *\n * Same shape as {@link createProgressiveScanner} but asynchronous, because\n * the work happens elsewhere. The main thread stays free: measured on a hard\n * frame, an in-thread decode blocks 208ms at a 40ms budget and 481ms at\n * 400ms, which at 60fps is 12 and 28 dropped frames respectively โ€” a visible\n * freeze on exactly the frames where someone is lining up a shot.\n *\n * Frames offered while a decode is in flight are DROPPED rather than queued.\n * A camera produces frames faster than they can be decoded, and a queue would\n * grow without bound while returning increasingly stale results; the next\n * frame is always a better input than a backlogged one.\n */\nexport interface WorkerScanner {\n /** Offer a frame. Resolves `null` if dropped, still ramping, or unreadable. */\n scan(image: GrayImage): Promise<DecodedSymbol | null>;\n /** Reset the effort ramp โ€” call when the scene changes. */\n reset(): void;\n /** Stop the worker and release it. */\n close(): void;\n /** Whether a decode is currently in flight. */\n readonly busy: boolean;\n}\n\n/**\n * Wrap a worker in the progressive interface.\n *\n * The worker is supplied rather than constructed here, because how a worker\n * is created is a bundler question โ€” `new Worker(new URL(...), { type:\n * \"module\" })` in Vite, a blob URL elsewhere โ€” and a library that guessed\n * would be wrong for most consumers.\n */\nexport const createWorkerScanner = (\n worker: {\n // `ArrayBufferLike[]` rather than `Transferable[]`: this package compiles\n // without the DOM lib on purpose, and a buffer is the only thing\n // transferred here.\n postMessage: (message: unknown, transfer?: ArrayBufferLike[]) => void;\n addEventListener?: (\n type: string,\n listener: (event: { data: unknown }) => void\n ) => void;\n on?: (type: string, listener: (message: unknown) => void) => void;\n terminate?: () => void;\n },\n {\n initialBudgetMs = 40,\n maxBudgetMs = 400,\n growth = 1.6\n }: ProgressiveOptions = {}\n): WorkerScanner => {\n let budget = initialBudgetMs;\n let nextId = 0;\n let inFlight = false;\n const pending = new Map<\n number,\n (response: { result: DecodedSymbol | null }) => void\n >();\n\n const receive = (payload: unknown): void => {\n // Narrowed by runtime checks rather than a cast: a DOM MessageEvent\n // carries the payload on `.data` while a Node message IS the payload, and\n // asserting one shape would silently misread the other.\n if (payload === null || typeof payload !== `object`) return;\n\n const envelope = payload as { data?: unknown };\n const body: unknown =\n typeof envelope.data === `object` && envelope.data !== null\n ? envelope.data\n : payload;\n\n if (body === null || typeof body !== `object`) return;\n if (!(`id` in body) || typeof body.id !== `number`) return;\n\n const response = body as { id: number; result: DecodedSymbol | null };\n const resolve = pending.get(response.id);\n if (resolve === undefined) return;\n\n pending.delete(response.id);\n inFlight = false;\n resolve(response);\n };\n\n if (typeof worker.addEventListener === `function`) {\n worker.addEventListener(`message`, receive);\n } else if (typeof worker.on === `function`) {\n worker.on(`message`, receive);\n }\n\n return {\n get busy(): boolean {\n return inFlight;\n },\n\n scan: async (image: GrayImage): Promise<DecodedSymbol | null> => {\n // Dropped rather than queued: a camera outruns the decoder, and a queue\n // would grow unbounded while returning ever staler results.\n if (inFlight) return null;\n\n inFlight = true;\n const id = nextId++;\n\n const settled = new Promise<{ result: DecodedSymbol | null }>(\n (resolve) => {\n pending.set(id, resolve);\n }\n );\n\n // Copied before transfer: transferring detaches the caller's buffer,\n // and a caller reusing one frame buffer across frames โ€” which is the\n // normal pattern โ€” would find it empty on the next read.\n const copy = new Uint8ClampedArray(image.data);\n worker.postMessage(\n { id, data: copy, width: image.width, height: image.height },\n [copy.buffer]\n );\n\n const { result } = await settled;\n\n budget =\n result === null\n ? Math.min(maxBudgetMs, budget * growth)\n : initialBudgetMs;\n\n return result;\n },\n\n reset: (): void => {\n budget = initialBudgetMs;\n },\n\n close: (): void => {\n pending.clear();\n inFlight = false;\n worker.terminate?.();\n }\n };\n};\n","/**\n * GPU-accelerated transform scoring.\n *\n * One stage of this decoder is worth moving to the GPU and the rest are not.\n * Profiling a 1024x768 frame: binarization costs 16ms, blur 28ms, downscale\n * 6ms โ€” all far too small to survive the cost of uploading a buffer,\n * dispatching a shader and reading the result back. But the corner search\n * makes **625 independent calls** to `scoreTransform` for about 179ms, which\n * is 78% of that stage and the largest single cost in the decoder.\n *\n * Those 625 calls score the same image under different transforms, share no\n * state, and each reads a few hundred pixels. That is the shape GPU compute\n * exists for: one upload of the image, 625 workgroups, one small readback.\n *\n * Availability is unusually good for a modern web API. WebGPU shipped\n * enabled-by-default in iOS 26, macOS Tahoe 26 and iPadOS 26, which removed\n * the last major holdout โ€” so unlike `BarcodeDetector`, this is something the\n * target platforms actually have. It still degrades to the CPU path\n * everywhere else, because a scanner that only works on new hardware is not a\n * scanner.\n */\n\nimport type { BitMatrix } from \"./types.js\";\nimport type { Transform } from \"./qr/sample.js\";\n\n/**\n * The scoring kernel.\n *\n * Mirrors `scoreTransform` exactly โ€” same nine-point module sampling, same\n * ring structure, same weights. Any divergence would make GPU and CPU\n * disagree about which transform is best, which is worse than not\n * accelerating at all: results would depend on the device.\n */\nconst SHADER = `\nstruct Params {\n width: u32,\n height: u32,\n size: u32,\n centerCount: u32,\n};\n\n@group(0) @binding(0) var<storage, read> bits: array<u32>;\n@group(0) @binding(1) var<storage, read> transforms: array<f32>;\n@group(0) @binding(2) var<storage, read> centers: array<u32>;\n@group(0) @binding(3) var<storage, read_write> scores: array<i32>;\n@group(0) @binding(4) var<uniform> params: Params;\n\nfn pixel(x: i32, y: i32) -> i32 {\n if (x < 0 || y < 0 || x >= i32(params.width) || y >= i32(params.height)) {\n return 0;\n }\n let index = u32(y) * params.width + u32(x);\n // Bits are packed one per u32 for simplicity: the readback is tiny and the\n // upload happens once per frame, so the memory is not the bottleneck.\n return select(-1, 1, bits[index] == 1u);\n}\n\nfn mapPoint(base: u32, x: f32, y: f32) -> vec2<f32> {\n let a11 = transforms[base + 0u];\n let a12 = transforms[base + 1u];\n let a13 = transforms[base + 2u];\n let a21 = transforms[base + 3u];\n let a22 = transforms[base + 4u];\n let a23 = transforms[base + 5u];\n let a31 = transforms[base + 6u];\n let a32 = transforms[base + 7u];\n let a33 = transforms[base + 8u];\n\n let w = a13 * x + a23 * y + a33;\n if (abs(w) < 1e-9) { return vec2<f32>(-1.0, -1.0); }\n return vec2<f32>((a11 * x + a21 * y + a31) / w, (a12 * x + a22 * y + a32) / w);\n}\n\nfn cell(base: u32, span: f32, mx: f32, my: f32) -> i32 {\n var score = 0;\n let offsets = array<f32, 3>(0.3, 0.5, 0.7);\n for (var v = 0u; v < 3u; v = v + 1u) {\n for (var u = 0u; u < 3u; u = u + 1u) {\n let p = mapPoint(base, (mx + offsets[u] - 3.5) / span, (my + offsets[v] - 3.5) / span);\n score = score + pixel(i32(round(p.x)), i32(round(p.y)));\n }\n }\n return score;\n}\n\nfn ring(base: u32, span: f32, cx: f32, cy: f32, radius: f32) -> i32 {\n var score = 0;\n let steps = i32(radius * 2.0);\n for (var i = 0; i < steps; i = i + 1) {\n let f = f32(i);\n score = score + cell(base, span, cx - radius + f, cy - radius);\n score = score + cell(base, span, cx - radius, cy + radius - f);\n score = score + cell(base, span, cx + radius, cy - radius + f);\n score = score + cell(base, span, cx + radius - f, cy + radius);\n }\n return score;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) id: vec3<u32>) {\n let index = id.x;\n if (index >= arrayLength(&scores)) { return; }\n\n let base = index * 9u;\n let size = f32(params.size);\n let span = size - 7.0;\n var score = 0;\n\n // Timing patterns alternate; the expected value flips each module.\n for (var i = 0u; i < params.size - 14u; i = i + 1u) {\n let expected = select(-1, 1, (i & 1u) == 1u);\n score = score + cell(base, span, f32(i) + 7.0, 6.0) * expected;\n score = score + cell(base, span, 6.0, f32(i) + 7.0) * expected;\n }\n\n // Finders: dark centre, dark ring, light ring, dark ring.\n let corners = array<vec2<f32>, 3>(\n vec2<f32>(0.0, 0.0),\n vec2<f32>(size - 7.0, 0.0),\n vec2<f32>(0.0, size - 7.0)\n );\n for (var c = 0u; c < 3u; c = c + 1u) {\n let x = corners[c].x + 3.0;\n let y = corners[c].y + 3.0;\n score = score + cell(base, span, x, y)\n + ring(base, span, x, y, 1.0)\n - ring(base, span, x, y, 2.0)\n + ring(base, span, x, y, 3.0);\n }\n\n // Alignment patterns: dark centre, light ring, dark ring.\n //\n // Two groups, matching the CPU exactly. The edge row and column come first\n // (skipping the last, which overlaps a finder), then the interior grid.\n // Scoring only the interior would give systematically different totals, and\n // since the whole technique ranks transforms against each other, GPU and\n // CPU would disagree about which corner is best โ€” decoding that depends on\n // the device is worse than not accelerating at all.\n for (var i = 1u; i + 1u < params.centerCount; i = i + 1u) {\n let c = f32(centers[i]);\n score = score + cell(base, span, 6.0, c)\n - ring(base, span, 6.0, c, 1.0)\n + ring(base, span, 6.0, c, 2.0);\n score = score + cell(base, span, c, 6.0)\n - ring(base, span, c, 6.0, 1.0)\n + ring(base, span, c, 6.0, 2.0);\n }\n\n for (var i = 1u; i < params.centerCount; i = i + 1u) {\n for (var j = 1u; j < params.centerCount; j = j + 1u) {\n let cx = f32(centers[i]);\n let cy = f32(centers[j]);\n score = score + cell(base, span, cx, cy)\n - ring(base, span, cx, cy, 1.0)\n + ring(base, span, cx, cy, 2.0);\n }\n }\n\n scores[index] = score;\n}\n`;\n\n/** Scores many transforms against one image, on the GPU. */\nexport interface GpuScorer {\n score(\n image: BitMatrix,\n transforms: readonly Transform[],\n size: number,\n alignmentCenters: readonly number[]\n ): Promise<Int32Array>;\n destroy(): void;\n}\n\n/** Whether this runtime exposes WebGPU at all. */\nexport const hasWebGpu = (): boolean =>\n typeof navigator === `object` && `gpu` in navigator;\n\n/**\n * Create a GPU scorer, or `null` where WebGPU is unavailable.\n *\n * Returns `null` rather than throwing: every caller must have a CPU path\n * anyway, so an absent GPU is a normal condition and not an error.\n */\nexport const createGpuScorer = async (): Promise<GpuScorer | null> => {\n if (!hasWebGpu()) return null;\n\n // Narrowed through a runtime check rather than a cast: `navigator.gpu` is\n // absent on every server runtime and on older browsers.\n const gpu: unknown = (navigator as unknown as { gpu?: unknown }).gpu;\n if (typeof gpu !== `object` || gpu === null) return null;\n\n const adapter = await (\n gpu as { requestAdapter: () => Promise<unknown> }\n ).requestAdapter();\n if (adapter === null || typeof adapter !== `object`) return null;\n\n const device = await (\n adapter as { requestDevice: () => Promise<GpuDeviceLike> }\n ).requestDevice();\n\n const module = device.createShaderModule({ code: SHADER });\n const pipeline = device.createComputePipeline({\n layout: `auto`,\n compute: { module, entryPoint: `main` }\n });\n\n return {\n score: async (image, transforms, size, alignmentCenters) => {\n const count = transforms.length;\n if (count === 0) return new Int32Array(0);\n\n // One u32 per pixel. Packing to bits would quarter the upload but adds\n // shifting in the inner loop, and the upload is not what costs here.\n const bits = new Uint32Array(image.bits.length);\n for (let i = 0; i < image.bits.length; i++) bits[i] = image.bits[i]!;\n\n const flat = new Float32Array(count * 9);\n for (const [index, transform] of transforms.entries()) {\n const base = index * 9;\n flat[base] = transform.a11;\n flat[base + 1] = transform.a12;\n flat[base + 2] = transform.a13;\n flat[base + 3] = transform.a21;\n flat[base + 4] = transform.a22;\n flat[base + 5] = transform.a23;\n flat[base + 6] = transform.a31;\n flat[base + 7] = transform.a32;\n flat[base + 8] = transform.a33;\n }\n\n const centers = Uint32Array.from(alignmentCenters);\n const params = new Uint32Array([\n image.width,\n image.height,\n size,\n centers.length\n ]);\n\n const upload = (data: ArrayBufferView, usage: number): GpuBufferLike => {\n const buffer = device.createBuffer({\n size: Math.max(4, data.byteLength),\n usage,\n mappedAtCreation: true\n });\n new Uint8Array(buffer.getMappedRange()).set(\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength)\n );\n buffer.unmap();\n return buffer;\n };\n\n const STORAGE = 0x80 | 0x4;\n const UNIFORM = 0x40 | 0x4;\n const COPY_SRC = 0x4;\n const COPY_DST = 0x8;\n const MAP_READ = 0x1;\n\n const bitsBuffer = upload(bits, STORAGE);\n const transformBuffer = upload(flat, STORAGE);\n const centerBuffer = upload(\n centers.length > 0 ? centers : new Uint32Array(1),\n STORAGE\n );\n const paramsBuffer = upload(params, UNIFORM);\n\n const scoreBuffer = device.createBuffer({\n size: count * 4,\n usage: 0x80 | COPY_SRC\n });\n const readBuffer = device.createBuffer({\n size: count * 4,\n usage: COPY_DST | MAP_READ\n });\n\n const bindGroup = device.createBindGroup({\n layout: pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: { buffer: bitsBuffer } },\n { binding: 1, resource: { buffer: transformBuffer } },\n { binding: 2, resource: { buffer: centerBuffer } },\n { binding: 3, resource: { buffer: scoreBuffer } },\n { binding: 4, resource: { buffer: paramsBuffer } }\n ]\n });\n\n const encoder = device.createCommandEncoder();\n const pass = encoder.beginComputePass();\n pass.setPipeline(pipeline);\n pass.setBindGroup(0, bindGroup);\n pass.dispatchWorkgroups(Math.ceil(count / 64));\n pass.end();\n encoder.copyBufferToBuffer(scoreBuffer, 0, readBuffer, 0, count * 4);\n device.queue.submit([encoder.finish()]);\n\n await readBuffer.mapAsync(MAP_READ);\n const scores = new Int32Array(readBuffer.getMappedRange().slice(0));\n readBuffer.unmap();\n\n for (const buffer of [\n bitsBuffer,\n transformBuffer,\n centerBuffer,\n paramsBuffer,\n scoreBuffer,\n readBuffer\n ]) {\n buffer.destroy();\n }\n\n return scores;\n },\n\n destroy: () => {\n device.destroy();\n }\n };\n};\n\n/**\n * The subset of WebGPU this module uses.\n *\n * Declared locally rather than pulled from a types package: this library ships\n * zero dependencies, and `@webgpu/types` would be one for a surface this\n * small.\n */\ninterface GpuBufferLike {\n getMappedRange(): ArrayBuffer;\n unmap(): void;\n destroy(): void;\n mapAsync(mode: number): Promise<void>;\n}\n\ninterface GpuDeviceLike {\n createShaderModule(descriptor: { code: string }): unknown;\n createComputePipeline(descriptor: {\n layout: string;\n compute: { module: unknown; entryPoint: string };\n }): { getBindGroupLayout(index: number): unknown };\n createBuffer(descriptor: {\n size: number;\n usage: number;\n mappedAtCreation?: boolean;\n }): GpuBufferLike;\n createBindGroup(descriptor: {\n layout: unknown;\n entries: Array<{ binding: number; resource: { buffer: GpuBufferLike } }>;\n }): unknown;\n createCommandEncoder(): {\n beginComputePass(): {\n setPipeline(pipeline: unknown): void;\n setBindGroup(index: number, group: unknown): void;\n dispatchWorkgroups(count: number): void;\n end(): void;\n };\n copyBufferToBuffer(\n source: GpuBufferLike,\n sourceOffset: number,\n destination: GpuBufferLike,\n destinationOffset: number,\n size: number\n ): void;\n finish(): unknown;\n };\n queue: { submit(buffers: unknown[]): void };\n destroy(): void;\n}\n","/**\n * A scan session, as a state machine.\n *\n * Modeled the way `machine.ts` models the device flow: states, events, and a\n * declarative transition table, hand-rolled so this stays dependency-free.\n *\n * Deliberately NOT applied to the decode pipeline itself. Binarize โ†’ locate โ†’\n * extract โ†’ decode is a fallible sequential pipeline, not a machine: each\n * stage runs once per frame and either produces a value or does not. Modeling\n * it as states would add ceremony without making a single illegal move\n * impossible, which is the only thing a transition table buys.\n *\n * What IS a machine is the session around it โ€” permission, camera lifecycle,\n * pause and resume โ€” and that part is identical whether the frames are being\n * searched for a QR, a DataMatrix, or an Aztec code. So it lives here,\n * symbology-agnostic, and the decoders plug into it.\n */\n\n/**\n * Lifecycle of one scanning session.\n *\n * `scanning` is the only state that consumes frames. `decoded` is terminal\n * rather than a return to `scanning`: a scanner that kept reading after a\n * successful decode would fire twice on the same symbol, and every consumer\n * would have to debounce it. Restarting is explicit.\n *\n * `denied` is separate from `failed` because they are different conversations\n * with the user โ€” one asks them to change a browser setting, the other is a\n * fault they cannot act on. Collapsing them is how a permission prompt ends up\n * reported as \"something went wrong\".\n */\nexport type ScanState =\n | `idle`\n | `starting`\n | `scanning`\n | `paused`\n | `decoded`\n | `denied`\n | `failed`\n | `stopped`;\n\n/** Why a scan session ended without a result. */\nexport type ScanFailure =\n /** No camera on this device, or none matching the requested facing mode. */\n | `no-camera`\n /** The page is not a secure context, so `getUserMedia` is unavailable. */\n | `insecure-context`\n /** The camera was lost mid-session โ€” unplugged, or claimed by another app. */\n | `camera-lost`\n /** The decoder itself threw. Distinct from \"no code in this frame\". */\n | `decoder-error`;\n\n/** Events driving a scan session. */\nexport type ScanEvent =\n /** Consumer asked to begin. */\n | { type: `START` }\n /** Camera acquired and frames are flowing. */\n | { type: `READY` }\n /** A frame decoded successfully. Terminal โ€” restarting is explicit. */\n | { type: `DECODE`; value: string }\n /** The user refused camera access. */\n | { type: `DENY` }\n /** Something broke that the user cannot act on. */\n | { type: `FAIL`; reason: ScanFailure }\n /** Tab hidden, or the consumer suspended scanning. */\n | { type: `PAUSE` }\n /** Visible again. */\n | { type: `RESUME` }\n /** Consumer tore the session down. */\n | { type: `STOP` };\n\n/**\n * Transitions, as a table.\n *\n * Everything absent is illegal by construction. That is what makes a `DECODE`\n * arriving after `STOP` a no-op rather than a race โ€” a real hazard here,\n * because a decode in flight when the camera stops would otherwise resolve\n * into a torn-down session.\n */\nconst SCAN_TRANSITIONS: {\n readonly [S in ScanState]?: {\n readonly [E in ScanEvent[`type`]]?: ScanState;\n };\n} = {\n idle: {\n START: `starting`\n },\n // Permission is resolved here, so this is the only state that can be denied.\n starting: {\n READY: `scanning`,\n DENY: `denied`,\n FAIL: `failed`,\n STOP: `stopped`\n },\n scanning: {\n DECODE: `decoded`,\n PAUSE: `paused`,\n FAIL: `failed`,\n STOP: `stopped`\n },\n // No DECODE here: a paused session is not reading frames, and accepting one\n // would mean a frame queued before the pause could resolve after it.\n paused: {\n RESUME: `scanning`,\n FAIL: `failed`,\n STOP: `stopped`\n }\n};\n\n/** Whether `event` would move `state`. */\nexport const canTransitionScan = (\n state: ScanState,\n event: ScanEvent[`type`]\n): boolean => SCAN_TRANSITIONS[state]?.[event] !== undefined;\n\n/** Apply an event. Unknown events for the current state are no-ops. */\nexport const scanTransition = (state: ScanState, event: ScanEvent): ScanState =>\n SCAN_TRANSITIONS[state]?.[event.type] ?? state;\n\n/**\n * Terminal states accept no further events.\n *\n * Derived from the table rather than listed, so a new state cannot be added\n * without its terminality following automatically.\n */\nexport const isScanSettled = (state: ScanState): boolean =>\n SCAN_TRANSITIONS[state] === undefined;\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/scan/qr/progressive.ts","../../src/scan/gpu.ts","../../src/scan/machine.ts"],"sourcesContent":["/**\n * Scanning across frames rather than within one.\n *\n * The retry ladder is what makes difficult symbols readable, and it costs far\n * more than one frame can afford. Measured on the benchmark corpus, the\n * decoder reads **61.6%** with no time limit and **41.9%** at the 120ms\n * default a viewfinder needs โ€” so a third of its capability is unreachable in\n * the configuration people actually run, and no budget recovers it: a sweep\n * showed recognition still climbing at 700ms.\n *\n * That is a false choice, because a camera is not a still image. It supplies\n * thirty frames a second, each nearly free and each slightly different. Ten\n * frames at 120ms is 1.2 seconds of decoding work at full frame rate, without\n * ever stalling the preview.\n *\n * So this spends a small budget per frame and ADVANCES through the ladder\n * across successive frames: cheap rungs on every frame, expensive ones only\n * once the cheap ones have failed repeatedly. A code held steadily in view\n * gets the whole ladder within a second; a frame with nothing in it costs one\n * cheap pass.\n *\n * The decoder itself stays pure and synchronous โ€” this wraps it rather than\n * changing it, so a still image can still run the whole ladder at once.\n */\n\nimport type { DecodedSymbol, GrayImage } from \"../types.js\";\nimport { createQrDecoder, type QrDecoderOptions } from \"./decoder.js\";\n\n/** A scanner that improves its effort as frames arrive. */\nexport interface ProgressiveScanner {\n /**\n * Offer one frame.\n *\n * Returns a symbol as soon as one is read, and `null` otherwise โ€” including\n * while effort is still ramping up.\n */\n scan(image: GrayImage): DecodedSymbol | null;\n\n /**\n * Reset the effort ramp.\n *\n * Call when the scene changes โ€” a new code presented, the camera moved\n * somewhere else โ€” so the next frame starts cheap again rather than\n * inheriting effort earned by a symbol that is no longer there.\n */\n reset(): void;\n\n /** How much effort the next frame will receive, in milliseconds. */\n readonly budgetMs: number;\n}\n\n/** Options for {@link createProgressiveScanner}. */\nexport interface ProgressiveOptions extends Omit<\n QrDecoderOptions,\n `timeBudgetMs`\n> {\n /**\n * Budget for the first frame, in milliseconds.\n *\n * Deliberately small. Most frames a camera delivers contain no code at all,\n * and this is what every one of them costs.\n */\n initialBudgetMs?: number;\n\n /**\n * Ceiling on the per-frame budget.\n *\n * 400ms by default: beyond that a single frame stalls the preview\n * noticeably, and at 30fps the ladder has had a dozen frames to work with\n * by the time the ramp reaches it.\n */\n maxBudgetMs?: number;\n\n /**\n * How much the budget grows per consecutive frame that shows a symbol.\n *\n * Growth is conditional on there being something to find: a frame with no\n * finder candidate at all should not earn the next frame more time, or a\n * camera pointed at a wall would ramp to its ceiling and stay there.\n */\n growth?: number;\n}\n\n/**\n * Create a scanner that spreads decoding effort across frames.\n *\n * The ramp is driven by evidence rather than by a timer. A frame in which the\n * decoder finds nothing resets it, because nothing is there to spend effort\n * on; a frame that finds a symbol but cannot read it raises the budget,\n * because that is exactly the case where more effort pays.\n */\nexport const createProgressiveScanner = ({\n initialBudgetMs = 40,\n maxBudgetMs = 400,\n growth = 1.6,\n ...decoderOptions\n}: ProgressiveOptions = {}): ProgressiveScanner => {\n let budget = initialBudgetMs;\n\n // One decoder per budget level, cached: constructing one is cheap, but a\n // camera loop calls this thirty times a second and there are only a handful\n // of distinct budgets in the ramp.\n const decoders = new Map<number, ReturnType<typeof createQrDecoder>>();\n const decoderFor = (ms: number): ReturnType<typeof createQrDecoder> => {\n const existing = decoders.get(ms);\n if (existing !== undefined) return existing;\n\n const created = createQrDecoder({ ...decoderOptions, timeBudgetMs: ms });\n decoders.set(ms, created);\n return created;\n };\n\n return {\n get budgetMs(): number {\n return budget;\n },\n\n scan: (image: GrayImage): DecodedSymbol | null => {\n const rounded = Math.round(budget);\n const result = decoderFor(rounded).decode(image);\n\n if (result !== null) {\n // Read it. Drop back to the cheap budget: whatever comes next is a\n // different scan, and starting expensive would waste effort on the\n // frames after a successful read.\n budget = initialBudgetMs;\n return result;\n }\n\n // Nothing read. Spend more next time, up to the ceiling โ€” the symbol may\n // simply need more of the ladder than this frame could afford.\n budget = Math.min(maxBudgetMs, budget * growth);\n return null;\n },\n\n reset: (): void => {\n budget = initialBudgetMs;\n }\n };\n};\n\n/**\n * Drive a decoder running in a worker.\n *\n * Same shape as {@link createProgressiveScanner} but asynchronous, because\n * the work happens elsewhere. The main thread stays free: measured on a hard\n * frame, an in-thread decode blocks 208ms at a 40ms budget and 481ms at\n * 400ms, which at 60fps is 12 and 28 dropped frames respectively โ€” a visible\n * freeze on exactly the frames where someone is lining up a shot.\n *\n * Frames offered while a decode is in flight are DROPPED rather than queued.\n * A camera produces frames faster than they can be decoded, and a queue would\n * grow without bound while returning increasingly stale results; the next\n * frame is always a better input than a backlogged one.\n */\nexport interface WorkerScanner {\n /** Offer a frame. Resolves `null` if dropped, still ramping, or unreadable. */\n scan(image: GrayImage): Promise<DecodedSymbol | null>;\n /** Reset the effort ramp โ€” call when the scene changes. */\n reset(): void;\n /** Stop the worker and release it. */\n close(): void;\n /** Whether a decode is currently in flight. */\n readonly busy: boolean;\n}\n\n/**\n * Wrap a worker in the progressive interface.\n *\n * The worker is supplied rather than constructed here, because how a worker\n * is created is a bundler question โ€” `new Worker(new URL(...), { type:\n * \"module\" })` in Vite, a blob URL elsewhere โ€” and a library that guessed\n * would be wrong for most consumers.\n */\nexport const createWorkerScanner = (\n worker: {\n // Declared as an overload pair, mirroring the DOM's own signature.\n //\n // This package compiles without the DOM lib on purpose, so the type is\n // written out rather than referencing `Worker`. It has to be written out\n // FAITHFULLY: the DOM declares postMessage as two overloads, one of which\n // takes a REQUIRED transfer list. A single signature with an optional\n // `transfer?` cannot match that โ€” parameters are contravariant โ€” so a real\n // `new Worker(...)` fails to compile against it under `strict` while\n // working perfectly at runtime, which is the worst kind of type error.\n postMessage: {\n (message: unknown, transfer: never[]): void;\n (message: unknown, options?: unknown): void;\n };\n addEventListener?: (\n type: string,\n listener: (event: { data: unknown }) => void\n ) => void;\n on?: (type: string, listener: (message: unknown) => void) => void;\n terminate?: () => void;\n },\n {\n initialBudgetMs = 40,\n maxBudgetMs = 400,\n growth = 1.6\n }: ProgressiveOptions = {}\n): WorkerScanner => {\n let budget = initialBudgetMs;\n let nextId = 0;\n let inFlight = false;\n const pending = new Map<\n number,\n (response: { result: DecodedSymbol | null }) => void\n >();\n\n const receive = (payload: unknown): void => {\n // Narrowed by runtime checks rather than a cast: a DOM MessageEvent\n // carries the payload on `.data` while a Node message IS the payload, and\n // asserting one shape would silently misread the other.\n if (payload === null || typeof payload !== `object`) return;\n\n const envelope = payload as { data?: unknown };\n const body: unknown =\n typeof envelope.data === `object` && envelope.data !== null\n ? envelope.data\n : payload;\n\n if (body === null || typeof body !== `object`) return;\n if (!(`id` in body) || typeof body.id !== `number`) return;\n\n const response = body as { id: number; result: DecodedSymbol | null };\n const resolve = pending.get(response.id);\n if (resolve === undefined) return;\n\n pending.delete(response.id);\n inFlight = false;\n resolve(response);\n };\n\n if (typeof worker.addEventListener === `function`) {\n worker.addEventListener(`message`, receive);\n } else if (typeof worker.on === `function`) {\n worker.on(`message`, receive);\n }\n\n return {\n get busy(): boolean {\n return inFlight;\n },\n\n scan: async (image: GrayImage): Promise<DecodedSymbol | null> => {\n // Dropped rather than queued: a camera outruns the decoder, and a queue\n // would grow unbounded while returning ever staler results.\n if (inFlight) return null;\n\n inFlight = true;\n const id = nextId++;\n\n const settled = new Promise<{ result: DecodedSymbol | null }>(\n (resolve) => {\n pending.set(id, resolve);\n }\n );\n\n // Copied before transfer: transferring detaches the caller's buffer,\n // and a caller reusing one frame buffer across frames โ€” which is the\n // normal pattern โ€” would find it empty on the next read.\n const copy = new Uint8ClampedArray(image.data);\n worker.postMessage(\n { id, data: copy, width: image.width, height: image.height },\n [copy.buffer]\n );\n\n const { result } = await settled;\n\n budget =\n result === null\n ? Math.min(maxBudgetMs, budget * growth)\n : initialBudgetMs;\n\n return result;\n },\n\n reset: (): void => {\n budget = initialBudgetMs;\n },\n\n close: (): void => {\n pending.clear();\n inFlight = false;\n worker.terminate?.();\n }\n };\n};\n","/**\n * GPU-accelerated transform scoring.\n *\n * One stage of this decoder is worth moving to the GPU and the rest are not.\n * Profiling a 1024x768 frame: binarization costs 16ms, blur 28ms, downscale\n * 6ms โ€” all far too small to survive the cost of uploading a buffer,\n * dispatching a shader and reading the result back. But the corner search\n * makes **625 independent calls** to `scoreTransform` for about 179ms, which\n * is 78% of that stage and the largest single cost in the decoder.\n *\n * Those 625 calls score the same image under different transforms, share no\n * state, and each reads a few hundred pixels. That is the shape GPU compute\n * exists for: one upload of the image, 625 workgroups, one small readback.\n *\n * Availability is unusually good for a modern web API. WebGPU shipped\n * enabled-by-default in iOS 26, macOS Tahoe 26 and iPadOS 26, which removed\n * the last major holdout โ€” so unlike `BarcodeDetector`, this is something the\n * target platforms actually have. It still degrades to the CPU path\n * everywhere else, because a scanner that only works on new hardware is not a\n * scanner.\n */\n\nimport type { BitMatrix } from \"./types.js\";\nimport type { Transform } from \"./qr/sample.js\";\n\n/**\n * The scoring kernel.\n *\n * Mirrors `scoreTransform` exactly โ€” same nine-point module sampling, same\n * ring structure, same weights. Any divergence would make GPU and CPU\n * disagree about which transform is best, which is worse than not\n * accelerating at all: results would depend on the device.\n */\nconst SHADER = `\nstruct Params {\n width: u32,\n height: u32,\n size: u32,\n centerCount: u32,\n};\n\n@group(0) @binding(0) var<storage, read> bits: array<u32>;\n@group(0) @binding(1) var<storage, read> transforms: array<f32>;\n@group(0) @binding(2) var<storage, read> centers: array<u32>;\n@group(0) @binding(3) var<storage, read_write> scores: array<i32>;\n@group(0) @binding(4) var<uniform> params: Params;\n\nfn pixel(x: i32, y: i32) -> i32 {\n if (x < 0 || y < 0 || x >= i32(params.width) || y >= i32(params.height)) {\n return 0;\n }\n let index = u32(y) * params.width + u32(x);\n // Bits are packed one per u32 for simplicity: the readback is tiny and the\n // upload happens once per frame, so the memory is not the bottleneck.\n return select(-1, 1, bits[index] == 1u);\n}\n\nfn mapPoint(base: u32, x: f32, y: f32) -> vec2<f32> {\n let a11 = transforms[base + 0u];\n let a12 = transforms[base + 1u];\n let a13 = transforms[base + 2u];\n let a21 = transforms[base + 3u];\n let a22 = transforms[base + 4u];\n let a23 = transforms[base + 5u];\n let a31 = transforms[base + 6u];\n let a32 = transforms[base + 7u];\n let a33 = transforms[base + 8u];\n\n let w = a13 * x + a23 * y + a33;\n if (abs(w) < 1e-9) { return vec2<f32>(-1.0, -1.0); }\n return vec2<f32>((a11 * x + a21 * y + a31) / w, (a12 * x + a22 * y + a32) / w);\n}\n\nfn cell(base: u32, span: f32, mx: f32, my: f32) -> i32 {\n var score = 0;\n let offsets = array<f32, 3>(0.3, 0.5, 0.7);\n for (var v = 0u; v < 3u; v = v + 1u) {\n for (var u = 0u; u < 3u; u = u + 1u) {\n let p = mapPoint(base, (mx + offsets[u] - 3.5) / span, (my + offsets[v] - 3.5) / span);\n score = score + pixel(i32(round(p.x)), i32(round(p.y)));\n }\n }\n return score;\n}\n\nfn ring(base: u32, span: f32, cx: f32, cy: f32, radius: f32) -> i32 {\n var score = 0;\n let steps = i32(radius * 2.0);\n for (var i = 0; i < steps; i = i + 1) {\n let f = f32(i);\n score = score + cell(base, span, cx - radius + f, cy - radius);\n score = score + cell(base, span, cx - radius, cy + radius - f);\n score = score + cell(base, span, cx + radius, cy - radius + f);\n score = score + cell(base, span, cx + radius - f, cy + radius);\n }\n return score;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) id: vec3<u32>) {\n let index = id.x;\n if (index >= arrayLength(&scores)) { return; }\n\n let base = index * 9u;\n let size = f32(params.size);\n let span = size - 7.0;\n var score = 0;\n\n // Timing patterns alternate; the expected value flips each module.\n for (var i = 0u; i < params.size - 14u; i = i + 1u) {\n let expected = select(-1, 1, (i & 1u) == 1u);\n score = score + cell(base, span, f32(i) + 7.0, 6.0) * expected;\n score = score + cell(base, span, 6.0, f32(i) + 7.0) * expected;\n }\n\n // Finders: dark centre, dark ring, light ring, dark ring.\n let corners = array<vec2<f32>, 3>(\n vec2<f32>(0.0, 0.0),\n vec2<f32>(size - 7.0, 0.0),\n vec2<f32>(0.0, size - 7.0)\n );\n for (var c = 0u; c < 3u; c = c + 1u) {\n let x = corners[c].x + 3.0;\n let y = corners[c].y + 3.0;\n score = score + cell(base, span, x, y)\n + ring(base, span, x, y, 1.0)\n - ring(base, span, x, y, 2.0)\n + ring(base, span, x, y, 3.0);\n }\n\n // Alignment patterns: dark centre, light ring, dark ring.\n //\n // Two groups, matching the CPU exactly. The edge row and column come first\n // (skipping the last, which overlaps a finder), then the interior grid.\n // Scoring only the interior would give systematically different totals, and\n // since the whole technique ranks transforms against each other, GPU and\n // CPU would disagree about which corner is best โ€” decoding that depends on\n // the device is worse than not accelerating at all.\n for (var i = 1u; i + 1u < params.centerCount; i = i + 1u) {\n let c = f32(centers[i]);\n score = score + cell(base, span, 6.0, c)\n - ring(base, span, 6.0, c, 1.0)\n + ring(base, span, 6.0, c, 2.0);\n score = score + cell(base, span, c, 6.0)\n - ring(base, span, c, 6.0, 1.0)\n + ring(base, span, c, 6.0, 2.0);\n }\n\n for (var i = 1u; i < params.centerCount; i = i + 1u) {\n for (var j = 1u; j < params.centerCount; j = j + 1u) {\n let cx = f32(centers[i]);\n let cy = f32(centers[j]);\n score = score + cell(base, span, cx, cy)\n - ring(base, span, cx, cy, 1.0)\n + ring(base, span, cx, cy, 2.0);\n }\n }\n\n scores[index] = score;\n}\n`;\n\n/** Scores many transforms against one image, on the GPU. */\nexport interface GpuScorer {\n score(\n image: BitMatrix,\n transforms: readonly Transform[],\n size: number,\n alignmentCenters: readonly number[]\n ): Promise<Int32Array>;\n destroy(): void;\n}\n\n/** Whether this runtime exposes WebGPU at all. */\nexport const hasWebGpu = (): boolean =>\n typeof navigator === `object` && `gpu` in navigator;\n\n/**\n * Create a GPU scorer, or `null` where WebGPU is unavailable.\n *\n * Returns `null` rather than throwing: every caller must have a CPU path\n * anyway, so an absent GPU is a normal condition and not an error.\n */\nexport const createGpuScorer = async (): Promise<GpuScorer | null> => {\n if (!hasWebGpu()) return null;\n\n // Narrowed through a runtime check rather than a cast: `navigator.gpu` is\n // absent on every server runtime and on older browsers.\n const gpu: unknown = (navigator as unknown as { gpu?: unknown }).gpu;\n if (typeof gpu !== `object` || gpu === null) return null;\n\n const adapter = await (\n gpu as { requestAdapter: () => Promise<unknown> }\n ).requestAdapter();\n if (adapter === null || typeof adapter !== `object`) return null;\n\n const device = await (\n adapter as { requestDevice: () => Promise<GpuDeviceLike> }\n ).requestDevice();\n\n const module = device.createShaderModule({ code: SHADER });\n const pipeline = device.createComputePipeline({\n layout: `auto`,\n compute: { module, entryPoint: `main` }\n });\n\n return {\n score: async (image, transforms, size, alignmentCenters) => {\n const count = transforms.length;\n if (count === 0) return new Int32Array(0);\n\n // One u32 per pixel. Packing to bits would quarter the upload but adds\n // shifting in the inner loop, and the upload is not what costs here.\n const bits = new Uint32Array(image.bits.length);\n for (let i = 0; i < image.bits.length; i++) bits[i] = image.bits[i]!;\n\n const flat = new Float32Array(count * 9);\n for (const [index, transform] of transforms.entries()) {\n const base = index * 9;\n flat[base] = transform.a11;\n flat[base + 1] = transform.a12;\n flat[base + 2] = transform.a13;\n flat[base + 3] = transform.a21;\n flat[base + 4] = transform.a22;\n flat[base + 5] = transform.a23;\n flat[base + 6] = transform.a31;\n flat[base + 7] = transform.a32;\n flat[base + 8] = transform.a33;\n }\n\n const centers = Uint32Array.from(alignmentCenters);\n const params = new Uint32Array([\n image.width,\n image.height,\n size,\n centers.length\n ]);\n\n const upload = (data: ArrayBufferView, usage: number): GpuBufferLike => {\n const buffer = device.createBuffer({\n size: Math.max(4, data.byteLength),\n usage,\n mappedAtCreation: true\n });\n new Uint8Array(buffer.getMappedRange()).set(\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength)\n );\n buffer.unmap();\n return buffer;\n };\n\n const STORAGE = 0x80 | 0x4;\n const UNIFORM = 0x40 | 0x4;\n const COPY_SRC = 0x4;\n const COPY_DST = 0x8;\n const MAP_READ = 0x1;\n\n const bitsBuffer = upload(bits, STORAGE);\n const transformBuffer = upload(flat, STORAGE);\n const centerBuffer = upload(\n centers.length > 0 ? centers : new Uint32Array(1),\n STORAGE\n );\n const paramsBuffer = upload(params, UNIFORM);\n\n const scoreBuffer = device.createBuffer({\n size: count * 4,\n usage: 0x80 | COPY_SRC\n });\n const readBuffer = device.createBuffer({\n size: count * 4,\n usage: COPY_DST | MAP_READ\n });\n\n const bindGroup = device.createBindGroup({\n layout: pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: { buffer: bitsBuffer } },\n { binding: 1, resource: { buffer: transformBuffer } },\n { binding: 2, resource: { buffer: centerBuffer } },\n { binding: 3, resource: { buffer: scoreBuffer } },\n { binding: 4, resource: { buffer: paramsBuffer } }\n ]\n });\n\n const encoder = device.createCommandEncoder();\n const pass = encoder.beginComputePass();\n pass.setPipeline(pipeline);\n pass.setBindGroup(0, bindGroup);\n pass.dispatchWorkgroups(Math.ceil(count / 64));\n pass.end();\n encoder.copyBufferToBuffer(scoreBuffer, 0, readBuffer, 0, count * 4);\n device.queue.submit([encoder.finish()]);\n\n await readBuffer.mapAsync(MAP_READ);\n const scores = new Int32Array(readBuffer.getMappedRange().slice(0));\n readBuffer.unmap();\n\n for (const buffer of [\n bitsBuffer,\n transformBuffer,\n centerBuffer,\n paramsBuffer,\n scoreBuffer,\n readBuffer\n ]) {\n buffer.destroy();\n }\n\n return scores;\n },\n\n destroy: () => {\n device.destroy();\n }\n };\n};\n\n/**\n * The subset of WebGPU this module uses.\n *\n * Declared locally rather than pulled from a types package: this library ships\n * zero dependencies, and `@webgpu/types` would be one for a surface this\n * small.\n */\ninterface GpuBufferLike {\n getMappedRange(): ArrayBuffer;\n unmap(): void;\n destroy(): void;\n mapAsync(mode: number): Promise<void>;\n}\n\ninterface GpuDeviceLike {\n createShaderModule(descriptor: { code: string }): unknown;\n createComputePipeline(descriptor: {\n layout: string;\n compute: { module: unknown; entryPoint: string };\n }): { getBindGroupLayout(index: number): unknown };\n createBuffer(descriptor: {\n size: number;\n usage: number;\n mappedAtCreation?: boolean;\n }): GpuBufferLike;\n createBindGroup(descriptor: {\n layout: unknown;\n entries: Array<{ binding: number; resource: { buffer: GpuBufferLike } }>;\n }): unknown;\n createCommandEncoder(): {\n beginComputePass(): {\n setPipeline(pipeline: unknown): void;\n setBindGroup(index: number, group: unknown): void;\n dispatchWorkgroups(count: number): void;\n end(): void;\n };\n copyBufferToBuffer(\n source: GpuBufferLike,\n sourceOffset: number,\n destination: GpuBufferLike,\n destinationOffset: number,\n size: number\n ): void;\n finish(): unknown;\n };\n queue: { submit(buffers: unknown[]): void };\n destroy(): void;\n}\n","/**\n * A scan session, as a state machine.\n *\n * Modeled the way `machine.ts` models the device flow: states, events, and a\n * declarative transition table, hand-rolled so this stays dependency-free.\n *\n * Deliberately NOT applied to the decode pipeline itself. Binarize โ†’ locate โ†’\n * extract โ†’ decode is a fallible sequential pipeline, not a machine: each\n * stage runs once per frame and either produces a value or does not. Modeling\n * it as states would add ceremony without making a single illegal move\n * impossible, which is the only thing a transition table buys.\n *\n * What IS a machine is the session around it โ€” permission, camera lifecycle,\n * pause and resume โ€” and that part is identical whether the frames are being\n * searched for a QR, a DataMatrix, or an Aztec code. So it lives here,\n * symbology-agnostic, and the decoders plug into it.\n */\n\n/**\n * Lifecycle of one scanning session.\n *\n * `scanning` is the only state that consumes frames. `decoded` is terminal\n * rather than a return to `scanning`: a scanner that kept reading after a\n * successful decode would fire twice on the same symbol, and every consumer\n * would have to debounce it. Restarting is explicit.\n *\n * `denied` is separate from `failed` because they are different conversations\n * with the user โ€” one asks them to change a browser setting, the other is a\n * fault they cannot act on. Collapsing them is how a permission prompt ends up\n * reported as \"something went wrong\".\n */\nexport type ScanState =\n | `idle`\n | `starting`\n | `scanning`\n | `paused`\n | `decoded`\n | `denied`\n | `failed`\n | `stopped`;\n\n/** Why a scan session ended without a result. */\nexport type ScanFailure =\n /** No camera on this device, or none matching the requested facing mode. */\n | `no-camera`\n /** The page is not a secure context, so `getUserMedia` is unavailable. */\n | `insecure-context`\n /** The camera was lost mid-session โ€” unplugged, or claimed by another app. */\n | `camera-lost`\n /** The decoder itself threw. Distinct from \"no code in this frame\". */\n | `decoder-error`;\n\n/** Events driving a scan session. */\nexport type ScanEvent =\n /** Consumer asked to begin. */\n | { type: `START` }\n /** Camera acquired and frames are flowing. */\n | { type: `READY` }\n /** A frame decoded successfully. Terminal โ€” restarting is explicit. */\n | { type: `DECODE`; value: string }\n /** The user refused camera access. */\n | { type: `DENY` }\n /** Something broke that the user cannot act on. */\n | { type: `FAIL`; reason: ScanFailure }\n /** Tab hidden, or the consumer suspended scanning. */\n | { type: `PAUSE` }\n /** Visible again. */\n | { type: `RESUME` }\n /** Consumer tore the session down. */\n | { type: `STOP` };\n\n/**\n * Transitions, as a table.\n *\n * Everything absent is illegal by construction. That is what makes a `DECODE`\n * arriving after `STOP` a no-op rather than a race โ€” a real hazard here,\n * because a decode in flight when the camera stops would otherwise resolve\n * into a torn-down session.\n */\nconst SCAN_TRANSITIONS: {\n readonly [S in ScanState]?: {\n readonly [E in ScanEvent[`type`]]?: ScanState;\n };\n} = {\n idle: {\n START: `starting`\n },\n // Permission is resolved here, so this is the only state that can be denied.\n starting: {\n READY: `scanning`,\n DENY: `denied`,\n FAIL: `failed`,\n STOP: `stopped`\n },\n scanning: {\n DECODE: `decoded`,\n PAUSE: `paused`,\n FAIL: `failed`,\n STOP: `stopped`\n },\n // No DECODE here: a paused session is not reading frames, and accepting one\n // would mean a frame queued before the pause could resolve after it.\n paused: {\n RESUME: `scanning`,\n FAIL: `failed`,\n STOP: `stopped`\n }\n};\n\n/** Whether `event` would move `state`. */\nexport const canTransitionScan = (\n state: ScanState,\n event: ScanEvent[`type`]\n): boolean => SCAN_TRANSITIONS[state]?.[event] !== undefined;\n\n/** Apply an event. Unknown events for the current state are no-ops. */\nexport const scanTransition = (state: ScanState, event: ScanEvent): ScanState =>\n SCAN_TRANSITIONS[state]?.[event.type] ?? state;\n\n/**\n * Terminal states accept no further events.\n *\n * Derived from the table rather than listed, so a new state cannot be added\n * without its terminality following automatically.\n */\nexport const isScanSettled = (state: ScanState): boolean =>\n SCAN_TRANSITIONS[state] === undefined;\n"],"mappings":";;;;;;;;;;AA2FA,MAAa,4BAA4B,EACvC,kBAAkB,IAClB,cAAc,KACd,SAAS,KACT,GAAG,mBACmB,CAAC,MAA0B;CACjD,IAAI,SAAS;CAKb,MAAM,2BAAW,IAAI,IAAgD;CACrE,MAAM,cAAc,OAAmD;EACrE,MAAM,WAAW,SAAS,IAAI,EAAE;EAChC,IAAI,aAAa,KAAA,GAAW,OAAO;EAEnC,MAAM,UAAU,gBAAgB;GAAE,GAAG;GAAgB,cAAc;EAAG,CAAC;EACvE,SAAS,IAAI,IAAI,OAAO;EACxB,OAAO;CACT;CAEA,OAAO;EACL,IAAI,WAAmB;GACrB,OAAO;EACT;EAEA,OAAO,UAA2C;GAEhD,MAAM,SAAS,WADC,KAAK,MAAM,MACK,CAAC,CAAC,CAAC,OAAO,KAAK;GAE/C,IAAI,WAAW,MAAM;IAInB,SAAS;IACT,OAAO;GACT;GAIA,SAAS,KAAK,IAAI,aAAa,SAAS,MAAM;GAC9C,OAAO;EACT;EAEA,aAAmB;GACjB,SAAS;EACX;CACF;AACF;;;;;;;;;AAmCA,MAAa,uBACX,QAqBA,EACE,kBAAkB,IAClB,cAAc,KACd,SAAS,QACa,CAAC,MACP;CAClB,IAAI,SAAS;CACb,IAAI,SAAS;CACb,IAAI,WAAW;CACf,MAAM,0BAAU,IAAI,IAGlB;CAEF,MAAM,WAAW,YAA2B;EAI1C,IAAI,YAAY,QAAQ,OAAO,YAAY,UAAU;EAErD,MAAM,WAAW;EACjB,MAAM,OACJ,OAAO,SAAS,SAAS,YAAY,SAAS,SAAS,OACnD,SAAS,OACT;EAEN,IAAI,SAAS,QAAQ,OAAO,SAAS,UAAU;EAC/C,IAAI,EAAE,QAAQ,SAAS,OAAO,KAAK,OAAO,UAAU;EAEpD,MAAM,WAAW;EACjB,MAAM,UAAU,QAAQ,IAAI,SAAS,EAAE;EACvC,IAAI,YAAY,KAAA,GAAW;EAE3B,QAAQ,OAAO,SAAS,EAAE;EAC1B,WAAW;EACX,QAAQ,QAAQ;CAClB;CAEA,IAAI,OAAO,OAAO,qBAAqB,YACrC,OAAO,iBAAiB,WAAW,OAAO;MACrC,IAAI,OAAO,OAAO,OAAO,YAC9B,OAAO,GAAG,WAAW,OAAO;CAG9B,OAAO;EACL,IAAI,OAAgB;GAClB,OAAO;EACT;EAEA,MAAM,OAAO,UAAoD;GAG/D,IAAI,UAAU,OAAO;GAErB,WAAW;GACX,MAAM,KAAK;GAEX,MAAM,UAAU,IAAI,SACjB,YAAY;IACX,QAAQ,IAAI,IAAI,OAAO;GACzB,CACF;GAKA,MAAM,OAAO,IAAI,kBAAkB,MAAM,IAAI;GAC7C,OAAO,YACL;IAAE;IAAI,MAAM;IAAM,OAAO,MAAM;IAAO,QAAQ,MAAM;GAAO,GAC3D,CAAC,KAAK,MAAM,CACd;GAEA,MAAM,EAAE,WAAW,MAAM;GAEzB,SACE,WAAW,OACP,KAAK,IAAI,aAAa,SAAS,MAAM,IACrC;GAEN,OAAO;EACT;EAEA,aAAmB;GACjB,SAAS;EACX;EAEA,aAAmB;GACjB,QAAQ,MAAM;GACd,WAAW;GACX,OAAO,YAAY;EACrB;CACF;AACF;;;;;;;;;;;AC/PA,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6If,MAAa,kBACX,OAAO,cAAc,YAAY,SAAS;;;;;;;AAQ5C,MAAa,kBAAkB,YAAuC;CACpE,IAAI,CAAC,UAAU,GAAG,OAAO;CAIzB,MAAM,MAAgB,UAA2C;CACjE,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM,OAAO;CAEpD,MAAM,UAAU,MACd,IACA,eAAe;CACjB,IAAI,YAAY,QAAQ,OAAO,YAAY,UAAU,OAAO;CAE5D,MAAM,SAAS,MACb,QACA,cAAc;CAEhB,MAAM,SAAS,OAAO,mBAAmB,EAAE,MAAM,OAAO,CAAC;CACzD,MAAM,WAAW,OAAO,sBAAsB;EAC5C,QAAQ;EACR,SAAS;GAAE;GAAQ,YAAY;EAAO;CACxC,CAAC;CAED,OAAO;EACL,OAAO,OAAO,OAAO,YAAY,MAAM,qBAAqB;GAC1D,MAAM,QAAQ,WAAW;GACzB,IAAI,UAAU,GAAG,uBAAO,IAAI,WAAW,CAAC;GAIxC,MAAM,OAAO,IAAI,YAAY,MAAM,KAAK,MAAM;GAC9C,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,KAAK,QAAQ,KAAK,KAAK,KAAK,MAAM,KAAK;GAEjE,MAAM,OAAO,IAAI,aAAa,QAAQ,CAAC;GACvC,KAAK,MAAM,CAAC,OAAO,cAAc,WAAW,QAAQ,GAAG;IACrD,MAAM,OAAO,QAAQ;IACrB,KAAK,QAAQ,UAAU;IACvB,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;IAC3B,KAAK,OAAO,KAAK,UAAU;GAC7B;GAEA,MAAM,UAAU,YAAY,KAAK,gBAAgB;GACjD,MAAM,SAAS,IAAI,YAAY;IAC7B,MAAM;IACN,MAAM;IACN;IACA,QAAQ;GACV,CAAC;GAED,MAAM,UAAU,MAAuB,UAAiC;IACtE,MAAM,SAAS,OAAO,aAAa;KACjC,MAAM,KAAK,IAAI,GAAG,KAAK,UAAU;KACjC;KACA,kBAAkB;IACpB,CAAC;IACD,IAAI,WAAW,OAAO,eAAe,CAAC,CAAC,CAAC,IACtC,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU,CAC9D;IACA,OAAO,MAAM;IACb,OAAO;GACT;GAEA,MAAM,UAAU;GAChB,MAAM,UAAU;GAGhB,MAAM,WAAW;GAEjB,MAAM,aAAa,OAAO,MAAM,OAAO;GACvC,MAAM,kBAAkB,OAAO,MAAM,OAAO;GAC5C,MAAM,eAAe,OACnB,QAAQ,SAAS,IAAI,0BAAU,IAAI,YAAY,CAAC,GAChD,OACF;GACA,MAAM,eAAe,OAAO,QAAQ,OAAO;GAE3C,MAAM,cAAc,OAAO,aAAa;IACtC,MAAM,QAAQ;IACd,OAAO;GACT,CAAC;GACD,MAAM,aAAa,OAAO,aAAa;IACrC,MAAM,QAAQ;IACd,OAAO;GACT,CAAC;GAED,MAAM,YAAY,OAAO,gBAAgB;IACvC,QAAQ,SAAS,mBAAmB,CAAC;IACrC,SAAS;KACP;MAAE,SAAS;MAAG,UAAU,EAAE,QAAQ,WAAW;KAAE;KAC/C;MAAE,SAAS;MAAG,UAAU,EAAE,QAAQ,gBAAgB;KAAE;KACpD;MAAE,SAAS;MAAG,UAAU,EAAE,QAAQ,aAAa;KAAE;KACjD;MAAE,SAAS;MAAG,UAAU,EAAE,QAAQ,YAAY;KAAE;KAChD;MAAE,SAAS;MAAG,UAAU,EAAE,QAAQ,aAAa;KAAE;IACnD;GACF,CAAC;GAED,MAAM,UAAU,OAAO,qBAAqB;GAC5C,MAAM,OAAO,QAAQ,iBAAiB;GACtC,KAAK,YAAY,QAAQ;GACzB,KAAK,aAAa,GAAG,SAAS;GAC9B,KAAK,mBAAmB,KAAK,KAAK,QAAQ,EAAE,CAAC;GAC7C,KAAK,IAAI;GACT,QAAQ,mBAAmB,aAAa,GAAG,YAAY,GAAG,QAAQ,CAAC;GACnE,OAAO,MAAM,OAAO,CAAC,QAAQ,OAAO,CAAC,CAAC;GAEtC,MAAM,WAAW,SAAS,QAAQ;GAClC,MAAM,SAAS,IAAI,WAAW,WAAW,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;GAClE,WAAW,MAAM;GAEjB,KAAK,MAAM,UAAU;IACnB;IACA;IACA;IACA;IACA;IACA;GACF,GACE,OAAO,QAAQ;GAGjB,OAAO;EACT;EAEA,eAAe;GACb,OAAO,QAAQ;EACjB;CACF;AACF;;;;;;;;;;;AC7OA,MAAM,mBAIF;CACF,MAAM,EACJ,OAAO,WACT;CAEA,UAAU;EACR,OAAO;EACP,MAAM;EACN,MAAM;EACN,MAAM;CACR;CACA,UAAU;EACR,QAAQ;EACR,OAAO;EACP,MAAM;EACN,MAAM;CACR;CAGA,QAAQ;EACN,QAAQ;EACR,MAAM;EACN,MAAM;CACR;AACF;;AAGA,MAAa,qBACX,OACA,UACY,iBAAiB,MAAM,GAAG,WAAW,KAAA;;AAGnD,MAAa,kBAAkB,OAAkB,UAC/C,iBAAiB,MAAM,GAAG,MAAM,SAAS;;;;;;;AAQ3C,MAAa,iBAAiB,UAC5B,iBAAiB,WAAW,KAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saeris/hanko",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "QR-assisted device sign-in (OAuth 2.0 Device Authorization Grant, RFC 8628) for TVs, kiosks, and anything without a keyboard",
5
5
  "keywords": [
6
6
  "oauth",