@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
387 lines (333 loc) • 10.5 kB
JavaScript
/** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */
/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
import { get_request_store } from '@sveltejs/kit/internal/server';
import { DEV } from 'esm-env';
import {
create_field_proxy,
set_nested_value,
throw_on_old_property_access,
deep_set,
normalize_issue,
flatten_issues
} from '../../../form-utils.js';
import { get_cache, run_remote_function } from './shared.js';
import { ValidationError } from '@sveltejs/kit/internal';
/**
* Creates a form object that can be spread onto a `<form>` element.
*
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
*
* @template Output
* @overload
* @param {() => MaybePromise<Output>} fn
* @returns {RemoteForm<void, Output>}
* @since 2.27
*/
/**
* Creates a form object that can be spread onto a `<form>` element.
*
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
*
* @template {RemoteFormInput} Input
* @template Output
* @overload
* @param {'unchecked'} validate
* @param {(data: Input, issue: InvalidField<Input>) => MaybePromise<Output>} fn
* @returns {RemoteForm<Input, Output>}
* @since 2.27
*/
/**
* Creates a form object that can be spread onto a `<form>` element.
*
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
*
* @template {StandardSchemaV1<RemoteFormInput, Record<string, any>>} Schema
* @template Output
* @overload
* @param {Schema} validate
* @param {(data: StandardSchemaV1.InferOutput<Schema>, issue: InvalidField<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
* @since 2.27
*/
/**
* @template {RemoteFormInput} Input
* @template Output
* @param {any} validate_or_fn
* @param {(data_or_issue: any, issue?: any) => MaybePromise<Output>} [maybe_fn]
* @returns {RemoteForm<Input, Output>}
* @since 2.27
*/
/*@__NO_SIDE_EFFECTS__*/
// @ts-ignore we don't want to prefix `fn` with an underscore, as that will be user-visible
export function form(validate_or_fn, maybe_fn) {
/** @type {any} */
const fn = maybe_fn ?? validate_or_fn;
/** @type {StandardSchemaV1 | null} */
const schema =
!maybe_fn || validate_or_fn === 'unchecked' ? null : /** @type {any} */ (validate_or_fn);
/**
* @param {string | number | boolean} [key]
*/
function create_instance(key) {
/** @type {RemoteForm<Input, Output>} */
const instance = {};
instance.method = 'POST';
Object.defineProperty(instance, 'enhance', {
value: () => {
return { action: instance.action, method: instance.method };
}
});
const button_props = {
type: 'submit',
onclick: () => {}
};
Object.defineProperty(button_props, 'enhance', {
value: () => {
return { type: 'submit', formaction: instance.buttonProps.formaction, onclick: () => {} };
}
});
Object.defineProperty(instance, 'buttonProps', {
value: button_props
});
/** @type {RemoteInfo} */
const __ = {
type: 'form',
name: '',
id: '',
fn: async (data, meta, form_data) => {
// TODO 3.0 remove this warning
if (DEV && !data) {
const error = () => {
throw new Error(
'Remote form functions no longer get passed a FormData object. ' +
"`form` now has the same signature as `query` or `command`, i.e. it expects to be invoked like `form(schema, callback)` or `form('unchecked', callback)`. " +
'The payload of the callback function is now a POJO instead of a FormData object. See https://kit.svelte.dev/docs/remote-functions#form for details.'
);
};
data = {};
for (const key of [
'append',
'delete',
'entries',
'forEach',
'get',
'getAll',
'has',
'keys',
'set',
'values'
]) {
Object.defineProperty(data, key, { get: error });
}
}
/** @type {{ submission: true, input?: Record<string, any>, issues?: InternalRemoteFormIssue[], result: Output }} */
const output = {};
// make it possible to differentiate between user submission and programmatic `field.set(...)` updates
output.submission = true;
const { event, state } = get_request_store();
const validated = await schema?.['~standard'].validate(data);
if (meta.validate_only) {
return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? [];
}
if (validated?.issues !== undefined) {
handle_issues(output, validated.issues, form_data);
} else {
if (validated !== undefined) {
data = validated.value;
}
state.refreshes ??= {};
const issue = create_issues();
try {
output.result = await run_remote_function(
event,
state,
true,
data,
(d) => d,
(data) => (!maybe_fn ? fn() : fn(data, issue))
);
} catch (e) {
if (e instanceof ValidationError) {
handle_issues(output, e.issues, form_data);
} else {
throw e;
}
}
}
// We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads
// where only one form submission is active at the same time
if (!event.isRemoteRequest) {
get_cache(__, state)[''] ??= output;
}
return output;
}
};
Object.defineProperty(instance, '__', { value: __ });
Object.defineProperty(instance, 'action', {
get: () => `?/remote=${__.id}`,
enumerable: true
});
Object.defineProperty(button_props, 'formaction', {
get: () => `?/remote=${__.id}`,
enumerable: true
});
Object.defineProperty(instance, 'fields', {
get() {
const data = get_cache(__)?.[''];
const issues = flatten_issues(data?.issues ?? []);
return create_field_proxy(
{},
() => data?.input ?? {},
(path, value) => {
if (data?.submission) {
// don't override a submission
return;
}
const input =
path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value);
(get_cache(__)[''] ??= {}).input = input;
},
() => issues
);
}
});
// TODO 3.0 remove
if (DEV) {
throw_on_old_property_access(instance);
}
Object.defineProperty(instance, 'result', {
get() {
try {
return get_cache(__)?.['']?.result;
} catch {
return undefined;
}
}
});
// On the server, pending is always 0
Object.defineProperty(instance, 'pending', {
get: () => 0
});
// On the server, buttonProps.pending is always 0
Object.defineProperty(button_props, 'pending', {
get: () => 0
});
Object.defineProperty(instance, 'preflight', {
// preflight is a noop on the server
value: () => instance
});
Object.defineProperty(instance, 'validate', {
value: () => {
throw new Error('Cannot call validate() on the server');
}
});
if (key == undefined) {
Object.defineProperty(instance, 'for', {
/** @type {RemoteForm<any, any>['for']} */
value: (key) => {
const { state } = get_request_store();
const cache_key = __.id + '|' + JSON.stringify(key);
let instance = (state.form_instances ??= new Map()).get(cache_key);
if (!instance) {
instance = create_instance(key);
instance.__.id = `${__.id}/${encodeURIComponent(JSON.stringify(key))}`;
instance.__.name = __.name;
state.form_instances.set(cache_key, instance);
}
return instance;
}
});
}
return instance;
}
return create_instance();
}
/**
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
* @param {readonly StandardSchemaV1.Issue[]} issues
* @param {FormData | null} form_data - null if the form is progressively enhanced
*/
function handle_issues(output, issues, form_data) {
output.issues = issues.map((issue) => normalize_issue(issue, true));
// if it was a progressively-enhanced submission, we don't need
// to return the input — it's already there
if (form_data) {
output.input = {};
for (let key of form_data.keys()) {
// redact sensitive fields
if (/^[.\]]?_/.test(key)) continue;
const is_array = key.endsWith('[]');
const values = form_data.getAll(key).filter((value) => typeof value === 'string');
if (is_array) key = key.slice(0, -2);
set_nested_value(
/** @type {Record<string, any>} */ (output.input),
key,
is_array ? values : values[0]
);
}
}
}
/**
* Creates an invalid function that can be used to imperatively mark form fields as invalid
* @returns {InvalidField<any>}
*/
function create_issues() {
return /** @type {InvalidField<any>} */ (
new Proxy(
/** @param {string} message */
(message) => {
// TODO 3.0 remove
if (typeof message !== 'string') {
throw new Error(
'`invalid` should now be imported from `@sveltejs/kit` to throw validation issues. ' +
"The second parameter provided to the form function (renamed to `issue`) is still used to construct issues, e.g. `invalid(issue.field('message'))`. " +
'For more info see https://github.com/sveltejs/kit/pulls/14768'
);
}
return create_issue(message);
},
{
get(target, prop) {
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
return create_issue_proxy(prop, []);
}
}
)
);
/**
* @param {string} message
* @param {(string | number)[]} path
* @returns {StandardSchemaV1.Issue}
*/
function create_issue(message, path = []) {
return {
message,
path
};
}
/**
* Creates a proxy that builds up a path and returns a function to create an issue
* @param {string | number} key
* @param {(string | number)[]} path
*/
function create_issue_proxy(key, path) {
const new_path = [...path, key];
/**
* @param {string} message
* @returns {StandardSchemaV1.Issue}
*/
const issue_func = (message) => create_issue(message, new_path);
return new Proxy(issue_func, {
get(target, prop) {
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
// Handle array access like invalid.items[0]
if (/^\d+$/.test(prop)) {
return create_issue_proxy(parseInt(prop, 10), new_path);
}
// Handle property access like invalid.field.nested
return create_issue_proxy(prop, new_path);
}
});
}
}