UNPKG

@kellanjs/actioncraft

Version:

Fluent, type-safe builder for Next.js server actions.

387 lines 15.9 kB
import { unstable_rethrow } from "next/navigation.js"; import { safeExecuteCallback } from "./core/callbacks.js"; import { createImplicitReturnErrorResult, createUnhandledErrorResult, } from "./core/errors.js"; import { log } from "./core/logging.js"; import { convertToClientError, serializeRawInput, } from "./core/transformation.js"; import { validateBindArgs, validateInput, validateOutput, } from "./core/validation.js"; import { EXTERNAL_ERROR_TYPES, } from "./types/errors.js"; import { err, isErr, isOk, isResultErr, isResultOk } from "./types/result.js"; // ============================================================================ // CRAFTER CLASS - TYPE-SAFE ACTION BUILDER // ============================================================================ /** * Builder class for creating type-safe server actions with validation, error handling, and callbacks. */ class Crafter { _config; _schemas; _errors; _callbacks; _actionImpl; constructor(config, schemas, errors, callbacks, actionImpl) { this._config = config; this._schemas = schemas; this._errors = errors; this._callbacks = callbacks; this._actionImpl = actionImpl; } // -------------------------------------------------------------------------- // FLUENT API METHODS // -------------------------------------------------------------------------- /** * Defines validation schemas for input, output, and bind arguments. * Resets previously defined actions and callbacks. */ schemas(schemas) { return new Crafter(this._config, schemas, this._errors, {}, undefined); } /** * Defines error functions for returning typed errors from actions. * Resets previously defined actions and callbacks. */ errors(errors) { return new Crafter(this._config, this._schemas, errors, {}, undefined); } /** * Defines the action implementation function containing business logic. * Resets previously defined callbacks. */ action(fn) { return new Crafter(this._config, this._schemas, this._errors, {}, fn); } /** * Defines lifecycle callbacks for action execution. * Must be called after action() for correct type inference. */ callbacks(callbacks) { return new Crafter(this._config, this._schemas, this._errors, callbacks, this._actionImpl); } /** * Builds and returns the final executable action function. */ craft() { if (!this._actionImpl) { throw new Error("Action implementation is not defined. Call .action() before calling .craft()."); } const craftedAction = (...args) => { return this._runAction(args); }; // Attach the action's config for runtime inspection (used by `initial()`) // eslint-disable-next-line @typescript-eslint/no-explicit-any craftedAction.__ac_config = this._config; return craftedAction; } // -------------------------------------------------------------------------- // ACTION EXECUTION // -------------------------------------------------------------------------- /** Orchestrates action execution including validation, business logic, callbacks, and result formatting. */ async _runAction(args) { // We know this exists because craft() verifies it const actionImpl = this._actionImpl; // Extract bindArgs, prevState, and input from the raw args const { bindArgs: rawBindArgs, prevState, input: rawInput, } = this._extractActionArgs(args); // Check for custom error handler const handleThrownErrorFn = this._config.handleThrownError ? (error) => err(this._config.handleThrownError(error)) : null; // Execute onStart callback before any processing await this._executeOnStartCallback({ rawInput, rawBindArgs, prevState, validatedInput: undefined, validatedBindArgs: undefined, }); // Track validation state for error handling let validatedInput = undefined; let validatedBindArgs = undefined; try { // Validate input and return on failure const inputValidation = await this._validateInput(rawInput); if (!isOk(inputValidation)) { await this._executeResultCallbacks(inputValidation, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, }); return this._toActionResult(inputValidation, rawInput); } // Update validation state validatedInput = inputValidation.value; // Validate bound arguments and return on failure const bindArgsValidation = await this._validateBindArgs(rawBindArgs); if (!isOk(bindArgsValidation)) { await this._executeResultCallbacks(bindArgsValidation, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, }); return this._toActionResult(bindArgsValidation, rawInput); } // Update validation state validatedBindArgs = bindArgsValidation.value; // Execute the user's action implementation const actionImplResult = await actionImpl({ input: inputValidation.value, bindArgs: bindArgsValidation.value, errors: this._buildErrorFunctions(), metadata: { rawInput, rawBindArgs, prevState }, }); // Return on `undefined` (implicit return error) if (actionImplResult === undefined) { const implicitReturnError = createImplicitReturnErrorResult(); await this._executeResultCallbacks(implicitReturnError, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, }); return this._toActionResult(implicitReturnError, rawInput); } let finalResult; // Process different return types from the action if (isResultErr(actionImplResult)) { finalResult = actionImplResult; } else { const outputData = isResultOk(actionImplResult) ? actionImplResult.value : actionImplResult; finalResult = await this._validateOutput(outputData); } // Execute callbacks and return final result await this._executeResultCallbacks(finalResult, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, }); // Use validated input for the values field on a successful run const inputForValues = isOk(finalResult) ? this._schemas.inputSchema ? inputValidation.value : rawInput : rawInput; return this._toActionResult(finalResult, inputForValues); } catch (error) { // Re-throw Next.js framework errors unstable_rethrow(error); // Handle unexpected thrown errors try { const errorResult = this._handleThrownError(error, handleThrownErrorFn); await this._executeResultCallbacks(errorResult, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, }); return this._toActionResult(errorResult, rawInput); } catch (handlerError) { // If we catch another error here, then we're done log(this._config.logger, "warn", "Error handling failure - both primary error and error handler threw", { primaryError: error, handlerError }); return this._toActionResult(createUnhandledErrorResult(), rawInput); } } } /** * Extracts bind arguments, previous state, and input from raw action arguments. */ _extractActionArgs(args) { const numBindSchemas = this._schemas.bindSchemas?.length ?? 0; if (this._config.useActionState) { return { bindArgs: args.slice(0, numBindSchemas), prevState: args[numBindSchemas], input: args[numBindSchemas + 1], }; } return { bindArgs: args.slice(0, numBindSchemas), // When useActionState is disabled the prevState parameter is never // present, so we cast to never (or undefined) to satisfy the type. prevState: undefined, input: args[numBindSchemas], }; } // -------------------------------------------------------------------------- // RESULT TRANSFORMATION // -------------------------------------------------------------------------- /** * Transforms internal Result objects to client-facing action result format. */ _toActionResult(result, inputForValues) { // Convert internal errors to client-facing errors const clientResult = isOk(result) ? result : err(convertToClientError(result.error)); // Handle useActionState format (always returns StatefulApiResult) if (this._config.useActionState) { if (isOk(clientResult)) { const successValues = this._schemas.inputSchema ? inputForValues : serializeRawInput(inputForValues); return { success: true, data: clientResult.value, values: successValues, }; } return { success: false, error: clientResult.error, values: serializeRawInput(inputForValues), }; } const format = this._config.resultFormat ?? "api"; // Return functional format if configured if (format === "functional") { return clientResult; } // Default API format if (isOk(clientResult)) { return { success: true, data: clientResult.value, }; } return { success: false, error: clientResult.error, }; } // -------------------------------------------------------------------------- // ERROR HANDLING // -------------------------------------------------------------------------- /** * Handles uncaught exceptions during action execution. */ _handleThrownError(error, customHandler) { const caughtErrorResult = customHandler ? customHandler(error) : createUnhandledErrorResult(); return caughtErrorResult; } // -------------------------------------------------------------------------- // VALIDATION // -------------------------------------------------------------------------- /** * Validates input using the shared helper. */ _validateInput(rawInput) { return validateInput(this._schemas, this._config, rawInput); } /** * Validates bound arguments using the configured bind schemas. */ _validateBindArgs(bindArgs) { return validateBindArgs(this._schemas, this._config, bindArgs); } /** * Validates output data using the configured output schema. */ _validateOutput(data) { return validateOutput(this._schemas, this._config, data); } // -------------------------------------------------------------------------- // CALLBACKS // -------------------------------------------------------------------------- /** * Executes the onStart callback if defined. */ async _executeOnStartCallback(metadata) { const callbacks = this._callbacks; if (callbacks.onStart) { await safeExecuteCallback(() => callbacks.onStart({ metadata }), "onStart", (level, msg, details) => log(this._config.logger, level, msg, details)); } } /** * Executes result-based lifecycle callbacks (onSuccess, onError, onSettled). */ async _executeResultCallbacks(result, metadata) { const callbacks = this._callbacks; // Success path if (isOk(result)) { await safeExecuteCallback(callbacks.onSuccess ? () => callbacks.onSuccess({ data: result.value, metadata }) : undefined, "onSuccess", (level, msg, details) => log(this._config.logger, level, msg, details)); } // Error path if (isErr(result)) { await safeExecuteCallback(callbacks.onError ? () => callbacks.onError({ error: result.error, metadata }) : undefined, "onError", (level, msg, details) => log(this._config.logger, level, msg, details)); } // onSettled always runs, regardless of result const finalResult = this._toActionResult(result); await safeExecuteCallback(callbacks.onSettled ? () => callbacks.onSettled({ result: finalResult, metadata }) : undefined, "onSettled", (level, msg, details) => log(this._config.logger, level, msg, details)); } // -------------------------------------------------------------------------- // UTILITY METHODS // -------------------------------------------------------------------------- /** * Creates error functions that return Result objects when called by action implementations. */ _buildErrorFunctions() { const errorFns = {}; for (const [key, errorDefFn] of Object.entries(this._errors)) { errorFns[key] = ((...args) => err(errorDefFn(...args))); } return errorFns; } } // ============================================================================ // PUBLIC API EXPORTS // ============================================================================ /** * Creates a new Crafter instance for building type-safe server actions. */ export function create(config) { return new Crafter(config ?? {}, {}, {}, {}, undefined); } /** * Creates an appropriate initial state for any action based on its configuration. * * For useActionState actions: returns StatefulApiResult with error and values * For functional format actions: returns Result.err() with error * For regular actions: returns ApiResult with error * * Usage: * - useActionState: const [state, action] = useActionState(myAction, initial(myAction)) * - useState: const [state, setState] = useState(initial(myAction)) */ export function initial(action) { const error = { type: EXTERNAL_ERROR_TYPES.INITIAL_STATE, message: "Action has not been executed yet", }; // Attempt to read the ActionCraft config attached during craft() // eslint-disable-next-line @typescript-eslint/no-explicit-any const cfg = action?.__ac_config; // Functional format -> Result<_, _> if (cfg?.resultFormat === "functional") { return err(error); } // useActionState enabled -> StatefulApiResult if (cfg?.useActionState) { return { success: false, error, values: undefined, }; } // Default ApiResult shape return { success: false, error, }; } //# sourceMappingURL=actioncraft-prev.js.map