@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 +1 -2
- package/README.md +13 -5
- package/package.json +3 -2
- package/src/index.js +61 -12
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
|
-
# `@
|
|
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
|
|
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 "@
|
|
11
|
+
import { createWorker, healthResponse } from "@agilesyndrome/cf-genai-base";
|
|
13
12
|
|
|
14
13
|
export default createWorker({
|
|
15
|
-
|
|
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.
|
|
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
|
-
*
|
|
3
|
-
* Site code owns
|
|
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
|
-
|
|
10
|
-
const
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
}
|