Merge pull request 'feat(template): a module that builds and loads — Phase 5 slice 1' (#2) from feat/template-module into main
Reviewed-on: #2
This commit is contained in:
@@ -4,11 +4,19 @@
|
|||||||
#
|
#
|
||||||
# ── What each job is really asking ───────────────────────────────────────────
|
# ── What each job is really asking ───────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# • `links` — every relative link resolves, and no link pins a reader to a
|
# • `prose` — the documentation, checked as far as documentation can be. Every
|
||||||
# commit snapshot of a document that moves. Nothing is fetched: this project's
|
# relative link resolves, and no link pins a reader to a commit snapshot of a
|
||||||
# Gitea is self-hosted, so an HTTP check would fail on a runner without
|
# document that moves. Nothing is fetched: this project's Gitea is self-hosted,
|
||||||
# credentials and teach everyone to ignore red. What breaks in practice is a
|
# so an HTTP check would fail on a runner without credentials and teach
|
||||||
# relative path after a file moves, and that is answerable offline.
|
# everyone to ignore red. What breaks in practice is a relative path after a
|
||||||
|
# file moves, and that is answerable offline.
|
||||||
|
#
|
||||||
|
# It also holds `template/README.md`'s rename checklist against the template
|
||||||
|
# tree, in both directions — an unlisted file that still carries the
|
||||||
|
# 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
|
||||||
|
# with the template, and it is prose, so it rots the way prose does. The two
|
||||||
|
# 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
|
||||||
@@ -60,7 +68,7 @@ env:
|
|||||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
links:
|
prose:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -70,11 +78,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
# No dependencies on purpose — this has to run on a clone with nothing
|
# No dependencies on purpose — every step in this job has to run on a clone
|
||||||
# installed, which is also how a reader will run it.
|
# with nothing installed, which is also how a reader will run them.
|
||||||
- name: Check every link in the book
|
- name: Check every link in the book
|
||||||
run: node scripts/checkLinks.js
|
run: node scripts/checkLinks.js
|
||||||
|
|
||||||
|
- name: Check the rename checklist against the template
|
||||||
|
run: node scripts/checkRenameSites.js
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# first thing a reader does.
|
||||||
|
- name: Test the checks themselves
|
||||||
|
run: node --test scripts/checkRenameSites.test.js
|
||||||
|
|
||||||
template:
|
template:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -136,3 +153,19 @@ jobs:
|
|||||||
- name: Run the template's tests
|
- name: Run the template's tests
|
||||||
if: steps.guard.outputs.present == 'true'
|
if: steps.guard.outputs.present == 'true'
|
||||||
run: npm test --prefix template/server
|
run: npm test --prefix template/server
|
||||||
|
|
||||||
|
# After the build, and that ordering is the point: two of the client tests
|
||||||
|
# read the BUILT chunk and SKIP when there is none. Run before the build,
|
||||||
|
# this job would report green while asking nothing about the artifact that
|
||||||
|
# ships — which is exactly how the first real module's two artifact tests sat
|
||||||
|
# green and inert.
|
||||||
|
- name: Run the template's client tests
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm test --prefix template/client
|
||||||
|
|
||||||
|
# The committed OpenAPI fragment, regenerated and compared. Core merges that
|
||||||
|
# file verbatim into its own spec, so a stale one documents a URL surface the
|
||||||
|
# module does not serve — and nothing at runtime will ever say so.
|
||||||
|
- name: Check the template's OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
run: npm run check:swagger --prefix template/server
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ 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 checks
|
still matches that core's `MODULE_API_VERSION`, builds the template and runs its
|
||||||
every link in the book. So a change to the contract breaks this repo's build
|
guards, checks every link in the book, and holds the template's rename checklist
|
||||||
loudly instead of leaving a chapter quietly wrong.
|
against the template's own tree. So a change to the contract breaks this repo's
|
||||||
|
build loudly instead of leaving a chapter quietly wrong.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"branch": "edge",
|
"branch": "edge",
|
||||||
"ref": "c3120ea3daee579ea9948e9e466065f25ee4e92f",
|
"ref": "1b692bf624404f9e4f924c231acfbfb7e9d0861a",
|
||||||
"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",
|
||||||
|
"template/module.json declares - slice 0 pinned its parent, before 1.4.0",
|
||||||
|
"existed, and the check below could not have passed against it.",
|
||||||
|
"",
|
||||||
"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",
|
||||||
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
||||||
|
|||||||
146
scripts/checkRenameSites.js
Normal file
146
scripts/checkRenameSites.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The rename checklist in `template/README.md`, checked against the tree.
|
||||||
|
//
|
||||||
|
// A reader's first action is to copy `template/` and make it theirs, and the only
|
||||||
|
// thing telling them where the placeholder name is buried is that table. A
|
||||||
|
// checklist nobody verifies is wrong by the second edit to the template — someone
|
||||||
|
// adds a file, mentions the placeholder id in it, and every reader after that
|
||||||
|
// ships a module with a stray `examplegame` in its OpenAPI tags.
|
||||||
|
//
|
||||||
|
// So this asserts the table and the tree agree, in BOTH directions:
|
||||||
|
//
|
||||||
|
// • every file that still mentions the placeholder is listed, and
|
||||||
|
// • every listed file exists and still mentions it.
|
||||||
|
//
|
||||||
|
// The second half is the one that is easy to leave out and is the more valuable:
|
||||||
|
// an entry that has stopped matching is an entry that will be read as instructions
|
||||||
|
// to edit something that is not there. Same rule the identifier check in core's CI
|
||||||
|
// follows about its own exemptions — an exemption that no longer matches fails the
|
||||||
|
// build rather than being quietly tolerated.
|
||||||
|
//
|
||||||
|
// **Why the placeholder is `examplegame` and not `example`.** This is a whole-file
|
||||||
|
// text search, and `example` appears in ordinary English ("for example") all over
|
||||||
|
// prose that is not a rename site at all. A placeholder that cannot occur by
|
||||||
|
// accident is what makes a check like this answerable rather than a source of
|
||||||
|
// false alarms someone eventually learns to ignore.
|
||||||
|
//
|
||||||
|
// Usage: node scripts/checkRenameSites.js (from the repo root)
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..')
|
||||||
|
const TEMPLATE = path.join(ROOT, 'template')
|
||||||
|
const CHECKLIST = path.join(TEMPLATE, 'README.md')
|
||||||
|
|
||||||
|
// Anything a rename has to touch: the id (`examplegame`), the display name
|
||||||
|
// ("Example Game"), and the placeholder world ("Example World"). One pattern
|
||||||
|
// rather than three, because they are one decision.
|
||||||
|
const PLACEHOLDER = /example[ -]?(game|world)/i
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// consequence of one.
|
||||||
|
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||||
|
|
||||||
|
// The checklist is the one file exempt from the scan: it is a table OF the
|
||||||
|
// placeholder and would trivially list itself.
|
||||||
|
const SELF = 'README.md'
|
||||||
|
|
||||||
|
/** Every file under `template/`, template-relative, sorted. */
|
||||||
|
function templateFiles(dir = TEMPLATE, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue
|
||||||
|
templateFiles(path.join(dir, entry.name), out)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
out.push(path.relative(TEMPLATE, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paths the checklist names, read from between its two markers.
|
||||||
|
*
|
||||||
|
* Delimited by explicit HTML comments rather than by looking for a heading or for
|
||||||
|
* every backticked path in the document: the README quotes plenty of paths in
|
||||||
|
* prose and in its tree diagram, and none of those are checklist entries. An
|
||||||
|
* explicit marker also means the table can be reformatted freely.
|
||||||
|
*/
|
||||||
|
function checklistPaths(markdown) {
|
||||||
|
const start = markdown.indexOf('<!-- rename-sites -->')
|
||||||
|
const end = markdown.indexOf('<!-- /rename-sites -->')
|
||||||
|
if (start === -1 || end === -1 || end < start) {
|
||||||
|
throw new Error(
|
||||||
|
'template/README.md has no <!-- rename-sites --> … <!-- /rename-sites --> block. ' +
|
||||||
|
'That block is the checklist this check exists to verify.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const table = markdown.slice(start, end)
|
||||||
|
const paths = []
|
||||||
|
for (const line of table.split('\n')) {
|
||||||
|
// A table row whose first cell is a backticked path.
|
||||||
|
const match = /^\|\s*`([^`]+)`\s*\|/.exec(line.trim())
|
||||||
|
if (match) paths.push(match[1])
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything wrong, as sentences. Empty means the checklist is current. */
|
||||||
|
function problems({ files, listed, contains }) {
|
||||||
|
const out = []
|
||||||
|
const listedSet = new Set(listed)
|
||||||
|
|
||||||
|
const duplicates = listed.filter((p, i) => listed.indexOf(p) !== i)
|
||||||
|
for (const p of new Set(duplicates)) out.push(`${p} is listed in the checklist twice.`)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === SELF) continue
|
||||||
|
if (!contains(file)) continue
|
||||||
|
if (!listedSet.has(file)) {
|
||||||
|
out.push(
|
||||||
|
`${file} still mentions the placeholder and is NOT in the rename checklist. ` +
|
||||||
|
'Add a row for it, or take the placeholder out of the file.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const present = new Set(files)
|
||||||
|
for (const file of listed) {
|
||||||
|
if (!present.has(file)) {
|
||||||
|
out.push(`the checklist lists ${file}, which does not exist. Remove the row or restore the file.`)
|
||||||
|
} else if (!contains(file)) {
|
||||||
|
out.push(
|
||||||
|
`the checklist lists ${file}, which no longer mentions the placeholder. ` +
|
||||||
|
'A row that has stopped matching tells a reader to edit something that is not there.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PLACEHOLDER, checklistPaths, problems, templateFiles, TEMPLATE }
|
||||||
|
|
||||||
|
if (require.main !== module) return
|
||||||
|
|
||||||
|
if (!fs.existsSync(TEMPLATE)) {
|
||||||
|
console.log('checkRenameSites: no template/ yet — nothing to check')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = templateFiles()
|
||||||
|
const listed = checklistPaths(fs.readFileSync(CHECKLIST, 'utf8'))
|
||||||
|
const contains = (file) => PLACEHOLDER.test(fs.readFileSync(path.join(TEMPLATE, file), 'utf8'))
|
||||||
|
|
||||||
|
const found = problems({ files, listed, contains })
|
||||||
|
|
||||||
|
if (found.length) {
|
||||||
|
console.error(`\n${found.length} problem(s) with the rename checklist in template/README.md:\n`)
|
||||||
|
for (const p of found) console.error(` - ${p}`)
|
||||||
|
console.error('')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OK — the rename checklist matches the template (${listed.length} files).`)
|
||||||
107
scripts/checkRenameSites.test.js
Normal file
107
scripts/checkRenameSites.test.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// The rename check, checked.
|
||||||
|
//
|
||||||
|
// A check that has never been shown to fail is a check nobody knows the state of.
|
||||||
|
// This one gates the only instructions a reader has for the first thing they do
|
||||||
|
// with the template, so both directions of it are exercised here against
|
||||||
|
// fixtures — no filesystem, because `problems()` takes its three inputs as
|
||||||
|
// arguments precisely so that it can be tested this way.
|
||||||
|
//
|
||||||
|
// Run by CI as `node --test scripts/`, which needs no dependencies and no
|
||||||
|
// package.json: Node's own test runner, over a repo with nothing installed.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const { PLACEHOLDER, checklistPaths, problems } = require('./checkRenameSites')
|
||||||
|
|
||||||
|
/** `problems()` with a `contains` built from a set of file names. */
|
||||||
|
const check = (files, listed, dirty) =>
|
||||||
|
problems({ files, listed, contains: (f) => new Set(dirty).has(f) })
|
||||||
|
|
||||||
|
test('a clean, complete checklist has no problems', () => {
|
||||||
|
assert.deepStrictEqual(check(['a.js', 'b.js', 'clean.js'], ['a.js', 'b.js'], ['a.js', 'b.js']), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a file that mentions the placeholder and is not listed fails', () => {
|
||||||
|
const found = check(['a.js', 'new.js'], ['a.js'], ['a.js', 'new.js'])
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0], /new\.js.*NOT in the rename checklist/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a listed file that no longer mentions the placeholder fails', () => {
|
||||||
|
// The direction that is easy to leave out, and the more valuable of the two: a
|
||||||
|
// row that has stopped matching reads as instructions to edit something that is
|
||||||
|
// not there any more.
|
||||||
|
const found = check(['a.js', 'b.js'], ['a.js', 'b.js'], ['a.js'])
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0], /b\.js.*no longer mentions/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a listed file that has been deleted fails', () => {
|
||||||
|
const found = check(['a.js'], ['a.js', 'gone.js'], ['a.js'])
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0], /gone\.js.*does not exist/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a duplicated row fails', () => {
|
||||||
|
const found = check(['a.js'], ['a.js', 'a.js'], ['a.js'])
|
||||||
|
assert.ok(found.some((p) => /listed in the checklist twice/.test(p)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the checklist file itself is exempt', () => {
|
||||||
|
// It is a table OF the placeholder, so it would otherwise always list itself.
|
||||||
|
assert.deepStrictEqual(check(['README.md'], [], ['README.md']), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the placeholder pattern matches every form a rename touches', () => {
|
||||||
|
for (const text of [
|
||||||
|
"const ID = 'examplegame'",
|
||||||
|
'ExamplegameWorldStatus',
|
||||||
|
'name: "Example Game"',
|
||||||
|
"worldName: 'Example World'",
|
||||||
|
'examplegame_world_status',
|
||||||
|
'example-game',
|
||||||
|
]) {
|
||||||
|
assert.ok(PLACEHOLDER.test(text), `should match: ${text}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the placeholder pattern does not fire on ordinary prose', () => {
|
||||||
|
// The reason the id is `examplegame` rather than `example`: a check that
|
||||||
|
// false-alarms on the word "example" in a comment is a check whose failures
|
||||||
|
// stop being read.
|
||||||
|
for (const text of [
|
||||||
|
'// for example, a router mounted under /shard',
|
||||||
|
'an example of what to catch',
|
||||||
|
'exampleValue',
|
||||||
|
'the game world',
|
||||||
|
]) {
|
||||||
|
assert.ok(!PLACEHOLDER.test(text), `should not match: ${text}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checklistPaths reads only the rows between the markers', () => {
|
||||||
|
const md = [
|
||||||
|
'# Heading',
|
||||||
|
'',
|
||||||
|
'Prose quoting `not/a/row.js` and a tree diagram.',
|
||||||
|
'',
|
||||||
|
'<!-- rename-sites -->',
|
||||||
|
'',
|
||||||
|
'| File | What to change |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| `module.json` | the id |',
|
||||||
|
'| `server/core.js` | the message |',
|
||||||
|
'',
|
||||||
|
'<!-- /rename-sites -->',
|
||||||
|
'',
|
||||||
|
'More prose about `also/not/a/row.js`.',
|
||||||
|
].join('\n')
|
||||||
|
assert.deepStrictEqual(checklistPaths(md), ['module.json', 'server/core.js'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a README with no markers is an error, not an empty checklist', () => {
|
||||||
|
// Silently reading zero entries would make every later assertion vacuous, and
|
||||||
|
// the check would pass on a README whose checklist someone deleted.
|
||||||
|
assert.throws(() => checklistPaths('# Heading\n\nno markers here\n'), /rename-sites/)
|
||||||
|
})
|
||||||
270
template/.gitea/workflows/release.yml
Normal file
270
template/.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# ── Publish an installable bundle (Gitea Actions) ─────────────────────────
|
||||||
|
#
|
||||||
|
# **This file does nothing where it sits.** Gitea only runs workflows found at
|
||||||
|
# the REPOSITORY root, and inside the kit this one is at `template/.gitea/…`. It
|
||||||
|
# arms itself the moment your copy of `template/` is a repository of its own —
|
||||||
|
# which is the point: packaging is the part of a module you cannot guess at, and
|
||||||
|
# copying a file beats retyping one out of a chapter.
|
||||||
|
#
|
||||||
|
# There is a GitHub Actions twin next door in `.github/workflows/release.yml`.
|
||||||
|
# Keep whichever host you use and delete the other.
|
||||||
|
#
|
||||||
|
# ── What a release IS ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# **An operator never builds anything.** That constraint is the shape of the
|
||||||
|
# whole module system, so a release is not source: it is the directory core's
|
||||||
|
# loader expects to find at `modules/<id>/`, already assembled — the prebuilt
|
||||||
|
# client chunk, any runtime dependency installed, the schema fragment and the
|
||||||
|
# OpenAPI fragment — packed exactly as it will be unpacked. The website's admin
|
||||||
|
# install downloads the tarball, verifies it against the `sha256` in the manifest,
|
||||||
|
# and unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||||
|
#
|
||||||
|
# ── The version is DECLARED, not derived ──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Your module already has one authoritative version: `module.json`'s. It is what
|
||||||
|
# core records in `installed_modules`, what the admin screen shows, and it sits
|
||||||
|
# beside the `coreApi` range you have to consider a bump against. Two sources for
|
||||||
|
# one number is how they drift — so **a release happens when a push to `main`
|
||||||
|
# leaves `module.json` at a version that has no release yet.** Bumping the version
|
||||||
|
# is an ordinary reviewed pull request; publishing is this file's business.
|
||||||
|
#
|
||||||
|
# It follows that this workflow never writes to a branch. It tags and publishes,
|
||||||
|
# so a protected `main` needs no push exception — which matters, because a release
|
||||||
|
# engine that has to push to `main` stops working the day someone tightens the
|
||||||
|
# rule. Re-running on an already-released version is a no-op.
|
||||||
|
#
|
||||||
|
# ── Before this can run ───────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 1. Change GITEA_HOST and REPO below to yours.
|
||||||
|
# 2. Settings → Actions → Secrets: add REGISTRY_TOKEN, a Gitea access token
|
||||||
|
# with `write:repository`, so the job can push the tag and create the release.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-module
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# ── CHANGE THESE ────────────────────────────────────────────────────────
|
||||||
|
GITEA_HOST: gitea.example.com
|
||||||
|
REPO: your-org/your-module
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Decide whether this commit releases
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="$(node -p "require('./module.json').id")"
|
||||||
|
VERSION="$(node -p "require('./module.json').version")"
|
||||||
|
echo "module.json: ${ID} ${VERSION}"
|
||||||
|
|
||||||
|
# Does a release already exist for this version? 404 means no, 200 means
|
||||||
|
# yes, and anything else — a network failure, a bad token — is not
|
||||||
|
# evidence of absence. Guessing "no" would publish over a good release,
|
||||||
|
# so refuse instead.
|
||||||
|
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||||
|
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||||
|
|
||||||
|
case "$HTTP" in
|
||||||
|
404) RELEASE=true ;;
|
||||||
|
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||||
|
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "id=${ID}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Before anything is built or tagged, so a repo without secrets fails
|
||||||
|
# legibly rather than half-publishing: the tag push can succeed on the
|
||||||
|
# credential `actions/checkout` left in the git config while the release API
|
||||||
|
# call 401s, leaving the repo tagged and unreleased.
|
||||||
|
- name: Verify release credentials are configured
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||||
|
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release credentials present."
|
||||||
|
|
||||||
|
- name: Build the client chunk
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client
|
||||||
|
|
||||||
|
# `--omit=dev`, and then PACKED. express and swagger-autogen are build- and
|
||||||
|
# test-time only — the shipped half is handed express on `ctx` — so this
|
||||||
|
# installs only what `dependencies` declares. Node resolves those by walking
|
||||||
|
# up from `modules/<id>/server/`, which is why they ship INSIDE the tarball
|
||||||
|
# rather than being installed on the operator's box.
|
||||||
|
#
|
||||||
|
# With no runtime dependencies at all this produces an empty tree and the
|
||||||
|
# copy below is a no-op. That is the shape to aim for.
|
||||||
|
- name: Install the shipped runtime dependencies
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: npm ci --omit=dev --prefix server
|
||||||
|
|
||||||
|
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||||
|
#
|
||||||
|
# Stated as an INCLUDE list, never an exclude list. An exclude list ships
|
||||||
|
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||||
|
# credential in it, an exclude list packs it and nobody finds out.
|
||||||
|
- name: Assemble the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
OUT="dist/${ID}-${VERSION}"
|
||||||
|
rm -rf dist && mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# The manifest core reads, the OpenAPI fragment, and the licence the
|
||||||
|
# code is under — a bundle shipping GPL code without its licence is not
|
||||||
|
# distributable.
|
||||||
|
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||||
|
|
||||||
|
# The server half, minus everything that never runs inside core's
|
||||||
|
# process: no `test/`, no `scripts/`, no `swagger/`.
|
||||||
|
mkdir -p "$OUT/server"
|
||||||
|
for d in boot.js core.js index.js db model router; do
|
||||||
|
cp -r "server/$d" "$OUT/server/"
|
||||||
|
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
|
||||||
|
# operator has no use for and core will never read.
|
||||||
|
mkdir -p "$OUT/client/dist"
|
||||||
|
cp client/dist/entry.js "$OUT/client/dist/"
|
||||||
|
|
||||||
|
# Prove the bundle is loadable before publishing it: these are the exact
|
||||||
|
# paths core's loader resolves out of module.json. A release whose entry
|
||||||
|
# point is missing otherwise fails on an operator's box, as a
|
||||||
|
# `startup_failed` row, instead of here.
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs"), path = require("path");
|
||||||
|
const root = process.argv[1];
|
||||||
|
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||||
|
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
|
||||||
|
if (!p) continue;
|
||||||
|
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log("bundle contents check: ok");
|
||||||
|
' "$OUT"
|
||||||
|
|
||||||
|
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
|
||||||
|
SHA="$(sha256sum "dist/${ID}-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
|
SIZE="$(stat -c%s "dist/${ID}-${VERSION}.tar.gz")"
|
||||||
|
|
||||||
|
# The install manifest — the URL an operator pastes into Admin →
|
||||||
|
# Modules. A per-asset sha256 fetched over HTTPS, no signatures.
|
||||||
|
jq -n \
|
||||||
|
--arg id "$ID" \
|
||||||
|
--arg name "$(node -p "require('./module.json').name")" \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||||
|
--arg artifact "${ID}-${VERSION}.tar.gz" \
|
||||||
|
--arg sha256 "$SHA" \
|
||||||
|
--argjson size "$SIZE" \
|
||||||
|
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/${ID}-${VERSION}.tar.gz" \
|
||||||
|
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||||
|
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||||
|
> "dist/${ID}-${VERSION}.json"
|
||||||
|
|
||||||
|
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
cat "dist/${ID}-${VERSION}.json"
|
||||||
|
|
||||||
|
- name: Write the changelog
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
|
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||||
|
{
|
||||||
|
echo "## ${ID} v${VERSION}"
|
||||||
|
echo
|
||||||
|
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||||
|
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
|
||||||
|
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||||
|
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||||
|
echo
|
||||||
|
echo "### Changes"
|
||||||
|
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||||
|
git log --no-merges --format='- %s' $RANGE || true
|
||||||
|
echo
|
||||||
|
echo "### Verifying this download"
|
||||||
|
echo
|
||||||
|
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
|
||||||
|
echo "trust anchor, and the website verifies it before unpacking."
|
||||||
|
echo
|
||||||
|
echo '```bash'
|
||||||
|
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||||
|
echo '```'
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Tag the release
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
git config user.name 'Module CI'
|
||||||
|
git config user.email 'ci@example.com'
|
||||||
|
git tag -a "$TAG" -m "${{ steps.plan.outputs.id }} ${TAG}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
- name: Create the release and upload the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||||
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||||
|
| jq -r '.id')"
|
||||||
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
|
for f in "${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}.json" SHA256SUMS; do
|
||||||
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
|
echo " uploaded ${f}"
|
||||||
|
done
|
||||||
241
template/.github/workflows/release.yml
vendored
Normal file
241
template/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# ── Publish an installable bundle (GitHub Actions) ────────────────────────
|
||||||
|
#
|
||||||
|
# The GitHub twin of `.gitea/workflows/release.yml`. **Keep whichever host your
|
||||||
|
# module lives on and delete the other** — nothing breaks if both are present,
|
||||||
|
# but two release engines racing to tag the same version is a mess nobody needs.
|
||||||
|
#
|
||||||
|
# **This file does nothing where it sits.** Workflows run only from the
|
||||||
|
# REPOSITORY root, and inside the kit this one is at `template/.github/…`. It arms
|
||||||
|
# itself the moment your copy of `template/` is a repository of its own.
|
||||||
|
#
|
||||||
|
# Nothing about a module's release depends on where it is hosted: core installs
|
||||||
|
# from a **URL**. Point Admin → Modules at the install manifest this job attaches
|
||||||
|
# to the release and add your host to the website's `MODULE_SOURCE_HOSTS`
|
||||||
|
# allowlist, and a module released here installs exactly like one released
|
||||||
|
# anywhere else.
|
||||||
|
#
|
||||||
|
# ── What a release IS ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# **An operator never builds anything.** So a release is not source: it is the
|
||||||
|
# directory core's loader expects to find at `modules/<id>/`, already assembled —
|
||||||
|
# the prebuilt client chunk, any runtime dependency installed, the schema fragment
|
||||||
|
# and the OpenAPI fragment — packed exactly as it will be unpacked. The website
|
||||||
|
# downloads the tarball, verifies it against the `sha256` in the manifest, and
|
||||||
|
# unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||||
|
#
|
||||||
|
# ── The version is DECLARED, not derived ──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Your module already has one authoritative version: `module.json`'s. It is what
|
||||||
|
# core records in `installed_modules` and what the admin screen shows. Two sources
|
||||||
|
# for one number is how they drift — so **a release happens when a push to `main`
|
||||||
|
# leaves `module.json` at a version that has no release yet.** Bumping the version
|
||||||
|
# is an ordinary reviewed pull request; publishing is this file's business.
|
||||||
|
#
|
||||||
|
# This workflow never writes to a branch, so a protected `main` needs no push
|
||||||
|
# exception. Re-running on an already-released version is a no-op.
|
||||||
|
#
|
||||||
|
# ── Before this can run ───────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Nothing to configure. `GITHUB_TOKEN` is provided automatically; the `contents:
|
||||||
|
# write` permission below is what lets it push a tag and create a release.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-module
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Decide whether this commit releases
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="$(node -p "require('./module.json').id")"
|
||||||
|
VERSION="$(node -p "require('./module.json').version")"
|
||||||
|
echo "module.json: ${ID} ${VERSION}"
|
||||||
|
|
||||||
|
# `gh release view` exits non-zero when the release does not exist — but
|
||||||
|
# it also exits non-zero when the API is unreachable, and those two are
|
||||||
|
# not the same answer. Ask for the status code instead: 404 means no,
|
||||||
|
# 200 means yes, anything else is not evidence of absence, and guessing
|
||||||
|
# "no" would publish over a good release.
|
||||||
|
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)"
|
||||||
|
|
||||||
|
case "$HTTP" in
|
||||||
|
404) RELEASE=true ;;
|
||||||
|
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||||
|
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "id=${ID}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build the client chunk
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client
|
||||||
|
|
||||||
|
# `--omit=dev`, and then PACKED. express and swagger-autogen are build- and
|
||||||
|
# test-time only — the shipped half is handed express on `ctx` — so this
|
||||||
|
# installs only what `dependencies` declares. Node resolves those by walking
|
||||||
|
# up from `modules/<id>/server/`, which is why they ship INSIDE the tarball
|
||||||
|
# rather than being installed on the operator's box.
|
||||||
|
#
|
||||||
|
# With no runtime dependencies at all this produces an empty tree and the
|
||||||
|
# copy below is a no-op. That is the shape to aim for.
|
||||||
|
- name: Install the shipped runtime dependencies
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: npm ci --omit=dev --prefix server
|
||||||
|
|
||||||
|
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||||
|
#
|
||||||
|
# Stated as an INCLUDE list, never an exclude list. An exclude list ships
|
||||||
|
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||||
|
# credential in it, an exclude list packs it and nobody finds out.
|
||||||
|
- name: Assemble the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
OUT="dist/${ID}-${VERSION}"
|
||||||
|
rm -rf dist && mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# The manifest core reads, the OpenAPI fragment, and the licence the
|
||||||
|
# code is under — a bundle shipping GPL code without its licence is not
|
||||||
|
# distributable.
|
||||||
|
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||||
|
|
||||||
|
# The server half, minus everything that never runs inside core's
|
||||||
|
# process: no `test/`, no `scripts/`, no `swagger/`.
|
||||||
|
mkdir -p "$OUT/server"
|
||||||
|
for d in boot.js core.js index.js db model router; do
|
||||||
|
cp -r "server/$d" "$OUT/server/"
|
||||||
|
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.
|
||||||
|
mkdir -p "$OUT/client/dist"
|
||||||
|
cp client/dist/entry.js "$OUT/client/dist/"
|
||||||
|
|
||||||
|
# Prove the bundle is loadable before publishing it: these are the exact
|
||||||
|
# paths core's loader resolves out of module.json. A release whose entry
|
||||||
|
# point is missing otherwise fails on an operator's box, as a
|
||||||
|
# `startup_failed` row, instead of here.
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs"), path = require("path");
|
||||||
|
const root = process.argv[1];
|
||||||
|
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||||
|
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
|
||||||
|
if (!p) continue;
|
||||||
|
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log("bundle contents check: ok");
|
||||||
|
' "$OUT"
|
||||||
|
|
||||||
|
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
|
||||||
|
SHA="$(sha256sum "dist/${ID}-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
|
SIZE="$(stat -c%s "dist/${ID}-${VERSION}.tar.gz")"
|
||||||
|
|
||||||
|
# The install manifest — the URL an operator pastes into Admin →
|
||||||
|
# Modules. A per-asset sha256 fetched over HTTPS, no signatures.
|
||||||
|
jq -n \
|
||||||
|
--arg id "$ID" \
|
||||||
|
--arg name "$(node -p "require('./module.json').name")" \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||||
|
--arg artifact "${ID}-${VERSION}.tar.gz" \
|
||||||
|
--arg sha256 "$SHA" \
|
||||||
|
--argjson size "$SIZE" \
|
||||||
|
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/v${VERSION}/${ID}-${VERSION}.tar.gz" \
|
||||||
|
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||||
|
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||||
|
> "dist/${ID}-${VERSION}.json"
|
||||||
|
|
||||||
|
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
cat "dist/${ID}-${VERSION}.json"
|
||||||
|
|
||||||
|
- name: Write the changelog
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
|
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||||
|
{
|
||||||
|
echo "## ${ID} v${VERSION}"
|
||||||
|
echo
|
||||||
|
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||||
|
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
|
||||||
|
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||||
|
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||||
|
echo
|
||||||
|
echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —"
|
||||||
|
echo "an operator installing this needs \`github.com\` on theirs."
|
||||||
|
echo
|
||||||
|
echo "### Changes"
|
||||||
|
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||||
|
git log --no-merges --format='- %s' $RANGE || true
|
||||||
|
echo
|
||||||
|
echo "### Verifying this download"
|
||||||
|
echo
|
||||||
|
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
|
||||||
|
echo "trust anchor, and the website verifies it before unpacking."
|
||||||
|
echo
|
||||||
|
echo '```bash'
|
||||||
|
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||||
|
echo '```'
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Tag and publish
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ID="${{ steps.plan.outputs.id }}"
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
|
||||||
|
git config user.name 'github-actions[bot]'
|
||||||
|
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
git tag -a "$TAG" -m "${ID} ${TAG}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--title "$TAG" \
|
||||||
|
--notes-file dist/CHANGELOG.md \
|
||||||
|
"dist/${ID}-${VERSION}.tar.gz" \
|
||||||
|
"dist/${ID}-${VERSION}.json" \
|
||||||
|
dist/SHA256SUMS
|
||||||
674
template/LICENSE.md
Normal file
674
template/LICENSE.md
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
145
template/README.md
Normal file
145
template/README.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# The template module
|
||||||
|
|
||||||
|
A Runic Gateway module that builds, loads, and does almost nothing. Copy it,
|
||||||
|
rename it, and you have a running module before you have read a chapter.
|
||||||
|
|
||||||
|
Installed into a core, it adds:
|
||||||
|
|
||||||
|
- **one public page** at `/examplegame/status`, and a nav row pointing at it;
|
||||||
|
- **one API route**, `GET /api/v1/public/world/status`, described in an OpenAPI
|
||||||
|
fragment core merges into its own `/api/docs`;
|
||||||
|
- **one table**, `examplegame_world_status`, created by an idempotent schema
|
||||||
|
fragment and dropped by a purge file;
|
||||||
|
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
|
||||||
|
|
||||||
|
That is deliberately less than your module will do. What it is *complete* about is
|
||||||
|
the shape: every seam a real module uses is here once, with the reasoning next to
|
||||||
|
it, and CI proves the whole thing still builds against a pinned core.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
```
|
||||||
|
module.json what core reads first — id, version, coreApi, mounts
|
||||||
|
server/
|
||||||
|
index.js register(ctx, api) — the entire server-side handshake
|
||||||
|
core.js the lazy accessors over ctx; read this second
|
||||||
|
boot.js onBoot / onShutdown
|
||||||
|
db/schema.sql idempotent, replayed every boot
|
||||||
|
db/purge.sql destructive, run only by an explicit admin purge
|
||||||
|
model/worldStatus/ the .db.js / .model.js pair
|
||||||
|
router/public/ one router, one controller, the #swagger annotations
|
||||||
|
swagger/doc.js tags and schemas the annotations refer to
|
||||||
|
scripts/checkImports.js the module boundary, enforced
|
||||||
|
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
|
||||||
|
test/ the suites — start with entry.test.js
|
||||||
|
client/
|
||||||
|
vite.config.js the library build: anchored aliases, external: []
|
||||||
|
src/entry.jsx registers routes and nav at evaluation time
|
||||||
|
src/core.js what core hands you: the seven-member UI kit
|
||||||
|
src/shim/ the four shared dependencies, re-exported from core
|
||||||
|
src/routes/public/ the page
|
||||||
|
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
|
||||||
|
test/ build.test.js and registration.test.js
|
||||||
|
.gitea/workflows/release.yml packaging CI — Gitea
|
||||||
|
.github/workflows/release.yml the same, for GitHub. Keep one, delete the other.
|
||||||
|
swagger-fragment.json generated; commit it
|
||||||
|
```
|
||||||
|
|
||||||
|
Neither workflow runs while it sits inside the kit — a workflow is only read from
|
||||||
|
a repository root. They arm themselves when your copy is a repository of its own.
|
||||||
|
|
||||||
|
## Build it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --prefix server
|
||||||
|
npm test --prefix server
|
||||||
|
npm run check:imports --prefix server
|
||||||
|
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client # → client/dist/entry.js, the chunk that ships
|
||||||
|
npm run check:externals --prefix client
|
||||||
|
npm test --prefix client # build FIRST: two of these tests read the chunk
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` in `client/` passes with no build, by skipping the tests that need one.
|
||||||
|
That is on purpose — the suite has to be runnable before the build — and it means
|
||||||
|
**a CI job that tests without building is a job asking nothing.** Build first.
|
||||||
|
|
||||||
|
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run swagger --prefix server # writes swagger-fragment.json
|
||||||
|
npm run check:swagger --prefix server # fails if it is stale
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install it
|
||||||
|
|
||||||
|
Three supported ways, and none of them builds anything on the operator's machine:
|
||||||
|
|
||||||
|
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON the
|
||||||
|
release workflow attaches beside the tarball. This is how an operator installs
|
||||||
|
your module.
|
||||||
|
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
|
||||||
|
deployment that declares its module set rather than clicking it.
|
||||||
|
3. **A directory on the volume.** Copy this whole tree to `<website>/modules/<id>/`
|
||||||
|
and restart. The fastest loop while you are developing.
|
||||||
|
|
||||||
|
For (3): **copy, do not symlink.** The loader lists directory entries and a
|
||||||
|
symlink is not a directory, so a linked module is skipped in silence.
|
||||||
|
|
||||||
|
## Rename it
|
||||||
|
|
||||||
|
Change `id` in `module.json` first, then work down the list. Nothing here is
|
||||||
|
subtle, and the suites catch most of a half-finished job: `schema.test.js` fails
|
||||||
|
the moment a table name stops matching the id, and `registration.test.js` fails
|
||||||
|
when a nav row stops matching its route.
|
||||||
|
|
||||||
|
Your id must match `^[a-z][a-z0-9-]{1,31}$`, must equal the directory name core
|
||||||
|
loads you from, and becomes your table prefix — so **no hyphen unless you enjoy
|
||||||
|
backticking table names**.
|
||||||
|
|
||||||
|
<!-- rename-sites -->
|
||||||
|
|
||||||
|
| File | What to change |
|
||||||
|
| --- | --- |
|
||||||
|
| `module.json` | `id`, `name`, `version`, the `mounts` prefix, `capabilities` |
|
||||||
|
| `server/package.json` | package `name` and `description` |
|
||||||
|
| `server/core.js` | the message every accessor throws |
|
||||||
|
| `server/boot.js` | the placeholder world name |
|
||||||
|
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||||
|
| `server/db/purge.sql` | the same table names |
|
||||||
|
| `server/model/worldStatus/worldStatus.db.js` | the `TABLE` constant |
|
||||||
|
| `server/router/public/world.router.js` | the `#swagger.tags` name |
|
||||||
|
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
|
||||||
|
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||||
|
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||||
|
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||||
|
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||||
|
| `client/package.json` | package `name` and `description` |
|
||||||
|
| `client/vite.config.js` | the guard plugin's `name` |
|
||||||
|
| `client/src/core.js` | the console tag on the identity check |
|
||||||
|
| `client/src/shim/rg.js` | the console tag on the missing-global error |
|
||||||
|
| `client/src/entry.jsx` | `ID`, and every route path and nav `to` |
|
||||||
|
| `client/test/registration.test.js` | the example path in the comment |
|
||||||
|
| `client/package-lock.json` | **regenerated** — `npm install --prefix client` |
|
||||||
|
| `swagger-fragment.json` | **regenerated** — `npm run swagger --prefix server` |
|
||||||
|
|
||||||
|
<!-- /rename-sites -->
|
||||||
|
|
||||||
|
That table is checked. `scripts/checkRenameSites.js` at the root of this kit
|
||||||
|
compares it against the tree on every pull request: a file that still mentions the
|
||||||
|
placeholder and is not listed fails the build, and so does a listed file with
|
||||||
|
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
|
||||||
|
by the second edit.
|
||||||
|
|
||||||
|
Two things you do **not** rename: the mount prefix `/world` need not be your id
|
||||||
|
(the server's prefix namespace is shared with core's, and `/status`, `/settings`,
|
||||||
|
`/version` and `/contact` are already taken), and the `world` / `worldStatus`
|
||||||
|
naming throughout is ordinary vocabulary you should replace with your own domain's
|
||||||
|
when you replace the feature.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later, like everything else in this project — see
|
||||||
|
[LICENSE.md](LICENSE.md). This directory is meant to be copied and made yours; it
|
||||||
|
carries that licence, and so does anything derived from it.
|
||||||
1792
template/client/package-lock.json
generated
Normal file
1792
template/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
template/client/package.json
Normal file
24
template/client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "examplegame-module-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Client half of the Example Game module — a prebuilt ESM chunk core injects into its own SPA",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "node --test",
|
||||||
|
"check:externals": "node scripts/checkExternals.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
template/client/scripts/checkExternals.js
Normal file
172
template/client/scripts/checkExternals.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
|
||||||
|
//
|
||||||
|
// The server half's boundary check reads source. The client half's has to read
|
||||||
|
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
|
||||||
|
// source: `import { useState } from 'react'` is correct in every file, and
|
||||||
|
// whether it ends up as core's React or as a second copy welded into the chunk
|
||||||
|
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
|
||||||
|
// see until a hook throws in the browser.
|
||||||
|
//
|
||||||
|
// So: build, then ask the artifact two questions.
|
||||||
|
//
|
||||||
|
// 1. **Is there a bare import left?** There must not be. Aliased shims are
|
||||||
|
// bundled, so a surviving bare specifier means an alias missed and
|
||||||
|
// `external` caught it — the loud failure the config prefers, but still a
|
||||||
|
// failure, and better found here than by a browser refusing to load.
|
||||||
|
// 2. **Did a shared dependency get bundled?** React's own source has
|
||||||
|
// fingerprints that no module of ours would contain by accident. Finding
|
||||||
|
// one means the chunk carries a second React, which is the silent version
|
||||||
|
// of the same mistake and the one worth the fingerprint check.
|
||||||
|
//
|
||||||
|
// Run after `npm run build`, in CI, on the artifact that ships.
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which characters of the chunk are inside a string, template or comment.
|
||||||
|
*
|
||||||
|
* **A check that reads code with a regexp fails on code that talks about
|
||||||
|
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||||
|
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||||
|
* because a button reading "Approve and import" put the token `import`
|
||||||
|
* immediately before a quote and the pattern could not tell that from a
|
||||||
|
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||||
|
* anything.
|
||||||
|
*
|
||||||
|
* The server half hit the same wall from the other side and answered it the same
|
||||||
|
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||||
|
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||||
|
* inside a string, because that distinction is a property of the parse.
|
||||||
|
*
|
||||||
|
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||||
|
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||||
|
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||||
|
* would take the answer with the noise.
|
||||||
|
*/
|
||||||
|
export function stringMask(src) {
|
||||||
|
const inString = new Uint8Array(src.length)
|
||||||
|
let i = 0
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i]
|
||||||
|
const two = src.slice(i, i + 2)
|
||||||
|
if (two === '//') {
|
||||||
|
const nl = src.indexOf('\n', i)
|
||||||
|
const end = nl === -1 ? src.length : nl
|
||||||
|
inString.fill(1, i, end)
|
||||||
|
i = end
|
||||||
|
} else if (two === '/*') {
|
||||||
|
const close = src.indexOf('*/', i + 2)
|
||||||
|
const end = close === -1 ? src.length : close + 2
|
||||||
|
inString.fill(1, i, end)
|
||||||
|
i = end
|
||||||
|
} else if (c === '"' || c === "'" || c === '`') {
|
||||||
|
// The opening quote itself stays unmasked: a specifier is read starting
|
||||||
|
// at its quote, and the regexp below anchors on that.
|
||||||
|
i += 1
|
||||||
|
while (i < src.length && src[i] !== c) {
|
||||||
|
// A backslash escapes the next character, including the closing quote.
|
||||||
|
const step = src[i] === '\\' ? 2 : 1
|
||||||
|
inString.fill(1, i, Math.min(i + step, src.length))
|
||||||
|
i += step
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static and dynamic imports that survived into the output. A relative or
|
||||||
|
// absolute specifier is a chunk that was split, which this build does not do —
|
||||||
|
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||||
|
//
|
||||||
|
// **This pattern used to require whitespace after `import`, and so could not see
|
||||||
|
// the one shape the build actually emits.** Minified Rollup output is
|
||||||
|
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||||
|
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||||
|
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||||
|
// import — the most likely way for an alias to miss — would have passed this
|
||||||
|
// check silently. It was found by writing the test for the false POSITIVE above
|
||||||
|
// it, which is the argument for testing a check against both answers.
|
||||||
|
//
|
||||||
|
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||||
|
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||||
|
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||||
|
// import's parenthesis.
|
||||||
|
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||||
|
|
||||||
|
/** Every bare specifier the chunk still imports at runtime. */
|
||||||
|
export function bareImports(chunk) {
|
||||||
|
const masked = stringMask(chunk)
|
||||||
|
const bare = new Set()
|
||||||
|
for (const match of chunk.matchAll(IMPORTS)) {
|
||||||
|
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||||
|
// unless the match began at position 0.
|
||||||
|
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||||
|
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||||
|
const specifier = match[1]
|
||||||
|
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||||
|
}
|
||||||
|
return [...bare]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||||
|
// packages ship and this module has no other reason to contain.
|
||||||
|
//
|
||||||
|
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||||
|
// library's source arrives as code AND as its own error-message strings, and
|
||||||
|
// masking would discard half the evidence. The direction of the risk is opposite
|
||||||
|
// to the import check's — here a false positive is a fingerprint too generic,
|
||||||
|
// which is a fixable choice of probe, not a property of the parse.
|
||||||
|
const BUNDLED = [
|
||||||
|
{ what: 'react', probe: 'react.development.js' },
|
||||||
|
{ what: 'react', probe: 'Invalid hook call' },
|
||||||
|
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||||
|
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||||
|
export function problemsWith(chunk) {
|
||||||
|
const problems = []
|
||||||
|
const bare = bareImports(chunk)
|
||||||
|
if (bare.length) {
|
||||||
|
problems.push(
|
||||||
|
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||||
|
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||||
|
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const { what, probe } of BUNDLED) {
|
||||||
|
if (chunk.includes(probe)) {
|
||||||
|
problems.push(
|
||||||
|
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||||
|
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||||
|
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only when run as a script. Importing this from a test must not read a chunk
|
||||||
|
// that may not have been built, and must not call process.exit.
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
if (!fs.existsSync(CHUNK)) {
|
||||||
|
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||||
|
if (problems.length) {
|
||||||
|
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||||
|
for (const p of problems) console.error(` - ${p}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||||
|
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||||
|
}
|
||||||
34
template/client/src/api.js
Normal file
34
template/client/src/api.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// ── This module's own API bindings ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||||
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
||||||
|
// `ApiError` thrown on any non-2xx. The paths are yours, because the routes at
|
||||||
|
// the other end are yours — `server/router/**` in this repo serves them.
|
||||||
|
//
|
||||||
|
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
||||||
|
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
||||||
|
// knows how to render. A module that calls `fetch` directly gets none of that
|
||||||
|
// and finds out one page at a time.
|
||||||
|
//
|
||||||
|
// Keeping the bindings in one file, ordered the way the routers are, is
|
||||||
|
// convention rather than contract — but the two halves of every call live in
|
||||||
|
// different directories and nothing checks them against each other, so anything
|
||||||
|
// that makes a mismatch easy to see is worth doing.
|
||||||
|
|
||||||
|
import rg from './core.js'
|
||||||
|
|
||||||
|
const { request: req, BASE } = rg.api
|
||||||
|
|
||||||
|
// ── public ────────────────────────────────────────────────────────────────
|
||||||
|
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
||||||
|
// `/api/v1/public/world/status` — the route `server/router/public/world.router.js`
|
||||||
|
// registers under the `/world` prefix `module.json` declares.
|
||||||
|
export const world = {
|
||||||
|
status: () => req('/public/world/status'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||||
|
// download link, an EventSource. Reach for `request` first.
|
||||||
|
export { BASE }
|
||||||
|
|
||||||
|
export default { world, BASE }
|
||||||
77
template/client/src/core.js
Normal file
77
template/client/src/core.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// ── What core hands this module, on the client side ────────────────────────
|
||||||
|
//
|
||||||
|
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||||
|
// Every page imports its layout, its state components and its hooks from here,
|
||||||
|
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
|
||||||
|
// §3.4.
|
||||||
|
//
|
||||||
|
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||||
|
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||||
|
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||||
|
// would capture `undefined` at file scope. There is no such gap here.
|
||||||
|
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||||
|
// and every module chunk is a deferred script the server injects *after* that
|
||||||
|
// bundle's tag, so by the time the first line of this file executes the global
|
||||||
|
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||||
|
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
|
||||||
|
// than being wrapped in an accessor that would cost it its identity.
|
||||||
|
//
|
||||||
|
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||||
|
// also goes through — the shims touch the global before this file does, so a
|
||||||
|
// check here would be unreachable.
|
||||||
|
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { rg as shared } from './shim/rg.js'
|
||||||
|
|
||||||
|
const rg = shared()
|
||||||
|
|
||||||
|
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
|
||||||
|
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
|
||||||
|
// finished artifact — and both reason about the chunk in isolation. Neither can
|
||||||
|
// see the one failure that only exists once the chunk meets a core: a
|
||||||
|
// `window.__rg` whose React is not the React that rendered the page.
|
||||||
|
//
|
||||||
|
// Identity is the only question worth asking. A second React satisfies every
|
||||||
|
// type check, renders its first element happily, and then throws about an invalid
|
||||||
|
// hook call somewhere unrelated — in a component that has nothing to do with it.
|
||||||
|
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||||
|
console.error(
|
||||||
|
'[examplegame] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||||
|
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
|
||||||
|
// 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 —
|
||||||
|
// 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
|
||||||
|
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing a
|
||||||
|
// kit component's props 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
|
||||||
|
// in, and drifts further every time core changes.
|
||||||
|
export const {
|
||||||
|
PublicLayout,
|
||||||
|
PageHeader,
|
||||||
|
Loading,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
useAsync,
|
||||||
|
useAuth,
|
||||||
|
useSite,
|
||||||
|
} = rg.ui
|
||||||
|
|
||||||
|
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||||
|
export const registry = rg.registry
|
||||||
|
|
||||||
|
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||||
|
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||||
|
// ever served, so there is nothing to re-check, only something to report.
|
||||||
|
export const coreApiVersion = rg.version
|
||||||
|
|
||||||
|
export default rg
|
||||||
79
template/client/src/entry.jsx
Normal file
79
template/client/src/entry.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// ── The client entry point ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core serves `dist/entry.js` from your module's directory and injects it into
|
||||||
|
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
|
||||||
|
// This file registers what the module has; core renders it. Normative:
|
||||||
|
// MODULE_API.md §3.3.
|
||||||
|
//
|
||||||
|
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||||
|
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||||
|
// is published — and before core's first render. There is no subscription and no
|
||||||
|
// late registration: 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.
|
||||||
|
//
|
||||||
|
// So everything below is a plain top-level call and every page is a STATIC
|
||||||
|
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
|
||||||
|
// and it is the one thing this seam cannot have.
|
||||||
|
|
||||||
|
import { registry, coreApiVersion } from './core.js'
|
||||||
|
|
||||||
|
import WorldStatus from './routes/public/WorldStatus.jsx'
|
||||||
|
|
||||||
|
// Your module id, exactly as `module.json` spells it. Core keys the registry by
|
||||||
|
// it and prefixes every route path with it.
|
||||||
|
const ID = 'examplegame'
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Paths are relative to your module's namespace and core prefixes them. Whatever
|
||||||
|
// you write here, a public route lands at `/<id>/<path>`, an admin route at
|
||||||
|
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. You cannot
|
||||||
|
// write the segment your routes hang under, which is the point: two modules
|
||||||
|
// installed side by side cannot collide, and an operator can see from a URL which
|
||||||
|
// module served it.
|
||||||
|
//
|
||||||
|
// So this one page is at `/examplegame/status`.
|
||||||
|
//
|
||||||
|
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||||
|
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||||
|
// possible, because the sidebar and the route table have to agree about who may
|
||||||
|
// see what, and they only do if one thing decides.
|
||||||
|
registry.registerRoutes(ID, {
|
||||||
|
public: [
|
||||||
|
{ path: 'status', element: <WorldStatus /> },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A registered row is an ORDINARY row from here on. It interleaves into core's
|
||||||
|
// own navigation, and an operator can reorder it, relabel it or hide it from the
|
||||||
|
// admin nav editor exactly as they can core's — because the interleave happens
|
||||||
|
// before the override merge, and the override layer is keyed by `to`.
|
||||||
|
//
|
||||||
|
// Three fields worth knowing before you need them:
|
||||||
|
//
|
||||||
|
// • `order` places the row among core's, which are keyed by their index. A row
|
||||||
|
// with NO order appends after them, rather than defaulting to 0 — otherwise
|
||||||
|
// "I didn't ask for a position" would mean "put me first".
|
||||||
|
// • `group` (admin sidebar) names an existing core group; an unknown name
|
||||||
|
// appends a new group at the end rather than dropping the row.
|
||||||
|
// • `icon` is a component, and core supplies no fallback. Public header rows
|
||||||
|
// carry no icons, so there is none here — but an admin or player row without
|
||||||
|
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||||
|
// Match the nav you are landing in: the admin sidebar draws at 18px with a
|
||||||
|
// 1.6 stroke, the player portal at 16px with a 2.
|
||||||
|
registry.registerNav(ID, {
|
||||||
|
area: 'public',
|
||||||
|
items: [
|
||||||
|
{ label: 'World', to: '/examplegame/status' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||||
|
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||||
|
// between the core that validated your manifest and the core that published this
|
||||||
|
// global is otherwise invisible from the browser, which is where the client half
|
||||||
|
// actually fails.
|
||||||
|
console.info(`[${ID}] registered against core API ${coreApiVersion}`)
|
||||||
65
template/client/src/routes/public/WorldStatus.jsx
Normal file
65
template/client/src/routes/public/WorldStatus.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// ── The one page ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
|
||||||
|
// 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
|
||||||
|
// the contract (§3.3). Admin and player routes are the other way round: core
|
||||||
|
// wraps those in their layouts for you.
|
||||||
|
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||||
|
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||||
|
// one fewer thing an operator ships.
|
||||||
|
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
|
||||||
|
function ago(iso) {
|
||||||
|
if (!iso) return 'never'
|
||||||
|
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||||
|
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||||
|
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorldStatus() {
|
||||||
|
// `useAsync` is core's fetch/loading/error hook, and the three components
|
||||||
|
// below are its three states. Using them rather than rolling your own is what
|
||||||
|
// makes a module page indistinguishable from a core one while it loads and
|
||||||
|
// while it fails.
|
||||||
|
const { data, loading, error } = useAsync(() => api.world.status(), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="World status"
|
||||||
|
subtitle="What the game server last told us about itself"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState error={error} />}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem', maxWidth: '32rem' }}>
|
||||||
|
<p>
|
||||||
|
<strong>{data.worldName || 'The world'}</strong> is{' '}
|
||||||
|
{data.online ? 'online' : 'offline'}
|
||||||
|
{data.online && data.players > 0 ? ` with ${data.players} playing` : ''}.
|
||||||
|
</p>
|
||||||
|
<p style={{ opacity: 0.7 }}>
|
||||||
|
Last reported {ago(data.updatedAt)}
|
||||||
|
{/* `stale` is a first-class part of the answer rather than something
|
||||||
|
the page infers from a timestamp. The server decides what counts
|
||||||
|
as stale, because the server is what knows how often the game is
|
||||||
|
supposed to check in. */}
|
||||||
|
{data.stale ? ' — this is out of date, so the world is shown as offline.' : '.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
template/client/src/shim/jsx-runtime.js
Normal file
16
template/client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// `react/jsx-runtime`, from core.
|
||||||
|
//
|
||||||
|
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
|
||||||
|
// under the automatic runtime, which is the default the tooling assumes. Those
|
||||||
|
// have to resolve to CORE's React like every other import — a second jsx runtime
|
||||||
|
// bound to a second React is the same one-React violation as bundling `react`
|
||||||
|
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||||
|
// a component that looks fine.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const jsxRuntime = rg().jsxRuntime
|
||||||
|
|
||||||
|
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||||
|
|
||||||
|
export default jsxRuntime.default ?? jsxRuntime
|
||||||
14
template/client/src/shim/react-dom.js
vendored
Normal file
14
template/client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// `react-dom/client`, from core.
|
||||||
|
//
|
||||||
|
// A module never calls `createRoot` — core owns the root and the module renders
|
||||||
|
// inside it. This exists because a transitive import can still reach for
|
||||||
|
// react-dom, and one that resolved to a bundled copy would put a second
|
||||||
|
// renderer in the page.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const reactDom = rg().reactDom
|
||||||
|
|
||||||
|
export default reactDom.default ?? reactDom
|
||||||
|
|
||||||
|
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||||
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// `react-router-dom`, from core.
|
||||||
|
//
|
||||||
|
// The sharpest of the four, because router state is not just a library — it is
|
||||||
|
// one live navigation context. A module with its own copy would get a router
|
||||||
|
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||||
|
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const router = rg().router
|
||||||
|
|
||||||
|
export default router.default ?? router
|
||||||
|
|
||||||
|
export const {
|
||||||
|
BrowserRouter,
|
||||||
|
Link,
|
||||||
|
NavLink,
|
||||||
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
createSearchParams,
|
||||||
|
generatePath,
|
||||||
|
matchPath,
|
||||||
|
useLocation,
|
||||||
|
useMatch,
|
||||||
|
useNavigate,
|
||||||
|
useOutletContext,
|
||||||
|
useParams,
|
||||||
|
useResolvedPath,
|
||||||
|
useSearchParams,
|
||||||
|
} = router
|
||||||
50
template/client/src/shim/react.js
vendored
Normal file
50
template/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// The shared React, taken from core rather than bundled.
|
||||||
|
//
|
||||||
|
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
|
||||||
|
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
|
||||||
|
// chunk, which the browser cannot resolve without an import map — and an import
|
||||||
|
// map has to be an inline `<script type="importmap">`, which core's
|
||||||
|
// `script-src 'self'` forbids. `output.globals` does not help either; it is
|
||||||
|
// iife/umd only, and this is an ES module. So each shared dependency is aliased
|
||||||
|
// to a two-line module that re-exports from the global core published before any
|
||||||
|
// module chunk evaluated.
|
||||||
|
//
|
||||||
|
// The named re-exports are not decoration: `import { useState } from 'react'`
|
||||||
|
// compiles to a named import, and a module with only a default export would fail
|
||||||
|
// at link time in the browser with a message about the binding, not about this.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const react = rg().react
|
||||||
|
|
||||||
|
export default react.default ?? react
|
||||||
|
|
||||||
|
export const {
|
||||||
|
Children,
|
||||||
|
Component,
|
||||||
|
Fragment,
|
||||||
|
StrictMode,
|
||||||
|
Suspense,
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
forwardRef,
|
||||||
|
isValidElement,
|
||||||
|
lazy,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useDebugValue,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useImperativeHandle,
|
||||||
|
useInsertionEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
useTransition,
|
||||||
|
} = react
|
||||||
29
template/client/src/shim/rg.js
Normal file
29
template/client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// The one place this module reads `window.__rg`, and the one place that says
|
||||||
|
// something useful when it is not there.
|
||||||
|
//
|
||||||
|
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||||
|
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||||
|
// modules evaluate dependencies in the source order of their import statements,
|
||||||
|
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||||
|
// that survives exactly until someone sorts the imports. Whichever module the
|
||||||
|
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||||
|
//
|
||||||
|
// A missing global means core did not publish its shared dependencies before
|
||||||
|
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||||
|
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||||
|
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||||
|
// called react.js, which reads like the module bundled React wrong — the
|
||||||
|
// opposite of what happened.
|
||||||
|
export function rg() {
|
||||||
|
const shared = window.__rg
|
||||||
|
if (!shared) {
|
||||||
|
throw new Error(
|
||||||
|
'[examplegame] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||||
|
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||||
|
'fault in this module.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
export default rg
|
||||||
154
template/client/test/build.test.js
Normal file
154
template/client/test/build.test.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// What can be checked about the client half without a browser.
|
||||||
|
//
|
||||||
|
// Not much, and being honest about that is the point: the client half's real
|
||||||
|
// failures are timing and resolution, and neither has a shape a DOM-less test
|
||||||
|
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
|
||||||
|
// proves this half works, and it is re-run whenever this seam changes.
|
||||||
|
//
|
||||||
|
// What IS testable here is the configuration that decides resolution — and one
|
||||||
|
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
|
||||||
|
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
|
||||||
|
// array form cannot. That is a property of the config, and a test can hold it.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const CLIENT = path.resolve(HERE, '..')
|
||||||
|
|
||||||
|
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||||
|
const configModule = await import('../vite.config.js')
|
||||||
|
const config = configModule.default
|
||||||
|
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||||
|
|
||||||
|
test('every alias is an anchored regexp, never a bare prefix string', () => {
|
||||||
|
const aliases = config.resolve.alias
|
||||||
|
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
|
||||||
|
for (const { find } of aliases) {
|
||||||
|
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
|
||||||
|
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('react and react/jsx-runtime resolve to different shims', () => {
|
||||||
|
// The exact collision the object form causes. Asserted on the outcome rather
|
||||||
|
// than on the config's shape, so it keeps holding however the config is
|
||||||
|
// rewritten.
|
||||||
|
const resolve = (specifier) =>
|
||||||
|
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
|
||||||
|
assert.ok(resolve('react'))
|
||||||
|
assert.ok(resolve('react/jsx-runtime'))
|
||||||
|
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every shared dependency is aliased', () => {
|
||||||
|
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
|
||||||
|
assert.ok(
|
||||||
|
config.resolve.alias.some(({ find }) => find.test(specifier)),
|
||||||
|
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
|
||||||
|
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
|
||||||
|
// listed in both is marked external and never aliased. The chunk then ships
|
||||||
|
// bare `import 'react'`, which no browser can resolve without an import map
|
||||||
|
// and CSP forbids one. §3.6 shows both; they do not compose.
|
||||||
|
assert.deepStrictEqual(config.build.rollupOptions.external, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
|
||||||
|
// The direction of this dependency is the finding. Deriving the forbidden
|
||||||
|
// package list FROM the alias list means deleting an alias also deletes the
|
||||||
|
// guard against what that alias prevented — which is precisely when the guard
|
||||||
|
// is needed. So the guard states the contract, and this asserts the aliases
|
||||||
|
// stay inside it.
|
||||||
|
const packages = new Set(guardedPackages)
|
||||||
|
for (const { specifier } of SHARED) {
|
||||||
|
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
|
||||||
|
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every alias points at a shim file that exists', () => {
|
||||||
|
for (const { find, replacement } of config.resolve.alias) {
|
||||||
|
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the build emits one unhashed entry.js, which is what module.json names', () => {
|
||||||
|
assert.deepStrictEqual(config.build.lib.formats, ['es'])
|
||||||
|
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
|
||||||
|
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
|
||||||
|
assert.strictEqual(config.build.outDir, 'dist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
|
||||||
|
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
||||||
|
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
||||||
|
// makes the "core did not publish its dependencies" message reachable. The
|
||||||
|
// shims touch the global before anything else in the chunk does, so a check
|
||||||
|
// placed in the first-imported file is a guarantee that lasts until someone
|
||||||
|
// sorts the imports.
|
||||||
|
const dir = path.join(CLIENT, 'src', 'shim')
|
||||||
|
const shims = fs.readdirSync(dir)
|
||||||
|
assert.ok(shims.length >= 5)
|
||||||
|
for (const file of shims) {
|
||||||
|
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||||
|
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
||||||
|
if (file === 'rg.js') {
|
||||||
|
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
||||||
|
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
||||||
|
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
||||||
|
// A shim may import its sibling helper and nothing else — anything further
|
||||||
|
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
||||||
|
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||||
|
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
||||||
|
// The artifact check itself, over the artifact that ships. Skipped rather than
|
||||||
|
// failed when there is no build: `npm test` must be runnable before `npm run
|
||||||
|
// build`, and CI runs them in order.
|
||||||
|
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
||||||
|
if (!fs.existsSync(chunk)) return
|
||||||
|
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an import inside a string is not an import — the check reads code, not text', () => {
|
||||||
|
// The regression that made this necessary: the first chunk with real content
|
||||||
|
// in it had a button labelled "Approve and import" put the token
|
||||||
|
// immediately before a quote. The check rejected the whole build, naming a
|
||||||
|
// fragment of minified JSX as the offending specifier.
|
||||||
|
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
||||||
|
assert.deepStrictEqual(bareImports(uiCopy), [])
|
||||||
|
|
||||||
|
// Neither is one in a comment, or in a template literal.
|
||||||
|
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
||||||
|
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
||||||
|
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
||||||
|
|
||||||
|
// And a real one still is, in each form the build could emit.
|
||||||
|
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
||||||
|
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
||||||
|
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
||||||
|
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
||||||
|
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
||||||
|
|
||||||
|
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
||||||
|
// string must not end it early and leave the tail looking like code.
|
||||||
|
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||||
|
})
|
||||||
177
template/client/test/registration.test.js
Normal file
177
template/client/test/registration.test.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// ── What the chunk registers, checked without a browser ───────────────────
|
||||||
|
//
|
||||||
|
// `build.test.js` says the honest thing about this half: its real failures are
|
||||||
|
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
|
||||||
|
// §7.7's browser smoke is what proves the client half works, and nothing here
|
||||||
|
// replaces it.
|
||||||
|
//
|
||||||
|
// What a test CAN do is read back what the chunk asked for. Registration is the
|
||||||
|
// one thing the chunk does at evaluation time, and it does it through an object
|
||||||
|
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
|
||||||
|
// the real React behind it, import the BUILT artifact, and inspect the result. No
|
||||||
|
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
|
||||||
|
// an object, and the route table is full of them by design.
|
||||||
|
//
|
||||||
|
// It catches a page that silently stops being routed, a nav row whose `to` drifts
|
||||||
|
// from its route's path, and the whole registration surface disappearing because
|
||||||
|
// something threw halfway down entry.jsx.
|
||||||
|
//
|
||||||
|
// **It runs against `dist/entry.js`, so build before you test.** The skip below
|
||||||
|
// is deliberate — `npm test` has to be runnable before `npm run build` — which
|
||||||
|
// means a CI job that tests without building is a job asking nothing at all. Ours
|
||||||
|
// builds first, on purpose.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import * as react from 'react'
|
||||||
|
import * as jsxRuntime from 'react/jsx-runtime'
|
||||||
|
import * as router from 'react-router-dom'
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||||
|
|
||||||
|
// A component, as far as the registry cares. The kit's real members are core's;
|
||||||
|
// nothing renders here, so a named stub is enough to be imported and passed on.
|
||||||
|
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||||
|
|
||||||
|
function fakeRg() {
|
||||||
|
const routes = { public: [], admin: [], player: [] }
|
||||||
|
const nav = { public: [], admin: [], player: [] }
|
||||||
|
const providers = new Map()
|
||||||
|
const extensions = new Map()
|
||||||
|
return {
|
||||||
|
version: manifest.coreApi.replace(/^\D+/, ''),
|
||||||
|
react,
|
||||||
|
jsxRuntime,
|
||||||
|
router,
|
||||||
|
// `react-dom/client` is imported for the identity check in core.js and never
|
||||||
|
// called — `createRoot` in a DOM-less process would throw. The shim reads
|
||||||
|
// this object, so the check compares against whatever is here.
|
||||||
|
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
|
||||||
|
ui: Object.fromEntries(
|
||||||
|
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
|
||||||
|
.map((n) => [n, stub(n)]),
|
||||||
|
),
|
||||||
|
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||||
|
registry: {
|
||||||
|
registerRoutes(id, byArea) {
|
||||||
|
for (const [area, list] of Object.entries(byArea || {})) {
|
||||||
|
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerNav(id, { area, items }) {
|
||||||
|
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||||
|
},
|
||||||
|
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
|
||||||
|
registerExtension(id, slot, Component) {
|
||||||
|
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||||
|
extensions.set(slot, { id, Component })
|
||||||
|
},
|
||||||
|
routesFor: (area) => routes[area],
|
||||||
|
navFor: (area) => nav[area],
|
||||||
|
},
|
||||||
|
_read: () => ({ routes, nav, providers, extensions }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded once: an ES module is evaluated a single time per process however many
|
||||||
|
// times it is imported, so every test below reads the same registration pass —
|
||||||
|
// which is also how it behaves in a browser.
|
||||||
|
let registered = null
|
||||||
|
let skip = false
|
||||||
|
|
||||||
|
if (!fs.existsSync(CHUNK)) {
|
||||||
|
skip = true
|
||||||
|
} else {
|
||||||
|
const rg = fakeRg()
|
||||||
|
globalThis.window = { __rg: rg }
|
||||||
|
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
|
||||||
|
registered = rg._read()
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
|
||||||
|
|
||||||
|
it('registers at least one route, namespaced under the module id', () => {
|
||||||
|
const all = Object.values(registered.routes).flat()
|
||||||
|
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||||
|
for (const [area, list] of Object.entries(registered.routes)) {
|
||||||
|
for (const r of list) {
|
||||||
|
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||||
|
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every route path is distinct within its area', () => {
|
||||||
|
// Two routes on one path is a page that can never be reached, and React
|
||||||
|
// renders the first one without complaint.
|
||||||
|
for (const [area, list] of Object.entries(registered.routes)) {
|
||||||
|
const paths = list.map((r) => r.path)
|
||||||
|
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every nav row points at a route this module actually registered', () => {
|
||||||
|
// The agreement that matters, and the one that rots quietly: a row survives a
|
||||||
|
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
|
||||||
|
// the FULL rendered path (`/examplegame/status`); routes carry the namespaced
|
||||||
|
// one (`examplegame/status`). Reconciling the two is the whole test.
|
||||||
|
const rendered = {
|
||||||
|
public: (p) => `/${p}`,
|
||||||
|
admin: (p) => `/admin/${p}`,
|
||||||
|
player: (p) => `/player/${p}`,
|
||||||
|
}
|
||||||
|
for (const [area, rows] of Object.entries(registered.nav)) {
|
||||||
|
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every admin and player nav row carries an icon', () => {
|
||||||
|
// Both of those navs draw a glyph on every core row, so a row without one reads
|
||||||
|
// as breakage rather than as a design — and core's player portal used to render
|
||||||
|
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
|
||||||
|
// the first time a module registered a row without one. Core guards it now; a
|
||||||
|
// missing icon there is still a visible defect and this is the cheap place to
|
||||||
|
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
|
||||||
|
for (const area of ['admin', 'player']) {
|
||||||
|
for (const row of registered.nav[area]) {
|
||||||
|
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||||
|
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
|
||||||
|
// So a row carrying a `feature` from a module that registered no provider is a
|
||||||
|
// row that always shows — which re-advertises a surface an operator hid.
|
||||||
|
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
|
||||||
|
if (gated.length === 0) return
|
||||||
|
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every slot module.json declares is one the chunk fills', () => {
|
||||||
|
// `module.json` declares SERVER slots, and the loader validates those before
|
||||||
|
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||||
|
// knows nothing about them — so this is the one place the two halves meet.
|
||||||
|
for (const slot of manifest.extensions || []) {
|
||||||
|
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers under exactly one module id, matching the manifest', () => {
|
||||||
|
const owners = new Set([
|
||||||
|
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||||
|
...Object.values(registered.nav).flat().map((r) => r.moduleId),
|
||||||
|
...[...registered.extensions.values()].map((e) => e.id),
|
||||||
|
...[...registered.providers.values()].map((p) => p.id),
|
||||||
|
])
|
||||||
|
assert.deepEqual([...owners], [manifest.id])
|
||||||
|
})
|
||||||
137
template/client/vite.config.js
Normal file
137
template/client/vite.config.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// ── The client half's library build ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
|
||||||
|
// same-origin `<script type="module" src>` before `</body>`. The operator never
|
||||||
|
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
|
||||||
|
// convenience — it is how the artifact that ships is made, and CI runs it.
|
||||||
|
//
|
||||||
|
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
|
||||||
|
// were each found the hard way and are worth reading before changing anything.
|
||||||
|
//
|
||||||
|
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
|
||||||
|
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
|
||||||
|
// — silently, to the wrong shim, and the chunk then fails at its first element
|
||||||
|
// with a message about `jsx` not being a function. `^react$` and
|
||||||
|
// `^react/jsx-runtime$` cannot collide.
|
||||||
|
//
|
||||||
|
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
|
||||||
|
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
|
||||||
|
// resolver runs, so a specifier listed there is marked external and never
|
||||||
|
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
|
||||||
|
// browser cannot resolve without an import map — and core's `script-src 'self'`
|
||||||
|
// forbids the inline script an import map has to be. (`output.globals` would
|
||||||
|
// have covered iife/umd and does nothing for an ES module.) The first real module
|
||||||
|
// shipped with both, built cleanly, and emitted exactly that chunk;
|
||||||
|
// `scripts/checkExternals.js` is what caught it. So: alias only, and nothing in
|
||||||
|
// `external`.
|
||||||
|
//
|
||||||
|
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
|
||||||
|
// below.** The risk it was covering is real — an alias that misses means a
|
||||||
|
// second React welded into the chunk, which loads fine and then throws about an
|
||||||
|
// invalid hook call somewhere unrelated. A resolution-time assertion catches
|
||||||
|
// that precisely, at build time, instead of by looking for fingerprints in
|
||||||
|
// minified output afterwards.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
|
||||||
|
|
||||||
|
// The shared dependencies, in one place: what a module must never bundle, and
|
||||||
|
// the shim it is aliased to instead. Adding to this list means adding to
|
||||||
|
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
|
||||||
|
// file can make on its own.
|
||||||
|
export const SHARED = [
|
||||||
|
{ specifier: 'react', shim: 'react' },
|
||||||
|
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
|
||||||
|
// A production `vite build` emits the non-dev runtime, but the plugin picks
|
||||||
|
// per mode and a `--mode development` build would reach for this one. Aliased
|
||||||
|
// rather than left to chance: the shim re-exports `jsxDEV` too.
|
||||||
|
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
|
||||||
|
{ specifier: 'react-dom', shim: 'react-dom' },
|
||||||
|
{ specifier: 'react-dom/client', shim: 'react-dom' },
|
||||||
|
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// The packages whose real source must never end up in the chunk.
|
||||||
|
//
|
||||||
|
// Stated independently of SHARED, and that is the whole point — an earlier
|
||||||
|
// version derived this from the alias list "so the two cannot disagree", which
|
||||||
|
// meant deleting an alias also deleted the guard against the thing that alias
|
||||||
|
// prevented. The guard then reported nothing on a chunk with react-router welded
|
||||||
|
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
|
||||||
|
// function of what this config happens to alias; `test/build.test.js` asserts
|
||||||
|
// every SHARED specifier is covered here, which is the direction the dependency
|
||||||
|
// belongs in.
|
||||||
|
//
|
||||||
|
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
|
||||||
|
// They cannot appear while the alias holds — nothing resolves through to them —
|
||||||
|
// so naming them costs nothing and closes the case where a module imports one
|
||||||
|
// directly and gets a second navigation context in a page that otherwise works.
|
||||||
|
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail the build if a shared dependency's real source is about to be bundled.
|
||||||
|
*
|
||||||
|
* This is the safety net, and it is a resolution-time one on purpose. The
|
||||||
|
* alternative — grepping the built chunk for a fingerprint — has to guess at
|
||||||
|
* strings that survive minification, and guesses at that are how a check ends up
|
||||||
|
* passing on a chunk that carries a second React. Here there is nothing to
|
||||||
|
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
|
||||||
|
* the alias that missed is named in the error.
|
||||||
|
*
|
||||||
|
* It hooks `transform` rather than `load`, and that is not interchangeable:
|
||||||
|
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
|
||||||
|
* means this hook is never called for it. Written against `load` this guard sat
|
||||||
|
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
|
||||||
|
* chunk with react-router welded into it and a green build — which is the exact
|
||||||
|
* failure it exists to prevent. `transform` runs for every module, every time.
|
||||||
|
*/
|
||||||
|
function assertSharedNotBundled() {
|
||||||
|
return {
|
||||||
|
name: 'examplegame:assert-shared-not-bundled',
|
||||||
|
enforce: 'post',
|
||||||
|
transform(code, id) {
|
||||||
|
const normalised = id.split('\\').join('/')
|
||||||
|
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
|
||||||
|
if (hit) {
|
||||||
|
this.error(
|
||||||
|
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
|
||||||
|
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
|
||||||
|
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), assertSharedNotBundled()],
|
||||||
|
resolve: {
|
||||||
|
alias: SHARED.map(({ specifier, shim: name }) => ({
|
||||||
|
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
|
||||||
|
replacement: shim(name),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
// Unhashed, deliberately: `module.json` names this file, and a hashed name
|
||||||
|
// would have to be discovered at runtime. Core answers the cache question
|
||||||
|
// instead, serving it `no-cache` so a revalidation catches a new build
|
||||||
|
// (MODULE_API.md §3.1).
|
||||||
|
fileName: () => 'entry.js',
|
||||||
|
},
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
// No inline bootstrap, for the same reason core disables it: an inline
|
||||||
|
// script is refused under `script-src 'self'`, and the failure is a chunk
|
||||||
|
// that never evaluates with a CSP report as the only clue.
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
|
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
|
||||||
|
rollupOptions: { external: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
14
template/module.json
Normal file
14
template/module.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "examplegame",
|
||||||
|
"name": "Example Game",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"coreApi": "^1.4.0",
|
||||||
|
"server": "server/index.js",
|
||||||
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
|
"schema": "server/db/schema.sql",
|
||||||
|
"purge": "server/db/purge.sql",
|
||||||
|
"mounts": {
|
||||||
|
"public": ["/world"]
|
||||||
|
},
|
||||||
|
"capabilities": ["world-status"]
|
||||||
|
}
|
||||||
92
template/server/boot.js
Normal file
92
template/server/boot.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// ── The lifecycle hooks ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
|
||||||
|
// where everything it could not do goes.
|
||||||
|
//
|
||||||
|
// core schema → your schema fragment → onBoot(ctx) → the listener binds
|
||||||
|
//
|
||||||
|
// So by the time `onBoot` runs your tables exist, core's settings are seeded, and
|
||||||
|
// nothing is serving traffic yet. That last part is a guarantee you can rely on:
|
||||||
|
// a module that must warm a cache before its first request gets to.
|
||||||
|
//
|
||||||
|
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
|
||||||
|
// not. A slow `onBoot` delays the listener, which is the promise above rather
|
||||||
|
// than a problem to be timed out.
|
||||||
|
//
|
||||||
|
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
|
||||||
|
// up.** Your routes stay mounted but answer 503, because a module that failed to
|
||||||
|
// warm up serving half-initialised data is worse than one that says it is down.
|
||||||
|
// You then get NO `onShutdown` — you are part-way through a warm-up you never
|
||||||
|
// finished, and being handed a half-built world to tear down is worse than not
|
||||||
|
// closing cleanly.
|
||||||
|
//
|
||||||
|
// This is where a real module opens its sidecar connection. **The website process
|
||||||
|
// never opens a connection to a game server** — that is §2.7, contract as of
|
||||||
|
// MODULE_API 1.4.0, not advice. What you connect to here is your sidecar: a
|
||||||
|
// service you write, which owns the socket to the game, persists what the game
|
||||||
|
// says before forwarding it, and answers reads from that store. See the kit's
|
||||||
|
// chapter 3 for why that shape and not a shorter one.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
|
||||||
|
|
||||||
|
const log = core.logger('boot')
|
||||||
|
|
||||||
|
// Whatever a real module would keep open — a sidecar WebSocket, a poll timer —
|
||||||
|
// is held here so `onShutdown` can close it. This template has one timer, purely
|
||||||
|
// so that there is something for the shutdown hook to actually do.
|
||||||
|
let refreshTimer = null
|
||||||
|
|
||||||
|
const REFRESH_MS = 30 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the game (in a real module: your sidecar) how it is doing, and store it.
|
||||||
|
*
|
||||||
|
* Isolated from the hooks so it is the one place a failure is handled: an
|
||||||
|
* unreachable game is expected, is not this module's fault, and must not become
|
||||||
|
* an unhandled rejection in core's process.
|
||||||
|
*/
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
// A real module calls its sidecar's REST API here. Two hardcoded values
|
||||||
|
// stand in, so that the page renders and the seam is visible.
|
||||||
|
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not refresh world status', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs once, after the schema and before the listener binds.
|
||||||
|
*
|
||||||
|
* Receives the same frozen `ctx` `register()` was given — not a second object
|
||||||
|
* built to look like it — so a module that only needs core at boot time can skip
|
||||||
|
* `core.init` entirely and use this argument.
|
||||||
|
*/
|
||||||
|
async function onBoot() {
|
||||||
|
await refresh()
|
||||||
|
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||||
|
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||||
|
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||||
|
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||||
|
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||||
|
log.info('booted', { refreshMs: REFRESH_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
|
||||||
|
*
|
||||||
|
* The database pool, the push dispatcher and the SSE fan-out are all still open,
|
||||||
|
* because flushing through them is the only thing this hook is for. There is a
|
||||||
|
* five-second budget per module, after which the hook is abandoned — abandoned
|
||||||
|
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||||
|
* Close what you opened, flush what is buffered, and return.
|
||||||
|
*/
|
||||||
|
async function onShutdown() {
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
log.info('shut down')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { onBoot, onShutdown, refresh, REFRESH_MS }
|
||||||
99
template/server/core.js
Normal file
99
template/server/core.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// ── Everything this module reaches in core ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
||||||
|
// code beneath it — models, controllers, utilities — is ordinary Node that
|
||||||
|
// requires its dependencies at file scope, the way any Node file does. This file
|
||||||
|
// is what lets both of those be true at the same time.
|
||||||
|
//
|
||||||
|
// **Every export is a lazy accessor, not a stored reference, and that is the
|
||||||
|
// whole point.** A model writes
|
||||||
|
//
|
||||||
|
// const { query } = require('../../core')
|
||||||
|
//
|
||||||
|
// at require time, which is before `register()` has been called and therefore
|
||||||
|
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
|
||||||
|
// out `undefined`, permanently, and the failure would surface much later as a
|
||||||
|
// TypeError inside a model with no clue pointing here. So each member resolves
|
||||||
|
// `ctx` when it is CALLED. Require order stops mattering for everything except
|
||||||
|
// `core.init()` itself, which `index.js` runs first.
|
||||||
|
//
|
||||||
|
// The same rule in the other direction: **never destructure off `ctx` at init
|
||||||
|
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
|
||||||
|
// value captured once is a value that cannot change.
|
||||||
|
//
|
||||||
|
// If `ctx` is missing every accessor throws the same message. The only ways to
|
||||||
|
// reach one before `register()` are a require cycle or a test that forgot to call
|
||||||
|
// `init`, and both want naming rather than `undefined`.
|
||||||
|
//
|
||||||
|
// ── This file is a NARROWING, on purpose ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// §2.3 lists everything core hands over. What is re-exported below is only what
|
||||||
|
// this module actually uses, which is the discipline worth copying: the file is
|
||||||
|
// then an honest statement of what your module depends on, and a test double for
|
||||||
|
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
|
||||||
|
// for it — not in advance.
|
||||||
|
|
||||||
|
let ctx = null
|
||||||
|
|
||||||
|
function need() {
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('examplegame: core accessed before register() — see server/core.js')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once, first thing in `register()`. */
|
||||||
|
function init(value) {
|
||||||
|
ctx = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
||||||
|
function _reset() {
|
||||||
|
ctx = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A logger that can be taken at require time and used after `register()`.
|
||||||
|
//
|
||||||
|
// A file writes `const log = require('../core').logger('world')` at file scope,
|
||||||
|
// so the object returned has to exist before `ctx` does. It is a façade whose
|
||||||
|
// four methods each resolve the real logger when called. Core namespaces the
|
||||||
|
// output with your module id, so these come out as `[examplegame:world]`.
|
||||||
|
function logger(namespace) {
|
||||||
|
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
||||||
|
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
_reset,
|
||||||
|
logger,
|
||||||
|
|
||||||
|
// Shared server dependencies. Core owns exactly one express, as it owns
|
||||||
|
// exactly one React on the client, and for the same reason: a second copy in
|
||||||
|
// the process is a second Router prototype and a second set of `instanceof`
|
||||||
|
// checks. A module could not resolve these for itself even if it were allowed
|
||||||
|
// to — it lives outside core's `server/` (§7.2).
|
||||||
|
get express() { return need().express },
|
||||||
|
get validator() { return need().validator },
|
||||||
|
|
||||||
|
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
|
||||||
|
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
|
||||||
|
// case that needs a connection it can hold (a streamed import, say).
|
||||||
|
query: (...args) => need().db.query(...args),
|
||||||
|
get pool() { return need().db.pool },
|
||||||
|
|
||||||
|
// Read-only access to who is asking. Minting a session is core's job; a module
|
||||||
|
// that needs an identity needs to *read* one.
|
||||||
|
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||||
|
|
||||||
|
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||||
|
// function reference at mount time, so a wrapper is what would end up in the
|
||||||
|
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||||
|
get middleware() { return need().middleware },
|
||||||
|
|
||||||
|
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||||
|
// only correct way to find a file you shipped, because the working directory is
|
||||||
|
// core's and the module's location is the loader's business.
|
||||||
|
get moduleRoot() { return need().paths.moduleRoot },
|
||||||
|
get moduleId() { return need().moduleId },
|
||||||
|
}
|
||||||
24
template/server/db/purge.sql
Normal file
24
template/server/db/purge.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- ── The teardown ──────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||||
|
-- Nothing on the boot path executes this file, and uninstalling your module does
|
||||||
|
-- not either: removing an operator's data is a second decision they have to make
|
||||||
|
-- on purpose, offered inside the uninstall flow and confirmed separately.
|
||||||
|
--
|
||||||
|
-- It exists because `schema.sql` does. A module that can create tables and
|
||||||
|
-- cannot drop them leaves an operator with orphaned data and no supported way to
|
||||||
|
-- remove it — so core refuses to load a module that declares one without the
|
||||||
|
-- other.
|
||||||
|
--
|
||||||
|
-- **Drop in the reverse of creation order.** With one table it does not matter;
|
||||||
|
-- with a parent and its children it does, because dropping a parent first fails
|
||||||
|
-- on the constraint and a purge that fails halfway is worse than one that never
|
||||||
|
-- ran — it leaves exactly the orphaned data this file exists to remove.
|
||||||
|
-- `IF EXISTS` on every line, so a partially-installed module still tears down.
|
||||||
|
--
|
||||||
|
-- **What does NOT belong here: rows you wrote into core's tables.** Notification
|
||||||
|
-- subscriptions, announce-job legs and settings rows live in core's schema, and
|
||||||
|
-- a module does not DELETE from core's tables. Core prunes what it knows you
|
||||||
|
-- registered, because it is the one that knows which registrant owned what.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS examplegame_world_status;
|
||||||
70
template/server/db/schema.sql
Normal file
70
template/server/db/schema.sql
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
-- ── The schema fragment ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Core replays this file on EVERY boot, statement by statement, immediately
|
||||||
|
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
||||||
|
--
|
||||||
|
-- **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. So a module's schema is not a sequence of changes to
|
||||||
|
-- apply once — it is a statement of what the tables should look like, written so
|
||||||
|
-- that running it against a database that already matches does nothing.
|
||||||
|
--
|
||||||
|
-- Which means: every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
||||||
|
-- IF NOT EXISTS. A statement that succeeds once and fails afterwards presents as
|
||||||
|
-- a module that worked until the first restart.
|
||||||
|
--
|
||||||
|
-- **And it means CHANGING a table is an ALTER, never an edit to its CREATE.**
|
||||||
|
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
||||||
|
-- there, so an edited column definition takes effect on a fresh install and on no
|
||||||
|
-- existing one — the worst possible split, because your development database is
|
||||||
|
-- usually the fresh one. Add the column with
|
||||||
|
-- `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, below the CREATE, and leave the
|
||||||
|
-- CREATE describing what a new install gets.
|
||||||
|
--
|
||||||
|
-- ── What core checks, and when ────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Core validates this file at LOAD time, before your module mounts anything —
|
||||||
|
-- so a rule broken here costs you the mount entirely rather than leaving you
|
||||||
|
-- with half-created tables and routes that 503. What is left for replay time is
|
||||||
|
-- the class only the database can answer: an unknown column type, a bad foreign
|
||||||
|
-- key. Those are post-mount and do answer 503.
|
||||||
|
--
|
||||||
|
-- • **Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE.** Not a
|
||||||
|
-- DROP denylist. This file replays every boot, so a TRUNCATE or a DELETE
|
||||||
|
-- would empty a table on every restart.
|
||||||
|
-- • **Every table you create must be prefixed with your module id** —
|
||||||
|
-- `examplegame_` here. Nothing else in the database is yours to create.
|
||||||
|
-- • **No table core declares, and none another module has claimed.**
|
||||||
|
--
|
||||||
|
-- A foreign key INTO a core table is allowed, and works because core's schema is
|
||||||
|
-- already in place when this runs. The reverse is not, and could not be: it
|
||||||
|
-- would make core's schema depend on your module being installed.
|
||||||
|
--
|
||||||
|
-- Teardown is `purge.sql`, which no boot ever runs. See it.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── World status ──────────────────────────────────────────────────────────
|
||||||
|
-- One row, id 1, holding the last thing the game server said about itself.
|
||||||
|
--
|
||||||
|
-- A singleton row rather than a settings key because it is *observed state* and
|
||||||
|
-- not configuration: it is written by whatever ingests from your sidecar, and an
|
||||||
|
-- operator never edits it. In a real module the writer is the sidecar ingest;
|
||||||
|
-- here `boot.js` writes it once so the page has something to render.
|
||||||
|
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
||||||
|
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
||||||
|
-- the same numbers back — which is what a quiet game looks like — leaves the
|
||||||
|
-- timestamp frozen at the first write, and the row then goes stale while nothing
|
||||||
|
-- is wrong. The writer sets the column explicitly instead; see
|
||||||
|
-- `model/worldStatus/worldStatus.db.js`.
|
||||||
|
CREATE TABLE IF NOT EXISTS examplegame_world_status (
|
||||||
|
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||||
|
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
players INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
world_name VARCHAR(120) NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the singleton. `INSERT IGNORE` rather than a plain INSERT: this runs
|
||||||
|
-- again on every boot, and the second run must be a no-op rather than a
|
||||||
|
-- duplicate-key error that fails the whole replay.
|
||||||
|
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);
|
||||||
98
template/server/index.js
Normal file
98
template/server/index.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// ── The server entry point ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core requires this file once, synchronously, while its own `app.js` is still
|
||||||
|
// being required, and calls the exported function with `(ctx, api)`. That is the
|
||||||
|
// entire server-side handshake: everything this module can reach arrives on
|
||||||
|
// `ctx`, and everything it can offer is registered through `api`.
|
||||||
|
//
|
||||||
|
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
|
||||||
|
//
|
||||||
|
// ── Three rules, and each one has a failure behind it ──────────────────────
|
||||||
|
//
|
||||||
|
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
|
||||||
|
// with the connection pool pointed at a dead port — the route-manifest
|
||||||
|
// generator and the OpenAPI generator both do it — so a module that queried
|
||||||
|
// at registration time would hang both. Anything that needs a live database
|
||||||
|
// goes in `onBoot`, which runs after the schema is up.
|
||||||
|
//
|
||||||
|
// 2. **Never resolve what core owns.** Your module lives at
|
||||||
|
// `<website>/modules/<id>/`, which is outside core's `server/`, so Node's
|
||||||
|
// resolver never reaches core's `node_modules` and `require('express')` from
|
||||||
|
// here simply fails. express, express-validator, the database, the logger and
|
||||||
|
// the middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`.
|
||||||
|
// This is not a style rule: a second express in the process would be a second
|
||||||
|
// `Router` prototype, exactly as a second React would be a second renderer.
|
||||||
|
//
|
||||||
|
// 3. **Never reach into core's tree.** No relative path may escape this module's
|
||||||
|
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
|
||||||
|
//
|
||||||
|
// ── Why the requires are INSIDE the function ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Every file below reaches core through `./core`, whose members resolve `ctx`
|
||||||
|
// when they are CALLED. But a router writes `const express = core.express` at its
|
||||||
|
// own file scope, and that runs the moment the file is required. So
|
||||||
|
// `core.init(ctx)` has to happen before the first `require` of anything under
|
||||||
|
// `router/`. Hoisting these to the top of the file breaks the module with an
|
||||||
|
// error about a missing `ctx`, thrown from a file that never mentions one.
|
||||||
|
//
|
||||||
|
// Node caches modules, so requiring here costs nothing after the first call.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
||||||
|
* @param {object} api what the module registers (§2.4)
|
||||||
|
*/
|
||||||
|
module.exports = function register(ctx, api) {
|
||||||
|
core.init(ctx)
|
||||||
|
|
||||||
|
/* eslint-disable global-require */
|
||||||
|
const worldRouter = require('./router/public/world.router')
|
||||||
|
const boot = require('./boot')
|
||||||
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
|
const log = core.logger()
|
||||||
|
|
||||||
|
// One prefix, on one tier. The keys here must match `module.json`'s `mounts`
|
||||||
|
// exactly — the loader compares the two and rejects a mismatch in either
|
||||||
|
// direction, so a route you forgot to declare and a prefix you declared and
|
||||||
|
// never registered both fail loudly at boot instead of quietly at runtime.
|
||||||
|
//
|
||||||
|
// This mounts at `/api/v1/public/world`. The router sits INSIDE the tier
|
||||||
|
// router, so it structurally cannot reach above its prefix, and the tier's own
|
||||||
|
// gate is already applied: `public` is behind nothing by design, `admin` sits
|
||||||
|
// behind `noindex, isLoggedIn, requireRole(...)` and `player` behind
|
||||||
|
// `noindex, requireAuth`. You add per-route gates on top; you never
|
||||||
|
// re-implement the tier gate.
|
||||||
|
//
|
||||||
|
// **Prefixes share one namespace with core's own, and `/world` was chosen to
|
||||||
|
// stay out of it.** Core answers `/api/v1/public/` + contact, modules, pages,
|
||||||
|
// posts, settings, status, version and wiki. The loader rejects a collision at
|
||||||
|
// registration time — but four of those eight are mounted at the tier root
|
||||||
|
// rather than under a prefix of their own, and the loader's probe cannot see
|
||||||
|
// them. `/status` would have been the obvious name for this module's route and
|
||||||
|
// is exactly the one that would have gone wrong. Check the list before you
|
||||||
|
// choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe).
|
||||||
|
api.registerRoutes({
|
||||||
|
public: { '/world': worldRouter },
|
||||||
|
})
|
||||||
|
|
||||||
|
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||||
|
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||||
|
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||||
|
// It has no timeout, deliberately: a slow boot delays the listener, which is
|
||||||
|
// the guarantee rather than a problem to be timed out.
|
||||||
|
//
|
||||||
|
// `onShutdown` runs while core's database pool and push dispatcher are still
|
||||||
|
// open, because flushing through them is the only thing it is for. It gets a
|
||||||
|
// five-second budget and is abandoned past it.
|
||||||
|
//
|
||||||
|
// Both are optional. A module with neither still reaches `started`.
|
||||||
|
api.onBoot(boot.onBoot)
|
||||||
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
|
log.info('registered', {
|
||||||
|
version: require('../module.json').version,
|
||||||
|
routes: 'public:/world',
|
||||||
|
})
|
||||||
|
}
|
||||||
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core's own backend is layered `router → controller → model → db`, with models
|
||||||
|
// arriving in pairs: a `.db.js` holding the SQL and a `.model.js` holding the
|
||||||
|
// logic that calls it. Your module is under no obligation to copy that — the
|
||||||
|
// contract says nothing about how you organise yourself — but the split earns
|
||||||
|
// its keep here for the same reason it does in core: the file with the queries
|
||||||
|
// in it has no branching to test, and the file with the branching in it has no
|
||||||
|
// database to stand up.
|
||||||
|
//
|
||||||
|
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always; a
|
||||||
|
// value interpolated into a query string is the one mistake in this file that
|
||||||
|
// nothing downstream can catch.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const TABLE = 'examplegame_world_status'
|
||||||
|
|
||||||
|
/** The singleton status row, or `null` if the schema replay has not run yet. */
|
||||||
|
async function getStatus() {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT online, players, world_name AS worldName, updated_at AS updatedAt
|
||||||
|
FROM ${TABLE}
|
||||||
|
WHERE id = 1`,
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite the singleton. Called by whatever ingests from your sidecar.
|
||||||
|
*
|
||||||
|
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
|
||||||
|
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
|
||||||
|
* value — an update that writes the same numbers back is a no-op and leaves the
|
||||||
|
* timestamp where it was. A game sitting quietly at the same player count writes
|
||||||
|
* exactly that update, so the column would freeze at the first write, the row
|
||||||
|
* would cross the freshness window, and the page would report the world offline
|
||||||
|
* while the game was up and reporting normally.
|
||||||
|
*
|
||||||
|
* That is invisible to every test — the model takes its timestamps as arguments,
|
||||||
|
* and nothing in a suite runs an UPDATE twice against a real database. It shows
|
||||||
|
* up as a page that was right when you looked at it and wrong an hour later.
|
||||||
|
*/
|
||||||
|
async function setStatus({ online, players, worldName }) {
|
||||||
|
await core.query(
|
||||||
|
`UPDATE ${TABLE}
|
||||||
|
SET online = ?, players = ?, world_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1`,
|
||||||
|
[online ? 1 : 0, players, worldName],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, setStatus, TABLE }
|
||||||
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ── The logic half ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shapes what the database returned into what a client should see. It is a
|
||||||
|
// separate file from the SQL so that it is testable without a database, and the
|
||||||
|
// suite next door tests it that way.
|
||||||
|
//
|
||||||
|
// The one decision worth pointing at: **a module answers when the game is
|
||||||
|
// unreachable rather than failing.** The website is the internet-facing process
|
||||||
|
// and your game is not; the game being down, or the sidecar being mid-restart,
|
||||||
|
// is an ordinary Tuesday and not an error condition for the site. A page that
|
||||||
|
// renders "offline, last seen 20 minutes ago" is right; a page that 500s because
|
||||||
|
// a socket is closed is a module that has made the site's availability depend on
|
||||||
|
// the game's.
|
||||||
|
|
||||||
|
const db = require('./worldStatus.db')
|
||||||
|
|
||||||
|
// Past this, the last thing the game said stops being news and starts being
|
||||||
|
// history. Presentation, so the number lives with the code that shapes the
|
||||||
|
// response rather than in the client.
|
||||||
|
const STALE_AFTER_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public view of the world's status.
|
||||||
|
*
|
||||||
|
* Never throws for an absent or stale row: both are answers, not failures.
|
||||||
|
*/
|
||||||
|
async function getPublicStatus(now = Date.now()) {
|
||||||
|
const row = await db.getStatus()
|
||||||
|
if (!row) {
|
||||||
|
// No row at all means the schema fragment has not been replayed — a fresh
|
||||||
|
// install whose first boot has not finished. Report it as offline rather
|
||||||
|
// than as an error; the next boot fixes it.
|
||||||
|
return { online: false, players: 0, worldName: null, updatedAt: null, stale: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = row.updatedAt ? new Date(row.updatedAt) : null
|
||||||
|
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
|
||||||
|
|
||||||
|
return {
|
||||||
|
// A stale row cannot claim the world is up. The row says what was true when
|
||||||
|
// it was written, and nothing has written it since.
|
||||||
|
online: Boolean(row.online) && !stale,
|
||||||
|
players: stale ? 0 : Number(row.players) || 0,
|
||||||
|
worldName: row.worldName || null,
|
||||||
|
updatedAt: updatedAt ? updatedAt.toISOString() : null,
|
||||||
|
stale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPublicStatus, STALE_AFTER_MS }
|
||||||
1056
template/server/package-lock.json
generated
Normal file
1056
template/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
template/server/package.json
Normal file
23
template/server/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "examplegame-module-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Server half of the Example Game module — routers, models and the schema fragment core loads at boot",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test",
|
||||||
|
"check:imports": "node scripts/checkImports.js",
|
||||||
|
"swagger": "node scripts/swaggerFragment.js",
|
||||||
|
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
|
||||||
|
"devDependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"swagger-autogen": "^2.23.7"
|
||||||
|
},
|
||||||
|
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router, because a fake Router would only ever test the fake. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
|
||||||
|
}
|
||||||
27
template/server/router/public/world.controller.js
Normal file
27
template/server/router/public/world.controller.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// ── Public · World — the handlers ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Thin on purpose: read the request, call a model, answer. Everything worth
|
||||||
|
// testing is in the model, which needs no express and no database to test.
|
||||||
|
//
|
||||||
|
// **A handler must not throw past express.** Core mounts your router inside its
|
||||||
|
// own tier router, so an unhandled rejection here reaches core's error handler
|
||||||
|
// and answers 500 — which is survivable, but it means an operator sees core
|
||||||
|
// blamed for a fault in your module. Catch, log through `core.logger` (so the
|
||||||
|
// line carries your module id), and answer something honest.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const worldStatus = require('../../model/worldStatus/worldStatus.model')
|
||||||
|
|
||||||
|
const log = core.logger('world')
|
||||||
|
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
res.json(await worldStatus.getPublicStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('failed to read world status', { error: err.message })
|
||||||
|
res.status(500).json({ error: 'Failed to read world status' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus }
|
||||||
50
template/server/router/public/world.router.js
Normal file
50
template/server/router/public/world.router.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ── Public · World ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mounted at `/api/v1/public/world` by `index.js`. One express Router, built
|
||||||
|
// from CORE's express (`core.express`) — never from a `require('express')` of
|
||||||
|
// your own, which would not resolve from here anyway (MODULE_API.md §7.2).
|
||||||
|
//
|
||||||
|
// **The tier's gate is already on.** This router sits inside core's public tier,
|
||||||
|
// which is behind nothing by design — the public API is public. Per-route
|
||||||
|
// middleware goes on top, and `siteMode` below is the one worth understanding:
|
||||||
|
// it is what makes a page respect the operator's maintenance switch. Core applies
|
||||||
|
// it to its own content routes (`/posts`, `/wiki`) and deliberately does not
|
||||||
|
// apply it to its status endpoints, because status is exactly what an operator
|
||||||
|
// wants visible *during* maintenance. Which of those two your route is depends on
|
||||||
|
// what it serves, and it is your decision to make.
|
||||||
|
//
|
||||||
|
// ── About the `#swagger` comments ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// They are not documentation *of* the code, they are the source the OpenAPI
|
||||||
|
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
|
||||||
|
// rules that cost this project real time:
|
||||||
|
//
|
||||||
|
// • swagger-autogen reads these as JavaScript literals it evaluates. It
|
||||||
|
// re-quotes `"` and a backtick to `'` first, so either one inside a
|
||||||
|
// single-quoted description ends the string early — and when it cannot parse
|
||||||
|
// an annotation it drops that annotation, prints an error, and then reports
|
||||||
|
// success. Use a typographic apostrophe (’) in prose. `swaggerFragment.js`
|
||||||
|
// captures those errors and makes them fatal, which is the only reason you
|
||||||
|
// will find out.
|
||||||
|
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
|
||||||
|
// evaluated as JS by the reader, so Swagger UI renders the backslash.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const world = require('./world.controller')
|
||||||
|
const { siteMode } = core.middleware
|
||||||
|
|
||||||
|
const worldRouter = express.Router()
|
||||||
|
|
||||||
|
worldRouter.get(
|
||||||
|
'/status',
|
||||||
|
// #swagger.tags = ['Public · Example Game']
|
||||||
|
// #swagger.summary = 'The game world’s current status'
|
||||||
|
// #swagger.description = 'What the game server last reported: whether it is up, how many players are on, and when that was. Answers with `online: false` and `stale: true` rather than failing when the game or its sidecar is unreachable — the site’s availability does not depend on the game’s.'
|
||||||
|
/* #swagger.responses[200] = { description: 'The world’s status', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameWorldStatus" } } } } */
|
||||||
|
siteMode,
|
||||||
|
world.getStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = worldRouter
|
||||||
190
template/server/scripts/checkImports.js
Normal file
190
template/server/scripts/checkImports.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ── §5.1 — zero internal imports ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The acceptance test for the whole module contract. A module that reaches into
|
||||||
|
// core's tree still works — right up until core moves a file — and the boundary
|
||||||
|
// this workstream exists to build is worth exactly as much as this check is.
|
||||||
|
//
|
||||||
|
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
|
||||||
|
// the violation but not the rule, and the difference matters in both directions:
|
||||||
|
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
|
||||||
|
// nested file (which it catches by accident) and false-alarms on a legitimate
|
||||||
|
// `require('../module.json')` from `server/` (which it catches wrongly). So this
|
||||||
|
// RESOLVES each specifier against the file that wrote it and asks whether the
|
||||||
|
// result is still inside the module root — the actual rule, stated once.
|
||||||
|
//
|
||||||
|
// Bare specifiers are checked too, and against a stricter list than "is it
|
||||||
|
// installed": core hands the module express, express-validator, the database and
|
||||||
|
// the logger on `ctx` precisely so the module never resolves them, and Node's
|
||||||
|
// resolver cannot reach core's `node_modules` from here anyway. A bare
|
||||||
|
// `require` that is not a Node builtin is therefore a module that will fail to
|
||||||
|
// load on a real install, with a message about a missing package rather than
|
||||||
|
// about the rule it broke.
|
||||||
|
//
|
||||||
|
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
|
||||||
|
// never run inside core's process — the fakes in `test/_fakes.js` build a real
|
||||||
|
// `express` router precisely so the module's routers are exercised for real —
|
||||||
|
// so they may use devDependencies. The containment check applies everywhere,
|
||||||
|
// because a test that reaches into core's tree is a test that passes on this
|
||||||
|
// machine and nowhere else.
|
||||||
|
//
|
||||||
|
// Run over the SERVER half. The client half's equivalents are its Vite build,
|
||||||
|
// which fails if a shared dependency resolves into node_modules, and
|
||||||
|
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
|
||||||
|
// specifier survived.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
// Node's own answer, not a list reconstructed from `builtinModules`. That list
|
||||||
|
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
|
||||||
|
// `node:test` passed locally and failed in CI on the very first run — reported
|
||||||
|
// as the module boundary being broken, which it was not. `isBuiltin` is the
|
||||||
|
// authoritative check and handles the `node:` prefix itself.
|
||||||
|
const { isBuiltin } = require('module')
|
||||||
|
|
||||||
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||||
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||||
|
|
||||||
|
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||||
|
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||||
|
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||||
|
// changes what CI installs and what the release tarball carries.
|
||||||
|
//
|
||||||
|
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
|
||||||
|
// release tarball carries `server/node_modules` because an operator never builds
|
||||||
|
// — so every entry is weight in the artifact and a package the operator's
|
||||||
|
// deployment now runs. Anything core already owns must come from `ctx` instead:
|
||||||
|
// a second express is a second Router prototype, a second express-rate-limit is
|
||||||
|
// a second store, and a limit enforced by two independent counters is not the
|
||||||
|
// limit either of them states.
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
||||||
|
|
||||||
|
// Directories whose contents never run inside core's process, and may therefore
|
||||||
|
// resolve this package's devDependencies.
|
||||||
|
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
||||||
|
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||||
|
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
|
||||||
|
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
|
||||||
|
|
||||||
|
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||||
|
// need a dependency, and a specifier this pattern misses is a specifier written
|
||||||
|
// to be missed, which review catches and a stricter regexp would not.
|
||||||
|
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank out comments and template literals before scanning.
|
||||||
|
*
|
||||||
|
* Not a nicety — without it this file fails on ITSELF, because the comments
|
||||||
|
* above name `require('../../../../etc/passwd')` as an example of what to
|
||||||
|
* catch, and index.js explains in prose why it must never `require('express')`.
|
||||||
|
* A boundary check that cannot survive being described is a check people stop
|
||||||
|
* writing comments around.
|
||||||
|
*
|
||||||
|
* A character walk rather than a regexp, because the two get in each other's
|
||||||
|
* way: `'https://x'` contains a line-comment opener inside a string, and
|
||||||
|
* `// don't` contains a quote inside a comment. Tracking the state is shorter
|
||||||
|
* than the regexp that would almost handle it. Content is replaced with spaces
|
||||||
|
* rather than removed so nothing else has to care.
|
||||||
|
*/
|
||||||
|
function stripCommentsAndTemplates(src) {
|
||||||
|
let out = ''
|
||||||
|
let i = 0
|
||||||
|
const keep = (n) => { out += src.slice(i, i + n); i += n }
|
||||||
|
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
|
||||||
|
while (i < src.length) {
|
||||||
|
const two = src.slice(i, i + 2)
|
||||||
|
if (two === '//') {
|
||||||
|
const nl = src.indexOf('\n', i)
|
||||||
|
blank(nl === -1 ? src.length : nl)
|
||||||
|
} else if (two === '/*') {
|
||||||
|
const end = src.indexOf('*/', i + 2)
|
||||||
|
blank(end === -1 ? src.length : end + 2)
|
||||||
|
} else if (src[i] === '"' || src[i] === "'") {
|
||||||
|
// Strings are KEPT — they are where the specifiers live.
|
||||||
|
const quote = src[i]
|
||||||
|
keep(1)
|
||||||
|
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
|
||||||
|
keep(1)
|
||||||
|
} else if (src[i] === '`') {
|
||||||
|
// Template literals are blanked: nothing may `require` a template, and a
|
||||||
|
// template holding SQL or HTML is a rich source of false positives.
|
||||||
|
i += 1
|
||||||
|
out += ' '
|
||||||
|
while (i < src.length && src[i] !== '`') {
|
||||||
|
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
out += ' '
|
||||||
|
} else {
|
||||||
|
keep(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
|
||||||
|
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
||||||
|
yield path.join(dir, entry.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every boundary violation under `root`, resolved against `moduleRoot`.
|
||||||
|
*
|
||||||
|
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
|
||||||
|
* has never been shown to fail is a check nobody knows the state of — and this
|
||||||
|
* one guards the acceptance criterion for the whole contract.
|
||||||
|
*/
|
||||||
|
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
|
||||||
|
const violations = []
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||||
|
for (const [, specifier] of source.matchAll(SPECIFIER)) {
|
||||||
|
if (specifier.startsWith('.')) {
|
||||||
|
const resolved = path.resolve(path.dirname(file), specifier)
|
||||||
|
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
|
||||||
|
violations.push({ file, specifier, why: 'escapes the module root' })
|
||||||
|
}
|
||||||
|
} else if (path.isAbsolute(specifier)) {
|
||||||
|
violations.push({ file, specifier, why: 'absolute path' })
|
||||||
|
} else {
|
||||||
|
const pkg = specifier.startsWith('@')
|
||||||
|
? specifier.split('/').slice(0, 2).join('/')
|
||||||
|
: specifier.split('/')[0]
|
||||||
|
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||||
|
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||||
|
// node_modules and is safe whatever this Node version enumerates.
|
||||||
|
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||||
|
if (!builtin && !allowed) {
|
||||||
|
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
|
||||||
|
|
||||||
|
// Required by a test, or run as the check? Only the second one exits.
|
||||||
|
if (require.main !== module) return
|
||||||
|
|
||||||
|
const violations = scan(SERVER_ROOT)
|
||||||
|
|
||||||
|
if (violations.length) {
|
||||||
|
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
|
||||||
|
for (const v of violations) {
|
||||||
|
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
|
||||||
|
}
|
||||||
|
console.error('')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)
|
||||||
255
template/server/scripts/swaggerFragment.js
Normal file
255
template/server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||||
|
// tags and schemas describing every route this module registers. Core merges the
|
||||||
|
// fragments of *started* modules over its own committed spec at request time and
|
||||||
|
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
|
||||||
|
//
|
||||||
|
// ── Why a module has to ship this at all ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
|
||||||
|
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
|
||||||
|
// arrives on a volume after core was built, is required by a filesystem loop, and
|
||||||
|
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
|
||||||
|
// follow, and core does not have your sources anyway. So nothing core can run
|
||||||
|
// will ever describe your routes.
|
||||||
|
//
|
||||||
|
// The failure mode is the dangerous one: swagger-autogen reports success and
|
||||||
|
// emits a spec with the routes simply absent. It happened twice inside core
|
||||||
|
// before anyone noticed, and once to the first module — 417 annotations that
|
||||||
|
// generated nothing at all, for two phases, because nobody had built the
|
||||||
|
// fragment. If you take one thing from this file, take that a green build is not
|
||||||
|
// evidence that anything was described.
|
||||||
|
//
|
||||||
|
// ── Where the prefixes come from ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||||
|
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
|
||||||
|
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
|
||||||
|
// because core merges the fragment verbatim and never re-derives a prefix.
|
||||||
|
//
|
||||||
|
// So this script **runs your own `register()`** against a recording `api` and
|
||||||
|
// reads the mounts back out. Every prefix is therefore the prefix that router is
|
||||||
|
// actually registered under — the same call an operator's core will make, rather
|
||||||
|
// than a table beside it that drifts the first time a mount moves. Which file a
|
||||||
|
// recorded router object came from is answered by `require.cache`: the module
|
||||||
|
// whose `exports` IS that router.
|
||||||
|
//
|
||||||
|
// The tier base paths are the one thing that cannot be derived here, because they
|
||||||
|
// are core's and not yours. They are §2.4's normative table, quoted below.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||||
|
|
||||||
|
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||||
|
const doc = require('../swagger/doc')
|
||||||
|
|
||||||
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||||
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||||
|
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||||
|
|
||||||
|
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||||
|
// router in core, behind its gate; the base path is core's and fixed.
|
||||||
|
const TIER_BASE = {
|
||||||
|
public: '/api/v1/public',
|
||||||
|
admin: '/api/v1/admin',
|
||||||
|
player: '/api/v1/player',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
|
||||||
|
*
|
||||||
|
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||||
|
* against — because registration must not touch a database (§2.2), and this
|
||||||
|
* script is exactly the kind of no-database caller that rule exists for.
|
||||||
|
*/
|
||||||
|
function mountedRouters() {
|
||||||
|
const register = require('../index')
|
||||||
|
const api = fakeApi()
|
||||||
|
register(fakeCtx(), api)
|
||||||
|
|
||||||
|
const fileOf = (router) => {
|
||||||
|
for (const mod of Object.values(require.cache)) {
|
||||||
|
if (mod && mod.exports === router) return mod.filename
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mounts = []
|
||||||
|
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||||
|
const base = TIER_BASE[tier]
|
||||||
|
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||||
|
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||||
|
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mounts.map(({ router, prefix, what }) => {
|
||||||
|
const file = fileOf(router)
|
||||||
|
if (!file) {
|
||||||
|
// A router built inline in index.js rather than required from its own
|
||||||
|
// file. swagger-autogen needs a file to read, so there is nothing to
|
||||||
|
// generate from — put the router in its own module.
|
||||||
|
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||||
|
}
|
||||||
|
return { file, prefix, what }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||||
|
*
|
||||||
|
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||||
|
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||||
|
* annotation, and prints `Success` in green. So its diagnostics are captured here
|
||||||
|
* and made fatal. Nothing else will tell you.
|
||||||
|
*/
|
||||||
|
async function fragmentFor(file) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
|
||||||
|
const out = path.join(dir, 'fragment.json')
|
||||||
|
|
||||||
|
const complaints = []
|
||||||
|
const realError = console.error
|
||||||
|
console.error = (...args) => {
|
||||||
|
const line = args.map(String).join(' ')
|
||||||
|
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||||
|
else realError(...args)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||||
|
// writes its result back into the object it was handed, so reusing one `doc`
|
||||||
|
// across several routers re-wraps the previous pass's output every time. The
|
||||||
|
// first module to hit this produced a 484 MB fragment from six routers.
|
||||||
|
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||||
|
...JSON.parse(JSON.stringify(doc)),
|
||||||
|
info: { title: 'examplegame fragment', version: '0' },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
console.error = realError
|
||||||
|
}
|
||||||
|
if (complaints.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||||
|
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||||
|
*
|
||||||
|
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
|
||||||
|
* to the PREFIX is moved to the front of each operation's parameter list —
|
||||||
|
* swagger-autogen orders parameters by where they appeared in the path it saw,
|
||||||
|
* which was only the tail.
|
||||||
|
*/
|
||||||
|
function prefixPaths(fragment, prefix) {
|
||||||
|
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||||
|
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||||
|
const paths = {}
|
||||||
|
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||||
|
for (const operation of Object.values(item)) {
|
||||||
|
const params = operation && operation.parameters
|
||||||
|
if (!Array.isArray(params)) continue
|
||||||
|
const rank = (q) => {
|
||||||
|
const i = outer.indexOf(q && q.name)
|
||||||
|
return i === -1 ? outer.length : i
|
||||||
|
}
|
||||||
|
operation.parameters = params
|
||||||
|
.map((q, i) => ({ q, i }))
|
||||||
|
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||||
|
.map(({ q }) => q)
|
||||||
|
}
|
||||||
|
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
|
||||||
|
// client calls. Core's generator normalises the same way.
|
||||||
|
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||||
|
*
|
||||||
|
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
|
||||||
|
* fragment carry. `info`, `servers` and the security schemes belong to the merged
|
||||||
|
* document, which is to say to core.
|
||||||
|
*/
|
||||||
|
async function build() {
|
||||||
|
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||||
|
let shared = false
|
||||||
|
|
||||||
|
for (const { file, prefix, what } of mountedRouters()) {
|
||||||
|
const generated = await fragmentFor(file)
|
||||||
|
// Tags and schemas are the same on every pass — each was handed the same
|
||||||
|
// `doc` — so take them from whichever ran first. What lands in the fragment
|
||||||
|
// has to be what swagger-autogen PRODUCED and not what it was given: those
|
||||||
|
// two differ (see fragmentFor), and core merges this file verbatim into a
|
||||||
|
// spec whose own schemas went through the same mill.
|
||||||
|
if (!shared) {
|
||||||
|
spec.tags = generated.tags || []
|
||||||
|
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||||
|
shared = true
|
||||||
|
}
|
||||||
|
const paths = prefixPaths(generated, prefix)
|
||||||
|
const count = Object.keys(paths).length
|
||||||
|
if (count === 0) {
|
||||||
|
// An empty result is precisely what the silent drop looks like, so it is a
|
||||||
|
// hard failure rather than a router that happens to declare no routes.
|
||||||
|
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||||
|
}
|
||||||
|
for (const [p, item] of Object.entries(paths)) {
|
||||||
|
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||||
|
spec.paths[p] = item
|
||||||
|
}
|
||||||
|
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, because swagger-autogen emits router-traversal order: without this,
|
||||||
|
// moving a route between files rewrites most of a committed artifact even when
|
||||||
|
// the API is provably unchanged.
|
||||||
|
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const check = process.argv.includes('--check')
|
||||||
|
const spec = await build()
|
||||||
|
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||||
|
|
||||||
|
if (!check) {
|
||||||
|
fs.writeFileSync(FRAGMENT, json)
|
||||||
|
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(FRAGMENT)) {
|
||||||
|
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
|
||||||
|
process.stderr.write(
|
||||||
|
'\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' +
|
||||||
|
'so a stale one documents a URL surface this module does not serve.\n',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`${err.stack}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }
|
||||||
56
template/server/swagger/doc.js
Normal file
56
template/server/swagger/doc.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// The tags and component schemas your `#swagger.*` annotations refer to.
|
||||||
|
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
|
||||||
|
// detail lives beside each route, exactly as it does in core.
|
||||||
|
//
|
||||||
|
// **Two rules about names, and both belong to the MERGED document rather than to
|
||||||
|
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
|
||||||
|
// over its own spec and serves the result at `/api/docs.json`, and core wins any
|
||||||
|
// key collision:
|
||||||
|
//
|
||||||
|
// • **Namespace what you DEFINE.** `ExamplegameWorldStatus`, not `WorldStatus`.
|
||||||
|
// A second game's module describing the same idea under the same bare name
|
||||||
|
// would silently clobber yours or be clobbered by it. The prefix is what
|
||||||
|
// makes two modules able to coexist.
|
||||||
|
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
|
||||||
|
// and `ValidationError` are core's; point at them and do not redefine them.
|
||||||
|
// They resolve in the merged document, where core's definitions are. Shipping
|
||||||
|
// your own copy is a collision core drops — which is the right outcome, and
|
||||||
|
// an expensive way to learn it.
|
||||||
|
//
|
||||||
|
// A tag is how the docs UI groups operations. Name yours after your module so an
|
||||||
|
// operator reading `/api/docs` can see which operations arrived with it.
|
||||||
|
//
|
||||||
|
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
|
||||||
|
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
|
||||||
|
// That is uniform across core's committed spec and is the house shape — match it,
|
||||||
|
// do not fight it.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: 'Public · Example Game',
|
||||||
|
description: 'Live world data, as last reported by the game server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
ExamplegameWorldStatus: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The game world’s status (GET /public/world/status).',
|
||||||
|
properties: {
|
||||||
|
online: { type: 'boolean', example: true },
|
||||||
|
players: { type: 'integer', example: 12 },
|
||||||
|
worldName: { type: 'string', nullable: true, example: 'Example World' },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
stale: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
|
||||||
|
example: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
107
template/server/test/_fakes.js
Normal file
107
template/server/test/_fakes.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// ── Test doubles for what core hands the module ───────────────────────────
|
||||||
|
//
|
||||||
|
// Your server half is testable WITHOUT core, and that is not a convenience — it
|
||||||
|
// is the contract holding. Everything a module may touch arrives on `ctx`
|
||||||
|
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
|
||||||
|
// what your module depends on. **If a test ever needs something that is not here,
|
||||||
|
// either your module reached past the boundary or §2.3 needs a new member.** Both
|
||||||
|
// are worth stopping for.
|
||||||
|
//
|
||||||
|
// The fake mirrors §2.3 member for member — including the freezing, so a module
|
||||||
|
// that assigns to `ctx.something` fails here the way it would in core.
|
||||||
|
//
|
||||||
|
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
|
||||||
|
// which is why it may `require('express')` when the module's own routers may not.
|
||||||
|
// It builds a REAL express Router on purpose: a fake Router would only ever test
|
||||||
|
// the fake.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
/** Records every call, so a test can assert what the module asked for. */
|
||||||
|
function spy(returns) {
|
||||||
|
const fn = (...args) => {
|
||||||
|
fn.calls.push(args)
|
||||||
|
return typeof returns === 'function' ? returns(...args) : returns
|
||||||
|
}
|
||||||
|
fn.calls = []
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLog() {
|
||||||
|
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeCtx(overrides = {}) {
|
||||||
|
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
|
||||||
|
// installed. Core always freezes; the unfrozen variant is never a claim about
|
||||||
|
// what a module is handed in production.
|
||||||
|
const { freeze = true, ...rest } = overrides
|
||||||
|
const logs = []
|
||||||
|
const ctx = {
|
||||||
|
moduleId: 'examplegame',
|
||||||
|
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||||
|
express,
|
||||||
|
validator: {},
|
||||||
|
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||||
|
log: (namespace) => {
|
||||||
|
const log = fakeLog()
|
||||||
|
logs.push({ namespace, log })
|
||||||
|
return log
|
||||||
|
},
|
||||||
|
auth: { getUserFromRequest: spy(null) },
|
||||||
|
middleware: {
|
||||||
|
requireAuth: (req, res, next) => next(),
|
||||||
|
requireRole: () => (req, res, next) => next(),
|
||||||
|
siteMode: (req, res, next) => next(),
|
||||||
|
validate: (req, res, next) => next(),
|
||||||
|
noindex: (req, res, next) => next(),
|
||||||
|
// The factory returns a pass-through rather than a real limiter: a test
|
||||||
|
// that tripped a rate limit would be a test whose result depended on how
|
||||||
|
// many times the suite had run.
|
||||||
|
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
|
||||||
|
accountChangeLimiter: (req, res, next) => next(),
|
||||||
|
},
|
||||||
|
site: { baseUrl: 'http://localhost:5173' },
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||||
|
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||||
|
// by the loop below and every `log.info` would throw on push. Keeping it out of
|
||||||
|
// the enumeration also makes the fake more faithful: a module iterating `ctx`
|
||||||
|
// sees §2.3's members and nothing a test put there.
|
||||||
|
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||||
|
if (!freeze) return ctx
|
||||||
|
for (const value of Object.values(ctx)) {
|
||||||
|
if (value && typeof value === 'object') Object.freeze(value)
|
||||||
|
}
|
||||||
|
return Object.freeze(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registration api, recording rather than mounting.
|
||||||
|
*
|
||||||
|
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
|
||||||
|
* that registers the same thing twice fails in its own suite rather than first on
|
||||||
|
* an operator's install.
|
||||||
|
*/
|
||||||
|
function fakeApi() {
|
||||||
|
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {} }
|
||||||
|
const called = new Set()
|
||||||
|
const once = (name) => {
|
||||||
|
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||||
|
called.add(name)
|
||||||
|
}
|
||||||
|
const api = {
|
||||||
|
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
|
||||||
|
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||||
|
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||||
|
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
||||||
|
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
|
||||||
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
|
}
|
||||||
|
api.record = record
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fakeCtx, fakeApi, spy }
|
||||||
149
template/server/test/checkImports.test.js
Normal file
149
template/server/test/checkImports.test.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// The boundary check, checked.
|
||||||
|
//
|
||||||
|
// `scripts/checkImports.js` is the acceptance test for the whole module contract
|
||||||
|
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
|
||||||
|
// nobody knows the state of. These point it at fixtures that break each rule and
|
||||||
|
// assert it says so — and at prose that merely *describes* breaking them, which
|
||||||
|
// is what it got wrong the first time it was run.
|
||||||
|
//
|
||||||
|
// **Every fixture is a template literal, and that is load-bearing.** The scanner
|
||||||
|
// reads the files in this directory too, so an ordinary quoted string holding
|
||||||
|
// `require('../../x')` would make this file fail the very check it is testing.
|
||||||
|
// Templates are blanked by the stripper for exactly this class of text: source
|
||||||
|
// being composed as data is not source being imported.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
|
||||||
|
|
||||||
|
/** Write `files` into a throwaway module tree and scan it. */
|
||||||
|
function scanFixture(files, { dev = new Set() } = {}) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
|
||||||
|
const src = path.join(root, 'server')
|
||||||
|
for (const [name, source] of Object.entries(files)) {
|
||||||
|
const file = path.join(src, name)
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||||
|
fs.writeFileSync(file, source)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the real server half is clean', () => {
|
||||||
|
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catches a relative path that escapes the module root', () => {
|
||||||
|
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.strictEqual(found[0].why, 'escapes the module root')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows a relative path that stays inside it, however deep', () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catches an absolute path', () => {
|
||||||
|
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
|
||||||
|
assert.strictEqual(found[0].why, 'absolute path')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catches a bare specifier in shipped code, even a devDependency', () => {
|
||||||
|
// The rule that makes the boundary real: express arrives on ctx. A shipped
|
||||||
|
// file requiring it would fail on a real install, because a module lives
|
||||||
|
// outside core's server/ and never reaches core's node_modules.
|
||||||
|
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
assert.match(found[0].why, /should this come from ctx/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows a devDependency in test code, which never runs inside core', () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows node builtins anywhere, with or without the node: prefix', () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows node:test, which older Node versions omit from builtinModules', () => {
|
||||||
|
// The first CI run failed on exactly this and on nothing else: `builtinModules`
|
||||||
|
// omits `test` on Node 20 and includes it on Node 24, so every test file in
|
||||||
|
// this suite was reported as breaking the module boundary. The check asks
|
||||||
|
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
|
||||||
|
// prefix as sufficient on its own — a prefixed specifier can never resolve to
|
||||||
|
// a package, whatever the running version enumerates.
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catches ESM and dynamic forms, not only require()', () => {
|
||||||
|
const found = scanFixture({
|
||||||
|
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
|
||||||
|
})
|
||||||
|
assert.strictEqual(found.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores a violation that is only DESCRIBED in a comment', () => {
|
||||||
|
// The first run of this check failed on its own documentation, and on
|
||||||
|
// index.js's comment explaining why the module must never require('express').
|
||||||
|
// Prose about the rule must not trip the rule.
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({
|
||||||
|
'a.js': [
|
||||||
|
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
|
||||||
|
`/* Nor import express from "express": core hands it over on ctx. */`,
|
||||||
|
`const path = require('path')`,
|
||||||
|
].join('\n'),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores a specifier-shaped string inside a template literal', () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a comment opener inside a string does not swallow the rest of the file', () => {
|
||||||
|
// The reason this is a character walk and not a regexp: a URL in a string
|
||||||
|
// contains `//`, and treating that as a comment would blank everything after
|
||||||
|
// it — turning the check into one that silently passes.
|
||||||
|
const found = scanFixture({
|
||||||
|
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
|
||||||
|
})
|
||||||
|
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a quote inside a comment does not swallow the rest of the file', () => {
|
||||||
|
const found = scanFixture({
|
||||||
|
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
|
||||||
|
})
|
||||||
|
assert.strictEqual(found.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stripping preserves line numbers', () => {
|
||||||
|
// Blanked rather than removed, so anything that later reports a line still
|
||||||
|
// reports the right one.
|
||||||
|
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
|
||||||
|
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
|
||||||
|
})
|
||||||
84
template/server/test/entry.test.js
Normal file
84
template/server/test/entry.test.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// ── The registration handshake ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The one suite every module should have, whatever else it does. Core validates
|
||||||
|
// all of this at boot and refuses to mount a module that fails — so testing it
|
||||||
|
// here is the difference between finding out in half a second and finding out on
|
||||||
|
// an operator's install.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||||
|
const manifest = require('../../module.json')
|
||||||
|
|
||||||
|
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||||||
|
function register(ctx = fakeCtx()) {
|
||||||
|
require('../core')._reset()
|
||||||
|
const api = fakeApi()
|
||||||
|
require('../index')(ctx, api)
|
||||||
|
return { api, ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registers exactly the mounts module.json declares', () => {
|
||||||
|
const { api } = register()
|
||||||
|
|
||||||
|
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||||||
|
// declared and never registered is as fatal as a route registered and never
|
||||||
|
// declared. Asserting it against the manifest rather than against a literal is
|
||||||
|
// what keeps the test true after you add a prefix.
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
Object.keys(api.record.routes).sort(),
|
||||||
|
Object.keys(manifest.mounts).sort(),
|
||||||
|
)
|
||||||
|
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||||||
|
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every registered mount is a real express router', () => {
|
||||||
|
const { api } = register()
|
||||||
|
for (const byPrefix of Object.values(api.record.routes)) {
|
||||||
|
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||||
|
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||||||
|
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefixes are one segment, lowercase, no parameters', () => {
|
||||||
|
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||||||
|
// module that fails it does not mount at all.
|
||||||
|
for (const prefixes of Object.values(manifest.mounts)) {
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registration touches no database and awaits nothing', () => {
|
||||||
|
const ctx = fakeCtx()
|
||||||
|
register(ctx)
|
||||||
|
|
||||||
|
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||||||
|
// port in two build tools, so a query here would hang both — and the symptom
|
||||||
|
// is a build that never finishes rather than an error naming this module.
|
||||||
|
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registers both lifecycle hooks', () => {
|
||||||
|
const { api } = register()
|
||||||
|
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||||
|
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the manifest declares what the loader requires', () => {
|
||||||
|
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||||
|
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||||
|
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||||||
|
// Declaring a schema without a purge is refused: a module that can create
|
||||||
|
// tables and cannot drop them leaves an operator with orphaned data.
|
||||||
|
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||||||
|
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||||||
|
// serves, so an entry in the module root would publish the whole module.
|
||||||
|
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||||
|
})
|
||||||
116
template/server/test/schema.test.js
Normal file
116
template/server/test/schema.test.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ── The schema fragment, checked against §2.6's rules ─────────────────────
|
||||||
|
//
|
||||||
|
// Core validates the fragment at LOAD time and refuses to mount a module that
|
||||||
|
// breaks a rule — with no tables created and no routes served. That is the right
|
||||||
|
// behaviour and a slow way to find a typo, so the same rules are checked here.
|
||||||
|
//
|
||||||
|
// **This is also the suite that catches a half-finished rename.** Change the id
|
||||||
|
// in `module.json` and forget a table name, and the prefix assertion below fails
|
||||||
|
// immediately rather than at an operator's first boot.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const manifest = require('../../module.json')
|
||||||
|
|
||||||
|
const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a SQL file into statements the way core does.
|
||||||
|
*
|
||||||
|
* Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the
|
||||||
|
* loader and the schema replay — this is a small stand-in for a test, and it is
|
||||||
|
* deliberately simple because the fragment it reads is deliberately simple. If
|
||||||
|
* your schema grows a stored procedure or a string containing a semicolon, stop
|
||||||
|
* trusting this and read the fragment a different way.
|
||||||
|
*/
|
||||||
|
function statements(sql) {
|
||||||
|
return sql
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.trim().startsWith('--'))
|
||||||
|
.join('\n')
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = statements(read(manifest.schema))
|
||||||
|
const purge = statements(read(manifest.purge))
|
||||||
|
|
||||||
|
// The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist:
|
||||||
|
// this file replays on every boot, so TRUNCATE or DELETE would empty a table on
|
||||||
|
// every restart — which no denylist naming only DROP would have caught.
|
||||||
|
const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
|
||||||
|
|
||||||
|
test('every statement starts with an allowed verb', () => {
|
||||||
|
for (const statement of schema) {
|
||||||
|
const verb = statement.split(/\s+/)[0].toUpperCase()
|
||||||
|
assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every table is prefixed with the module id', () => {
|
||||||
|
for (const statement of schema) {
|
||||||
|
const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement)
|
||||||
|
if (!match) continue
|
||||||
|
assert.ok(
|
||||||
|
match[1].startsWith(`${manifest.id}_`),
|
||||||
|
`table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the fragment is idempotent — it replays on every boot', () => {
|
||||||
|
for (const statement of schema) {
|
||||||
|
if (/^CREATE\s+TABLE/i.test(statement)) {
|
||||||
|
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS')
|
||||||
|
}
|
||||||
|
if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) {
|
||||||
|
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS')
|
||||||
|
}
|
||||||
|
if (/^INSERT\s+INTO/i.test(statement)) {
|
||||||
|
// A plain INSERT succeeds once and then fails the whole replay on the next
|
||||||
|
// boot with a duplicate key — the classic "worked until I restarted it".
|
||||||
|
assert.ok(
|
||||||
|
/INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement),
|
||||||
|
'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('purge drops every table the schema creates', () => {
|
||||||
|
const created = schema
|
||||||
|
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m) => m[1])
|
||||||
|
const dropped = purge
|
||||||
|
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m) => m[1])
|
||||||
|
|
||||||
|
for (const table of created) {
|
||||||
|
assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`)
|
||||||
|
}
|
||||||
|
for (const table of dropped) {
|
||||||
|
assert.ok(created.includes(table), `${table} is dropped but never created`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('purge drops in the reverse of creation order', () => {
|
||||||
|
// With one table this proves nothing; with a parent and its children it is the
|
||||||
|
// difference between a clean teardown and a purge that fails halfway, leaving
|
||||||
|
// exactly the orphaned data it exists to remove.
|
||||||
|
const created = schema
|
||||||
|
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m) => m[1])
|
||||||
|
const dropped = purge
|
||||||
|
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m) => m[1])
|
||||||
|
|
||||||
|
assert.deepStrictEqual(dropped, [...created].reverse())
|
||||||
|
})
|
||||||
58
template/server/test/worldStatus.test.js
Normal file
58
template/server/test/worldStatus.test.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// ── The model, with no database ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The `.db.js` / `.model.js` split pays for itself here: the logic worth testing
|
||||||
|
// is in the model, and the model's only dependency is a function that returns a
|
||||||
|
// row. Stub that and there is nothing to stand up.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const db = require('../model/worldStatus/worldStatus.db')
|
||||||
|
const { getPublicStatus, STALE_AFTER_MS } = require('../model/worldStatus/worldStatus.model')
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-08-12T12:00:00Z')
|
||||||
|
|
||||||
|
/** Replace `getStatus` for one test and put it back afterwards. */
|
||||||
|
function withRow(row, fn) {
|
||||||
|
const real = db.getStatus
|
||||||
|
db.getStatus = async () => row
|
||||||
|
return Promise.resolve(fn()).finally(() => { db.getStatus = real })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a fresh row reports the world online', () =>
|
||||||
|
withRow(
|
||||||
|
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - 1000) },
|
||||||
|
async () => {
|
||||||
|
const status = await getPublicStatus(NOW)
|
||||||
|
assert.strictEqual(status.online, true)
|
||||||
|
assert.strictEqual(status.players, 12)
|
||||||
|
assert.strictEqual(status.worldName, 'Example World')
|
||||||
|
assert.strictEqual(status.stale, false)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
test('a stale row is reported offline, whatever it says', () =>
|
||||||
|
withRow(
|
||||||
|
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - STALE_AFTER_MS - 1) },
|
||||||
|
async () => {
|
||||||
|
const status = await getPublicStatus(NOW)
|
||||||
|
// The row claims the world is up. Nothing has written it in longer than the
|
||||||
|
// freshness window, so the claim is not evidence of anything.
|
||||||
|
assert.strictEqual(status.online, false)
|
||||||
|
assert.strictEqual(status.players, 0)
|
||||||
|
assert.strictEqual(status.stale, true)
|
||||||
|
// The name is still worth showing — it does not go stale the way a player
|
||||||
|
// count does.
|
||||||
|
assert.strictEqual(status.worldName, 'Example World')
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
test('no row at all is an answer, not an error', () =>
|
||||||
|
withRow(null, async () => {
|
||||||
|
// A fresh install whose first boot has not finished replaying the schema.
|
||||||
|
// The site must render; the next boot fixes it.
|
||||||
|
const status = await getPublicStatus(NOW)
|
||||||
|
assert.deepStrictEqual(status, {
|
||||||
|
online: false, players: 0, worldName: null, updatedAt: null, stale: true,
|
||||||
|
})
|
||||||
|
}))
|
||||||
133
template/swagger-fragment.json
Normal file
133
template/swagger-fragment.json
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/public/world/status": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Public · Example Game"
|
||||||
|
],
|
||||||
|
"summary": "The game world’s current status",
|
||||||
|
"description": "What the game server last reported: whether it is up, how many players are on, and when that was. Answers with `online: false` and `stale: true` rather than failing when the game or its sidecar is unreachable — the site’s availability does not depend on the game’s.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The world’s status",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ExamplegameWorldStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Public · Example Game",
|
||||||
|
"description": "Live world data, as last reported by the game server"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"ExamplegameWorldStatus": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "The game world’s status (GET /public/world/status)."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"online": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "boolean"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"players": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worldName": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Example World"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stale": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "boolean"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Has nothing reported in longer than the freshness window? A stale row is reported offline."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user