cooper-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.d.ts +60 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +66 -0
- package/dist/ai.js.map +1 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +40 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +16 -0
- package/dist/auth.js.map +1 -0
- package/dist/bridge.d.ts +15 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +217 -0
- package/dist/bridge.js.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +69 -0
- package/dist/cache.js.map +1 -0
- package/dist/cron.d.ts +22 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +26 -0
- package/dist/cron.js.map +1 -0
- package/dist/db.d.ts +46 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +158 -0
- package/dist/db.js.map +1 -0
- package/dist/error.d.ts +18 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +30 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +16 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +23 -0
- package/dist/islands.js.map +1 -0
- package/dist/middleware.d.ts +20 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +26 -0
- package/dist/middleware.js.map +1 -0
- package/dist/nats.d.ts +46 -0
- package/dist/nats.d.ts.map +1 -0
- package/dist/nats.js +157 -0
- package/dist/nats.js.map +1 -0
- package/dist/pubsub.d.ts +27 -0
- package/dist/pubsub.d.ts.map +1 -0
- package/dist/pubsub.js +152 -0
- package/dist/pubsub.js.map +1 -0
- package/dist/queue.d.ts +39 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +298 -0
- package/dist/queue.js.map +1 -0
- package/dist/rateLimit.d.ts +29 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +70 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/registry.d.ts +75 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +41 -0
- package/dist/registry.js.map +1 -0
- package/dist/secrets.d.ts +10 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +35 -0
- package/dist/secrets.js.map +1 -0
- package/dist/ssr.d.ts +53 -0
- package/dist/ssr.d.ts.map +1 -0
- package/dist/ssr.js +39 -0
- package/dist/ssr.js.map +1 -0
- package/dist/storage.d.ts +28 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +61 -0
- package/dist/storage.js.map +1 -0
- package/package.json +40 -0
- package/src/ai.ts +99 -0
- package/src/api.ts +56 -0
- package/src/auth.ts +16 -0
- package/src/bridge.ts +267 -0
- package/src/cache.ts +86 -0
- package/src/cron.ts +32 -0
- package/src/db.ts +211 -0
- package/src/error.ts +44 -0
- package/src/index.ts +17 -0
- package/src/islands.ts +28 -0
- package/src/middleware.ts +27 -0
- package/src/nats.ts +186 -0
- package/src/pubsub.ts +208 -0
- package/src/queue.ts +414 -0
- package/src/rateLimit.ts +89 -0
- package/src/registry.ts +98 -0
- package/src/secrets.ts +40 -0
- package/src/ssr.ts +58 -0
- package/src/storage.ts +79 -0
- package/tsconfig.json +17 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface CacheConfig {
|
|
2
|
+
ttl?: string; // e.g. "10m", "1h", "30s"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface CacheClient<T> {
|
|
6
|
+
get(key: string): Promise<T | null>;
|
|
7
|
+
set(key: string, value: T, opts?: { ttl?: string }): Promise<void>;
|
|
8
|
+
getOrSet(key: string, factory: () => Promise<T>, opts?: { ttl?: string }): Promise<T>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
invalidatePrefix(prefix: string): Promise<void>;
|
|
11
|
+
increment(key: string, opts?: { ttl?: string }): Promise<number>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseTTL(ttl: string): number {
|
|
15
|
+
const match = ttl.match(/^(\d+)(s|m|h|d)$/);
|
|
16
|
+
if (!match) return 600;
|
|
17
|
+
const [, num, unit] = match;
|
|
18
|
+
const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
19
|
+
return parseInt(num) * (multipliers[unit] ?? 60);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Declare a typed cache.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* export const userCache = cache<User>("users", { ttl: "10m" });
|
|
27
|
+
* const user = await userCache.getOrSet(userId, () => db.queryRow(...));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function cache<T = any>(name: string, config?: CacheConfig): CacheClient<T> {
|
|
31
|
+
const defaultTTL = config?.ttl ?? "10m";
|
|
32
|
+
let redis: any = null;
|
|
33
|
+
|
|
34
|
+
const ensureRedis = async () => {
|
|
35
|
+
if (redis) return redis;
|
|
36
|
+
const Redis = (await import("ioredis")).default;
|
|
37
|
+
const url = process.env.COOPER_VALKEY_URL ?? "redis://localhost:6379";
|
|
38
|
+
redis = new Redis(url);
|
|
39
|
+
return redis;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const prefixed = (key: string) => `cooper:${name}:${key}`;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async get(key: string): Promise<T | null> {
|
|
46
|
+
const r = await ensureRedis();
|
|
47
|
+
const val = await r.get(prefixed(key));
|
|
48
|
+
return val ? JSON.parse(val) : null;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async set(key: string, value: T, opts?: { ttl?: string }): Promise<void> {
|
|
52
|
+
const r = await ensureRedis();
|
|
53
|
+
const ttlSec = parseTTL(opts?.ttl ?? defaultTTL);
|
|
54
|
+
await r.setex(prefixed(key), ttlSec, JSON.stringify(value));
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async getOrSet(key: string, factory: () => Promise<T>, opts?: { ttl?: string }): Promise<T> {
|
|
58
|
+
const existing = await this.get(key);
|
|
59
|
+
if (existing !== null) return existing;
|
|
60
|
+
const value = await factory();
|
|
61
|
+
await this.set(key, value, opts);
|
|
62
|
+
return value;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async delete(key: string): Promise<void> {
|
|
66
|
+
const r = await ensureRedis();
|
|
67
|
+
await r.del(prefixed(key));
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async invalidatePrefix(prefix: string): Promise<void> {
|
|
71
|
+
const r = await ensureRedis();
|
|
72
|
+
const keys = await r.keys(prefixed(prefix) + "*");
|
|
73
|
+
if (keys.length > 0) await r.del(...keys);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async increment(key: string, opts?: { ttl?: string }): Promise<number> {
|
|
77
|
+
const r = await ensureRedis();
|
|
78
|
+
const k = prefixed(key);
|
|
79
|
+
const val = await r.incr(k);
|
|
80
|
+
if (val === 1 && opts?.ttl) {
|
|
81
|
+
await r.expire(k, parseTTL(opts.ttl));
|
|
82
|
+
}
|
|
83
|
+
return val;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { registry } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
export interface CronConfig {
|
|
4
|
+
schedule: string;
|
|
5
|
+
handler: () => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Declare a cron job.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const cleanup = cron("cleanup", {
|
|
13
|
+
* schedule: "every 1 hour",
|
|
14
|
+
* handler: async () => {
|
|
15
|
+
* await db.query("DELETE FROM sessions WHERE expires_at < NOW()");
|
|
16
|
+
* },
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function cron(name: string, config: CronConfig) {
|
|
21
|
+
registry.registerCron(name, {
|
|
22
|
+
name,
|
|
23
|
+
schedule: config.schedule,
|
|
24
|
+
handler: config.handler,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
_cooper_type: "cron" as const,
|
|
29
|
+
name,
|
|
30
|
+
schedule: config.schedule,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { registry } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
export interface DatabaseConfig {
|
|
4
|
+
engine?: "postgres" | "mysql" | "mongodb" | "dynamodb";
|
|
5
|
+
migrations?: string;
|
|
6
|
+
partitionKey?: string;
|
|
7
|
+
sortKey?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TransactionClient {
|
|
11
|
+
/** Run a query returning multiple rows */
|
|
12
|
+
query<T = any>(sql: string, params?: any[]): Promise<T[]>;
|
|
13
|
+
/** Run a query returning a single row or null */
|
|
14
|
+
queryRow<T = any>(sql: string, params?: any[]): Promise<T | null>;
|
|
15
|
+
/** Run a query returning affected row count */
|
|
16
|
+
exec(sql: string, params?: any[]): Promise<{ rowCount: number }>;
|
|
17
|
+
/** Insert and return the inserted row */
|
|
18
|
+
insert<T = any>(table: string, data: Record<string, any>): Promise<T>;
|
|
19
|
+
/** Access the underlying connection (single client, not pool) */
|
|
20
|
+
conn: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DatabaseClient {
|
|
24
|
+
/** Run a query returning multiple rows */
|
|
25
|
+
query<T = any>(sql: string, params?: any[]): Promise<T[]>;
|
|
26
|
+
/** Run a query returning a single row or null */
|
|
27
|
+
queryRow<T = any>(sql: string, params?: any[]): Promise<T | null>;
|
|
28
|
+
/** Run a query returning affected row count */
|
|
29
|
+
exec(sql: string, params?: any[]): Promise<{ rowCount: number }>;
|
|
30
|
+
/** Insert and return the inserted row */
|
|
31
|
+
insert<T = any>(table: string, data: Record<string, any>): Promise<T>;
|
|
32
|
+
/** Run a callback inside a transaction — auto-commits on success, rolls back on error */
|
|
33
|
+
transaction<R = any>(fn: (tx: TransactionClient) => Promise<R>): Promise<R>;
|
|
34
|
+
/** Access the underlying connection pool (for ORMs like Drizzle) */
|
|
35
|
+
pool: any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Declare a database.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* export const db = database("main", { migrations: "./migrations" });
|
|
43
|
+
* const user = await db.queryRow<User>("SELECT * FROM users WHERE id = $1", [id]);
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function database(name: string, config?: DatabaseConfig): DatabaseClient {
|
|
47
|
+
const engine = config?.engine ?? "postgres";
|
|
48
|
+
|
|
49
|
+
// Connection details injected by the Rust runtime via env vars
|
|
50
|
+
const connStr = process.env[`COOPER_DB_${name.toUpperCase()}_URL`]
|
|
51
|
+
?? `postgres://cooper:cooper@localhost:5432/cooper_${name}`;
|
|
52
|
+
|
|
53
|
+
let pool: any = null;
|
|
54
|
+
|
|
55
|
+
const ensurePool = async () => {
|
|
56
|
+
if (pool) return pool;
|
|
57
|
+
|
|
58
|
+
if (engine === "postgres") {
|
|
59
|
+
const pg = await import("pg");
|
|
60
|
+
pool = new pg.default.Pool({ connectionString: connStr });
|
|
61
|
+
return pool;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (engine === "mysql") {
|
|
65
|
+
const mysql = await import("mysql2/promise");
|
|
66
|
+
pool = await mysql.createPool(connStr);
|
|
67
|
+
return pool;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(`Database engine "${engine}" not yet supported in JS runtime`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const client: DatabaseClient = {
|
|
74
|
+
async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
|
75
|
+
const p = await ensurePool();
|
|
76
|
+
if (engine === "postgres") {
|
|
77
|
+
const res = await p.query(sql, params);
|
|
78
|
+
return res.rows as T[];
|
|
79
|
+
}
|
|
80
|
+
if (engine === "mysql") {
|
|
81
|
+
const [rows] = await p.execute(sql, params);
|
|
82
|
+
return rows as T[];
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async queryRow<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
|
88
|
+
const rows = await client.query<T>(sql, params);
|
|
89
|
+
return rows[0] ?? null;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async exec(sql: string, params?: any[]): Promise<{ rowCount: number }> {
|
|
93
|
+
const p = await ensurePool();
|
|
94
|
+
if (engine === "postgres") {
|
|
95
|
+
const res = await p.query(sql, params);
|
|
96
|
+
return { rowCount: res.rowCount ?? 0 };
|
|
97
|
+
}
|
|
98
|
+
if (engine === "mysql") {
|
|
99
|
+
const [result] = await p.execute(sql, params);
|
|
100
|
+
return { rowCount: (result as any).affectedRows ?? 0 };
|
|
101
|
+
}
|
|
102
|
+
return { rowCount: 0 };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async insert<T = any>(table: string, data: Record<string, any>): Promise<T> {
|
|
106
|
+
const keys = Object.keys(data);
|
|
107
|
+
const values = Object.values(data);
|
|
108
|
+
const placeholders = keys.map((_, i) =>
|
|
109
|
+
engine === "postgres" ? `$${i + 1}` : "?"
|
|
110
|
+
);
|
|
111
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
|
|
112
|
+
const row = await client.queryRow<T>(sql, values);
|
|
113
|
+
return row!;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async transaction<R = any>(fn: (tx: TransactionClient) => Promise<R>): Promise<R> {
|
|
117
|
+
const p = await ensurePool();
|
|
118
|
+
|
|
119
|
+
if (engine === "postgres") {
|
|
120
|
+
const pgClient = await p.connect();
|
|
121
|
+
try {
|
|
122
|
+
await pgClient.query("BEGIN");
|
|
123
|
+
|
|
124
|
+
const tx: TransactionClient = {
|
|
125
|
+
async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
|
126
|
+
const res = await pgClient.query(sql, params);
|
|
127
|
+
return res.rows as T[];
|
|
128
|
+
},
|
|
129
|
+
async queryRow<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
|
130
|
+
const res = await pgClient.query(sql, params);
|
|
131
|
+
return (res.rows[0] as T) ?? null;
|
|
132
|
+
},
|
|
133
|
+
async exec(sql: string, params?: any[]): Promise<{ rowCount: number }> {
|
|
134
|
+
const res = await pgClient.query(sql, params);
|
|
135
|
+
return { rowCount: res.rowCount ?? 0 };
|
|
136
|
+
},
|
|
137
|
+
async insert<T = any>(table: string, data: Record<string, any>): Promise<T> {
|
|
138
|
+
const keys = Object.keys(data);
|
|
139
|
+
const values = Object.values(data);
|
|
140
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`);
|
|
141
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
|
|
142
|
+
const res = await pgClient.query(sql, values);
|
|
143
|
+
return res.rows[0] as T;
|
|
144
|
+
},
|
|
145
|
+
get conn() { return pgClient; },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const result = await fn(tx);
|
|
149
|
+
await pgClient.query("COMMIT");
|
|
150
|
+
return result;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
await pgClient.query("ROLLBACK");
|
|
153
|
+
throw err;
|
|
154
|
+
} finally {
|
|
155
|
+
pgClient.release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (engine === "mysql") {
|
|
160
|
+
const conn = await p.getConnection();
|
|
161
|
+
try {
|
|
162
|
+
await conn.beginTransaction();
|
|
163
|
+
|
|
164
|
+
const tx: TransactionClient = {
|
|
165
|
+
async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
|
166
|
+
const [rows] = await conn.execute(sql, params);
|
|
167
|
+
return rows as T[];
|
|
168
|
+
},
|
|
169
|
+
async queryRow<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
|
170
|
+
const [rows] = await conn.execute(sql, params);
|
|
171
|
+
return ((rows as any[])[0] as T) ?? null;
|
|
172
|
+
},
|
|
173
|
+
async exec(sql: string, params?: any[]): Promise<{ rowCount: number }> {
|
|
174
|
+
const [result] = await conn.execute(sql, params);
|
|
175
|
+
return { rowCount: (result as any).affectedRows ?? 0 };
|
|
176
|
+
},
|
|
177
|
+
async insert<T = any>(table: string, data: Record<string, any>): Promise<T> {
|
|
178
|
+
const keys = Object.keys(data);
|
|
179
|
+
const values = Object.values(data);
|
|
180
|
+
const placeholders = keys.map(() => "?");
|
|
181
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
182
|
+
await conn.execute(sql, values);
|
|
183
|
+
const [rows] = await conn.execute(`SELECT * FROM ${table} WHERE id = LAST_INSERT_ID()`);
|
|
184
|
+
return (rows as any[])[0] as T;
|
|
185
|
+
},
|
|
186
|
+
get conn() { return conn; },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const result = await fn(tx);
|
|
190
|
+
await conn.commit();
|
|
191
|
+
return result;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
await conn.rollback();
|
|
194
|
+
throw err;
|
|
195
|
+
} finally {
|
|
196
|
+
conn.release();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw new Error(`Transactions not supported for engine "${engine}"`);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
get pool() {
|
|
204
|
+
return pool;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
registry.registerDatabase(name, { name, engine, pool: client });
|
|
209
|
+
|
|
210
|
+
return client;
|
|
211
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error codes with automatic HTTP status mapping.
|
|
3
|
+
*/
|
|
4
|
+
export type ErrorCode =
|
|
5
|
+
| "NOT_FOUND"
|
|
6
|
+
| "UNAUTHORIZED"
|
|
7
|
+
| "PERMISSION_DENIED"
|
|
8
|
+
| "RATE_LIMITED"
|
|
9
|
+
| "INVALID_ARGUMENT"
|
|
10
|
+
| "VALIDATION_FAILED"
|
|
11
|
+
| "INTERNAL";
|
|
12
|
+
|
|
13
|
+
const STATUS_MAP: Record<ErrorCode, number> = {
|
|
14
|
+
NOT_FOUND: 404,
|
|
15
|
+
UNAUTHORIZED: 401,
|
|
16
|
+
PERMISSION_DENIED: 403,
|
|
17
|
+
RATE_LIMITED: 429,
|
|
18
|
+
INVALID_ARGUMENT: 400,
|
|
19
|
+
VALIDATION_FAILED: 422,
|
|
20
|
+
INTERNAL: 500,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class CooperError extends Error {
|
|
24
|
+
public readonly code: ErrorCode;
|
|
25
|
+
public readonly statusCode: number;
|
|
26
|
+
public retryAfter?: number;
|
|
27
|
+
|
|
28
|
+
constructor(code: ErrorCode, message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "CooperError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.statusCode = STATUS_MAP[code] ?? 500;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toJSON() {
|
|
36
|
+
return {
|
|
37
|
+
error: {
|
|
38
|
+
code: this.code,
|
|
39
|
+
message: this.message,
|
|
40
|
+
...(this.retryAfter !== undefined && { retryAfter: this.retryAfter }),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { api } from "./api.js";
|
|
2
|
+
export { CooperError, type ErrorCode } from "./error.js";
|
|
3
|
+
export { database, type DatabaseClient, type TransactionClient } from "./db.js";
|
|
4
|
+
export { middleware, cooper } from "./middleware.js";
|
|
5
|
+
export { authHandler } from "./auth.js";
|
|
6
|
+
export { topic, type Topic } from "./pubsub.js";
|
|
7
|
+
export { closeNats } from "./nats.js";
|
|
8
|
+
export { cron } from "./cron.js";
|
|
9
|
+
export { cache, type CacheClient } from "./cache.js";
|
|
10
|
+
export { bucket, type BucketClient } from "./storage.js";
|
|
11
|
+
export { secret } from "./secrets.js";
|
|
12
|
+
export { queue, type QueueClient } from "./queue.js";
|
|
13
|
+
export { page, layout, pageLoader, Suspense } from "./ssr.js";
|
|
14
|
+
export { island } from "./islands.js";
|
|
15
|
+
export { vectorStore, llmGateway } from "./ai.js";
|
|
16
|
+
export { rateLimit, type RateLimitConfig } from "./rateLimit.js";
|
|
17
|
+
export { registry } from "./registry.js";
|
package/src/islands.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type HydrationStrategy = "load" | "visible" | "idle" | "interaction" | "none";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mark a component as an island — it will be hydrated on the client.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* // islands/LikeButton.island.tsx
|
|
8
|
+
* export default island(function LikeButton({ userId, initialCount }) {
|
|
9
|
+
* const [count, setCount] = useState(initialCount);
|
|
10
|
+
* return <button onClick={...}>Like ({count})</button>;
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function island<P = any>(
|
|
15
|
+
component: (props: P) => any
|
|
16
|
+
): (props: P & { hydrate?: HydrationStrategy }) => any {
|
|
17
|
+
// Mark the component for the bundler
|
|
18
|
+
const wrapper = (props: P & { hydrate?: HydrationStrategy }) => {
|
|
19
|
+
// Server-side: render the component to HTML
|
|
20
|
+
// Client-side: hydrate based on strategy
|
|
21
|
+
return component(props);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
(wrapper as any)._cooper_island = true;
|
|
25
|
+
(wrapper as any)._cooper_hydrate = "load"; // default strategy
|
|
26
|
+
|
|
27
|
+
return wrapper;
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { registry, type MiddlewareFn } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Define a middleware function.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const rateLimiter = middleware(async (req, next) => {
|
|
8
|
+
* const count = await userCache.increment(`rate:${req.ip}`, { ttl: "1m" });
|
|
9
|
+
* if (count > 100) throw new CooperError("RATE_LIMITED");
|
|
10
|
+
* return next(req);
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function middleware(fn: MiddlewareFn): MiddlewareFn {
|
|
15
|
+
return fn;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cooper instance for global middleware registration.
|
|
20
|
+
*/
|
|
21
|
+
export const cooper = {
|
|
22
|
+
use(...middlewares: MiddlewareFn[]) {
|
|
23
|
+
for (const mw of middlewares) {
|
|
24
|
+
registry.addGlobalMiddleware(mw);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
package/src/nats.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS Connection Manager — singleton lazy connection to embedded NATS.
|
|
3
|
+
*
|
|
4
|
+
* JetStream is used for durable pub/sub with delivery guarantees.
|
|
5
|
+
* Falls back gracefully if NATS is unavailable (logs warning once).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
connect,
|
|
10
|
+
type NatsConnection,
|
|
11
|
+
type JetStreamClient,
|
|
12
|
+
type JetStreamManager,
|
|
13
|
+
JSONCodec,
|
|
14
|
+
RetentionPolicy,
|
|
15
|
+
StorageType,
|
|
16
|
+
AckPolicy,
|
|
17
|
+
} from "nats";
|
|
18
|
+
|
|
19
|
+
let nc: NatsConnection | null = null;
|
|
20
|
+
let js: JetStreamClient | null = null;
|
|
21
|
+
let jsm: JetStreamManager | null = null;
|
|
22
|
+
let connectPromise: Promise<boolean> | null = null;
|
|
23
|
+
let warnedOnce = false;
|
|
24
|
+
|
|
25
|
+
const jc = JSONCodec();
|
|
26
|
+
|
|
27
|
+
function getNatsUrl(): string {
|
|
28
|
+
return process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function doConnect(): Promise<boolean> {
|
|
32
|
+
try {
|
|
33
|
+
nc = await connect({ servers: getNatsUrl(), maxReconnectAttempts: 5 });
|
|
34
|
+
js = nc.jetstream();
|
|
35
|
+
jsm = await nc.jetstreamManager();
|
|
36
|
+
return true;
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
if (!warnedOnce) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[cooper] NATS unavailable at ${getNatsUrl()} — pub/sub will use in-memory fallback. ${err.message}`
|
|
41
|
+
);
|
|
42
|
+
warnedOnce = true;
|
|
43
|
+
}
|
|
44
|
+
nc = null;
|
|
45
|
+
js = null;
|
|
46
|
+
jsm = null;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensureConnected(): Promise<boolean> {
|
|
52
|
+
if (nc && !nc.isClosed()) return true;
|
|
53
|
+
if (connectPromise) return connectPromise;
|
|
54
|
+
connectPromise = doConnect().finally(() => {
|
|
55
|
+
connectPromise = null;
|
|
56
|
+
});
|
|
57
|
+
return connectPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getJetStream(): JetStreamClient | null {
|
|
61
|
+
return js;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getJetStreamManager(): JetStreamManager | null {
|
|
65
|
+
return jsm;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getConnection(): NatsConnection | null {
|
|
69
|
+
return nc;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { jc as jsonCodec };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sanitize a topic name into a valid NATS stream name.
|
|
76
|
+
* NATS streams: alphanumeric + dash + underscore only.
|
|
77
|
+
*/
|
|
78
|
+
export function streamName(topicName: string): string {
|
|
79
|
+
return "COOPER_" + topicName.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sanitize a subscriber name into a valid NATS durable consumer name.
|
|
84
|
+
*/
|
|
85
|
+
export function consumerName(subscriberName: string): string {
|
|
86
|
+
return subscriberName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ensure a JetStream stream exists for a topic.
|
|
91
|
+
* Creates it if missing, no-ops if it already exists.
|
|
92
|
+
*/
|
|
93
|
+
export async function ensureStream(
|
|
94
|
+
topicName: string,
|
|
95
|
+
config?: { dedup?: boolean }
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (!jsm) return;
|
|
98
|
+
|
|
99
|
+
const name = streamName(topicName);
|
|
100
|
+
const subject = `cooper.topic.${topicName}`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await jsm.streams.info(name);
|
|
104
|
+
} catch {
|
|
105
|
+
await jsm.streams.add({
|
|
106
|
+
name,
|
|
107
|
+
subjects: [subject],
|
|
108
|
+
retention: RetentionPolicy.Interest,
|
|
109
|
+
max_msgs: -1,
|
|
110
|
+
max_bytes: -1,
|
|
111
|
+
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days in nanos
|
|
112
|
+
storage: StorageType.File,
|
|
113
|
+
num_replicas: 1,
|
|
114
|
+
duplicate_window: config?.dedup
|
|
115
|
+
? 2 * 60 * 1_000_000_000 // 2 min dedup window
|
|
116
|
+
: 0,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensure a JetStream stream exists for a job queue.
|
|
123
|
+
* Uses WorkQueue retention — each message consumed by exactly one worker.
|
|
124
|
+
*/
|
|
125
|
+
export async function ensureQueueStream(
|
|
126
|
+
queueName: string,
|
|
127
|
+
config?: { dedup?: boolean }
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (!jsm) return;
|
|
130
|
+
|
|
131
|
+
const name = "QUEUE_" + queueName.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
|
|
132
|
+
const subject = `cooper.queue.${queueName}`;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await jsm.streams.info(name);
|
|
136
|
+
} catch {
|
|
137
|
+
await jsm.streams.add({
|
|
138
|
+
name,
|
|
139
|
+
subjects: [subject],
|
|
140
|
+
retention: RetentionPolicy.Workqueue,
|
|
141
|
+
max_msgs: -1,
|
|
142
|
+
max_bytes: -1,
|
|
143
|
+
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days in nanos
|
|
144
|
+
storage: StorageType.File,
|
|
145
|
+
num_replicas: 1,
|
|
146
|
+
duplicate_window: config?.dedup
|
|
147
|
+
? 5 * 60 * 1_000_000_000 // 5 min dedup window for queues
|
|
148
|
+
: 0,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ensure a JetStream stream exists for a dead-letter queue.
|
|
155
|
+
* Uses Limits retention — messages stay until explicitly purged.
|
|
156
|
+
*/
|
|
157
|
+
export async function ensureDLQStream(dlqName: string): Promise<void> {
|
|
158
|
+
if (!jsm) return;
|
|
159
|
+
|
|
160
|
+
const name = "DLQ_" + dlqName.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
|
|
161
|
+
const subject = `cooper.dlq.${dlqName}`;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await jsm.streams.info(name);
|
|
165
|
+
} catch {
|
|
166
|
+
await jsm.streams.add({
|
|
167
|
+
name,
|
|
168
|
+
subjects: [subject],
|
|
169
|
+
retention: RetentionPolicy.Limits,
|
|
170
|
+
max_msgs: 10000,
|
|
171
|
+
max_bytes: -1,
|
|
172
|
+
max_age: 30 * 24 * 60 * 60 * 1_000_000_000, // 30 days
|
|
173
|
+
storage: StorageType.File,
|
|
174
|
+
num_replicas: 1,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Graceful shutdown — drain and close the connection.
|
|
181
|
+
*/
|
|
182
|
+
export async function closeNats(): Promise<void> {
|
|
183
|
+
if (nc && !nc.isClosed()) {
|
|
184
|
+
await nc.drain();
|
|
185
|
+
}
|
|
186
|
+
}
|