keryx 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/classes/Action.ts CHANGED
@@ -50,6 +50,9 @@ export type ActionConstructorInputs = {
50
50
  method?: HTTP_METHOD;
51
51
  };
52
52
 
53
+ /** Per-action timeout in ms (overrides global `config.server.web.actionTimeout`; 0 disables) */
54
+ timeout?: number;
55
+
53
56
  /** Configure this action as a background task/job */
54
57
  task?: {
55
58
  /** Optional recurring frequency in milliseconds */
@@ -85,6 +88,7 @@ export abstract class Action {
85
88
  route: RegExp | string;
86
89
  method: HTTP_METHOD;
87
90
  };
91
+ timeout?: number;
88
92
  task?: {
89
93
  frequency?: number;
90
94
  queue: string;
@@ -95,6 +99,7 @@ export abstract class Action {
95
99
  this.description = args.description ?? `An Action: ${this.name}`;
96
100
  this.inputs = args.inputs;
97
101
  this.middleware = args.middleware ?? [];
102
+ this.timeout = args.timeout;
98
103
  this.mcp = { enabled: true, ...args.mcp };
99
104
  this.web = {
100
105
  route: args.web?.route ?? `/${this.name}`,
@@ -111,11 +116,26 @@ export abstract class Action {
111
116
  * It can be `async`.
112
117
  * Usually the goal of this run method is to return the data that you want to be sent to API consumers.
113
118
  * If error is thrown in this method, it will be logged, caught, and returned to the client as `error`
119
+ *
120
+ * @param params - The validated and coerced action inputs. The type is inferred from the
121
+ * action's `inputs` Zod schema (falls back to `Record<string, unknown>` when no schema is
122
+ * defined). By the time `run` is called, all middleware `runBefore` hooks have already
123
+ * executed and may have mutated the params.
124
+ * @param connection - The connection that initiated this action. Provides access to the
125
+ * caller's session (`connection.session`), subscription state, and raw transport handle.
126
+ * It is `undefined` when the action is invoked outside an HTTP/WebSocket request context
127
+ * (e.g., as a background task via the Resque worker or via `api.actions.run()`).
128
+ * @param abortSignal - An `AbortSignal` tied to the action's timeout. The signal is aborted
129
+ * when the per-action `timeout` (or the global `config.actions.timeout`, default 300 000 ms)
130
+ * elapses. Long-running actions should check `abortSignal.aborted` or pass the signal to
131
+ * cancellable APIs (e.g., `fetch`) to exit promptly. Not provided when timeouts are
132
+ * disabled (`timeout: 0`).
114
133
  * @throws {TypedError} All errors thrown should be TypedError instances
115
134
  */
116
135
  abstract run(
117
136
  params: ActionParams<Action>,
118
137
  connection?: Connection,
138
+ abortSignal?: AbortSignal,
119
139
  ): Promise<any>;
120
140
  }
121
141
 
@@ -76,7 +76,26 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
76
76
  }
77
77
  }
78
78
 
79
- response = await action.run(formattedParams, this);
79
+ const timeoutMs = action.timeout ?? config.actions.timeout;
80
+ if (timeoutMs > 0) {
81
+ const controller = new AbortController();
82
+ const timeoutError = new TypedError({
83
+ message: `Action '${action.name}' timed out after ${timeoutMs}ms`,
84
+ type: ErrorType.CONNECTION_ACTION_TIMEOUT,
85
+ });
86
+ const timeoutPromise = new Promise<never>((_, reject) => {
87
+ setTimeout(() => {
88
+ controller.abort();
89
+ reject(timeoutError);
90
+ }, timeoutMs);
91
+ });
92
+ response = await Promise.race([
93
+ action.run(formattedParams, this, controller.signal),
94
+ timeoutPromise,
95
+ ]);
96
+ } else {
97
+ response = await action.run(formattedParams, this);
98
+ }
80
99
 
81
100
  for (const middleware of action.middleware ?? []) {
82
101
  if (middleware.runAfter) {
@@ -27,6 +27,7 @@ export enum ErrorType {
27
27
  "CONNECTION_CHANNEL_AUTHORIZATION" = "CONNECTION_CHANNEL_AUTHORIZATION",
28
28
  "CONNECTION_CHANNEL_VALIDATION" = "CONNECTION_CHANNEL_VALIDATION",
29
29
 
30
+ "CONNECTION_ACTION_TIMEOUT" = "CONNECTION_ACTION_TIMEOUT",
30
31
  "CONNECTION_RATE_LIMITED" = "CONNECTION_RATE_LIMITED",
31
32
 
32
33
  "CONNECTION_TASK_DEFINITION" = "CONNECTION_TASK_DEFINITION",
@@ -56,6 +57,7 @@ export const ErrorStatusCodes: Record<ErrorType, number> = {
56
57
  [ErrorType.CONNECTION_NOT_SUBSCRIBED]: 406,
57
58
  [ErrorType.CONNECTION_CHANNEL_AUTHORIZATION]: 403,
58
59
  [ErrorType.CONNECTION_CHANNEL_VALIDATION]: 400,
60
+ [ErrorType.CONNECTION_ACTION_TIMEOUT]: 408,
59
61
  [ErrorType.CONNECTION_RATE_LIMITED]: 429,
60
62
 
61
63
  [ErrorType.CONNECTION_TASK_DEFINITION]: 500,
@@ -0,0 +1,7 @@
1
+ import { loadFromEnvIfSet } from "../util/config";
2
+
3
+ export const configActions = {
4
+ timeout: await loadFromEnvIfSet("ACTION_TIMEOUT", 300_000),
5
+ fanOutBatchSize: await loadFromEnvIfSet("ACTION_FAN_OUT_BATCH_SIZE", 100),
6
+ fanOutResultTtl: await loadFromEnvIfSet("ACTION_FAN_OUT_RESULT_TTL", 600),
7
+ };
package/config/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { configActions } from "./actions";
1
2
  import { configChannels } from "./channels";
2
3
  import { configDatabase } from "./database";
3
4
  import { configLogger } from "./logger";
@@ -11,6 +12,7 @@ import { configSession } from "./session";
11
12
  import { configTasks } from "./tasks";
12
13
 
13
14
  export const config = {
15
+ actions: configActions,
14
16
  channels: configChannels,
15
17
  process: configProcess,
16
18
  logger: configLogger,
@@ -5,13 +5,11 @@ import { api, logger } from "../api";
5
5
  import { DEFAULT_QUEUE, type Action } from "../classes/Action";
6
6
  import { Initializer } from "../classes/Initializer";
7
7
  import { ErrorType, TypedError } from "../classes/TypedError";
8
+ import { config } from "../config";
8
9
  import { globLoader } from "../util/glob";
9
10
 
10
11
  const namespace = "actions";
11
12
 
12
- const DEFAULT_FAN_OUT_BATCH_SIZE = 100;
13
- const DEFAULT_FAN_OUT_RESULT_TTL = 600; // 10 minutes in seconds
14
-
15
13
  export type FanOutJob = {
16
14
  action: string;
17
15
  inputs?: TaskInputs;
@@ -130,8 +128,10 @@ export class Actions extends Initializer {
130
128
  return { ...job, queue: resolvedQueue, inputs: job.inputs ?? {} };
131
129
  });
132
130
 
133
- const batchSize = resolvedOptions.batchSize ?? DEFAULT_FAN_OUT_BATCH_SIZE;
134
- const resultTtl = resolvedOptions.resultTtl ?? DEFAULT_FAN_OUT_RESULT_TTL;
131
+ const batchSize =
132
+ resolvedOptions.batchSize ?? config.actions.fanOutBatchSize;
133
+ const resultTtl =
134
+ resolvedOptions.resultTtl ?? config.actions.fanOutResultTtl;
135
135
  const fanOutId = randomUUID();
136
136
  const metaKey = `fanout:${fanOutId}`;
137
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",