How the Hook System Works
You do not need this page to use hooks. It is here for when you want to know why the design is the way it is.
The problem it solves
Section titled “The problem it solves”Request samples are a projection of your API description, rebuilt on every run. Nothing about a request is stored, so nothing about a request can go stale.
Hooks are different: they are yours, they are the investment, and they have to survive the description changing underneath them. That makes one question central — what is a hook’s identity?
In the first version of the sampler, a hook’s identity was its location: a
file at …/launches/[id]/@GET/auth.authorize.ts applied to whatever sat below
that directory. That is discoverable and requires no naming, and it has one fatal
property: rename a path in the API description and the hook is orphaned at a dead
directory, silently doing nothing. The tree could not tell you, because as far as
the filesystem was concerned nothing had happened.
So a hook’s identity is now a Selector — the fully-qualified address of a Transaction — written in the code. A Selector that stops existing is a value that stops existing, and that is something a compiler and a loader can both see.
A Selector is also the only way Thymian ever writes a Transaction down. Check lines, test-case names, rule headings, report locations and error messages all print it verbatim, so any transaction you read in the terminal can be pasted straight into a hook as its target:
✖ POST /launches/{id}/crew-members (application/json) -> 409beforeEach( 'POST /launches/{id}/crew-members (application/json) -> 409', (request) => { // … },);Discovery
Section titled “Discovery”Directory.thymian/sampler/hooks/
- auth.ts
Directorylaunches/
- seed.ts
The loader walks hooks/ recursively and, for each file:
- Keeps
.ts,.mts,.cts,.js,.mjs,.cjs. Skips declaration files and dot-directories, so an editor’s cache inside the tree is not scanned. - Imports it through
jiti, which transpiles TypeScript on the fly. The bare specifier@thymian/hooksis aliased to a module inside the plugin, which is why a hook runs with nothing generated and nothing installed beside it. - Collects every export — named or default — that is a registration, or an array of them.
A registration is a value, produced by calling beforeEach, afterEach,
defineSample, authorize, beforeAll or afterAll. Nothing is invoked to
find out whether an export is a hook: the check is structural, so a function you
export for your own reasons is never called by accident.
The directory layout carries no meaning. Moving a hook file is a non-event.
Why registrations are returned values
Section titled “Why registrations are returned values”The obvious alternative is an ambient registry — beforeEach(…) pushing into a
module-level list the plugin then reads. It cannot work here. jiti evaluates the
aliased runtime in its own module registry, so the plugin and the hook file hold
different instances of that module: the list the plugin reads is never the list
the hook wrote to. Returning the registration sidesteps the realm boundary
entirely, and export-based discovery is what makes the returned value reachable.
Two hooks of the same kind on one Transaction both run, in registration order: file order on the outside — hooks-directory-relative, sorted, so it does not depend on the filesystem — and source order inside each file.
Source order matters because an ES module namespace exposes its keys sorted, so export order is not registration order. Each registration is stamped with a counter as it is created, and the loader assigns one scan-wide sequence number once the file order is settled.
Resolution
Section titled “Resolution”Once the description is loaded, the sampler builds a transaction catalog: a bijection between every Transaction and its Selector, sorted by Selector. Then each registration’s target is resolved against it:
| Target | Resolved by |
|---|---|
| a Selector | a map lookup |
| a list of Selectors | a lookup each |
| a filter | evaluating it against every Transaction |
Four things make a hook unresolvable, and each gets its own diagnostic naming the file and the export: a Selector that names nothing, a value that is not a legal filter value, a path or glob that names nothing, and a filter whose legal values intersect nothing.
An unresolved hook fails the run — before the first request is sent, so
nothing has happened yet. It fails when the run starts rather than when the
description is published, because thymian sampler validate has to survive
publishing in order to report every unresolved hook at once instead of dying on
the first.
A hook is never re-bound to a nearby Transaction. That would be the silent staleness the whole design exists to remove.
Execution
Section titled “Execution”core.request.sample: generate the request → defineSamplehttp-testing.beforeRequest: [first request ever → beforeAll] → beforeEach …http-testing.authorize: if the run option and request.authorize agree → authorize → the request is sent →http-testing.afterResponse: afterEach …core.close: [if a request was sent] afterAll + cleanups, reversedEach of those is an action on Thymian’s event bus, so the sampler plugs into the same seams any plugin can. There is no sampler-specific machinery in the core.
Mutate in place
Section titled “Mutate in place”Every hook returns nothing. A hook receives the request or response object and changes it; the return value is discarded.
That is not a style preference. If a return value were honoured, the most ordinary shorthand there is —
beforeEach(sel, (request) => (request.headers['x-trace'] = 'yes'));— evaluates to the string 'yes', and the whole request would be replaced by it.
beforeAll and the latch
Section titled “beforeAll and the latch”“Once before the run” has no meaning until something decides when the run starts,
and the sampler’s first sight of a run is its first request. So a latch is armed
by the first beforeRequest, ahead of that Transaction’s own beforeEach hooks.
The same latch gates teardown, which is why thymian sampler show and
thymian sampler init never run somebody’s afterAll.
The latch is armed before the beforeAll callbacks run, so a beforeAll that
threw still gets its teardown, and a second request cannot re-run setup that half
succeeded.
Teardown
Section titled “Teardown”The cleanups beforeAll returned and the afterAll hooks are one list, run
in reverse order of registration. So an afterAll registered between two
beforeAll hooks runs between their cleanups — grouping the two kinds apart
would put it at one end or the other, which is not what “reverse order” means.
Teardown is best-effort: core.close happens after the report is written, so a
failure there cannot be acted on. An error is logged as a warning and the rest of
the teardown still runs, because one leaking fixture must not strand the others.
Cross-endpoint requests
Section titled “Cross-endpoint requests”utils.request is keyed by Selector and resolved through the same catalog, so
the strings the committed types carry and the strings resolved at run time are
the same strings by construction — there is no second key language to keep in
step.
By default a nested request runs the target’s own pipeline, so seeding behaves like the real run. The active Selector chain travels with the hook context, and re-entering a Selector already on it fails loudly with the chain printed. The chain travels in the context rather than living in the runner so that it describes this call’s ancestry rather than whatever the runner did last.