#Static Files and Proxying
Two primitives for the requests an $action should not be handling: files that
exist on disk, and requests that belong to somebody else's server.
#$serve
$serve mounts a directory on a path prefix. Declaring one registers the
AlephaServerStatic module (and the HTTP server with it), so the
alepha.with() below is only needed when you call ServerStaticProvider
yourself without any $serve.
1import { $serve } from "alepha/server/static";2 3class Assets {4 public = $serve({5 path: "/static",6 root: "./public",7 });8}
1import { AlephaServerStatic } from "alepha/server/static";2 3alepha.with(AlephaServerStatic);
path defaults to / and root to the working directory (a relative root
is resolved against it too), so $serve({}) in a container that starts in the
right place already does something useful.
| Option | Default | Effect |
|---|---|---|
path |
"/" |
URL prefix the directory is mounted at |
root |
process.cwd() |
Directory on disk |
indexFallback |
true |
Serve index.html when the path is a directory |
historyApiFallback |
false |
Serve index.html for extensionless misses |
ignoreDotEnvFiles |
true |
Skip dot files and dot directories |
cacheControl |
off | Cache-Control headers, see below |
headersFile |
false |
Apply the root _headers file, see below |
disabled |
false |
Declared, but not read yet: it has no effect |
name |
the field name | Name for logging and debugging |
silent |
false |
Leave its requests out of the request log |
#What a served file gets
The directory is walked once, at boot, and every file becomes its own
route. A file added to root after the server started is not served until the
next start; this is a server for build output, not for uploads (see
Storage for those).
Each file answers with:
Content-Typefrom its extension,Last-Modified,Accept-Rangesand anETag; a matchingIf-None-Match(or, without one,If-Modified-Since) gets a304.- The precompressed sibling when one exists and the client accepts it:
app.js.brforbr, elseapp.js.gzforgzip, withVary: accept-encodingand anETagper encoding. - Under
indexFallback,/docs/index.htmlalso answers/docs/; and a request for the prefix itself without its trailing slash (/static) is redirected with a301to/static/.
#Single-page apps want historyApiFallback
A client-side router owns routes the server has no file for. Without
historyApiFallback, a visitor who reloads on /settings/profile gets a 404,
because there is no settings/profile.html on disk. With it, every miss under
path is answered by index.html and the router takes over. A miss whose path
contains a dot (/missing.css) still gets a plain 404, so a broken asset URL
does not come back as HTML.
Do not turn it on for a directory that also serves an API, since it converts every genuine 404 into a page.
#ignoreDotEnvFiles defaults to on, and should stay on
The default skips every file and directory whose name starts with a dot, which
is the difference between serving your build output and serving your .env.
The one case that seems to need it off, a .well-known directory, does not:
only names below root are checked, so give it its own narrow $serve
rather than opening the whole root.
1import { $serve } from "alepha/server/static";2 3class Assets {4 wellKnown = $serve({5 path: "/.well-known",6 root: "./public/.well-known",7 });8}
#Cache headers
cacheControl is off unless you ask for it. Pass {} for the defaults:
1import { $serve } from "alepha/server/static"; 2 3class Assets { 4 public = $serve({ 5 path: "/static", 6 root: "./dist/client", 7 cacheControl: { 8 maxAge: [1, "year"], 9 immutable: true,10 },11 });12}
| Option | Default | Effect |
|---|---|---|
fileTypes |
js, css, woff, woff2, ttf, eot, otf, jpg, jpeg, png, svg, gif | Extensions that get the header |
maxAge |
[30, "days"] |
Freshness lifetime |
immutable |
true |
Adds immutable, so browsers never revalidate |
⚠️ maxAge is a duration, and a bare number is milliseconds. maxAge: 3600
means 3.6 seconds, not an hour; the provider logs a warning for any bare number.
Write [1, "hours"].
immutable is only safe for content-hashed filenames. A build that emits
app.js rather than app.4f2a1c.js and marks it immutable for a year has just
made that file unfixable in every browser that saw it.
#_headers files
headersFile: true applies the Cloudflare-style _headers file at the root of
the served directory to every response, so one build sends the same headers on
every host. alepha build writes that file into dist/public, and the app's
own public server turns this on; any other $serve leaves it off.
The file is read once at boot, and a line that does not parse fails the boot.
Its matching rules are applied after every other response hook; a response no
rule caches gets public, max-age=0, must-revalidate, and cacheControl is
ignored. With the option on, /_headers, /_redirects and /.assetsignore
are never served; with it off, /_headers and /_redirects are files like any
other.
#$proxy
$proxy forwards a path family to another origin: an API gateway in front of
services, a legacy backend you are strangling, a dev-time bridge to something
running elsewhere.
1import { $proxy } from "alepha/server/proxy";2 3class Gateway {4 api = $proxy({5 path: "/legacy/*",6 target: "https://legacy.example.com",7 });8}
As with $serve, declaring a $proxy registers its module
(AlephaServerProxy); registering it by hand is only for calling
ServerProxyProvider.createProxy() yourself.
1import { AlephaServerProxy } from "alepha/server/proxy";2 3alepha.with(AlephaServerProxy);
path must end in /*. A path without the wildcard throws
Proxy path should end with '/*' when the server configures, rather than
quietly matching one URL. The proxy answers every method on that path family.
The upstream URL is target plus the full request path, prefix included,
plus the query string: /legacy/users?page=2 goes to
https://legacy.example.com/legacy/users?page=2. Strip the prefix with
rewrite when the upstream does not know it is mounted under one.
target takes a string or a function, and the function is re-read per request,
so it can resolve from configuration rather than being frozen at boot.
What travels upstream, and back:
- Every incoming header is forwarded as is,
CookieandAuthorizationincluded, exceptAccept-Encoding, forced toidentityso the body comes back uncompressed. - The request body is streamed upstream for every method but
GET,HEADandOPTIONS, and the response body is streamed back without buffering. - The upstream call carries the request's abort signal: a client that disconnects cancels it.
#Rewriting, and the two hooks
1import { $proxy } from "alepha/server/proxy"; 2 3class Gateway { 4 secure = $proxy({ 5 path: "/secure/*", 6 target: "https://internal.example.com", 7 rewrite: (url) => { 8 url.pathname = url.pathname.replace("/secure", ""); 9 },10 beforeRequest: async (request, proxyRequest) => {11 proxyRequest.headers = {12 ...proxyRequest.headers,13 "X-Forwarded-Host": String(request.headers.host ?? ""),14 };15 },16 afterResponse: async (request, proxyResponse) => {17 if (!proxyResponse.ok) {18 // report it19 }20 },21 });22}
rewrite(url)mutates the outgoing URL in place. The usual job is stripping the prefix that routed the request here, since the upstream service does not know it is mounted under/secure.beforeRequest(request, proxyRequest)mutates the outgoingRequestInit. This is where a service token or a forwarding header goes. On workerd it may also setfetcherto a service binding, which routes the subrequest through that Worker.afterResponse(request, proxyResponse)observes the response, after its status, headers (minus hop-by-hop ones, with everyset-cookiekept) and body have been copied onto the reply.
disabled takes a boolean, read once when the server configures, which makes a
proxy a feature flag: point /v2/* at a new service and turn it off without
removing the declaration.
#It is a proxy, so it is a hole
Everything matching path leaves your process for another origin, carrying
the client's headers (its cookies and bearer token included) unless
beforeRequest removes them. Two habits are worth keeping:
make target something you control rather than something derived from request
input, and mount the proxy under a prefix narrow enough that you can say out
loud what it covers.
#See also
- Static Deployment for shipping a client build without a server at all
- Middlewares for
$middleware, which applies behaviour to a path family the way$proxyforwards one