UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

814 lines (704 loc) 22.5 kB
/** @import { RemoteForm } from '@sveltejs/kit' */ /** @import { BinaryFormMeta, InternalRemoteFormIssue } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { DEV } from 'esm-env'; import * as devalue from 'devalue'; import { text_decoder, text_encoder } from './utils.js'; /** * Sets a value in a nested object using a path string, mutating the original object * @param {Record<string, any>} object * @param {string} path_string * @param {any} value */ export function set_nested_value(object, path_string, value) { if (path_string.startsWith('n:')) { path_string = path_string.slice(2); value = value === '' ? undefined : parseFloat(value); } else if (path_string.startsWith('b:')) { path_string = path_string.slice(2); value = value === 'on'; } deep_set(object, split_path(path_string), value); } /** * Convert `FormData` into a POJO * @param {FormData} data */ export function convert_formdata(data) { /** @type {Record<string, any>} */ const result = {}; for (let key of data.keys()) { const is_array = key.endsWith('[]'); /** @type {any[]} */ let values = data.getAll(key); if (is_array) key = key.slice(0, -2); if (values.length > 1 && !is_array) { throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`); } // an empty `<input type="file">` will submit a non-existent file, bizarrely values = values.filter( (entry) => typeof entry === 'string' || entry.name !== '' || entry.size > 0 ); if (key.startsWith('n:')) { key = key.slice(2); values = values.map((v) => (v === '' ? undefined : parseFloat(/** @type {string} */ (v)))); } else if (key.startsWith('b:')) { key = key.slice(2); values = values.map((v) => v === 'on'); } set_nested_value(result, key, is_array ? values : values[0]); } return result; } export const BINARY_FORM_CONTENT_TYPE = 'application/x-sveltekit-formdata'; const BINARY_FORM_VERSION = 0; /** * The binary format is as follows: * - 1 byte: Format version * - 4 bytes: Length of the header (u32) * - 2 bytes: Length of the file offset table (u16) * - header: devalue.stringify([data, meta]) * - file offset table: JSON.stringify([offset1, offset2, ...]) (empty if no files) (offsets start from the end of the table) * - file1, file2, ... * @param {Record<string, any>} data * @param {BinaryFormMeta} meta */ export function serialize_binary_form(data, meta) { /** @type {Array<BlobPart>} */ const blob_parts = [new Uint8Array([BINARY_FORM_VERSION])]; /** @type {Array<[file: File, index: number]>} */ const files = []; if (!meta.remote_refreshes?.length) { delete meta.remote_refreshes; } const encoded_header = devalue.stringify([data, meta], { File: (file) => { if (!(file instanceof File)) return; files.push([file, files.length]); return [file.name, file.type, file.size, file.lastModified, files.length - 1]; } }); const encoded_header_buffer = text_encoder.encode(encoded_header); let encoded_file_offsets = ''; if (files.length) { // Sort small files to the front files.sort(([a], [b]) => a.size - b.size); /** @type {Array<number>} */ const file_offsets = new Array(files.length); let start = 0; for (const [file, index] of files) { file_offsets[index] = start; start += file.size; } encoded_file_offsets = JSON.stringify(file_offsets); } const length_buffer = new Uint8Array(4); const length_view = new DataView(length_buffer.buffer); length_view.setUint32(0, encoded_header_buffer.byteLength, true); blob_parts.push(length_buffer.slice()); length_view.setUint16(0, encoded_file_offsets.length, true); blob_parts.push(length_buffer.slice(0, 2)); blob_parts.push(encoded_header_buffer); blob_parts.push(encoded_file_offsets); for (const [file] of files) { blob_parts.push(file); } return { blob: new Blob(blob_parts) }; } /** * @param {Request} request * @returns {Promise<{ data: Record<string, any>; meta: BinaryFormMeta; form_data: FormData | null }>} */ export async function deserialize_binary_form(request) { if (request.headers.get('content-type') !== BINARY_FORM_CONTENT_TYPE) { const form_data = await request.formData(); return { data: convert_formdata(form_data), meta: {}, form_data }; } if (!request.body) { throw new Error('Could not deserialize binary form: no body'); } const reader = request.body.getReader(); /** @type {Array<Promise<Uint8Array<ArrayBuffer> | undefined>>} */ const chunks = []; /** * @param {number} index * @returns {Promise<Uint8Array<ArrayBuffer> | undefined>} */ async function get_chunk(index) { if (index in chunks) return chunks[index]; let i = chunks.length; while (i <= index) { chunks[i] = reader.read().then((chunk) => chunk.value); i++; } return chunks[index]; } /** * @param {number} offset * @param {number} length * @returns {Promise<Uint8Array | null>} */ async function get_buffer(offset, length) { /** @type {Uint8Array} */ let start_chunk; let chunk_start = 0; /** @type {number} */ let chunk_index; for (chunk_index = 0; ; chunk_index++) { const chunk = await get_chunk(chunk_index); if (!chunk) return null; const chunk_end = chunk_start + chunk.byteLength; // If this chunk contains the target offset if (offset >= chunk_start && offset < chunk_end) { start_chunk = chunk; break; } chunk_start = chunk_end; } // If the buffer is completely contained in one chunk, do a subarray if (offset + length <= chunk_start + start_chunk.byteLength) { return start_chunk.subarray(offset - chunk_start, offset + length - chunk_start); } // Otherwise, copy the data into a new buffer const buffer = new Uint8Array(length); buffer.set(start_chunk.subarray(offset - chunk_start)); let cursor = start_chunk.byteLength - offset + chunk_start; while (cursor < length) { chunk_index++; let chunk = await get_chunk(chunk_index); if (!chunk) return null; if (chunk.byteLength > length - cursor) { chunk = chunk.subarray(0, length - cursor); } buffer.set(chunk, cursor); cursor += chunk.byteLength; } return buffer; } const header = await get_buffer(0, 1 + 4 + 2); if (!header) throw new Error('Could not deserialize binary form: too short'); if (header[0] !== BINARY_FORM_VERSION) { throw new Error( `Could not deserialize binary form: got version ${header[0]}, expected version ${BINARY_FORM_VERSION}` ); } const header_view = new DataView(header.buffer, header.byteOffset, header.byteLength); const data_length = header_view.getUint32(1, true); const file_offsets_length = header_view.getUint16(5, true); // Read the form data const data_buffer = await get_buffer(1 + 4 + 2, data_length); if (!data_buffer) throw new Error('Could not deserialize binary form: data too short'); /** @type {Array<number>} */ let file_offsets; /** @type {number} */ let files_start_offset; if (file_offsets_length > 0) { // Read the file offset table const file_offsets_buffer = await get_buffer(1 + 4 + 2 + data_length, file_offsets_length); if (!file_offsets_buffer) throw new Error('Could not deserialize binary form: file offset table too short'); file_offsets = /** @type {Array<number>} */ ( JSON.parse(text_decoder.decode(file_offsets_buffer)) ); files_start_offset = 1 + 4 + 2 + data_length + file_offsets_length; } const [data, meta] = devalue.parse(text_decoder.decode(data_buffer), { File: ([name, type, size, last_modified, index]) => { return new Proxy( new LazyFile( name, type, size, last_modified, get_chunk, files_start_offset + file_offsets[index] ), { getPrototypeOf() { // Trick validators into thinking this is a normal File return File.prototype; } } ); } }); // Read the request body asyncronously so it doesn't stall void (async () => { let has_more = true; while (has_more) { const chunk = await get_chunk(chunks.length); has_more = !!chunk; } })(); return { data, meta, form_data: null }; } /** @implements {File} */ class LazyFile { /** @type {(index: number) => Promise<Uint8Array<ArrayBuffer> | undefined>} */ #get_chunk; /** @type {number} */ #offset; /** * @param {string} name * @param {string} type * @param {number} size * @param {number} last_modified * @param {(index: number) => Promise<Uint8Array<ArrayBuffer> | undefined>} get_chunk * @param {number} offset */ constructor(name, type, size, last_modified, get_chunk, offset) { this.name = name; this.type = type; this.size = size; this.lastModified = last_modified; this.webkitRelativePath = ''; this.#get_chunk = get_chunk; this.#offset = offset; // TODO - hacky, required for private members to be accessed on proxy this.arrayBuffer = this.arrayBuffer.bind(this); this.bytes = this.bytes.bind(this); this.slice = this.slice.bind(this); this.stream = this.stream.bind(this); this.text = this.text.bind(this); } /** @type {ArrayBuffer | undefined} */ #buffer; async arrayBuffer() { this.#buffer ??= await new Response(this.stream()).arrayBuffer(); return this.#buffer; } async bytes() { return new Uint8Array(await this.arrayBuffer()); } /** * @param {number=} start * @param {number=} end * @param {string=} contentType */ slice(start = 0, end = this.size, contentType = this.type) { // https://github.com/nodejs/node/blob/a5f3cd8cb5ba9e7911d93c5fd3ebc6d781220dd8/lib/internal/blob.js#L240 if (start < 0) { start = Math.max(this.size + start, 0); } else { start = Math.min(start, this.size); } if (end < 0) { end = Math.max(this.size + end, 0); } else { end = Math.min(end, this.size); } const size = Math.max(end - start, 0); const file = new LazyFile( this.name, contentType, size, this.lastModified, this.#get_chunk, this.#offset + start ); return file; } stream() { let cursor = 0; let chunk_index = 0; return new ReadableStream({ start: async (controller) => { let chunk_start = 0; let start_chunk = null; for (chunk_index = 0; ; chunk_index++) { const chunk = await this.#get_chunk(chunk_index); if (!chunk) return null; const chunk_end = chunk_start + chunk.byteLength; // If this chunk contains the target offset if (this.#offset >= chunk_start && this.#offset < chunk_end) { start_chunk = chunk; break; } chunk_start = chunk_end; } // If the buffer is completely contained in one chunk, do a subarray if (this.#offset + this.size <= chunk_start + start_chunk.byteLength) { controller.enqueue( start_chunk.subarray(this.#offset - chunk_start, this.#offset + this.size - chunk_start) ); controller.close(); } else { controller.enqueue(start_chunk.subarray(this.#offset - chunk_start)); cursor = start_chunk.byteLength - this.#offset + chunk_start; } }, pull: async (controller) => { chunk_index++; let chunk = await this.#get_chunk(chunk_index); if (!chunk) { controller.error('Could not deserialize binary form: incomplete file data'); controller.close(); return; } if (chunk.byteLength > this.size - cursor) { chunk = chunk.subarray(0, this.size - cursor); } controller.enqueue(chunk); cursor += chunk.byteLength; if (cursor >= this.size) { controller.close(); } } }); } async text() { return text_decoder.decode(await this.arrayBuffer()); } } const path_regex = /^[a-zA-Z_$]\w*(\.[a-zA-Z_$]\w*|\[\d+\])*$/; /** * @param {string} path */ export function split_path(path) { if (!path_regex.test(path)) { throw new Error(`Invalid path ${path}`); } return path.split(/\.|\[|\]/).filter(Boolean); } /** * Check if a property key is dangerous and could lead to prototype pollution * @param {string} key */ function check_prototype_pollution(key) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') { throw new Error( `Invalid key "${key}"` + (DEV ? ': This key is not allowed to prevent prototype pollution.' : '') ); } } /** * Sets a value in a nested object using an array of keys, mutating the original object. * @param {Record<string, any>} object * @param {string[]} keys * @param {any} value */ export function deep_set(object, keys, value) { let current = object; for (let i = 0; i < keys.length - 1; i += 1) { const key = keys[i]; check_prototype_pollution(key); const is_array = /^\d+$/.test(keys[i + 1]); const exists = key in current; const inner = current[key]; if (exists && is_array !== Array.isArray(inner)) { throw new Error(`Invalid array key ${keys[i + 1]}`); } if (!exists) { current[key] = is_array ? [] : {}; } current = current[key]; } const final_key = keys[keys.length - 1]; check_prototype_pollution(final_key); current[final_key] = value; } /** * @param {StandardSchemaV1.Issue} issue * @param {boolean} server Whether this issue came from server validation */ export function normalize_issue(issue, server = false) { /** @type {InternalRemoteFormIssue} */ const normalized = { name: '', path: [], message: issue.message, server }; if (issue.path !== undefined) { let name = ''; for (const segment of issue.path) { const key = /** @type {string | number} */ ( typeof segment === 'object' ? segment.key : segment ); normalized.path.push(key); if (typeof key === 'number') { name += `[${key}]`; } else if (typeof key === 'string') { name += name === '' ? key : '.' + key; } } normalized.name = name; } return normalized; } /** * @param {InternalRemoteFormIssue[]} issues */ export function flatten_issues(issues) { /** @type {Record<string, InternalRemoteFormIssue[]>} */ const result = {}; for (const issue of issues) { (result.$ ??= []).push(issue); let name = ''; if (issue.path !== undefined) { for (const key of issue.path) { if (typeof key === 'number') { name += `[${key}]`; } else if (typeof key === 'string') { name += name === '' ? key : '.' + key; } (result[name] ??= []).push(issue); } } } return result; } /** * Gets a nested value from an object using a path array * @param {Record<string, any>} object * @param {(string | number)[]} path * @returns {any} */ export function deep_get(object, path) { let current = object; for (const key of path) { if (current == null || typeof current !== 'object') { return current; } current = current[key]; } return current; } /** * Creates a proxy-based field accessor for form data * @param {any} target - Function or empty POJO * @param {() => Record<string, any>} get_input - Function to get current input data * @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data * @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues * @param {(string | number)[]} path - Current access path * @returns {any} Proxy object with name(), value(), and issues() methods */ export function create_field_proxy(target, get_input, set_input, get_issues, path = []) { const get_value = () => { return deep_get(get_input(), path); }; return new Proxy(target, { get(target, prop) { if (typeof prop === 'symbol') return target[prop]; // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { return create_field_proxy({}, get_input, set_input, get_issues, [ ...path, parseInt(prop, 10) ]); } const key = build_path_string(path); if (prop === 'set') { const set_func = function (/** @type {any} */ newValue) { set_input(path, newValue); return newValue; }; return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]); } if (prop === 'value') { return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]); } if (prop === 'issues' || prop === 'allIssues') { const issues_func = () => { const all_issues = get_issues()[key === '' ? '$' : key]; if (prop === 'allIssues') { return all_issues?.map((issue) => ({ path: issue.path, message: issue.message })); } return all_issues ?.filter((issue) => issue.name === key) ?.map((issue) => ({ path: issue.path, message: issue.message })); }; return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]); } if (prop === 'as') { /** * @param {string} type * @param {string} [input_value] */ const as_func = (type, input_value) => { const is_array = type === 'file multiple' || type === 'select multiple' || (type === 'checkbox' && typeof input_value === 'string'); const prefix = type === 'number' || type === 'range' ? 'n:' : type === 'checkbox' && !is_array ? 'b:' : ''; // Base properties for all input types /** @type {Record<string, any>} */ const base_props = { name: prefix + key + (is_array ? '[]' : ''), get 'aria-invalid'() { const issues = get_issues(); return key in issues ? 'true' : undefined; } }; // Add type attribute only for non-text inputs and non-select elements if (type !== 'text' && type !== 'select' && type !== 'select multiple') { base_props.type = type === 'file multiple' ? 'file' : type; } // Handle submit and hidden inputs if (type === 'submit' || type === 'hidden') { if (DEV) { if (!input_value) { throw new Error(`\`${type}\` inputs must have a value`); } } return Object.defineProperties(base_props, { value: { value: input_value, enumerable: true } }); } // Handle select inputs if (type === 'select' || type === 'select multiple') { return Object.defineProperties(base_props, { multiple: { value: is_array, enumerable: true }, value: { enumerable: true, get() { return get_value(); } } }); } // Handle checkbox inputs if (type === 'checkbox' || type === 'radio') { if (DEV) { if (type === 'radio' && !input_value) { throw new Error('Radio inputs must have a value'); } if (type === 'checkbox' && is_array && !input_value) { throw new Error('Checkbox array inputs must have a value'); } } return Object.defineProperties(base_props, { value: { value: input_value ?? 'on', enumerable: true }, checked: { enumerable: true, get() { const value = get_value(); if (type === 'radio') { return value === input_value; } if (is_array) { return (value ?? []).includes(input_value); } return value; } } }); } // Handle file inputs if (type === 'file' || type === 'file multiple') { return Object.defineProperties(base_props, { multiple: { value: is_array, enumerable: true }, files: { enumerable: true, get() { const value = get_value(); // Convert File/File[] to FileList-like object if (value instanceof File) { // In browsers, we can create a proper FileList using DataTransfer if (typeof DataTransfer !== 'undefined') { const fileList = new DataTransfer(); fileList.items.add(value); return fileList.files; } // Fallback for environments without DataTransfer return { 0: value, length: 1 }; } if (Array.isArray(value) && value.every((f) => f instanceof File)) { if (typeof DataTransfer !== 'undefined') { const fileList = new DataTransfer(); value.forEach((file) => fileList.items.add(file)); return fileList.files; } // Fallback for environments without DataTransfer /** @type {any} */ const fileListLike = { length: value.length }; value.forEach((file, index) => { fileListLike[index] = file; }); return fileListLike; } return null; } } }); } // Handle all other input types (text, number, etc.) return Object.defineProperties(base_props, { value: { enumerable: true, get() { const value = get_value(); return value != null ? String(value) : ''; } } }); }; return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']); } // Handle property access (nested fields) return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]); } }); } /** * Builds a path string from an array of path segments * @param {(string | number)[]} path * @returns {string} */ export function build_path_string(path) { let result = ''; for (const segment of path) { if (typeof segment === 'number') { result += `[${segment}]`; } else { result += result === '' ? segment : '.' + segment; } } return result; } /** * @param {RemoteForm<any, any>} instance * @deprecated remove in 3.0 */ export function throw_on_old_property_access(instance) { Object.defineProperty(instance, 'field', { value: (/** @type {string} */ name) => { const new_name = name.endsWith('[]') ? name.slice(0, -2) : name; throw new Error( `\`form.field\` has been removed: Instead of \`<input name={form.field('${name}')} />\` do \`<input {...form.fields.${new_name}.as(type)} />\`` ); } }); for (const property of ['input', 'issues']) { Object.defineProperty(instance, property, { get() { const new_name = property === 'issues' ? 'issues' : 'value'; return new Proxy( {}, { get(_, prop) { const prop_string = typeof prop === 'string' ? prop : String(prop); const old = prop_string.includes('[') || prop_string.includes('.') ? `['${prop_string}']` : `.${prop_string}`; const replacement = `.${prop_string}.${new_name}()`; throw new Error( `\`form.${property}\` has been removed: Instead of \`form.${property}${old}\` write \`form.fields${replacement}\`` ); } } ); } }); } }