@x-tech-laboratories/x-libs 0.9.0 → 0.11.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 +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 +2 -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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Server-sent events: each open page keeps one stream and is told through it when the data changed, so it fetches at
|
|
2
|
+
// once instead of polling. Every stream gets a client id, so the page that made a change is not told about its own.
|
|
3
|
+
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
6
|
+
export const default_client_id_header = "x-client-id";
|
|
7
|
+
// ── Stream ───────────────────────────────────────────────────────────────────
|
|
8
|
+
export class EventStream {
|
|
9
|
+
heartbeat_ms;
|
|
10
|
+
retry_ms;
|
|
11
|
+
hello_event;
|
|
12
|
+
changed_event;
|
|
13
|
+
client_id_header;
|
|
14
|
+
streams = new Map();
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.heartbeat_ms = options.heartbeat_ms ?? 25_000;
|
|
17
|
+
this.retry_ms = options.retry_ms ?? 3000;
|
|
18
|
+
this.hello_event = options.hello_event === undefined ? "hello" : options.hello_event;
|
|
19
|
+
this.changed_event = options.changed_event ?? "changed";
|
|
20
|
+
this.client_id_header = options.client_id_header ?? default_client_id_header;
|
|
21
|
+
}
|
|
22
|
+
get client_count() {
|
|
23
|
+
return this.streams.size;
|
|
24
|
+
}
|
|
25
|
+
// The client id a request carries, or "" for one that sent none.
|
|
26
|
+
client_id_of(request) {
|
|
27
|
+
return String(request.headers[this.client_id_header] ?? "");
|
|
28
|
+
}
|
|
29
|
+
send(data, options = {}) {
|
|
30
|
+
const lines = data.split("\n").map((line) => `data: ${line}`).join("\n");
|
|
31
|
+
const text = `${options.event === undefined ? "" : `event: ${options.event}\n`}${lines}\n\n`;
|
|
32
|
+
for (const { client, response } of this.streams.values()) {
|
|
33
|
+
if (client.id === options.except_client_id)
|
|
34
|
+
continue;
|
|
35
|
+
if (options.to !== undefined && !options.to(client))
|
|
36
|
+
continue;
|
|
37
|
+
response.write(text);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// The page that made a change already shows it, so only the others are told.
|
|
41
|
+
announce_change(except_client_id = "") {
|
|
42
|
+
this.send("", { event: this.changed_event, except_client_id });
|
|
43
|
+
}
|
|
44
|
+
// Serves the stream on the path, and ends every stream when the app closes, since one never ends by itself.
|
|
45
|
+
register(app, path = "/events") {
|
|
46
|
+
app.get(path, (request, reply) => {
|
|
47
|
+
reply.hijack();
|
|
48
|
+
const response = reply.raw;
|
|
49
|
+
const id = randomUUID();
|
|
50
|
+
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" });
|
|
51
|
+
response.write(`retry: ${this.retry_ms}\n\n`);
|
|
52
|
+
if (this.hello_event !== null)
|
|
53
|
+
response.write(`event: ${this.hello_event}\ndata: ${id}\n\n`);
|
|
54
|
+
this.streams.set(id, { client: { id, request }, response });
|
|
55
|
+
const heartbeat = setInterval(() => response.write(": still here\n\n"), this.heartbeat_ms);
|
|
56
|
+
request.raw.on("close", () => {
|
|
57
|
+
clearInterval(heartbeat);
|
|
58
|
+
this.streams.delete(id);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
app.addHook("onClose", async () => this.close());
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
for (const { response } of this.streams.values())
|
|
65
|
+
response.end();
|
|
66
|
+
this.streams.clear();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The page a browser signs in on with the server's access token: a page of its own, so signing in needs nothing from
|
|
2
|
+
// the app's built files, with colours that follow the light and dark themes.
|
|
3
|
+
function escape_html(text) {
|
|
4
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
5
|
+
}
|
|
6
|
+
export function render_login_page(options) {
|
|
7
|
+
const title = escape_html(options.title);
|
|
8
|
+
const action = escape_html(options.action ?? "login");
|
|
9
|
+
const prompt = escape_html(options.prompt ?? "Enter the access token this server was set up with.");
|
|
10
|
+
const failure_text = escape_html(options.failure_text ?? "That token is not right.");
|
|
11
|
+
return `<!doctype html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="UTF-8" />
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
16
|
+
<title>${title}</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root {
|
|
19
|
+
color-scheme: light dark;
|
|
20
|
+
--surface-root: #f1f5f9;
|
|
21
|
+
--surface-default: #ffffff;
|
|
22
|
+
--text-heading: #0f172a;
|
|
23
|
+
--text-secondary: #475569;
|
|
24
|
+
--border-control: #8792a3;
|
|
25
|
+
--accent: #2563eb;
|
|
26
|
+
--text-on-fill: #fff;
|
|
27
|
+
--status-danger: #dc2626;
|
|
28
|
+
}
|
|
29
|
+
@media (prefers-color-scheme: dark) {
|
|
30
|
+
:root {
|
|
31
|
+
--surface-root: #060b14;
|
|
32
|
+
--surface-default: #16213a;
|
|
33
|
+
--text-heading: #f8fafc;
|
|
34
|
+
--text-secondary: #cbd5e1;
|
|
35
|
+
--border-control: #64748b;
|
|
36
|
+
--accent: #60a5fa;
|
|
37
|
+
--text-on-fill: #0b1220;
|
|
38
|
+
--status-danger: #f87171;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
body {
|
|
42
|
+
margin: 0;
|
|
43
|
+
min-height: 100vh;
|
|
44
|
+
display: grid;
|
|
45
|
+
place-items: center;
|
|
46
|
+
padding: 0 16px;
|
|
47
|
+
background: var(--surface-root);
|
|
48
|
+
color: var(--text-heading);
|
|
49
|
+
font: 15px system-ui, sans-serif;
|
|
50
|
+
}
|
|
51
|
+
form {
|
|
52
|
+
width: min(340px, 100%);
|
|
53
|
+
display: grid;
|
|
54
|
+
gap: 12px;
|
|
55
|
+
padding: 24px;
|
|
56
|
+
border-radius: 12px;
|
|
57
|
+
background: var(--surface-default);
|
|
58
|
+
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
|
59
|
+
}
|
|
60
|
+
h1 { margin: 0; font-size: 20px; }
|
|
61
|
+
p { margin: 0; color: var(--text-secondary); }
|
|
62
|
+
.error { color: var(--status-danger); }
|
|
63
|
+
input, button { font: inherit; padding: 8px 10px; border-radius: 8px; }
|
|
64
|
+
input { border: 1px solid var(--border-control); background: transparent; color: inherit; }
|
|
65
|
+
button { border: 0; background: var(--accent); color: var(--text-on-fill); cursor: pointer; }
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<form method="post" action="${action}">
|
|
70
|
+
<h1>${title}</h1>
|
|
71
|
+
<p>${prompt}</p>
|
|
72
|
+
${options.has_failed ? `<p class="error">${failure_text}</p>` : ""}
|
|
73
|
+
<input type="password" name="token" autocomplete="current-password" aria-label="Access token" autofocus required />
|
|
74
|
+
<button type="submit">Sign in</button>
|
|
75
|
+
</form>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from "fastify";
|
|
2
|
+
export interface ErrorRepliesOptions {
|
|
3
|
+
format?: "text" | "json";
|
|
4
|
+
not_found_text?: string;
|
|
5
|
+
not_found?: boolean;
|
|
6
|
+
log_failure?: (message: string, error: Error) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare const BAD_JSON: unique symbol;
|
|
9
|
+
export declare function send_text(reply: FastifyReply, status: number, text: string): FastifyReply;
|
|
10
|
+
export declare function send_error(reply: FastifyReply, status: number, text: string, format?: "text" | "json"): FastifyReply;
|
|
11
|
+
export declare function register_error_replies(app: FastifyInstance, options?: ErrorRepliesOptions): void;
|
|
12
|
+
export declare function register_lenient_json_body(app: FastifyInstance): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// How a server answers what it could not do: every refusal in plain words, or as JSON { error }, and a JSON body
|
|
2
|
+
// parser that hands the route an empty body as no arguments and a bad one as a marker, so the route answers in its
|
|
3
|
+
// own words instead of Fastify's.
|
|
4
|
+
// What the JSON parser hands a route when the body was not JSON.
|
|
5
|
+
export const BAD_JSON = Symbol("bad json");
|
|
6
|
+
// ── Replies ──────────────────────────────────────────────────────────────────
|
|
7
|
+
export function send_text(reply, status, text) {
|
|
8
|
+
return reply.code(status).type("text/plain; charset=utf-8").send(text);
|
|
9
|
+
}
|
|
10
|
+
export function send_error(reply, status, text, format = "text") {
|
|
11
|
+
if (format === "json")
|
|
12
|
+
return reply.code(status).send({ error: text });
|
|
13
|
+
return send_text(reply, status, text);
|
|
14
|
+
}
|
|
15
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
16
|
+
// Errors answer with their own status and message, and unknown paths with 404, all in the one format.
|
|
17
|
+
export function register_error_replies(app, options = {}) {
|
|
18
|
+
const { format = "text", not_found_text = "not found", not_found = true } = options;
|
|
19
|
+
const log_failure = options.log_failure ?? ((message, error) => console.error(message, error));
|
|
20
|
+
app.setErrorHandler((error, request, reply) => {
|
|
21
|
+
const status = error.statusCode ?? 500;
|
|
22
|
+
if (status >= 500)
|
|
23
|
+
log_failure(`${request.method} ${request.url} failed:`, error);
|
|
24
|
+
return send_error(reply, status, error.message, format);
|
|
25
|
+
});
|
|
26
|
+
if (not_found)
|
|
27
|
+
app.setNotFoundHandler(async (_request, reply) => send_error(reply, 404, not_found_text, format));
|
|
28
|
+
}
|
|
29
|
+
// An empty body parses as undefined and a body that is not JSON as BAD_JSON, for the route to refuse in its own words.
|
|
30
|
+
export function register_lenient_json_body(app) {
|
|
31
|
+
app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
|
|
32
|
+
if (body === "")
|
|
33
|
+
return done(null, undefined);
|
|
34
|
+
try {
|
|
35
|
+
done(null, JSON.parse(body));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
done(null, BAD_JSON);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from "fastify";
|
|
2
|
+
export interface SignInGuardOptions {
|
|
3
|
+
is_signed_in: (request: FastifyRequest) => boolean;
|
|
4
|
+
api_prefix?: string;
|
|
5
|
+
open_paths?: Iterable<string>;
|
|
6
|
+
login_path?: string | null;
|
|
7
|
+
refusal?: "text" | "json";
|
|
8
|
+
refusal_text?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TokenSignInOptions extends Omit<SignInGuardOptions, "is_signed_in"> {
|
|
11
|
+
secret: string;
|
|
12
|
+
title: string;
|
|
13
|
+
cookie_name?: string;
|
|
14
|
+
session_seconds?: number;
|
|
15
|
+
failed_login_delay_ms?: number;
|
|
16
|
+
login_path?: string;
|
|
17
|
+
logout_path?: string;
|
|
18
|
+
home_path?: string;
|
|
19
|
+
render_login_page?: (has_failed: boolean) => string;
|
|
20
|
+
}
|
|
21
|
+
export declare function same_secret(first: string, second: string): boolean;
|
|
22
|
+
export declare function session_value(secret: string, cookie_name: string): string;
|
|
23
|
+
export declare function cookie_name_for(title: string): string;
|
|
24
|
+
export declare function is_token_signed_in(request: FastifyRequest, secret: string, cookie_name: string): boolean;
|
|
25
|
+
export declare function path_of(request: FastifyRequest): string;
|
|
26
|
+
export declare function register_sign_in_guard(app: FastifyInstance, options: SignInGuardOptions): void;
|
|
27
|
+
export declare function register_token_sign_in(app: FastifyInstance, options: TokenSignInOptions): Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Signing in to a server that has one secret: a browser posts it on the login page and keeps a cookie derived from it,
|
|
2
|
+
// other programs send it as a bearer header, and the guard turns away every request that carries neither. The guard
|
|
3
|
+
// alone also serves servers with their own sessions, which hand it the check.
|
|
4
|
+
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import fastify_cookie from "@fastify/cookie";
|
|
7
|
+
import fastify_formbody from "@fastify/formbody";
|
|
8
|
+
import { render_login_page } from "./login_page.js";
|
|
9
|
+
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
10
|
+
const default_session_seconds = 90 * 24 * 60 * 60;
|
|
11
|
+
const default_failed_login_delay_ms = 1000;
|
|
12
|
+
// ── Secrets ──────────────────────────────────────────────────────────────────
|
|
13
|
+
export function same_secret(first, second) {
|
|
14
|
+
const first_bytes = Buffer.from(first);
|
|
15
|
+
const second_bytes = Buffer.from(second);
|
|
16
|
+
return first_bytes.length === second_bytes.length && timingSafeEqual(first_bytes, second_bytes);
|
|
17
|
+
}
|
|
18
|
+
// The cookie holds a value derived from the secret, so changing the secret signs every browser out.
|
|
19
|
+
export function session_value(secret, cookie_name) {
|
|
20
|
+
return createHmac("sha256", secret).update(cookie_name).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
export function cookie_name_for(title) {
|
|
23
|
+
return `${title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_").replaceAll(/^_|_$/g, "")}_session`;
|
|
24
|
+
}
|
|
25
|
+
// A browser signs in with its cookie; other programs can send the secret as a bearer header instead.
|
|
26
|
+
export function is_token_signed_in(request, secret, cookie_name) {
|
|
27
|
+
if (secret === "")
|
|
28
|
+
return true;
|
|
29
|
+
const bearer = /^Bearer (.+)$/.exec(request.headers.authorization ?? "")?.[1];
|
|
30
|
+
if (bearer !== undefined)
|
|
31
|
+
return same_secret(bearer, secret);
|
|
32
|
+
return same_secret(request.cookies[cookie_name] ?? "", session_value(secret, cookie_name));
|
|
33
|
+
}
|
|
34
|
+
// A proxy that ends TLS says so in a header, and the cookie is then kept to secure connections.
|
|
35
|
+
function is_secure(request) {
|
|
36
|
+
return request.headers["x-forwarded-proto"] === "https" || "encrypted" in request.raw.socket;
|
|
37
|
+
}
|
|
38
|
+
function set_session_cookie(request, reply, cookie_name, value, max_age_seconds) {
|
|
39
|
+
void reply.setCookie(cookie_name, value, { path: "/", httpOnly: true, sameSite: "strict", maxAge: max_age_seconds, secure: is_secure(request) });
|
|
40
|
+
}
|
|
41
|
+
// ── Guard ────────────────────────────────────────────────────────────────────
|
|
42
|
+
export function path_of(request) {
|
|
43
|
+
return request.url.split("?")[0] ?? "";
|
|
44
|
+
}
|
|
45
|
+
// Turns away every request that is not signed in: a page load goes to the login page, while a call gets a refusal it can act on.
|
|
46
|
+
export function register_sign_in_guard(app, options) {
|
|
47
|
+
const { is_signed_in, api_prefix = "/api/", login_path = "/login", refusal = "text", refusal_text = "sign in first" } = options;
|
|
48
|
+
const open_paths = new Set(options.open_paths ?? []);
|
|
49
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
50
|
+
const path = path_of(request);
|
|
51
|
+
// Preflight checks carry no credentials, and the sign-in routes are open by nature.
|
|
52
|
+
if (request.method === "OPTIONS" || open_paths.has(path))
|
|
53
|
+
return;
|
|
54
|
+
if (is_signed_in(request))
|
|
55
|
+
return;
|
|
56
|
+
if (!path.startsWith(api_prefix)) {
|
|
57
|
+
if (login_path === null)
|
|
58
|
+
return;
|
|
59
|
+
return reply.redirect(login_path, 303);
|
|
60
|
+
}
|
|
61
|
+
void reply.code(401).header("cache-control", "no-store");
|
|
62
|
+
if (refusal === "json")
|
|
63
|
+
return reply.send({ error: refusal_text });
|
|
64
|
+
return reply.type("text/plain; charset=utf-8").send(refusal_text);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// ── Token sign-in ────────────────────────────────────────────────────────────
|
|
68
|
+
// Signs browsers in and out with the server's one secret, and guards everything else.
|
|
69
|
+
export async function register_token_sign_in(app, options) {
|
|
70
|
+
const { secret, title, login_path = "/login", logout_path = "/logout", home_path = "/", session_seconds = default_session_seconds, failed_login_delay_ms = default_failed_login_delay_ms, } = options;
|
|
71
|
+
const cookie_name = options.cookie_name ?? cookie_name_for(title);
|
|
72
|
+
const render = options.render_login_page ?? ((has_failed) => render_login_page({ title, has_failed, action: login_path }));
|
|
73
|
+
await app.register(fastify_cookie);
|
|
74
|
+
await app.register(fastify_formbody);
|
|
75
|
+
register_sign_in_guard(app, {
|
|
76
|
+
...options,
|
|
77
|
+
login_path,
|
|
78
|
+
open_paths: [login_path, logout_path, ...(options.open_paths ?? [])],
|
|
79
|
+
is_signed_in: (request) => is_token_signed_in(request, secret, cookie_name),
|
|
80
|
+
});
|
|
81
|
+
app.get(login_path, async (_request, reply) => reply.type("text/html; charset=utf-8").send(render(false)));
|
|
82
|
+
app.post(login_path, async (request, reply) => {
|
|
83
|
+
const token = request.body?.token ?? "";
|
|
84
|
+
if (secret === "" || same_secret(token, secret)) {
|
|
85
|
+
set_session_cookie(request, reply, cookie_name, session_value(secret, cookie_name), session_seconds);
|
|
86
|
+
return reply.redirect(home_path, 303);
|
|
87
|
+
}
|
|
88
|
+
await new Promise((wait) => setTimeout(wait, failed_login_delay_ms));
|
|
89
|
+
return reply.code(401).type("text/html; charset=utf-8").send(render(true));
|
|
90
|
+
});
|
|
91
|
+
app.get(logout_path, async (request, reply) => {
|
|
92
|
+
set_session_cookie(request, reply, cookie_name, "", 0);
|
|
93
|
+
return reply.redirect(login_path, 303);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
export interface WebFolderOptions {
|
|
3
|
+
folder: string;
|
|
4
|
+
immutable_path?: string;
|
|
5
|
+
page_cache_control?: string;
|
|
6
|
+
when_missing?: "warn" | "throw" | "skip";
|
|
7
|
+
page_fallback?: boolean;
|
|
8
|
+
api_prefix?: string;
|
|
9
|
+
api_not_found?: (request: FastifyRequest, reply: FastifyReply) => unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function register_web_folder(app: FastifyInstance, options: WebFolderOptions): Promise<boolean>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The built web page served from a folder, tuned for files from the Nix store: those are all dated 1970 and a rebuilt
|
|
2
|
+
// page keeps its size, so the size-and-date ETag is off and the page is fetched whole each time, while the bundles
|
|
3
|
+
// carry a content hash in their name and are kept for a year.
|
|
4
|
+
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import fastify_static from "@fastify/static";
|
|
8
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
9
|
+
// Whether the folder was found and is being served.
|
|
10
|
+
export async function register_web_folder(app, options) {
|
|
11
|
+
const { immutable_path = "/assets/", page_cache_control = "no-cache", when_missing = "warn", api_prefix = "/api/" } = options;
|
|
12
|
+
const folder = resolve(options.folder);
|
|
13
|
+
if (!existsSync(folder)) {
|
|
14
|
+
if (when_missing === "throw")
|
|
15
|
+
throw new Error(`The web page is not built; nothing is at ${folder}`);
|
|
16
|
+
if (when_missing === "warn")
|
|
17
|
+
console.warn(`no web page found at ${folder}; only the API is served`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
await app.register(fastify_static, {
|
|
21
|
+
root: folder,
|
|
22
|
+
etag: false,
|
|
23
|
+
lastModified: false,
|
|
24
|
+
cacheControl: false,
|
|
25
|
+
setHeaders: (reply, path) => {
|
|
26
|
+
const immutable = path.includes(immutable_path);
|
|
27
|
+
void reply.header("cache-control", immutable ? "public, max-age=31536000, immutable" : page_cache_control);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
if (options.page_fallback) {
|
|
31
|
+
const api_not_found = options.api_not_found ?? ((_request, reply) => reply.code(404).type("text/plain; charset=utf-8").send("not found"));
|
|
32
|
+
app.setNotFoundHandler((request, reply) => {
|
|
33
|
+
const path = request.url.split("?")[0] ?? "";
|
|
34
|
+
if (path.startsWith(api_prefix) || (request.method !== "GET" && request.method !== "HEAD"))
|
|
35
|
+
return api_not_found(request, reply);
|
|
36
|
+
return reply.header("cache-control", page_cache_control).sendFile("index.html");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
package/dist/web/dom.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type ClassValue = string | false | null | undefined;
|
|
2
|
+
export declare function cx(...names: ClassValue[]): string;
|
|
1
3
|
export type Child = Node | string | number | null | undefined | false | Child[];
|
|
2
4
|
type Attributes = Record<string, string | number | boolean | EventListener | null | undefined>;
|
|
3
5
|
export declare function h<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attributes?: Attributes, ...children: Child[]): HTMLElementTagNameMap[Tag];
|
package/dist/web/dom.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// Builds elements without innerHTML, so names from hosts and logs are always shown as text.
|
|
1
|
+
// Builds elements without innerHTML, so names from hosts and logs are always shown as text, and joins class names.
|
|
2
|
+
// Joins the class names that are set, so conditional classes read as one expression.
|
|
3
|
+
export function cx(...names) {
|
|
4
|
+
return names.filter(Boolean).join(" ");
|
|
5
|
+
}
|
|
2
6
|
export function h(tag, attributes = {}, ...children) {
|
|
3
7
|
const element = document.createElement(tag);
|
|
4
8
|
for (const [name, value] of Object.entries(attributes)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function describe_error(error: unknown): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Words for anything thrown, since libraries throw plain objects and strings as well as Errors.
|
|
2
|
+
// An Error's message, a shaka-style object's category and code, a string as it is, and anything else as JSON.
|
|
3
|
+
export function describe_error(error) {
|
|
4
|
+
if (error instanceof Error)
|
|
5
|
+
return error.message || error.name;
|
|
6
|
+
if (typeof error === "string")
|
|
7
|
+
return error;
|
|
8
|
+
if (error === null || error === undefined)
|
|
9
|
+
return "Unknown error";
|
|
10
|
+
if (typeof error !== "object")
|
|
11
|
+
return String(error);
|
|
12
|
+
const coded = error;
|
|
13
|
+
if (typeof coded.code === "number") {
|
|
14
|
+
const name = typeof coded.category === "number" ? `${coded.category}.${coded.code}` : String(coded.code);
|
|
15
|
+
const text = typeof coded.message === "string" && coded.message ? coded.message : undefined;
|
|
16
|
+
const detail = text ?? (Array.isArray(coded.data) ? coded.data.find((item) => typeof item === "string") : undefined);
|
|
17
|
+
return detail ? `Error ${name}: ${detail}` : `Error ${name}`;
|
|
18
|
+
}
|
|
19
|
+
if (typeof coded.message === "string" && coded.message)
|
|
20
|
+
return coded.message;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.stringify(error);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return String(error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -24,3 +24,12 @@ export declare function Verdict({ ok, label }: {
|
|
|
24
24
|
ok: boolean | null | undefined;
|
|
25
25
|
label: string;
|
|
26
26
|
}): import("react").JSX.Element;
|
|
27
|
+
interface StatusPillProps {
|
|
28
|
+
tone?: Tone;
|
|
29
|
+
busy?: boolean;
|
|
30
|
+
label: ReactNode;
|
|
31
|
+
action?: ReactNode;
|
|
32
|
+
title?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function StatusPill({ tone, busy, label, action, title }: StatusPillProps): import("react").JSX.Element;
|
|
35
|
+
export {};
|
package/dist/web/react/badges.js
CHANGED
|
@@ -26,3 +26,7 @@ export function Verdict({ ok, label }) {
|
|
|
26
26
|
const known = ok !== null && ok !== undefined;
|
|
27
27
|
return _jsxs("span", { className: `verdict tone-${known ? (ok ? "good" : "bad") : "none"}`, children: [label, ": ", known ? (ok ? "pass" : "fail") : "no verdict"] });
|
|
28
28
|
}
|
|
29
|
+
// A state dot and its text, such as "Idle" or "Researching", with an optional button, for a top bar.
|
|
30
|
+
export function StatusPill({ tone = "none", busy = false, label, action, title }) {
|
|
31
|
+
return (_jsxs("span", { className: "status-pill", title: title, children: [_jsx("span", { className: busy ? `status-dot tone-${tone} busy` : `status-dot tone-${tone}`, "aria-hidden": "true" }), _jsx("span", { className: "status-text small", children: label }), action] }));
|
|
32
|
+
}
|
|
@@ -55,6 +55,16 @@ interface DateTimeFieldProps {
|
|
|
55
55
|
allow_time?: boolean;
|
|
56
56
|
}
|
|
57
57
|
export declare function DateTimeField({ label, value, on_change, allow_time }: DateTimeFieldProps): import("react").JSX.Element;
|
|
58
|
+
interface PathInputProps {
|
|
59
|
+
label: string;
|
|
60
|
+
value: string;
|
|
61
|
+
on_change: (value: string) => void;
|
|
62
|
+
pick: (current: string) => Promise<string | null>;
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
browse_label?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function PathInput({ label, value, on_change, pick, placeholder, disabled, browse_label }: PathInputProps): import("react").JSX.Element;
|
|
58
68
|
interface ColorPickerProps {
|
|
59
69
|
label: string;
|
|
60
70
|
value: string | null;
|
|
@@ -123,6 +123,22 @@ export function DateTimeField({ label, value, on_change, allow_time = true }) {
|
|
|
123
123
|
const join = (next_day, next_time) => on_change(next_day ? (next_time && allow_time ? `${next_day}T${next_time}` : next_day) : null);
|
|
124
124
|
return (_jsxs("div", { className: "field date-time-field", children: [_jsx("span", { className: "field-label", children: label }), _jsxs("div", { className: "date-time-inputs", children: [_jsx("input", { type: "date", "aria-label": `${label} date`, value: day, onChange: (event) => join(event.currentTarget.value, time) }), day && allow_time && _jsx("input", { type: "time", "aria-label": `${label} time`, value: time, onChange: (event) => join(day, event.currentTarget.value) }), day && _jsx("button", { type: "button", className: "icon", "aria-label": `Clear ${label.toLowerCase()} date`, onClick: () => on_change(null), children: "\u00D7" })] })] }));
|
|
125
125
|
}
|
|
126
|
+
// A path typed by hand or picked through a Browse button.
|
|
127
|
+
export function PathInput({ label, value, on_change, pick, placeholder, disabled = false, browse_label = "Browse…" }) {
|
|
128
|
+
const [picking, set_picking] = useState(false);
|
|
129
|
+
async function browse() {
|
|
130
|
+
set_picking(true);
|
|
131
|
+
try {
|
|
132
|
+
const chosen = await pick(value);
|
|
133
|
+
if (chosen !== null)
|
|
134
|
+
on_change(chosen);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
set_picking(false);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return (_jsxs("span", { className: "path-row", children: [_jsx("input", { type: "text", "aria-label": label, spellCheck: false, placeholder: placeholder, disabled: disabled, value: value, onChange: (event) => on_change(event.currentTarget.value) }), _jsx("button", { type: "button", disabled: disabled || picking, onClick: () => void browse(), children: browse_label })] }));
|
|
141
|
+
}
|
|
126
142
|
// Swatches, a None choice, and the system colour chooser for any other colour.
|
|
127
143
|
export function ColorPicker({ label, value, swatches, on_change, allow_none = true }) {
|
|
128
144
|
const is_swatch = value !== null && swatches.some((swatch) => swatch.toLowerCase() === value.toLowerCase());
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode, type RefObject, type UIEvent } from "react";
|
|
2
|
+
export declare const at_bottom: (element: HTMLElement) => boolean;
|
|
3
|
+
export interface FollowTail<Element extends HTMLElement> {
|
|
4
|
+
ref: RefObject<Element | null>;
|
|
5
|
+
following: boolean;
|
|
6
|
+
set_following: (following: boolean) => void;
|
|
7
|
+
on_scroll: (event: UIEvent<Element>) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function use_follow_tail<Element extends HTMLElement>(dependency: unknown, active?: boolean): FollowTail<Element>;
|
|
10
|
+
interface FollowToggleProps {
|
|
11
|
+
following: boolean;
|
|
12
|
+
on_change: (following: boolean) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function FollowToggle({ following, on_change, disabled, label }: FollowToggleProps): import("react").JSX.Element;
|
|
17
|
+
interface FollowingOutputProps {
|
|
18
|
+
text: string;
|
|
19
|
+
active?: boolean;
|
|
20
|
+
toggle?: boolean;
|
|
21
|
+
class_name?: string;
|
|
22
|
+
tools?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
export declare function FollowingOutput({ text, active, toggle, class_name, tools }: FollowingOutputProps): import("react").JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Output that keeps its newest lines in view as they arrive, unless the reader has scrolled up to read, with a Follow switch.
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "../dom.js";
|
|
5
|
+
// How far above the bottom still counts as at the bottom.
|
|
6
|
+
const FOLLOW_SLACK_PIXELS = 8;
|
|
7
|
+
export const at_bottom = (element) => element.scrollTop + element.clientHeight >= element.scrollHeight - FOLLOW_SLACK_PIXELS;
|
|
8
|
+
// Scrolls the box to its end whenever the dependency changes while following; active false, such as once a run has ended, leaves the scroll alone.
|
|
9
|
+
export function use_follow_tail(dependency, active = true) {
|
|
10
|
+
const ref = useRef(null);
|
|
11
|
+
const [following, set_following] = useState(true);
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
const element = ref.current;
|
|
14
|
+
if (following && active && element)
|
|
15
|
+
element.scrollTop = element.scrollHeight;
|
|
16
|
+
}, [dependency, following, active]);
|
|
17
|
+
const on_scroll = useCallback((event) => {
|
|
18
|
+
if (!active)
|
|
19
|
+
return;
|
|
20
|
+
set_following(at_bottom(event.currentTarget));
|
|
21
|
+
}, [active]);
|
|
22
|
+
return { ref, following, set_following, on_scroll };
|
|
23
|
+
}
|
|
24
|
+
export function FollowToggle({ following, on_change, disabled = false, label = "Follow" }) {
|
|
25
|
+
return (_jsxs("label", { className: "follow-toggle", children: [_jsx("input", { type: "checkbox", checked: following, disabled: disabled, onChange: (event) => on_change(event.currentTarget.checked) }), " ", label] }));
|
|
26
|
+
}
|
|
27
|
+
// Streamed text in an output box that follows its tail.
|
|
28
|
+
export function FollowingOutput({ text, active = true, toggle = false, class_name, tools }) {
|
|
29
|
+
const tail = use_follow_tail(text, active);
|
|
30
|
+
const output = _jsx("pre", { ref: tail.ref, className: cx("output", "following-output", class_name), onScroll: tail.on_scroll, children: text });
|
|
31
|
+
if (!toggle && tools === undefined)
|
|
32
|
+
return output;
|
|
33
|
+
return (_jsxs("div", { className: "following", children: [_jsxs("div", { className: "following-tools", children: [tools, toggle && _jsx(FollowToggle, { following: tail.following, on_change: tail.set_following, disabled: !active })] }), output] }));
|
|
34
|
+
}
|
|
@@ -8,9 +8,10 @@ interface PageHeadingProps {
|
|
|
8
8
|
banner?: string | ReactNode;
|
|
9
9
|
actions?: ReactNode;
|
|
10
10
|
class_name?: string;
|
|
11
|
+
level?: 1 | 2 | 3;
|
|
11
12
|
children?: ReactNode;
|
|
12
13
|
}
|
|
13
|
-
export declare function PageHeading({ title, subtitle, icon, banner, actions, class_name, children }: PageHeadingProps): import("react").JSX.Element;
|
|
14
|
+
export declare function PageHeading({ title, subtitle, icon, banner, actions, class_name, level, children }: PageHeadingProps): import("react").JSX.Element;
|
|
14
15
|
interface SectionProps {
|
|
15
16
|
title: ReactNode;
|
|
16
17
|
tools?: ReactNode;
|
package/dist/web/react/layout.js
CHANGED
|
@@ -3,8 +3,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import { Fragment } from "react";
|
|
4
4
|
export { moved } from "../tables.js";
|
|
5
5
|
// A page's heading: an optional banner, the title with its icon and subtitle, and the page's buttons.
|
|
6
|
-
export function PageHeading({ title, subtitle, icon, banner, actions, class_name, children }) {
|
|
7
|
-
|
|
6
|
+
export function PageHeading({ title, subtitle, icon, banner, actions, class_name, level = 1, children }) {
|
|
7
|
+
const Title = `h${level}`;
|
|
8
|
+
return (_jsxs("header", { className: class_name ? `page-head ${class_name}` : "page-head", children: [typeof banner === "string" ? _jsx("img", { className: "page-banner", src: banner, alt: "", loading: "lazy" }) : banner, _jsxs("div", { className: "page-title spread", children: [_jsxs("div", { className: "page-identity", children: [icon, _jsxs("div", { children: [_jsx(Title, { children: title }), subtitle && _jsx("p", { className: "page-subtitle muted", children: subtitle })] })] }), actions && _jsx("div", { className: "head-actions", children: actions })] }), children] }));
|
|
8
9
|
}
|
|
9
10
|
export function Section({ title, tools, class_name, on_context_menu, children }) {
|
|
10
11
|
return (_jsxs("section", { className: class_name ? `panel ${class_name}` : "panel", onContextMenu: on_context_menu, children: [_jsxs("div", { className: "panel-head", children: [_jsx("h2", { children: title }), tools && _jsx("div", { className: "panel-tools", children: tools })] }), children] }));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function render_markdown(text: string): string;
|
|
2
|
+
interface MarkdownProps {
|
|
3
|
+
text: string;
|
|
4
|
+
class_name?: string;
|
|
5
|
+
follow?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function Markdown({ text, class_name, follow }: MarkdownProps): import("react").JSX.Element;
|
|
8
|
+
export {};
|