@mudlet/mudlet-web 0.3.0 → 0.3.1

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.
@@ -19,6 +19,10 @@ interface CharLoginModalProps {
19
19
  /** Decline GMCP login — sends the empty reply so the server falls back to
20
20
  * its text login prompt. */
21
21
  onCancel: () => void;
22
+ /** Where focus goes once the popup closes. The server raised this dialog,
23
+ * not a click, so there is no meaningful opener to return to — the caller
24
+ * points this at the command line so the player can type straight away. */
25
+ restoreFocusTo?: () => HTMLElement | null | undefined;
22
26
  }
23
27
  /**
24
28
  * Credentials popup for GMCP `Char.Login` authentication. Rendered as a real
@@ -28,5 +32,5 @@ interface CharLoginModalProps {
28
32
  * the inline warning); the password is otherwise handed straight to `onSubmit`
29
33
  * and relayed to the server, never stored. Cancelling falls back to text login.
30
34
  */
31
- export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialRemember, allowRemember, onSubmit, onCancel, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
35
+ export declare function CharLoginModal({ connectionName, error, initialAccount, initialPassword, initialRemember, allowRemember, onSubmit, onCancel, restoreFocusTo, }: CharLoginModalProps): import("react/jsx-runtime").JSX.Element;
32
36
  export {};
@@ -1,4 +1,5 @@
1
1
  import type { ProfileVFS } from '../scripting/vfs/ProfileVFS';
2
+ export declare function collectZipEntries(vfs: ProfileVFS, dirPath: string, prefix: string, out: Record<string, Uint8Array>, depth?: number): void;
2
3
  export interface VFSNode {
3
4
  name: string;
4
5
  path: string;
@@ -1,9 +1,13 @@
1
1
  import type { ProfileVFS } from '../scripting/vfs/ProfileVFS';
2
+ import type { TlsStatus } from '../mud/events';
2
3
  interface SettingsModalProps {
3
4
  onClose: () => void;
4
5
  /** Active profile id; null on the connection screen (only theme is editable). */
5
6
  connectionId: string | null;
6
7
  vfs?: ProfileVFS | null;
8
+ /** TLS state of the live session, when there is one. Drives the certificate
9
+ * box below the secure-connection reminder. */
10
+ tlsStatus?: TlsStatus | null;
7
11
  }
8
- export declare function SettingsModal({ onClose, connectionId, vfs }: SettingsModalProps): import("react/jsx-runtime").JSX.Element;
12
+ export declare function SettingsModal({ onClose, connectionId, vfs, tlsStatus }: SettingsModalProps): import("react/jsx-runtime").JSX.Element;
9
13
  export {};
@@ -0,0 +1,18 @@
1
+ import type { TlsStatus } from '../mud/events';
2
+ interface Props {
3
+ status: TlsStatus;
4
+ /** The plaintext port to fall back to, when one was remembered. */
5
+ revertPort?: number;
6
+ onRevert: () => void;
7
+ onDismiss: () => void;
8
+ }
9
+ /**
10
+ * Explains a TLS connection that didn't work, and offers the way back.
11
+ *
12
+ * Mudlet reacts to a certificate failure by opening Preferences on the
13
+ * connection tab with the offending checkbox highlighted; the browser
14
+ * equivalent is to say which setting would allow it and let the user undo the
15
+ * port change in one click.
16
+ */
17
+ export declare function TlsAlertBanner({ status, revertPort, onRevert, onDismiss }: Props): import("react/jsx-runtime").JSX.Element | null;
18
+ export {};
@@ -0,0 +1,20 @@
1
+ import type { TlsCertInfo } from '../mud/events';
2
+ interface Props {
3
+ cert: TlsCertInfo | null;
4
+ /** False when the proxy runtime cannot read peer certificates at all. */
5
+ certInspection: boolean;
6
+ protocol?: string;
7
+ cipher?: string;
8
+ /** Faults the profile's ignore-flags waved through, if any. */
9
+ acceptedDespite?: string[];
10
+ }
11
+ /**
12
+ * The certificate of the current secure connection.
13
+ *
14
+ * Shows the same four fields Mudlet surfaces in Preferences → Connection
15
+ * (issuer, issued-to, expiry, serial), plus the negotiated protocol. When the
16
+ * proxy can't inspect certificates it says so rather than rendering an empty
17
+ * box — "unknown" and "none" would look identical otherwise.
18
+ */
19
+ export declare function TlsCertificateBox({ cert, certInspection, protocol, cipher, acceptedDespite }: Props): import("react/jsx-runtime").JSX.Element | null;
20
+ export {};
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ /** The secure port the server advertised via MSSP. */
3
+ port: number;
4
+ onAccept: () => void;
5
+ onDecline: () => void;
6
+ }
7
+ /**
8
+ * Offers the MSSP-advertised secure port. Wording follows Mudlet's prompt
9
+ * (cTelnet::promptTlsConnectionAvailable) so the choice reads the same to
10
+ * anyone arriving from the desktop client.
11
+ *
12
+ * Declining is permanent for the profile, so it is stated on the button's
13
+ * description rather than hidden behind a "don't ask again" checkbox.
14
+ */
15
+ export declare function TlsUpgradeModal({ port, onAccept, onDecline }: Props): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -14,5 +14,11 @@ export interface ModalFocusOptions {
14
14
  /** Close on Escape via `onClose`. Default true. Set false when the modal has
15
15
  * its own Escape handling (then `onClose` may be omitted). */
16
16
  closeOnEscape?: boolean;
17
+ /** Where focus should land on close, instead of the opener. Use when the
18
+ * dialog was raised by the game rather than by a click, so "the opener" is
19
+ * either nothing or a control that has since gone away — the GMCP login
20
+ * popup hands focus to the command line this way. Falls back to the opener
21
+ * when it returns null. */
22
+ restoreFocusTo?: () => HTMLElement | null | undefined;
17
23
  }
18
24
  export declare function useModalFocus<T extends HTMLElement = HTMLDivElement>(onClose?: () => void, opts?: ModalFocusOptions): React.RefObject<T | null>;
@@ -70,6 +70,13 @@ export interface LabelState {
70
70
  visitedColor?: string;
71
71
  underline: boolean;
72
72
  };
73
+ /** Hrefs clicked in this label, tracked so `linkStyle.visitedColor` can be
74
+ * applied explicitly — CSS `:visited` only ever matches real browser
75
+ * history, so it never fires for the `send:`/`prompt:` pseudo-schemes label
76
+ * links use. Mirrors Mudlet's `TLabel::mVisitedLinks`, which exists for the
77
+ * same reason (Qt won't style them either). Only populated while a visited
78
+ * color is set, as Mudlet does. */
79
+ visitedLinks?: Set<string>;
73
80
  /** Click handler installed by setLabelClickCallback. The event table mirrors
74
81
  * Mudlet's `mudlet.mouse_button` shape: `{button, x, y, alt, ctrl, shift, meta}`. */
75
82
  onClick?: (event: LabelMouseEvent) => void;
@@ -117,11 +124,16 @@ export interface LabelWheelEvent extends LabelMouseEvent {
117
124
  };
118
125
  }
119
126
  type Listener = (labels: LabelState[]) => void;
127
+ /** Runs the action behind an `<a href>` clicked inside a label — installed by
128
+ * ScriptingAPI, which owns the send/prompt/openUrl/Lua machinery the schemes
129
+ * map onto (see {@link LabelManager.setLinkActivator}). */
130
+ type LinkActivator = (href: string) => void;
120
131
  export declare class LabelManager {
121
132
  readonly overlayZ: OverlayLayerOrder;
122
133
  private readonly labels;
123
134
  private readonly byParent;
124
135
  private readonly listeners;
136
+ private linkActivator;
125
137
  private indexAdd;
126
138
  private indexRemove;
127
139
  constructor(overlayZ?: OverlayLayerOrder);
@@ -175,6 +187,19 @@ export declare class LabelManager {
175
187
  setStyleSheet(name: string, css: string): boolean;
176
188
  /** Mudlet `setLinkStyle(labelName, linkColor, linkVisitedColor, underline)`. */
177
189
  setLinkStyle(name: string, color: string, visitedColor: string, underline: boolean): boolean;
190
+ /**
191
+ * Install the handler that runs a clicked label link (`<a href="send:…">`).
192
+ * The manager itself has no scripting access, so LabelOverlay routes clicks
193
+ * here and ScriptingAPI supplies the behaviour.
194
+ */
195
+ setLinkActivator(fn: LinkActivator | null): void;
196
+ /**
197
+ * A link inside `name`'s rich text was clicked. Records it as visited (when
198
+ * a visited color is configured) and runs the activator — Mudlet's
199
+ * `TLabel::slot_linkActivated`, which does the same two things in the same
200
+ * order.
201
+ */
202
+ activateLink(name: string, href: string): void;
178
203
  /** Mudlet `resetLinkStyle(labelName)` — drop any setLinkStyle override. */
179
204
  resetLinkStyle(name: string): boolean;
180
205
  /** Mudlet `getLabelStyleSheet(name)` — the Qt-style CSS last set via
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `<a href>` links inside a label's rich text — Mudlet's `TLabel::slot_linkActivated`
3
+ * (src/TLabel.cpp).
4
+ *
5
+ * Geyser packages build clickable UI by echoing anchors into a label:
6
+ *
7
+ * myLabel:echo([[<a href="send:help topics">Help</a>]])
8
+ *
9
+ * and the href's scheme decides what a click does. The rules deliberately differ
10
+ * from the OSC 8 links the main window renders (`classifyHyperlinkUri`):
11
+ *
12
+ * - a scheme-less href is **Lua source** to run, not a dropped link;
13
+ * - only `send:`, `prompt:`, `http:` and `https:` are recognised (no `ftp:`);
14
+ * - the payload is **not** percent-decoded — Qt hands `linkActivated` the href
15
+ * verbatim, so `send:cast fireball` reaches the MUD with its literal space.
16
+ *
17
+ * Any other scheme is ignored rather than falling through to the Lua branch,
18
+ * which is what stops a `javascript:`/`file:`/`data:` href from executing.
19
+ */
20
+ export type LabelLinkAction = {
21
+ kind: 'send';
22
+ command: string;
23
+ } | {
24
+ kind: 'prompt';
25
+ command: string;
26
+ } | {
27
+ kind: 'url';
28
+ url: string;
29
+ }
30
+ /** No scheme at all — the whole href is a Lua chunk. */
31
+ | {
32
+ kind: 'lua';
33
+ code: string;
34
+ };
35
+ /**
36
+ * Classify a label link href. Returns null when the link does nothing (empty,
37
+ * or an unrecognised scheme).
38
+ *
39
+ * Like Mudlet, the split is on the first `:` rather than an RFC 3986 scheme
40
+ * match — so a scheme-less chunk that happens to contain a colon
41
+ * (`myTable:method()`) is treated as an unknown scheme and ignored.
42
+ */
43
+ export declare function classifyLabelLink(href: string): LabelLinkAction | null;
44
+ /**
45
+ * The href of the anchor an event target sits inside, or null when the click
46
+ * didn't land on a link.
47
+ *
48
+ * Read through `getAttribute` on purpose: the `.href` *property* resolves the
49
+ * value against the page URL and percent-encodes it, which would turn
50
+ * `send:help topics` into `send:help%20topics` and mangle the command.
51
+ */
52
+ export declare function labelLinkHref(target: EventTarget | null): string | null;
@@ -11,7 +11,29 @@
11
11
  * the send/prompt/url action, reveals a spoiler, toggles selection, arms
12
12
  * visibility); the Menu key or Shift+F10 opens its context menu. Mirrors
13
13
  * Mudlet's TTextEdit key handling.
14
+ *
15
+ * Being focusable has a mouse-side cost, which `restoreFocusAfterLinkClick`
16
+ * pays back: a tabIndex of -1 keeps a span out of the Tab order but still lets
17
+ * a click focus it, and Mudlet's TTextEdit never takes focus off the command
18
+ * line that way.
19
+ */
20
+ /**
21
+ * Give focus back to the command line after a link is clicked with the mouse,
22
+ * so the player can keep typing — Mudlet's behaviour, where the output never
23
+ * takes focus at all.
24
+ *
25
+ * Call from a *capture*-phase click listener on the output container: a link's
26
+ * own handler stops propagation, so a bubble-phase listener never sees it. The
27
+ * hand-back is deferred to a microtask (i.e. after the click has been fully
28
+ * dispatched) and happens only if a link is still holding focus — a link that
29
+ * opened a dialog or moved focus deliberately keeps its say. Keyboard
30
+ * activation (`link.click()` from Enter/Space, which reports `detail === 0`)
31
+ * leaves focus on the link so Ctrl+]/Ctrl+[ can carry on from there.
14
32
  */
33
+ export declare function restoreFocusAfterLinkClick(e: {
34
+ detail: number;
35
+ target: EventTarget | null;
36
+ }, commandInput: () => HTMLElement | null | undefined): void;
15
37
  /**
16
38
  * One representative element per logical link under `root`, in document order.
17
39
  * A multicolour link is rendered as several spans (one per colour run) sharing
@@ -146,6 +146,9 @@ export declare class WindowManager {
146
146
  /** Bridge to ScriptingEngine.downloadMap — the map UI's "download map from
147
147
  * game" action (Mudlet dlgMapper's Download button). */
148
148
  onDownloadMap?: () => void;
149
+ /** Bridge to the Lua speedwalk entry point (Mudlet Host::startSpeedWalk),
150
+ * wired by ScriptingEngine. Driven by {@link startSpeedWalk}. */
151
+ onStartSpeedWalk?: (from: number, to: number) => void;
149
152
  /** The MMP map URL from GMCP `Client.Map`, or '' when none announced. */
150
153
  get mmpMapLocation(): string;
151
154
  /** Record the game-announced map URL (Mudlet TMap::setMmpMapLocation). */
@@ -327,6 +330,20 @@ export declare class WindowManager {
327
330
  registerMapCallback(id: string, cb: (roomId: number) => void): void;
328
331
  unregisterMapCallback(id: string): void;
329
332
  centerView(roomId: number): boolean;
333
+ /**
334
+ * Mudlet `T2DMap::initiateSpeedWalk` — start a speedwalk from the player's
335
+ * current room to `targetRoomId`, the room the user double-clicked on the
336
+ * map (or the far side of a double-clicked out-of-area exit).
337
+ *
338
+ * Like Mudlet, an unknown target room is ignored outright, while an unknown
339
+ * *player* room is not: it pathfinds from -1 (Mudlet uses the missing
340
+ * profile entry, 0), finds nothing, and the Lua side reports the mapper's
341
+ * "cannot find a path" message rather than failing silently.
342
+ *
343
+ * Returns false when the gesture didn't start a walk — no such room, or no
344
+ * scripting runtime attached to walk it.
345
+ */
346
+ startSpeedWalk(targetRoomId: number): boolean;
330
347
  registerMapControl(id: string, ctrl: MapControl): void;
331
348
  unregisterMapControl(id: string): void;
332
349
  /** Live zoom of the displayed 2D map, or null when no map panel is mounted.
@@ -1,131 +1,131 @@
1
- // Service worker that serves ProfileVFS-backed assets at /__vfs/<connectionId>/<path>.
2
- // The page is the source of truth for VFS contents (the SW can't share folder
3
- // handles or always-open the same IDB store), so each request round-trips to
4
- // the client via MessageChannel.
5
-
6
- const CACHE_NAME = 'mudix-vfs-v1';
7
- // Scope path always ends with '/'. On a root-served deploy this is '/'; on
8
- // GitHub Pages or any subpath deploy it's '/<repo>/'. The intercept prefix is
9
- // '<scope>__vfs/' so SW-controlled URLs stay inside scope.
10
- const SCOPE_PATH = new URL(self.registration.scope).pathname;
11
- const PREFIX = `${SCOPE_PATH}__vfs/`;
12
- const READ_TIMEOUT_MS = 5000;
13
-
14
- self.addEventListener('install', () => self.skipWaiting());
15
- self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
16
-
17
- self.addEventListener('fetch', (event) => {
18
- const url = new URL(event.request.url);
19
- if (url.origin !== self.location.origin) return;
20
- if (!url.pathname.startsWith(PREFIX)) return;
21
- event.respondWith(handle(event, url));
22
- });
23
-
24
- async function handle(event, url) {
25
- const rest = url.pathname.slice(PREFIX.length).split('/').filter(Boolean);
26
- if (rest.length < 2) return new Response('Bad request', { status: 400 });
27
- const connectionId = decodeURIComponent(rest[0]);
28
- const filePath = '/' + rest.slice(1).map(decodeURIComponent).join('/');
29
-
30
- const cache = await caches.open(CACHE_NAME);
31
- const cached = await cache.match(event.request);
32
- if (cached) return cached;
33
-
34
- const client = await pickClient(event);
35
- if (!client) {
36
- // Either there's no app tab loaded (direct URL hit in fresh tab) or
37
- // the only candidate is the tab currently navigating to this URL.
38
- return new Response('No app tab loaded; open mudix first', { status: 503 });
39
- }
40
-
41
- const reply = await ask(client, { type: 'vfs:read', connectionId, path: filePath });
42
- if (!reply || !reply.ok) {
43
- return new Response(reply?.error ?? 'Not found', { status: 404 });
44
- }
45
-
46
- const headers = new Headers();
47
- headers.set('Content-Type', reply.contentType || 'application/octet-stream');
48
- // no-store keeps this response out of the browser's own HTTP cache, which
49
- // has no validator to revalidate against (we set none) and would otherwise
50
- // race our explicit Cache Storage entry above — that race is what produced
51
- // stale 304-with-no-body responses for cached images. The Cache Storage
52
- // entry (invalidated on writes via postMessage) is the only cache that
53
- // should own this decision.
54
- headers.set('Cache-Control', 'no-store');
55
- const response = new Response(reply.bytes, { headers });
56
- cache.put(event.request, response.clone()).catch(() => {});
57
- return response;
58
- }
59
-
60
- async function pickClient(event) {
61
- // Prefer the client that issued this request — for sub-resource fetches
62
- // that's the loaded app page, which can reply immediately.
63
- const requestingId = event.clientId || event.resultingClientId || '';
64
- if (requestingId) {
65
- const c = await self.clients.get(requestingId);
66
- if (c && isUsable(c)) return c;
67
- }
68
- const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
69
- const usable = all.filter(isUsable);
70
- return usable.find((c) => c.focused) ?? usable[0] ?? null;
71
- }
72
-
73
- // Skip clients that are themselves navigating to a /__vfs/ URL — those are
74
- // fresh tabs in the middle of loading the SW response; they have no JS running
75
- // and can't reply, so picking one guarantees a timeout.
76
- function isUsable(client) {
77
- try {
78
- return !new URL(client.url).pathname.startsWith(PREFIX);
79
- } catch {
80
- return false;
81
- }
82
- }
83
-
84
- function ask(client, message) {
85
- return new Promise((resolve) => {
86
- const channel = new MessageChannel();
87
- const timer = setTimeout(() => {
88
- channel.port1.close();
89
- resolve({ ok: false, error: 'timeout' });
90
- }, READ_TIMEOUT_MS);
91
- channel.port1.onmessage = (e) => {
92
- clearTimeout(timer);
93
- channel.port1.close();
94
- resolve(e.data);
95
- };
96
- client.postMessage(message, [channel.port2]);
97
- });
98
- }
99
-
100
- self.addEventListener('message', (event) => {
101
- const data = event.data;
102
- if (!data) return;
103
- if (data.type === 'vfs:invalidate') {
104
- event.waitUntil(invalidate(data));
105
- }
106
- });
107
-
108
- async function invalidate({ connectionId, path }) {
109
- if (!connectionId) {
110
- await caches.delete(CACHE_NAME);
111
- return;
112
- }
113
- const cache = await caches.open(CACHE_NAME);
114
- const connPrefix = `${PREFIX}${encodeURIComponent(connectionId)}/`;
115
- const keys = await cache.keys();
116
- if (path) {
117
- const segs = path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
118
- const target = `${connPrefix}${segs}`;
119
- await Promise.all(
120
- keys
121
- .filter((req) => new URL(req.url).pathname === target)
122
- .map((req) => cache.delete(req)),
123
- );
124
- return;
125
- }
126
- await Promise.all(
127
- keys
128
- .filter((req) => new URL(req.url).pathname.startsWith(connPrefix))
129
- .map((req) => cache.delete(req)),
130
- );
131
- }
1
+ // Service worker that serves ProfileVFS-backed assets at /__vfs/<connectionId>/<path>.
2
+ // The page is the source of truth for VFS contents (the SW can't share folder
3
+ // handles or always-open the same IDB store), so each request round-trips to
4
+ // the client via MessageChannel.
5
+
6
+ const CACHE_NAME = 'mudix-vfs-v1';
7
+ // Scope path always ends with '/'. On a root-served deploy this is '/'; on
8
+ // GitHub Pages or any subpath deploy it's '/<repo>/'. The intercept prefix is
9
+ // '<scope>__vfs/' so SW-controlled URLs stay inside scope.
10
+ const SCOPE_PATH = new URL(self.registration.scope).pathname;
11
+ const PREFIX = `${SCOPE_PATH}__vfs/`;
12
+ const READ_TIMEOUT_MS = 5000;
13
+
14
+ self.addEventListener('install', () => self.skipWaiting());
15
+ self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
16
+
17
+ self.addEventListener('fetch', (event) => {
18
+ const url = new URL(event.request.url);
19
+ if (url.origin !== self.location.origin) return;
20
+ if (!url.pathname.startsWith(PREFIX)) return;
21
+ event.respondWith(handle(event, url));
22
+ });
23
+
24
+ async function handle(event, url) {
25
+ const rest = url.pathname.slice(PREFIX.length).split('/').filter(Boolean);
26
+ if (rest.length < 2) return new Response('Bad request', { status: 400 });
27
+ const connectionId = decodeURIComponent(rest[0]);
28
+ const filePath = '/' + rest.slice(1).map(decodeURIComponent).join('/');
29
+
30
+ const cache = await caches.open(CACHE_NAME);
31
+ const cached = await cache.match(event.request);
32
+ if (cached) return cached;
33
+
34
+ const client = await pickClient(event);
35
+ if (!client) {
36
+ // Either there's no app tab loaded (direct URL hit in fresh tab) or
37
+ // the only candidate is the tab currently navigating to this URL.
38
+ return new Response('No app tab loaded; open mudix first', { status: 503 });
39
+ }
40
+
41
+ const reply = await ask(client, { type: 'vfs:read', connectionId, path: filePath });
42
+ if (!reply || !reply.ok) {
43
+ return new Response(reply?.error ?? 'Not found', { status: 404 });
44
+ }
45
+
46
+ const headers = new Headers();
47
+ headers.set('Content-Type', reply.contentType || 'application/octet-stream');
48
+ // no-store keeps this response out of the browser's own HTTP cache, which
49
+ // has no validator to revalidate against (we set none) and would otherwise
50
+ // race our explicit Cache Storage entry above — that race is what produced
51
+ // stale 304-with-no-body responses for cached images. The Cache Storage
52
+ // entry (invalidated on writes via postMessage) is the only cache that
53
+ // should own this decision.
54
+ headers.set('Cache-Control', 'no-store');
55
+ const response = new Response(reply.bytes, { headers });
56
+ cache.put(event.request, response.clone()).catch(() => {});
57
+ return response;
58
+ }
59
+
60
+ async function pickClient(event) {
61
+ // Prefer the client that issued this request — for sub-resource fetches
62
+ // that's the loaded app page, which can reply immediately.
63
+ const requestingId = event.clientId || event.resultingClientId || '';
64
+ if (requestingId) {
65
+ const c = await self.clients.get(requestingId);
66
+ if (c && isUsable(c)) return c;
67
+ }
68
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
69
+ const usable = all.filter(isUsable);
70
+ return usable.find((c) => c.focused) ?? usable[0] ?? null;
71
+ }
72
+
73
+ // Skip clients that are themselves navigating to a /__vfs/ URL — those are
74
+ // fresh tabs in the middle of loading the SW response; they have no JS running
75
+ // and can't reply, so picking one guarantees a timeout.
76
+ function isUsable(client) {
77
+ try {
78
+ return !new URL(client.url).pathname.startsWith(PREFIX);
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function ask(client, message) {
85
+ return new Promise((resolve) => {
86
+ const channel = new MessageChannel();
87
+ const timer = setTimeout(() => {
88
+ channel.port1.close();
89
+ resolve({ ok: false, error: 'timeout' });
90
+ }, READ_TIMEOUT_MS);
91
+ channel.port1.onmessage = (e) => {
92
+ clearTimeout(timer);
93
+ channel.port1.close();
94
+ resolve(e.data);
95
+ };
96
+ client.postMessage(message, [channel.port2]);
97
+ });
98
+ }
99
+
100
+ self.addEventListener('message', (event) => {
101
+ const data = event.data;
102
+ if (!data) return;
103
+ if (data.type === 'vfs:invalidate') {
104
+ event.waitUntil(invalidate(data));
105
+ }
106
+ });
107
+
108
+ async function invalidate({ connectionId, path }) {
109
+ if (!connectionId) {
110
+ await caches.delete(CACHE_NAME);
111
+ return;
112
+ }
113
+ const cache = await caches.open(CACHE_NAME);
114
+ const connPrefix = `${PREFIX}${encodeURIComponent(connectionId)}/`;
115
+ const keys = await cache.keys();
116
+ if (path) {
117
+ const segs = path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
118
+ const target = `${connPrefix}${segs}`;
119
+ await Promise.all(
120
+ keys
121
+ .filter((req) => new URL(req.url).pathname === target)
122
+ .map((req) => cache.delete(req)),
123
+ );
124
+ return;
125
+ }
126
+ await Promise.all(
127
+ keys
128
+ .filter((req) => new URL(req.url).pathname.startsWith(connPrefix))
129
+ .map((req) => cache.delete(req)),
130
+ );
131
+ }