| 1 | import type { ActResult } from "./actions.js"; |
| 2 | import type { GuestDebugger, GuestPage } from "./guestView.js"; |
| 3 | import type { LocatedRef } from "./refResolver.js"; |
| 4 | import { REGISTRY_KEY } from "./snapshot.js"; |
| 5 | import { acquireDebugger, type DebuggerSender } from "./debuggerLease.js"; |
| 6 | import { runChildFrame } from "./frameRuntime.js"; |
| 7 | |
| 8 | const OBJECT_GROUP = "reasonix-upload"; |
| 9 | |
| 10 | // The executable source is fixed. Snapshot metadata crosses the CDP boundary |
| 11 | // only as call arguments, never as JavaScript source text. |
| 12 | const FIND_UPLOAD_NODE = `function (key, docId, snapshotId, ref) { |
| 13 | const registry = globalThis[key]; |
| 14 | if (!registry || registry.docId !== docId || registry.snapshotId !== snapshotId) return null; |
| 15 | const element = registry.refs.get(ref); |
| 16 | return element && element.isConnected && element.tagName === 'INPUT' && element.type === 'file' ? element : null; |
| 17 | }`; |
| 18 | |
| 19 | interface ExecutionContext { |
| 20 | id: number; |
| 21 | auxData?: { isDefault?: boolean; frameId?: string }; |
| 22 | } |
| 23 | |
| 24 | function objectIdOf(result: unknown): string | null { |
| 25 | const objectId = (result as { result?: { objectId?: unknown } } | null)?.result?.objectId; |
| 26 | return typeof objectId === "string" ? objectId : null; |
| 27 | } |
| 28 | |
| 29 | // Runtime.enable replays executionContextCreated for every live context |
| 30 | // before its own reply, so a listener registered around it sees them all. |
| 31 | async function executionContexts(dbg: GuestDebugger, send: DebuggerSender): Promise<ExecutionContext[]> { |
| 32 | const contexts: ExecutionContext[] = []; |
| 33 | const listener = (_event: unknown, method: string, params: unknown) => { |
| 34 | if (method !== "Runtime.executionContextCreated") return; |
| 35 | const context = (params as { context?: ExecutionContext } | null)?.context; |
| 36 | if (context && typeof context.id === "number") contexts.push(context); |
| 37 | }; |
| 38 | dbg.on("message", listener); |
| 39 | try { |
| 40 | await send("Runtime.enable"); |
| 41 | } finally { |
| 42 | dbg.removeListener("message", listener); |
| 43 | } |
| 44 | return contexts; |
| 45 | } |
| 46 | |
| 47 | // Find the original registry node in its execution context, including the |
| 48 | // main frame's isolated world. A CSS path would silently select a replacement |
| 49 | // input after a rerender or a navigation. |
| 50 | async function findObjectId(dbg: GuestDebugger, located: LocatedRef, owned: boolean, send: DebuggerSender): Promise<string | null> { |
| 51 | const args = [REGISTRY_KEY, located.binding.docId, located.snapshotId, located.ref].map((value) => ({ value })); |
| 52 | try { |
| 53 | for (const context of await executionContexts(dbg, send)) { |
| 54 | if (located.isMainFrame && context.auxData?.isDefault) continue; |
| 55 | try { |
| 56 | const objectId = objectIdOf(await send("Runtime.callFunctionOn", { functionDeclaration: FIND_UPLOAD_NODE, executionContextId: context.id, arguments: args, objectGroup: OBJECT_GROUP })); |
| 57 | if (objectId) return objectId; |
| 58 | } catch { |
| 59 | // A context that vanished mid-walk is simply not the one we want. |
| 60 | } |
| 61 | } |
| 62 | } finally { |
| 63 | if (owned) await send("Runtime.disable").catch(() => undefined); |
| 64 | } |
| 65 | return null; |
| 66 | } |
| 67 | |
| 68 | export async function uploadFiles(page: GuestPage, located: LocatedRef, files: string[], verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 69 | if (located.tag !== "input" || located.type !== "file") return { executed: false, reason: "element is not a file input" }; |
| 70 | const dbg = page.debugger; |
| 71 | if (!located.isMainFrame) { |
| 72 | return await runChildFrame(page, located.frame, "", async (contextId, sessionId, send) => { |
| 73 | const args = [REGISTRY_KEY, located.binding.docId, located.snapshotId, located.ref].map(value => ({ value })); |
| 74 | try { |
| 75 | const objectId = objectIdOf(await send("Runtime.callFunctionOn", { functionDeclaration: FIND_UPLOAD_NODE, executionContextId: contextId, arguments: args, objectGroup: OBJECT_GROUP }, sessionId)); |
| 76 | if (!objectId) return { executed: false, reason: "file input reference expired" }; |
| 77 | verify(); dispatch(); |
| 78 | await send("DOM.setFileInputFiles", { objectId, files }, sessionId); |
| 79 | return { executed: true }; |
| 80 | } finally { await send("Runtime.releaseObjectGroup", { objectGroup: OBJECT_GROUP }, sessionId).catch(() => undefined); } |
| 81 | }) as ActResult; |
| 82 | } |
| 83 | const release = acquireDebugger(dbg); |
| 84 | try { |
| 85 | const objectId = await findObjectId(dbg, located, release.owned, release.send); |
| 86 | if (!objectId) return { executed: false, reason: "file input could not be located in the page" }; |
| 87 | verify(); |
| 88 | dispatch(); |
| 89 | await release.send("DOM.setFileInputFiles", { objectId, files }); |
| 90 | await release.send("Runtime.releaseObjectGroup", { objectGroup: OBJECT_GROUP }).catch(() => undefined); |
| 91 | return { executed: true }; |
| 92 | } finally { |
| 93 | release(); |
| 94 | } |
| 95 | } |
| 96 |