Tutorial: your first web app — tcm-ts

Tutorial: your first web app

You'll build a shared notes page: type a note, press Enter, and everyone sees it. It has a database, server code and browser code, and no routes, fetch calls or API layer.

1. Make a codebase with the server library

With tcm installed:

mkdir notes && cd notes
tcm init
tcm lib add tcm server --at notes.lib.server

The last line mounts the server library tcm ships (tables, sessions, jobs), read-only, at notes.lib.server. (tcm init app makes a small working app instead.)

2. Write the app

Put this in scratch.ts:

// tcm: namespace notes
import { table } from "#codebase/notes/lib/server";

export type Note = { text: string; at: Date };

/** Every note anyone has written. */
export const notes = table<Note>("notes");

export async function addNote(text: string): Promise<void> {
  await notes.insert({ text, at: new Date() });
}

export async function listNotes(): Promise<string[]> {
  return (await notes.find({}, { orderBy: "at", desc: true })).map((r) => r.value.text);
}

export async function main() {
  const input = document.createElement("input");
  const list = document.createElement("ul");
  const show = async () => {
    list.replaceChildren(...(await listNotes()).map((t) => Object.assign(document.createElement("li"), { textContent: t })));
  };
  input.onkeydown = async (e) => {
    if (e.key !== "Enter") return;
    await addNote(input.value);
    input.value = "";
    await show();
  };
  document.body.append(input, list);
  await show();
}
  • table<Note>("notes") is a typed table: rows are stored on the server and checked against Note when written. Dates come back as dates.
  • addNote and listNotes use the table; main uses document. Nothing says which runs where.
  • main calls listNotes() and addNote() like local functions.

3. Add it

$ tcm add
  + new       notes.Note type
  + new       notes.notes : Table<Note>
  + new       notes.addNote : (text: string) => Promise<void>
  + new       notes.listNotes : () => Promise<string[]>
  + new       notes.main : () => Promise<void>

4. See where things run

$ tcm view notes.addNote
// notes.addNote #2w6iz4qg : (text: string) => Promise<void>
// runs on the server: it uses notes.notes, which uses notes.lib.server.table, which uses notes.lib.server.Table, which uses #tcm/host (database); browser code calls it by hash

$ tcm view notes.main
// notes.main #o2wxk6e6 : () => Promise<void>
// runs in the browser: it uses document

tcm infers placement from what code uses:

  • the database, Node or the server library: the server;
  • the DOM: the browser;
  • anything else: wherever it's called from.

A browser call to a server function becomes a request to that exact function, by hash. Server functions must be async, since a network call can't return at once; tcm tells you if one isn't.

5. Run it

$ tcm dev notes.main
tcm dev notes.main #o2wxk6e6
  url      http://127.0.0.1:5173/
  browser  1 definition(s)
  server   2 function(s) the browser calls by hash: notes.addNote #2w6iz4qg, notes.listNotes #o3tcbfex

Open the URL, type a note and press Enter. A second tab shows the same notes: they're in the table, on the server.

The server line lists the app's only endpoints. Nothing else on the server can be called from outside, and each endpoint checks its arguments against its parameter types before it runs.

In VS Code, the Apps view's ▶ button next to notes.main does the same and opens the page beside your code.

6. Change it

Leave tcm dev running. Write a new listNotes in scratch.ts that shows the newest note in capitals:

// tcm: namespace notes
import { notes } from "#codebase/notes";

export async function listNotes(): Promise<string[]> {
  return (await notes.find({}, { orderBy: "at", desc: true })).map((r, i) => (i === 0 ? r.value.text.toUpperCase() : r.value.text));
}
$ tcm update
  ~ update    notes.listNotes : () => Promise<string[]>  (was #o3tcbfex)
  ~ updated   notes.main (dependent)

main calls listNotes, so it was updated too. The page reloads with the change: the dev server follows the codebase, with nothing to restart or rebuild.

7. Build it

$ tcm build notes.main
built notes.main #o2wxk6e6 into .tcm/apps/o2wxk6e6ghnnvykwiutwzxn6fz33byqvl2jj672b6z5o4wwa57lq
  browser  1 definition(s)
  server   2 function(s) the browser calls by hash: notes.addNote #2w6iz4qg, notes.listNotes #o3tcbfex

A production build is immutable and named by the app's hash.

8. Serve it

tcm start notes.main --port 8080

tcm start serves whatever notes.main names and keeps watching the name. After tcm update it builds the new version and switches to it; after tcm reset it switches back. Deploying and rolling back are both moving a name. Open pages keep using the version they loaded until they reload, so an update never breaks someone mid-session.

Next

Built from c482a14e