clone

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

feat/141-demo-review-fixes
lofi / package / recipes / web-share.ts
web-share.ts 6.2K
1/**2 * Opt-in helpers for outbound Web Share and inbound text/link share targets.3 *4 * The helpers feature-detect browser support and parse received values without5 * mutating application data. Add the matching manifest member and receiver UI6 * only when the product has a concrete sharing workflow.7 *8 * @module9 */1011/** Data accepted by the browser Web Share API. */12export type WebShareData = {13 readonly title?: string;14 readonly text?: string;15 readonly url?: string;16 readonly files?: readonly File[];17};1819/** Minimal browser seam used by {@link shareOrFallback}. */20export type WebShareClient = {21 canShare?: (data: WebShareData) => boolean;22 share?: (data: WebShareData) => Promise<void>;23};2425/** Stable, value-free outcome from one outbound share attempt. */26export type WebShareOutcome = "shared" | "cancelled" | "fallback" | "failed";2728/** Browser seam and fallback used for one outbound share attempt. */29export type ShareOrFallbackOptions = {30 /** Override the browser seam for tests or non-window environments. */31 client?: WebShareClient;32 /** Normal web action used when native sharing is unavailable. */33 fallback: (data: WebShareData) => void | Promise<void>;34};3536function browserShareClient(): WebShareClient | undefined {37 if (!("navigator" in globalThis)) return undefined;38 const candidate = globalThis.navigator as Navigator & WebShareClient;39 return candidate;40}4142function cancelled(error: unknown): boolean {43 return error instanceof DOMException && error.name === "AbortError";44}4546/**47 * Attempt a user-triggered native share and use a normal web fallback only48 * when the capability is unavailable or rejects the payload up front.49 *50 * Call this function directly from a click/submit handler: browsers require51 * transient user activation for `navigator.share()`. A runtime share failure52 * is reported as `failed` so callers can offer a retry or explicit copy action53 * without unexpectedly copying data after the native sheet opened.54 */55export async function shareOrFallback(56 data: WebShareData,57 options: ShareOrFallbackOptions,58): Promise<WebShareOutcome> {59 const client = options.client ?? browserShareClient();60 if (!client?.share) {61 await options.fallback(data);62 return "fallback";63 }64 if (client.canShare) {65 try {66 if (!client.canShare(data)) {67 await options.fallback(data);68 return "fallback";69 }70 } catch {71 await options.fallback(data);72 return "fallback";73 }74 }75 try {76 await client.share(data);77 return "shared";78 } catch (error) {79 return cancelled(error) ? "cancelled" : "failed";80 }81}8283/** Length limits applied to received title, text, and URL values. */84export type TextShareTargetLimits = {85 title: number;86 text: number;87 url: number;88};8990/** Default limits for the text/link share-target recipe. */91export const TEXT_SHARE_LIMITS: Readonly<TextShareTargetLimits> = {92 title: 120,93 text: 2_000,94 url: 2_048,95} as const;9697/** Query-parameter names declared by the matching manifest share target. */98export type TextShareTargetNames = {99 title: string;100 text: string;101 url: string;102};103104/** Parsed text/link values awaiting explicit user confirmation. */105export type TextShareTargetDraft = {106 readonly title?: string;107 readonly text?: string;108 readonly url?: string;109};110111/** Stable validation issue that never contains received share values. */112export type TextShareTargetIssue =113 | "duplicate-title"114 | "duplicate-text"115 | "duplicate-url"116 | "title-too-long"117 | "text-too-long"118 | "url-too-long"119 | "invalid-url"120 | "unsupported-url-protocol"121 | "empty-share";122123/**124 * Accepted draft or value-free rejection from an inbound share target.125 *126 * Unlike the singular-`issue` results of the other recipes, the rejection arm127 * carries a plural `issues` list: the title, text, and URL fields are validated128 * independently, and every field that fails is reported together so a receiver129 * page can explain the whole rejection at once.130 */131export type TextShareTargetResult =132 | { ok: true; draft: TextShareTargetDraft }133 | { ok: false; issues: readonly TextShareTargetIssue[] };134135/** Optional manifest-name and input-limit overrides for the inbound parser. */136export type ParseTextShareTargetOptions = {137 names?: Partial<TextShareTargetNames>;138 limits?: Partial<TextShareTargetLimits>;139};140141const defaultNames: TextShareTargetNames = { title: "title", text: "text", url: "url" };142143/**144 * Parse an inbound GET share target as an untrusted draft.145 *146 * Unknown parameters are ignored. Duplicate, oversized, malformed, and147 * non-HTTP(S) values reject the whole draft. The caller must still present the148 * returned draft for explicit user confirmation before persisting anything.149 */150export function parseTextShareTarget(151 input: URL | URLSearchParams | string,152 options: ParseTextShareTargetOptions = {},153): TextShareTargetResult {154 const parameters = input instanceof URL155 ? input.searchParams156 : input instanceof URLSearchParams157 ? input158 : new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);159 const names = { ...defaultNames, ...options.names };160 const limits = { ...TEXT_SHARE_LIMITS, ...options.limits };161 const issues: TextShareTargetIssue[] = [];162163 const read = (field: keyof TextShareTargetNames): string | undefined => {164 const values = parameters.getAll(names[field]);165 if (values.length > 1) {166 issues.push(`duplicate-${field}` as TextShareTargetIssue);167 return undefined;168 }169 const value = values[0]?.trim();170 if (!value) return undefined;171 if (value.length > limits[field]) {172 issues.push(`${field}-too-long` as TextShareTargetIssue);173 return undefined;174 }175 return value;176 };177178 const title = read("title");179 const text = read("text");180 const rawUrl = read("url");181 let url: string | undefined;182 if (rawUrl) {183 try {184 const parsed = new URL(rawUrl);185 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {186 issues.push("unsupported-url-protocol");187 } else {188 url = parsed.href;189 }190 } catch {191 issues.push("invalid-url");192 }193 }194 if (!title && !text && !url && issues.length === 0) {195 issues.push("empty-share");196 }197 if (issues.length > 0) return { ok: false, issues };198 return { ok: true, draft: { title, text, url } };199}