nothing-browser 0.0.16 → 0.0.17

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.
@@ -1,21 +1,58 @@
1
+ // piggy/server/index.d.ts
1
2
  import { Elysia } from "elysia";
3
+
2
4
  export type BeforeMiddleware = (ctx: {
3
- params: Record<string, string>;
4
- query: Record<string, string>;
5
- body: any;
6
- headers: Record<string, string>;
7
- set: any;
5
+ params: Record<string, string>;
6
+ query: Record<string, string>;
7
+ body: any;
8
+ headers: Record<string, string>;
9
+ set: any;
8
10
  }) => void | Promise<void>;
9
- export type RouteHandler = (params: Record<string, string>, query: Record<string, string>, body: any) => Promise<any>;
11
+
12
+ export type RouteHandler = (
13
+ params: Record<string, string>,
14
+ query: Record<string, string>,
15
+ body: any
16
+ ) => Promise<any>;
17
+
18
+ export interface RouteParameter {
19
+ name: string;
20
+ in: "query" | "path" | "header" | "cookie";
21
+ description?: string;
22
+ required?: boolean;
23
+ schema?: Record<string, any>;
24
+ }
25
+
26
+ export interface RouteDetail {
27
+ tags?: string[];
28
+ summary?: string;
29
+ description?: string;
30
+ deprecated?: boolean;
31
+ hide?: boolean;
32
+ parameters?: RouteParameter[];
33
+ }
34
+
10
35
  export interface RouteConfig {
11
- path: string;
12
- method: "GET" | "POST" | "PUT" | "DELETE";
13
- handler: RouteHandler;
14
- ttl: number;
15
- before: BeforeMiddleware[];
36
+ path: string;
37
+ method: "GET" | "POST" | "PUT" | "DELETE";
38
+ handler: RouteHandler;
39
+ ttl: number;
40
+ before: BeforeMiddleware[];
41
+ detail?: RouteDetail;
16
42
  }
43
+
17
44
  export declare const routeRegistry: Map<string, RouteConfig>;
18
45
  export declare const keepAliveSites: Set<string>;
19
- export declare function startServer(port: number, hostname?: string): Promise<Elysia>;
20
- export declare function stopServer(): void;
21
- //# sourceMappingURL=index.d.ts.map
46
+
47
+ export declare function startServer(
48
+ port: number,
49
+ hostname?: string,
50
+ openapiOpts?: {
51
+ title?: string;
52
+ version?: string;
53
+ description?: string;
54
+ path?: string;
55
+ }
56
+ ): Promise<Elysia>;
57
+
58
+ export declare function stopServer(): void;
@@ -1,5 +1,6 @@
1
1
  // piggy/server/index.ts
2
2
  import { Elysia } from "elysia";
3
+ import { openapi } from "@elysiajs/openapi";
3
4
  import * as cache from "../cache/memory";
4
5
  import logger from "../logger";
5
6
 
@@ -17,12 +18,30 @@ export type RouteHandler = (
17
18
  body: any
18
19
  ) => Promise<any>;
19
20
 
21
+ export interface RouteParameter {
22
+ name: string;
23
+ in: "query" | "path" | "header" | "cookie";
24
+ description?: string;
25
+ required?: boolean;
26
+ schema?: Record<string, any>;
27
+ }
28
+
29
+ export interface RouteDetail {
30
+ tags?: string[];
31
+ summary?: string;
32
+ description?: string;
33
+ deprecated?: boolean;
34
+ hide?: boolean;
35
+ parameters?: RouteParameter[];
36
+ }
37
+
20
38
  export interface RouteConfig {
21
39
  path: string;
22
40
  method: "GET" | "POST" | "PUT" | "DELETE";
23
41
  handler: RouteHandler;
24
42
  ttl: number;
25
43
  before: BeforeMiddleware[];
44
+ detail?: RouteDetail;
26
45
  }
27
46
 
28
47
  export const routeRegistry = new Map<string, RouteConfig>();
@@ -45,28 +64,56 @@ function mapError(err: Error, site: string) {
45
64
 
46
65
  let _app: Elysia | null = null;
47
66
 
48
- export async function startServer(port: number, hostname = "0.0.0.0"): Promise<Elysia> {
67
+ export async function startServer(
68
+ port: number,
69
+ hostname = "0.0.0.0",
70
+ openapiOpts?: {
71
+ title?: string;
72
+ version?: string;
73
+ description?: string;
74
+ path?: string;
75
+ }
76
+ ): Promise<Elysia> {
49
77
  _app = new Elysia();
50
78
 
51
- // ── Health route ────────────────────────────────────────────────────────────
79
+ // ── OpenAPI ───────────────────────────────────────────────────────────────
80
+ _app.use(
81
+ openapi({
82
+ path: openapiOpts?.path ?? "/openapi",
83
+ documentation: {
84
+ info: {
85
+ title: openapiOpts?.title ?? "Piggy API",
86
+ version: openapiOpts?.version ?? "1.0.0",
87
+ description: openapiOpts?.description ?? "Auto-generated docs for all registered piggy routes",
88
+ },
89
+ },
90
+ })
91
+ );
92
+
93
+ // ── Health ────────────────────────────────────────────────────────────────
52
94
  _app.get("/health", () => ({
53
95
  status: "ok",
54
96
  routes: routeRegistry.size,
55
97
  cacheEntries: cache.size(),
56
98
  uptime: process.uptime(),
57
- }));
99
+ }), {
100
+ detail: { tags: ["_piggy"], summary: "Health check" },
101
+ });
58
102
 
59
- // ── Cache management routes ─────────────────────────────────────────────────
103
+ // ── Cache ─────────────────────────────────────────────────────────────────
60
104
  _app.delete("/cache", () => {
61
105
  cache.clear();
62
106
  return { cleared: true };
107
+ }, {
108
+ detail: { tags: ["_piggy"], summary: "Clear all cache entries" },
63
109
  });
64
110
 
65
- _app.get("/cache/keys", () => ({ keys: cache.keys() }));
111
+ _app.get("/cache/keys", () => ({ keys: cache.keys() }), {
112
+ detail: { tags: ["_piggy"], summary: "List all cache keys" },
113
+ });
66
114
 
67
- // ── Registered site routes ──────────────────────────────────────────────────
115
+ // ── Registered site routes ────────────────────────────────────────────────
68
116
  for (const [registryKey, config] of routeRegistry.entries()) {
69
- // registryKey format is "siteName:path" e.g. "movie:/title"
70
117
  const colonIdx = registryKey.indexOf(":");
71
118
  const siteName = registryKey.substring(0, colonIdx);
72
119
  const fullPath = `/${siteName}${config.path}`;
@@ -75,7 +122,6 @@ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<E
75
122
  logger.info(`[server] mounting ${config.method} ${fullPath} (ttl=${config.ttl}ms)`);
76
123
 
77
124
  const routeHandler = async ({ params, query, body, headers, set }: any) => {
78
- // 1. Run before middleware
79
125
  for (const mw of config.before) {
80
126
  try {
81
127
  await mw({ params, query, body, headers, set });
@@ -85,7 +131,6 @@ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<E
85
131
  }
86
132
  }
87
133
 
88
- // 2. Cache check
89
134
  const cacheKey = `${siteName}:${fullPath}:${JSON.stringify({ params, query })}`;
90
135
  const hit = cache.get(cacheKey);
91
136
  if (hit !== null) {
@@ -94,7 +139,6 @@ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<E
94
139
  return hit;
95
140
  }
96
141
 
97
- // 3. Execute handler
98
142
  let result: any;
99
143
  try {
100
144
  result = await config.handler(params, query, body);
@@ -104,7 +148,6 @@ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<E
104
148
  return mapped;
105
149
  }
106
150
 
107
- // 4. Store in cache
108
151
  if (config.ttl > 0) {
109
152
  cache.set(cacheKey, result, config.ttl);
110
153
  set.headers["x-cache"] = "MISS";
@@ -113,15 +156,25 @@ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<E
113
156
  return result;
114
157
  };
115
158
 
116
- if (method === "get") _app.get(fullPath, routeHandler);
117
- else if (method === "post") _app.post(fullPath, routeHandler);
118
- else if (method === "put") _app.put(fullPath, routeHandler);
119
- else if (method === "delete") _app.delete(fullPath, routeHandler);
159
+ const routeDetail = {
160
+ tags: config.detail?.tags ?? [siteName],
161
+ summary: config.detail?.summary ?? `${config.method} ${fullPath}`,
162
+ description: config.detail?.description,
163
+ deprecated: config.detail?.deprecated ?? false,
164
+ hide: config.detail?.hide ?? false,
165
+ parameters: config.detail?.parameters ?? [],
166
+ };
167
+
168
+ if (method === "get") _app.get(fullPath, routeHandler, { detail: routeDetail });
169
+ else if (method === "post") _app.post(fullPath, routeHandler, { detail: routeDetail });
170
+ else if (method === "put") _app.put(fullPath, routeHandler, { detail: routeDetail });
171
+ else if (method === "delete") _app.delete(fullPath, routeHandler, { detail: routeDetail });
120
172
  }
121
173
 
122
174
  _app.listen({ port, hostname });
123
175
 
124
176
  logger.success(`🚀 Piggy API server → http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`);
177
+ logger.success(`📖 OpenAPI docs → http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}/openapi`);
125
178
  logger.info(` Routes mounted: ${routeRegistry.size}`);
126
179
  routeRegistry.forEach((cfg, key) => {
127
180
  const siteName = key.substring(0, key.indexOf(":"));
@@ -0,0 +1,26 @@
1
+ // piggy/store/index.d.ts
2
+ export type FieldType = "string" | "number" | "boolean" | "object" | "array";
3
+
4
+ export interface FieldSchema {
5
+ type: FieldType;
6
+ required?: boolean;
7
+ default?: any;
8
+ }
9
+
10
+ export interface StoreSchema {
11
+ name: string;
12
+ destination: string;
13
+ fields: Record<string, FieldSchema>;
14
+ }
15
+
16
+ export interface PiggyStoreConfig {
17
+ stores: StoreSchema[];
18
+ }
19
+
20
+ export declare function loadStoreConfig(configPath?: string): PiggyStoreConfig;
21
+ export declare function getSchema(storeName: string): StoreSchema | null;
22
+ export declare function shapeRecord(data: Record<string, any>, schema: StoreSchema): Record<string, any>;
23
+ export declare function storeRecord(
24
+ storeName: string,
25
+ data: Record<string, any> | Record<string, any>[]
26
+ ): Promise<{ stored: number; skipped: number }>;
@@ -0,0 +1,230 @@
1
+ // piggy/store/index.ts
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import logger from "../logger";
5
+
6
+ // ── Types ─────────────────────────────────────────────────────────────────────
7
+
8
+ export type FieldType = "string" | "number" | "boolean" | "object" | "array";
9
+
10
+ export interface FieldSchema {
11
+ type: FieldType;
12
+ required?: boolean; // default false — missing = NULL not error
13
+ default?: any; // fallback if missing (overrides NULL)
14
+ }
15
+
16
+ export interface StoreSchema {
17
+ name: string; // store identifier (matches site name or custom)
18
+ destination: string; // "./data.json" or "./data.db"
19
+ fields: Record<string, FieldSchema>;
20
+ }
21
+
22
+ export interface PiggyStoreConfig {
23
+ stores: StoreSchema[];
24
+ }
25
+
26
+ // ── Load piggy.store.json ─────────────────────────────────────────────────────
27
+
28
+ let _config: PiggyStoreConfig | null = null;
29
+
30
+ export function loadStoreConfig(configPath = "./piggy.store.json"): PiggyStoreConfig {
31
+ if (_config) return _config;
32
+
33
+ const abs = resolve(configPath);
34
+ if (!existsSync(abs)) {
35
+ logger.warn(`[store] piggy.store.json not found at ${abs} — store() calls will no-op`);
36
+ return { stores: [] };
37
+ }
38
+
39
+ try {
40
+ _config = JSON.parse(readFileSync(abs, "utf8")) as PiggyStoreConfig;
41
+ logger.success(`[store] loaded ${_config.stores.length} schema(s) from ${abs}`);
42
+ return _config;
43
+ } catch (e: any) {
44
+ logger.error(`[store] failed to parse piggy.store.json: ${e.message}`);
45
+ return { stores: [] };
46
+ }
47
+ }
48
+
49
+ export function getSchema(storeName: string): StoreSchema | null {
50
+ const config = loadStoreConfig();
51
+ return config.stores.find(s => s.name === storeName) ?? null;
52
+ }
53
+
54
+ // ── Validate & shape one record against schema ────────────────────────────────
55
+
56
+ export function shapeRecord(data: Record<string, any>, schema: StoreSchema): Record<string, any> {
57
+ const shaped: Record<string, any> = {};
58
+
59
+ for (const [field, def] of Object.entries(schema.fields)) {
60
+ const raw = data[field];
61
+
62
+ // Missing field
63
+ if (raw === undefined || raw === null) {
64
+ if (def.default !== undefined) {
65
+ shaped[field] = def.default;
66
+ } else {
67
+ shaped[field] = null; // NULL — not an error, just absent
68
+ }
69
+ continue;
70
+ }
71
+
72
+ // Type coercion / validation
73
+ switch (def.type) {
74
+ case "string":
75
+ shaped[field] = String(raw);
76
+ break;
77
+ case "number":
78
+ const n = Number(raw);
79
+ shaped[field] = isNaN(n) ? null : n;
80
+ break;
81
+ case "boolean":
82
+ shaped[field] = Boolean(raw);
83
+ break;
84
+ case "object":
85
+ shaped[field] = typeof raw === "object" && !Array.isArray(raw) ? raw : null;
86
+ break;
87
+ case "array":
88
+ shaped[field] = Array.isArray(raw) ? raw : null;
89
+ break;
90
+ default:
91
+ shaped[field] = raw;
92
+ }
93
+ }
94
+
95
+ // Extra fields on incoming data are silently dropped — not in schema = ignored
96
+ return shaped;
97
+ }
98
+
99
+ // ── JSON backend ──────────────────────────────────────────────────────────────
100
+
101
+ function appendToJson(destination: string, record: Record<string, any>): void {
102
+ const abs = resolve(destination);
103
+ mkdirSync(dirname(abs), { recursive: true });
104
+
105
+ let existing: any[] = [];
106
+ if (existsSync(abs)) {
107
+ try {
108
+ existing = JSON.parse(readFileSync(abs, "utf8"));
109
+ if (!Array.isArray(existing)) existing = [existing];
110
+ } catch {
111
+ existing = [];
112
+ }
113
+ }
114
+
115
+ existing.push({ ...record, _storedAt: new Date().toISOString() });
116
+ writeFileSync(abs, JSON.stringify(existing, null, 2), "utf8");
117
+ }
118
+
119
+ // ── SQLite backend ────────────────────────────────────────────────────────────
120
+
121
+ function appendToSqlite(destination: string, record: Record<string, any>, schema: StoreSchema): void {
122
+ // Use bun:sqlite if available, else require better-sqlite3
123
+ const abs = resolve(destination);
124
+ mkdirSync(dirname(abs), { recursive: true });
125
+
126
+ const isBun = typeof (globalThis as any).Bun !== "undefined";
127
+
128
+ if (isBun) {
129
+ const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
130
+ const db = new Database(abs);
131
+ ensureTable(db, schema, "bun");
132
+ insertRecord(db, schema, record, "bun");
133
+ db.close();
134
+ } else {
135
+ const Database = require("better-sqlite3");
136
+ const db = new Database(abs);
137
+ ensureTable(db, schema, "node");
138
+ insertRecord(db, schema, record, "node");
139
+ db.close();
140
+ }
141
+ }
142
+
143
+ function ensureTable(db: any, schema: StoreSchema, runtime: "bun" | "node"): void {
144
+ const tableName = schema.name.replace(/[^a-zA-Z0-9_]/g, "_");
145
+ const cols = Object.entries(schema.fields).map(([name, def]) => {
146
+ const sqlType = def.type === "number" ? "REAL"
147
+ : def.type === "boolean" ? "INTEGER"
148
+ : def.type === "object" || def.type === "array" ? "TEXT" // JSON stringified
149
+ : "TEXT";
150
+ return ` ${name} ${sqlType}`;
151
+ });
152
+ cols.push(" _storedAt TEXT");
153
+
154
+ const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n${cols.join(",\n")}\n)`;
155
+
156
+ if (runtime === "bun") {
157
+ db.run(sql);
158
+ } else {
159
+ db.prepare(sql).run();
160
+ }
161
+ }
162
+
163
+ function insertRecord(db: any, schema: StoreSchema, record: Record<string, any>, runtime: "bun" | "node"): void {
164
+ const tableName = schema.name.replace(/[^a-zA-Z0-9_]/g, "_");
165
+ const fields = [...Object.keys(schema.fields), "_storedAt"];
166
+ const values = fields.map(f => {
167
+ if (f === "_storedAt") return new Date().toISOString();
168
+ const v = record[f];
169
+ // Serialize objects/arrays as JSON strings for SQLite
170
+ if (v !== null && (typeof v === "object" || Array.isArray(v))) return JSON.stringify(v);
171
+ return v ?? null;
172
+ });
173
+
174
+ const placeholders = runtime === "bun"
175
+ ? fields.map((_, i) => `?${i + 1}`).join(", ")
176
+ : fields.map(() => "?").join(", ");
177
+
178
+ const sql = `INSERT INTO ${tableName} (${fields.join(", ")}) VALUES (${placeholders})`;
179
+
180
+ if (runtime === "bun") {
181
+ db.run(sql, values);
182
+ } else {
183
+ db.prepare(sql).run(values);
184
+ }
185
+ }
186
+
187
+ // ── Main store function ───────────────────────────────────────────────────────
188
+
189
+ export async function storeRecord(
190
+ storeName: string,
191
+ data: Record<string, any> | Record<string, any>[]
192
+ ): Promise<{ stored: number; skipped: number }> {
193
+ const schema = getSchema(storeName);
194
+
195
+ if (!schema) {
196
+ logger.warn(`[store] no schema found for "${storeName}" in piggy.store.json — data not stored`);
197
+ return { stored: 0, skipped: 1 };
198
+ }
199
+
200
+ const records = Array.isArray(data) ? data : [data];
201
+ let stored = 0;
202
+ let skipped = 0;
203
+
204
+ const isJson = schema.destination.endsWith(".json");
205
+ const isSqlite = schema.destination.endsWith(".db") || schema.destination.endsWith(".sqlite");
206
+
207
+ for (const record of records) {
208
+ try {
209
+ const shaped = shapeRecord(record, schema);
210
+
211
+ if (isJson) {
212
+ appendToJson(schema.destination, shaped);
213
+ } else if (isSqlite) {
214
+ appendToSqlite(schema.destination, shaped, schema);
215
+ } else {
216
+ logger.error(`[store] unsupported destination format: ${schema.destination} (use .json or .db)`);
217
+ skipped++;
218
+ continue;
219
+ }
220
+
221
+ stored++;
222
+ logger.success(`[store][${storeName}] stored record → ${schema.destination}`);
223
+ } catch (e: any) {
224
+ logger.error(`[store][${storeName}] failed to store record: ${e.message}`);
225
+ skipped++;
226
+ }
227
+ }
228
+
229
+ return { stored, skipped };
230
+ }