@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
326 lines (286 loc) • 8.73 kB
JavaScript
import * as devalue from 'devalue';
import { DEV } from 'esm-env';
import { json } from '../../../exports/index.js';
import { get_status, normalize_error } from '../../../utils/error.js';
import { is_form_content_type, negotiate } from '../../../utils/http.js';
import { HttpError, Redirect, ActionFailure, SvelteKitError } from '../../control.js';
import { handle_error_and_jsonify } from '../utils.js';
import { with_event } from '../../app/server/event.js';
/** @param {import('@sveltejs/kit').RequestEvent} event */
export function is_action_json_request(event) {
const accept = negotiate(event.request.headers.get('accept') ?? '*/*', [
'application/json',
'text/html'
]);
return accept === 'application/json' && event.request.method === 'POST';
}
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {import('types').SSROptions} options
* @param {import('types').SSRNode['server'] | undefined} server
*/
export async function handle_action_json_request(event, options, server) {
const actions = server?.actions;
if (!actions) {
const no_actions_error = new SvelteKitError(
405,
'Method Not Allowed',
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
);
return action_json(
{
type: 'error',
error: await handle_error_and_jsonify(event, options, no_actions_error)
},
{
status: no_actions_error.status,
headers: {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
// "The server must generate an Allow header field in a 405 status code response"
allow: 'GET'
}
}
);
}
check_named_default_separate(actions);
try {
const data = await call_action(event, actions);
if (__SVELTEKIT_DEV__) {
validate_action_return(data);
}
if (data instanceof ActionFailure) {
return action_json({
type: 'failure',
status: data.status,
// @ts-expect-error we assign a string to what is supposed to be an object. That's ok
// because we don't use the object outside, and this way we have better code navigation
// through knowing where the related interface is used.
data: stringify_action_response(
data.data,
/** @type {string} */ (event.route.id),
options.hooks.transport
)
});
} else {
return action_json({
type: 'success',
status: data ? 200 : 204,
// @ts-expect-error see comment above
data: stringify_action_response(
data,
/** @type {string} */ (event.route.id),
options.hooks.transport
)
});
}
} catch (e) {
const err = normalize_error(e);
if (err instanceof Redirect) {
return action_json_redirect(err);
}
return action_json(
{
type: 'error',
error: await handle_error_and_jsonify(event, options, check_incorrect_fail_use(err))
},
{
status: get_status(err)
}
);
}
}
/**
* @param {HttpError | Error} error
*/
function check_incorrect_fail_use(error) {
return error instanceof ActionFailure
? new Error('Cannot "throw fail()". Use "return fail()"')
: error;
}
/**
* @param {import('@sveltejs/kit').Redirect} redirect
*/
export function action_json_redirect(redirect) {
return action_json({
type: 'redirect',
status: redirect.status,
location: redirect.location
});
}
/**
* @param {import('@sveltejs/kit').ActionResult} data
* @param {ResponseInit} [init]
*/
function action_json(data, init) {
return json(data, init);
}
/**
* @param {import('@sveltejs/kit').RequestEvent} event
*/
export function is_action_request(event) {
return event.request.method === 'POST';
}
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {import('types').SSRNode['server'] | undefined} server
* @returns {Promise<import('@sveltejs/kit').ActionResult>}
*/
export async function handle_action_request(event, server) {
const actions = server?.actions;
if (!actions) {
// TODO should this be a different error altogether?
event.setHeaders({
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
// "The server must generate an Allow header field in a 405 status code response"
allow: 'GET'
});
return {
type: 'error',
error: new SvelteKitError(
405,
'Method Not Allowed',
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
)
};
}
check_named_default_separate(actions);
try {
const data = await call_action(event, actions);
if (__SVELTEKIT_DEV__) {
validate_action_return(data);
}
if (data instanceof ActionFailure) {
return {
type: 'failure',
status: data.status,
data: data.data
};
} else {
return {
type: 'success',
status: 200,
// @ts-expect-error this will be removed upon serialization, so `undefined` is the same as omission
data
};
}
} catch (e) {
const err = normalize_error(e);
if (err instanceof Redirect) {
return {
type: 'redirect',
status: err.status,
location: err.location
};
}
return {
type: 'error',
error: check_incorrect_fail_use(err)
};
}
}
/**
* @param {import('@sveltejs/kit').Actions} actions
*/
function check_named_default_separate(actions) {
if (actions.default && Object.keys(actions).length > 1) {
throw new Error(
'When using named actions, the default action cannot be used. See the docs for more info: https://svelte.dev/docs/kit/form-actions#named-actions'
);
}
}
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {NonNullable<import('types').ServerNode['actions']>} actions
* @throws {Redirect | HttpError | SvelteKitError | Error}
*/
async function call_action(event, actions) {
const url = new URL(event.request.url);
let name = 'default';
for (const param of url.searchParams) {
if (param[0].startsWith('/')) {
name = param[0].slice(1);
if (name === 'default') {
throw new Error('Cannot use reserved action name "default"');
}
break;
}
}
const action = actions[name];
if (!action) {
throw new SvelteKitError(404, 'Not Found', `No action with name '${name}' found`);
}
if (!is_form_content_type(event.request)) {
throw new SvelteKitError(
415,
'Unsupported Media Type',
`Form actions expect form-encoded data — received ${event.request.headers.get(
'content-type'
)}`
);
}
return with_event(event, () => action(event));
}
/** @param {any} data */
function validate_action_return(data) {
if (data instanceof Redirect) {
throw new Error('Cannot `return redirect(...)` — use `redirect(...)` instead');
}
if (data instanceof HttpError) {
throw new Error('Cannot `return error(...)` — use `error(...)` or `return fail(...)` instead');
}
}
/**
* Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context
* @param {any} data
* @param {string} route_id
* @param {import('types').ServerHooks['transport']} transport
*/
export function uneval_action_response(data, route_id, transport) {
const replacer = (/** @type {any} */ thing) => {
for (const key in transport) {
const encoded = transport[key].encode(thing);
if (encoded) {
return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
}
}
};
return try_serialize(data, (value) => devalue.uneval(value, replacer), route_id);
}
/**
* Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context
* @param {any} data
* @param {string} route_id
* @param {import('types').ServerHooks['transport']} transport
*/
function stringify_action_response(data, route_id, transport) {
const encoders = Object.fromEntries(
Object.entries(transport).map(([key, value]) => [key, value.encode])
);
return try_serialize(data, (value) => devalue.stringify(value, encoders), route_id);
}
/**
* @param {any} data
* @param {(data: any) => string} fn
* @param {string} route_id
*/
function try_serialize(data, fn, route_id) {
try {
return fn(data);
} catch (e) {
// If we're here, the data could not be serialized with devalue
const error = /** @type {any} */ (e);
// if someone tries to use `json()` in their action
if (data instanceof Response) {
throw new Error(
`Data returned from action inside ${route_id} is not serializable. Form actions need to return plain objects or fail(). E.g. return { success: true } or return fail(400, { message: "invalid" });`
);
}
// if devalue could not serialize a property on the object, etc.
if ('path' in error) {
let message = `Data returned from action inside ${route_id} is not serializable: ${error.message}`;
if (error.path !== '') message += ` (data.${error.path})`;
throw new Error(message);
}
throw error;
}
}