@ham2k/extension-tools 0.5.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.mjs CHANGED
@@ -28,14 +28,19 @@ import yazl from "yazl"
28
28
  ///
29
29
  /// Raised when a bundle using something new could not work at all on an app
30
30
  /// without it — not for every addition. 2 adds `host.webSocket` and the
31
- /// manifest's `webSockets` grant.
32
- export const H2KEXT_API_VERSION = 2
31
+ /// manifest's `webSockets` grant; 3 adds timers and `onShow`/`onHide`.
32
+ export const H2KEXT_API_VERSION = 3
33
33
 
34
34
  /// The first API with `host.webSocket`. A manifest declaring `webSockets`
35
35
  /// under an older one is refused: an older app would install it and fail only
36
36
  /// when it tried to connect.
37
37
  export const WEB_SOCKETS_API_VERSION = 2
38
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
43
+
39
44
  /// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
40
45
  /// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
41
46
  /// authors who never met from choosing the same key.
@@ -127,6 +132,7 @@ export const SHARED_MODULES_BY_API = {
127
132
  ],
128
133
  }
129
134
  SHARED_MODULES_BY_API[2] = SHARED_MODULES_BY_API[1]
135
+ SHARED_MODULES_BY_API[3] = SHARED_MODULES_BY_API[2]
130
136
 
131
137
  /// What a scaffolded extension declares on day one: the eight modules the SDK
132
138
  /// barrel itself reaches, so a project that has written no code yet still
@@ -254,6 +260,31 @@ function calendarDateProblem(value) {
254
260
  return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day ? null : "is not a day that exists"
255
261
  }
256
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
+
257
288
  /// Everything wrong with a candidate bundle, as a list — an author fixing one
258
289
  /// problem per run is an author running this five times.
259
290
  export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
@@ -344,6 +375,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
344
375
  // parse as "does not gate", so a malformed one would ship as worldwide.
345
376
  for (const problem of validateRelevance(manifest.relevance)) say(problem)
346
377
 
378
+ for (const problem of validateAdifFieldsScope(manifest)) say(problem)
379
+
347
380
  return problems
348
381
  }
349
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",