Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ warehouse/admin/templates/* @pypi/warehouse-reviewers @pypi/warehou
warehouse/static/* @pypi/warehouse-reviewers @pypi/warehouse-frontend
warehouse/templates/* @pypi/warehouse-reviewers @pypi/warehouse-frontend
.stylelintrc.json @pypi/warehouse-reviewers @pypi/warehouse-frontend
.oxlintrc.json @pypi/warehouse-reviewers @pypi/warehouse-frontend
.oxfmtrc.json @pypi/warehouse-reviewers @pypi/warehouse-frontend
babel.cfg @pypi/warehouse-reviewers @pypi/warehouse-frontend
babel.config.js @pypi/warehouse-reviewers @pypi/warehouse-frontend
eslint.config.mjs @pypi/warehouse-reviewers @pypi/warehouse-frontend
package-lock.json @pypi/warehouse-reviewers @pypi/warehouse-frontend
bun.lock @pypi/warehouse-reviewers @pypi/warehouse-frontend
package.json @pypi/warehouse-reviewers @pypi/warehouse-frontend
webpack.config.js @pypi/warehouse-reviewers @pypi/warehouse-frontend
webpack.plugin.localize.js @pypi/warehouse-reviewers @pypi/warehouse-frontend
rspack.config.js @pypi/warehouse-reviewers @pypi/warehouse-frontend
rspack.plugin.localize.js @pypi/warehouse-reviewers @pypi/warehouse-frontend
rspack.plugin.manifest.js @pypi/warehouse-reviewers @pypi/warehouse-frontend

# This is not *technically* a frontend file, but editing the frontend will likely
# mean that these need to be regenerated, so we treat it like it's a frontend file.
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/node-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 25.6.0
cache: 'npm'
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.9
- name: Install Node dependencies
run: npm ci
run: bun install --frozen-lockfile
- name: Run ${{ matrix.name }}
run: ${{ matrix.command }}
3 changes: 3 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"ignorePatterns": []
}
22 changes: 22 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": ["warehouse/static/js/vendor/**", "warehouse/static/dist/**", "warehouse/admin/static/dist/**"],
"env": {
"browser": true,
"es2015": true,
"node": true,
"jest": true
},
"globals": {
"define": "readonly",
"require": "readonly",
"$": "readonly",
"jQuery": "readonly"
},
"categories": {
"correctness": "error"
},
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
37 changes: 22 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@
ARG PYTHON_IMAGE_VERSION=3.13.13-slim-bookworm

# First things first, we build an image which is where we're going to compile
# our static assets with. We use this stage in development.
# our static assets with. We use this stage in development. Node base is kept
# because some dependencies (sharp, sass-embedded) need node-gyp / native
# modules built against Node's headers; bun is just dropped in for the
# install + run steps which it does substantially faster than npm.
FROM node:25.8.1-bookworm AS static-deps

# Pull in bun for fast installs (bun install --frozen-lockfile reads bun.lock).
COPY --from=oven/bun:1.3.9 /usr/local/bin/bun /usr/local/bin/bun

WORKDIR /opt/warehouse/src/

# However, we do want to trigger a reinstall of our node.js dependencies anytime
# our package.json changes, so we'll ensure that we're copying that into our
# static container prior to actually installing the npm dependencies.
COPY package.json package-lock.json babel.config.js /opt/warehouse/src/
# Trigger a reinstall whenever package.json or bun.lock changes.
COPY package.json bun.lock babel.config.js /opt/warehouse/src/

# Installing npm dependencies is done as a distinct step and *prior* to copying
# over our static files so that, you guessed it, we don't invalidate the cache
# of installed dependencies just because files have been modified.
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
# Installing the JS dependencies is done as a distinct step and *prior* to
# copying over our static files so we don't invalidate this layer just
# because source files changed.
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
bun install --frozen-lockfile



Expand All @@ -26,16 +30,19 @@ FROM static-deps AS static

# Actually copy over our static files, we only copy over the static files to
# save a small amount of space in our image and because we don't need them. We
# copy `webpack.config.js` last even though it's least likely to change, because
# copy `rspack.config.js` last even though it's least likely to change, because
# it's very small so copying it needlessly isn't a big deal but it will save a
# small amount of copying when only `webpack.config.js` is modified.
# small amount of copying when only `rspack.config.js` is modified.
COPY warehouse/static/ /opt/warehouse/src/warehouse/static/
COPY warehouse/admin/static/ /opt/warehouse/src/warehouse/admin/static/
COPY warehouse/locale/ /opt/warehouse/src/warehouse/locale/
COPY webpack.config.js /opt/warehouse/src/
COPY webpack.plugin.localize.js /opt/warehouse/src/
COPY rspack.config.js /opt/warehouse/src/
COPY rspack.plugin.localize.js /opt/warehouse/src/
COPY rspack.plugin.manifest.js /opt/warehouse/src/
COPY bin/post-build.mjs /opt/warehouse/src/bin/

RUN NODE_ENV=production npm run build
RUN NODE_ENV=production bun run build \
&& bun bin/post-build.mjs



Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ default:
@echo
@exit 1

.state/docker-build-base: Dockerfile package.json package-lock.json pyproject.toml uv.lock
.state/docker-build-base: Dockerfile package.json bun.lock pyproject.toml uv.lock
# Build our base container for this project.
docker compose build --build-arg IPYTHON=$(IPYTHON) --force-rm base

# Mark the state so we don't rebuild this needlessly.
mkdir -p .state
touch .state/docker-build-base

.state/docker-build-static: Dockerfile package.json package-lock.json babel.config.js
.state/docker-build-static: Dockerfile package.json bun.lock babel.config.js
# Build our static container for this project.
docker compose build --force-rm static

Expand Down
152 changes: 152 additions & 0 deletions bin/post-build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
/* SPDX-License-Identifier: Apache-2.0 */

/* Post-bundler build step:
* - Pre-compress text assets (.js, .css, .svg, .html, .map) with gzip and
* brotli so whitenoise can serve the .gz/.br sibling without recompressing.
* - Image-optimise raster + svg assets in dist/images.
*
* Replaces compression-webpack-plugin + image-minimizer-webpack-plugin which
* were dropped during the rspack swap (no rspack-compatible equivalents).
*
* Usage: bun bin/post-build.mjs (or `node bin/post-build.mjs`).
*/

import { readdir, readFile, writeFile, stat } from "node:fs/promises";
import { join, extname } from "node:path";
import { gzip, brotliCompress, constants } from "node:zlib";
import { promisify } from "node:util";

const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);

const DIST_DIRS = ["warehouse/static/dist", "warehouse/admin/static/dist"];

// Files to pre-compress; matches the previous compression-webpack-plugin
// scope (any text-shaped asset where minRatio: 1 made compression worthwhile).
const COMPRESSIBLE = /\.(js|css|svg|html|map|json|txt|wasm)$/i;

// Smaller-after-compression threshold mirrors the old `minRatio: 1`.
const MIN_RATIO = 1;

async function* walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === "ENOENT") {
return;
}
throw err;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
}
}

async function compressOne(file) {
if (!COMPRESSIBLE.test(file)) {
return null;
}
const buf = await readFile(file);
const orig = buf.length;
const [gz, br] = await Promise.all([
gzipAsync(buf, { level: 9, memLevel: 9 }),
brotliAsync(buf, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}),
]);
const wrote = [];
if (gz.length < orig / MIN_RATIO) {
await writeFile(file + ".gz", gz);
wrote.push("gz");
}
if (br.length < orig / MIN_RATIO) {
await writeFile(file + ".br", br);
wrote.push("br");
}
return wrote;
}

async function optimiseImages(distDir) {
const imageDir = join(distDir, "images");
let imageStat;
try {
imageStat = await stat(imageDir);
} catch (err) {
if (err.code === "ENOENT") {
return { raster: 0, svg: 0 };
}
throw err;
}
if (!imageStat.isDirectory()) {
return { raster: 0, svg: 0 };
}

const sharp = (await import("sharp")).default;
const { optimize: svgoOptimize } = await import("svgo");

let raster = 0;
let svg = 0;
for await (const file of walk(imageDir)) {
const ext = extname(file).toLowerCase();
if (/\.(png|jpe?g|gif)$/.test(ext)) {
const before = await readFile(file);
const after = await sharp(before).toBuffer();
if (after.length < before.length) {
await writeFile(file, after);
raster++;
}
} else if (ext === ".svg") {
const before = await readFile(file, "utf8");
const result = svgoOptimize(before, {
multipass: true,
plugins: ["preset-default"],
});
if (result.data && result.data.length < before.length) {
await writeFile(file, result.data);
svg++;
}
}
}
return { raster, svg };
}

async function main() {
const start = Date.now();
let compressed = 0;
let imgRaster = 0;
let imgSvg = 0;

for (const dir of DIST_DIRS) {
for await (const file of walk(dir)) {
// Don't re-compress the compressed siblings or source maps' siblings.
if (file.endsWith(".gz") || file.endsWith(".br")) {
continue;
}
const wrote = await compressOne(file);
if (wrote && wrote.length) {
compressed++;
}
}
const { raster, svg } = await optimiseImages(dir);
imgRaster += raster;
imgSvg += svg;
}

const ms = Date.now() - start;
console.log(
`post-build: pre-compressed ${compressed} files, ` +
`optimised ${imgRaster} raster + ${imgSvg} svg images in ${ms} ms`,
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
4 changes: 2 additions & 2 deletions bin/static_lint
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
set -ex

# Actually run our tests.
npm run lint
npm run stylelint
bun run lint
bun run stylelint
7 changes: 6 additions & 1 deletion bin/static_pipeline
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
set -ex

# Compile all static assets.
npm run build
bun run build

# Pre-compress (.gz/.br) text assets and image-optimise raster/svg.
# Replaces the in-bundler compression / image-min plugins which were dropped
# during the rspack swap; see bin/post-build.mjs for what runs.
bun bin/post-build.mjs

# Test that our JS source is included in the bundle
bin/test-sourcemaps
2 changes: 1 addition & 1 deletion bin/static_tests
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ set -ex

export TZ="${TIMEZONE:-UTC}"
# Actually run our tests.
npm run test $@
bun run test $@
Loading
Loading