Follow

Satis extension matrix

Which TYPO3 extension version works where — scraped, combined and rendered as a matrix

Running many TYPO3 installations means the same recurring question: which extension version fits which TYPO3 version, and which of our projects would a bump break?

This service answers it by scraping the Satis Composer repository for extension metadata, combining it with project requirements and rendering the result as a matrix you can read at a glance.


FactDetail
Question answeredExtension × TYPO3 version compatibility
InputSatis repository metadata, semantic versions
StackKoa, cheerio, handlebars, node-sass
StartedJune 2018

What's in it

Planning tool, not a report

Built to decide an upgrade order across projects, not to archive data.

Reads the source of truth

Satis metadata plus real version constraints rather than a hand-kept table.

In detail

What is actually inside it

The question, not the data

It answers "which extension version fits which TYPO3 version, and what would a bump cost us" — the matrix is a decision aid, not a report.

Scraping, deliberately

Satis exposes a web UI, not an API, so cheerio parses what the repository actually serves. Pragmatic, and the caching layer keeps it from hammering internal infrastructure.

Agency-shaped

Built to compare many installations across projects, which is the opposite of what an extension maintainer needs — it optimises for the fleet owner.

DetailValue
Answersextension × TYPO3 compatibility
StackKoa, cheerio, semver, node-cache, hbs + list.js
StartedJune 2018

Technical notes

The decisions behind it

Why Satis at all?
It was our Composer repository. The metadata about which extension versions exist (and their constraints) lived there, so that is where the truth was read from.
Why a web service instead of a CLI?
The matrix was discussed in meetings. A URL everyone can open and filter beats a report someone has to regenerate.

Reading the fleet

One HTML fetch, then seven sequential raw-file probes per installation

Everything on the matrix page is produced from a single HTML fetch plus a fan-out of raw-file fetches. There is no API anywhere in the chain.

The service issues one authenticated GET https://$SATIS_HOST/ and hands the body to cheerio. Every #package-list .card is then classified by one selector: if the card header contains a link mentioning BASE_DISTRIBUTION_NAME (default rm-dist) it is a distribution — one of our installations — otherwise it is an extension. The package name is read straight out of the card's id attribute, with a .replace('"', '') because Satis renders a stray quote there.

Extensions contribute two fields: the distributions they are Required by (the <dd> after dt:contains(Required by)), and their current version, defined as the first release link whose text passes semver-regex. That definition matters: dev-master, dev-develop and any non-semver tag are skipped, and correctness depends entirely on Satis listing releases newest-first.

For a distribution the payload is gitUrl (dt:contains(Releases) → a:contains(dev-master)). The Bitbucket project key is not parsed as a URL: it is sliced out of that SSH string by byte offset between ${BITBUCKET_PORT}/ and /${BASE_DISTRIBUTION_NAME}.git, with a hard-coded + 5 that silently assumes a four-digit port like 7999. That key is then used to build raw-file URLs, which go over HTTPS — the SSH port in the git URL is only ever parsed, never connected to.

Each distribution triggers seven Bitbucket fetches, executed sequentially with await (the distributions themselves are fanned out in parallel through Promise.all): composer.lock and composer.json at refs/heads/master; two Bamboo spec scripts, build/03-write-install-files.sh and deploy/backup-typo3.util.sh, detected by existence alone; .rm-frontendrc.js, whose existence means "new frontend integration"; and two Bitbucket browse pages, src/Patches and patches, where #browse-table tbody tr length > 1 is taken as "patches exist". All of it is cached per URL in node-cache; the assembled result is cached under the single key matrixHomepage for HOMEPAGE_CACHE_TTL seconds (600 by default, same as FETCH_CACHE_TTL).

The last derived value is the colour. semver-diff(usedVersion, satisCurrentVersion) becomes the class on each cell — diff--patch, diff--minor, diff--major, or literally diff--null when the two versions are equal, because that is what semver-diff returns for identical input. That one class is the whole colour language of the matrix: on row or column hover it paints the cell yellow, orange, red or green (the UI legend calls the green state "current").

Sharp edges

Failure modes and dead scaffolding found in the source

Does the master/develop switch actually read the develop branch?
No. Every Bitbucket URL is built with the literal branch 'master', so develop is never fetched. The switch is fully scaffolded — the develop label, span.diff--develop rules and body.branchToggled selectors all exist in the SCSS — but no script ever adds branchToggled to <body>; public/javascript/main.js only binds hover handlers. The app's own "How it's done" copy, which promises data "for both develop and master branches", describes intent rather than shipped behaviour.
Why can clearing one repository's cache leave stale cells on screen?
GET /cache/clear/:repository deletes a hand-written key list, and that list drifted from the fetch code. A January 2021 commit moved two Bamboo fetches from build-distribution/… to build/… and from distribution-deployment/util/… to deploy/…, but the clear route still deletes the old URLs — so 03-write-install-files.sh and backup-typo3.util.sh keep their cached values. The two patch-folder listing URLs are absent from the clear list entirely. Only GET /cache/clear (flushAll) is guaranteed to drop everything.
What happens when a distribution repo has no composer.lock?
It takes the whole page down. fetchUrl throws on a 404, the caller catches any error whose message contains 404 and substitutes a [false] sentinel — but that sentinel is then assigned to distribution.extensions.master.rm, and the very next line calls false.forEach(...), which throws a TypeError inside the request. The rejection propagates through Promise.all and there is no wrapping try/catch in the route, so the matrix returns a 500 instead of the intended X cell. Only the pure existence probes (the Bamboo scripts, .rm-frontendrc.js) handle absence gracefully, because they discard the body and resolve [true]. A 500, a 401 from bad BITBUCKET_PASSWORD, or a network timeout fails the same way: only the exact string 404 is swallowed.
Are misses cached?
No. fetchUrl calls cache.set only after a body has been read successfully; a 404 throws before it gets there. Since several fields are existence probes that are supposed to 404 on older distributions, every render after the cache expires re-requests all of them. A cold render therefore costs one Satis fetch, seven sequential Bitbucket requests for the slowest distribution, and one Packagist fetch for the current TYPO3 core version.
What is list.js doing in the stack?
Nothing. It is a declared dependency in package.json — and named in this project's own stack table — but the string appears nowhere in server.js, the Handlebars template or public/javascript/main.js: no new List(), no <script> tag for it. All highlighting is hand-written jQuery: main.js copies the diff--* class from the version <span> up to its <td>, then toggles active on the hovered row or column. list.js — together with the unused body.distributionToggled SCSS — is leftover scaffolding.

The README as a code review

What the TODO list says about the shape of the service

The README's TODO list is really a description of the code's shape. A single server.js (~18 KB, last changed in January 2021) does the scraping, the composer.lock evaluation and the condensation into view data, and it passes results between stages as positional tuples.

filterComposerLockToRmPackages returns a seven-element array, consumed by the caller as composerLock[0] through composerLock[6]; most other extract* functions return a one-element array ([hasPatches], [newFrontendIntegration]) purely so they can hang off .then(). The two halves are coupled only by index, and that coupling — not the HTTP code — is what the TODO "more general units (satis info extraction, composer.lock evaluation, data condensation)" is asking to break.

There is no test directory. server.js and public/javascript/main.js are the only application scripts, and the only proof the chain works is the rendered matrix.

Generalize the units

Split satis info extraction, composer.lock evaluation and data condensation — currently three responsibilities in one 18 KB file, wired by array indices.

Self-host the assets

Bootstrap 4, jQuery 3.2, Font Awesome 5 and Typekit all load from CDNs (with SRI hashes). The README's "no CDNs" item is about an internal tool that currently phones four external hosts to render.

Helpers into their own file

extension, version, typo3Version and isDivider are registered inline in server.js, and version builds HTML by string concatenation into new hbs.SafeString(...).

Half-refactored

21 commits since June 2018; the SCSS refactor and const-by-default are done, while the unused list.js dependency and the CDN loading are not. Dependabot bumps are the only later changes.