Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

NullJS

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(" ") });
  },
});

Install

curl -fsSL https://raw.githubusercontent.com/tothalex/nulljs-public/main/install.sh | sh

Supported 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).

Quickstart

nulljs create my-app
cd my-app
nulljs dev

nulljs 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/hello

Declaring functions

Every 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 routeroute 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).

Server-side-rendered pages

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" }),
});

Single-page apps

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: "*" };

How routing composes

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.

Performance

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.

What you get

  • 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 mcp exposes deploy/logs/invoke as MCP tools; nulljs dev --headless emits NDJSON events for scripts and agents. All read commands take --json.
  • Production: nulljs serve runs the same binary as a server; nulljs host sets it up under systemd. Deploys are signed with a local private key that never leaves your machine.

How it works

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.

License

MIT

About

NullJS — a serverless JavaScript platform in a single binary

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages