Enable LLMs to handle webhooks with plaintext files
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

run formatter

+142 -186
+2 -2
README.md
··· 29 29 verify: 30 30 hmac: 31 31 header: X-My-Header-Signature 32 - prefix: "sha256=" # optional: stripped before comparing the digest 32 + prefix: "sha256=" # optional: stripped before comparing the digest 33 33 secret: $MY_WEBHOOK_SECRET 34 34 payload: 35 35 contentType: json ··· 116 116 117 117 ### Per Request 118 118 119 - 1. The requested path is checked against registered lure paths. 119 + 1. The requested path is checked against registered lure paths. 120 120 2. On a hit, we immediately return a 204 response, to keep the response 121 121 time as low as possible. 122 122 3. Webhook requests are copied and added to an in-memory queue for processing.
+6 -6
SKILL.md
··· 37 37 ```yaml 38 38 verify: 39 39 hmac: 40 - header: X-Hub-Signature-256 # or query: param-name 41 - prefix: "sha256=" # optional: stripped before comparing the digest 42 - secret: $ENV_VAR_NAME # must be an environment variable reference 40 + header: X-Hub-Signature-256 # or query: param-name 41 + prefix: "sha256=" # optional: stripped before comparing the digest 42 + secret: $ENV_VAR_NAME # must be an environment variable reference 43 43 ``` 44 44 45 45 Literal (e.g. Forgejo, Omi — compares value directly against the secret): ··· 47 47 ```yaml 48 48 verify: 49 49 literal: 50 - header: Authorization # or query: uid 51 - secret: $ENV_VAR_NAME # must be an environment variable reference 50 + header: Authorization # or query: uid 51 + secret: $ENV_VAR_NAME # must be an environment variable reference 52 52 ``` 53 53 54 54 ### `payload` (optional) 55 55 56 56 ```yaml 57 57 payload: 58 - contentType: json # currently the only supported value 58 + contentType: json # currently the only supported value 59 59 ``` 60 60 61 61 ### `config` (optional)
+7 -1
package.json
··· 2 2 "name": "lure", 3 3 "version": "1.0.0", 4 4 "description": "Process webhook events into LLM-consumable prompts", 5 - "keywords": ["webhook", "llm", "prompt", "liquid", "template"], 5 + "keywords": [ 6 + "liquid", 7 + "llm", 8 + "prompt", 9 + "template", 10 + "webhook" 11 + ], 6 12 "license": "ISC", 7 13 "author": "Graham Barber", 8 14 "type": "module",
+14 -4
packages/core/package.json
··· 2 2 "name": "@lure-hooks/core", 3 3 "version": "1.0.0", 4 4 "description": "Core webhook processing logic for Lure — verification, Liquid templating, and queue management", 5 - "keywords": ["webhook", "llm", "prompt", "liquid", "template", "hmac", "verification"], 5 + "keywords": [ 6 + "hmac", 7 + "liquid", 8 + "llm", 9 + "prompt", 10 + "template", 11 + "verification", 12 + "webhook" 13 + ], 6 14 "license": "ISC", 7 15 "author": "Graham Barber", 16 + "files": [ 17 + "dist" 18 + ], 8 19 "type": "module", 9 20 "main": "./dist/index.js", 10 21 "types": "./dist/index.d.ts", ··· 14 25 "types": "./dist/index.d.ts" 15 26 } 16 27 }, 17 - "files": ["dist"], 18 28 "scripts": { 19 29 "build": "tsc --build", 20 30 "test": "vitest run" 21 31 }, 22 - "packageManager": "pnpm@11.0.0-dev.1005", 23 32 "dependencies": { 24 33 "@standard-schema/spec": "^1.1.0", 25 34 "arktype": "^2.2.0", ··· 30 39 }, 31 40 "devDependencies": { 32 41 "vitest": "^4.1.0" 33 - } 42 + }, 43 + "packageManager": "pnpm@11.0.0-dev.1005" 34 44 }
+4 -1
packages/core/src/__tests__/loader.test.ts
··· 70 70 expect(original).toBeDefined(); 71 71 72 72 // Write invalid content 73 - await fs.writeFile(filePath, "---\nverify:\n hmac:\n secret: invalid_no_dollar\n header: X-Sig\n---\n"); 73 + await fs.writeFile( 74 + filePath, 75 + "---\nverify:\n hmac:\n secret: invalid_no_dollar\n header: X-Sig\n---\n", 76 + ); 74 77 await expect(cache.set(filePath)).rejects.toThrow(); 75 78 76 79 // Original entry retained
+3 -4
packages/core/src/__tests__/render.test.ts
··· 62 62 }); 63 63 64 64 it("renders conditional blocks", async () => { 65 - const lure = makeLure( 66 - "{% if payload.merged %}Merged!{% else %}Not merged{% endif %}", 67 - { frontmatter: { payload: { contentType: "json" } } }, 68 - ); 65 + const lure = makeLure("{% if payload.merged %}Merged!{% else %}Not merged{% endif %}", { 66 + frontmatter: { payload: { contentType: "json" } }, 67 + }); 69 68 const body = JSON.stringify({ merged: true }); 70 69 const req = makeRequest({ 71 70 rawBody: new Uint8Array(Buffer.from(body)),
+7 -21
packages/core/src/__tests__/schema.test.ts
··· 23 23 24 24 it("rejects invalid secret pattern (no $ prefix)", async () => { 25 25 const data = { verify: { hmac: { secret: "plain_secret", header: "X-Sig" } } }; 26 - await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow( 27 - LureParseError, 28 - ); 26 + await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow(LureParseError); 29 27 }); 30 28 31 29 it("rejects secret with lowercase letters", async () => { 32 30 const data = { verify: { hmac: { secret: "$my_secret", header: "X-Sig" } } }; 33 - await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow( 34 - LureParseError, 35 - ); 31 + await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow(LureParseError); 36 32 }); 37 33 38 34 it("rejects verify block with both header and query", async () => { 39 35 const data = { verify: { hmac: { secret: "$MY_SECRET", header: "X-Sig", query: "sig" } } }; 40 - await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow( 41 - LureParseError, 42 - ); 36 + await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow(LureParseError); 43 37 }); 44 38 45 39 it("rejects verify block with neither header nor query", async () => { 46 40 const data = { verify: { literal: { secret: "$MY_SECRET" } } }; 47 - await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow( 48 - LureParseError, 49 - ); 41 + await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow(LureParseError); 50 42 }); 51 43 52 44 it("accepts literal strategy via query", async () => { ··· 57 49 58 50 it("rejects invalid contentType", async () => { 59 51 const data = { payload: { contentType: "xml" } }; 60 - await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow( 61 - LureParseError, 62 - ); 52 + await expect(validateFrontmatter(data, FILE, undefined)).rejects.toThrow(LureParseError); 63 53 }); 64 54 65 55 it("rejects non-object input", async () => { 66 - await expect(validateFrontmatter("invalid", FILE, undefined)).rejects.toThrow( 67 - LureParseError, 68 - ); 56 + await expect(validateFrontmatter("invalid", FILE, undefined)).rejects.toThrow(LureParseError); 69 57 }); 70 58 71 59 it("validates config with configSchema when present", async () => { ··· 96 84 }, 97 85 }; 98 86 const data = { config: { bad: true } }; 99 - await expect( 100 - validateFrontmatter(data, FILE, configSchema), 101 - ).rejects.toThrow(LureParseError); 87 + await expect(validateFrontmatter(data, FILE, configSchema)).rejects.toThrow(LureParseError); 102 88 }); 103 89 });
+6 -13
packages/core/src/handler.ts
··· 5 5 import { createQueue } from "./queue.js"; 6 6 import type { FSWatcher } from "chokidar"; 7 7 8 - export async function createLureHandler< 9 - TSchema extends StandardSchemaV1 | undefined = undefined, 10 - >(options: LureHandlerOptions<TSchema>): Promise<LureHandler> { 8 + export async function createLureHandler<TSchema extends StandardSchemaV1 | undefined = undefined>( 9 + options: LureHandlerOptions<TSchema>, 10 + ): Promise<LureHandler> { 11 11 const { 12 12 basePath: rawBasePath, 13 13 luresDir, ··· 18 18 watch = false, 19 19 } = options; 20 20 21 - const basePath = 22 - "/" + rawBasePath.replace(/^\/+/, "").replace(/\/+$/, ""); 21 + const basePath = "/" + rawBasePath.replace(/^\/+/, "").replace(/\/+$/, ""); 23 22 24 23 const cache = new LureCache( 25 24 luresDir, ··· 43 42 handle(req) { 44 43 if (basePath === "/") { 45 44 // root basePath: every path matches; lurePath is the full path 46 - } else if ( 47 - req.path !== basePath && 48 - !req.path.startsWith(basePath + "/") 49 - ) { 45 + } else if (req.path !== basePath && !req.path.startsWith(basePath + "/")) { 50 46 return false; 51 47 } 52 48 53 - const lurePath = 54 - basePath === "/" 55 - ? req.path 56 - : req.path.slice(basePath.length) || "/"; 49 + const lurePath = basePath === "/" ? req.path : req.path.slice(basePath.length) || "/"; 57 50 const lure = cache.get(lurePath); 58 51 if (lure === undefined) { 59 52 return false;
+5 -19
packages/core/src/loader.ts
··· 35 35 const resolved = nodePath.resolve(filePath); 36 36 const resolvedDir = nodePath.resolve(this.luresDir); 37 37 38 - if ( 39 - !resolved.startsWith(resolvedDir + nodePath.sep) && 40 - resolved !== resolvedDir 41 - ) { 42 - throw new LureParseError( 43 - `Path traversal detected: ${filePath}`, 44 - filePath, 45 - ); 38 + if (!resolved.startsWith(resolvedDir + nodePath.sep) && resolved !== resolvedDir) { 39 + throw new LureParseError(`Path traversal detected: ${filePath}`, filePath); 46 40 } 47 41 48 42 const content = await fs.readFile(filePath, "utf-8"); 49 43 const { data, content: template } = matter(content); 50 44 51 - const frontmatter = await validateFrontmatter( 52 - data, 53 - filePath, 54 - this.configSchema, 55 - ); 45 + const frontmatter = await validateFrontmatter(data, filePath, this.configSchema); 56 46 57 47 if (!this.allowUnverified && frontmatter.verify === undefined) { 58 48 throw new LureParseError( ··· 61 51 ); 62 52 } 63 53 64 - const relative = nodePath.relative( 65 - resolvedDir, 66 - nodePath.resolve(filePath), 67 - ); 68 - const lurePath = 69 - "/" + relative.replace(/\.lure$/, "").replace(/\\/g, "/"); 54 + const relative = nodePath.relative(resolvedDir, nodePath.resolve(filePath)); 55 + const lurePath = "/" + relative.replace(/\.lure$/, "").replace(/\\/g, "/"); 70 56 71 57 const parsed: ParsedLure = { 72 58 filePath,
+1 -4
packages/core/src/queue.ts
··· 42 42 } 43 43 } 44 44 45 - console.error( 46 - `Callback failed after ${maxAttempts} attempts for ${lurePath}:`, 47 - lastError, 48 - ); 45 + console.error(`Callback failed after ${maxAttempts} attempts for ${lurePath}:`, lastError); 49 46 }); 50 47 }, 51 48
+1 -4
packages/core/src/render.ts
··· 3 3 4 4 const engine = new Liquid({ strictVariables: false, strictFilters: false }); 5 5 6 - export async function renderTemplate( 7 - lure: ParsedLure, 8 - req: LureRequest, 9 - ): Promise<string> { 6 + export async function renderTemplate(lure: ParsedLure, req: LureRequest): Promise<string> { 10 7 let payload: unknown = null; 11 8 12 9 if (lure.frontmatter.payload?.contentType === "json") {
+4 -13
packages/core/src/schema.ts
··· 43 43 const result = frontmatterValidator(data); 44 44 45 45 if (result instanceof type.errors) { 46 - throw new LureParseError( 47 - `Invalid frontmatter: ${result.summary}`, 48 - filePath, 49 - ); 46 + throw new LureParseError(`Invalid frontmatter: ${result.summary}`, filePath); 50 47 } 51 48 52 49 if (result.verify !== undefined) { 53 - const params = 54 - "hmac" in result.verify ? result.verify.hmac : result.verify.literal; 50 + const params = "hmac" in result.verify ? result.verify.hmac : result.verify.literal; 55 51 56 52 if (!SECRET_PATTERN.test(params.secret)) { 57 53 throw new LureParseError( ··· 70 66 ); 71 67 } 72 68 if (!hasHeader && !hasQuery) { 73 - throw new LureParseError( 74 - "verify must specify one of header or query", 75 - filePath, 76 - ); 69 + throw new LureParseError("verify must specify one of header or query", filePath); 77 70 } 78 71 } 79 72 80 73 let config: unknown = result.config; 81 74 82 75 if (configSchema !== undefined && result.config !== undefined) { 83 - const configResult = await configSchema["~standard"].validate( 84 - result.config, 85 - ); 76 + const configResult = await configSchema["~standard"].validate(result.config); 86 77 if (configResult.issues !== undefined) { 87 78 const messages = configResult.issues.map((i) => i.message).join(", "); 88 79 throw new LureParseError(`Invalid config: ${messages}`, filePath);
+5 -10
packages/core/src/types.ts
··· 20 20 query?: string; 21 21 } 22 22 23 - export type LureVerify = 24 - | { hmac: LureHmacParams } 25 - | { literal: LureLiteralParams }; 23 + export type LureVerify = { hmac: LureHmacParams } | { literal: LureLiteralParams }; 26 24 27 25 export interface LurePayload { 28 26 contentType?: "json"; ··· 41 39 template: string; 42 40 } 43 41 44 - type ConfigOf<TSchema extends StandardSchemaV1 | undefined> = 45 - TSchema extends StandardSchemaV1 46 - ? StandardSchemaV1.InferOutput<TSchema> 47 - : unknown; 42 + type ConfigOf<TSchema extends StandardSchemaV1 | undefined> = TSchema extends StandardSchemaV1 43 + ? StandardSchemaV1.InferOutput<TSchema> 44 + : unknown; 48 45 49 - export interface LureHandlerOptions< 50 - TSchema extends StandardSchemaV1 | undefined = undefined, 51 - > { 46 + export interface LureHandlerOptions<TSchema extends StandardSchemaV1 | undefined = undefined> { 52 47 basePath: string; 53 48 luresDir: string; 54 49 callback: (prompt: string, config: ConfigOf<TSchema>) => Promise<void>;
+1 -4
packages/core/src/verify.ts
··· 47 47 return safeEqual(signature, secretValue); 48 48 } 49 49 50 - export async function verifyRequest( 51 - lure: ParsedLure, 52 - req: LureRequest, 53 - ): Promise<boolean> { 50 + export async function verifyRequest(lure: ParsedLure, req: LureRequest): Promise<boolean> { 54 51 if (lure.frontmatter.verify === undefined) { 55 52 return true; 56 53 }
+1 -3
packages/core/src/watcher.ts
··· 4 4 import type { LureCache } from "./loader.js"; 5 5 6 6 export function watchLures(luresDir: string, cache: LureCache): FSWatcher { 7 - const pattern = nodePath 8 - .join(nodePath.resolve(luresDir), "**", "*.lure") 9 - .replace(/\\/g, "/"); 7 + const pattern = nodePath.join(nodePath.resolve(luresDir), "**", "*.lure").replace(/\\/g, "/"); 10 8 11 9 const watcher = chokidar.watch(pattern, { ignoreInitial: true }); 12 10
+18 -18
packages/express/README.md
··· 12 12 ## Usage 13 13 14 14 ```ts 15 - import express from 'express'; 16 - import { createLureHandler } from '@lure-hooks/express'; 15 + import express from "express"; 16 + import { createLureHandler } from "@lure-hooks/express"; 17 17 18 18 const app = express(); 19 19 20 20 const lure = await createLureHandler({ 21 - basePath: '/webhooks', 22 - luresDir: './lures', 21 + basePath: "/webhooks", 22 + luresDir: "./lures", 23 23 callback: async (prompt, config) => { 24 24 // Send the prompt to your LLM of choice 25 25 }, ··· 33 33 ### With config schema 34 34 35 35 ```ts 36 - import express from 'express'; 37 - import { createLureHandler } from '@lure-hooks/express'; 38 - import * as v from 'valibot'; 36 + import express from "express"; 37 + import { createLureHandler } from "@lure-hooks/express"; 38 + import * as v from "valibot"; 39 39 40 40 const app = express(); 41 41 42 42 const lure = await createLureHandler({ 43 - basePath: '/webhooks', 44 - luresDir: './lures', 43 + basePath: "/webhooks", 44 + luresDir: "./lures", 45 45 configSchema: v.object({ 46 46 channel: v.string(), 47 47 }), ··· 54 54 55 55 ### Options 56 56 57 - | Option | Type | Default | Description | 58 - |---|---|---|---| 59 - | `basePath` | `string` | — | URL path prefix for all lure endpoints | 60 - | `luresDir` | `string` | — | Path to the directory containing `.lure` files | 61 - | `callback` | `(prompt: string, config: TConfig) => Promise<void>` | — | Called with the rendered prompt on each verified webhook. `TConfig` is inferred from `configSchema`, or `unknown` if omitted | 62 - | `configSchema` | Standard Schema | — | Schema for validating the `config` frontmatter block. Informs the type of `config` in `callback` | 63 - | `maxAttempts` | `number` | `1` | Number of times to attempt `callback` before dropping the webhook | 64 - | `allowUnverified` | `boolean` | `true` | Whether to allow `.lure` files without a `verify` block | 65 - | `watch` | `boolean` | `false` | Watch `luresDir` for changes and reload lures automatically | 57 + | Option | Type | Default | Description | 58 + | ----------------- | ---------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | 59 + | `basePath` | `string` | — | URL path prefix for all lure endpoints | 60 + | `luresDir` | `string` | — | Path to the directory containing `.lure` files | 61 + | `callback` | `(prompt: string, config: TConfig) => Promise<void>` | — | Called with the rendered prompt on each verified webhook. `TConfig` is inferred from `configSchema`, or `unknown` if omitted | 62 + | `configSchema` | Standard Schema | — | Schema for validating the `config` frontmatter block. Informs the type of `config` in `callback` | 63 + | `maxAttempts` | `number` | `1` | Number of times to attempt `callback` before dropping the webhook | 64 + | `allowUnverified` | `boolean` | `true` | Whether to allow `.lure` files without a `verify` block | 65 + | `watch` | `boolean` | `false` | Watch `luresDir` for changes and reload lures automatically |
+12 -6
packages/express/package.json
··· 2 2 "name": "@lure-hooks/express", 3 3 "version": "1.0.0", 4 4 "description": "Express middleware adapter for Lure — process webhook events into LLM prompts", 5 - "keywords": ["webhook", "llm", "prompt", "express", "middleware"], 5 + "keywords": [ 6 + "express", 7 + "llm", 8 + "middleware", 9 + "prompt", 10 + "webhook" 11 + ], 6 12 "license": "ISC", 7 13 "author": "Graham Barber", 14 + "files": [ 15 + "dist" 16 + ], 8 17 "type": "module", 9 18 "main": "./dist/index.js", 10 19 "types": "./dist/index.d.ts", ··· 14 23 "types": "./dist/index.d.ts" 15 24 } 16 25 }, 17 - "files": [ 18 - "dist" 19 - ], 20 26 "scripts": { 21 27 "build": "tsc --build", 22 28 "test": "vitest run" 23 29 }, 24 - "packageManager": "pnpm@11.0.0-dev.1005", 25 30 "dependencies": { 26 31 "@lure-hooks/core": "workspace:^", 27 32 "express": "^5.2.1" ··· 33 38 "supertest": "^7.2.2", 34 39 "typescript": "^5.9.3", 35 40 "vitest": "^4.1.0" 36 - } 41 + }, 42 + "packageManager": "pnpm@11.0.0-dev.1005" 37 43 }
+1 -4
packages/express/src/__tests__/index.test.ts
··· 109 109 }); 110 110 111 111 it("forwards request headers to the template", async () => { 112 - await writeLure( 113 - "event.lure", 114 - "---\n---\nToken: {{ headers['x-token'] }}\n", 115 - ); 112 + await writeLure("event.lure", "---\n---\nToken: {{ headers['x-token'] }}\n"); 116 113 let resolveCallback!: () => void; 117 114 const callbackDone = new Promise<void>((r) => { 118 115 resolveCallback = r;
+6 -12
packages/express/src/index.ts
··· 3 3 import type { LureHandlerOptions } from "@lure-hooks/core"; 4 4 import type { StandardSchemaV1 } from "@standard-schema/spec"; 5 5 6 - export async function createLureHandler< 7 - TSchema extends StandardSchemaV1 | undefined = undefined, 8 - >(options: LureHandlerOptions<TSchema>): Promise<express.RequestHandler> { 9 - const basePath = 10 - "/" + options.basePath.replace(/^\/+/, "").replace(/\/+$/, ""); 6 + export async function createLureHandler<TSchema extends StandardSchemaV1 | undefined = undefined>( 7 + options: LureHandlerOptions<TSchema>, 8 + ): Promise<express.RequestHandler> { 9 + const basePath = "/" + options.basePath.replace(/^\/+/, "").replace(/\/+$/, ""); 11 10 12 11 const handler = await coreCreateLureHandler(options); 13 12 14 13 const rawBodyMiddleware = express.raw({ type: "*/*" }); 15 14 16 15 return (req, res, next) => { 17 - if ( 18 - basePath !== "/" && 19 - req.path !== basePath && 20 - !req.path.startsWith(basePath + "/") 21 - ) { 16 + if (basePath !== "/" && req.path !== basePath && !req.path.startsWith(basePath + "/")) { 22 17 next(); 23 18 return; 24 19 } ··· 29 24 return; 30 25 } 31 26 32 - const rawBody = 33 - req.body instanceof Buffer ? new Uint8Array(req.body) : new Uint8Array(0); 27 + const rawBody = req.body instanceof Buffer ? new Uint8Array(req.body) : new Uint8Array(0); 34 28 35 29 const headers = new Headers(); 36 30 for (const [key, value] of Object.entries(req.headers)) {
+16 -16
packages/fetch/README.md
··· 13 13 ## Usage 14 14 15 15 ```ts 16 - import { createLureHandler } from '@lure-hooks/fetch'; 16 + import { createLureHandler } from "@lure-hooks/fetch"; 17 17 18 18 const lure = await createLureHandler({ 19 - basePath: '/webhooks', 20 - luresDir: './lures', 19 + basePath: "/webhooks", 20 + luresDir: "./lures", 21 21 callback: async (prompt, config) => { 22 22 // Send the prompt to your LLM of choice 23 23 }, ··· 37 37 implements the Standard Schema spec (Zod, Valibot, Arktype, etc.) will work. 38 38 39 39 ```ts 40 - import { createLureHandler } from '@lure-hooks/fetch'; 41 - import * as v from 'valibot'; 40 + import { createLureHandler } from "@lure-hooks/fetch"; 41 + import * as v from "valibot"; 42 42 43 43 const lure = await createLureHandler({ 44 - basePath: '/webhooks', 45 - luresDir: './lures', 44 + basePath: "/webhooks", 45 + luresDir: "./lures", 46 46 configSchema: v.object({ 47 47 channel: v.string(), 48 48 }), ··· 54 54 55 55 ### Options 56 56 57 - | Option | Type | Default | Description | 58 - |---|---|---|---| 59 - | `basePath` | `string` | — | URL path prefix for all lure endpoints | 60 - | `luresDir` | `string` | — | Path to the directory containing `.lure` files | 61 - | `callback` | `(prompt: string, config: TConfig) => Promise<void>` | — | Called with the rendered prompt on each verified webhook. `TConfig` is inferred from `configSchema`, or `unknown` if omitted | 62 - | `configSchema` | Standard Schema | — | Schema for validating the `config` frontmatter block. Informs the type of `config` in `callback` | 63 - | `maxAttempts` | `number` | `1` | Number of times to attempt `callback` before dropping the webhook | 64 - | `allowUnverified` | `boolean` | `true` | Whether to allow `.lure` files without a `verify` block | 65 - | `watch` | `boolean` | `false` | Watch `luresDir` for changes and reload lures automatically | 57 + | Option | Type | Default | Description | 58 + | ----------------- | ---------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | 59 + | `basePath` | `string` | — | URL path prefix for all lure endpoints | 60 + | `luresDir` | `string` | — | Path to the directory containing `.lure` files | 61 + | `callback` | `(prompt: string, config: TConfig) => Promise<void>` | — | Called with the rendered prompt on each verified webhook. `TConfig` is inferred from `configSchema`, or `unknown` if omitted | 62 + | `configSchema` | Standard Schema | — | Schema for validating the `config` frontmatter block. Informs the type of `config` in `callback` | 63 + | `maxAttempts` | `number` | `1` | Number of times to attempt `callback` before dropping the webhook | 64 + | `allowUnverified` | `boolean` | `true` | Whether to allow `.lure` files without a `verify` block | 65 + | `watch` | `boolean` | `false` | Watch `luresDir` for changes and reload lures automatically |
+14 -6
packages/fetch/package.json
··· 2 2 "name": "@lure-hooks/fetch", 3 3 "version": "1.0.0", 4 4 "description": "Fetch API adapter for Lure — process webhook events into LLM prompts with Deno, Bun, and Cloudflare Workers", 5 - "keywords": ["webhook", "llm", "prompt", "fetch", "deno", "bun", "cloudflare-workers"], 5 + "keywords": [ 6 + "bun", 7 + "cloudflare-workers", 8 + "deno", 9 + "fetch", 10 + "llm", 11 + "prompt", 12 + "webhook" 13 + ], 6 14 "license": "ISC", 7 15 "author": "Graham Barber", 16 + "files": [ 17 + "dist" 18 + ], 8 19 "type": "module", 9 20 "main": "./dist/index.js", 10 21 "types": "./dist/index.d.ts", ··· 14 25 "types": "./dist/index.d.ts" 15 26 } 16 27 }, 17 - "files": [ 18 - "dist" 19 - ], 20 28 "scripts": { 21 29 "build": "tsc --build", 22 30 "test": "vitest run" 23 31 }, 24 - "packageManager": "pnpm@11.0.0-dev.1005", 25 32 "dependencies": { 26 33 "@lure-hooks/core": "workspace:^" 27 34 }, ··· 30 37 "@types/node": "^25.5.0", 31 38 "typescript": "^5.9.3", 32 39 "vitest": "^4.1.0" 33 - } 40 + }, 41 + "packageManager": "pnpm@11.0.0-dev.1005" 34 42 }
+5 -5
packages/fetch/src/__tests__/index.test.ts
··· 128 128 }); 129 129 130 130 it("forwards request headers to the template", async () => { 131 - await writeLure( 132 - "event.lure", 133 - "---\n---\nToken: {{ headers['x-token'] }}\n", 134 - ); 131 + await writeLure("event.lure", "---\n---\nToken: {{ headers['x-token'] }}\n"); 135 132 let resolveCallback!: () => void; 136 133 const callbackDone = new Promise<void>((r) => { 137 134 resolveCallback = r; ··· 156 153 }); 157 154 158 155 it("reads and forwards body as rawBody", async () => { 159 - await writeLure("data.lure", "---\npayload:\n contentType: json\n---\nAction: {{ payload.action }}\n"); 156 + await writeLure( 157 + "data.lure", 158 + "---\npayload:\n contentType: json\n---\nAction: {{ payload.action }}\n", 159 + ); 160 160 let resolveCallback!: () => void; 161 161 const callbackDone = new Promise<void>((r) => { 162 162 resolveCallback = r;
+3 -10
packages/fetch/src/index.ts
··· 2 2 import type { LureHandlerOptions } from "@lure-hooks/core"; 3 3 import type { StandardSchemaV1 } from "@standard-schema/spec"; 4 4 5 - export async function createLureHandler< 6 - TSchema extends StandardSchemaV1 | undefined = undefined, 7 - >( 5 + export async function createLureHandler<TSchema extends StandardSchemaV1 | undefined = undefined>( 8 6 options: LureHandlerOptions<TSchema>, 9 7 ): Promise<(req: Request) => Promise<Response | null>> { 10 - const basePath = 11 - "/" + options.basePath.replace(/^\/+/, "").replace(/\/+$/, ""); 8 + const basePath = "/" + options.basePath.replace(/^\/+/, "").replace(/\/+$/, ""); 12 9 13 10 const handler = await coreCreateLureHandler(options); 14 11 15 12 return async (req: Request): Promise<Response | null> => { 16 13 const url = new URL(req.url); 17 14 18 - if ( 19 - basePath !== "/" && 20 - url.pathname !== basePath && 21 - !url.pathname.startsWith(basePath + "/") 22 - ) { 15 + if (basePath !== "/" && url.pathname !== basePath && !url.pathname.startsWith(basePath + "/")) { 23 16 return null; 24 17 } 25 18