Files
hapi/web/src/lib/attachmentAdapter.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

243 lines
9.2 KiB
TypeScript

import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachment } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { AttachmentMetadata } from '@/types/api'
import { isImageMimeType } from '@/lib/fileAttachments'
import { randomId } from '@/lib/randomId'
import { getRestoredUploadMetadata } from '@/lib/composer-attachment-drafts'
import type { AttachmentDraftHandoff } from '@/lib/composer-draft-transfer'
/** Composer / share upload ceiling — keep deep-link fetch in sync. */
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
const MAX_PREVIEW_BYTES = 5 * 1024 * 1024
type PendingUploadAttachment = PendingAttachment & {
path?: string
previewUrl?: string
uploadSessionId?: string
}
export function createAttachmentAdapter(
api: ApiClient,
sessionId: string,
resolveSessionId?: () => Promise<string>,
// Always hand off after resume merges into a new session id — even when
// the pick is cancelled — so the caller can navigate off a deleted source.
// Cancellation is re-checked at transfer save time via isCancelled().
onSessionResolved?: (sessionId: string, pending: AttachmentDraftHandoff) => Promise<void>,
): AttachmentAdapter {
const cancelledAttachmentIds = new Set<string>()
const deleteUpload = async (path?: string, uploadSessionId = sessionId) => {
if (!path) return
try {
await api.deleteUploadFile(uploadSessionId, path)
} catch {
// Best effort cleanup
}
}
return {
// assistant-ui uses the exact "*" sentinel for an allow-all adapter.
// "*/*" is forwarded to MIME matching and rejects every file before
// this adapter's add() method can run.
accept: '*',
async *add({ file }): AsyncGenerator<PendingAttachment> {
// Upload paths are scoped to the session that created them. An
// inactive composer may resume into a different session id, so its
// persisted file must follow the normal resolve/transfer flow and
// be uploaded again by the resumed composer. Pathless restored
// metadata still supplies a stable id so draft merge cannot
// duplicate the same File across persistence passes.
const restored = getRestoredUploadMetadata(file)
if (!resolveSessionId && restored?.path) {
yield {
id: restored.id,
type: 'file',
name: file.name,
contentType: file.type || 'application/octet-stream',
file,
status: { type: 'requires-action', reason: 'composer-send' },
path: restored.path,
previewUrl: restored.previewUrl,
uploadSessionId: restored.uploadSessionId,
} as PendingUploadAttachment
return
}
const id = restored?.id ?? randomId()
const contentType = file.type || 'application/octet-stream'
try {
let previewUrl: string | undefined
if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) {
try {
previewUrl = await fileToDataUrl(file)
} catch {
// Preview generation is optional; retry the read for the upload payload below.
}
}
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'running', reason: 'uploading', progress: 0 },
previewUrl
} as PendingUploadAttachment
if (cancelledAttachmentIds.has(id)) {
return
}
if (file.size > MAX_UPLOAD_BYTES) {
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'incomplete', reason: 'error' }
}
return
}
const uploadSessionId = resolveSessionId ? await resolveSessionId() : sessionId
// Resume may already have merged the source session away. Always
// hand off with a live cancellation predicate so transfer can
// drop this id (even if already persisted on the source draft).
if (uploadSessionId !== sessionId && onSessionResolved) {
await onSessionResolved(uploadSessionId, {
id,
file,
previewUrl,
isCancelled: () => cancelledAttachmentIds.has(id),
})
return
}
if (cancelledAttachmentIds.has(id)) {
return
}
const content = previewUrl
? base64FromDataUrl(previewUrl)
: await fileToBase64(file)
if (cancelledAttachmentIds.has(id)) {
return
}
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'running', reason: 'uploading', progress: 50 },
previewUrl
} as PendingUploadAttachment
const result = await api.uploadFile(uploadSessionId, file.name, content, contentType)
if (cancelledAttachmentIds.has(id)) {
if (result.success && result.path) {
await deleteUpload(result.path, uploadSessionId)
}
return
}
if (!result.success || !result.path) {
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'incomplete', reason: 'error' }
}
return
}
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'requires-action', reason: 'composer-send' },
path: result.path,
previewUrl,
uploadSessionId,
} as PendingUploadAttachment
} catch {
yield {
id,
type: 'file',
name: file.name,
contentType,
file,
status: { type: 'incomplete', reason: 'error' }
}
}
},
async remove(attachment: Attachment): Promise<void> {
cancelledAttachmentIds.add(attachment.id)
const path = (attachment as PendingUploadAttachment).path
const uploadSessionId = (attachment as PendingUploadAttachment).uploadSessionId
await deleteUpload(path, uploadSessionId)
},
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
const pending = attachment as PendingUploadAttachment
const path = pending.path
// Build AttachmentMetadata to be sent with the message
const metadata: AttachmentMetadata | undefined = path ? {
id: attachment.id,
filename: attachment.name,
mimeType: attachment.contentType ?? 'application/octet-stream',
size: attachment.file?.size ?? 0,
path,
previewUrl: pending.previewUrl
} : undefined
return {
id: attachment.id,
type: attachment.type,
name: attachment.name,
contentType: attachment.contentType,
status: { type: 'complete' },
// Store metadata as JSON in the text content for extraction by assistant-runtime
content: metadata ? [{ type: 'text', text: JSON.stringify({ __attachmentMetadata: metadata }) }] : []
}
}
}
}
async function fileToBase64(file: File): Promise<string> {
return base64FromDataUrl(await fileToDataUrl(file))
}
function base64FromDataUrl(dataUrl: string): string {
const separatorIndex = dataUrl.indexOf(',')
const base64 = separatorIndex >= 0 ? dataUrl.slice(separatorIndex + 1) : ''
if (!base64) {
throw new Error('Failed to read file')
}
return base64
}
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result as string)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}