Serverless deployment
Serverless function platforms cannot hold a WebSocket open. That rules out Wisp, which is one long-lived socket carrying every stream. The way around it is the Bare transport: ordinary HTTP, one request in and one response out, which is exactly the shape a function has.
This works, and for a lot of projects it is the right call. If you have no server, no budget, and a handful of users, serverless is the only way to put a whole proxy somewhere for free, and the costs below never come due at that size.
What it is not is a path that scales. Read the tradeoffs before you commit, so that if the project grows the move is planned rather than forced. My first ever proxy landed me a $600 monthly bill on a serverless host. Do not make that mistake.
Fair warning that this is a shrinking corner of the ecosystem. Most proxies run on a cheap VPS now, because it is cheaper, faster, and does not lose WebSocket sites. If you have the option, skip to the alternative.
node builder/cli.js --out ./my-proxy --preset serverless
The pieces
Scramjet (the rewriter, unchanged)
over
bare-transport (plain HTTP, no WebSocket)
to
@tomphttp/bare-server-node (running in the same function)
@mercuryworkshop/bare-transport is the Bare transport for proxy-transports,
which is the interface Scramjet 2.x uses. It is not bare-as-module3,
despite that being the name most search results give you. That one is the older
bare-mux version and Scramjet cannot use it. See
the two Bare packages.
proxy-bootstrap cannot wire Bare; it ships a stub that throws
"Bare transport not implemented yet". Serverless builds use
manual wiring, which is what you want anyway.
The server
One file, and it is the same server you would run anywhere else plus a Bare server bolted onto the front.
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import express from "express";
import { scramjetPath } from "@mercuryworkshop/scramjet/path";
import { createBareServer } from "@tomphttp/bare-server-node";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const dirOf = specifier => path.dirname(require.resolve(specifier));
const app = express();
app.use((_req, res, next) => {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
next();
});
app.use("/scram/", express.static(scramjetPath));
app.use("/utils/", express.static(dirOf("@mercuryworkshop/scramjet-utils")));
app.use(
"/controller/",
express.static(dirOf("@mercuryworkshop/scramjet-controller"))
);
app.use("/baremod/", express.static(dirOf("@mercuryworkshop/bare-transport")));
app.use(express.static(path.join(__dirname, "public")));
const bareServer = createBareServer("/bare/");
const handleRequest = (req, res) => {
if (bareServer.shouldRoute(req)) {
bareServer.routeRequest(req, res);
return;
}
app(req, res);
};
export default handleRequest;
if (!process.env.VERCEL) {
http.createServer(handleRequest).listen(process.env.PORT || 3000);
}
Two things to notice. There is no upgrade handler, because there are no
WebSockets to route. And the isolation headers are still here: nothing in this
stack requires them, but they are what let a proxied site use
SharedArrayBuffer, so keep them. See
cross-origin isolation.
The export default handleRequest is what the platform invokes. The
if (!process.env.VERCEL) guard is so the same file still runs locally with
node server.js; rename the variable if your host sets a different one.
vercel.json
{
"version": 2,
"builds": [
{
"src": "server.js",
"use": "@vercel/node",
"config": {
"includeFiles": [
"public/**",
"node_modules/@mercuryworkshop/scramjet/**",
"node_modules/@mercuryworkshop/scramjet-controller/**",
"node_modules/@mercuryworkshop/scramjet-utils/**",
"node_modules/@mercuryworkshop/bare-transport/**"
]
}
}
],
"routes": [{ "src": "/(.*)", "dest": "server.js" }]
}
Everything routes to the one exported function. includeFiles matters more than
it looks: the bundler traces imports to decide what to ship, and it cannot see
that express.static(dirOf(...)) needs those directories at runtime. Leave a
package out and you get 404s on the engine files with a server that started
fine. Other platforms have an equivalent setting under a different name.
The client
Identical to any other Scramjet setup except for the transport, which takes the
Bare server URL directly rather than a { wisp } object:
const { default: BareClient } = await import("/baremod/index.mjs");
const controller = new api.Controller({
serviceworker,
transport: new BareClient(new URL("/bare/", location.href).href),
config: {
scramjetPath: "/scram/scramjet.js",
wasmPath: "/scram/scramjet.wasm",
injectPath: "/controller/controller.inject.js"
}
});
await controller.wait();
Everything downstream, frames, plugins, cookies, is unchanged. The transport is the only difference between this and a Wisp deployment, which is the whole point of the transport abstraction. See Transports.
What you are giving up
WebSocket sites will not work
Not "will be slow". Will not work. Discord, most chat apps, live dashboards,
collaborative editors, anything with real-time updates. The Bare spec does
define WebSocket tunnelling, and bare-transport implements it, but it needs a
connection the function cannot hold open.
This rules out a large fraction of what people want a proxy for.
Your server can inspect target traffic
The Bare server terminates TLS with the target site. It can read every URL, cookie, form post, and response that passes through the function.
With Wisp, HTTPS target TLS terminates in the browser and the relay sees ciphertext plus connection metadata. Plain HTTP destinations are not encrypted end to end either way.
If you deploy this, tell your users. It is a legitimate engineering tradeoff and a bad thing to be quiet about.
It gets expensive faster than anything else here
Every byte of every proxied page crosses the function twice, in from the target and out to the user, and serverless egress is billed at a premium rate per GB. A proxy is nothing but egress.
At low traffic this genuinely does not matter, which is why plenty of small proxies run this way without trouble. It matters once traffic grows, and it grows faster than people expect: one person watching an hour of video can move several GB.
A VPS with a few TB of included transfer costs a few dollars a month and does not surprise you. Serverless has no equivalent ceiling. Compare your provider's per-GB egress price against a VPS bandwidth allowance before you deploy this somewhere the public can reach. In complete honesty, I would recommend unlimited-bandwidth VPSs exclusively unless your project is really small.
Execution limits and cold starts
Functions have a wall-clock limit. Long downloads, video streaming, and slow endpoints get cut off. Cold starts add latency to the first request after idle, and a proxy's first request also pulls the rewriter wasm.
Check the host's terms
Public proxies attract abuse reports and may violate a provider's acceptable-use policy. Read the current terms for your provider before deploying one.
The alternative worth considering
Split the deployment instead of forcing everything into one function:
Frontend → any static host (free, fast, no server)
Backend → a small VPS, Fly, Render, Railway, Koyeb (WebSockets work)
Point the client at the remote Wisp server:
const transport = new LibcurlClient({
wisp: "wss://backend.crllect.dev/wisp/"
});
You get target-site WebSockets and TLS terminating in the browser, while the part that costs nothing to host stays on the free tier. The only thing you give up is a single deployment target.
If you can do this, do this.
Where to go next
- Wisp vs Bare. What you are actually choosing between, and what each one exposes.
- Transports. The three transports and the two confusingly named Bare packages.
- Deployment. Hosting a normal, non-serverless proxy.
- Wiring Scramjet. The manual wiring this page assumes.
Source: docs/guides/serverless.md
Verified against Scramjet 2.0.67-alpha.2, controller 0.0.14, and Ultraviolet undefined on 2026-08-02. If this page and upstream disagree, upstream is right.