feat(module): the bundle skeleton (phase 3, slice 0)
The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.
Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.
Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.
Three findings, each of which had produced a green build that was wrong.
MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier 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. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.
That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.
checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,25 +5,33 @@
|
|||||||
# shaped like that repo's `server/` and `client/`, and it is loaded into that
|
# shaped like that repo's `server/` and `client/`, and it is loaded into that
|
||||||
# repo's process, so it is checked the same way with the same Node version.
|
# repo's process, so it is checked the same way with the same Node version.
|
||||||
#
|
#
|
||||||
# ── Package guard ────────────────────────────────────────────────────────────
|
# ── What each job is really asking ───────────────────────────────────────────
|
||||||
# This repo is in the planning phase and has no module code yet: the API contract
|
|
||||||
# is settled in Phase 1 and Phase 3 is what extracts the UO half of `website/`
|
|
||||||
# into this repo (docs/website/MODULE_SYSTEM.md §2.7). Rather than leave the repo
|
|
||||||
# ungated until then — or land a workflow that red-Xes every governance/docs PR —
|
|
||||||
# each half's gates are conditional on its package.json existing. Before the code
|
|
||||||
# lands, the job reports green with a notice saying so. The moment a package.json
|
|
||||||
# appears the gates arm themselves; nothing here has to change.
|
|
||||||
#
|
#
|
||||||
# The same trick guards the client build, which is the higher-risk half: it must
|
# The tests are the ordinary half. The two `check:*` scripts are the interesting
|
||||||
# build with Vite in library mode with react/react-dom/react-router-dom EXTERNAL,
|
# one, because they are the acceptance criteria of the module contract itself
|
||||||
# because there is exactly one React instance in the page and core owns it. A
|
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
|
||||||
# module that bundles its own React loads and then breaks hooks at runtime, which
|
#
|
||||||
# is precisely the kind of failure worth catching before merge.
|
# • `server: check:imports` — no relative path escapes the module root, and no
|
||||||
|
# shipped file resolves a bare specifier. A module that reaches into core's
|
||||||
|
# tree works right up until core moves a file, and the whole boundary is
|
||||||
|
# worth exactly as much as this check is (§5.1).
|
||||||
|
#
|
||||||
|
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
|
||||||
|
# failure is invisible in source: `import { useState } from 'react'` is
|
||||||
|
# correct in every file, and whether it becomes core's React or a bare
|
||||||
|
# specifier no browser can resolve is decided by vite.config.js. It has to
|
||||||
|
# be asked of the artifact, so it runs after the build. (The other half —
|
||||||
|
# a shared dependency being BUNDLED — fails the build itself, from a
|
||||||
|
# resolution-time guard inside vite.config.js.)
|
||||||
|
#
|
||||||
|
# Building the chunk in CI is not only a check: it is how the chunk that ships is
|
||||||
|
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
|
||||||
#
|
#
|
||||||
# Not here yet, deliberately, because there is nothing for them to act on until
|
# Not here yet, deliberately, because there is nothing for them to act on until
|
||||||
# Phase 3: a release workflow (the `module-uo-<version>.tar.gz` artifact and its
|
# the extraction is further along: the release workflow (the
|
||||||
# sha256 manifest), the zero-internal-imports check, and the module's own frozen
|
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
|
||||||
# route manifest. Each lands with the code it checks.
|
# own frozen route manifest, which needs core checked out at a pinned ref
|
||||||
|
# (MODULE_API.md §5.3). Each lands with the slice it checks.
|
||||||
#
|
#
|
||||||
# Enforcement (one-time, in the Gitea UI):
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
@@ -54,22 +62,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Detect whether the server half exists yet
|
|
||||||
id: detect
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f server/package.json ]; then
|
|
||||||
echo "pkg=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "==> server/package.json found - running the server gates."
|
|
||||||
else
|
|
||||||
echo "pkg=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "==> No server/package.json yet (planning phase)."
|
|
||||||
echo " Skipping install/test. These gates arm themselves as soon"
|
|
||||||
echo " as Phase 3 lands the server half - see MODULE_SYSTEM.md."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -78,50 +71,34 @@ jobs:
|
|||||||
# `npm ci` rather than `npm install`: it also proves the lockfile is in
|
# `npm ci` rather than `npm install`: it also proves the lockfile is in
|
||||||
# sync with package.json instead of silently updating it.
|
# sync with package.json instead of silently updating it.
|
||||||
- name: Install server deps
|
- name: Install server deps
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
run: npm ci --prefix server
|
run: npm ci --prefix server
|
||||||
|
|
||||||
- name: Run server tests
|
- name: Run server tests
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
run: npm test --prefix server
|
run: npm test --prefix server
|
||||||
|
|
||||||
|
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||||
|
run: npm run check:imports --prefix server
|
||||||
|
|
||||||
client-build:
|
client-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Detect whether the client half exists yet
|
|
||||||
id: detect
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f client/package.json ]; then
|
|
||||||
echo "pkg=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "==> client/package.json found - running the client gates."
|
|
||||||
else
|
|
||||||
echo "pkg=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "==> No client/package.json yet (planning phase)."
|
|
||||||
echo " Skipping install/test/build. These gates arm themselves as"
|
|
||||||
echo " soon as Phase 3 lands the client half."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: client/package-lock.json
|
||||||
|
|
||||||
- name: Install client deps
|
- name: Install client deps
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
run: npm ci --prefix client
|
run: npm ci --prefix client
|
||||||
|
|
||||||
- name: Run client tests
|
- name: Run client tests
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
run: npm test --prefix client
|
run: npm test --prefix client
|
||||||
|
|
||||||
# Building the ESM chunk in CI is not just a check: it is how the chunk
|
|
||||||
# that ships in the release is produced, since an operator never builds.
|
|
||||||
- name: Build the client chunk
|
- name: Build the client chunk
|
||||||
if: ${{ steps.detect.outputs.pkg == 'true' }}
|
|
||||||
run: npm run build --prefix client
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||||
|
run: npm run check:externals --prefix client
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -24,36 +24,69 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i
|
|||||||
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
|
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
|
||||||
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
|
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
|
||||||
|
|
||||||
## Status: planning — no module code exists yet
|
## Status: the bundle skeleton exists; the extraction has started
|
||||||
|
|
||||||
This repo currently holds governance scaffolding only. The design of record is
|
The design of record is
|
||||||
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
||||||
in the docs repo — **read it before opening a PR here.** It defines the module API surface, the
|
and the normative contract is
|
||||||
packaging, the state machine, the install/uninstall/purge model, and the phases.
|
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||||
|
in the docs repo — **read them before opening a PR here.** Where the two differ, the contract wins.
|
||||||
|
|
||||||
| Phase | Where it happens | State |
|
| Phase | Where it happens | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | 🟡 in progress |
|
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
|
||||||
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ⬜ blocking |
|
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
|
||||||
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ⬜ |
|
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
|
||||||
| 3 — extract the UO half of the site into this repo | `website`, here | ⬜ |
|
| 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress |
|
||||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
||||||
|
|
||||||
Nothing lands here until Phase 1 has settled the contract this module is written against. Phase 3 is
|
Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first
|
||||||
what fills the repo.
|
and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one
|
||||||
|
merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core
|
||||||
|
drops it.
|
||||||
|
|
||||||
## What it will contain
|
**Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the
|
||||||
|
delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves
|
||||||
|
the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the
|
||||||
|
renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after
|
||||||
|
this one adds registrations to `server/index.js` and `client/src/entry.jsx`.
|
||||||
|
|
||||||
|
## Working on it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
|
||||||
|
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
|
||||||
|
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
|
||||||
|
```
|
||||||
|
|
||||||
|
The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests:
|
||||||
|
no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into
|
||||||
|
the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build
|
||||||
|
itself, from a guard inside `vite.config.js`.
|
||||||
|
|
||||||
|
Running it against a real core means checking this repo out as `website/modules/uo`, building the
|
||||||
|
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
|
||||||
|
that proves the client half works: its real failure modes are timing and module resolution, and
|
||||||
|
neither has a shape a DOM-less test runner can see.
|
||||||
|
|
||||||
|
## What it contains
|
||||||
|
|
||||||
One repo, one bundle: the server half and the client half live side by side and version together, so
|
One repo, one bundle: the server half and the client half live side by side and version together, so
|
||||||
a route and the screen that calls it can never be mismatched.
|
a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
|
||||||
|
|
||||||
```
|
```
|
||||||
module.json id, version, coreApi range, mounts, extensions
|
module.json ✅ id, version, coreApi range, mounts, extensions
|
||||||
|
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
|
||||||
|
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
|
||||||
|
server/test/ ✅ node --test, with a fake ctx standing in for core
|
||||||
server/ routers, controllers, models, utils
|
server/ routers, controllers, models, utils
|
||||||
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||||
|
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
|
||||||
|
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
|
||||||
|
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
|
||||||
client/src/ route components, nav registrations, feature provider
|
client/src/ route components, nav registrations, feature provider
|
||||||
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
|
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
|
||||||
```
|
```
|
||||||
|
|
||||||
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.
|
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.
|
||||||
|
|||||||
1792
client/package-lock.json
generated
Normal file
1792
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "module-uo-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Client half of module-uo — 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, react-dom/client and react-router-dom are EXTERNAL in the Vite build and arrive at runtime on window.__rg — there is exactly one React in the page and core owns it (MODULE_API.md §3.2, §7.2). They are devDependencies only so Vite and the JSX transform can typecheck and resolve during the build.",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
client/scripts/checkExternals.js
Normal file
79
client/scripts/checkExternals.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/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')
|
||||||
|
|
||||||
|
if (!fs.existsSync(CHUNK)) {
|
||||||
|
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = fs.readFileSync(CHUNK, 'utf8')
|
||||||
|
const problems = []
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g
|
||||||
|
const bare = new Set()
|
||||||
|
for (const [, specifier] of chunk.matchAll(IMPORTS)) {
|
||||||
|
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||||
|
}
|
||||||
|
if (bare.size) {
|
||||||
|
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).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||||
|
// packages ship and this module has no other reason to contain.
|
||||||
|
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' },
|
||||||
|
]
|
||||||
|
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).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`)
|
||||||
82
client/src/entry.jsx
Normal file
82
client/src/entry.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// ── module-uo's client entry point ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// This file is the whole of the chunk's top-level behaviour: core injects
|
||||||
|
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
|
||||||
|
// registers what it has, and core renders it. The normative contract is
|
||||||
|
// 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 DOMContentLoaded, which is what core waits for
|
||||||
|
// before its first render. There is no subscription and no late registration: a
|
||||||
|
// module that registered asynchronously would register after the routes had been
|
||||||
|
// read, and the symptom is a page that redirects home with nothing logged. That
|
||||||
|
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
||||||
|
// can see it, which is why §7.7's browser smoke exists.
|
||||||
|
//
|
||||||
|
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
|
||||||
|
// on purpose. What it proves is the delivery path itself, and the imports below
|
||||||
|
// are how it proves the hardest part of it.
|
||||||
|
|
||||||
|
// These four specifiers are the whole shared-dependency contract, written the
|
||||||
|
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
|
||||||
|
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
|
||||||
|
// React, core's renderer and core's router, and no second copy of any of them.
|
||||||
|
// A module author writes these imports exactly as they would in any app.
|
||||||
|
//
|
||||||
|
// They are here in slice 0 rather than arriving with the first page because an
|
||||||
|
// unexercised alias is an unproven one: with nothing importing `react`, the
|
||||||
|
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
|
||||||
|
// this whole slice exists to prove has not been touched.
|
||||||
|
import { createElement, isValidElement } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
const rg = window.__rg
|
||||||
|
|
||||||
|
// A module that cannot see the global is a module core did not load — which
|
||||||
|
// means the injection or the ordering broke, not the module. Say so, once,
|
||||||
|
// rather than throwing a TypeError about a property of undefined three frames
|
||||||
|
// deep in a component.
|
||||||
|
if (!rg) {
|
||||||
|
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
|
||||||
|
} else {
|
||||||
|
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
|
||||||
|
// easiest of the four to get wrong and the hardest to notice: Vite's
|
||||||
|
// object-form alias prefix-matches, so a `react` key silently captures
|
||||||
|
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
|
||||||
|
// function` in whichever component happens to render first.
|
||||||
|
const probe = <span>module-uo</span>
|
||||||
|
|
||||||
|
// The self-check: are the bindings this chunk imported the SAME objects core
|
||||||
|
// published? Identity is the only question worth asking. A bundled second
|
||||||
|
// React satisfies every type check, renders its first element happily, and
|
||||||
|
// then throws about an invalid hook call somewhere unrelated.
|
||||||
|
const shared = [
|
||||||
|
['react', createElement === rg.react.createElement],
|
||||||
|
['react/jsx-runtime', isValidElement(probe)],
|
||||||
|
['react-dom/client', createRoot === rg.reactDom.createRoot],
|
||||||
|
['react-router-dom', Link === rg.router.Link],
|
||||||
|
]
|
||||||
|
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
|
||||||
|
|
||||||
|
if (bundled.length) {
|
||||||
|
console.error(
|
||||||
|
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
|
||||||
|
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Registrations land here, slice by slice:
|
||||||
|
//
|
||||||
|
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
|
||||||
|
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
|
||||||
|
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
|
||||||
|
//
|
||||||
|
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
|
||||||
|
// before this file is ever served, so there is nothing to re-check here. It
|
||||||
|
// is logged because a mismatch between the core that validated the manifest
|
||||||
|
// and the core that published this global would otherwise be invisible from
|
||||||
|
// the browser, which is where the client half actually fails.
|
||||||
|
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
client/src/shim/jsx-runtime.js
Normal file
14
client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// `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.
|
||||||
|
|
||||||
|
const jsxRuntime = window.__rg.jsxRuntime
|
||||||
|
|
||||||
|
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||||
|
|
||||||
|
export default jsxRuntime.default ?? jsxRuntime
|
||||||
12
client/src/shim/react-dom.js
vendored
Normal file
12
client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// `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.
|
||||||
|
|
||||||
|
const reactDom = window.__rg.reactDom
|
||||||
|
|
||||||
|
export default reactDom.default ?? reactDom
|
||||||
|
|
||||||
|
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||||
30
client/src/shim/react-router-dom.js
vendored
Normal file
30
client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// `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.
|
||||||
|
|
||||||
|
const router = window.__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
|
||||||
48
client/src/shim/react.js
vendored
Normal file
48
client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
const react = window.__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
|
||||||
106
client/test/build.test.js
Normal file
106
client/test/build.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// 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 the Phase 1 spike 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 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('every shim reads from window.__rg and imports nothing', () => {
|
||||||
|
const dir = path.join(CLIENT, 'src', 'shim')
|
||||||
|
const shims = fs.readdirSync(dir)
|
||||||
|
assert.ok(shims.length >= 4)
|
||||||
|
for (const file of shims) {
|
||||||
|
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||||
|
assert.match(source, /window\.__rg/, `${file} does not read the global`)
|
||||||
|
// A shim that imported anything would be a shim with a dependency to
|
||||||
|
// resolve, which is the problem it exists to remove.
|
||||||
|
assert.doesNotMatch(source, /^\s*import\s/m, `${file} imports something`)
|
||||||
|
}
|
||||||
|
})
|
||||||
136
client/vite.config.js
Normal file
136
client/vite.config.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// ── 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.) Slice 0 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: 'module-uo: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: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
8
module.json
Normal file
8
module.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "uo",
|
||||||
|
"name": "Ultima Online",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"coreApi": "^1.0.0",
|
||||||
|
"server": "server/index.js",
|
||||||
|
"client": { "entry": "client/dist/entry.js" }
|
||||||
|
}
|
||||||
51
server/index.js
Normal file
51
server/index.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// ── module-uo's server entry point ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core requires this file once, synchronously, while `app.js` is still being
|
||||||
|
// required, and calls the exported function with `(ctx, api)`. The normative
|
||||||
|
// contract is docs/website/MODULE_API.md §2.2; the three rules that shape every
|
||||||
|
// line below are worth restating where they will be read:
|
||||||
|
//
|
||||||
|
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
|
||||||
|
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
|
||||||
|
// at a dead port, so a module that queried at registration time would hang
|
||||||
|
// both. Anything needing a live database belongs in `onBoot`.
|
||||||
|
// 2. **Never resolve what core owns.** This module lives at
|
||||||
|
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
|
||||||
|
// reaches core's `node_modules` and `require('express')` fails outright.
|
||||||
|
// express and express-validator arrive on `ctx`; so do the database, the
|
||||||
|
// logger, the middleware and the rest of §2.3.
|
||||||
|
// 3. **Never reach into core's tree.** No relative path may escape this
|
||||||
|
// module's root. `scripts/checkImports.js` enforces that in CI (§5.1)
|
||||||
|
// rather than leaving it to review.
|
||||||
|
//
|
||||||
|
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately
|
||||||
|
// registers NOTHING. The bundle exists, core discovers it, validates it, mounts
|
||||||
|
// its zero routes, serves its client chunk and reports it `started` — which is
|
||||||
|
// the whole delivery path proved end to end before a single UO file moves into
|
||||||
|
// it. Slice 1 brings the atlas; every slice after that adds registrations here
|
||||||
|
// and deletes the matching files from 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) {
|
||||||
|
const log = ctx.log()
|
||||||
|
|
||||||
|
// Registrations land here, slice by slice:
|
||||||
|
//
|
||||||
|
// api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
|
||||||
|
// api.registerExtension('admin.users.detail', usersShardRouter)
|
||||||
|
// api.registerNotificationStreams(streams)
|
||||||
|
// api.registerAnnounceLeg({ leg: 'towncrier', ... })
|
||||||
|
// api.onBoot(async (ctx) => { ... })
|
||||||
|
// api.onShutdown(async () => { ... })
|
||||||
|
//
|
||||||
|
// `api` is referenced by this log line and nothing else yet, on purpose: an
|
||||||
|
// entry point that took `api` and never named it would read like an oversight
|
||||||
|
// rather than a stage of the extraction.
|
||||||
|
log.info('registered', {
|
||||||
|
version: require('../module.json').version,
|
||||||
|
registers: Object.keys(api).length,
|
||||||
|
})
|
||||||
|
}
|
||||||
932
server/package-lock.json
generated
Normal file
932
server/package-lock.json
generated
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
{
|
||||||
|
"name": "module-uo-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "module-uo-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"devDependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-validator": "^7.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
|
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-validator": {
|
||||||
|
"version": "7.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||||
|
"integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"validator": "~13.15.23"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||||
|
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "module-uo-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Server half of module-uo — 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"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"//dependencies": "Deliberately none. Everything the shipped server half needs arrives on ctx (MODULE_API.md §2.3) — a module lives outside core's server/ and cannot resolve core's node_modules. The two below are devDependencies because test/_fakes.js builds a REAL express router: a fake Router would test the fake.",
|
||||||
|
"devDependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-validator": "^7.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
176
server/scripts/checkImports.js
Normal file
176
server/scripts/checkImports.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/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 equivalent is its Vite build:
|
||||||
|
// the four shared dependencies are `external`, and anything else that stays a
|
||||||
|
// bare import in the emitted chunk is unresolvable in the browser.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { builtinModules } = require('module')
|
||||||
|
|
||||||
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||||
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||||
|
|
||||||
|
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
|
||||||
|
|
||||||
|
// Dependencies this half is allowed to resolve for itself. Empty, and that is
|
||||||
|
// the design: everything the server half needs comes from `ctx` (§2.3). A new
|
||||||
|
// entry here is a real decision — it becomes a package an operator's install
|
||||||
|
// has to carry — so it should be argued for in a PR, not added in passing.
|
||||||
|
const ALLOWED_PACKAGES = new Set([])
|
||||||
|
|
||||||
|
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 devDependencies = new Set(
|
||||||
|
Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).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, 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 = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||||
|
if (!BUILTINS.has(specifier) && !BUILTINS.has(pkg) && !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}).`)
|
||||||
103
server/test/_fakes.js
Normal file
103
server/test/_fakes.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Test doubles for what core hands the module.
|
||||||
|
//
|
||||||
|
// The module's server half is testable WITHOUT core, and that is not a
|
||||||
|
// convenience — it is the contract holding. Everything the module may touch
|
||||||
|
// arrives on `ctx` (MODULE_API.md §2.3), so a `ctx` this file can build is a
|
||||||
|
// complete statement of the module's dependencies. If a test ever needs
|
||||||
|
// something that is not here, either the module reached past the boundary or
|
||||||
|
// §2.3 needs a member; both are worth stopping for.
|
||||||
|
//
|
||||||
|
// `fakeCtx` 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.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
/** Records every call, so a test can assert what a 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() {
|
||||||
|
const log = { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeCtx(overrides = {}) {
|
||||||
|
const logs = []
|
||||||
|
const ctx = {
|
||||||
|
moduleId: 'uo',
|
||||||
|
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||||
|
express,
|
||||||
|
validator: require('express-validator'),
|
||||||
|
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||||
|
log: (namespace) => {
|
||||||
|
const log = fakeLog()
|
||||||
|
logs.push({ namespace, log })
|
||||||
|
return log
|
||||||
|
},
|
||||||
|
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
||||||
|
auth: { getUserFromRequest: spy(null) },
|
||||||
|
push: { publish: spy(Promise.resolve()) },
|
||||||
|
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
|
||||||
|
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
// 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` call would throw on push — which is
|
||||||
|
// how this was found. Keeping it off the enumeration also makes the fake more
|
||||||
|
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
|
||||||
|
// a test put there.
|
||||||
|
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||||
|
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") because a
|
||||||
|
* module that registers the same thing twice must fail in its own test suite
|
||||||
|
* and not 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) },
|
||||||
|
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 }
|
||||||
136
server/test/checkImports.test.js
Normal file
136
server/test/checkImports.test.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// 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-uo-'))
|
||||||
|
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('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)
|
||||||
|
})
|
||||||
71
server/test/entry.test.js
Normal file
71
server/test/entry.test.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// The entry point's contract with core (MODULE_API.md §2.2).
|
||||||
|
//
|
||||||
|
// Slice 0 registers nothing, so there is very little behaviour to assert — and
|
||||||
|
// the rules that DO apply are the ones that would otherwise be discovered on an
|
||||||
|
// operator's install: registering synchronously, never awaiting, never touching
|
||||||
|
// a database, never mutating what it was handed. Those hold for every slice
|
||||||
|
// after this one too, which is why they are tested against the entry point
|
||||||
|
// rather than against whatever it happens to register today.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const register = require('../index')
|
||||||
|
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||||
|
|
||||||
|
test('exports a single register function', () => {
|
||||||
|
assert.strictEqual(typeof register, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registers synchronously and returns nothing to await', () => {
|
||||||
|
const result = register(fakeCtx(), fakeApi())
|
||||||
|
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
|
||||||
|
// promise would be a module whose registration core silently never waits for.
|
||||||
|
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('touches no database at registration time', () => {
|
||||||
|
const ctx = fakeCtx()
|
||||||
|
register(ctx, fakeApi())
|
||||||
|
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registers nothing in slice 0', () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
register(fakeCtx(), api)
|
||||||
|
assert.strictEqual(api.record.routes, null)
|
||||||
|
assert.strictEqual(api.record.streams, null)
|
||||||
|
assert.deepStrictEqual(api.record.extensions, [])
|
||||||
|
assert.deepStrictEqual(api.record.legs, [])
|
||||||
|
assert.deepStrictEqual(api.record.hooks, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('takes a frozen ctx and does not try to write to it', () => {
|
||||||
|
const ctx = fakeCtx()
|
||||||
|
assert.ok(Object.isFrozen(ctx))
|
||||||
|
// Core freezes one level deep; a module that assigned to ctx would throw here
|
||||||
|
// in strict mode and fail silently outside it. Either way it must not.
|
||||||
|
assert.doesNotThrow(() => register(ctx, fakeApi()))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logs through ctx.log, never through console', () => {
|
||||||
|
const ctx = fakeCtx()
|
||||||
|
register(ctx, fakeApi())
|
||||||
|
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
|
||||||
|
const { log } = ctx.logs[0]
|
||||||
|
assert.strictEqual(log.info.calls.length, 1)
|
||||||
|
assert.strictEqual(log.info.calls[0][0], 'registered')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('carries no hidden state between calls', () => {
|
||||||
|
// Core calls register() exactly once, and the `once()` guard that enforces
|
||||||
|
// that lives in core's `api` — not here. What this asserts is the module's
|
||||||
|
// own half of it: registering into a second `api` produces the same result as
|
||||||
|
// the first, so nothing is memoised at file scope where a re-register would
|
||||||
|
// silently do less than it appears to.
|
||||||
|
const first = fakeApi()
|
||||||
|
const second = fakeApi()
|
||||||
|
register(fakeCtx(), first)
|
||||||
|
register(fakeCtx(), second)
|
||||||
|
assert.deepStrictEqual(second.record, first.record)
|
||||||
|
})
|
||||||
81
server/test/manifest.test.js
Normal file
81
server/test/manifest.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// `module.json` is what core validates before it will load anything, and every
|
||||||
|
// rule it is checked against lives in core's loader (MODULE_API.md §2.1, §4.3).
|
||||||
|
// Restating those rules here means a manifest mistake fails in this repo's CI,
|
||||||
|
// which can say what is wrong, rather than on an install, where the symptom is a
|
||||||
|
// module that is simply absent.
|
||||||
|
//
|
||||||
|
// These are the loader's own patterns, copied deliberately rather than imported:
|
||||||
|
// this repo has no dependency on core's source, and a copy that drifts is
|
||||||
|
// exactly what the coreApi range exists to catch.
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'module.json'), 'utf8'))
|
||||||
|
|
||||||
|
const KEYS = new Set([
|
||||||
|
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||||
|
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||||
|
])
|
||||||
|
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||||
|
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
|
||||||
|
const TIERS = ['public', 'admin', 'player']
|
||||||
|
|
||||||
|
test('declares no key core would reject', () => {
|
||||||
|
for (const key of Object.keys(manifest)) {
|
||||||
|
assert.ok(KEYS.has(key), `unknown key "${key}" — core rejects rather than ignores it`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('id is "uo" and matches the directory it installs as', () => {
|
||||||
|
assert.ok(ID.test(manifest.id))
|
||||||
|
assert.strictEqual(manifest.id, 'uo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('version and coreApi are present and semver-shaped', () => {
|
||||||
|
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||||
|
assert.match(manifest.coreApi, /^[\^~]?\d+\.\d+\.\d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every declared file exists', () => {
|
||||||
|
for (const key of ['server', 'schema', 'purge']) {
|
||||||
|
if (manifest[key]) {
|
||||||
|
assert.ok(fs.existsSync(path.join(ROOT, manifest[key])), `${key}: ${manifest[key]} is missing`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a schema fragment always comes with a purge', () => {
|
||||||
|
// Core enforces this too. A module that can create tables and cannot drop them
|
||||||
|
// leaves an operator with orphaned data and no supported way to remove it.
|
||||||
|
if (manifest.schema) assert.ok(manifest.purge, 'declares schema but no purge')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('client.entry is in a subdirectory, because its directory is what gets served', () => {
|
||||||
|
assert.ok(manifest.client, 'module-uo ships a client half')
|
||||||
|
const entry = manifest.client.entry
|
||||||
|
assert.match(path.basename(entry), /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/)
|
||||||
|
// The rule worth a test of its own: core serves `dirname(entry)`, so an entry
|
||||||
|
// in the module root would publish the server source and module.json.
|
||||||
|
assert.notStrictEqual(path.dirname(path.resolve(ROOT, entry)), ROOT)
|
||||||
|
assert.ok(path.resolve(ROOT, entry).startsWith(ROOT + path.sep), 'entry escapes the module root')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declared mounts are single lowercase segments in known tiers', () => {
|
||||||
|
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||||
|
assert.ok(TIERS.includes(tier), `unknown tier "${tier}"`)
|
||||||
|
for (const prefix of prefixes) assert.match(prefix, PREFIX)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
|
||||||
|
// Nothing to check yet — slice 0 registers neither. The assertion that matters
|
||||||
|
// is that the manifest does not quietly claim capabilities the module does not
|
||||||
|
// serve, since `GET /api/v1/public/modules` publishes them to clients.
|
||||||
|
assert.deepStrictEqual(manifest.capabilities || [], [])
|
||||||
|
assert.deepStrictEqual(manifest.mounts || {}, {})
|
||||||
|
assert.deepStrictEqual(manifest.extensions || [], [])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user