Updato
- 6 Devlogs
- 5 Total hours
An updater and CDN for websites and single-file builds of apps!
An updater and CDN for websites and single-file builds of apps!
updato is published. it’s on npm. it has a version number and everything.
switched the build from vite to tsdown. vite was fine for dev but tsdown gives you
dual esm/cjs output with proper .mjs/.cjs extensions and matching declaration files
out of the box. the package exports map has real conditional exports now — separate
import and require entries with their own types. it’s the kind of package.json
that doesn’t make people file issues.
scoped it to @nellowtcs/updato because apparently updato was too close to upath
for npm’s similarity check. it is not similar. at all. but scoped names are better
practice anyway so whatever, npm wins this round.
the release workflow strips private, scripts, and devDependencies from
package.json before publish. originally did that with an inline node script, swapped
it to a one-liner jq pipe because why run a whole runtime to delete three keys.
released 1.0.0. immediately released 1.0.1 because of the name conflict. so it goes.
the important thing is npm i @nellowtcs/updato works and the types resolve and the
imports are correct. tested it, it’s real.
all of it:
one item on the todo list: custom domain for the worker. that’s a “maybe” and it’s
been a “maybe” for a while. it can stay a maybe.
this started as a dumb idea about pushing updates without a server. it is now a
real system with five directories and a package on npm and documentation with a
sidebar. i don’t know when it became a real project but it did and now it’s done.
first release. final (nah, probably not) devlog.
this is the “make it real” commit. everything that was on the todo list under “before
you can call this a real project” happened in one sitting.
built a full docs site using docmd. lives in Docs/ with its own package. seven pages
covering everything: home/landing page, quickstart guide, core concepts explainer, and
individual reference pages for the client library, github action, worker, hot-swap
internals, manifest format, and download metrics.
the client library page has full config tables, event references, api docs for every
public method, and the UpdateNotification component. the action page documents every
input and shows what the cdn branch looks like after a deploy. the worker page covers
both endpoints, version comparison logic, caching, and rate limiting.
the hot-swap page explains how each asset type gets replaced: scripts via dom
replacement with data-hot-file tagging for subsequent updates, css via
CSSStyleSheet.replaceSync and adoptedStyleSheets (which is CSP-safe), and images
via base64 data uris.
used the “sky” theme with system dark mode toggle. search, sitemap plugin, code
highlighting, all enabled.
new workflow at .github/workflows/static.yml. builds the client library, builds the
demo, builds the docs, then assembles them into a single pages-root/ directory. demo
goes under /demo/, docs go at the root. deploys to gh-pages with
peaceiris/actions-gh-pages.
.github/workflows/release-npm.yml. triggers on github release publish or manual
dispatch with a dry-run option. builds the client library, strips private, scripts,
and devDependencies from package.json, copies in the readme and license, then
publishes with --provenance. also removed "private": true from Build/package.json
so npm will actually accept it.
daily cron job plus on-push when lockfiles change. runs npm audit --audit-level=high
across every package (root, Build, Worker, Action, Demo, Docs). all the audit steps
have || true so the workflow reports issues without failing the build, which felt
like the right balance for now.
split the test job out of ci.yml into its own .github/workflows/test.yml. same
matrix across node 20 and 22, runs jest in all three packages. keeps the ci workflow
focused on lint and build.
checked off docs. what’s left: update the readme with better examples, maybe attach
the worker to a custom domain, and publish to npm. the npm workflow is ready, just
need to actually pull the trigger.
got in a flow state and forgot to devlog. here’s the catch-up.
the whole system was hardcoded to a branch called cdn. now you can pass a branch parameter everywhere. the worker accepts it as a query param (defaults to cdn), the client sends it with check requests, and download urls use the right branch. manifest cache keys in kv include the branch name so different environments don’t collide.
the idea is you could have cdn-staging and cdn-production in the same repo and point different app builds at different ones. added branch validation in the worker too since someone’s going to try passing something weird eventually.
also built an UpdateNotification class in update-ui.ts. it’s a drop-in banner component that handles the whole flow. you pick top or bottom, set it dismissable or not, and call notification.show(result) when there’s an update. clicking the button downloads and applies. if the download fails it resets. styling is all inline so there’s no css file to import.
updated both examples to use it. the example-app.html went from a hand-rolled banner with window.__pendingUpdate to just notification.show(result). much cleaner.
the action now scans html files for <script type="module"> tags and includes a modules array in the manifest. the client passes this through to hot-swap so module scripts get replaced with type="module" set on the new element. the regex for finding them is a little fragile but it works for standard vite/webpack output.
also switched the action bundler from @vercel/ncc to esbuild. ncc was fine but esbuild is faster and i was already using it elsewhere.
added an opt-in DownloadMetrics class that records timing data per file download (start, end, duration, size, url) in localStorage. pass it into the config and it starts tracking. you can export as json or clear it. useful for debugging slow updates but not the kind of thing you want on by default.
wrote jest tests for all three packages.
action tests mock @actions/core, @actions/github, and @actions/exec. covers input parsing, repo context, package.json version reading, recursive file listing, module script detection, and clone url construction. had to export a bunch of internal functions to make them testable.
client tests use jsdom with mocked fetch. covers hot-swap (regular scripts, module scripts, stylesheets, images, unknown types), the metrics class, and the main updato class (version tracking, cache management, update checking, downloads, event callbacks).
worker tests cover validation helpers, the full fetch handler (missing params, invalid params, successful checks, unknown endpoints, OPTIONS, method rejection), manifest validation with every field failure case, and the rate limiter with fake timers.
ci workflow at .github/workflows/ci.yml. runs on push and pr to main, matrix across node 20 and 22. lints, runs all tests, builds all three packages.
checked off: multi-branch, notification ui, module types, metrics, tests, ci. what’s left is docs, a demo, and release automation. the core functionality is done.
changed name: Updato to name: Updato Action in action.yml. that’s it. that’s the
fix. spent hours filing a support ticket, got a polite “no” from github, and the
solution was adding one word. sometimes software development is humbling.
ripped the hot-swap logic out of updato.ts and gave it a proper home in
hot-swap.ts. while i was in there i figured i might as well make it actually good.
it now supports images too. png, jpg, gif, svg, webp, ico, bmp, avif. it finds
matching <img>, <source>, and favicon <link> elements by basename and swaps their
src/href to a base64 data uri. so if your update is just a new logo, no reload needed.
also switched the element matching from substring (src*="${file}") to basename
comparison, which is way less fragile. before, a file called app.js could’ve
accidentally matched webapp.js. now it actually compares the filename part properly.
the css swap switched from btoa() to encodeURIComponent() which handles unicode
better. small thing but the kind of thing that would’ve been a weird bug later.
everything returns a typed HotSwapResult now instead of just a boolean. so the caller
knows what got swapped, what type it was, and which file it was for.
added a real validator in Worker/src/manifest.ts. before, the worker was just casting
response.json() as Manifest and hoping for the best with a couple of if-checks. now
there’s a validateManifest() function that checks every field properly and returns
typed errors. each error tells you which field failed and why.
this runs both when fetching fresh manifests from github and when reading cached ones
from kv. if a cached manifest is corrupted it just falls through and re-fetches.
new file: Worker/src/rate-limit.ts. uses cloudflare kv to track requests per ip.
60 requests per 60 second window, which is generous enough for normal usage but should
keep anyone from hammering it. uses CF-Connecting-IP for the real client ip, falls
back to X-Forwarded-For.
returns a proper 429 with a Retry-After header when you hit the limit. the kv entries
auto-expire so they don’t pile up forever.
I half wanted to make the 429 return silly messages, but I gotta be serious apparently sometimes :/
had to add a kv namespace binding in wrangler.toml for this. the worker was fully
stateless before, now it has a little bit of state. felt like a big architectural
decision for what’s basically a counter but whatever, it’s the right call.
while i was adding kv for rate limiting i figured i might as well use it for manifest
caching too. previously every /check request was hitting github’s raw content cdn
directly. now it caches the manifest in kv with a configurable ttl (defaults to 300
seconds). means fewer github api calls and faster responses for repeat checks.
copied the eslint + prettier setup from Build into Worker. also added projectService
and tsconfigRootDir to both eslint configs so typescript-eslint can actually find the
tsconfigs. this was one of those “why is eslint yelling at me” rabbit holes.
checked off rate limiting and manifest validation. removed the already-completed items
that were cluttering the list. it’s getting shorter.
two things happened here and they’re very different vibes.
so previously applyUpdate() was just… reload the page. which works, obviously, but
it’s not exactly elegant. if you’re updating a css file there’s no reason to nuke the
entire page state.
now it actually tries to be smart about it. when you call applyUpdate(), it looks at
each file’s extension and attempts to swap it in-place:
.js files, it finds the matching <script> tag by src and replaces it with a.css files, it finds the matching <link> stylesheet and swaps it with aif none of the files can be hot-swapped (like if there’s no matching dom element, or
it’s a file type it doesn’t know about), it falls back to the classic
window.location.reload(). so nothing breaks, it just tries the nice path first.
the base64 css thing is a little cursed honestly. but it works and avoids needing to
host the file anywhere. the browser just reads it inline. i might revisit this later
if it causes issues with large stylesheets but for now it’s fine.
added eslint and prettier to the Build package because the code was starting to look
inconsistent and it was bothering me. set up a flat config with typescript-eslint,
browser globals, the usual. named the prettier script make-pretty because i thought
it was funny. and idk, it’s how i do it ig
prettier immediately reformatted a bunch of stuff in updato.ts. removed some
unnecessary quote wrapping on "Accept", collapsed a few multi-line expressions that
didn’t need to be multi-line, cleaned up trailing commas. nothing behavioral, just
formatting.
also committed the lockfile. should’ve done that earlier but here we are.
checked off hot-swap support and the linting setup. removed the version bump script
todo since that was already done in the last round. the list is getting shorter. slowly.
so updato exists now. figured i should write down what actually happened before i forget why i made half these decisions.
updato is a decentralized update system for browser apps. the whole idea is: you push code, your app knows there’s a new version, and it updates itself. no app store, no server infrastructure you have to maintain. just github and a cloudflare worker.
it’s three pieces:
cdn branch on your repo. every version gets its own folder, plus there’s always a latest/ copy. it writes a manifest.json with metadata about what’s current.there’s commit mode and version mode. commit mode means every push is a new version and the “version” is just the commit sha. version mode uses semver from your package.json. commit mode is nice for apps where you just want continuous deployment. version mode is for when you want to be more intentional about it.
honestly i went back and forth on whether to support both. decided it was easy enough to keep and the action just reads a flag.
lives in Action/. typescript, compiled with ncc. it clones your cdn branch into a temp directory (or inits a fresh one if the branch doesn’t exist yet), copies your dist folder into latest/ and versions/{version}/, writes the manifest, commits, and pushes. the commit messages are kinda nice actually, they show what version replaced what: deploy: abc123 (was def456).
one thing i’m quietly proud of is that it handles the “branch doesn’t exist yet” case gracefully. first run just works.
lives in Worker/. pretty small. two endpoints: /check and /manifest. /check takes a repo and your current version, fetches the manifest from github, does the comparison, and returns whether there’s an update. wrote my own semver parser because i didn’t want to pull in a dependency for what’s basically three number comparisons. it’s probably fine. it validates the repo format with a regex so you can’t pass in weird stuff.
set up wrangler.toml with a production and staging env.
lives in Build/. this is the part that actually runs in people’s apps. it’s a class called Updato (creative naming, i know). you init it with your repo, mode, and current version. then you can check for updates, download them, and apply them. downloads get cached in localStorage with version tags so stale cache gets cleaned up automatically.
built with vite, outputs both esm and umd so you can import it or just drop a script tag.
wrote a bump-version.js script that bumps the version across all four package.jsons (root + the three workspace packages), commits, tags, and pushes. one command to do a release, basically. also a little print-cdn-commit.js that tells you what the latest cdn branch commit is, which is useful for debugging.
there’s a TODO.md with a bunch of stuff i want to do eventually. hot-swap support, rate limiting in the worker, tests, linting, npm publishing. the usual “i’ll get to it” list.
it works. the action runs, the worker deploys, the client checks and downloads. trying to publish the action to the github marketplace but the name “Updato” is apparently reserved by a ghost account that doesn’t exist. so that’s cool. working on that.