@stacknet/stacks 0.2.0 → 0.2.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/README.md +136 -0
- package/dist/{billing-eQZIWeNW.d.cts → billing-cj0eSVrp.d.cts} +59 -1
- package/dist/{billing-eQZIWeNW.d.ts → billing-cj0eSVrp.d.ts} +59 -1
- package/dist/clients/index.cjs +4 -4
- package/dist/clients/index.d.cts +24 -1
- package/dist/clients/index.d.ts +24 -1
- package/dist/clients/index.js +4 -4
- package/dist/index.cjs +7 -7
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +7 -7
- package/dist/proxy/index.cjs +2 -2
- package/dist/proxy/index.js +2 -2
- package/dist/streaming/index.cjs +5 -5
- package/dist/streaming/index.js +5 -5
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +15 -13
- package/src/clients/agents.ts +0 -250
- package/src/clients/billing.ts +0 -197
- package/src/clients/coder.ts +0 -655
- package/src/clients/files.ts +0 -86
- package/src/clients/index.ts +0 -93
- package/src/clients/magma.ts +0 -299
- package/src/clients/mcp.ts +0 -208
- package/src/clients/network.ts +0 -118
- package/src/clients/points.ts +0 -403
- package/src/clients/skills.ts +0 -236
- package/src/clients/social.ts +0 -286
- package/src/clients/stack-management.ts +0 -279
- package/src/clients/task-network.ts +0 -303
- package/src/clients/user.ts +0 -84
- package/src/clients/widgets.ts +0 -171
- package/src/index.ts +0 -387
- package/src/managers/index.ts +0 -10
- package/src/managers/task-manager.ts +0 -332
- package/src/proxy/forwarder.ts +0 -235
- package/src/proxy/index.ts +0 -32
- package/src/proxy/route-handlers.ts +0 -1107
- package/src/streaming/component-stream.ts +0 -319
- package/src/streaming/index.ts +0 -21
- package/src/streaming/sse.ts +0 -266
- package/src/types/agent.ts +0 -108
- package/src/types/billing.ts +0 -121
- package/src/types/chat.ts +0 -58
- package/src/types/coder.ts +0 -345
- package/src/types/credential.ts +0 -111
- package/src/types/file.ts +0 -15
- package/src/types/imagination.ts +0 -50
- package/src/types/index.ts +0 -20
- package/src/types/mcp.ts +0 -35
- package/src/types/network.ts +0 -97
- package/src/types/points.ts +0 -250
- package/src/types/skill.ts +0 -107
- package/src/types/social.ts +0 -109
- package/src/types/stack.ts +0 -269
- package/src/types/task.ts +0 -41
- package/src/types/user.ts +0 -29
- package/src/types/widget.ts +0 -57
- package/src/utils/constants.ts +0 -26
- package/src/utils/errors.ts +0 -169
- package/src/utils/helpers.ts +0 -85
- package/src/utils/index.ts +0 -7
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TaskManager - Redis-backed task state management
|
|
3
|
-
*
|
|
4
|
-
* Manages task lifecycle for long-running operations with real-time updates
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { TaskType, TaskStatus, TaskState } from '../types';
|
|
8
|
-
import { TASK_PREFIX, CHAT_TASKS_PREFIX, TASK_TTL } from '../utils/constants';
|
|
9
|
-
import { generateUUID } from '../utils/helpers';
|
|
10
|
-
|
|
11
|
-
// Redis client type - we use a generic interface to avoid hard dependency
|
|
12
|
-
export interface RedisClientLike {
|
|
13
|
-
hSet(key: string, value: Record<string, string>): Promise<number>;
|
|
14
|
-
hGetAll(key: string): Promise<Record<string, string>>;
|
|
15
|
-
expire(key: string, seconds: number): Promise<boolean>;
|
|
16
|
-
sAdd(key: string, ...members: string[]): Promise<number>;
|
|
17
|
-
sMembers(key: string): Promise<string[]>;
|
|
18
|
-
sRem(key: string, ...members: string[]): Promise<number>;
|
|
19
|
-
del(key: string): Promise<number>;
|
|
20
|
-
publish(channel: string, message: string): Promise<number>;
|
|
21
|
-
subscribe(channel: string, callback: (message: string) => void): Promise<void>;
|
|
22
|
-
unsubscribe(channel: string): Promise<void>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface TaskManagerConfig {
|
|
26
|
-
redis?: RedisClientLike;
|
|
27
|
-
getRedisClient?: () => Promise<RedisClientLike>;
|
|
28
|
-
taskTTL?: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class TaskManager {
|
|
32
|
-
private redis?: RedisClientLike;
|
|
33
|
-
private getRedisClient?: () => Promise<RedisClientLike>;
|
|
34
|
-
private taskTTL: number;
|
|
35
|
-
|
|
36
|
-
constructor(config: TaskManagerConfig = {}) {
|
|
37
|
-
this.redis = config.redis;
|
|
38
|
-
this.getRedisClient = config.getRedisClient;
|
|
39
|
-
this.taskTTL = config.taskTTL || TASK_TTL;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Get Redis client (lazily initialized)
|
|
44
|
-
*/
|
|
45
|
-
private async getClient(): Promise<RedisClientLike> {
|
|
46
|
-
if (this.redis) return this.redis;
|
|
47
|
-
if (this.getRedisClient) {
|
|
48
|
-
this.redis = await this.getRedisClient();
|
|
49
|
-
return this.redis;
|
|
50
|
-
}
|
|
51
|
-
throw new Error('Redis client not configured. Provide redis or getRedisClient in config.');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Create a new task and return its ID
|
|
56
|
-
*/
|
|
57
|
-
async createTask(
|
|
58
|
-
chatId: string,
|
|
59
|
-
messageId: string,
|
|
60
|
-
type: TaskType,
|
|
61
|
-
initialMessage: string = 'Starting...'
|
|
62
|
-
): Promise<string> {
|
|
63
|
-
const redis = await this.getClient();
|
|
64
|
-
const taskId = generateUUID();
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
|
|
67
|
-
const task: TaskState = {
|
|
68
|
-
id: taskId,
|
|
69
|
-
chatId,
|
|
70
|
-
messageId,
|
|
71
|
-
type,
|
|
72
|
-
status: 'pending',
|
|
73
|
-
progress: 0,
|
|
74
|
-
message: initialMessage,
|
|
75
|
-
createdAt: now,
|
|
76
|
-
updatedAt: now,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
await redis.hSet(`${TASK_PREFIX}${taskId}`, this.taskToRedis(task));
|
|
80
|
-
await redis.expire(`${TASK_PREFIX}${taskId}`, this.taskTTL);
|
|
81
|
-
|
|
82
|
-
await redis.sAdd(`${CHAT_TASKS_PREFIX}${chatId}`, taskId);
|
|
83
|
-
await redis.expire(`${CHAT_TASKS_PREFIX}${chatId}`, this.taskTTL);
|
|
84
|
-
|
|
85
|
-
console.log(`[TaskManager] Created task ${taskId} for chat ${chatId} (${type})`);
|
|
86
|
-
|
|
87
|
-
return taskId;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Update task progress
|
|
92
|
-
*/
|
|
93
|
-
async updateProgress(
|
|
94
|
-
taskId: string,
|
|
95
|
-
progress: number,
|
|
96
|
-
message: string
|
|
97
|
-
): Promise<void> {
|
|
98
|
-
const redis = await this.getClient();
|
|
99
|
-
|
|
100
|
-
await redis.hSet(`${TASK_PREFIX}${taskId}`, {
|
|
101
|
-
status: 'generating',
|
|
102
|
-
progress: progress.toString(),
|
|
103
|
-
message,
|
|
104
|
-
updatedAt: Date.now().toString(),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
await redis.publish(
|
|
108
|
-
`task:progress:${taskId}`,
|
|
109
|
-
JSON.stringify({
|
|
110
|
-
taskId,
|
|
111
|
-
progress,
|
|
112
|
-
message,
|
|
113
|
-
status: 'generating',
|
|
114
|
-
})
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
console.log(`[TaskManager] Task ${taskId} progress: ${progress}% - ${message}`);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Mark task as complete with result
|
|
122
|
-
*/
|
|
123
|
-
async complete(taskId: string, result: unknown): Promise<void> {
|
|
124
|
-
const redis = await this.getClient();
|
|
125
|
-
|
|
126
|
-
await redis.hSet(`${TASK_PREFIX}${taskId}`, {
|
|
127
|
-
status: 'complete',
|
|
128
|
-
progress: '100',
|
|
129
|
-
message: 'Complete',
|
|
130
|
-
result: JSON.stringify(result),
|
|
131
|
-
updatedAt: Date.now().toString(),
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
await redis.publish(
|
|
135
|
-
`task:progress:${taskId}`,
|
|
136
|
-
JSON.stringify({
|
|
137
|
-
taskId,
|
|
138
|
-
progress: 100,
|
|
139
|
-
message: 'Complete',
|
|
140
|
-
status: 'complete',
|
|
141
|
-
result,
|
|
142
|
-
})
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
console.log(`[TaskManager] Task ${taskId} completed`);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Mark task as failed
|
|
150
|
-
*/
|
|
151
|
-
async fail(taskId: string, error: string): Promise<void> {
|
|
152
|
-
const redis = await this.getClient();
|
|
153
|
-
|
|
154
|
-
await redis.hSet(`${TASK_PREFIX}${taskId}`, {
|
|
155
|
-
status: 'failed',
|
|
156
|
-
message: error,
|
|
157
|
-
error,
|
|
158
|
-
updatedAt: Date.now().toString(),
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
await redis.publish(
|
|
162
|
-
`task:progress:${taskId}`,
|
|
163
|
-
JSON.stringify({
|
|
164
|
-
taskId,
|
|
165
|
-
status: 'failed',
|
|
166
|
-
error,
|
|
167
|
-
})
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
console.log(`[TaskManager] Task ${taskId} failed: ${error}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Get task by ID
|
|
175
|
-
*/
|
|
176
|
-
async getTask(taskId: string): Promise<TaskState | null> {
|
|
177
|
-
const redis = await this.getClient();
|
|
178
|
-
const data = await redis.hGetAll(`${TASK_PREFIX}${taskId}`);
|
|
179
|
-
|
|
180
|
-
if (!data || Object.keys(data).length === 0) {
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return this.redisToTask(data);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Get all tasks for a chat
|
|
189
|
-
*/
|
|
190
|
-
async getTasksForChat(chatId: string): Promise<TaskState[]> {
|
|
191
|
-
const redis = await this.getClient();
|
|
192
|
-
const taskIds = await redis.sMembers(`${CHAT_TASKS_PREFIX}${chatId}`);
|
|
193
|
-
|
|
194
|
-
if (!taskIds || taskIds.length === 0) {
|
|
195
|
-
return [];
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const tasks: TaskState[] = [];
|
|
199
|
-
for (const taskId of taskIds) {
|
|
200
|
-
const task = await this.getTask(taskId);
|
|
201
|
-
if (task) {
|
|
202
|
-
tasks.push(task);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return tasks.sort((a, b) => b.createdAt - a.createdAt);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Get pending/generating tasks for a chat
|
|
211
|
-
*/
|
|
212
|
-
async getPendingTasksForChat(chatId: string): Promise<TaskState[]> {
|
|
213
|
-
const tasks = await this.getTasksForChat(chatId);
|
|
214
|
-
return tasks.filter((t) => t.status === 'pending' || t.status === 'generating');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Delete a task
|
|
219
|
-
*/
|
|
220
|
-
async deleteTask(taskId: string): Promise<void> {
|
|
221
|
-
const redis = await this.getClient();
|
|
222
|
-
const task = await this.getTask(taskId);
|
|
223
|
-
|
|
224
|
-
if (task) {
|
|
225
|
-
await redis.del(`${TASK_PREFIX}${taskId}`);
|
|
226
|
-
await redis.sRem(`${CHAT_TASKS_PREFIX}${task.chatId}`, taskId);
|
|
227
|
-
console.log(`[TaskManager] Deleted task ${taskId}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Clean up old completed/failed tasks for a chat
|
|
233
|
-
*/
|
|
234
|
-
async cleanupOldTasks(chatId: string, maxAge: number = 86400000): Promise<void> {
|
|
235
|
-
const tasks = await this.getTasksForChat(chatId);
|
|
236
|
-
const now = Date.now();
|
|
237
|
-
|
|
238
|
-
for (const task of tasks) {
|
|
239
|
-
if (
|
|
240
|
-
(task.status === 'complete' || task.status === 'failed') &&
|
|
241
|
-
now - task.updatedAt > maxAge
|
|
242
|
-
) {
|
|
243
|
-
await this.deleteTask(task.id);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Subscribe to task progress updates
|
|
250
|
-
*/
|
|
251
|
-
async subscribeToTask(
|
|
252
|
-
taskId: string,
|
|
253
|
-
callback: (update: { taskId: string; status: TaskStatus; progress?: number; message?: string; result?: unknown; error?: string }) => void
|
|
254
|
-
): Promise<() => Promise<void>> {
|
|
255
|
-
const redis = await this.getClient();
|
|
256
|
-
const channel = `task:progress:${taskId}`;
|
|
257
|
-
|
|
258
|
-
await redis.subscribe(channel, (message) => {
|
|
259
|
-
let data: unknown;
|
|
260
|
-
try {
|
|
261
|
-
data = JSON.parse(message);
|
|
262
|
-
} catch {
|
|
263
|
-
return; // not JSON — ignore
|
|
264
|
-
}
|
|
265
|
-
// Shape-check: Redis pub/sub is a trust boundary. If anything else
|
|
266
|
-
// (a compromised publisher, a misconfigured tenant, or an operator
|
|
267
|
-
// pushing test data) posts to this channel, the message reaches
|
|
268
|
-
// this callback. Drop anything that doesn't match our own
|
|
269
|
-
// publisher's shape before handing it to consumer code — otherwise
|
|
270
|
-
// an attacker with publish access could inject arbitrary objects
|
|
271
|
-
// (prototype keys, unexpected fields) into the UI layer.
|
|
272
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) return;
|
|
273
|
-
const d = data as Record<string, unknown>;
|
|
274
|
-
if (typeof d.taskId !== 'string' || d.taskId !== taskId) return;
|
|
275
|
-
if (typeof d.status !== 'string') return;
|
|
276
|
-
if (d.progress !== undefined && typeof d.progress !== 'number') return;
|
|
277
|
-
if (d.message !== undefined && typeof d.message !== 'string') return;
|
|
278
|
-
if (d.error !== undefined && typeof d.error !== 'string') return;
|
|
279
|
-
callback({
|
|
280
|
-
taskId: d.taskId,
|
|
281
|
-
status: d.status as TaskStatus,
|
|
282
|
-
progress: d.progress as number | undefined,
|
|
283
|
-
message: d.message as string | undefined,
|
|
284
|
-
result: d.result,
|
|
285
|
-
error: d.error as string | undefined,
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
return async () => {
|
|
290
|
-
await redis.unsubscribe(channel);
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
private taskToRedis(task: TaskState): Record<string, string> {
|
|
295
|
-
return {
|
|
296
|
-
id: task.id,
|
|
297
|
-
chatId: task.chatId,
|
|
298
|
-
messageId: task.messageId,
|
|
299
|
-
type: task.type,
|
|
300
|
-
status: task.status,
|
|
301
|
-
progress: task.progress.toString(),
|
|
302
|
-
message: task.message,
|
|
303
|
-
result: task.result || '',
|
|
304
|
-
error: task.error || '',
|
|
305
|
-
createdAt: task.createdAt.toString(),
|
|
306
|
-
updatedAt: task.updatedAt.toString(),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
private redisToTask(data: Record<string, string>): TaskState {
|
|
311
|
-
return {
|
|
312
|
-
id: data.id,
|
|
313
|
-
chatId: data.chatId,
|
|
314
|
-
messageId: data.messageId,
|
|
315
|
-
type: data.type as TaskType,
|
|
316
|
-
status: data.status as TaskStatus,
|
|
317
|
-
progress: parseInt(data.progress, 10),
|
|
318
|
-
message: data.message,
|
|
319
|
-
result: data.result || undefined,
|
|
320
|
-
error: data.error || undefined,
|
|
321
|
-
createdAt: parseInt(data.createdAt, 10),
|
|
322
|
-
updatedAt: parseInt(data.updatedAt, 10),
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Factory function to create a TaskManager
|
|
329
|
-
*/
|
|
330
|
-
export function createTaskManager(config?: TaskManagerConfig): TaskManager {
|
|
331
|
-
return new TaskManager(config);
|
|
332
|
-
}
|
package/src/proxy/forwarder.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic request forwarding utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { DEFAULT_TASK_NETWORK_URL } from '../utils/constants';
|
|
6
|
-
|
|
7
|
-
export interface ForwarderConfig {
|
|
8
|
-
baseUrl?: string;
|
|
9
|
-
defaultHeaders?: Record<string, string>;
|
|
10
|
-
/** Request timeout in milliseconds. Applied via AbortSignal to every
|
|
11
|
-
* outbound fetch. Default: 30_000 (30s). Previously declared but not
|
|
12
|
-
* wired — an upstream hang would leak file descriptors indefinitely. */
|
|
13
|
-
timeout?: number;
|
|
14
|
-
/** Hard cap on inbound JSON body size (bytes) read by `createProxyHandler`.
|
|
15
|
-
* Requests larger than this are rejected with 413. Default: 1 MiB. */
|
|
16
|
-
maxBodyBytes?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface RequestOptions {
|
|
20
|
-
method?: string;
|
|
21
|
-
headers?: Record<string, string>;
|
|
22
|
-
body?: unknown;
|
|
23
|
-
searchParams?: Record<string, string | undefined>;
|
|
24
|
-
stream?: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
-
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
29
|
-
|
|
30
|
-
/** Headers that are safe to forward from an inbound request to the upstream.
|
|
31
|
-
* `createProxyHandler` previously copied every inbound header — including
|
|
32
|
-
* `cookie`, attacker-set `x-forwarded-for`, and the consumer app's session
|
|
33
|
-
* cookies — to an arbitrary upstream. Only the headers that carry request
|
|
34
|
-
* semantics (auth + content negotiation + a request id for tracing) are
|
|
35
|
-
* preserved now. */
|
|
36
|
-
const FORWARDABLE_REQUEST_HEADERS = new Set([
|
|
37
|
-
'authorization',
|
|
38
|
-
'content-type',
|
|
39
|
-
'accept',
|
|
40
|
-
'accept-language',
|
|
41
|
-
'x-request-id',
|
|
42
|
-
'x-correlation-id',
|
|
43
|
-
'idempotency-key',
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
/** Build the outbound URL by resolving `path` relative to `baseUrl`. Using
|
|
47
|
-
* the URL constructor here (instead of string concatenation) prevents a
|
|
48
|
-
* path like ".evil.com/x" from producing an unintended host.
|
|
49
|
-
* Throws if the resolved URL escapes the baseUrl origin. */
|
|
50
|
-
function resolveUrl(baseUrl: string, path: string): string {
|
|
51
|
-
// Ensure baseUrl ends with '/' so URL() treats it as a directory root.
|
|
52
|
-
const baseForResolve = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
53
|
-
// Drop leading '/' on path so it doesn't replace the baseUrl pathname.
|
|
54
|
-
const rel = path.startsWith('/') ? path.slice(1) : path;
|
|
55
|
-
const resolved = new URL(rel, baseForResolve);
|
|
56
|
-
const base = new URL(baseForResolve);
|
|
57
|
-
if (resolved.origin !== base.origin) {
|
|
58
|
-
throw new Error(`forwarder: resolved URL ${resolved.origin} escapes baseUrl origin ${base.origin}`);
|
|
59
|
-
}
|
|
60
|
-
return resolved.toString();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Forward a request to a backend URL
|
|
65
|
-
*/
|
|
66
|
-
export async function forwardRequest(
|
|
67
|
-
path: string,
|
|
68
|
-
options: RequestOptions = {},
|
|
69
|
-
config: ForwarderConfig = {}
|
|
70
|
-
): Promise<Response> {
|
|
71
|
-
const baseUrl = config.baseUrl || DEFAULT_TASK_NETWORK_URL;
|
|
72
|
-
const { method = 'GET', headers = {}, body, searchParams, stream } = options;
|
|
73
|
-
|
|
74
|
-
// Build URL safely, then attach search params.
|
|
75
|
-
const urlObj = new URL(resolveUrl(baseUrl, path));
|
|
76
|
-
if (searchParams) {
|
|
77
|
-
Object.entries(searchParams).forEach(([key, value]) => {
|
|
78
|
-
if (value !== undefined) {
|
|
79
|
-
urlObj.searchParams.set(key, value);
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const fetchOptions: RequestInit = {
|
|
85
|
-
method,
|
|
86
|
-
headers: {
|
|
87
|
-
...config.defaultHeaders,
|
|
88
|
-
...headers,
|
|
89
|
-
},
|
|
90
|
-
signal: AbortSignal.timeout(config.timeout ?? DEFAULT_TIMEOUT_MS),
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
if (body && method !== 'GET') {
|
|
94
|
-
fetchOptions.headers = {
|
|
95
|
-
...fetchOptions.headers,
|
|
96
|
-
'Content-Type': 'application/json',
|
|
97
|
-
};
|
|
98
|
-
fetchOptions.body = JSON.stringify(body);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const response = await fetch(urlObj.toString(), fetchOptions);
|
|
102
|
-
|
|
103
|
-
// For streaming responses, return directly
|
|
104
|
-
if (stream && response.body) {
|
|
105
|
-
return response;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return response;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Forward and return JSON response
|
|
113
|
-
*/
|
|
114
|
-
export async function forwardJSON<T = unknown>(
|
|
115
|
-
path: string,
|
|
116
|
-
options: RequestOptions = {},
|
|
117
|
-
config: ForwarderConfig = {}
|
|
118
|
-
): Promise<{ data: T; status: number }> {
|
|
119
|
-
const response = await forwardRequest(path, options, config);
|
|
120
|
-
const data = await response.json();
|
|
121
|
-
return { data, status: response.status };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Read a request body with a hard byte cap. Returns `null` if the body
|
|
125
|
-
* exceeds the cap, so the caller can return 413 without first buffering
|
|
126
|
-
* an attacker-sized payload into memory. */
|
|
127
|
-
async function readBodyCapped(request: Request, maxBytes: number): Promise<unknown | { _tooLarge: true }> {
|
|
128
|
-
const contentLength = request.headers.get('content-length');
|
|
129
|
-
if (contentLength) {
|
|
130
|
-
const n = parseInt(contentLength, 10);
|
|
131
|
-
if (Number.isFinite(n) && n > maxBytes) return { _tooLarge: true };
|
|
132
|
-
}
|
|
133
|
-
if (!request.body) return undefined;
|
|
134
|
-
const reader = request.body.getReader();
|
|
135
|
-
let received = 0;
|
|
136
|
-
const chunks: Uint8Array[] = [];
|
|
137
|
-
// Streaming read with byte cap — aborts as soon as the cap is exceeded.
|
|
138
|
-
// eslint-disable-next-line no-constant-condition
|
|
139
|
-
while (true) {
|
|
140
|
-
const { done, value } = await reader.read();
|
|
141
|
-
if (done) break;
|
|
142
|
-
received += value.byteLength;
|
|
143
|
-
if (received > maxBytes) {
|
|
144
|
-
try { await reader.cancel(); } catch { /* ignore */ }
|
|
145
|
-
return { _tooLarge: true };
|
|
146
|
-
}
|
|
147
|
-
chunks.push(value);
|
|
148
|
-
}
|
|
149
|
-
if (received === 0) return undefined;
|
|
150
|
-
const buf = new Uint8Array(received);
|
|
151
|
-
let offset = 0;
|
|
152
|
-
for (const c of chunks) { buf.set(c, offset); offset += c.byteLength; }
|
|
153
|
-
const text = new TextDecoder().decode(buf);
|
|
154
|
-
if (text.trim() === '') return undefined;
|
|
155
|
-
try {
|
|
156
|
-
return JSON.parse(text);
|
|
157
|
-
} catch {
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Create a proxy handler that forwards requests.
|
|
164
|
-
*
|
|
165
|
-
* SECURITY: only a small whitelist of inbound headers is forwarded (see
|
|
166
|
-
* FORWARDABLE_REQUEST_HEADERS). Everything else — including `cookie`,
|
|
167
|
-
* `x-forwarded-for`, `host` — is stripped so a caller cannot poison the
|
|
168
|
-
* upstream's view of the request or smuggle the consumer app's cookies to
|
|
169
|
-
* a third-party StackNet endpoint.
|
|
170
|
-
*/
|
|
171
|
-
export function createProxyHandler(
|
|
172
|
-
path: string | ((req: Request) => string),
|
|
173
|
-
config: ForwarderConfig = {}
|
|
174
|
-
) {
|
|
175
|
-
const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
176
|
-
|
|
177
|
-
return async (request: Request): Promise<Response> => {
|
|
178
|
-
const url = new URL(request.url);
|
|
179
|
-
const targetPath = typeof path === 'function' ? path(request) : path;
|
|
180
|
-
|
|
181
|
-
// Forward search params
|
|
182
|
-
const searchParams: Record<string, string> = {};
|
|
183
|
-
url.searchParams.forEach((value, key) => {
|
|
184
|
-
searchParams[key] = value;
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
let body: unknown = undefined;
|
|
188
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
189
|
-
const read = await readBodyCapped(request, maxBodyBytes);
|
|
190
|
-
if (read && typeof read === 'object' && (read as { _tooLarge?: boolean })._tooLarge) {
|
|
191
|
-
return Response.json(
|
|
192
|
-
{ error: `Request body exceeds ${maxBodyBytes} bytes` },
|
|
193
|
-
{ status: 413 },
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
body = read;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Forward ONLY the whitelisted headers.
|
|
200
|
-
const requestHeaders: Record<string, string> = {};
|
|
201
|
-
request.headers.forEach((value, key) => {
|
|
202
|
-
if (FORWARDABLE_REQUEST_HEADERS.has(key.toLowerCase())) {
|
|
203
|
-
requestHeaders[key] = value;
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
const response = await forwardRequest(
|
|
208
|
-
targetPath,
|
|
209
|
-
{
|
|
210
|
-
method: request.method,
|
|
211
|
-
headers: requestHeaders,
|
|
212
|
-
body,
|
|
213
|
-
searchParams,
|
|
214
|
-
},
|
|
215
|
-
config
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
// Check if streaming response
|
|
219
|
-
const contentType = response.headers.get('content-type') || '';
|
|
220
|
-
if (contentType.includes('text/event-stream')) {
|
|
221
|
-
return new Response(response.body, {
|
|
222
|
-
status: response.status,
|
|
223
|
-
headers: {
|
|
224
|
-
'Content-Type': 'text/event-stream',
|
|
225
|
-
'Cache-Control': 'no-cache',
|
|
226
|
-
'Connection': 'keep-alive',
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Return JSON response
|
|
232
|
-
const data = await response.json();
|
|
233
|
-
return Response.json(data, { status: response.status });
|
|
234
|
-
};
|
|
235
|
-
}
|
package/src/proxy/index.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stacknet/stacks - Proxy utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export {
|
|
6
|
-
forwardRequest,
|
|
7
|
-
forwardJSON,
|
|
8
|
-
createProxyHandler,
|
|
9
|
-
type ForwarderConfig,
|
|
10
|
-
type RequestOptions,
|
|
11
|
-
} from './forwarder';
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
createAgentRoutes,
|
|
15
|
-
createAgentDetailRoutes,
|
|
16
|
-
createAgentExecuteRoute,
|
|
17
|
-
createAgentToggleRoutes,
|
|
18
|
-
createSkillRoutes,
|
|
19
|
-
createSkillDetailRoutes,
|
|
20
|
-
createWidgetRoutes,
|
|
21
|
-
createWidgetDetailRoutes,
|
|
22
|
-
createImaginationRoutes,
|
|
23
|
-
// Stack management routes
|
|
24
|
-
createStackRoutes,
|
|
25
|
-
createStackDetailRoutes,
|
|
26
|
-
createStackKeysRoutes,
|
|
27
|
-
createStackMembersRoutes,
|
|
28
|
-
type RouteHandlerConfig,
|
|
29
|
-
type StackRouteHandlerConfig,
|
|
30
|
-
type RouteHandler,
|
|
31
|
-
type CRUDRouteHandlers,
|
|
32
|
-
} from './route-handlers';
|