TypeScript Workers
Write a Cloudflare-Workers-shaped handler in TypeScript and TotisWorkers runs it on an embedded JavaScript engine (QuickJS-ng) inside the function sandbox. The handler's env.DB is the app's own columnar SQL engine, living under the app's root on the file-system — no connection string, no external database, no separate service.
ts
import { Router, json, type Env, type ExportedHandler } from "./tinys3-worker";
const router = new Router();
router.get("/by-category", async (_req, env) => {
const r = await env.DB.query(
"SELECT category, SUM(amount) AS revenue FROM sales GROUP BY category",
);
return json(r.rows);
});
export default {
fetch: (request, env, ctx) => router.handle(request, env, ctx),
} satisfies ExportedHandler<Env>;Where this fits
You ship TypeScript or JavaScript (the supported app languages; both are bundled server-side); the QuickJS-ng runtime is shared across every app. The full SDK contract is the Worker SDK reference; the platform's layers and data flow are in the Architecture overview.
How it works
deploy raw worker.ts ──▶ server bundles (SWC wasm: strip types, inline SDK)
stores source apps/<appId>/_code_/worker.ts + compiled apps/<appId>/_code_/worker.js
<label>.<apps base>/<path> (or /apps/<label>/<path>)
──▶ shared QuickJS-ng runtime runs export default.fetch(request, env, ctx)
──▶ env.DB.query(sql) ──host bridge──▶ the app's SQL engine (apps/<appId>/_data_/db)
──▶ Response ──▶ HTTP response- Deploy plain scripts. You ship raw TypeScript; the server bundles it (no local/CLI build) with an embedded SWC-based wasm compiler — types stripped, the SDK inlined,
export defaultwired to the runtime. Your source stays atapps/<appId>/_code_/worker.ts, next to the compiled_code_/worker.js. - One app, one root. Code (
_code_/worker.ts+_code_/worker.js), data (_data_/db), and the static site all live under the app's rootapps/<appId>/on the file-system. fetch(Request) → Response. The real Worker contract: your handler routes onrequest.url(now a fully-qualified absolute URL — usenew URL(request.url).pathname), readsawait request.json(), and returnsResponse.json(...)ornew Response(body, { status }).- Confined. Fuel, wall-clock, host-call-time, and memory limits apply exactly as for the WASM functions; the guest has no ambient IO.
Deploy a new app from the console
The fastest way — no CLI, no local build. The platform console (served at hosts.platform) is where you create the org/project/environment/app, deploy the Worker, manage secrets and bindings, attach custom domains, and upload a static site. It authorizes on your signed-in session, so there's nothing to paste.
- Create the app. In the console, create (or pick) an organisation, a project and an environment, then create an app under it (e.g.
analytics). - Deploy the Worker. On the app's Deploy page, paste or upload the raw TypeScript and deploy. The console sends it to the server, which bundles it server-side, stores the source at
apps/<appId>/_code_/worker.tsand the compiled bundle at_code_/worker.js, and runs your migrations once — no build step anywhere. - Upload a site (optional) with Upload site folder to publish a static frontend alongside the Worker.
- Add secrets on the Secrets tab (
env.SECRETS.NAME, encrypted at rest under the instance KEK — values are never shown back). Secrets can be set at org, project or app scope. - Attach custom domains on the app's Domains page (up to 20 per app; exact or
*.example.com).
The rest of this page covers the same thing from the CLI (for scripts and CI).
Prerequisites (CLI)
- A running TotisWorkers server (
totis-workers --config config.yaml) and a console account.totis loginauthorizes via the device flow — no admin key. - No build toolchain. The server bundles your TypeScript; you just send the source.
1. Write the Worker
Grab the SDK types (tinys3-worker.ts + globals.d.ts) from examples/js-worker for editor autocomplete and write your handler. A Worker is an export default { fetch } — route however you like; the SDK's Router is optional sugar. Import values only from ./tinys3-worker (the SDK) and types from ./globals.
2. Deploy the source
Create the project, environment and app in the console, then send the raw worker.ts with the CLI — the server strips types, inlines the SDK, and stores both the source (_code_/worker.ts) and the compiled bundle (_code_/worker.js). Only a public app answers anonymous HTTP; set it on the app in the console (the code stays private regardless).
bash
totis login --console https://console.example.com
totis deploy --project demo --env production --app analytics worker.tsA syntax or type error is reported in the deploy response (bundle failed: …) and nothing is published. (A pre-bundled .js file deploys the same way.)
3. Call it
The app answers at <label>.<apps base>/<path> (or /apps/<label>/<path> without an apps base), where <label> is <app>-<project>-<org> plus -<env> outside production; your worker routes the rest:
bash
curl -XPOST https://analytics-demo-acme.apps.example.com/seed
# {"seeded":true,"rows":2000}
curl https://analytics-demo-acme.apps.example.com/by-category
# [{"category":"Audio","lines":500,"revenue":26000}, ...]
curl https://analytics-demo-acme.apps.example.com/category/Audio
# {"category":"Audio","revenue":26000}Schema: migrations, not a setup call
Don't make callers hit a /setup route to create tables. Declare migrations on your default export — the server runs them once at deploy time (tracked in a _migrations table), so the app provisions its own database:
ts
export default {
migrations: [
"CREATE TABLE IF NOT EXISTS sales (id BIGINT, category VARCHAR, amount DOUBLE) " +
"ENGINE = MergeTree ORDER BY (category, id)",
],
fetch: (request, env, ctx) => router.handle(request, env, ctx),
} satisfies ExportedHandler<Env>;Migrations are append-only: add new statements to the end and redeploy — only the ones not yet applied run. The deploy response logs how many were applied.
The app boundary is enforced, not assumed
Each app is confined to its own root apps/<appId>/ on the file-system, resolved through the host registry. An app's code (_code_/) and database (_data_/) are never served over HTTP — only the static site is public, and only when the app is marked public. There is no S3 surface and no bucket policy to get wrong: the host serves the worker and the site, nothing else, and one app cannot read another's code or tables.
Querying the database
env.DB is a Cloudflare D1-shaped client over the app's own engine — prepare().bind().all(), first(), run(), batch(), exec() (the legacy pg-shaped query() still works). Both table engines are available (row-store for OLTP, ENGINE = MergeTree for columnar analytics; see the D1 API):
ts
await env.DB.exec(
"CREATE TABLE IF NOT EXISTS sales (id BIGINT, category VARCHAR, amount DOUBLE) " +
"ENGINE = MergeTree ORDER BY (category, id)",
);
const row = await env.DB.prepare("SELECT SUM(amount) AS revenue FROM sales").first();
row.revenue; // rows are objects keyed by column nameenv.DB.batch([...]) runs its statements atomically (BEGIN … COMMIT under the write lock). Known gaps: meta.last_row_id is always 0, binary values are stored as base64 text, and dump() is unsupported — see the reference.
Auth without reinventing it
The SDK ships an HS256 JWT helper and an authed() route wrapper, so you don't hand-roll login sessions. Sign a token on login and protect routes by wrapping their handlers — the verified claims arrive as the last argument:
ts
import { jwt, authed, json } from "./tinys3-worker";
router.post("/login", async (req, env) => {
const { user, pass } = await req.json();
// …check credentials…
return json({ token: await jwt.sign({ sub: user }, env.SECRETS.JWT_SECRET) });
});
router.get("/me", authed((_req, _env, _ctx, _params, claims) => json({ you: claims.sub })));Set JWT_SECRET once on the app's Secrets tab in the console. Full API in the Worker SDK reference.
Bindings
| Binding | What it is |
|---|---|
env.DB | The app's SQL engine, D1-shaped (prepare/bind/first/all/run/batch/exec); ? placeholders bind params (injection-safe). |
env.db("slug") | The same D1 client for another database in the app's project/env, by slug (details). |
env.queue | Send to any queue in the app's project/env by slug: send(slug, body, opts?) / sendBatch(slug, msgs) (details, queues guide). |
env.S3 | R2-shaped client over an external S3 bucket, when the S3_* secrets are set. |
env.CACHE | Per-node in-memory TTL cache. |
env.SECRETS.NAME | Admin-set secrets, encrypted at rest, injected at invoke (also on env.NAME). |
env.<NAME> (email) | A bound org email connection: send({ from, to, subject, text/html, … }) → { messageId }. Owner/admin creates the binding. |
request.headers | A Headers object (legacy lower-cased property access still works). |
See the Worker SDK reference for the full contract.
Limitations today
- The engine is QuickJS-ng, not V8. It covers a large slice of modern ECMAScript plus a Cloudflare-compatible web-platform surface (
Request/Response/Headers,URL,TextEncoder/TextDecoder,Blob/File/FormData, the stream classes,AbortController,structuredClone,crypto/crypto.subtle,console, opt-infetch) and theenvbindings (DB/db(slug)/queue/S3/CACHE/SECRETS). There is no other ambient IO, and several Workers features are not supported yet (Durable Objects, outbound WebSockets, true streaming). Queues are supported (see the queues guide). - One entry per app:
apps/<appId>/_code_/worker.js. The worker routes internally onrequest.url(a fully-qualified absolute URL).
See the Worker SDK reference for the full API.