@kellanjs/actioncraft
Version:
Fluent, type-safe builder for Next.js server actions.
354 lines • 14.7 kB
JavaScript
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