Skip to content

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 default wired to the runtime. Your source stays at apps/<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 root apps/<appId>/ on the file-system.
  • fetch(Request) → Response. The real Worker contract: your handler routes on request.url (now a fully-qualified absolute URL — use new URL(request.url).pathname), reads await request.json(), and returns Response.json(...) or new 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.

  1. 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).
  2. 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.ts and the compiled bundle at _code_/worker.js, and runs your migrations once — no build step anywhere.
  3. Upload a site (optional) with Upload site folder to publish a static frontend alongside the Worker.
  4. 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.
  5. 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 login authorizes 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.ts

A 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 name

env.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 ​

BindingWhat it is
env.DBThe 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.queueSend to any queue in the app's project/env by slug: send(slug, body, opts?) / sendBatch(slug, msgs) (details, queues guide).
env.S3R2-shaped client over an external S3 bucket, when the S3_* secrets are set.
env.CACHEPer-node in-memory TTL cache.
env.SECRETS.NAMEAdmin-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.headersA 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-in fetch) and the env bindings (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 on request.url (a fully-qualified absolute URL).

See the Worker SDK reference for the full API.

TinyS3 is AGPLv3 licensed. Cloudflare and Cloudflare Workers are trademarks of Cloudflare, Inc. Amazon S3 and AWS are trademarks of Amazon.com, Inc. or its affiliates. MinIO is a trademark of MinIO, Inc. They are named only to describe compatibility; totis.ai is not affiliated with, endorsed by, or sponsored by any of them.