@ham2k/extension-tools 0.1.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/README.md +49 -0
- package/h2kext-build.mjs +208 -0
- package/h2kext-pack.mjs +73 -0
- package/h2kext.mjs +311 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @ham2k/extension-tools
|
|
2
|
+
|
|
3
|
+
Build and package extensions for the [Ham2K Logger](https://ham2k.com).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install --save-dev @ham2k/extension-tools esbuild
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Building
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { build } from 'esbuild'
|
|
13
|
+
import { buildExtension } from '@ham2k/extension-tools'
|
|
14
|
+
|
|
15
|
+
await buildExtension(build, { dir: import.meta.dirname })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Reads your `manifest.json`, compiles `src/index.ts` into `build/index.js`, and
|
|
19
|
+
copies the manifest alongside it. The esbuild settings it applies are
|
|
20
|
+
requirements of the runtime rather than preferences: one IIFE because the
|
|
21
|
+
sandbox evaluates a script and has no module loader, `neutral` because there is
|
|
22
|
+
no Node and no DOM, and `es2020` because that is what its engine speaks.
|
|
23
|
+
|
|
24
|
+
esbuild is yours to bring — the preset takes your `build` function rather than
|
|
25
|
+
importing its own, so there is only ever one copy of it.
|
|
26
|
+
|
|
27
|
+
Everything your manifest's `sharedDependencies` names becomes a lookup on the
|
|
28
|
+
library the host already holds, which is worth roughly thirty times the bundle
|
|
29
|
+
size. Using one **without** declaring it fails the build rather than quietly
|
|
30
|
+
shipping a private copy; pass `inline: ['liquidjs']` if that copy is what you
|
|
31
|
+
want, which is how you use a version the host does not carry.
|
|
32
|
+
|
|
33
|
+
## Packaging
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npx h2kext-pack build -o my-extension.h2kext
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Packages a directory holding a built `index.js`, a `manifest.json` and an
|
|
40
|
+
optional `assets/`. It packages; it does not compile.
|
|
41
|
+
|
|
42
|
+
It reports every problem at once, and refuses what would install cleanly and
|
|
43
|
+
then misbehave where you cannot see it. Extension keys are `<callsign>-<name>`
|
|
44
|
+
— the namespace every author of this software already holds, so two people who
|
|
45
|
+
never met cannot ship the same key.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MPL-2.0
|
package/h2kext-build.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
//
|
|
4
|
+
// Building an extension: the esbuild settings a Ham2K bundle needs, and the
|
|
5
|
+
// plugin that takes the host up on the libraries it carries (HALO-442,
|
|
6
|
+
// docs/extensions/development.md).
|
|
7
|
+
//
|
|
8
|
+
// Standalone in the same way h2kext.mjs is, and for the same reason — that
|
|
9
|
+
// module is its only import, because its whole purpose is to work for someone
|
|
10
|
+
// with none of this repo. esbuild it does NOT import: the caller passes their
|
|
11
|
+
// own `build` in, so an author's toolchain and this preset cannot end up
|
|
12
|
+
// running two copies of it.
|
|
13
|
+
//
|
|
14
|
+
// Ships beside the packer, in `@ham2k/extension-tools`, because both read the
|
|
15
|
+
// FORMAT — the API version, the manifest's entry names, the list of libraries
|
|
16
|
+
// the host carries. Neither depends on the SDK an extension imports, so the
|
|
17
|
+
// runtime package stays free of any build-time surface.
|
|
18
|
+
//
|
|
19
|
+
// The repo's own build uses it too, on the same terms, which is what keeps the
|
|
20
|
+
// path a third party takes from being the untested one.
|
|
21
|
+
|
|
22
|
+
import { copyFile, mkdir, readFile } from 'node:fs/promises'
|
|
23
|
+
import { createRequire } from 'node:module'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, sharedModulesFor } from './h2kext.mjs'
|
|
27
|
+
|
|
28
|
+
export class BuildError extends Error {}
|
|
29
|
+
|
|
30
|
+
/// Rewrites imports of the libraries the host carries into lookups on the
|
|
31
|
+
/// kernel's single instance, so a bundle need not ship its own copy.
|
|
32
|
+
///
|
|
33
|
+
/// Only what the manifest DECLARES is rewritten. Declaring is what lets the
|
|
34
|
+
/// host check, before it loads anything, that the version it holds is one the
|
|
35
|
+
/// bundle can work with — an undeclared lookup is a bundle that loads happily
|
|
36
|
+
/// and then dies at the first call into a library that turned out to be a
|
|
37
|
+
/// different major, in someone else's app, far from the cause.
|
|
38
|
+
///
|
|
39
|
+
/// So using an offered library without declaring it FAILS the build rather
|
|
40
|
+
/// than quietly inlining a private copy. Inlining is a legitimate choice — it
|
|
41
|
+
/// is how you use a version the host does not carry — and [inline] is how to
|
|
42
|
+
/// make it, but it is a choice worth stating, because the silent version costs
|
|
43
|
+
/// roughly thirty times the bundle size and nothing anywhere says so.
|
|
44
|
+
export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_VERSION, inline = [] } = {}) {
|
|
45
|
+
const offered = sharedModulesFor(api)
|
|
46
|
+
const declared = Object.keys(sharedDependencies)
|
|
47
|
+
|
|
48
|
+
// Refused rather than ignored. Externalizing a name the host has no instance
|
|
49
|
+
// of emits `sharedModules["lodash"]`, which is `undefined` at the first call
|
|
50
|
+
// — a build that succeeded and a bundle that cannot work.
|
|
51
|
+
const notOffered = declared.filter((pkg) => !offered.includes(pkg))
|
|
52
|
+
if (notOffered.length) {
|
|
53
|
+
throw new BuildError(
|
|
54
|
+
`${MANIFEST_ENTRY} declares sharedDependencies the host does not carry (${notOffered.join(', ')}). ` +
|
|
55
|
+
'Bundle those into your build instead — only what the host offers can be shared.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The two say opposite things about the same package, so neither can be
|
|
59
|
+
// honoured silently: whichever won, the other statement would be a lie.
|
|
60
|
+
const both = declared.filter((pkg) => inline.includes(pkg))
|
|
61
|
+
if (both.length) {
|
|
62
|
+
throw new BuildError(
|
|
63
|
+
`${both.join(', ')} appears in both sharedDependencies and inline. ` +
|
|
64
|
+
"Declare it to use the host's copy, or inline it to ship your own — not both.")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const undeclared = offered.filter((pkg) => !declared.includes(pkg) && !inline.includes(pkg))
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: 'ham2k-shared-modules',
|
|
71
|
+
setup(build) {
|
|
72
|
+
if (declared.length) {
|
|
73
|
+
build.onResolve({ filter: matcherFor(declared) }, (args) => ({
|
|
74
|
+
path: args.path,
|
|
75
|
+
namespace: 'ham2k-shared-module',
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A separate resolver rather than one branch inside the first: esbuild
|
|
80
|
+
// consults filters in order, and a plugin that resolved a package only
|
|
81
|
+
// to return null would hand it back to the very resolution this exists
|
|
82
|
+
// to intercept.
|
|
83
|
+
if (undeclared.length) {
|
|
84
|
+
build.onResolve({ filter: matcherFor(undeclared) }, (args) => ({
|
|
85
|
+
// The fix goes in `text`, not in a `notes` entry: a note reaches
|
|
86
|
+
// esbuild's pretty printer and nothing else, so a build script that
|
|
87
|
+
// catches the error — or runs at logLevel 'silent' — shows the
|
|
88
|
+
// author the complaint with the answer stripped off.
|
|
89
|
+
errors: [{
|
|
90
|
+
text: `'${args.path}' is carried by the Ham2K extension host, but this bundle does not declare it. ` +
|
|
91
|
+
`Add it to "sharedDependencies" in ${MANIFEST_ENTRY} with the version range you work with ` +
|
|
92
|
+
`(e.g. {${JSON.stringify(args.path)}: "^1.0.0"}), or pass inline: [${JSON.stringify(args.path)}] ` +
|
|
93
|
+
'to ship your own copy — which is how you use a version the host does not carry.',
|
|
94
|
+
}],
|
|
95
|
+
}))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
build.onLoad({ filter: /.*/, namespace: 'ham2k-shared-module' }, (args) => ({
|
|
99
|
+
contents: `module.exports = globalThis.__polo.sharedModules[${JSON.stringify(args.path)}];`,
|
|
100
|
+
loader: 'js',
|
|
101
|
+
}))
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// An esbuild filter matching exactly [packages] and nothing else. Package
|
|
107
|
+
/// names carry regex metacharacters (`@ham2k/lib-qson-adif` has a slash, and a
|
|
108
|
+
/// dot would be a wildcard), so every one is escaped.
|
|
109
|
+
function matcherFor(packages) {
|
|
110
|
+
const alternatives = packages.map((pkg) => pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')
|
|
111
|
+
return new RegExp(`^(${alternatives})$`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Where liquidjs's BROWSER build lives, or null if liquidjs is not installed.
|
|
115
|
+
///
|
|
116
|
+
/// Every author who inlines liquidjs hits this: its `main`/`module` are the
|
|
117
|
+
/// NODE builds, which import fs, path and stream, and `platform: 'neutral'`
|
|
118
|
+
/// ignores the `browser` field that would have picked the right one. Without
|
|
119
|
+
/// the alias the build fails on unresolvable node built-ins before tree-shaking
|
|
120
|
+
/// gets a chance to drop the half that wanted them (HALO-443).
|
|
121
|
+
///
|
|
122
|
+
/// Resolved from the CALLER's directory, not this file's — the point is to find
|
|
123
|
+
/// the author's copy.
|
|
124
|
+
function liquidBrowserBuild(fromDir) {
|
|
125
|
+
try {
|
|
126
|
+
// `resolve` because createRequire rejects a relative path outright, and
|
|
127
|
+
// `dir` is the caller's to pass — a relative one lands in the catch below
|
|
128
|
+
// and loses the alias, which is precisely the failure this function exists
|
|
129
|
+
// to prevent, arriving with nothing to point at the cause.
|
|
130
|
+
const require = createRequire(resolve(fromDir, 'noop.js'))
|
|
131
|
+
return join(dirname(require.resolve('liquidjs/package.json')), 'dist/liquid.browser.mjs')
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// Not installed is the normal case for an extension that neither inlines
|
|
134
|
+
// liquidjs nor uses it, and there is nothing to alias. Anything else is a
|
|
135
|
+
// real fault, and swallowing it would hand the author five unresolvable
|
|
136
|
+
// node built-ins instead.
|
|
137
|
+
if (e.code === 'MODULE_NOT_FOUND') return null
|
|
138
|
+
throw e
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The esbuild options a Ham2K extension bundle is built with.
|
|
143
|
+
///
|
|
144
|
+
/// Everything here is a requirement of the runtime rather than a preference:
|
|
145
|
+
/// one IIFE because the sandbox evaluates a script and has no module loader;
|
|
146
|
+
/// `platform: 'neutral'` because there is no node and no DOM; es2020 because
|
|
147
|
+
/// that is what QuickJS-NG speaks.
|
|
148
|
+
export function buildOptionsFor({ entry, outfile, manifest = {}, dir = process.cwd(), alias = {}, inline = [], ...rest } = {}) {
|
|
149
|
+
const liquid = liquidBrowserBuild(dir)
|
|
150
|
+
return {
|
|
151
|
+
entryPoints: [entry],
|
|
152
|
+
...(outfile ? { outfile } : { write: false }),
|
|
153
|
+
bundle: true,
|
|
154
|
+
format: 'iife',
|
|
155
|
+
platform: 'neutral',
|
|
156
|
+
mainFields: ['module', 'main'],
|
|
157
|
+
target: 'es2020',
|
|
158
|
+
logLevel: 'warning',
|
|
159
|
+
...rest,
|
|
160
|
+
alias: {
|
|
161
|
+
...(liquid ? { liquidjs: liquid } : {}),
|
|
162
|
+
...alias,
|
|
163
|
+
},
|
|
164
|
+
plugins: [
|
|
165
|
+
sharedModulesPlugin({ sharedDependencies: manifest.sharedDependencies, api: manifest.api, inline }),
|
|
166
|
+
...(rest.plugins ?? []),
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Builds one extension directory into something `h2kext-pack` can package:
|
|
172
|
+
/// a `manifest.json` and an `index.js`, side by side.
|
|
173
|
+
///
|
|
174
|
+
/// import { build } from 'esbuild'
|
|
175
|
+
/// import { buildExtension } from '@ham2k/extension-tools'
|
|
176
|
+
/// await buildExtension(build, { dir: import.meta.dirname })
|
|
177
|
+
///
|
|
178
|
+
/// [esbuild] is the caller's `build` function, passed in rather than imported
|
|
179
|
+
/// so this module needs no copy of its own.
|
|
180
|
+
export async function buildExtension(esbuild, { dir, entry = 'src/index.ts', outDir = 'build', ...rest } = {}) {
|
|
181
|
+
if (!dir) throw new BuildError('buildExtension needs `dir`: the extension directory holding manifest.json')
|
|
182
|
+
|
|
183
|
+
let manifest
|
|
184
|
+
try {
|
|
185
|
+
manifest = JSON.parse(await readFile(join(dir, MANIFEST_ENTRY), 'utf8'))
|
|
186
|
+
} catch (e) {
|
|
187
|
+
throw new BuildError(`could not read ${join(dir, MANIFEST_ENTRY)}: ${e.message}`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const out = join(dir, outDir)
|
|
191
|
+
await mkdir(out, { recursive: true })
|
|
192
|
+
|
|
193
|
+
await esbuild(buildOptionsFor({
|
|
194
|
+
entry: join(dir, entry),
|
|
195
|
+
outfile: join(out, 'index.js'),
|
|
196
|
+
manifest,
|
|
197
|
+
dir,
|
|
198
|
+
...rest,
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
// The packer reads both from one directory, so the manifest the bundle was
|
|
202
|
+
// built against is the manifest that ships with it. Copied rather than left
|
|
203
|
+
// for the author to remember: a stale manifest beside a fresh bundle is a
|
|
204
|
+
// declared dependency list that no longer describes the code.
|
|
205
|
+
await copyFile(join(dir, MANIFEST_ENTRY), join(out, MANIFEST_ENTRY))
|
|
206
|
+
|
|
207
|
+
return { outDir: out, manifest }
|
|
208
|
+
}
|
package/h2kext-pack.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
//
|
|
5
|
+
// Packages an extension into a `.h2kext` a user can install
|
|
6
|
+
// (HALO-422, docs/extensions/distribution.md).
|
|
7
|
+
//
|
|
8
|
+
// node tools/h2kext-pack.mjs <directory> [-o out.h2kext]
|
|
9
|
+
//
|
|
10
|
+
// The directory holds a built `index.js`, a `manifest.json`, and optionally an
|
|
11
|
+
// `assets/` folder. Nothing here compiles anything — see h2kext.mjs.
|
|
12
|
+
|
|
13
|
+
import { pack, PackError, H2KEXT_API_VERSION, RESERVED_PREFIX } from './h2kext.mjs'
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2)
|
|
16
|
+
|
|
17
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
18
|
+
console.log(`Package an extension directory into a .h2kext bundle.
|
|
19
|
+
|
|
20
|
+
h2kext-pack <directory> [-o <out.h2kext>] [--force-name]
|
|
21
|
+
|
|
22
|
+
The directory must contain:
|
|
23
|
+
manifest.json key, name, version, description, category, api (${H2KEXT_API_VERSION})
|
|
24
|
+
index.js your extension, built
|
|
25
|
+
assets/ optional
|
|
26
|
+
|
|
27
|
+
The key is '<yourcallsign>-<name>' — e.g. ki2d-my-clock. The callsign is a
|
|
28
|
+
namespace you already have, globally unique, so two authors who never met
|
|
29
|
+
cannot collide.
|
|
30
|
+
|
|
31
|
+
--force-name waive the naming convention (Ham2K's own builds, which
|
|
32
|
+
ship bare keys and the reserved '${RESERVED_PREFIX}-' prefix)
|
|
33
|
+
|
|
34
|
+
Defaults to <key>-<version>.h2kext in the working directory.`)
|
|
35
|
+
process.exit(args.length === 0 ? 1 : 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Walked by index rather than matched by value: `args.indexOf(a)` finds the
|
|
39
|
+
// FIRST occurrence, so `-o foo foo` mistook the output path for the directory.
|
|
40
|
+
const forceName = args.includes('--force-name')
|
|
41
|
+
let dir
|
|
42
|
+
let outPath
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
if (args[i] === '-o') {
|
|
45
|
+
outPath = args[++i]
|
|
46
|
+
} else if (!args[i].startsWith('-') && dir === undefined) {
|
|
47
|
+
dir = args[i]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!dir) {
|
|
52
|
+
console.error('h2kext-pack: name the directory to package')
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await pack(dir, { outPath, forceName })
|
|
58
|
+
const assets = result.files.length - 2
|
|
59
|
+
console.log(`${result.outPath}`)
|
|
60
|
+
console.log(` ${result.manifest.key} ${result.manifest.version} — ${result.manifest.name}`)
|
|
61
|
+
console.log(` ${assets} asset${assets === 1 ? '' : 's'}`)
|
|
62
|
+
if (result.manifest.domains?.length) {
|
|
63
|
+
// Named on the way out, because this is the one part of the manifest the
|
|
64
|
+
// person installing it will be asked to approve.
|
|
65
|
+
console.log(` may reach: ${result.manifest.domains.join(', ')}`)
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e instanceof PackError) {
|
|
69
|
+
console.error(`h2kext-pack: ${dir} is not ready to package:\n${e.message}`)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
throw e
|
|
73
|
+
}
|
package/h2kext.mjs
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
//
|
|
4
|
+
// The `.h2kext` bundle format: what one contains, what makes one valid, and how
|
|
5
|
+
// to write it (HALO-422, docs/extensions/distribution.md).
|
|
6
|
+
//
|
|
7
|
+
// Deliberately standalone. It imports nothing from this repo — not the bundle
|
|
8
|
+
// list, not the esbuild options, not the SDK — because its whole purpose is to
|
|
9
|
+
// work for someone who has none of that. Its only dependency is a zip writer.
|
|
10
|
+
// Lifting this file and its CLI into a published npm package should be a
|
|
11
|
+
// packaging change, not a rewrite.
|
|
12
|
+
//
|
|
13
|
+
// It PACKAGES; it does not compile. The input is a directory that already
|
|
14
|
+
// holds a built `index.js` and a `manifest.json`. How that JS got built is the
|
|
15
|
+
// author's business — which is exactly why the checks below matter, since the
|
|
16
|
+
// one way it can be built WRONG is invisible until the extension is running
|
|
17
|
+
// inside somebody's app.
|
|
18
|
+
|
|
19
|
+
import { createWriteStream } from 'node:fs'
|
|
20
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
21
|
+
import { join, posix, relative, sep } from 'node:path'
|
|
22
|
+
import { parseCallsign } from '@ham2k/lib-callsigns'
|
|
23
|
+
import yazl from 'yazl'
|
|
24
|
+
|
|
25
|
+
/// The extension API this format describes. A bundle declares the version it
|
|
26
|
+
/// was built against and the host refuses one it does not speak, so an old app
|
|
27
|
+
/// meeting a newer bundle says so instead of failing at a call site.
|
|
28
|
+
export const H2KEXT_API_VERSION = 1
|
|
29
|
+
|
|
30
|
+
/// `<prefix>-<name>`, all lowercase. The shape is what the kernel's
|
|
31
|
+
/// `bundleMayDefine` prefix rule can reason about; the PREFIX is what keeps two
|
|
32
|
+
/// authors who never met from choosing the same key.
|
|
33
|
+
export const KEY_PATTERN = /^[a-z0-9]+-[a-z0-9-]*[a-z0-9]$/
|
|
34
|
+
|
|
35
|
+
/// The prefix reserved for Ham2K's own extensions. Refused to a distributed
|
|
36
|
+
/// bundle unless it is one of ours: there is no signing yet, so anyone could
|
|
37
|
+
/// claim it, and a key is what the app shows when it says where a hook came
|
|
38
|
+
/// from.
|
|
39
|
+
export const RESERVED_PREFIX = 'ham2k'
|
|
40
|
+
|
|
41
|
+
/// Whether [prefix] is a real amateur callsign — the namespace every author of
|
|
42
|
+
/// this software already has, globally unique, and free.
|
|
43
|
+
export function isCallsignPrefix(prefix) {
|
|
44
|
+
const parsed = parseCallsign(prefix.toUpperCase())
|
|
45
|
+
return !!parsed.baseCall && parsed.baseCall === prefix.toUpperCase()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The shape a key must have whatever else is waived: lowercase, letters
|
|
49
|
+
/// digits and hyphens. Not a naming convention — the key namespaces KV
|
|
50
|
+
/// storage, scopes secrets, and identifies hooks, so anything outside this
|
|
51
|
+
/// breaks the runtime rather than merely looking wrong.
|
|
52
|
+
///
|
|
53
|
+
/// A leading DIGIT is legitimate and must stay allowed: plenty of callsigns
|
|
54
|
+
/// begin with one (2E0ABC, 9A1AA, 4X4XYZ), and the convention below builds a
|
|
55
|
+
/// key out of exactly that.
|
|
56
|
+
export const SAFE_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
57
|
+
|
|
58
|
+
/// Why [key] is not a usable extension key, or null if it is.
|
|
59
|
+
///
|
|
60
|
+
/// [forceName] waives the naming CONVENTION — the callsign prefix and the
|
|
61
|
+
/// reserved `ham2k-` — for Ham2K's own packaging, which builds the extensions
|
|
62
|
+
/// that ship inside the app under bare keys (`pota`, `sota`) and the reserved
|
|
63
|
+
/// prefix. It does not waive [SAFE_KEY_PATTERN], which is not a convention.
|
|
64
|
+
///
|
|
65
|
+
/// A guardrail, not a gate: a standalone tool cannot enforce who runs it. The
|
|
66
|
+
/// app is where the rule bites, refusing a reserved or colliding key at
|
|
67
|
+
/// install.
|
|
68
|
+
export function keyProblem(key, { forceName = false } = {}) {
|
|
69
|
+
if (typeof key !== 'string' || !SAFE_KEY_PATTERN.test(key)) {
|
|
70
|
+
return `key '${key}' must be lowercase, and contain only letters, digits and hyphens`
|
|
71
|
+
}
|
|
72
|
+
if (forceName) return null
|
|
73
|
+
if (!KEY_PATTERN.test(key)) {
|
|
74
|
+
return `key '${key}' must be '<callsign>-<name>' (e.g. ki2d-my-clock)`
|
|
75
|
+
}
|
|
76
|
+
const prefix = key.slice(0, key.indexOf('-'))
|
|
77
|
+
if (prefix === RESERVED_PREFIX) {
|
|
78
|
+
return `key '${key}' uses the '${RESERVED_PREFIX}-' prefix, which is reserved for Ham2K's own extensions`
|
|
79
|
+
}
|
|
80
|
+
if (!isCallsignPrefix(prefix)) {
|
|
81
|
+
return `key '${key}' must start with your callsign — '${prefix}' is not one (e.g. ki2d-my-clock)`
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// The only entries a bundle may contain, beyond `assets/`.
|
|
87
|
+
export const MANIFEST_ENTRY = 'manifest.json'
|
|
88
|
+
export const BUNDLE_ENTRY = 'index.js'
|
|
89
|
+
export const ASSETS_PREFIX = 'assets/'
|
|
90
|
+
|
|
91
|
+
/// The libraries the extension host carries, per API version, so a bundle does
|
|
92
|
+
/// not have to ship its own copy of each. The kernel holds one instance and
|
|
93
|
+
/// hands it to every extension through `__polo.sharedModules`.
|
|
94
|
+
///
|
|
95
|
+
/// NAMES only. The VERSION a given app carries is that build's business, which
|
|
96
|
+
/// is the whole reason a bundle declares a RANGE in `sharedDependencies` and
|
|
97
|
+
/// the host checks it at load: a list of names is stable across releases,
|
|
98
|
+
/// a list of versions would be a lie the moment either side shipped.
|
|
99
|
+
///
|
|
100
|
+
/// Deliberately duplicated from build-config.mjs's `sharedPackages` rather than
|
|
101
|
+
/// imported — this module has to work with none of this repo present. A test
|
|
102
|
+
/// in the repo asserts the two agree, which is the only place that check can
|
|
103
|
+
/// live once this file is published on its own.
|
|
104
|
+
export const SHARED_MODULES_BY_API = {
|
|
105
|
+
1: [
|
|
106
|
+
'@ham2k/lib-callsigns',
|
|
107
|
+
'@ham2k/lib-country-files',
|
|
108
|
+
'@ham2k/lib-cqmag-data',
|
|
109
|
+
'@ham2k/lib-dxcc-data',
|
|
110
|
+
'@ham2k/lib-format-tools',
|
|
111
|
+
'@ham2k/lib-geo-tools',
|
|
112
|
+
'@ham2k/lib-operation-data',
|
|
113
|
+
'@ham2k/lib-qson-adif',
|
|
114
|
+
'@ham2k/lib-qson-cabrillo',
|
|
115
|
+
'@ham2k/lib-qson-tools',
|
|
116
|
+
'i18next',
|
|
117
|
+
'liquidjs',
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// What the host offers a bundle declaring [api].
|
|
122
|
+
export function sharedModulesFor(api) {
|
|
123
|
+
return SHARED_MODULES_BY_API[api] ?? SHARED_MODULES_BY_API[H2KEXT_API_VERSION]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class PackError extends Error {}
|
|
127
|
+
|
|
128
|
+
/// Everything wrong with a candidate bundle, as a list — an author fixing one
|
|
129
|
+
/// problem per run is an author running this five times.
|
|
130
|
+
export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName = false } = {}) {
|
|
131
|
+
const problems = []
|
|
132
|
+
const say = (m) => problems.push(`${source}: ${m}`)
|
|
133
|
+
|
|
134
|
+
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
135
|
+
return [`${source}: must be a JSON object`]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const field of ['key', 'name', 'version', 'description']) {
|
|
139
|
+
if (typeof manifest[field] !== 'string' || manifest[field].trim() === '') {
|
|
140
|
+
say(`missing required field '${field}'`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (manifest.key !== undefined) {
|
|
145
|
+
const problem = keyProblem(manifest.key, { forceName })
|
|
146
|
+
if (problem) say(problem)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (manifest.api === undefined) {
|
|
150
|
+
say(`missing 'api' — declare the extension API this was built against (currently ${H2KEXT_API_VERSION})`)
|
|
151
|
+
} else if (!Number.isInteger(manifest.api)) {
|
|
152
|
+
say(`'api' must be an integer, not ${JSON.stringify(manifest.api)}`)
|
|
153
|
+
} else if (manifest.api > H2KEXT_API_VERSION) {
|
|
154
|
+
say(`'api' is ${manifest.api}, but this packer only knows ${H2KEXT_API_VERSION}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// An extension with no category is CORE to the host: always enabled, and
|
|
158
|
+
// never listed in the Extensions panel. That is a status the app grants its
|
|
159
|
+
// own extensions, not one a distributed bundle may claim for itself — a user
|
|
160
|
+
// could neither see it nor turn it off.
|
|
161
|
+
if (typeof manifest.category !== 'string' || manifest.category.trim() === '') {
|
|
162
|
+
say("missing 'category' — a bundle with none is treated as core: always on, and invisible in the Extensions panel")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (manifest.sharedDependencies !== undefined) {
|
|
166
|
+
const declared = manifest.sharedDependencies
|
|
167
|
+
if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
|
|
168
|
+
say("'sharedDependencies' must be an object of package name to version range")
|
|
169
|
+
} else {
|
|
170
|
+
const offered = sharedModulesFor(manifest.api)
|
|
171
|
+
for (const [pkg, range] of Object.entries(declared)) {
|
|
172
|
+
if (!offered.includes(pkg)) {
|
|
173
|
+
say(`'sharedDependencies' names '${pkg}', which the host does not carry — bundle it into your build instead`)
|
|
174
|
+
} else if (typeof range !== 'string' || range.trim() === '') {
|
|
175
|
+
say(`'sharedDependencies' entry '${pkg}' needs a version range, e.g. "^1.0.0"`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `experiments` gates availability against the APP's experiment catalog. A
|
|
182
|
+
// key the host does not define hides the extension forever, with nothing in
|
|
183
|
+
// the UI to explain it, so a distributed bundle must not carry one.
|
|
184
|
+
if (manifest.experiments !== undefined) {
|
|
185
|
+
say("'experiments' gates on the app's own experiment catalog and would hide this extension permanently — remove it")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return problems
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Every library a bundle takes from the host rather than carrying its own
|
|
192
|
+
/// copy — the `globalThis.__polo.sharedModules[...]` lookups its build emitted.
|
|
193
|
+
///
|
|
194
|
+
/// Taking them is the supported path and worth roughly thirty times the bundle
|
|
195
|
+
/// size. What the caller checks is that each one was DECLARED: the host carries
|
|
196
|
+
/// whatever version its build shipped with and makes no promise, so an
|
|
197
|
+
/// undeclared lookup is a bundle the app cannot version-check before loading.
|
|
198
|
+
/// It loads happily and then dies at the first call into a library that turned
|
|
199
|
+
/// out to be absent or a different major — far from the cause, and in someone
|
|
200
|
+
/// else's app.
|
|
201
|
+
export function findSharedModuleReferences(source) {
|
|
202
|
+
const hits = []
|
|
203
|
+
const pattern = /__polo\s*\.\s*sharedModules\s*\[\s*["']([^"']+)["']\s*\]/g
|
|
204
|
+
for (const match of source.matchAll(pattern)) hits.push(match[1])
|
|
205
|
+
return [...new Set(hits)]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Reads a bundle directory and returns `{manifest, files}` ready to write, or
|
|
209
|
+
/// throws a [PackError] naming everything wrong with it.
|
|
210
|
+
export async function readBundleDirectory(dir, { forceName = false } = {}) {
|
|
211
|
+
let manifestRaw
|
|
212
|
+
try {
|
|
213
|
+
manifestRaw = await readFile(join(dir, MANIFEST_ENTRY), 'utf8')
|
|
214
|
+
} catch {
|
|
215
|
+
throw new PackError(`no ${MANIFEST_ENTRY} in ${dir}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let manifest
|
|
219
|
+
try {
|
|
220
|
+
manifest = JSON.parse(manifestRaw)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new PackError(`${MANIFEST_ENTRY} is not valid JSON: ${e.message}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const problems = validateManifest(manifest, { forceName })
|
|
226
|
+
|
|
227
|
+
let bundleSource
|
|
228
|
+
try {
|
|
229
|
+
bundleSource = await readFile(join(dir, BUNDLE_ENTRY), 'utf8')
|
|
230
|
+
} catch {
|
|
231
|
+
throw new PackError(`no ${BUNDLE_ENTRY} in ${dir} — build your extension first; this packages, it does not compile`)
|
|
232
|
+
}
|
|
233
|
+
if (bundleSource.trim() === '') problems.push(`${BUNDLE_ENTRY}: is empty`)
|
|
234
|
+
|
|
235
|
+
// Using the host's shared libraries is allowed, and is much the better
|
|
236
|
+
// option — inlining them costs about ten times the bundle size. What is NOT
|
|
237
|
+
// allowed is using one silently: the host carries whatever version that
|
|
238
|
+
// build shipped with, so a bundle has to say what it can work with, and the
|
|
239
|
+
// host checks it before loading. Undeclared, it loads happily and then dies
|
|
240
|
+
// at the first call into a library that turned out to be a different major.
|
|
241
|
+
const declaredShared = manifest?.sharedDependencies ?? {}
|
|
242
|
+
const undeclared = findSharedModuleReferences(bundleSource).filter((pkg) => !(pkg in declaredShared))
|
|
243
|
+
if (undeclared.length) {
|
|
244
|
+
problems.push(
|
|
245
|
+
`${BUNDLE_ENTRY}: uses the host's shared modules without declaring them (${undeclared.join(', ')}). ` +
|
|
246
|
+
`Add them to 'sharedDependencies' in ${MANIFEST_ENTRY} with the version range you work with, ` +
|
|
247
|
+
'e.g. {"' + undeclared[0] + '": "^1.0.0"}, so the host can refuse to load this against a version you never tested.')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (problems.length) throw new PackError(problems.join('\n'))
|
|
251
|
+
|
|
252
|
+
const files = [
|
|
253
|
+
{ entry: MANIFEST_ENTRY, path: join(dir, MANIFEST_ENTRY) },
|
|
254
|
+
{ entry: BUNDLE_ENTRY, path: join(dir, BUNDLE_ENTRY) },
|
|
255
|
+
...(await collectAssets(dir)),
|
|
256
|
+
]
|
|
257
|
+
return { manifest, files }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Everything under `assets/`, as forward-slashed entry names. Zip entry names
|
|
261
|
+
/// are `/`-separated by specification, so a Windows author must not produce a
|
|
262
|
+
/// bundle whose paths only their own machine can read.
|
|
263
|
+
async function collectAssets(dir) {
|
|
264
|
+
const root = join(dir, ASSETS_PREFIX.slice(0, -1))
|
|
265
|
+
try {
|
|
266
|
+
if (!(await stat(root)).isDirectory()) return []
|
|
267
|
+
} catch (e) {
|
|
268
|
+
// No assets/ is ordinary. Anything else — unreadable, an I/O error, a
|
|
269
|
+
// broken symlink — is not, and swallowing it ships a bundle silently
|
|
270
|
+
// missing files the author believes are in it.
|
|
271
|
+
if (e.code === 'ENOENT') return []
|
|
272
|
+
throw new PackError(`could not read ${root}: ${e.message}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const found = []
|
|
276
|
+
const walk = async (current) => {
|
|
277
|
+
for (const item of await readdir(current, { withFileTypes: true })) {
|
|
278
|
+
const full = join(current, item.name)
|
|
279
|
+
if (item.isDirectory()) await walk(full)
|
|
280
|
+
else if (item.isFile()) {
|
|
281
|
+
found.push({ entry: ASSETS_PREFIX + relative(root, full).split(sep).join(posix.sep), path: full })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await walk(root)
|
|
286
|
+
return found.sort((a, b) => a.entry.localeCompare(b.entry))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// Writes the `.h2kext` at [outPath]. Resolves once the file is closed —
|
|
290
|
+
/// yazl's `end()` only starts the write, and a caller that reported success
|
|
291
|
+
/// before then would be describing a file that is still being produced.
|
|
292
|
+
export function writeBundle(files, outPath) {
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
const zip = new yazl.ZipFile()
|
|
295
|
+
for (const file of files) zip.addFile(file.path, file.entry)
|
|
296
|
+
const out = createWriteStream(outPath)
|
|
297
|
+
zip.outputStream.pipe(out)
|
|
298
|
+
out.on('close', () => resolve(outPath))
|
|
299
|
+
out.on('error', reject)
|
|
300
|
+
zip.outputStream.on('error', reject)
|
|
301
|
+
zip.end()
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// Validate a directory and write its `.h2kext`. Returns `{manifest, outPath}`.
|
|
306
|
+
export async function pack(dir, { outPath, forceName = false } = {}) {
|
|
307
|
+
const { manifest, files } = await readBundleDirectory(dir, { forceName })
|
|
308
|
+
const target = outPath ?? join(process.cwd(), `${manifest.key}-${manifest.version}.h2kext`)
|
|
309
|
+
await writeBundle(files, target)
|
|
310
|
+
return { manifest, outPath: target, files }
|
|
311
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ham2k/extension-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build and package extensions for the Ham2K Logger",
|
|
5
|
+
"keywords": ["ham2k", "ham radio", "amateur radio", "extension", "esbuild", "h2kext"],
|
|
6
|
+
"license": "MPL-2.0",
|
|
7
|
+
"author": "Sebastian Delmont <sd@ham2k.com>",
|
|
8
|
+
"homepage": "https://ham2k.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ham2k/halo.git",
|
|
12
|
+
"directory": "extensions/tools"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./h2kext-build.mjs",
|
|
17
|
+
"./format": "./h2kext.mjs"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"h2kext-pack": "./h2kext-pack.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"h2kext.mjs",
|
|
24
|
+
"h2kext-build.mjs",
|
|
25
|
+
"h2kext-pack.mjs",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@ham2k/lib-callsigns": "^1.0.2",
|
|
33
|
+
"yazl": "^3.3.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"esbuild": ">=0.20"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"esbuild": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|