@x-tech-laboratories/x-libs 0.9.0 → 0.11.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.
- package/README.md +48 -8
- package/dist/electron/bridge.d.ts +26 -3
- package/dist/electron/bridge.js +57 -5
- package/dist/electron/fetch.d.ts +27 -0
- package/dist/electron/fetch.js +109 -1
- package/dist/server/config.d.ts +39 -0
- package/dist/server/config.js +109 -0
- package/dist/server/cors.d.ts +8 -0
- package/dist/server/cors.js +13 -0
- package/dist/server/events.d.ts +33 -0
- package/dist/server/events.js +68 -0
- package/dist/server/login_page.d.ts +8 -0
- package/dist/server/login_page.js +79 -0
- package/dist/server/replies.d.ts +12 -0
- package/dist/server/replies.js +41 -0
- package/dist/server/sign_in.d.ts +27 -0
- package/dist/server/sign_in.js +95 -0
- package/dist/server/web_folder.d.ts +11 -0
- package/dist/server/web_folder.js +40 -0
- package/dist/web/dom.d.ts +2 -0
- package/dist/web/dom.js +5 -1
- package/dist/web/errors.d.ts +1 -0
- package/dist/web/errors.js +27 -0
- package/dist/web/react/badges.d.ts +9 -0
- package/dist/web/react/badges.js +4 -0
- package/dist/web/react/controls.d.ts +10 -0
- package/dist/web/react/controls.js +16 -0
- package/dist/web/react/following.d.ts +25 -0
- package/dist/web/react/following.js +34 -0
- package/dist/web/react/layout.d.ts +2 -1
- package/dist/web/react/layout.js +3 -2
- package/dist/web/react/markdown.d.ts +8 -0
- package/dist/web/react/markdown.js +40 -0
- package/dist/web/react/page_view.d.ts +16 -0
- package/dist/web/react/page_view.js +6 -0
- package/dist/web/react/progress.d.ts +10 -0
- package/dist/web/react/progress.js +9 -0
- package/dist/web/react/rating.d.ts +8 -0
- package/dist/web/react/rating.js +23 -0
- package/dist/web/react/states.d.ts +2 -1
- package/dist/web/react/states.js +7 -6
- package/dist/web/react/store.d.ts +4 -0
- package/dist/web/react/store.js +7 -0
- package/dist/web/react/suggestions.d.ts +23 -0
- package/dist/web/react/suggestions.js +57 -0
- package/dist/web/react/table.d.ts +1 -0
- package/dist/web/react/table.js +4 -1
- package/dist/web/react/test_stubs.js +23 -17
- package/dist/web/react/volume.d.ts +8 -0
- package/dist/web/react/volume.js +42 -0
- package/dist/web/store.d.ts +11 -0
- package/dist/web/store.js +17 -0
- package/dist/web/watch.d.ts +5 -0
- package/dist/web/watch.js +20 -0
- package/package.json +24 -2
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
|
|
|
10
10
|
| `log` | Loggers by scope and level from a setting such as `"warn,play:debug"`; a malformed setting falls back instead of silencing logging, and a setting read through a function applies to loggers made before it arrived. |
|
|
11
11
|
| `dates` | Local dates as `"2026-06-10"` or `"2026-06-10T09:30"`: reading, writing, adding days and months, overdue, and Yesterday/Today/Tomorrow labels. |
|
|
12
12
|
| `backups` | Named backups of an app's data in any store, newest first, with daily automatic ones kept to a set number. |
|
|
13
|
-
| `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server; `with_cloneable_arguments` flattens what structured clone refuses, such as a Proxy. |
|
|
13
|
+
| `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server; `handle` takes a `trusted` guard on the sender frame and answers in `envelopes`, which the client unwraps into Errors with the message alone, and a plain client strips Electron's remote-method prefix; `to_envelope`; `with_cloneable_arguments` flattens what structured clone refuses, such as a Proxy. |
|
|
14
14
|
| `electron/events` | `define_events`: messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name. |
|
|
15
15
|
| `electron/app` | `hold_single_instance`, `bring_to_front`, and `first_argument` for a link handed over on the command line. |
|
|
16
16
|
| `electron/window` | `create_window`: links and other sites open in the browser, the background follows the system theme, and the size and place are remembered, through the app's write guard if it has one; timers can keep running while hidden. |
|
|
17
|
-
| `electron/fetch` | `create_main_fetch` and `create_page_fetch`: the page's requests sent from the main process past cross-origin rules, to an allow-list of hosts, with headers such as `Origin` pinned. |
|
|
17
|
+
| `electron/fetch` | `create_main_fetch` and `create_page_fetch`: the page's requests sent from the main process past cross-origin rules, to an allow-list of hosts, with headers such as `Origin` pinned; `create_main_stream_fetch` and `create_page_stream_fetch` carry the body chunk by chunk over an events channel into a `ReadableStream` body, for a player that reads as it lands, cancelled by the request's signal. |
|
|
18
18
|
| `agent/commands` | `run_command` and `stream_command`: one shell command, in its own process group. |
|
|
19
19
|
| `agent/long_runs` | `LongRuns`: commands that outlive the agent, their output and exit code kept on disk. |
|
|
20
20
|
| `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
|
|
@@ -26,30 +26,70 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
|
|
|
26
26
|
| `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
|
|
27
27
|
| `server/hash_password` | The `hash-password` command. |
|
|
28
28
|
| `server/backup_files` | `file_backup_store`: the `backups` store as files in a folder on disk. |
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
29
|
+
| `server/sign_in`, `server/login_page` | `register_token_sign_in`: a server with one secret signs browsers in on its own login page, keeps them in by an HMAC-derived cookie, takes the secret as a bearer header, waits after a wrong try, and turns the rest away (401 in text or JSON under the API prefix, 303 to the login page elsewhere, with open paths); `register_sign_in_guard` is the turning-away alone, for an app with its own sessions. |
|
|
30
|
+
| `server/events` | `EventStream`: server-sent events with a client id per page, a heartbeat, `announce_change(except_client_id)` so the page that made a change is not told, nameless or named events to all or chosen clients, and every stream ended with the app. |
|
|
31
|
+
| `server/web_folder` | `register_web_folder`: the built page from a folder, tuned for the Nix store (no etag or date, hashed `/assets/` kept a year, the page fetched fresh), a warning, error or nothing when the folder is missing, and the page for unknown paths when it routes itself. |
|
|
32
|
+
| `server/replies` | `register_error_replies`: errors and unknown paths answered in plain text or as `{ error }`; `register_lenient_json_body`: an empty body as no arguments and bad JSON as `BAD_JSON`, for the route to refuse in its own words; `send_text`. |
|
|
33
|
+
| `server/config` | `read_server_config`: host, port, web folder and the app's options from flags, then the environment, then defaults, with usage text for `--help`, the secret from `<PREFIX>_TOKEN_FILE` or `<PREFIX>_TOKEN`, and no listening beyond this machine without one; `read_secret_file`. |
|
|
34
|
+
| `server/cors` | `register_open_cors`: any origin may call the API, as a native or mobile shell does from a page of its own, with the client id header allowed. |
|
|
35
|
+
| `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder and `cx` class joiner, formatters (including `compact_count`, such as `"1.4M"`), hash router and theme choice, kept in this browser or the app's settings. |
|
|
36
|
+
| `web/states`, `web/tables`, `web/menus`, `web/split`, `web/errors` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing; `describe_error` for anything thrown, such as shaka's coded objects. |
|
|
31
37
|
| `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, tree order, paths, and where a dropped or keyed row lands, its siblings ordered by position or, with `by_list_order`, as the list gives them. |
|
|
32
|
-
| `web/watch`, `web/react/watch` | `create_watch`: a change counter a store bumps; `use_watch` redraws only the components that read that store. |
|
|
38
|
+
| `web/watch`, `web/react/watch` | `create_watch`: a change counter a store bumps; `use_watch` redraws only the components that read that store; `coalesce_bumps` lands a bump at most once per interval, for streamed tokens. |
|
|
39
|
+
| `web/store`, `web/react/store` | `create_store`: a value with a watch, set or updated, redrawing at a steady pace when `coalesce_in_milliseconds` is given; `use_store` reads it. |
|
|
33
40
|
| `web/prefs`, `web/react/prefs` | Remembered view settings that read as their default when stored corrupt, stale or of the wrong shape; `use_pref` keeps one as component state. |
|
|
34
41
|
| `web/selection`, `web/virtual`, `web/drag` | Picking rows with plain, Ctrl and Shift clicks from a fixed anchor, or as lists of ids with the anchor kept by id, the row an arrow or page key moves to; which rows of a long list a viewport needs; a badge such as "3 tasks" as the drag image. |
|
|
35
42
|
| `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar in a `.sidebar-content` box, resizable, theme button, a `status_bar` at the foot, toasts, dialogs and menus, with or without logins (no account UI without a `user`, `logged_out` hiding the sidebar and tools), its phone menu held by the Shell or the app; `use_route`. |
|
|
36
|
-
| `web/react/layout`, `badges`, `states` | `PageHeading` with a subtitle, icon, banner and
|
|
43
|
+
| `web/react/layout`, `badges`, `states` | `PageHeading` with a subtitle, icon, banner, actions and a `level` for inside dialogs and panels; panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows, `RowActions` and `moved` for ordered lists, `SelectionBar` for picked rows; badges, chips and `StatusPill`, a state dot with text and an action; `use_resource` with its `status` (idle until `enabled`, loading, loaded, failed), a `watch` that reloads it quietly, and its loading states, `Loaded` going by the status so a null result shows its `empty` slot or the children. |
|
|
37
44
|
| `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover; `use_now`, the time read again each minute. |
|
|
38
45
|
| `web/react/reveal` | `use_reveal`: a long list shown a page at a time, the next page added as its end scrolls into view, starting from the count shown before the page was left if given. |
|
|
39
46
|
| `web/react/media` | `Avatar`, a round picture or the name's first letter; `MediaTile` and `MediaGrid`: videos, albums or playlists as tiles in a grid or as rows, with text over the picture's corner such as a length, lines under the title, a faded look, and buttons in the corner. |
|
|
40
47
|
| `web/react/menu_button` | `MenuButton`: a ⋮ button that opens a menu under itself, even inside a link, saying through `aria-expanded` whether it is open. |
|
|
41
|
-
| `web/react/test_stubs` | Stands in for `showPopover`, `hidePopover`, `showModal` and `
|
|
48
|
+
| `web/react/test_stubs` | Stands in for `showPopover`, `hidePopover`, `showModal`, `close`, `matchMedia` and `scrollTo`, which jsdom lacks; an app's widget tests import it for its side effects, and a node test's shared setup may import it too. |
|
|
42
49
|
| `web/react/list_picker` | `pick_list_dialog`: adds one thing to any of the app's lists, such as a video to playlists, ticking the lists that hold it and making a new one. |
|
|
43
|
-
| `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that hand on typing as it pauses, submit on Enter or a button, clear on Escape, can take a paste whole, take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, rows that draw again only when they change, `virtual` rows drawn only where they show, a last column that fills the room left, group headings that hide when the filter empties them, and cards on phones; a pager. |
|
|
50
|
+
| `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that hand on typing as it pauses, submit on Enter or a button, clear on Escape, can take a paste whole, take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `PathInput` with a Browse button on the app's picker, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, rows that draw again only when they change, `virtual` rows drawn only where they show, a last column that fills the room left, group headings that hide when the filter empties them, a `wrap_class` for Halon's `scroll` and `sticky-head`, and cards on phones; a pager. |
|
|
44
51
|
| `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
|
|
45
52
|
| `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, reorder by dragging or with Alt and the arrow keys, show several picked, and take drops from outside. |
|
|
46
53
|
| `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed, and `undoable` actions that commit or undo exactly once; modal dialogs with a subtitle, header actions, a full-window size and a `form` whose submit button sends it, and confirm and text dialogs, openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere, their keep-open items ticking at once; resize handles and splits. |
|
|
54
|
+
| `web/react/page_view` | `PageView`: a full-window page over the app on the Dialog, such as Settings or an item's properties, with a Close button, actions beside it and section tabs that stay put while the body scrolls. |
|
|
55
|
+
| `web/react/progress` | `ProgressBar`: a bar with its value out of a max, busy while the amount of work is unknown, and words or the percentage beside it. |
|
|
56
|
+
| `web/react/rating` | `Rating`: stars that show a rating or take one by click or keyboard, with unrated kept apart from none. |
|
|
57
|
+
| `web/react/volume` | `VolumeButton`: a speaker button that opens a slider; scrolling over it adjusts the volume and a middle-click mutes. |
|
|
58
|
+
| `web/react/following` | `use_follow_tail`, `FollowingOutput` and `FollowToggle`: output that keeps its newest lines in view as they arrive unless the reader scrolled up, with a Follow switch. |
|
|
59
|
+
| `web/react/markdown` | `Markdown` and `render_markdown` on marked and DOMPurify, chosen because marked covers the hand-written parser feature for feature and adds tables and nested lists: raw HTML shows as text, only web links are clickable and open in a new tab, and `follow` keeps streamed text in view. |
|
|
60
|
+
| `web/react/suggestions` | `Suggestions`: completions under any text field from a sync or async source; arrow keys choose, Tab or Enter take, Escape dismisses, other keys pass through. |
|
|
47
61
|
|
|
48
62
|
```ts
|
|
49
63
|
import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
|
|
50
64
|
import { parse_duration } from "@x-tech-laboratories/x-libs/durations";
|
|
51
65
|
```
|
|
52
66
|
|
|
67
|
+
## A web app's server
|
|
68
|
+
|
|
69
|
+
The server modules are small Fastify pieces an app registers one by one, so each app keeps only what it needs.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const config = read_server_config({
|
|
73
|
+
program: "x-tasks-server", summary: "Serves X-Tasks as a web app.", env_prefix: "X_TASKS", default_port: 3918,
|
|
74
|
+
web_folder_default: join(import.meta.dirname, "../web"),
|
|
75
|
+
options: { database: { variable: "X_TASKS_DB", help: "the task database; it is made if missing", placeholder: "<file>", required: true } },
|
|
76
|
+
});
|
|
77
|
+
const app = fastify({ forceCloseConnections: true });
|
|
78
|
+
register_lenient_json_body(app);
|
|
79
|
+
register_error_replies(app);
|
|
80
|
+
await register_token_sign_in(app, { secret: config.secret, title: "X-Tasks" });
|
|
81
|
+
const changes = new EventStream();
|
|
82
|
+
await app.register(async (api) => {
|
|
83
|
+
await register_open_cors(api);
|
|
84
|
+
changes.register(api, "/events");
|
|
85
|
+
api.post("/add", async (request) => { ...; changes.announce_change(changes.client_id_of(request)); });
|
|
86
|
+
}, { prefix: "/api" });
|
|
87
|
+
await register_web_folder(app, { folder: config.web_folder });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An app with its own sessions, such as one logging in by name and password, uses `register_sign_in_guard` with its
|
|
91
|
+
own `is_signed_in`, `login_path: null` so its page loads and logs in itself, and `refusal: "json"`.
|
|
92
|
+
|
|
53
93
|
## The page's widgets
|
|
54
94
|
|
|
55
95
|
The widgets build Halon's markup, so their look comes from Halon's web stylesheets in `@x-tech-laboratories/halon`,
|
|
@@ -2,6 +2,11 @@ export interface IpcMainLike {
|
|
|
2
2
|
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
|
3
3
|
removeHandler(channel: string): void;
|
|
4
4
|
}
|
|
5
|
+
export interface IpcMainEventLike {
|
|
6
|
+
senderFrame?: {
|
|
7
|
+
url: string;
|
|
8
|
+
} | null;
|
|
9
|
+
}
|
|
5
10
|
export interface IpcRendererLike {
|
|
6
11
|
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
|
|
7
12
|
}
|
|
@@ -16,17 +21,35 @@ export type Implementation<Methods extends Record<keyof Methods, AnyMethod>> = {
|
|
|
16
21
|
[Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Awaited<ReturnType<Methods[Name]>> | Promise<Awaited<ReturnType<Methods[Name]>>>;
|
|
17
22
|
};
|
|
18
23
|
export type CallRemote = (method: string, args: unknown[]) => Promise<unknown>;
|
|
24
|
+
export type Envelope<Result = unknown> = {
|
|
25
|
+
ok: true;
|
|
26
|
+
result: Result;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
31
|
+
export interface HandleOptions {
|
|
32
|
+
trusted?: (event: IpcMainEventLike) => boolean;
|
|
33
|
+
envelopes?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface ClientOptions {
|
|
36
|
+
envelopes?: boolean;
|
|
37
|
+
}
|
|
19
38
|
export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
|
|
20
39
|
api_name: string;
|
|
21
40
|
method_names: readonly (keyof Methods & string)[];
|
|
22
41
|
channel: (method: keyof Methods & string) => string;
|
|
23
|
-
expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererLike) => void;
|
|
24
|
-
ipc_client: (ipc_renderer: IpcRendererLike) => Client<Methods>;
|
|
25
|
-
handle: (ipc_main: IpcMainLike, implementation: Implementation<Methods
|
|
42
|
+
expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererLike, options?: ClientOptions) => void;
|
|
43
|
+
ipc_client: (ipc_renderer: IpcRendererLike, options?: ClientOptions) => Client<Methods>;
|
|
44
|
+
handle: (ipc_main: IpcMainLike, implementation: Implementation<Methods>, options?: HandleOptions) => () => void;
|
|
26
45
|
web_rpc: (call_remote: CallRemote) => Client<Methods>;
|
|
27
46
|
dispatch: (implementation: Implementation<Methods>, method: string, args: unknown[]) => Promise<unknown>;
|
|
28
47
|
}
|
|
29
48
|
export declare function define_bridge<Methods extends Record<keyof Methods, AnyMethod>>(api_name: string, method_names: readonly (keyof Methods & string)[]): Bridge<Methods>;
|
|
49
|
+
export declare function to_envelope<Result>(work: () => Result | Promise<Result>): Promise<Envelope<Awaited<Result>>>;
|
|
50
|
+
export declare function clean_error_message(message: string): string;
|
|
51
|
+
export declare function clean_invoker(ipc_renderer: IpcRendererLike): IpcRendererLike;
|
|
52
|
+
export declare function unwrapping_invoker(ipc_renderer: IpcRendererLike): IpcRendererLike;
|
|
30
53
|
export declare function to_cloneable(value: unknown): unknown;
|
|
31
54
|
export declare function with_cloneable_arguments<Methods extends Record<keyof Methods, AnyMethod>>(client: Client<Methods>): Client<Methods>;
|
|
32
55
|
export {};
|
package/dist/electron/bridge.js
CHANGED
|
@@ -11,18 +11,29 @@ export function define_bridge(api_name, method_names) {
|
|
|
11
11
|
const answer = implementation[method];
|
|
12
12
|
return answer.apply(implementation, args);
|
|
13
13
|
}
|
|
14
|
+
const ipc_client = (ipc_renderer, options = {}) => {
|
|
15
|
+
const invoke = options.envelopes ? unwrapping_invoker(ipc_renderer) : clean_invoker(ipc_renderer);
|
|
16
|
+
return client_through((method, args) => invoke.invoke(channel(method), ...args));
|
|
17
|
+
};
|
|
14
18
|
return {
|
|
15
19
|
api_name,
|
|
16
20
|
method_names,
|
|
17
21
|
channel,
|
|
18
22
|
dispatch,
|
|
19
|
-
ipc_client
|
|
20
|
-
expose: (context_bridge, ipc_renderer) => {
|
|
21
|
-
context_bridge.exposeInMainWorld(api_name,
|
|
23
|
+
ipc_client,
|
|
24
|
+
expose: (context_bridge, ipc_renderer, options) => {
|
|
25
|
+
context_bridge.exposeInMainWorld(api_name, ipc_client(ipc_renderer, options));
|
|
22
26
|
},
|
|
23
|
-
handle: (ipc_main, implementation) => {
|
|
27
|
+
handle: (ipc_main, implementation, options = {}) => {
|
|
24
28
|
for (const method of method_names) {
|
|
25
|
-
ipc_main.handle(channel(method), (
|
|
29
|
+
ipc_main.handle(channel(method), (event, ...args) => {
|
|
30
|
+
const answer = () => {
|
|
31
|
+
if (options.trusted && !options.trusted((event ?? {})))
|
|
32
|
+
throw new Error("Untrusted sender.");
|
|
33
|
+
return dispatch(implementation, method, args);
|
|
34
|
+
};
|
|
35
|
+
return options.envelopes ? to_envelope(answer) : answer();
|
|
36
|
+
});
|
|
26
37
|
}
|
|
27
38
|
return () => { for (const method of method_names)
|
|
28
39
|
ipc_main.removeHandler(channel(method)); };
|
|
@@ -30,6 +41,47 @@ export function define_bridge(api_name, method_names) {
|
|
|
30
41
|
web_rpc: client_through,
|
|
31
42
|
};
|
|
32
43
|
}
|
|
44
|
+
// ── Envelopes and errors ─────────────────────────────────────────────────────
|
|
45
|
+
// Runs the work and puts its result or its failure's words in an envelope.
|
|
46
|
+
export async function to_envelope(work) {
|
|
47
|
+
try {
|
|
48
|
+
return { ok: true, result: await work() };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const REMOTE_ERROR_PREFIX = /^Error invoking remote method '[^']*': (?:\w*Error: )?/;
|
|
55
|
+
// Electron reports a rejected handler as "Error invoking remote method 'x': Error: words", which the page shows as the words alone.
|
|
56
|
+
export function clean_error_message(message) {
|
|
57
|
+
return message.replace(REMOTE_ERROR_PREFIX, "");
|
|
58
|
+
}
|
|
59
|
+
// An ipcRenderer whose rejections carry clean messages.
|
|
60
|
+
export function clean_invoker(ipc_renderer) {
|
|
61
|
+
return {
|
|
62
|
+
invoke: async (channel, ...args) => {
|
|
63
|
+
try {
|
|
64
|
+
return await ipc_renderer.invoke(channel, ...args);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error)
|
|
68
|
+
throw new Error(clean_error_message(error.message));
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// An ipcRenderer that opens envelope replies, throwing a failure as an Error with its message.
|
|
75
|
+
export function unwrapping_invoker(ipc_renderer) {
|
|
76
|
+
return {
|
|
77
|
+
invoke: async (channel, ...args) => {
|
|
78
|
+
const envelope = (await ipc_renderer.invoke(channel, ...args));
|
|
79
|
+
if (!envelope.ok)
|
|
80
|
+
throw new Error(envelope.error);
|
|
81
|
+
return envelope.result;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
33
85
|
// ── Cloning ──────────────────────────────────────────────────────────────────
|
|
34
86
|
// A value that survives structured clone passes as it is, and anything else, such as a Proxy, crosses as plain JSON.
|
|
35
87
|
export function to_cloneable(value) {
|
package/dist/electron/fetch.d.ts
CHANGED
|
@@ -12,14 +12,41 @@ export interface FetchAnswer {
|
|
|
12
12
|
body: Uint8Array<ArrayBuffer> | null;
|
|
13
13
|
}
|
|
14
14
|
export type FetchMethod = (request: FetchRequest) => FetchAnswer;
|
|
15
|
+
export interface StreamRequest extends FetchRequest {
|
|
16
|
+
stream_id: string;
|
|
17
|
+
}
|
|
18
|
+
export type StreamHead = Omit<FetchAnswer, "body">;
|
|
19
|
+
export type StreamChunk = {
|
|
20
|
+
stream_id: string;
|
|
21
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
22
|
+
} | {
|
|
23
|
+
stream_id: string;
|
|
24
|
+
done: true;
|
|
25
|
+
} | {
|
|
26
|
+
stream_id: string;
|
|
27
|
+
error: string;
|
|
28
|
+
};
|
|
29
|
+
export type StreamStartMethod = (request: StreamRequest) => StreamHead;
|
|
30
|
+
export type StreamCancelMethod = (stream_id: string) => void;
|
|
15
31
|
export interface FetchPolicy {
|
|
16
32
|
hosts: readonly string[];
|
|
17
33
|
allow_http?: boolean;
|
|
18
34
|
}
|
|
19
35
|
export declare function is_allowed_url(url: string, policy: FetchPolicy): boolean;
|
|
20
36
|
export declare function create_main_fetch(policy: FetchPolicy, send?: typeof fetch): (request: FetchRequest) => Promise<FetchAnswer>;
|
|
37
|
+
export interface MainStreamFetch {
|
|
38
|
+
start: (request: StreamRequest) => Promise<StreamHead>;
|
|
39
|
+
cancel: (stream_id: string) => void;
|
|
40
|
+
}
|
|
41
|
+
export declare function create_main_stream_fetch(policy: FetchPolicy, emit: (chunk: StreamChunk) => void, send?: typeof fetch): MainStreamFetch;
|
|
21
42
|
export interface PageFetchOptions {
|
|
22
43
|
pinned_headers?: Record<string, string>;
|
|
23
44
|
}
|
|
24
45
|
export declare function to_response(answer: FetchAnswer): Response;
|
|
25
46
|
export declare function create_page_fetch(send: (request: FetchRequest) => Promise<FetchAnswer>, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
47
|
+
export interface PageStreamBridge {
|
|
48
|
+
start: (request: StreamRequest) => Promise<StreamHead>;
|
|
49
|
+
cancel?: (stream_id: string) => Promise<void> | void;
|
|
50
|
+
on_chunk: (handler: (chunk: StreamChunk) => void) => () => void;
|
|
51
|
+
}
|
|
52
|
+
export declare function create_page_stream_fetch(bridge: PageStreamBridge, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
package/dist/electron/fetch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Fetching from Electron's main process for a page whose own requests the browser's cross-origin rules would block.
|
|
1
|
+
// Fetching from Electron's main process for a page whose own requests the browser's cross-origin rules would block, with the body whole or streamed in chunks.
|
|
2
2
|
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
3
3
|
export function is_allowed_url(url, policy) {
|
|
4
4
|
let parsed;
|
|
@@ -34,6 +34,56 @@ export function create_main_fetch(policy, send = fetch) {
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
// The main process's half of a streamed fetch: answers with the head and sends each chunk of the body through emit as it arrives.
|
|
38
|
+
export function create_main_stream_fetch(policy, emit, send = fetch) {
|
|
39
|
+
const open = new Map();
|
|
40
|
+
async function pump(stream_id, body, controller) {
|
|
41
|
+
const reader = body.getReader();
|
|
42
|
+
try {
|
|
43
|
+
while (!controller.signal.aborted) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
emit({ stream_id, bytes: new Uint8Array(value) });
|
|
48
|
+
}
|
|
49
|
+
if (!controller.signal.aborted)
|
|
50
|
+
emit({ stream_id, done: true });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!controller.signal.aborted)
|
|
54
|
+
emit({ stream_id, error: error instanceof Error ? error.message : String(error) });
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
open.delete(stream_id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
start: async (request) => {
|
|
62
|
+
if (!is_allowed_url(request.url, policy))
|
|
63
|
+
throw new Error(`fetching ${request.url} is not allowed`);
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
open.set(request.stream_id, controller);
|
|
66
|
+
const response = await send(request.url, { method: request.method, headers: request.headers, body: request.body, signal: controller.signal });
|
|
67
|
+
const headers = [];
|
|
68
|
+
response.headers.forEach((value, name) => {
|
|
69
|
+
if (!DROPPED_ANSWER_HEADERS.has(name))
|
|
70
|
+
headers.push([name, value]);
|
|
71
|
+
});
|
|
72
|
+
const has_body = response.body !== null && !NULL_BODY_STATUSES.has(response.status) && request.method !== "HEAD";
|
|
73
|
+
if (has_body)
|
|
74
|
+
void pump(request.stream_id, response.body, controller);
|
|
75
|
+
else {
|
|
76
|
+
open.delete(request.stream_id);
|
|
77
|
+
emit({ stream_id: request.stream_id, done: true });
|
|
78
|
+
}
|
|
79
|
+
return { status: response.status, status_text: response.statusText, headers };
|
|
80
|
+
},
|
|
81
|
+
cancel: (stream_id) => {
|
|
82
|
+
open.get(stream_id)?.abort();
|
|
83
|
+
open.delete(stream_id);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
37
87
|
function abort_error() {
|
|
38
88
|
return new DOMException("The request was aborted", "AbortError");
|
|
39
89
|
}
|
|
@@ -65,3 +115,61 @@ export function create_page_fetch(send, options = {}) {
|
|
|
65
115
|
return to_response(answer);
|
|
66
116
|
};
|
|
67
117
|
}
|
|
118
|
+
let next_stream_number = 0;
|
|
119
|
+
const new_stream_id = () => `${Date.now().toString(36)}-${(next_stream_number++).toString(36)}`;
|
|
120
|
+
// The page's half of a streamed fetch: a fetch whose Response body is a ReadableStream fed by the chunks, so a player reads them as they land.
|
|
121
|
+
export function create_page_stream_fetch(bridge, options = {}) {
|
|
122
|
+
return async (input, init) => {
|
|
123
|
+
const request = new Request(input, init);
|
|
124
|
+
const signal = init?.signal ?? request.signal;
|
|
125
|
+
const stream_id = new_stream_id();
|
|
126
|
+
const headers = new Headers(request.headers);
|
|
127
|
+
for (const [name, value] of Object.entries(options.pinned_headers ?? {}))
|
|
128
|
+
headers.set(name, value);
|
|
129
|
+
const body = request.method === "GET" || request.method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer());
|
|
130
|
+
// Chunks may land before the head answers, so the listener is in place first and the stream holds them.
|
|
131
|
+
let stop_listening = () => { };
|
|
132
|
+
const stream = new ReadableStream({
|
|
133
|
+
start(controller) {
|
|
134
|
+
const finish = () => { stop_listening(); signal?.removeEventListener("abort", on_abort); };
|
|
135
|
+
const on_abort = () => {
|
|
136
|
+
finish();
|
|
137
|
+
void bridge.cancel?.(stream_id);
|
|
138
|
+
try {
|
|
139
|
+
controller.error(abort_error());
|
|
140
|
+
}
|
|
141
|
+
catch { /* already closed */ }
|
|
142
|
+
};
|
|
143
|
+
stop_listening = bridge.on_chunk((chunk) => {
|
|
144
|
+
if (chunk.stream_id !== stream_id)
|
|
145
|
+
return;
|
|
146
|
+
if ("bytes" in chunk)
|
|
147
|
+
controller.enqueue(chunk.bytes);
|
|
148
|
+
else {
|
|
149
|
+
finish();
|
|
150
|
+
if ("error" in chunk)
|
|
151
|
+
controller.error(new TypeError(chunk.error));
|
|
152
|
+
else
|
|
153
|
+
controller.close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
signal?.addEventListener("abort", on_abort, { once: true });
|
|
157
|
+
},
|
|
158
|
+
cancel() {
|
|
159
|
+
stop_listening();
|
|
160
|
+
void bridge.cancel?.(stream_id);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
const head = await until_aborted(bridge.start({ stream_id, url: request.url, method: request.method, headers: [...headers.entries()], body }), signal);
|
|
165
|
+
const streams = !NULL_BODY_STATUSES.has(head.status) && request.method !== "HEAD";
|
|
166
|
+
if (!streams)
|
|
167
|
+
void stream.cancel();
|
|
168
|
+
return new Response(streams ? stream : null, { status: head.status, statusText: head.status_text, headers: head.headers });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
stop_listening();
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface ConfigOption {
|
|
2
|
+
variable?: string;
|
|
3
|
+
flag?: string;
|
|
4
|
+
help: string;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
default?: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ServerConfigSpec<Name extends string = never> {
|
|
10
|
+
program: string;
|
|
11
|
+
summary: string;
|
|
12
|
+
env_prefix: string;
|
|
13
|
+
default_port: number;
|
|
14
|
+
options?: Record<Name, ConfigOption>;
|
|
15
|
+
built_in?: Partial<Record<BuiltInName, Partial<ConfigOption>>>;
|
|
16
|
+
web_folder_default?: string;
|
|
17
|
+
secret?: string | false;
|
|
18
|
+
argv?: string[];
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
exit_on_help?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface ServerConfig<Name extends string = never> {
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
web_folder: string;
|
|
26
|
+
secret: string;
|
|
27
|
+
values: Record<Name, string | undefined>;
|
|
28
|
+
usage: string;
|
|
29
|
+
}
|
|
30
|
+
type BuiltInName = "host" | "port" | "web_folder";
|
|
31
|
+
export declare class HelpRequested extends Error {
|
|
32
|
+
readonly usage: string;
|
|
33
|
+
constructor(usage: string);
|
|
34
|
+
}
|
|
35
|
+
export declare const loopback_hosts: string[];
|
|
36
|
+
export declare function is_loopback(host: string): boolean;
|
|
37
|
+
export declare function read_secret_file(path: string): string;
|
|
38
|
+
export declare function read_server_config<Name extends string = never>(spec: ServerConfigSpec<Name>): ServerConfig<Name>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// A server's settings from the command line and the environment: the address and port, the web folder, the app's own
|
|
2
|
+
// options, and the secret browsers sign in with, read from a file or a variable. Without a secret the server may only
|
|
3
|
+
// listen on this machine.
|
|
4
|
+
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
export class HelpRequested extends Error {
|
|
8
|
+
usage;
|
|
9
|
+
constructor(usage) {
|
|
10
|
+
super(usage);
|
|
11
|
+
this.usage = usage;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
15
|
+
export const loopback_hosts = ["127.0.0.1", "::1", "localhost"];
|
|
16
|
+
export function is_loopback(host) {
|
|
17
|
+
return loopback_hosts.includes(host);
|
|
18
|
+
}
|
|
19
|
+
// ── Secrets ──────────────────────────────────────────────────────────────────
|
|
20
|
+
// The file's text without its final newline; an unreadable or empty file is an error.
|
|
21
|
+
export function read_secret_file(path) {
|
|
22
|
+
let secret;
|
|
23
|
+
try {
|
|
24
|
+
secret = readFileSync(path, "utf8").replace(/\r?\n$/, "");
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new Error(`Could not read the secret at ${path}: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
if (secret.trim() === "")
|
|
30
|
+
throw new Error(`The secret at ${path} is empty`);
|
|
31
|
+
return secret.trim();
|
|
32
|
+
}
|
|
33
|
+
// ── Reading ──────────────────────────────────────────────────────────────────
|
|
34
|
+
function capitals(name) {
|
|
35
|
+
return name.replaceAll("-", "_").toUpperCase();
|
|
36
|
+
}
|
|
37
|
+
function usage_text(program, summary, options, secret_note) {
|
|
38
|
+
const entries = Object.values(options);
|
|
39
|
+
const synopsis = entries
|
|
40
|
+
.map((option) => (option.required ? `--${option.flag} ${option.placeholder}` : `[--${option.flag} ${option.placeholder}]`))
|
|
41
|
+
.join(" ");
|
|
42
|
+
const flag_width = Math.max(...entries.map((option) => option.flag.length)) + 2;
|
|
43
|
+
const variable_width = Math.max(...entries.map((option) => option.variable.length));
|
|
44
|
+
const rows = entries.map((option) => {
|
|
45
|
+
const suffix = option.default === undefined ? "" : ` (default ${option.default})`;
|
|
46
|
+
return ` ${`--${option.flag}`.padEnd(flag_width)} ${option.variable.padEnd(variable_width)} ${option.help}${suffix}`;
|
|
47
|
+
});
|
|
48
|
+
return `Usage: ${program} ${synopsis}\n\n${summary} Every option can also be set in the environment:\n${rows.join("\n")}${secret_note}`;
|
|
49
|
+
}
|
|
50
|
+
export function read_server_config(spec) {
|
|
51
|
+
const { env_prefix, argv = process.argv.slice(2), env = process.env, exit_on_help = true } = spec;
|
|
52
|
+
const secret_name = spec.secret === undefined ? "token" : spec.secret;
|
|
53
|
+
const own = (spec.options ?? {});
|
|
54
|
+
const changes = spec.built_in ?? {};
|
|
55
|
+
const built_in = {
|
|
56
|
+
host: { help: "the address to listen on", placeholder: "<address>", default: "127.0.0.1", ...changes.host },
|
|
57
|
+
port: { help: "the port to listen on", placeholder: "<number>", default: String(spec.default_port), ...changes.port },
|
|
58
|
+
};
|
|
59
|
+
if (spec.web_folder_default !== undefined) {
|
|
60
|
+
built_in.web_folder = { help: "the built web page", placeholder: "<folder>", default: spec.web_folder_default, ...changes.web_folder };
|
|
61
|
+
}
|
|
62
|
+
const options = Object.fromEntries(Object.entries({ ...own, ...built_in }).map(([name, option]) => [name, {
|
|
63
|
+
...option,
|
|
64
|
+
variable: option.variable ?? `${env_prefix}_${capitals(name)}`,
|
|
65
|
+
flag: option.flag ?? name.replaceAll("_", "-"),
|
|
66
|
+
placeholder: option.placeholder ?? "<value>",
|
|
67
|
+
}]));
|
|
68
|
+
const secret_note = secret_name === false ? "" : `\n\nThe ${secret_name} browsers sign in with is read from the file named by ${env_prefix}_${capitals(secret_name)}_FILE,\nor from ${env_prefix}_${capitals(secret_name)}. Without one the server only listens on this machine.`;
|
|
69
|
+
const usage = usage_text(spec.program, spec.summary, options, secret_note);
|
|
70
|
+
const { values: flags } = parseArgs({
|
|
71
|
+
args: argv,
|
|
72
|
+
options: { ...Object.fromEntries(Object.values(options).map((option) => [option.flag, { type: "string" }])), help: { type: "boolean" } },
|
|
73
|
+
});
|
|
74
|
+
if (flags.help) {
|
|
75
|
+
if (!exit_on_help)
|
|
76
|
+
throw new HelpRequested(usage);
|
|
77
|
+
console.log(usage);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
const value_of = (name) => {
|
|
81
|
+
const option = options[name];
|
|
82
|
+
return flags[option.flag] ?? env[option.variable] ?? option.default;
|
|
83
|
+
};
|
|
84
|
+
for (const [name, option] of Object.entries(options)) {
|
|
85
|
+
if (option.required && value_of(name) === undefined)
|
|
86
|
+
throw new Error(`no ${option.flag} given\n\n${usage}`);
|
|
87
|
+
}
|
|
88
|
+
const port_text = value_of("port");
|
|
89
|
+
const port = Number(port_text);
|
|
90
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
91
|
+
throw new Error(`not a port: ${port_text}`);
|
|
92
|
+
let secret = "";
|
|
93
|
+
if (secret_name !== false) {
|
|
94
|
+
const secret_file = env[`${env_prefix}_${capitals(secret_name)}_FILE`];
|
|
95
|
+
secret = secret_file ? read_secret_file(secret_file) : (env[`${env_prefix}_${capitals(secret_name)}`] ?? "").trim();
|
|
96
|
+
}
|
|
97
|
+
const host = value_of("host");
|
|
98
|
+
if (secret_name !== false && secret === "" && !is_loopback(host)) {
|
|
99
|
+
throw new Error(`listening on ${host} needs a ${secret_name}; set ${env_prefix}_${capitals(secret_name)}_FILE or ${env_prefix}_${capitals(secret_name)}`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
host,
|
|
103
|
+
port,
|
|
104
|
+
web_folder: "web_folder" in options ? value_of("web_folder") : "",
|
|
105
|
+
secret,
|
|
106
|
+
values: Object.fromEntries(Object.keys(own).map((name) => [name, value_of(name)])),
|
|
107
|
+
usage,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
export interface OpenCorsOptions {
|
|
3
|
+
methods?: string[];
|
|
4
|
+
allowed_headers?: string[];
|
|
5
|
+
client_id_header?: string;
|
|
6
|
+
max_age_seconds?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function register_open_cors(app: FastifyInstance, options?: OpenCorsOptions): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Cross-origin access for an API that a native or mobile shell calls from a page of its own: any origin may, since a
|
|
2
|
+
// browser sends no cookies with such a call and the server's secret is still needed.
|
|
3
|
+
import fastify_cors from "@fastify/cors";
|
|
4
|
+
import { default_client_id_header } from "./events.js";
|
|
5
|
+
export async function register_open_cors(app, options = {}) {
|
|
6
|
+
const { methods = ["GET", "POST"], allowed_headers = [], client_id_header = default_client_id_header, max_age_seconds = 86_400 } = options;
|
|
7
|
+
await app.register(fastify_cors, {
|
|
8
|
+
origin: "*",
|
|
9
|
+
methods,
|
|
10
|
+
allowedHeaders: ["authorization", "content-type", client_id_header, ...allowed_headers],
|
|
11
|
+
maxAge: max_age_seconds,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from "fastify";
|
|
2
|
+
export interface EventStreamOptions {
|
|
3
|
+
heartbeat_ms?: number;
|
|
4
|
+
retry_ms?: number;
|
|
5
|
+
hello_event?: string | null;
|
|
6
|
+
changed_event?: string;
|
|
7
|
+
client_id_header?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StreamClient {
|
|
10
|
+
id: string;
|
|
11
|
+
request: FastifyRequest;
|
|
12
|
+
}
|
|
13
|
+
export interface SendOptions {
|
|
14
|
+
event?: string;
|
|
15
|
+
except_client_id?: string;
|
|
16
|
+
to?: (client: StreamClient) => boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare const default_client_id_header = "x-client-id";
|
|
19
|
+
export declare class EventStream {
|
|
20
|
+
readonly heartbeat_ms: number;
|
|
21
|
+
readonly retry_ms: number;
|
|
22
|
+
readonly hello_event: string | null;
|
|
23
|
+
readonly changed_event: string;
|
|
24
|
+
readonly client_id_header: string;
|
|
25
|
+
private readonly streams;
|
|
26
|
+
constructor(options?: EventStreamOptions);
|
|
27
|
+
get client_count(): number;
|
|
28
|
+
client_id_of(request: FastifyRequest): string;
|
|
29
|
+
send(data: string, options?: SendOptions): void;
|
|
30
|
+
announce_change(except_client_id?: string): void;
|
|
31
|
+
register(app: FastifyInstance, path?: string): void;
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|