// A very small HTTP router, so the worker reads as a set of routes rather than // a chain of ifs unpicking a URL. The shape is lifted from the one in // htmx-serviceworker-todo, which is in turn a miniature of Hono or Express: // register a method and a path pattern, get the matching handler back. // // Patterns are matched segment by segment against a path relative to the // worker's own scope, so the app still works if it is ever served from a // subdirectory. A segment written as :name matches anything and lands in // params. Segment counts must be equal -- there are no wildcards, because // nothing here needs one. // // A handler returning a string gets it as an HTML response; returning a // Response passes it through untouched; returning nothing means "not mine // after all", and the caller falls back to serving the URL as a file. (function (scope) { 'use strict'; function parse(pattern) { return pattern.split('/').map(function (part) { return part.charAt(0) === ':' ? { name: part.slice(1) } : { part: part }; }); } // Everything a handler is given: the event it must attach cache writes to, // the URL, the path parameters, and the two questions every handler here // asks -- who is calling, and are they replacing the whole document. function Call(event, url, params) { this.event = event; this.request = event.request; this.url = url; this.params = params; // htmx stamps every request it makes; a navigation never carries it. this.htmx = this.request.headers.get('HX-Request') === 'true'; this.navigate = this.request.mode === 'navigate'; } Call.prototype.form = function () { return this.request.formData(); }; // base is the worker's scope path, e.g. "/" -- the prefix every pattern is // written without. function Router(base) { this.base = base; this.routes = []; } Router.prototype.route = function (method, pattern, handler) { this.routes.push({ method: method, path: parse(pattern), handler: handler }); return this; }; Router.prototype.get = function (pattern, handler) { return this.route('GET', pattern, handler); }; Router.prototype.post = function (pattern, handler) { return this.route('POST', pattern, handler); }; // The path a pattern is matched against: absolute, but with the scope // removed, so "/GEN" is "/GEN" whether the app sits at the origin root or // under /bible/. Router.prototype.relative = function (url) { var path = url.pathname; return path.indexOf(this.base) === 0 ? '/' + path.slice(this.base.length) : path; }; Router.prototype.find = function (method, path) { var segments = path.split('/'); for (var i = 0; i < this.routes.length; i++) { var route = this.routes[i]; if (route.method !== method || route.path.length !== segments.length) continue; var params = {}; var ok = route.path.every(function (segment, n) { if (!segment.name) return segment.part === segments[n]; params[segment.name] = decodeURIComponent(segments[n]); return true; }); if (ok) return { handler: route.handler, params: params }; } return null; }; // Returns a promise of a Response, or null when nothing matched -- null // being the signal to the caller that this URL is a file, not a route. Router.prototype.handle = function (event, url) { var found = this.find(event.request.method, this.relative(url)); if (!found) return null; return Promise.resolve(found.handler(new Call(event, url, found.params))) .then(function (result) { if (result instanceof Response) return result; if (typeof result !== 'string') return null; return new Response(result, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', // Built here from live state -- the bookmark, the templates, the // cache -- so storing it would pin a stale copy over the real thing. 'Cache-Control': 'no-store' } }); }); }; scope.Router = Router; })(self);