@ham2k/extension-tools 0.4.0 → 0.6.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/h2kext-build.mjs CHANGED
@@ -24,7 +24,7 @@ import { copyFile, mkdir, readFile } from "node:fs/promises"
24
24
  import { createRequire } from "node:module"
25
25
  import { dirname, join, resolve } from "node:path"
26
26
 
27
- import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from "./h2kext.mjs"
27
+ import { H2KEXT_API_VERSION, MANIFEST_ENTRY, TIMERS_API_VERSION, sharedModulesFor } from "./h2kext.mjs"
28
28
 
29
29
  /// What an extension's own `build.mjs` says, for everything that has to write
30
30
  /// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
@@ -202,14 +202,51 @@ function liquidBrowserBuild(fromDir) {
202
202
  }
203
203
  }
204
204
 
205
+ /// Where the author's SDK keeps the file that stands in for the timer globals
206
+ /// (sdk/src/timers.ts), or null if it has none. Resolved from the CALLER's
207
+ /// directory for the same reason as [liquidBrowserBuild]: it must be the very
208
+ /// SDK copy the extension imports, or the bundle carries a second one whose
209
+ /// timers were never handed an identity.
210
+ ///
211
+ /// Missing is not an error: a bundle that never schedules anything builds as
212
+ /// it always did, and one that does fails where it calls `setTimeout`. But an
213
+ /// SDK that is installed and simply too old gets a warning, since the manifest
214
+ /// has just claimed an API it cannot fully build.
215
+ function sdkTimersFile(fromDir) {
216
+ // Resolved under this process's conditions, while esbuild resolves the
217
+ // extension's own SDK import under its defaults. The two agree for an
218
+ // author's build, which passes no custom condition; this repo's own
219
+ // extensions pass [timers] explicitly rather than rely on it.
220
+ try {
221
+ return createRequire(resolve(fromDir, "noop.js")).resolve("@ham2k/extension-sdk/timers")
222
+ } catch (e) {
223
+ if (e.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
224
+ console.warn(
225
+ "warning: extension API 3 wants an @ham2k/extension-sdk with timers — this one has none, so a bare setTimeout in the bundle is left undefined",
226
+ )
227
+ return null
228
+ }
229
+ if (e.code === "MODULE_NOT_FOUND") return null
230
+ throw e
231
+ }
232
+ }
233
+
205
234
  /// The esbuild options a Ham2K extension bundle is built with.
206
235
  ///
207
236
  /// Everything here is a requirement of the runtime rather than a preference:
208
237
  /// one IIFE because the sandbox evaluates a script and has no module loader;
209
238
  /// `platform: 'neutral'` because there is no node and no DOM; es2020 because
210
239
  /// that is what QuickJS-NG speaks.
211
- export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.cwd(), alias = {}, inline = [], ...rest } = {}) {
240
+ ///
241
+ /// The sandbox has no timer globals of its own, so from extension API 3 the
242
+ /// bundle's `setTimeout` and friends are INJECTED: every bare use, the npm
243
+ /// dependencies' included, becomes the SDK's, which runs on the extension's
244
+ /// own timers. [timers] names the file to inject when the SDK is not the
245
+ /// package the caller's directory resolves, and `false` injects nothing — the
246
+ /// kernel, which IS the implementation.
247
+ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.cwd(), alias = {}, inline = [], timers, ...rest } = {}) {
212
248
  const liquid = liquidBrowserBuild(dir)
249
+ const timersFile = timers !== false && (manifest.api ?? H2KEXT_API_VERSION) >= TIMERS_API_VERSION ? (timers ?? sdkTimersFile(dir)) : null
213
250
  return {
214
251
  entryPoints: [entry],
215
252
  ...(outfile ? { outfile } : { write: false }),
@@ -225,6 +262,7 @@ export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.c
225
262
  ...alias,
226
263
  },
227
264
  plugins: [sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }), ...(rest.plugins ?? [])],
265
+ inject: [...(timersFile ? [timersFile] : []), ...(rest.inject ?? [])],
228
266
  }
229
267
  }
230
268
 
package/h2kext-pack.mjs CHANGED
@@ -64,6 +64,9 @@ try {
64
64
  // person installing it will be asked to approve.
65
65
  console.log(` may reach: ${result.manifest.domains.join(", ")}`)
66
66
  }
67
+ if (result.manifest.webSockets?.length) {
68
+ console.log(` may hold connections to: ${result.manifest.webSockets.join(", ")}`)
69
+ }
67
70
  } catch (e) {
68
71
  if (e instanceof PackError) {
69
72
  console.error(`h2kext-pack: ${dir} is not ready to package:\n${e.message}`)
package/h2kext.mjs CHANGED
@@ -25,7 +25,21 @@ import yazl from "yazl"
25
25
  /// The extension API this format describes. A bundle declares the version it
26
26
  /// was built against and the host refuses one it does not speak, so an old app
27
27
  /// meeting a newer bundle says so instead of failing at a call site.
28
- export const H2KEXT_API_VERSION = 1
28
+ ///
29
+ /// Raised when a bundle using something new could not work at all on an app
30
+ /// without it — not for every addition. 2 adds `host.webSocket` and the
31
+ /// manifest's `webSockets` grant; 3 adds timers and `onShow`/`onHide`.
32
+ export const H2KEXT_API_VERSION = 3
33
+
34
+ /// The first API with `host.webSocket`. A manifest declaring `webSockets`
35
+ /// under an older one is refused: an older app would install it and fail only
36
+ /// when it tried to connect.
37
+ export const WEB_SOCKETS_API_VERSION = 2
38
+
39
+ /// The first API with timers. The build points a bundle's bare `setTimeout`
40
+ /// and friends at the SDK's only from here on: an older app's kernel hands an
41
+ /// extension no timers, and a bundle declaring less says it runs there.
42
+ export const TIMERS_API_VERSION = 3
29
43
 
30
44
  /// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
31
45
  /// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
@@ -117,6 +131,8 @@ export const SHARED_MODULES_BY_API = {
117
131
  "liquidjs",
118
132
  ],
119
133
  }
134
+ SHARED_MODULES_BY_API[2] = SHARED_MODULES_BY_API[1]
135
+ SHARED_MODULES_BY_API[3] = SHARED_MODULES_BY_API[2]
120
136
 
121
137
  /// What a scaffolded extension declares on day one: the eight modules the SDK
122
138
  /// barrel itself reaches, so a project that has written no code yet still
@@ -244,6 +260,31 @@ function calendarDateProblem(value) {
244
260
  return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
245
261
  }
246
262
 
263
+ /// Everything wrong with how a manifest scopes its `adifFields` hook, as a
264
+ /// list — empty for one with no such hook.
265
+ ///
266
+ /// The full ADIF export asks an extension for fields only about a contact
267
+ /// carrying a reference that extension answers for, unless the manifest says
268
+ /// `adifFieldsScope: "always"`. A bundle that adds fields and declares neither
269
+ /// a `ref:` type nor the opt-in writes nothing to that file, and nothing at
270
+ /// export time says so. The opt-in is a manifest field rather than something
271
+ /// on the hook so that this check can see it without running the bundle.
272
+ ///
273
+ /// Shared with build-config.mjs's manifest checks, like [validateRelevance].
274
+ export function validateAdifFieldsScope(manifest) {
275
+ const scope = manifest?.adifFieldsScope
276
+ if (scope !== undefined && scope !== "always") {
277
+ return [`'adifFieldsScope' must be "always", or left out to scope 'adifFields' to the references this extension handles`]
278
+ }
279
+ const hooks = Array.isArray(manifest?.hooks) ? manifest.hooks : []
280
+ if (!hooks.includes("adifFields") || scope === "always") return []
281
+ if (hooks.some((hook) => typeof hook === "string" && hook.startsWith("ref:"))) return []
282
+ return [
283
+ `'hooks' lists 'adifFields' but no 'ref:' type — the full ADIF export asks an extension only about contacts carrying a reference it handles, ` +
284
+ `so these fields would never be written; declare the 'ref:' types, or set "adifFieldsScope": "always" (docs/extensions/hooks.md)`,
285
+ ]
286
+ }
287
+
247
288
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
248
289
  /// problem per run is an author running this five times.
249
290
  export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
@@ -273,6 +314,14 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
273
314
  say(`'api' is ${manifest.api}, but this packer only knows ${H2KEXT_API_VERSION}`)
274
315
  }
275
316
 
317
+ if (manifest.webSockets !== undefined) {
318
+ if (!Array.isArray(manifest.webSockets) || manifest.webSockets.some((host) => typeof host !== "string")) {
319
+ say("'webSockets' must be a list of hosts")
320
+ } else if (manifest.webSockets.length && Number.isInteger(manifest.api) && manifest.api < WEB_SOCKETS_API_VERSION) {
321
+ say(`'webSockets' needs 'api' ${WEB_SOCKETS_API_VERSION} or later — an app older than that would install this and fail only when it connects`)
322
+ }
323
+ }
324
+
276
325
  // An extension with no category is CORE to the host: always enabled, and
277
326
  // never listed in the Extensions panel. That is a status the app grants its
278
327
  // own extensions, not one a distributed bundle may claim for itself — a user
@@ -326,6 +375,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
326
375
  // parse as "does not gate", so a malformed one would ship as worldwide.
327
376
  for (const problem of validateRelevance(manifest.relevance)) say(problem)
328
377
 
378
+ for (const problem of validateAdifFieldsScope(manifest)) say(problem)
379
+
329
380
  return problems
330
381
  }
331
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",