feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
Release / release (push) Failing after 1m18s

This commit is contained in:
slawek committed 2026-10-28 19:08:10 +01:00
commit 4505b37abc
30 files changed
+4826

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- '*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 26
registry-url: ${{ secrets.CI_URL }}/api/packages/${{ secrets.CI_OWNER }}/npm/
scope: '@koszewscy'
- name: Install
run: npm ci --no-fund
- name: Check
run: |
npm run typecheck
npm run lint
- name: Build package
run: npm run build
- name: Publish package
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
package="$(node -p 'const p = require("./package.json"); `${p.name}@${p.version}`')"
if npm view "$package" version >/dev/null 2>&1; then
echo "$package is already published, skipping."
exit 0
fi
npm publish
+4
View File
@@ -0,0 +1,4 @@
/node_modules/
/dist/
/tmp/
.env
+9
View File
@@ -0,0 +1,9 @@
{
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"typescript/triple-slash-reference": "off"
},
"ignorePatterns": ["node_modules", "dist"]
}
+48
View File
@@ -0,0 +1,48 @@
# Architecture Decision Records
## ADR-1: The toolkit is TypeScript source, linked into the applications
Status: Accepted
Context: manage-ui and sql-query-webui share their server helpers and web components. The
applications run their servers with Node's type stripping and build their web code with Vite.
The toolkit is to be published to Gitea later; until then it is developed next to them.
Decision: The toolkit ships its `.ts` and `.tsx` files without a build, with three entry points:
`./server`, `./web` and `./vite`. The applications depend on the checkout by a `file:` path, which
npm links; Node runs the server helpers from the checkout's real path, where type stripping
applies, and Vite compiles the web code. The server helpers import only Node's modules at run
time, and types of Fastify; the Vite helpers import only types of Vite, taking `loadEnv`'s answer
from the application. The web code's React, MUI and Emotion come from the application, which
dedupes them with `TOOLKIT_RESOLVE`. The peer dependencies are optional, as an application uses
only some of the entry points, and are development dependencies for the toolkit's own checks.
Consequences: The applications need the checkout at `../sk-react-toolkit`, and their container
builds cannot install it until it is published. Node refuses type stripping in `node_modules`,
so the published package is compiled (ADR-2).
## ADR-2: The published package is built in dist/ and published to Gitea by a tag
Status: Accepted
Context: An installed package lives in `node_modules`, where Node does not strip types, so the
published package has to be JavaScript, while the linked checkout stays TypeScript source. The
repository is on Gitea, which has an npm registry per owner and Actions mostly compatible with
GitHub's.
Decision: `build.mjs` compiles the toolkit with `tsconfig.build.json`, which rewrites the `.ts`
import extensions to `.js`, into `dist/` with declarations, and writes `dist/package.json` with
exports of the compiled files and declarations; the package published is `dist/`, while the
source `package.json` stays private with exports of the source. The name is
`@koszewscy/sk-react-toolkit`, scoped to the owner, so an application takes only this scope from
Gitea; it imports it as `sk-react-toolkit` by an npm alias, so the switch from the checkout to the
published package changes only the dependency. The package is built and published only by the
`Release` workflow, which follows linux-cloud-router's: it runs for each tag and on demand, with
the secrets `CI_URL`, `CI_OWNER` and `CI_TOKEN`, and skips a version already published.
`actions/setup-node` gives npm the registry of the package's scope and the token, so the package
carries no registry; `release.mjs` refuses a tag `x.y.z` that is not the version of
`package.json`.
Consequences: A release raises the version in both CHANGELOG.md and `package.json`, and a
re-run, or a tag `x.y.z-n`, publishes nothing. The declarations of `renderApp` reference the
declaration of the `virtual:font` module, which `build.mjs` copies into `dist/`.
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 0.1.0
feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
- `sk-react-toolkit/server`: `HttpError`, `strict`, the error and not-found handlers, the log,
`fail`, `gitVersion`, `exitOnSignals` and `readJsonObject`.
- `sk-react-toolkit/web`: `request` and `ApiError`, the theme and `renderApp`, the font styles,
`Card`, `ThemeMenu`, `SwitcherMenu`, `TextInput`, `RowDialog`, `useValidation` and
`createBrowserSettings`.
- `sk-react-toolkit/vite`: the `VITE_FONT` font plugin and the resolve options that keep one copy
of React, MUI and Emotion.
- `npm run build` builds the published package, `@koszewscy/sk-react-toolkit`, compiled to
JavaScript with declarations, in `dist/`. The `Release` workflow of Gitea Actions checks, builds
and publishes each tag to the Gitea npm registry, skipping a version already published.
+11
View File
@@ -0,0 +1,11 @@
# Instructions for this repository
## CHANGELOG.md
- `CHANGELOG.md` records the changes of each version, newest entry first. `release.mjs` reads it to commit, tag and push.
- An entry is a `## x.y.z` heading, a blank line, one summary line, a blank line, and the changes as `-` items.
- The summary line is the commit message: a type prefix as in the git history (`feature:`, `fix:`, `update:`), then one sentence. It must not start with `-` or `#`.
- An entry whose version is already tagged (`git tag`) is released and must not be changed. Changes after it go into a new entry above it.
- Record every change you make in the newest untagged entry; when there is none, add one. Raise the patch version for fixes and the minor version for features.
- Items describe what changed for the user, not the reasoning; reasons go to `ADR.md`.
- Do not run `release.mjs` without `-n`; committing, tagging and pushing need the user's approval.
+101
View File
@@ -0,0 +1,101 @@
# sk-react-toolkit
The shared code of the web tools built as a Fastify server in TypeScript and a React application
with MUI built by Vite, such as azure-platform's manage-ui and sql-query-webui. It is TypeScript
source: Node 26 runs the server helpers, and the application's Vite build compiles the web code.
## Using it
The package is `@koszewscy/sk-react-toolkit`, published to the npm registry of Gitea,
`https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/`. An application imports it as
`sk-react-toolkit`, by an alias. Its `.npmrc` names the registry of the scope:
```ini
@koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/
```
```json
{ "dependencies": { "sk-react-toolkit": "npm:@koszewscy/sk-react-toolkit@^0.1.0" } }
```
While the toolkit and an application change together, the application depends on the checkout
next to its own repository instead, the TypeScript source:
```json
{ "dependencies": { "sk-react-toolkit": "file:../sk-react-toolkit" } }
```
npm links the checkout into `node_modules`, and `npm ci` keeps the link. The web application adds
the toolkit's resolve options and font plugin to its `vite.config.ts`, so React, MUI and Emotion
come from its own `node_modules`, and allows the dev server to read the checkout:
```ts
import { font, fontChoice, TOOLKIT_RESOLVE } from 'sk-react-toolkit/vite'
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => ({
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))],
resolve: TOOLKIT_RESOLVE,
server: { fs: { allow: ['..', '../../sk-react-toolkit'] } },
}))
```
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
code; `vite` for the Vite helpers.
## sk-react-toolkit/server
| Export | Purpose |
| --- | --- |
| `HttpError` | an error answered with its status and `{ error }`, with `problems` and a `detail` message for a refused write |
| `strict(properties)` | the JSON schema of an object with exactly these properties, all required |
| `errorHandler(log)` | Fastify's error handler: `{ error }`, or `{ error, problems, message }`; unexpected 5xx errors logged with their stack |
| `notFoundHandler(webDir, buildHint)` | 404 for `/api` paths, `index.html` for the others, 503 with the hint while the frontend is not built |
| `createLog`, `timestampsOn`, `QUIET`, `Log` | the log of a server, with optional timestamps and requests; `QUIET` for tests |
| `fail(message)` | prints the error in red and ends the process with status 2 |
| `gitVersion(dir, variable)` | `git describe` of the checkout, else the environment variable, else `dev` |
| `exitOnSignals(close)` | closes the server on Ctrl-C and SIGTERM, ending the process after 5 seconds at most |
| `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file |
## sk-react-toolkit/web
| Export | Purpose |
| --- | --- |
| `request`, `ApiError`, `Validation` | a JSON call to the backend, its error with the problems of a refused write, and the answer of a validate endpoint |
| `appTheme(colorSchemes?)`, `renderApp(options)` | the theme (light, dark and system, Regular font, small text fields) and the startup that retries the first call while showing Connecting |
| `REGULAR`, `MONOSPACED`, `verbatim` | the font styles and the attributes of inputs the browser must not change |
| `Card` | a centered panel with a title and a message |
| `ThemeMenu` | the button cycling light, dark and system theme |
| `SwitcherMenu`, `SwitcherItem` | a button naming the item chosen, whose menu chooses another, such as a page or a connection |
| `TextInput` | a text field with clear and show/hide buttons |
| `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook |
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
## sk-react-toolkit/vite
| Export | Purpose |
| --- | --- |
| `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` |
| `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion |
## Development
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
and `npm run lint` check the code. `npm run build` (`build.mjs`) builds the published package in
`dist/`: the code compiled to JavaScript with its declarations, README.md, CHANGELOG.md and a
`package.json` whose exports name the compiled files.
## Releases
`./release.mjs` commits, tags the version of the newest CHANGELOG.md entry and pushes `main` with
the tag; `-n` prints the git commands instead. The version of a release of the code, `x.y.z`, is
that of `package.json`, so a release raises both. A tag `x.y.z-n` releases changes that are not
code changes; its version is already published, so its run publishes nothing.
The `Release` workflow, `.gitea/workflows/release.yml`, builds and publishes the package on Gitea
Actions for each tag and on demand: it installs the dependencies, typechecks and lints the code,
builds the package and publishes `dist/` to the npm registry of the owner `CI_OWNER` on the server
`CI_URL`, with `CI_TOKEN`, a token with the package scope; all three are secrets of the
repository. A version already published is skipped, as the registry refuses the same version
again.
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
// Builds the published package in dist/: the toolkit compiled to JavaScript with its declarations, the declaration of
// the font plugin's virtual module, README.md, CHANGELOG.md and a package.json whose exports name the compiled files.
import { spawnSync } from 'node:child_process';
import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = import.meta.dirname;
const DIST = join(ROOT, 'dist');
const ENTRIES = ['server', 'web', 'vite'];
function fail(message) {
console.error(`build: ${message}`);
process.exit(1);
}
rmSync(DIST, { recursive: true, force: true });
const tsc = spawnSync(join(ROOT, 'node_modules', '.bin', 'tsc'), ['-p', join(ROOT, 'tsconfig.build.json')], { stdio: 'inherit' });
if (tsc.error) fail(`tsc: ${tsc.error.message}; run npm ci first`);
if (tsc.status !== 0) process.exit(tsc.status ?? 1);
// The compiled renderApp keeps its import of virtual:font, so its declarations reference the module's declaration.
copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-font.d.ts'));
const renderApp = join(DIST, 'web', 'renderApp.d.ts');
writeFileSync(renderApp, `/// <reference path="./virtual-font.d.ts" />\n${readFileSync(renderApp, 'utf8')}`);
for (const file of ['README.md', 'CHANGELOG.md']) copyFileSync(join(ROOT, file), join(DIST, file));
const source = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
const published = {
name: source.name,
version: source.version,
description: source.description,
type: source.type,
engines: source.engines,
repository: source.repository,
exports: Object.fromEntries(ENTRIES.map((entry) => [`./${entry}`, { types: `./${entry}/index.d.ts`, default: `./${entry}/index.js` }])),
peerDependencies: source.peerDependencies,
peerDependenciesMeta: source.peerDependenciesMeta,
};
writeFileSync(join(DIST, 'package.json'), `${JSON.stringify(published, null, 2)}\n`);
console.log(`${published.name}@${published.version} in ${DIST}`);
+3749
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
"private": true,
"type": "module",
"engines": {
"node": ">=26"
},
"repository": {
"type": "git",
"url": "https://gitea.koszewscy.waw.pl/koszewscy/sk-react-toolkit.git"
},
"exports": {
"./server": "./server/index.ts",
"./web": "./web/index.ts",
"./vite": "./vite/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"lint": "oxlint",
"build": "node build.mjs"
},
"peerDependencies": {
"@fastify/static": "^10.1.7",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"fastify": "^5.12.7",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"vite": "^8.3.5"
},
"peerDependenciesMeta": {
"@fastify/static": { "optional": true },
"fastify": { "optional": true },
"vite": { "optional": true },
"@emotion/react": { "optional": true },
"@emotion/styled": { "optional": true },
"@mui/icons-material": { "optional": true },
"@mui/material": { "optional": true },
"react": { "optional": true },
"react-dom": { "optional": true }
},
"devDependencies": {
"@fastify/static": "^10.1.7",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"fastify": "^5.12.7",
"oxlint": "^1.89.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
Executable
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Commits everything, tags the version of the newest CHANGELOG.md entry and pushes main with the tags.
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
const CHANGELOG = `${import.meta.dirname}/CHANGELOG.md`;
const usage = `Usage: release.mjs [-h|--help] [-n|--dry-run] [--no-push] [-m|--message <message>]
-m, --message commit message; default: the summary line of the newest CHANGELOG.md entry,
the line between blank lines right after its "## x.y.z" or "## x.y.z-n" heading
The tag is the version of the heading: x.y.z for a release of the code, which must be the version
of package.json and which the build workflow publishes, x.y.z-n, n from 2, for a later release
of the same code with changes that are not code changes, which is not published.
-n, --dry-run print the git commands instead of running them
--no-push commit and tag, but do not push`;
const { values } = parseArgs({
options: {
message: { type: 'string', short: 'm' },
'dry-run': { type: 'boolean', short: 'n', default: false },
'no-push': { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
if (values.help) {
console.log(usage);
process.exit(0);
}
function fail(message) {
console.error(`release: ${message}`);
process.exit(1);
}
// Runs git in the repository, or prints the command on a dry run; stops with git's exit status when it fails.
function git(...args) {
if (values['dry-run']) {
console.log(['git', ...args].map((arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`)).join(' '));
return;
}
const { status } = spawnSync('git', args, { cwd: import.meta.dirname, stdio: 'inherit' });
if (status !== 0) process.exit(status ?? 1);
}
// The newest entry: its version from the first "## x.y.z" or "## x.y.z-n" heading, n from 2, and its summary line when
// the heading is followed by a blank line, one line that is not a "-" item, and another blank line.
const lines = readFileSync(CHANGELOG, 'utf8').split('\n');
const heading = lines.findIndex((line) => line.startsWith('## '));
if (heading === -1) fail(`${CHANGELOG} has no "## x.y.z" entry`);
const tag = lines[heading].slice(3).trim();
const [version, revision, ...rest] = tag.split('-');
const parts = version.split('.');
const number = (part) => part !== '' && String(Number(part)) === part;
if (rest.length > 0 || parts.length !== 3 || !parts.every(number) || (revision !== undefined && !(number(revision) && Number(revision) >= 2))) {
fail(`${CHANGELOG}: "${tag}" is not a version such as 0.8.0 or 0.8.0-2`);
}
// The tag of a release of the code is the version of package.json, which the build workflow publishes.
const packageVersion = JSON.parse(readFileSync(`${import.meta.dirname}/package.json`, 'utf8')).version;
if (revision === undefined && version !== packageVersion) {
fail(`${CHANGELOG}: ${tag} is not the version of package.json, ${packageVersion}`);
}
const [above, line, below] = lines.slice(heading + 1, heading + 4);
const summary = above?.trim() === '' && below?.trim() === '' && line?.trim() && !line.startsWith('-') && !line.startsWith('#') ? line.trim() : undefined;
const message = values.message ?? summary;
if (!message) {
fail(`no commit message: pass -m, or put a summary line between blank lines right after the newest heading of ${CHANGELOG}`);
}
if (spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: import.meta.dirname }).status === 0) {
fail(`tag ${tag} exists; add a new CHANGELOG.md entry first`);
}
git('add', '-A');
git('commit', '-m', message);
git('tag', tag);
if (!values['no-push']) git('push', 'origin', 'main', '--tags');
+66
View File
@@ -0,0 +1,66 @@
import type {} from '@fastify/static'
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { Log } from './log.ts'
/**
* An error answered with its status code and message as { error }. A refused write also carries the problems by
* field and a message about what it would break elsewhere, answered as { error, problems, message }.
*/
export class HttpError extends Error {
statusCode: number
problems?: Record<string, string>
detail?: string
constructor(statusCode: number, message: string, problems?: Record<string, string>, detail?: string) {
super(message)
this.statusCode = statusCode
this.problems = problems
this.detail = detail
}
}
/**
* A JSON schema of an object with exactly the properties given, all required. With Fastify's removeAdditional off, a
* body with another property is refused with 400.
*/
export function strict(properties: Record<string, unknown>) {
return { type: 'object', required: Object.keys(properties), additionalProperties: false, properties }
}
/**
* The error handler of setErrorHandler: the status of the error, 500 when it has none, with { error }, and the
* problems and message of an HttpError that has problems. An error of 500 or more that is not an HttpError is logged
* with its stack. A request that fails its schema is answered with "The request is not valid".
*/
export function errorHandler(log: Log) {
return (error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
const status = error.statusCode ?? 500
if (status >= 500 && !(error instanceof HttpError)) {
log.error(`${request.method} ${request.url}: ${error.stack ?? error.message}`)
}
const message = error.validation ? `The request is not valid (${error.message}).` : error.message
if (error instanceof HttpError && error.problems !== undefined) {
return reply.code(status).send({ error: message, problems: error.problems, message: error.detail ?? '' })
}
return reply.code(status).send({ error: message })
}
}
/**
* The not-found handler of setNotFoundHandler for an application of webDir, the built frontend served by
* @fastify/static: a path under /api is 404 with { error }; any other path answers index.html, so the frontend's
* routes work on a reload, or 503 with buildHint while the frontend is not built.
*/
export function notFoundHandler(webDir: string, buildHint: string) {
return (request: FastifyRequest, reply: FastifyReply) => {
if (request.url === '/api' || request.url.startsWith('/api/')) {
return reply.code(404).send({ error: `'${request.method} ${request.url.split('?')[0]}' is not an endpoint of this tool.` })
}
if (!existsSync(join(webDir, 'index.html'))) {
return reply.code(503).type('text/plain; charset=utf-8').send(`${buildHint}\n`)
}
return reply.type('text/html; charset=utf-8').sendFile('index.html')
}
}
+3
View File
@@ -0,0 +1,3 @@
export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
+42
View File
@@ -0,0 +1,42 @@
/** The log of a server: events to stdout, warnings and errors in colour to stderr, and requests when they are on. */
export type Log = {
event: (text: string) => void
warn: (text: string) => void
error: (text: string) => void
request: (text: string) => void
}
/** A log that writes nothing, for tests. */
export const QUIET: Log = { event() {}, warn() {}, error() {}, request() {} }
/**
* The log of a server. With timestamps each line starts with the local time with milliseconds; requests logs each
* request to stderr, otherwise request writes nothing.
*/
export function createLog(options: { timestamps: boolean; requests: boolean }): Log {
const line = (text: string) => {
if (!options.timestamps) {
return text
}
const now = new Date()
const pad = (value: number, size = 2) => String(value).padStart(size, '0')
return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)} ${text}`
}
return {
event: (text) => console.log(line(text)),
warn: (text) => console.error(`\x1b[33m${line(`warning: ${text}`)}\x1b[0m`),
error: (text) => console.error(`\x1b[31m${line(`error: ${text}`)}\x1b[0m`),
request: options.requests ? (text) => console.error(line(text)) : () => {},
}
}
/** Whether --timestamps auto|on|off asks for timestamps: auto only when stdout is a terminal. */
export function timestampsOn(choice: string) {
return choice === 'on' || (choice === 'auto' && process.stdout.isTTY === true)
}
/** Prints the message in red to stderr as an error and ends the process with status 2. */
export function fail(message: string): never {
console.error(`\x1b[31merror: ${message}\x1b[0m`)
process.exit(2)
}
+45
View File
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
/**
* The git description of the checkout of dir now, a commit or a tag changing it without a restart; outside a
* checkout, or without git, the variable named fallbackVariable, which a container image sets, or "dev".
*/
export function gitVersion(dir: string, fallbackVariable: string) {
const described = spawnSync('git', ['describe', '--tags', '--always', '--dirty'], { cwd: dir, encoding: 'utf8' })
return described.status === 0 ? described.stdout.trim() : process.env[fallbackVariable] || 'dev'
}
/**
* Ctrl-C and SIGTERM, which watch mode sends on a restart, call close and end the process when it settles, or after 5
* seconds.
*/
export function exitOnSignals(close: () => Promise<unknown>) {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
setTimeout(() => process.exit(0), 5000).unref()
void close().then(() => process.exit(0))
})
}
}
/**
* The JSON object of the file at path, and its version, the SHA-256 of the bytes. With missingAsEmpty a missing file
* reads as {}, whose version is that of "{}". A file that cannot be read, invalid JSON or another value than an
* object throws with the reason.
*/
export function readJsonObject(path: string, options: { missingAsEmpty: boolean }): { data: Record<string, unknown>; version: string } {
let bytes: Buffer
let data: unknown
try {
bytes = options.missingAsEmpty && !existsSync(path) ? Buffer.from('{}') : readFileSync(path)
data = JSON.parse(bytes.toString('utf8'))
} catch (error) {
throw new Error(`${path} cannot be read: ${(error as Error).message}`)
}
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`${path} must hold a JSON object`)
}
return { data: data as Record<string, unknown>, version: createHash('sha256').update(bytes).digest('hex') }
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"rewriteRelativeImportExtensions": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "preserve",
"moduleResolution": "bundler",
"types": ["node"],
"jsx": "react-jsx",
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"erasableSyntaxOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["server", "web", "vite"],
"exclude": ["dist"]
}
+56
View File
@@ -0,0 +1,56 @@
import type { Plugin } from 'vite'
/** The choices of VITE_FONT. */
export const FONTS = ['cdn', 'bundled', 'none'] as const
export type Font = (typeof FONTS)[number]
const FONT_MODULE = 'virtual:font'
/**
* Provides the fonts of the toolkit's fonts.ts as VITE_FONT selects: cdn adds the Google Fonts links for Roboto and
* Roboto Mono to index.html, bundled imports @fontsource/roboto through the virtual:font module, none adds nothing; a
* font not provided falls back to the system fonts of its stack. The toolkit's renderApp imports virtual:font.
*/
export function font(choice: Font): Plugin {
const resolved = `\0${FONT_MODULE}`
return {
name: 'font',
resolveId(id) {
return id === FONT_MODULE ? resolved : undefined
},
load(id) {
if (id !== resolved) {
return undefined
}
return choice === 'bundled' ? ['300', '400', '500', '700'].map((weight) => `import '@fontsource/roboto/${weight}.css'`).join('\n') : ''
},
transformIndexHtml() {
if (choice !== 'cdn') {
return []
}
return [
{ tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.googleapis.com' } },
{ tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: true } },
{ tag: 'link', attrs: { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@300;400;500;700&display=swap' } },
]
},
}
}
/** VITE_FONT of env, the variables Vite's loadEnv answers, cdn when it is not set; another value throws. */
export function fontChoice(env: Record<string, string>): Font {
const choice = env.VITE_FONT ?? 'cdn'
if (!FONTS.includes(choice as Font)) {
throw new Error(`VITE_FONT=${choice} is not one of ${FONTS.join(', ')}.`)
}
return choice as Font
}
/**
* The resolve options of an application that uses the toolkit's web code through a link: the toolkit's imports of
* React, MUI and Emotion are resolved from the application's own node_modules, so the page has one copy of each.
*/
export const TOOLKIT_RESOLVE = {
dedupe: ['react', 'react-dom', '@mui/material', '@mui/icons-material', '@mui/system', '@emotion/react', '@emotion/styled'],
}
+17
View File
@@ -0,0 +1,17 @@
import { Box, Paper, Typography } from '@mui/material'
/** A centered panel with a title and a message, for states before the application can render. */
export default function Card(props: { title: string; message: string }) {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', p: 3 }}>
<Paper sx={{ maxWidth: 480, p: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{props.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{props.message}
</Typography>
</Paper>
</Box>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'
import { useState, type FormEvent, type ReactNode } from 'react'
import { ApiError, type Validation } from './api.ts'
import { useValidation } from './useValidation.ts'
/**
* A dialog that edits one row. The server validates the value as it changes (validatePath); onSave writes it and
* the dialog closes when it succeeds. fields renders the inputs from the value, a setter and the problems by field;
* below shows extra information from the validation, such as subnet placements.
*/
export default function RowDialog<T, V extends Validation = Validation>(props: {
title: string
initial: T
validatePath: string
saveLabel: string
onSave: (value: T) => Promise<void>
onCancel: () => void
fields: (value: T, set: (next: T) => void, problems: Record<string, string>) => ReactNode
below?: (result: V | null) => ReactNode
}) {
const [value, setValue] = useState<T>(props.initial)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const validation = useValidation<V>(props.validatePath, value)
const problems = validation.result?.problems ?? {}
const message = validation.result?.message ?? ''
const blocked = validation.pending || Object.keys(problems).length > 0 || message !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(value)
} catch (caught) {
const failure = caught as ApiError
setError([failure.message, failure.detail].filter(Boolean).join('\n'))
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{validation.error && <Alert severity="error">{validation.error}</Alert>}
{message && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{message}</Alert>}
{props.fields(value, setValue, problems)}
{props.below?.(validation.result)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{props.saveLabel}
</Button>
</DialogActions>
</Dialog>
)
}
+59
View File
@@ -0,0 +1,59 @@
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
import { Button, ListItemIcon, ListItemText, Menu, MenuItem, Typography } from '@mui/material'
import { useState, type ReactNode } from 'react'
import { MONOSPACED } from './fonts.ts'
/**
* An item of a SwitcherMenu: its key, the label and icon of the button and the menu, and optionally a second line in
* the Monospaced font, a hint at the right end, such as the key that opens it, and whether it can be chosen.
*/
export type SwitcherItem<K> = {
key: K
label: string
icon: ReactNode
secondary?: string
hint?: string
disabled?: boolean
}
/**
* A button naming the item chosen, current, with its icon, whose menu chooses another with onChoose. title is the
* button's tooltip.
*/
export default function SwitcherMenu<K>(props: { items: SwitcherItem<K>[]; current: SwitcherItem<K>; title?: string; onChoose: (key: K) => void }) {
const { items, current } = props
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
return (
<>
<Button
variant="text"
color="inherit"
startIcon={current.icon}
endIcon={<ArrowDropDown />}
aria-haspopup="menu"
aria-expanded={anchor !== null}
title={props.title}
onClick={(event) => setAnchor(event.currentTarget)}
>
{current.label}
</Button>
<Menu anchorEl={anchor} open={anchor !== null} onClose={() => setAnchor(null)}>
{items.map((item, index) => (
<MenuItem
key={index}
selected={item.key === current.key}
disabled={item.disabled}
onClick={() => {
setAnchor(null)
props.onChoose(item.key)
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} secondary={item.secondary} slotProps={{ secondary: { sx: MONOSPACED } }} />
{item.hint !== undefined && <Typography variant="body2" color="text.secondary" sx={{ ml: 3 }}>{item.hint}</Typography>}
</MenuItem>
))}
</Menu>
</>
)
}
+72
View File
@@ -0,0 +1,72 @@
import Clear from '@mui/icons-material/Clear'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { IconButton, InputAdornment, TextField } from '@mui/material'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { verbatim } from './fonts.ts'
/** Keeps the focus in the input when one of its buttons is pressed. */
function keepFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault()
}
/**
* A text field with a clear button, a show/hide button for secrets, and several lines for 'lines'. The browser
* may not change what is typed (no spell check, capitalisation or completion). A small field shows no label; the
* label remains its accessible name.
*/
export default function TextInput(props: {
label: string
value: string
kind?: 'text' | 'secret' | 'lines'
problem?: string
help?: string
disabled?: boolean
autoFocus?: boolean
start?: ReactNode
size?: 'small' | 'medium'
onChange: (value: string) => void
onBlur?: () => void
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void
}) {
const { label, value, kind = 'text', problem, help, disabled = false, onChange } = props
const [shown, setShown] = useState(false)
return (
<TextField
label={props.size === 'small' ? undefined : label}
value={value}
size={props.size ?? (kind === 'lines' ? 'medium' : undefined)}
fullWidth
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
autoFocus={props.autoFocus}
disabled={disabled}
error={problem !== undefined}
helperText={props.size === 'small' ? problem : problem || help || ' '}
multiline={kind === 'lines'}
minRows={kind === 'lines' ? 2 : undefined}
type={kind === 'secret' && !shown ? 'password' : 'text'}
onChange={(event) => onChange(event.target.value)}
slotProps={{
htmlInput: { ...verbatim, 'aria-label': label },
input: {
startAdornment: props.start === undefined ? undefined : <InputAdornment position="start">{props.start}</InputAdornment>,
endAdornment: (
<InputAdornment position="end">
{kind === 'secret' && (
<IconButton aria-label={shown ? `hide ${label}` : `show ${label}`} disabled={disabled} onClick={() => setShown(!shown)} onMouseDown={keepFocus} onMouseUp={keepFocus}>
{shown ? <VisibilityOff /> : <Visibility />}
</IconButton>
)}
{value !== '' && (
<IconButton aria-label={`clear ${label}`} disabled={disabled} edge="end" onClick={() => onChange('')} onMouseDown={keepFocus} onMouseUp={keepFocus}>
<Clear />
</IconButton>
)}
</InputAdornment>
),
},
}}
/>
)
}
+26
View File
@@ -0,0 +1,26 @@
import DarkMode from '@mui/icons-material/DarkMode'
import LightMode from '@mui/icons-material/LightMode'
import SettingsBrightness from '@mui/icons-material/SettingsBrightness'
import { IconButton, Tooltip, useColorScheme } from '@mui/material'
import type { ReactNode } from 'react'
type Mode = 'light' | 'dark' | 'system'
const NEXT: Record<Mode, Mode> = { light: 'dark', dark: 'system', system: 'light' }
const ICONS: Record<Mode, ReactNode> = { light: <LightMode />, dark: <DarkMode />, system: <SettingsBrightness /> }
/** An icon button cycling light, dark and system theme. MUI stores the choice; mode is undefined on the first render. */
export default function ThemeMenu() {
const { mode, setMode } = useColorScheme()
if (!mode) {
return null
}
return (
<Tooltip title={`Theme: ${mode}. Switch to ${NEXT[mode]}.`}>
<IconButton aria-label={`theme ${mode}, switch to ${NEXT[mode]}`} onClick={() => setMode(NEXT[mode])}>
{ICONS[mode]}
</IconButton>
</Tooltip>
)
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The answer of a validate endpoint: problems maps a field of the validated value to a message; message describes what
* the value would break elsewhere, or is empty.
*/
export type Validation = {
problems: Record<string, string>
message: string
}
/** A failed call: the server's message and status, with the problems and message of a refused write (422). */
export class ApiError extends Error {
status: number
problems: Record<string, string>
detail: string
constructor(message: string, status: number, problems: Record<string, string> = {}, detail = '') {
super(message)
this.status = status
this.problems = problems
this.detail = detail
}
}
/** Calls the backend and returns the decoded JSON; a failed call throws ApiError with the backend's message. */
export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string; problems?: Record<string, string>; message?: string } | null
throw new ApiError(payload?.error ?? `${method} ${path} failed (${response.status}).`, response.status, payload?.problems, payload?.message)
}
return (await response.json()) as T
}
+32
View File
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react'
/**
* The switches a browser keeps for an application, each off until set: keys maps each setting to its localStorage key.
* browserSetting reads one, setBrowserSetting keeps it and tells every user of useBrowserSetting, which follows it as
* it changes in this tab or in another tab of the browser. event names the event a change dispatches on window.
*/
export function createBrowserSettings<K extends string>(keys: Record<K, string>, event: string) {
function browserSetting(setting: K) {
return window.localStorage.getItem(keys[setting]) === 'on'
}
function setBrowserSetting(setting: K, enabled: boolean) {
window.localStorage.setItem(keys[setting], enabled ? 'on' : 'off')
window.dispatchEvent(new Event(event))
}
function subscribe(changed: () => void) {
window.addEventListener(event, changed)
window.addEventListener('storage', changed)
return () => {
window.removeEventListener(event, changed)
window.removeEventListener('storage', changed)
}
}
function useBrowserSetting(setting: K) {
return useSyncExternalStore(subscribe, () => browserSetting(setting))
}
return { browserSetting, setBrowserSetting, useBrowserSetting }
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The two font styles of the page: Regular, the theme's font of every element, and Monospaced, at 90% of the size of
* the surrounding text, for values read character by character, such as IDs, addresses, names of variables, code and
* the output of tools.
*/
export const REGULAR = '"Roboto", "Helvetica", "Arial", sans-serif'
export const MONOSPACED = {
fontFamily: '"Roboto Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: '90%',
}
/** Input attributes for values that must not be changed by the browser, shown in the Monospaced font. */
export const verbatim = { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'none', autoComplete: 'off', style: MONOSPACED }
+10
View File
@@ -0,0 +1,10 @@
export { ApiError, request, type Validation } from './api.ts'
export { createBrowserSettings } from './browserSettings.ts'
export { default as Card } from './Card.tsx'
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
export { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx'
export { default as SwitcherMenu, type SwitcherItem } from './SwitcherMenu.tsx'
export { default as TextInput } from './TextInput.tsx'
export { default as ThemeMenu } from './ThemeMenu.tsx'
export { useValidation } from './useValidation.ts'
+52
View File
@@ -0,0 +1,52 @@
/// <reference path="./virtual-font.d.ts" />
import 'virtual:font'
import { CssBaseline, ThemeProvider, createTheme, type ThemeOptions } from '@mui/material'
import { StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import Card from './Card.tsx'
import { REGULAR } from './fonts.ts'
/**
* The theme of the applications: light, dark and system modes, the choice stored in localStorage by MUI, the Regular
* font on every element and text fields of the small size. colorSchemes replaces the plain light and dark schemes,
* such as to add palette entries of a component.
*/
export function appTheme(colorSchemes: ThemeOptions['colorSchemes'] = { dark: true }) {
return createTheme({
colorSchemes,
cssVariables: { colorSchemeSelector: 'class' },
typography: { fontFamily: REGULAR },
components: { MuiTextField: { defaultProps: { size: 'small' } } },
})
}
/**
* Renders the application into #root with the theme and the fonts of the font plugin: app with what load answers,
* typically the backend's information. load is tried up to 20 times, 500 ms apart, showing Connecting, and then Not
* available with the last error.
*/
export async function renderApp<T>(options: { theme: ReturnType<typeof createTheme>; load: () => Promise<T>; app: (loaded: T) => ReactNode }) {
const root = createRoot(document.getElementById('root')!)
const render = (node: ReactNode) => {
root.render(
<StrictMode>
<ThemeProvider theme={options.theme}>
<CssBaseline />
{node}
</ThemeProvider>
</StrictMode>,
)
}
let lastError = ''
for (let attempt = 0; attempt < 20; attempt++) {
try {
render(options.app(await options.load()))
return
} catch (error) {
lastError = (error as Error).message
render(<Card title="Connecting" message="Waiting for the backend to answer." />)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
render(<Card title="Not available" message={lastError} />)
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { request, type Validation } from './api.ts'
/**
* Asks the server to validate value at path (a .../validate endpoint) 250 ms after it last
* changed. pending is true until the answer for the current value arrives; until then the previous result stays.
*/
export function useValidation<T extends Validation>(path: string | null, value: unknown): { result: T | null; error: string; pending: boolean } {
const key = path === null ? '' : `${path}\n${JSON.stringify(value)}`
const [answer, setAnswer] = useState<{ key: string; result: T | null; error: string } | null>(null)
useEffect(() => {
if (key === '') {
return
}
const [target, body] = [key.slice(0, key.indexOf('\n')), JSON.parse(key.slice(key.indexOf('\n') + 1))]
const timer = window.setTimeout(() => {
request<T>('POST', target, { value: body })
.then((result) => setAnswer({ key, result, error: '' }))
.catch((caught: Error) => setAnswer({ key, result: null, error: caught.message }))
}, 250)
return () => window.clearTimeout(timer)
}, [key])
return { result: answer?.result ?? null, error: answer?.error ?? '', pending: key !== '' && answer?.key !== key }
}
+2
View File
@@ -0,0 +1,2 @@
// The module the font plugin of sk-react-toolkit/vite provides; it holds the font imports VITE_FONT selects.
declare module 'virtual:font' {}