Skip to content

Fix static files under 768KB 404ing behind express-session (worker returns ArrayBuffer) - #351

Merged
dimdenGD merged 1 commit into
dimdenGD:mainfrom
emilioastarita:fix/worker-read-arraybuffer-end
Jul 23, 2026
Merged

Fix static files under 768KB 404ing behind express-session (worker returns ArrayBuffer)#351
dimdenGD merged 1 commit into
dimdenGD:mainfrom
emilioastarita:fix/worker-read-arraybuffer-end

Conversation

@emilioastarita

Copy link
Copy Markdown
Contributor

Practical impact

On any multi-core host with default settings, every static file smaller than 768KB mounted after express-session (or any middleware that wraps res.end, e.g. passport) returns 404, even though the file exists on disk and is readable. Statics mounted before the session middleware work, which is why this hides in development and small apps.

Cause

ultimate-express defaults threads = 1 on multi-core hosts and serves files under 768KB through the file worker (response.js, sendFile). The worker transfers file contents as an ArrayBuffer (src/worker.js). ultimate-express's own res.end() accepts an ArrayBuffer, so a bare app serves the file fine. But when res.end has been wrapped per-request — express-session wraps it and calls Buffer.byteLength(chunk) to set Content-Length — the ArrayBuffer is rejected:

TypeError [ERR_INVALID_ARG_TYPE]: The "chunk" argument must be of type
string or an instance of Buffer, TypedArray, or DataView.
Received an instance of ArrayBuffer

That rejection lands in sendFile's .catch, which calls the static middleware's fallthrough next(); the request falls through to the outer 404 handler.

Repro

const express = require('ultimate-express');
const session = require('express-session');
const app = express();                       // threads defaults to 1 on multi-core
app.set('case sensitive routing', false);    // force normal dispatch
app.use(session({ secret: 'x', resave: false, saveUninitialized: true }));
app.use('/assets', express.static(DIR_WITH_A_SMALL_FILE));
app.use((req, res) => res.status(404).send('catchall'));
app.listen(3000, async () => {
  await new Promise(r => setTimeout(r, 200));
  const res = await fetch('http://127.0.0.1:3000/assets/small.js');
  console.log(res.status); // ultimate-express: 404, express: 200
});

Fix

Wrap the worker result in a Buffer (zero-copy) at the readFileWithWorker resolve boundary, so the helper returns the same type as fs.readFile — which every express-compatible end() wrapper accepts. It is the only consumer of the worker read, and the conversion is a no-op for any already-Buffer value.

Tests

New tests/tests/middlewares/express-static-session.js with controls: default threads + session (the bug), threads: 0 + session (pipe path), default threads without session (unwrapped end accepts the ArrayBuffer — why it hides), and a >768KB file (stream path bypasses the worker). Fails on master, passes with the fix, output identical to express 4 and 5. Full suite passes (only the unrelated app-cluster.js / port-3000 environmental failure).

Context

Found integrating @bull-board/express's sub-app on a production Express 5-style stack; verified against real express 5.2.1.

…atic files

The file worker (used for files < 768KB, enabled by default on multi-core
machines where threads defaults to 1) transfers file contents as an
ArrayBuffer. ultimate-express's own res.end() accepts an ArrayBuffer, so a
bare app serves these files fine. But when res.end has been wrapped
per-request by middleware that inspects the chunk -- express-session is the
canonical case, it calls Buffer.byteLength(chunk) to set Content-Length --
the ArrayBuffer is rejected with ERR_INVALID_ARG_TYPE. The rejection lands
in sendFile's .catch, which calls the static middleware's fallthrough
next(), and the request ends up at the outer 404 handler.

Net effect: every static file under 768KB mounted after express-session
returns 404 on multi-core boxes with default settings, even though the file
exists and is readable.

Wrap the worker result in a Buffer (zero-copy) at the resolve boundary so
readFileWithWorker returns the same type as fs.readFile, which every
express-compatible end() wrapper accepts.
@emilioastarita
emilioastarita marked this pull request as ready for review July 23, 2026 19:18
@dimdenGD
dimdenGD merged commit 0560abf into dimdenGD:main Jul 23, 2026
4 checks passed
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