Documentation
A web server for Bun, Node.js and Functions/Workers with the basics built-in:
import server, { status } from "@server/next";
import { Books, BookSchema } from "./db.js"; // Your own DB/ORM/etc
export default server()
.get("/books", () => Books.list())
.post("/books", { body: BookSchema }, async ctx => {
const book = await Books.create(ctx.body);
return status(201).send(book);
});It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, streaming, testing, error handling, websockets, etc.
Getting started
First install it:
npm install @server/next
bun add @server/nextThat's everything: 63 login providers and file storage come with it, so auth: 'cookie:github' works with no database and uploads takes a folder path. See Dependencies for S3 and the rest.
Now you can create your first simple server:
// index.js
import server from "@server/next";
export default server()
.get("/", () => "Hello world")
.post("/", (ctx) => {
console.log(ctx.body);
return 201;
});Then run node . or bun . and open your browser on http://localhost:3000/ to see the message.
Routes take plain (ctx) => value functions like these, receiving the context and returning the response; chain several and they become middleware, each running until one responds.
There are some important bits that you might want to update:
SECRETS: an environment variable with a long, unique random secret. It will be used to sign and/or encrypt things as needed. Put it in .env (+gitignore it) or in your provider's secret manager. It is a comma-separated list, so that a key can be rotated without signing everyone out.
auth: logging in. The one-liner (auth: 'cookie:github') needs no database at all; add the onLogin/getUser callbacks when you want your own user records. There is a tutorial per setup.
uploads: if you want to accept user-uploaded files you need to tell the server where to put them. Pass a path for storing files locally, or a Bucket instance for cloud storage. See File handling for the full guide.
Dependencies
Server JS ships with two libraries included, so there is nothing extra to install: antarctic powers the 63 auth providers, and bucket powers uploads and public. The second is re-exported:
import server, { bucket } from "@server/next";The simple form needs no import: uploads takes a folder path, and auth takes no storage at all. For key-value storage of your own, polystore pairs well (npm install polystore), turning a Map, Redis or DynamoDB into one interface; see the Persisting data tutorial.
Validation is the same story: any Standard Schema library works as a route schema, so zod, valibot or arktype are your dependencies, not ours (valibot users also want @valibot/to-json-schema for the OpenAPI spec).
File handling
Storing files is explicit: with no uploads option, a request that carries a file is refused rather than losing it quietly. Configure uploads to accept them, or uploads: false to ignore file fields on purpose.
Files are streamed to their destination as they arrive, so even large uploads aren't buffered in memory. Size limits are checked chunk by chunk as the bytes flow, and an oversized file is aborted and removed mid-write.
Local storage
Pass a directory path and files are saved there automatically:
export default server({ uploads: './uploads' })
.post('/profile', (ctx) => {
console.log(ctx.body.avatar);
// {
// name: 'photo.jpg', // original filename
// path: 'xKj3mN9pQr2s4tUv.jpg', // key within the bucket
// type: 'image/jpeg', // MIME type
// size: 45231, // bytes
// }
});Store path to find the file again later. It's the file's key inside the bucket, not a location on disk, so it reads the same whether you store files locally or in the cloud. Pass it back to the bucket to read the file or serve it:
import server, { bucket } from "@server/next";
const uploads = bucket.FS('./uploads');
export default server({ uploads })
.get('/photo/:key', (ctx) => uploads.file(ctx.url.params.key));Cloud storage
uploads and public take any object with the Bucket interface, so Bun's own s3 client or an adapter you write works as well as a bucket instance. The uploaded-file object shape is identical whichever you use:
import server, { bucket } from "@server/next";
const uploads = bucket.S3('my-bucket', {
id: process.env.S3_ID,
secret: process.env.S3_SECRET,
});
export default server({ uploads })
.post('/profile', (ctx) => {
console.log(ctx.body.avatar);
// { name, path, type, size }
});To write a bucket of your own, file(key) returns a handle with write(content, { type }), stream(), bytes() and exists(), where content is a string, Buffer or ReadableStream. Three more are optional:
create(content, { type })on the bucket, which names the file itself and resolves to it. Without it the key is picked for you and written throughfile(key).write().remove()ordelete()on the handle, called for a file that failsminFileSizeor is cut off mid-upload. With neither, those files stay where they landed.info()andslice(start, end)on the handle, used when serving throughpublicforETagvalidators and range requests. Without them assets are still served, whole and unvalidated.
Per-route destinations
The uploads option also works per route, so avatars, videos and documents can go to different folders or buckets with different limits. A route's value replaces the global wholesale (limits included), and false skips file fields for that route:
export default server({ uploads: './uploads' })
.post('/avatar', { uploads: { bucket: './avatars', maxFileSize: '5mb' } },
(ctx) => ctx.body.avatar)
.post('/videos', { uploads: videos }, // an S3 bucket just for these
(ctx) => ctx.body.file)
.post('/survey', { uploads: false }, // no files accepted here
(ctx) => ctx.body);Multiple files
Repeat a field name to collect multiple values into an array on ctx.body (HTML's name="tags[]" works too; the [] is stripped). This applies to files as well, so a repeated file field gives you an array of UploadedFiles.
Validation
To add size or type constraints, pass uploads an object with the destination bucket plus the limits, instead of a bare path:
export default server({
uploads: {
bucket: './uploads', // a path or a Bucket
maxFileSize: '5mb',
fileType: ['image/jpeg', 'image/png', '.jpg', '.png'],
},
})
.post('/profile', (ctx) => {
// { name, path, type, size }
console.log(ctx.body.avatar);
});If a file fails validation, the request throws before any handler runs.
To refuse a whole request rather than a single file, use validate. It runs before the body is read, with ctx.user already resolved, so an upload nobody is allowed to make never reaches the bucket. Only signed-in users may upload:
export default server({
auth: 'cookie:github',
uploads: { bucket: './uploads', validate: (ctx) => Boolean(ctx.user) },
})
.post('/profile', (ctx) => ctx.body.avatar);A .use() middleware is too late for this: middleware run after the body has been parsed, so the file is already stored by the time one of them can check ctx.user. Return false for a plain 403, or throw for an error of your own. It says whether a request may upload, not where its files land; that is Per-user folders.
Object-form options:
| Option | Type | Description |
|---|---|---|
bucket | path | Bucket | Where to store the files (required) |
maxFileSize | number | string | Largest single file. Default '10mb' |
maxTotalSize | number | string | Largest total across one request. Default '100mb' |
maxFiles | number | Most files in one request. Default 100 |
minFileSize | number | string | Smallest single file, same format |
fileType | string[] | Allowed extensions (.jpg) and/or MIME types (image/jpeg), OR logic |
validate | (ctx) => unknown | Runs before the body is read; return false to refuse the request with a 403 |
The two size limits apply however uploads was configured, so uploads: './uploads' is bounded too, and they are enforced while the file streams: an oversized upload is a 413 with nothing left in the bucket.
Stored files are named after their type, never after what the client called them: a random key plus that type's extension (8Kq2….png). The type comes from the bytes when the format has a signature, and from the declared Content-Type when it doesn't (CSV, SVG, JSON, plain text). The client's filename stays available as ctx.body.<field>.name. Use fileType to narrow what a declared type may be: it is checked against the same type the file is stored as, so neither a mislabelled PNG nor a .csv name wrapped around text/html gets through.
Per-user folders
The bucket is fixed when the server starts, so a route cannot pick where its files go, and validate only decides whether the request may upload at all. To give each user their own folder, move the file once it has landed. moveTo() takes a full key, so it can cross folders; rename() only changes the filename within the one it is already in:
import server, { bucket } from "@server/next";
const uploads = bucket.FS('./uploads');
export default server({
auth: 'cookie:github',
uploads: { bucket: uploads, validate: (ctx) => Boolean(ctx.user) },
})
.post('/profile', async (ctx) => {
const { path } = ctx.body.avatar;
// 'u42/8Kq2....png', keeping the name the upload gave it
const file = await uploads.file(path).moveTo(`${ctx.user.id}/${path}`);
return { path: file.path };
});The returned file carries the new key, and ctx.body.avatar.path is stale from that point on. Storing that key is what lets you find the file again; serving it back is a route of your own.
Serving stored files
public serves a folder as open static assets. To serve a file from a bucket behind your own logic (an auth check, an ownership check, a signed link), return it from a normal route instead. Nothing is exposed until your handler says so:
import server, { bucket } from "@server/next";
const uploads = bucket.FS('./uploads');
export default server({ uploads, auth: 'cookie:github' })
.get('/files/:id', async (ctx) => {
if (!ctx.user) return 401;
const stored = uploads.file(ctx.url.params.id);
if (!(await isOwner(ctx.user, stored))) return 403; // your check
return stored; // streamed back, typed, 404 if missing
});Returning the bucket file handle streams it with the file's own Content-Type and a 404 when it doesn't exist. Three shapes work, pick per need:
// 1. The handle: streamed (no buffering), type from the name, 404 if missing
.get('/avatar', () => uploads.file('avatars/me.jpg'))
// 2. Through file(): same, but chainable (add download(), headers(), a status)
.get('/avatar', () => file(uploads.file('avatars/me.jpg')))
// 3. Raw bytes: buffered in memory; set the type yourself (bytes carry no name)
.get('/avatar', async () =>
type('jpg').send(await uploads.file('avatars/me.jpg').bytes()))Prefer 1 or 2 (streaming) for large files; use 3 only when you need the bytes in hand.
Signed temporary URLs. Streaming a private file routes its bytes through your server. If your storage provider issues short-lived signed URLs, redirect straight to the object after your auth check so the transfer offloads to the provider:
.get('/files/:id', async (ctx) => {
if (!ctx.user) return 401;
const url = await uploads.file(ctx.url.params.id).signedUrl({ expires: '5m' });
return redirect(url);
});Streaming, raw, and large uploads
The default auto mode handles forms and files for you. For the cases it doesn't, set the parser option per route:
- A raw, single-file body. Posting a file as the whole request body (e.g.
Content-Type: image/png, no form) is stored as one file inautomode;ctx.bodyis its{ name, path, type, size }reference, just like a form field.
- Very large files, or a per-request destination. Use
parser: 'stream'to receive the request'sReadableStreamasctx.bodyand pipe it to storage yourself, after your own checks, without buffering.uploads.folder(prefix)scopes a sub-path:
import server, { bucket } from "@server/next";
const uploads = bucket.S3('my-bucket', {
id: process.env.S3_ID,
secret: process.env.S3_SECRET,
});
export default server({ uploads })
.post('/videos/:id', { parser: 'stream' },
requireUser, async (ctx) => {
await uploads
.folder(ctx.url.params.id)
.file('video.mp4')
.write(ctx.body);
return 201;
});- Webhooks or exact bytes. Use
parser: 'raw'to get the unparsedBuffer(for example, to verify a signature).
WebSockets
Define WebSocket handlers with .socket(event, fn), where event is 'open', 'message', or 'close'. Inside a handler, ctx.socket is the current connection, ctx.sockets is every open connection (for broadcasting), and on a 'message' event ctx.body is the data the client sent.
import server, { file } from "@server/next";
export default server()
.get('/', () => file('./index.html'))
.socket('open', (ctx) => {
ctx.socket.send('welcome');
})
.socket('message', (ctx) => {
// echo back to the sender
ctx.socket.send(`You said: ${ctx.body}`);
// ...or broadcast to everyone
for (const socket of ctx.sockets) {
socket.send('someone said something');
}
})
.socket('close', () => {
// a client disconnected
});Connect from the browser with the standard WebSocket API:
const ws = new WebSocket(`ws://${location.host}`);
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log(e.data);The upgrade is handled for you; you don't open a separate port or socket path. .socket() works the same on Node and Bun. Edge runtimes (Cloudflare and Netlify) don't support long-lived socket connections.
Authenticated sockets
If auth is configured, the connecting user is resolved during the WebSocket handshake and exposed as ctx.user in every socket handler, the same object you get on HTTP routes (undefined when nobody is signed in). A browser sends its session cookie automatically on the upgrade, so the cookie strategy just works; there's nothing extra to pass from the client. As with HTTP, this only loads the user, it does not reject the connection, so guard inside the handler and close() unauthenticated sockets yourself:
export default server({ auth: 'cookie:github' })
.socket('open', (ctx) => {
if (!ctx.user) return ctx.socket.close();
ctx.socket.send(`welcome ${ctx.user.name}`);
});A browser WebSocket can't set headers, so the cookie strategy authenticates browser sockets; non-browser clients can send an Authorization: Bearer header on the upgrade for the token / jwt strategies. A missing or expired credential connects as anonymous (ctx.user undefined); a present-but-invalid one is rejected at the handshake with 401.
Proxying requests
fetch() returns a web Response, and returning one from a route forwards it as-is: status, headers, and a streamed body (never buffered into memory). That's the whole mechanism behind a backend-for-frontend, an API gateway, or hiding an upstream credential from the browser:
export default server()
.get('/gh/*', (ctx) =>
fetch(`https://api.github.com${ctx.url.pathname.replace('/gh', '')}`, {
headers: { authorization: `Bearer ${process.env.GH_TOKEN}` },
}),
);To transform the response instead of forwarding it verbatim, read it and return something new:
.get('/data', async () => {
const res = await fetch('https://upstream.example/data');
if (!res.ok) return res.status; // forward the upstream error status
const data = await res.json();
return { ...data, cachedAt: new Date().toISOString() };
});Templates
Server JS has no built-in view engine, and doesn't need one: any template engine works by sending its rendered output with type('html'), and none of them is a dependency of the core. Compile your templates once and call them from your routes. A returned string is always text/plain, so the type is what marks it as a page.
// EJS
import server, { type } from "@server/next";
import ejs from "ejs";
export default server()
.get("/users/:id", (ctx) =>
type("html").send(ejs.renderFile("./views/user.ejs", { id: ctx.url.params.id })),
);// Pug: compileFile caches the compiled template
import server, { type } from "@server/next";
import pug from "pug";
const home = pug.compileFile("./views/home.pug");
export default server().get("/", () => type("html").send(home({ name: "World" })));// Handlebars: compile once from disk
import server, { type } from "@server/next";
import Handlebars from "handlebars";
import { readFile } from "node:fs/promises";
const tpl = Handlebars.compile(await readFile("./views/home.hbs", "utf8"));
export default server().get("/", () => type("html").send(tpl({ name: "World" })));type("html") takes a string or a promise of one, so an async renderer needs no await.
JSX
JSX is an amazing template language and so Server JS supports it when using Bun. Configure it in your tsconfig.json, which sets it up for both Bun (the runtime) and your editor (types):
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@server/next"
}
}Or, if you have no tsconfig.json, the same two settings in bunfig.toml:
jsx = "react-jsx"
jsxImportSource = "@server/next"One or the other, not both; tsconfig.json is the one to reach for, since it also types JSX in your editor. Both lines are needed either way: jsx turns on JSX and the automatic runtime, and jsxImportSource points it at Server JS instead of React. Keep them directly in the file, since Bun doesn't read JSX settings through extends. Then name your files .jsx (or .tsx) and you are ready to go!
import server from "@server/next";
export default server()
.get('/', () => <Home />)
.get('/:page', ctx => <Page id={ctx.url.params.page} />);The main difference from normal JSX is that you include the whole document (the <html>, <body>, etc. tags) since whatever you return is sent as the HTML. The trade-offs:
- We will send the html fragments unmodified, so
() => <div>Hello</div>will render"<div>Hello</div>". - Exception: if you define
<html>...</html>as the top level, we will automatically inject<!DOCTYPE html>, since it's not possible to inject that with JSX. - You also need to define the top level tags and html structure such as
<html>,<head>,<body>, etc. We recommend putting those into a template and reusing it, but that's up to your preferences. - This is a great match for HTMX!
- You can use fragments as usual with
<></>(but not with<Fragment>). - Since you are using JSX, normal interpolation is safe from XSS since any special characters are encoded as their html entities.
- You can set up raw HTML, e.g. to avoid having inline JS scripts escaped, like this:
<script dangerouslySetInnerHTML={{ __html: "alert('hello world');" }}></script>.
Some examples:
export default server()
.get('/', () => (
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My first app</title>
</head>
<body>
<h1>My first app</h1>
<form>
Your name:
<input name="firstname" />
<br />
<button>Send</button>
</form>
<script src="https://cdn.jsdelivr.net/gh/franciscop/server-next@master/handle-form.js"></script>
</body>
</html>
));Note
You cannot render JSX to a plain string. It is only turned into HTML as the response is sent, so its escaping can never be bypassed by concatenating the result into other markup.
Styles
A <style> block is rendered as written. Add minify to strip its comments and whitespace:
<style minify>{`
/* a reset */
body { margin: 0; }
`}</style>
// <style>body{margin:0}</style>Warning
The contents of
<style>and<script>are sent raw, since encoding them would break the CSS or JS. Never interpolate raw user input into one, since a</style>or a</script>in the value ends it and the rest runs as HTML.
HTMX
There's no explicit HTMX integration, but two approaches work well. First, the vanilla version:
import server, { file, type } from "@server/next";
export default server()
.get('/', () => file('index.html'))
.post('/action', async (ctx) => {
// do sth
return type('html').send('<div>Success!</div>');
});In here, we assume you have a template in index.html and are loading HTMX from there. Then when an action occurs, you send the HTML fragment back with type('html'), since a plain string is sent as text.
Warning
If you interpolate strings like that, you might be subject to XSS attacks!
.post('/action', async (ctx) => {
// DO NOT DO THIS
return type('html').send(`<div>Success! ${ctx.url.query.name}</div>`);
// DO NOT DO THIS
});For that reason we recommend that you set up JSX with Server JS and then instead reply like this:
.post('/action', async (ctx) => {
// This is safe since html entities will be encoded:
return <div>Success! {ctx.url.query.name}</div>;
});Since JSX will treat that interpolation as a text interpolation and not a html interpolation, html entities will be escaped as expected and presented as plain text.
Platforms
Server JS is designed to run the same code unmodified across all supported runtimes and platforms. No adapter imports, no platform-specific entrypoints.
// This file works on Node.js, Bun, Cloudflare
// Workers, and Netlify Functions
import server from "@server/next";
export default server()
.get('/', () => 'Hello world');The export is the app itself, and every runtime reads the fetch() on it, the Fetchable interface that Bun, Deno and Cloudflare all take as a server entry point:
- Node.js: run the file with
node .; served over the built-in HTTP server. - Bun: run it with
bun .; faster, and unlocks JSX and native S3 buckets. - Cloudflare Workers: the default export is a standard fetch handler.
- Netlify Functions: the same file, deployed as a function.
Netlify Edge Functions are the one place this does not run: they require the default export to be a plain function, and they have no top-level await, which this library's storage layer needs. Use a normal Netlify Function instead.
Node.js
The default export default server() also starts an HTTP server and listens on the configured port (default 3000). Run with:
node index.jsNode.js 24+ is required.
Bun
Run with:
bun index.jsNo extra setup needed. Bun also unlocks JSX templates and native S3 bucket support.
Cloudflare Workers
The default export is a standard fetch handler, which is what Cloudflare Workers expect. Deploy as usual with wrangler.
import server from "@server/next";
export default server()
.get('/', () => 'Hello from the edge');Netlify Functions
Put the file in netlify/functions/ and declare its path, as any function does. Nothing else changes:
import server from "@server/next";
export default server()
.get('/hello', () => 'Hello from Netlify');
export const config = { path: '/hello' };Edge Functions are not supported: they take only a plain function as the default export, and their runtime has no top-level await.
Environment variables
Regardless of platform, these environment variables are read automatically if set:
| Variable | Option | Default |
|---|---|---|
PORT | port | 3000 |
SECRETS | secrets | random |
PUBLIC | public | null |
CORS | cors | null |
AUTH | auth | null |
See Options › Environment variables for the full list.
Migration from 1.x
Server JS 2.x is a ground-up rewrite on top of web standards (Request/Response), with first-class support for Bun, Node, and other platforms. The concepts from 1.x carry over, but the syntax changed.
⚠️ Defaults that changed silently. Check these first. These don't throw an error, so they're easy to miss on a migrated app:
- No CSRF token middleware (1.x bundled Csurf). SameSite cookies and the OAuth
statecheck still cover the common vector, seesecurity.publicis off: 1.x served./publicautomatically; 2.x serves nothing until you setpublic.- Uploads must be configured: 1.x wrote to a temp folder by default; 2.x refuses a request carrying a file until you set
uploads, oruploads: falseto ignore file fields.- Option precedence flipped: an explicit
server({...})option now wins over its env var, the reverse of 1.x. Overriding a hardcoded default via env in production no longer takes effect.
Runtime and dependencies
1.x ran on Express (pulling in Express, socket.io, formidable, and more). 2.x is built directly on the web-standard Request/Response and runs unchanged on Bun, Node, and edge runtimes.
- No Express underneath,
server.utils.modern()and raw Express middleware are gone; middleware are plain(ctx) => …functions (see Middleware). - No
req/resobjects: the request comes fully parsed onctx(headers,cookies,body,ip) and the response is the handler's return value, so code reaching intoctx.req/ctx.res(req.pipe,res.writeHead) becomes a plain return. bucketreplaces the built-in file adapters foruploads/public, shipped and re-exported. For key-value storage, polystore is a good companion (npm install polystore); the framework itself stores nothing.
Creating the server
1.x took an options object and an array of routes; 2.x chains routes onto the default export and exports it:
// 1.x
const server = require('server');
const { get, post } = server.router;
server({ port: 3000 }, [
get('/', (ctx) => 'Hello world'),
post('/', (ctx) => `Received ${ctx.data}`),
]);
// 2.x
import server from "@server/next";
export default server({ port: 3000 })
.get('/', (ctx) => 'Hello world')
.post('/', (ctx) => `Received ${ctx.body}`);Routing
Router methods are chained onto the server (or a standalone router()) instead of imported from server.router:
| 1.x | 2.x |
|---|---|
get, post, put, head, options | same, chained: .get('/x', fn) |
del(...) | .delete(...) |
patch | .patch(...) |
socket(...) | .socket(event, fn), see WebSockets |
error('name', fn) | onError option |
sub('/prefix', ...) | router() + .use() |
Error handling is now the single onError option instead of per-name routes; branch on error.code inside it for the namespaced cases. Routing is first-match, one route per request, Express regex paths and /prefix/*-then-specific layering don't apply, so use .use() for shared guards. See Router.
Replies
Return a plain value (string, object, number, Response) instead of a server.reply helper; the helpers still exist but are now imported from the package:
| 1.x reply | 2.x |
|---|---|
send, json, redirect, file, download, status, type | same, imported from @server/next |
header(...) | headers(...) |
cookie(...) | cookies(...) |
render(view, locals) | removed, import a template engine (Templates) |
jsonp(...) | removed |
| , | cache(...) (new) |
Context (ctx)
Everything about the URL now lives under ctx.url (a URL instance), and the parsed body is always ctx.body:
| 1.x | 2.x |
|---|---|
ctx.data | ctx.body |
ctx.url (a string) | ctx.url (a URL, string operations like ctx.url.includes('?') break) |
ctx.params | ctx.url.params |
ctx.query | ctx.url.query |
ctx.path | ctx.url.pathname |
ctx.files | ctx.body (files sit alongside text fields) |
ctx.secure, ctx.xhr | removed (derive from ctx.url.protocol / ctx.headers) |
ctx.req, ctx.res | removed, everything is parsed onto ctx and the response is the return value |
ctx.method, ctx.headers, ctx.cookies, ctx.ip, ctx.options | same |
New in 2.x: ctx.user, ctx.platform, ctx.signal, ctx.socket / ctx.sockets, ctx.app.
Options
| 1.x option | 2.x |
|---|---|
port | same |
favicon | removed, drop a favicon.ico into public, or serve it as a route |
secret | now secrets, a list: the first signs, all verify. Settable as an option (1.x was env-only) |
public | off by default (1.x served ./public) |
security | headers only, no CSRF middleware |
log | on/off only, no levels, ctx.log, or report |
session | removed: there is no per-device data bag. Logins are whatever onLogin stores; keep your own state in a cookie or your database |
parse | parser, auto/raw/stream; the size cap is security.maxBodySize (1mb default) |
views + engine | removed, import a template engine (Templates) |
socket (socket.io) | removed, native .socket() |
env | removed, use ctx.platform.production |
| , | uploads, cors, auth, openapi, onError, onResponse, cache (new) |
Environment variables: LOG → LOG_LEVEL; VIEWS / ENGINE are gone (templating removed). SECRET → SECRETS (comma-separated). PORT, PUBLIC, CORS, AUTH still work (FAVICON is gone with the option).
WebSockets
socket.io is replaced by native WebSockets, no socket library on either end, the same on Node and Bun:
| 1.x (socket.io) | 2.x (native) |
|---|---|
socket('chat', fn) (any event name) | .socket('open' | 'message' | 'close', fn) |
ctx.io, ctx.socket | ctx.socket (this one), ctx.sockets (all) |
payload arg / ctx.data | ctx.body |
ctx.io.emit(...) | ctx.socket.send(...), or loop ctx.sockets |
| socket.io client | browser WebSocket |
socket.io's rooms, namespaces, named events, reconnection, and transport fallbacks have no equivalent, if you need it please build it on top of what we offer. Edge runtimes (Cloudflare, Netlify) don't support sockets. See WebSockets.