clone

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

feat/prf-provision-custody
lofi / package / recipes / web-share.ts
web-share.ts 5.9K
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 title?: string;14 text?: string;15 url?: string;16 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 title?: string;107 text?: string;108 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/** Accepted draft or value-free rejection from an inbound share target. */124export type TextShareTargetResult =125 | { ok: true; draft: TextShareTargetDraft; issues: readonly [] }126 | { ok: false; issues: readonly TextShareTargetIssue[] };127128/** Optional manifest-name and input-limit overrides for the inbound parser. */129export type ParseTextShareTargetOptions = {130 names?: Partial<TextShareTargetNames>;131 limits?: Partial<TextShareTargetLimits>;132};133134const defaultNames: TextShareTargetNames = { title: "title", text: "text", url: "url" };135136/**137 * Parse an inbound GET share target as an untrusted draft.138 *139 * Unknown parameters are ignored. Duplicate, oversized, malformed, and140 * non-HTTP(S) values reject the whole draft. The caller must still present the141 * returned draft for explicit user confirmation before persisting anything.142 */143export function parseTextShareTarget(144 input: URL | URLSearchParams | string,145 options: ParseTextShareTargetOptions = {},146): TextShareTargetResult {147 const parameters = input instanceof URL148 ? input.searchParams149 : input instanceof URLSearchParams150 ? input151 : new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);152 const names = { ...defaultNames, ...options.names };153 const limits = { ...TEXT_SHARE_LIMITS, ...options.limits };154 const issues: TextShareTargetIssue[] = [];155 const draft: TextShareTargetDraft = {};156157 const read = (field: keyof TextShareTargetNames): string | undefined => {158 const values = parameters.getAll(names[field]);159 if (values.length > 1) {160 issues.push(`duplicate-${field}` as TextShareTargetIssue);161 return undefined;162 }163 const value = values[0]?.trim();164 if (!value) return undefined;165 if (value.length > limits[field]) {166 issues.push(`${field}-too-long` as TextShareTargetIssue);167 return undefined;168 }169 return value;170 };171172 draft.title = read("title");173 draft.text = read("text");174 const rawUrl = read("url");175 if (rawUrl) {176 try {177 const parsed = new URL(rawUrl);178 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {179 issues.push("unsupported-url-protocol");180 } else {181 draft.url = parsed.href;182 }183 } catch {184 issues.push("invalid-url");185 }186 }187 if (!draft.title && !draft.text && !draft.url && issues.length === 0) {188 issues.push("empty-share");189 }190 if (issues.length > 0) return { ok: false, issues };191 return { ok: true, draft, issues: [] };192}