@flaredev/core 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/server.js ADDED
@@ -0,0 +1,319 @@
1
+ import {
2
+ createValidators,
3
+ storedFields
4
+ } from "./chunk-DARDHZPX.js";
5
+
6
+ // src/server/store.ts
7
+ import { and, asc, count, desc, eq, getTableColumns, inArray, isNull, or, sql } from "drizzle-orm";
8
+
9
+ // src/server/query.ts
10
+ var TIMESTAMP_FIELDS = ["createdAt", "updatedAt"];
11
+ var MAX_PER_PAGE = 100;
12
+ var MAX_SEARCH_LENGTH = 200;
13
+ function isSortable(def) {
14
+ return def.sortable ?? !["text", "file"].includes(def.kind);
15
+ }
16
+ function isFilterable(def) {
17
+ return def.filterable ?? ["enum", "boolean", "belongsTo"].includes(def.kind);
18
+ }
19
+ function isSearchable(def) {
20
+ return def.searchable ?? def.kind === "string";
21
+ }
22
+ function coerce(def, raw) {
23
+ if (raw === "null") return def.required ? void 0 : null;
24
+ switch (def.kind) {
25
+ case "boolean":
26
+ return raw === "true" || raw === "1" ? true : raw === "false" || raw === "0" ? false : void 0;
27
+ case "int":
28
+ return /^-?\d+$/.test(raw) ? Number(raw) : void 0;
29
+ case "float":
30
+ return raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : void 0;
31
+ case "enum":
32
+ return def.options.includes(raw) ? raw : void 0;
33
+ default:
34
+ return raw;
35
+ }
36
+ }
37
+ function parseListQuery(resource, params) {
38
+ const issues = [];
39
+ const fields = new Map(storedFields(resource));
40
+ const positiveInt = (name, fallback, max = Number.MAX_SAFE_INTEGER) => {
41
+ const raw = params.get(name);
42
+ if (raw === null) return fallback;
43
+ const value = Number(raw);
44
+ if (!/^\d+$/.test(raw) || value < 1 || value > max) {
45
+ issues.push({ param: name, message: `must be an integer from 1 to ${max === Number.MAX_SAFE_INTEGER ? "\u221E" : max}` });
46
+ return fallback;
47
+ }
48
+ return value;
49
+ };
50
+ const page = positiveInt("page", 1);
51
+ const perPage = positiveInt("perPage", resource.perPage, MAX_PER_PAGE);
52
+ let sort = resource.defaultSort;
53
+ const rawSort = params.get("sort");
54
+ if (rawSort) {
55
+ const direction = rawSort.startsWith("-") ? "desc" : "asc";
56
+ const field = rawSort.replace(/^-/, "");
57
+ const def = fields.get(field);
58
+ if (TIMESTAMP_FIELDS.includes(field) || def && isSortable(def)) sort = { field, direction };
59
+ else issues.push({ param: "sort", message: `can't sort by "${field}"` });
60
+ }
61
+ const q = params.get("q")?.trim().slice(0, MAX_SEARCH_LENGTH) || void 0;
62
+ const filters = {};
63
+ for (const [name, raw] of params) {
64
+ const match = /^filter\[(.+)\]$/.exec(name);
65
+ if (!match) continue;
66
+ const key = match[1];
67
+ const def = fields.get(key);
68
+ if (!def || !isFilterable(def)) {
69
+ issues.push({ param: name, message: `can't filter by "${key}"` });
70
+ continue;
71
+ }
72
+ const value = coerce(def, raw);
73
+ if (value === void 0) issues.push({ param: name, message: `invalid value "${raw}"` });
74
+ else filters[key] = value;
75
+ }
76
+ return issues.length ? { issues } : { query: { page, perPage, sort, q, filters } };
77
+ }
78
+
79
+ // src/server/store.ts
80
+ function errorText(error) {
81
+ const parts = [];
82
+ for (let current = error, depth = 0; current && depth < 5; depth++) {
83
+ if (current instanceof Error) {
84
+ parts.push(current.message);
85
+ current = current.cause;
86
+ } else {
87
+ parts.push(String(current));
88
+ break;
89
+ }
90
+ }
91
+ return parts.join(" | ");
92
+ }
93
+ function createResourceStore(options) {
94
+ const { resource, table, getDb } = options;
95
+ const validators = createValidators(resource);
96
+ const columns = getTableColumns(table);
97
+ const fields = storedFields(resource);
98
+ for (const key of ["id", "createdAt", "updatedAt", ...fields.map(([key2]) => key2)]) {
99
+ if (!columns[key]) {
100
+ throw new Error(`Resource "${resource.name}": table has no column for "${key}". Run \`flare sync-types\` and create a migration.`);
101
+ }
102
+ }
103
+ const idColumn = columns.id;
104
+ const columnKeyByName = new Map(Object.entries(columns).map(([key, column]) => [column.name, key]));
105
+ const fail = (status, error, extra = {}) => ({ ok: false, status, error, ...extra });
106
+ function constraintFailure(error, action) {
107
+ const text = errorText(error);
108
+ const unique = /UNIQUE constraint failed: ([\w.]+(?:, [\w.]+)*)/.exec(text);
109
+ if (unique) {
110
+ const key = columnKeyByName.get(unique[1].split(", ")[0].split(".").pop());
111
+ const label = key ? resource.fields[key]?.label ?? key : "Value";
112
+ return fail(409, `${label} is already taken.`, key ? { field: key } : {});
113
+ }
114
+ if (/FOREIGN KEY constraint failed/.test(text)) {
115
+ return action === "delete" ? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`) : fail(422, "A related record doesn't exist.");
116
+ }
117
+ if (/CHECK constraint failed/.test(text)) return fail(422, "A value is outside its allowed options.");
118
+ return void 0;
119
+ }
120
+ async function guarded(action, work) {
121
+ try {
122
+ return await work();
123
+ } catch (error) {
124
+ const failure = constraintFailure(error, action);
125
+ if (failure) return failure;
126
+ throw error;
127
+ }
128
+ }
129
+ async function announce(action, result) {
130
+ if (!options.onChange || !result.ok) return;
131
+ try {
132
+ await options.onChange({ resource: resource.name, action, id: String(result.data.id) });
133
+ } catch (error) {
134
+ console.error(`[flare] ${resource.name} ${action} cache invalidation failed:`, error);
135
+ }
136
+ }
137
+ async function writing(action, work) {
138
+ const result = await guarded(action, work);
139
+ await announce(action, result);
140
+ return result;
141
+ }
142
+ const invalid = (issues) => fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
143
+ const byId = (id) => eq(idColumn, id);
144
+ const notFound = () => fail(404, `${resource.label} not found.`);
145
+ return {
146
+ resource,
147
+ table,
148
+ /** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
149
+ async list(params) {
150
+ const parsed = parseListQuery(resource, params);
151
+ if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
152
+ const { page, perPage, sort, q, filters } = parsed.query;
153
+ const conditions = [];
154
+ if (q) {
155
+ const pattern = `%${q.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
156
+ const searchable = fields.filter(([, def]) => isSearchable(def)).map(([key]) => columns[key]);
157
+ if (searchable.length) conditions.push(or(...searchable.map((column) => sql`${column} LIKE ${pattern} ESCAPE '\\'`)));
158
+ }
159
+ for (const [key, value] of Object.entries(filters)) {
160
+ conditions.push(value === null ? isNull(columns[key]) : eq(columns[key], value));
161
+ }
162
+ const where = conditions.length ? and(...conditions) : void 0;
163
+ const order = sort.direction === "asc" ? asc(columns[sort.field]) : desc(columns[sort.field]);
164
+ const db = getDb();
165
+ const [rows, totals] = await Promise.all([
166
+ db.select().from(table).where(where).orderBy(order, asc(idColumn)).limit(perPage).offset((page - 1) * perPage),
167
+ db.select({ total: count() }).from(table).where(where)
168
+ ]);
169
+ const total = totals[0]?.total ?? 0;
170
+ return {
171
+ ok: true,
172
+ data: { data: rows, meta: { page, perPage, total, totalPages: Math.max(1, Math.ceil(total / perPage)) } }
173
+ };
174
+ },
175
+ async get(id) {
176
+ const [record] = await getDb().select().from(table).where(byId(id)).limit(1);
177
+ return record ? { ok: true, data: record } : notFound();
178
+ },
179
+ /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
180
+ async titles(ids) {
181
+ const unique = [...new Set(ids.filter(Boolean))];
182
+ if (unique.length === 0) return {};
183
+ const titleColumn = columns[resource.titleField] ?? idColumn;
184
+ const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(inArray(idColumn, unique));
185
+ return Object.fromEntries(rows.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
186
+ },
187
+ create(input) {
188
+ const result = validators.create.safeParse(input);
189
+ if (!result.success) return Promise.resolve(invalid(result.error.issues));
190
+ return writing("create", async () => {
191
+ const now = /* @__PURE__ */ new Date();
192
+ const [record] = await getDb().insert(table).values({ ...result.data, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
193
+ return { ok: true, data: record };
194
+ });
195
+ },
196
+ /** Partial update (PATCH semantics). */
197
+ update(id, input) {
198
+ const result = validators.update.safeParse(input);
199
+ if (!result.success) return Promise.resolve(invalid(result.error.issues));
200
+ return writing("update", async () => {
201
+ const [record] = await getDb().update(table).set({ ...result.data, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
202
+ return record ? { ok: true, data: record } : notFound();
203
+ });
204
+ },
205
+ /** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
206
+ replace(id, input) {
207
+ const result = validators.create.safeParse(input);
208
+ if (!result.success) return Promise.resolve(invalid(result.error.issues));
209
+ return writing("update", async () => {
210
+ const values = {};
211
+ for (const [key, def] of fields) {
212
+ values[key] = "default" in def && def.default !== void 0 ? def.default : def.required ? void 0 : null;
213
+ }
214
+ Object.assign(values, result.data, { updatedAt: /* @__PURE__ */ new Date() });
215
+ const [record] = await getDb().update(table).set(values).where(byId(id)).returning();
216
+ return record ? { ok: true, data: record } : notFound();
217
+ });
218
+ },
219
+ delete(id) {
220
+ return writing("delete", async () => {
221
+ const deleted = await getDb().delete(table).where(byId(id)).returning({ id: idColumn });
222
+ return deleted.length ? { ok: true, data: { id } } : notFound();
223
+ });
224
+ }
225
+ };
226
+ }
227
+
228
+ // src/server/handlers.ts
229
+ var problem = (status, error, extra = {}) => Response.json({ error, ...extra }, { status });
230
+ function failureResponse(failure) {
231
+ if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
232
+ const extra = {};
233
+ if (failure.issues) extra.issues = failure.issues;
234
+ if (failure.field) extra.field = failure.field;
235
+ return problem(failure.status, failure.error, extra);
236
+ }
237
+ var respond = (result, init) => result.ok ? Response.json(result.data, init) : failureResponse(result);
238
+ function createResourceHandlers(options) {
239
+ const { resource, authorize } = options;
240
+ const store = createResourceStore(options);
241
+ const crossOrigin = (request) => {
242
+ const origin = request.headers.get("origin");
243
+ return origin !== null && origin !== new URL(request.url).origin;
244
+ };
245
+ async function readBody(request) {
246
+ if (crossOrigin(request)) return { response: problem(403, "Cross-origin request blocked.") };
247
+ const type = request.headers.get("content-type") ?? "";
248
+ if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
249
+ try {
250
+ return { body: await request.json() };
251
+ } catch {
252
+ return { response: problem(400, "Request body isn't valid JSON.") };
253
+ }
254
+ }
255
+ const guard = async (context) => await authorize(context) ?? void 0;
256
+ async function drain(request) {
257
+ if (!request.body || request.bodyUsed) return;
258
+ try {
259
+ const reader = request.body.getReader();
260
+ while (!(await reader.read()).done) ;
261
+ } catch {
262
+ }
263
+ }
264
+ const draining = (handler) => async (request, ...rest) => {
265
+ const response = await handler(request, ...rest);
266
+ await drain(request);
267
+ return response;
268
+ };
269
+ async function GET(request) {
270
+ const denied = await guard({ request, resource, action: "list" });
271
+ if (denied) return denied;
272
+ return respond(await store.list(new URL(request.url).searchParams));
273
+ }
274
+ async function POST(request) {
275
+ const denied = await guard({ request, resource, action: "create" });
276
+ if (denied) return denied;
277
+ const read = await readBody(request);
278
+ if ("response" in read) return read.response;
279
+ const result = await store.create(read.body);
280
+ if (!result.ok) return failureResponse(result);
281
+ const location = `${new URL(request.url).pathname.replace(/\/$/, "")}/${result.data.id}`;
282
+ return Response.json(result.data, { status: 201, headers: { location } });
283
+ }
284
+ async function item(request, context, action, work) {
285
+ const { id } = await context.params;
286
+ const denied = await guard({ request, resource, action, id });
287
+ if (denied) return denied;
288
+ return work(id);
289
+ }
290
+ const withBody = (request, run) => readBody(request).then((read) => "response" in read ? read.response : run(read.body));
291
+ const itemHandlers = {
292
+ GET: (request, context) => item(request, context, "read", async (id) => respond(await store.get(id))),
293
+ PATCH: (request, context) => item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.update(id, body)))),
294
+ PUT: (request, context) => item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.replace(id, body)))),
295
+ DELETE: (request, context) => item(request, context, "delete", async (id) => {
296
+ if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");
297
+ const result = await store.delete(id);
298
+ return result.ok ? new Response(null, { status: 204 }) : failureResponse(result);
299
+ })
300
+ };
301
+ return {
302
+ collection: { GET, POST: draining(POST) },
303
+ item: {
304
+ GET: itemHandlers.GET,
305
+ PATCH: draining(itemHandlers.PATCH),
306
+ PUT: draining(itemHandlers.PUT),
307
+ DELETE: draining(itemHandlers.DELETE)
308
+ },
309
+ store
310
+ };
311
+ }
312
+ export {
313
+ createResourceHandlers,
314
+ createResourceStore,
315
+ isFilterable,
316
+ isSearchable,
317
+ isSortable,
318
+ parseListQuery
319
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wire protocol shared by the server (RealtimeChannel DO), the framework's
3
+ * non-React client, and the useRealtime hook.
4
+ *
5
+ * All frames are single JSON text messages. There is no binary path and no
6
+ * message exceeding 1 MiB. Unknown message types are ignored server-side.
7
+ */
8
+ /** Arbitrary application metadata a connection can attach to its presence. */
9
+ type RealtimePresenceData = Record<string, unknown> | null;
10
+ /** One connected member, as announced in `hello` and `presence` frames. */
11
+ interface RealtimeMember {
12
+ /** Server-assigned id for this connection (unique within the channel). */
13
+ connectionId: string;
14
+ presence: RealtimePresenceData;
15
+ }
16
+ /** Reconnect policy for the client, with sane server-friendly defaults. */
17
+ interface RealtimeReconnectPolicy {
18
+ /**
19
+ * Delay before the first reconnect after a drop. Doubled on every failed
20
+ * attempt (with optional jitter) up to `maxDelayMs`. Reset after the socket
21
+ * has stayed open for `stableMs`.
22
+ */
23
+ minDelayMs?: number;
24
+ /** Hard cap on any single reconnect delay. */
25
+ maxDelayMs?: number;
26
+ /** Exponential multiplier: delay * factor per attempt. */
27
+ factor?: number;
28
+ /** Add up to ±jitterRatio*delay of randomness to avoid thundering herds. */
29
+ jitterRatio?: number;
30
+ /** Give up reconnecting after this many failed attempts (0 = never). */
31
+ maxRetries?: number;
32
+ /**
33
+ * How long to attempt reconnecting without user interaction before the hook
34
+ * reports `status: "dead"` instead of `"reconnecting"`.
35
+ */
36
+ deadAfterMs?: number;
37
+ /** Send a keepalive ping every `heartbeatMs`; force-reconnect after two intervals without a pong. */
38
+ heartbeatMs?: number;
39
+ /**
40
+ * How long a socket must stay open before the backoff resets. A server that
41
+ * accepts and immediately closes (an auth reject, a crashing DO) keeps backing
42
+ * off instead of reconnecting at `minDelayMs` forever.
43
+ */
44
+ stableMs?: number;
45
+ }
46
+ /** Client connection lifecycle, as surfaced by useRealtime and RealtimeClient. */
47
+ type RealtimeStatus = "connecting" | "open" | "reconnecting" | "dead" | "closed";
48
+ interface RealtimeEvent {
49
+ e: string;
50
+ d: unknown;
51
+ from?: string;
52
+ }
53
+
54
+ export type { RealtimePresenceData as R, RealtimeReconnectPolicy as a, RealtimeStatus as b, RealtimeMember as c, RealtimeEvent as d };
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@flaredev/core",
3
+ "version": "0.1.0",
4
+ "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
+ "license": "MIT",
6
+ "author": "Muke Johnbaptist",
7
+ "homepage": "https://flare-docs.codetotech.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MUKE-coder/flare-framework.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/MUKE-coder/flare-framework/issues"
15
+ },
16
+ "keywords": [
17
+ "cloudflare",
18
+ "workers",
19
+ "vinext",
20
+ "fullstack",
21
+ "framework",
22
+ "d1",
23
+ "drizzle",
24
+ "generator",
25
+ "admin"
26
+ ],
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ },
35
+ "./server": {
36
+ "types": "./dist/server.d.ts",
37
+ "import": "./dist/server.js"
38
+ },
39
+ "./client": {
40
+ "types": "./dist/client.d.ts",
41
+ "import": "./dist/client.js"
42
+ },
43
+ "./react": {
44
+ "types": "./dist/react.d.ts",
45
+ "import": "./dist/react.js"
46
+ },
47
+ "./realtime": {
48
+ "types": "./dist/realtime/client.d.ts",
49
+ "import": "./dist/realtime/client.js"
50
+ },
51
+ "./realtime/server": {
52
+ "types": "./dist/realtime/server.d.ts",
53
+ "import": "./dist/realtime/server.js"
54
+ },
55
+ "./security": {
56
+ "types": "./dist/security/index.d.ts",
57
+ "import": "./dist/security/index.js"
58
+ },
59
+ "./security/server": {
60
+ "types": "./dist/security/worker.d.ts",
61
+ "import": "./dist/security/worker.js"
62
+ }
63
+ },
64
+ "files": [
65
+ "dist",
66
+ "README.md"
67
+ ],
68
+ "dependencies": {
69
+ "zod": "^4.6.5"
70
+ },
71
+ "devDependencies": {
72
+ "@types/react": "^19.3.0",
73
+ "@types/sql.js": "^1.4.11",
74
+ "drizzle-orm": "^0.45.2",
75
+ "sql.js": "^1.14.2"
76
+ },
77
+ "peerDependencies": {
78
+ "drizzle-orm": "^0.45.2",
79
+ "react": "^18.2 || ^19.0.0"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "drizzle-orm": {
83
+ "optional": true
84
+ },
85
+ "react": {
86
+ "optional": true
87
+ }
88
+ },
89
+ "engines": {
90
+ "node": ">=22"
91
+ },
92
+ "publishConfig": {
93
+ "access": "public"
94
+ },
95
+ "scripts": {
96
+ "build": "tsup",
97
+ "dev": "tsup --watch",
98
+ "typecheck": "tsc --noEmit"
99
+ }
100
+ }