import { html } from '@hyperspan/html'; import { createRoute, HTTPResponseException, returnHTMLResponse } from './server'; import * as z from 'zod'; import type { Hyperspan as HS } from './types'; import { assetHash, formDataToJSON } from './utils'; import { buildClientJS } from './client/js'; import { validateBody, ZodValidationError } from './middleware'; import { debug } from 'debug'; const log = debug('hyperspan:actions'); const actionsClientJS = await buildClientJS( import.meta.resolve('./client/_hs/hyperspan-actions.client') ); /** * Actions = Form + route handler * Automatically handles and parses form data * * HOW THIS WORKS: * --- * 1. Renders in any template as initial form markup with action.render() * 2. Binds form onSubmit function to custom client JS handling via web component * 3. Submits form with JavaScript fetch() + FormData as normal POST form submission * 4. All validation and save logic is run on the server * 5. Replaces form content in place with HTML response content from server via the Idiomorph library * 6. Handles any Exception thrown on server as error displayed back to user on the page */ export function createAction(params: { name: string; schema: S; }): HS.Action; export function createAction(params: { name: string; schema?: undefined }): HS.Action; export function createAction(params: { name: string; schema?: S; }): HS.Action { const { name, schema } = params; const path = `/__actions/${assetHash(name)}`; let _handler: Parameters['post']>[0] | null = null; let _errorHandler: Parameters['errorHandler']>[0] | null = null; function actionResponseOptions(status?: number) { return { ...(route._serverConfig?.responseOptions ?? {}), ...(route._config?.responseOptions ?? {}), ...(status !== undefined ? { status } : {}), }; } const route = createRoute({ path, name }) .get((c: HS.Context) => api.render(c)) .post( async (c: HS.Context) => { if (!_handler) { throw new Error('Action POST handler not set! Every action must have a POST handler.'); } const data = (c.vars.body as HS.InferActionData) || formDataToJSON(await c.req.formData()) || {}; log('POST handler', { data }); const response = await _handler(c, { data }); log('POST handler response', { response }); if (response instanceof Response) { // Replace redirects with special header because fetch() automatically follows redirects // and we want to redirect the user to the actual full page instead if ([301, 302, 307, 308].includes(response.status)) { response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/'); response.headers.delete('Location'); } } return response; }, { middleware: schema ? [validateBody(schema as unknown as z.ZodObject | z.ZodAny)] : [] } ) /** * Custom error handler for the action since validateBody() throws a HTTPResponseException */ .errorHandler(async (c: HS.Context, err: HTTPResponseException) => { const data = (c.vars.body as HS.InferActionData) || formDataToJSON(await c.req.formData()) || {}; const error = (err._error as ZodValidationError) || err; // Set the status to 400 if it's a ZodValidationError, otherwise 500 (Error thrown by user POST handler) c.res.status = err._error ? 400 : 500; log('errorHandler', { data, error }); return await returnHTMLResponse( c, () => { return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error }); }, actionResponseOptions(c.res.status ?? 400) ); }); // Set the name of the action for the route route._config.name = name; const api: HS.Action = { _kind: 'hsAction', _config: route._config, get _serverConfig() { return route._serverConfig; }, set _serverConfig(config: HS.Config | undefined) { route._serverConfig = config; }, _path() { return path; }, _form: null, /** * Form to render * This will be wrapped in a web component and submitted via fetch() */ form(_formFn) { api._form = _formFn; return api; }, /** * Process form data * * Returns result from form processing if successful * Re-renders form with data and error information otherwise */ post(handler) { _handler = handler; return api; }, /** * Get form renderer method */ render(c, props) { const formContent = api._form ? api._form(c, props || {}) : null; return formContent ? html`${formContent}${actionsClientJS.renderScriptTag()}` : null; }, errorHandler(handler) { _errorHandler = handler; return api; }, use(middleware: HS.MiddlewareFunction, opts?: HS.MiddlewareMethodOptions) { route.use(middleware, opts); return api; }, middleware(middleware: Array, opts?: HS.MiddlewareMethodOptions) { route.middleware(middleware, opts); return api; }, fetch: route.fetch, }; return api; }