diff --git a/.changeset/devtools-upload-viewer.md b/.changeset/devtools-upload-viewer.md new file mode 100644 index 000000000..4beefcf8d --- /dev/null +++ b/.changeset/devtools-upload-viewer.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Show server function calls that send files in the dev toolbar as a Seroval tree. The arguments used to show as one raw string, with the files listed apart. Each file now shows in its place in the arguments, with its name and type. diff --git a/packages/start/src/fns/uploads.ts b/packages/start/src/fns/uploads.ts index fbea77045..729aec57e 100644 --- a/packages/start/src/fns/uploads.ts +++ b/packages/start/src/fns/uploads.ts @@ -9,7 +9,7 @@ import { createPlugin, type Plugin, type SerovalNode } from "seroval"; export const ARGS_PART = "args"; export const FILE_PART = "file"; -const UPLOAD_TAG = "solid-start/upload"; +export const UPLOAD_TAG = "solid-start/upload"; /* * Migration note: seroval's binary mode (`binary.serialize` and diff --git a/packages/start/src/shared/dev-toolbar/functions/SerovalViewer.tsx b/packages/start/src/shared/dev-toolbar/functions/SerovalViewer.tsx index 84ae6a522..a9e21c88e 100644 --- a/packages/start/src/shared/dev-toolbar/functions/SerovalViewer.tsx +++ b/packages/start/src/shared/dev-toolbar/functions/SerovalViewer.tsx @@ -1,11 +1,13 @@ -import type { SerovalNode } from "seroval"; +import { Feature, fromCrossJSON, type SerovalNode } from "seroval"; import { createEffect, createSignal, For, type JSX, Show } from "solid-js"; import { Badge } from "../../ui/Badge.tsx"; +import { BlobViewer } from "./BlobViewer.tsx"; import { HexViewer } from "./HexViewer.tsx"; import { PropertySeparator, SerovalValue } from "./SerovalValue.tsx"; import { SerovalChunkReader } from "../../../fns/serialization.ts"; +import { FILE_PART, UPLOAD_TAG } from "../../../fns/uploads.ts"; import "./SerovalViewer.css"; function LinkIcon(props: JSX.IntrinsicElements["svg"] & { title: string }): JSX.Element { @@ -29,6 +31,8 @@ function LinkIcon(props: JSX.IntrinsicElements["svg"] & { title: string }): JSX. export interface SerovalViewerProps { stream: Request | Response; + /** The form a multipart call sent, so each upload shows its file part. */ + uploads?: FormData; } function getNodeType(node: SerovalNode) { @@ -386,6 +390,7 @@ interface RenderContext { getNode: (index: number) => SerovalNode | undefined; getPromise: (index: number) => Extract | undefined; getStream: (index: number) => Extract[] | undefined; + getUpload: (node: SerovalNode) => Blob | undefined; } function getStreamKeyword(t: 32 | 33 | 34): string { @@ -908,7 +913,15 @@ function TreeValue(props: TreeValueProps): JSX.Element { ); // Plugin = 25, - case 25: + case 25: { + const upload = ctx.getUpload(node); + if (upload) { + return ( + + + + ); + } return ( ); + } // IteratorFactoryInstance = 28, case 28: // AsyncIteratorFactoryInstance = 30, @@ -1035,6 +1049,38 @@ function createSimpleStore>(initial: }; } +/** + * Reads a primitive node, such as a string or a number, in an upload node. + */ +function readPrimitive(node: SerovalNode | undefined): unknown { + if (!node) return undefined; + return fromCrossJSON(node, { refs: new Map(), disabledFeatures: Feature.RegExp }); +} + +/** + * Finds the file part of an upload node written by `createUploadSerializer`, + * and gives it the type, name and last modified time the node holds, as the + * server does. + */ +function getUpload(uploads: FormData | undefined, node: SerovalNode): Blob | undefined { + if (!uploads || node.t !== 25 || node.c !== UPLOAD_TAG) return undefined; + const fields = node.s as { i?: SerovalNode; t?: SerovalNode; n?: SerovalNode; l?: SerovalNode }; + const index = readPrimitive(fields.i); + if (typeof index !== "number") return undefined; + const part = uploads.get(`${FILE_PART}${index}`); + if (!(part instanceof Blob)) return undefined; + // A client before the metadata fields sends only the index. + const type = readPrimitive(fields.t); + if (typeof type !== "string") return part; + const name = readPrimitive(fields.n); + if (typeof name !== "string") return new Blob([part], { type }); + const lastModified = readPrimitive(fields.l); + return new File([part], name, { + type, + lastModified: typeof lastModified === "number" ? lastModified : undefined, + }); +} + export function SerovalViewer(props: SerovalViewerProps): JSX.Element { const [selected, setSelected] = createSignal(); @@ -1128,6 +1174,7 @@ export function SerovalViewer(props: SerovalViewerProps): JSX.Element { getNode: index => references.read(index), getPromise: index => promises.read(index), getStream: index => streams.read(index), + getUpload: node => getUpload(props.uploads, node), }} node={root} seen={[]} diff --git a/packages/start/src/shared/dev-toolbar/functions/index.tsx b/packages/start/src/shared/dev-toolbar/functions/index.tsx index fc6a935ec..620efc16e 100644 --- a/packages/start/src/shared/dev-toolbar/functions/index.tsx +++ b/packages/start/src/shared/dev-toolbar/functions/index.tsx @@ -1,5 +1,6 @@ -import { createMemo, createSignal, For, type JSX, Show } from "solid-js"; +import { createMemo, createResource, createSignal, For, type JSX, Show, Suspense } from "solid-js"; import { BODY_FORMAL_FILE, BODY_FORMAT_KEY, BodyFormat } from "../../../fns/shared.ts"; +import { ARGS_PART } from "../../../fns/uploads.ts"; import { Badge } from "../../ui/Badge.tsx"; import IconButton from "../../ui/IconButton.tsx"; import Placeholder from "../../ui/Placeholder.tsx"; @@ -31,6 +32,32 @@ async function getURLSearchParams(source: Response | Request): Promise props.source.formData()); + return ( + + + {current => { + const args = current.get(ARGS_PART); + return ( + }> + + + ); + }} + + + ); +} + interface ContentViewerProps { source: ServerFunctionRequest | ServerFunctionResponse; } @@ -48,6 +75,8 @@ function ContentViewer(props: ContentViewerProps): JSX.Element { case startType === "true": case startType === BodyFormat.Seroval: return ; + case startType === BodyFormat.SerovalFormData: + return ; case startType === BodyFormat.String: return ; case startType === BodyFormat.File: