@mikrojs/native 0.18.0 → 0.18.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 (68) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +2 -1
  27. package/include/mikrojs/ota_client.h +342 -0
  28. package/include/mikrojs/ota_config.h +100 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/sys_codec.h +61 -0
  34. package/package.json +7 -5
  35. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  36. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  38. package/runtime/internal.d.ts +22 -16
  39. package/runtime/kv/shared.ts +11 -5
  40. package/runtime/kv/types.ts +4 -4
  41. package/runtime/ota/client.ts +12 -51
  42. package/runtime/ota/config.ts +18 -0
  43. package/runtime/ota/ota.ts +28 -70
  44. package/runtime/ota/types.ts +220 -2
  45. package/runtime/schema/core.ts +539 -0
  46. package/runtime/schema/schema.ts +36 -314
  47. package/runtime/schema/shared.ts +494 -0
  48. package/runtime/schema/types.ts +84 -12
  49. package/scripts/bundle-runtime.js +33 -0
  50. package/scripts/gen-checkin-fixtures.js +323 -0
  51. package/src/builtins.cpp +7 -8
  52. package/src/fs.cpp +3 -0
  53. package/src/mem.cpp +38 -0
  54. package/src/mik_abort.cpp +8 -1
  55. package/src/mik_cbor.cpp +43 -5
  56. package/src/mik_inspect.cpp +128 -22
  57. package/src/mik_ota_client.cpp +1230 -0
  58. package/src/mik_ota_config.cpp +296 -0
  59. package/src/mik_ota_js_hooks.cpp +190 -0
  60. package/src/mik_ota_policy.cpp +419 -0
  61. package/src/mik_ota_slots.cpp +249 -0
  62. package/src/mik_repl.cpp +9 -3
  63. package/src/mik_result.cpp +3 -1
  64. package/src/mik_sys_codec.cpp +167 -0
  65. package/src/mikrojs.cpp +15 -0
  66. package/src/modules.cpp +32 -13
  67. package/runtime/ota/client-impl.ts +0 -590
  68. package/runtime/ota/policy.ts +0 -299
@@ -1,590 +0,0 @@
1
- // Internal: the OTA client state machine behind `mikro/ota/client`. Bundled
2
- // into ota/client.js; not exposed as its own subpath. Exists as a seam so host
3
- // tests can drive the whole client against fakes — no hardware, no registry,
4
- // no real restart.
5
-
6
- import type {Request, RequestError} from 'mikro/http/helpers'
7
- import {err, ok} from 'mikro/result'
8
-
9
- import type {CborError} from '../cbor/types.js'
10
- import type {Result} from '../result/types.js'
11
- import type {DeviceName} from '../sys/types.js'
12
- import type {Diagnostic, Offer, Ota, OtaError, Update} from './types.js'
13
-
14
- export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
15
-
16
- /** Immutable facts about this device and the firmware under it. */
17
- export interface DeviceIdentity {
18
- deviceId: string
19
- /** Firmware version string, and the hash of that image. A build published
20
- * against a different firmware is withheld rather than offered. */
21
- firmware: string
22
- firmwareHash: string
23
- /** QuickJS bytecode version the linked engine reads; a build compiled for
24
- * another one cannot run here. */
25
- bytecode: number
26
- }
27
-
28
- /**
29
- * Every effect the client can have on the world, in one record. Nothing below
30
- * `createOtaClient` reaches for a runtime import — effects arrive here, so the
31
- * policy can run against a fake with no hardware and no risk of a real
32
- * restart().
33
- */
34
- export interface ClientIo {
35
- /** Wall-clock delay. The only clock the client has. */
36
- sleep(ms: number): Promise<void>
37
- /** HTTP. The only network the client has. */
38
- request: Request
39
- /** Uniform [0, 1). Injected so tests can pin the jitter. */
40
- random(): number
41
- /** Serial diagnostics. Format string and args pass through untouched. */
42
- log(level: LogLevel, format: string, ...args: unknown[]): void
43
- /** CBOR codec for the check-in wire. */
44
- encode(value: unknown): Result<Uint8Array, CborError>
45
- decode(data: Uint8Array): Result<unknown, CborError>
46
-
47
- /** The `mikro/ota` policy surface (staging, trials, retry budgets). */
48
- ota: Ota
49
- identity(): DeviceIdentity
50
- /** Bytes free on the partition a build is staged onto, or undefined when the
51
- * platform cannot report it. */
52
- storageFree(): number | undefined
53
- deviceName(): DeviceName
54
- setDeviceName(name: DeviceName): void
55
- restart(): never
56
- }
57
-
58
- export interface CheckOptions {
59
- /** Budget for the check-in round trip. Default 10s. */
60
- checkinTimeoutMs?: number
61
- /** Budget for the build download. Separate from the check-in's because it is
62
- * a total wallclock deadline that cancels the transfer mid-stream, and an
63
- * image needs orders of magnitude more of it than a check-in body does.
64
- * Default 5m. */
65
- downloadTimeoutMs?: number
66
- /** Require a completed check-in (via `ota.confirm()`, which the client fires
67
- * itself) before an installed build is kept. Default true. */
68
- requireConfirm?: boolean
69
- /** Clean boots a trial may consume before an unconfirmed build reverts.
70
- * A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
71
- * networks should raise this above the default 1. */
72
- trialBoots?: number
73
- }
74
-
75
- /** Runs after its round settles — after the check and any download, and before
76
- * an auto-restart — so the network brought up in `beforeCheck` can go down. */
77
- export type Teardown = () => void | Promise<void>
78
-
79
- export interface WatchOptions extends CheckOptions {
80
- /** Steady interval between rounds, end-of-round to start-of-next. Default 30m. */
81
- checkinIntervalMs?: number
82
- /** Delay before the first round. Default 5s. */
83
- initialDelayMs?: number
84
- /** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
85
- retryAfterFailureMs?: number
86
- /** Spread every scheduled sleep by ±10%, so a fleet that lost power together
87
- * does not check in phase-locked forever. Default true; pass false for
88
- * exact intervals (a demo watching for the update to land, a single
89
- * device where the spread only delays it). */
90
- jitter?: boolean
91
- /** Bring the network up for one round. `err` skips the round (retried at the
92
- * failure interval) and no teardown runs — partial setup is the hook's own
93
- * job to unwind. State shared with the teardown stays in this one scope. */
94
- beforeCheck?(): Promise<Result<Teardown, unknown>>
95
- }
96
-
97
- export interface Watcher {
98
- /** Prevent future rounds and cancel the pending sleep. An in-flight round
99
- * completes, but a build it stages no longer auto-restarts — it stays armed
100
- * for the next natural reboot. */
101
- stop(): void
102
- }
103
-
104
- /** Why an offered build was not armed. Each is the policy working as intended;
105
- * the distinction is logged and returned because the app's next move differs. */
106
- export type DeclineReason =
107
- | 'trial-pending'
108
- | 'current'
109
- | 'abandoned'
110
- | 'exhausted'
111
- | 'download-failed'
112
- | 'install-failed'
113
-
114
- /** The check-in never completed. */
115
- export type CheckError = RequestError | CborError | {name: 'Status'; status: number}
116
-
117
- export type CheckResult =
118
- /** Build downloaded, verified, and armed. The app restarts when ready. */
119
- | {status: 'staged'; offer: Offer}
120
- | {status: 'up-to-date'}
121
- /** An offer arrived but was not armed. */
122
- | {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
123
- /** Transient: the check-in did not complete, so the running trial (if any)
124
- * was not confirmed. */
125
- | {status: 'failed'; error: CheckError}
126
- /** The registry rejected the update key (HTTP 401). Permanent until
127
- * re-enrollment over the cable. */
128
- | {status: 'unauthorized'}
129
- | {status: 'not-enrolled'}
130
-
131
- export interface OtaClient {
132
- check(options?: CheckOptions): Promise<CheckResult>
133
- watch(options?: WatchOptions): Watcher
134
- }
135
-
136
- const DEFAULT_CHECKIN_TIMEOUT_MS = 10_000
137
- const DEFAULT_DOWNLOAD_TIMEOUT_MS = 300_000
138
- const DEFAULT_INTERVAL_MS = 30 * 60_000
139
- const DEFAULT_INITIAL_DELAY_MS = 5_000
140
- const DEFAULT_RETRY_MS = 60_000
141
-
142
- /** What the watch loop does after a round: retry at the shortened interval, or
143
- * wait the full one. Deliberately not "did the check-in succeed" — a rejected
144
- * update key also waits the full interval (a dead key mustn't hammer the
145
- * registry every retry interval forever). */
146
- type NextRound = 'soon' | 'later'
147
-
148
- interface Enrollment {
149
- registryUrl: string
150
- bearer: string
151
- }
152
-
153
- export function createOtaClient(io: ClientIo): OtaClient {
154
- // One check at a time: staging is a single native session, and two
155
- // interleaved check-ins would corrupt it. Later calls queue behind the
156
- // running one; each still gets its own result.
157
- let chain: Promise<unknown> = Promise.resolve()
158
-
159
- // reconcile() clears its report as it reads, so it runs once per boot and
160
- // the report is held here until a check-in actually delivers it — a failed
161
- // first check-in no longer loses it.
162
- let reconciled = false
163
- let lastInstall: Diagnostic | undefined
164
-
165
- let warnedInsecure = false
166
-
167
- function enqueue<T>(task: () => Promise<T>): Promise<T> {
168
- const run = chain.then(task)
169
- chain = run.then(
170
- () => undefined,
171
- () => undefined,
172
- )
173
- return run
174
- }
175
-
176
- function enrollment(): Enrollment | undefined {
177
- // Written as a pair over the cable by `mikro ota enroll`; enrollment is the
178
- // opt-in. Update keys never arrive over the network.
179
- const registryUrl = io.ota.registry()
180
- const bearer = io.ota.bearer()
181
- if (registryUrl === undefined || bearer === undefined) return undefined
182
- return {registryUrl, bearer}
183
- }
184
-
185
- function reconcileOnce(): void {
186
- if (reconciled) return
187
- reconciled = true
188
- const report = io.ota.reconcile()
189
- lastInstall = report.lastInstall
190
- if (report.reverted) {
191
- io.log('warn', 'ota: previous update failed its trial and was rolled back')
192
- }
193
- if (report.installed) {
194
- io.log('info', 'ota: installed build %s on this boot', report.installed.slice(0, 12))
195
- }
196
- }
197
-
198
- async function runCheck(enrolled: Enrollment, options: CheckOptions): Promise<CheckResult> {
199
- const {registryUrl, bearer} = enrolled
200
- const allowInsecure = isPrivateHttp(registryUrl)
201
- if (allowInsecure && !warnedInsecure) {
202
- // Every boot, not once at enrollment: a device that quietly runs a
203
- // forgeable update channel should say so for as long as it does.
204
- warnedInsecure = true
205
- io.log(
206
- 'warn',
207
- 'ota: registry is http:// on a private network — updates are NOT authenticated',
208
- )
209
- }
210
- reconcileOnce()
211
-
212
- const running = io.ota.running()
213
- const identity = io.identity()
214
- const named = io.deviceName()
215
- const free = io.storageFree()
216
- // The shape is a contract with the registry's /api/v1/checkin: these are
217
- // the inputs it arbitrates an offer from. Optional keys are omitted, not
218
- // sent as undefined — the registry reads an absent `free` as "no figure to
219
- // report", and JSON clients drop the key the same way.
220
- const report = {
221
- ...identity,
222
- running,
223
- // The name pair: `[rev]` when cleared, `[rev, name]` otherwise. Sent
224
- // every time so a lost response settles on the next check-in.
225
- name: named.name === undefined ? [named.rev] : [named.rev, named.name],
226
- ...(free === undefined ? {} : {free}),
227
- ...(lastInstall === undefined ? {} : {lastInstall}),
228
- }
229
- io.log(
230
- 'debug',
231
- 'ota: checking %s (running %s v%s, bytecode %d, fw %s)',
232
- `${registryUrl}/api/v1/checkin`,
233
- running.checksum?.slice(0, 12) ?? 'none',
234
- running.version ?? '?',
235
- identity.bytecode,
236
- identity.firmware,
237
- )
238
-
239
- const encoded = io.encode(report)
240
- if (!encoded.ok) {
241
- io.log('error', 'ota: could not encode the check-in report', encoded.error)
242
- return {status: 'failed', error: encoded.error}
243
- }
244
- const res = await io.request(`${registryUrl}/api/v1/checkin`, {
245
- method: 'POST',
246
- headers: {
247
- 'content-type': 'application/cbor',
248
- accept: 'application/cbor',
249
- authorization: `Bearer ${bearer}`,
250
- },
251
- timeoutMs: options.checkinTimeoutMs ?? DEFAULT_CHECKIN_TIMEOUT_MS,
252
- body: encoded.value,
253
- })
254
- if (!res.ok) {
255
- io.log('error', 'ota: checkin failed', res.error)
256
- return {status: 'failed', error: res.error}
257
- }
258
- const response = res.value
259
- if (response.status === 401) {
260
- // The update key no longer authenticates (rotated, device deleted).
261
- // Only a 401 means this. There is no fallback secret — re-enroll over
262
- // the cable. Release the connection before returning.
263
- await response.close()
264
- io.log(
265
- 'error',
266
- 'ota: registry rejected the update key; re-enroll with `mikro ota enroll --re-enroll`',
267
- )
268
- return {status: 'unauthorized'}
269
- }
270
- if (response.status === 415) {
271
- // The wire is CBOR-only by design; there is no JSON fallback to hide a
272
- // registry that predates it.
273
- await response.close()
274
- io.log(
275
- 'error',
276
- 'ota: registry does not accept CBOR check-ins; upgrade the registry to a version that supports application/cbor',
277
- )
278
- return {status: 'failed', error: {name: 'Status', status: 415}}
279
- }
280
- if (!response.ok) {
281
- // Release the connection before the heap-sensitive image request — an
282
- // abandoned response holds its native slot and TLS buffers until its
283
- // timeout fires.
284
- await response.close()
285
- io.log('error', 'ota: checkin returned status %d', response.status)
286
- return {status: 'failed', error: {name: 'Status', status: response.status}}
287
- }
288
- const raw = await response.bytes()
289
- if (!raw.ok) {
290
- io.log('error', 'ota: reading the checkin response failed', raw.error)
291
- return {status: 'failed', error: raw.error}
292
- }
293
- const body = io.decode(raw.value)
294
- if (!body.ok) {
295
- io.log('error', 'ota: invalid checkin response body', body.error)
296
- return {status: 'failed', error: body.error}
297
- }
298
-
299
- // The check-in completed: the cached install report is delivered.
300
- lastInstall = undefined
301
-
302
- // Confirm before applying: a completed check-in is the whole health signal
303
- // requireConfirm waits for, whether or not an offer follows — and resolving
304
- // the trial now lets an offer published during it stage in this same pass
305
- // (applyOffer skips every offer while a trial is unresolved).
306
- if (running.trial) {
307
- io.log('info', 'ota: check-in completed — confirming this build as healthy')
308
- }
309
- io.ota.confirm()
310
-
311
- const renamed = nameFrom(body.value)
312
- if (renamed !== undefined) {
313
- io.setDeviceName(renamed)
314
- io.log('info', 'ota: registry renamed this device to %s', renamed.name ?? '(no name)')
315
- }
316
-
317
- const offer = io.ota.parseOffer(body.value, {allowInsecure})
318
- if (offer === undefined) {
319
- io.log('debug', 'ota: up to date, running the latest build')
320
- return {status: 'up-to-date'}
321
- }
322
- io.log(
323
- 'info',
324
- 'ota: update available (%s, %d bytes); downloading',
325
- offer.checksum.slice(0, 12),
326
- offer.size,
327
- )
328
- const applied = await io.ota.applyOffer(
329
- offer,
330
- (update) => download(enrolled, options, offer, update),
331
- {
332
- requireConfirm: options.requireConfirm ?? true,
333
- trialBoots: options.trialBoots ?? 1,
334
- },
335
- )
336
- if (!applied.ok) {
337
- const reason: DeclineReason =
338
- applied.error.name === 'DownloadFailed' ? 'download-failed' : 'install-failed'
339
- io.log('error', 'ota: update failed', applied.error)
340
- return {status: 'not-staged', reason, error: applied.error}
341
- }
342
- if (applied.value === 'staged') {
343
- io.log('info', 'ota: download verified and staged')
344
- return {status: 'staged', offer}
345
- }
346
- // Not staged, not an error — applyOffer declined the offer:
347
- // current already running this build
348
- // trial-pending the running build hasn't resolved its trial (nearly
349
- // unreachable: the confirm above resolves it first)
350
- // abandoned the build is corrupt and won't be retried
351
- // exhausted the retry budget for this build is spent until next boot
352
- io.log('info', 'ota: offer not staged (%s)', applied.value)
353
- return {status: 'not-staged', reason: applied.value}
354
- }
355
-
356
- /** The download pump: stream the build straight into the staging area,
357
- * resuming an interrupted transfer with a Range request. Chunked on purpose:
358
- * buffering the whole build would put ~2x its size on the heap. */
359
- async function download(
360
- enrolled: Enrollment,
361
- options: CheckOptions,
362
- offer: Offer,
363
- update: Update,
364
- ): Promise<Result<void, {message: string}>> {
365
- const from = update.resumeOffset
366
- // Already complete: a finish that failed transiently leaves every byte on
367
- // flash and the offer pending, so resuming would ask for `bytes=<size>-`
368
- // and take the 416 as a failed download forever. Hand it straight back to
369
- // re-verify.
370
- if (from >= offer.size) return ok()
371
- if (from > 0) {
372
- io.log('info', 'ota: resuming download from byte %d of %d', from, offer.size)
373
- }
374
- // Same-origin only. `offer.url` may legitimately point at another host (a
375
- // CDN, an object store), so the update key goes out only when the download
376
- // is on the registry's own origin. A build fetched elsewhere is a public
377
- // artifact the checksum vouches for; a store that needs auth carries it in
378
- // a signed url instead.
379
- const headers: Record<string, string> = {}
380
- if (from > 0) headers.range = `bytes=${from}-`
381
- if (sameOrigin(offer.url, enrolled.registryUrl)) {
382
- headers.authorization = `Bearer ${enrolled.bearer}`
383
- }
384
- const res = await io.request(offer.url, {
385
- headers,
386
- timeoutMs: options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS,
387
- })
388
- if (!res.ok) {
389
- return err({message: `download failed: ${describeRequestError(res.error)}`})
390
- }
391
- const response = res.value
392
- if (!response.ok) {
393
- await response.close()
394
- return err({message: `download returned status ${response.status}`})
395
- }
396
- // A server free to ignore Range answers 200 with the whole build. Drop the
397
- // prefix we already hold rather than failing, which would spend a retry
398
- // for nothing.
399
- let skip = response.status === 206 ? 0 : from
400
- for await (const chunk of response.body) {
401
- if (!chunk.ok) {
402
- await response.close()
403
- return err({message: `reading the download failed: ${describeRequestError(chunk.error)}`})
404
- }
405
- let bytes = chunk.value
406
- if (skip > 0) {
407
- if (bytes.length <= skip) {
408
- skip -= bytes.length
409
- continue
410
- }
411
- bytes = bytes.subarray(skip)
412
- skip = 0
413
- }
414
- const written = update.write(bytes)
415
- if (!written.ok) {
416
- await response.close()
417
- return written
418
- }
419
- }
420
- return ok()
421
- }
422
-
423
- function check(options: CheckOptions = {}): Promise<CheckResult> {
424
- const enrolled = enrollment()
425
- if (enrolled === undefined) {
426
- return Promise.resolve({status: 'not-enrolled'})
427
- }
428
- return enqueue(() => runCheck(enrolled, options))
429
- }
430
-
431
- function watch(options: WatchOptions = {}): Watcher {
432
- const maybeEnrolled = enrollment()
433
- if (maybeEnrolled === undefined) {
434
- io.log('info', 'ota: device not enrolled; run `mikro ota enroll` to enable OTA updates')
435
- return {stop: () => undefined}
436
- }
437
- // Explicitly typed: the narrowing above does not reach into the hoisted
438
- // round() declaration below.
439
- const enrolled: Enrollment = maybeEnrolled
440
- const intervalMs = options.checkinIntervalMs ?? DEFAULT_INTERVAL_MS
441
- const retryMs = Math.min(options.retryAfterFailureMs ?? DEFAULT_RETRY_MS, intervalMs)
442
-
443
- let stopped = false
444
- let cancelSleep: (() => void) | undefined
445
-
446
- // ±10% so a power-cut fleet doesn't stay phase-locked on the registry.
447
- const jitterOn = options.jitter ?? true
448
- function jitter(ms: number): number {
449
- if (!jitterOn) return ms
450
- return Math.round(ms * (0.9 + io.random() * 0.2))
451
- }
452
-
453
- function pause(ms: number): Promise<void> {
454
- return new Promise((resolve) => {
455
- cancelSleep = resolve
456
- void io.sleep(jitter(ms)).then(resolve)
457
- })
458
- }
459
-
460
- async function round(): Promise<NextRound> {
461
- let teardown: Teardown | undefined
462
- if (options.beforeCheck) {
463
- let setup: Result<Teardown, unknown>
464
- try {
465
- setup = await options.beforeCheck()
466
- } catch (e) {
467
- setup = err(e)
468
- }
469
- if (!setup.ok) {
470
- io.log('warn', 'ota: beforeCheck failed; skipping this check', setup.error)
471
- return 'soon'
472
- }
473
- teardown = setup.value
474
- }
475
-
476
- // Contain OTA faults: an uncaught throw (e.g. an OOM from the native
477
- // HTTP layer under a low-heap stream) must not bubble out of the
478
- // detached loop. A crashed check is just a check that didn't complete.
479
- let outcome: CheckResult | undefined
480
- try {
481
- outcome = await enqueue(() => runCheck(enrolled, options))
482
- } catch (e) {
483
- io.log('error', 'ota: check crashed', e)
484
- }
485
-
486
- // Teardown before any restart: the round is over either way, and the
487
- // hook's invariant — teardown runs whenever setup succeeded — must not
488
- // depend on what the check found.
489
- if (teardown !== undefined) {
490
- try {
491
- await teardown()
492
- } catch (e) {
493
- io.log('warn', 'ota: teardown failed', e)
494
- }
495
- }
496
-
497
- if (outcome === undefined) return 'soon'
498
- if (outcome.status === 'staged') {
499
- if (stopped) {
500
- // stop() won the race: leave the build armed for the next natural
501
- // reboot instead of restarting under the caller.
502
- io.log('info', 'ota: update staged; watcher stopped, restart deferred')
503
- return 'later'
504
- }
505
- io.log('info', 'ota: staged; restarting to install')
506
- io.restart() // never returns
507
- }
508
- return outcome.status === 'failed' ? 'soon' : 'later'
509
- }
510
-
511
- async function loop(): Promise<void> {
512
- await pause(options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS)
513
- while (!stopped) {
514
- const next = await round()
515
- if (stopped) return
516
- await pause(next === 'later' ? intervalMs : retryMs)
517
- }
518
- }
519
-
520
- // Detached: never awaited by the caller, so the app keeps running
521
- // regardless of OTA outcome. round() contains every throw, so this
522
- // promise cannot reject.
523
- void loop()
524
-
525
- return {
526
- stop: () => {
527
- stopped = true
528
- cancelSleep?.()
529
- },
530
- }
531
- }
532
-
533
- return {check, watch}
534
- }
535
-
536
- /** Render a RequestError into prose. `DownloadFn`'s contract carries only a
537
- * `message` string forward into the `DownloadFailed` the policy reports, so
538
- * this line is all that survives to explain a failed download on serial —
539
- * include the underlying message, not just the variant name. */
540
- function describeRequestError(error: RequestError): string {
541
- return 'message' in error && error.message !== '' ? `${error.name}: ${error.message}` : error.name
542
- }
543
-
544
- /**
545
- * The name pair a check-in response carries, as `[rev, name]` (or `[rev]` when
546
- * it was cleared), or undefined when there is nothing to adopt. No `name` field
547
- * means "no change" and never "clear it": a response lost after the registry
548
- * adopted the device's own name looks exactly the same as one that never
549
- * arrived, and re-sending the pair next check-in settles it.
550
- */
551
- export function nameFrom(body: unknown): DeviceName | undefined {
552
- if (typeof body !== 'object' || body === null) return undefined
553
- const pair = (body as {name?: unknown}).name
554
- if (!Array.isArray(pair)) return undefined
555
- const rev = pair[0]
556
- if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 0) return undefined
557
- const value = pair[1]
558
- return typeof value === 'string' && value !== '' ? {rev, name: value} : {rev}
559
- }
560
-
561
- /** True when both urls share a scheme and authority. Deliberately literal: with
562
- * no URL parser here, a spelled-out default port does not compare equal. Used
563
- * to decide whether the update key may ride the download request. */
564
- export function sameOrigin(a: string, b: string): boolean {
565
- const origin = (url: string): string | undefined => {
566
- const sep = url.indexOf('://')
567
- if (sep < 0) return undefined
568
- const start = sep + 3
569
- const end = url.indexOf('/', start)
570
- const authority = end < 0 ? url.slice(start) : url.slice(start, end)
571
- return authority === '' ? undefined : (url.slice(0, start) + authority).toLowerCase()
572
- }
573
- const left = origin(a)
574
- return left !== undefined && left === origin(b)
575
- }
576
-
577
- /** True for http:// on a LAN/loopback/mDNS host: development, not the internet.
578
- * Anywhere else the scheme is not a judgement call — over http the offer's
579
- * checksum is forgeable in the same response that names it. */
580
- export function isPrivateHttp(url: string): boolean {
581
- if (!url.startsWith('http://')) return false
582
- const host = (url.slice(7).split('/')[0] ?? '').split(':')[0] ?? ''
583
- if (host === 'localhost' || host.endsWith('.local')) return true
584
- const p = host.split('.').map((s) => parseInt(s, 10))
585
- if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
586
- if (p[0] === 10 || p[0] === 127) return true
587
- if (p[0] === 192 && p[1] === 168) return true
588
- if (p[0] === 169 && p[1] === 254) return true
589
- return p[0] === 172 && p[1]! >= 16 && p[1]! <= 31
590
- }