@marianmeres/scanner 1.0.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/AGENTS.md ADDED
@@ -0,0 +1,144 @@
1
+ # @marianmeres/scanner — Agent Guide
2
+
3
+ ## Quick Reference
4
+
5
+ - **Stack**: Deno, TypeScript, browser-targeted (DOM libs enabled)
6
+ - **Runtime dependencies**: `barcode-detector` (npm; zxing-cpp wasm ponyfill),
7
+ `@marianmeres/store` (reactive store, Svelte-compatible `subscribe`),
8
+ `@marianmeres/clog` (logging), `@marianmeres/mediaperms` (permission lifecycle
9
+ inside the default CameraAdapter)
10
+ - **Test**: `deno task test` | **Build example**: `deno task build:example` |
11
+ **Format**: `deno fmt`
12
+
13
+ ## Project Structure
14
+
15
+ ```
16
+ /src
17
+ mod.ts — Main entry: re-exports everything below (headless, no DOM construction)
18
+ types.ts — All public types/interfaces + ScannerErrorCode const
19
+ scanner.ts — createScanner() core (state, detection loop, start/stop lifecycle)
20
+ camera-adapter.ts — createDefaultCameraAdapter() (navigator.mediaDevices streams + mediaperms permission lifecycle), detectFacing()
21
+ detector.ts — createDefaultDetector() (barcode-detector ponyfill), toScanResult(), DEFAULT_FORMATS
22
+ scan-image.ts — scanImage() standalone still-image decode
23
+ stage.ts — Subpath export "./stage": createScannerStage() DOM viewfinder UI
24
+ /tests
25
+ _helpers.ts — Shared test doubles (mock adapter/detector/track/stream/video, recording logger)
26
+ scanner.test.ts — Core lifecycle/scan-loop tests (mock adapter + detector, no real browser APIs)
27
+ camera-adapter.test.ts — Default adapter tests (fake navigator.mediaDevices, injected fake mediaperms)
28
+ scan-image.test.ts — scanImage() mapping/throw/fetch tests
29
+ stage.test.ts — Stage DOM tests (@b-fuze/deno-dom + mock Scanner)
30
+ /scripts
31
+ build-npm.ts — NPM package build (@marianmeres/npmbuild; entryPoints: mod, stage)
32
+ SPEC.md — Agreed v1 spec (design rationale)
33
+ ```
34
+
35
+ ## What This Library Does
36
+
37
+ Framework-agnostic browser primitive for scanning QR/barcodes from a live camera
38
+ stream: headless controller (`createScanner`) with reactive state, single-shot and
39
+ continuous modes, torch + camera switching, standalone still-image decode
40
+ (`scanImage`), and a mountable viewfinder stage UI (`createScannerStage`, `/stage`
41
+ subpath).
42
+
43
+ Decoding always uses the `barcode-detector` ponyfill (zxing-cpp wasm) — the native
44
+ `BarcodeDetector` is deliberately NOT used. The ~1 MB wasm loads lazily from the
45
+ jsDelivr CDN by default; `wasmOverrides.locateFile` enables self-hosting (CSP).
46
+
47
+ ## Key Concepts
48
+
49
+ | Concept | Description |
50
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | **CameraAdapter** | Injectable seam for camera acquisition; default wraps `navigator.mediaDevices` for streams/enumeration and delegates the permission lifecycle to `@marianmeres/mediaperms` (share an app-owned instance via `createDefaultCameraAdapter({ perms })`). |
52
+ | **Detector** | Injectable seam for the decode engine (W3C `BarcodeDetector`-shaped); default wraps the ponyfill. Mock in tests. |
53
+ | **Reactive state** | `@marianmeres/store` powers `subscribe()` (Svelte `$store` compatible, fires immediately) |
54
+ | **Never-throw** | Scanner methods never throw/reject — errors land in `state.error` (`ScannerErrorCode`) |
55
+ | **Stream retention** | Unlike mediaperms (probe + stop), the scanner RETAINS the `MediaStream` for the live preview; tracks stop on `stop()`/`destroy()` |
56
+ | **Headless/DOM split** | Core constructs no DOM (video element lazily, only when needed); all chrome lives in `stage.ts` |
57
+
58
+ ## Public API
59
+
60
+ ### Main entry (`@marianmeres/scanner`)
61
+
62
+ | Export | Type | Purpose |
63
+ | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
64
+ | `createScanner(config?)` | Factory | Main entry point, returns `Scanner` instance |
65
+ | `scanImage(source, options?)` | Function | Decode a still image (Blob/File/ImageData/img/canvas/url). THROWS on failure (documented exception to never-throw) |
66
+ | `createDefaultCameraAdapter(o?)` | Factory | Real browser adapter (`getUserMedia` streams + mediaperms permissions; `{ perms, permsConfig }` options) |
67
+ | `createDefaultDetector(options?)` | Factory | Ponyfill-backed `Detector` (formats + `wasmOverrides`) |
68
+ | `classifyAcquireError(e)` | Helper | Map a `getUserMedia` rejection to `ScannerError` |
69
+ | `detectFacing(label)` | Helper | Best-effort `"user"`/`"environment"`/`null` from device label |
70
+ | `toScanResult(d, timestamp?)` | Helper | Raw detection → public `ScanResult` |
71
+ | `ScannerErrorCode` | Const | Frozen object of typed `state.error.code` values |
72
+ | `DEFAULT_FORMATS` | Const | `["qr_code"]` |
73
+
74
+ ### Stage subpath (`@marianmeres/scanner/stage`)
75
+
76
+ | Export | Type | Purpose |
77
+ | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `createScannerStage(scanner, options)` | Factory | Mounts viewfinder UI (video, overlay cutout, corner guides, scan line, opt-in cancel/torch/switch buttons) into a container. Returns `{ el, destroy }`. THROWS without DOM/container. |
79
+
80
+ ### Scanner instance methods
81
+
82
+ | Method | Returns | Description |
83
+ | --------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
84
+ | `subscribe(cb)` | `() => void` | Reactive subscription (fires immediately) |
85
+ | `get()` | `ScannerState` | Current state snapshot |
86
+ | `start()` | `Promise<ScanResult \| null>` | Acquire camera + scan. Single: first hit; continuous: `null` when stopped. Concurrent calls coalesce. Never rejects. |
87
+ | `stop()` | `void` | Cancel: stop loop + tracks, resolve pending `start()` with `null` |
88
+ | `listCameras()` | `Promise<CameraInfo[]>` | Enumerate devices (also updates `state.cameras`) |
89
+ | `setCamera(deviceId)` | `Promise<void>` | Live-switch while scanning; otherwise applied on next `start()` |
90
+ | `setTorch(on)` | `Promise<boolean>` | `false` when unsupported/failed (never rejects) |
91
+ | `getVideo()` | `HTMLVideoElement \| null` | Preview element (lazily created; `null` without DOM) |
92
+ | `destroy()` | `void` | Release everything. Idempotent. |
93
+
94
+ ## Critical Conventions
95
+
96
+ 1. Use `globalThis` never `window` (Deno compatibility)
97
+ 2. Scanner methods never throw — classify failures into `state.error` via
98
+ `classifyAcquireError` / `ScannerErrorCode`. Exceptions: standalone `scanImage()`
99
+ and the stage factory throw by design.
100
+ 3. Permission denial (`NotAllowedError`) sets `state.permission = "denied"` +
101
+ a dedicated `PERMISSION_DENIED` error — but there is no built-in denial UI
102
+ (mediaperms territory).
103
+ 4. Tests use injectable `adapter` + `detector` — never depend on real browser APIs;
104
+ real wasm decode runs only in the browser example, not CI.
105
+ 5. `start()` caches an in-flight promise (concurrent calls coalesce); async init is
106
+ guarded by a `generation` counter — keep both when touching lifecycle code.
107
+ 6. Detection loop: `requestVideoFrameCallback` → rAF → `setTimeout` fallback;
108
+ throttled by `scanIntervalMs`; skips while `document.visibilityState === "hidden"`
109
+ or `paused` (live camera switch); stops after 5 consecutive detector failures
110
+ (`DETECTOR_FAILED`).
111
+ 7. The default detector is created lazily inside `start()` (wasm cost only when
112
+ used); `scanImage` creates its own unless given one.
113
+ 8. Stage is a pure consumer of the headless controller — no scanner state lives in
114
+ `stage.ts`; destroying one never destroys the other.
115
+ 9. Never auto-start on mount — `start()` must remain an explicit call (iOS Safari
116
+ gesture/permission rules).
117
+ 10. Format: tabs, 90-char line width, 4-space indent width (`deno fmt`)
118
+
119
+ ## Before Making Changes
120
+
121
+ - [ ] Read `SPEC.md` for design decisions and out-of-scope items
122
+ - [ ] Read `src/types.ts` first — all public contracts live there
123
+ - [ ] Check existing patterns (mediaperms is the architectural blueprint)
124
+ - [ ] Run `deno task test`
125
+ - [ ] Run `deno fmt` and `deno check src/mod.ts src/stage.ts`
126
+
127
+ ## Platform Notes
128
+
129
+ - **HTTPS required**: `getUserMedia` exists only in secure contexts (localhost OK).
130
+ On insecure origins browsers typically omit `navigator.mediaDevices` entirely →
131
+ `NOT_SUPPORTED`; `INSECURE_CONTEXT` means getUserMedia threw `SecurityError`
132
+ (e.g. Permissions-Policy).
133
+ - **Camera labels are empty** until permission has been granted at least once; the
134
+ scanner re-enumerates after a successful `start()`.
135
+ - **Mobile browsers cannot open two cameras at once** — `setCamera()` stops the
136
+ current tracks before acquiring the new stream.
137
+ - **Torch** is a track capability (`getCapabilities().torch`) — effectively
138
+ Android/Chrome only; always feature-detected, `setTorch()` resolves `false`
139
+ elsewhere.
140
+ - **In-app WebView specifics are handled at the permission level only** — the
141
+ default adapter's mediaperms delegation brings platform detection and
142
+ webview-bridge awareness (pass `permsConfig` through
143
+ `createDefaultCameraAdapter`); stream acquisition quirks of exotic WebViews
144
+ remain untested.