@agilesyndrome/cf-genai-base 0.1.1 → 0.1.3

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/CONTRACT.md CHANGED
@@ -4,8 +4,7 @@ Every site built from this foundation follows the same edge contract.
4
4
 
5
5
  ## Worker entrypoint
6
6
 
7
- `createWorker({ fetch, auth?, scheduled?, security? })` owns the Worker lifecycle.
8
- The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
7
+ `createWorker({ fetch, features?, middleware?, auth?, scheduled?, security? })` owns the Worker lifecycle. Features run in declaration order and may call `next()` or return a response. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
9
8
  is optional and must use `ctx.waitUntil` for background work.
10
9
 
11
10
  ## Routes
package/README.md CHANGED
@@ -1,21 +1,29 @@
1
- # `@easleyowl/cf-genai-base`
1
+ # `@agilesyndrome/cf-genai-base`
2
2
 
3
3
  Opinionated startup boilerplate for small Cloudflare Workers.
4
4
 
5
5
  The base is deliberately small: a site still owns its router, HTML, D1
6
- queries, R2 keys, and scheduled jobs. `createWorker` composes an optional auth
7
- handler, normalizes uncaught failures, and applies baseline response headers.
6
+ queries, R2 keys, and scheduled jobs. `createWorker` composes ordered feature middleware, normalizes uncaught failures, and applies baseline response headers.
8
7
  Use D1 bindings for durable application data and R2 bindings for binary assets;
9
8
  do not put either into module-level state.
10
9
 
11
10
  ```js
12
- import { createWorker, healthResponse } from "@easleyowl/cf-genai-base";
11
+ import { createWorker, healthResponse } from "@agilesyndrome/cf-genai-base";
13
12
 
14
13
  export default createWorker({
15
- auth: (request, env) => auth.handle(request, env),
14
+ features: [auth],
16
15
  fetch: async (request, env) => {
17
16
  if (new URL(request.url).pathname === "/health") return healthResponse(env);
18
17
  return router(request, env);
19
18
  },
20
19
  });
21
20
  ```
21
+
22
+ Features expose `middleware(request, env, ctx, next, state)` and may short-circuit reserved routes, attach request state, or call `next()`.
23
+
24
+ ## Shared platform helpers
25
+
26
+ `createWorker` can own `/health` and `/api/health`, run a boot validator before
27
+ requests, and optionally deliver server-side PostHog events. Use
28
+ `assertBoot(env, { bindings: ["DB"], required: ["AUTH_SESSION_SECRET"] })` in a
29
+ site initializer to fail closed when its Cloudflare configuration is incomplete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js"
@@ -8,7 +8,8 @@
8
8
  "description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
9
9
  "license": "MIT",
10
10
  "publishConfig": {
11
- "access": "public"
11
+ "access": "public",
12
+ "provenance": true
12
13
  },
13
14
  "files": [
14
15
  "src",
package/src/index.js CHANGED
@@ -1,23 +1,56 @@
1
1
  /**
2
- * Small, opinionated Worker composition layer shared by the Easley Owl sites.
3
- * Site code owns routing and data access; this owns lifecycle and the edges.
2
+ * Lean, opinionated Worker composition for Cloudflare sites.
3
+ * Site code owns domain routes and data; this owns lifecycle and edge concerns.
4
4
  */
5
- export function createWorker({ fetch, scheduled, auth, security = true }) {
5
+ export function createWorker({ fetch, scheduled, auth, middleware = [], features = [], health, boot, metrics, security = true }) {
6
+ if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
7
+ const chain = [
8
+ ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
9
+ ...middleware,
10
+ ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
11
+ ].filter(Boolean);
6
12
  return {
7
13
  async fetch(request, env, ctx) {
8
14
  try {
9
- const authResponse = auth ? await auth(request, env, ctx) : null;
10
- const response = authResponse || await fetch(request, env, ctx);
15
+ if (boot) await boot(env, { request, ctx });
16
+ const url = new URL(request.url);
17
+ const state = Object.create(null);
18
+ const dispatch = async (index, currentRequest = request) => {
19
+ const layer = chain[index];
20
+ if (!layer) {
21
+ if (url.pathname === "/health" || url.pathname === "/api/health") {
22
+ const details = health ? await health(env, { request: currentRequest, ctx, state }) : {};
23
+ return healthResponse(env, details);
24
+ }
25
+ return fetch(currentRequest, env, ctx, state);
26
+ }
27
+ if (typeof layer !== "function") throw new TypeError("Worker middleware must be a function");
28
+ return layer(currentRequest, env, ctx, (nextRequest = currentRequest) => dispatch(index + 1, nextRequest), state);
29
+ };
30
+ const response = await dispatch(0);
31
+ if (metrics) metrics.request(request, response, env, ctx);
11
32
  return security ? secureResponse(response) : response;
12
33
  } catch (error) {
13
34
  console.error("[worker] request failed", error);
14
- return secureResponse(Response.json({ error: "Internal server error" }, { status: 500 }));
35
+ return secureResponse(Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }));
15
36
  }
16
37
  },
17
38
  ...(scheduled ? { scheduled } : {}),
18
39
  };
19
40
  }
20
41
 
42
+ export function validateBoot(env, { bindings = [], required = [] } = {}) {
43
+ const missingBindings = bindings.filter((name) => !env?.[name]);
44
+ const missingValues = required.filter((name) => !env?.[name] || String(env[name]).startsWith("replace-with-"));
45
+ return { ok: missingBindings.length === 0 && missingValues.length === 0, missingBindings, missingValues };
46
+ }
47
+
48
+ export function assertBoot(env, spec = {}) {
49
+ const result = validateBoot(env, spec);
50
+ if (!result.ok) throw new Error(`Worker boot validation failed: ${[...result.missingBindings, ...result.missingValues].join(", ")}`);
51
+ return result;
52
+ }
53
+
21
54
  export function secureResponse(response) {
22
55
  const headers = new Headers(response.headers);
23
56
  headers.set("X-Content-Type-Options", "nosniff");
@@ -34,10 +67,26 @@ export function methodNotAllowed(allow = "GET") {
34
67
  }
35
68
 
36
69
  export function healthResponse(env, details = {}) {
37
- return Response.json({
38
- ok: true,
39
- version: String(env.BUILD_SHA || "unknown").slice(0, 7),
40
- build_number: env.BUILD_NUMBER ? String(env.BUILD_NUMBER) : null,
41
- ...details,
42
- }, { headers: { "Cache-Control": "no-store" } });
70
+ return Response.json({ ok: true, version: String(env.BUILD_SHA || "unknown").slice(0, 7), build_number: env.BUILD_NUMBER ? String(env.BUILD_NUMBER) : null, ...details }, { headers: { "Cache-Control": "no-store" } });
71
+ }
72
+
73
+ export function createMetrics({ tokenEnv = "POSTHOG_TOKEN", host = "https://us.i.posthog.com" } = {}) {
74
+ return {
75
+ request(request, response, env, ctx) {
76
+ if (!env?.[tokenEnv] || !ctx?.waitUntil || new URL(request.url).pathname === "/health") return;
77
+ const event = response.status >= 500 ? "server_error" : "request";
78
+ ctx.waitUntil(track(env, event, { path: new URL(request.url).pathname, method: request.method, status: response.status }, { tokenEnv, host }));
79
+ },
80
+ track: (env, event, properties, ctx) => ctx?.waitUntil?.(track(env, event, properties, { tokenEnv, host })),
81
+ };
82
+ }
83
+
84
+ async function track(env, event, properties, { tokenEnv, host }) {
85
+ try {
86
+ const token = String(env?.[tokenEnv] || "");
87
+ if (!token) return;
88
+ await fetch(`${host.replace(/\/+$/, "")}/capture/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: token, event, properties: { ...properties, distinct_id: properties?.distinct_id || "anonymous" } }) });
89
+ } catch (error) {
90
+ console.error("[metrics] delivery failed", error);
91
+ }
43
92
  }