UNPKG

@kellanjs/actioncraft

Version:

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

354 lines 14.7 kB
import { safeExecuteCallback } from "../core/callbacks.js"; import { createUnhandledErrorResult, createImplicitReturnErrorResult, } from "../core/errors.js"; import { log } from "../core/logging.js"; import { serializeRawInput, convertToClientError, } from "../core/transformation.js"; import { validateInput, validateBindArgs, validateOutput, } from "../core/validation.js"; import {} from "../types/errors.js"; import { err, isOk, isResultOk, isResultErr, isErr } from "../types/result.js"; import { INTERNAL } from "./internal.js"; import { unstable_rethrow } from "next/navigation.js"; // ============================================================================ // EXECUTOR CLASS - Build and execute your action // ============================================================================ export class Executor { _config; _schemas; _errors; _callbacks; _handler; _actionId; constructor(crafter) { this._config = crafter[INTERNAL]().config; this._schemas = crafter[INTERNAL]().schemas; this._errors = crafter[INTERNAL]().errors; this._callbacks = crafter[INTERNAL]().callbacks; this._handler = crafter[INTERNAL]().handler; } /** * Builds and returns the final executable server action. */ craft() { if (!this._handler) { throw new Error("A handler implementation is required"); } // Generate a unique ID for this action instance this._actionId = this._generateActionId(); const craftedAction = (...args) => { return this._runAction(args); }; // Attach the action's config and ID for runtime inspection // eslint-disable-next-line @typescript-eslint/no-explicit-any craftedAction.__ac_config = this._config; // eslint-disable-next-line @typescript-eslint/no-explicit-any craftedAction.__ac_id = this._actionId; return craftedAction; } /** * Generates a unique identifier for this action instance. */ _generateActionId() { return crypto.randomUUID(); } // -------------------------------------------------------------------------- // ACTION EXECUTION // -------------------------------------------------------------------------- /** * Orchestrates action execution (validation, business logic, callbacks, and result formatting.) */ async _runAction(args) { // We know these exist because craft() creates/verifies them const handler = this._handler; const actionId = this._actionId; // 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), actionId) : null; // Track validation state for error handling let validatedInput = undefined; let validatedBindArgs = undefined; try { // Execute onStart callback before any processing await this._executeOnStartCallback({ rawInput, rawBindArgs, prevState, validatedInput: undefined, validatedBindArgs: undefined, actionId, }); // Validate input and return on failure const inputValidation = await this._validateInput(rawInput); if (!isOk(inputValidation)) { await this._executeResultCallbacks(inputValidation, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); 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, actionId, }); return this._toActionResult(bindArgsValidation, rawInput); } // Update validation state validatedBindArgs = bindArgsValidation.value; // Execute the user's action handler const handlerResult = await handler({ input: inputValidation.value, bindArgs: bindArgsValidation.value, errors: this._buildErrorFunctions(), metadata: { rawInput, rawBindArgs, prevState, actionId, }, }); // Return on `undefined` (implicit return error) if (handlerResult === undefined) { const implicitReturnError = createImplicitReturnErrorResult(actionId); await this._executeResultCallbacks(implicitReturnError, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); return this._toActionResult(implicitReturnError, rawInput); } let finalResult; // Process different return types from the action if (isResultErr(handlerResult)) { // Ensure error result has correct action ID finalResult = this._ensureResultActionId(handlerResult); } else { const outputData = isResultOk(handlerResult) ? handlerResult.value : handlerResult; finalResult = await this._validateOutput(outputData); } // Execute callbacks and return final result await this._executeResultCallbacks(finalResult, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); // 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, actionId, }); 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(actionId), 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], }; } // For regular actions (non-useActionState), the input is the first argument after bind args // If there are no bind schemas, the input is the first argument (args[0]) 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), this._actionId); // 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, __ac_id: this._actionId, }; } return { success: false, error: clientResult.error, values: serializeRawInput(inputForValues), __ac_id: this._actionId, }; } 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, __ac_id: this._actionId, }; } return { success: false, error: clientResult.error, __ac_id: this._actionId, }; } // -------------------------------------------------------------------------- // ERROR HANDLING // -------------------------------------------------------------------------- /** * Handles uncaught exceptions during action execution. */ _handleThrownError(error, customHandler) { const caughtErrorResult = customHandler ? customHandler(error) : createUnhandledErrorResult(this._actionId); return caughtErrorResult; } // -------------------------------------------------------------------------- // VALIDATION // -------------------------------------------------------------------------- /** * Validates input using the shared helper. */ _validateInput(rawInput) { return validateInput(this._schemas, this._config, rawInput, this._actionId); } /** * Validates bound arguments using the configured bind schemas. */ _validateBindArgs(bindArgs) { return validateBindArgs(this._schemas, this._config, bindArgs, this._actionId); } /** * Validates output data using the configured output schema. */ _validateOutput(data) { return validateOutput(this._schemas, this._config, data, this._actionId); } // -------------------------------------------------------------------------- // 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 // -------------------------------------------------------------------------- /** * Ensures a Result object has the correct action ID. */ _ensureResultActionId(result) { if (!result.__ac_id || result.__ac_id === "unknown") { return { ...result, __ac_id: this._actionId, }; } return result; } /** * Creates error functions that return a Result object when called by the action handler. */ _buildErrorFunctions() { const errorFns = {}; for (const [key, errorDefFn] of Object.entries(this._errors)) { errorFns[key] = ((...args) => err(errorDefFn(...args), this._actionId)); } return errorFns; } } //# sourceMappingURL=executor.js.map