182 lines
10 KiB
Markdown
182 lines
10 KiB
Markdown
# 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 depends on a
|
|
published version and imports `@koszewscy/sk-react-toolkit/server`, `/web` and `/vite`. Its
|
|
`.npmrc` names the registry of the scope:
|
|
|
|
```ini
|
|
@koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/
|
|
```
|
|
|
|
```json
|
|
{ "dependencies": { "@koszewscy/sk-react-toolkit": "^0.2.0" } }
|
|
```
|
|
|
|
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`:
|
|
|
|
```ts
|
|
import { font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/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,
|
|
}))
|
|
```
|
|
|
|
To change the toolkit and an application together, `npm link --no-save` replaces the installed
|
|
package with the checkout, the TypeScript source, in each package of the application, whatever
|
|
version it names. The applications run it for both their packages as `npm run dev-link`:
|
|
|
|
```json
|
|
{ "scripts": { "dev-link": "npm link --no-save --no-fund ../sk-react-toolkit && cd web && npm link --no-save --no-fund ../../sk-react-toolkit" } }
|
|
```
|
|
|
|
`package.json` and the lock files keep the published version; `npm ci` or `npm install` replaces
|
|
the link with it again, and `npm run dev-link` links the checkout again. A change of the toolkit
|
|
reaches the released application through a release of the toolkit and an update of the
|
|
dependency.
|
|
|
|
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 |
|
|
| `encryptionKey()`, `encrypt(text, key)`, `decrypt(encrypted, key)` | the key of the secrets and AES-256-GCM encryption; see [Credential store](#credential-store) |
|
|
| `CredentialStore`, `credentialProblems`, `credentialValues`, `registerCredentialRoutes`, `POSTGRESQL_KIND` | the credential store; see [Credential store](#credential-store) |
|
|
|
|
## 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 |
|
|
| `CredentialsButton` | the dialog of a credential store; see [Credential store](#credential-store) |
|
|
|
|
## 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 |
|
|
|
|
## Credential store
|
|
|
|
A credential store is a list of credentials in a key of the application's `config.json`, all of
|
|
the one kind the application gives the store, such as a database connection or a service
|
|
principal; an application with several kinds has a store, a key, for each. Each entry has a
|
|
`name`, unique and not one of the store's reserved names, and the fields of the store's kind:
|
|
|
|
```json
|
|
{
|
|
"pg_connections": [
|
|
{
|
|
"name": "state",
|
|
"host": "db.example.com",
|
|
"port": 5432,
|
|
"database": "platform",
|
|
"user": "tofu",
|
|
"ssl_mode": "verify-full",
|
|
"password": "<encrypted>"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
A kind (`CredentialKind`) has a label for one credential and names its fields
|
|
(`CredentialField`), each by its key in `config.json`,
|
|
with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether
|
|
it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL
|
|
connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or
|
|
`verify-full`) and a secret `password`.
|
|
|
|
A secret field is encrypted with AES-256-GCM, stored as base64 of a random IV, the ciphertext and
|
|
the authentication tag. The key is the SHA-256 of `ENCRYPTION_KEY`, else of the content of the
|
|
file `ENCRYPTION_KEY_FILE` names, without its final newline. Without either, no secret can be
|
|
saved or used; a secret encrypted with another key cannot be decrypted and has to be entered
|
|
again.
|
|
|
|
`CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved
|
|
names and whether a missing `config.json` reads as `{}`. The key is a key of `config.json` or a
|
|
path of keys separated by dots, such as `connections.pg`, for a list in nested objects; a write
|
|
creates the objects of the path that are missing and keeps their other keys, so stores of several
|
|
kinds can share one object:
|
|
|
|
```json
|
|
{
|
|
"connections": {
|
|
"pg": [{ "name": "state", "host": "db.example.com", "port": 5432, "...": "..." }],
|
|
"mssql": [{ "name": "warehouse", "host": "sql.example.com", "port": 1433, "...": "..." }]
|
|
}
|
|
}
|
|
```
|
|
|
|
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
|
|
for the application's own check of `config.json`.
|
|
- `credentialValues(store, name)`: a credential with its secrets decrypted; HttpError 404 for a
|
|
name that does not exist, 400 without a key or for a secret the key does not decrypt.
|
|
- `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST
|
|
<prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`,
|
|
with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never
|
|
answered) and `stored` (which secrets are kept), the kind, the reserved names, the path of
|
|
`config.json`, its version and whether the server has a key. A write takes the version read and
|
|
the credentials; a secret is null to keep the secret of the credential named `original`, `''`
|
|
for none, or the new secret, which is encrypted. A file that changed since the version read is
|
|
409, credentials with problems 422; the other keys of `config.json` are kept.
|
|
|
|
`CredentialsButton` (`stores`, `title`, `icon`, `note`, `onSaved`) opens the credentials of the
|
|
stores in a dialog. `stores` lists the prefix of each store's routes and the label of its tab; the
|
|
tabs are shown when there are several stores, which share one `config.json`. Each tab has a list at
|
|
the left with Add and Remove, and the fields of the credential selected, as the store's kind names
|
|
them; a tab with a problem has a red label. A secret shows only whether it is kept: Set or Change
|
|
replaces it and Remove drops it. Save writes each store that changed, each write with the version
|
|
the previous one answers.
|
|
|
|
## 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.
|