@effing/fn 0.44.0 → 0.45.1

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.
@@ -108,7 +108,7 @@ function matchRoute(url) {
108
108
  }
109
109
  function sendJsonError(res, status, error) {
110
110
  if (res.headersSent) {
111
- if (!res.writableEnded) res.end();
111
+ if (!res.writableEnded) res.destroy();
112
112
  return;
113
113
  }
114
114
  res.statusCode = status;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/segments.ts","../../src/server/url-builder.ts","../../src/server/pipe-response.ts","../../src/server/listener.ts"],"sourcesContent":["import { deserialize, serialize } from \"@effing/serde\";\nimport type { Bounds } from \"../types\";\n\nexport const MAX_DIMENSION = 8192;\n\nexport type FnSegmentPayload = {\n id: string;\n props?: Record<string, unknown>;\n bounds: Bounds;\n};\n\nexport function validateBounds(raw: unknown): Bounds {\n if (raw === null || typeof raw !== \"object\" || Array.isArray(raw)) {\n throw new Error('Segment missing \"bounds\"');\n }\n const { width, height } = raw as { width?: unknown; height?: unknown };\n if (!Number.isInteger(width) || !Number.isInteger(height)) {\n throw new Error(\"Bounds must be integers\");\n }\n const w = width as number;\n const h = height as number;\n if (w <= 0 || h <= 0 || w > MAX_DIMENSION || h > MAX_DIMENSION) {\n throw new Error(`Bounds out of range (1..${MAX_DIMENSION}): ${w}x${h}`);\n }\n return { width: w, height: h };\n}\n\nexport function signFnSegment(\n payload: FnSegmentPayload,\n secretKey: string,\n): Promise<string> {\n return serialize(payload, secretKey);\n}\n\nexport type VerifyFnSegmentOptions = {\n /** When true (default), the segment must include a props object. */\n requireProps?: boolean;\n};\n\nexport async function verifyFnSegment(\n segment: string,\n secretKey: string,\n options: VerifyFnSegmentOptions = {},\n): Promise<FnSegmentPayload> {\n const { requireProps = true } = options;\n\n let payload: { id?: unknown; props?: unknown; bounds?: unknown };\n try {\n payload = await deserialize<{\n id?: unknown;\n props?: unknown;\n bounds?: unknown;\n }>(segment, secretKey);\n } catch {\n throw new Error(\"Invalid segment signature\");\n }\n\n if (typeof payload.id !== \"string\" || payload.id === \"\") {\n throw new Error('Segment missing \"id\"');\n }\n\n let props: Record<string, unknown> | undefined;\n if (requireProps) {\n if (\n payload.props === null ||\n typeof payload.props !== \"object\" ||\n Array.isArray(payload.props)\n ) {\n throw new Error('Segment missing \"props\"');\n }\n props = payload.props as Record<string, unknown>;\n } else if (\n payload.props !== undefined &&\n payload.props !== null &&\n typeof payload.props === \"object\" &&\n !Array.isArray(payload.props)\n ) {\n props = payload.props as Record<string, unknown>;\n }\n\n const bounds = validateBounds(payload.bounds);\n return { id: payload.id, props, bounds };\n}\n","import { effieWebUrl, type EffieWebUrl } from \"@effing/effie\";\nimport type { Bounds, FnKind, FnUrlBuilder } from \"../types\";\nimport { signFnSegment } from \"./segments\";\n\nexport type CreateUrlBuilderOptions = {\n secretKey: string;\n /**\n * Compose the final URL from a kind and a signed segment. Self-host typically\n * returns `${baseUrl}/${kind}/${segment}`; the cloud fn server uses a\n * tenant/project-prefixed shape.\n */\n buildUrl: (kind: FnKind, segment: string) => string;\n};\n\nexport function createUrlBuilder(\n options: CreateUrlBuilderOptions,\n): FnUrlBuilder {\n const { secretKey, buildUrl } = options;\n return {\n async buildUrl(\n kind: FnKind,\n id: string,\n props: Record<string, unknown>,\n bounds: Bounds,\n ): Promise<EffieWebUrl> {\n const segment = await signFnSegment({ id, props, bounds }, secretKey);\n return effieWebUrl(buildUrl(kind, segment));\n },\n };\n}\n\nexport type CreateFlatUrlBuilderOptions = {\n /** Base URL the fn server is reachable at — e.g. `http://localhost:3839` or `https://example.com`. */\n baseUrl: string;\n secretKey: string;\n};\n\n/** Convenience: builds `${baseUrl}/${kind}/${segment}` URLs. */\nexport function createFlatUrlBuilder(\n options: CreateFlatUrlBuilderOptions,\n): FnUrlBuilder {\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n return createUrlBuilder({\n secretKey: options.secretKey,\n buildUrl: (kind, segment) => `${baseUrl}/${kind}/${segment}`,\n });\n}\n","import type { ServerResponse } from \"node:http\";\nimport { Readable } from \"node:stream\";\n\n/**\n * Send a Web `Response` (as returned by `imageResponse`/`annieResponse`/\n * `effieResponse`) over a Node `ServerResponse`. Headers and status are\n * copied; the body is streamed if present.\n */\nexport async function pipeWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, key) => {\n res.setHeader(key, value);\n });\n\n const body = response.body;\n if (!body) {\n res.end();\n return;\n }\n\n const nodeStream = Readable.fromWeb(\n body as unknown as import(\"node:stream/web\").ReadableStream<Uint8Array>,\n );\n nodeStream.pipe(res);\n\n await new Promise<void>((resolve, reject) => {\n nodeStream.on(\"end\", () => resolve());\n nodeStream.on(\"error\", reject);\n res.on(\"close\", () => resolve());\n });\n}\n","import type {\n IncomingMessage,\n RequestListener,\n ServerResponse,\n} from \"node:http\";\nimport type { FnKind, FnModuleLoader } from \"../types\";\nimport { imageResponse, annieResponse, effieResponse } from \"../responses\";\nimport { verifyFnSegment } from \"./segments\";\nimport { pipeWebResponse } from \"./pipe-response\";\n\nexport type CreateFnHttpListenerOptions = {\n moduleLoader: FnModuleLoader;\n secretKey: string;\n /**\n * Optional Cache-Control header value for successful responses. Defaults to\n * `undefined` (the underlying response helpers apply their own default).\n */\n cacheControl?: string;\n};\n\nconst KIND_PATTERNS: { kind: FnKind; pattern: RegExp }[] = [\n { kind: \"image\", pattern: /^\\/image\\/([^/?#]+)$/ },\n { kind: \"annie\", pattern: /^\\/annie\\/([^/?#]+)$/ },\n { kind: \"effie\", pattern: /^\\/effie\\/([^/?#]+)$/ },\n];\n\nfunction matchRoute(\n url: string | undefined,\n): { kind: FnKind; segment: string } | null {\n if (!url) return null;\n const path = url.split(\"?\")[0];\n for (const { kind, pattern } of KIND_PATTERNS) {\n const m = pattern.exec(path);\n if (m) return { kind, segment: decodeURIComponent(m[1]) };\n }\n return null;\n}\n\nfunction sendJsonError(\n res: ServerResponse,\n status: number,\n error: string,\n): void {\n if (res.headersSent) {\n if (!res.writableEnded) res.end();\n return;\n }\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"application/json\");\n res.end(JSON.stringify({ error }));\n}\n\nexport function createFnHttpListener(\n options: CreateFnHttpListenerOptions,\n): RequestListener {\n const { moduleLoader, secretKey, cacheControl } = options;\n\n return (req: IncomingMessage, res: ServerResponse) => {\n handleRequest(req, res, { moduleLoader, secretKey, cacheControl }).catch(\n (err: unknown) => {\n const message = err instanceof Error ? err.message : String(err);\n\n console.error(\"[fn-server] unhandled error:\", message);\n sendJsonError(res, 500, \"Internal error\");\n },\n );\n };\n}\n\nasync function handleRequest(\n req: IncomingMessage,\n res: ServerResponse,\n options: Required<\n Pick<CreateFnHttpListenerOptions, \"moduleLoader\" | \"secretKey\">\n > & {\n cacheControl?: string;\n },\n): Promise<void> {\n const { moduleLoader, secretKey, cacheControl } = options;\n\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\n res.statusCode = 405;\n res.setHeader(\"Allow\", \"GET, HEAD\");\n res.end();\n return;\n }\n\n const matched = matchRoute(req.url);\n if (!matched) {\n sendJsonError(res, 404, \"Not found\");\n return;\n }\n\n let payload;\n try {\n payload = await verifyFnSegment(matched.segment, secretKey);\n } catch (err) {\n const message = err instanceof Error ? err.message : \"Invalid segment\";\n sendJsonError(res, 400, message);\n return;\n }\n\n if (!moduleLoader.hasModule(matched.kind, payload.id)) {\n sendJsonError(res, 404, `No ${matched.kind} found for id '${payload.id}'`);\n return;\n }\n\n const mod = await moduleLoader.loadModule(matched.kind, payload.id);\n let parsedProps: unknown = payload.props ?? {};\n if (mod.propsSchema) {\n try {\n parsedProps = mod.propsSchema.parse(parsedProps);\n } catch (err) {\n const message = err instanceof Error ? err.message : \"Invalid props\";\n sendJsonError(res, 400, message);\n return;\n }\n }\n\n const runnerArgs = { props: parsedProps, bounds: payload.bounds };\n\n if (matched.kind === \"image\") {\n const bytes = await (\n mod as { runner: (a: typeof runnerArgs) => Promise<Buffer> }\n ).runner(runnerArgs);\n await pipeWebResponse(res, imageResponse(bytes, { cacheControl }));\n return;\n }\n\n if (matched.kind === \"annie\") {\n const frames = (\n mod as { runner: (a: typeof runnerArgs) => AsyncIterable<Buffer> }\n ).runner(runnerArgs);\n const abort = new AbortController();\n res.on(\"close\", () => abort.abort());\n await pipeWebResponse(\n res,\n annieResponse(frames, {\n signal: abort.signal,\n filename: payload.id,\n cacheControl,\n }),\n );\n return;\n }\n\n const data = await (\n mod as { runner: (a: typeof runnerArgs) => Promise<unknown> }\n ).runner(runnerArgs);\n await pipeWebResponse(\n res,\n effieResponse(data as Parameters<typeof effieResponse>[0], {\n cacheControl,\n }),\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/segments.ts","../../src/server/url-builder.ts","../../src/server/pipe-response.ts","../../src/server/listener.ts"],"sourcesContent":["import { deserialize, serialize } from \"@effing/serde\";\nimport type { Bounds } from \"../types\";\n\nexport const MAX_DIMENSION = 8192;\n\nexport type FnSegmentPayload = {\n id: string;\n props?: Record<string, unknown>;\n bounds: Bounds;\n};\n\nexport function validateBounds(raw: unknown): Bounds {\n if (raw === null || typeof raw !== \"object\" || Array.isArray(raw)) {\n throw new Error('Segment missing \"bounds\"');\n }\n const { width, height } = raw as { width?: unknown; height?: unknown };\n if (!Number.isInteger(width) || !Number.isInteger(height)) {\n throw new Error(\"Bounds must be integers\");\n }\n const w = width as number;\n const h = height as number;\n if (w <= 0 || h <= 0 || w > MAX_DIMENSION || h > MAX_DIMENSION) {\n throw new Error(`Bounds out of range (1..${MAX_DIMENSION}): ${w}x${h}`);\n }\n return { width: w, height: h };\n}\n\nexport function signFnSegment(\n payload: FnSegmentPayload,\n secretKey: string,\n): Promise<string> {\n return serialize(payload, secretKey);\n}\n\nexport type VerifyFnSegmentOptions = {\n /** When true (default), the segment must include a props object. */\n requireProps?: boolean;\n};\n\nexport async function verifyFnSegment(\n segment: string,\n secretKey: string,\n options: VerifyFnSegmentOptions = {},\n): Promise<FnSegmentPayload> {\n const { requireProps = true } = options;\n\n let payload: { id?: unknown; props?: unknown; bounds?: unknown };\n try {\n payload = await deserialize<{\n id?: unknown;\n props?: unknown;\n bounds?: unknown;\n }>(segment, secretKey);\n } catch {\n throw new Error(\"Invalid segment signature\");\n }\n\n if (typeof payload.id !== \"string\" || payload.id === \"\") {\n throw new Error('Segment missing \"id\"');\n }\n\n let props: Record<string, unknown> | undefined;\n if (requireProps) {\n if (\n payload.props === null ||\n typeof payload.props !== \"object\" ||\n Array.isArray(payload.props)\n ) {\n throw new Error('Segment missing \"props\"');\n }\n props = payload.props as Record<string, unknown>;\n } else if (\n payload.props !== undefined &&\n payload.props !== null &&\n typeof payload.props === \"object\" &&\n !Array.isArray(payload.props)\n ) {\n props = payload.props as Record<string, unknown>;\n }\n\n const bounds = validateBounds(payload.bounds);\n return { id: payload.id, props, bounds };\n}\n","import { effieWebUrl, type EffieWebUrl } from \"@effing/effie\";\nimport type { Bounds, FnKind, FnUrlBuilder } from \"../types\";\nimport { signFnSegment } from \"./segments\";\n\nexport type CreateUrlBuilderOptions = {\n secretKey: string;\n /**\n * Compose the final URL from a kind and a signed segment. Self-host typically\n * returns `${baseUrl}/${kind}/${segment}`; the cloud fn server uses a\n * tenant/project-prefixed shape.\n */\n buildUrl: (kind: FnKind, segment: string) => string;\n};\n\nexport function createUrlBuilder(\n options: CreateUrlBuilderOptions,\n): FnUrlBuilder {\n const { secretKey, buildUrl } = options;\n return {\n async buildUrl(\n kind: FnKind,\n id: string,\n props: Record<string, unknown>,\n bounds: Bounds,\n ): Promise<EffieWebUrl> {\n const segment = await signFnSegment({ id, props, bounds }, secretKey);\n return effieWebUrl(buildUrl(kind, segment));\n },\n };\n}\n\nexport type CreateFlatUrlBuilderOptions = {\n /** Base URL the fn server is reachable at — e.g. `http://localhost:3839` or `https://example.com`. */\n baseUrl: string;\n secretKey: string;\n};\n\n/** Convenience: builds `${baseUrl}/${kind}/${segment}` URLs. */\nexport function createFlatUrlBuilder(\n options: CreateFlatUrlBuilderOptions,\n): FnUrlBuilder {\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n return createUrlBuilder({\n secretKey: options.secretKey,\n buildUrl: (kind, segment) => `${baseUrl}/${kind}/${segment}`,\n });\n}\n","import type { ServerResponse } from \"node:http\";\nimport { Readable } from \"node:stream\";\n\n/**\n * Send a Web `Response` (as returned by `imageResponse`/`annieResponse`/\n * `effieResponse`) over a Node `ServerResponse`. Headers and status are\n * copied; the body is streamed if present.\n */\nexport async function pipeWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, key) => {\n res.setHeader(key, value);\n });\n\n const body = response.body;\n if (!body) {\n res.end();\n return;\n }\n\n const nodeStream = Readable.fromWeb(\n body as unknown as import(\"node:stream/web\").ReadableStream<Uint8Array>,\n );\n nodeStream.pipe(res);\n\n await new Promise<void>((resolve, reject) => {\n nodeStream.on(\"end\", () => resolve());\n nodeStream.on(\"error\", reject);\n res.on(\"close\", () => resolve());\n });\n}\n","import type {\n IncomingMessage,\n RequestListener,\n ServerResponse,\n} from \"node:http\";\nimport type { FnKind, FnModuleLoader } from \"../types\";\nimport { imageResponse, annieResponse, effieResponse } from \"../responses\";\nimport { verifyFnSegment } from \"./segments\";\nimport { pipeWebResponse } from \"./pipe-response\";\n\nexport type CreateFnHttpListenerOptions = {\n moduleLoader: FnModuleLoader;\n secretKey: string;\n /**\n * Optional Cache-Control header value for successful responses. Defaults to\n * `undefined` (the underlying response helpers apply their own default).\n */\n cacheControl?: string;\n};\n\nconst KIND_PATTERNS: { kind: FnKind; pattern: RegExp }[] = [\n { kind: \"image\", pattern: /^\\/image\\/([^/?#]+)$/ },\n { kind: \"annie\", pattern: /^\\/annie\\/([^/?#]+)$/ },\n { kind: \"effie\", pattern: /^\\/effie\\/([^/?#]+)$/ },\n];\n\nfunction matchRoute(\n url: string | undefined,\n): { kind: FnKind; segment: string } | null {\n if (!url) return null;\n const path = url.split(\"?\")[0];\n for (const { kind, pattern } of KIND_PATTERNS) {\n const m = pattern.exec(path);\n if (m) return { kind, segment: decodeURIComponent(m[1]) };\n }\n return null;\n}\n\nfunction sendJsonError(\n res: ServerResponse,\n status: number,\n error: string,\n): void {\n if (res.headersSent) {\n // Too late for an error response (an annie failed mid-stream, say).\n // Abort it, so the client sees it failed rather than a clean, truncated\n // end.\n if (!res.writableEnded) res.destroy();\n return;\n }\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"application/json\");\n res.end(JSON.stringify({ error }));\n}\n\nexport function createFnHttpListener(\n options: CreateFnHttpListenerOptions,\n): RequestListener {\n const { moduleLoader, secretKey, cacheControl } = options;\n\n return (req: IncomingMessage, res: ServerResponse) => {\n handleRequest(req, res, { moduleLoader, secretKey, cacheControl }).catch(\n (err: unknown) => {\n const message = err instanceof Error ? err.message : String(err);\n\n console.error(\"[fn-server] unhandled error:\", message);\n sendJsonError(res, 500, \"Internal error\");\n },\n );\n };\n}\n\nasync function handleRequest(\n req: IncomingMessage,\n res: ServerResponse,\n options: Required<\n Pick<CreateFnHttpListenerOptions, \"moduleLoader\" | \"secretKey\">\n > & {\n cacheControl?: string;\n },\n): Promise<void> {\n const { moduleLoader, secretKey, cacheControl } = options;\n\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\n res.statusCode = 405;\n res.setHeader(\"Allow\", \"GET, HEAD\");\n res.end();\n return;\n }\n\n const matched = matchRoute(req.url);\n if (!matched) {\n sendJsonError(res, 404, \"Not found\");\n return;\n }\n\n let payload;\n try {\n payload = await verifyFnSegment(matched.segment, secretKey);\n } catch (err) {\n const message = err instanceof Error ? err.message : \"Invalid segment\";\n sendJsonError(res, 400, message);\n return;\n }\n\n if (!moduleLoader.hasModule(matched.kind, payload.id)) {\n sendJsonError(res, 404, `No ${matched.kind} found for id '${payload.id}'`);\n return;\n }\n\n const mod = await moduleLoader.loadModule(matched.kind, payload.id);\n let parsedProps: unknown = payload.props ?? {};\n if (mod.propsSchema) {\n try {\n parsedProps = mod.propsSchema.parse(parsedProps);\n } catch (err) {\n const message = err instanceof Error ? err.message : \"Invalid props\";\n sendJsonError(res, 400, message);\n return;\n }\n }\n\n const runnerArgs = { props: parsedProps, bounds: payload.bounds };\n\n if (matched.kind === \"image\") {\n const bytes = await (\n mod as { runner: (a: typeof runnerArgs) => Promise<Buffer> }\n ).runner(runnerArgs);\n await pipeWebResponse(res, imageResponse(bytes, { cacheControl }));\n return;\n }\n\n if (matched.kind === \"annie\") {\n const frames = (\n mod as { runner: (a: typeof runnerArgs) => AsyncIterable<Buffer> }\n ).runner(runnerArgs);\n const abort = new AbortController();\n res.on(\"close\", () => abort.abort());\n await pipeWebResponse(\n res,\n annieResponse(frames, {\n signal: abort.signal,\n filename: payload.id,\n cacheControl,\n }),\n );\n return;\n }\n\n const data = await (\n mod as { runner: (a: typeof runnerArgs) => Promise<unknown> }\n ).runner(runnerArgs);\n await pipeWebResponse(\n res,\n effieResponse(data as Parameters<typeof effieResponse>[0], {\n cacheControl,\n }),\n );\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effing/fn",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "description": "Effing function runtime — pluggable module loading and URL building",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,9 +22,9 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "@effing/annie": "0.44.0",
26
- "@effing/effie": "0.44.0",
27
- "@effing/serde": "0.44.0"
25
+ "@effing/annie": "0.45.1",
26
+ "@effing/effie": "0.45.1",
27
+ "@effing/serde": "0.45.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.0.0",