docs(book): the four chapters — Phase 5 slice 2 #3
@@ -15,8 +15,14 @@
|
|||||||
# tree, in both directions — an unlisted file that still carries the
|
# tree, in both directions — an unlisted file that still carries the
|
||||||
# placeholder, and a listed file that no longer does, are both failures. That
|
# placeholder, and a listed file that no longer does, are both failures. That
|
||||||
# checklist is the only instruction a reader has for the first thing they do
|
# checklist is the only instruction a reader has for the first thing they do
|
||||||
# with the template, and it is prose, so it rots the way prose does. The two
|
# with the template, and it is prose, so it rots the way prose does.
|
||||||
# checks in `scripts/` have their own unit tests, run in the same job.
|
#
|
||||||
|
# And it checks that every path the book names in backticks still exists. The
|
||||||
|
# chapters teach out of `template/`, none of those mentions is a markdown link,
|
||||||
|
# and nothing else in this repo would ever look at them — so renaming one
|
||||||
|
# template file would leave four chapters quietly pointing at nothing. That is
|
||||||
|
# the cheap half of "is the book still true"; the other half is a reviewer's.
|
||||||
|
# All three checks in `scripts/` have their own unit tests, run in the same job.
|
||||||
#
|
#
|
||||||
# • `template` — the interesting one, and the anti-rot mechanism of the whole
|
# • `template` — the interesting one, and the anti-rot mechanism of the whole
|
||||||
# repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in
|
# repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in
|
||||||
@@ -86,11 +92,17 @@ jobs:
|
|||||||
- name: Check the rename checklist against the template
|
- name: Check the rename checklist against the template
|
||||||
run: node scripts/checkRenameSites.js
|
run: node scripts/checkRenameSites.js
|
||||||
|
|
||||||
|
- name: Check every path the book names still exists
|
||||||
|
run: node scripts/checkChapterPaths.js
|
||||||
|
|
||||||
# The checks, checked. A check that has never been shown to fail is a check
|
# The checks, checked. A check that has never been shown to fail is a check
|
||||||
# nobody knows the state of — and this one gates the instructions for the
|
# nobody knows the state of — and these gate the instructions for the first
|
||||||
# first thing a reader does.
|
# thing a reader does. Named file by file rather than `node --test scripts/`:
|
||||||
|
# directory mode is not portable across the Node versions people run this on.
|
||||||
- name: Test the checks themselves
|
- name: Test the checks themselves
|
||||||
run: node --test scripts/checkRenameSites.test.js
|
run: |
|
||||||
|
node --test scripts/checkRenameSites.test.js
|
||||||
|
node --test scripts/checkChapterPaths.test.js
|
||||||
|
|
||||||
template:
|
template:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -84,9 +84,14 @@ scripts/ the checks CI runs over both
|
|||||||
|
|
||||||
CI clones core at a **pinned commit**, asserts the version the template declares
|
CI clones core at a **pinned commit**, asserts the version the template declares
|
||||||
still matches that core's `MODULE_API_VERSION`, builds the template and runs its
|
still matches that core's `MODULE_API_VERSION`, builds the template and runs its
|
||||||
guards, checks every link in the book, and holds the template's rename checklist
|
guards, checks every link in the book, holds the template's rename checklist
|
||||||
against the template's own tree. So a change to the contract breaks this repo's
|
against the template's own tree, and checks that every path a chapter names is
|
||||||
build loudly instead of leaving a chapter quietly wrong.
|
still there. So a change to the contract breaks this repo's build loudly instead
|
||||||
|
of leaving a chapter quietly wrong.
|
||||||
|
|
||||||
|
None of that can tell you whether a paragraph has become untrue about a file that
|
||||||
|
still exists. That is a reviewer's job on every pull request, and a
|
||||||
|
`MODULE_API_VERSION` bump is when it is owed in full.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
244
book/01-first-module.md
Normal file
244
book/01-first-module.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 1. Your first module in twenty minutes
|
||||||
|
|
||||||
|
No theory in this chapter. You will copy a module that already works, rename it,
|
||||||
|
build it, install it into a running core, and load a page it serves. Everything
|
||||||
|
after this chapter is a change to something that runs, rather than a step toward
|
||||||
|
something that might.
|
||||||
|
|
||||||
|
That order is deliberate. The module system has a lot of seams — a server entry
|
||||||
|
point, a client chunk, a schema fragment, a nav registration, an OpenAPI fragment
|
||||||
|
— and each one is easy to understand and unpleasant to debug in the abstract. Get
|
||||||
|
all of them working at once with almost no content in them, and you can then break
|
||||||
|
exactly one at a time on purpose.
|
||||||
|
|
||||||
|
**What you need:** a Runic Gateway core you can restart, Node 20 or newer, and
|
||||||
|
about twenty minutes. You do not need core's source, and you should not read it —
|
||||||
|
if this chapter cannot be followed without it, that is a bug in this chapter and
|
||||||
|
[worth telling us about][issues].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The pieces you are about to copy
|
||||||
|
|
||||||
|
`template/` is a whole module, in the shape a real one has. Nine things matter and
|
||||||
|
the rest is filling:
|
||||||
|
|
||||||
|
| Piece | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `template/module.json` | The first thing core reads. Your id, your version, the core API range you need, and a declaration of every prefix you will mount. |
|
||||||
|
| `template/server/index.js` | The server-side handshake: one exported function, called once with `(ctx, api)`. |
|
||||||
|
| `template/server/core.js` | Lazy accessors over `ctx`, so the rest of your server code can reach core the way ordinary code reaches a library. |
|
||||||
|
| `template/server/boot.js` | `onBoot` and `onShutdown` — where anything needing a live database goes. |
|
||||||
|
| `template/server/db/schema.sql` | Your tables. Idempotent, replayed at every boot. |
|
||||||
|
| `template/server/db/purge.sql` | The same tables, dropped. Run only when an operator explicitly purges you. |
|
||||||
|
| `template/client/src/entry.jsx` | The client-side handshake: registers your routes and your nav rows into core's SPA. |
|
||||||
|
| `template/client/vite.config.js` | The library build that produces the chunk core serves — and the aliases that make your React core's React. |
|
||||||
|
| `template/swagger-fragment.json` | Generated. Core merges it into its own API documentation. |
|
||||||
|
|
||||||
|
Two of those have a reputation. `vite.config.js` is the highest-risk mechanical
|
||||||
|
detail in the whole system and chapter 2 spends real time on why; `module.json`'s
|
||||||
|
`mounts` is the one field people fill in wrong and discover at boot. Neither
|
||||||
|
matters yet — the template has both right.
|
||||||
|
|
||||||
|
## Copy it, and make it yours
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r template/ ~/my-module
|
||||||
|
cd ~/my-module
|
||||||
|
```
|
||||||
|
|
||||||
|
Your module id is the single most load-bearing string in it: it is the directory
|
||||||
|
core loads you from, the key in core's database, the URL segment every one of your
|
||||||
|
pages hangs under, and the prefix every one of your tables must carry. It must
|
||||||
|
match `^[a-z][a-z0-9-]{1,31}$`, and you want no hyphen in it unless you enjoy
|
||||||
|
backticking table names.
|
||||||
|
|
||||||
|
Change `id` in `module.json` first, then work down the checklist in
|
||||||
|
`template/README.md` — it names every file that still carries the placeholder,
|
||||||
|
and it is [verified by CI][renamecheck] in both directions, so it is not the kind
|
||||||
|
of checklist that is wrong by the second edit.
|
||||||
|
|
||||||
|
**The placeholder is `examplegame`, not `example`, and that is not an aesthetic
|
||||||
|
choice.** A check for a leftover `example` fires on the phrase "for example" in
|
||||||
|
ordinary prose, and a check that cries wolf is a check people learn to ignore. If
|
||||||
|
you build your own checks later, pick placeholder names that cannot occur by
|
||||||
|
accident.
|
||||||
|
|
||||||
|
## Build it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --prefix server
|
||||||
|
npm test --prefix server
|
||||||
|
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client # → client/dist/entry.js
|
||||||
|
npm test --prefix client
|
||||||
|
```
|
||||||
|
|
||||||
|
Build **before** you run the client tests. Two of them read the built chunk and
|
||||||
|
skip when there is none, so a run in the other order passes while asking nothing
|
||||||
|
about the artifact that actually ships. That ordering has bitten this project
|
||||||
|
twice in two different repositories, which is why it is called out here rather
|
||||||
|
than left to a CI file.
|
||||||
|
|
||||||
|
What you have now is `client/dist/entry.js` — a prebuilt ES module — and a server
|
||||||
|
tree that has never been compiled at all, because it does not need to be.
|
||||||
|
|
||||||
|
**An operator never builds anything.** That is the constraint the whole delivery
|
||||||
|
path is designed around: a module arrives as a tarball with the chunk already in
|
||||||
|
it, and core serves that file untouched. Your build machine is the only place a
|
||||||
|
bundler ever runs.
|
||||||
|
|
||||||
|
## Install it
|
||||||
|
|
||||||
|
Three supported ways, and for the next twenty minutes you want the third:
|
||||||
|
|
||||||
|
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON your
|
||||||
|
release workflow publishes beside your tarball. This is how a real operator
|
||||||
|
installs you.
|
||||||
|
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
|
||||||
|
deployment that declares its module set instead of clicking it.
|
||||||
|
3. **A directory on the volume.** Copy your whole module tree to
|
||||||
|
`<website>/modules/<your-id>/` and restart core.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r ~/my-module <website>/modules/my-id
|
||||||
|
# restart core
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy it. Do not symlink it.** The loader lists directory entries and asks each
|
||||||
|
whether it is a directory; a symlink answers no, and your module is skipped in
|
||||||
|
complete silence. This is the single most common way a first install appears to do
|
||||||
|
nothing at all.
|
||||||
|
|
||||||
|
Two more things that look like your module failing and are not:
|
||||||
|
|
||||||
|
- If core is running in a container, your files have to be on the volume core sees
|
||||||
|
— `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the
|
||||||
|
repository directory next to it.
|
||||||
|
- A core with a fresh database boots in **maintenance mode**, and public module
|
||||||
|
pages sit behind the same maintenance gate core's own do. Your page will look
|
||||||
|
broken while the site is not live yet.
|
||||||
|
|
||||||
|
## What you should see
|
||||||
|
|
||||||
|
Restart core and read the log. A module that loaded says so:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"}
|
||||||
|
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}}
|
||||||
|
INFO [modules] schema ensured for module "examplegame" {"statements":2}
|
||||||
|
INFO [examplegame:boot] booted {"refreshMs":30000}
|
||||||
|
INFO [modules] module "examplegame" started
|
||||||
|
```
|
||||||
|
|
||||||
|
Your two lines and core's three, interleaved: core narrates each step of your load
|
||||||
|
in its own `[modules]` namespace, and your logger is namespaced with your id. That
|
||||||
|
alternation is the quickest way to see how far a load got.
|
||||||
|
|
||||||
|
Then, in the browser:
|
||||||
|
|
||||||
|
- **`/examplegame/status`** renders your page, with a **World** row in the public
|
||||||
|
header pointing at it. That row is now an ordinary nav row: an operator can
|
||||||
|
reorder it, relabel it or hide it from the nav editor exactly as they can core's.
|
||||||
|
- **`/api/v1/public/world/status`** answers JSON.
|
||||||
|
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your
|
||||||
|
`module.json`. This is how a client — core's SPA, the Android app, anything —
|
||||||
|
feature-detects you.
|
||||||
|
- **`/api/docs`** shows your route under its own tag, merged out of the OpenAPI
|
||||||
|
fragment you committed. (`/api/docs.json` is the raw merged document, if you
|
||||||
|
would rather grep it.)
|
||||||
|
- **Admin → Modules** shows you as `started`.
|
||||||
|
|
||||||
|
Open the browser console while you are there. Your entry logs the core API version
|
||||||
|
it registered against, and any complaint the client half has to make will be sitting
|
||||||
|
next to it.
|
||||||
|
|
||||||
|
## The state your module is in
|
||||||
|
|
||||||
|
Core keeps one row per module and its `state` column has five values. Four are
|
||||||
|
outcomes and one is an operator's decision:
|
||||||
|
|
||||||
|
| State | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| `installed` | Files are on the volume; the row was just created. |
|
||||||
|
| `enabled` | Cleared for this boot to try. Every non-disabled row is reset to this at each boot. |
|
||||||
|
| `started` | Loaded, registered, schema replayed, `onBoot` returned. This is the one you want. |
|
||||||
|
| `startup_failed` | Something went wrong; the panel shows the stage and the reason. The site came up anyway. |
|
||||||
|
| `disabled` | An operator switched you off. Nothing else — not a failure, not a reinstall — moves this. |
|
||||||
|
|
||||||
|
The important half of that table is what it implies: **a module that fails to
|
||||||
|
load never takes the site down.** Core try/catches your entire lifecycle, records
|
||||||
|
where you broke, and serves everything else. You are debugging from an admin
|
||||||
|
screen, not from a stack trace in a crash loop.
|
||||||
|
|
||||||
|
**A retry is a restart.** Every boot resets non-disabled rows to `enabled` and
|
||||||
|
writes that boot's outcome, so the panel always describes the run you are looking
|
||||||
|
at rather than a run from last week.
|
||||||
|
|
||||||
|
## The four ways it fails
|
||||||
|
|
||||||
|
When something is wrong, the shape of the failure tells you where to look before
|
||||||
|
you read a single message.
|
||||||
|
|
||||||
|
**1. Your module is not in the panel at all.** The loader never saw a directory
|
||||||
|
worth scanning. It is a symlink; or it is in the wrong place; or it has no
|
||||||
|
`module.json` at the top of it. Note the bundle shape here — a release tarball's
|
||||||
|
top-level directory is `<name>-<version>`, so an unpacked bundle copied wholesale
|
||||||
|
leaves core looking at a directory with nothing in it but another directory.
|
||||||
|
|
||||||
|
**2. It is `startup_failed`, and your routes and nav are simply absent.** The
|
||||||
|
failure happened before anything was mounted: a malformed `module.json`, an
|
||||||
|
unsatisfiable `coreApi`, a prefix that collides with core's, a schema fragment
|
||||||
|
breaking a rule. Nothing of yours is on the URL surface, so nothing of yours can
|
||||||
|
half-work.
|
||||||
|
|
||||||
|
**3. It is `startup_failed`, and your routes answer `503`.** The failure happened
|
||||||
|
after mounting — the database rejected a statement in your fragment, or your
|
||||||
|
`onBoot` threw. Your routes stay mounted deliberately: the URL surface is a
|
||||||
|
property of what is installed, not of whether a boot hook succeeded on this
|
||||||
|
machine. A module that failed to warm up says it is down; it does not serve half
|
||||||
|
its data.
|
||||||
|
|
||||||
|
**4. It answers `404` everywhere.** Someone disabled you. Same mechanism — mounted
|
||||||
|
and guarded, never unmounted.
|
||||||
|
|
||||||
|
The panel names the stage each failure happened in, and the stages are the
|
||||||
|
loader's own validation steps, listed in [`MODULE_API.md`][api] §4.3 and §4.4. Read
|
||||||
|
the stage first; it is usually enough. Core logs the same thing at boot —
|
||||||
|
`module "…" failed to load — continuing without it {"stage":…,"reason":…}` — so you
|
||||||
|
do not need the panel to debug this.
|
||||||
|
|
||||||
|
**In all four cases you disappear from `/api/v1/public/modules`.** That endpoint
|
||||||
|
answers what this backend is *serving*, so a client feature-detecting your
|
||||||
|
capability renders a site without it rather than one advertising something that
|
||||||
|
`503`s. It is also a quick check with no login: if you are not in that list, you
|
||||||
|
are not running, whatever the page looks like.
|
||||||
|
|
||||||
|
## What to do next
|
||||||
|
|
||||||
|
You have a module. Now break it on purpose, once each, and watch what the panel
|
||||||
|
says:
|
||||||
|
|
||||||
|
- Add a prefix to `module.json`'s `mounts` and do not register it. → stage
|
||||||
|
`register`, *"declared public/extra but never registered it"*. What you declared
|
||||||
|
and what you registered must match, in both directions.
|
||||||
|
- Rename one of your tables so it no longer starts with your id. → stage `schema`,
|
||||||
|
at **load** time, before anything is mounted: your routes answer `404`.
|
||||||
|
- Throw inside `onBoot`. → after mounting, so the same route answers `503` with
|
||||||
|
*"Module unavailable"* instead of vanishing.
|
||||||
|
|
||||||
|
Those are the three outcomes above, and the messages are what this core actually
|
||||||
|
prints for them — they were run to write this paragraph rather than predicted.
|
||||||
|
|
||||||
|
Twenty minutes of that is worth more than any chapter, because every one of those
|
||||||
|
failures is one you will cause accidentally later, and you will recognise it.
|
||||||
|
|
||||||
|
Then read [chapter 2](02-website-module.md), which is the same module explained —
|
||||||
|
what `ctx` hands you and why it is handed rather than imported, what each
|
||||||
|
`register*` call is for, why the client half is built the way it is, and what a
|
||||||
|
module must never do.
|
||||||
|
|
||||||
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
|
[renamecheck]: ../scripts/checkRenameSites.js
|
||||||
487
book/02-website-module.md
Normal file
487
book/02-website-module.md
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
# 2. The website module
|
||||||
|
|
||||||
|
The module you built in [chapter 1](01-first-module.md), explained. This is the
|
||||||
|
longest chapter in the book because the website module is most of the work, and
|
||||||
|
because almost every part of it is shaped by a constraint that is invisible until
|
||||||
|
you hit it.
|
||||||
|
|
||||||
|
Nothing here is normative. [`MODULE_API.md`][api] is the contract; where this
|
||||||
|
chapter and the contract disagree, the contract is right and this chapter has a
|
||||||
|
bug. What is here is the reasoning — which is exactly what a contract cannot carry
|
||||||
|
without becoming unreadable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape of the whole thing
|
||||||
|
|
||||||
|
A module is a directory core reads at boot. Core loads it, hands it two objects,
|
||||||
|
and takes back whatever it registers.
|
||||||
|
|
||||||
|
```
|
||||||
|
core boots (its own schema and seed have already run)
|
||||||
|
└─ scans modules/*/module.json
|
||||||
|
└─ validates yours ← nothing mounted yet: a failure here leaves
|
||||||
|
│ nothing of yours on the URL surface at all
|
||||||
|
└─ require(server entry)
|
||||||
|
└─ register(ctx, api) ← your one synchronous handshake
|
||||||
|
└─ second pass: mounts everything that survived
|
||||||
|
└─ replays your schema fragment
|
||||||
|
└─ onBoot(ctx) ← the first moment a database exists
|
||||||
|
└─ HTTP listener binds
|
||||||
|
```
|
||||||
|
|
||||||
|
Two properties of that sequence explain most of the rules that follow.
|
||||||
|
|
||||||
|
**It is synchronous and it happens during `require`.** Core's route-manifest
|
||||||
|
generator and its OpenAPI generator both require the app with the database pool
|
||||||
|
pointed at a dead port — that is how they introspect a real Express app without a
|
||||||
|
database. So `register()` may not `await` and may not query. A module that did
|
||||||
|
would hang both build tools, and the symptom would be a CI job that never
|
||||||
|
finishes rather than an error anyone can read.
|
||||||
|
|
||||||
|
**Mounting is a second pass.** Every module is validated before any module is
|
||||||
|
mounted. If mounting happened inside the scan loop, the first module's layers
|
||||||
|
would be sitting on the tier router while the second was validated —
|
||||||
|
indistinguishable from core's own — and the second would be told it collided with
|
||||||
|
*core*, naming the wrong culprit. You will never see this; it is why the failure
|
||||||
|
messages you do see are trustworthy.
|
||||||
|
|
||||||
|
## `module.json`
|
||||||
|
|
||||||
|
Every field is documented in [§2.1][api]. Three of them decide whether your module
|
||||||
|
loads at all.
|
||||||
|
|
||||||
|
**`id`** is the directory core loads you from, the key of your database row, the
|
||||||
|
URL segment your pages hang under, and the required prefix of every table you
|
||||||
|
create. It must equal its own directory name — a module renamed by copying it to a
|
||||||
|
different directory is rejected rather than quietly mounted under a name nothing
|
||||||
|
else agrees with.
|
||||||
|
|
||||||
|
**`coreApi`** is a semver range against core's `MODULE_API_VERSION`. Set it to the
|
||||||
|
version you developed against and let it drift upward deliberately. This is the
|
||||||
|
one number that decides whether a module written today loads against a core
|
||||||
|
shipped next year, and a range that is too loose does not fail — it half-works.
|
||||||
|
|
||||||
|
**`mounts`** declares every prefix you will register, per tier. **The loader
|
||||||
|
compares it with what you actually register and rejects a mismatch in both
|
||||||
|
directions.** A prefix you declared and never registered fails just as loudly as a
|
||||||
|
route you registered without declaring. That is the point: the file is a statement
|
||||||
|
of your URL surface that cannot rot, because it is checked against reality at
|
||||||
|
every boot.
|
||||||
|
|
||||||
|
**Choosing prefixes is the part to slow down on.** They share one namespace with
|
||||||
|
core's own, so `/status` is not available to you — and the loader's collision
|
||||||
|
probe cannot see all of core's, because several of core's endpoints are mounted at
|
||||||
|
the tier root rather than under a prefix of their own. `template/server/index.js`
|
||||||
|
carries the current list of what core answers on the public tier in a comment
|
||||||
|
beside the registration. Read it before you choose, and choose a noun from your
|
||||||
|
own domain rather than a generic one.
|
||||||
|
|
||||||
|
`capabilities` is the opposite kind of field: opaque strings core never
|
||||||
|
interprets, published by `GET /api/v1/public/modules` while you are `started`, so
|
||||||
|
that a client — the SPA, the Android app — can feature-detect you. Two modules may
|
||||||
|
declare the same one. A client must treat an unknown capability as absent, and must
|
||||||
|
never infer a URL from one.
|
||||||
|
|
||||||
|
## The server entry point
|
||||||
|
|
||||||
|
One exported function, called once: `register(ctx, api)`. `ctx` is what core hands
|
||||||
|
you; `api` is what you hand back. Read `template/server/index.js` — it is short,
|
||||||
|
and every comment in it is load-bearing.
|
||||||
|
|
||||||
|
### Why `ctx` is handed over rather than imported
|
||||||
|
|
||||||
|
Your module lives at `<website>/modules/<id>/`, outside core's `server/`. Node's
|
||||||
|
resolver walks *up* from a file looking for `node_modules`, so it never reaches
|
||||||
|
core's — and `require('express')` from inside a module simply fails.
|
||||||
|
|
||||||
|
That is the mechanical reason, and it is the shallow one. The real reason is that
|
||||||
|
there is exactly one of certain things in the process and core owns them: one
|
||||||
|
express, so there is one `Router` prototype; one database pool; one logger; one
|
||||||
|
session reader. A second express resolved from your own dependencies would work
|
||||||
|
for about a week and then produce a routing bug nobody can reproduce.
|
||||||
|
|
||||||
|
So the rule generalises past the two obvious cases: **anything shared between core
|
||||||
|
and a module is owned by core and handed over, never resolved by the module.** On
|
||||||
|
the server that is `express` and `express-validator`; on the client it is React,
|
||||||
|
`react-dom`, `react-router-dom` and the JSX runtime. Both halves of the system
|
||||||
|
have one mechanism for it, and it is the same rule twice.
|
||||||
|
|
||||||
|
[§2.3][api] lists every member of `ctx`. It is a curated list, not core's
|
||||||
|
internals: `ctx.auth` is one function rather than core's whole auth facade,
|
||||||
|
because minting sessions is core's job and a module that needs an identity needs
|
||||||
|
to *read* one. `ctx.settings` is three functions rather than a settings model with
|
||||||
|
two dozen. Expect the narrowing, and expect to occasionally want something that is
|
||||||
|
not there — that is a conversation about a minor version bump, not a reason to
|
||||||
|
reach around it.
|
||||||
|
|
||||||
|
### The lazy-accessor pattern, and the require order it forces
|
||||||
|
|
||||||
|
`ctx` exists only from the moment `register()` is called. But the code underneath
|
||||||
|
— models, controllers, routers — is ordinary Node that requires its dependencies
|
||||||
|
at file scope, and *that* runs before `register()` does.
|
||||||
|
|
||||||
|
`template/server/core.js` is what makes both true at once: every member is an
|
||||||
|
accessor that resolves `ctx` **when it is called**, so a model can write
|
||||||
|
`const { query } = require('../../core')` at the top of the file, exactly as
|
||||||
|
ordinary code does.
|
||||||
|
|
||||||
|
Two consequences, and both have cost this project time:
|
||||||
|
|
||||||
|
**Require order is load-bearing.** A router writes `const express = core.express`
|
||||||
|
at *its* file scope, and that runs the moment the router is required. So
|
||||||
|
`core.init(ctx)` has to happen before the first `require` of anything under
|
||||||
|
`router/`. This is why `template/server/index.js` requires its routers *inside*
|
||||||
|
the register function instead of at the top of the file. Hoist them and the module
|
||||||
|
breaks with an error about a missing `ctx`, thrown from a file that never mentions
|
||||||
|
one.
|
||||||
|
|
||||||
|
**Never destructure a getter at init time.** Core is free to hand over an accessor
|
||||||
|
rather than a value — `ctx.site.baseUrl` is one — and a value captured once at
|
||||||
|
startup is a value that cannot change afterwards.
|
||||||
|
|
||||||
|
`template/server/core.js` is also deliberately a *narrowing*: it re-exports only
|
||||||
|
what the module actually uses. Copy that discipline. It makes the file an honest
|
||||||
|
statement of your dependencies, and it makes a test double for it — see
|
||||||
|
`template/server/test/_fakes.js` — a complete one rather than a guess.
|
||||||
|
|
||||||
|
## What you register
|
||||||
|
|
||||||
|
Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
|
||||||
|
is not their signatures but the model behind them.
|
||||||
|
|
||||||
|
**Every call stages; nothing is committed until your whole module is known good.**
|
||||||
|
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||||
|
own stack trace. Whether a *name* is taken can only be answered once your batch is
|
||||||
|
complete, and is checked when the loader commits it. So a module that registers two
|
||||||
|
notification streams and then throws leaves nothing behind. That matters more than
|
||||||
|
it sounds: a half-registered catalog is a stream a user can subscribe to and
|
||||||
|
nothing will ever publish to, which is worse than a missing one because it looks
|
||||||
|
like it works.
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
`api.registerRoutes({ public, admin, player })` — one router per prefix per tier.
|
||||||
|
|
||||||
|
**The tier gate is already applied.** A router registered under `admin` sits
|
||||||
|
behind core's own `noindex, isLoggedIn, requireRole(...)`; under `player`, behind
|
||||||
|
`noindex, requireAuth`; under `public`, behind nothing, by design. You add
|
||||||
|
per-route gates on top of that and you never re-implement the tier gate. A module
|
||||||
|
cannot supply its own auth wrapper, and that restriction is one of the few places
|
||||||
|
the boundary is genuinely load-bearing rather than organisational: the server's
|
||||||
|
route table and the client's sidebar have to agree about who may see what, and
|
||||||
|
they only do if one thing decides.
|
||||||
|
|
||||||
|
Your router is mounted *inside* the tier router, so it structurally cannot reach
|
||||||
|
above its own prefix. This is not enforcement by review; there is no path
|
||||||
|
expressible from inside your router that escapes it.
|
||||||
|
|
||||||
|
### Extension slots
|
||||||
|
|
||||||
|
Sometimes what you have to add is not a page of your own but a section of core's.
|
||||||
|
An operator looking at a user in the admin panel wants that user's characters
|
||||||
|
right there, not on a separate screen.
|
||||||
|
|
||||||
|
`api.registerExtension(slot, router)` mounts your routes under a core resource,
|
||||||
|
and its client twin renders your component inside a core page. **Only core may
|
||||||
|
declare a slot; a module may only fill one**, and one module per slot.
|
||||||
|
|
||||||
|
The naming rule is worth internalising, because it is what keeps a game-agnostic
|
||||||
|
core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
|
||||||
|
`site.footer.status` is "the status-ish spot in the footer" — not a declaration
|
||||||
|
that core knows what a game server's status is. Core supplies the position and the
|
||||||
|
styling; the module owns the label, the target, the data, and whether it renders
|
||||||
|
anything at all. The moment core types a slot by its content, it has re-acquired
|
||||||
|
the semantics the module system exists to remove.
|
||||||
|
|
||||||
|
### Notification streams, announce legs, post hooks
|
||||||
|
|
||||||
|
Three registries for three genuinely different things, and the distinctions are
|
||||||
|
easy to get wrong:
|
||||||
|
|
||||||
|
- **A notification stream** is a subscribable channel. You register the catalog
|
||||||
|
entry — id, label, whether it is personal, whether it needs a linked game
|
||||||
|
account — and core uses it for the subscribe endpoint and its gates. You publish
|
||||||
|
to it yourself with `ctx.push.publish`. Core never maps your events to your
|
||||||
|
streams; you have already resolved the id, and it follows that the safety rule
|
||||||
|
about which of your events may reach a *public* stream lives in your module too
|
||||||
|
— which is right, because the event kinds, the stream list and the filter are
|
||||||
|
then one file that moves together.
|
||||||
|
- **An announce leg** is one-shot delivery with retry. Core's CMS publishes a post,
|
||||||
|
every registered leg tries to deliver it somewhere, and your `classify` maps your
|
||||||
|
own result to `done` / `retry` / `terminal`. A leg that throws is caught,
|
||||||
|
classified as a retry, and never blocks another leg.
|
||||||
|
- **A post hook** maintains idempotent state, runs on delete as well as save, and
|
||||||
|
refreshes silently on an edit.
|
||||||
|
|
||||||
|
The last two fire on the same transition and are deliberately not one call. A leg
|
||||||
|
that must not be retried and a `classify` that means nothing would be the cost of
|
||||||
|
merging them.
|
||||||
|
|
||||||
|
Every hook is awaited and none may throw past core: a subscriber's failure costs
|
||||||
|
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
|
||||||
|
somebody's blog post edit would be a worse bug than a stale mirror.
|
||||||
|
|
||||||
|
### The lifecycle hooks
|
||||||
|
|
||||||
|
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and
|
||||||
|
**before the HTTP listener binds**. It is the first moment a database exists, so
|
||||||
|
it is where everything that needs one goes: warming a cache, backfilling,
|
||||||
|
connecting to your sidecar.
|
||||||
|
|
||||||
|
**`onBoot` has no timeout, deliberately.** A slow boot delays the listener, and
|
||||||
|
that is the guarantee rather than a problem to be timed out — a module that must
|
||||||
|
not serve traffic before it has warmed up gets exactly that. If your `onBoot`
|
||||||
|
throws, you are `startup_failed`: your routes stay mounted and answer `503`, and
|
||||||
|
the site comes up without you.
|
||||||
|
|
||||||
|
`api.onShutdown(fn)` runs while core's pool, push dispatcher and event fan-out are
|
||||||
|
all still open, because flushing through them is the only thing it is for. It has
|
||||||
|
a five-second budget and is abandoned past it — the process is exiting anyway, and
|
||||||
|
the alternative is a host where stopping the service waits for a kill.
|
||||||
|
|
||||||
|
A module whose `onBoot` threw gets **no** `onShutdown`. It is part-way through a
|
||||||
|
warm-up it never finished, and handing it a half-built world to tear down is worse
|
||||||
|
than not closing cleanly. A module with no hooks at all still reaches `started`:
|
||||||
|
having nothing to warm up is not the same as never having started.
|
||||||
|
|
||||||
|
## The schema fragment
|
||||||
|
|
||||||
|
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
|
||||||
|
boot**, statement by statement, right after core's own schema.
|
||||||
|
|
||||||
|
**There is no migration runner anywhere in this project, and that is a decision
|
||||||
|
rather than an omission.** Core's own schema is one idempotent file replayed the
|
||||||
|
same way. What you get in exchange is that a module's schema is a single readable
|
||||||
|
statement of what its tables are, with no ordering history to reconstruct and no
|
||||||
|
migration table to get out of step with the tables themselves.
|
||||||
|
|
||||||
|
What it costs you is that **changing a table is an `ALTER`, never an edit to its
|
||||||
|
`CREATE`.** `CREATE TABLE IF NOT EXISTS` no-ops against an existing table, so an
|
||||||
|
edited column definition lands on fresh installs only — and your development
|
||||||
|
database is usually the fresh one, which is what makes this bite six months later
|
||||||
|
on somebody else's instance. Add the column with
|
||||||
|
`ALTER TABLE … ADD COLUMN IF NOT EXISTS`, leave the `CREATE` alone, and both paths
|
||||||
|
converge.
|
||||||
|
|
||||||
|
Two rules the loader enforces before your module is mounted at all:
|
||||||
|
|
||||||
|
**Leading verbs are an allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Not a
|
||||||
|
`DROP` denylist — because the file is replayed at every boot, `TRUNCATE` and
|
||||||
|
`DELETE` would empty a table at every restart and `RENAME` would fail at the
|
||||||
|
second one. A denylist only ever bans what somebody thought of.
|
||||||
|
|
||||||
|
**Table names are namespaced `<id>_` and collision-checked** against core's tables
|
||||||
|
and every other module's. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on
|
||||||
|
the same grounds as the rest: it succeeds exactly once and fails every boot after,
|
||||||
|
which presents to an operator as a module that broke on restart.
|
||||||
|
|
||||||
|
Both are checked by *reading the file*, before anything mounts, and that split is
|
||||||
|
the design: everything knowable without a database costs you the mount, so a
|
||||||
|
rule-breaking fragment never half-applies; what only a database can answer — an
|
||||||
|
unknown column type, a bad foreign key — happens later and answers `503`.
|
||||||
|
|
||||||
|
`purge.sql` is the destructive counterpart, and it is required whenever you ship a
|
||||||
|
schema. It runs **only** when an operator explicitly purges you, never on
|
||||||
|
uninstall. A module that can create tables and cannot drop them leaves an operator
|
||||||
|
with orphaned data and no supported way to remove it.
|
||||||
|
|
||||||
|
One more thing about a file that replays: **a guard and the statement it guards
|
||||||
|
must live in the same file.** If you write a one-shot data fix conditioned on a
|
||||||
|
marker, put both the marker and the fix in your own fragment. Core's schema
|
||||||
|
replays in full before any module's, so a marker core writes has already been
|
||||||
|
written by the time your guard reads it — a real defect this project shipped and
|
||||||
|
did not notice, because it is latent until the day someone installs on an older
|
||||||
|
version.
|
||||||
|
|
||||||
|
## The client half
|
||||||
|
|
||||||
|
Your client half is a **prebuilt ES module**. Core serves it from your module's
|
||||||
|
directory as a same-origin script and injects a `<script type="module" src>` for
|
||||||
|
it before `</body>`. There is no bundling step on the operator's machine, ever.
|
||||||
|
|
||||||
|
### One React, and core owns it
|
||||||
|
|
||||||
|
`window.__rg` is core's published set of shared dependencies plus the registry,
|
||||||
|
the UI kit and a request primitive ([§3.2][api]). Your build does not bundle React
|
||||||
|
— it aliases every shared specifier to a two-line shim that re-exports from that
|
||||||
|
global.
|
||||||
|
|
||||||
|
The failure this prevents is specific and nasty: a second React in the page is a
|
||||||
|
second hook dispatcher, so your component throws about an invalid hook call
|
||||||
|
somewhere unrelated to the mistake, in a page that otherwise loads fine.
|
||||||
|
|
||||||
|
`template/client/vite.config.js` is the whole mechanism, and its comments are the
|
||||||
|
most valuable prose in the template. Three things there were wrong first and are
|
||||||
|
now contract:
|
||||||
|
|
||||||
|
- **The aliases use the array form with anchored regexes.** Vite's object form does
|
||||||
|
prefix matching, so a `react` key also silently rewrites `react/jsx-runtime` — to
|
||||||
|
the wrong shim.
|
||||||
|
- **The aliases replace `external`; they do not accompany it.** Rollup asks
|
||||||
|
`external` *before* Vite's alias resolver runs, so a specifier in both is never
|
||||||
|
aliased and the chunk ships bare `import 'react'` specifiers. A browser cannot
|
||||||
|
resolve those without an import map, and core's `script-src 'self'` forbids the
|
||||||
|
inline script an import map has to be. The first real module shipped exactly that
|
||||||
|
chunk, from a clean green build.
|
||||||
|
- **The build guard hooks `transform`, not `load`**, and its forbidden-package list
|
||||||
|
is stated rather than derived from the alias list. `load` is first-wins, so
|
||||||
|
written against it the guard sat in the build doing nothing. Deriving the list
|
||||||
|
means deleting an alias also deletes the guard against what that alias prevented
|
||||||
|
— precisely when it is needed.
|
||||||
|
|
||||||
|
`template/client/scripts/checkExternals.js` asks the **built chunk** whether any
|
||||||
|
bare specifier survived. That question cannot be asked of source:
|
||||||
|
`import { useState } from 'react'` is correct in every file, and which React it
|
||||||
|
becomes is decided by the build config. Run it in your CI.
|
||||||
|
|
||||||
|
### Registration happens at evaluation time
|
||||||
|
|
||||||
|
`template/client/src/entry.jsx` registers your routes and nav rows with plain
|
||||||
|
top-level calls. There is no subscription and no late registration: module chunks
|
||||||
|
are deferred scripts that execute after core's bundle and before core's first
|
||||||
|
render, so everything you register is present in that first render.
|
||||||
|
|
||||||
|
**So every page is a static import, and lazy-loading your routes is the one thing
|
||||||
|
this seam cannot have.** A module that registered asynchronously would register
|
||||||
|
after the route table had been read, and the symptom is a page that redirects home
|
||||||
|
with nothing logged anywhere — indistinguishable from a module that failed to
|
||||||
|
load.
|
||||||
|
|
||||||
|
That timing is also where this project's most instructive client-side bug lived.
|
||||||
|
Core's own render used to wait on `document.readyState === 'loading'`; but a
|
||||||
|
deferred script runs *after* the document is parsed, so `readyState` is already
|
||||||
|
`'interactive'`, and core mounted immediately — before any module chunk had
|
||||||
|
evaluated. Every unit test passed. It was found by loading a real chunk in a real
|
||||||
|
browser, which is the only place it was visible.
|
||||||
|
|
||||||
|
### Nav, and what a registered row becomes
|
||||||
|
|
||||||
|
`registry.registerNav` interleaves your rows into **core's** navigation groups,
|
||||||
|
and from that moment your row is an ordinary row: an operator can reorder,
|
||||||
|
relabel or hide it from the nav editor exactly as they can core's. That works
|
||||||
|
because the interleave happens *before* the admin override merge — the override
|
||||||
|
layer is keyed by a row's `to`, and it drops keys its base does not declare, so a
|
||||||
|
row appended afterwards would be unorderable, unrelabellable and unhideable.
|
||||||
|
|
||||||
|
Three details worth knowing before you need them:
|
||||||
|
|
||||||
|
- **A row with no `order` appends after core's rows** rather than defaulting to
|
||||||
|
zero. "I didn't ask for a position" must not mean "put me first".
|
||||||
|
- **An unknown `group` name appends a new group** rather than dropping your row.
|
||||||
|
- **`icon` has no core fallback.** Public header rows carry no icons, so a public
|
||||||
|
row needs none; an admin or player row without one is the only glyph-less row in
|
||||||
|
its sidebar, which reads as breakage. Match the nav you land in rather than
|
||||||
|
shipping one glyph for everywhere.
|
||||||
|
|
||||||
|
`registerFeatureProvider` is how a row can be conditional: core keeps a generic
|
||||||
|
flag context and you supply the hook that fills your namespace. **The namespace
|
||||||
|
comes from the registration, not from parsing the string**, so a typo'd namespace
|
||||||
|
is not a thing that can exist.
|
||||||
|
|
||||||
|
**Everything in this layer fails open.** No provider, an answer still in flight, a
|
||||||
|
malformed row — all of them show the link. This is presentation and the server is
|
||||||
|
the gate: a UI mistake that hides a page from someone entitled to it is worse in
|
||||||
|
every case than one that shows a link which then answers `403`.
|
||||||
|
|
||||||
|
### The UI kit
|
||||||
|
|
||||||
|
Core publishes a small set of components and hooks on `window.__rg.ui`
|
||||||
|
([§3.4][api] is the list): the public layout, a page header, the loading, error
|
||||||
|
and empty states, the async hook every data page uses, and read-only access to the
|
||||||
|
session and site settings. Enough to build a page that looks like the site it is
|
||||||
|
installed in, and nothing else.
|
||||||
|
|
||||||
|
**It is curated and closed, not a re-export of core's component library.** Adding
|
||||||
|
to it is a minor version bump; changing a kit component's props is a major one.
|
||||||
|
That is a real constraint on core, and it is the price of the boundary being worth
|
||||||
|
anything.
|
||||||
|
|
||||||
|
So: when you want an eighth thing, bundle it. Tables, chips, tabs, editors — those
|
||||||
|
are yours, and your chunk carries them. Reaching into core's tree for a component
|
||||||
|
is the one thing that is never available, and `template/server/scripts/checkImports.js`
|
||||||
|
exists to make sure a moment of weakness fails the build instead of shipping.
|
||||||
|
|
||||||
|
One thing that surprises everyone once: **core's public pages render the public
|
||||||
|
layout themselves** — it is a component, not a route wrapper. A public page of
|
||||||
|
yours that does not use `ui.PublicLayout` renders bare, with no site chrome. That
|
||||||
|
is the contract working as intended, not a bug to hunt.
|
||||||
|
|
||||||
|
## The OpenAPI fragment
|
||||||
|
|
||||||
|
Every module that registers routes ships `swagger-fragment.json` in its bundle
|
||||||
|
root, and core merges the fragments of started modules into its own API document.
|
||||||
|
The filename is fixed rather than declared, like `module.json` itself.
|
||||||
|
|
||||||
|
**Generate it from your own registrations** — `template/server/scripts/swaggerFragment.js`
|
||||||
|
is a working generator. It runs your `register()` against a recording `api` and
|
||||||
|
resolves each router back to its source file, so a mount prefix exists in exactly
|
||||||
|
one place rather than being retyped into a generator that then drifts.
|
||||||
|
|
||||||
|
Two rules and one trap:
|
||||||
|
|
||||||
|
**Namespace what you define; reference core's shared schemas by core's name.** A
|
||||||
|
schema you invented gets your prefix. `Error` and `ValidationError` are core's:
|
||||||
|
reference them and do not redefine them. They resolve in the merged document,
|
||||||
|
which is the only place both halves exist — and shipping your own copy is a
|
||||||
|
collision core drops, arriving at the same result the expensive way.
|
||||||
|
|
||||||
|
**Commit the generated file and check it is current in CI.** Core merges it
|
||||||
|
verbatim, so a stale fragment documents a URL surface you do not serve, and
|
||||||
|
nothing at runtime will ever say so.
|
||||||
|
|
||||||
|
The trap: **swagger-autogen reports a broken annotation and then prints
|
||||||
|
`Success`.** It logs a syntax error, drops that annotation, and exits zero. The
|
||||||
|
template's generator captures those diagnostics and fails on them — keep that.
|
||||||
|
Two ways an annotation breaks are an object literal one brace short, and a `"` or
|
||||||
|
a backtick inside a single-quoted description. A third is only visible in a
|
||||||
|
rendered page: an escaped apostrophe (`\'`) survives literally into the output,
|
||||||
|
because the annotation is never evaluated as JavaScript. Use a typographic `’`.
|
||||||
|
|
||||||
|
## Packaging and release
|
||||||
|
|
||||||
|
`template/.gitea/workflows/release.yml` (and its GitHub twin) is a working release
|
||||||
|
pipeline. Copy it to the root of your module's repository — a workflow file is
|
||||||
|
only read from a repository root, which is why it does nothing where it sits
|
||||||
|
inside the kit.
|
||||||
|
|
||||||
|
**A release is not source.** It is the directory core's loader expects to find at
|
||||||
|
`modules/<id>/`, already assembled: the prebuilt chunk, your runtime dependencies
|
||||||
|
installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball,
|
||||||
|
verifies it against the `sha256` in the install manifest, and unpacks it. Nothing
|
||||||
|
runs `npm` on the way.
|
||||||
|
|
||||||
|
**The version is declared in `module.json`, not computed from commit subjects.**
|
||||||
|
You already have one authoritative version — it is what core records and what the
|
||||||
|
admin panel shows — and two sources for one number is how they drift. A release
|
||||||
|
happens when a push to `main` leaves a version that has no release yet, so
|
||||||
|
bumping is an ordinary reviewed change and publishing is the workflow's business.
|
||||||
|
The workflow tags and publishes and never writes to a branch, so a protected
|
||||||
|
`main` needs no exception.
|
||||||
|
|
||||||
|
The install manifest is the JSON your workflow publishes beside the tarball. Its
|
||||||
|
URL is what an operator pastes into Admin → Modules, and the host it lives on has
|
||||||
|
to be on that core's allowlist — an operator-controlled setting, so tell your users
|
||||||
|
where you publish.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
[§2.7][api] is the list. Each item has a failure behind it:
|
||||||
|
|
||||||
|
| Rule | What it prevents |
|
||||||
|
| --- | --- |
|
||||||
|
| No `require` outside your own directory (bar built-ins and your own dependencies) | Two copies of a thing there must be one of; and a module that survives a core refactor only by luck. |
|
||||||
|
| Do not mutate `ctx`, `req.user`, or anything core handed you | A module changing another module's world, invisibly. |
|
||||||
|
| No app-level middleware, no Express error handler | One module deciding how every other module's errors are rendered. |
|
||||||
|
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. |
|
||||||
|
| No `process.exit`, no signal handlers, no listeners | A module taking the site down, or racing core's shutdown. |
|
||||||
|
| Write only inside your module root and the upload directory | A module that cannot be uninstalled cleanly. |
|
||||||
|
| **Never open a connection to a game server from the website process** | The whole of [chapter 3](03-sidecar.md). |
|
||||||
|
|
||||||
|
That last one is newer than the others and is the reason this kit is three
|
||||||
|
chapters and not one. It is also the only rule in the list with **no CI behind
|
||||||
|
it** — an outbound socket is not statically detectable the way an internal
|
||||||
|
`require` is — so it is enforced in review and by understanding it, which is what
|
||||||
|
the next chapter is for.
|
||||||
|
|
||||||
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
185
book/03-sidecar.md
Normal file
185
book/03-sidecar.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# 3. The sidecar
|
||||||
|
|
||||||
|
Your module may not open a connection to a game server. Not a game socket, not an
|
||||||
|
RCON channel, not a query port, not an engine's admin API. It talks to a
|
||||||
|
**sidecar**, and the sidecar talks to the game.
|
||||||
|
|
||||||
|
That is a rule in the contract ([`MODULE_API.md`][api] §2.7, as of
|
||||||
|
`MODULE_API_VERSION` 1.4.0) rather than advice this kit is offering. It is also
|
||||||
|
the rule most likely to feel like ceremony when your game already exposes a
|
||||||
|
perfectly good remote-control protocol and your module is fifty lines from
|
||||||
|
working. This chapter is why it is not.
|
||||||
|
|
||||||
|
**It is the one rule in that list with no CI behind it.** An outbound socket is
|
||||||
|
not statically detectable the way an internal `require` is. So it is enforced by
|
||||||
|
review, and by you having read this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a sidecar is
|
||||||
|
|
||||||
|
A small, separate service that owns the connection to your game, keeps a durable
|
||||||
|
copy of what the game said, and exposes an HTTP + WebSocket API that the website's
|
||||||
|
backend reads.
|
||||||
|
|
||||||
|
```
|
||||||
|
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
||||||
|
│ (your module)
|
||||||
|
▼
|
||||||
|
its own store
|
||||||
|
```
|
||||||
|
|
||||||
|
Three properties, and each is doing real work.
|
||||||
|
|
||||||
|
## 1. The game dials out; the sidecar listens
|
||||||
|
|
||||||
|
The sidecar binds the listener. The game connects **to it**, and the game opens no
|
||||||
|
listening port at all.
|
||||||
|
|
||||||
|
This is the inversion people find surprising and it is the load-bearing one. The
|
||||||
|
website is the internet-facing process; your game is not, and must not become
|
||||||
|
reachable because a web app knows how to reach it. A module holding the connection
|
||||||
|
makes the public web app the thing the game trusts, and puts the game's address
|
||||||
|
inside the same process as every request from the internet.
|
||||||
|
|
||||||
|
In `uo-link`, that listener is `sidecar/src/shard.rs` — `serve` binds a loopback
|
||||||
|
address and accepts shard connections forever, handling one at a time and looping
|
||||||
|
back to accept the next. The game plugin does the dialling, with its own backoff.
|
||||||
|
Loopback, in that deployment, because the sidecar runs on the game host: the only
|
||||||
|
socket the game speaks over never leaves the machine.
|
||||||
|
|
||||||
|
Only the website's backend talks to the sidecar, and it authenticates. `uo-link`'s
|
||||||
|
`web.rs` requires a token on every request — accepted as a bearer header, an API-key
|
||||||
|
header, or a query parameter, that last one only because browser WebSocket clients
|
||||||
|
cannot set handshake headers — and compares it in constant time. Auth is always on;
|
||||||
|
there is no unauthenticated mode to accidentally deploy.
|
||||||
|
|
||||||
|
## 2. Persist before you forward
|
||||||
|
|
||||||
|
This is the property that makes a sidecar worth having even when your game is
|
||||||
|
already remote-controllable, and the one a message-passing diagram never conveys.
|
||||||
|
|
||||||
|
**The sidecar owns the durable copy.** It writes what the game said into its own
|
||||||
|
store, and answers reads from that store — not by round-tripping the game.
|
||||||
|
|
||||||
|
`uo-link` does this in `sidecar/src/store.rs`: SQLite, holding event history, the
|
||||||
|
latest snapshot of every board the site renders, the economy series and the
|
||||||
|
published ruleset. `insert_event` is called for every live event as it is
|
||||||
|
broadcast; the `upsert_*` functions keep one current row per board; the REST read
|
||||||
|
paths query that store.
|
||||||
|
|
||||||
|
What it buys, concretely:
|
||||||
|
|
||||||
|
- **A website that is down, restarting or mid-deploy loses nothing.** Events that
|
||||||
|
arrive while nothing is listening are still recorded. Without a store they are
|
||||||
|
simply gone, and your first deploy of the week is a hole in your data.
|
||||||
|
- **A page renders the last thing the game said rather than going blank.** A rules
|
||||||
|
page that empties itself because the game restarted is worse than a stale one.
|
||||||
|
- **The live feed is allowed to be lossy.** `uo-link`'s WebSocket fan-out drops
|
||||||
|
frames for a consumer that has fallen behind and logs that it did — deliberately,
|
||||||
|
because durability is the store's job and not the socket's. A feed that instead
|
||||||
|
buffered without limit for a slow client would eventually take the sidecar down.
|
||||||
|
|
||||||
|
That last point is the reasoning to carry into your own design. Once the store is
|
||||||
|
authoritative, every other component is allowed to be best-effort, and each of them
|
||||||
|
gets simpler. Skip the store and you find yourself trying to make a socket reliable,
|
||||||
|
which is the hard version of this problem.
|
||||||
|
|
||||||
|
A module cannot do any of this from inside the website process. There is nowhere to
|
||||||
|
put what arrives while the website is not running, because the website not running
|
||||||
|
is exactly the case.
|
||||||
|
|
||||||
|
## 3. The wire is a versioned contract, not a build dependency
|
||||||
|
|
||||||
|
Your sidecar and your module ship separately, on different schedules, to hosts you
|
||||||
|
do not control. So the wire between them is a compatibility contract with a version
|
||||||
|
on it.
|
||||||
|
|
||||||
|
`uo-link` declares `PROTOCOL_VERSION` in `sidecar/src/main.rs`, stamps
|
||||||
|
`X-UOLink-Version` onto every response from `web.rs`, and **refuses a request whose
|
||||||
|
declared version does not match** rather than parsing it optimistically. A refusal
|
||||||
|
is a clear failure an operator can act on; a mis-parse is a wrong number on a page
|
||||||
|
with nobody to tell.
|
||||||
|
|
||||||
|
Two habits come with that:
|
||||||
|
|
||||||
|
- **Bump the version in the same change that changes a message shape**, on every
|
||||||
|
side that declares it. In this project a protocol bump has three declaration
|
||||||
|
sites — the sidecar, the game-side overlay's manifest, and the documented spec —
|
||||||
|
and the tooling refuses to pair components that disagree.
|
||||||
|
- **Version the *shape*, not the content.** Adding a new event kind that old
|
||||||
|
consumers ignore is not a break. Changing what a field means is, even when the
|
||||||
|
JSON still parses.
|
||||||
|
|
||||||
|
## The worked example
|
||||||
|
|
||||||
|
`uo-link` is a complete implementation of everything above, and it is small enough
|
||||||
|
to read:
|
||||||
|
|
||||||
|
| File | What it owns |
|
||||||
|
| --- | --- |
|
||||||
|
| `sidecar/src/shard.rs` | The listener the game dials into; one connection at a time, then accept the next. |
|
||||||
|
| `sidecar/src/store.rs` | SQLite: event history, per-board snapshots, the series and the ruleset. |
|
||||||
|
| `sidecar/src/web.rs` | HTTP + WebSocket for the website, the auth middleware, the version header and the lossy live fan-out. |
|
||||||
|
| `sidecar/src/rpc.rs` | Request/reply correlation, so a website read can ask the game a question and match the answer. |
|
||||||
|
| `sidecar/src/config.rs` | The config file, including a token generated on first run rather than defaulted. |
|
||||||
|
|
||||||
|
The protocol it speaks is specified in [`link/PLAN.md`][linkplan] and
|
||||||
|
[`link/INTEGRATION.md`][linkint]. Those are normative for that sidecar; your game
|
||||||
|
is not Ultima Online and your messages will not be its messages. What transfers is
|
||||||
|
the structure — a listener the game dials into, a store written before anything is
|
||||||
|
forwarded, a lossy live feed, an authenticated read API with a version on it.
|
||||||
|
|
||||||
|
## "But my game already speaks a remote-control protocol"
|
||||||
|
|
||||||
|
Then your sidecar is **thin**, not absent.
|
||||||
|
|
||||||
|
Rust — the survival game — is the worked example here, in
|
||||||
|
[`rust-dryrun.md`][dryrun]: a module designed on paper for a game chosen for how
|
||||||
|
little it shares with Ultima Online. Rust ships RCON over WebSocket, so a
|
||||||
|
`rust-link` has no protocol to invent and no game-side plugin to write at all. It
|
||||||
|
keeps:
|
||||||
|
|
||||||
|
- the RCON connection, its credentials and its reconnect loop, **out of an Express
|
||||||
|
process** — where the failure mode is a wedged request handler;
|
||||||
|
- a store, so the site is not blank whenever the game is restarting, which for that
|
||||||
|
genre is a daily scheduled event;
|
||||||
|
- an HTTP + WS API with a version on it, so the module talks to one shape of thing
|
||||||
|
regardless of what the game speaks.
|
||||||
|
|
||||||
|
It drops the bespoke wire protocol and the plugin. That is what "thin" means: less
|
||||||
|
code, not a different architecture.
|
||||||
|
|
||||||
|
That document originally concluded the opposite — "no sidecar, the module dials
|
||||||
|
RCON directly" — and it carries a dated correction saying so, rather than having
|
||||||
|
been quietly rewritten. The value of a dry run is the record of what it found,
|
||||||
|
including where it was overruled.
|
||||||
|
|
||||||
|
## Building yours
|
||||||
|
|
||||||
|
There is no template for a sidecar in this kit; it is your program, in your
|
||||||
|
language, and the surface it must expose is the surface your module reads. What to
|
||||||
|
settle before writing code:
|
||||||
|
|
||||||
|
1. **Which direction does the connection go?** The game dials out. If your game
|
||||||
|
cannot — if it only accepts connections — then your sidecar is the client to the
|
||||||
|
game and the listener for the website, and the rule that stands is the one that
|
||||||
|
matters: the address of the game is known to the sidecar and to nothing else.
|
||||||
|
2. **What is durable?** Everything a page must still render when the game is down.
|
||||||
|
Write it before you forward it.
|
||||||
|
3. **What is a snapshot and what is an event?** They are different storage
|
||||||
|
problems: an event is appended and read back as history, a board is one current
|
||||||
|
row per subject that you overwrite. `uo-link`'s store holds both, and keeping
|
||||||
|
them separate is why a restart does not replay a year of events at a page.
|
||||||
|
4. **What is the version, and where is it declared?** One place, on every response,
|
||||||
|
refused on mismatch.
|
||||||
|
5. **How does the website authenticate?** A token, generated rather than defaulted,
|
||||||
|
always required.
|
||||||
|
|
||||||
|
Then chapter 4, if your game needs code inside it — which is the part where getting
|
||||||
|
it wrong takes the game down rather than the website.
|
||||||
|
|
||||||
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
179
book/04-game-plugin.md
Normal file
179
book/04-game-plugin.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 4. The game-side plugin
|
||||||
|
|
||||||
|
The chapter with the least code and the highest stakes. Everything else in this
|
||||||
|
book fails by showing an operator a broken web page; this part fails by taking the
|
||||||
|
game down while people are playing it.
|
||||||
|
|
||||||
|
If your game already speaks a remote-control protocol, you may not need any of
|
||||||
|
this — see the end of [chapter 3](03-sidecar.md). If it does not, something has to
|
||||||
|
run inside the game and feed your sidecar, and the rules below are what keep that
|
||||||
|
something from being the reason the server froze.
|
||||||
|
|
||||||
|
The worked example is `servuo-plugins`, the Ultima Online shard plugin, whose link
|
||||||
|
layer is one file: `overlay/Scripts/Custom/Bridge/BridgeLink.cs`. It is C# against
|
||||||
|
a specific game engine and none of that transfers. The threading contract at the
|
||||||
|
top of it does, entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one rule: never block the game
|
||||||
|
|
||||||
|
A game server is a loop. Whatever thread runs the world is the thread that must
|
||||||
|
not stop, and every rule in this chapter is a restatement of that.
|
||||||
|
|
||||||
|
**Emitting an event must enqueue and return.** It formats nothing expensive, waits
|
||||||
|
on nothing, and touches no socket. In `BridgeLink.cs`, `Emit` is called from the
|
||||||
|
game's own thread, appends a line to a queue, signals a waiting writer, and
|
||||||
|
returns. A sidecar that is slow, wedged, restarting or entirely absent cannot stall
|
||||||
|
the game, because the game never touches the connection.
|
||||||
|
|
||||||
|
The failure this prevents is not hypothetical, and it is not a small one: a socket
|
||||||
|
write from the world thread against a peer that has stopped reading blocks until
|
||||||
|
the OS buffer drains. That is a frozen game server, caused by a monitoring
|
||||||
|
feature, at exactly the moment something else is already wrong.
|
||||||
|
|
||||||
|
## The queue is bounded, and it drops the oldest
|
||||||
|
|
||||||
|
An unbounded queue in front of an absent consumer is a memory leak with a delay
|
||||||
|
timer on it. So the queue has a cap, and when it is full **the oldest record is
|
||||||
|
dropped and counted**.
|
||||||
|
|
||||||
|
`Emit` bounds first and enqueues second, so the queue can sit transiently one over
|
||||||
|
the cap and never grows without limit. `BridgeLink` exposes counters — sent,
|
||||||
|
dropped, received, connects, write errors, current depth — and those counters are
|
||||||
|
what an operator debugs from later.
|
||||||
|
|
||||||
|
Dropping is correct here, and it is worth being explicit about why: **telemetry is
|
||||||
|
worth less than the game's memory.** If your sidecar has been unreachable for ten
|
||||||
|
minutes, the useful thing is the most recent state of the world, not a
|
||||||
|
ten-minute-old backlog delivered before it. Newest-wins is the honest policy, and
|
||||||
|
"stall the game rather than lose an event" is never the trade to make.
|
||||||
|
|
||||||
|
Where losing events is genuinely unacceptable, the answer is the sidecar's store
|
||||||
|
([chapter 3](03-sidecar.md)), not a bigger queue inside the game.
|
||||||
|
|
||||||
|
## One writer thread owns the socket
|
||||||
|
|
||||||
|
A dedicated thread drains the queue and owns the connection. It connects,
|
||||||
|
reconnects with backoff, and writes.
|
||||||
|
|
||||||
|
**A single writer is also what keeps event ordering intact** — with two, the order
|
||||||
|
events reach the sidecar is the order two threads happened to be scheduled in, and
|
||||||
|
you find out from a board that says a player logged out before they logged in.
|
||||||
|
|
||||||
|
The reconnect loop in `LinkLoop` backs off with a low ceiling — a few seconds,
|
||||||
|
because a loopback reconnect is cheap and a sidecar restart should cost a few
|
||||||
|
seconds of buffering rather than half a minute of blindness. Pick your ceiling from
|
||||||
|
what the connection actually costs, not from a habit borrowed from internet
|
||||||
|
clients.
|
||||||
|
|
||||||
|
One detail there is subtle enough to be worth stealing: `BridgeLink` tags each
|
||||||
|
connection attempt with an **epoch**, so a reader thread from a previous connection
|
||||||
|
cannot tear down the connection that replaced it. Joining a thread can time out;
|
||||||
|
the stale thread's cleanup then runs against whatever is current. If you write a
|
||||||
|
reconnect loop, write it so a late-arriving cleanup from a dead connection is a
|
||||||
|
no-op.
|
||||||
|
|
||||||
|
## Read the world only on the game's thread
|
||||||
|
|
||||||
|
Inbound is the mirror image. A reader thread parses lines off the socket, and then
|
||||||
|
**hands each one to the game's own thread** to act on — `BridgeLink`'s `Dispatch`
|
||||||
|
does it by scheduling a zero-delay callback on the game's timer, which is the
|
||||||
|
engine's supported way in. The reader itself never touches the world's objects.
|
||||||
|
|
||||||
|
Two rules fall out and both are absolute:
|
||||||
|
|
||||||
|
- **Every read of the world happens on the world's thread.** Game engines are
|
||||||
|
overwhelmingly single-threaded about their state, and reading a collection while
|
||||||
|
the loop mutates it is a crash or, worse, a corruption you notice a week later.
|
||||||
|
- **The writer thread only ever sees plain data.** Format your line — a string, a
|
||||||
|
buffer, whatever your wire is — on the game's thread while the objects are safe
|
||||||
|
to read, and hand the finished bytes over. Never hand the writer a live game
|
||||||
|
object to serialise.
|
||||||
|
|
||||||
|
And an error boundary at the seam: a malformed command from the sidecar must never
|
||||||
|
escape into a game code path. `BridgeLink` wraps the inbound handler and logs
|
||||||
|
anything it throws, because the alternative is an exception unwinding somewhere in
|
||||||
|
the engine's main loop.
|
||||||
|
|
||||||
|
## Reconnect, and what to send on connect
|
||||||
|
|
||||||
|
Your sidecar restarts independently of your game. It comes back with an empty
|
||||||
|
picture, and it cannot ask the game for one without an inbound path you may not
|
||||||
|
have built yet.
|
||||||
|
|
||||||
|
So **anything the sidecar needs up front is re-sent on every connect, not once at
|
||||||
|
startup.** In `servuo-plugins` that is an explicit event: the link exposes a
|
||||||
|
"connected" hook that runs on the game thread, and each feature area subscribes to
|
||||||
|
it and re-emits its current state — the hello line, the house registry, the guild
|
||||||
|
and governor boards, the market, the ruleset. A sidecar that has just started is
|
||||||
|
therefore fully populated within one connection, with no negotiation.
|
||||||
|
|
||||||
|
The general form: **for every board your website renders, have exactly one place
|
||||||
|
that can produce its current state, and call it on connect.** If you cannot name
|
||||||
|
that place for some piece of state, your sidecar will eventually be missing it and
|
||||||
|
nobody will know why.
|
||||||
|
|
||||||
|
## Events, snapshots, and the state that has neither
|
||||||
|
|
||||||
|
You will end up emitting two different kinds of thing, and confusing them is a
|
||||||
|
design mistake that shows up as a bad page.
|
||||||
|
|
||||||
|
**An event** is something that happened, at a time: a player logged in, a house
|
||||||
|
fell, a trade completed. Events are appended and read back as history.
|
||||||
|
|
||||||
|
**A snapshot** is the current state of a subject: this board's rows, this guild's
|
||||||
|
membership, the published ruleset. Snapshots overwrite; nobody wants the history of
|
||||||
|
a leaderboard's every intermediate ordering.
|
||||||
|
|
||||||
|
Send both, and be clear at the wire about which a message is — your sidecar's store
|
||||||
|
handles them differently ([chapter 3](03-sidecar.md)), and a snapshot appended as
|
||||||
|
history is a table that grows forever.
|
||||||
|
|
||||||
|
Then there is the state your engine gives you no hook for at all. Player vitals,
|
||||||
|
decay timers, money supply: nothing fires when they change. `servuo-plugins` polls
|
||||||
|
those on the game's own thread with repeating timers, in
|
||||||
|
`overlay/Scripts/Custom/Bridge/BridgeSweeps.cs`, and the comment worth copying is
|
||||||
|
that this is only acceptable **because the cost was measured**. A full pass of all
|
||||||
|
three sweeps is well under a millisecond at that shard's scale. Measure yours
|
||||||
|
before you add a timer to a game loop, and if a sweep is expensive, sample it —
|
||||||
|
never move it off the game thread.
|
||||||
|
|
||||||
|
Two practical notes from that file, both general:
|
||||||
|
|
||||||
|
- **Emit a transition, not a level.** The decay sweep keeps the last known level per
|
||||||
|
house and emits only when one changes, and it takes a silent baseline at startup
|
||||||
|
so a restart does not re-announce every house's current state as news.
|
||||||
|
- **Know when your engine suspends timers.** These do not fire during a world save,
|
||||||
|
so a sweep that would have landed mid-save simply happens a few seconds later.
|
||||||
|
That is fine for all three — but it is fine because someone checked, not by
|
||||||
|
default.
|
||||||
|
|
||||||
|
## A checklist for the plugin you are about to write
|
||||||
|
|
||||||
|
1. The emit path enqueues and returns. Nothing on the game thread touches a socket.
|
||||||
|
2. The queue is bounded and drops the oldest, and something counts the drops.
|
||||||
|
3. One writer thread owns the connection; ordering is therefore intact.
|
||||||
|
4. Reconnect with a bounded backoff; a stale connection's cleanup cannot affect a
|
||||||
|
newer one.
|
||||||
|
5. Inbound lines are marshalled onto the game thread before touching the world, and
|
||||||
|
a handler that throws cannot escape into the engine.
|
||||||
|
6. Every board's current state has exactly one producer, and all of them run on
|
||||||
|
connect.
|
||||||
|
7. Every world read is on the world's thread; the writer sees only plain data.
|
||||||
|
8. Anything polled has had its cost measured against a realistic world.
|
||||||
|
|
||||||
|
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
||||||
|
all — which is the entire point of the arrangement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
That is the book. The three parts are a module core loads, a sidecar that owns the
|
||||||
|
game connection and the durable copy of what it said, and a plugin that feeds the
|
||||||
|
sidecar without ever waiting on it.
|
||||||
|
|
||||||
|
If you got this far and built something, the places you got stuck are the most
|
||||||
|
valuable thing this repo can receive — [tell us][issues], and please say where you
|
||||||
|
left the kit and what you did next.
|
||||||
|
|
||||||
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
117
book/README.md
117
book/README.md
@@ -1,99 +1,40 @@
|
|||||||
# The book
|
# The book
|
||||||
|
|
||||||
Four chapters, in the order the work happens. **None of them are written yet** —
|
Four chapters, in the order the work happens.
|
||||||
this is the outline, landed first so the shape can be argued with before the prose
|
|
||||||
exists. Chapter status is in the table; a chapter that is not there yet is not
|
|
||||||
there yet, rather than a stub that reads like an answer.
|
|
||||||
|
|
||||||
Read [the dry run][dryrun] before any of them.
|
Read [the dry run][dryrun] before any of them — a complete module designed on
|
||||||
|
paper for a second game, and the shortest honest picture of the whole job.
|
||||||
|
|
||||||
| # | Chapter | File | Status |
|
| # | Chapter | What it covers |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | Your first module in twenty minutes | `01-first-module.md` | not written |
|
| 1 | [Your first module in twenty minutes](01-first-module.md) | Copy the template, rename it, build it, install it, see a page. No theory. |
|
||||||
| 2 | The website module | `02-website-module.md` | not written |
|
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
||||||
| 3 | The sidecar | `03-sidecar.md` | not written |
|
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
||||||
| 4 | The game-side plugin | `04-game-plugin.md` | not written |
|
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
||||||
|
|
||||||
They are named but not linked on purpose: a link to a file that does not exist is
|
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
|
||||||
the thing this repo's link check is for, and an outline should not be the first
|
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
|
||||||
thing to fail it.
|
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
|
||||||
|
purpose: those repositories move for their own reasons and a line number in a book
|
||||||
|
is wrong the moment they do.
|
||||||
|
|
||||||
## 1. Your first module in twenty minutes
|
## What is normative, and what is here
|
||||||
|
|
||||||
Copy `template/`, rename it, build it, install it, see a page. No theory. The point
|
Nothing in these chapters is. Where a chapter and one of these disagree, the
|
||||||
is to reach a working module before learning anything, so that everything after it
|
document is right and the chapter has a bug — [say so][issues]:
|
||||||
is a change to something that already runs rather than a step toward something that
|
|
||||||
might.
|
|
||||||
|
|
||||||
- What the pieces of `template/` are, one paragraph each.
|
| Authority | For |
|
||||||
- `module.json`: the fields you must change, and `coreApi`.
|
| --- | --- |
|
||||||
- Building the client chunk. Why a module ships **prebuilt** and an operator never
|
| [`MODULE_API.md`][api] | Everything a module may do. |
|
||||||
builds anything.
|
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||||
- Installing it: the admin panel, the `MODULES` environment variable, or a directory
|
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
||||||
on the volume.
|
|
||||||
- Reading the state your module lands in, and the four ways it can fail to load.
|
|
||||||
|
|
||||||
## 2. The website module
|
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
||||||
|
cost this project time.
|
||||||
The bulk of the kit.
|
|
||||||
|
|
||||||
- **`module.json`** — every field, and which are load-bearing at boot.
|
|
||||||
- **The server entry point.** `register(ctx, api)`; what `ctx` hands you and why
|
|
||||||
each member is handed rather than imported; the lazy-accessor pattern that lets a
|
|
||||||
ported file keep a file-scope `require`, and the require-order rule that comes
|
|
||||||
with it.
|
|
||||||
- **The `register*` calls** — routes per tier, notification streams, announce legs,
|
|
||||||
post hooks, extension slots. Worked examples of each, with the distinctions that
|
|
||||||
are easy to get wrong (a leg is one-shot delivery with retry; a post hook is
|
|
||||||
idempotent state that also runs on delete).
|
|
||||||
- **The schema fragment.** Idempotent, replayed every boot, leading-verb allowlist,
|
|
||||||
the table-prefix rule, and why there is no migration runner anywhere in this
|
|
||||||
project. What belongs in `purge.sql` instead.
|
|
||||||
- **The client half.** The prebuilt ESM chunk; `window.__rg`; the shared-dependency
|
|
||||||
rule (core owns React and hands it over — a module that resolves its own gets two
|
|
||||||
Reacts and a broken page); the Vite library build with anchored aliases and
|
|
||||||
`external: []`, and *why* that combination rather than the obvious one.
|
|
||||||
- **Routes, nav and features on the client**, and how a module's nav row becomes an
|
|
||||||
ordinary row an operator can reorder, relabel or hide.
|
|
||||||
- **The UI kit** — seven members, closed on purpose. What to do about the eighth
|
|
||||||
thing you want.
|
|
||||||
- **The OpenAPI fragment**, and how to generate it from your own registrations.
|
|
||||||
- **Packaging and release CI**: the tarball, the install manifest, the checksum,
|
|
||||||
and the version living in `module.json`.
|
|
||||||
- **Boundaries.** What a module must not do, each with the failure it prevents.
|
|
||||||
|
|
||||||
## 3. The sidecar
|
|
||||||
|
|
||||||
Why it exists, why it is **not optional**, and what "thin" means for a game that
|
|
||||||
already speaks a remote-control protocol.
|
|
||||||
|
|
||||||
- The invariant: your game is never network-reachable; it **dials out**, the
|
|
||||||
sidecar listens, and only the website's backend talks to the sidecar.
|
|
||||||
- **Persist before you forward.** The sidecar owns the durable copy — event
|
|
||||||
history, the latest snapshot of every board, whatever a page must still be able
|
|
||||||
to render when the game or the website is down. A live feed is allowed to be
|
|
||||||
lossy *because* the store is not.
|
|
||||||
- The wire as a **versioned compatibility contract** rather than a build
|
|
||||||
dependency: a version on every response, a mismatch refused rather than
|
|
||||||
mis-parsed, and what a bump obliges you to change in the same commit.
|
|
||||||
- Auth, and why the sidecar is the only exposed part.
|
|
||||||
- `uo-link` as the worked example, and what a *thin* sidecar for an RCON-style game
|
|
||||||
keeps and drops.
|
|
||||||
|
|
||||||
## 4. The game-side plugin
|
|
||||||
|
|
||||||
The chapter with the least code and the highest stakes: a plugin that gets this
|
|
||||||
wrong takes the game down when the sidecar wedges.
|
|
||||||
|
|
||||||
- **Never block the game thread.** Enqueue and return; a bounded, drop-oldest queue;
|
|
||||||
a dedicated writer thread that drains it. Dropping the oldest event is correct,
|
|
||||||
and stalling the game to avoid it is not.
|
|
||||||
- **Read the world only on the game's own thread**, and hand plain data to the
|
|
||||||
writer.
|
|
||||||
- Reconnect, backoff, and what to send on connect so the sidecar can rebuild its
|
|
||||||
picture without asking.
|
|
||||||
- What to emit at all: the difference between an event stream and a state snapshot,
|
|
||||||
and why both exist.
|
|
||||||
- `servuo-plugins` as the worked example. The constraints are general; the C# is not.
|
|
||||||
|
|
||||||
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||||
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||||
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||||
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
|
|||||||
142
scripts/checkChapterPaths.js
Normal file
142
scripts/checkChapterPaths.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Every path in this repo that a chapter names in backticks must exist.
|
||||||
|
//
|
||||||
|
// The book teaches out of `template/`: it says "open
|
||||||
|
// `template/server/index.js`", "the aliases are in `template/client/vite.config.js`",
|
||||||
|
// "your tables go in `template/server/db/schema.sql`". None of that is a markdown
|
||||||
|
// link, so `checkLinks.js` never looks at it — and none of it is code, so nothing
|
||||||
|
// else does either. Rename one template file and four chapters quietly point at
|
||||||
|
// nothing, which is the exact rot this repo exists to be immune to.
|
||||||
|
//
|
||||||
|
// This is the cheap half of "is the book still true", and it is honest about
|
||||||
|
// being only the half a machine can answer. Whether a paragraph has become wrong
|
||||||
|
// about a file that still exists is a reviewer's job (MODULE_SYSTEM.md §2.10).
|
||||||
|
//
|
||||||
|
// ── What counts as a claim about this repo ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An inline code span whose text begins with one of this repo's own top-level
|
||||||
|
// directories, `ANCHORS` below. That is what makes the check answerable: a
|
||||||
|
// chapter also quotes `server/index.js` loosely, and `sidecar/src/store.rs`,
|
||||||
|
// which lives in another repo entirely and cannot be resolved here. Anchoring on
|
||||||
|
// our own directory names means every token this check reads is a claim it can
|
||||||
|
// actually settle.
|
||||||
|
//
|
||||||
|
// **The anchors are stated, not derived from the tree**, and that is deliberate
|
||||||
|
// for the reason core's own build guard states it (MODULE_API.md §3.6): a list
|
||||||
|
// derived from what exists cannot fail when what exists changes. Rename
|
||||||
|
// `template/` and a derived anchor set would simply stop checking every
|
||||||
|
// `template/…` mention in the book, silently, at the moment they all became
|
||||||
|
// wrong. So the anchors are written down — and each one must exist, or this check
|
||||||
|
// fails. An anchor that has stopped matching is a check that has stopped
|
||||||
|
// checking, the same rule the identifier exemptions in core's CI follow.
|
||||||
|
//
|
||||||
|
// Fenced blocks are excluded (`lib/markdown.js`). A fence in this book is often a
|
||||||
|
// listing of the reader's own future tree, and their files are not ours.
|
||||||
|
//
|
||||||
|
// Usage: node scripts/checkChapterPaths.js (from the repo root)
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { codeSpans } = require('./lib/markdown')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
// This repo's own top-level directories. See the note above on why this is a list
|
||||||
|
// and not a directory scan.
|
||||||
|
const ANCHORS = ['template/', 'book/', 'scripts/', 'ci/']
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||||
|
|
||||||
|
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||||
|
function markdownFiles(dir = ROOT, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue
|
||||||
|
markdownFiles(path.join(dir, entry.name), out)
|
||||||
|
} else if (entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repo paths a document claims, from its inline code spans.
|
||||||
|
*
|
||||||
|
* A span is a claim when it starts with an anchor and names something a
|
||||||
|
* filesystem could answer for. Three kinds are skipped, each because the answer
|
||||||
|
* would be "no" for a reason that is not a mistake:
|
||||||
|
*
|
||||||
|
* • a placeholder — `template/<id>/…`, `scripts/*.js` — which is a shape rather
|
||||||
|
* than a path;
|
||||||
|
* • a span with whitespace in it, which is a phrase or a command line
|
||||||
|
* (`npm ci --prefix template/server` is not a path and its first word is not
|
||||||
|
* an anchor either, but a span like `cd template/server && npm test` would
|
||||||
|
* slip through on its first token without this);
|
||||||
|
* • trailing prose punctuation, stripped rather than skipped, so `template/`
|
||||||
|
* ending a sentence still resolves.
|
||||||
|
*/
|
||||||
|
function claimedPaths(markdown) {
|
||||||
|
const found = []
|
||||||
|
for (const { text, line } of codeSpans(markdown)) {
|
||||||
|
const token = text.trim()
|
||||||
|
if (/\s/.test(token)) continue
|
||||||
|
if (!ANCHORS.some((a) => token.startsWith(a))) continue
|
||||||
|
if (/[<>*?]|\.\.\./.test(token)) continue
|
||||||
|
// A path may legitimately end in `/` (a directory); anything else in this set
|
||||||
|
// is the sentence around it, not part of the name.
|
||||||
|
const cleaned = token.replace(/[.,;:)\]]+$/, '')
|
||||||
|
if (cleaned) found.push({ path: cleaned, line })
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything wrong, as sentences. Empty means every claim resolves. */
|
||||||
|
function problems({ claims, exists }) {
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const anchor of ANCHORS) {
|
||||||
|
const dir = anchor.replace(/\/$/, '')
|
||||||
|
if (!exists(dir)) {
|
||||||
|
out.push(
|
||||||
|
`${anchor} is listed as an anchor and does not exist. ` +
|
||||||
|
'Either restore it or update ANCHORS — an anchor that matches nothing is a ' +
|
||||||
|
'check that has silently stopped checking.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, path: claimed, line } of claims) {
|
||||||
|
if (!exists(claimed)) {
|
||||||
|
out.push(`${file}:${line}: no such path — ${claimed}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ANCHORS, claimedPaths, problems, markdownFiles }
|
||||||
|
|
||||||
|
if (require.main !== module) return
|
||||||
|
|
||||||
|
const files = markdownFiles()
|
||||||
|
const claims = []
|
||||||
|
for (const file of files) {
|
||||||
|
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
|
||||||
|
for (const claim of claimedPaths(text)) claims.push({ file, ...claim })
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = (p) => fs.existsSync(path.join(ROOT, p))
|
||||||
|
const found = problems({ claims, exists })
|
||||||
|
|
||||||
|
if (found.length) {
|
||||||
|
console.error(`\ncheckChapterPaths: ${found.length} problem(s):\n`)
|
||||||
|
for (const p of found) console.error(` - ${p}`)
|
||||||
|
console.error('')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`checkChapterPaths: ${claims.length} path(s) claimed across ${files.length} markdown file(s) — all present.`,
|
||||||
|
)
|
||||||
81
scripts/checkChapterPaths.test.js
Normal file
81
scripts/checkChapterPaths.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// The chapter-path check, checked.
|
||||||
|
//
|
||||||
|
// Same rule as the rename check's own suite: a check written when the thing it
|
||||||
|
// guards is already clean never fires again, and nothing distinguishes "still
|
||||||
|
// checking" from "quietly broken" without cases it is required to reject. Every
|
||||||
|
// "must not catch" case below is a real span that appears in the book.
|
||||||
|
//
|
||||||
|
// No filesystem — `problems()` takes `exists` as an argument precisely so it can
|
||||||
|
// be tested this way, and `claimedPaths()` is pure.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const { ANCHORS, claimedPaths, problems } = require('./checkChapterPaths')
|
||||||
|
|
||||||
|
/** `problems()` over a fixture set of paths that exist. */
|
||||||
|
const check = (claims, present) =>
|
||||||
|
problems({ claims, exists: (p) => new Set([...present, ...ANCHORS.map((a) => a.replace(/\/$/, ''))]).has(p) })
|
||||||
|
|
||||||
|
test('a claim that resolves is not a problem', () => {
|
||||||
|
assert.deepStrictEqual(check([{ file: 'book/01.md', line: 3, path: 'template/module.json' }],
|
||||||
|
['template/module.json']), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a claim that does not resolve fails, naming the file and line', () => {
|
||||||
|
const found = check([{ file: 'book/02-website-module.md', line: 41, path: 'template/server/gone.js' }], [])
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0], /book\/02-website-module\.md:41.*template\/server\/gone\.js/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing anchor fails on its own', () => {
|
||||||
|
// The half that keeps this check honest: if `template/` is renamed, every
|
||||||
|
// template path in the book is wrong AND the check would stop looking at them.
|
||||||
|
const found = problems({ claims: [], exists: (p) => p !== 'template' })
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0], /template\/ is listed as an anchor and does not exist/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('paths are read only from inline code spans', () => {
|
||||||
|
const md = 'Open the entry point and read it: template/server/index.js, then stop.'
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a code span inside a fenced block is not a claim', () => {
|
||||||
|
// A fence is usually the reader's own future tree, and their files are not ours.
|
||||||
|
const md = ['```', '`template/nope.js`', 'template/also-nope.js', '```'].join('\n')
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a path in another repo is not this check\'s business', () => {
|
||||||
|
const md = 'The store is `sidecar/src/store.rs`, and the plugin is `overlay/Scripts/Custom/Bridge/BridgeLink.cs`.'
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a placeholder shape is not a path', () => {
|
||||||
|
const md = 'Your copy lands at `template/<id>/module.json`, and the checks are `scripts/*.js`.'
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a command line is not a path', () => {
|
||||||
|
// The first token is an anchor in neither case, but a span that BEGINS with one
|
||||||
|
// and carries arguments would otherwise be read as a filename with spaces in it.
|
||||||
|
const md = 'Run `npm ci --prefix template/server`, or `template/server && npm test` if you must.'
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trailing sentence punctuation is stripped, not skipped', () => {
|
||||||
|
const md = 'It all lives under `template/`.'
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/', line: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a claim on a later line reports that line', () => {
|
||||||
|
const md = ['# Title', '', 'See `template/server/boot.js`.'].join('\n')
|
||||||
|
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/server/boot.js', line: 3 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every anchor is a directory of this repo', () => {
|
||||||
|
// Stated, not derived (see the header) — so this asserts the stated list is
|
||||||
|
// still the real one at the moment it is written down.
|
||||||
|
assert.ok(ANCHORS.every((a) => a.endsWith('/')), 'anchors are directory prefixes')
|
||||||
|
})
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
const { stripFences } = require('./lib/markdown')
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..')
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
const QUIET = process.argv.includes('--quiet')
|
const QUIET = process.argv.includes('--quiet')
|
||||||
|
|
||||||
@@ -38,31 +40,10 @@ function markdownFiles(dir = ROOT, out = []) {
|
|||||||
return out.sort()
|
return out.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fenced code blocks are stripped before links are read: a fence can legitimately
|
// Fenced code blocks are stripped before links are read (`lib/markdown.js`): a
|
||||||
// contain a path that does not exist (a directory listing of a project the reader
|
// fence can legitimately contain a path that does not exist — a directory listing
|
||||||
// has not created yet), and flagging those would make the check useless in exactly
|
// of a project the reader has not created yet — and flagging those would make the
|
||||||
// the document type this repo is made of. Stripped by walking lines and toggling
|
// check useless in exactly the document type this repo is made of.
|
||||||
// on a fence marker, rather than by regexp — a fence's own content can contain
|
|
||||||
// anything, including a line that looks like the end of one.
|
|
||||||
function stripFences(text) {
|
|
||||||
const out = []
|
|
||||||
let fence = null
|
|
||||||
for (const line of text.split(/\r?\n/)) {
|
|
||||||
const m = /^\s*(```+|~~~+)/.exec(line)
|
|
||||||
if (fence) {
|
|
||||||
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
|
||||||
out.push('')
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (m) {
|
|
||||||
fence = m[1]
|
|
||||||
out.push('')
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.push(line)
|
|
||||||
}
|
|
||||||
return out.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
|
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
|
||||||
function linksIn(text) {
|
function linksIn(text) {
|
||||||
|
|||||||
60
scripts/lib/markdown.js
Normal file
60
scripts/lib/markdown.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// The two pieces of markdown handling both checks in this directory need, in one
|
||||||
|
// place rather than two copies that drift.
|
||||||
|
//
|
||||||
|
// Shared code, not a shared description. Core's own loader and its schema replay
|
||||||
|
// use one splitter for the same reason (MODULE_API.md §2.6): two implementations
|
||||||
|
// of "what counts as a code fence" would disagree eventually, and the check that
|
||||||
|
// disagreed quietly would be the one still reporting green.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text with every fenced code block blanked out, line count preserved.
|
||||||
|
*
|
||||||
|
* Fenced blocks are stripped before either check reads anything, because a fence
|
||||||
|
* can legitimately contain a path or a link that does not exist: a directory
|
||||||
|
* listing of the project the reader has not created yet, a URL in an example. In
|
||||||
|
* a repo made entirely of that document type, flagging them makes the check
|
||||||
|
* useless.
|
||||||
|
*
|
||||||
|
* Done by walking lines and toggling on a fence marker rather than by regexp — a
|
||||||
|
* fence's own content can contain anything, including a line that looks like the
|
||||||
|
* end of one. Lines are replaced by empty strings rather than removed so that
|
||||||
|
* line numbers in a report still point at the right place.
|
||||||
|
*/
|
||||||
|
function stripFences(text) {
|
||||||
|
const out = []
|
||||||
|
let fence = null
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const m = /^\s*(```+|~~~+)/.exec(line)
|
||||||
|
if (fence) {
|
||||||
|
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
||||||
|
out.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (m) {
|
||||||
|
fence = m[1]
|
||||||
|
out.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.push(line)
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every inline code span outside a fenced block, with the 1-based line it is on.
|
||||||
|
*
|
||||||
|
* `[a](b)` inside backticks is an example rather than a link, and `template/x.js`
|
||||||
|
* inside backticks is a claim about this repo's tree — which is why one check
|
||||||
|
* throws these away and the other reads only these.
|
||||||
|
*/
|
||||||
|
function codeSpans(text) {
|
||||||
|
const found = []
|
||||||
|
stripFences(text).split(/\r?\n/).forEach((line, i) => {
|
||||||
|
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
||||||
|
found.push({ text: m[1], line: i + 1 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { stripFences, codeSpans }
|
||||||
Reference in New Issue
Block a user