Registry / RepositoryTypeScriptRustPython
Rollpie
ReadmeFiles
Versions
Info
Download
0.1.280.1 KB2026-09-140.1.167.6 KB2026-09-140.1.064.9 KB2026-09-14
Version
0.1.2
Copyright
Rollpie, Irohabook
Publisher
math
Published
2026-09-14
Size
80.1 KB
Downloads
0
Checksum
65387e928cd11d6abf76cfb0f7a839dd3b2b7bb9cdbe78e7b058d3213d9fbcc2
Dependencies
None

README.md

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# mon

A Rust library that provides the common parts of a server on top of axum.
It brings a shared error type that answers in JSON, and extractors that also fail in JSON.
It also builds HTML, relays requests to another server, and keeps sessions in one of four stores.

Data is stored in SQLite through sqlx.

The Japanese documentation is in [README.ja.md](./README.ja.md).

## Usage

**mon claims no paths.**
The application decides what goes on which path, and whether to place anything at all.
`route::wrap` only applies the middleware and inserts the state; it adds no routes.

```
use axum::Router;
use axum::routing::get;
use mon::{AppState, conf, database, page, relay, route, server, session};
use std::sync::Arc;
use tokio::net::TcpListener;

let conf = Arc::new(conf::load()?);              // building a Conf directly also works
let database = database::connect(&conf.database_url).await?;
let store = session::store(&conf, &database);
session::prepare(&store).await?;
session::start_sweep(store.clone(), conf.session_max_age);

// the names, and whether to place each part at all, are decided here
let routes = my_routes()
    .route("/health", get(route::health))
    .nest("/api/session", session::router())
    .nest("/relay", relay::router())
    .nest_service("/static", route::assets(&conf))
    .fallback(page::not_found);

let state = AppState {
    database,
    client: relay::client(&conf)?,
    session: store,
    conf: conf.clone(),
};

// limits the time for receiving headers, and hands the TCP peer to ClientIp
let listener = TcpListener::bind((conf.host, conf.port)).await?;
server::run(listener, route::wrap(routes, state), &conf, shutdown).await?;
```

`shutdown` is a future that completes when the server should stop, such as on Ctrl+C or SIGTERM.
`server::run` then stops accepting and returns after the requests in progress finish.

If you want `/health` to be a page about a clinic's services, simply put that page there.
mon does not take the path first. `tests/collision.rs` checks this.

Names other than paths are not fixed either.
The cookie name comes from `SESSION_COOKIE`, the session table from `SESSION_TABLE`, the store directory from `SESSION_DIRECTORY`, and the request ID header from `REQUEST_ID_HEADER`.
Leave any of them empty and it is not used.

The application creates its own tables.
The only table mon creates is `session`, when the `sqlite` store is chosen, and `session::prepare` creates it with `create table if not exists`.
It does not mix into the application's migrations.

## Running

```
cargo run                       # mon alone, on http://localhost:3000
cargo run --example todo        # an example with /api/todos and a list page
RUST_LOG=debug cargo run        # more detailed logs
PORT=8080 cargo run             # another port
cargo test                      # tests
cargo build --release           # with LTO and strip
```

The crate uses edition 2024, so it needs rustc 1.85 or later.

## Building for Linux

In production, run the executable from a release build instead of `cargo run`.
No separate application server such as gunicorn is needed.
The executable contains the HTTP server itself (hyper, through `axum::serve`).
It runs on the multi-threaded tokio runtime, so one process uses every CPU core.

Build in the directory of your application that depends on mon.
In mon's own directory, the same commands build `src/main.rs`, which runs mon alone.

A build is native code for one operating system and one CPU.
An executable built on macOS runs only on macOS, so build one for the server.

On a Linux machine, `cargo build --release` is enough, and the executable appears in `target/release/`.

From macOS, cross-compile with cargo-zigbuild.
The bundled SQLite and the TLS library (aws-lc-sys) contain C code, so they need a C compiler for Linux, which zig provides.

```
brew install zig
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-gnu
```

The executable appears in `target/x86_64-unknown-linux-gnu/release/`.
For an ARM server, use `aarch64-unknown-linux-gnu` instead.
Running `uname -m` on the server tells which one it is.
If the server says a `GLIBC` version is not found, add the server's glibc version to the target, as in `x86_64-unknown-linux-gnu.2.28`.
Running `ldd --version` on the server shows that version.

The release profile uses fat LTO and `codegen-units = 1`, so a build takes a lot of memory and CPU time.
Avoid building on a small server that is already answering requests.

## Running on a server

The server needs no Rust toolchain.
SQLite is compiled into the executable, so SQLite does not need to be installed either.
Copy the executable to the server, along with the static directory if `route::assets` serves one.

Run it under systemd, which starts it when the machine boots and starts it again whenever it stops.
The following is an example, where `app` stands for the name of your application.

```
[Unit]
Description=app
After=network.target

[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/srv/app/app
Environment=PORT=3000
Environment=SESSION_SECURE=true
Environment=CLIENT_IP_HEADER=x-forwarded-for
UMask=0077
Restart=always

[Install]
WantedBy=multi-user.target
```

```
sudo cp app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now app
```

Always set `WorkingDirectory`.
By default, mon.db, `static` and `session` are looked up relative to the directory the process starts in.
Absolute paths can be given instead through `DATABASE_URL`, `STATIC_DIRECTORY` and `SESSION_DIRECTORY`.
The account in `User=` needs write access to that directory, because mon.db and its WAL files are created there.
`UMask=0077` makes mon.db, its WAL files and the session files readable only by that account.
Logs go to standard output, so `journalctl -u app` shows them.

To update, put the new executable next to the old one, rename it over the old one, and run `sudo systemctl restart app`.
Overwriting a running executable in place fails with "Text file busy".
rsync writes to a new file and then renames it, so it can be used as it is.
`systemctl restart` sends SIGTERM first.
`src/main.rs` passes Ctrl+C and SIGTERM to `server::run` as `shutdown`, so the requests in progress finish before the process exits.

Put a reverse proxy such as nginx in front to handle TLS.
Inside the server block for your domain, pass requests to mon like this.

```
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}
```

With a single nginx, `CLIENT_IP_HEADER=x-forwarded-for` and `TRUSTED_PROXY=0` read the client IP correctly, as described under "Client IP".
`SESSION_SECURE=true` is needed because browsers reach the site over HTTPS.

mon listens on the address in `HOST`, which defaults to 127.0.0.1.
Requests that do not pass through the proxy therefore cannot reach it.
When the proxy runs on another machine or mon runs in a container, set `HOST=0.0.0.0` and let nothing but the proxy reach the port.
While `CLIENT_IP_HEADER` is set, a request that reaches the port directly can forge `X-Forwarded-For`.

Start your application with `server::run`, not `axum::serve`.
`axum::serve` sets no limit on the time for receiving headers, so a client that sends headers slowly, or connects and sends nothing, keeps the connection.
`server::run` closes such connections after `REQUEST_TIMEOUT`.

## Parts mon provides

The application decides where each part goes.
Paths that appear below assume the arrangement in the example above.

- `route::health` - returns only `{"status": "ok"}` and does not touch the database
- `session::router()` - reads with `GET`, writes with `POST`, and discards with `DELETE`
- `relay::router()` - relays `/` and everything under it
- `route::assets(&conf)` - a service that serves static files
- `page::not_found` - the HTML for a request that matches no route
- `server::run` - listens with a limit on the time for receiving headers, and stops after the requests in progress finish

Every error is JSON of the form `{"error": "..."}`. Panics and timeouts take the same form.
A timeout answers 503, not 408, because 408 would allow the client to send the same request again.
There are three exceptions. `page::not_found` returns HTML.
The 404 from `route::assets` and the 413 for a body over the limit are text/plain.
Both come from tower-http as they are.

## Environment variables

All of them are optional. They are read in one place, conf.rs.

An unset variable takes its default, but **a variable that is set and cannot be read stops the startup**.
Quietly falling back to the default would start the server on a port nobody asked for, and the process would run while the proxy in front sees 502, the hardest failure to track down.

mon stops with messages like these. They are written in Japanese.

```
PORT=800o             -> PORT=800o を数として読めません(invalid digit found in string)
PORT=0                -> PORT=0 は 1 以上にしてください
CORS_CREDENTIALS=ture -> CORS_CREDENTIALS=ture は true か false で書いてください
```

Numeric settings do not accept 0, because `PORT=0` lets the OS pick any free port.
Only two accept 0: `CORS_MAX_AGE`, where it means preflights are not cached, and `TRUSTED_PROXY`, where it means nothing is skipped.

- `PORT` - the port to listen on. Default 3000
- `HOST` - the address to listen on. Default 127.0.0.1, which only the same machine can reach
- `DATABASE_URL` - where the SQLite database is. Default `sqlite:mon.db`
- `STATIC_DIRECTORY` - the directory of static files. Default `static`
- `STATIC_CACHE_CONTROL` - the Cache-Control sent with static files. Default `no-cache`. Empty sends none
- `RELAY_ORIGIN` - the origin the relay forwards to. Default `http://localhost:6060`
- `CORS_ORIGIN` - allowed origins, separated by commas. Empty allows every origin
- `CORS_METHOD` - allowed methods, separated by commas. Empty leaves it to the defaults
- `CORS_HEADER` - allowed request headers, separated by commas. Empty leaves it to the defaults
- `CORS_EXPOSE_HEADER` - response headers that JavaScript may read. Empty leaves it to the defaults
- `CORS_MAX_AGE` - seconds the browser may keep a preflight result. Default 3600
- `CORS_CREDENTIALS` - whether requests with cookies are allowed. Default false
- `SESSION_STORE` - where sessions are kept: `none` `memory` `file` `sqlite`. Default sqlite
- `SESSION_MEMORY_LIMIT` - the maximum number of sessions the memory store holds. Default 100000
- `SESSION_DIRECTORY` - the directory for the file store. Default `session`
- `SESSION_TABLE` - the table name for the sqlite store. Default `session`
- `SESSION_COOKIE` - the cookie name. Default `sid`
- `SESSION_MAX_AGE` - seconds a session is kept. Default 2592000 (30 days)
- `SESSION_SECURE` - whether the cookie gets Secure. Default false. Turn it on in production
- `SESSION_SAME_SITE` - `lax` `strict` `none`. Default lax
- `REQUEST_ID_HEADER` - the header that carries the request ID. Default `x-request-id`. Empty adds none
- `CLIENT_IP_HEADER` - the header to read the client IP from. Empty by default, which means the TCP peer is used
- `TRUSTED_PROXY` - how many entries of that header to skip from the right. Default 0
- `CONTENT_SECURITY_POLICY` - the CSP added to responses. Empty by default, which adds none
- `BODY_LIMIT` - the maximum request body size in bytes. Default 2 MiB
- `REQUEST_TIMEOUT` - the maximum seconds for one request, which `server::run` also applies to receiving headers. Default 10
- `RELAY_TIMEOUT` - the maximum seconds to wait for the upstream of the relay. Default 3

## CORS

With `CORS_ORIGIN` empty, requests from any origin pass; when it is set, only those origins pass.
CorsLayer answers the preflight OPTIONS itself, so no handler is needed for it.

A value in `CORS_ORIGIN` must match the Origin the browser sends, character for character.
Write it as `scheme://host`, with a port if needed, and without a path or a trailing slash.
Write it in lowercase.
Any other form stops the startup, because a setting that is written yet has no effect is the hardest to notice.
`CORS_ORIGIN=*` is refused too. To allow every origin, leave it empty.
`*` in `CORS_METHOD`, `CORS_HEADER` and `CORS_EXPOSE_HEADER` is refused in the same way, and so is a value that cannot be read as a method or a header name.

`CORS_CREDENTIALS=true` lets requests with cookies through.
CORS then forbids answering `*` for the origin, the methods and the headers.
If credentials are turned on while `CORS_ORIGIN` is empty, mon logs a warning and drops only the credentials.
When methods and headers are not specified, mon answers with the following.

- Methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
- Request headers: content-type, authorization
- Response headers readable from JavaScript: x-request-id

Without credentials, all three are `*`.

## Client IP

Behind a proxy such as nginx, the TCP peer is the proxy itself.
The real client's address arrives in `X-Forwarded-For` or `X-Real-IP`.
`CLIENT_IP_HEADER` names the header to read, and `TRUSTED_PROXY` sets how many entries to skip from the right.

The client can write anything at the left end of the header.
nginx's `$proxy_add_x_forwarded_for` appends the address it saw on the right, so only the right side can be trusted.
With one nginx, set only `CLIENT_IP_HEADER=x-forwarded-for` and leave `TRUSTED_PROXY` at 0.
When a CDN is added in front, set it to 1.

Unless `CLIENT_IP_HEADER` is set, mon does not look at any header.
A forgotten setting never results in a forged value being taken.

In a handler, use it like this.

```
async fn where_from(ClientIp(address): ClientIp) -> String {
    address.map(|value| value.to_string()).unwrap_or_default()
}
```

`server::run` hands the TCP peer to `ClientIp`.
With `axum::serve`, pass `into_make_service_with_connect_info::<SocketAddr>()` to get the same.
Without either, the header alone decides, and if that is missing too, the value is `None`.
The resolved address also goes into the log span.

## Sessions

The cookie holds only the ID, and the contents are saved in a store.
`SESSION_STORE` chooses the store.
`sqlite` uses the `session` table in mon.db, `file` writes one JSON file per session, `memory` keeps sessions inside the process, and `none` uses no sessions.

The `memory` store is emptied by a restart, and with two processes, a session goes missing in one of them.
It is for development.
`SESSION_MEMORY_LIMIT` caps how many sessions it holds.
When it is full, a request that would create a new session gets 500.

**A session is not issued until it is written.**
A client that only reads gets nothing created and no `Set-Cookie`.
Only when a handler writes to the session does mon create an ID, save it and send the cookie.
Otherwise one record would accumulate for every first-time visitor, crawlers included.
A route where an anonymous client can write to its session still adds one record per request.
Keep the number of such requests down in the proxy, for example with nginx's `limit_req`.

The ID is 32 random bytes written in hex.
mon decides by whether the ID exists in the store, so it does not sign it.
An ID sent by the client is used only when the store actually holds it.
An unknown ID is not adopted; mon creates a new one instead.

The stores keep the SHA-256 of the ID instead of the ID itself.
A leaked table, file or backup therefore does not reveal any cookie value.

The `file` store creates its directory with mode 0700 and each file with 0600, so other accounts on the machine cannot read them.
It writes a temporary file and renames it into place, so a reader never sees a half-written file.
The sweep removes only files named the way mon names them, and leaves everything else in the directory alone.

`SESSION_SECURE` defaults to false so that mon runs on `http://localhost` in development.
Set `SESSION_SECURE=true` in production.
Browsers discard a `SESSION_SAME_SITE=none` cookie without Secure, so mon refuses to start with that combination.

Expired sessions are removed when read, and by a sweep every hour.
A session past half its lifetime is saved again even without changes, which extends it.

`Set-Cookie` is sent only when a session is saved. Responses that only read get none.
Sending it every time would keep even static file responses out of shared caches and CDNs.
When `Set-Cookie` is sent, mon adds `private` to `Cache-Control` and removes `public` and `s-maxage`.
Shared caches may store a response that carries `Set-Cookie` (RFC 9111), and would then hand one person's `sid` to another.

Where the client's status changes, such as at login, call `session.renew().await` to change the ID.
The contents stay, and the old ID is removed from the store.
Without this, an attacker can place an ID they obtained themselves into the victim's browser and pass authentication with it.
mon never adopts an unknown ID, which stops forged IDs, but an ID obtained legitimately can only be made useless by changing it.

In a handler, use it like this.

```
async fn login(session: Session) -> ... {
    session.write().await.language = Some("ja".to_owned())
}
```

Calling `write()` marks the session to be saved. `read()` only looks and does not save.
`write()` returns a copy of the contents, which is written back when it is dropped.
While it is held, `read()`, `renew()` and `destroy()` still return at once, and `read()` shows the contents before the write-back.
Holding two results of `write()` at the same time panics, because the one dropped first would be lost.

Add the fields a session holds to `SessionData` in session.rs.
They are saved as JSON, so adding fields changes nothing in the stores.

`session::router()` accepts a `language` of up to 35 letters, digits and hyphens, and answers 422 otherwise.
It returns only `language`, so fields added to `SessionData` are not shown to the client.

If saving what a handler wrote fails, mon answers 500 instead of the handler's response, so that the client does not believe it was saved.
The same goes for `destroy()` when the session cannot be removed from the store.

## Logging

`log::start(default)` sends logs to standard output.
Writing happens on a dedicated thread, so request handling does not stop even when the output blocks.
The span of each request records the path without the query string, because query strings can carry tokens.

```
// keep the guard until the end. Dropping it stops the writer thread and loses lines not yet written
let _log = log::start("info,tower_http=debug");
```

`RUST_LOG`, when set, takes precedence over the level given here.
When writing cannot keep up, lines are dropped instead of waiting, so that request handling never stops.
The buffer holds 128,000 lines, and lines beyond that are lost.
`dropped()` tells how many were lost. Nothing reports it unless you ask.

The library does not install this on its own, so the application decides whether to call it.
To set it up yourself, use `tracing_appender::non_blocking` directly instead of this function.

## Relay

The path and query after `/relay/` are joined to `RELAY_ORIGIN` and forwarded there.
The method, headers and body are sent as they are, and the status, headers and body that come back are returned as they are.
Only headers about the connection itself (connection, host, transfer-encoding and so on) are neither sent nor returned.
Redirects are not followed. A 302 from the upstream reaches the client as it is, location included.
Following it would deliver only the final response to the client and lose the original 302.

Whether `CLIENT_IP_HEADER` is set also tells mon whether a proxy stands in front of it.
When it is set, `X-Forwarded-For`, `-Proto` and `-Host` are added only when missing.
The ones present were added by the proxy, and rewriting them would shift the entries the upstream counts.
When it is empty, mon regards itself as the front.
It drops every `X-Forwarded-*`, `Forwarded` and `X-Real-IP` header the client sent, and writes `X-Forwarded-For`, `-Proto` and `-Host` itself.
The host header is not forwarded, so without these headers the upstream cannot learn the public host.
The status depends on the upstream: 502 when it cannot be reached, and 504 when it accepts the connection but does not answer.
Timeouts apply to two waits: until the connection is made, and until the next chunk arrives.
A timeout on the whole response would cut off a large response in the middle of streaming.

mon's session cookie is removed from the `Cookie` header sent to the upstream.
A `Set-Cookie` from the upstream for that cookie name is dropped as well.
Otherwise the upstream would learn session IDs, or could replace the client's session.

## Static files

`route::assets(&conf)` is a service that serves `STATIC_DIRECTORY`.
It adds the value of `STATIC_CACHE_CONTROL`.
The default is `no-cache`, and an empty value adds nothing.

Without the header, the browser guesses an expiry from the time since Last-Modified and does not ask again until then.
Replacing a file under the same name still shows the old one.
`no-cache` does not mean the response is not stored; it means the browser must ask before using it.
If the file has not changed, a 304 comes back, so one round trip is enough.

If file names include a hash, use `max-age=31536000, immutable`.

## CSP

The value of `CONTENT_SECURITY_POLICY` is added to every response as Content-Security-Policy.
It is empty by default, and then nothing is added.
What a page may load differs between applications, so mon has no default value.

```
CONTENT_SECURITY_POLICY="default-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
```

If a handler sets its own policy, that one is kept.
To vary it per page, set it in the handler.

`frame-ancestors` replaces X-Frame-Options. With it in the policy, X-Frame-Options is not needed.

`html::document` writes its styles directly into a `<style>` element.
With only `default-src 'self'`, the styles of `page::not_found` stop working, so add `style-src 'unsafe-inline'` or put your own 404 in place.

## Storage

The application prepares its own tables.
The only table mon creates is the session table, which `session::prepare` creates with `create table if not exists`.
mon has no migration system.
If the library had one, mon's tables would mix into the application's migrations.

The table name can also be set with `SESSION_TABLE`.
The default is `session`; change it if a table with that name already exists.
Its `id` column holds the SHA-256 of each session ID.

The compile-time macro `sqlx::query!` is not used.
It would need a database connection or the `.sqlx/` cache on every build.
Runtime `query_as` and the `FromRow` derive are used instead.

## Layout

```
src/
├── lib.rs       the public API
├── main.rs      a binary that runs mon alone
├── conf.rs      settings: Conf::default, and load, which reads the environment
├── state.rs     the state shared by every handler
├── database.rs  the SQLite pool
├── route.rs     routing and middleware (tests are here too)
├── server.rs    listening, with a limit on the time for receiving headers
├── session.rs   sessions (the cookie and the stores)
├── error.rs     the shared error type
├── extract.rs   Json / Path / Query
├── html.rs      HTML building
├── ip.rs        the client IP (how far to trust the headers)
├── log.rs       logging setup (calling it is the application's choice)
├── page.rs      the HTML when no route matches (placing it is the application's choice)
└── relay.rs     the relay to another server

examples/
└── todo.rs      an example with /api/todos and a list page

tests/
├── collision.rs mon claims no paths
├── relay.rs     the relay, checked against an upstream started inside the test
└── server.rs    server::run, checked over real TCP connections
```

## Trying it

Start `cargo run --example todo` first.

```
curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"title":"shopping"}'
curl localhost:3000/api/todos
curl -X PATCH localhost:3000/api/todos/<id> -H 'content-type: application/json' -d '{"done":true}'
curl -i -X DELETE localhost:3000/api/todos/<id>
curl -i -X POST localhost:3000/api/session \
	-H 'content-type: application/json' -d '{"language":"ja"}'
```

## Formatting

rustfmt is not used.
rustfmt collapses the two blank lines between functions into one.
It also has no option to drop the spaces inside braces, which this code writes as `{key: value}`.
Neither can be expressed in its settings, so the code is aligned by hand.

## License

Dual licensed under MIT and Apache-2.0. Take whichever you prefer.

- [LICENSE-MIT](./LICENSE-MIT)
- [LICENSE-APACHE](./LICENSE-APACHE)