A serverless JavaScript platform in a single binary. Write TypeScript functions — HTTP routes, cron jobs, event handlers — and full sites in React, Svelte, or Solid (server-rendered pages and client-rendered SPAs), and NullJS runs them sandboxed, with the request routing, dashboard, and deploy pipeline built in. No containers, no Node processes per function, no separate server to install.
import { defineRoute, json } from "@tothalex/cloud";
export default defineRoute({
name: "echo",
route: "POST /echo",
schema: {
type: "object",
required: ["text"],
properties: { text: { type: "string" }, repeat: { type: "integer", maximum: 10 } },
},
handler: async (request) => {
const { text, repeat = 1 } = request.body; // parsed, validated, and TYPED from the schema
return json({ echo: Array(repeat).fill(text).join(" ") });
},
});curl -fsSL https://raw.githubusercontent.com/tothalex/nulljs-public/main/install.sh | shSupported platforms: macOS (Apple Silicon), Linux x64, Linux arm64. Binaries are on the
releases page; pin a version with
NULLJS_VERSION=vX.Y.Z when running the installer. Later, nulljs update upgrades
in place (nulljs update --check to just look).
nulljs create my-app
cd my-app
nulljs devnulljs dev starts everything in one process: the API + dashboard on :3000, the
function gateway on :3001, a file watcher that deploys on save, and a live terminal UI.
Try the example route:
curl http://my-app.localhost:3001/helloEvery function is one file that default-exports its definition. The directory decides
the trigger type: src/function/api/ for HTTP routes, src/function/cron/ for
schedules, src/function/event/ for event handlers.
HTTP route — route is "METHOD /path" with :params and * catch-alls; a
schema validates the body before your handler runs and types request.body:
import { defineRoute, json } from "@tothalex/cloud";
export default defineRoute({
name: "get-user",
route: "GET /users/:id",
handler: async (request) => json({ id: request.params.id }),
});Every handler also receives a context as its last argument. ctx.waitUntil(promise)
keeps the invocation alive after the response has been sent — for analytics, cache
writes, or event fan-out the caller should not wait for. The work shares the
function's timeout; a rejection is logged against the invocation and never affects
the delivered response:
handler: async (request, ctx) => {
ctx.waitUntil(send("page.viewed", { path: request.path }));
return json({ ok: true });
},Cron — 5- or 6-field expressions; the handler receives only the context:
import { defineCron } from "@tothalex/cloud";
import { send } from "cloud/event";
export default defineCron({
name: "ticker",
cron: "*/10 * * * * *",
handler: async () => {
await send("tick", { at: new Date().toISOString() });
},
});Event — receives payloads sent with cloud/event's send(); declare a schema
and the payload arrives parsed, validated, and typed:
import { defineEvent } from "@tothalex/cloud";
export default defineEvent({
name: "on-tick",
event: "tick",
schema: { type: "object", required: ["at"], properties: { at: { type: "string" } } },
handler: async (payload) => {
console.log("tick at", payload.at);
},
});Shared config across all types: timeout (seconds), size
(small/medium/large/xlarge), and secrets (which secrets to inject as
process.env — list exactly what the function needs).
A file under src/page/ is an SSR page: the platform renders it to HTML per request
and hydrates it in the browser with the same props. Discovery is recursive — a page's
files can sit directly in src/page/ or in their own subdirectory
(src/page/dashboard/dashboard.svelte + dashboard.ts); either way name and route
default from the file stem. props(request) runs server-side with secrets and
cloud/* access; returning a response-shaped value (a redirect(), a notFound())
short-circuits rendering. Pick your framework per page:
React (src/page/home.tsx):
import { defineReactPage } from "@tothalex/cloud";
type Props = { message: string };
export const Page = ({ message }: Props) => <h1>{message}</h1>;
export default defineReactPage<Props>({
name: "home",
route: "/",
props: async (request) => ({ message: `Hello ${request.query_params.name ?? "World"}` }),
});Svelte (src/page/docs.svelte + sibling src/page/docs.ts config — a .svelte
file can't hold the config export; name/route default from the file stem):
<script lang="ts">
let { topic } = $props();
let clicks = $state(0);
</script>
<h1>Docs: {topic}</h1>
<button onclick={() => clicks++}>clicked {clicks}</button>
<style>
h1 { color: rebeccapurple; } /* scoped, ships as a stylesheet automatically */
</style>import { defineSveltePage } from "@tothalex/cloud";
export default defineSveltePage<{ topic: string }>({
props: async (request) => ({ topic: request.query_params.topic ?? "intro" }),
});Solid (src/page/board.tsx — same single-file shape as React; the
defineSolidPage call is what marks the page as Solid, and the page's JSX stays in
this one file):
/** @jsxImportSource solid-js */
import { createSignal } from "solid-js";
import { defineSolidPage } from "@tothalex/cloud";
type Props = { title: string };
export const Page = (props: Props) => {
const [n, setN] = createSignal(0);
return <button onClick={() => setN(n() + 1)}>{props.title}: {n()}</button>;
};
export default defineSolidPage<Props>({
name: "board",
route: "/board",
props: async () => ({ title: "welcome" }),
});The SPA entry is src/index.tsx (React by default, Solid when it imports solid-js)
or src/index.svelte (Svelte). The server serves a static shell plus your bundled
assets; the app mounts client-side. Only route is read from the config — "*" (the
default) makes the SPA the app-wide fallback.
// src/index.tsx — React SPA
export const Page: React.FC = () => <div>Hello, World</div>;
export const config = { name: "index", route: "*" };<!-- src/index.svelte — Svelte SPA (route in an optional sibling src/index.ts) -->
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>hits {count}</button>/** @jsxImportSource solid-js */
// src/index.tsx — Solid SPA (the solid-js import is what selects Solid)
import { createSignal } from "solid-js";
export const Page = () => {
const [n, setN] = createSignal(0);
return <button onClick={() => setN(n() + 1)}>hits {n()}</button>;
};
export const config = { name: "index", route: "*" };One app can serve API routes, SSR pages, and a routed SPA together. Requests match by
specificity, not declaration order: exact path segments beat :params, which beat
the * catch-all.
- API routes and SSR pages own their exact paths (
/,/docs,GET /users/:id). - An SPA at
route: "*"takes everything else — including client-router deep links like/account/settings, which serve the SPA shell so the router can take over. - Mount an SPA under a subtree with
route: "/app/*": it serves/app,/app/, and every path below, while more specific routes still win theirs.
Two rules to remember: the SPA fallback answers GET only (an unmatched POST is still a 404), and an unknown path under an SPA mount returns the shell with status 200 — "not found" under an SPA mount is the client router's job.
Measured end to end — the latency a client sees — on an Apple M4 (10 cores), with 16 concurrent keep-alive connections over 5-second runs.
Functions
| endpoint | p50 | p99 | throughput | CPU per invocation |
|---|---|---|---|---|
| JSON response, no work | 0.67 ms | 2.5 ms | ~20,000 req/s | 0.28 ms |
| 700 B JSON body, schema-validated, typed handler | 0.80 ms | 3.1 ms | ~17,000 req/s | 0.29 ms |
| CPU-bound: sort 100k numbers, stringify 10k objects, SHA-256 of 1 MB | 15 ms (single client) | — | ~290 req/s (all cores busy) | 36 ms |
cloud/cache set + get |
1.0 ms | 6.1 ms | ~12,000 req/s | 0.7 ms |
cloud/got to a 20 ms upstream |
25 ms | 30 ms | 2,500 req/s at 64 connections | 0.6 ms |
Handlers that await I/O scale with concurrency: the outbound row's throughput grows linearly with the number of clients while its latency stays at the upstream's.
Server-rendered pages (react 19.2, svelte 5.56, solid-js 1.9)
| page | React | Svelte | Solid |
|---|---|---|---|
| simple (~20 elements) — render CPU | 0.02 ms | 0.02 ms | 0.02 ms |
| medium (100 cards, ~900 elements) — render CPU | 0.24 ms | 0.12 ms | 0.17 ms |
| heavy (3,200 dynamic cells + deep recursion) — render CPU | 1.9 ms | 0.9 ms | 1.8 ms |
| medium page, p50 latency | 0.80 ms | 0.62 ms | 0.82 ms |
| client assets | ~187 KB | ~35–42 KB | ~10–12 KB |
Rendering is a small part of a page's cost in any of the three; Svelte and Solid ship far smaller client bundles, which is the main reason to prefer them for new pages.
Events and cron
| event delivery latency at 1,000 events/s | 0 ms p50 · 1 ms p99 |
| event handler throughput | ~10,000 events/s on this machine; when handlers fall behind, send() fails with an error rather than dropping events silently |
| cron | every scheduled run fired; a job still running when its next tick comes is skipped, never stacked |
Sustained throughput on this machine, with every invocation recorded and queryable, is about 13,000 invocations/s.
- Functions: HTTP routes, cron schedules, and app-internal events, written in TypeScript with full inference — request bodies and event payloads are typed from their JSON Schemas, and validation runs before your handler does.
- Runtime modules:
cloud/postgres(SQL),cloud/cache(KV),cloud/got(HTTP),cloud/secret(secrets + JWT + password hashing),cloud/event,cloud/uuid— no npm dependencies to bundle for the common cases. - Sites: SSR pages and SPAs in React, Svelte, or Solid, deployed alongside your functions with live reload in development — no separate frontend toolchain to install or configure.
- Observability:
nulljs logs --follow,nulljs invocations(with error messages on failures), a web dashboard, and a machine-readable API (/api/openapi.json). - Testing: handlers are unit-testable in milliseconds with the in-memory
cloud/*doubles from@tothalex/cloud/testing. - AI-ready: projects scaffold with agent docs and skills;
nulljs mcpexposes deploy/logs/invoke as MCP tools;nulljs dev --headlessemits NDJSON events for scripts and agents. All read commands take--json. - Production:
nulljs serveruns the same binary as a server;nulljs hostsets it up under systemd. Deploys are signed with a local private key that never leaves your machine.
The nulljs binary is the whole platform: it serves {app}.{domain} requests to
your functions and pages, takes deploys, records telemetry, and hosts the dashboard.
Functions run isolated from each other with per-function CPU, memory and timeout
limits. Deploys bundle your TypeScript without a Node toolchain and go over the same
authenticated API the CLI, dashboard and MCP tools all use.
MIT