@ixo/oracle-runtime-workers 0.2.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.
Files changed (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * UCAN authentication for the Workers shell — a port of the Node runtime's
3
+ * `AuthHeaderMiddleware` (`modules/auth/*`), same headers, same semantics:
4
+ *
5
+ * - Primary: `Authorization: Bearer <invocation>` + `X-Auth-Type: ucan` — a
6
+ * user-self-signed root invocation for `{ can: '*', with: 'ixo:oracle' }`
7
+ * addressed to this oracle's DID. Proves WHO is calling; short TTL.
8
+ * - Fallback: `x-ucan-delegation: <base64 CAR>` — accepted alone for legacy
9
+ * clients and always used for downstream authorization when present. A
10
+ * delegation is only trusted when its issuer equals the authenticated DID.
11
+ *
12
+ * The returned `userDid` is always the cryptographically recovered signer,
13
+ * never a client-claimed value. `did:ixo` keys resolve through Blocksync.
14
+ * Verdicts are cached per isolate keyed by a hash of the token, bounded by
15
+ * the token's own expiry.
16
+ */
17
+ import {
18
+ createIxoDIDResolver,
19
+ createUCANValidator,
20
+ defineCapability,
21
+ InMemoryInvocationStore,
22
+ } from '@ixo/ucan';
23
+
24
+ /**
25
+ * One replay-protection store per isolate, WITHOUT the library's hourly
26
+ * `setInterval` sweep: a pending timer keeps a Durable Object resident, and
27
+ * this code also runs inside the user object (socket CONNECT auth), where a
28
+ * leaked interval per CONNECT blocked WebSocket hibernation entirely. Expired
29
+ * entries are dropped lazily on lookup and swept every N validations instead.
30
+ */
31
+ const invocationStore = new InMemoryInvocationStore({
32
+ enableAutoCleanup: false,
33
+ });
34
+ const SWEEP_EVERY = 500;
35
+ let validations = 0;
36
+
37
+ function sweepInvocationStore(): void {
38
+ validations += 1;
39
+ if (validations % SWEEP_EVERY === 0) void invocationStore.cleanup();
40
+ }
41
+
42
+ export const DEFAULT_UCAN_AUTH_MAX_TTL_SECONDS = 900;
43
+ const THREE_MINUTES_MS = 3 * 60 * 1000;
44
+ const ORACLE_AUTH_RESOURCE = 'ixo:oracle';
45
+ const OracleAuthCapability = defineCapability({ can: '*', protocol: 'ixo:' });
46
+
47
+ export interface AuthConfig {
48
+ oracleDid: string;
49
+ blocksyncUri: string;
50
+ maxTtlSeconds?: number;
51
+ }
52
+
53
+ export interface AuthResult {
54
+ userDid: string;
55
+ /** Raw delegation CAR when the client supplied a valid one for this user. */
56
+ delegation?: string;
57
+ delegationExpiration?: number;
58
+ /** Which artifact authenticated the request. */
59
+ via: 'invocation' | 'delegation';
60
+ }
61
+
62
+ export type AuthOutcome =
63
+ | { ok: true; auth: AuthResult }
64
+ | { ok: false; status: number; error: string };
65
+
66
+ interface CacheEntry<T> {
67
+ value: T;
68
+ expiresAt: number;
69
+ }
70
+
71
+ /** Tiny bounded TTL cache — per isolate, lost on eviction (that's fine). */
72
+ class TtlCache<T> {
73
+ private readonly map = new Map<string, CacheEntry<T>>();
74
+ constructor(private readonly max = 5000) {}
75
+ get(key: string): T | undefined {
76
+ const hit = this.map.get(key);
77
+ if (!hit) return undefined;
78
+ if (hit.expiresAt <= Date.now()) {
79
+ this.map.delete(key);
80
+ return undefined;
81
+ }
82
+ return hit.value;
83
+ }
84
+ set(key: string, value: T, ttlMs: number): void {
85
+ if (this.map.size >= this.max) {
86
+ const oldest = this.map.keys().next().value;
87
+ if (oldest !== undefined) this.map.delete(oldest);
88
+ }
89
+ this.map.set(key, { value, expiresAt: Date.now() + ttlMs });
90
+ }
91
+ }
92
+
93
+ const invocationCache = new TtlCache<{ userDid: string; expiration: number }>();
94
+ const delegationCache = new TtlCache<{
95
+ userDid: string;
96
+ expiration?: number;
97
+ }>();
98
+
99
+ async function sha256(text: string): Promise<string> {
100
+ const digest = await crypto.subtle.digest(
101
+ 'SHA-256',
102
+ new TextEncoder().encode(text),
103
+ );
104
+ return Array.from(new Uint8Array(digest))
105
+ .map((b) => b.toString(16).padStart(2, '0'))
106
+ .join('');
107
+ }
108
+
109
+ async function validateInvocation(
110
+ invocation: string,
111
+ cfg: AuthConfig,
112
+ ): Promise<
113
+ | { ok: true; userDid: string; expiration: number }
114
+ | { ok: false; error: string }
115
+ > {
116
+ const key = await sha256(invocation);
117
+ const cached = invocationCache.get(key);
118
+ if (cached) return { ok: true, ...cached };
119
+
120
+ sweepInvocationStore();
121
+ const validator = await createUCANValidator({
122
+ serverDid: cfg.oracleDid,
123
+ rootIssuers: ['*'],
124
+ didResolver: createIxoDIDResolver({ indexerUrl: cfg.blocksyncUri }),
125
+ invocationStore,
126
+ });
127
+ const result = await validator.validate(
128
+ invocation,
129
+ OracleAuthCapability,
130
+ ORACLE_AUTH_RESOURCE,
131
+ );
132
+ if (!result.ok)
133
+ return {
134
+ ok: false,
135
+ error: `[${result.error?.code}] ${result.error?.message}`,
136
+ };
137
+ if (!result.invoker)
138
+ return { ok: false, error: 'Invocation validated without an invoker DID' };
139
+ if (typeof result.expiration !== 'number' || !isFinite(result.expiration)) {
140
+ return { ok: false, error: 'Auth invocation must declare an expiration' };
141
+ }
142
+ const nowSeconds = Math.floor(Date.now() / 1000);
143
+ const maxTtl = cfg.maxTtlSeconds ?? DEFAULT_UCAN_AUTH_MAX_TTL_SECONDS;
144
+ if (result.expiration > nowSeconds + maxTtl) {
145
+ return {
146
+ ok: false,
147
+ error: `Auth invocation TTL exceeds maximum of ${maxTtl}s`,
148
+ };
149
+ }
150
+ const verdict = { userDid: result.invoker, expiration: result.expiration };
151
+ invocationCache.set(
152
+ key,
153
+ verdict,
154
+ Math.max(1000, (result.expiration - nowSeconds) * 1000),
155
+ );
156
+ return { ok: true, ...verdict };
157
+ }
158
+
159
+ async function validateDelegation(
160
+ header: string,
161
+ cfg: AuthConfig,
162
+ ): Promise<
163
+ | { ok: true; userDid: string; expiration?: number }
164
+ | { ok: false; error: string }
165
+ > {
166
+ const key = await sha256(header);
167
+ const cached = delegationCache.get(key);
168
+ if (cached) return { ok: true, ...cached };
169
+
170
+ sweepInvocationStore();
171
+ const validator = await createUCANValidator({
172
+ serverDid: cfg.oracleDid,
173
+ rootIssuers: [],
174
+ didResolver: createIxoDIDResolver({ indexerUrl: cfg.blocksyncUri }),
175
+ invocationStore,
176
+ });
177
+ const result = await validator.validateDelegation(header);
178
+ if (!result.ok)
179
+ return {
180
+ ok: false,
181
+ error: `[${result.error?.code}] ${result.error?.message}`,
182
+ };
183
+ if (!result.invoker)
184
+ return { ok: false, error: 'Delegation validated without an invoker DID' };
185
+ const verdict = { userDid: result.invoker, expiration: result.expiration };
186
+ const ttlMs =
187
+ typeof result.expiration === 'number'
188
+ ? Math.max(1000, result.expiration * 1000 - Date.now())
189
+ : THREE_MINUTES_MS;
190
+ delegationCache.set(key, verdict, Math.min(ttlMs, THREE_MINUTES_MS));
191
+ return { ok: true, ...verdict };
192
+ }
193
+
194
+ function extractInvocation(headers: Headers): string | null {
195
+ const authType = headers.get('x-auth-type');
196
+ const authorization = headers.get('authorization');
197
+ if (!authorization || authType?.toLowerCase() !== 'ucan') return null;
198
+ const [scheme, token] = authorization.split(' ', 2);
199
+ if (scheme?.toLowerCase() !== 'bearer' || !token) return null;
200
+ return token.trim();
201
+ }
202
+
203
+ /** Authenticate one request. Never throws. */
204
+ export async function authenticate(
205
+ headers: Headers,
206
+ cfg: AuthConfig,
207
+ ): Promise<AuthOutcome> {
208
+ const invocation = extractInvocation(headers);
209
+ const delegationHeader = headers.get('x-ucan-delegation')?.trim() || null;
210
+
211
+ if (!invocation && !delegationHeader) {
212
+ return {
213
+ ok: false,
214
+ status: 401,
215
+ error:
216
+ 'Missing Authorization (UCAN invocation) or x-ucan-delegation header',
217
+ };
218
+ }
219
+
220
+ let userDid: string | null = null;
221
+ let via: AuthResult['via'] = 'delegation';
222
+
223
+ if (invocation) {
224
+ const inv = await validateInvocation(invocation, cfg);
225
+ if (!inv.ok)
226
+ return {
227
+ ok: false,
228
+ status: 401,
229
+ error: `Invalid UCAN invocation: ${inv.error}`,
230
+ };
231
+ userDid = inv.userDid;
232
+ via = 'invocation';
233
+ }
234
+
235
+ let delegation: string | undefined;
236
+ let delegationExpiration: number | undefined;
237
+ if (delegationHeader) {
238
+ const del = await validateDelegation(delegationHeader, cfg);
239
+ if (!del.ok) {
240
+ // With a valid invocation the delegation is only authorization material;
241
+ // a bad one is ignored so the caller can still chat. Alone, it's fatal.
242
+ if (!userDid)
243
+ return {
244
+ ok: false,
245
+ status: 401,
246
+ error: `Invalid UCAN delegation: ${del.error}`,
247
+ };
248
+ } else if (userDid && del.userDid !== userDid) {
249
+ // Never pair one user's invocation with another user's delegation.
250
+ if (!invocation) userDid = del.userDid;
251
+ } else {
252
+ userDid ??= del.userDid;
253
+ delegation = delegationHeader;
254
+ delegationExpiration = del.expiration;
255
+ }
256
+ }
257
+
258
+ if (!userDid)
259
+ return { ok: false, status: 401, error: 'Unable to authenticate request' };
260
+ return { ok: true, auth: { userDid, delegation, delegationExpiration, via } };
261
+ }
262
+
263
+ /** Normalise a route exclusion into a matcher over (method, path). */
264
+ export interface RouteExclusion {
265
+ path: string;
266
+ method?: string;
267
+ }
268
+
269
+ export function isExcluded(
270
+ method: string,
271
+ path: string,
272
+ exclusions: readonly RouteExclusion[],
273
+ ): boolean {
274
+ const norm = (p: string) => '/' + p.replace(/^\/+/, '').replace(/\/+$/, '');
275
+ const target = norm(path);
276
+ return exclusions.some((ex) => {
277
+ const m = (ex.method ?? 'ALL').toUpperCase();
278
+ if (m !== 'ALL' && m !== method.toUpperCase()) return false;
279
+ const pattern = norm(ex.path);
280
+ if (pattern.endsWith('/*') || pattern.endsWith('/{*path}')) {
281
+ const base = pattern.replace(/\/(\*|\{\*path\})$/, '');
282
+ return target === base || target.startsWith(base + '/');
283
+ }
284
+ // `:param` segments match one path segment.
285
+ const pSeg = pattern.split('/');
286
+ const tSeg = target.split('/');
287
+ if (pSeg.length !== tSeg.length) return false;
288
+ return pSeg.every((s, i) => s.startsWith(':') || s === tSeg[i]);
289
+ });
290
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Lossless per-blob gzip for the checkpointer's BLOB columns.
3
+ *
4
+ * The saver's schema already stores each message once (`messages` PK is the
5
+ * message id) and prunes checkpoints per thread, so the remaining size lever
6
+ * is the serialized JSON itself. Every blob written through this codec is
7
+ * gzip-compressed; reads detect the gzip magic bytes and decompress
8
+ * transparently, so legacy uncompressed rows (files written by the Node
9
+ * runtime, or by this runtime before the codec) keep loading unchanged and
10
+ * both kinds coexist in one file.
11
+ *
12
+ * NOT compressed: `checkpoints.metadata` — `list()` filters it SQL-side via
13
+ * `json(CAST(metadata AS TEXT))`, which must keep seeing plaintext JSON. It
14
+ * is bounded (source/step/parents) so the loss is negligible.
15
+ *
16
+ * A compressed blob is a plain gzip stream — `gunzip` on the exported file
17
+ * shows the original JSON. Nothing is dropped, reordered, or summarized:
18
+ * compress(decompress(x)) round-trips bit-exact at the value level.
19
+ */
20
+
21
+ /** Blobs below this size gain nothing from gzip (header ≈ 20 bytes). */
22
+ export const MIN_COMPRESS_BYTES = 256;
23
+
24
+ const textEncoder = new TextEncoder();
25
+ const textDecoder = new TextDecoder();
26
+
27
+ export function isGzip(bytes: Uint8Array): boolean {
28
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
29
+ }
30
+
31
+ async function pipeThrough(
32
+ bytes: Uint8Array,
33
+ transform: TransformStream<Uint8Array, Uint8Array>,
34
+ ): Promise<Uint8Array> {
35
+ const source = new ReadableStream<Uint8Array>({
36
+ start(controller) {
37
+ controller.enqueue(bytes);
38
+ controller.close();
39
+ },
40
+ });
41
+ const stream = source.pipeThrough(transform);
42
+ const chunks: Uint8Array[] = [];
43
+ let total = 0;
44
+ const reader = stream.getReader();
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ chunks.push(value);
49
+ total += value.length;
50
+ }
51
+ const out = new Uint8Array(total);
52
+ let offset = 0;
53
+ for (const chunk of chunks) {
54
+ out.set(chunk, offset);
55
+ offset += chunk.length;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
61
+ return pipeThrough(bytes, new CompressionStream('gzip'));
62
+ }
63
+
64
+ export function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
65
+ return pipeThrough(bytes, new DecompressionStream('gzip'));
66
+ }
67
+
68
+ /**
69
+ * Encode one value for a BLOB column: small values pass through untouched;
70
+ * larger ones are gzipped. An incompressible payload (already-compressed
71
+ * media) that would GROW is stored uncompressed.
72
+ */
73
+ export async function compressForStorage(
74
+ data: Uint8Array | string,
75
+ ): Promise<Uint8Array> {
76
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
77
+ if (bytes.length < MIN_COMPRESS_BYTES) return bytes;
78
+ const compressed = await gzipBytes(bytes);
79
+ return compressed.length < bytes.length ? compressed : bytes;
80
+ }
81
+
82
+ /**
83
+ * Read one BLOB/TEXT column value back to its JSON text, decompressing when
84
+ * the gzip magic is present. Handles every historical shape: TEXT rows,
85
+ * legacy plain BLOBs, and codec-written gzip BLOBs.
86
+ */
87
+ export async function readBlobText(raw: Uint8Array | string): Promise<string> {
88
+ if (typeof raw === 'string') return raw;
89
+ const bytes = isGzip(raw) ? await gunzipBytes(raw) : raw;
90
+ return textDecoder.decode(bytes);
91
+ }
92
+
93
+ /** Decode SQLite `hex(column)` output (upper-case hex) back to bytes. */
94
+ export function hexToBytes(hex: string): Uint8Array {
95
+ const out = new Uint8Array(hex.length / 2);
96
+ for (let i = 0; i < out.length; i++) {
97
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Background re-compression of legacy (uncompressed) checkpointer blobs.
3
+ *
4
+ * A file imported from the Node runtime arrives with every blob as plain
5
+ * JSON. New writes are gzipped by `blob-codec.ts`, but the imported bulk —
6
+ * which is what makes multi-GB users multi-GB — only shrinks if rewritten.
7
+ * `compactStep` rewrites a bounded batch per call so the user object's alarm
8
+ * can chip away at arbitrarily large files without ever hogging a tick;
9
+ * `finishCompaction` reclaims the freed pages once nothing is left.
10
+ *
11
+ * Lossless by construction: each row's blob is read, gzipped, and written
12
+ * back — the read path decompresses transparently, so the decoded value is
13
+ * bit-identical to what the legacy row held. No row is ever deleted.
14
+ *
15
+ * Selection is by gzip magic (`substr(col, 1, 2) != X'1F8B'`) behind a
16
+ * per-table rowid cursor that the CALLER persists between calls. The cursor
17
+ * is what lets incompressible rows (embedded media that gzip would grow —
18
+ * left unrewritten) be passed over exactly once instead of starving the rows
19
+ * behind them. Rows inserted after compaction started are codec-compressed
20
+ * already, so a monotonic cursor cannot miss anything.
21
+ */
22
+ import { gzipBytes, isGzip, MIN_COMPRESS_BYTES } from './blob-codec';
23
+ import type { DoSqliteDatabase } from './database';
24
+
25
+ interface CompactTarget {
26
+ table: string;
27
+ column: string;
28
+ }
29
+
30
+ /** `checkpoints.metadata` is deliberately absent — see blob-codec.ts. */
31
+ const TARGETS: readonly CompactTarget[] = [
32
+ { table: 'messages', column: 'message' },
33
+ { table: 'checkpoints', column: 'checkpoint' },
34
+ { table: 'writes', column: 'value' },
35
+ ];
36
+
37
+ const textEncoder = new TextEncoder();
38
+
39
+ /** Highest rowid examined per table; persist between calls, `{}` to start. */
40
+ export type CompactCursors = Record<string, number>;
41
+
42
+ export interface CompactStepResult {
43
+ /** Rows rewritten this step. */
44
+ rewritten: number;
45
+ /** Bytes saved this step (pre minus post). */
46
+ savedBytes: number;
47
+ /** Rows examined (incl. incompressible ones passed over). */
48
+ examined: number;
49
+ /** True once every table's scan is exhausted. */
50
+ done: boolean;
51
+ /** Updated cursors — persist and pass into the next call. */
52
+ cursors: CompactCursors;
53
+ }
54
+
55
+ /**
56
+ * Examine up to `batch` legacy rows and rewrite the compressible ones as
57
+ * gzipped blobs. Call repeatedly (persisting `cursors`) until `done`.
58
+ */
59
+ export async function compactStep(
60
+ db: DoSqliteDatabase,
61
+ batch = 200,
62
+ cursors: CompactCursors = {},
63
+ ): Promise<CompactStepResult> {
64
+ // One transaction per step: the batch lands atomically and cannot
65
+ // interleave with a turn's own checkpoint transaction.
66
+ return db.transaction(() => compactStepInTx(db, batch, cursors));
67
+ }
68
+
69
+ async function compactStepInTx(
70
+ db: DoSqliteDatabase,
71
+ batch: number,
72
+ cursors: CompactCursors,
73
+ ): Promise<CompactStepResult> {
74
+ let budget = batch;
75
+ let rewritten = 0;
76
+ let savedBytes = 0;
77
+ let examined = 0;
78
+ let exhausted = true;
79
+ const next: CompactCursors = { ...cursors };
80
+
81
+ for (const { table, column } of TARGETS) {
82
+ let cursor = next[table] ?? 0;
83
+ while (budget > 0) {
84
+ const rows = await db.exec<{ rowid: number; data: Uint8Array | string }>(
85
+ `SELECT rowid, ${column} AS data FROM ${table}
86
+ WHERE rowid > ?
87
+ AND ${column} IS NOT NULL
88
+ AND length(${column}) >= ?
89
+ AND substr(${column}, 1, 2) != X'1F8B'
90
+ ORDER BY rowid
91
+ LIMIT ?`,
92
+ [cursor, MIN_COMPRESS_BYTES, budget],
93
+ );
94
+ if (rows.length === 0) break;
95
+ for (const { rowid, data } of rows) {
96
+ cursor = Math.max(cursor, rowid);
97
+ budget -= 1;
98
+ examined += 1;
99
+ const bytes =
100
+ typeof data === 'string' ? textEncoder.encode(data) : data;
101
+ if (isGzip(bytes)) continue;
102
+ const compressed = await gzipBytes(bytes);
103
+ // Incompressible (already-compressed media): leave as-is; the cursor
104
+ // has moved past it, so it is never re-attempted.
105
+ if (compressed.length >= bytes.length) continue;
106
+ await db.run(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`, [
107
+ compressed,
108
+ rowid,
109
+ ]);
110
+ rewritten += 1;
111
+ savedBytes += bytes.length - compressed.length;
112
+ }
113
+ }
114
+ next[table] = cursor;
115
+ if (budget <= 0) {
116
+ // Budget ran out mid-table — cannot know whether scans are exhausted.
117
+ exhausted = false;
118
+ break;
119
+ }
120
+ }
121
+
122
+ return { rewritten, savedBytes, examined, done: exhausted, cursors: next };
123
+ }
124
+
125
+ /**
126
+ * Reclaim freed pages after compaction. The rebuild (`DoSqliteDatabase.compact`:
127
+ * `VACUUM INTO` a spill file + swap, constant memory) transiently needs
128
+ * about twice the current size in DO storage — refused near the cap so
129
+ * compaction can never be the thing that overflows it. Falls back to
130
+ * `incremental_vacuum` (a no-op on files whose auto_vacuum is NONE, i.e.
131
+ * legacy imports that have never been fully vacuumed).
132
+ */
133
+ export async function finishCompaction(
134
+ db: DoSqliteDatabase,
135
+ opts: { fileSize: number; storageCapBytes?: number },
136
+ ): Promise<{ vacuumed: 'full' | 'incremental' }> {
137
+ const cap = opts.storageCapBytes ?? 10 * 1024 * 1024 * 1024;
138
+ if (opts.fileSize * 2 < cap * 0.9) {
139
+ await db.compact();
140
+ return { vacuumed: 'full' };
141
+ }
142
+ // Refuses to run inside a transaction — hold the transaction mutex (turn
143
+ // checkpoints queue behind) without opening one.
144
+ await db.withoutTransactions(() => db.run('PRAGMA incremental_vacuum'));
145
+ return { vacuumed: 'incremental' };
146
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `CHUNK_CACHE_BYTES`: the per-object budget of clean 64 KiB chunks the DO
3
+ * VFS keeps in memory (see `DoVfs`). Every user object of a script shares
4
+ * one isolate (128 MB), so this is the knob that trades turn latency (more
5
+ * cache = fewer DO storage reads) against how many active users fit.
6
+ */
7
+ import { DEFAULT_CACHE_PAGES, VFS_PAGE_SIZE } from './do-vfs';
8
+
9
+ export const DEFAULT_CHUNK_CACHE_BYTES = DEFAULT_CACHE_PAGES * VFS_PAGE_SIZE;
10
+ export const MIN_CHUNK_CACHE_BYTES = 1024 * 1024;
11
+ export const MAX_CHUNK_CACHE_BYTES = 64 * 1024 * 1024;
12
+
13
+ /**
14
+ * Parse the env value (plain bytes, or with a `k`/`m` suffix: `4m`,
15
+ * `4096k`). Unset → default; malformed or out of range → default plus a
16
+ * warning through `warn`.
17
+ */
18
+ export function parseChunkCacheBytes(
19
+ raw: string | undefined,
20
+ warn: (message: string) => void = () => undefined,
21
+ ): number {
22
+ if (raw === undefined || raw.trim() === '') return DEFAULT_CHUNK_CACHE_BYTES;
23
+ const match = /^\s*(\d+)\s*([kKmM]?)\s*$/.exec(raw);
24
+ if (!match) {
25
+ warn(
26
+ `CHUNK_CACHE_BYTES=${JSON.stringify(raw)} is not a byte count — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
27
+ );
28
+ return DEFAULT_CHUNK_CACHE_BYTES;
29
+ }
30
+ const unit = match[2]?.toLowerCase();
31
+ const multiplier = unit === 'k' ? 1024 : unit === 'm' ? 1024 * 1024 : 1;
32
+ const bytes = Number(match[1]) * multiplier;
33
+ if (bytes < MIN_CHUNK_CACHE_BYTES || bytes > MAX_CHUNK_CACHE_BYTES) {
34
+ warn(
35
+ `CHUNK_CACHE_BYTES=${raw} is outside ${MIN_CHUNK_CACHE_BYTES}..${MAX_CHUNK_CACHE_BYTES} — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
36
+ );
37
+ return DEFAULT_CHUNK_CACHE_BYTES;
38
+ }
39
+ return bytes;
40
+ }
41
+
42
+ /** The `cachePages` open option for a byte budget. */
43
+ export function cachePagesForBytes(bytes: number): number {
44
+ return Math.max(1, Math.round(bytes / VFS_PAGE_SIZE));
45
+ }