Skip to content

Add a table that a vendor's modules share - #1314

Closed
Jamesr51d wants to merge 627 commits into
IABTechLab:mainfrom
jwrosewell:split/26-shared-tables
Closed

Jamesr51d wants to merge 627 commits into
IABTechLab:mainfrom
jwrosewell:split/26-shared-tables

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on robots.txt as a middleware (#1313). The additions that follow the seven pull requests open here are listed in #777.
Compare split/25-robots-txt-middleware with split/26-shared-tables
to see only this pull request's change.

What this pull request does

[shared.example]
endpoint = "https://api.example.com/v1"
license_key = "example_license_key"

One [shared.<vendor>] table per vendor, holding what every module of that vendor reads. Core declares the tables and reads none of them. A vendor's crates read their own as typed settings with Settings::shared_config::<T>(vendor), which refuses a table that cannot be read as T or fails its validation, naming the table.

A builder marked with IntegrationBuilder::with_shared_settings(vendor) is run by the table rather than by a selection, and so takes no module name. Settings without the table run none of that vendor's modules, a selection naming one of them is refused with the absent table named, and a builder the table runs is selected by no section. A table that no builder reads is refused when the registry is built, the one place every builder is known, and a table whose name is not lower case letters, digits, _ or - is refused when the settings load.

Core does not refuse a key written both in a module's own table and in the shared table. Which one wins is the vendor crate's choice.

A secret in the table is declared with the builder's secret settings at its path inside the table, as a module's own secrets are declared in its own table, resolved from the secret store when the table is present.

The seam probe gains a second builder, shared_builder, which [shared.seam-probe] runs and whose route answers the table's marker. The Axum adapter's test serves the route with the table, finds it absent without, and is refused at startup for the table with no builder reading it.

Why

A vendor with several modules, a device module, a geo module and an identifier module, say, has one service they all call and one key for it. Without this the key is written once in each module's table, and a module cannot be built on what another module's table says. The table is also what runs the vendor's builder, so the deployment runs the vendor's modules together or not at all.

How it was verified

CI passed on 7926a0ecf on the fork. Not exercised: no vendor crate reads a shared table in the stock build. The probe's builder is the one reader, through the Axum adapter.

References #1284.

`inspect::config::build_view` gives the settings a deployment is running,
after defaults are applied, with values masked as `XXXX` and every
masked path listed. Anyone can read the scripts a page runs, and this is
what lets anyone read the configuration that runs at the edge.

Three kinds of value are masked.

1. Every secret, whatever the publisher's document says. The settings
   loader now records each leaf it fills from the secret store, with the
   value it wrote, and the view masks those paths, every leaf declared a
   secret, and then any string that still contains one of the values,
   wherever it sits, a key included.
2. Every value sensitive by default, unless the publisher shows it.
   `Redacted` values are, and so are the settings now written with
   `redacted::sensitive`, being the origin URLs, the origin host
   override, the Basic-auth path, and the store names and ids.
3. Everything the publisher hides.

The publisher decides with a new `[inspect]` section, `show`, `hide` and
`config = false`, each entry a path pattern such as
`proxy.asset_routes[].origin_url`. A pattern that would not be honored
as written refuses the configuration, both when a deployment is
validated and when the settings load. That is a pattern that matches
nothing, a `show` naming a secret, a pattern another pattern already
covers, a `show` a `hide` covers, a pattern that changes nothing, and
any pattern beside `config = false`.

Marking is a mode of serialization. `redacted::to_marked_value` wraps
each sensitive value under a marker key while it runs, and every other
serialization, the configuration push included, is unchanged, which a
test checks field by field. The section is left out of a stored
document unless the publisher wrote one, which a test checks too, so an
existing document gains nothing.

The note at the top of `redacted.rs` said the build script compiles the
file. It does not, on this branch or on `main`, so the note goes.

`handle_config` answers `/_ts/config` and `/_ts/config.json`. No adapter
routes to it yet.
`GET /_ts/config` answers the masked view of the running settings as a
page, and `/_ts/config.json` as data, with no credential, on all four
adapters. Each routes to core's `handle_config`, so the view is the same
on each, which a parity test checks.

It says nothing about the request, so on Fastly it is answered ahead of
everything a request passes through, and the Edge Cookie lifecycle does
not run. `[inspect] config = false` answers `404`.

The API reference describes the endpoint, and the configuration guide
describes `[inspect]`, what is always masked and what is masked by
default.
`inspect::data::handle_data` answers `/_ts/data` with the identity graph
record stored against the identifier in the request's own `ts-ec`
cookie, being when the record was made, the consent recorded, the
location and device class kept, and which partners hold an identifier of
their own for the same browser. A deployment that writes an identifier
to a browser can show the person using that browser what it keeps
against it.

The Edge Cookie is `HttpOnly`, so a script on the page cannot read the
identifier, and the page does not undo that.

1. Only a browser opening the address as a page is answered, which the
   browser marks with `Sec-Fetch-Mode: navigate` and
   `Sec-Fetch-Dest: document`. Anything else is answered `403`.
2. The page is sent with `Content-Security-Policy: sandbox`, so the
   document has an origin of its own and a page that opened it cannot
   read it.
3. No identifier is shown. The Edge Cookie's value and each partner's
   identifier show as `XXXX`.
4. It is never stored, and no other origin is told it may read it.

Checked in Chrome against a server answering by the same rule with the
same headers. A page's `fetch` was refused, with and without the two
headers set by the script, and so were a frame and an embedded object.
A page that opened the address could not read it, and could once the
sandbox policy was left out. A service worker registered for the whole
site did read it, as it reads every navigation on the site, and so can
whoever holds an identifier and sends it as the cookie. Neither finds an
identifier in the page, which is why none is shown.

The record is shown as it is stored. A test sets every field the record
has and lists them, so a field added to the record fails until it is
decided whether the field is an identifier.

The page renderer takes a lede of its own, because this address has no
`.json` form, and the mask moves up to `inspect` now that two pages use
it. `ec::request_cookie_ec` hands out the identifier a request's cookie
carries. No adapter routes to the handler yet.
`GET /_ts/data` routes to core's `handle_data` on all four adapters.

Fastly keeps the identity graph, so it hands the handler the graph and
the Edge Cookie module the deployment selects. It answers ahead of the
Edge Cookie lifecycle, so nothing is written for the reader. Axum,
Cloudflare and Spin keep no identity graph, so each answers that nothing
is held, and a parity test checks that the three refuse and answer
alike.

The API reference describes the endpoint, who can read the page and why
no identifier is in it.
…form

A module changes a page through four hook traits today. The integration
seam design replaces them with one middleware contract (section 3.7),
and asks that each converted implementation reproduces its recorded
output. This is the recording, made by the hooks as they are.

`html_processor::test_support::assert_page_is_recorded`, behind
`test-utils`, runs a page through the document pipeline of a deployment
that writes a module's settings and links its builder, and compares what
a reader receives with a file, byte for byte. With `TS_RECORD_PAGES` set
it writes the file instead. The script bundle's `?v=` hash is replaced
by a fixed word, so a recording changes when a module's page change
does and not when a browser module is rebuilt.

Eleven module crates and core's own script proxy each gain one test and
three files under `src/fixtures`, being the settings a deployment
writes, the page the origin sends and the page a reader receives. A
`.gitattributes` line keeps the two pages' line endings as committed,
as the seam probe does for its script.

Between them the recordings pass through all twenty hook
implementations in this tree, being nine attribute rewriters, three
script rewriters, one stream processor and seven head injectors. Two of
the head injectors act on a request their module marked, the Google
Publisher Tag diagnostics and DataDome's tag, and the recordings are of
a request with no mark, so the first writes nothing and the second
writes its tag.

Two things the recordings show as they are. The Google Tag Manager
page holds `'&amp;l='+l` where the origin's snippet holds `'&l='+l`,
because a rewritten script is written back escaped. And Prebid's script
removal takes out a link whose address matches a script pattern, as it
takes out a script.
When a script rewriter replaces a script's text, the HTML processor wrote
the replacement with `ContentType::Text`, which escapes `&`, `<` and `>`.
A browser decodes no entity inside a `<script>`, so the page ran the
escaped text as it stood. `main` does the same.

It reached every inline script the Google Tag Manager module rewrites.
The standard snippet's `'&l='+l` came out as `'&amp;l='+l`, so a page
that names its own data layer asked for a parameter called `amp;l` and
its container read the default data layer. A loader of the page's own
holding `&&`, `<` or `>` became a syntax error. It reached a
`script#__NEXT_DATA__` the Next.js module rewrites as well, wherever the
payload holds one of the three characters unescaped.

The replacement is written with `ContentType::Html`, which is inserted
as it is. A rewriter is handed the text as the page wrote it, so that is
the right content type in any element.

The recording of the Google Tag Manager page changes by the one line
that showed the fault, from `'&amp;l='+l` to `'&l='+l`, and no other
recording changes.

Four tests, each failing before the change. One in the HTML processor
swaps a host inside a script holding `&&`, `<`, `>` and a query string.
Two in the Google Tag Manager module run the standard snippet with a
data layer name of its own, and a loader holding operators, through the
page pipeline. One in the Next.js module runs a payload holding a query
string and a value with all three characters.

This comes before the page hooks convert, because the middleware
contract hands each handler a script's text as the one before it left
it and writes it back as it is, and converting a hook must leave its
recorded page as it was.
The Google Tag Manager module holds a script's text back while a marker
might still arrive in a later part, and writes it with the script's last
part. A part that arrived after the held text had stopped looking like a
marker was kept as it came, so it went out at once, ahead of the text
held before it, and the script reached the page out of order. `main`
does the same.

The parser hands a script's text over in parts split where a `<` might
close the script, so this needs no help from the network. Before this
change,

    footer.innerHTML = '<b>Search with google</b> <i>or</i> <u>another</u> engine';

came out as

    footer.innerHTML = '<> <i>or</i> <u>another</u> engine';b>Search with google</b

because the part ending on `google` is held and the parts after it are
not. It also happened when the origin's bytes broke after `google`, or
after a longer start of either marker such as `googletag`, in any inline
script that carried on past the next break.

When nothing held can become a marker any more, what was held is now
written ahead of the part that showed it, and the rest of the script
passes through as before. No recording changes.

Three tests, each failing before the change. One page arrives whole and
writes markup, one arrives in parts with no character that needs
escaping, and one is a script that is held, never rewritten, and holds
`&&` and `<`, which needed the change before this one as well.
A module changes a page through four hook traits, and every hook of a
selected module runs on every page. This adds the other way to make a
page change, which the modules convert to in the commits that follow. A
middleware is a page change a module supplies and the operator's
settings place. It runs only on the pages a `[[fetch]]` entry names it
for, in the order that entry gives.

    [[fetch]]
    media_type = "text/html"
    path = "/news/"
    middleware = ["tag.example.cleanup", "tag.example"]

    [[fetch]]
    media_type = "text/html"
    middleware = ["tag.example"]

A page takes the first entry that covers it. An entry places and orders,
and switches nothing on. The section that selects a module still decides
whether it runs, and a middleware reads its settings from its module's
own table.

`trusted_server_core::middleware` holds the contract. A middleware is
asked once for each document, and answers with any of head markup,
markup for straight after the script bundle, element handlers, text
handlers and a stream processor.

What core does with it.

1. The registry takes `.with_middleware(...)` from a registration. At
   startup it refuses two registrations that supply one name, a name no
   entry could write, and an entry naming a middleware that no running
   module supplies, listing the names that could be written. A
   middleware no entry names is logged as a warning, because a module
   may be selected for what else it does.
2. The settings read `[[fetch]]` and refuse an entry that could not do
   what it says, when they load and at deploy validation. That is a
   media type other than `text/html`, a path that is not a request path
   prefix, an entry that names nothing or names one middleware twice, a
   key an entry does not read, and an entry an earlier one already
   covers.
3. The publisher path carries the request path to where a document's
   processor is made, and the chain for that path is planned there.
4. The HTML processor carries the plan out after the hooks of the same
   kind. Every text handler, a hook's or a middleware's, is now handed a
   script as the one before it left it. Before, a second rewriter that
   matched the same script was handed the origin's text, and its
   replacement overwrote the first's.

A fetch middleware is told nothing about the reader, because what it
leaves is what a shared template stores. The entries are part of the
settings a template's fingerprint covers, so changing them selects a
template of its own.

A middleware chooses its selectors for each document, so one that does
not parse cannot be refused at startup. The response fails with a 500
that names the middleware, through both finalizers, where the parser
would otherwise panic.

The seam probe supplies a middleware from outside core, with one of each
handler, and the Axum and Fastly adapters' probe tests cover an entry at
startup.

Tests cover the entries, the registry's checks, what the document
pipeline does with each part of a plan, a whole request through both
finalizers at a covered path and at another, and a stored shared
template. Eleven of them were watched failing with the code broken in
six places, being the chain dropped from the publisher path, the chain
planned for the root path whatever was asked for, text handlers handed
the origin's text, the registry's check removed, and each of the two
settings checks removed.

The guides describe `[[fetch]]` and the contract, and the documentation
test compiles the guide's middleware fixture as it compiles the existing
one.

No stock module supplies a middleware yet, so no page changes.
The commit before this one places a middleware on the page as the origin
sent it, where what it writes is stored with a shared template. Some
page changes belong to one reader. This adds the second phase. A
`[[serve]]` entry names the middleware that run on each reader's copy,
whether the page came from the store or from the origin, and nothing
they write is stored.

    [[serve]]
    media_type = "text/html"
    middleware = ["tag.example.reader"]

A middleware says which phase it runs in. Startup refuses an entry that
names one in the other phase, and the message says which phase it runs
in.

A serve middleware is handed what its module's request hooks left on the
reader's request. A request that carries any was already kept off the
shared template path, so a page from the store has none to hand over.

A page leaves from four places, and the serve entry runs at each.

1. A page that is not stored runs it on the same pass as the fetch
   entry, after it, so a streamed page is still streamed.
2. A page transformed as a shared template runs it once the template is
   stored and this reader's copy is assembled.
3. A page from the store runs it after assembly in the buffered
   finalizer.
4. A page from the store runs it piece by piece in the streaming
   finalizer, so the head still goes out before the auction is awaited.

A serve middleware works on a page core has already rewritten, and
nothing of core's own is written twice. Its head markup goes straight
before the script bundle's tag, after the fetch entry's, and its markup
for after the bundle goes straight after the tag.

A page from the store keeps no record of its origin's host. A serve
middleware is told the host from the settings, so it is told the same
on a hit as on a miss.

A serve entry that cannot run fails the response with a 500 at each of
the four places, before a byte of the page is sent.

The seam probe supplies a serve middleware from outside core, and the
Axum adapter's probe tests cover a `[[serve]]` entry at startup, one
that names a serve middleware and one that names a fetch middleware.

Tests drive a serve middleware through a whole request at each of the
four places and through both finalizers, with an auction and without,
and read the stored template to see it holds what the fetch entry wrote
and nothing the serve entry wrote. Each of the four places was then
broken in turn, and tests failed for each. Tests also failed with the
host read from the page's own record, and with the serve entry run
inside the transform of a page that is stored.

The guides describe both phases.
The Axum adapter's probe tests already build an adapter with a
`[[serve]]` entry that names the probe's serve middleware, and are
refused one that names its fetch middleware. The Fastly adapter is the
primary deployment target, so its probe tests now make the same two
checks where that adapter builds its state.
A page takes one entry, so a deployment that runs several modules names
all their middleware in the one entry that covers those pages. An
operator who instead writes an entry for each module was refused with
advice to put the entry with the longer path first, which does not help
when both entries cover the same pages. That case now says that the
second entry covers the same pages as the first, and that the middleware
for those pages belong in one entry's list.
The modules convert their page hooks to middleware in the commits that
follow, and each module's tests then need the same four things.

`AttributeRewrite::each` gives one handler for each of several
attributes, all asking one decision, which is the shape nine of the
attribute hooks have, a script's address judged in `src` and in `href`.

`middleware::test_support::context` is what a test hands a middleware it
calls directly.

`html_processor::test_support::place_on_every_page` writes the entry
that covers every page, and `create_page_processor` builds the processor
of a test page with the middleware both phases' entries name. The
recording helper builds its processor through it, so a test and a
recording run a page the same way.
The GPT module embeds `gpt_bootstrap.js` as it is on disk and writes it
into the head of every page. A checkout that rewrites line endings, as a
Windows checkout does under `core.autocrlf`, changed the bytes of the
script that is served, so a deployment built there served a different
page from one built anywhere else.

The recorded page showed it. The Axum tests failed on the Windows runner
in `the_page_a_reader_receives_is_the_recorded_one`, and giving the file
CRLF line endings on a machine where the test passes makes the same test
fail.

The file is pinned to LF in the crate's `.gitattributes`, as the seam
probe pins the script it embeds.
Testlight pointed a `src` or `href` that names `testlight.js` at its
shim through an attribute hook, on every page of a deployment that
selected it. It now supplies that change as the middleware
`testing.testlight`, which runs on the pages a `[[fetch]]` entry names it
for. The decision is the one the hook made, and it still does nothing
unless `rewrite_scripts` is set.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.

This is the first module to convert, so the changelog gains the entry
for the whole change, which is breaking for a deployment that selects a
converted module and writes no entry. The configuration guide gains the
table of the middleware the stock modules supply, with this one row.
Permutive pointed a `src` or `href` that is its SDK's address at the
first-party endpoint through an attribute hook, on every page of a
deployment that selected it. It now supplies that change as the
middleware `audience.permutive`, which runs on the pages a `[[fetch]]`
entry names it for. The decision is the one the hook made, and it still
does nothing unless `rewrite_sdk` is set.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
lockr pointed a `src` or `href` that is its SDK's address at the
first-party endpoint through an attribute hook, on every page of a
deployment that selected it. It now supplies that change as the
middleware `identity.lockr`, which runs on the pages a `[[fetch]]` entry
names it for. The decision is the one the hook made, and it still does
nothing unless `rewrite_sdk` is set.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
Didomi wrote `window.__tsjs_didomi` into the head through a head
injector, on every page of a deployment that selected it. The script
hands the browser module the path Didomi is served under. It is now the
middleware `cmp.didomi`, which runs on the pages a `[[fetch]]` entry
names it for, and writes the same script.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
Sourcepoint changed a page through two hooks, on every page of a
deployment that selected it. A head injector wrote the browser module's
settings and, with `rewrite_sdk` set, the trap on `window._sp_`. An
attribute hook pointed a `src` or `href` on Sourcepoint's CDN at the
first-party path, also only with `rewrite_sdk` set.

Both are now the one middleware `cmp.sourcepoint`, which runs on the
pages a `[[fetch]]` entry names it for and makes the same two changes
under the same setting.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
Google Tag Manager changed a page through two hooks, on every page of a
deployment that selected it. An attribute hook pointed Google's tag
addresses in `src` and `href` at the first-party path, and a script hook
did the same in the text of inline scripts.

Both are now the one middleware `tag.google-tag-manager`, which runs on
the pages a `[[fetch]]` entry names it for and makes the same decisions.

The script hook kept the text it was holding in the document's state,
under its own id, so that one document's held text could not reach the
next. The handler that replaces it is made for one document and holds
the text itself. The test that an interrupted document leaves nothing
for the next one now asks the registered middleware for a handler twice.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
DataDome changed a page through two hooks, on every page of a deployment
that selected it.

An attribute hook pointed a `src` or `href` that is a DataDome script's
address at the first-party path, when `rewrite_sdk` is set. That is now
the fetch middleware `bot-protection.datadome`. It makes the same
decision under the same setting, on the pages a `[[fetch]]` entry names
it for.

A head injector wrote the client tag, and left it out for a request the
request filter had marked. A change that depends on the request cannot
be stored for every reader, so it is now the serve middleware
`bot-protection.datadome.tag`, which runs on each reader's copy of the
pages a `[[serve]]` entry names it for. It reads the same mark from the
document's state and writes the same tag.

The design text expected the tag to move to the start of the head. It
does not move. A serve middleware's head markup goes straight before
the script bundle, which is where the hook's markup went, so the
recorded page is unchanged. Its settings gain the two entries.
Prebid changed a page through two hooks, on every page of a deployment
that ran it. A head injector wrote the configuration Prebid.js reads and
the tag that loads the bundle. An attribute hook removed an element
whose `src` or `href` matches `script_patterns`, which is the
publisher's own Prebid script.

Both are now the one middleware `auction.prebid`, which runs on the
pages a `[[fetch]]` entry names it for and makes the same two changes.
Neither depends on the request, so both stay in the fetch phase and what
they write is stored with a shared template, as it was.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
A browser module can read a setting from the tag the script bundle was
loaded by. Two modules use that today, and each sets its attribute
through a method of the head injector trait, which goes when the page
hooks have converted. The attribute is not a change to a page that an
entry places. It belongs to the module's browser code and is the same on
every page the bundle is written into.

A registration now states it with `.with_bundle_tag_attribute(name,
value)`. The registry puts those after the attributes the head injectors
still ask for, in registration order, and keeps the first value a name
is given as it did. The tag is written as markup with no escaping, so a
name or a value that could end the attribute refuses startup, where the
tag's writer only asserted it in a debug build.

Core's tagging stand-in states its attribute this way, so the test that
a page's bundle tag carries it runs through the new path.
APS implemented the head injector trait, wrote no markup through it, and
used it only to put `data-ts-aps-rendering-mode="publisher_native"` on
the script bundle's tag where a demand source asks for native rendering.

It now states that attribute on its registration and supplies no
middleware, so no entry names it and no page is changed in its name. The
attribute is on the tag in the same case as before, and the recorded
page is unchanged.
GPT changed a page through two hooks, on every page of a deployment that
selected it. A head injector wrote the `tsjs.adInit` bootstrap, and an
attribute hook pointed the GPT script's address at the first-party path
when `rewrite_script` is set.

Both are now the one middleware `ad-tag.google`, which runs on the pages
a `[[fetch]]` entry names it for and makes the same two changes.

The head injector also put `data-ts-gam-attribution="true"` on the
script bundle's tag when `gam_attribution_enabled` is set. The
registration states that attribute now, so it is on the tag of every
page the bundle is written into, as it was.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
GPT diagnostics wrote its bootstrap and the tag that loads its module
through a head injector, which ran on every page of a deployment that
selected it. What it writes depends on the request, because a reader
turns diagnostics on with `?ts_console=1` and off again, so it has to
run for each reader and never once for a page that is stored.

It is now the serve middleware `ad-tag.google.diagnostics`, which runs
on each reader's copy of the pages a `[[serve]]` entry names it for. The
bootstrap still goes ahead of the script bundle and the module's tag
straight after it, and a request that did not activate diagnostics still
gets neither.

The recorded page is unchanged. Its settings gain the entry that places
the middleware, and so do the settings the browser tests run with, which
select this module for the diagnostics test.
Next.js changed a page through three hooks, on every page of a
deployment that selected it. Two script rewriters worked on the text of
the `__NEXT_DATA__` script and of the React Server Components payload
scripts, and a stream processor substituted the placeholders the second
of them left.

All three are now the one middleware `framework.nextjs`, which runs on
the pages a `[[fetch]]` entry names it for. Its action carries two text
handlers, in the order the rewriters were registered in, and the stream
processor. They share the state the module keeps for a document as they
did, because all three are made for the document together.

The rewriters keep their logic. What each is told about a chunk of text
is now a type of this crate with the same fields, less the selector,
which neither rewriter read. Their tests change by that type's name
alone.

The recorded page is unchanged. Its settings gain the entry that places
the middleware, and so do core's settings for the Next.js auction tests
that the adapters and the parity suite share. The two Google Tag Manager
tests that run Next.js alongside it name both.
Core's JavaScript asset proxy pointed a configured script at its
first-party path, or removed one that is blocked, through an attribute
hook. The hook ran on every page of a deployment that selected the
proxy, and ahead of every module's own.

It is now the middleware `js_asset_proxy`, which runs on the pages a
`[[fetch]]` entry names it for. Its selector asks about the `src` of
`script` elements alone, which the hook checked for itself.

Its place among the page changes is the place the entry gives it. A
middleware is asked about an address as the one named before it left
it, so the proxy is named first where it has to see the address the page
wrote. One test names it ahead of a module and another names the module
ahead of it, and the guide says to name it first.

The tag stand-in that core's tests run beside the proxy becomes a fetch
middleware in the same change, because the two were tested together
through the order of their hooks.

The test that asked the hook about an element that is not a script is
removed. The selector decides that now, and the page test that holds an
image with a configured address covers it. A test is added for a script
tag written in upper case.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
Core's tests stand two modules in for a vendor's. One carries a deferred
browser module and writes its settings into the head, and the other
rewrites script payloads in two passes. Both made their page change
through the hooks.

Each now supplies one fetch middleware under its own name, and a test
that runs one on a page places it with an entry.

The payload stand-in kept its captured payloads and its held script text
in the document's state, because one rewriter served every document. Its
text handler and its stream processor are now made for one document, and
share what the handler captures between the two of them.

Two tests checked that the payload stand-in had registered a stream
processor before going on. They check that its middleware runs on the
page.

The test that two configurations of a module never share a template now
also checks that each page carries the markup its own configuration
writes. Nothing looked at what the deferred stand-in wrote before.
…ware

Core's tests stand one module in for a vendor's that acts on one
request. Its preparer or its filter marks the request, and its head
injector read the mark and wrote a script ahead of the bundle and its
module's tag after it. What it writes is one reader's, which the page
path kept out of a shared template by handing a shared mode no request
state.

It now supplies one serve middleware under its own name, which reads
the mark from the state the request left. A serve middleware runs on a
reader's copy and nothing it writes is stored, so the same output is
kept out of a shared template by where it runs.

The tests of a request a module acts on place the middleware with a
`[[serve]]` entry.

The tests that render whole documents compose the serve entry as the
production function does, which runs none on a template every reader
shares. They also run a serve middleware that writes for every reader,
marked or not, and refuse its output in a shared template. Without it a
template that ran the serve entry would still pass, because a shared
mode is handed no request state and the stand-in writes nothing without
a mark.
Core moves the origin's address in `srcset` and `imagesrcset`, as it
does in `href`, `src` and `action`, and no test said so. Taking either
rewrite out left every test passing.

This test runs both through the document processor, with a bare host
among the candidates of the `srcset`, and a `srcset` that names another
host, which is left as it is.
Jamesr51d and others added 24 commits October 13, 2026 16:54
Core's version_header module is removed, with its call from the finalize
step of the Axum, Cloudflare and Spin adapters and from the Fastly adapter's
health probe and its settings fallback, so no response carries x-ts-version
unless a [[serve]] entry names the service_version middleware. That
middleware now writes the git version the build compiled in from
TRUSTED_SERVER__GIT_VERSION or local git as X-TS-Version, when the build
knows one, and the version the adapter reports for the service as
X-TS-Service-Version, when it reports one. HEADER_X_TS_FASTLY_VERSION, which
nothing wrote, goes with the module.

The health tests of the three adapters prove the probe carries no version,
the middleware's own tests prove each header is written when its version is
known and nothing when neither is, and the publisher tests prove both
headers reach a buffered page and neither reaches a page no entry names the
middleware for. The configuration guide, the getting started guide, the
EdgeZero guide and the changelog say the same.
# Conflicts:
#	docs/guide/configuration.md
# Conflicts:
#	docs/guide/configuration.md
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1367, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record.

@jwrosewell jwrosewell closed this Oct 15, 2026
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