@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,299 +0,0 @@
1
- import {err, ok} from 'mikro/result'
2
-
3
- import type {Result} from '../result/types.js'
4
- import type {
5
- ApplyOutcome,
6
- DownloadFn,
7
- InstallOptions,
8
- InstallOutcome,
9
- Offer,
10
- Ota,
11
- OtaDownloadError,
12
- OtaError,
13
- OtaInstallError,
14
- RunningBuild,
15
- Update,
16
- } from './types.js'
17
-
18
- /** The native:mikro/ota contract (ESP-only C module; stubbed on host). */
19
- export interface NativeOta {
20
- stageBegin(
21
- checksum: string,
22
- size: number,
23
- ): {ok: true; resumeOffset: number} | {ok: false; error: string}
24
- // No `kind` here, unlike stageFinish: every write failure is a storage
25
- // problem and maps to StagingFull, so there is nothing to discriminate on.
26
- stageWrite(bytes: Uint8Array): {ok: true} | {ok: false; error: string}
27
- stageFinish(
28
- trialBoots: number,
29
- requireConfirm: boolean,
30
- installNow: boolean,
31
- ): {ok: true} | {ok: false; error: string; kind: 'corrupt' | 'transient' | 'oom'}
32
- stageAbort(): void
33
- markValid(): void
34
- revert(): {ok: true} | {ok: false; error: string}
35
- running(): {checksum?: string; trial: boolean}
36
- reconcile(): {
37
- installed?: string
38
- reverted: boolean
39
- diagnostic?: {reason: string; detail?: string}
40
- }
41
- }
42
-
43
- /** Crash-loop-safe policy state, persisted to NVS with short keys. */
44
- export interface OtaStore {
45
- getUrl(): string | undefined
46
- setUrl(url: string): void
47
- /** Checksum the retry budget is currently counting against. */
48
- getAttempt(): string | undefined
49
- setAttempt(checksum: string): void
50
- getTries(): number
51
- setTries(n: number): void
52
- getBad(): string | undefined
53
- setBad(checksum: string): void
54
- /** True while an install attempt is running. Still true at boot means the
55
- * last one never returned, i.e. it crashed the device. */
56
- getInFlight(): boolean
57
- setInFlight(value: boolean): void
58
- }
59
-
60
- interface OtaDeps {
61
- native: NativeOta
62
- store: OtaStore
63
- /** Live app version from /app/package.json, or undefined if unreadable. */
64
- readAppVersion(): string | undefined
65
- /** The check-in bearer: the device update key from the system store, or
66
- * undefined on an un-enrolled device. */
67
- bearer(): string | undefined
68
- /** The registry url the update key was issued against, or undefined. */
69
- registry(): string | undefined
70
- }
71
-
72
- /** Number of staging attempts for one url before a checksum is abandoned. */
73
- const MAX_TRIES = 3
74
-
75
- /** Validate an untrusted registry value into an `Offer`, or `undefined`. */
76
- export function parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined {
77
- // null/undefined is the registry's "no update available" signal, not a
78
- // malformed offer, so return quietly without a warning.
79
- if (raw === null || raw === undefined) return undefined
80
- const reject = (reason: string): undefined => {
81
- // eslint-disable-next-line no-console
82
- console.warn(`ota: ignoring offer (${reason})`)
83
- return undefined
84
- }
85
- if (typeof raw !== 'object') return reject('not an object')
86
- const o = raw as Record<string, unknown>
87
- // No url is "no update", not a malformed offer: a check-in with nothing newer
88
- // still returns a body when the registry has something else to say — a name to
89
- // adopt, say — and the reference client reads that before parseOffer. Warn
90
- // only when a url is present but unusable, so a dashboard rename that coincides
91
- // with "up to date" does not log a misleading warning on every device.
92
- if (o.url === undefined) return undefined
93
- if (typeof o.url !== 'string') return reject('missing url')
94
- // https is required so the device never downloads executable bytecode over
95
- // plaintext; allowInsecure (dev only) also permits http for local registries.
96
- const schemeOk =
97
- o.url.startsWith('https://') || (opts?.allowInsecure === true && o.url.startsWith('http://'))
98
- // Only the path has to name a .tgz. A registry is free to hang a query on the
99
- // url — a signed-url registry puts an expiry and signature there — and the
100
- // device treats the whole string as opaque, so rejecting on the query would
101
- // forbid that for no gain.
102
- const pathEnd = o.url.search(/[?#]/)
103
- const urlPath = pathEnd < 0 ? o.url : o.url.slice(0, pathEnd)
104
- if (!schemeOk || !urlPath.endsWith('.tgz')) {
105
- return reject(opts?.allowInsecure ? 'url must be an http(s) .tgz' : 'url must be an https .tgz')
106
- }
107
- if (typeof o.checksum !== 'string' || o.checksum.length === 0) return reject('missing checksum')
108
- // Must be a positive integer: a negative size fails the first write with
109
- // TooLarge, and 0 disables the cap on both sides, leaving the download
110
- // unbounded.
111
- if (typeof o.size !== 'number' || !Number.isInteger(o.size) || o.size <= 0) {
112
- return reject('invalid size')
113
- }
114
- // The url's host is not checked. The offer arrives in an authenticated
115
- // check-in response from the enrolled registry, so the registry is trusted to
116
- // name where the build lives — a CDN or object store on another host, a signed
117
- // url with its own query. Integrity is the checksum, verified over the whole
118
- // download before install, so a wrong host yields a failed install, never a
119
- // bad one; and the update key is the caller's to confine (the reference client
120
- // attaches it only when the url is same-origin with the registry).
121
- return {url: o.url, checksum: o.checksum, size: o.size}
122
- }
123
-
124
- function makeUpdate(native: NativeOta, size: number | undefined, resumeOffset: number): Update {
125
- let written = resumeOffset
126
- return {
127
- resumeOffset,
128
- write(bytes) {
129
- if (size !== undefined && written + bytes.length > size) {
130
- return err({name: 'TooLarge' as const, message: `build exceeds offered size ${size}`})
131
- }
132
- const r = native.stageWrite(bytes)
133
- if (!r.ok) return err({name: 'StagingFull' as const, message: r.error})
134
- written += bytes.length
135
- return ok()
136
- },
137
- finish(options) {
138
- const r = native.stageFinish(
139
- options?.trialBoots ?? 1,
140
- options?.requireConfirm ?? false,
141
- options?.install === 'now',
142
- )
143
- if (r.ok) return ok()
144
- return err({name: 'InstallFailed' as const, kind: r.kind, message: r.error})
145
- },
146
- abort() {
147
- native.stageAbort()
148
- },
149
- }
150
- }
151
-
152
- /** True if the failure means the bytes are bad and must not be retried. */
153
- function isAbandon(e: OtaError): boolean {
154
- return e.name === 'InstallFailed' && e.kind === 'corrupt'
155
- }
156
-
157
- export function createOta(deps: OtaDeps): Ota {
158
- const {native, store, readAppVersion} = deps
159
-
160
- function beginUpdate(options: {checksum: string; size: number}) {
161
- const r = native.stageBegin(options.checksum, options.size)
162
- if (!r.ok) return err({name: 'StagingFailed' as const, message: r.error})
163
- return ok(makeUpdate(native, options.size, r.resumeOffset))
164
- }
165
- // beginUpdate stays internal: staging is only ever driven through the policy.
166
-
167
- function reconcile(): InstallOutcome {
168
- // Give the budget back once per boot. Everything it counts is transient
169
- // (OOM, a truncated download), and a reboot is the one signal available
170
- // here that conditions may have changed — there is no clock to back off
171
- // against. Without this the budget is a permanent latch: three OOM
172
- // failures would strand the device on the old build forever, even once
173
- // free heap recovered.
174
- //
175
- // Unless the last attempt crashed. A native OOM panics into a restart, so
176
- // an unconditional reset zeroes the count on exactly the failure the budget
177
- // exists to bound, and the device reboots into the same attempt forever.
178
- // The in-flight flag is still set only in that case, so the crashed attempt
179
- // keeps its bump and MAX_TRIES eventually binds.
180
- if (store.getInFlight()) store.setInFlight(false)
181
- else store.setTries(0)
182
- const r = native.reconcile()
183
- const out: InstallOutcome = {reverted: r.reverted}
184
- if (r.installed !== undefined) out.installed = r.installed
185
- if (r.diagnostic !== undefined) out.lastInstall = r.diagnostic
186
- return out
187
- }
188
-
189
- function running(): RunningBuild {
190
- const r = native.running()
191
- const out: RunningBuild = {trial: r.trial}
192
- if (r.checksum !== undefined) out.checksum = r.checksum
193
- const v = readAppVersion()
194
- if (v !== undefined) out.version = v
195
- return out
196
- }
197
-
198
- function revert(): Result<void, OtaInstallError> {
199
- const r = native.revert()
200
- if (r.ok) return ok()
201
- return err({name: 'InstallFailed' as const, kind: 'transient' as const, message: r.error})
202
- }
203
-
204
- async function applyOffer(
205
- offer: Offer,
206
- download: DownloadFn,
207
- options?: InstallOptions,
208
- ): Promise<Result<ApplyOutcome, OtaError>> {
209
- const run = native.running()
210
- // (a) a trial is unresolved. Reported distinctly because the caller's
211
- // response differs from every other skip: the build on trial still needs
212
- // confirming, and treating this like "nothing to do" lets the trial lapse
213
- // and roll back a healthy build just because a newer one was published.
214
- if (run.trial) return ok('trial-pending')
215
- // (b) already running this build
216
- if (offer.checksum === run.checksum) return ok('current')
217
- // (d) device has given up on this build
218
- if (offer.checksum === store.getBad()) return ok('abandoned')
219
- // (e) retry budget, keyed on url *and* checksum. Exhausting it only stops
220
- // attempts against this exact build at this exact url; the checksum is not
221
- // abandoned, because everything counted here is transient by construction
222
- // (a corrupt build is abandoned at (h) instead). Marking it bad would be
223
- // unrecoverable: check (d) runs before this, so re-publishing the same
224
- // build at a fresh url could never revive it, and nothing clears `bad`. A
225
- // device on flaky wifi would permanently lose a good build after three
226
- // failed downloads.
227
- //
228
- // The checksum has to be part of the key because a registry that serves a
229
- // stable url ("/latest.tgz", re-pointed on publish) would otherwise never
230
- // reset: three failures against the old build would skip every future one
231
- // at that url, permanently, since the reset on success is unreachable.
232
- if (offer.url !== store.getUrl() || offer.checksum !== store.getAttempt()) {
233
- store.setUrl(offer.url)
234
- store.setAttempt(offer.checksum)
235
- store.setTries(0)
236
- }
237
- const tries = store.getTries()
238
- if (tries >= MAX_TRIES) return ok('exhausted')
239
- // (f) bump before the attempt, so a crash mid-attempt still counts — the
240
- // flag is what makes that true across a reboot (see reconcile). The finally
241
- // clears it on every ordinary exit, and deliberately does not run when the
242
- // attempt takes the device down with it.
243
- store.setTries(tries + 1)
244
- store.setInFlight(true)
245
- try {
246
- return await attempt(offer, download, options)
247
- } finally {
248
- store.setInFlight(false)
249
- }
250
- }
251
-
252
- async function attempt(
253
- offer: Offer,
254
- download: DownloadFn,
255
- options?: InstallOptions,
256
- ): Promise<Result<ApplyOutcome, OtaError>> {
257
- // (g) stage, download, verify
258
- const begun = beginUpdate({checksum: offer.checksum, size: offer.size})
259
- if (!begun.ok) return err(begun.error)
260
- const update = begun.value
261
- // The download callback is the app's transport code. A failure there (network,
262
- // a write rejection) is transient: keep the bumped tries so it retries next time,
263
- // and do NOT abandon the checksum (that is only for a corrupt build).
264
- const downloaded = await download(update)
265
- if (!downloaded.ok) {
266
- update.abort()
267
- const downloadError: OtaDownloadError = {
268
- name: 'DownloadFailed',
269
- message: downloaded.error.message,
270
- }
271
- return err(downloadError)
272
- }
273
- // (h) a corrupt build is the one thing worth abandoning: the same bytes will
274
- // fail identically forever. Abort first, or the verified-bad staging file
275
- // (up to the whole build) sits on the app partition until some later offer
276
- // happens to reclaim it.
277
- const finished = update.finish(options)
278
- if (!finished.ok) {
279
- if (isAbandon(finished.error)) {
280
- update.abort()
281
- store.setBad(offer.checksum)
282
- }
283
- return err(finished.error)
284
- }
285
- store.setTries(0)
286
- return ok('staged')
287
- }
288
-
289
- return {
290
- reconcile,
291
- running,
292
- parseOffer,
293
- applyOffer,
294
- confirm: () => native.markValid(),
295
- revert,
296
- bearer: deps.bearer,
297
- registry: deps.registry,
298
- }
299
- }