@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 +6 -0
- package/README.md +265 -524
- package/dist/scan/index.d.mts +4 -1
- package/dist/scan/index.d.mts.map +1 -1
- package/dist/scan/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,605 +1,346 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
WinterTC primitives: Node, browsers, Deno, Bun, Cloudflare Workers.
|
|
9
|
+
</div>
|
|
23
10
|
|
|
24
|
-
|
|
11
|
+
---
|
|
25
12
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
35
|
+
loop until approved or expired
|
|
36
|
+
TV->>API: POST /device/token
|
|
37
|
+
API-->>TV: authorization_pending
|
|
38
|
+
end
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
### Decoding one image
|
|
115
80
|
|
|
116
81
|
```ts
|
|
117
|
-
import {
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
hardware you cannot attach a profiler to.
|
|
84
|
+
const decoder = createQrDecoder();
|
|
85
|
+
const symbol = decoder.decode(toGray(rgba, width, height));
|
|
137
86
|
|
|
138
|
-
|
|
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
|
-
|
|
90
|
+
### Scanning with a camera
|
|
152
91
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
397
|
-
protecting โ and the reason the payload stays an `https://` URL.
|
|
173
|
+
let scanning = true;
|
|
398
174
|
|
|
399
|
-
|
|
175
|
+
while (scanning) {
|
|
176
|
+
const frame = camera.grab();
|
|
400
177
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
489
|
-
|
|
490
|
-
a
|
|
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
|
-
|
|
218
|
+
```mermaid
|
|
219
|
+
flowchart LR
|
|
220
|
+
subgraph device["๐บ Device"]
|
|
221
|
+
client["@saeris/hanko/client"]
|
|
222
|
+
end
|
|
494
223
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
224
|
+
subgraph api["โ๏ธ Your API"]
|
|
225
|
+
server["@saeris/hanko + /handlers"]
|
|
226
|
+
store[("DeviceGrantStore")]
|
|
227
|
+
server <--> store
|
|
228
|
+
end
|
|
498
229
|
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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]
|
|
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
|
-
[
|
|
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
|
package/dist/scan/index.d.mts
CHANGED
|
@@ -88,7 +88,10 @@ interface WorkerScanner {
|
|
|
88
88
|
* would be wrong for most consumers.
|
|
89
89
|
*/
|
|
90
90
|
declare const createWorkerScanner: (worker: {
|
|
91
|
-
postMessage:
|
|
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;
|
|
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"}
|
package/dist/scan/index.mjs.map
CHANGED
|
@@ -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":"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"}
|
package/package.json
CHANGED