keryx 0.21.9 → 0.22.1

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
@@ -181,10 +181,12 @@ export abstract class Action {
181
181
  * action's `inputs` Zod schema (falls back to `Record<string, unknown>` when no schema is
182
182
  * defined). By the time `run` is called, all middleware `runBefore` hooks have already
183
183
  * executed and may have mutated the params.
184
- * @param connection - The connection that initiated this action. Provides access to the
185
- * caller's session (`connection.session`), subscription state, and raw transport handle.
186
- * It is `undefined` when the action is invoked outside an HTTP/WebSocket request context
187
- * (e.g., as a background task via the Resque worker or via `api.actions.run()`).
184
+ * @param connection - The connection that initiated this action. Always defined. Provides
185
+ * access to the caller's session (`connection.session`), subscription state, and raw
186
+ * transport handle. For background tasks (resque worker), `connection.type === "task"`
187
+ * and `connection.session.data` is empty tasks are fresh starts, so actions that
188
+ * need user context should receive it as an input parameter rather than reading from
189
+ * the session.
188
190
  * @param abortSignal - An `AbortSignal` tied to the action's timeout. The signal is aborted
189
191
  * when the per-action `timeout` (or the global `config.actions.timeout`, default 300 000 ms)
190
192
  * elapses. Long-running actions should check `abortSignal.aborted` or pass the signal to
@@ -194,7 +196,7 @@ export abstract class Action {
194
196
  */
195
197
  abstract run(
196
198
  params: ActionParams<Action>,
197
- connection?: Connection,
199
+ connection: Connection,
198
200
  abortSignal?: AbortSignal,
199
201
  ): Promise<any>;
200
202
  }
@@ -279,9 +279,11 @@ export class Resque extends Initializer {
279
279
  };
280
280
 
281
281
  /**
282
- * Wrap an action as a node-resque job. Creates a temporary `Connection` with type `"resque"`,
283
- * converts inputs to a plain object, and runs the action via `connection.act()`. Handles
284
- * fan-out result/error collection and recurring task re-enqueue.
282
+ * Wrap an action as a node-resque job. Creates a fresh `Connection` of type `"task"`
283
+ * with an empty in-memory session stub (tasks are fresh starts no Redis read/write
284
+ * for session), converts inputs to a plain object, and runs the action via
285
+ * `connection.act()`. Handles fan-out result/error collection and recurring task
286
+ * re-enqueue.
285
287
  */
286
288
  wrapActionAsJob = (
287
289
  action: Action,
@@ -291,18 +293,6 @@ export class Resque extends Initializer {
291
293
  pluginOptions: {},
292
294
 
293
295
  perform: async function (params: ActionParams<typeof action>) {
294
- const connection = new Connection(
295
- "resque",
296
- `job:${api.process.name}:${SERVER_JOB_COUNTER++}}`,
297
- );
298
-
299
- const propagatedCorrelationId = params._correlationId as
300
- | string
301
- | undefined;
302
- if (propagatedCorrelationId) {
303
- connection.correlationId = propagatedCorrelationId;
304
- }
305
-
306
296
  const plainParams: Record<string, unknown> =
307
297
  typeof params === "object" && params !== null
308
298
  ? Object.fromEntries(
@@ -312,6 +302,27 @@ export class Resque extends Initializer {
312
302
  )
313
303
  : {};
314
304
 
305
+ const propagatedCorrelationId = plainParams._correlationId as
306
+ | string
307
+ | undefined;
308
+
309
+ const connection = new Connection(
310
+ "task",
311
+ `job:${api.process.name}:${SERVER_JOB_COUNTER++}`,
312
+ );
313
+ if (propagatedCorrelationId) {
314
+ connection.correlationId = propagatedCorrelationId;
315
+ }
316
+ // Synthesize an empty session in-memory — tasks are fresh starts; needed data
317
+ // must come through action params, not session state.
318
+ connection.session = {
319
+ id: `task:${connection.id}`,
320
+ cookieName: config.session.cookieName,
321
+ createdAt: Date.now(),
322
+ data: {},
323
+ };
324
+ connection.sessionLoaded = true;
325
+
315
326
  const fanOutId = plainParams._fanOutId as string | undefined;
316
327
 
317
328
  let response: Awaited<ReturnType<(typeof action)["run"]>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.21.9",
3
+ "version": "0.22.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import type { ActionMiddleware } from "keryx/classes/Action.ts";
3
3
 
4
4
  export const SessionMiddleware: ActionMiddleware = {
5
5
  runBefore: async (_params, connection: Connection<{ userId?: number }>) => {
6
- if (!connection.session || !connection.session.data.userId) {
6
+ if (!connection.session?.data.userId) {
7
7
  throw new TypedError({
8
8
  message: "Session not found",
9
9
  type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
package/testing/index.ts CHANGED
@@ -2,6 +2,14 @@ import { afterAll, beforeAll } from "bun:test";
2
2
  import { api } from "../api";
3
3
  import type { WebServer } from "../servers/web";
4
4
 
5
+ export {
6
+ buildWebSocket,
7
+ createSession,
8
+ createUser,
9
+ subscribeToChannel,
10
+ waitForBroadcastMessages,
11
+ } from "./websocket";
12
+
5
13
  /**
6
14
  * Generous lifecycle hook timeout (15s) for `beforeAll` / `afterAll`.
7
15
  *
@@ -0,0 +1,171 @@
1
+ import { expect } from "bun:test";
2
+ import { api } from "../api";
3
+ import type { WebServer } from "../servers/web";
4
+
5
+ const wsUrl = () => {
6
+ const web = api.servers.servers.find(
7
+ (s: { name: string }) => s.name === "web",
8
+ ) as WebServer | undefined;
9
+ return (web?.url || "")
10
+ .replace("https://", "wss://")
11
+ .replace("http://", "ws://");
12
+ };
13
+
14
+ /**
15
+ * Open a WebSocket against the running test server and return the socket along
16
+ * with a mutable array that accumulates every `message` event as it arrives.
17
+ *
18
+ * The promise resolves once the socket's `open` event fires, so callers can
19
+ * immediately send actions without an additional readiness check.
20
+ *
21
+ * @param options.headers - Request headers to include in the WebSocket upgrade
22
+ * (for example a session cookie). Optional.
23
+ * @returns An object with the open `socket` and the live `messages` array that
24
+ * every subsequent handler populates.
25
+ */
26
+ export const buildWebSocket = async (
27
+ options: { headers?: Record<string, string> } = {},
28
+ ) => {
29
+ const socket = new WebSocket(wsUrl(), { headers: options.headers });
30
+ const messages: MessageEvent[] = [];
31
+ socket.addEventListener("message", (event) => {
32
+ messages.push(event);
33
+ });
34
+ socket.addEventListener("error", (event) => {
35
+ console.error(event);
36
+ });
37
+ await new Promise((resolve) => {
38
+ socket.addEventListener("open", resolve);
39
+ });
40
+ return { socket, messages };
41
+ };
42
+
43
+ /**
44
+ * Send a `user:create` action over the given WebSocket and return the created
45
+ * user from the server's response.
46
+ *
47
+ * Assumes this is the first action sent on the socket — it reads `messages[0]`.
48
+ *
49
+ * @throws {Error} If the server responds with an error payload.
50
+ */
51
+ export const createUser = async (
52
+ socket: WebSocket,
53
+ messages: MessageEvent[],
54
+ name: string,
55
+ email: string,
56
+ password: string,
57
+ ) => {
58
+ socket.send(
59
+ JSON.stringify({
60
+ messageType: "action",
61
+ action: "user:create",
62
+ messageId: 1,
63
+ params: { name, email, password },
64
+ }),
65
+ );
66
+
67
+ while (messages.length === 0) await Bun.sleep(10);
68
+ const response = JSON.parse(messages[0].data);
69
+
70
+ if (response.error) {
71
+ throw new Error(`User creation failed: ${response.error.message}`);
72
+ }
73
+
74
+ return response.response.user;
75
+ };
76
+
77
+ /**
78
+ * Send a `session:create` action over the given WebSocket and return the
79
+ * response payload (user + session).
80
+ *
81
+ * Assumes `createUser` was invoked first — it reads `messages[1]`.
82
+ *
83
+ * @throws {Error} If the server responds with an error payload.
84
+ */
85
+ export const createSession = async (
86
+ socket: WebSocket,
87
+ messages: MessageEvent[],
88
+ email: string,
89
+ password: string,
90
+ ) => {
91
+ socket.send(
92
+ JSON.stringify({
93
+ messageType: "action",
94
+ action: "session:create",
95
+ messageId: 2,
96
+ params: { email, password },
97
+ }),
98
+ );
99
+
100
+ while (messages.length < 2) await Bun.sleep(10);
101
+ const response = JSON.parse(messages[1].data);
102
+
103
+ if (response.error) {
104
+ throw new Error(`Session creation failed: ${response.error.message}`);
105
+ }
106
+
107
+ return response.response;
108
+ };
109
+
110
+ /**
111
+ * Subscribe the socket to a channel and wait for the server's subscribe
112
+ * confirmation.
113
+ *
114
+ * Matches the confirmation by content rather than index, because presence
115
+ * broadcast events (join/leave) delivered via Redis pub/sub can arrive before
116
+ * the subscribe confirmation and shift message indices.
117
+ */
118
+ export const subscribeToChannel = async (
119
+ socket: WebSocket,
120
+ messages: MessageEvent[],
121
+ channel: string,
122
+ ) => {
123
+ socket.send(JSON.stringify({ messageType: "subscribe", channel }));
124
+
125
+ let response: Record<string, any> | undefined;
126
+ while (!response) {
127
+ for (const m of messages) {
128
+ const parsed = JSON.parse(m.data);
129
+ if (parsed.subscribed?.channel === channel) {
130
+ response = parsed;
131
+ break;
132
+ }
133
+ }
134
+ if (!response) await Bun.sleep(10);
135
+ }
136
+ return response;
137
+ };
138
+
139
+ /**
140
+ * Wait briefly and return all broadcast (non-action-reply) messages received on
141
+ * the socket so far, asserting the expected count.
142
+ *
143
+ * Broadcasts are distinguished from action replies by the absence of a
144
+ * `messageId` field. Uses `expect()` internally so callers see a readable
145
+ * failure with the raw broadcast payload dumped to stderr on mismatch.
146
+ *
147
+ * @throws {Error} When the observed broadcast count does not equal
148
+ * `expectedCount`.
149
+ */
150
+ export const waitForBroadcastMessages = async (
151
+ messages: MessageEvent[],
152
+ expectedCount: number,
153
+ ) => {
154
+ await Bun.sleep(100);
155
+
156
+ const broadcastMessages: Record<string, any>[] = [];
157
+ for (const message of messages) {
158
+ const parsedMessage = JSON.parse(message.data);
159
+ if (!parsedMessage.messageId) {
160
+ broadcastMessages.push(parsedMessage);
161
+ }
162
+ }
163
+
164
+ try {
165
+ expect(broadcastMessages.length).toBe(expectedCount);
166
+ } catch (e) {
167
+ console.error(JSON.stringify(broadcastMessages, null, 2));
168
+ throw e;
169
+ }
170
+ return broadcastMessages;
171
+ };