clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/site-feedback
nzip / worker / src / notify_ui.ts
notify_ui.ts 15.0K
1import type { NotifyEnv } from "./notify_enrollment.ts";23const APP_MANIFEST = JSON.stringify({4 id: "/",5 name: "nzip",6 short_name: "nzip",7 description: "Personal nzip notifications",8 start_url: "/",9 scope: "/",10 display: "standalone",11 background_color: "#1e1810",12 theme_color: "#1e1810",13 icons: [{14 src: "/_notify/icon.svg",15 sizes: "any",16 type: "image/svg+xml",17 purpose: "any",18 }],19});2021const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">22<rect width="512" height="512" rx="96" fill="#1e1810"/>23<text x="256" y="310" text-anchor="middle" font-family="ui-monospace,monospace" font-size="190" font-weight="700" fill="#f2e8d4">n<tspan fill="#ff6b4a">z</tspan>ip</text>24</svg>`;2526const SERVICE_WORKER = String.raw`self.addEventListener("push", (event) => {27 let payload = {};28 try { payload = event.data ? event.data.json() : {}; } catch (_) {}29 const title = typeof payload.title === "string" ? payload.title : "nzip";30 const body = typeof payload.body === "string" ? payload.body : "Notification received";31 const options = {32 body,33 tag: typeof payload.tag === "string" ? payload.tag : undefined,34 icon: "/_notify/icon.svg",35 badge: "/_notify/icon.svg",36 data: {37 eventId: typeof payload.eventId === "string" ? payload.eventId : null,38 hasPath: typeof payload.path === "string"39 }40 };41 const visible = self.registration.showNotification(title, options);42 const renew = fetch("/_notify/enrollments/renew", {43 method: "POST",44 credentials: "include",45 headers: { "content-type": "application/json" },46 body: "{}"47 }).catch(() => undefined);48 event.waitUntil(Promise.all([visible, renew]));49});5051self.addEventListener("notificationclick", (event) => {52 event.notification.close();53 event.waitUntil((async () => {54 let path = "/";55 const data = event.notification.data || {};56 if (data.hasPath && data.eventId) {57 try {58 const response = await fetch("/_notify/click-target", {59 method: "POST",60 credentials: "include",61 headers: { "content-type": "application/json" },62 body: JSON.stringify({ eventId: data.eventId })63 });64 if (response.ok) {65 const result = await response.json();66 if (typeof result.path === "string" && result.path.startsWith("/")) path = result.path;67 else path = "/?notify=link-expired";68 } else path = "/?notify=link-expired";69 } catch (_) { path = "/?notify=link-expired"; }70 }71 const absolute = new URL(path, self.location.origin).href;72 const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });73 for (const client of windows) {74 if (client.url === absolute && "focus" in client) return client.focus();75 }76 return clients.openWindow(path);77 })());78});`;7980const APP_SCRIPT = String.raw`(() => {81 const footer = document.querySelector("footer");82 const status = document.getElementById("notify-status");83 const isStandalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;84 let state = "unpaired";85 let polling = 0;86 let pairingTimer = 0;87 let operation = false;88 let pairingEnabled = false;8990 const api = async (path, options = {}) => {91 const response = await fetch(path, { credentials: "same-origin", ...options });92 const data = await response.json().catch(() => ({}));93 if (!response.ok) throw new Error(data.error || "request failed");94 return data;95 };96 const post = (path, body = {}) => api(path, {97 method: "POST",98 headers: { "content-type": "application/json" },99 body: JSON.stringify(body)100 });101 const setStatus = (text) => { status.textContent = text || ""; };102 const button = (id, text) => {103 const value = document.createElement("button");104 value.id = id;105 value.type = "button";106 value.textContent = text;107 return value;108 };109 const render = (next, pairing = pairingEnabled) => {110 state = next.status || state;111 pairingEnabled = pairing;112 footer.replaceChildren();113 const args = document.createElement("a");114 args.href = "https://args.io/cat/nzip";115 args.textContent = "args";116 footer.append(args);117 if (state === "unpaired" && pairingEnabled) {118 footer.append(" · ", button("pair", "pair"));119 document.getElementById("pair").addEventListener("click", beginPairing);120 return;121 }122 if (state === "approved" || state === "active") {123 const paired = document.createElement("span");124 paired.textContent = "paired";125 footer.append(" · ", paired);126 if (isStandalone) {127 const on = state === "active" || next.notifications === "on";128 const toggle = button("notify-toggle", on ? "notifications on" : "notifications off");129 toggle.addEventListener("click", () => toggleNotifications(on));130 footer.append(" · ", toggle);131 }132 }133 };134 const showPairingWindow = (pairing) => {135 if (pairingTimer) clearTimeout(pairingTimer);136 pairingTimer = 0;137 const enabled = pairing.enabled === true && Number.isFinite(pairing.expiresAt);138 render({ status: "unpaired" }, enabled);139 if (!enabled) return;140 const delay = Math.max(0, pairing.expiresAt * 1000 - Date.now());141 pairingTimer = setTimeout(() => {142 pairingTimer = 0;143 if (state === "unpaired") render({ status: "unpaired" }, false);144 }, delay);145 };146 const installAssets = async () => {147 if (!document.querySelector('link[rel="manifest"]')) {148 const manifest = document.createElement("link");149 manifest.rel = "manifest";150 manifest.href = "/_notify/app.webmanifest";151 document.head.append(manifest);152 }153 if ("serviceWorker" in navigator) await navigator.serviceWorker.register("/_notify/sw.js", { scope: "/" });154 };155 const platformInstruction = () => {156 if (isStandalone) return setStatus("");157 if (/iPhone|iPad|iPod/.test(navigator.userAgent)) setStatus("share → add to home screen");158 else setStatus("install from your browser menu");159 };160 async function beginPairing() {161 if (operation) return;162 operation = true;163 try {164 const result = await post("/_notify/enrollments");165 sessionStorage.setItem("nzip-pairing-code", result.code);166 setStatus(result.code + "\nwaiting");167 render({ status: "pending" });168 startPolling();169 } catch (error) { setStatus(error.message); }170 finally { operation = false; }171 }172 function startPolling() {173 if (polling) return;174 const started = Date.now();175 const poll = async () => {176 try {177 const current = await api("/_notify/enrollments/current");178 if (current.status === "approved") {179 const active = await post("/_notify/enrollments/activate");180 sessionStorage.removeItem("nzip-pairing-code");181 await installAssets();182 render(active);183 platformInstruction();184 polling = 0;185 return;186 }187 if (current.status === "expired" || current.status === "unpaired") {188 sessionStorage.removeItem("nzip-pairing-code");189 setStatus(current.status === "expired" ? "pairing expired" : "");190 render({ status: "unpaired" });191 polling = 0;192 return;193 }194 } catch (_) { setStatus("waiting · retrying"); }195 const elapsed = Date.now() - started;196 const base = elapsed < 60000 ? 3000 : 10000;197 polling = setTimeout(poll, base + Math.floor(Math.random() * 1000));198 };199 polling = setTimeout(poll, 3000);200 }201 const vapidKey = (value) => {202 const padding = "=".repeat((4 - value.length % 4) % 4);203 const raw = atob((value + padding).replaceAll("-", "+").replaceAll("_", "/"));204 return Uint8Array.from(raw, (char) => char.charCodeAt(0));205 };206 const sameKey = (left, right) => {207 if (!left) return false;208 const bytes = new Uint8Array(left);209 return bytes.length === right.length && bytes.every((value, index) => value === right[index]);210 };211 const reconcileSubscription = async (current) => {212 const registration = await navigator.serviceWorker.ready;213 const key = await api("/_notify/vapid-public-key");214 const applicationServerKey = vapidKey(key.publicKey);215 let subscription = await registration.pushManager.getSubscription();216 if (subscription && !sameKey(subscription.options.applicationServerKey, applicationServerKey)) {217 await subscription.unsubscribe();218 subscription = null;219 }220 if (!subscription && current.status === "active" && Notification.permission === "granted") {221 subscription = await registration.pushManager.subscribe({222 userVisibleOnly: true,223 applicationServerKey224 });225 }226 if (subscription) return post("/_notify/subscriptions", subscription.toJSON());227 if (current.status === "active") {228 return api("/_notify/subscriptions/current", { method: "DELETE" });229 }230 return current;231 };232 async function toggleNotifications(on) {233 if (operation) return;234 operation = true;235 const prior = on ? "notifications on" : "notifications off";236 const control = document.getElementById("notify-toggle");237 if (control) { control.disabled = true; control.textContent = "notifications …"; }238 try {239 const registration = await navigator.serviceWorker.ready;240 if (on) {241 const subscription = await registration.pushManager.getSubscription();242 if (subscription) await subscription.unsubscribe();243 const next = await api("/_notify/subscriptions/current", { method: "DELETE" });244 render(next);245 } else {246 const permission = await Notification.requestPermission();247 if (permission !== "granted") {248 render({ status: "approved", notifications: "off" });249 const blocked = document.getElementById("notify-toggle");250 if (blocked) { blocked.textContent = "notifications blocked"; blocked.disabled = true; }251 return;252 }253 const key = await api("/_notify/vapid-public-key");254 const existing = await registration.pushManager.getSubscription();255 const subscription = existing || await registration.pushManager.subscribe({256 userVisibleOnly: true,257 applicationServerKey: vapidKey(key.publicKey)258 });259 const next = await post("/_notify/subscriptions", subscription.toJSON());260 render(next);261 }262 } catch (error) {263 setStatus(error.message);264 const restored = document.getElementById("notify-toggle");265 if (restored) { restored.disabled = false; restored.textContent = prior; }266 } finally { operation = false; }267 }268 (async () => {269 if (new URL(location.href).searchParams.get("notify") === "link-expired") setStatus("link expired");270 try {271 const current = await api("/_notify/enrollments/current");272 if (current.status === "approved" || current.status === "active") {273 await installAssets();274 if (isStandalone) {275 const reconciled = await reconcileSubscription(current);276 render(reconciled);277 post("/_notify/enrollments/renew").catch(() => undefined);278 } else {279 render(current);280 platformInstruction();281 }282 } else if (current.status === "pending") {283 render(current);284 const code = sessionStorage.getItem("nzip-pairing-code");285 setStatus((code ? code + "\n" : "") + "waiting");286 startPolling();287 } else {288 const pairing = await api("/_notify/pairing");289 showPairingWindow(pairing);290 }291 } catch (_) { render({ status: "unpaired" }, false); setStatus("offline"); }292 })();293})();`;294295function escapeHtml(value: string): string {296 return value.replace(/[&<>"']/g, (character) =>297 ({298 "&": "&amp;",299 "<": "&lt;",300 ">": "&gt;",301 '"': "&quot;",302 "'": "&#39;",303 })[character]!);304}305306/** Claim-independent shell. Safe to cache publicly; all state is fetched client-side. */307export function notifyLandingPage(env: NotifyEnv): string {308 const host = escapeHtml(new URL(env.PUBLIC_BASE).host);309 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="theme-color" content="#1e1810"><title>${host}</title>310<style>*{box-sizing:border-box}body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;padding:24px;text-align:center}.landing{display:grid;gap:24px;justify-items:center}.wordmark{color:#f2e8d4;font-size:clamp(40px,10vw,72px);font-weight:700;letter-spacing:-.18em;line-height:.8}.wordmark .z{color:#ff6b4a}#notify-status{color:#8a8172;font-size:12px;line-height:1.6;white-space:pre-line;min-height:3.2em;letter-spacing:.02em}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom));color:#6b6355;font-size:12px}a,button{color:#6b6355;font:inherit;text-decoration:underline}button{appearance:none;background:none;border:0;padding:0;cursor:pointer}a:hover,button:hover{color:#d99a5b}button:focus-visible,a:focus-visible{outline:2px solid #ffb347;outline-offset:4px}button:disabled{cursor:default;text-decoration:none}</style></head>311<body><main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div><div id="notify-status" aria-live="polite"></div></div></main><footer><a href="https://args.io/cat/nzip">args</a></footer><script>${APP_SCRIPT}</script></body></html>`;312}313314function assetResponse(315 request: Request,316 body: string,317 contentType: string,318 headers: HeadersInit = {},319): Response {320 return new Response(request.method === "HEAD" ? null : body, {321 headers: {322 "content-type": contentType,323 "cache-control": "public, max-age=3600",324 "x-content-type-options": "nosniff",325 ...headers,326 },327 });328}329330export function notifyAssetResponse(331 request: Request,332 env: NotifyEnv,333 url: URL,334): Response | null {335 if (url.pathname === "/_notify/vapid-public-key") {336 if (!env.VAPID_PUBLIC_KEY) {337 return new Response(JSON.stringify({ error: "not found" }), {338 status: 404,339 headers: {340 "content-type": "application/json",341 "cache-control": "no-store",342 },343 });344 }345 return new Response(346 request.method === "HEAD"347 ? null348 : JSON.stringify({ publicKey: env.VAPID_PUBLIC_KEY }),349 {350 headers: {351 "content-type": "application/json; charset=utf-8",352 "cache-control": "public, max-age=3600",353 "x-content-type-options": "nosniff",354 },355 },356 );357 }358 if (url.pathname === "/_notify/app.webmanifest") {359 return assetResponse(360 request,361 APP_MANIFEST,362 "application/manifest+json; charset=utf-8",363 );364 }365 if (url.pathname === "/_notify/sw.js") {366 return assetResponse(367 request,368 SERVICE_WORKER,369 "text/javascript; charset=utf-8",370 {371 "cache-control": "no-cache",372 "service-worker-allowed": "/",373 },374 );375 }376 if (url.pathname === "/_notify/icon.svg") {377 return assetResponse(request, ICON, "image/svg+xml; charset=utf-8", {378 "cache-control": "public, max-age=86400",379 });380 }381 return null;382}