Building a full-stack app

In this tutorial you build Notes, a small full-stack app in one HTML file: a page, a worker that serves a JSON API on : paths, per-user storage in KV, and sign-in. You test every step in the RTCode.io playground, then deploy the file to RTEdge.net and restrict who can view it. It takes about 20 minutes.

Prerequisites:

  • An RTCode.io account. You sign in with GitHub, Google, Microsoft or Apple. You need the account to deploy, and in the playground it is the user that your worker sees.
  • Basic HTML and JavaScript.
  • A name for your site: a subdomain of rt.ht with at least four characters. The tutorial writes it as name, which is served at https://name.rt.ht/.

Step 1: Answer requests with a worker

Open an empty playground at rtcode.io/x/ and replace its content with this page. The <script type=worker edge> holds the backend. The : in front of notes addresses it: the worker sees the path /notes. The edge attribute marks the script for deployment, and request.rt.edge tells the code where it runs.

html
<h1>Notes</h1>
<pre id=out></pre>
<script type=module>
  out.textContent = JSON.stringify(await fetch(':notes').then(r => r.json()), null, 2)
</script>
<script type=worker edge>
  export default {
    fetch: request => {
      if (new URL(request.url).pathname !== '/notes') return new Response('Not found', { status: 404 })
      return Response.json({ edge: !!request.rt.edge, notes: [] })
    }
  }
</script>

You should see this JSON in the output:

plain
{
  "edge": false,
  "notes": []
}

Tip: In the playground, GET / always returns your page, and the : prefix reaches the worker for any path, in the playground and on RTEdge.net. Keep fetch as the only property of export default, and never leave a worker script empty. See Using workers.

Step 2: Save notes per user

Now the worker stores notes in KV. request.rt.user is the signed-in user: in the playground it is the account you are signed in to RTCode.io with. KV.open({ user, route }) opens the notes of that user in this route. The worker checks the user first, because a null user would open the scope that all visitors share. Call KV.open() without await.

A POST adds the note in its text parameter, and a GET lists the notes. The page adds a note with the current time every time it loads, so you can watch the storage grow.

html
<h1>Notes</h1>
<pre id=log></pre>
<script type=module>
  const api = (method, query = '') => fetch(':notes' + query, { method }).then(r => r.json())
  await api('POST', '?text=' + encodeURIComponent(new Date().toLocaleTimeString()))
  log.textContent = JSON.stringify(await api('GET'), null, 2)
</script>
<script type=worker edge>
  export default {
    fetch: async (request, { KV }) => {
      const { pathname, searchParams } = new URL(request.url)
      if (pathname !== '/notes') return new Response('Not found', { status: 404 })
      const { user, route } = request.rt
      const edge = !!request.rt.edge
      if (!user) return Response.json({ edge, notes: [] }, { status: request.method === 'GET' ? 200 : 401 })
      const kv = KV.open({ user, route })
      let notes = await kv.get('notes', 'json') ?? []
      if (request.method === 'POST') {
        const text = searchParams.get('text')?.trim().slice(0, 200)
        if (text) { notes = [...notes, text]; await kv.put('notes', JSON.stringify(notes)) }
      }
      return Response.json({ edge, notes })
    }
  }
</script>

You should see a list with one time stamp. Press F5 to reload the output: the list gains one more entry each time, because the worker stored the earlier ones. If the list stays empty, you are not signed in to RTCode.io: a signed-out user gets 401 for a POST, and nothing is stored.

Note: Playground data lives in your browser and is separate from the data of a deployed site. A value plus its metadata may be up to 64 KiB in the playground and up to 10 MiB on RTEdge.net. The worker keeps the list as one JSON string, which is enough for a personal list. KV has no transactions, so two tabs that save at the same moment can overwrite each other. In the playground, route is the output hostname, so another playground that uses the key notes in the same scope shares your data. See KV in the playground.

Step 3: Build the page and add sign-in

Replace the file with the full app. The page shows a form and the list, and a Clear button sends a DELETE, which the worker now handles with kv.delete().

On a page, await fetch('::su').then(r => r.json()) returns the signed-in user: an email address, or null. Both the playground and RTEdge.net answer it. The buttons use three more URL suffixes that a deployed site answers: ::si signs in, ::sa signs in as another account, and ::so signs out. Appending a suffix to location.pathname keeps the current path, query and hash, so each action returns to the same page.

The playground emulates only ::su, so the buttons stay hidden there: the page asks the worker whether it runs on the edge, and shows them only when it does. The form hides itself for visitors who are not signed in.

html
<h1>Notes</h1>
<p>User: <output id=who></output></p>
<p id=auth hidden>
  <button id=si>Sign in</button>
  <button id=sa>Switch account</button>
  <button id=so>Sign out</button>
</p>
<form id=add hidden>
  <input id=note placeholder="Write a note" required>
  <button>Add</button>
  <button id=wipe type=button>Clear</button>
</form>
<ul id=notes></ul>
<script type=module>
  const api = (method, query = '') => fetch(':notes' + query, { method }).then(r => r.json())
  const draw = ({ notes: items }) => notes.replaceChildren(...items.map(text => Object.assign(document.createElement('li'), { textContent: text })))
  const user = await fetch('::su').then(r => r.json())   // an email, or null
  const first = await api('GET')
  who.value = user ?? 'signed out'
  auth.hidden = !first.edge                               // ::si, ::sa and ::so work on RTEdge.net only
  si.hidden = !!user
  sa.hidden = so.hidden = !user
  add.hidden = !user
  for (const b of auth.querySelectorAll('button')) b.onclick = () => location.pathname += `::${b.id}`
  add.onsubmit = async e => {
    e.preventDefault()
    draw(await api('POST', '?text=' + encodeURIComponent(note.value)))
    note.value = ''
  }
  wipe.onclick = async () => draw(await api('DELETE'))
  draw(first)
</script>
<script type=worker edge>
  export default {
    fetch: async (request, { KV }) => {
      const { pathname, searchParams } = new URL(request.url)
      if (pathname !== '/notes') return new Response('Not found', { status: 404 })
      const { user, route } = request.rt
      const edge = !!request.rt.edge
      if (!user) return Response.json({ edge, notes: [] }, { status: request.method === 'GET' ? 200 : 401 })
      const kv = KV.open({ user, route })
      let notes = await kv.get('notes', 'json') ?? []
      if (request.method === 'POST') {
        const text = searchParams.get('text')?.trim().slice(0, 200)
        if (text) { notes = [...notes, text]; await kv.put('notes', JSON.stringify(notes)) }
      } else if (request.method === 'DELETE') {
        notes = []
        await kv.delete('notes')
      }
      return Response.json({ edge, notes })
    }
  }
</script>

You should see User: followed by your email address, a form, and the notes that you saved in Step 2. Add a note and it appears in the list. Clear removes all of your notes. No sign-in buttons appear yet: they wait for the deployed site.

Step 4: Try other users

The editor supplies request.rt through the sync.requestRT hook, so you can test the app as someone else, or signed out, without leaving the playground. Run one of these lines in io.eval.js or in the browser's DevTools console on the playground tab, and then press F5 to reload the output:

js
sync.requestRT = ({ rt }) => ({ ...rt, user: 'ada@example.com' })
sync.requestRT = ({ rt }) => ({ ...rt, user: null })
sync.requestRT = ({ rt }) => ({ ...rt, user: window.user?.email ?? null })  // the default

You should see User: ada@example.com with a list of her own, which starts empty, after the first line. After the second line, User: signed out and no form. The worker answers 401 to a POST from a signed-out user, so nothing is stored. The third line restores your account, as does reloading the editor.

Note: The hook receives { rt, url, method } and must return a plain object. If it fails or takes longer than 5 seconds, the worker gets user: null. See Simulating users in the playground.

Step 5: Deploy to RTEdge.net

  1. Make sure you are signed in to RTCode.io. Without an account, deploying shows Sign in to deploy. and opens the user menu.
  2. Press Ctrl+S / ⌘+S, or choose File ▸ Edge ▸ Deploy. With no route yet, a toast says No routes to deploy. and the Code dialog opens on Routes with a Deploy button.
  3. Type your route, such as name, and press Deploy. A name without a dot is a subdomain of rt.ht, so name is served at https://name.rt.ht/. It needs at least four characters and cannot be reserved or taken by someone else. A name that is not yours yet is claimed for you on the first deploy, with the toast You have reserved this domain!
  4. When the deploy succeeds, a toast links to your site, and a second toast links to the worker at name.rt.ht/:. If the worker cannot be uploaded, the platform's message appears as a separate toast.
  5. Open the site with Ctrl+E / ⌘+E, or choose File ▸ Edge ▸ Open.

You can change the route later with Alt+R / ⌥+R or File ▸ Edge ▸ Route. An agent that works in your playground folder can deploy with await edge.save.limit({ message }); see edge.save.limit().

You should see your page at the first address below, with User: signed out, a Sign in button and no form. The API answers at the second address:

plain
https://name.rt.ht/
https://name.rt.ht/:notes

The second URL returns {"edge":true,"notes":[]}. The link in the second toast, name.rt.ht/:, shows Not found, because the worker only serves /notes. If your page is not there yet, reload after a moment: deployments are replicated, and other locations can briefly show the previous version.

Note: On RTEdge.net, the worker sees edge: true, a real route (name) and the visitor's session. Every <script type=worker> is blanked out of the served page, and the source that ? opens is private, because the deployment has an edge worker without io:guest: only you and your editors can open it. See The edge worker runtime and Deploying.

Step 6: Use the live site

Sessions are per host: you sign in to name.rt.ht on its own, even when the editor already knows you.

  1. Click Sign in. The browser goes to RTCode.io, where you choose GitHub, Google, Microsoft or Apple, and then comes back to your page. If you are already signed in to RTCode.io in this browser, it comes back at once. GitHub needs an email address on your profile.
  2. The page shows User: with your address and the form. Add a note and reload: it stays, because the worker stored it in the KV of this route.
  3. Open https://name.rt.ht/:notes in the address bar. It is the same API that the page calls, and it answers with your notes, because it uses your session.
  4. Click Switch account to see the provider picker again, or Sign out to end the session of this host. You stay signed in to RTCode.io.

You should see that the notes you saved in the playground are not here: playground data and deployed data are separate stores. Your session is valid for 24 hours, after which ::su returns null until you sign in again. Sessions are signed with ECDSA P-384 and checked at the edge, and multi-factor sign-in and passkeys work through your identity provider.

Step 7: Restrict who can view it

An edge:user attribute on any element decides who may view the deployed page. Add one to a <meta> at the top of your file, and deploy again with Ctrl+S / ⌘+S. The attribute is removed from the served page, and the playground does not enforce it: access rules apply on RTEdge.net only, so there is nothing to see in the playground.

html
<meta edge:user=*>

The value of edge:user is a space-separated list of who may view the page. The owner and the editors of the route may always view it. Write the values in lower case.

ValueWho may view
*Any signed-in account. Visitors who are not signed in are never matched.
example.comAccounts whose email domain is exactly example.com. Subdomains such as mail.example.com do not match.
you@example.comThat exact account.
example.com you@example.orgAny of the listed domains and accounts.

You should see the following after you deploy again:

Note: A page with edge:user has no social preview, because crawlers get the 401 page. The source of the deployment stays private to you and your editors. See Restricting access.

Summary

Next steps

Using workers

The handler forms, : URLs, and what differs between the playground and RTEdge.net.

Storing data in KV

The six scopes, limits and consistency of key–value storage.

Adding sign-in

More ways to sign visitors in, including popups.

Restricting access

View and source permits with edge:user, io:user and io:guest.

Keeping secrets out of deployments

What the served page and the source reveal.

Deploying

Routes, domains, versions and what a deploy produces.

The edge worker runtime

How a deployed worker runs, and which requests reach it.

Request.rt

Every field of the request metadata.

See also