@agentuity/runtime 0.0.112 → 0.1.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/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)) {
@@ -447,17 +531,18 @@ export function createCompressionMiddleware(staticConfig?: CompressionConfig) {
447
531
  }
448
532
 
449
533
  /**
450
- * Create lightweight session middleware for web routes (analytics).
534
+ * Create lightweight thread middleware for web routes (analytics).
451
535
  *
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.
536
+ * Sets thread cookie that persists across page views for client-side analytics.
537
+ * This middleware does NOT:
538
+ * - Create or track sessions (no session ID)
539
+ * - Set session/thread response headers
540
+ * - Send events to Catalyst sessions table
455
541
  *
456
- * Uses the existing ThreadIDProvider for thread ID generation to ensure
457
- * consistency with the OTel middleware.
542
+ * This is intentionally separate from createOtelMiddleware to avoid
543
+ * polluting the sessions table with web browsing activity.
458
544
  *
459
- * - Session cookie (asid): Per browser session, 30-minute sliding expiry
460
- * - Thread cookie (atid): Managed by ThreadIDProvider, 1-week expiry
545
+ * - Thread cookie (atid_a): Analytics-readable copy, 1-week expiry
461
546
  */
462
547
  export function createWebSessionMiddleware() {
463
548
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -467,44 +552,24 @@ export function createWebSessionMiddleware() {
467
552
 
468
553
  const secret = getSessionSecret();
469
554
 
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
555
  // Use ThreadProvider.restore() to get/create thread (handles header, cookie, generation)
477
556
  const threadProvider = getThreadProvider();
478
557
  const thread = await threadProvider.restore(c);
479
558
 
480
- // Set session cookie with sliding expiry
481
- // httpOnly: false so beacon script can read it for analytics
559
+ // Set thread cookie for analytics
560
+ // httpOnly: false so beacon script can read it
482
561
  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
562
  await setSignedCookie(c, 'atid_a', thread.id, secret, {
494
563
  httpOnly: false, // Readable by JavaScript for analytics
495
564
  secure: isSecure,
496
565
  sameSite: 'Lax',
497
566
  path: '/',
498
- maxAge: 604800, // 1 week (same as thread)
567
+ maxAge: 604800, // 1 week
499
568
  });
500
569
 
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);
570
+ // Store in context for handler to access in same request
571
+ // (cookies aren't readable until the next request)
572
+ c.set('_webThreadId', thread.id);
508
573
 
509
574
  await next();
510
575
  });