clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

Merge pull request #78 from FelineStateMachine/feat/protocol-handler-recipe

FelineStateMachine committed 726d6d2
feat(pwa): add protocol handler recipe
commit
726d6d21435222b172f14c1b33bf757c8dd9c81e
parent
b4658630c426a30e12376d334d1dbdb94190ee5f
parent
d64ab90b08f75958d676349cd266010fc04fbab8
author
FelineStateMachine <dami.etoile@gmail.com>
17 files changed, +508 -20
MREADME.md4+-
Mapps/reference/deno.json1+
Mdeno.json1+
Mdocs/getting-started.md23+++---
Mdocs/recipes/README.md2+
Adocs/recipes/protocol-handler.md70++++++++++++++++++++
Mpackage/astro/mod.ts20+++++-
Mpackage/astro/mod_test.ts2+
Mpackage/create_core.ts6+-
Mpackage/create_core_test.ts9++-
Apackage/recipes/protocol-handler.ts104+++++++++++++++++++++++++++++
Apackage/recipes/protocol-handler_test.ts53+++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/pwa-validation.ts84++++++++++++++++++++++++
Mpackage/tooling/pwa-validation_test.ts37++++++++++
Mtools/doc_lint.ts1+
Mtools/golden.ts107++++++++++++++++++++++++++++++-
M README.md +2 -2
M apps/reference/deno.json +1 -0
M deno.json +1 -0
M docs/getting-started.md +12 -11
@@ -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.
M docs/recipes/README.md +2 -0
A docs/recipes/protocol-handler.md +70 -0
@@ -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.
M package/astro/mod.ts +19 -1
@@ -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)
M package/astro/mod_test.ts +2 -0
M package/create_core.ts +5 -1
M package/create_core_test.ts +7 -2
A package/recipes/protocol-handler.ts +104 -0
@@ -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+}
A package/recipes/protocol-handler_test.ts +53 -0
@@ -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+});
M package/testdata/starter.snapshot.json +2 -2
M package/tooling/pwa-validation.ts +84 -0
@@ -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) {
M package/tooling/pwa-validation_test.ts +37 -0
@@ -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 }
M tools/doc_lint.ts +1 -0
M tools/golden.ts +106 -1
@@ -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);