@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/dist/app.d.ts +79 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +12 -0
- package/dist/app.js.map +1 -1
- package/dist/cors.d.ts +42 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +117 -0
- package/dist/cors.js.map +1 -0
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/sse.d.ts +11 -0
- package/dist/handlers/sse.d.ts.map +1 -1
- package/dist/handlers/sse.js +76 -3
- package/dist/handlers/sse.js.map +1 -1
- package/dist/handlers/stream.d.ts.map +1 -1
- package/dist/handlers/stream.js +30 -0
- package/dist/handlers/stream.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +11 -10
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +120 -69
- package/dist/middleware.js.map +1 -1
- package/package.json +6 -6
- package/src/app.ts +90 -3
- package/src/cors.ts +137 -0
- package/src/handlers/index.ts +8 -1
- package/src/handlers/sse.ts +78 -3
- package/src/handlers/stream.ts +31 -0
- package/src/index.ts +4 -0
- package/src/middleware.ts +137 -72
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
|
+
}
|
package/src/handlers/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export { websocket, type WebSocketConnection, type WebSocketHandler } from './websocket';
|
|
2
|
-
export {
|
|
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';
|
package/src/handlers/sse.ts
CHANGED
|
@@ -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:
|
|
104
|
-
|
|
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
|
-
|
|
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) {
|
package/src/handlers/stream.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
-
...
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
534
|
+
* Create lightweight thread middleware for web routes (analytics).
|
|
451
535
|
*
|
|
452
|
-
* Sets
|
|
453
|
-
* This
|
|
454
|
-
*
|
|
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
|
-
*
|
|
457
|
-
*
|
|
542
|
+
* This is intentionally separate from createOtelMiddleware to avoid
|
|
543
|
+
* polluting the sessions table with web browsing activity.
|
|
458
544
|
*
|
|
459
|
-
* -
|
|
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
|
|
481
|
-
// httpOnly: false so beacon script can read it
|
|
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
|
|
567
|
+
maxAge: 604800, // 1 week
|
|
499
568
|
});
|
|
500
569
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
c.set('
|
|
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
|
});
|