@wildwinter/app-shell 0.54.0 → 0.55.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 CHANGED
@@ -248,6 +248,41 @@ host.append(rows.row(`${nodeId}:${decl.name}`, { decl, value, adapter: { nodes:
248
248
  project lists, and the words for a pick's problems (`not-a-choice`,
249
249
  `pick-ambiguous`, `pick-clash`). Needs toolkit 0.2.5.
250
250
 
251
+ ## v0.55 (one live link server)
252
+
253
+ **`@wildwinter/app-shell/live-link`** (main process): the loopback WebSocket
254
+ server a running game connects to, once for the family. Patterpad's and
255
+ Storyletter's were copies; each now plugs its own protocol into this one.
256
+ `ws` (8 or later) is an optional peer the app supplies.
257
+
258
+ ```ts
259
+ import { createLiveLinkServer } from "@wildwinter/app-shell/live-link";
260
+
261
+ const server = createLiveLinkServer<MyFields>({
262
+ port: 4471,
263
+ currentBuildHash: () => projectHash(),
264
+ onStatus: (s) => sendToWindows(s),
265
+ protocol: ({ pushStatus }) => ({
266
+ hello: ({ build, project, msg }) => { /* a run begins: read msg's own fields */ },
267
+ message: (msg) => { /* anything after the hello; pushStatus() if it changes the status */ },
268
+ reset: () => { /* the game went away */ },
269
+ describe: () => ({ /* your fields on a connected status */ }),
270
+ }),
271
+ });
272
+ server.start();
273
+ server.pushBundle(hash, json); // after a save
274
+ ```
275
+
276
+ - Loopback only, and a web page's Origin is refused (`isLocalOrigin`).
277
+ - One game at a time: a new connection replaces the old.
278
+ - Nothing reaches the protocol before a hello, which names the build: `match`,
279
+ `stale`, or `unknown` (no project open).
280
+ - `pushBundle` sends `{ t: "bundle", v: 1, build, data }` only to a game that
281
+ has said hello and runs another build, and shows the build stale until the
282
+ game re-hellos with the new one.
283
+ - A taken port reports an error and leaves the server off. The status is the
284
+ link-status chip's.
285
+
251
286
  ## Roadmap
252
287
 
253
288
  Later slices lift the rest of the common shell from Patterpad (canonical),
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { mountWelcome } from './chunk-XSS7KM6F.js';
2
2
  export { feedUpdaterDownloadProgress, showUpdaterDialog } from './chunk-C245WRPF.js';
3
+ export { EDIT_MENU, FILE_MENU, GO_MENU, PANE_MENU, PLAY_MENU, PUBLISH_MENU, REVIEW_MENU, VIEW_MENU } from './chunk-T25WXWPB.js';
3
4
  export { defaultProblemCopy, describeProblem, problemLine, problemName } from './chunk-4OAORWEM.js';
4
5
  export { PROPERTY_NAME_HINT, PROPERTY_TYPES, RESERVED_PROPERTY_NAMES, bindPropertyName, bindPropertyRef, defaultControl, dupGuard, expandableRow, firstIllegalPropertyName, focusNewRow, isCaseOnlyPropertyName, isValidPropertyName, mountPropertyList, mountSettingsDialog, propertyNameProblem, propertyNameify, propertyRefProblem, revalidatePropertyRefs, revealRow, revealRowWhenReady } from './chunk-5BKGDTYD.js';
5
6
  export { defaultLabel, fallbackText, fieldRow, fieldRows, fieldValueProblem, rungStrip, stageGateNote } from './chunk-XJM733E6.js';
@@ -10,7 +11,6 @@ export { kitTile, openKitGallery } from './chunk-62KWJPRP.js';
10
11
  export { linkAddress, linkStatusClass, linkStatusTip, mountLinkStatus } from './chunk-UQTUCOGV.js';
11
12
  import { isEditableTarget } from './chunk-QVACRWG7.js';
12
13
  export { copyWithFeedback, debounce, formatCount, isEditableTarget, isLocalOrigin, plural, relativeTime } from './chunk-QVACRWG7.js';
13
- export { EDIT_MENU, FILE_MENU, GO_MENU, PANE_MENU, PLAY_MENU, PUBLISH_MENU, REVIEW_MENU, VIEW_MENU } from './chunk-T25WXWPB.js';
14
14
  import { dialogFrame } from './chunk-VXEFIL43.js';
15
15
  export { dialogFrame } from './chunk-VXEFIL43.js';
16
16
  export { createFloating } from './chunk-PFERSFRB.js';
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ var ws = require('ws');
4
+
5
+ // src/live-link.ts
6
+
7
+ // src/util.ts
8
+ function isLocalOrigin(origin) {
9
+ if (origin === void 0 || origin === "") return true;
10
+ let host;
11
+ try {
12
+ host = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(origin) ? origin : `http://${origin}`).hostname;
13
+ } catch {
14
+ return false;
15
+ }
16
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
17
+ }
18
+
19
+ // src/live-link.ts
20
+ function createLiveLinkServer(opts) {
21
+ const { port } = opts;
22
+ let wss = null;
23
+ let client = null;
24
+ let connected = null;
25
+ let clientBuild = null;
26
+ const status = () => {
27
+ if (!wss) return { state: "off" };
28
+ if (!client || !connected) return { state: "listening", port };
29
+ return {
30
+ ...protocol.describe(),
31
+ state: "connected",
32
+ port,
33
+ build: connected.build,
34
+ ...connected.project !== void 0 ? { project: connected.project } : {}
35
+ };
36
+ };
37
+ const push = () => opts.onStatus(status());
38
+ const pushStatus = () => {
39
+ if (connected) push();
40
+ };
41
+ const protocol = opts.protocol({ pushStatus });
42
+ const forget = () => {
43
+ connected = null;
44
+ clientBuild = null;
45
+ protocol.reset();
46
+ };
47
+ const handleMessage = (raw) => {
48
+ let msg;
49
+ try {
50
+ msg = JSON.parse(raw);
51
+ } catch {
52
+ return;
53
+ }
54
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) return;
55
+ const m = msg;
56
+ if (m["t"] !== "hello") {
57
+ if (connected) protocol.message(m);
58
+ return;
59
+ }
60
+ clientBuild = typeof m["build"] === "string" ? m["build"] : null;
61
+ const current = opts.currentBuildHash();
62
+ const build = current === null ? "unknown" : clientBuild === current ? "match" : "stale";
63
+ const project = typeof m["project"] === "string" ? m["project"] : void 0;
64
+ connected = { build, ...project !== void 0 ? { project } : {} };
65
+ protocol.hello({ build, ...project !== void 0 ? { project } : {}, msg: m });
66
+ push();
67
+ };
68
+ return {
69
+ isOn: () => wss !== null,
70
+ status,
71
+ pushStatus,
72
+ pushBundle(build, data) {
73
+ if (!client || !connected) return;
74
+ if (clientBuild === build) return;
75
+ try {
76
+ client.send(JSON.stringify({ t: "bundle", v: 1, build, data }));
77
+ } catch {
78
+ }
79
+ if (connected.build !== "stale") {
80
+ connected = { ...connected, build: "stale" };
81
+ push();
82
+ }
83
+ },
84
+ start() {
85
+ if (wss) return;
86
+ try {
87
+ wss = new ws.WebSocketServer({
88
+ host: "127.0.0.1",
89
+ port,
90
+ verifyClient: (info) => isLocalOrigin(info.origin)
91
+ }, () => push());
92
+ } catch (e) {
93
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
94
+ wss = null;
95
+ return;
96
+ }
97
+ wss.on("error", (e) => {
98
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
99
+ if (!client) {
100
+ const s = wss;
101
+ wss = null;
102
+ try {
103
+ s?.close();
104
+ } catch {
105
+ }
106
+ }
107
+ });
108
+ wss.on("connection", (ws) => {
109
+ if (client) {
110
+ try {
111
+ client.close();
112
+ } catch {
113
+ }
114
+ }
115
+ client = ws;
116
+ forget();
117
+ ws.on("message", (data) => handleMessage(data.toString()));
118
+ ws.on("close", () => {
119
+ if (client === ws) {
120
+ client = null;
121
+ forget();
122
+ push();
123
+ }
124
+ });
125
+ ws.on("error", () => {
126
+ });
127
+ push();
128
+ });
129
+ },
130
+ stop() {
131
+ try {
132
+ client?.close();
133
+ } catch {
134
+ }
135
+ client = null;
136
+ forget();
137
+ const s = wss;
138
+ wss = null;
139
+ try {
140
+ s?.close();
141
+ } catch {
142
+ }
143
+ push();
144
+ }
145
+ };
146
+ }
147
+
148
+ exports.createLiveLinkServer = createLiveLinkServer;
149
+ //# sourceMappingURL=live-link.cjs.map
150
+ //# sourceMappingURL=live-link.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util.ts","../src/live-link.ts"],"names":["WebSocketServer"],"mappings":";;;;;;;AA0HO,SAAS,cAAc,MAAA,EAAqC;AACjE,EAAA,IAAI,MAAA,KAAW,MAAA,IAAa,MAAA,KAAW,EAAA,EAAI,OAAO,IAAA;AAClD,EAAA,IAAI,IAAA;AACJ,EAAA,IAAI;AAEF,IAAA,IAAA,GAAO,IAAI,GAAA,CAAI,0BAAA,CAA2B,IAAA,CAAK,MAAM,IAAI,MAAA,GAAS,CAAA,OAAA,EAAU,MAAM,CAAA,CAAE,CAAA,CAAE,QAAA;AAAA,EACxF,CAAA,CAAA,MAAQ;AAAE,IAAA,OAAO,KAAA;AAAA,EAAO;AACxB,EAAA,OAAO,IAAA,KAAS,WAAA,IAAe,IAAA,KAAS,WAAA,IAAe,IAAA,KAAS,OAAA;AAClE;;;ACxCO,SAAS,qBAAgD,IAAA,EAAmD;AACjH,EAAA,MAAM,EAAE,MAAK,GAAI,IAAA;AACjB,EAAA,IAAI,GAAA,GAA8B,IAAA;AAClC,EAAA,IAAI,MAAA,GAA2B,IAAA;AAE/B,EAAA,IAAI,SAAA,GAA+D,IAAA;AACnE,EAAA,IAAI,WAAA,GAA6B,IAAA;AAEjC,EAAA,MAAM,SAAS,MAA+B;AAC5C,IAAA,IAAI,CAAC,GAAA,EAAK,OAAO,EAAE,OAAO,KAAA,EAAM;AAChC,IAAA,IAAI,CAAC,UAAU,CAAC,SAAA,SAAkB,EAAE,KAAA,EAAO,aAAa,IAAA,EAAK;AAC7D,IAAA,OAAO;AAAA,MACL,GAAG,SAAS,QAAA,EAAS;AAAA,MACrB,KAAA,EAAO,WAAA;AAAA,MAAa,IAAA;AAAA,MAAM,OAAO,SAAA,CAAU,KAAA;AAAA,MAC3C,GAAI,UAAU,OAAA,KAAY,MAAA,GAAY,EAAE,OAAA,EAAS,SAAA,CAAU,OAAA,EAAQ,GAAI;AAAC,KAC1E;AAAA,EACF,CAAA;AACA,EAAA,MAAM,IAAA,GAAO,MAAY,IAAA,CAAK,QAAA,CAAS,QAAQ,CAAA;AAC/C,EAAA,MAAM,aAAa,MAAY;AAAE,IAAA,IAAI,WAAW,IAAA,EAAK;AAAA,EAAG,CAAA;AACxD,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,QAAA,CAAS,EAAE,YAAY,CAAA;AAE7C,EAAA,MAAM,SAAS,MAAY;AAAE,IAAA,SAAA,GAAY,IAAA;AAAM,IAAA,WAAA,GAAc,IAAA;AAAM,IAAA,QAAA,CAAS,KAAA,EAAM;AAAA,EAAG,CAAA;AAErF,EAAA,MAAM,aAAA,GAAgB,CAAC,GAAA,KAAsB;AAC3C,IAAA,IAAI,GAAA;AACJ,IAAA,IAAI;AAAE,MAAA,GAAA,GAAM,IAAA,CAAK,MAAM,GAAG,CAAA;AAAA,IAAG,CAAA,CAAA,MAAQ;AAAE,MAAA;AAAA,IAAQ;AAC/C,IAAA,IAAI,GAAA,KAAQ,QAAQ,OAAO,GAAA,KAAQ,YAAY,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,EAAG;AACnE,IAAA,MAAM,CAAA,GAAI,GAAA;AACV,IAAA,IAAI,CAAA,CAAE,GAAG,CAAA,KAAM,OAAA,EAAS;AACtB,MAAA,IAAI,SAAA,EAAW,QAAA,CAAS,OAAA,CAAQ,CAAC,CAAA;AACjC,MAAA;AAAA,IACF;AACA,IAAA,WAAA,GAAc,OAAO,CAAA,CAAE,OAAO,MAAM,QAAA,GAAW,CAAA,CAAE,OAAO,CAAA,GAAI,IAAA;AAC5D,IAAA,MAAM,OAAA,GAAU,KAAK,gBAAA,EAAiB;AACtC,IAAA,MAAM,QAAuB,OAAA,KAAY,IAAA,GAAO,SAAA,GAAY,WAAA,KAAgB,UAAU,OAAA,GAAU,OAAA;AAChG,IAAA,MAAM,OAAA,GAAU,OAAO,CAAA,CAAE,SAAS,MAAM,QAAA,GAAW,CAAA,CAAE,SAAS,CAAA,GAAI,MAAA;AAClE,IAAA,SAAA,GAAY,EAAE,OAAO,GAAI,OAAA,KAAY,SAAY,EAAE,OAAA,EAAQ,GAAI,EAAC,EAAG;AACnE,IAAA,QAAA,CAAS,KAAA,CAAM,EAAE,KAAA,EAAO,GAAI,OAAA,KAAY,MAAA,GAAY,EAAE,OAAA,EAAQ,GAAI,EAAC,EAAI,GAAA,EAAK,GAAG,CAAA;AAC/E,IAAA,IAAA,EAAK;AAAA,EACP,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,MAAM,GAAA,KAAQ,IAAA;AAAA,IACpB,MAAA;AAAA,IACA,UAAA;AAAA,IACA,UAAA,CAAW,OAAe,IAAA,EAAoB;AAC5C,MAAA,IAAI,CAAC,MAAA,IAAU,CAAC,SAAA,EAAW;AAC3B,MAAA,IAAI,gBAAgB,KAAA,EAAO;AAC3B,MAAA,IAAI;AAAE,QAAA,MAAA,CAAO,IAAA,CAAK,IAAA,CAAK,SAAA,CAAU,EAAE,CAAA,EAAG,QAAA,EAAU,CAAA,EAAG,CAAA,EAAG,KAAA,EAAO,IAAA,EAAM,CAAC,CAAA;AAAA,MAAG,CAAA,CAAA,MAAQ;AAAA,MAAyB;AAGxG,MAAA,IAAI,SAAA,CAAU,UAAU,OAAA,EAAS;AAAE,QAAA,SAAA,GAAY,EAAE,GAAG,SAAA,EAAW,KAAA,EAAO,OAAA,EAAQ;AAAG,QAAA,IAAA,EAAK;AAAA,MAAG;AAAA,IAC3F,CAAA;AAAA,IACA,KAAA,GAAc;AACZ,MAAA,IAAI,GAAA,EAAK;AACT,MAAA,IAAI;AAIF,QAAA,GAAA,GAAM,IAAIA,kBAAA,CAAgB;AAAA,UACxB,IAAA,EAAM,WAAA;AAAA,UAAa,IAAA;AAAA,UACnB,YAAA,EAAc,CAAC,IAAA,KAA8B,aAAA,CAAc,KAAK,MAAM;AAAA,SACxE,EAAG,MAAM,IAAA,EAAM,CAAA;AAAA,MACjB,SAAS,CAAA,EAAG;AACV,QAAA,IAAA,CAAK,QAAA,CAAS,EAAE,KAAA,EAAO,OAAA,EAAS,OAAA,EAAS,CAAA,YAAa,KAAA,GAAQ,CAAA,CAAE,OAAA,GAAU,MAAA,CAAO,CAAC,CAAA,EAAG,CAAA;AACrF,QAAA,GAAA,GAAM,IAAA;AACN,QAAA;AAAA,MACF;AACA,MAAA,GAAA,CAAI,EAAA,CAAG,OAAA,EAAS,CAAC,CAAA,KAAM;AACrB,QAAA,IAAA,CAAK,QAAA,CAAS,EAAE,KAAA,EAAO,OAAA,EAAS,OAAA,EAAS,CAAA,YAAa,KAAA,GAAQ,CAAA,CAAE,OAAA,GAAU,MAAA,CAAO,CAAC,CAAA,EAAG,CAAA;AAGrF,QAAA,IAAI,CAAC,MAAA,EAAQ;AAAE,UAAA,MAAM,CAAA,GAAI,GAAA;AAAK,UAAA,GAAA,GAAM,IAAA;AAAM,UAAA,IAAI;AAAE,YAAA,CAAA,EAAG,KAAA,EAAM;AAAA,UAAG,CAAA,CAAA,MAAQ;AAAA,UAAwB;AAAA,QAAE;AAAA,MAChG,CAAC,CAAA;AACD,MAAA,GAAA,CAAI,EAAA,CAAG,YAAA,EAAc,CAAC,EAAA,KAAO;AAE3B,QAAA,IAAI,MAAA,EAAQ;AAAE,UAAA,IAAI;AAAE,YAAA,MAAA,CAAO,KAAA,EAAM;AAAA,UAAG,CAAA,CAAA,MAAQ;AAAA,UAAa;AAAA,QAAE;AAC3D,QAAA,MAAA,GAAS,EAAA;AAAI,QAAA,MAAA,EAAO;AACpB,QAAA,EAAA,CAAG,EAAA,CAAG,WAAW,CAAC,IAAA,KAAS,cAAc,IAAA,CAAK,QAAA,EAAU,CAAC,CAAA;AACzD,QAAA,EAAA,CAAG,EAAA,CAAG,SAAS,MAAM;AAAE,UAAA,IAAI,WAAW,EAAA,EAAI;AAAE,YAAA,MAAA,GAAS,IAAA;AAAM,YAAA,MAAA,EAAO;AAAG,YAAA,IAAA,EAAK;AAAA,UAAG;AAAA,QAAE,CAAC,CAAA;AAChF,QAAA,EAAA,CAAG,EAAA,CAAG,SAAS,MAAM;AAAA,QAAqD,CAAC,CAAA;AAC3E,QAAA,IAAA,EAAK;AAAA,MACP,CAAC,CAAA;AAAA,IACH,CAAA;AAAA,IACA,IAAA,GAAa;AACX,MAAA,IAAI;AAAE,QAAA,MAAA,EAAQ,KAAA,EAAM;AAAA,MAAG,CAAA,CAAA,MAAQ;AAAA,MAAa;AAC5C,MAAA,MAAA,GAAS,IAAA;AAAM,MAAA,MAAA,EAAO;AACtB,MAAA,MAAM,CAAA,GAAI,GAAA;AAAK,MAAA,GAAA,GAAM,IAAA;AACrB,MAAA,IAAI;AAAE,QAAA,CAAA,EAAG,KAAA,EAAM;AAAA,MAAG,CAAA,CAAA,MAAQ;AAAA,MAAwB;AAClD,MAAA,IAAA,EAAK;AAAA,IACP;AAAA,GACF;AACF","file":"live-link.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The small idioms both apps wrote by hand and never gave a name: the plural\n// suffix (about forty sites), the debounce (eight copies), \"is the focus in a\n// field\" (four copies that disagreed about <select>), the count with grouping\n// separators, clipboard copy with the `.copied` revert (three timings), and\n// relative time (two wordings), and which connection to a live link is this\n// machine's own (isLocalOrigin, two copies). One answer each, here.\n//\n// DOM-free except where the idiom is about the DOM (isEditableTarget,\n// copyWithFeedback), so main-process code can import the rest.\n// ---------------------------------------------------------------------------\n\n/** \"1 link\" / \"3 links\". The plural form defaults to `noun + \"s\"`; pass the\n * irregular one (\"entry\", \"entries\") when that is wrong. */\nexport function plural(n: number, noun: string, pluralForm = `${noun}s`): string {\n return `${n} ${n === 1 ? noun : pluralForm}`;\n}\n\nexport interface Debounced<A extends unknown[]> {\n (...args: A): void;\n /** Drop the pending call. */\n cancel(): void;\n /** Run the pending call now, if there is one. */\n flush(): void;\n}\n\n/** Trailing-edge debounce: the call runs `ms` after the LAST call. */\nexport function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number): Debounced<A> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let pending: A | undefined;\n const run = (): void => {\n timer = undefined;\n if (pending === undefined) return;\n const args = pending;\n pending = undefined;\n fn(...args);\n };\n const debounced = ((...args: A): void => {\n pending = args;\n if (timer !== undefined) clearTimeout(timer);\n timer = setTimeout(run, ms);\n }) as Debounced<A>;\n debounced.cancel = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; pending = undefined; };\n debounced.flush = () => { if (timer !== undefined) clearTimeout(timer); run(); };\n return debounced;\n}\n\n/**\n * Is the keyboard's focus in a field? The one agreed answer: an input, a\n * textarea, a select, or anything contenteditable (the attribute is read as\n * well as `isContentEditable`, which jsdom does not implement). A window-level\n * shortcut checks this before acting, so a bare letter typed into a field\n * stays a letter.\n */\nexport function isEditableTarget(t: EventTarget | null | undefined): boolean {\n if (!(t instanceof Element)) return false;\n if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t instanceof HTMLSelectElement) return true;\n if (t instanceof HTMLElement && t.isContentEditable === true) return true;\n const attr = t.getAttribute(\"contenteditable\");\n return attr !== null && attr !== \"false\";\n}\n\n/** A count for reading: grouping separators in the user's locale. */\nexport function formatCount(n: number): string {\n return n.toLocaleString();\n}\n\nconst copyTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>();\n\n/**\n * Copy `text` to the clipboard and light `el` with `.copied` for `ms` (the\n * family's 1000). A second click restarts the timer rather than stacking a\n * second revert under the first. Resolves when the clipboard write has.\n */\nexport function copyWithFeedback(el: Element, text: string, ms = 1000): Promise<void> {\n const write = typeof navigator !== \"undefined\" && navigator.clipboard\n ? navigator.clipboard.writeText(text).catch(() => {})\n : Promise.resolve();\n el.classList.add(\"copied\");\n const prior = copyTimers.get(el);\n if (prior !== undefined) clearTimeout(prior);\n copyTimers.set(el, setTimeout(() => { el.classList.remove(\"copied\"); copyTimers.delete(el); }, ms));\n return write;\n}\n\n/**\n * How long ago, in one wording: \"just now\", \"4 minutes ago\", \"an hour ago\",\n * \"3 hours ago\", \"yesterday\", \"5 days ago\", and past a week the date itself.\n * Coarse on purpose: the question a reader has is whether the thing is from\n * this sitting, not how many seconds ago it was. A future date is \"just now\".\n */\nexport function relativeTime(date: Date | number | string, now: number = Date.now()): string {\n const then = date instanceof Date ? date.getTime() : new Date(date).getTime();\n if (!Number.isFinite(then)) return \"\";\n const mins = Math.floor((now - then) / 60000);\n if (mins < 1) return \"just now\";\n if (mins < 60) return `${plural(mins, \"minute\")} ago`;\n const hours = Math.floor(mins / 60);\n if (hours === 1) return \"an hour ago\";\n if (hours < 24) return `${hours} hours ago`;\n const days = Math.floor(hours / 24);\n if (days === 1) return \"yesterday\";\n if (days < 7) return `${days} days ago`;\n return new Date(then).toLocaleDateString(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" });\n}\n\n/**\n * Whether a WebSocket upgrade's Origin is this machine, so a game may take a\n * live link's slot (Patterpad's Live Link and Storyletter's, which listen on\n * loopback). A web page in any browser can open a socket to localhost, and the\n * browser stamps the upgrade with the page's Origin, so the Origin is the check:\n *\n * - no Origin: Unity's ClientWebSocket, Godot and Node send none;\n * - a loopback host, literally `localhost`, `127.0.0.1` or `[::1]`, with or\n * without a scheme and port: Unreal's libwebsockets sends the bare address\n * (`127.0.0.1`), and a browser game on a local dev server sends\n * `http://localhost:5173`;\n * - anything else is refused: another site, `null` (a file:// page sends it,\n * but so does a sandboxed frame on any site), and a name that only resolves\n * to loopback (`127.0.0.1.nip.io`), since the Origin names the domain, not\n * the address.\n */\nexport function isLocalOrigin(origin: string | undefined): boolean {\n if (origin === undefined || origin === \"\") return true;\n let host: string;\n try {\n // A browser sends scheme://host[:port]; Unreal sends a bare host, which URL would misread as a scheme.\n host = new URL(/^[a-z][a-z0-9+.-]*:\\/\\//i.test(origin) ? origin : `http://${origin}`).hostname;\n } catch { return false; }\n return host === \"localhost\" || host === \"127.0.0.1\" || host === \"[::1]\";\n}\n","// ---------------------------------------------------------------------------\n// The live link server: the loopback WebSocket server, in the main process,\n// that a running game connects to. Patterpad's debug-link.ts and Storyletter's\n// live-link.ts were this file twice (the family parity review of 2026-10, item\n// 14), so the parts that were the same are here once and each app plugs in its\n// own protocol:\n//\n// - loopback only, and refused to any Origin that isn't this machine\n// (isLocalOrigin), so a web page can't take the slot;\n// - one game at a time: a new connection replaces the old;\n// - nothing is honoured before a hello, which names the build the game runs,\n// judged against the project's (match / stale / unknown);\n// - the status off -> listening -> connected, or error, pushed to the app;\n// - pushBundle, gated on the build the game reported, and the build shown as\n// stale until the game re-hellos with the one it was sent;\n// - a bind failure (the port is taken) leaves the server off, not dead \"on\".\n//\n// What the game sends after its hello (Patterpad's frames, Storyletter's\n// boards and traces), and what the app adds to the connected status, is the\n// protocol's. Main-process code: `ws` is an optional peer the app supplies.\n// The chip that shows the status is link-status.ts.\n// ---------------------------------------------------------------------------\n\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { isLocalOrigin } from \"./util.js\";\n\n/** How the game's build compares with the project's. */\nexport type LiveLinkBuild = \"match\" | \"stale\" | \"unknown\";\n\n/** One parsed message from the game: a JSON object with its `t`. */\nexport type LiveLinkMessage = Record<string, unknown>;\n\n/** The server's status, with the protocol's own fields on `connected`. */\nexport type LiveLinkServerStatus<X extends object = object> =\n | { state: \"off\" }\n | { state: \"error\"; message: string }\n | { state: \"listening\"; port: number }\n | ({ state: \"connected\"; port: number; project?: string; build: LiveLinkBuild } & X);\n\n/** A hello, as the server read it. */\nexport interface LiveLinkHello {\n /** The game's build judged against the project's. */\n build: LiveLinkBuild;\n /** What the game said it is, if it did. */\n project?: string;\n /** The whole message, for the protocol's own fields (flows, boxes). */\n msg: LiveLinkMessage;\n}\n\n/** An app's half of the link. */\nexport interface LiveLinkProtocol<X extends object = object> {\n /** A hello landed: a run begins, or a pushed bundle took. The status is\n * pushed after it returns. */\n hello(hello: LiveLinkHello): void;\n /** Any other message, once the game has said hello. Call `pushStatus` when\n * it changes what the status shows. */\n message(msg: LiveLinkMessage): void;\n /** The game went away, was replaced, or the server stopped: forget the run. */\n reset(): void;\n /** The protocol's fields on a connected status. */\n describe(): X;\n}\n\nexport interface LiveLinkServerOptions<X extends object = object> {\n /** Each app has its own, so both editors can listen on one machine. */\n port: number;\n /** The project's current compiled hash, read at each hello; null with no\n * project open, which makes the build \"unknown\". */\n currentBuildHash: () => string | null;\n /** Make the protocol. `pushStatus` sends the status again while a game is\n * connected (before that, nothing the protocol adds is shown). */\n protocol: (link: { pushStatus(): void }) => LiveLinkProtocol<X>;\n /** The latest status, for the app's windows and menu. */\n onStatus: (status: LiveLinkServerStatus<X>) => void;\n}\n\nexport interface LiveLinkServer<X extends object = object> {\n start(): void;\n stop(): void;\n isOn(): boolean;\n status(): LiveLinkServerStatus<X>;\n /** Send the status again while a game is connected: something the protocol\n * shows changed outside a message (the app followed another flow). */\n pushStatus(): void;\n /** Push a freshly compiled bundle to the connected game. Nothing happens when\n * no game has said hello, or when it already runs this build. The game\n * applies it and re-hellos with the new build; until then it shows stale. */\n pushBundle(build: string, data: string): void;\n}\n\nexport function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X> {\n const { port } = opts;\n let wss: WebSocketServer | null = null;\n let client: WebSocket | null = null;\n // Set by a hello: nothing else is honoured until it is.\n let connected: { project?: string; build: LiveLinkBuild } | null = null;\n let clientBuild: string | null = null; // the build the game reported: gates pushBundle\n\n const status = (): LiveLinkServerStatus<X> => {\n if (!wss) return { state: \"off\" };\n if (!client || !connected) return { state: \"listening\", port };\n return {\n ...protocol.describe(),\n state: \"connected\", port, build: connected.build,\n ...(connected.project !== undefined ? { project: connected.project } : {}),\n };\n };\n const push = (): void => opts.onStatus(status());\n const pushStatus = (): void => { if (connected) push(); };\n const protocol = opts.protocol({ pushStatus });\n\n const forget = (): void => { connected = null; clientBuild = null; protocol.reset(); };\n\n const handleMessage = (raw: string): void => {\n let msg: unknown;\n try { msg = JSON.parse(raw); } catch { return; }\n if (msg === null || typeof msg !== \"object\" || Array.isArray(msg)) return;\n const m = msg as LiveLinkMessage;\n if (m[\"t\"] !== \"hello\") {\n if (connected) protocol.message(m);\n return;\n }\n clientBuild = typeof m[\"build\"] === \"string\" ? m[\"build\"] : null;\n const current = opts.currentBuildHash();\n const build: LiveLinkBuild = current === null ? \"unknown\" : clientBuild === current ? \"match\" : \"stale\";\n const project = typeof m[\"project\"] === \"string\" ? m[\"project\"] : undefined;\n connected = { build, ...(project !== undefined ? { project } : {}) };\n protocol.hello({ build, ...(project !== undefined ? { project } : {}), msg: m });\n push();\n };\n\n return {\n isOn: () => wss !== null,\n status,\n pushStatus,\n pushBundle(build: string, data: string): void {\n if (!client || !connected) return; // nothing connected, or no hello yet\n if (clientBuild === build) return; // the game already runs this build\n try { client.send(JSON.stringify({ t: \"bundle\", v: 1, build, data })); } catch { /* socket went away */ }\n // Until the game re-hellos with the new build it is on the old one, and\n // the chip says so rather than keep the match it had at the hello.\n if (connected.build !== \"stale\") { connected = { ...connected, build: \"stale\" }; push(); }\n },\n start(): void {\n if (wss) return;\n try {\n // Loopback only: only processes on this machine can reach it. And not\n // a web page on it: any site can open a WebSocket to localhost, and the\n // browser stamps the upgrade with the page's Origin.\n wss = new WebSocketServer({\n host: \"127.0.0.1\", port,\n verifyClient: (info: { origin?: string }) => isLocalOrigin(info.origin),\n }, () => push()); // \"listening\" once actually bound\n } catch (e) {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n wss = null;\n return;\n }\n wss.on(\"error\", (e) => {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n // A bind failure leaves no server behind: the next click starts again\n // rather than finding a dead one \"on\".\n if (!client) { const s = wss; wss = null; try { s?.close(); } catch { /* already closing */ } }\n });\n wss.on(\"connection\", (ws) => {\n // One game at a time: a new connection replaces the old.\n if (client) { try { client.close(); } catch { /* gone */ } }\n client = ws; forget();\n ws.on(\"message\", (data) => handleMessage(data.toString()));\n ws.on(\"close\", () => { if (client === ws) { client = null; forget(); push(); } });\n ws.on(\"error\", () => { /* a flaky client must not take the editor down */ });\n push();\n });\n },\n stop(): void {\n try { client?.close(); } catch { /* gone */ }\n client = null; forget();\n const s = wss; wss = null;\n try { s?.close(); } catch { /* already closing */ }\n push();\n },\n };\n}\n"]}
@@ -0,0 +1,71 @@
1
+ /** How the game's build compares with the project's. */
2
+ type LiveLinkBuild = "match" | "stale" | "unknown";
3
+ /** One parsed message from the game: a JSON object with its `t`. */
4
+ type LiveLinkMessage = Record<string, unknown>;
5
+ /** The server's status, with the protocol's own fields on `connected`. */
6
+ type LiveLinkServerStatus<X extends object = object> = {
7
+ state: "off";
8
+ } | {
9
+ state: "error";
10
+ message: string;
11
+ } | {
12
+ state: "listening";
13
+ port: number;
14
+ } | ({
15
+ state: "connected";
16
+ port: number;
17
+ project?: string;
18
+ build: LiveLinkBuild;
19
+ } & X);
20
+ /** A hello, as the server read it. */
21
+ interface LiveLinkHello {
22
+ /** The game's build judged against the project's. */
23
+ build: LiveLinkBuild;
24
+ /** What the game said it is, if it did. */
25
+ project?: string;
26
+ /** The whole message, for the protocol's own fields (flows, boxes). */
27
+ msg: LiveLinkMessage;
28
+ }
29
+ /** An app's half of the link. */
30
+ interface LiveLinkProtocol<X extends object = object> {
31
+ /** A hello landed: a run begins, or a pushed bundle took. The status is
32
+ * pushed after it returns. */
33
+ hello(hello: LiveLinkHello): void;
34
+ /** Any other message, once the game has said hello. Call `pushStatus` when
35
+ * it changes what the status shows. */
36
+ message(msg: LiveLinkMessage): void;
37
+ /** The game went away, was replaced, or the server stopped: forget the run. */
38
+ reset(): void;
39
+ /** The protocol's fields on a connected status. */
40
+ describe(): X;
41
+ }
42
+ interface LiveLinkServerOptions<X extends object = object> {
43
+ /** Each app has its own, so both editors can listen on one machine. */
44
+ port: number;
45
+ /** The project's current compiled hash, read at each hello; null with no
46
+ * project open, which makes the build "unknown". */
47
+ currentBuildHash: () => string | null;
48
+ /** Make the protocol. `pushStatus` sends the status again while a game is
49
+ * connected (before that, nothing the protocol adds is shown). */
50
+ protocol: (link: {
51
+ pushStatus(): void;
52
+ }) => LiveLinkProtocol<X>;
53
+ /** The latest status, for the app's windows and menu. */
54
+ onStatus: (status: LiveLinkServerStatus<X>) => void;
55
+ }
56
+ interface LiveLinkServer<X extends object = object> {
57
+ start(): void;
58
+ stop(): void;
59
+ isOn(): boolean;
60
+ status(): LiveLinkServerStatus<X>;
61
+ /** Send the status again while a game is connected: something the protocol
62
+ * shows changed outside a message (the app followed another flow). */
63
+ pushStatus(): void;
64
+ /** Push a freshly compiled bundle to the connected game. Nothing happens when
65
+ * no game has said hello, or when it already runs this build. The game
66
+ * applies it and re-hellos with the new build; until then it shows stale. */
67
+ pushBundle(build: string, data: string): void;
68
+ }
69
+ declare function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X>;
70
+
71
+ export { type LiveLinkBuild, type LiveLinkHello, type LiveLinkMessage, type LiveLinkProtocol, type LiveLinkServer, type LiveLinkServerOptions, type LiveLinkServerStatus, createLiveLinkServer };
@@ -0,0 +1,71 @@
1
+ /** How the game's build compares with the project's. */
2
+ type LiveLinkBuild = "match" | "stale" | "unknown";
3
+ /** One parsed message from the game: a JSON object with its `t`. */
4
+ type LiveLinkMessage = Record<string, unknown>;
5
+ /** The server's status, with the protocol's own fields on `connected`. */
6
+ type LiveLinkServerStatus<X extends object = object> = {
7
+ state: "off";
8
+ } | {
9
+ state: "error";
10
+ message: string;
11
+ } | {
12
+ state: "listening";
13
+ port: number;
14
+ } | ({
15
+ state: "connected";
16
+ port: number;
17
+ project?: string;
18
+ build: LiveLinkBuild;
19
+ } & X);
20
+ /** A hello, as the server read it. */
21
+ interface LiveLinkHello {
22
+ /** The game's build judged against the project's. */
23
+ build: LiveLinkBuild;
24
+ /** What the game said it is, if it did. */
25
+ project?: string;
26
+ /** The whole message, for the protocol's own fields (flows, boxes). */
27
+ msg: LiveLinkMessage;
28
+ }
29
+ /** An app's half of the link. */
30
+ interface LiveLinkProtocol<X extends object = object> {
31
+ /** A hello landed: a run begins, or a pushed bundle took. The status is
32
+ * pushed after it returns. */
33
+ hello(hello: LiveLinkHello): void;
34
+ /** Any other message, once the game has said hello. Call `pushStatus` when
35
+ * it changes what the status shows. */
36
+ message(msg: LiveLinkMessage): void;
37
+ /** The game went away, was replaced, or the server stopped: forget the run. */
38
+ reset(): void;
39
+ /** The protocol's fields on a connected status. */
40
+ describe(): X;
41
+ }
42
+ interface LiveLinkServerOptions<X extends object = object> {
43
+ /** Each app has its own, so both editors can listen on one machine. */
44
+ port: number;
45
+ /** The project's current compiled hash, read at each hello; null with no
46
+ * project open, which makes the build "unknown". */
47
+ currentBuildHash: () => string | null;
48
+ /** Make the protocol. `pushStatus` sends the status again while a game is
49
+ * connected (before that, nothing the protocol adds is shown). */
50
+ protocol: (link: {
51
+ pushStatus(): void;
52
+ }) => LiveLinkProtocol<X>;
53
+ /** The latest status, for the app's windows and menu. */
54
+ onStatus: (status: LiveLinkServerStatus<X>) => void;
55
+ }
56
+ interface LiveLinkServer<X extends object = object> {
57
+ start(): void;
58
+ stop(): void;
59
+ isOn(): boolean;
60
+ status(): LiveLinkServerStatus<X>;
61
+ /** Send the status again while a game is connected: something the protocol
62
+ * shows changed outside a message (the app followed another flow). */
63
+ pushStatus(): void;
64
+ /** Push a freshly compiled bundle to the connected game. Nothing happens when
65
+ * no game has said hello, or when it already runs this build. The game
66
+ * applies it and re-hellos with the new build; until then it shows stale. */
67
+ pushBundle(build: string, data: string): void;
68
+ }
69
+ declare function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X>;
70
+
71
+ export { type LiveLinkBuild, type LiveLinkHello, type LiveLinkMessage, type LiveLinkProtocol, type LiveLinkServer, type LiveLinkServerOptions, type LiveLinkServerStatus, createLiveLinkServer };
@@ -0,0 +1,134 @@
1
+ import { isLocalOrigin } from './chunk-QVACRWG7.js';
2
+ import { WebSocketServer } from 'ws';
3
+
4
+ function createLiveLinkServer(opts) {
5
+ const { port } = opts;
6
+ let wss = null;
7
+ let client = null;
8
+ let connected = null;
9
+ let clientBuild = null;
10
+ const status = () => {
11
+ if (!wss) return { state: "off" };
12
+ if (!client || !connected) return { state: "listening", port };
13
+ return {
14
+ ...protocol.describe(),
15
+ state: "connected",
16
+ port,
17
+ build: connected.build,
18
+ ...connected.project !== void 0 ? { project: connected.project } : {}
19
+ };
20
+ };
21
+ const push = () => opts.onStatus(status());
22
+ const pushStatus = () => {
23
+ if (connected) push();
24
+ };
25
+ const protocol = opts.protocol({ pushStatus });
26
+ const forget = () => {
27
+ connected = null;
28
+ clientBuild = null;
29
+ protocol.reset();
30
+ };
31
+ const handleMessage = (raw) => {
32
+ let msg;
33
+ try {
34
+ msg = JSON.parse(raw);
35
+ } catch {
36
+ return;
37
+ }
38
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) return;
39
+ const m = msg;
40
+ if (m["t"] !== "hello") {
41
+ if (connected) protocol.message(m);
42
+ return;
43
+ }
44
+ clientBuild = typeof m["build"] === "string" ? m["build"] : null;
45
+ const current = opts.currentBuildHash();
46
+ const build = current === null ? "unknown" : clientBuild === current ? "match" : "stale";
47
+ const project = typeof m["project"] === "string" ? m["project"] : void 0;
48
+ connected = { build, ...project !== void 0 ? { project } : {} };
49
+ protocol.hello({ build, ...project !== void 0 ? { project } : {}, msg: m });
50
+ push();
51
+ };
52
+ return {
53
+ isOn: () => wss !== null,
54
+ status,
55
+ pushStatus,
56
+ pushBundle(build, data) {
57
+ if (!client || !connected) return;
58
+ if (clientBuild === build) return;
59
+ try {
60
+ client.send(JSON.stringify({ t: "bundle", v: 1, build, data }));
61
+ } catch {
62
+ }
63
+ if (connected.build !== "stale") {
64
+ connected = { ...connected, build: "stale" };
65
+ push();
66
+ }
67
+ },
68
+ start() {
69
+ if (wss) return;
70
+ try {
71
+ wss = new WebSocketServer({
72
+ host: "127.0.0.1",
73
+ port,
74
+ verifyClient: (info) => isLocalOrigin(info.origin)
75
+ }, () => push());
76
+ } catch (e) {
77
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
78
+ wss = null;
79
+ return;
80
+ }
81
+ wss.on("error", (e) => {
82
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
83
+ if (!client) {
84
+ const s = wss;
85
+ wss = null;
86
+ try {
87
+ s?.close();
88
+ } catch {
89
+ }
90
+ }
91
+ });
92
+ wss.on("connection", (ws) => {
93
+ if (client) {
94
+ try {
95
+ client.close();
96
+ } catch {
97
+ }
98
+ }
99
+ client = ws;
100
+ forget();
101
+ ws.on("message", (data) => handleMessage(data.toString()));
102
+ ws.on("close", () => {
103
+ if (client === ws) {
104
+ client = null;
105
+ forget();
106
+ push();
107
+ }
108
+ });
109
+ ws.on("error", () => {
110
+ });
111
+ push();
112
+ });
113
+ },
114
+ stop() {
115
+ try {
116
+ client?.close();
117
+ } catch {
118
+ }
119
+ client = null;
120
+ forget();
121
+ const s = wss;
122
+ wss = null;
123
+ try {
124
+ s?.close();
125
+ } catch {
126
+ }
127
+ push();
128
+ }
129
+ };
130
+ }
131
+
132
+ export { createLiveLinkServer };
133
+ //# sourceMappingURL=live-link.js.map
134
+ //# sourceMappingURL=live-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/live-link.ts"],"names":[],"mappings":"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","file":"live-link.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The live link server: the loopback WebSocket server, in the main process,\n// that a running game connects to. Patterpad's debug-link.ts and Storyletter's\n// live-link.ts were this file twice (the family parity review of 2026-10, item\n// 14), so the parts that were the same are here once and each app plugs in its\n// own protocol:\n//\n// - loopback only, and refused to any Origin that isn't this machine\n// (isLocalOrigin), so a web page can't take the slot;\n// - one game at a time: a new connection replaces the old;\n// - nothing is honoured before a hello, which names the build the game runs,\n// judged against the project's (match / stale / unknown);\n// - the status off -> listening -> connected, or error, pushed to the app;\n// - pushBundle, gated on the build the game reported, and the build shown as\n// stale until the game re-hellos with the one it was sent;\n// - a bind failure (the port is taken) leaves the server off, not dead \"on\".\n//\n// What the game sends after its hello (Patterpad's frames, Storyletter's\n// boards and traces), and what the app adds to the connected status, is the\n// protocol's. Main-process code: `ws` is an optional peer the app supplies.\n// The chip that shows the status is link-status.ts.\n// ---------------------------------------------------------------------------\n\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { isLocalOrigin } from \"./util.js\";\n\n/** How the game's build compares with the project's. */\nexport type LiveLinkBuild = \"match\" | \"stale\" | \"unknown\";\n\n/** One parsed message from the game: a JSON object with its `t`. */\nexport type LiveLinkMessage = Record<string, unknown>;\n\n/** The server's status, with the protocol's own fields on `connected`. */\nexport type LiveLinkServerStatus<X extends object = object> =\n | { state: \"off\" }\n | { state: \"error\"; message: string }\n | { state: \"listening\"; port: number }\n | ({ state: \"connected\"; port: number; project?: string; build: LiveLinkBuild } & X);\n\n/** A hello, as the server read it. */\nexport interface LiveLinkHello {\n /** The game's build judged against the project's. */\n build: LiveLinkBuild;\n /** What the game said it is, if it did. */\n project?: string;\n /** The whole message, for the protocol's own fields (flows, boxes). */\n msg: LiveLinkMessage;\n}\n\n/** An app's half of the link. */\nexport interface LiveLinkProtocol<X extends object = object> {\n /** A hello landed: a run begins, or a pushed bundle took. The status is\n * pushed after it returns. */\n hello(hello: LiveLinkHello): void;\n /** Any other message, once the game has said hello. Call `pushStatus` when\n * it changes what the status shows. */\n message(msg: LiveLinkMessage): void;\n /** The game went away, was replaced, or the server stopped: forget the run. */\n reset(): void;\n /** The protocol's fields on a connected status. */\n describe(): X;\n}\n\nexport interface LiveLinkServerOptions<X extends object = object> {\n /** Each app has its own, so both editors can listen on one machine. */\n port: number;\n /** The project's current compiled hash, read at each hello; null with no\n * project open, which makes the build \"unknown\". */\n currentBuildHash: () => string | null;\n /** Make the protocol. `pushStatus` sends the status again while a game is\n * connected (before that, nothing the protocol adds is shown). */\n protocol: (link: { pushStatus(): void }) => LiveLinkProtocol<X>;\n /** The latest status, for the app's windows and menu. */\n onStatus: (status: LiveLinkServerStatus<X>) => void;\n}\n\nexport interface LiveLinkServer<X extends object = object> {\n start(): void;\n stop(): void;\n isOn(): boolean;\n status(): LiveLinkServerStatus<X>;\n /** Send the status again while a game is connected: something the protocol\n * shows changed outside a message (the app followed another flow). */\n pushStatus(): void;\n /** Push a freshly compiled bundle to the connected game. Nothing happens when\n * no game has said hello, or when it already runs this build. The game\n * applies it and re-hellos with the new build; until then it shows stale. */\n pushBundle(build: string, data: string): void;\n}\n\nexport function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X> {\n const { port } = opts;\n let wss: WebSocketServer | null = null;\n let client: WebSocket | null = null;\n // Set by a hello: nothing else is honoured until it is.\n let connected: { project?: string; build: LiveLinkBuild } | null = null;\n let clientBuild: string | null = null; // the build the game reported: gates pushBundle\n\n const status = (): LiveLinkServerStatus<X> => {\n if (!wss) return { state: \"off\" };\n if (!client || !connected) return { state: \"listening\", port };\n return {\n ...protocol.describe(),\n state: \"connected\", port, build: connected.build,\n ...(connected.project !== undefined ? { project: connected.project } : {}),\n };\n };\n const push = (): void => opts.onStatus(status());\n const pushStatus = (): void => { if (connected) push(); };\n const protocol = opts.protocol({ pushStatus });\n\n const forget = (): void => { connected = null; clientBuild = null; protocol.reset(); };\n\n const handleMessage = (raw: string): void => {\n let msg: unknown;\n try { msg = JSON.parse(raw); } catch { return; }\n if (msg === null || typeof msg !== \"object\" || Array.isArray(msg)) return;\n const m = msg as LiveLinkMessage;\n if (m[\"t\"] !== \"hello\") {\n if (connected) protocol.message(m);\n return;\n }\n clientBuild = typeof m[\"build\"] === \"string\" ? m[\"build\"] : null;\n const current = opts.currentBuildHash();\n const build: LiveLinkBuild = current === null ? \"unknown\" : clientBuild === current ? \"match\" : \"stale\";\n const project = typeof m[\"project\"] === \"string\" ? m[\"project\"] : undefined;\n connected = { build, ...(project !== undefined ? { project } : {}) };\n protocol.hello({ build, ...(project !== undefined ? { project } : {}), msg: m });\n push();\n };\n\n return {\n isOn: () => wss !== null,\n status,\n pushStatus,\n pushBundle(build: string, data: string): void {\n if (!client || !connected) return; // nothing connected, or no hello yet\n if (clientBuild === build) return; // the game already runs this build\n try { client.send(JSON.stringify({ t: \"bundle\", v: 1, build, data })); } catch { /* socket went away */ }\n // Until the game re-hellos with the new build it is on the old one, and\n // the chip says so rather than keep the match it had at the hello.\n if (connected.build !== \"stale\") { connected = { ...connected, build: \"stale\" }; push(); }\n },\n start(): void {\n if (wss) return;\n try {\n // Loopback only: only processes on this machine can reach it. And not\n // a web page on it: any site can open a WebSocket to localhost, and the\n // browser stamps the upgrade with the page's Origin.\n wss = new WebSocketServer({\n host: \"127.0.0.1\", port,\n verifyClient: (info: { origin?: string }) => isLocalOrigin(info.origin),\n }, () => push()); // \"listening\" once actually bound\n } catch (e) {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n wss = null;\n return;\n }\n wss.on(\"error\", (e) => {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n // A bind failure leaves no server behind: the next click starts again\n // rather than finding a dead one \"on\".\n if (!client) { const s = wss; wss = null; try { s?.close(); } catch { /* already closing */ } }\n });\n wss.on(\"connection\", (ws) => {\n // One game at a time: a new connection replaces the old.\n if (client) { try { client.close(); } catch { /* gone */ } }\n client = ws; forget();\n ws.on(\"message\", (data) => handleMessage(data.toString()));\n ws.on(\"close\", () => { if (client === ws) { client = null; forget(); push(); } });\n ws.on(\"error\", () => { /* a flaky client must not take the editor down */ });\n push();\n });\n },\n stop(): void {\n try { client?.close(); } catch { /* gone */ }\n client = null; forget();\n const s = wss; wss = null;\n try { s?.close(); } catch { /* already closing */ }\n push();\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildwinter/app-shell",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "The shared shell for the editor family (Patterpad, Storylet Studio): the app-agnostic design-token grammar, the per-entity colour hash, and the vanilla-TS DOM primitive. Two palettes, one grammar - each app supplies its own palette/fonts over these tokens.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -185,6 +185,11 @@
185
185
  "import": "./dist/link-status.js",
186
186
  "require": "./dist/link-status.cjs"
187
187
  },
188
+ "./live-link": {
189
+ "types": "./dist/live-link.d.ts",
190
+ "import": "./dist/live-link.js",
191
+ "require": "./dist/live-link.cjs"
192
+ },
188
193
  "./property-list": {
189
194
  "types": "./dist/property-list.d.ts",
190
195
  "import": "./dist/property-list.js",
@@ -251,7 +256,8 @@
251
256
  "@wildwinter/simple-vc-lib": ">=0.5.0",
252
257
  "@wildwinter/toolkit": ">=0.2.5 <1.0.0",
253
258
  "electron": ">=28",
254
- "electron-updater": ">=6"
259
+ "electron-updater": ">=6",
260
+ "ws": ">=8"
255
261
  },
256
262
  "peerDependenciesMeta": {
257
263
  "electron": {
@@ -265,9 +271,13 @@
265
271
  },
266
272
  "@wildwinter/toolkit": {
267
273
  "optional": true
274
+ },
275
+ "ws": {
276
+ "optional": true
268
277
  }
269
278
  },
270
279
  "devDependencies": {
280
+ "@types/ws": "^8.18.2",
271
281
  "@wildwinter/expr": "^0.6.0",
272
282
  "@wildwinter/scoperegistry": "^0.9.0",
273
283
  "@wildwinter/simple-vc-lib": "^0.5.2",
@@ -277,6 +287,7 @@
277
287
  "jsdom": "^25.0.0",
278
288
  "tsup": "^8.3.5",
279
289
  "typescript": "^5.6.3",
280
- "vitest": "^3.2.7"
290
+ "vitest": "^3.2.7",
291
+ "ws": "^8.22.0"
281
292
  }
282
293
  }