cooper-stack 0.5.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/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 +30 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +75 -0
- package/dist/db.js.map +1 -0
- package/dist/error.d.ts +16 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +28 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -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/pubsub.d.ts +24 -0
- package/dist/pubsub.d.ts.map +1 -0
- package/dist/pubsub.js +48 -0
- package/dist/pubsub.js.map +1 -0
- package/dist/queue.d.ts +36 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +100 -0
- package/dist/queue.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 +38 -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 +109 -0
- package/src/error.ts +42 -0
- package/src/index.ts +15 -0
- package/src/islands.ts +28 -0
- package/src/middleware.ts +27 -0
- package/src/pubsub.ts +69 -0
- package/src/queue.ts +133 -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/bridge.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cooper JS Worker Bridge
|
|
4
|
+
*
|
|
5
|
+
* Spawned by the Rust runtime as a subprocess. Communicates via
|
|
6
|
+
* JSON-RPC over stdin/stdout. Loads user TypeScript modules and
|
|
7
|
+
* executes handler functions on demand.
|
|
8
|
+
*
|
|
9
|
+
* Protocol:
|
|
10
|
+
* → { "id": 1, "method": "call", "params": { "source": "services/users/api.ts", "export": "getUser", "input": {...}, "auth": {...} } }
|
|
11
|
+
* ← { "id": 1, "result": {...} }
|
|
12
|
+
* ← { "id": 1, "error": { "code": "NOT_FOUND", "message": "User not found" } }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { registry } from "./registry.js";
|
|
16
|
+
import { CooperError } from "./error.js";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
const projectRoot = process.env.COOPER_PROJECT_ROOT ?? process.cwd();
|
|
23
|
+
|
|
24
|
+
// Module cache — avoid re-importing on every request
|
|
25
|
+
const moduleCache = new Map<string, any>();
|
|
26
|
+
|
|
27
|
+
async function loadModule(sourcePath: string): Promise<any> {
|
|
28
|
+
if (moduleCache.has(sourcePath)) return moduleCache.get(sourcePath)!;
|
|
29
|
+
|
|
30
|
+
const fullPath = path.resolve(projectRoot, sourcePath);
|
|
31
|
+
const fileUrl = pathToFileURL(fullPath).href;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import(fileUrl);
|
|
35
|
+
moduleCache.set(sourcePath, mod);
|
|
36
|
+
return mod;
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
throw new Error(`Failed to load module "${sourcePath}": ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RPCRequest {
|
|
43
|
+
id: number;
|
|
44
|
+
method: string;
|
|
45
|
+
params: any;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RPCResponse {
|
|
49
|
+
id: number;
|
|
50
|
+
result?: any;
|
|
51
|
+
error?: { code: string; message: string; statusCode?: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleCall(params: {
|
|
55
|
+
source: string;
|
|
56
|
+
export: string;
|
|
57
|
+
input: any;
|
|
58
|
+
auth?: any;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
}): Promise<any> {
|
|
61
|
+
const mod = await loadModule(params.source);
|
|
62
|
+
const exported = mod[params.export];
|
|
63
|
+
|
|
64
|
+
if (!exported) {
|
|
65
|
+
throw new CooperError("NOT_FOUND", `Export "${params.export}" not found in ${params.source}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Get the handler function
|
|
69
|
+
let handler: Function;
|
|
70
|
+
let routeConfig: any = null;
|
|
71
|
+
|
|
72
|
+
if (exported._cooper_type === "api") {
|
|
73
|
+
handler = exported.handler;
|
|
74
|
+
routeConfig = exported.config;
|
|
75
|
+
} else if (typeof exported === "function") {
|
|
76
|
+
handler = exported;
|
|
77
|
+
} else {
|
|
78
|
+
throw new CooperError("INTERNAL", `Export "${params.export}" is not callable`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validation — run Zod schema if present.
|
|
82
|
+
// Use passthrough() to preserve path params that aren't in the schema.
|
|
83
|
+
let validatedInput = params.input;
|
|
84
|
+
if (routeConfig?.validate) {
|
|
85
|
+
const schema = routeConfig.validate;
|
|
86
|
+
const lenient = typeof schema.passthrough === "function" ? schema.passthrough() : schema;
|
|
87
|
+
const result = lenient.safeParse(params.input);
|
|
88
|
+
if (!result.success) {
|
|
89
|
+
throw new CooperError(
|
|
90
|
+
"VALIDATION_FAILED",
|
|
91
|
+
result.error.issues.map((i: any) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
validatedInput = result.data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Auth — verify token and inject principal
|
|
98
|
+
let principal: any = undefined;
|
|
99
|
+
if (routeConfig?.auth) {
|
|
100
|
+
if (!params.auth?.token) {
|
|
101
|
+
throw new CooperError("UNAUTHORIZED", "Authentication required");
|
|
102
|
+
}
|
|
103
|
+
if (registry.authHandler) {
|
|
104
|
+
principal = await registry.authHandler(params.auth.token);
|
|
105
|
+
} else {
|
|
106
|
+
throw new CooperError("INTERNAL", "No auth handler registered");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Middleware chain
|
|
111
|
+
const middlewares = [
|
|
112
|
+
...registry.globalMiddleware,
|
|
113
|
+
...(routeConfig?.middleware ?? []),
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const req = {
|
|
117
|
+
...validatedInput,
|
|
118
|
+
headers: params.headers ?? {},
|
|
119
|
+
ip: params.headers?.["x-forwarded-for"] ?? "127.0.0.1",
|
|
120
|
+
method: routeConfig?.method ?? "GET",
|
|
121
|
+
path: routeConfig?.path ?? "/",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Build the middleware chain
|
|
125
|
+
let idx = 0;
|
|
126
|
+
const runMiddleware = async (currentReq: any): Promise<any> => {
|
|
127
|
+
if (idx < middlewares.length) {
|
|
128
|
+
const mw = middlewares[idx++];
|
|
129
|
+
return mw(currentReq, runMiddleware);
|
|
130
|
+
}
|
|
131
|
+
// End of chain — call the actual handler
|
|
132
|
+
if (routeConfig?.auth) {
|
|
133
|
+
return handler(validatedInput, principal);
|
|
134
|
+
}
|
|
135
|
+
return handler(validatedInput);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return runMiddleware(req);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function handleCron(params: { source: string; export: string }): Promise<any> {
|
|
142
|
+
const mod = await loadModule(params.source);
|
|
143
|
+
const exported = mod[params.export];
|
|
144
|
+
|
|
145
|
+
if (!exported) {
|
|
146
|
+
throw new CooperError("NOT_FOUND", `Cron "${params.export}" not found in ${params.source}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (exported._cooper_type === "cron") {
|
|
150
|
+
// The cron was registered via the SDK, find it in the registry
|
|
151
|
+
const cronEntry = registry.crons.get(exported.name);
|
|
152
|
+
if (cronEntry) {
|
|
153
|
+
await cronEntry.handler();
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new CooperError("INTERNAL", `Cannot execute cron "${params.export}"`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handlePubSub(params: {
|
|
162
|
+
topic: string;
|
|
163
|
+
subscriber: string;
|
|
164
|
+
data: any;
|
|
165
|
+
}): Promise<any> {
|
|
166
|
+
const topicEntry = registry.topics.get(params.topic);
|
|
167
|
+
if (!topicEntry) {
|
|
168
|
+
throw new CooperError("NOT_FOUND", `Topic "${params.topic}" not registered`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sub = topicEntry.subscribers.get(params.subscriber);
|
|
172
|
+
if (!sub) {
|
|
173
|
+
throw new CooperError("NOT_FOUND", `Subscriber "${params.subscriber}" not found on topic "${params.topic}"`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await sub.handler(params.data);
|
|
177
|
+
return { ok: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function handleRequest(req: RPCRequest): Promise<RPCResponse> {
|
|
181
|
+
try {
|
|
182
|
+
let result: any;
|
|
183
|
+
|
|
184
|
+
switch (req.method) {
|
|
185
|
+
case "call":
|
|
186
|
+
result = await handleCall(req.params);
|
|
187
|
+
break;
|
|
188
|
+
case "cron":
|
|
189
|
+
result = await handleCron(req.params);
|
|
190
|
+
break;
|
|
191
|
+
case "pubsub":
|
|
192
|
+
result = await handlePubSub(req.params);
|
|
193
|
+
break;
|
|
194
|
+
case "ping":
|
|
195
|
+
result = { pong: true, pid: process.pid };
|
|
196
|
+
break;
|
|
197
|
+
case "invalidate":
|
|
198
|
+
// Clear module cache for hot reload
|
|
199
|
+
moduleCache.clear();
|
|
200
|
+
result = { ok: true };
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
throw new CooperError("INVALID_ARGUMENT", `Unknown method: ${req.method}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { id: req.id, result };
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
if (err instanceof CooperError) {
|
|
209
|
+
return {
|
|
210
|
+
id: req.id,
|
|
211
|
+
error: { code: err.code, message: err.message, statusCode: err.statusCode },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id: req.id,
|
|
216
|
+
error: { code: "INTERNAL", message: err.message ?? "Unknown error", statusCode: 500 },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Main loop — read JSON lines from stdin, write responses to stdout
|
|
222
|
+
const rl = createInterface({ input: process.stdin });
|
|
223
|
+
|
|
224
|
+
rl.on("line", async (line) => {
|
|
225
|
+
if (!line.trim()) return;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const req: RPCRequest = JSON.parse(line);
|
|
229
|
+
const res = await handleRequest(req);
|
|
230
|
+
process.stdout.write(JSON.stringify(res) + "\n");
|
|
231
|
+
} catch (err: any) {
|
|
232
|
+
process.stdout.write(
|
|
233
|
+
JSON.stringify({ id: 0, error: { code: "INTERNAL", message: `Bridge parse error: ${err.message}` } }) + "\n"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Preload auth handlers and middleware — these register via side effects
|
|
239
|
+
async function preloadSideEffects() {
|
|
240
|
+
const fs = await import("node:fs");
|
|
241
|
+
const servicesDir = path.join(projectRoot, "services");
|
|
242
|
+
if (!fs.existsSync(servicesDir)) return;
|
|
243
|
+
|
|
244
|
+
const scanDir = (dir: string) => {
|
|
245
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
246
|
+
const fullPath = path.join(dir, entry.name);
|
|
247
|
+
if (entry.isDirectory()) {
|
|
248
|
+
scanDir(fullPath);
|
|
249
|
+
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".js")) {
|
|
250
|
+
try {
|
|
251
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
252
|
+
if (content.includes("authHandler") || content.includes("middleware(")) {
|
|
253
|
+
const relative = path.relative(projectRoot, fullPath);
|
|
254
|
+
loadModule(relative).catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
scanDir(servicesDir);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await preloadSideEffects();
|
|
265
|
+
|
|
266
|
+
// Signal ready
|
|
267
|
+
process.stdout.write(JSON.stringify({ id: 0, result: { ready: true, pid: process.pid } }) + "\n");
|
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,109 @@
|
|
|
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 DatabaseClient {
|
|
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 pool (for ORMs like Drizzle) */
|
|
20
|
+
pool: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Declare a database.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* export const db = database("main", { migrations: "./migrations" });
|
|
28
|
+
* const user = await db.queryRow<User>("SELECT * FROM users WHERE id = $1", [id]);
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function database(name: string, config?: DatabaseConfig): DatabaseClient {
|
|
32
|
+
const engine = config?.engine ?? "postgres";
|
|
33
|
+
|
|
34
|
+
// Connection details injected by the Rust runtime via env vars
|
|
35
|
+
const connStr = process.env[`COOPER_DB_${name.toUpperCase()}_URL`]
|
|
36
|
+
?? `postgres://cooper:cooper@localhost:5432/cooper_${name}`;
|
|
37
|
+
|
|
38
|
+
let pool: any = null;
|
|
39
|
+
|
|
40
|
+
const ensurePool = async () => {
|
|
41
|
+
if (pool) return pool;
|
|
42
|
+
|
|
43
|
+
if (engine === "postgres") {
|
|
44
|
+
const pg = await import("pg");
|
|
45
|
+
pool = new pg.default.Pool({ connectionString: connStr });
|
|
46
|
+
return pool;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (engine === "mysql") {
|
|
50
|
+
const mysql = await import("mysql2/promise");
|
|
51
|
+
pool = await mysql.createPool(connStr);
|
|
52
|
+
return pool;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Database engine "${engine}" not yet supported in JS runtime`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const client: DatabaseClient = {
|
|
59
|
+
async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
|
60
|
+
const p = await ensurePool();
|
|
61
|
+
if (engine === "postgres") {
|
|
62
|
+
const res = await p.query(sql, params);
|
|
63
|
+
return res.rows as T[];
|
|
64
|
+
}
|
|
65
|
+
if (engine === "mysql") {
|
|
66
|
+
const [rows] = await p.execute(sql, params);
|
|
67
|
+
return rows as T[];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async queryRow<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
|
73
|
+
const rows = await client.query<T>(sql, params);
|
|
74
|
+
return rows[0] ?? null;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async exec(sql: string, params?: any[]): Promise<{ rowCount: number }> {
|
|
78
|
+
const p = await ensurePool();
|
|
79
|
+
if (engine === "postgres") {
|
|
80
|
+
const res = await p.query(sql, params);
|
|
81
|
+
return { rowCount: res.rowCount ?? 0 };
|
|
82
|
+
}
|
|
83
|
+
if (engine === "mysql") {
|
|
84
|
+
const [result] = await p.execute(sql, params);
|
|
85
|
+
return { rowCount: (result as any).affectedRows ?? 0 };
|
|
86
|
+
}
|
|
87
|
+
return { rowCount: 0 };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async insert<T = any>(table: string, data: Record<string, any>): Promise<T> {
|
|
91
|
+
const keys = Object.keys(data);
|
|
92
|
+
const values = Object.values(data);
|
|
93
|
+
const placeholders = keys.map((_, i) =>
|
|
94
|
+
engine === "postgres" ? `$${i + 1}` : "?"
|
|
95
|
+
);
|
|
96
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
|
|
97
|
+
const row = await client.queryRow<T>(sql, values);
|
|
98
|
+
return row!;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
get pool() {
|
|
102
|
+
return pool;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
registry.registerDatabase(name, { name, engine, pool: client });
|
|
107
|
+
|
|
108
|
+
return client;
|
|
109
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
|
|
27
|
+
constructor(code: ErrorCode, message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "CooperError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.statusCode = STATUS_MAP[code] ?? 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
toJSON() {
|
|
35
|
+
return {
|
|
36
|
+
error: {
|
|
37
|
+
code: this.code,
|
|
38
|
+
message: this.message,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { api } from "./api.js";
|
|
2
|
+
export { CooperError, type ErrorCode } from "./error.js";
|
|
3
|
+
export { database, type DatabaseClient } 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 { cron } from "./cron.js";
|
|
8
|
+
export { cache, type CacheClient } from "./cache.js";
|
|
9
|
+
export { bucket, type BucketClient } from "./storage.js";
|
|
10
|
+
export { secret } from "./secrets.js";
|
|
11
|
+
export { queue, type QueueClient } from "./queue.js";
|
|
12
|
+
export { page, layout, pageLoader, Suspense } from "./ssr.js";
|
|
13
|
+
export { island } from "./islands.js";
|
|
14
|
+
export { vectorStore, llmGateway } from "./ai.js";
|
|
15
|
+
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/pubsub.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { registry } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
export interface TopicConfig {
|
|
4
|
+
deliveryGuarantee?: "at-least-once" | "exactly-once";
|
|
5
|
+
orderBy?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SubscribeConfig {
|
|
9
|
+
concurrency?: number;
|
|
10
|
+
handler: (data: any) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Topic<T> {
|
|
14
|
+
publish(data: T): Promise<void>;
|
|
15
|
+
subscribe(name: string, config: SubscribeConfig): any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Declare a typed Pub/Sub topic.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* export const UserSignedUp = topic<{ userId: string; email: string }>(
|
|
23
|
+
* "user-signed-up",
|
|
24
|
+
* { deliveryGuarantee: "at-least-once" }
|
|
25
|
+
* );
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function topic<T = any>(name: string, config?: TopicConfig): Topic<T> {
|
|
29
|
+
const subscribers = new Map<string, { handler: Function; options: any }>();
|
|
30
|
+
|
|
31
|
+
const t: Topic<T> = {
|
|
32
|
+
async publish(data: T) {
|
|
33
|
+
// In local dev, deliver directly to subscribers
|
|
34
|
+
// In production, publish to NATS/SNS/Pub/Sub
|
|
35
|
+
const natsUrl = process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
|
|
36
|
+
|
|
37
|
+
// Direct local delivery for development
|
|
38
|
+
for (const [subName, sub] of subscribers) {
|
|
39
|
+
try {
|
|
40
|
+
await sub.handler(data);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(`[cooper] Subscriber "${subName}" failed:`, err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
subscribe(subName: string, subConfig: SubscribeConfig) {
|
|
48
|
+
subscribers.set(subName, {
|
|
49
|
+
handler: subConfig.handler,
|
|
50
|
+
options: subConfig,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
registry.registerTopic(name, {
|
|
54
|
+
name,
|
|
55
|
+
subscribers,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
_cooper_type: "subscription",
|
|
60
|
+
topic: name,
|
|
61
|
+
name: subName,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
registry.registerTopic(name, { name, subscribers });
|
|
67
|
+
|
|
68
|
+
return t;
|
|
69
|
+
}
|