@ham2k/extension-sdk 0.5.6 → 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/AGENTS.md CHANGED
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
34
34
  ## The smallest whole extension
35
35
 
36
36
  ```ts
37
- import { defineExtension } from '@ham2k/extension-sdk'
38
- import type { PanelHook } from '@ham2k/extension-sdk'
37
+ import { defineExtension } from "@ham2k/extension-sdk"
38
+ import type { PanelHook } from "@ham2k/extension-sdk"
39
39
 
40
- import manifest from '../manifest.json'
40
+ import manifest from "../manifest.json"
41
41
 
42
42
  const HelloPanel: PanelHook = {
43
43
  async getPanels() {
44
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
44
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
45
45
  },
46
46
  async render() {
47
- return { kind: 'markdown', content: '# Hello, World!' }
47
+ return { kind: "markdown", content: "# Hello, World!" }
48
48
  },
49
49
  }
50
50
 
51
51
  defineExtension({
52
52
  ...manifest,
53
53
  onActivation({ registerHook }) {
54
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
54
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
55
55
  },
56
56
  })
57
57
  ```
@@ -100,9 +100,10 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
100
100
  evaluating one script. Reach the outside world through `host`:
101
101
 
102
102
  ```ts
103
- import { host } from '@ham2k/extension-sdk'
103
+ import { host } from "@ham2k/extension-sdk"
104
104
 
105
105
  await host.fetch(url) // https only, and only domains your manifest lists
106
+ host.webSocket(url) // wss:// to hosts your manifest's webSockets lists
106
107
  await host.kvGet('key') // storage, namespaced to your extension
107
108
  await host.kvSet('key', value)
108
109
  await host.showForm(form) // ask the user something
@@ -114,7 +115,7 @@ host.log('…') // the app's console
114
115
 
115
116
  ## Traps worth knowing before you write anything
116
117
 
117
- - **A panel returns content, not widgets.** `{ kind: 'markdown', content }` and
118
+ - **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
118
119
  its siblings. There is no way to reach the app's UI toolkit, by design.
119
120
  - **`host.fetch` only reaches domains your manifest's `domains` lists**, over
120
121
  https. An undeclared host is refused at the call, not at build.
@@ -122,7 +123,7 @@ host.log('…') // the app's console
122
123
  carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
123
124
  `sharedDependencies` rewrites your imports into lookups on the app's single
124
125
  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
+ `inline: ["liquidjs"]` in your build ships your own copy, for when you need a
126
127
  version the app does not carry. A scaffolded manifest already declares the
127
128
  eight this SDK's own modules reach — importing anything from it pulls them
128
129
  in — so deleting one from the manifest breaks a build that was working.
package/README.md CHANGED
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
9
9
  ```
10
10
 
11
11
  ```ts
12
- import { defineExtension } from '@ham2k/extension-sdk'
13
- import type { PanelHook } from '@ham2k/extension-sdk'
12
+ import { defineExtension } from "@ham2k/extension-sdk"
13
+ import type { PanelHook } from "@ham2k/extension-sdk"
14
14
 
15
- import manifest from '../manifest.json'
15
+ import manifest from "../manifest.json"
16
16
 
17
17
  const HelloPanel: PanelHook = {
18
18
  async getPanels() {
19
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
19
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
20
20
  },
21
21
  async render() {
22
- return { kind: 'markdown', content: '# Hello, World!' }
22
+ return { kind: "markdown", content: "# Hello, World!" }
23
23
  },
24
24
  }
25
25
 
26
26
  defineExtension({
27
27
  ...manifest,
28
28
  onActivation({ registerHook }) {
29
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
29
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
30
30
  },
31
31
  })
32
32
  ```
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
23
23
  const prefix = `${rules.key}-adif`;
24
24
  return {
25
25
  async getExportTypes() {
26
- return [{
27
- ...exportTypeDefinition(rules.activationType, "adif", rules.label),
28
- templateCategory: "reference",
29
- templateSample: rules.templateSample ?? {
30
- log: { ref: "REF-1234", refName: "Example Reference" },
31
- operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
26
+ return [
27
+ {
28
+ ...exportTypeDefinition(rules.activationType, "adif", rules.label),
29
+ templateCategory: "reference",
30
+ templateSample: rules.templateSample ?? {
31
+ log: { ref: "REF-1234", refName: "Example Reference" },
32
+ operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
33
+ }
32
34
  }
33
- }];
35
+ ];
34
36
  },
35
37
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
36
38
  const refs = refsOfType(operation, rules.activationType);
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
113
115
  if (refsOfType(operation, rules.activationType).length > 0) return [];
114
116
  if (huntedQsos(qsos ?? []).length === 0) return [];
115
117
  const t = tFor(ctx);
116
- return [{
117
- exportType,
118
- qsoCount: huntedQsos(qsos ?? []).length,
119
- templateData: { activity: rules.label },
120
- format: "adif",
121
- label: t("adifForHunter", { program: rules.label }),
122
- filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
123
- icon: rules.icon,
124
- color: rules.color,
125
- selectedByDefault: true
126
- // No `refType`: hunted refs live on QSOs, not on operation segments,
127
- // so this isn't the segment-scoped slice `activityExportHook` uses —
128
- // it spans the whole log. (See `HuntingExportRules.color` for why the
129
- // accent has to be threaded explicitly instead.)
130
- }];
118
+ return [
119
+ {
120
+ exportType,
121
+ qsoCount: huntedQsos(qsos ?? []).length,
122
+ templateData: { activity: rules.label },
123
+ format: "adif",
124
+ label: t("adifForHunter", { program: rules.label }),
125
+ filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
126
+ icon: rules.icon,
127
+ color: rules.color,
128
+ selectedByDefault: true
129
+ // No `refType`: hunted refs live on QSOs, not on operation segments,
130
+ // so this isn't the segment-scoped slice `activityExportHook` uses —
131
+ // it spans the whole log. (See `HuntingExportRules.color` for why the
132
+ // accent has to be threaded explicitly instead.)
133
+ }
134
+ ];
131
135
  },
132
136
  async generateExport(args, ctx) {
133
137
  const requested = String(args.exportType ?? "");
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
163
167
  }
164
168
  };
165
169
  }
166
- async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
170
+ async function adifForExport({
171
+ operation,
172
+ qsos,
173
+ segments,
174
+ includePrivateData,
175
+ includeLookupData,
176
+ exportSettings,
177
+ exportData,
178
+ exportTitle,
179
+ mainHandler,
180
+ includeFieldsFrom
181
+ }) {
167
182
  const { hooks } = await import("@ham2k/extension-sdk");
168
183
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
169
184
  operation,
@@ -62,9 +62,7 @@ function activityScorer(rules) {
62
62
  if (candidates.length === 0) notice ?? (notice = "mode");
63
63
  }
64
64
  if (unique.has("ref")) {
65
- candidates = candidates.filter(
66
- (c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
67
- );
65
+ candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
68
66
  if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
69
67
  }
70
68
  if (notice) return notice;
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
189
187
  const detail = refKeys.map(
190
188
  (key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
191
189
  );
192
- const parts = [
193
- qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
194
- ];
190
+ const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
195
191
  if (huntedQsos > 0) {
196
192
  const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
197
- parts.push(
198
- huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
199
- );
193
+ parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
200
194
  }
201
195
  return {
202
196
  key: "activation",
package/dist/base64.js ADDED
@@ -0,0 +1,38 @@
1
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+ const VALUES = new Map([...ALPHABET].map((c, i) => [c, i]));
3
+ function bytesToBase64(bytes) {
4
+ let out = "";
5
+ for (let i = 0; i < bytes.length; i += 3) {
6
+ const a = bytes[i];
7
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
8
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
9
+ const n = a << 16 | b << 8 | c;
10
+ out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
11
+ out += i + 1 < bytes.length ? ALPHABET[n >> 6 & 63] : "=";
12
+ out += i + 2 < bytes.length ? ALPHABET[n & 63] : "=";
13
+ }
14
+ return out;
15
+ }
16
+ function base64ToBytes(text) {
17
+ if (text.length % 4 !== 0) throw new Error("invalid base64");
18
+ const padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
19
+ const bytes = new Uint8Array(text.length / 4 * 3 - padding);
20
+ let o = 0;
21
+ for (let i = 0; i < text.length; i += 4) {
22
+ let n = 0;
23
+ for (let j = 0; j < 4; j++) {
24
+ const ch = text[i + j];
25
+ const value = ch === "=" && i + 4 === text.length && j >= 4 - padding ? 0 : VALUES.get(ch);
26
+ if (value === void 0) throw new Error("invalid base64");
27
+ n = n << 6 | value;
28
+ }
29
+ if (o < bytes.length) bytes[o++] = n >> 16 & 255;
30
+ if (o < bytes.length) bytes[o++] = n >> 8 & 255;
31
+ if (o < bytes.length) bytes[o++] = n & 255;
32
+ }
33
+ return bytes;
34
+ }
35
+ export {
36
+ base64ToBytes,
37
+ bytesToBase64
38
+ };
@@ -0,0 +1,71 @@
1
+ function copyOnWrite(base) {
2
+ return isContainer(base) ? wrap(base) : base;
3
+ }
4
+ function isContainer(value) {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+ function wrap(target) {
8
+ const own = /* @__PURE__ */ new Map();
9
+ const deleted = /* @__PURE__ */ new Set();
10
+ return new Proxy(target, {
11
+ get(t, key) {
12
+ if (own.has(key)) return own.get(key);
13
+ if (deleted.has(key)) return void 0;
14
+ const value = Reflect.get(t, key);
15
+ if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
16
+ const view = wrap(value);
17
+ own.set(key, view);
18
+ return view;
19
+ }
20
+ return value;
21
+ },
22
+ set(_t, key, value) {
23
+ own.set(key, value);
24
+ deleted.delete(key);
25
+ return true;
26
+ },
27
+ has(t, key) {
28
+ if (deleted.has(key)) return false;
29
+ return own.has(key) || key in t;
30
+ },
31
+ deleteProperty(t, key) {
32
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
33
+ if (descriptor && !descriptor.configurable) return false;
34
+ own.delete(key);
35
+ if (descriptor) deleted.add(key);
36
+ return true;
37
+ },
38
+ ownKeys(t) {
39
+ const keys = Reflect.ownKeys(t).filter((key) => !deleted.has(key));
40
+ for (const key of own.keys()) {
41
+ if (!keys.includes(key)) keys.push(key);
42
+ }
43
+ return keys;
44
+ },
45
+ getOwnPropertyDescriptor(t, key) {
46
+ if (deleted.has(key)) return void 0;
47
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
48
+ if (!own.has(key)) return descriptor;
49
+ return descriptor ? { ...descriptor, value: own.get(key) } : { value: own.get(key), writable: true, enumerable: true, configurable: true };
50
+ },
51
+ // The other ways to write. Without these traps `Object.defineProperty`
52
+ // and `Object.freeze` fall through to the base — the held checkpoint —
53
+ // and one such call would enter a candidate into every later verdict, or
54
+ // lock the sheet for the rest of the session.
55
+ defineProperty(_t, key, descriptor) {
56
+ if ("get" in descriptor || "set" in descriptor) return false;
57
+ own.set(key, descriptor.value);
58
+ deleted.delete(key);
59
+ return true;
60
+ },
61
+ preventExtensions() {
62
+ return false;
63
+ },
64
+ setPrototypeOf() {
65
+ return false;
66
+ }
67
+ });
68
+ }
69
+ export {
70
+ copyOnWrite
71
+ };
@@ -26,22 +26,23 @@ function exportFilename(parts) {
26
26
  const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
27
27
  const extension = parts.extension.replace(/^\./, "");
28
28
  const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
29
- const modifier = [
30
- (parts.modifier ?? "").trim(),
31
- isTestOperation(parts.stationCall) ? "Testing" : ""
32
- ].filter((part) => part !== "").join(" ");
29
+ const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
33
30
  const stem = oneLine(
34
- renderTemplate(NAME_TEMPLATES[shape], {
35
- op: dateValues(millis),
36
- log: logValues({
37
- station: String(parts.stationCall ?? "").trim(),
38
- ref: parts.ref,
39
- activity: parts.activity,
40
- modifier,
41
- extension,
42
- compact: parts.compact
43
- })
44
- }, shape)
31
+ renderTemplate(
32
+ NAME_TEMPLATES[shape],
33
+ {
34
+ op: dateValues(millis),
35
+ log: logValues({
36
+ station: String(parts.stationCall ?? "").trim(),
37
+ ref: parts.ref,
38
+ activity: parts.activity,
39
+ modifier,
40
+ extension,
41
+ compact: parts.compact
42
+ })
43
+ },
44
+ shape
45
+ )
45
46
  );
46
47
  return `${sanitize(stem) || "export"}.${extension}`;
47
48
  }
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
118
118
  const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
119
119
  const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
120
120
  const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
121
- const titleContext = settings.includePrivateData === false ? { ...context, op: { ...context.op, title: "", userTitle: "", userNotes: "", grid: String(context.op.grid ?? "").slice(0, 6) }, log: { ...context.log, modifier: "" } } : context;
121
+ const titleContext = settings.includePrivateData === false ? {
122
+ ...context,
123
+ op: {
124
+ ...context.op,
125
+ title: "",
126
+ userTitle: "",
127
+ userNotes: "",
128
+ grid: String(context.op.grid ?? "").slice(0, 6)
129
+ },
130
+ log: { ...context.log, modifier: "" }
131
+ } : context;
122
132
  const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
123
133
  return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
124
134
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.5.6
1
+ // @ham2k/extension-sdk 0.6.0
2
2
  /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface SvgScene {
4
4
  version: 1;
@@ -394,6 +394,7 @@ export interface LoggingControlDescriptor {
394
394
  key: string;
395
395
  label: string;
396
396
  shortLabel?: string;
397
+ skipFocus?: boolean;
397
398
  icon?: string;
398
399
  color?: string;
399
400
  order?: number;
@@ -634,12 +635,15 @@ export interface ScoreQsosRequest {
634
635
  ref?: Record<string, JSONValue>;
635
636
  resumeFrom?: JSONValue;
636
637
  resumeDay?: number;
638
+ resumeKey?: string;
639
+ checkpointKey?: string;
637
640
  }
638
641
  export interface ScoreQsosResult {
639
642
  qsoScores: Record<string, QsoScoreVerdict>;
640
643
  daySections: DayScoreSummary[];
641
644
  operationSummary: Record<string, ScoreTally>;
642
645
  scoresheet?: JSONValue;
646
+ checkpointHeld?: true;
643
647
  scoresheetDay?: number;
644
648
  }
645
649
  export interface ScoreQsoRequest {
@@ -651,11 +655,13 @@ export interface ScoreQsoRequest {
651
655
  resumeFrom?: JSONValue;
652
656
  resumeDay?: number;
653
657
  qsos?: Record<string, JSONValue>[];
658
+ resumeKey?: string;
654
659
  }
655
660
  export interface QsoScoreNotices {
656
661
  notices: string[];
657
662
  alerts: string[];
658
663
  dupe?: boolean;
664
+ checkpointHeld?: true;
659
665
  }
660
666
  export interface ScoreCandidatesRequest {
661
667
  operation: Record<string, JSONValue>;
@@ -668,8 +674,11 @@ export interface ScoreCandidatesRequest {
668
674
  resumeFrom?: JSONValue;
669
675
  resumeDay?: number;
670
676
  qsos?: Record<string, JSONValue>[];
677
+ resumeKey?: string;
671
678
  }
672
- export type ScoreCandidatesResult = Record<string, QsoScoreNotices>;
679
+ export type ScoreCandidatesResult = Record<string, QsoScoreNotices> & {
680
+ $checkpointHeld?: true;
681
+ };
673
682
  export interface ScoringHook {
674
683
  scoreQsos(args: ScoreQsosRequest, ctx: HookContext): Promise<ScoreQsosResult>;
675
684
  scoreQso?(args: ScoreQsoRequest, ctx: HookContext): Promise<QsoScoreNotices>;
@@ -953,6 +962,7 @@ export interface ExtensionManifest {
953
962
  enabledByDefault?: boolean;
954
963
  hooks?: string[];
955
964
  domains?: string[];
965
+ webSockets?: string[];
956
966
  allowUserAgentOverride?: boolean;
957
967
  requiresLocation?: boolean;
958
968
  requiresRadioRead?: boolean;
@@ -997,6 +1007,29 @@ export interface FetchResponse {
997
1007
  status: number;
998
1008
  body: string;
999
1009
  }
1010
+ export interface WebSocketOptions {
1011
+ protocols?: string[];
1012
+ }
1013
+ export interface WebSocketCloseEvent {
1014
+ code: number;
1015
+ reason: string;
1016
+ wasClean: boolean;
1017
+ }
1018
+ export interface ExtensionWebSocket {
1019
+ readonly url: string;
1020
+ readonly readyState: 0 | 1 | 2 | 3;
1021
+ readonly protocol: string;
1022
+ onopen: (() => void) | null;
1023
+ onmessage: ((event: {
1024
+ data: string | ArrayBuffer;
1025
+ }) => void) | null;
1026
+ onerror: ((event: {
1027
+ message: string;
1028
+ }) => void) | null;
1029
+ onclose: ((event: WebSocketCloseEvent) => void) | null;
1030
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
1031
+ close(code?: number, reason?: string): void;
1032
+ }
1000
1033
  export interface DeviceLocation {
1001
1034
  latitude: number;
1002
1035
  longitude: number;
@@ -1461,6 +1494,8 @@ export declare function isUnexpectedSideband(mode: string | undefined | null, {
1461
1494
  freqKHz?: number;
1462
1495
  band?: string;
1463
1496
  }): boolean;
1497
+ export declare const RESUME_MISSING = "resume-missing";
1498
+ export declare const CHECKPOINT_HELD_KEY = "$checkpointHeld";
1464
1499
  export interface ContestScorerOptions {
1465
1500
  scope?: ScoringScope;
1466
1501
  }
@@ -1527,7 +1562,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
1527
1562
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1528
1563
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1529
1564
  };
1530
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
1565
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
1531
1566
  operation: Record<string, JSONValue>;
1532
1567
  qsos: Record<string, JSONValue>[];
1533
1568
  segments?: OperationSegmentPayload[];
@@ -1693,6 +1728,7 @@ export declare const host: {
1693
1728
  }): Promise<PanelRadioTuneResult>;
1694
1729
  tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
1695
1730
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
1731
+ webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
1696
1732
  kvGet(key: string): Promise<JSONValue | null>;
1697
1733
  getLocation(): Promise<DeviceLocation | null>;
1698
1734
  getVersionInfo(): Promise<VersionInfo>;
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes, bytesToBase64 } from "./base64.js";
1
2
  export * from "./types.js";
2
3
  export * from "./svgScene.js";
3
4
  export * from "./i18n.js";
@@ -38,6 +39,85 @@ function defineExtension(def) {
38
39
  }
39
40
  });
40
41
  }
42
+ const MAX_FRAME_SIZE = 1024 * 1024;
43
+ function utf8Length(text) {
44
+ let length = 0;
45
+ for (const ch of text) {
46
+ const cp = ch.codePointAt(0);
47
+ length += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
48
+ }
49
+ return length;
50
+ }
51
+ function openWebSocket(call, url, options) {
52
+ let readyState = 0;
53
+ let protocol = "";
54
+ const failed = (what) => (e) => kernel().log(`webSocket ${what} on ${url} failed: ${e instanceof Error ? e.message : String(e)}`);
55
+ const socket = {
56
+ url,
57
+ get readyState() {
58
+ return readyState;
59
+ },
60
+ get protocol() {
61
+ return protocol;
62
+ },
63
+ onopen: null,
64
+ onmessage: null,
65
+ onerror: null,
66
+ onclose: null,
67
+ send(data) {
68
+ if (readyState !== 1) throw new Error("WebSocket is not open");
69
+ let frame;
70
+ if (typeof data === "string") {
71
+ if (data.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
72
+ frame = { data };
73
+ } else {
74
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
75
+ if (bytes.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
76
+ frame = { binary: bytesToBase64(bytes) };
77
+ }
78
+ call("webSocketSend", { socketId, ...frame }).catch(failed("send"));
79
+ },
80
+ close(code, reason) {
81
+ if (code !== void 0 && (!Number.isInteger(code) || code !== 1e3 && (code < 3e3 || code > 4999))) {
82
+ throw new Error("close code must be 1000 or 3000-4999");
83
+ }
84
+ if (reason !== void 0 && (typeof reason !== "string" || utf8Length(reason) > 123)) {
85
+ throw new Error("close reason must be a string of at most 123 UTF-8 bytes");
86
+ }
87
+ if (readyState >= 2) return;
88
+ const before = readyState;
89
+ readyState = 2;
90
+ call("webSocketClose", { socketId, ...code !== void 0 ? { code } : {}, ...reason !== void 0 ? { reason } : {} }).catch((e) => {
91
+ if (readyState === 2) readyState = before;
92
+ failed("close")(e);
93
+ });
94
+ }
95
+ };
96
+ if (typeof kernel().registerSocket !== "function") throw new Error("host.webSocket needs a newer version of HaLo");
97
+ const socketId = kernel().registerSocket((event) => {
98
+ switch (event.type) {
99
+ case "open":
100
+ if (readyState !== 0) return;
101
+ readyState = 1;
102
+ protocol = String(event.protocol ?? "");
103
+ return socket.onopen?.();
104
+ case "message": {
105
+ const data = typeof event.binary === "string" ? base64ToBytes(event.binary).buffer : String(event.data);
106
+ return socket.onmessage?.({ data });
107
+ }
108
+ case "error":
109
+ return socket.onerror?.({ message: String(event.message ?? "") });
110
+ case "close":
111
+ readyState = 3;
112
+ return socket.onclose?.({ code: Number(event.code), reason: String(event.reason ?? ""), wasClean: event.wasClean === true });
113
+ }
114
+ });
115
+ call("webSocketOpen", { socketId, url, ...options?.protocols ? { protocols: options.protocols } : {} }).catch((e) => {
116
+ kernel().socketEvent(socketId, JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) }));
117
+ kernel().socketEvent(socketId, JSON.stringify({ type: "close", code: 1006, reason: "", wasClean: false }));
118
+ });
119
+ return socket;
120
+ }
41
121
  const hooks = {
42
122
  /// EVERY hook in the category — including the CALLER's own. A hook that
43
123
  /// delegates within its own category therefore re-enters itself; use
@@ -76,6 +156,11 @@ const host = {
76
156
  async fetch(url, options) {
77
157
  return await hostCallFn()("fetch", { url, ...options });
78
158
  },
159
+ /// A WebSocket to a host the manifest's `webSockets` lists — see
160
+ /// `ExtensionWebSocket`. Returns at once, connecting, as a browser's does.
161
+ webSocket(url, options) {
162
+ return openWebSocket(hostCallFn(), url, options);
163
+ },
79
164
  async kvGet(key) {
80
165
  return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
81
166
  },