@dejadex/web 0.0.0-stage → 0.1.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +222 -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 +91 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +332 -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 +40 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +248 -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 +431 -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 +282 -0
@@ -0,0 +1,431 @@
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
+ /**
76
+ * Default IndexedDB (`dejadex-usage`), memory where it is unavailable. The
77
+ * default store is the identifier's own: `destroy()` closes it. A store passed
78
+ * here is the host's: `destroy()` leaves it open, and the host closes it.
79
+ */
80
+ readonly usageStore?: UsageStore
81
+ /** Background failures (a device engine that failed to load). Never called from `identify`. */
82
+ readonly onError?: (error: Error) => void
83
+
84
+ /** @internal Test seams. */
85
+ readonly fetch?: ServerFetch
86
+ /** @internal */
87
+ readonly now?: () => number
88
+ /** @internal */
89
+ readonly online?: () => boolean
90
+ /** @internal */
91
+ readonly loadEngine?: (config: EngineConfig) => Promise<DeviceEngine>
92
+ /** @internal */
93
+ readonly verify?: Ed25519Verify
94
+ }
95
+
96
+ export interface Identifier {
97
+ /** At most 9 crops (JPEG for the server path). Always resolves. */
98
+ identify(crops: readonly Blob[], options?: IdentifyCallOptions): Promise<IdentifyOutcome>
99
+ /** Verifies and installs a lease (rejects with a `ScannerError` when it is not valid here). */
100
+ setLease(grant: LeaseGrant): Promise<Lease>
101
+ /** The identifier's own session id, used when a call names none. */
102
+ readonly session: string
103
+ /** Starts a new default session: cards seen before are counted again. */
104
+ newSession(): string
105
+ /** Loads (or awaits) the device engine for the current lease. */
106
+ prepare(): Promise<DeviceEngineInfo>
107
+ status(): IdentifierStatus
108
+ /** Hands queued usage to `deliver` (the host forwards it to its backend). See `UsageMeter.drain`. */
109
+ drainUsage(deliver: (batch: UsageBatch) => Promise<void>): Promise<DrainResult>
110
+ pendingUsage(): Promise<number>
111
+ /**
112
+ * The body the host sends its backend to renew: the current lease and the
113
+ * last report `seq` of it already delivered (null before the first lease).
114
+ * Drain usage first, or the service refuses the renewal (USAGE_UNREPORTED).
115
+ *
116
+ * `previousLeaseId` names another lease to renew instead: one the host
117
+ * stored but has not set here (it expired, or this page never identified).
118
+ * Null asks as a device with no lease.
119
+ */
120
+ leaseRequest(previousLeaseId?: string | null): Promise<LeaseRequest>
121
+ /**
122
+ * Releases the device engine and its Worker, and closes the usage store the
123
+ * identifier opened itself (never one passed as `options.usageStore`). Later
124
+ * calls resolve `unavailable`, and the usage methods reject once that store
125
+ * is closed. Drain usage first: what is still queued stays on the device
126
+ * until a later identifier drains it or the host deletes `dejadex-usage`.
127
+ */
128
+ destroy(): Promise<void>
129
+ }
130
+
131
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message || err.name : String(err))
132
+ const codeText = (err: unknown): string => (err instanceof ScannerError ? `${err.code}: ${err.message}` : errorText(err))
133
+
134
+ function validate(options: IdentifierOptions): void {
135
+ const fail = (m: string): never => {
136
+ throw new ScannerError('INVALID_OPTIONS', m)
137
+ }
138
+ if (typeof options !== 'object' || options === null) fail('options are required')
139
+ if (typeof options.domain !== 'string' || !DOMAIN_CODE.test(options.domain)) fail(`not a domain code: ${String(options.domain)}`)
140
+ if (!Array.isArray(options.leasePublicKeys) || options.leasePublicKeys.length === 0) fail('leasePublicKeys must list at least one key')
141
+ const modes: readonly unknown[] = ['auto', 'local', 'server']
142
+ if (options.identification !== undefined && !modes.includes(options.identification)) fail(`unknown identification mode: ${String(options.identification)}`)
143
+ if (options.serverUrl !== undefined && options.serverUrl !== null) {
144
+ try {
145
+ const u = new URL(options.serverUrl)
146
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') fail('serverUrl must be http(s)')
147
+ } catch {
148
+ fail(`serverUrl is not a URL: ${options.serverUrl}`)
149
+ }
150
+ }
151
+ for (const [name, v] of [['slowDeviceBudgetMs', options.slowDeviceBudgetMs], ['serverTimeoutMs', options.serverTimeoutMs]] as const) {
152
+ if (v !== undefined && (!Number.isFinite(v) || v <= 0)) fail(`${name} must be a positive number`)
153
+ }
154
+ }
155
+
156
+ function defaultOrigin(): string | null {
157
+ const loc = (globalThis as { location?: { origin?: string } }).location
158
+ return typeof loc?.origin === 'string' && loc.origin !== 'null' ? loc.origin : null
159
+ }
160
+
161
+ function defaultEngineLoader(choice: IdentifierOptions['engine']): (config: EngineConfig) => Promise<DeviceEngine> {
162
+ const useWorker = choice === undefined ? typeof Worker !== 'undefined' : choice !== 'inline'
163
+ if (!useWorker) return async (config) => loadDeviceEngine(config, await defaultEngineDeps())
164
+ const make =
165
+ typeof choice === 'function' ? choice : (): WorkerLike => new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
166
+ return async (config) => {
167
+ const worker = make()
168
+ try {
169
+ return await connectWorkerEngine(worker, config)
170
+ } catch (err) {
171
+ worker.terminate()
172
+ throw err
173
+ }
174
+ }
175
+ }
176
+
177
+ export function createIdentifier(options: IdentifierOptions): Identifier {
178
+ validate(options)
179
+ const mode = options.identification ?? 'auto'
180
+ const endpoint = options.serverUrl ? `${options.serverUrl.trim().replace(/\/+$/, '')}/v1/identify` : null
181
+ const budget = options.slowDeviceBudgetMs ?? DEFAULT_SLOW_DEVICE_BUDGET_MS
182
+ const serverTimeoutMs = options.serverTimeoutMs ?? DEFAULT_SERVER_TIMEOUT_MS
183
+ const now = options.now ?? (() => Date.now())
184
+ const online = options.online ?? (() => (globalThis as { navigator?: { onLine?: boolean } }).navigator?.onLine !== false)
185
+ const origin = options.origin === undefined ? defaultOrigin() : options.origin
186
+ const doFetch: ServerFetch = options.fetch ?? ((url, init) => fetch(url, init))
187
+ const loadEngine = options.loadEngine ?? defaultEngineLoader(options.engine)
188
+ const health = new ServerHealth()
189
+ // A store the identifier opened is closed by `destroy()`; the host's is not.
190
+ const ownStore = options.usageStore === undefined
191
+ const storeReady: Promise<UsageStore> = options.usageStore ? Promise.resolve(options.usageStore) : defaultUsageStore()
192
+ const meterReady: Promise<UsageMeter> = storeReady.then((store) => new UsageMeter({ store, now }))
193
+
194
+ let lease: Lease | null = null
195
+ /** The constructor's lease while it is being verified; `identify` waits for it. */
196
+ let initialLease: Promise<unknown> | null = null
197
+ let manifestUrl: string | null = null
198
+ let session = randomId()
199
+ let destroyed = false
200
+
201
+ // The device engine for the current lease, and its load.
202
+ let engine: DeviceEngine | null = null
203
+ let engineKey: string | null = null
204
+ let loading: Promise<DeviceEngine> | null = null
205
+ let deviceError: string | null = null
206
+
207
+ const keyOf = (claims: LeaseClaims, url: string) => `${url}\n${claims.modelVersion}\n${claims.indexVersion}`
208
+
209
+ function startLoad(): Promise<DeviceEngine> {
210
+ if (destroyed) return Promise.reject(new ScannerError('INVALID_OPTIONS', 'the identifier was destroyed'))
211
+ if (!lease || !manifestUrl) return Promise.reject(new ScannerError('LEASE_INVALID', 'no lease is set'))
212
+ const key = keyOf(lease.claims, manifestUrl)
213
+ if (engine && engineKey === key) return Promise.resolve(engine)
214
+ if (loading) return loading
215
+ const config: EngineConfig = {
216
+ manifestUrl,
217
+ expect: { modelVersion: lease.claims.modelVersion, indexVersion: lease.claims.indexVersion, shard: options.domain },
218
+ runtime: options.runtime ?? {},
219
+ }
220
+ deviceError = null
221
+ const attempt = loadEngine(config).then(
222
+ async (loaded) => {
223
+ const previous = engine
224
+ engine = loaded
225
+ engineKey = key
226
+ loading = null
227
+ if (previous && previous !== loaded) await previous.release().catch(() => undefined)
228
+ if (destroyed) await loaded.release().catch(() => undefined)
229
+ return loaded
230
+ },
231
+ (err: unknown) => {
232
+ loading = null
233
+ deviceError = codeText(err)
234
+ throw err
235
+ },
236
+ )
237
+ loading = attempt
238
+ return attempt
239
+ }
240
+
241
+ const preload = () => {
242
+ if (options.preload === false) return
243
+ startLoad().catch((err: unknown) => options.onError?.(err instanceof Error ? err : new Error(String(err))))
244
+ }
245
+
246
+ function deviceState(): DeviceState {
247
+ const e = engine
248
+ if (e === null || lease === null || manifestUrl === null || engineKey !== keyOf(lease.claims, manifestUrl)) {
249
+ return { ready: false, backend: null, perCropMs: Number.NaN }
250
+ }
251
+ return { ready: true, backend: e.info.backend, perCropMs: e.info.perCropMs }
252
+ }
253
+
254
+ async function setLease(grant: LeaseGrant): Promise<Lease> {
255
+ if (destroyed) throw new ScannerError('INVALID_OPTIONS', 'the identifier was destroyed')
256
+ if (typeof grant?.token !== 'string' || typeof grant.manifestUrl !== 'string') {
257
+ throw new ScannerError('INVALID_OPTIONS', 'a lease grant is { token, manifestUrl }')
258
+ }
259
+ const verified = await verifyLease(grant.token, {
260
+ publicKeys: options.leasePublicKeys,
261
+ origin,
262
+ domains: [options.domain],
263
+ now: Math.floor(now() / 1000),
264
+ ...(options.verify ? { verify: options.verify } : {}),
265
+ })
266
+ let url: string
267
+ try {
268
+ url = new URL(grant.manifestUrl, defaultOrigin() ?? undefined).href
269
+ } catch {
270
+ throw new ScannerError('INVALID_OPTIONS', `manifestUrl is not a URL: ${grant.manifestUrl}`)
271
+ }
272
+ lease = verified
273
+ manifestUrl = url
274
+ // Every lease reports at least once (the renewal gate), used or not.
275
+ await (await meterReady).touch(verified.claims.jti, session)
276
+ preload()
277
+ return verified
278
+ }
279
+
280
+ const aborted = (): IdentifyOutcome => ({ ok: false, reason: 'unavailable', detail: 'aborted' })
281
+
282
+ /** Resolves with the device's answer, or `null` when the caller aborted first. */
283
+ async function onDevice(crops: readonly Blob[], top: number, signal: AbortSignal | undefined): Promise<IdentifyResult[] | null> {
284
+ const work = startLoad().then((e) => e.identify(crops, top))
285
+ if (!signal) return work
286
+ if (signal.aborted) return null
287
+ return new Promise((resolve, reject) => {
288
+ const onAbort = () => resolve(null)
289
+ signal.addEventListener('abort', onAbort, { once: true })
290
+ work.then(
291
+ (r) => {
292
+ signal.removeEventListener('abort', onAbort)
293
+ resolve(r)
294
+ },
295
+ (e: unknown) => {
296
+ signal.removeEventListener('abort', onAbort)
297
+ reject(e)
298
+ },
299
+ )
300
+ })
301
+ }
302
+
303
+ async function identify(crops: readonly Blob[], call: IdentifyCallOptions = {}): Promise<IdentifyOutcome> {
304
+ try {
305
+ if (destroyed) return { ok: false, reason: 'unavailable', detail: 'INVALID_OPTIONS: the identifier was destroyed' }
306
+ if (!Array.isArray(crops) || crops.length > MAX_CROPS_PER_REQUEST) {
307
+ return { ok: false, reason: 'rejected', detail: `INVALID_OPTIONS: at most ${MAX_CROPS_PER_REQUEST} crops per call` }
308
+ }
309
+ const top = call.top ?? DEFAULT_TOP
310
+ if (!Number.isInteger(top) || top < 1 || top > MAX_TOP) return { ok: false, reason: 'rejected', detail: `INVALID_OPTIONS: top must be 1..${MAX_TOP}` }
311
+ const sessionId = call.session ?? session
312
+ if (!SESSION.test(sessionId)) return { ok: false, reason: 'rejected', detail: 'INVALID_OPTIONS: session must be 1..128 printable characters' }
313
+ if (call.signal?.aborted) return aborted()
314
+
315
+ if (initialLease) await initialLease
316
+ const current = lease
317
+ if (!current) return { ok: false, reason: 'rejected', detail: 'LEASE_INVALID: no lease is set' }
318
+ if (Math.floor(now() / 1000) >= current.claims.exp) return { ok: false, reason: 'rejected', detail: 'LEASE_EXPIRED: renew the lease' }
319
+ const meter = await meterReady
320
+ if (meter.exhausted(current.claims)) return { ok: false, reason: 'rejected', detail: 'ALLOWANCE_EXHAUSTED: renew the lease' }
321
+ if (crops.length === 0) return { ok: true, modelVersion: current.claims.modelVersion, results: [], source: 'device' }
322
+
323
+ const device = deviceState()
324
+ const serverBlocked = endpoint === null ? 'no serverUrl' : health.blocked(current.claims.jti, now(), online())
325
+ const route = chooseRoute({ mode, device, serverBlocked, slowDeviceBudgetMs: budget })
326
+
327
+ let results: readonly IdentifyResult[] | null = null
328
+ let source: 'device' | 'server' = 'device'
329
+ let serverFailure: ServerAnswer | null = null
330
+ if (route === 'server' && endpoint !== null) {
331
+ const answer = await postIdentify({
332
+ endpoint,
333
+ crops,
334
+ token: current.token,
335
+ top,
336
+ session: sessionId,
337
+ ...(call.purpose ? { purpose: call.purpose } : {}),
338
+ timeoutMs: serverDeadline(device, crops.length, serverTimeoutMs),
339
+ ...(call.signal ? { signal: call.signal } : {}),
340
+ fetch: doFetch,
341
+ })
342
+ const checked: ServerAnswer =
343
+ answer.ok && answer.modelVersion !== current.claims.modelVersion
344
+ ? { ok: false, kind: 'malformed', detail: `answer is model ${answer.modelVersion}, the lease is ${current.claims.modelVersion}` }
345
+ : answer
346
+ health.record(checked, current.claims.jti, now())
347
+ if (checked.ok) {
348
+ results = checked.results
349
+ source = 'server'
350
+ } else if (checked.kind === 'aborted') {
351
+ return aborted()
352
+ } else {
353
+ serverFailure = checked
354
+ }
355
+ }
356
+
357
+ if (results === null) {
358
+ try {
359
+ results = await onDevice(crops, top, call.signal)
360
+ } catch (err) {
361
+ const why = codeText(err)
362
+ if (serverFailure?.ok === false && serverFailure.kind === 'timeout') return { ok: false, reason: 'timeout', detail: `${serverFailure.detail}; device: ${why}` }
363
+ const reason = err instanceof ScannerError && err.code === 'INVALID_OPTIONS' ? 'rejected' : 'unavailable'
364
+ return { ok: false, reason, detail: serverFailure?.ok === false ? `${serverFailure.detail}; device: ${why}` : why }
365
+ }
366
+ if (results === null) return aborted()
367
+ }
368
+
369
+ await meter.record({ lease: current.claims, session: sessionId, purpose: call.purpose, results })
370
+ return { ok: true, modelVersion: current.claims.modelVersion, results, source }
371
+ } catch (err) {
372
+ return { ok: false, reason: 'unavailable', detail: codeText(err) }
373
+ }
374
+ }
375
+
376
+ const identifier: Identifier = {
377
+ identify,
378
+ setLease,
379
+ get session() {
380
+ return session
381
+ },
382
+ newSession() {
383
+ session = randomId()
384
+ return session
385
+ },
386
+ async prepare() {
387
+ return (await startLoad()).info
388
+ },
389
+ status() {
390
+ const claims = lease?.claims ?? null
391
+ const ready = deviceState().ready
392
+ return {
393
+ lease: claims,
394
+ device: ready ? 'ready' : loading ? 'loading' : deviceError ? 'failed' : 'idle',
395
+ deviceInfo: ready && engine ? engine.info : null,
396
+ deviceError,
397
+ serverPaused: endpoint === null ? 'no serverUrl' : health.blocked(claims?.jti ?? null, now(), online()),
398
+ }
399
+ },
400
+ async drainUsage(deliver) {
401
+ return (await meterReady).drain(deliver)
402
+ },
403
+ async pendingUsage() {
404
+ return (await meterReady).pending()
405
+ },
406
+ async leaseRequest(previousLeaseId) {
407
+ return (await meterReady).leaseRequest(previousLeaseId === undefined ? (lease?.claims.jti ?? null) : previousLeaseId)
408
+ },
409
+ async destroy() {
410
+ destroyed = true
411
+ const e = engine
412
+ engine = null
413
+ engineKey = null
414
+ await e?.release().catch(() => undefined)
415
+ if (ownStore) (await storeReady).close?.()
416
+ },
417
+ }
418
+
419
+ if (options.lease) {
420
+ initialLease = setLease(options.lease).then(
421
+ () => {
422
+ initialLease = null
423
+ },
424
+ (err: unknown) => {
425
+ initialLease = null
426
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
427
+ },
428
+ )
429
+ }
430
+ return identifier
431
+ }
@@ -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
+ }