Welcome#
Week 7#
Full stack#
what changed this week?#
Jev (TypeSafe AI, 15 September): a general model that returns a typed value rather than text
Claude Opus 5.5 (Anthropic, 22 September): claims Fable 5.1 performance on most tasks, at 40% less than Opus 5
GPT-6 Sol and Luna (OpenAI, 22 September): cheaper tiers of GPT-6 Astra, at half the API price
today’s beats#
where the state lives now there’s a server
journey of a form: browser → server → SQLite and back
secrets, and deploying to Fly
static ends here#
- A2 shipped Monday — crit retros this week
- the course switches to full-stack from c7: your pages now come from a server
- the final project spec lands soon (and will be full-stack)
the code on today’s slides comes from two repos: the static template you’ve shipped from since week 1, and the C7 starter
Astro is the default, not compulsory
the page you’ve been shipping#
<!-- index.html, the static template -->
<main>
<h1>COMP4020 prototype</h1>
<p>Replace this with your prototype…</p>
</main>
<script type="module" src="./main.ts"></script>pnpm build writes it into dist/, and GitHub Pages serves that folder as-is
every visitor gets the same bytes, and main.ts runs in their browser
the full stack#
browser the HTML index.astro renders
│ HTTP
server api/messages.ts, lib/db.ts
│ better-sqlite3
database .data/app.db here, /data/app.db on Fly*static: only the browser runs your code, from files built in CI
full-stack: a Node* server renders each page per request, and reads SQLite* to do it
(“stack” is loose: three places code and data sit, joined by HTTP and a database driver)
demo: what should the guestbook do next, and where would that state live?#
then we leave it running and talk about where state lives
server-side state#
state locations#
week 4: the URL, the DOM and a store, all in one browser tab, plus
localStorage
if it has to survive a reload
from today two more: the server (e.g. Node) process’s memory and the database
each piece of state needs a single source of truth: the copy you treat as correct when two differ
the app on every slide today#
the starter’s guestbook, live at lecture-demos.fly.dev: one text box, a Sign button, a list of messages
<input id="body" name="body" required maxlength="500" />HTML: only name="body" reaches the server; id="body" is for the
<label>
body: the message you typed, under the same name in all four files it
passes through
TypeScript on these slides is server code, except inside a <script>
what survives?#
| state | reload | restart | redeploy |
|---|---|---|---|
| the URL | |||
| the DOM | |||
| a JS store | |||
localStorage | |||
| server memory | |||
| the database |
the guestbook uses two: the DOM and the messages table
what survives?#
| state | reload | restart | redeploy |
|---|---|---|---|
| the URL | kept | ||
| the DOM | gone | ||
| a JS store | gone | ||
localStorage | kept | ||
| server memory | kept | ||
| the database | kept |
a reload rebuilds the tab from scratch; the URL and localStorage stay in the
browser
what survives?#
| state | reload | restart | redeploy |
|---|---|---|---|
| the URL | kept | kept | |
| the DOM | gone | kept | |
| a JS store | gone | kept | |
localStorage | kept | kept | |
| server memory | kept | gone | |
| the database | kept | kept |
a server restart (a crash, an auto-stop) empties its memory; the open tab keeps its DOM
what survives?#
| state | reload | restart | redeploy |
|---|---|---|---|
| the URL | kept | kept | kept |
| the DOM | gone | kept | kept |
| a JS store | gone | kept | kept |
localStorage | kept | kept | kept |
| server memory | kept | gone | gone |
| the database | kept | kept | kept |
only the database survives a redeploy: it’s the source of truth, and the <li>s
are a copy rebuilt on every reload
the agent reads the repo, not the data#
# in the starter, with pnpm dev running
$ curl -si localhost:4321/api/messages \
-H 'origin: http://localhost:4321' -d "body=g'day"
HTTP/1.1 303 See Other
$ sqlite3 .data/app.db 'select id, body from messages'
1|g'day.data/ is gitignored and production’s database is on a Fly volume: the agent
reads neither
“did it save?”: the agent can query the running app and the table (week 5’s agentic manual testing)
what’s different in production?#
a new machine every deploy: only the volume at /data survives
auto-stop: the first request after a quiet spell is held until the machine starts
no dev server: the Node adapter in the Dockerfile runs the app, with no hot reload and no error overlay
Ben uses… “where does this app keep its state?”#
list every place this app keeps state (browser, server memory, database) and say which survives a reload, a restart and a redeploy. Don’t change anything yet
cheap, and checkable: I can reload, restart and redeploy to test each answer
Web#
which box does what?#
forms, in the running app#
<form method="post" action="/api/messages">
<label for="body">Leave a message</label>
<input id="body" name="body" required maxlength="500" />
<button>Sign</button>
</form>method and action: the browser does the POST — no JavaScript here at all
name: the only part of this markup sent to the server (body=g'day), and
required runs only in the browser, so it isn’t validation — anything can post
here
the HTTP request#
POST /api/messages HTTP/1.1
Host: localhost:4321
Origin: http://localhost:4321
Content-Type: application/x-www-form-urlencoded
body=g%27dayeverything the browser sends the server when you press Sign: some
headers,
a blank line, then name=value pairs,
percent-encoded
the handler’s form.get("body") decodes that last line back to g'day
one request, end to end#
a rename on one side#
- <input id="body" name="body" required maxlength="500" />
+ <input id="body" name="message" required maxlength="500" />index.astro is fine. the handler, untouched, still reads form.get("body"),
gets "", and its if (body) guard skips the insert
303
(see other: go GET this URL), then
200
(OK), then an unchanged list: no exception, no row, and no sign the message was
dropped
the rename, on both sides#
<!-- src/pages/index.astro -->
- <input id="body" name="body" required maxlength="500" />
+ <input id="body" name="message" required maxlength="500" />
// src/pages/api/messages.ts
- String(form.get("body") ?? "").trim();
+ String(form.get("message") ?? "").trim();the field name is the contract between the form and the handler, and nothing in TypeScript checks it
check coverage#
✓ accepts a message and redirects back to the page
✓ persists the message: a fresh page load includes it
✓ broadcasts new messages over the SSE streamspec/guestbook.test.ts posts body=… straight to /api/messages, without
loading the form
rename the handler’s field and the last two fail; rename only the form’s and all three pass
finding the form-only rename takes a browser: you, or an agent driving one
routing#
pages/index.astro GET /
pages/readme.astro GET /readme/
pages/api/messages.ts POST /api/messages export const POSTthe file path is the URL; the exported name is the method
nothing registers a route — output: "server" in astro.config.ts is what puts
a Node server behind them instead of building HTML files
(more on server-side routing)
the same request, seen from the browser#
POST /api/messages 303 body=g'day
GET / 200 the list, re-rendered from SQLite
GET /api/events 200 text/event-stream — (pending)two requests for one button press: Post/Redirect/Get, and the 303 is what lets the form work with no JavaScript
the third one never finishes: it’s the live stream that pushes new messages to the other tabs (server-sent events)
server-side secrets#
---
// runs on the server, per request: process.env is safe here
const messages = listMessages();
---
<!-- rendered on the server, but this HTML is sent to the browser -->
<ul id="messages">
{messages.map((message) => <li>{message.body}</li>)}
</ul>
<script>
// bundled and sent to the browser: anyone can read it
const source = new EventSource("/api/events");
</script>your app on Fly, in one picture#
the contract: Dockerfile, fly.toml and the CI workflow are fixed, so
swapping the stack rewrites only the Dockerfile and the check entrypoints
one app per repo: <repo>.fly.dev, one small machine that auto-stops, the
database on a 1GB volume at /data
who deploys: you, by hand (flyctl deploy), while the repo is private; CI
on every push to main once /ship flips it public
topics/hosting-access is the whole story
yes, and…#
how does it get pushed further? weird ideas welcome
C7: an ANU system#
build the full-stack replacement for the ANU system that reliably ruins your week — enrolment, timetabling, room booking, whichever one it is
model the slice that annoys you, wired end to end: the core flow has to survive a reload
live at its *.fly.dev URL by the cutoff
deadline radar#
Monday/Wednesday, week 8: C7 live at its *.fly.dev URL two hours
before your session, with reflections/crit-7.md in the repo
this week: your Fly token, pasted into mise.local.toml, and one deploy by
hand — before the repo is public, CI can’t do it for you
tl;dr#
a server adds two places for state to live, its memory and the database, and the database is the source of truth
the agent reads the repo, not the data: check a change against the running app and the table
deploy to Fly this week: production differs from pnpm dev in ways you only see
after a deploy
ask your agent#
ask until you can explain these back:
- where state lives: every place this app keeps state, and which of it survives a reload, a restart and a redeploy
- routing: which file serves
/api/messages, and what makes it a POST? - the deploy contract: what in this repo would I rewrite to move off Astro?
- secrets: which of my code runs on the server, and which ships to the browser?