Files
hapi/web/src/lib/shareTransfer.ts
T
a0621194bb feat(web): native/deep-link ingest for /share (GET url, text, title) (#1413)
* feat(web): ingest GET /share?url=&text=&title= deep links

Native companions that cannot POST via Web Share Target can open the
same session picker by synthesizing the IndexedDB transfer client-side.
When id is present, the existing SW path still wins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve share deep-link whitespace; scrub content beside id

Keep GET content strings verbatim when non-empty (match POST form-data).
When id is present with leftover url/text/title, replace to ?id= only so
payload does not linger in the address bar. Query-param contract stays —
fragments would break shipped native companions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): ingest /share deep links via URL fragment, not query

Shared url/text/title must not appear on the HTTP request line — hub
Hono logger (and any access log) records path+query. Native companions
open /share#url=&text=&title=; the client reads the fragment, scrubs it,
and continues with the existing ?id= picker path. Query validateSearch
keeps only id/error (Web Share Target redirect).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep /share hash ingest across StrictMode remount

Capture the fragment in useState and reuse a single putShareTransfer
promise so the first effect's scrub + cleanup cancel does not lose the
deep-link under React.StrictMode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): fetch companion fileUrl into /share transfer files

Native shares cannot put binaries in the hash fragment. Companions host a
one-shot CORS URL and pass fileUrl/fileName/fileType; the share page fetches
bytes into the same IndexedDB files[] as Web Share Target POST.

* fix(web): cap share fileUrl fetch at the composer upload ceiling

Stream fileUrl downloads with Content-Length and body size checks matching
MAX_UPLOAD_BYTES so a crafted deep link cannot buffer unbounded bytes into
IndexedDB. Align native deep-link docs on the fileUrl hand-off vs POST.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): cast streamed fileUrl chunks to BlobPart for tsc

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:36:58 +01:00

386 lines
13 KiB
TypeScript

import { shareTargetPathname } from './sharePath'
import { MAX_UPLOAD_BYTES } from './attachmentAdapter'
/**
* Share-target transfer storage.
*
* Android Chrome's Web Share Target API delivers a multipart POST to
* /share. The service worker can't hand the resulting Blob objects to the
* SPA via window state (the form POST is processed before any window
* exists), so we stash the payload in IndexedDB under a transfer id and
* 303-redirect to /share?id=<transferId>. The SPA route then pulls the
* payload out.
*
* Two concerns live in this module:
*
* 1. Persistence — wraps an IDB object store (`transfers`) with a typed
* put/get/delete and an opportunistic TTL sweep. IDB is used because it
* survives the SW->document hop and accepts Blobs directly; localStorage
* is string-only and would force an expensive base64 round-trip.
*
* 2. Form parsing — `buildSharePayloadFromFormData` and `ingestShareRequest`
* are pure functions that the service worker calls. Keeping them out of
* the SW lifecycle code lets unit tests cover the multipart shape
* without spinning up a real ServiceWorkerGlobalScope.
*/
const DB_NAME = 'hapi-share-transfers'
const DB_VERSION = 1
const STORE = 'transfers'
export const SHARE_TRANSFER_TTL_MS = 60 * 60 * 1000
export type ShareTransferFile = {
name: string
type: string
blob: Blob
}
export type ShareTransferPayload = {
title: string
text: string
url: string
files: ShareTransferFile[]
createdAt: number
}
/**
* Router search for `/share`: only SW redirect fields. Native deep-link
* content must NOT live in the query string — it is logged by hub request
* middleware (Hono `logger()`) and any upstream access log. Companions open
* `/share#url=&text=&title=` instead; see `parseShareHash`.
*/
export type ShareSearch = {
id?: string
error?: string
}
/** Deep-link content fields (hash fragment only). */
export type ShareDeepLinkFields = {
url?: string
text?: string
title?: string
/**
* Companion-hosted one-shot HTTP(S) URL for a shared file (image/video).
* Fragment stays text-sized; the share page fetches bytes into IDB.
* Not logged on the hub request line (fragment-only).
*/
fileUrl?: string
fileName?: string
fileType?: string
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
// Emptiness uses trim, but return the original string so GET ingest
// matches POST form-data (which preserves surrounding whitespace).
return value.trim().length > 0 ? value : undefined
}
/** Router `validateSearch` for `/share` — id/error only. */
export function parseShareSearch(search: Record<string, unknown>): ShareSearch {
const result: ShareSearch = {}
if (typeof search.id === 'string' && search.id) {
result.id = search.id
}
if (typeof search.error === 'string' && search.error) {
result.error = search.error
}
return result
}
/** Parse url/text/title(/file*) from a flat record (hash params or tests). */
export function parseShareDeepLinkFields(
fields: Record<string, unknown>
): ShareDeepLinkFields {
const result: ShareDeepLinkFields = {}
const url = nonEmptyString(fields.url)
if (url) result.url = url
const text = nonEmptyString(fields.text)
if (text) result.text = text
const title = nonEmptyString(fields.title)
if (title) result.title = title
const fileUrl = nonEmptyString(fields.fileUrl)
if (fileUrl) result.fileUrl = fileUrl
const fileName = nonEmptyString(fields.fileName)
if (fileName) result.fileName = fileName
const fileType = nonEmptyString(fields.fileType)
if (fileType) result.fileType = fileType
return result
}
/**
* Read native deep-link content from the URL fragment.
* Fragments are not sent on the HTTP request, so shared text never reaches
* hub access logs. `hash` may include a leading `#`.
*/
export function parseShareHash(hash: string): ShareDeepLinkFields {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
if (!raw) return {}
return parseShareDeepLinkFields(
Object.fromEntries(new URLSearchParams(raw).entries())
)
}
/** Drop the fragment from the address bar after reading (no navigation). */
export function scrubShareHashFromLocation(): void {
if (typeof window === 'undefined') return
if (!window.location.hash) return
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`
)
}
/** True when deep-link content is present (id path is decided separately). */
export function hasShareDeepLinkContent(fields: ShareDeepLinkFields): boolean {
return Boolean(fields.url || fields.text || fields.title || fields.fileUrl)
}
/**
* Text/url/title payload (no fetch). Prefer {@link buildSharePayloadFromDeepLink}
* when `fileUrl` may be present.
*/
export function buildSharePayloadFromSearchFields(
fields: ShareDeepLinkFields,
now: number = Date.now()
): ShareTransferPayload {
return {
title: fields.title ?? '',
text: fields.text ?? '',
url: fields.url ?? '',
files: [],
createdAt: now,
}
}
/**
* Native companion ingest: text fields plus optional `fileUrl` fetch into
* `files[]` (same shape as Web Share Target POST). `fileUrl` must be
* CORS-readable from the HAPI origin (companions send ACAO *).
* Enforces {@link MAX_UPLOAD_BYTES} while streaming so a crafted link cannot
* buffer an unbounded response into IndexedDB before the composer rejects it.
*/
export async function buildSharePayloadFromDeepLink(
fields: ShareDeepLinkFields,
now: number = Date.now(),
deps: { fetch?: typeof fetch } = {}
): Promise<ShareTransferPayload> {
const base = buildSharePayloadFromSearchFields(fields, now)
const fileUrl = fields.fileUrl?.trim()
if (!fileUrl) return base
const doFetch = deps.fetch ?? fetch
const response = await doFetch(fileUrl)
if (!response.ok) {
throw new Error(`share fileUrl fetch failed: ${response.status}`)
}
const contentLength = response.headers.get('content-length')
if (contentLength) {
const declared = Number(contentLength)
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
throw new Error(
`share fileUrl too large: ${declared} bytes (max ${MAX_UPLOAD_BYTES})`
)
}
}
const blob = await readResponseBlobLimited(response, MAX_UPLOAD_BYTES)
const headerType = response.headers.get('content-type')?.split(';')[0]?.trim()
const type = fields.fileType?.trim()
|| headerType
|| blob.type
|| 'application/octet-stream'
const name = fields.fileName?.trim() || guessFileName(fileUrl, type)
return {
...base,
files: [{ name, type, blob }],
}
}
async function readResponseBlobLimited(
response: Response,
maxBytes: number
): Promise<Blob> {
if (!response.body) {
const blob = await response.blob()
if (blob.size > maxBytes) {
throw new Error(
`share fileUrl too large: ${blob.size} bytes (max ${maxBytes})`
)
}
return blob
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new Error(
`share fileUrl too large: exceeds ${maxBytes} bytes`
)
}
chunks.push(value)
}
const contentType = response.headers.get('content-type') ?? undefined
return new Blob(chunks as BlobPart[], { type: contentType })
}
function guessFileName(fileUrl: string, type: string): string {
try {
const path = new URL(fileUrl).pathname
const leaf = path.split('/').filter(Boolean).pop()
if (leaf && leaf.includes('.')) return leaf
} catch {
// ignore invalid URL
}
const subtype = type.split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'bin'
return `shared.${subtype}`
}
type StoredRecord = ShareTransferPayload & { id: string }
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open share-transfer DB'))
})
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T> | null): Promise<T | null> {
return new Promise((resolve, reject) => {
openDb().then((db) => {
const transaction = db.transaction(STORE, mode)
const store = transaction.objectStore(STORE)
const request = run(store)
transaction.oncomplete = () => {
db.close()
resolve(request ? request.result : null)
}
transaction.onerror = () => {
db.close()
reject(transaction.error ?? new Error('share-transfer tx failed'))
}
transaction.onabort = () => {
db.close()
reject(transaction.error ?? new Error('share-transfer tx aborted'))
}
}, reject)
})
}
export async function putShareTransfer(payload: ShareTransferPayload): Promise<string> {
const id = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
const record: StoredRecord = { id, ...payload }
await tx<IDBValidKey>('readwrite', (store) => store.put(record))
return id
}
export async function getShareTransfer(id: string): Promise<ShareTransferPayload | null> {
const record = await tx<StoredRecord | undefined>('readonly', (store) => store.get(id))
if (!record) return null
const { id: _id, ...payload } = record
return payload
}
export async function deleteShareTransfer(id: string): Promise<void> {
await tx<undefined>('readwrite', (store) => store.delete(id))
}
export async function cleanupExpiredShareTransfers(now: number = Date.now()): Promise<number> {
return new Promise((resolve, reject) => {
openDb().then((db) => {
const transaction = db.transaction(STORE, 'readwrite')
const store = transaction.objectStore(STORE)
const cursorReq = store.openCursor()
let removed = 0
cursorReq.onsuccess = () => {
const cursor = cursorReq.result
if (!cursor) return
const value = cursor.value as StoredRecord
if (now - value.createdAt > SHARE_TRANSFER_TTL_MS) {
cursor.delete()
removed++
}
cursor.continue()
}
transaction.oncomplete = () => {
db.close()
resolve(removed)
}
transaction.onerror = () => {
db.close()
reject(transaction.error ?? new Error('share-transfer cleanup failed'))
}
}, reject)
})
}
/**
* Pure form-data -> payload conversion. Exposed for unit tests.
*
* The Web Share Target manifest declares `title`, `text`, `url`, and a
* `files` part. Android Chrome sometimes omits parts the source app didn't
* supply, so each text field falls back to '' and `files` filters out
* non-File entries (string parts named 'files' have been observed when an
* app shares text-only).
*/
export async function buildSharePayloadFromFormData(
formData: FormData,
now: number = Date.now()
): Promise<ShareTransferPayload> {
const title = stringField(formData, 'title')
const text = stringField(formData, 'text')
const url = stringField(formData, 'url')
const fileEntries = formData.getAll('files').filter((entry): entry is File => entry instanceof File)
const files: ShareTransferFile[] = fileEntries.map((file) => ({
name: file.name,
type: file.type || 'application/octet-stream',
blob: file
}))
return { title, text, url, files, createdAt: now }
}
function stringField(formData: FormData, name: string): string {
const value = formData.get(name)
return typeof value === 'string' ? value : ''
}
export type ShareIngestDeps = {
put: (payload: ShareTransferPayload) => Promise<string>
now?: () => number
}
export type ShareIngestResult = { redirectTo: string }
/**
* Service-worker entry point. Reads the multipart form, persists it via the
* injected `put` (defaulting to IndexedDB in production), and returns the
* relative URL to redirect to. The 303 status that converts the POST into
* a GET is set by the SW caller.
*/
export async function ingestShareRequest(
request: Request,
deps: ShareIngestDeps
): Promise<ShareIngestResult> {
const now = deps.now ? deps.now() : Date.now()
const formData = await request.formData()
const payload = await buildSharePayloadFromFormData(formData, now)
const id = await deps.put(payload)
const sharePath = shareTargetPathname()
return { redirectTo: `${sharePath}?id=${encodeURIComponent(id)}` }
}