@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.
- package/CHANGELOG.md +21 -0
- package/README.md +214 -2
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/geometry/homography.d.ts +75 -0
- package/dist/geometry/homography.d.ts.map +1 -0
- package/dist/geometry/homography.js +146 -0
- package/dist/geometry/homography.js.map +1 -0
- package/dist/identify/assets.d.ts +39 -0
- package/dist/identify/assets.d.ts.map +1 -0
- package/dist/identify/assets.js +167 -0
- package/dist/identify/assets.js.map +1 -0
- package/dist/identify/decode.d.ts +6 -0
- package/dist/identify/decode.d.ts.map +1 -0
- package/dist/identify/decode.js +53 -0
- package/dist/identify/decode.js.map +1 -0
- package/dist/identify/engine.d.ts +32 -0
- package/dist/identify/engine.d.ts.map +1 -0
- package/dist/identify/engine.js +127 -0
- package/dist/identify/engine.js.map +1 -0
- package/dist/identify/identifier.d.ts +81 -0
- package/dist/identify/identifier.d.ts.map +1 -0
- package/dist/identify/identifier.js +327 -0
- package/dist/identify/identifier.js.map +1 -0
- package/dist/identify/manifest.d.ts +60 -0
- package/dist/identify/manifest.d.ts.map +1 -0
- package/dist/identify/manifest.js +211 -0
- package/dist/identify/manifest.js.map +1 -0
- package/dist/identify/preprocess.d.ts +52 -0
- package/dist/identify/preprocess.d.ts.map +1 -0
- package/dist/identify/preprocess.js +195 -0
- package/dist/identify/preprocess.js.map +1 -0
- package/dist/identify/router.d.ts +60 -0
- package/dist/identify/router.d.ts.map +1 -0
- package/dist/identify/router.js +83 -0
- package/dist/identify/router.js.map +1 -0
- package/dist/identify/runtime.d.ts +69 -0
- package/dist/identify/runtime.d.ts.map +1 -0
- package/dist/identify/runtime.js +140 -0
- package/dist/identify/runtime.js.map +1 -0
- package/dist/identify/search.d.ts +35 -0
- package/dist/identify/search.d.ts.map +1 -0
- package/dist/identify/search.js +99 -0
- package/dist/identify/search.js.map +1 -0
- package/dist/identify/server.d.ts +60 -0
- package/dist/identify/server.d.ts.map +1 -0
- package/dist/identify/server.js +149 -0
- package/dist/identify/server.js.map +1 -0
- package/dist/identify/shard.d.ts +31 -0
- package/dist/identify/shard.d.ts.map +1 -0
- package/dist/identify/shard.js +146 -0
- package/dist/identify/shard.js.map +1 -0
- package/dist/identify/types.d.ts +107 -0
- package/dist/identify/types.d.ts.map +1 -0
- package/dist/identify/types.js +7 -0
- package/dist/identify/types.js.map +1 -0
- package/dist/identify/worker.d.ts +2 -0
- package/dist/identify/worker.d.ts.map +1 -0
- package/dist/identify/worker.js +13 -0
- package/dist/identify/worker.js.map +1 -0
- package/dist/identify/workerProtocol.d.ts +54 -0
- package/dist/identify/workerProtocol.d.ts.map +1 -0
- package/dist/identify/workerProtocol.js +113 -0
- package/dist/identify/workerProtocol.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lease/verify.d.ts +69 -0
- package/dist/lease/verify.d.ts.map +1 -0
- package/dist/lease/verify.js +235 -0
- package/dist/lease/verify.js.map +1 -0
- package/dist/scanner.d.ts +10 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +58 -0
- package/dist/scanner.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage/meter.d.ts +83 -0
- package/dist/usage/meter.d.ts.map +1 -0
- package/dist/usage/meter.js +111 -0
- package/dist/usage/meter.js.map +1 -0
- package/dist/usage/store.d.ts +52 -0
- package/dist/usage/store.d.ts.map +1 -0
- package/dist/usage/store.js +198 -0
- package/dist/usage/store.js.map +1 -0
- package/package.json +64 -4
- package/src/errors.ts +24 -0
- package/src/geometry/homography.ts +178 -0
- package/src/identify/assets.ts +189 -0
- package/src/identify/decode.ts +54 -0
- package/src/identify/engine.ts +167 -0
- package/src/identify/identifier.ts +419 -0
- package/src/identify/manifest.ts +240 -0
- package/src/identify/preprocess.ts +227 -0
- package/src/identify/router.ts +115 -0
- package/src/identify/runtime.ts +197 -0
- package/src/identify/search.ts +115 -0
- package/src/identify/server.ts +167 -0
- package/src/identify/shard.ts +154 -0
- package/src/identify/types.ts +122 -0
- package/src/identify/worker.ts +13 -0
- package/src/identify/workerProtocol.ts +136 -0
- package/src/index.ts +71 -0
- package/src/lease/verify.ts +252 -0
- package/src/scanner.ts +56 -0
- package/src/types.ts +196 -0
- package/src/usage/meter.ts +165 -0
- 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
|
+
}
|