Merge pull request 'fix(kit): everything the acceptance run found — Phase 5 slice 3' (#4) from fix/acceptance-findings into main
Reviewed-on: #4
This commit is contained in:
@@ -47,6 +47,30 @@ cp -r template/ ~/my-module
|
|||||||
cd ~/my-module
|
cd ~/my-module
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Run every check on the untouched copy before you change a line.** Jump ahead to
|
||||||
|
*Build it* and run all of it — the tests, the build, the three guards — on the
|
||||||
|
template exactly as it arrived:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --prefix server && npm test --prefix server
|
||||||
|
npm run check:imports --prefix server
|
||||||
|
npm run check:swagger --prefix server
|
||||||
|
npm ci --prefix client && npm run build --prefix client
|
||||||
|
npm run check:externals --prefix client && npm test --prefix client
|
||||||
|
```
|
||||||
|
|
||||||
|
It takes two minutes and it buys you a **baseline**. Every one of those commands
|
||||||
|
is green on a pristine template, so from here on a red one is something you did —
|
||||||
|
and you will know which edit did it, because you were green a moment ago. Without
|
||||||
|
that, the first failure is ambiguous forever: is this my mistake, or was the
|
||||||
|
template already like this?
|
||||||
|
|
||||||
|
That is not a hypothetical. The kit's own acceptance run
|
||||||
|
([`kit-acceptance.md`][acceptance]) found `check:swagger` failing on an untouched
|
||||||
|
copy on Windows, with a message that blamed the reader's routes. It is fixed, and
|
||||||
|
the reason the run *found* it rather than being derailed by it is that it had a
|
||||||
|
baseline.
|
||||||
|
|
||||||
Your module id is the single most load-bearing string in it: it is the directory
|
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
|
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
|
pages hangs under, and the prefix every one of your tables must carry. It must
|
||||||
@@ -242,3 +266,4 @@ module must never do.
|
|||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
||||||
[renamecheck]: ../scripts/checkRenameSites.js
|
[renamecheck]: ../scripts/checkRenameSites.js
|
||||||
|
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
||||||
|
|||||||
@@ -392,12 +392,34 @@ and empty states, the async hook every data page uses, and read-only access to t
|
|||||||
session and site settings. Enough to build a page that looks like the site it is
|
session and site settings. Enough to build a page that looks like the site it is
|
||||||
installed in, and nothing else.
|
installed in, and nothing else.
|
||||||
|
|
||||||
|
**`PublicLayout` needs a `shell`, and this is the one that will catch you.** The
|
||||||
|
layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
|
||||||
|
prop is the *body*: the centred max-width column, the vertical padding, and the
|
||||||
|
element whose `flex: 1` is the only thing holding the footer at the bottom of the
|
||||||
|
viewport.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<PublicLayout shell="narrow"> // 'narrow' · 'mid' · 'wide'
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit it and your content starts hard against the left edge of the window with no
|
||||||
|
padding, and the footer climbs up underneath it. It reads as a stylesheet bug in
|
||||||
|
your module and it is not one — core's own pages write that wrapper by hand, and
|
||||||
|
before `MODULE_API_VERSION` 1.5.0 a module had no way to. **Name a width, never a
|
||||||
|
class:** the class names are core's stylesheet's and it is free to rename them,
|
||||||
|
which is exactly why they are not in the contract and this prop is.
|
||||||
|
|
||||||
|
That paragraph exists because the kit's acceptance run
|
||||||
|
([`kit-acceptance.md`][acceptance]) built a module by following this chapter to the
|
||||||
|
letter, and its page rendered outside the site. Everything else it wrote was right.
|
||||||
|
|
||||||
**It is curated and closed, not a re-export of core's component library.** Adding
|
**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.
|
to it is a minor version bump, and so is adding an optional prop to a member;
|
||||||
|
changing an existing prop is a major one.
|
||||||
That is a real constraint on core, and it is the price of the boundary being worth
|
That is a real constraint on core, and it is the price of the boundary being worth
|
||||||
anything.
|
anything.
|
||||||
|
|
||||||
So: when you want an eighth thing, bundle it. Tables, chips, tabs, editors — those
|
So: when you want something it does not have, bundle it. Tables, chips, tabs, editors — those
|
||||||
are yours, and your chunk carries them. Reaching into core's tree for a component
|
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`
|
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.
|
exists to make sure a moment of weakness fails the build instead of shipping.
|
||||||
@@ -433,10 +455,26 @@ nothing at runtime will ever say so.
|
|||||||
The trap: **swagger-autogen reports a broken annotation and then prints
|
The trap: **swagger-autogen reports a broken annotation and then prints
|
||||||
`Success`.** It logs a syntax error, drops that annotation, and exits zero. The
|
`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.
|
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
|
The usual cause is an object literal one brace short.
|
||||||
a backtick inside a single-quoted description. A third is only visible in a
|
|
||||||
rendered page: an escaped apostrophe (`\'`) survives literally into the output,
|
**A quote character is worse, because it does not log anything.** These
|
||||||
because the annotation is never evaluated as JavaScript. Use a typographic `’`.
|
annotations are evaluated as JavaScript literals, so a `'` or a `"` inside a
|
||||||
|
single-quoted description ends the string early — and for a `"` in the middle of
|
||||||
|
a sentence the result is not an error at all. The value is silently **truncated**
|
||||||
|
at that character:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// #swagger.summary = 'A "quoted" world status'
|
||||||
|
// → "summary": "A \"" and swagger-autogen still prints Success
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing throws, so the generator's error capture has nothing to capture. The only
|
||||||
|
signal is `check:swagger` calling the fragment stale, with a message that blames
|
||||||
|
your routes. **If that check fires and your routes did not change, look for a
|
||||||
|
quote in an annotation first.** Backticks are safe — Markdown spans survive
|
||||||
|
verbatim. And an escaped apostrophe (`\'`) is a third case, visible only in a
|
||||||
|
rendered page: the annotation is never evaluated as JavaScript by the reader, so
|
||||||
|
Swagger UI shows the backslash. Use a typographic `’` throughout.
|
||||||
|
|
||||||
## Packaging and release
|
## Packaging and release
|
||||||
|
|
||||||
@@ -485,3 +523,4 @@ it** — an outbound socket is not statically detectable the way an internal
|
|||||||
the next chapter is for.
|
the next chapter is for.
|
||||||
|
|
||||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||||
|
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"branch": "edge",
|
"branch": "edge",
|
||||||
"ref": "1b692bf624404f9e4f924c231acfbfb7e9d0861a",
|
"ref": "4ad8b2bb0ede2747622075dcfa4cb1fe460f91ca",
|
||||||
"why": [
|
"why": [
|
||||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||||
"This one is the MODULE_API_VERSION 1.4.0 bump, which is the version",
|
"This one is the MODULE_API_VERSION 1.5.0 bump, which is the version",
|
||||||
"template/module.json declares - slice 0 pinned its parent, before 1.4.0",
|
"template/module.json declares. It moved here from the 1.4.0 bump because",
|
||||||
"existed, and the check below could not have passed against it.",
|
"the kit's acceptance run found PublicLayout had no way to give a module",
|
||||||
|
"page the site's body wrapper, and core grew a `shell` prop for it - so the",
|
||||||
|
"template now uses a member that only exists at this ref and later.",
|
||||||
"",
|
"",
|
||||||
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||||
"version template/module.json declares still equals this core's",
|
"version template/module.json declares still equals this core's",
|
||||||
|
|||||||
@@ -34,9 +34,23 @@ const TEMPLATE = path.join(ROOT, 'template')
|
|||||||
const CHECKLIST = path.join(TEMPLATE, 'README.md')
|
const CHECKLIST = path.join(TEMPLATE, 'README.md')
|
||||||
|
|
||||||
// Anything a rename has to touch: the id (`examplegame`), the display name
|
// Anything a rename has to touch: the id (`examplegame`), the display name
|
||||||
// ("Example Game"), and the placeholder world ("Example World"). One pattern
|
// ("Example Game"), the placeholder world ("Example World"), and the two
|
||||||
// rather than three, because they are one decision.
|
// publishing placeholders in the Gitea release workflow (`gitea.example.com`,
|
||||||
const PLACEHOLDER = /example[ -]?(game|world)/i
|
// `your-org/your-module`). One pattern rather than four, because they are one
|
||||||
|
// decision — everything a reader must change before this template is theirs.
|
||||||
|
//
|
||||||
|
// **Every alternative has to be a string that cannot occur by accident**, which
|
||||||
|
// is the same rule that made the id `examplegame` rather than `example` (see the
|
||||||
|
// header). The publishing pair was added after the acceptance run found the
|
||||||
|
// release workflow carrying `# CHANGE THESE` placeholders that the checklist did
|
||||||
|
// not list and this pattern could not see — CI silent by construction
|
||||||
|
// (docs/modules/kit-acceptance.md, F3).
|
||||||
|
//
|
||||||
|
// The near-miss is worth keeping: the obvious widening is `example\.com`, and it
|
||||||
|
// is WRONG. `server/test/checkImports.test.js` uses `https://example.com/x` as a
|
||||||
|
// fixture — a URL in a string, testing that a URL in a string is not an import —
|
||||||
|
// and it is not a rename site. The host is matched in full instead.
|
||||||
|
const PLACEHOLDER = /example[ -]?(game|world)|gitea\.example\.com|your-(org|module)/i
|
||||||
|
|
||||||
// Directories with nothing of ours in them. `dist` and `node_modules` are build
|
// Directories with nothing of ours in them. `dist` and `node_modules` are build
|
||||||
// output — a chunk full of the placeholder is not a rename site, it is the
|
// output — a chunk full of the placeholder is not a rename site, it is the
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ test('the placeholder pattern matches every form a rename touches', () => {
|
|||||||
"worldName: 'Example World'",
|
"worldName: 'Example World'",
|
||||||
'examplegame_world_status',
|
'examplegame_world_status',
|
||||||
'example-game',
|
'example-game',
|
||||||
|
// The publishing pair, added after the acceptance run found the release
|
||||||
|
// workflow unlisted and unmatchable (kit-acceptance.md F3).
|
||||||
|
' GITEA_HOST: gitea.example.com',
|
||||||
|
' REPO: your-org/your-module',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(PLACEHOLDER.test(text), `should match: ${text}`)
|
assert.ok(PLACEHOLDER.test(text), `should match: ${text}`)
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,14 @@ test('the placeholder pattern does not fire on ordinary prose', () => {
|
|||||||
'an example of what to catch',
|
'an example of what to catch',
|
||||||
'exampleValue',
|
'exampleValue',
|
||||||
'the game world',
|
'the game world',
|
||||||
|
// A real span from server/test/checkImports.test.js, and the reason the
|
||||||
|
// publishing host is matched in full rather than as `example.com`: it is a
|
||||||
|
// fixture URL inside a string, in a test about URLs inside strings, and it is
|
||||||
|
// not a rename site. The obvious widening would have failed the build on it.
|
||||||
|
"const url = 'https://example.com/x'",
|
||||||
|
// Prose about the reader's own module, which is not the hyphenated token.
|
||||||
|
'copy your module directory onto the volume',
|
||||||
|
'your org will need a release token',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(!PLACEHOLDER.test(text), `should not match: ${text}`)
|
assert.ok(!PLACEHOLDER.test(text), `should not match: ${text}`)
|
||||||
}
|
}
|
||||||
|
|||||||
22
template/.gitattributes
vendored
Normal file
22
template/.gitattributes
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Check every text file out with LF, on every platform.
|
||||||
|
#
|
||||||
|
# This exists because of a real failure, reported by the kit's acceptance run
|
||||||
|
# (docs/modules/kit-acceptance.md, finding F1): on a default Windows clone,
|
||||||
|
# `npm run check:swagger` failed on a PRISTINE, unedited template. The check
|
||||||
|
# compares the committed swagger-fragment.json against what the generator writes;
|
||||||
|
# the generator writes LF, and core.autocrlf had handed the reader CRLF. The
|
||||||
|
# message blamed "the routes or their annotations", which is the first command the
|
||||||
|
# kit tells a reader to run telling them a false thing about their own work.
|
||||||
|
#
|
||||||
|
# The check itself now normalises line endings before comparing, so this file is
|
||||||
|
# the belt to that pair of braces: it also stops a CRLF blob ever being COMMITTED
|
||||||
|
# by a reader who copies this template, which would break the same check for
|
||||||
|
# everyone who cloned their repo afterwards.
|
||||||
|
#
|
||||||
|
# It travels with the template on purpose — a copied module directory keeps its
|
||||||
|
# own attributes, and this is one of the things the copier should not have to know.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Nothing here is binary today. If your module ships an image or a font, mark it,
|
||||||
|
# because `text=auto` guesses and a wrong guess corrupts the file:
|
||||||
|
# *.png binary
|
||||||
@@ -151,12 +151,23 @@ jobs:
|
|||||||
|
|
||||||
# The server half, minus everything that never runs inside core's
|
# The server half, minus everything that never runs inside core's
|
||||||
# process: no `test/`, no `scripts/`, no `swagger/`.
|
# process: no `test/`, no `scripts/`, no `swagger/`.
|
||||||
|
#
|
||||||
|
# An EXCLUSION list, not an include list, and that is the whole point.
|
||||||
|
# This was `for d in boot.js core.js index.js db model router`, which
|
||||||
|
# meant adding `server/utils/` — an ordinary thing to do — silently
|
||||||
|
# dropped it from every release: the bundle check below only resolves
|
||||||
|
# the five paths module.json names, so nothing failed here, and the
|
||||||
|
# module died on an operator's box as a `startup_failed` row instead
|
||||||
|
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
|
||||||
|
# because the failure mode inverts: forget to exclude something and you
|
||||||
|
# ship a harmless extra file, rather than omitting a required one.
|
||||||
mkdir -p "$OUT/server"
|
mkdir -p "$OUT/server"
|
||||||
for d in boot.js core.js index.js db model router; do
|
for e in server/*; do
|
||||||
cp -r "server/$d" "$OUT/server/"
|
case "$(basename "$e")" in
|
||||||
|
test|scripts|swagger|package-lock.json) continue ;;
|
||||||
|
esac
|
||||||
|
cp -r "$e" "$OUT/server/"
|
||||||
done
|
done
|
||||||
cp server/package.json "$OUT/server/"
|
|
||||||
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
|
|
||||||
|
|
||||||
# The client half is the BUILT chunk only. `client/src` is source an
|
# The client half is the BUILT chunk only. `client/src` is source an
|
||||||
# operator has no use for and core will never read.
|
# operator has no use for and core will never read.
|
||||||
|
|||||||
19
template/.github/workflows/release.yml
vendored
19
template/.github/workflows/release.yml
vendored
@@ -135,12 +135,23 @@ jobs:
|
|||||||
|
|
||||||
# The server half, minus everything that never runs inside core's
|
# The server half, minus everything that never runs inside core's
|
||||||
# process: no `test/`, no `scripts/`, no `swagger/`.
|
# process: no `test/`, no `scripts/`, no `swagger/`.
|
||||||
|
#
|
||||||
|
# An EXCLUSION list, not an include list, and that is the whole point.
|
||||||
|
# This was `for d in boot.js core.js index.js db model router`, which
|
||||||
|
# meant adding `server/utils/` — an ordinary thing to do — silently
|
||||||
|
# dropped it from every release: the bundle check below only resolves
|
||||||
|
# the five paths module.json names, so nothing failed here, and the
|
||||||
|
# module died on an operator's box as a `startup_failed` row instead
|
||||||
|
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
|
||||||
|
# because the failure mode inverts: forget to exclude something and you
|
||||||
|
# ship a harmless extra file, rather than omitting a required one.
|
||||||
mkdir -p "$OUT/server"
|
mkdir -p "$OUT/server"
|
||||||
for d in boot.js core.js index.js db model router; do
|
for e in server/*; do
|
||||||
cp -r "server/$d" "$OUT/server/"
|
case "$(basename "$e")" in
|
||||||
|
test|scripts|swagger|package-lock.json) continue ;;
|
||||||
|
esac
|
||||||
|
cp -r "$e" "$OUT/server/"
|
||||||
done
|
done
|
||||||
cp server/package.json "$OUT/server/"
|
|
||||||
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
|
|
||||||
|
|
||||||
# The client half is the BUILT chunk only.
|
# The client half is the BUILT chunk only.
|
||||||
mkdir -p "$OUT/client/dist"
|
mkdir -p "$OUT/client/dist"
|
||||||
|
|||||||
36
template/.gitignore
vendored
Normal file
36
template/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# The template's own ignore rules, so they travel with a copy.
|
||||||
|
#
|
||||||
|
# The kit repo's root .gitignore covers these paths too, but that file stays
|
||||||
|
# behind: copy `template/` out, `git init`, and you inherit nothing — `node_modules/`
|
||||||
|
# included. Reported by the acceptance run (docs/modules/kit-acceptance.md, F6).
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ── The built client chunk ────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Ignored HERE and shipped in the RELEASE, which is not a contradiction: a module
|
||||||
|
# is installed prebuilt (an operator never builds anything), but the artifact is
|
||||||
|
# built by CI from the source next to it, and a chunk committed by hand goes stale
|
||||||
|
# beside fresh source without anything saying so.
|
||||||
|
#
|
||||||
|
# Your release workflow builds it before packing the bundle. If you would rather
|
||||||
|
# commit it, delete this line and accept that you now have to remember.
|
||||||
|
client/dist/
|
||||||
|
|
||||||
|
# env / secrets — a module's env vars are the operator's, never the repo's
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# release staging, produced by .gitea/workflows/release.yml
|
||||||
|
/dist/
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# logs / os / editor
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -35,7 +35,7 @@ server/
|
|||||||
client/
|
client/
|
||||||
vite.config.js the library build: anchored aliases, external: []
|
vite.config.js the library build: anchored aliases, external: []
|
||||||
src/entry.jsx registers routes and nav at evaluation time
|
src/entry.jsx registers routes and nav at evaluation time
|
||||||
src/core.js what core hands you: the seven-member UI kit
|
src/core.js what core hands you: the UI kit (eight exports)
|
||||||
src/shim/ the four shared dependencies, re-exported from core
|
src/shim/ the four shared dependencies, re-exported from core
|
||||||
src/routes/public/ the page
|
src/routes/public/ the page
|
||||||
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
|
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
|
||||||
@@ -103,6 +103,7 @@ backticking table names**.
|
|||||||
| File | What to change |
|
| File | What to change |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `module.json` | `id`, `name`, `version`, the `mounts` prefix, `capabilities` |
|
| `module.json` | `id`, `name`, `version`, the `mounts` prefix, `capabilities` |
|
||||||
|
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
|
||||||
| `server/package.json` | package `name` and `description` |
|
| `server/package.json` | package `name` and `description` |
|
||||||
| `server/core.js` | the message every accessor throws |
|
| `server/core.js` | the message every accessor throws |
|
||||||
| `server/boot.js` | the placeholder world name |
|
| `server/boot.js` | the placeholder world name |
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
|
// The curated kit (§3.4). Eight exports, and it is CLOSED: layout, headings, the
|
||||||
// three data-page states, the fetch hook, and read-only access to the session and
|
// three data-page states, the fetch hook, and read-only access to the session and
|
||||||
// the site's settings. Anything else your pages need — tables, tabs, an editor —
|
// the site's settings. Anything else your pages need — tables, tabs, an editor —
|
||||||
// you bundle yourself, in a `components/` directory of your own.
|
// you bundle yourself, in a `components/` directory of your own.
|
||||||
//
|
//
|
||||||
// Closed is a real constraint and it is the price of the boundary being worth
|
// Closed is a real constraint and it is the price of the boundary being worth
|
||||||
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing a
|
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
|
||||||
// kit component's props is a major one. Use them, though. A module page that
|
// existing prop on a kit component is a major one. Use them, though. A module page that
|
||||||
// ships its own layout is a page that stops looking like the site it is installed
|
// ships its own layout is a page that stops looking like the site it is installed
|
||||||
// in, and drifts further every time core changes.
|
// in, and drifts further every time core changes.
|
||||||
export const {
|
export const {
|
||||||
|
|||||||
@@ -3,13 +3,24 @@
|
|||||||
// An ordinary React component. Nothing about being inside a module changes how
|
// An ordinary React component. Nothing about being inside a module changes how
|
||||||
// you write one — the only differences are where React comes from (core, via the
|
// you write one — the only differences are where React comes from (core, via the
|
||||||
// aliases in vite.config.js, so the import below looks completely normal and is
|
// aliases in vite.config.js, so the import below looks completely normal and is
|
||||||
// not) and where the chrome comes from (`../../core.js`, the seven-member kit).
|
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||||
//
|
//
|
||||||
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
|
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
|
||||||
// maintenance gate and nothing else, so a page that omits the layout renders
|
// maintenance gate and nothing else, so a page that omits the layout renders
|
||||||
// bare — no header, no footer, no site chrome — which looks like a bug and is
|
// bare — no header, no footer, no site chrome — which looks like a bug and is
|
||||||
// the contract (§3.3). Admin and player routes are the other way round: core
|
// the contract (§3.3). Admin and player routes are the other way round: core
|
||||||
// wraps those in their layouts for you.
|
// wraps those in their layouts for you.
|
||||||
|
//
|
||||||
|
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||||
|
// centred column, the vertical padding, and the thing that holds the footer at
|
||||||
|
// the bottom of the viewport. Without it your content starts hard against the
|
||||||
|
// left edge of the window and the footer rides up underneath it, which reads as
|
||||||
|
// a CSS bug in your module and is not one. Widths are 'narrow', 'mid' and
|
||||||
|
// 'wide'; name a width, never a class, because the classes belong to core's
|
||||||
|
// stylesheet and it is free to rename them (§3.4, MODULE_API_VERSION 1.5.0).
|
||||||
|
//
|
||||||
|
// This is here because the kit's acceptance run got it wrong by following the
|
||||||
|
// kit: a module built to the letter of chapter 2 rendered outside the site.
|
||||||
|
|
||||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
import api from '../../api.js'
|
import api from '../../api.js'
|
||||||
@@ -34,7 +45,7 @@ export default function WorldStatus() {
|
|||||||
const { data, loading, error } = useAsync(() => api.world.status(), [])
|
const { data, loading, error } = useAsync(() => api.world.status(), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout shell="narrow">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="World status"
|
title="World status"
|
||||||
subtitle="What the game server last told us about itself"
|
subtitle="What the game server last told us about itself"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "examplegame",
|
"id": "examplegame",
|
||||||
"name": "Example Game",
|
"name": "Example Game",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.4.0",
|
"coreApi": "^1.5.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
@@ -19,13 +19,21 @@
|
|||||||
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
|
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
|
||||||
// rules that cost this project real time:
|
// rules that cost this project real time:
|
||||||
//
|
//
|
||||||
// • swagger-autogen reads these as JavaScript literals it evaluates. It
|
// • swagger-autogen reads these as JavaScript literals it evaluates, so a
|
||||||
// re-quotes `"` and a backtick to `'` first, so either one inside a
|
// QUOTE CHARACTER inside a single-quoted description ends the string early.
|
||||||
// single-quoted description ends the string early — and when it cannot parse
|
// Both `'` and `"` — use a typographic apostrophe (’) in prose, and rewrite
|
||||||
// an annotation it drops that annotation, prints an error, and then reports
|
// a quoted phrase without the quotes. A backtick is fine: Markdown spans like
|
||||||
// success. Use a typographic apostrophe (’) in prose. `swaggerFragment.js`
|
// `online: false` below survive verbatim, and the fragment shows them.
|
||||||
// captures those errors and makes them fatal, which is the only reason you
|
//
|
||||||
// will find out.
|
// **The failure is silent, and this is the part worth remembering.** It is
|
||||||
|
// not always a parse error you get told about. A `"` in the middle of a
|
||||||
|
// description truncates the value at that character — `'A "quoted" status'`
|
||||||
|
// becomes `A "` — while swagger-autogen prints `Success` in green and the
|
||||||
|
// error capture below sees nothing to capture, because nothing threw. The
|
||||||
|
// only signal is `npm run check:swagger` reporting the fragment stale, whose
|
||||||
|
// message will blame your routes. When it does and your routes did not
|
||||||
|
// change, look for a quote in an annotation before you look anywhere else.
|
||||||
|
// (Measured, not inferred: docs/modules/kit-acceptance.md, F5.)
|
||||||
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
|
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
|
||||||
// evaluated as JS by the reader, so Swagger UI renders the backslash.
|
// evaluated as JS by the reader, so Swagger UI renders the backslash.
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,17 @@ async function main() {
|
|||||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
|
// Compared with line endings normalised, and that is not fussiness. A default
|
||||||
|
// Windows clone checks this file out as CRLF while the generator above writes
|
||||||
|
// LF, so a byte comparison failed on a PRISTINE template and told the reader
|
||||||
|
// their routes had changed — the kit's acceptance run lost ten minutes to it
|
||||||
|
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
|
||||||
|
// only fail for the reason it names; this one names a diagnosis, so it has to
|
||||||
|
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
|
||||||
|
// place, and this stops it mattering if it does.
|
||||||
|
const lf = (s) => s.replace(/\r\n/g, '\n')
|
||||||
|
|
||||||
|
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
||||||
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
||||||
|
|||||||
124
template/server/test/noGameConnection.test.js
Normal file
124
template/server/test/noGameConnection.test.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// ── §2.7's last rule, given the CI it does not have ───────────────────────
|
||||||
|
//
|
||||||
|
// `book/02-website-module.md` is explicit that "the website process never opens a
|
||||||
|
// connection to a game server" is the **one boundary rule with no CI behind it**:
|
||||||
|
// an outbound socket is not statically detectable the way an internal `require`
|
||||||
|
// is, so in general the rule is held up by review and by understanding it.
|
||||||
|
//
|
||||||
|
// True of the general case, and not a reason to check nothing. A module can state
|
||||||
|
// a narrower, completely decidable property about **itself**, and this one says:
|
||||||
|
// the shipped server half references no networking primitive at all. Everything
|
||||||
|
// it knows arrives from its own tables, which its sidecar writes.
|
||||||
|
//
|
||||||
|
// Adopted from the kit's acceptance run (`docs/modules/kit-acceptance.md`), where
|
||||||
|
// a reader building a Rust module wrote it unprompted after reading that the rule
|
||||||
|
// had no CI — and observed that for Rust in particular, which ships RCON over
|
||||||
|
// WebSocket, `new WebSocket(rconUrl)` in `boot.js` is about ten lines away.
|
||||||
|
//
|
||||||
|
// ── WHEN YOU ADD A SIDECAR CLIENT, NARROW THIS. DO NOT DELETE IT. ─────────
|
||||||
|
//
|
||||||
|
// Talking to *your sidecar* over HTTP is the expected shape and is not what §2.7
|
||||||
|
// forbids — the rule is about the **game server**. So the moment your module
|
||||||
|
// grows, say, `server/sidecarClient.js`, this test starts failing correctly and
|
||||||
|
// the fix is to allow that one file:
|
||||||
|
//
|
||||||
|
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
||||||
|
//
|
||||||
|
// and keep the rest of the tree under the ban. What you get for that is a test
|
||||||
|
// that names the *one* file allowed to reach the network — which is exactly the
|
||||||
|
// file a reviewer should be reading closely, and exactly the place a game-server
|
||||||
|
// URL would appear if the rule were ever broken.
|
||||||
|
//
|
||||||
|
// Scope: SHIPPED code only. `test/` and `scripts/` never run inside core's process.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const SERVER_ROOT = path.resolve(__dirname, '..')
|
||||||
|
const NOT_SHIPPED = new Set(['test', 'scripts', 'node_modules', 'swagger'])
|
||||||
|
|
||||||
|
/** Every shipped `.js` file under `server/`. */
|
||||||
|
function shippedFiles(dir = SERVER_ROOT, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (dir === SERVER_ROOT && NOT_SHIPPED.has(entry.name)) continue
|
||||||
|
if (entry.name === 'node_modules') continue
|
||||||
|
shippedFiles(path.join(dir, entry.name), out)
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||||
|
out.push(path.join(dir, entry.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank comments, so prose ABOUT the rule does not trip the rule.
|
||||||
|
*
|
||||||
|
* This file is itself the proof that it is needed: the paragraphs above say
|
||||||
|
* "WebSocket" several times. `scripts/checkImports.js` documents hitting exactly
|
||||||
|
* this on its own documentation, and it is the third time in this project's
|
||||||
|
* history that a boundary check has failed on the text explaining it.
|
||||||
|
*
|
||||||
|
* Blanked rather than deleted, so line numbers in a failure still point at the
|
||||||
|
* right line.
|
||||||
|
*/
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
||||||
|
.replace(/^[ \t]*\/\/.*$/gm, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each is a way a Node process opens a socket. Matched as identifiers, so a
|
||||||
|
// column named `websocket_url` inside a SQL string would not fire.
|
||||||
|
const NETWORKING = [
|
||||||
|
/\brequire\(\s*['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]\s*\)/,
|
||||||
|
/\bfrom\s+['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]/,
|
||||||
|
/\brequire\(\s*['"](?:ws|socket\.io-client|undici|axios|node-fetch|got)['"]\s*\)/,
|
||||||
|
/\bnew\s+WebSocket\b/,
|
||||||
|
/\bfetch\s*\(/,
|
||||||
|
/\bXMLHttpRequest\b/,
|
||||||
|
/\bEventSource\b/,
|
||||||
|
]
|
||||||
|
|
||||||
|
test('no shipped file references a networking primitive (§2.7)', () => {
|
||||||
|
const offenders = []
|
||||||
|
for (const file of shippedFiles()) {
|
||||||
|
const code = stripComments(fs.readFileSync(file, 'utf8'))
|
||||||
|
for (const pattern of NETWORKING) {
|
||||||
|
if (pattern.test(code)) {
|
||||||
|
offenders.push(`${path.relative(SERVER_ROOT, file)} matches ${pattern}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
offenders,
|
||||||
|
[],
|
||||||
|
'the website process must never open a connection to a game server. If this is ' +
|
||||||
|
'your sidecar client, allow that one file rather than removing the check — see ' +
|
||||||
|
`the header of this file.\n ${offenders.join('\n ')}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the check can actually fail — it is pointed at a real violation', () => {
|
||||||
|
// A check that has never been shown to fail is a check nobody knows the state
|
||||||
|
// of. This is the game-server dial the rule exists to stop.
|
||||||
|
const violation = "const socket = new WebSocket('ws://10.0.0.5:28016/' + rconPassword)"
|
||||||
|
assert.ok(
|
||||||
|
NETWORKING.some((p) => p.test(stripComments(violation))),
|
||||||
|
'the guard would not have caught a direct game-server dial',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prose describing the rule does not trip it', () => {
|
||||||
|
const prose = [
|
||||||
|
'// A game shipping RCON over WebSocket means a module COULD write',
|
||||||
|
"// const s = new WebSocket(url); require('net')",
|
||||||
|
'// in about ten lines. It must not.',
|
||||||
|
'const x = 1',
|
||||||
|
].join('\n')
|
||||||
|
for (const pattern of NETWORKING) {
|
||||||
|
assert.ok(!pattern.test(stripComments(prose)), `${pattern} fired on a comment`)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user