More fullstack fixes (#4737)

* allow `--server` only builds!

* rollback example

* capture `/:route` routes

* fix: properly implement file uploads in forms

* implement desktop form uploads

* Performant serialization of form bytes

* finish up file stuff

* cleanups

* add request headers to streaming context

* remove some unwraps
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-10-07 23:07:43 -07:00
1 parent fc01a002fd
commit 92792aded9
47 files changed
+1241 -703

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
export { setAttributeInner } from "./set_attribute";
export { retrieveFormValues } from "./form";
-63
View File
@@ -1,63 +0,0 @@
// Consistently deserialize forms and form elements for use across web/desktop/mobile
type FormValues = {
valid?: boolean;
values: { [key: string]: FormDataEntryValue[] };
}
export function retrieveValues(event: Event, target: HTMLElement): FormValues {
let contents: FormValues = {
values: {}
};
// If there's a form...
let form = target.closest("form");
// If the target is an input, and the event is input or change, we want to get the value without going through the form
if (form) {
if (
event.type === "input"
|| event.type === "change"
|| event.type === "submit"
|| event.type === "reset"
|| event.type === "click"
) {
contents = retrieveFormValues(form);
}
}
return contents;
}
// todo: maybe encode spaces or something?
// We encode select multiple as a comma separated list which breaks... when there's commas in the values
export function retrieveFormValues(form: HTMLFormElement): FormValues {
const formData = new FormData(form);
const contents: { [key: string]: FormDataEntryValue[] } = {};
formData.forEach((value, key) => {
if (value instanceof File) {
contents[key] = [value.name];
return;
}
if (contents[key]) {
contents[key].push(value);
} else {
contents[key] = [value];
}
});
return {
valid: form.checkValidity(),
values: contents
};
}
export function retrieveSelectValue(target: HTMLSelectElement): string[] {
// there might be multiple...
let options = target.selectedOptions;
let values = [];
for (let i = 0; i < options.length; i++) {
values.push(options[i].value);
}
return values;
}
+116 -34
View File
@@ -4,7 +4,7 @@
// provide since it doesn't have access to the dom.
import { BaseInterpreter, NodeId } from "./core";
import { SerializedEvent, serializeEvent } from "./serialize";
import { SerializedEvent, serializeEvent, SerializedFileData, extractSerializedFormValues, SerializedFormObject } from "./serialize";
// okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope
// we want to extend it, and it technically extends base interpreter. To make typescript happy,
@@ -21,6 +21,7 @@ export class NativeInterpreter extends JSChannel_ {
intercept_link_redirects: boolean;
ipc: any;
edits: WebSocket;
baseUri: string;
eventsPath: string;
headless: boolean;
kickStylesheets: boolean;
@@ -30,9 +31,10 @@ export class NativeInterpreter extends JSChannel_ {
// however, for now we need to support it since WebSockets in fullstack doesn't exist yet
liveview: boolean;
constructor(eventsPath: string, headless: boolean) {
constructor(baseUri: string, headless: boolean) {
super();
this.eventsPath = eventsPath;
this.baseUri = baseUri;
this.eventsPath = `${baseUri}/__events`;
this.kickStylesheets = false;
this.headless = headless;
}
@@ -79,20 +81,76 @@ export class NativeInterpreter extends JSChannel_ {
// Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it
let target_id = getTargetId(target);
if (target_id !== null) {
const message = this.serializeIpcMessage("file_dialog", {
event: "change&input",
accept: target.getAttribute("accept"),
directory: target.getAttribute("webkitdirectory") === "true",
multiple: target.hasAttribute("multiple"),
target: target_id,
bubbles: event.bubbles,
});
this.ipc.postMessage(message);
event.preventDefault();
// Handle file inputs specifically, since we want to get the real file inputs from the native
// dialog and then set the form inputs accordingly.
if (
target instanceof HTMLInputElement &&
target.getAttribute("type") === "file"
) {
// Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it
event.preventDefault();
const contents = serializeEvent(event, target);
const target_name = target.getAttribute("name") || "";
let requestData = {
event: "change&input",
accept: target.getAttribute("accept"),
directory: target.getAttribute("webkitdirectory") === "true",
multiple: target.hasAttribute("multiple"),
target: target_id,
bubbles: event.bubbles,
target_name,
values: contents.values,
};
this.fetchAgainstHost("__file_dialog", requestData).then(response => response.json()).then(resp => {
const formObjects: SerializedFormObject[] = resp.values;
// Create a new DataTransfer to hold the files
const dataTransfer = new DataTransfer();
// We name the file the path, so we can just use the path as the name later on.
for (let formObject of formObjects) {
if (formObject.key == target_name && formObject.file != null) {
const file = new File([], formObject.file.path, {
type: formObject.file.content_type,
lastModified: formObject.file.last_modified,
});
dataTransfer.items.add(file);
}
}
// Set the files on the input
target.files = dataTransfer.files;
let body = {
data: contents,
element: target_id,
bubbles: event.bubbles,
};
// And then dispatch the actual event against the dom
contents.values = formObjects;
this.sendSerializedEvent({
...body,
name: "input",
});
this.sendSerializedEvent({
...body,
name: "change",
});
});
return;
}
}
}
});
// @ts-ignore - wry gives us this
this.ipc = window.ipc;
@@ -103,8 +161,23 @@ export class NativeInterpreter extends JSChannel_ {
super.initialize(root, handler);
}
serializeIpcMessage(method: string, params = {}) {
return JSON.stringify({ method, params });
fetchAgainstHost(path: string, data: { [key: string]: any }): Promise<Response> {
let encoded_data = new TextEncoder().encode(JSON.stringify(data));
const base64data = btoa(
String.fromCharCode.apply(null, Array.from(encoded_data))
);
return fetch(`${this.baseUri}/${path}`, {
method: "GET",
headers: {
"x-dioxus-data": base64data
}
});
}
sendIpcMessage(method: string, params = {}) {
const body = JSON.stringify({ method, params });
this.ipc.postMessage(body);
}
scrollTo(id: NodeId, options: ScrollIntoViewOptions): boolean {
@@ -181,11 +254,14 @@ export class NativeInterpreter extends JSChannel_ {
// Windows drag-n-drop fix code. Called by wry drag-n-drop handler over the event loop.
handleWindowsDragDrop() {
// @ts-ignore
if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true,
cancelable: true,
});
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
let data = new DataTransfer();
@@ -199,23 +275,29 @@ export class NativeInterpreter extends JSChannel_ {
cancelable: true,
dataTransfer: data,
});
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragDropEvent);
// @ts-ignore
window.dxDragLastElement = null;
}
}
handleWindowsDragOver(xPos, yPos) {
handleWindowsDragOver(xPos: number, yPos: number) {
const displayScaleFactor = window.devicePixelRatio || 1;
xPos /= displayScaleFactor;
yPos /= displayScaleFactor;
const element = document.elementFromPoint(xPos, yPos);
// @ts-ignore
if (element != window.dxDragLastElement) {
// @ts-ignore
if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true,
cancelable: true,
});
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
}
@@ -224,17 +306,21 @@ export class NativeInterpreter extends JSChannel_ {
cancelable: true,
});
element.dispatchEvent(dragOverEvent);
// @ts-ignore
window.dxDragLastElement = element;
}
}
handleWindowsDragLeave() {
// @ts-ignore
if (window.dxDragLastElement) {
const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true,
cancelable: true,
});
// @ts-ignore
window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
// @ts-ignore
window.dxDragLastElement = null;
}
}
@@ -266,31 +352,30 @@ export class NativeInterpreter extends JSChannel_ {
handleEvent(event: Event, name: string, bubbles: boolean) {
const target = event.target!;
const realId = getTargetId(target)!;
const element = getTargetId(target)!;
const contents = serializeEvent(event, target);
// Handle the event on the virtualdom and then preventDefault if it also preventsDefault
// Some listeners
let body = {
name: name,
name,
data: contents,
element: realId,
element,
bubbles,
};
// liveview does not have synchronous event handling, so we need to send the event to the host
if (this.liveview) {
// Okay, so the user might've requested some files to be read
if (
target instanceof HTMLInputElement &&
(event.type === "change" || event.type === "input")
) {
if (target.getAttribute("type") === "file") {
this.readFiles(target, contents, bubbles, realId, name);
return;
}
if (
this.liveview &&
target instanceof HTMLInputElement &&
(event.type === "change" || event.type === "input")
) {
if (target.getAttribute("type") === "file") {
this.readFiles(target, contents, bubbles, element, name);
return;
}
}
const response = this.sendSerializedEvent(body);
// capture/prevent default of the event if the virtualdom wants to
if (response) {
@@ -316,8 +401,7 @@ export class NativeInterpreter extends JSChannel_ {
bubbles: boolean;
}): EventSyncResult | void {
if (this.liveview) {
const message = this.serializeIpcMessage("user_event", body);
this.ipc.postMessage(message);
this.sendIpcMessage("user_event", body);
} else {
// Run the event handler on the virtualdom
return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body));
@@ -337,9 +421,7 @@ export class NativeInterpreter extends JSChannel_ {
const href = a_element.getAttribute("href");
if (href !== "" && href !== null && href !== undefined) {
this.ipc.postMessage(
this.serializeIpcMessage("browser_open", { href })
);
this.sendIpcMessage("browser_open", { href })
}
}
}
+104 -23
View File
@@ -1,11 +1,13 @@
// Handle serialization of the event data across the IPC boundarytype SerializedEvent = {};
import { retrieveSelectValue, retrieveValues } from "./form";
// Handle serialization of the event data across the IPC boundary
//
// Note that for files, we don't serialize their contents, expecting the renderer itself to handle
// reading the file contents if needed. Reading files is an async operation, and should be done lazily
// if the app needs it.
export type AppTouchEvent = TouchEvent;
export type SerializedEvent = {
values?: { [key: string]: FormDataEntryValue[] };
values?: SerializedFormObject[];
value?: string;
[key: string]: any;
};
@@ -17,7 +19,7 @@ export function serializeEvent(
let contents = {};
// merge the object into the contents
let extend = (obj: any) => (contents = { ...contents, ...obj });
let extend = (obj: SerializedEvent) => (contents = { ...contents, ...obj });
if (event instanceof WheelEvent) {
extend(serializeWheelEvent(event));
@@ -84,22 +86,20 @@ export function serializeEvent(
// If there's any files, we need to serialize them
if (event instanceof DragEvent) {
// let files: { [key: string]: Uint8Array } = {};
// if (event.dataTransfer && event.dataTransfer.files) {
// files["a"] = new Uint8Array(0);
// // files = {
// // entries: Array.from(event.dataTransfer.files).map((file) => {
// // return {
// // name: file.name,
// // type: file.type,
// // size: file.size,
// // last_modified: file.lastModified,
// // };
// // }
// // };
// // files = await serializeFileList(event.dataTransfer.files);
// }
// extend({ files: files });
let files: SerializedFormObject[] = [];
if (event.dataTransfer && event.dataTransfer.files) {
for (let i = 0; i < event.dataTransfer.files.length; i++) {
let file = event.dataTransfer.files[i];
let data: SerializedFileData = {
path: file.name,
size: file.size,
last_modified: file.lastModified,
content_type: file.type,
}
files.push({ key: file.name, file: data });
}
}
extend({ files });
}
if (event.type === "scroll") {
@@ -173,7 +173,7 @@ function serializeInputEvent(
// Attempt to retrieve the values from the form
if (target instanceof HTMLElement) {
let values = retrieveValues(event, target);
let values = extractSerializedFormValues(event, target);
contents.values = values.values;
contents.valid = values.valid;
}
@@ -376,4 +376,85 @@ function serializeScrollEvent(event: Event): SerializedEvent {
client_width: clientWidth,
client_height: clientHeight,
};
}
}
export type SerializedFormData = {
valid?: boolean;
values: SerializedFormObject[];
}
export type SerializedFormObject = {
key: string;
text?: string;
file?: SerializedFileData;
}
export type SerializedFileData = {
// we store the real path of the file as the name.
// This makes it possible to simply read the file during event handling
path: string;
size?: number;
last_modified?: number;
content_type?: string;
contents?: string; // base64 encoded, if present. not required to br present
};
export function extractSerializedFormValues(event: Event, target: HTMLElement): SerializedFormData {
let contents: SerializedFormData = {
values: []
};
// If there's a form...
let form = target.closest("form");
// If the target is an input, and the event is input or change, we want to get the value without going through the form
if (form) {
if (
event.type === "input"
|| event.type === "change"
|| event.type === "submit"
|| event.type === "reset"
|| event.type === "click"
) {
contents = retrieveFormValues(form);
}
}
return contents;
}
// todo: maybe encode spaces or something?
// We encode select multiple as a comma separated list which breaks... when there's commas in the values
function retrieveFormValues(form: HTMLFormElement): SerializedFormData {
const formData = new FormData(form);
const contents: SerializedFormObject[] = [];
formData.forEach((value, key) => {
if (value instanceof File) {
let fileData: SerializedFileData = {
path: value.name,
size: value.size,
last_modified: value.lastModified,
content_type: value.type,
};
contents.push({ key, file: fileData });
} else {
contents.push({ key, text: value });
}
});
return {
valid: form.checkValidity(),
values: contents
};
}
export function retrieveSelectValue(target: HTMLSelectElement): string[] {
// there might be multiple...
let options = target.selectedOptions;
let values = [];
for (let i = 0; i < options.length; i++) {
values.push(options[i].value);
}
return values;
}