@pikku/core 0.12.111 → 0.12.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/dev/hot-reload.d.ts +0 -1
- package/dist/dev/hot-reload.js +4 -25
- package/dist/dev/module-runner.js +104 -25
- package/knowledge/decisions/internals/a-reset-suite-makes-the-dev-seed-the-fixture.md +50 -0
- package/knowledge/decisions/internals/hot-reload-reads-the-changed-source-never-a-compiled-copy.md +38 -0
- package/knowledge/decisions/internals/index.md +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 0.12.112
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 02eeffb: fix: hot reload reads the changed source instead of a leftover compiled copy
|
|
6
|
+
|
|
7
|
+
`pikku dev` announced `Hot-reloaded: <fn>` and kept serving the previous
|
|
8
|
+
implementation whenever a `.js` from an earlier `tsc`, `pikku dist` or bundler
|
|
9
|
+
run sat beside the edited `.ts` — the reloader preferred that file, and dev
|
|
10
|
+
never rebuilds it. The changed source is now what runs, and the module runner
|
|
11
|
+
resolves and compiles the project's own TypeScript dependencies so a helper
|
|
12
|
+
edited alongside its caller reloads with it.
|
|
13
|
+
|
|
1
14
|
## 0.12.111
|
|
2
15
|
|
|
3
16
|
### Patch Changes
|
package/dist/dev/hot-reload.d.ts
CHANGED
package/dist/dev/hot-reload.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { watch } from 'node:fs';
|
|
2
|
-
import { stat } from 'node:fs/promises';
|
|
3
2
|
import { basename, join, resolve, relative } from 'node:path';
|
|
4
3
|
import { pikkuState } from '../pikku-state.js';
|
|
5
4
|
import { clearMiddlewareCache } from '../middleware-runner.js';
|
|
@@ -14,24 +13,6 @@ const isFunctionConfig = (value) => {
|
|
|
14
13
|
'func' in value &&
|
|
15
14
|
typeof value.func === 'function');
|
|
16
15
|
};
|
|
17
|
-
const findCompiledFile = async (tsFile, srcDir, pikkuDir) => {
|
|
18
|
-
const rel = relative(srcDir, tsFile).replace(/\.ts$/, '.js');
|
|
19
|
-
const candidates = [
|
|
20
|
-
join(pikkuDir, 'dist', rel),
|
|
21
|
-
join(srcDir, rel),
|
|
22
|
-
tsFile.replace(/\.ts$/, '.js'),
|
|
23
|
-
];
|
|
24
|
-
for (const candidate of candidates) {
|
|
25
|
-
try {
|
|
26
|
-
await stat(candidate);
|
|
27
|
-
return candidate;
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
// not found, try next
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
};
|
|
35
16
|
const isWatchedTsFile = (filename) => {
|
|
36
17
|
return (filename.endsWith('.ts') &&
|
|
37
18
|
!filename.endsWith('.test.ts') &&
|
|
@@ -55,9 +36,8 @@ const reloadFailureReason = (error) => {
|
|
|
55
36
|
return ` ${error.stack ?? error.message}`;
|
|
56
37
|
};
|
|
57
38
|
export async function pikkuDevReloader(options) {
|
|
58
|
-
const { srcDirectories, logger
|
|
39
|
+
const { srcDirectories, logger } = options;
|
|
59
40
|
const absSrcDirs = srcDirectories.map((d) => resolve(d));
|
|
60
|
-
const absPikkuDir = resolve(pikkuDir);
|
|
61
41
|
const watchers = [];
|
|
62
42
|
const functionsMap = pikkuState(null, 'function', 'functions');
|
|
63
43
|
const moduleRunner = createModuleRunner();
|
|
@@ -68,15 +48,14 @@ export async function pikkuDevReloader(options) {
|
|
|
68
48
|
const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
|
|
69
49
|
if (!srcDir)
|
|
70
50
|
return;
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const result = await moduleRunner.run(importPath);
|
|
51
|
+
// knowledge: decisions/internals/hot-reload-reads-the-changed-source-never-a-compiled-copy.md
|
|
52
|
+
const result = await moduleRunner.run(changedTsFile);
|
|
74
53
|
if (!result.ok) {
|
|
75
54
|
// Keeping the old code leaves the process disagreeing with the file on
|
|
76
55
|
// disk, and the only symptom is stale output from a function that looks
|
|
77
56
|
// correct in the editor — so the reason has to be printed here, where it
|
|
78
57
|
// is still known, rather than left for the developer to reconstruct.
|
|
79
|
-
logger.error(`Failed to import: ${relative(process.cwd(),
|
|
58
|
+
logger.error(`Failed to import: ${relative(process.cwd(), changedTsFile)} (keeping old code)\n` +
|
|
80
59
|
reloadFailureReason(result.error));
|
|
81
60
|
return;
|
|
82
61
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { compileFunction } from 'node:vm';
|
|
6
6
|
let transformSync;
|
|
@@ -18,7 +18,78 @@ const loadTransform = async () => {
|
|
|
18
18
|
* is correct, and no amount of re-reading it will reveal that the reloader —
|
|
19
19
|
* not the file — is what cannot cope. */
|
|
20
20
|
export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(error.message);
|
|
21
|
+
const TYPESCRIPT_FILE = /\.(m|c)?tsx?$/;
|
|
22
|
+
const isFile = (path) => {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(path).isFile();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** Where a relative specifier's TypeScript source lives, for the specifiers a
|
|
31
|
+
* plain `require` cannot resolve. A TS project writes `./sibling.js` for a file
|
|
32
|
+
* that only exists as `./sibling.ts`, and a reloaded module is the source, so
|
|
33
|
+
* every one of its relative imports arrives in that unresolvable form. */
|
|
34
|
+
const findTypeScriptSource = (specifier, importerDir) => {
|
|
35
|
+
if (!specifier.startsWith('.') && !isAbsolute(specifier))
|
|
36
|
+
return undefined;
|
|
37
|
+
const base = resolve(importerDir, specifier);
|
|
38
|
+
const withoutJs = base.replace(/\.(m|c)?js$/, '');
|
|
39
|
+
const candidates = [
|
|
40
|
+
`${withoutJs}.ts`,
|
|
41
|
+
`${withoutJs}.tsx`,
|
|
42
|
+
`${withoutJs}.mts`,
|
|
43
|
+
`${withoutJs}.cts`,
|
|
44
|
+
join(base, 'index.ts'),
|
|
45
|
+
join(base, 'index.tsx'),
|
|
46
|
+
];
|
|
47
|
+
return candidates.find(isFile);
|
|
48
|
+
};
|
|
49
|
+
const isProjectTypeScript = (path) => TYPESCRIPT_FILE.test(path) && !path.includes(`${sep}node_modules${sep}`);
|
|
50
|
+
/** JSX is a syntax esbuild only parses when asked, so a `.tsx` helper handed to
|
|
51
|
+
* the `ts` loader fails to compile and the file importing it keeps its old
|
|
52
|
+
* code. */
|
|
53
|
+
const loaderFor = (path) => {
|
|
54
|
+
if (path.endsWith('.tsx'))
|
|
55
|
+
return 'tsx';
|
|
56
|
+
if (TYPESCRIPT_FILE.test(path))
|
|
57
|
+
return 'ts';
|
|
58
|
+
if (path.endsWith('.jsx'))
|
|
59
|
+
return 'jsx';
|
|
60
|
+
return 'js';
|
|
61
|
+
};
|
|
62
|
+
const evaluateModule = (absPath, cache) => {
|
|
63
|
+
const cached = cache.get(absPath);
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached;
|
|
66
|
+
const source = readFileSync(absPath, 'utf-8');
|
|
67
|
+
const { code } = transformSync(source, {
|
|
68
|
+
loader: loaderFor(absPath),
|
|
69
|
+
format: 'cjs',
|
|
70
|
+
sourcefile: absPath,
|
|
71
|
+
});
|
|
72
|
+
const fn = compileFunction(code, ['require', 'exports', 'module', '__filename', '__dirname'], { filename: absPath });
|
|
73
|
+
const moduleObj = { exports: {} };
|
|
74
|
+
// Seeded before evaluation so an import cycle terminates the way CJS's own
|
|
75
|
+
// cache makes it terminate, and overwritten after because esbuild's `cjs`
|
|
76
|
+
// emit assigns a fresh `module.exports` on the way out.
|
|
77
|
+
cache.set(absPath, moduleObj.exports);
|
|
78
|
+
fn(createRequireForFile(absPath, cache), moduleObj.exports, moduleObj, absPath, dirname(absPath));
|
|
79
|
+
cache.set(absPath, moduleObj.exports);
|
|
80
|
+
return moduleObj.exports;
|
|
81
|
+
};
|
|
21
82
|
/** The `require` a reloaded module is run with.
|
|
83
|
+
*
|
|
84
|
+
* Resolution goes through the host first, so every package binds to the
|
|
85
|
+
* instance the rest of the process holds and a user file's `wire*` side
|
|
86
|
+
* effect mutates live state rather than a private copy.
|
|
87
|
+
*
|
|
88
|
+
* A resolved TypeScript file outside `node_modules` is the project's own
|
|
89
|
+
* source, and is compiled here instead of handed back to the host: both hosts
|
|
90
|
+
* cache what they load, so a helper edited alongside the function importing it
|
|
91
|
+
* would otherwise keep answering with the version loaded at startup — the same
|
|
92
|
+
* stale-code-under-a-successful-reload the reloader exists to avoid.
|
|
22
93
|
*
|
|
23
94
|
* On Bun, `createRequire`'s referrer stops being honoured somewhere in a
|
|
24
95
|
* long-running process: the same relative specifier that resolves in a fresh
|
|
@@ -27,47 +98,55 @@ export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(erro
|
|
|
27
98
|
* is left serving the previous code from an edit that looked applied. Bun's
|
|
28
99
|
* own resolver still answers correctly when handed the importer's directory
|
|
29
100
|
* outright, so the specifier is resolved to an absolute path first and the
|
|
30
|
-
* referrer never has to survive the trip.
|
|
31
|
-
*
|
|
101
|
+
* referrer never has to survive the trip.
|
|
102
|
+
*
|
|
103
|
+
* What neither host resolves is a relative `./sibling.js` whose only file on
|
|
104
|
+
* disk is `./sibling.ts` — node's `require` has no `.js → .ts` rewrite, and
|
|
105
|
+
* the reloader hands it source. Those, and only those, are compiled here.
|
|
32
106
|
*/
|
|
33
|
-
const createRequireForFile = (absPath) => {
|
|
107
|
+
const createRequireForFile = (absPath, cache) => {
|
|
34
108
|
const base = createRequire(pathToFileURL(absPath));
|
|
35
109
|
const bun = globalThis.Bun;
|
|
36
|
-
if (!bun)
|
|
37
|
-
return base;
|
|
38
110
|
const importerDir = dirname(absPath);
|
|
39
|
-
const
|
|
111
|
+
const resolveThroughHost = (specifier) => {
|
|
112
|
+
if (bun) {
|
|
113
|
+
try {
|
|
114
|
+
return bun.resolveSync(specifier, importerDir);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
40
120
|
try {
|
|
41
|
-
return
|
|
121
|
+
return base.resolve(specifier);
|
|
42
122
|
}
|
|
43
123
|
catch {
|
|
44
|
-
return
|
|
124
|
+
return undefined;
|
|
45
125
|
}
|
|
46
126
|
};
|
|
47
127
|
// Only resolution is delegated; the require itself stays outside the guard so
|
|
48
128
|
// a module that throws while evaluating reports its own error rather than
|
|
49
129
|
// being retried under a second specifier.
|
|
50
|
-
const
|
|
51
|
-
|
|
130
|
+
const loadingRequire = ((specifier) => {
|
|
131
|
+
const resolved = resolveThroughHost(specifier) ??
|
|
132
|
+
findTypeScriptSource(specifier, importerDir);
|
|
133
|
+
if (!resolved)
|
|
134
|
+
return base(specifier);
|
|
135
|
+
if (isProjectTypeScript(resolved))
|
|
136
|
+
return evaluateModule(resolved, cache);
|
|
137
|
+
return base(resolved);
|
|
138
|
+
});
|
|
139
|
+
return Object.assign(loadingRequire, base);
|
|
52
140
|
};
|
|
53
141
|
export const createModuleRunner = () => {
|
|
54
142
|
const registry = new Map();
|
|
55
143
|
const run = async (filePath) => {
|
|
56
144
|
const absPath = resolve(filePath);
|
|
57
145
|
try {
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
format: 'cjs',
|
|
63
|
-
sourcefile: absPath,
|
|
64
|
-
});
|
|
65
|
-
const fn = compileFunction(code, ['require', 'exports', 'module', '__filename', '__dirname'], { filename: absPath });
|
|
66
|
-
const require = createRequireForFile(absPath);
|
|
67
|
-
const moduleObj = { exports: {} };
|
|
68
|
-
fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath));
|
|
69
|
-
registry.set(absPath, moduleObj.exports);
|
|
70
|
-
return { ok: true, exports: moduleObj.exports };
|
|
146
|
+
await loadTransform();
|
|
147
|
+
const exports = evaluateModule(absPath, new Map());
|
|
148
|
+
registry.set(absPath, exports);
|
|
149
|
+
return { ok: true, exports };
|
|
71
150
|
}
|
|
72
151
|
catch (thrown) {
|
|
73
152
|
// A bad edit, or the one known limitation: a file using top-level
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: With scenarios.reset on, the dev seed is the fixture every assertion is written against
|
|
4
|
+
description: The rollback restores the database as the suite found it, so seed rows are the shared baseline — which makes an absolute date in the seed a test that expires
|
|
5
|
+
tags: cli, scenarios
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A reset suite makes the dev seed the fixture
|
|
9
|
+
|
|
10
|
+
`scenarios.reset` takes a copy of every table when the suite starts and restores
|
|
11
|
+
that copy between scenarios. It is a snapshot-and-restore, not a
|
|
12
|
+
truncate-and-reseed: nothing replays the seed file, and nothing regenerates its
|
|
13
|
+
rows.
|
|
14
|
+
|
|
15
|
+
Two things follow, and both are easy to get wrong.
|
|
16
|
+
|
|
17
|
+
**The seed is the fixture.** Before a reset was available, a project usually had
|
|
18
|
+
a hand-rolled reset RPC that wiped and re-created the handful of rows its
|
|
19
|
+
scenarios touched. That RPC was the fixture and the seed was scenery. With the
|
|
20
|
+
rollback on, the whole seed is in scope — every booking, invoice and event a
|
|
21
|
+
developer put there for their own convenience is now a row some scenario can
|
|
22
|
+
count. Assertions written as counts against the old narrow fixture go wrong the
|
|
23
|
+
moment the baseline widens.
|
|
24
|
+
|
|
25
|
+
**A date literal in the seed expires.** A seed is applied once, and from then on
|
|
26
|
+
the restore keeps handing back the same rows. Write `'2026-03-14'` and the
|
|
27
|
+
fixture is correct on the day it was written and quietly wrong a month later —
|
|
28
|
+
the booking that was supposed to be upcoming is in the past, and the scenario
|
|
29
|
+
that asserts on it fails for a reason that has nothing to do with the code. Seed
|
|
30
|
+
dates belong relative to the moment the seed is applied:
|
|
31
|
+
|
|
32
|
+
```sql
|
|
33
|
+
INSERT INTO booking (id, starts_at, ends_at) VALUES
|
|
34
|
+
('b_upcoming', date('now', '+200 days'), date('now', '+207 days')),
|
|
35
|
+
('b_ended', date('now', '-8 days'), date('now', '-1 days'));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`date('now', ...)` in sqlite, `now() + interval '200 days'` in postgres. Pick the
|
|
39
|
+
offsets so each row sits unambiguously on the side of whatever boundary the
|
|
40
|
+
scenarios test, rather than a day away from it.
|
|
41
|
+
|
|
42
|
+
There is a third limit worth stating plainly: the baseline is the database _as
|
|
43
|
+
the suite starts_, so it is not a pristine seed. Run N+1 inherits whatever run N
|
|
44
|
+
left behind. A value that must be unique in the database — an invoice number, a
|
|
45
|
+
reference code — has to be scoped to the run, not hard-coded, or the second run
|
|
46
|
+
collides with the first.
|
|
47
|
+
|
|
48
|
+
**What this rules out:** treating the reset as "the suite starts from the seed
|
|
49
|
+
every time". It starts from wherever the database was, and keeps returning
|
|
50
|
+
there.
|
package/knowledge/decisions/internals/hot-reload-reads-the-changed-source-never-a-compiled-copy.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Hot reload reads the changed source, never a compiled copy of it
|
|
4
|
+
description: A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
|
|
5
|
+
tags: core, dev
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Hot reload reads the changed source, never a compiled copy of it
|
|
9
|
+
|
|
10
|
+
The reloader watches `.ts` files and runs the file that changed. It used to
|
|
11
|
+
look for a compiled `.js` first — `<outDir>/dist/<rel>.js`, then the sibling
|
|
12
|
+
`<file>.js` — and import the source only when neither existed.
|
|
13
|
+
|
|
14
|
+
`pikku dev` compiles nothing. Every one of those `.js` files is a leftover from
|
|
15
|
+
an earlier `tsc`, `pikku dist` or bundler run, so reading one re-registered the
|
|
16
|
+
implementation the developer had just replaced and logged `Hot-reloaded: <fn>`
|
|
17
|
+
over the top of it. The log line reads as confirmation, so the change looks
|
|
18
|
+
applied and wrong rather than unapplied — which is the expensive way round
|
|
19
|
+
(#1721).
|
|
20
|
+
|
|
21
|
+
The lookup was a leftover of the first implementation, which imported raw file
|
|
22
|
+
content through a `data:` URL and genuinely could not handle TypeScript. The
|
|
23
|
+
module runner has transpiled with esbuild since, so the source was loadable all
|
|
24
|
+
along and the compiled candidates could only ever supply stale code.
|
|
25
|
+
|
|
26
|
+
Loading the source means the reloader also owns resolution. `require('./x.js')`
|
|
27
|
+
where only `x.ts` exists is what every relative import in a TypeScript project
|
|
28
|
+
looks like, and node's `require` has no `.js → .ts` rewrite. Resolution goes
|
|
29
|
+
through the host first — so packages bind to the instance the rest of the
|
|
30
|
+
process holds and `wire*` side effects mutate live state — and a resolved
|
|
31
|
+
TypeScript file outside `node_modules` is then compiled by the reloader rather
|
|
32
|
+
than handed back, because both node and Bun cache what they load and a helper
|
|
33
|
+
edited alongside its caller would otherwise keep answering with the copy loaded
|
|
34
|
+
at startup. That cache is scoped to a single reload for the same reason.
|
|
35
|
+
|
|
36
|
+
**What this rules out:** reintroducing a compiled-output lookup as a
|
|
37
|
+
"performance" shortcut, and letting the host load project `.ts` dependencies so
|
|
38
|
+
reloads inherit its module cache.
|
|
@@ -12,6 +12,7 @@ caller is entitled to assume.
|
|
|
12
12
|
<!-- pikku:knowledge-index -->
|
|
13
13
|
|
|
14
14
|
- [A non-streaming agent run registers with aiRunState on the same terms as a streaming one](a-non-streaming-agent-run-registers-with-airunstate-too.md) — Otherwise interruptAIAgent finds the run, passes the ownership check, then cannot stop it — and reports that as if the run were on another host
|
|
15
|
+
- [With scenarios.reset on, the dev seed is the fixture every assertion is written against](a-reset-suite-makes-the-dev-seed-the-fixture.md) — The rollback restores the database as the suite found it, so seed rows are the shared baseline — which makes an absolute date in the seed a test that expires
|
|
15
16
|
- [A resumed turn is as interruptible as the first one](a-resumed-agent-turn-is-as-interruptible-as-the-first.md) — It is the same person listening to the same voice, and after an approval it is where most of the reply actually gets spoken
|
|
16
17
|
- [A scenario step's prose template is offered to a virtual user unfilled](a-scenario-step-template-is-offered-unfilled.md) — A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
|
|
17
18
|
- [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
|
|
@@ -73,6 +74,7 @@ caller is entitled to assume.
|
|
|
73
74
|
- [Gateway webhook challenges echo bytes not JSON](gateway-webhook-challenges-echo-bytes-not-json.md) — String verification challenges are returned raw with returnsJSON false, because platforms byte-compare the echo and JSON quoting fails the handshake
|
|
74
75
|
- [Gateway wiring is a meta-wiring over HTTP and channels](gateway-wiring-is-a-meta-wiring-over-http-and-channels.md) — wireGateway writes handler implementations into the HTTP and channel state directly while the inspector compiles the corresponding meta, so runtime registration deliberately writes no meta
|
|
75
76
|
- [Generated src paths in pikku meta are absolute](generated-src-paths-in-pikku-meta-are-absolute.md) — emailsMeta.src is resolved by the CLI at generation time, so reading through the project-relative helpers produces a wrong compound path
|
|
77
|
+
- [Hot reload reads the changed source, never a compiled copy of it](hot-reload-reads-the-changed-source-never-a-compiled-copy.md) — A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
|
|
76
78
|
- [Hot reload writes into the function map captured at startup, not pikkuState's current one](hot-reload-writes-into-the-function-map-captured-at-startup.md) — A dev-server watcher may have swapped in a codegen-scoped map whose writes are discarded on restore, and schemas are deliberately left alone
|
|
77
79
|
- [HTTP request bodies are read once and shared between consumers](http-request-bodies-are-read-once-and-shared.md) — The fetch request wrapper memoises the single-use body and builds web Requests lazily, at the cost of holding the whole body in memory
|
|
78
80
|
- [HTTP route groups cascade config in a fixed, per-field order](http-route-groups-cascade-config-in-a-fixed-order.md) — basePath concatenates, tags and middleware merge outward-in, auth is overridden by the innermost group
|