@dejadex/web 0.0.0-stage → 0.1.0

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +214 -2
  3. package/dist/errors.d.ts +8 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/geometry/homography.d.ts +75 -0
  8. package/dist/geometry/homography.d.ts.map +1 -0
  9. package/dist/geometry/homography.js +146 -0
  10. package/dist/geometry/homography.js.map +1 -0
  11. package/dist/identify/assets.d.ts +39 -0
  12. package/dist/identify/assets.d.ts.map +1 -0
  13. package/dist/identify/assets.js +167 -0
  14. package/dist/identify/assets.js.map +1 -0
  15. package/dist/identify/decode.d.ts +6 -0
  16. package/dist/identify/decode.d.ts.map +1 -0
  17. package/dist/identify/decode.js +53 -0
  18. package/dist/identify/decode.js.map +1 -0
  19. package/dist/identify/engine.d.ts +32 -0
  20. package/dist/identify/engine.d.ts.map +1 -0
  21. package/dist/identify/engine.js +127 -0
  22. package/dist/identify/engine.js.map +1 -0
  23. package/dist/identify/identifier.d.ts +81 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +327 -0
  26. package/dist/identify/identifier.js.map +1 -0
  27. package/dist/identify/manifest.d.ts +60 -0
  28. package/dist/identify/manifest.d.ts.map +1 -0
  29. package/dist/identify/manifest.js +211 -0
  30. package/dist/identify/manifest.js.map +1 -0
  31. package/dist/identify/preprocess.d.ts +52 -0
  32. package/dist/identify/preprocess.d.ts.map +1 -0
  33. package/dist/identify/preprocess.js +195 -0
  34. package/dist/identify/preprocess.js.map +1 -0
  35. package/dist/identify/router.d.ts +60 -0
  36. package/dist/identify/router.d.ts.map +1 -0
  37. package/dist/identify/router.js +83 -0
  38. package/dist/identify/router.js.map +1 -0
  39. package/dist/identify/runtime.d.ts +69 -0
  40. package/dist/identify/runtime.d.ts.map +1 -0
  41. package/dist/identify/runtime.js +140 -0
  42. package/dist/identify/runtime.js.map +1 -0
  43. package/dist/identify/search.d.ts +35 -0
  44. package/dist/identify/search.d.ts.map +1 -0
  45. package/dist/identify/search.js +99 -0
  46. package/dist/identify/search.js.map +1 -0
  47. package/dist/identify/server.d.ts +60 -0
  48. package/dist/identify/server.d.ts.map +1 -0
  49. package/dist/identify/server.js +149 -0
  50. package/dist/identify/server.js.map +1 -0
  51. package/dist/identify/shard.d.ts +31 -0
  52. package/dist/identify/shard.d.ts.map +1 -0
  53. package/dist/identify/shard.js +146 -0
  54. package/dist/identify/shard.js.map +1 -0
  55. package/dist/identify/types.d.ts +107 -0
  56. package/dist/identify/types.d.ts.map +1 -0
  57. package/dist/identify/types.js +7 -0
  58. package/dist/identify/types.js.map +1 -0
  59. package/dist/identify/worker.d.ts +2 -0
  60. package/dist/identify/worker.d.ts.map +1 -0
  61. package/dist/identify/worker.js +13 -0
  62. package/dist/identify/worker.js.map +1 -0
  63. package/dist/identify/workerProtocol.d.ts +54 -0
  64. package/dist/identify/workerProtocol.d.ts.map +1 -0
  65. package/dist/identify/workerProtocol.js +113 -0
  66. package/dist/identify/workerProtocol.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +16 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lease/verify.d.ts +69 -0
  72. package/dist/lease/verify.d.ts.map +1 -0
  73. package/dist/lease/verify.js +235 -0
  74. package/dist/lease/verify.js.map +1 -0
  75. package/dist/scanner.d.ts +10 -0
  76. package/dist/scanner.d.ts.map +1 -0
  77. package/dist/scanner.js +58 -0
  78. package/dist/scanner.js.map +1 -0
  79. package/dist/types.d.ts +180 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/usage/meter.d.ts +83 -0
  84. package/dist/usage/meter.d.ts.map +1 -0
  85. package/dist/usage/meter.js +111 -0
  86. package/dist/usage/meter.js.map +1 -0
  87. package/dist/usage/store.d.ts +52 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +198 -0
  90. package/dist/usage/store.js.map +1 -0
  91. package/package.json +64 -4
  92. package/src/errors.ts +24 -0
  93. package/src/geometry/homography.ts +178 -0
  94. package/src/identify/assets.ts +189 -0
  95. package/src/identify/decode.ts +54 -0
  96. package/src/identify/engine.ts +167 -0
  97. package/src/identify/identifier.ts +419 -0
  98. package/src/identify/manifest.ts +240 -0
  99. package/src/identify/preprocess.ts +227 -0
  100. package/src/identify/router.ts +115 -0
  101. package/src/identify/runtime.ts +197 -0
  102. package/src/identify/search.ts +115 -0
  103. package/src/identify/server.ts +167 -0
  104. package/src/identify/shard.ts +154 -0
  105. package/src/identify/types.ts +122 -0
  106. package/src/identify/worker.ts +13 -0
  107. package/src/identify/workerProtocol.ts +136 -0
  108. package/src/index.ts +71 -0
  109. package/src/lease/verify.ts +252 -0
  110. package/src/scanner.ts +56 -0
  111. package/src/types.ts +196 -0
  112. package/src/usage/meter.ts +165 -0
  113. package/src/usage/store.ts +245 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * `createIdentifier`: crops in, catalogue ids out, on the device or through
3
+ * `POST /v1/identify`, under a verified lease, with usage counted on the
4
+ * device. See `types.ts` beside this file for the answer's shape and README.md
5
+ * for host wiring.
6
+ *
7
+ * Per call:
8
+ * 1. The lease must be set, unexpired, and have allowance left.
9
+ * 2. `router.ts` picks the device or the server.
10
+ * 3. A server attempt is bounded by the fallback deadline; on any failure
11
+ * (or a late answer) the same crops are identified on the device.
12
+ * 4. The answer is metered (`usage/meter.ts`) before it is returned.
13
+ *
14
+ * `identify` never throws: every failure is an outcome.
15
+ */
16
+ import { ScannerError } from '../errors.js'
17
+ import { verifyLease, type Ed25519Verify, type LeasePublicKey } from '../lease/verify.js'
18
+ import type { DomainCode, Lease, LeaseClaims, LeaseGrant, LeaseRequest } from '../types.js'
19
+ import { UsageMeter, type DrainResult } from '../usage/meter.js'
20
+ import { defaultUsageStore, randomId, type UsageStore } from '../usage/store.js'
21
+ import { defaultEngineDeps, loadDeviceEngine, type DeviceEngine, type EngineConfig } from './engine.js'
22
+ import {
23
+ DEFAULT_SERVER_TIMEOUT_MS,
24
+ DEFAULT_SLOW_DEVICE_BUDGET_MS,
25
+ ServerHealth,
26
+ chooseRoute,
27
+ serverDeadline,
28
+ type DeviceState,
29
+ } from './router.js'
30
+ import type { RuntimeOptions } from './runtime.js'
31
+ import { postIdentify, type ServerAnswer, type ServerFetch } from './server.js'
32
+ import {
33
+ DEFAULT_TOP,
34
+ MAX_CROPS_PER_REQUEST,
35
+ MAX_TOP,
36
+ type DeviceEngineInfo,
37
+ type IdentificationMode,
38
+ type IdentifierStatus,
39
+ type IdentifyCallOptions,
40
+ type IdentifyOutcome,
41
+ type IdentifyResult,
42
+ } from './types.js'
43
+ import { connectWorkerEngine, type WorkerLike } from './workerProtocol.js'
44
+ import type { UsageBatch } from '../types.js'
45
+
46
+ const DOMAIN_CODE = /^[a-z][a-z0-9]*_[a-z]{2}$/
47
+ const SESSION = /^[\x20-\x7e]{1,128}$/
48
+
49
+ export interface IdentifierOptions {
50
+ /** The shard to identify against; the lease must grant it. */
51
+ readonly domain: DomainCode
52
+ /** The service's lease-signing public keys (JWK `x`, optionally with `kid`). */
53
+ readonly leasePublicKeys: readonly LeasePublicKey[]
54
+ /** The first lease, as the host's backend returned it. Later ones go through `setLease`. */
55
+ readonly lease?: LeaseGrant
56
+ /** Base URL of the identify route, `{serverUrl}/v1/identify`. Omit or null: the device only. */
57
+ readonly serverUrl?: string | null
58
+ /** Default `auto`. `local` never sends an image. */
59
+ readonly identification?: IdentificationMode
60
+ /** `auto` uses the server when the device takes longer than this per crop (no WebGPU). Default 300. */
61
+ readonly slowDeviceBudgetMs?: number
62
+ /** The server's deadline while the device engine is not loaded yet. Default 8000. */
63
+ readonly serverTimeoutMs?: number
64
+ readonly runtime?: RuntimeOptions
65
+ /**
66
+ * Where the device engine runs: `worker` (default where `Worker` exists), `inline`
67
+ * (this thread; tests, or a host that already runs the SDK in its own Worker), or a
68
+ * factory for a Worker the host builds from `@dejadex/web/identify-worker`.
69
+ */
70
+ readonly engine?: 'worker' | 'inline' | (() => WorkerLike)
71
+ /** Download and warm the device engine as soon as a lease is set. Default true. */
72
+ readonly preload?: boolean
73
+ /** The page origin the lease must allow. Default `location.origin`. */
74
+ readonly origin?: string | null
75
+ /** Default IndexedDB (`dejadex-usage`), memory where it is unavailable. */
76
+ readonly usageStore?: UsageStore
77
+ /** Background failures (a device engine that failed to load). Never called from `identify`. */
78
+ readonly onError?: (error: Error) => void
79
+
80
+ /** @internal Test seams. */
81
+ readonly fetch?: ServerFetch
82
+ /** @internal */
83
+ readonly now?: () => number
84
+ /** @internal */
85
+ readonly online?: () => boolean
86
+ /** @internal */
87
+ readonly loadEngine?: (config: EngineConfig) => Promise<DeviceEngine>
88
+ /** @internal */
89
+ readonly verify?: Ed25519Verify
90
+ }
91
+
92
+ export interface Identifier {
93
+ /** At most 9 crops (JPEG for the server path). Always resolves. */
94
+ identify(crops: readonly Blob[], options?: IdentifyCallOptions): Promise<IdentifyOutcome>
95
+ /** Verifies and installs a lease (rejects with a `ScannerError` when it is not valid here). */
96
+ setLease(grant: LeaseGrant): Promise<Lease>
97
+ /** The identifier's own session id, used when a call names none. */
98
+ readonly session: string
99
+ /** Starts a new default session: cards seen before are counted again. */
100
+ newSession(): string
101
+ /** Loads (or awaits) the device engine for the current lease. */
102
+ prepare(): Promise<DeviceEngineInfo>
103
+ status(): IdentifierStatus
104
+ /** Hands queued usage to `deliver` (the host forwards it to its backend). See `UsageMeter.drain`. */
105
+ drainUsage(deliver: (batch: UsageBatch) => Promise<void>): Promise<DrainResult>
106
+ pendingUsage(): Promise<number>
107
+ /**
108
+ * The body the host sends its backend to renew: the current lease and the
109
+ * last report `seq` of it already delivered (null before the first lease).
110
+ * Drain usage first, or the service refuses the renewal (USAGE_UNREPORTED).
111
+ *
112
+ * `previousLeaseId` names another lease to renew instead: one the host
113
+ * stored but has not set here (it expired, or this page never identified).
114
+ * Null asks as a device with no lease.
115
+ */
116
+ leaseRequest(previousLeaseId?: string | null): Promise<LeaseRequest>
117
+ /** Releases the device engine and its Worker. Later calls resolve `unavailable`. */
118
+ destroy(): Promise<void>
119
+ }
120
+
121
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message || err.name : String(err))
122
+ const codeText = (err: unknown): string => (err instanceof ScannerError ? `${err.code}: ${err.message}` : errorText(err))
123
+
124
+ function validate(options: IdentifierOptions): void {
125
+ const fail = (m: string): never => {
126
+ throw new ScannerError('INVALID_OPTIONS', m)
127
+ }
128
+ if (typeof options !== 'object' || options === null) fail('options are required')
129
+ if (typeof options.domain !== 'string' || !DOMAIN_CODE.test(options.domain)) fail(`not a domain code: ${String(options.domain)}`)
130
+ if (!Array.isArray(options.leasePublicKeys) || options.leasePublicKeys.length === 0) fail('leasePublicKeys must list at least one key')
131
+ const modes: readonly unknown[] = ['auto', 'local', 'server']
132
+ if (options.identification !== undefined && !modes.includes(options.identification)) fail(`unknown identification mode: ${String(options.identification)}`)
133
+ if (options.serverUrl !== undefined && options.serverUrl !== null) {
134
+ try {
135
+ const u = new URL(options.serverUrl)
136
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') fail('serverUrl must be http(s)')
137
+ } catch {
138
+ fail(`serverUrl is not a URL: ${options.serverUrl}`)
139
+ }
140
+ }
141
+ for (const [name, v] of [['slowDeviceBudgetMs', options.slowDeviceBudgetMs], ['serverTimeoutMs', options.serverTimeoutMs]] as const) {
142
+ if (v !== undefined && (!Number.isFinite(v) || v <= 0)) fail(`${name} must be a positive number`)
143
+ }
144
+ }
145
+
146
+ function defaultOrigin(): string | null {
147
+ const loc = (globalThis as { location?: { origin?: string } }).location
148
+ return typeof loc?.origin === 'string' && loc.origin !== 'null' ? loc.origin : null
149
+ }
150
+
151
+ function defaultEngineLoader(choice: IdentifierOptions['engine']): (config: EngineConfig) => Promise<DeviceEngine> {
152
+ const useWorker = choice === undefined ? typeof Worker !== 'undefined' : choice !== 'inline'
153
+ if (!useWorker) return async (config) => loadDeviceEngine(config, await defaultEngineDeps())
154
+ const make =
155
+ typeof choice === 'function' ? choice : (): WorkerLike => new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
156
+ return async (config) => {
157
+ const worker = make()
158
+ try {
159
+ return await connectWorkerEngine(worker, config)
160
+ } catch (err) {
161
+ worker.terminate()
162
+ throw err
163
+ }
164
+ }
165
+ }
166
+
167
+ export function createIdentifier(options: IdentifierOptions): Identifier {
168
+ validate(options)
169
+ const mode = options.identification ?? 'auto'
170
+ const endpoint = options.serverUrl ? `${options.serverUrl.trim().replace(/\/+$/, '')}/v1/identify` : null
171
+ const budget = options.slowDeviceBudgetMs ?? DEFAULT_SLOW_DEVICE_BUDGET_MS
172
+ const serverTimeoutMs = options.serverTimeoutMs ?? DEFAULT_SERVER_TIMEOUT_MS
173
+ const now = options.now ?? (() => Date.now())
174
+ const online = options.online ?? (() => (globalThis as { navigator?: { onLine?: boolean } }).navigator?.onLine !== false)
175
+ const origin = options.origin === undefined ? defaultOrigin() : options.origin
176
+ const doFetch: ServerFetch = options.fetch ?? ((url, init) => fetch(url, init))
177
+ const loadEngine = options.loadEngine ?? defaultEngineLoader(options.engine)
178
+ const health = new ServerHealth()
179
+ const meterReady: Promise<UsageMeter> = (options.usageStore ? Promise.resolve(options.usageStore) : defaultUsageStore()).then(
180
+ (store) => new UsageMeter({ store, now }),
181
+ )
182
+
183
+ let lease: Lease | null = null
184
+ /** The constructor's lease while it is being verified; `identify` waits for it. */
185
+ let initialLease: Promise<unknown> | null = null
186
+ let manifestUrl: string | null = null
187
+ let session = randomId()
188
+ let destroyed = false
189
+
190
+ // The device engine for the current lease, and its load.
191
+ let engine: DeviceEngine | null = null
192
+ let engineKey: string | null = null
193
+ let loading: Promise<DeviceEngine> | null = null
194
+ let deviceError: string | null = null
195
+
196
+ const keyOf = (claims: LeaseClaims, url: string) => `${url}\n${claims.modelVersion}\n${claims.indexVersion}`
197
+
198
+ function startLoad(): Promise<DeviceEngine> {
199
+ if (destroyed) return Promise.reject(new ScannerError('INVALID_OPTIONS', 'the identifier was destroyed'))
200
+ if (!lease || !manifestUrl) return Promise.reject(new ScannerError('LEASE_INVALID', 'no lease is set'))
201
+ const key = keyOf(lease.claims, manifestUrl)
202
+ if (engine && engineKey === key) return Promise.resolve(engine)
203
+ if (loading) return loading
204
+ const config: EngineConfig = {
205
+ manifestUrl,
206
+ expect: { modelVersion: lease.claims.modelVersion, indexVersion: lease.claims.indexVersion, shard: options.domain },
207
+ runtime: options.runtime ?? {},
208
+ }
209
+ deviceError = null
210
+ const attempt = loadEngine(config).then(
211
+ async (loaded) => {
212
+ const previous = engine
213
+ engine = loaded
214
+ engineKey = key
215
+ loading = null
216
+ if (previous && previous !== loaded) await previous.release().catch(() => undefined)
217
+ if (destroyed) await loaded.release().catch(() => undefined)
218
+ return loaded
219
+ },
220
+ (err: unknown) => {
221
+ loading = null
222
+ deviceError = codeText(err)
223
+ throw err
224
+ },
225
+ )
226
+ loading = attempt
227
+ return attempt
228
+ }
229
+
230
+ const preload = () => {
231
+ if (options.preload === false) return
232
+ startLoad().catch((err: unknown) => options.onError?.(err instanceof Error ? err : new Error(String(err))))
233
+ }
234
+
235
+ function deviceState(): DeviceState {
236
+ const e = engine
237
+ if (e === null || lease === null || manifestUrl === null || engineKey !== keyOf(lease.claims, manifestUrl)) {
238
+ return { ready: false, backend: null, perCropMs: Number.NaN }
239
+ }
240
+ return { ready: true, backend: e.info.backend, perCropMs: e.info.perCropMs }
241
+ }
242
+
243
+ async function setLease(grant: LeaseGrant): Promise<Lease> {
244
+ if (destroyed) throw new ScannerError('INVALID_OPTIONS', 'the identifier was destroyed')
245
+ if (typeof grant?.token !== 'string' || typeof grant.manifestUrl !== 'string') {
246
+ throw new ScannerError('INVALID_OPTIONS', 'a lease grant is { token, manifestUrl }')
247
+ }
248
+ const verified = await verifyLease(grant.token, {
249
+ publicKeys: options.leasePublicKeys,
250
+ origin,
251
+ domains: [options.domain],
252
+ now: Math.floor(now() / 1000),
253
+ ...(options.verify ? { verify: options.verify } : {}),
254
+ })
255
+ let url: string
256
+ try {
257
+ url = new URL(grant.manifestUrl, defaultOrigin() ?? undefined).href
258
+ } catch {
259
+ throw new ScannerError('INVALID_OPTIONS', `manifestUrl is not a URL: ${grant.manifestUrl}`)
260
+ }
261
+ lease = verified
262
+ manifestUrl = url
263
+ // Every lease reports at least once (the renewal gate), used or not.
264
+ await (await meterReady).touch(verified.claims.jti, session)
265
+ preload()
266
+ return verified
267
+ }
268
+
269
+ const aborted = (): IdentifyOutcome => ({ ok: false, reason: 'unavailable', detail: 'aborted' })
270
+
271
+ /** Resolves with the device's answer, or `null` when the caller aborted first. */
272
+ async function onDevice(crops: readonly Blob[], top: number, signal: AbortSignal | undefined): Promise<IdentifyResult[] | null> {
273
+ const work = startLoad().then((e) => e.identify(crops, top))
274
+ if (!signal) return work
275
+ if (signal.aborted) return null
276
+ return new Promise((resolve, reject) => {
277
+ const onAbort = () => resolve(null)
278
+ signal.addEventListener('abort', onAbort, { once: true })
279
+ work.then(
280
+ (r) => {
281
+ signal.removeEventListener('abort', onAbort)
282
+ resolve(r)
283
+ },
284
+ (e: unknown) => {
285
+ signal.removeEventListener('abort', onAbort)
286
+ reject(e)
287
+ },
288
+ )
289
+ })
290
+ }
291
+
292
+ async function identify(crops: readonly Blob[], call: IdentifyCallOptions = {}): Promise<IdentifyOutcome> {
293
+ try {
294
+ if (destroyed) return { ok: false, reason: 'unavailable', detail: 'INVALID_OPTIONS: the identifier was destroyed' }
295
+ if (!Array.isArray(crops) || crops.length > MAX_CROPS_PER_REQUEST) {
296
+ return { ok: false, reason: 'rejected', detail: `INVALID_OPTIONS: at most ${MAX_CROPS_PER_REQUEST} crops per call` }
297
+ }
298
+ const top = call.top ?? DEFAULT_TOP
299
+ if (!Number.isInteger(top) || top < 1 || top > MAX_TOP) return { ok: false, reason: 'rejected', detail: `INVALID_OPTIONS: top must be 1..${MAX_TOP}` }
300
+ const sessionId = call.session ?? session
301
+ if (!SESSION.test(sessionId)) return { ok: false, reason: 'rejected', detail: 'INVALID_OPTIONS: session must be 1..128 printable characters' }
302
+ if (call.signal?.aborted) return aborted()
303
+
304
+ if (initialLease) await initialLease
305
+ const current = lease
306
+ if (!current) return { ok: false, reason: 'rejected', detail: 'LEASE_INVALID: no lease is set' }
307
+ if (Math.floor(now() / 1000) >= current.claims.exp) return { ok: false, reason: 'rejected', detail: 'LEASE_EXPIRED: renew the lease' }
308
+ const meter = await meterReady
309
+ if (meter.exhausted(current.claims)) return { ok: false, reason: 'rejected', detail: 'ALLOWANCE_EXHAUSTED: renew the lease' }
310
+ if (crops.length === 0) return { ok: true, modelVersion: current.claims.modelVersion, results: [], source: 'device' }
311
+
312
+ const device = deviceState()
313
+ const serverBlocked = endpoint === null ? 'no serverUrl' : health.blocked(current.claims.jti, now(), online())
314
+ const route = chooseRoute({ mode, device, serverBlocked, slowDeviceBudgetMs: budget })
315
+
316
+ let results: readonly IdentifyResult[] | null = null
317
+ let source: 'device' | 'server' = 'device'
318
+ let serverFailure: ServerAnswer | null = null
319
+ if (route === 'server' && endpoint !== null) {
320
+ const answer = await postIdentify({
321
+ endpoint,
322
+ crops,
323
+ token: current.token,
324
+ top,
325
+ session: sessionId,
326
+ ...(call.purpose ? { purpose: call.purpose } : {}),
327
+ timeoutMs: serverDeadline(device, crops.length, serverTimeoutMs),
328
+ ...(call.signal ? { signal: call.signal } : {}),
329
+ fetch: doFetch,
330
+ })
331
+ const checked: ServerAnswer =
332
+ answer.ok && answer.modelVersion !== current.claims.modelVersion
333
+ ? { ok: false, kind: 'malformed', detail: `answer is model ${answer.modelVersion}, the lease is ${current.claims.modelVersion}` }
334
+ : answer
335
+ health.record(checked, current.claims.jti, now())
336
+ if (checked.ok) {
337
+ results = checked.results
338
+ source = 'server'
339
+ } else if (checked.kind === 'aborted') {
340
+ return aborted()
341
+ } else {
342
+ serverFailure = checked
343
+ }
344
+ }
345
+
346
+ if (results === null) {
347
+ try {
348
+ results = await onDevice(crops, top, call.signal)
349
+ } catch (err) {
350
+ const why = codeText(err)
351
+ if (serverFailure?.ok === false && serverFailure.kind === 'timeout') return { ok: false, reason: 'timeout', detail: `${serverFailure.detail}; device: ${why}` }
352
+ const reason = err instanceof ScannerError && err.code === 'INVALID_OPTIONS' ? 'rejected' : 'unavailable'
353
+ return { ok: false, reason, detail: serverFailure?.ok === false ? `${serverFailure.detail}; device: ${why}` : why }
354
+ }
355
+ if (results === null) return aborted()
356
+ }
357
+
358
+ await meter.record({ lease: current.claims, session: sessionId, purpose: call.purpose, results })
359
+ return { ok: true, modelVersion: current.claims.modelVersion, results, source }
360
+ } catch (err) {
361
+ return { ok: false, reason: 'unavailable', detail: codeText(err) }
362
+ }
363
+ }
364
+
365
+ const identifier: Identifier = {
366
+ identify,
367
+ setLease,
368
+ get session() {
369
+ return session
370
+ },
371
+ newSession() {
372
+ session = randomId()
373
+ return session
374
+ },
375
+ async prepare() {
376
+ return (await startLoad()).info
377
+ },
378
+ status() {
379
+ const claims = lease?.claims ?? null
380
+ const ready = deviceState().ready
381
+ return {
382
+ lease: claims,
383
+ device: ready ? 'ready' : loading ? 'loading' : deviceError ? 'failed' : 'idle',
384
+ deviceInfo: ready && engine ? engine.info : null,
385
+ deviceError,
386
+ serverPaused: endpoint === null ? 'no serverUrl' : health.blocked(claims?.jti ?? null, now(), online()),
387
+ }
388
+ },
389
+ async drainUsage(deliver) {
390
+ return (await meterReady).drain(deliver)
391
+ },
392
+ async pendingUsage() {
393
+ return (await meterReady).pending()
394
+ },
395
+ async leaseRequest(previousLeaseId) {
396
+ return (await meterReady).leaseRequest(previousLeaseId === undefined ? (lease?.claims.jti ?? null) : previousLeaseId)
397
+ },
398
+ async destroy() {
399
+ destroyed = true
400
+ const e = engine
401
+ engine = null
402
+ engineKey = null
403
+ await e?.release().catch(() => undefined)
404
+ },
405
+ }
406
+
407
+ if (options.lease) {
408
+ initialLease = setLease(options.lease).then(
409
+ () => {
410
+ initialLease = null
411
+ },
412
+ (err: unknown) => {
413
+ initialLease = null
414
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
415
+ },
416
+ )
417
+ }
418
+ return identifier
419
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The model-and-index manifest of one shard, as `ml/src/dejadex_ml/shard_export.py`
3
+ * writes it (`manifest_<domain>.json`). It names every file the device
4
+ * downloads with its SHA-256 and size, and carries the values the SDK decides
5
+ * with, so none of them is hard-coded here:
6
+ *
7
+ * { format: 1, modelVersion, indexVersion, shard, rows, dims, dtype: "fp16",
8
+ * notACardBelow, confidentMargin, checksum: "sha256:<index>",
9
+ * preprocessing: { width, height, resize: "pil-bilinear", mean, std, orientations },
10
+ * embedding: { input, output, token },
11
+ * files: { index: File, ids: File, models: [ File & { precision, licence } ] } }
12
+ *
13
+ * File = { path, sha256, size }; `path` resolves against the manifest's URL,
14
+ * so a manifest may list relative paths or absolute (signed) URLs.
15
+ *
16
+ * The lease's manifest URL carries a Cloud CDN prefix signature over its
17
+ * pair's directory (`URLPrefix`, `Expires`, `KeyName`, `Signature`). Resolving
18
+ * a relative path drops the query, so the signature is carried onto each file
19
+ * URL that falls under the signed prefix, on the same origin, and onto no
20
+ * other: a file elsewhere never sees this lease's signature.
21
+ */
22
+ import { ScannerError } from '../errors.js'
23
+ import type { Orientation } from './preprocess.js'
24
+
25
+ export interface ManifestFile {
26
+ /** Absolute, resolved against the manifest's URL; carries its signature when under the signed prefix. */
27
+ readonly url: string
28
+ readonly sha256: string
29
+ readonly size: number
30
+ }
31
+
32
+ export type ModelPrecision = 'fp16' | 'fp32'
33
+
34
+ export interface ManifestModel extends ManifestFile {
35
+ readonly precision: ModelPrecision
36
+ readonly licence: string
37
+ }
38
+
39
+ export interface Manifest {
40
+ readonly modelVersion: string
41
+ readonly indexVersion: string
42
+ readonly shard: string
43
+ readonly rows: number
44
+ readonly dims: number
45
+ readonly notACardBelow: number
46
+ readonly confidentMargin: number
47
+ readonly preprocessing: {
48
+ readonly width: number
49
+ readonly height: number
50
+ readonly mean: readonly [number, number, number]
51
+ readonly std: readonly [number, number, number]
52
+ readonly orientations: readonly Orientation[]
53
+ }
54
+ readonly embedding: { readonly input: string; readonly output: string; readonly token: number }
55
+ readonly index: ManifestFile
56
+ readonly ids: ManifestFile
57
+ readonly models: readonly ManifestModel[]
58
+ }
59
+
60
+ /** Licences a shipped model may carry (closed commercial distribution). */
61
+ export const LICENCE_ALLOWLIST: readonly string[] = ['Apache-2.0', 'MIT', 'BSD-2-Clause', 'BSD-3-Clause']
62
+
63
+ const SHA256 = /^[0-9a-f]{64}$/
64
+
65
+ const bad = (why: string): ScannerError => new ScannerError('MODEL_INTEGRITY', `manifest: ${why}`)
66
+
67
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
68
+ const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0
69
+ const posInt = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) > 0
70
+ const cosine = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= -1 && v <= 1
71
+
72
+ function triple(v: unknown, name: string): readonly [number, number, number] {
73
+ if (!Array.isArray(v) || v.length !== 3 || !v.every((x) => typeof x === 'number' && Number.isFinite(x))) {
74
+ throw bad(`${name} must be three numbers`)
75
+ }
76
+ return [v[0], v[1], v[2]] as const
77
+ }
78
+
79
+ /** The four signing parameters, in Cloud CDN's order. */
80
+ const SIGNING_PARAMS = ['URLPrefix', 'Expires', 'KeyName', 'Signature'] as const
81
+
82
+ export interface SignedPrefix {
83
+ /** The decoded `URLPrefix`: scheme, host and path. */
84
+ readonly prefix: string
85
+ /** `URLPrefix=…&Expires=…&KeyName=…&Signature=…`, the values exactly as received. */
86
+ readonly query: string
87
+ }
88
+
89
+ /** base64url (padded or not) to text; null when it is not base64url of UTF-8. */
90
+ function base64urlText(value: string): string | null {
91
+ if (!/^[A-Za-z0-9_-]+={0,2}$/.test(value)) return null
92
+ try {
93
+ const b64 = value.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/')
94
+ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4))
95
+ return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ /** The raw values of the signing parameters in a query, undecoded. */
102
+ function signingValues(search: string): Map<string, string[]> {
103
+ const found = new Map<string, string[]>()
104
+ for (const part of search.replace(/^\?/, '').split('&')) {
105
+ const eq = part.indexOf('=')
106
+ const name = eq < 0 ? part : part.slice(0, eq)
107
+ if ((SIGNING_PARAMS as readonly string[]).includes(name)) found.set(name, [...(found.get(name) ?? []), eq < 0 ? '' : part.slice(eq + 1)])
108
+ }
109
+ return found
110
+ }
111
+
112
+ /**
113
+ * The prefix signature a manifest URL carries, or null when it carries none
114
+ * (or a malformed one). The raw query is read, never `URLSearchParams`, so
115
+ * the values, `=` padding included, travel byte for byte.
116
+ */
117
+ export function signedPrefixOf(manifestUrl: string): SignedPrefix | null {
118
+ let url: URL
119
+ try {
120
+ url = new URL(manifestUrl)
121
+ } catch {
122
+ return null
123
+ }
124
+ const found = signingValues(url.search)
125
+ const values: string[] = []
126
+ for (const p of SIGNING_PARAMS) {
127
+ const v = found.get(p)
128
+ if (v === undefined || v.length !== 1 || v[0] === '') return null
129
+ values.push(v[0] as string)
130
+ }
131
+ const prefix = base64urlText(values[0] as string)
132
+ if (prefix === null) return null
133
+ let prefixUrl: URL
134
+ try {
135
+ prefixUrl = new URL(prefix)
136
+ } catch {
137
+ return null
138
+ }
139
+ if (prefixUrl.protocol !== 'https:' && prefixUrl.protocol !== 'http:') return null
140
+ return { prefix, query: SIGNING_PARAMS.map((p, i) => `${p}=${values[i] as string}`).join('&') }
141
+ }
142
+
143
+ /**
144
+ * `url` with the signing query added when it lies under the signed prefix and
145
+ * on the prefix's origin; anything else is returned unchanged, so another
146
+ * host or another pair's directory never receives this signature. A URL that
147
+ * already carries signing parameters (an absolute signed URL) keeps its own.
148
+ */
149
+ export function carrySignature(url: string, signed: SignedPrefix | null): string {
150
+ if (signed === null) return url
151
+ const u = new URL(url)
152
+ if (u.origin !== new URL(signed.prefix).origin) return url
153
+ if (!`${u.origin}${u.pathname}`.startsWith(signed.prefix)) return url
154
+ if (signingValues(u.search).size > 0) return url
155
+ return `${u.origin}${u.pathname}${u.search === '' ? '?' : `${u.search}&`}${signed.query}${u.hash}`
156
+ }
157
+
158
+ function file(v: unknown, name: string, base: string, signed: SignedPrefix | null): ManifestFile {
159
+ if (!isRecord(v)) throw bad(`files.${name} is missing`)
160
+ if (!nonEmpty(v.path)) throw bad(`files.${name}.path is missing`)
161
+ if (typeof v.sha256 !== 'string' || !SHA256.test(v.sha256)) throw bad(`files.${name}.sha256 is not a SHA-256 hex digest`)
162
+ if (!posInt(v.size)) throw bad(`files.${name}.size is not a positive integer`)
163
+ let url: string
164
+ try {
165
+ url = new URL(v.path, base).href
166
+ } catch {
167
+ throw bad(`files.${name}.path does not resolve against ${base}`)
168
+ }
169
+ return { url: carrySignature(url, signed), sha256: v.sha256, size: v.size }
170
+ }
171
+
172
+ /** Validates a parsed manifest document. `manifestUrl` is what relative paths resolve against. */
173
+ export function parseManifest(doc: unknown, manifestUrl: string): Manifest {
174
+ if (!isRecord(doc)) throw bad('not a JSON object')
175
+ if (doc.format !== 1) throw bad(`format ${String(doc.format)} is not supported`)
176
+ const { modelVersion, indexVersion, shard, rows, dims, dtype, notACardBelow, confidentMargin, checksum } = doc
177
+ if (!nonEmpty(modelVersion) || !nonEmpty(indexVersion) || !nonEmpty(shard)) throw bad('modelVersion, indexVersion and shard are required')
178
+ if (!posInt(rows) || !posInt(dims)) throw bad('rows and dims must be positive integers')
179
+ if (dtype !== 'fp16') throw bad(`dtype ${String(dtype)} is not supported`)
180
+ if (!cosine(notACardBelow) || !cosine(confidentMargin)) throw bad('notACardBelow and confidentMargin must be cosines')
181
+
182
+ const p = doc.preprocessing
183
+ if (!isRecord(p)) throw bad('preprocessing is missing')
184
+ if (p.resize !== 'pil-bilinear') throw bad(`resize ${String(p.resize)} is not supported`)
185
+ if (!posInt(p.width) || !posInt(p.height)) throw bad('preprocessing width and height must be positive integers')
186
+ const orientations = p.orientations
187
+ if (
188
+ !Array.isArray(orientations) ||
189
+ orientations.length === 0 ||
190
+ !orientations.every((o) => o === 0 || o === 180) ||
191
+ new Set(orientations).size !== orientations.length
192
+ ) {
193
+ throw bad('orientations must list 0 and/or 180')
194
+ }
195
+
196
+ const e = doc.embedding
197
+ if (!isRecord(e) || !nonEmpty(e.input) || !nonEmpty(e.output) || !Number.isSafeInteger(e.token) || (e.token as number) < 0) {
198
+ throw bad('embedding must name input, output and token')
199
+ }
200
+
201
+ const files = doc.files
202
+ if (!isRecord(files)) throw bad('files is missing')
203
+ const signed = signedPrefixOf(manifestUrl)
204
+ const index = file(files.index, 'index', manifestUrl, signed)
205
+ if (checksum !== `sha256:${index.sha256}`) throw bad('checksum does not match files.index.sha256')
206
+ const ids = file(files.ids, 'ids', manifestUrl, signed)
207
+ if (!Array.isArray(files.models) || files.models.length === 0) throw bad('files.models lists no model')
208
+ const models = files.models.map((m, i): ManifestModel => {
209
+ if (!isRecord(m)) throw bad(`files.models[${i}] is not an object`)
210
+ if (m.precision !== 'fp16' && m.precision !== 'fp32') {
211
+ throw bad(`files.models[${i}].precision ${String(m.precision)} is not shipped (fp16 or fp32)`)
212
+ }
213
+ if (typeof m.licence !== 'string' || !LICENCE_ALLOWLIST.includes(m.licence)) {
214
+ throw bad(`files.models[${i}].licence ${String(m.licence)} is outside the allowlist`)
215
+ }
216
+ return { ...file(m, `models[${i}]`, manifestUrl, signed), precision: m.precision, licence: m.licence }
217
+ })
218
+ if (new Set(models.map((m) => m.precision)).size !== models.length) throw bad('one model file per precision')
219
+
220
+ return {
221
+ modelVersion,
222
+ indexVersion,
223
+ shard,
224
+ rows,
225
+ dims,
226
+ notACardBelow,
227
+ confidentMargin,
228
+ preprocessing: {
229
+ width: p.width,
230
+ height: p.height,
231
+ mean: triple(p.mean, 'preprocessing.mean'),
232
+ std: triple(p.std, 'preprocessing.std'),
233
+ orientations: orientations as Orientation[],
234
+ },
235
+ embedding: { input: e.input, output: e.output, token: e.token as number },
236
+ index,
237
+ ids,
238
+ models,
239
+ }
240
+ }