durable-actors 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <div align="center">
1
+ <div align="left">
2
2
  <a href="https://github.com/TerseAI/durable-actors/blob/main/.github/assets/team-agent.gif">
3
3
  <picture>
4
4
  <source media="(prefers-reduced-motion: reduce)" srcset="https://raw.githubusercontent.com/TerseAI/durable-actors/main/.github/assets/team-agent.png">
@@ -9,7 +9,7 @@
9
9
  <h1>Durable Actors</h1>
10
10
 
11
11
  <p><strong>Durable state for collaborative apps and AI agents.</strong></p>
12
- <p>TypeScript actors. Rust runtime. Built by Terse.</p>
12
+ <p>TypeScript actors. Rust runtime.</p>
13
13
 
14
14
  <p>
15
15
  <a href="https://github.com/TerseAI/durable-actors/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/TerseAI/durable-actors?style=flat&amp;logo=github&amp;color=f5a623"></a>
@@ -97,17 +97,9 @@ console.log(await counter.increment())
97
97
 
98
98
  For complete sample applications, see [AI Chat](https://github.com/TerseAI/durable-actors/tree/main/examples/ai-chat), [Collaborative documents](https://github.com/TerseAI/durable-actors/tree/main/examples/documents), and [Chatroom](https://github.com/TerseAI/durable-actors/tree/main/examples/chat).
99
99
 
100
- ## Define an Actor
100
+ ## Define an actor
101
101
 
102
- Install `ai` in your actor project:
103
-
104
- ```sh
105
- pnpm install ai
106
- # Or with npm:
107
- npm install ai
108
- ```
109
-
110
- Define and export actors in your actor project’s `src/actors.ts`, the default entrypoint loaded by `durable-actors dev`. For example, a chat history actor:
102
+ Install `ai` in your actor project, then define actors in `src/actors.ts`. This chat history stores completed exchanges in one persisted array:
111
103
 
112
104
  ```ts
113
105
  import type { UIMessage } from "ai"
@@ -120,16 +112,15 @@ export class ChatHistory extends Actor {
120
112
  return this.messages
121
113
  }
122
114
 
123
- async append(message: UIMessage) {
124
- this.messages.push(message)
125
- return this.messages
115
+ async append(...messages: UIMessage[]) {
116
+ this.messages.push(...messages)
126
117
  }
127
118
  }
128
119
  ```
129
120
 
130
- ## Stream from the backend (Express)
121
+ ## Stream from your backend
131
122
 
132
- After adding `ChatHistory`, rerun `npx durable-actors generate` in your application and use its generated client:
123
+ Generate a type-safe client with `npx durable-actors generate`. In your Express backend, load the conversation, stream a reply, and save the prompt and reply together when the stream succeeds:
133
124
 
134
125
  ```ts
135
126
  import { openai } from "@ai-sdk/openai"
@@ -149,7 +140,8 @@ app.post("/api/chat", async (request, response) => {
149
140
  const [message] = await validateUIMessages({ messages: [request.body.messages.at(-1)] })
150
141
  if (message.role !== "user") return response.sendStatus(400)
151
142
  const chat = actors.ChatHistory.get(request.body.id)
152
- const messages = await chat.append(message)
143
+ const messages = [...(await chat.load()), message]
144
+ let failed = false
153
145
  const result = streamText({
154
146
  model: openai("gpt-5-mini"),
155
147
  messages: await convertToModelMessages(messages)
@@ -160,15 +152,23 @@ app.post("/api/chat", async (request, response) => {
160
152
  stream: result.stream,
161
153
  originalMessages: messages,
162
154
  generateMessageId: generateId,
155
+ onError: () => {
156
+ failed = true
157
+ return "Reply failed. Check the API key and try again."
158
+ },
163
159
  onEnd: async ({ responseMessage, outcome }) => {
164
- if (outcome.status === "completed") await chat.append(responseMessage)
160
+ if (!failed && outcome.status === "completed") await chat.append(message, responseMessage)
165
161
  }
166
162
  })
167
163
  })
168
164
  })
169
165
  ```
170
166
 
171
- ## Connect the frontend (React)
167
+ Failed or interrupted attempts are not saved. Each completed exchange is persisted in one actor call.
168
+
169
+ ## Connect the frontend
170
+
171
+ The AI SDK's `useChat` handles streaming and retries:
172
172
 
173
173
  ```tsx
174
174
  import { useChat } from "@ai-sdk/react"
@@ -177,7 +177,7 @@ import type { UIMessage } from "ai"
177
177
  const history: UIMessage[] = await fetch("/api/chat/lobby").then(response => response.json())
178
178
 
179
179
  function Chat() {
180
- const { messages, sendMessage, status } = useChat({ id: "lobby", messages: history })
180
+ const { messages, sendMessage, regenerate, status, error } = useChat({ id: "lobby", messages: history })
181
181
  const busy = status === "submitted" || status === "streaming"
182
182
 
183
183
  return (
@@ -187,14 +187,11 @@ function Chat() {
187
187
  {message.role}: {message.parts.map(part => (part.type === "text" ? part.text : "")).join("")}
188
188
  </p>
189
189
  ))}
190
- <form
191
- action={async form => {
192
- await sendMessage({ text: String(form.get("message")) })
193
- }}
194
- >
190
+ <form action={async form => { await sendMessage({ text: String(form.get("message")) }) }}>
195
191
  <input name="message" aria-label="Message" required disabled={busy} />
196
192
  <button disabled={busy}>Send</button>
197
193
  </form>
194
+ {error && <p role="alert">{error.message} <button onClick={() => regenerate()} disabled={busy}>Retry</button></p>}
198
195
  </>
199
196
  )
200
197
  }
@@ -1,11 +1,25 @@
1
+ import { watch } from "chokidar";
1
2
  interface ActorSourceWatcherOptions {
2
3
  projectDirectory: string;
3
4
  dataDirectory?: string;
4
5
  }
5
- interface ActorSourceWatcher {
6
+ declare function watchActorSources(options: ActorSourceWatcherOptions, refresh: () => Promise<void>): Promise<ActorSourceWatcher>;
7
+ declare class ActorSourceWatcher {
8
+ private readonly options;
9
+ private readonly refresh;
10
+ private readonly createWatcher;
11
+ private readonly warn;
12
+ private watcher?;
13
+ private timer?;
14
+ private updates;
15
+ private closing?;
16
+ private disabled;
17
+ constructor(options: ActorSourceWatcherOptions, refresh: () => Promise<void>, createWatcher: typeof watch, warn: (message: string) => void);
18
+ start(): Promise<void>;
6
19
  close(): Promise<void>;
20
+ private scheduleRefresh;
21
+ private waitUntilReady;
22
+ private disable;
7
23
  }
8
- declare function watchActorSources(options: ActorSourceWatcherOptions, refresh: () => Promise<void>): Promise<ActorSourceWatcher>;
9
- export { watchActorSources };
10
- export type { ActorSourceWatcher };
24
+ export { ActorSourceWatcher, watchActorSources };
11
25
  //# sourceMappingURL=actor-source-watcher.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"actor-source-watcher.d.ts","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"AAGA,UAAU,yBAAyB;IAC/B,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,UAAU,kBAAkB;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACzB;AAED,iBAAe,iBAAiB,CAC5B,OAAO,EAAE,yBAAyB,EAClC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC7B,OAAO,CAAC,kBAAkB,CAAC,CAkC7B;AAqBD,OAAO,EAAE,iBAAiB,EAAE,CAAA;AAC5B,YAAY,EAAE,kBAAkB,EAAE,CAAA"}
1
+ {"version":3,"file":"actor-source-watcher.d.ts","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,EAAE,MAAM,UAAU,CAAA;AAGhD,UAAU,yBAAyB;IAC/B,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,iBAAe,iBAAiB,CAC5B,OAAO,EAAE,yBAAyB,EAClC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC7B,OAAO,CAAC,kBAAkB,CAAC,CAI7B;AAED,cAAM,kBAAkB;IAQhB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAVzB,OAAO,CAAC,OAAO,CAAC,CAAW;IAC3B,OAAO,CAAC,KAAK,CAAC,CAA+B;IAC7C,OAAO,CAAC,OAAO,CAAoB;IACnC,OAAO,CAAC,OAAO,CAAC,CAAe;IAC/B,OAAO,CAAC,QAAQ,CAAQ;gBAGH,OAAO,EAAE,yBAAyB,EAClC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC5B,aAAa,EAAE,OAAO,KAAK,EAC3B,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI;IAG9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsBtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,cAAc;YAgBR,OAAO;CAUxB;AAyBD,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,CAAA"}
@@ -1,39 +1,97 @@
1
1
  import { watch } from "chokidar";
2
2
  import path from "node:path";
3
3
  async function watchActorSources(options, refresh) {
4
- const watcher = watch(options.projectDirectory, {
5
- ignored: (watchedPath, stats) => ignoredActorPath(watchedPath, options) ||
6
- (stats !== undefined && !stats.isDirectory() && (!stats.isFile() || !isActorSource(watchedPath))),
7
- ignoreInitial: true,
8
- followSymlinks: false,
9
- atomic: true,
10
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }
11
- });
12
- let timer;
13
- let updates = Promise.resolve();
14
- watcher.on("all", (_event, changedPath) => {
15
- if (!isActorSource(changedPath))
4
+ const watcher = new ActorSourceWatcher(options, refresh, watch, message => console.warn(message));
5
+ await watcher.start();
6
+ return watcher;
7
+ }
8
+ class ActorSourceWatcher {
9
+ options;
10
+ refresh;
11
+ createWatcher;
12
+ warn;
13
+ watcher;
14
+ timer;
15
+ updates = Promise.resolve();
16
+ closing;
17
+ disabled = false;
18
+ constructor(options, refresh, createWatcher, warn) {
19
+ this.options = options;
20
+ this.refresh = refresh;
21
+ this.createWatcher = createWatcher;
22
+ this.warn = warn;
23
+ }
24
+ async start() {
25
+ try {
26
+ this.watcher = this.createWatcher(this.options.projectDirectory, {
27
+ ignored: (watchedPath, stats) => ignoredActorPath(watchedPath, this.options) ||
28
+ (stats !== undefined && !stats.isDirectory() && (!stats.isFile() || !isActorSource(watchedPath))),
29
+ ignoreInitial: true,
30
+ followSymlinks: false,
31
+ atomic: true,
32
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }
33
+ });
34
+ this.watcher.on("all", (_event, changedPath) => this.scheduleRefresh(changedPath));
35
+ await this.waitUntilReady(this.watcher);
36
+ }
37
+ catch (error) {
38
+ if (isWatchLimit(error))
39
+ await this.disable(error);
40
+ else {
41
+ await this.close();
42
+ throw error;
43
+ }
44
+ }
45
+ }
46
+ async close() {
47
+ this.disabled = true;
48
+ clearTimeout(this.timer);
49
+ if (!this.closing) {
50
+ this.closing = this.watcher?.close() ?? Promise.resolve();
51
+ // Chokidar removes listeners on close, but pending filesystem operations can still emit errors.
52
+ this.watcher?.on("error", () => { });
53
+ }
54
+ await this.closing;
55
+ await this.updates;
56
+ }
57
+ scheduleRefresh(changedPath) {
58
+ if (this.disabled || !isActorSource(changedPath))
16
59
  return;
17
- clearTimeout(timer);
18
- timer = setTimeout(() => {
19
- updates = updates.then(refresh).catch(reportWatchError);
60
+ clearTimeout(this.timer);
61
+ this.timer = setTimeout(() => {
62
+ this.updates = this.updates.then(this.refresh).catch(reportWatchError);
20
63
  }, 75);
21
- });
22
- await new Promise((resolve, reject) => {
23
- watcher.once("ready", resolve);
24
- watcher.once("error", reject);
25
- }).catch(async (error) => {
26
- await watcher.close();
27
- throw error;
28
- });
29
- watcher.on("error", reportWatchError);
30
- return {
31
- async close() {
32
- clearTimeout(timer);
33
- await watcher.close();
34
- await updates;
35
- }
36
- };
64
+ }
65
+ waitUntilReady(watcher) {
66
+ return new Promise((resolve, reject) => {
67
+ let ready = false;
68
+ watcher.once("ready", () => {
69
+ ready = true;
70
+ resolve();
71
+ });
72
+ watcher.on("error", error => {
73
+ if (this.disabled)
74
+ return;
75
+ if (isWatchLimit(error))
76
+ void this.disable(error).then(resolve, reject);
77
+ else if (!ready)
78
+ reject(error);
79
+ else
80
+ reportWatchError(error);
81
+ });
82
+ });
83
+ }
84
+ async disable(error) {
85
+ if (!this.disabled)
86
+ this.warn(`Actor source watcher reached the OS file/watch limit (${error.code}). Automatic reload is disabled; ` +
87
+ "the development server will continue running. Restart it to apply source changes. " +
88
+ "Close other watchers or raise your OS file/watch limit, then restart to restore automatic reload. " +
89
+ "Use --no-watch to disable watching explicitly.");
90
+ await this.close();
91
+ }
92
+ }
93
+ function isWatchLimit(error) {
94
+ return error instanceof Error && "code" in error && ["EMFILE", "ENFILE", "ENOSPC"].includes(String(error.code));
37
95
  }
38
96
  function ignoredActorPath(candidate, options) {
39
97
  const relative = path.relative(options.projectDirectory, candidate);
@@ -55,5 +113,5 @@ function isActorSource(candidate) {
55
113
  function reportWatchError(error) {
56
114
  console.error(`Actor source update failed: ${error instanceof Error ? error.message : String(error)}`);
57
115
  }
58
- export { watchActorSources };
116
+ export { ActorSourceWatcher, watchActorSources };
59
117
  //# sourceMappingURL=actor-source-watcher.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"actor-source-watcher.js","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"actor-source-watcher.js","sourceRoot":"","sources":["../../src/cli/actor-source-watcher.ts"],"names":[],"mappings":"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"}
@@ -10,7 +10,7 @@
10
10
  "check": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "durable-actors": "0.5.2"
13
+ "durable-actors": "0.5.3"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^22.0.0",
@@ -29,36 +29,14 @@ Open [localhost:3000](http://127.0.0.1:3000), send a message, and reload after t
29
29
 
30
30
  ## Save the conversation
31
31
 
32
- [ChatHistory](src/actors.ts) keeps one conversation per actor ID:
32
+ [ChatHistory](src/actors.ts) stores messages in one `@Persisted` array. The [backend](src/backend.ts) streams the model's reply, then saves the user message and completed reply together in one actor call.
33
33
 
34
- ```ts
35
- import type { UIMessage } from "ai"
36
- import { Actor, Persisted } from "durable-actors"
37
-
38
- export class ChatHistory extends Actor {
39
- @Persisted private messages: UIMessage[] = []
40
-
41
- async load() {
42
- return this.messages
43
- }
44
-
45
- async append(message: UIMessage) {
46
- this.messages.push(message)
47
- return this.messages
48
- }
49
- }
50
- ```
51
-
52
- ## Stream the reply
53
-
54
- The [Express backend](src/backend.ts) appends the user message, sends the saved conversation to the model, and streams the reply. It saves the assistant message when the reply completes.
55
-
56
- The [React client](src/Chat.tsx) loads saved messages and uses `useChat` to display the stream. In-progress streams are not resumed after a reload.
34
+ Failed or interrupted attempts are not saved. The [React client](src/Chat.tsx) shows the error and uses the AI SDK's built-in retry action. Reloading restores only completed exchanges; streams are not resumed.
57
35
 
58
36
  The lobby is shared and has no authentication. Add authentication and chat ownership checks before using it for private conversations.
59
37
 
60
38
  ## Development
61
39
 
62
- Both processes read `.env`; actor state lives in `.durable-actors/`. Actor code reloads automatically; restart `npm run dev` after editing the actor class imported by the backend. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](../README.md).
40
+ Both processes read `.env`; actor state lives in `.durable-actors/`. Actor code reloads automatically; restart `npm run dev` after editing the actor class imported by the backend. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
63
41
 
64
42
  `npm run build` checks TypeScript and builds the frontend.
@@ -15,7 +15,7 @@
15
15
  "@ai-sdk/react": "^4.0.100",
16
16
  "ai": "^7.0.97",
17
17
  "express": "^5.2.1",
18
- "durable-actors": "0.5.2",
18
+ "durable-actors": "0.5.3",
19
19
  "react": "^19.3.0",
20
20
  "react-dom": "^19.3.0"
21
21
  },
@@ -6,7 +6,7 @@ import type { UIMessage } from "ai"
6
6
  const initialMessages: UIMessage[] = await fetch("/api/chat/lobby").then(response => response.json())
7
7
 
8
8
  function Chat() {
9
- const { messages, sendMessage, status, error } = useChat({ id: "lobby", messages: initialMessages })
9
+ const { messages, sendMessage, regenerate, status, error } = useChat({ id: "lobby", messages: initialMessages })
10
10
  const busy = status === "submitted" || status === "streaming"
11
11
 
12
12
  return (
@@ -29,7 +29,14 @@ function Chat() {
29
29
  </label>
30
30
  <button disabled={busy}>{busy ? "Thinking…" : "Send"}</button>
31
31
  </form>
32
- {error && <p role="alert">{error.message}</p>}
32
+ {error && (
33
+ <p role="alert">
34
+ {error.message}{" "}
35
+ <button onClick={() => regenerate()} disabled={busy}>
36
+ Retry
37
+ </button>
38
+ </p>
39
+ )}
33
40
  </main>
34
41
  )
35
42
  }
@@ -8,8 +8,7 @@ export class ChatHistory extends Actor {
8
8
  return this.messages
9
9
  }
10
10
 
11
- async append(message: UIMessage) {
12
- this.messages.push(message)
13
- return this.messages
11
+ async append(...messages: UIMessage[]) {
12
+ this.messages.push(...messages)
14
13
  }
15
14
  }
@@ -19,7 +19,8 @@ app.post("/api/chat", async (request, response) => {
19
19
  const [message] = await validateUIMessages({ messages: [request.body.messages.at(-1)] })
20
20
  if (message.role !== "user") return response.sendStatus(400)
21
21
  const chat = ChatHistory.get(request.body.id)
22
- const messages = await chat.append(message)
22
+ const messages = [...(await chat.load()), message]
23
+ let failed = false
23
24
  const result = streamText({
24
25
  model: openai("gpt-5-mini"),
25
26
  messages: await convertToModelMessages(messages)
@@ -30,8 +31,12 @@ app.post("/api/chat", async (request, response) => {
30
31
  stream: result.stream,
31
32
  originalMessages: messages,
32
33
  generateMessageId: generateId,
34
+ onError: () => {
35
+ failed = true
36
+ return "Reply failed. Check the API key and try again."
37
+ },
33
38
  onEnd: async ({ responseMessage, outcome }) => {
34
- if (outcome.status === "completed") await chat.append(responseMessage)
39
+ if (!failed && outcome.status === "completed") await chat.append(message, responseMessage)
35
40
  }
36
41
  })
37
42
  })
@@ -69,6 +69,6 @@ Everyone joins as a guest. Add authentication and room access checks before issu
69
69
 
70
70
  ## Development
71
71
 
72
- Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](../README.md).
72
+ Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
73
73
 
74
74
  `npm run build` generates clients, checks TypeScript, and builds the frontend.
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "express": "^5.2.1",
15
- "durable-actors": "0.5.2",
15
+ "durable-actors": "0.5.3",
16
16
  "react": "^19.3.0",
17
17
  "react-dom": "^19.3.0"
18
18
  },
@@ -48,6 +48,6 @@ Editing pauses while disconnected. Reload to reconnect; unsaved edits are not st
48
48
 
49
49
  ## Development
50
50
 
51
- Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](../README.md).
51
+ Both processes read `.env`; saved state lives in `.durable-actors/`. Actor code reloads automatically. After changing public actor types, restart `npm run dev` to regenerate the client. For multiple examples, set distinct `PORT`, `DURABLE_ACTORS_PORT`, and matching control-plane URLs; see [Run the examples together](https://github.com/TerseAI/durable-actors/tree/main/examples#run-the-examples-together).
52
52
 
53
53
  `npm run build` generates clients, checks TypeScript, and builds the frontend.
@@ -19,7 +19,7 @@
19
19
  "@tiptap/y-tiptap": "^3.0.7",
20
20
  "express": "^5.2.1",
21
21
  "lib0": "^0.2.117",
22
- "durable-actors": "0.5.2",
22
+ "durable-actors": "0.5.3",
23
23
  "react": "^19.3.0",
24
24
  "react-dom": "^19.3.0",
25
25
  "yjs": "^13.6.32"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "durable-actors",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Provider-neutral actors for sandbox compute",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,7 +93,7 @@
93
93
  "vite": "^8.3.0",
94
94
  "ws": "^8.18.3",
95
95
  "zod": "^4.3.6",
96
- "durable-actors-observer": "0.5.2"
96
+ "durable-actors-observer": "0.5.3"
97
97
  },
98
98
  "devDependencies": {
99
99
  "@types/node": "^22.0.0",