feat(pwa): add protocol handler recipe
- commit
- 726d6d21435222b172f14c1b33bf757c8dd9c81e
- parent
- b4658630c426a30e12376d334d1dbdb94190ee5f
- parent
- d64ab90b08f75958d676349cd266010fc04fbab8
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 4 | +- |
| M | apps/ | 1 | + |
| M | deno.json | 1 | + |
| M | docs/ | 23 | +++--- |
| M | docs/ | 2 | + |
| A | docs/ | 70 | ++++++++++++++++++++ |
| M | package/ | 20 | +++++- |
| M | package/ | 2 | + |
| M | package/ | 6 | +- |
| M | package/ | 9 | ++- |
| A | package/ | 104 | +++++++++++++++++++++++++++++ |
| A | package/ | 53 | +++++++++++++++ |
| M | package/ | 4 | +- |
| M | package/ | 84 | ++++++++++++++++++++++++ |
| M | package/ | 37 | ++++++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 107 | ++++++++++++++++++++++++++++++- |
@@ -135,8 +135,8 @@ permission APIs.
135135 136136 OS-facing capabilities stay out of the starter until the product opts in. The137137 [installed-app recipe catalog](docs/recipes/README.md) contains tested, isolated entrypoints for Web138-Share, installed-window launch handling, and validated file-import previews. Each keeps manifest139-changes opt-in and documents input validation, offline behavior, and ordinary-web fallbacks.138+Share, installed-window launch handling, validated file-import previews, and custom-protocol item139+links. Each keeps manifest changes opt-in and documents validation, offline behavior, and fallbacks.140140 141141 ## Testing local-first behavior142142 @@ -14,6 +14,7 @@
1414 "@nzip/lofi/provision": "../../package/commands/provision.ts",1515 "@nzip/lofi/recipes/file-handler": "../../package/recipes/file-handler.ts",1616 "@nzip/lofi/recipes/launch-handler": "../../package/recipes/launch-handler.ts",17+ "@nzip/lofi/recipes/protocol-handler": "../../package/recipes/protocol-handler.ts",1718 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",1819 "@nzip/lofi/test": "../../package/commands/run_tests.ts",1920 "@nzip/lofi/testing": "../../package/testing/mod.ts",@@ -16,6 +16,7 @@
1616 "./provision": "./package/commands/provision.ts",1717 "./recipes/file-handler": "./package/recipes/file-handler.ts",1818 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",19+ "./recipes/protocol-handler": "./package/recipes/protocol-handler.ts",1920 "./recipes/web-share": "./package/recipes/web-share.ts",2021 "./testing": "./package/testing/mod.ts",2122 "./test": "./package/commands/run_tests.ts"@@ -62,17 +62,18 @@ allowing framework fixes to arrive through a package upgrade.
6262 6363 The final package map is intentionally small:6464 65-| Import | Supported use |66-| ----------------------------------- | --------------------------------------------------- |67-| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68-| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69-| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70-| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71-| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72-| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73-| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |74-| `@nzip/lofi/testing` | Local-first Playwright helpers |75-| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |65+| Import | Supported use |66+| ------------------------------------- | --------------------------------------------------- |67+| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69+| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70+| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72+| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73+| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |75+| `@nzip/lofi/testing` | Local-first Playwright helpers |76+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7677 7778 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to7879 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.@@ -20,6 +20,8 @@ flowchart LR
2020 installed window while preserving ordinary launch behavior elsewhere.2121 - [File handling and import previews](file-handler.md) — validate explicitly associated files into2222 transient drafts before the user confirms persistence.23+- [Custom-protocol item links](protocol-handler.md) — parse one product-owned `web+` scheme into24+ allow-listed collaborative-list identifiers without arbitrary redirects.2325 2426 ## Contract for every recipe2527 @@ -0,0 +1,70 @@
1+# Custom-protocol collaborative-list links2+3+Use this recipe when a companion tool needs to open one collaborative-list item in an installed PWA.4+The manifest surface is5+[experimental and not Baseline](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/protocol_handlers),6+so every shared item must also have an ordinary HTTPS deep link.7+8+## 1. Declare one custom scheme9+10+The starter registers no protocols. Add one lowercase `web+` scheme and a same-scope, prerendered11+handler route:12+13+```json14+{15+ "protocol_handlers": [16+ {17+ "protocol": "web+lofi",18+ "url": "./open-item/?url=%s"19+ }20+ ]21+}22+```23+24+The validator requires exactly one `%s`, as the entire value of one query parameter. It rejects25+privileged schemes, duplicate protocols, extra members, fragments, escaped scope, and a handler26+route that is missing from the offline production output.27+28+Protocol registration and conflicts are browser- and OS-dependent. A browser may ask the user for29+confirmation, another application may remain the default, and manifest changes may require an app30+refresh or reinstall. To remove the capability, delete `protocol_handlers`, rebuild, refresh or31+reinstall the PWA, and verify the OS association separately.32+33+## 2. Parse identifiers, not a redirect34+35+Use links shaped like:36+37+```text38+web+lofi:collaborative-list/list_123/item/item_45639+```40+41+On the action route:42+43+```ts44+import { parseCollaborativeListProtocolTarget } from "@nzip/lofi/recipes/protocol-handler";45+46+const result = parseCollaborativeListProtocolTarget(location.search, {47+ protocol: "web+lofi",48+ parameter: "url",49+ maxLength: 512,50+});51+52+if (result.ok) {53+ showItemPreview(result.target.listId, result.target.itemId);54+} else {55+ showGenericInvalidLink();56+}57+```58+59+`URLSearchParams` performs the one query decode. The parser rejects remaining percent escapes rather60+than decoding a second time. It bounds the decoded URL, requires the exact configured scheme and the61+exact `collaborative-list/LIST/item/ITEM` shape, and allow-lists both IDs. It returns identifiers62+only; the received URL can never become `location.href`, a router destination, or proof of caller63+identity.64+65+Build the fallback HTTPS link from your own origin and the validated identifiers. Unsupported66+browsers and devices then open the same item through ordinary navigation.67+68+Test direct HTTPS navigation, a valid protocol launch, wrong schemes, malformed and double-encoded69+values, duplicate parameters, oversized input, offline action-route startup, fallback links, handler70+conflicts, and removal/reinstallation behavior.@@ -61,7 +61,12 @@ const preactFiles = [
6161 "use-device-capabilities.ts",6262 ] as const;6363 64-const recipeFiles = ["file-handler.ts", "launch-handler.ts", "web-share.ts"] as const;64+const recipeFiles = [65+ "file-handler.ts",66+ "launch-handler.ts",67+ "protocol-handler.ts",68+ "web-share.ts",69+] as const;6570 6671 async function readPackageFile(path: string): Promise<string> {6772 const url = new URL(path, import.meta.url);@@ -85,6 +90,13 @@ function renderConfig(projectRoot: string): string {
8590 "recipes",8691 "launch-handler.ts",8792 );93+ const protocolHandlerRecipe = join(94+ projectRoot,95+ ".lofi",96+ "package",97+ "recipes",98+ "protocol-handler.ts",99+ );88100 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");89101 return `import preact from "@astrojs/preact";90102 import { defineConfig } from "astro/config";@@ -130,6 +142,9 @@ export default defineConfig({
130142 } },131143 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/launch-handler$/, replacement: ${132144 JSON.stringify(launchHandlerRecipe)145+ } },146+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/protocol-handler$/, replacement: ${147+ JSON.stringify(protocolHandlerRecipe)133148 } },134149 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${135150 JSON.stringify(webShareRecipe)@@ -146,6 +161,9 @@ export default defineConfig({
146161 { find: /^npm:jazz-tools@[^/]+$/, replacement: "jazz-tools" },147162 { find: "@nzip/lofi/recipes/launch-handler", replacement: ${148163 JSON.stringify(launchHandlerRecipe)164+ } },165+ { find: "@nzip/lofi/recipes/protocol-handler", replacement: ${166+ JSON.stringify(protocolHandlerRecipe)149167 } },150168 { find: "@nzip/lofi/recipes/file-handler", replacement: ${151169 JSON.stringify(fileHandlerRecipe)@@ -16,6 +16,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1616 'trailingSlash: "always"',1717 ".lofi/package/runtime/mod.ts",1818 ".lofi/package/recipes/file-handler.ts",19+ ".lofi/package/recipes/protocol-handler.ts",1920 ".lofi/package/recipes/web-share.ts",2021 "^jsr:@nzip\\/lofi@[^/]+",2122 "^npm:preact@[^/]+",@@ -36,6 +37,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
3637 await Deno.stat(`${root}/.lofi/package/access/mod.ts`);3738 await Deno.stat(`${root}/.lofi/package/recipes/file-handler.ts`);3839 await Deno.stat(`${root}/.lofi/package/recipes/launch-handler.ts`);40+ await Deno.stat(`${root}/.lofi/package/recipes/protocol-handler.ts`);3941 await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);4042 } finally {4143 await Deno.remove(root, { recursive: true });@@ -84,7 +84,7 @@ OS-facing capabilities remain absent until the product opts in. Start with the
8484 [installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)8585 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web8686 fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated87-file-import previews without adding any of them to the starter manifest.87+file-import previews, and allow-listed custom protocol links without adding any to the starter.8888 8989 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9090 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -137,6 +137,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
137137 subpath: "recipes/launch-handler",138138 localPath: "recipes/launch-handler.ts",139139 },140+ "@nzip/lofi/recipes/protocol-handler": {141+ subpath: "recipes/protocol-handler",142+ localPath: "recipes/protocol-handler.ts",143+ },140144 "@nzip/lofi/recipes/web-share": {141145 subpath: "recipes/web-share",142146 localPath: "recipes/web-share.ts",@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 14,110- `expected one package prefix, access, two integrations, six commands, three recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 15,110+ `expected one package prefix, access, two integrations, six commands, four recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -153,6 +153,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
153153 "./provision",154154 "./recipes/file-handler",155155 "./recipes/launch-handler",156+ "./recipes/protocol-handler",156157 "./recipes/web-share",157158 "./test",158159 "./testing",@@ -169,6 +170,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
169170 packageManifest.exports["./recipes/launch-handler"],170171 "./package/recipes/launch-handler.ts",171172 );173+ assertEquals(174+ packageManifest.exports["./recipes/protocol-handler"],175+ "./package/recipes/protocol-handler.ts",176+ );172177 assertEquals(173178 packageManifest.exports["./recipes/web-share"],174179 "./package/recipes/web-share.ts",@@ -0,0 +1,104 @@
1+/**2+ * Opt-in parser for a narrow custom-protocol collaborative-list deep link.3+ *4+ * @module5+ */6+7+/** A validated collaborative-list item reference, never an arbitrary redirect. */8+export type CollaborativeListProtocolTarget = {9+ readonly listId: string;10+ readonly itemId: string;11+};12+13+/** Stable rejection that never contains the received protocol URL. */14+export type CollaborativeListProtocolIssue =15+ | "missing-target"16+ | "duplicate-target"17+ | "unexpected-parameter"18+ | "target-too-long"19+ | "invalid-target"20+ | "wrong-protocol"21+ | "unsupported-shape"22+ | "invalid-list-id"23+ | "invalid-item-id";24+25+/** Accepted item reference or value-free rejection. */26+export type CollaborativeListProtocolResult =27+ | { ok: true; target: CollaborativeListProtocolTarget }28+ | { ok: false; issue: CollaborativeListProtocolIssue };29+30+/** Options matching one `protocol_handlers` manifest entry. */31+export type ParseCollaborativeListProtocolOptions = {32+ /** Exact custom scheme, such as `web+lofi`. */33+ protocol: string;34+ /** Handler-route query parameter receiving `%s`. Defaults to `url`. */35+ parameter?: string;36+ /** Maximum decoded protocol URL length. Defaults to 512. */37+ maxLength?: number;38+};39+40+function configuredOptions(options: ParseCollaborativeListProtocolOptions): {41+ protocol: string;42+ parameter: string;43+ maxLength: number;44+} {45+ if (!/^web\+[a-z]+$/.test(options.protocol)) {46+ throw new TypeError("protocol must be a custom web+ scheme with lowercase ASCII letters");47+ }48+ const parameter = options.parameter ?? "url";49+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(parameter)) {50+ throw new TypeError("protocol query parameter must be a short lowercase name");51+ }52+ const maxLength = options.maxLength ?? 512;53+ if (!Number.isSafeInteger(maxLength) || maxLength < 1 || maxLength > 4096) {54+ throw new TypeError("protocol target length must be an integer from 1 to 4096");55+ }56+ return { protocol: options.protocol, parameter, maxLength };57+}58+59+/**60+ * Decode the handler query once and allow-list one collaborative-list item.61+ *62+ * Expected payload: `web+scheme:collaborative-list/LIST_ID/item/ITEM_ID`.63+ * IDs are restricted to portable unreserved characters, and the result contains64+ * identifiers only. The received URL is never returned or used as a redirect.65+ */66+export function parseCollaborativeListProtocolTarget(67+ search: string | URLSearchParams,68+ options: ParseCollaborativeListProtocolOptions,69+): CollaborativeListProtocolResult {70+ const configured = configuredOptions(options);71+ const params = typeof search === "string" ? new URLSearchParams(search) : search;72+ const unexpected = [...params.keys()].some((name) => name !== configured.parameter);73+ if (unexpected) return { ok: false, issue: "unexpected-parameter" };74+ const values = params.getAll(configured.parameter);75+ if (values.length === 0) return { ok: false, issue: "missing-target" };76+ if (values.length !== 1) return { ok: false, issue: "duplicate-target" };77+ const value = values[0];78+ if (value.length > configured.maxLength) return { ok: false, issue: "target-too-long" };79+ // URLSearchParams has already decoded the query value. Reject remaining escapes80+ // instead of applying a second decode with ambiguous semantics.81+ if (value.includes("%")) return { ok: false, issue: "invalid-target" };82+ let target: URL;83+ try {84+ target = new URL(value);85+ } catch {86+ return { ok: false, issue: "invalid-target" };87+ }88+ if (target.protocol !== `${configured.protocol}:`) {89+ return { ok: false, issue: "wrong-protocol" };90+ }91+ if (target.search || target.hash || target.host || target.username || target.password) {92+ return { ok: false, issue: "unsupported-shape" };93+ }94+ const segments = target.pathname.split("/");95+ if (96+ segments.length !== 4 || segments[0] !== "collaborative-list" || segments[2] !== "item"97+ ) {98+ return { ok: false, issue: "unsupported-shape" };99+ }100+ const identifier = /^[A-Za-z0-9_-]{1,64}$/;101+ if (!identifier.test(segments[1])) return { ok: false, issue: "invalid-list-id" };102+ if (!identifier.test(segments[3])) return { ok: false, issue: "invalid-item-id" };103+ return { ok: true, target: { listId: segments[1], itemId: segments[3] } };104+}@@ -0,0 +1,53 @@
1+import { parseCollaborativeListProtocolTarget } from "./protocol-handler.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+const options = { protocol: "web+lofi", parameter: "url", maxLength: 256 } as const;8+9+Deno.test("protocol target decodes once into allow-listed identifiers", () => {10+ const result = parseCollaborativeListProtocolTarget(11+ "?url=web%2Blofi%3Acollaborative-list%2Flist_1%2Fitem%2Fitem-2",12+ options,13+ );14+ assert(result.ok, `valid protocol target failed: ${JSON.stringify(result)}`);15+ assert(result.target.listId === "list_1", "list ID drifted");16+ assert(result.target.itemId === "item-2", "item ID drifted");17+18+ const encodedTwice = parseCollaborativeListProtocolTarget(19+ "?url=web%252Blofi%253Acollaborative-list%252Flist_1%252Fitem%252Fitem-2",20+ options,21+ );22+ assert(!encodedTwice.ok && encodedTwice.issue === "invalid-target", "double encoding passed");23+});24+25+Deno.test("protocol target rejects duplicates, extras, length, scheme, and shape without values", () => {26+ const cases = [27+ ["", "missing-target"],28+ ["?url=a&url=b", "duplicate-target"],29+ ["?url=a&next=https%3A%2F%2Foutside.invalid", "unexpected-parameter"],30+ [`?url=${"a".repeat(257)}`, "target-too-long"],31+ ["?url=not-a-url", "invalid-target"],32+ ["?url=web%2Bother%3Acollaborative-list%2Flist%2Fitem%2Fitem", "wrong-protocol"],33+ ["?url=web%2Blofi%3Ahttps%3A%2F%2Foutside.invalid", "unsupported-shape"],34+ ["?url=web%2Blofi%3Acollaborative-list%2Fbad%21%2Fitem%2Fitem", "invalid-list-id"],35+ ["?url=web%2Blofi%3Acollaborative-list%2Flist%2Fitem%2Fbad%21", "invalid-item-id"],36+ ] as const;37+ for (const [search, issue] of cases) {38+ const result = parseCollaborativeListProtocolTarget(search, options);39+ assert(!result.ok && result.issue === issue, `${issue} produced ${JSON.stringify(result)}`);40+ }41+});42+43+Deno.test("protocol target rejects privileged and ambiguous configuration", () => {44+ for (const protocol of ["https", "mailto", "web+UPPER", "web+"]) {45+ let rejected = false;46+ try {47+ parseCollaborativeListProtocolTarget("", { protocol });48+ } catch (error) {49+ rejected = error instanceof TypeError;50+ }51+ assert(rejected, `unsafe protocol configuration passed: ${protocol}`);52+ }53+});@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "e5c834268efb574c1409bae762c2ae8d907faff0e55e37a5b6b21281d789817f",4+ "deno.json": "bd627e90ccb70c01ad0420aee863cccc9b254fbaa9dfe57971eae9e9e6ef67d2",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "a18ea936a0fbe6ae22e6ef0f395f0a5cdfa4ae26a4566f242ad93e8d3c9c9c5d",14+ "README.md": "e9fbef6e2f5d29f7ad5aa51dc8d76123599e76fb804d7b8abf640716e89afc55",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -29,6 +29,11 @@ const imageTypes = new Map([
2929 ["image/svg+xml", new Set([".svg"])],3030 ["image/webp", new Set([".webp"])],3131 ]);32+const protocolPlaceholderValue = "web+lofi:placeholder";33+34+function protocolHandlerUrl(value: string, manifestUrl: URL): URL {35+ return new URL(value.replace("%s", encodeURIComponent(protocolPlaceholderValue)), manifestUrl);36+}3237 3338 function issue(detail: string, remediation: string): PwaValidationIssue {3439 return { detail, remediation };@@ -554,6 +559,66 @@ async function parseAndValidateManifest(
554559 }555560 }556561 562+ if (manifest.protocol_handlers !== undefined) {563+ if (!Array.isArray(manifest.protocol_handlers) || manifest.protocol_handlers.length === 0) {564+ issues.push(565+ issue(`${manifestFile}: protocol_handlers must be a non-empty array`, remediation),566+ );567+ } else {568+ const protocols = new Set<string>();569+ for (const [index, raw] of manifest.protocol_handlers.entries()) {570+ const label = `${manifestFile}: protocol_handlers[${index}]`;571+ if (!isObject(raw)) {572+ issues.push(issue(`${label} must be an object`, remediation));573+ continue;574+ }575+ const unknown = Object.keys(raw).filter((member) =>576+ !["protocol", "url"].includes(member)577+ );578+ if (unknown.length > 0) {579+ issues.push(issue(`${label} contains unsupported members`, remediation));580+ }581+ if (!nonEmptyString(raw.protocol) || !/^web\+[a-z]+$/.test(raw.protocol)) {582+ issues.push(583+ issue(`${label}.protocol must be a custom lowercase web+ scheme`, remediation),584+ );585+ } else if (protocols.has(raw.protocol)) {586+ issues.push(issue(`${label}.protocol must not repeat`, remediation));587+ } else {588+ protocols.add(raw.protocol);589+ }590+ if (!nonEmptyString(raw.url)) {591+ issues.push(issue(`${label}.url must be a non-empty URL template`, remediation));592+ continue;593+ }594+ if ((raw.url.match(/%s/g) ?? []).length !== 1) {595+ issues.push(issue(`${label}.url must contain exactly one %s placeholder`, remediation));596+ continue;597+ }598+ try {599+ const url = protocolHandlerUrl(raw.url, manifestUrl);600+ if (!withinScope(url, scope)) {601+ issues.push(issue(`${label}.url must stay inside manifest scope`, remediation));602+ }603+ if (url.hash) {604+ issues.push(issue(`${label}.url must not contain a fragment`, remediation));605+ }606+ const values = [...url.searchParams.values()];607+ if (values.length !== 1 || values[0] !== protocolPlaceholderValue) {608+ issues.push(609+ issue(610+ `${label}.url must put %s as the complete value of one query parameter`,611+ remediation,612+ ),613+ );614+ }615+ } catch {616+ issues.push(issue(`${label}.url is not a valid URL template`, remediation));617+ }618+ }619+ }620+ }621+557622 if (manifest.share_target !== undefined) {558623 const label = `${manifestFile}: share_target`;559624 const target = manifest.share_target;@@ -820,6 +885,25 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
820885 }821886 }822887 }888+ if (Array.isArray(result.manifest?.protocol_handlers)) {889+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);890+ for (const [index, raw] of result.manifest.protocol_handlers.entries()) {891+ if (!isObject(raw) || !nonEmptyString(raw.url) || !raw.url.includes("%s")) continue;892+ try {893+ const route = emittedRoutePath(protocolHandlerUrl(raw.url, manifestUrl), basePath);894+ if (!route || !htmlFiles.includes(route)) {895+ issues.push(896+ issue(897+ `dist/${manifestFile}: protocol_handlers[${index}].url has no emitted offline route`,898+ remediation,899+ ),900+ );901+ }902+ } catch {903+ // Source validation already reports malformed handler URL templates.904+ }905+ }906+ }823907 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;824908 const appleIcons = new Set<string>();825909 for (const path of htmlFiles) {@@ -236,6 +236,38 @@ Deno.test("source PWA validation checks opt-in file handlers", async () => {
236236 }237237 });238238 239+Deno.test("source PWA validation checks opt-in protocol handlers", async () => {240+ const { root, project } = await makeProject();241+ try {242+ const manifest = await readManifest(project);243+ manifest.protocol_handlers = [{244+ protocol: "mailto",245+ url: "../outside/%s?again=%s#fragment",246+ extra: true,247+ }, {248+ protocol: "web+lofi",249+ url: "./open/?url=prefix-%s",250+ }, {251+ protocol: "web+lofi",252+ url: "./open/?url=%s",253+ }];254+ await writeManifest(project, manifest);255+ const issues = await sourcePwaIssues(project, "/field-notes/");256+ assert(hasIssue(issues, "custom lowercase web+"), "privileged protocol was accepted");257+ assert(hasIssue(issues, "exactly one %s"), "duplicate placeholder was accepted");258+ assert(hasIssue(issues, "complete value"), "prefixed placeholder was accepted");259+ assert(hasIssue(issues, "must not repeat"), "duplicate protocol was accepted");260+ assert(hasIssue(issues, "contains unsupported members"), "unknown protocol member passed");261+262+ manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open/?url=%s" }];263+ await writeManifest(project, manifest);264+ const valid = await sourcePwaIssues(project, "/field-notes/");265+ assert(valid.length === 0, `valid protocol handler was rejected: ${JSON.stringify(valid)}`);266+ } finally {267+ await Deno.remove(root, { recursive: true });268+ }269+});270+239271 Deno.test("source PWA validation permits replacement branding and optional members", async () => {240272 const { root, project } = await makeProject();241273 try {@@ -342,6 +374,7 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
342374 action: "./import/",343375 accept: { "application/json": [".json"] },344376 }];377+ manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open/?url=%s" }];345378 await Deno.writeTextFile(346379 join(dist, "manifest.webmanifest"),347380 `${JSON.stringify(manifest, null, 2)}\n`,@@ -359,6 +392,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
359392 hasIssue(issues, "file_handlers[0].action has no emitted offline route"),360393 "missing file-handler route passed",361394 );395+ assert(396+ hasIssue(issues, "protocol_handlers[0].url has no emitted offline route"),397+ "missing protocol-handler route passed",398+ );362399 } finally {363400 await Deno.remove(root, { recursive: true });364401 }@@ -11,6 +11,7 @@ const entrypoints = [
1111 "package/commands/provision.ts",1212 "package/recipes/file-handler.ts",1313 "package/recipes/launch-handler.ts",14+ "package/recipes/protocol-handler.ts",1415 "package/recipes/web-share.ts",1516 "package/testing/mod.ts",1617 "package/commands/run_tests.ts",@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 15, `expected 15 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 16, `expected 16 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;@@ -853,6 +853,67 @@ export default function FileImport() {
853853 );854854 }855855 856+async function installProtocolHandlerRecipe(projectRoot: string) {857+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");858+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));859+ manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open-item/?url=%s" }];860+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);861+ await Deno.writeTextFile(862+ join(projectRoot, "src", "pages", "open-item.astro"),863+ `---864+import ProtocolItem from "../islands/ProtocolItem.tsx";865+import Shell from "../layouts/Shell.astro";866+import "../styles/global.css";867+---868+869+<Shell title="Open collaborative-list item">870+ <ProtocolItem client:load />871+</Shell>872+`,873+ );874+ await Deno.writeTextFile(875+ join(projectRoot, "src", "islands", "ProtocolItem.tsx"),876+ `import { useEffect, useState } from "preact/hooks";877+import {878+ parseCollaborativeListProtocolTarget,879+ type CollaborativeListProtocolResult,880+} from "@nzip/lofi/recipes/protocol-handler";881+882+export default function ProtocolItem() {883+ const [result, setResult] = useState<CollaborativeListProtocolResult>();884+ useEffect(() => {885+ setResult(parseCollaborativeListProtocolTarget(location.search, {886+ protocol: "web+lofi",887+ parameter: "url",888+ maxLength: 256,889+ }));890+ }, []);891+ const base = import.meta.env.BASE_URL;892+ if (!result) return <p role="status">Opening item…</p>;893+ if (!result.ok) return (894+ <section class="island">895+ <h1>Open collaborative-list item</h1>896+ <p role="alert">This item link is not valid.</p>897+ <a data-protocol-fallback href={base}>Open the app normally</a>898+ </section>899+ );900+ const { listId, itemId } = result.target;901+ const fallback = new URL(base, location.origin);902+ fallback.searchParams.set("list", listId);903+ fallback.searchParams.set("item", itemId);904+ return (905+ <section class="island">906+ <h1>Open collaborative-list item</h1>907+ <p data-protocol-list>{listId}</p>908+ <p data-protocol-item>{itemId}</p>909+ <a data-protocol-fallback href={fallback.href}>Open with HTTPS</a>910+ </section>911+ );912+}913+`,914+ );915+}916+856917 async function main() {857918 const { source, version } = parseArgs(Deno.args);858919 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {@@ -1099,6 +1160,10 @@ async function main() {
10991160 await stage("install web-share recipe", () => installWebShareRecipe(projectRoot!));11001161 await stage("install launch-handler recipe", () => installLaunchHandlerRecipe(projectRoot!));11011162 await stage("install file-handler recipe", () => installFileHandlerRecipe(projectRoot!));1163+ await stage(1164+ "install protocol-handler recipe",1165+ () => installProtocolHandlerRecipe(projectRoot!),1166+ );11021167 11031168 await stage(11041169 "build",@@ -1440,6 +1505,46 @@ async function main() {
14401505 description: "task app after file-handler exercise",14411506 });14421507 });1508+ await stage("protocol-handler recipe browser", async () => {1509+ await context!.setOffline(true);1510+ try {1511+ const payload = encodeURIComponent(1512+ "web+lofi:collaborative-list/list_123/item/item_456",1513+ );1514+ await page!.goto(`${origin}open-item/?url=${payload}`, {1515+ waitUntil: "domcontentloaded",1516+ });1517+ await page!.getByText("list_123").waitFor({ state: "visible" });1518+ await page!.getByText("item_456").waitFor({ state: "visible" });1519+ const fallback = await page!.locator("[data-protocol-fallback]").getAttribute("href");1520+ assert(1521+ fallback === `${origin}?list=list_123&item=item_456`,1522+ "protocol recipe did not preserve the ordinary HTTPS fallback",1523+ );1524+1525+ const invalid = encodeURIComponent("web+lofi:https://outside.invalid/private-value");1526+ await page!.goto(`${origin}open-item/?url=${invalid}`, {1527+ waitUntil: "domcontentloaded",1528+ });1529+ await page!.getByRole("alert").waitFor({ state: "visible" });1530+ const rejected = await page!.locator("body").innerText();1531+ assert(!rejected.includes("outside.invalid"), "rejected protocol host leaked into the UI");1532+ assert(!rejected.includes("private-value"), "rejected protocol value leaked into the UI");1533+1534+ await page!.goto(`${origin}open-item/`, { waitUntil: "domcontentloaded" });1535+ await page!.getByRole("alert").waitFor({ state: "visible" });1536+ assert(1537+ await page!.locator("[data-protocol-fallback]").getAttribute("href") === "/",1538+ "direct handler visit lost its ordinary app fallback",1539+ );1540+ } finally {1541+ await context!.setOffline(false);1542+ }1543+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1544+ await waitForReady(page!, taskAppReady, undefined, {1545+ description: "task app after protocol-handler exercise",1546+ });1547+ });14431548 await stage("waiting worker update browser", async () => {14441549 const workerPath = join(projectRoot!, "dist", "sw.js");14451550 const worker = await Deno.readTextFile(workerPath);