Skip to content

Let a middleware prepare with the request's services and refuse a request - #1364

Draft
jwrosewell wants to merge 13 commits into
feature/22-creatingfrom
feature/23-prepare
Draft

jwrosewell wants to merge 13 commits into
feature/22-creatingfrom
feature/23-prepare

Conversation

@jwrosewell

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 6 files, 1038 lines added, 12 removed
docs/guide 1 file, 52 lines added, 0 removed
CHANGELOG.md 1 file, 1 line added, 0 removed
crates/trusted-server-core/benches 1 file, 1 line added, 0 removed

Closes

Closes #1281

Test plan

CI: 19 of 19 checks green, the fork's CI on d9fb81a (jwrosewell#18).

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on the pull request named above

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on making a file the origin does not have (#1309). The additions that follow the seven pull requests open here are listed in #777.

Compare split/22-creating with split/23-prepare

to see only this pull request's change.

What this pull request does

A middleware whose decision needs the request's services, or has to wait on the store or the network, answers Middleware::prepare with a future. The request's handler runs it before the document is processed, as the middleware's module with the permissions it declares, and what it answers is kept under the middleware's name and phase for create in that phase to take.

fn prepare<'a>(&'a self, call: ModuleCall<'a>, context: &'a MiddlewareContext<'a>) -> Option<Preparation<'a>> {

    Some(Box::pin(async move {

        let lines = self.lines_for(call, context.request_host).await?;

        Ok(Box::new(lines) as Box<dyn Any + Send>)

    }))

}



fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction {

    let lines: Vec<String> = context.take_prepared(MODULE).unwrap_or_default();

    MiddlewareAction { stream: Some(Box::new(AppendLines::new(lines))), ..MiddlewareAction::pass() }

}

A preparation may answer a Refusal instead, with a status, an optional Retry-After in whole seconds rounded up, and a message, and the request is answered with that status, no document is processed, and nothing is stored or served from the store.

Why

create is synchronous, is given no services, and is asked inside the synchronous streaming paths that the Fastly adapter needs. A middleware that makes a well known file from what the store holds or a service answers, as robots.txt does, could not decide there, and could not fail closed when it had nothing to give, which for robots.txt matters because an empty file tells crawlers to crawl everything. The preparation runs where the handler is asynchronous on every adapter, and only its answer, a Send value, travels to the streaming paths, so create, the plan and every streaming path are as they were.

The rules

  • A middleware that prepares nothing is asked create as before, and nothing of it changes.

  • On a document fetched for the request that will not be stored, both phases are prepared, fetch then serve. On a document that may be stored, only the fetch phase is prepared, so the serve middleware of the reader it was fetched for find nothing prepared. On a copy read from the store, the serve phase alone. The preparations of a phase run in the order the entry names the middleware.

  • The first refusal ends the preparations of a document, and the response it makes is marked private and unstorable.

  • A fetch preparation of a document that may be stored is handed the request's services and nothing of the reader, neither its permissions, location nor Edge Cookie, because its answer is stored with the document.

What breaks

MiddlewareContext, HtmlProcessorConfig and ReaderDocument each gain a required pub field prepared, so every struct literal of them outside this repository stops compiling.

How it was verified

CI passed on 1c0c244f7 on the fork. Not exercised: no preparation has run against a real store or network, because the fixtures answer from what they are called with. The preparation on a copy read from the store has no test, because the test services have no template cache.

References #1281.

jwrosewell and others added 13 commits October 11, 2026 09:31
`Middleware::prepare` answers a future for a document whose decision needs
the request's services or has to wait, on the key-value store or the
network. The request's handler runs it before the document is processed,
once per phase the document goes through, as the middleware's module and
with the permissions it declares, and keeps what it answers under the
middleware's name in the context's `Prepared`, where `create` takes it as
the type it prepared. `create` and every streaming path stay synchronous,
so the Fastly adapter is as it was. A middleware that prepares nothing is
asked `create` as before.

A preparation may answer a `Refusal` instead, with a status, an optional
`Retry-After` and a message. The handler answers the request with that
status and the message as plain text, marks the response private and
unstorable, and processes no document, so a middleware responsible for a
file fails closed when it cannot make it. The first refusal ends the
preparations of a document. A copy read from the store prepares its serve
chain the same way before it is assembled.

The middleware fixture gains one that prepares and one that refuses, and the
tests cover what is prepared reaching `create` in either phase, the refusal's
status, headers and body, the chain keeping each answer under its name, and
the first refusal ending the preparation.
The section says when a middleware prepares, what it is called with, where
its answer goes, how a refusal is answered, and what each phase's
preparation must not depend on. The changelog records the addition.
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-core/src/middleware.rs
#	crates/trusted-server-core/src/middleware/tests.rs
#	crates/trusted-server-core/src/publisher.rs
…e reader

A document that may be stored is prepared with the request's services alone, so no reader permission or Edge Cookie reaches the shared template. Retry-After rounds up to whole seconds. TextDocument is renamed ResponseDocument, the prepared field docs use one phrase, and the guide states which phases are prepared.
A document that is not stored is prepared in both phases before either is
planned, and both kept what they prepared in one store under the
middleware's name alone. A middleware running in both phases had its fetch
preparation replaced by its serve one, so the fetch create took the serve
value and the serve create took nothing. Prepared is now keyed by phase and
name, and MiddlewareContext::take_prepared takes what was prepared in the
context's own phase.
# Conflicts:
#	crates/trusted-server-core/src/html_processor.rs
#	crates/trusted-server-core/src/integrations/registry.rs
#	crates/trusted-server-core/src/middleware.rs
#	crates/trusted-server-core/src/publisher.rs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants