@agentuity/runtime 0.1.0 → 0.1.2

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/src/cors.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * CORS trusted origin helpers for same-origin configuration.
3
+ *
4
+ * Provides the same trusted-origin logic as @agentuity/auth,
5
+ * allowing CORS to be restricted to platform-trusted domains.
6
+ */
7
+
8
+ import type { Context } from 'hono';
9
+
10
+ /**
11
+ * Safely extract origin from a URL string.
12
+ * Returns undefined if the URL is invalid.
13
+ */
14
+ function safeOrigin(url: string | undefined): string | undefined {
15
+ if (!url) return undefined;
16
+ try {
17
+ return new URL(url).origin;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Parse an origin-like value (URL or bare domain) into a normalized origin.
25
+ *
26
+ * - Full URLs (http://... or https://...) are parsed as-is
27
+ * - Bare domains (example.com) are treated as https://
28
+ * - Invalid values return undefined
29
+ */
30
+ function parseOriginLike(value: string): string | undefined {
31
+ const trimmed = value.trim();
32
+ if (!trimmed) return undefined;
33
+
34
+ // If it looks like a URL (has a scheme), parse directly
35
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
36
+ return safeOrigin(trimmed);
37
+ }
38
+
39
+ // Otherwise, treat as host[:port] and assume https
40
+ return safeOrigin(`https://${trimmed}`);
41
+ }
42
+
43
+ /**
44
+ * Build the static trusted origins set from environment variables.
45
+ *
46
+ * Reads from:
47
+ * - AGENTUITY_BASE_URL - The base URL for the deployment
48
+ * - AGENTUITY_CLOUD_DOMAINS - Platform-set domains (comma-separated)
49
+ * - AUTH_TRUSTED_DOMAINS - Developer-set additional domains (comma-separated)
50
+ */
51
+ function buildEnvTrustedOrigins(): Set<string> {
52
+ const agentuityURL = process.env.AGENTUITY_BASE_URL;
53
+ const cloudDomains = process.env.AGENTUITY_CLOUD_DOMAINS;
54
+ const devTrustedDomains = process.env.AUTH_TRUSTED_DOMAINS;
55
+
56
+ const origins = new Set<string>();
57
+
58
+ const agentuityOrigin = safeOrigin(agentuityURL);
59
+ if (agentuityOrigin) origins.add(agentuityOrigin);
60
+
61
+ // Platform-set cloud domains (deployment, project, PR, custom domains, tunnels)
62
+ if (cloudDomains) {
63
+ for (const raw of cloudDomains.split(',')) {
64
+ const origin = parseOriginLike(raw);
65
+ if (origin) origins.add(origin);
66
+ }
67
+ }
68
+
69
+ // Developer-set additional trusted domains
70
+ if (devTrustedDomains) {
71
+ for (const raw of devTrustedDomains.split(',')) {
72
+ const origin = parseOriginLike(raw);
73
+ if (origin) origins.add(origin);
74
+ }
75
+ }
76
+
77
+ return origins;
78
+ }
79
+
80
+ /**
81
+ * Options for createTrustedCorsOrigin.
82
+ */
83
+ export interface TrustedCorsOriginOptions {
84
+ /**
85
+ * Additional origins to allow on top of environment-derived ones.
86
+ * Can be full URLs (https://example.com) or bare domains (example.com).
87
+ */
88
+ allowedOrigins?: string[];
89
+ }
90
+
91
+ /**
92
+ * Create a Hono CORS origin callback that only allows trusted origins.
93
+ *
94
+ * Trusted origins are derived from:
95
+ * - AGENTUITY_BASE_URL environment variable
96
+ * - AGENTUITY_CLOUD_DOMAINS environment variable (comma-separated)
97
+ * - AUTH_TRUSTED_DOMAINS environment variable (comma-separated)
98
+ * - The same-origin of the incoming request URL
99
+ * - Any additional origins specified in allowedOrigins option
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * import { createApp, createTrustedCorsOrigin } from '@agentuity/runtime';
104
+ *
105
+ * await createApp({
106
+ * cors: {
107
+ * origin: createTrustedCorsOrigin({
108
+ * allowedOrigins: ['https://admin.myapp.com'],
109
+ * }),
110
+ * },
111
+ * });
112
+ * ```
113
+ */
114
+ export function createTrustedCorsOrigin(
115
+ options?: TrustedCorsOriginOptions
116
+ ): (origin: string, c: Context) => string | undefined {
117
+ // Build static origins from env vars at creation time
118
+ const baseOrigins = buildEnvTrustedOrigins();
119
+
120
+ // Add any extra origins from options
121
+ if (options?.allowedOrigins) {
122
+ for (const raw of options.allowedOrigins) {
123
+ const origin = parseOriginLike(raw);
124
+ if (origin) baseOrigins.add(origin);
125
+ }
126
+ }
127
+
128
+ return (origin: string, c: Context): string | undefined => {
129
+ // Build allowed set per-request to include same-origin of the server
130
+ const allowed = new Set(baseOrigins);
131
+ const requestOrigin = safeOrigin(c.req.url);
132
+ if (requestOrigin) allowed.add(requestOrigin);
133
+
134
+ // Only echo back if trusted; otherwise return undefined (no CORS header)
135
+ return allowed.has(origin) ? origin : undefined;
136
+ };
137
+ }
@@ -1,4 +1,11 @@
1
1
  export { websocket, type WebSocketConnection, type WebSocketHandler } from './websocket';
2
- export { sse, type SSEMessage, type SSEStream, type SSEHandler } from './sse';
2
+ export {
3
+ sse,
4
+ type SSEMessage,
5
+ type SSEStream,
6
+ type SSEHandler,
7
+ STREAM_DONE_PROMISE_KEY,
8
+ IS_STREAMING_RESPONSE_KEY,
9
+ } from './sse';
3
10
  export { stream, type StreamHandler } from './stream';
4
11
  export { cron, type CronHandler, type CronMetadata } from './cron';
@@ -3,6 +3,19 @@ import { streamSSE as honoStreamSSE } from 'hono/streaming';
3
3
  import { getAgentAsyncLocalStorage } from '../_context';
4
4
  import type { Env } from '../app';
5
5
 
6
+ /**
7
+ * Context variable key for stream completion promise.
8
+ * Used by middleware to defer session/thread saving until stream completes.
9
+ * @internal
10
+ */
11
+ export const STREAM_DONE_PROMISE_KEY = '_streamDonePromise';
12
+
13
+ /**
14
+ * Context variable key to indicate this is a streaming response.
15
+ * @internal
16
+ */
17
+ export const IS_STREAMING_RESPONSE_KEY = '_isStreamingResponse';
18
+
6
19
  /**
7
20
  * SSE message format for Server-Sent Events.
8
21
  */
@@ -84,8 +97,38 @@ export function sse<E extends Env = Env>(handler: SSEHandler<E>): Handler<E> {
84
97
  const asyncLocalStorage = getAgentAsyncLocalStorage();
85
98
  const capturedContext = asyncLocalStorage.getStore();
86
99
 
100
+ // Track stream completion for deferred session/thread saving
101
+ // This promise resolves when the stream closes (normally or via abort)
102
+ let resolveDone: (() => void) | undefined;
103
+ let rejectDone: ((reason?: unknown) => void) | undefined;
104
+ const donePromise = new Promise<void>((resolve, reject) => {
105
+ resolveDone = resolve;
106
+ rejectDone = reject;
107
+ });
108
+
109
+ // Idempotent function to mark stream as completed
110
+ let isDone = false;
111
+ const markDone = (error?: unknown) => {
112
+ if (isDone) return;
113
+ isDone = true;
114
+ if (error && rejectDone) {
115
+ rejectDone(error);
116
+ } else if (resolveDone) {
117
+ resolveDone();
118
+ }
119
+ };
120
+
121
+ // Expose completion tracking to middleware
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ (c as any).set(STREAM_DONE_PROMISE_KEY, donePromise);
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ (c as any).set(IS_STREAMING_RESPONSE_KEY, true);
126
+
87
127
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
128
  return honoStreamSSE(c, async (stream: any) => {
129
+ // Track if user registered an onAbort callback
130
+ let userAbortCallback: (() => void) | undefined;
131
+
89
132
  const wrappedStream: SSEStream = {
90
133
  write: async (data) => {
91
134
  if (
@@ -100,12 +143,44 @@ export function sse<E extends Env = Env>(handler: SSEHandler<E>): Handler<E> {
100
143
  return stream.writeSSE({ data: String(data) });
101
144
  },
102
145
  writeSSE: stream.writeSSE.bind(stream),
103
- onAbort: stream.onAbort.bind(stream),
104
- close: stream.close?.bind(stream) ?? (() => {}),
146
+ onAbort: (callback: () => void) => {
147
+ userAbortCallback = callback;
148
+ stream.onAbort(() => {
149
+ try {
150
+ callback();
151
+ } finally {
152
+ // Mark stream as done on abort
153
+ markDone();
154
+ }
155
+ });
156
+ },
157
+ close: () => {
158
+ try {
159
+ stream.close?.();
160
+ } finally {
161
+ // Mark stream as done on close
162
+ markDone();
163
+ }
164
+ },
105
165
  };
106
166
 
167
+ // Always register internal abort handler if user doesn't register one
168
+ // This ensures we track completion even if user doesn't call onAbort
169
+ stream.onAbort(() => {
170
+ if (!userAbortCallback) {
171
+ // Only mark done if user didn't register their own handler
172
+ // (their handler wrapper already calls markDone)
173
+ markDone();
174
+ }
175
+ });
176
+
107
177
  const runInContext = async () => {
108
- await handler(c, wrappedStream);
178
+ try {
179
+ await handler(c, wrappedStream);
180
+ } catch (err) {
181
+ markDone(err);
182
+ throw err;
183
+ }
109
184
  };
110
185
 
111
186
  if (capturedContext) {
@@ -2,6 +2,7 @@ import type { Context, Handler } from 'hono';
2
2
  import { stream as honoStream } from 'hono/streaming';
3
3
  import { getAgentAsyncLocalStorage } from '../_context';
4
4
  import type { Env } from '../app';
5
+ import { STREAM_DONE_PROMISE_KEY, IS_STREAMING_RESPONSE_KEY } from './sse';
5
6
 
6
7
  /**
7
8
  * Handler function for streaming responses.
@@ -59,6 +60,33 @@ export function stream<E extends Env = Env>(handler: StreamHandler<E>): Handler<
59
60
  const asyncLocalStorage = getAgentAsyncLocalStorage();
60
61
  const capturedContext = asyncLocalStorage.getStore();
61
62
 
63
+ // Track stream completion for deferred session/thread saving
64
+ // This promise resolves when the stream completes (pipe finishes or errors)
65
+ let resolveDone: (() => void) | undefined;
66
+ let rejectDone: ((reason?: unknown) => void) | undefined;
67
+ const donePromise = new Promise<void>((resolve, reject) => {
68
+ resolveDone = resolve;
69
+ rejectDone = reject;
70
+ });
71
+
72
+ // Idempotent function to mark stream as completed
73
+ let isDone = false;
74
+ const markDone = (error?: unknown) => {
75
+ if (isDone) return;
76
+ isDone = true;
77
+ if (error && rejectDone) {
78
+ rejectDone(error);
79
+ } else if (resolveDone) {
80
+ resolveDone();
81
+ }
82
+ };
83
+
84
+ // Expose completion tracking to middleware
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ (c as any).set(STREAM_DONE_PROMISE_KEY, donePromise);
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ (c as any).set(IS_STREAMING_RESPONSE_KEY, true);
89
+
62
90
  c.header('Content-Type', 'application/octet-stream');
63
91
 
64
92
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -70,8 +98,11 @@ export function stream<E extends Env = Env>(handler: StreamHandler<E>): Handler<
70
98
  streamResult = await streamResult;
71
99
  }
72
100
  await s.pipe(streamResult);
101
+ // Stream completed successfully
102
+ markDone();
73
103
  } catch (err) {
74
104
  c.var.logger?.error('Stream error:', err);
105
+ markDone(err);
75
106
  throw err;
76
107
  }
77
108
  };
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  export {
31
31
  type AppConfig,
32
32
  type CompressionConfig,
33
+ type CorsConfig,
33
34
  type Variables,
34
35
  type TriggerType,
35
36
  type PrivateVariables,
@@ -42,6 +43,9 @@ export {
42
43
  runShutdown,
43
44
  fireEvent,
44
45
  } from './app';
46
+
47
+ // cors.ts exports (trusted origin helpers)
48
+ export { createTrustedCorsOrigin, type TrustedCorsOriginOptions } from './cors';
45
49
  export { addEventListener, removeEventListener } from './_events';
46
50
 
47
51
  // middleware.ts exports (Vite-native)
package/src/middleware.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  import { createMiddleware } from 'hono/factory';
7
7
  import { cors } from 'hono/cors';
8
8
  import { compress } from 'hono/compress';
9
- import { getSignedCookie, setSignedCookie } from 'hono/cookie';
10
- import type { Env, CompressionConfig } from './app';
9
+ import { setSignedCookie } from 'hono/cookie';
10
+ import type { Env, CompressionConfig, CorsConfig } from './app';
11
+ import { createTrustedCorsOrigin } from './cors';
11
12
  import type { Logger } from './logger';
12
13
  import { getAppConfig } from './app';
13
14
  import { generateId } from './session';
@@ -27,6 +28,7 @@ import { TraceState } from '@opentelemetry/core';
27
28
  import * as runtimeConfig from './_config';
28
29
  import { getSessionEventProvider } from './_services';
29
30
  import { internal } from './logger/internal';
31
+ import { STREAM_DONE_PROMISE_KEY, IS_STREAMING_RESPONSE_KEY } from './handlers/sse';
30
32
 
31
33
  const SESSION_HEADER = 'x-session-id';
32
34
  const THREAD_HEADER = 'x-thread-id';
@@ -171,38 +173,79 @@ export function createBaseMiddleware(config: MiddlewareConfig) {
171
173
  * }));
172
174
  * ```
173
175
  */
174
- export function createCorsMiddleware(staticOptions?: Parameters<typeof cors>[0]) {
176
+ export function createCorsMiddleware(staticOptions?: CorsConfig) {
175
177
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
178
  return createMiddleware<Env<any>>(async (c, next) => {
177
179
  // Lazy resolve: merge app config with static options
178
180
  const appConfig = getAppConfig();
181
+ const appCors = appConfig?.cors;
179
182
  const corsOptions = {
180
- ...appConfig?.cors,
183
+ ...appCors,
181
184
  ...staticOptions,
182
185
  };
183
186
 
187
+ // Extract Agentuity-specific options
188
+ const { sameOrigin, allowedOrigins, ...honoCorsOptions } = corsOptions;
189
+
190
+ // Determine origin handler based on sameOrigin setting
191
+ let originHandler: NonNullable<Parameters<typeof cors>[0]>['origin'];
192
+ if (sameOrigin) {
193
+ // Use trusted origins (env vars + allowedOrigins + same-origin)
194
+ originHandler = createTrustedCorsOrigin({ allowedOrigins });
195
+ } else if (honoCorsOptions.origin !== undefined) {
196
+ // Use explicitly provided origin
197
+ originHandler = honoCorsOptions.origin;
198
+ } else {
199
+ // Default: reflect any origin (backwards compatible)
200
+ originHandler = (origin: string) => origin;
201
+ }
202
+
203
+ // Required headers that must always be allowed/exposed for runtime functionality
204
+ const requiredAllowHeaders = [THREAD_HEADER];
205
+ const requiredExposeHeaders = [
206
+ TOKENS_HEADER,
207
+ DURATION_HEADER,
208
+ THREAD_HEADER,
209
+ SESSION_HEADER,
210
+ DEPLOYMENT_HEADER,
211
+ ];
212
+
213
+ // Default headers to allow (used if none specified)
214
+ const defaultAllowHeaders = [
215
+ 'Content-Type',
216
+ 'Authorization',
217
+ 'Accept',
218
+ 'Origin',
219
+ 'X-Requested-With',
220
+ ];
221
+
222
+ // Default headers to expose (used if none specified)
223
+ const defaultExposeHeaders = ['Content-Length'];
224
+
184
225
  const corsMiddleware = cors({
185
- origin: corsOptions?.origin ?? ((origin: string) => origin),
186
- allowHeaders: corsOptions?.allowHeaders ?? [
187
- 'Content-Type',
188
- 'Authorization',
189
- 'Accept',
190
- 'Origin',
191
- 'X-Requested-With',
192
- THREAD_HEADER,
226
+ ...honoCorsOptions,
227
+ origin: originHandler,
228
+ // Always include required headers, merge with user-provided or defaults
229
+ allowHeaders: [
230
+ ...(honoCorsOptions.allowHeaders ?? defaultAllowHeaders),
231
+ ...requiredAllowHeaders,
193
232
  ],
194
- allowMethods: ['POST', 'GET', 'OPTIONS', 'HEAD', 'PUT', 'DELETE', 'PATCH'],
233
+ allowMethods: honoCorsOptions.allowMethods ?? [
234
+ 'POST',
235
+ 'GET',
236
+ 'OPTIONS',
237
+ 'HEAD',
238
+ 'PUT',
239
+ 'DELETE',
240
+ 'PATCH',
241
+ ],
242
+ // Always include required headers, merge with user-provided or defaults
195
243
  exposeHeaders: [
196
- 'Content-Length',
197
- TOKENS_HEADER,
198
- DURATION_HEADER,
199
- THREAD_HEADER,
200
- SESSION_HEADER,
201
- DEPLOYMENT_HEADER,
244
+ ...(honoCorsOptions.exposeHeaders ?? defaultExposeHeaders),
245
+ ...requiredExposeHeaders,
202
246
  ],
203
- maxAge: 600,
204
- credentials: true,
205
- ...(corsOptions ?? {}),
247
+ maxAge: honoCorsOptions.maxAge ?? 600,
248
+ credentials: honoCorsOptions.credentials ?? true,
206
249
  });
207
250
 
208
251
  return corsMiddleware(c, next);
@@ -311,27 +354,15 @@ export function createOtelMiddleware() {
311
354
  }
312
355
  }
313
356
 
314
- try {
315
- await next();
316
- // Save session/thread and send events
357
+ // Factor out finalization logic so it can run synchronously or deferred
358
+ const finalizeSession = async (statusCode?: number) => {
317
359
  internal.info('[session] saving session %s (thread: %s)', sessionId, thread.id);
318
360
  await sessionProvider.save(session);
319
361
  internal.info('[session] session saved, now saving thread');
320
362
  await threadProvider.save(thread);
321
363
  internal.info('[session] thread saved');
322
- span.setStatus({ code: SpanStatusCode.OK });
323
- } catch (ex) {
324
- if (ex instanceof Error) {
325
- span.recordException(ex);
326
- }
327
- span.setStatus({
328
- code: SpanStatusCode.ERROR,
329
- message: (ex as Error).message ?? String(ex),
330
- });
331
- throw ex;
332
- } finally {
364
+
333
365
  // Send session complete event
334
- // The provider decides whether to actually send based on its requirements
335
366
  if (sessionEventProvider) {
336
367
  try {
337
368
  const userData = session.serializeUserData();
@@ -347,7 +378,7 @@ export function createOtelMiddleware() {
347
378
  await sessionEventProvider.complete({
348
379
  id: sessionId,
349
380
  threadId: isEmpty ? null : thread.id,
350
- statusCode: c.res?.status ?? 200,
381
+ statusCode: statusCode ?? c.res?.status ?? 200,
351
382
  agentIds: agentIds?.length ? agentIds : undefined,
352
383
  userData,
353
384
  });
@@ -357,7 +388,60 @@ export function createOtelMiddleware() {
357
388
  // Silently ignore session complete errors - don't block response
358
389
  }
359
390
  }
391
+ };
392
+
393
+ try {
394
+ await next();
395
+
396
+ // Check if this is a streaming response that needs deferred finalization
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ const streamDone = (c as any).get(STREAM_DONE_PROMISE_KEY) as
399
+ | Promise<void>
400
+ | undefined;
401
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
402
+ const isStreaming = Boolean((c as any).get(IS_STREAMING_RESPONSE_KEY));
403
+
404
+ if (isStreaming && streamDone) {
405
+ // Defer session/thread saving until stream completes
406
+ // This ensures thread state changes made during streaming are persisted
407
+ internal.info(
408
+ '[session] deferring session/thread save until streaming completes (session %s)',
409
+ sessionId
410
+ );
411
+
412
+ handler.waitUntil(async () => {
413
+ try {
414
+ await streamDone;
415
+ internal.info(
416
+ '[session] stream completed, now saving session/thread (session %s)',
417
+ sessionId
418
+ );
419
+ } catch (ex) {
420
+ // Stream ended with an error/abort; still try to persist the latest state
421
+ internal.info(
422
+ '[session] stream ended with error, still saving state: %s',
423
+ ex
424
+ );
425
+ }
426
+ await finalizeSession();
427
+ });
360
428
 
429
+ span.setStatus({ code: SpanStatusCode.OK });
430
+ } else {
431
+ // Non-streaming: save session/thread synchronously (existing behavior)
432
+ await finalizeSession();
433
+ span.setStatus({ code: SpanStatusCode.OK });
434
+ }
435
+ } catch (ex) {
436
+ if (ex instanceof Error) {
437
+ span.recordException(ex);
438
+ }
439
+ span.setStatus({
440
+ code: SpanStatusCode.ERROR,
441
+ message: (ex as Error).message ?? String(ex),
442
+ });
443
+ throw ex;
444
+ } finally {
361
445
  const headers: Record<string, string> = {};
362
446
  propagation.inject(context.active(), headers);
363
447
  for (const key of Object.keys(headers)) {
@@ -394,11 +478,18 @@ export function createOtelMiddleware() {
394
478
  * });
395
479
  * ```
396
480
  */
397
- export function createCompressionMiddleware(staticConfig?: CompressionConfig) {
481
+ export function createCompressionMiddleware(
482
+ staticConfig?: CompressionConfig,
483
+ /**
484
+ * Optional config resolver for testing. When provided, this is used instead of getAppConfig().
485
+ * @internal
486
+ */
487
+ configResolver?: () => { compression?: CompressionConfig | false } | undefined
488
+ ) {
398
489
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
490
  return createMiddleware<Env<any>>(async (c, next) => {
400
491
  // Lazy resolve: merge app config with static config
401
- const appConfig = getAppConfig();
492
+ const appConfig = configResolver ? configResolver() : getAppConfig();
402
493
  const appCompressionConfig = appConfig?.compression;
403
494
 
404
495
  // Check if compression is explicitly disabled
@@ -447,17 +538,18 @@ export function createCompressionMiddleware(staticConfig?: CompressionConfig) {
447
538
  }
448
539
 
449
540
  /**
450
- * Create lightweight session middleware for web routes (analytics).
541
+ * Create lightweight thread middleware for web routes (analytics).
451
542
  *
452
- * Sets session and thread cookies that persist across page views.
453
- * This is a lighter-weight alternative to createOtelMiddleware for
454
- * routes that don't need full tracing but need session/thread tracking.
543
+ * Sets thread cookie that persists across page views for client-side analytics.
544
+ * This middleware does NOT:
545
+ * - Create or track sessions (no session ID)
546
+ * - Set session/thread response headers
547
+ * - Send events to Catalyst sessions table
455
548
  *
456
- * Uses the existing ThreadIDProvider for thread ID generation to ensure
457
- * consistency with the OTel middleware.
549
+ * This is intentionally separate from createOtelMiddleware to avoid
550
+ * polluting the sessions table with web browsing activity.
458
551
  *
459
- * - Session cookie (asid): Per browser session, 30-minute sliding expiry
460
- * - Thread cookie (atid): Managed by ThreadIDProvider, 1-week expiry
552
+ * - Thread cookie (atid_a): Analytics-readable copy, 1-week expiry
461
553
  */
462
554
  export function createWebSessionMiddleware() {
463
555
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -467,44 +559,24 @@ export function createWebSessionMiddleware() {
467
559
 
468
560
  const secret = getSessionSecret();
469
561
 
470
- // Check for existing session cookie
471
- let sessionId = await getSignedCookie(c, secret, 'asid');
472
- if (!sessionId || typeof sessionId !== 'string') {
473
- sessionId = generateId('sess');
474
- }
475
-
476
562
  // Use ThreadProvider.restore() to get/create thread (handles header, cookie, generation)
477
563
  const threadProvider = getThreadProvider();
478
564
  const thread = await threadProvider.restore(c);
479
565
 
480
- // Set session cookie with sliding expiry
481
- // httpOnly: false so beacon script can read it for analytics
566
+ // Set thread cookie for analytics
567
+ // httpOnly: false so beacon script can read it
482
568
  const isSecure = c.req.url.startsWith('https://');
483
- await setSignedCookie(c, 'asid', sessionId, secret, {
484
- httpOnly: false, // Readable by JavaScript for analytics
485
- secure: isSecure,
486
- sameSite: 'Lax',
487
- path: '/',
488
- maxAge: 30 * 60, // 30 minutes
489
- });
490
-
491
- // Note: Thread cookie is set by ThreadProvider with httpOnly: true
492
- // We need a readable copy for analytics
493
569
  await setSignedCookie(c, 'atid_a', thread.id, secret, {
494
570
  httpOnly: false, // Readable by JavaScript for analytics
495
571
  secure: isSecure,
496
572
  sameSite: 'Lax',
497
573
  path: '/',
498
- maxAge: 604800, // 1 week (same as thread)
574
+ maxAge: 604800, // 1 week
499
575
  });
500
576
 
501
- // Set in context for access by handlers (use existing Variables types)
502
- c.set('sessionId', sessionId);
503
- c.set('thread', thread);
504
-
505
- // Set response headers for debugging/tracing
506
- c.header(SESSION_HEADER, sessionId);
507
- c.header(THREAD_HEADER, thread.id);
577
+ // Store in context for handler to access in same request
578
+ // (cookies aren't readable until the next request)
579
+ c.set('_webThreadId', thread.id);
508
580
 
509
581
  await next();
510
582
  });