mates-fullstack
v1.0.11
Published
Full-stack Node.js framework built on the Mates SPA framework. RPC server functions, REST, WebSockets.
Maintainers
Readme
mates-fullstack
TypeScript-first full-stack framework for Mates apps. It adds direct RPC-style server functions, raw HTTP escape hatches, WebSockets, static assets, and production builds without requiring a compiler in production.
No JSX. No virtual DOM. No Express/Hono/Fastify. Runtime server is pure Node.js (node:http). Bundling and TypeScript transforms use esbuild.
Install
npm install mates-fullstack matesAdd scripts to your app:
{
"scripts": {
"dev": "mates-fullstack dev",
"build": "mates-fullstack build",
"start": "mates-fullstack start"
}
}Project structure
mates-fullstack is convention-based; no config file is required.
my-app/
client/
App.ts # root SPA component and client router
client.ts # browser entry point; calls renderX(App, ...)
server/
api/ # RPC functions callable from client imports
helpers/ # server-only utilities; never bundled for browser
main.ts # optional middleware registration
socket/ # optional WebSocket handlers
shared/ # code/types used by both sides
public/ # inside client/ — static files copied to dist/public in production
package.jsonMinimal app
client/App.ts
import { html } from "mates";
export default () => {
return () => html`<h1>Hello from mates-fullstack</h1>`;
};client/client.ts
import { renderX } from "mates";
import App from "./App";
renderX(App, document.getElementById("app")!);server/api/todos.ts
import { ValidationError } from "mates-fullstack";
export async function listTodos(_payload: {}) {
return [{ id: 1, title: "Write docs" }];
}
export async function addTodo(payload: { title: string }) {
if (!payload.title.trim()) {
throw new ValidationError("Invalid todo", {
fields: { title: "Required" },
});
}
return { id: Date.now(), title: payload.title };
}Client code calls server functions directly
import { asyncAction, html } from "mates";
import { listTodos } from "../server/api/todos";
const TodoPage = () => {
const load = asyncAction(() => listTodos({}));
load();
return () => html`
<pre>${JSON.stringify(load.data(), null, 2)}</pre>
`;
};During development and production builds, imports from server/api/** are converted into browser-safe RPC stubs. Imports from any other server/** path are blocked from client code.
Commands
npm run dev # native ESM dev server, no browser bundle
npm run build # production client bundle + compiled server runtime
npm run start # start production server from dist/Development mode
Development intentionally serves source modules instead of a client bundle:
- app source modules:
/_src/** - package modules:
/_pkg/** - framework helpers:
/_mates/** - import map injected into the HTML shell
- chokidar watches
client/(includingclient/public/),server/,shared/,.env*, and config files - reload generation is owned by the parent dev process and delivered through SSE
This makes stack traces point at source-like module URLs and keeps rebuilds fast.
Authentication
Email/Password + Social Login
mates-fullstack provides a complete auth system out of the box. Access tokens are short-lived (15 min default), stored in httpOnly cookies. Refresh tokens (30 days) live in a separate httpOnly cookie and rotate automatically, with JTI replay detection.
// server/main.ts
import { useAuth, auth, useArctic } from "mates-fullstack";
useAuth({ secret: "your-production-jwt-secret-min-8-chars" });
useArctic({
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
onSuccess: async (profile, ctx) => {
const user = await db.users.upsert({ ... });
await auth.login(ctx, { userId: user.id, email: user.email });
},
},
});Routes /auth/google and /auth/google/callback are registered automatically. onSuccess gives you full control — call auth.login() to issue tokens, or skip it to just link accounts.
10 built-in OAuth providers: Google, GitHub, Discord, Microsoft, Twitter, LinkedIn, Facebook, Apple, Spotify, GitLab. Custom providers via arcticProvider({ handler, ... }).
Cross-Domain SSO
For apps on different domains sharing one auth server. The auth server signs a 30-second code JWT with a shared secret — each app verifies it locally, no round-trip needed.
// auth.com/server/main.ts
import { useSsoProvider } from "mates-fullstack";
useSsoProvider({
secret: process.env.SSO_SECRET!,
login: "/login",
allowedOrigins: ["https://app1.com", "https://app2.com"],
});
// app1.com/server/main.ts
import { useSsoClient } from "mates-fullstack";
useSsoClient({
authUrl: "https://auth.com",
secret: process.env.SSO_SECRET!,
protected: ["/dashboard", "/settings"],
});Each app issues its own httpOnly session cookies. Works with social login — put useArctic() on the auth server, useSsoClient() on every app.
Middleware
All middleware registers globally in server/main.ts and runs in registration order.
Recommended server/main.ts:
import {
useAuth, requestLogger, useCSP, useSecurityHeaders,
useCors,
} from "mates-fullstack";
useAuth({ secret: "your-production-jwt-secret-min-8-chars" });
requestLogger({ skip: [/^\/health$/, /^\/_mates\//] });
// Safe headers (nosniff, frame, referrer, …) are on by default.
useCSP({ defaultSrc: "'self'", scriptSrc: "'self'" }); // CSP is opt-in
useSecurityHeaders({ hsts: true }); // optional extras
useCors({ origins: "https://app.example.com" });Auth is on by default once AUTH_JWT_SECRET or JWT_SECRET is set in .env. There is no auto-generated secret — you must supply one. Call useAuth({ … }) only for options:
// .env
// JWT_SECRET=your-jwt-secret-min-32-chars
useAuth({ throwErrorIfTokensExpired: false }); // optional overrides
// useAuth(false); // disable auth entirely
// Load roles once per request onto ctx.roles (after auth):
// import { useRoles, getAuth } from "mates-fullstack";
// useRoles(async () => {
// const session = getAuth();
// return session ? db.rolesFor(session.userId!) : [];
// });Protect server functions with higher-order wrappers:
import { withAuth, withRoles, getAuth } from "mates-fullstack";
export const me = withAuth(async () => getAuth());
export const wipeAll = withRoles(async () => {
// …
}, ["admin"]);Or call the primitives directly:
import { checkAuth, checkRoles, isAuthorised, getRoles } from "mates-fullstack";
export async function adminOnly() {
checkAuth();
checkRoles(["admin"]);
if (isAuthorised(["editor"])) { /* … */ }
return getRoles();
}Request Logger
requestLogger({ skip: [/^\/health$/, /^\/assets\//] });Logs every request (method, path, IP) on arrival and every RPC response (status, ms) on completion. Stamps x-request-id on every response. Structured logging — NDJSON in production, pretty-printed in dev.
Security Headers
Safe baseline headers are on by default (except CSP). Override or disable with useSecurityHeaders() / useResponseHeaders(). Opt into CSP with useCSP().
Default on: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-XSS-Protection: 0, plus a few legacy safe headers.
Opt-in: CSP (useCSP), HSTS, COOP, COEP, CORP, Permissions-Policy, Origin-Agent-Cluster.
useCSP({ defaultSrc: "'self'", scriptSrc: "'self'" });
useSecurityHeaders({
frameOptions: "DENY",
hsts: true, // skipped in development
coop: "same-origin-allow-popups",
});
useSecurityHeaders(false); // disable all security headersPer-header helpers (useXContentTypeOptions(), useStrictTransportSecurity(), …) still work and update the same shared policy.
CORS
useCors({ origins: "https://app.example.com", credentials: true });
useCors({ origins: (origin) => origin.endsWith(".example.com") });Handles preflight OPTIONS automatically (returns 204). Reflects origin when credentials: true as required by the CORS spec. Sets Vary: Origin.
Rate Limiting (opt-in)
Not enabled by default — call rateLimit() in server/main.ts when you need it:
rateLimit({ max: 100, window: "1m" }); // all routes
rateLimit({ max: 10, window: "15m", pathPrefix: "/auth" }); // auth only
rateLimit({ max: 5, window: "1h", key: (c) => String(c.auth?.userId) }); // per-userSliding-window counter, in-memory. Returns 429 with Retry-After and X-RateLimit-* headers. Auto-evicts expired entries. Custom key function for per-user or per-endpoint limiting.
Raw HTTP hooks
For cases where an external service dictates the HTTP contract (webhooks, custom OAuth flows):
import { onHttpRequest } from "mates-fullstack";
onHttpRequest(async (req, ctx) => {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/stripe-webhook") {
const body = await req.text();
await handleWebhook(body, ctx.reqHeaders["stripe-signature"]);
return new Response("ok");
}
});Request Limits / Resource Protection
Built-in limits prevent CPU and memory exhaustion. All have safe defaults — override only to tune for your app.
// server/main.ts — at the top, before middleware
import { configureRpcRunner } from "mates-fullstack/internal";
configureRpcRunner({
maxJsonBytes: 5 * 1024 * 1024, // default 1MB
maxJsonDepth: 30, // default 50
maxArrayLength: 5000, // default 10_000
maxStringLength: 50_000, // default 100_000
maxUploadBytes: 50 * 1024 * 1024, // default 10MB
maxMultipartParts: 100, // default 200
});Or set server-level limits when starting:
await startServer({
maxConnections: 2000, // default 1000
staticStreamTimeout: 120_000, // default 60s
maxSseClients: 1000, // default 500
});All defaults are active without any configuration call. See DOCS.md for full details.
Public entrypoints
| Import | Use for |
|---|---|
| mates-fullstack | stable app API: errors, redirects, middleware registration, ServerCtx, validation/sanitize/upload helpers |
| mates-fullstack/client | browser hydration and RPC helpers |
| mates-fullstack/browser | browser-safe error/redirect/stream/download helpers |
| mates-fullstack/server | server-safe helpers and middleware runners |
| mates-fullstack/internal | unstable build/runtime/scanner internals for tooling |
License
MIT
