@ham2k/extension-sdk 0.1.0 → 0.2.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/AGENTS.md ADDED
@@ -0,0 +1,139 @@
1
+ # Writing a Ham2K Logger extension
2
+
3
+ *This file describes the published `@ham2k/extension-sdk` package, as installed
4
+ in an extension project's `node_modules`. Working inside the Ham2K Logger
5
+ repository itself is a different setup — see `docs/extensions/README.md` there.*
6
+
7
+ You are building an extension for the Ham2K Logger — a TypeScript module the
8
+ app loads into a sandbox and calls through **hooks**. Extensions are where the
9
+ app's domain knowledge lives: award programs, contests, callsign lookups, spot
10
+ sources, exporters, panels.
11
+
12
+ Everything you need is in this package. `samples/` holds one worked extension
13
+ per shape — start there, with the one whose shape matches what you are
14
+ building. `docs/` is the reference, pinned to the version of the SDK installed
15
+ here; `dist/index.d.ts` is the typed surface, and is the final word wherever
16
+ prose and types disagree.
17
+
18
+ Paths like `app/lib/config/secret_policy.dart` or `app/experiments.yaml`,
19
+ mentioned in those docs, are files in the Ham2K Logger's own repository
20
+ (<https://github.com/ham2k/halo>). They are not part of your project and you
21
+ cannot read them here.
22
+
23
+ ## The shape of a project
24
+
25
+ ```
26
+ manifest.json your extension's public identity — read without running code
27
+ src/index.ts imports the manifest, calls defineExtension
28
+ build.mjs runs @ham2k/extension-tools' build preset
29
+ build/ generated: index.js + manifest.json
30
+ ```
31
+
32
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes all of it.
33
+
34
+ ## The smallest whole extension
35
+
36
+ ```ts
37
+ import { defineExtension } from '@ham2k/extension-sdk'
38
+ import type { PanelHook } from '@ham2k/extension-sdk'
39
+
40
+ import manifest from '../manifest.json'
41
+
42
+ const HelloPanel: PanelHook = {
43
+ async getPanels() {
44
+ return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
45
+ },
46
+ async render() {
47
+ return { kind: 'markdown', content: '# Hello, World!' }
48
+ },
49
+ }
50
+
51
+ defineExtension({
52
+ ...manifest,
53
+ onActivation({ registerHook }) {
54
+ registerHook('panel', { key: manifest.key, hook: HelloPanel })
55
+ },
56
+ })
57
+ ```
58
+
59
+ `manifest.json` needs at least `key`, `name`, `version`, `description`,
60
+ `category`, `api`, and the `sharedDependencies` below. The manifest is spread
61
+ into `defineExtension` rather than retyped: two sources for the same identity
62
+ drift, and the packer reads the file.
63
+
64
+ ## Building and shipping
65
+
66
+ ```sh
67
+ node build.mjs # → build/index.js
68
+ npx -p @ham2k/extension-tools h2kext-pack build -o my-extension.h2kext
69
+ ```
70
+
71
+ The packer is also the checker. It reports every problem at once and refuses
72
+ anything that would install cleanly and then misbehave where you cannot watch
73
+ it, so a clean pack is the strongest evidence available outside the app itself.
74
+ Run it whenever you have changed the manifest or the hooks you register.
75
+
76
+ `tsc --noEmit` is the other half: the hook interfaces are typed, and a hook that
77
+ typechecks is registered under a category the host knows, with the methods it
78
+ will actually call.
79
+
80
+ ## Where to read next
81
+
82
+ | Doing this | Read |
83
+ | --- | --- |
84
+ | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel) |
85
+ | Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
86
+ | A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
87
+ | Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
88
+ | Contest or award scoring, and the UI an activity contributes | `docs/hooks.md` §`scoring`, §`activity` |
89
+ | A settings screen for your extension | `docs/settings.md` |
90
+ | Any form, dialog or field the user fills in | `docs/forms.md` |
91
+ | Panel text, export filenames, ADIF fields — anything with `{{ }}` in it | `docs/templates.md` |
92
+ | Packaging, the `.h2kext` format, keys, using the host's libraries | `docs/distribution.md` |
93
+
94
+ `docs/hooks.md` is long. Read the section for the category you are registering,
95
+ not the file.
96
+
97
+ ## What the sandbox is
98
+
99
+ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
100
+ evaluating one script. Reach the outside world through `host`:
101
+
102
+ ```ts
103
+ import { host } from '@ham2k/extension-sdk'
104
+
105
+ await host.fetch(url) // https only, and only domains your manifest lists
106
+ await host.kvGet('key') // storage, namespaced to your extension
107
+ await host.kvSet('key', value)
108
+ await host.showForm(form) // ask the user something
109
+ await host.showMessage({ … }) // tell them something
110
+ host.log('…') // the app's console
111
+ ```
112
+
113
+ `dist/index.d.ts` has the rest, with the types.
114
+
115
+ ## Traps worth knowing before you write anything
116
+
117
+ - **A panel returns content, not widgets.** `{ kind: 'markdown', content }` and
118
+ its siblings. There is no way to reach the app's UI toolkit, by design.
119
+ - **`host.fetch` only reaches domains your manifest's `domains` lists**, over
120
+ https. An undeclared host is refused at the call, not at build.
121
+ - **Using a shared library without declaring it fails the build.** The app
122
+ carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
123
+ `sharedDependencies` rewrites your imports into lookups on the app's single
124
+ copy, which is worth roughly thirty times the bundle size. Don't install them.
125
+ `inline: ['liquidjs']` in your build ships your own copy, for when you need a
126
+ version the app does not carry. A scaffolded manifest already declares the
127
+ eight this SDK's own modules reach — importing anything from it pulls them
128
+ in — so deleting one from the manifest breaks a build that was working.
129
+ - **Your key is `<callsign>-<name>`**, lowercase. It namespaces your storage and
130
+ is what the app shows when it says where something came from. `ham2k-` is
131
+ reserved.
132
+ - **`host.secret()` returns null** for anything not built into the app. Degrade;
133
+ don't throw. A hook that throws takes its whole feature down.
134
+ - **Hook method names are routed by string.** The host finds your hook by its
135
+ registered key and then calls the method by name, so `renderPanel` where
136
+ `render` was meant is an extension that installs, activates, and does nothing.
137
+ This is what typing your hook as `PanelHook` (or its siblings) catches.
138
+ - **Every hook is async and crosses a bridge.** Some context reads carry a whole
139
+ log — fine when exporting, not on a per-keystroke path.
package/README.md CHANGED
@@ -26,7 +26,7 @@ const HelloPanel: PanelHook = {
26
26
  defineExtension({
27
27
  ...manifest,
28
28
  onActivation({ registerHook }) {
29
- registerHook('panel', { key: 'ki2d-hello-world', hook: HelloPanel })
29
+ registerHook('panel', { key: manifest.key, hook: HelloPanel })
30
30
  },
31
31
  })
32
32
  ```
@@ -34,9 +34,24 @@ defineExtension({
34
34
  A panel returns *content*, not widgets — which is what keeps extensions out of
35
35
  the app's UI toolkit.
36
36
 
37
- `@ham2k/extension-tools` builds and packages it into a `.h2kext` the app can
38
- install. See [the extension docs](https://github.com/ham2k/halo/tree/main/docs/extensions)
39
- for hooks, forms, templates and distribution.
37
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes
38
+ all of that and the build script; the same package packages the result into a
39
+ `.h2kext` the app can install.
40
+
41
+ ## The reference is in this package
42
+
43
+ `samples/` holds one worked extension per shape — a callsign lookup with the
44
+ credentials it needs, an award program, a contest and its scorer, an HTML
45
+ panel. Each builds and packs; each says in its header what it leaves out.
46
+
47
+ `AGENTS.md` beside this file is the map — the manifest, the sandbox's limits,
48
+ and which of `docs/` answers what. `docs/` holds the references themselves:
49
+ every hook category, forms, settings panels, templates, and the bundle format.
50
+
51
+ They travel in the package rather than being linked to, so they are readable
52
+ offline and describe the version installed here rather than whatever the
53
+ repository says today. If an agent is writing your extension, point it at
54
+ `node_modules/@ham2k/extension-sdk/AGENTS.md` — `h2kext-init` does that for you.
40
55
 
41
56
  ## About the peer dependencies
42
57
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.1.0
1
+ // @ham2k/extension-sdk 0.2.0
2
2
  export type CallInfo = {
3
3
  call: string;
4
4
  baseCall?: string;
@@ -0,0 +1,279 @@
1
+ # Distributing an extension — the `.h2kext` bundle
2
+
3
+ An extension can be handed to someone as a single file they install into the
4
+ app: no registry, no app-store release. This describes the format and the tool
5
+ that writes one.
6
+
7
+ Writing the extension in the first place is the dev loop's job —
8
+ [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md).
9
+
10
+ ## The format
11
+
12
+ A `.h2kext` is a **zip**:
13
+
14
+ ```
15
+ manifest.json required — identity and capabilities
16
+ index.js required — your extension, built
17
+ assets/ optional — icons, data, licence text
18
+ ```
19
+
20
+ Zip rather than tar.gz for one reason above the others: the host must read
21
+ `manifest.json` **without** running any JS and often without unpacking the
22
+ rest — to list an installed-but-disabled extension, and to show someone what a
23
+ file is asking for *before* they agree to install it. A zip's central
24
+ directory makes that a seek; a tar.gz is a solid stream that must be
25
+ decompressed from the beginning every time. Signed-archive precedent (`.vsix`,
26
+ `.xpi`, `.crx`, `.jar`, `.epub` are all zip) and being openable by
27
+ double-click on macOS and Windows settle the rest.
28
+
29
+ ### manifest.json
30
+
31
+ The same manifest a built-in extension carries, plus `api`:
32
+
33
+ ```json
34
+ {
35
+ "key": "w7abc-notes",
36
+ "name": "W7ABC Notes",
37
+ "version": "1.2.3",
38
+ "description": "Notes about the stations I work",
39
+ "category": "dashboard",
40
+ "api": 1,
41
+ "domains": ["notes.example.org"],
42
+ "icon": "note-text"
43
+ }
44
+ ```
45
+
46
+ `api` is the extension API the bundle was built against. The host refuses one
47
+ it does not speak, so an older app meeting a newer bundle says so instead of
48
+ failing somewhere deep in a hook.
49
+
50
+ Two fields are **refused** in a distributed bundle, and it is worth
51
+ understanding why:
52
+
53
+ - **No `category`** makes an extension *core* to the host: always enabled, and
54
+ never listed in the Extensions panel. That is a status the app grants its own
55
+ extensions. A distributed bundle claiming it would be one the user could
56
+ neither see nor turn off.
57
+ - **`experiments`** gates availability against the *app's* experiment catalog.
58
+ A key the host doesn't define hides the extension permanently, with nothing
59
+ in the UI to explain it.
60
+
61
+ ## Using the host's libraries
62
+
63
+ The host carries a set of libraries and hands every extension the same
64
+ instances, so a bundle does not have to ship its own copies:
65
+
66
+ ```
67
+ @ham2k/lib-callsigns @ham2k/lib-qson-adif
68
+ @ham2k/lib-country-files @ham2k/lib-qson-cabrillo
69
+ @ham2k/lib-cqmag-data @ham2k/lib-qson-tools
70
+ @ham2k/lib-dxcc-data i18next
71
+ @ham2k/lib-format-tools liquidjs
72
+ @ham2k/lib-geo-tools
73
+ @ham2k/lib-operation-data
74
+ ```
75
+
76
+ It matters more than it sounds. The same extension, built both ways:
77
+
78
+ | | bundle | packaged |
79
+ |---|---|---|
80
+ | dependencies inlined | 1430 KB | 218 KB |
81
+ | using the host's | 22 KB | **7 KB** |
82
+
83
+ **Declare what you need.** The host carries whatever version *that build*
84
+ shipped with, and you have no say in which app your bundle lands in. So the
85
+ manifest states the ranges you work with:
86
+
87
+ ```json
88
+ "sharedDependencies": {
89
+ "@ham2k/lib-callsigns": "^1.0.0",
90
+ "liquidjs": "^10.0.0"
91
+ }
92
+ ```
93
+
94
+ The host checks those before loading, and refuses a bundle it cannot satisfy —
95
+ which is a message naming the library and both versions, instead of a call
96
+ failing somewhere deep in a hook against a major you never tested.
97
+
98
+ The packer refuses a bundle that reaches for a shared module **without**
99
+ declaring it, and one that declares a package the host doesn't carry. Anything
100
+ outside that list you bundle yourself, as normal.
101
+
102
+ Which versions a particular build carries are in its
103
+ `assets/extensions/index.json`, under `sharedModules`.
104
+
105
+ Emitting the lookups is the build preset's job — see **Building** below.
106
+
107
+ ## Building
108
+
109
+ ```sh
110
+ npm install @ham2k/extension-sdk
111
+ npm install --save-dev @ham2k/extension-tools esbuild
112
+ ```
113
+
114
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes a working one of everything
115
+ below — manifest, source, build script — so the rest of this section is what to
116
+ change rather than what to type.
117
+
118
+ ```js
119
+ import { build } from 'esbuild'
120
+ import { buildExtension } from '@ham2k/extension-tools'
121
+
122
+ await buildExtension(build, { dir: import.meta.dirname })
123
+ ```
124
+
125
+ That reads `manifest.json`, compiles `src/index.ts` into `build/index.js`, and
126
+ copies the manifest alongside it, ready for the packer. The esbuild settings it
127
+ applies are requirements of the runtime rather than preferences: one IIFE
128
+ because the sandbox evaluates a script and has no module loader, `neutral`
129
+ because there is no Node and no DOM, and `es2020` because that is what
130
+ QuickJS-NG speaks.
131
+
132
+ esbuild is yours to bring — the preset takes your `build` function rather than
133
+ importing its own, so there is only ever one copy of it.
134
+
135
+ Everything `sharedDependencies` names becomes a lookup on the host's instance.
136
+ Anything else you use is bundled, as normal.
137
+
138
+ **Using one of the host's libraries without declaring it fails the build**,
139
+ naming the library and the two ways out. That is deliberate: the alternative is
140
+ a bundle that silently carries its own copy — thirty times the size, with its
141
+ own module state — and nothing anywhere that says so. If the second copy is
142
+ what you want, which is how you use a version the host does not carry, say so:
143
+
144
+ ```js
145
+ await buildExtension(build, { dir: import.meta.dirname, inline: ['liquidjs'] })
146
+ ```
147
+
148
+ `extensions/samples/hello-world` is the shortest complete example. Declaring
149
+ what it uses rather than carrying it took the packaged bundle from 185 KB to
150
+ **4 KB**.
151
+
152
+ The preset also settles a trap every author would otherwise hit: the SDK
153
+ reaches `liquidjs`, whose `main`/`module` are its **Node** builds, and
154
+ `platform: 'neutral'` ignores the `browser` field that would pick the right
155
+ one, so a hand-written esbuild config fails on unresolvable Node built-ins
156
+ before it compiles anything — even for an extension that never renders a
157
+ template.
158
+
159
+ ## Packaging
160
+
161
+ ```sh
162
+ node extensions/tools/h2kext-pack.mjs <directory> [-o out.h2kext]
163
+ ```
164
+
165
+ The directory holds your built `index.js`, your `manifest.json`, and optionally
166
+ `assets/`. The packer **packages; it does not compile** — building is yours,
167
+ which is what lets this work outside this repo.
168
+
169
+ It reports every problem at once rather than one per run:
170
+
171
+ ```
172
+ h2kext-pack: ./build is not ready to package:
173
+ manifest.json: missing required field 'name'
174
+ manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
175
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 1)
176
+ ```
177
+
178
+ `--force-name` waives the naming convention below. It exists for Ham2K's own
179
+ packaging, which builds the extensions that ship inside the app under bare keys
180
+ and the reserved prefix; it does not waive what the runtime needs of a key.
181
+
182
+ `extensions/tools/h2kext.mjs` holds the format and its rules, and
183
+ `h2kext-build.mjs` the build preset. Both are free of any dependency on the
184
+ rest of this repo, and ship together as **`@ham2k/extension-tools`** — the
185
+ toolchain half, separate from the **`@ham2k/extension-sdk`** an extension
186
+ imports and bundles.
187
+
188
+ Two things about the SDK package are load-bearing rather than incidental, and
189
+ `extensions/sdk/build.mjs` asserts both:
190
+
191
+ - It ships as **one file per module**, not one bundle. Rolled into a single
192
+ file, your bundler can no longer tell which parts your extension reaches, and
193
+ Hello World comes out five times larger carrying an ADIF importer it cannot
194
+ call.
195
+ - Its JavaScript keeps the shared libraries as **bare specifiers**. The build
196
+ preset can only rewrite an import it can see; resolved inside the SDK, they
197
+ would be gone before it ran, and every extension would carry private copies
198
+ again.
199
+
200
+ `kernel.ts` lives in the SDK's source directory and is never published. It is
201
+ the host — it defines the runtime and holds the one instance of every shared
202
+ library that extensions look up. This repo's own
203
+ build uses the same plugin, which is what keeps the path you take from being
204
+ the untested one.
205
+
206
+ ## Keys
207
+
208
+ An extension key is **`<yourcallsign>-<name>`**:
209
+
210
+ ```
211
+ ki2d-my-clock w7abc-notes 2e0abc-contest-helper
212
+ ```
213
+
214
+ Lowercase, letters digits and hyphens. The callsign is a namespace every author
215
+ of this software already holds — globally unique, free, and already meaningful
216
+ to everyone here — so two people who never met cannot ship the same key. The
217
+ packer refuses anything else.
218
+
219
+ `ham2k-` is reserved for Ham2K's own extensions, and refused to everyone else.
220
+ There is no signing yet, so anyone could claim it, and the key is what the app
221
+ shows when it says where a hook came from.
222
+
223
+ The extensions built into the app use bare keys (`pota`, `sota`, …). Those are
224
+ reserved too: hook identity has to stay unambiguous, and build-secret
225
+ visibility is by key prefix, so an installed extension calling itself `sota`
226
+ would be asking to read `SOTA_*`. The app refuses any collision at install —
227
+ the packer cannot, because a standalone tool has no way to know what the app it
228
+ will be installed into contains.
229
+
230
+ ## What an installed extension does not get
231
+
232
+ **Build secrets.** `host.secret()` returns null for an installed extension
233
+ whatever key it claims, exactly as it does for a dev-served one: visibility
234
+ follows where the code came from, not what it calls itself. A feature that
235
+ needs one degrades the same way it does in a build without that value
236
+ configured.
237
+
238
+ The sandbox is otherwise the same one the built-in extensions run in — no
239
+ filesystem, no network beyond the `domains` the manifest declares and the user
240
+ approved, no timers.
241
+
242
+ ## Installing one
243
+
244
+ **Settings → Extensions → Install from file…**, pick the `.h2kext`, and agree
245
+ to what it asks for. The runtime restarts with it loaded, and the extension
246
+ appears in its own category alongside the app's own, marked *Installed* and
247
+ with a way to remove it again.
248
+
249
+ What the operator is shown before anything is written is the whole point of
250
+ the format being a zip: the file is opened and read, and only then unpacked.
251
+
252
+ Installing a bundle whose key is already installed **updates it in place**.
253
+ What it stored survives — an update is not a reinstall, and re-entering
254
+ credentials every time is how people learn not to update.
255
+
256
+ **Every install asks, including an update that wants nothing new.** Comparing
257
+ capabilities and staying quiet when they had not grown would let a version
258
+ through on the strength of its key alone, and nothing about the *code* behind
259
+ that key is compared — a bundle can change completely and still ask for the
260
+ same things. That may be worth revisiting once a bundle can be shown to come
261
+ from whoever it claims; it is not worth it while a key is all there is.
262
+
263
+ Removing an extension removes what it stored with it.
264
+
265
+ ## What is checked at install
266
+
267
+ Beyond what the packer already refused:
268
+
269
+ - the key is not one the app ships, and does not begin `ham2k-`;
270
+ - the `api` version is one this build speaks;
271
+ - every `sharedDependencies` range is satisfied by what this build carries;
272
+ - entry names are sanitised on extract, so `../` in one writes nothing.
273
+
274
+ Each has its own message. A bundle that cannot be installed says why.
275
+
276
+ ## Native only, for now
277
+
278
+ Installed bundles are files on disk. On web the app runs from its own assets
279
+ and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md).