From 24710836a2ed177bf4e3fa3278e3b33f65e2ec87 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 30 Jul 2026 08:56:27 +0200 Subject: [PATCH 01/77] feat(event-handler): HTTP response streaming on Lambda and self-hosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds incremental HTTP response delivery to Webiny, plus a File Manager "Re-enrich with AI" action that exercises it end to end. Core (@webiny/event-handler-core) Introduces `HttpStreamBody`, an explicit marker a route wraps its source in to opt into streaming. Additive: `IHttpResponse.body` was already `any`, so existing routes are untouched. Transports that can stream write chunks as produced; those that cannot call `collect()`. Self-hosted (@webiny/event-handler-server) Streams via `res.flushHeaders()` + per-chunk writes, honouring back-pressure and client disconnects. Also fixes a latent bug the streaming path made reachable: the error handler called `writeHead(500)` unconditionally, which throws ERR_HTTP_HEADERS_SENT once headers are out and masks the real error. AWS (@webiny/event-handler-aws) API Gateway cannot stream — it buffers the whole Lambda response regardless of how it was produced — so streaming requires a Lambda Function URL with `InvokeMode: RESPONSE_STREAM`, and a Lambda's handler entry is fixed per function. Hence a second function off the same bundle (`handler.streamHandler`) with its own transport: Function URL event type, translator, terminal handler writing to the response stream, and `createStreamLambdaHandler`. `streamifyResponse` is applied eagerly, because the runtime inspects the exported handler for the mark it attaches; a lazy wrap would silently fall back to buffered responses. The existing API Gateway translator now drains a streaming body instead of failing, so a streaming route still works over that transport as one buffered response. Auth Adds `x-webiny-authorization`, read ahead of `Authorization` by both the AWS and self-hosted extractors. Behind CloudFront with Origin Access Control, SigV4 occupies `Authorization`, so a viewer bearer token cannot survive to the origin. Using a separate header keeps the Function URL private (AWS_IAM + OAC) without depending on that interaction. Infra (@webiny/project-aws) `ApiGraphqlStream` (Lambda + Function URL, reusing the graphql IAM role, 300s timeout), a CloudFront OAC, a `/stream/*` cache behavior ordered first with `compress: false` — compression buffers chunks and defeats incremental delivery — and an invoke permission scoped to the distribution ARN. File Manager Extracts the AI enrichment logic out of `AiImageEnrichmentTask` into `Prepare`/`Apply` use cases shared with a new SSE route, so the task and the route cannot drift. The route resolves everything it can before opening the stream, so missing file / non-image / no provider / license come back as real status codes rather than buried in a 200. Frontend adds `ApiStreamClient` and a generic `readServerSentEvents` reader, with auth and tenant decorators mirroring the GraphQL client. Not yet verified against a real deployment: whether CloudFront passes chunks through unbuffered, and that `handler.streamHandler` resolves in the built bundle. Co-Authored-By: Claude Opus 5 (1M context) --- .../AiImageEnrichmentStreamRoute.test.ts | 246 +++++++++++++ packages/ai-powerups/package.json | 2 + .../AiImageEnrichmentStreamRoute.ts | 179 +++++++++ .../AiImageEnrichmentTask.ts | 150 ++------ .../ApplyImageEnrichmentUseCase.ts | 57 +++ .../PrepareImageEnrichmentUseCase.ts | 74 ++++ .../AiImageEnrichment/abstractions.ts | 76 ++++ .../api/features/AiImageEnrichment/errors.ts | 51 +++ .../api/features/AiImageEnrichment/feature.ts | 10 + .../AiImageEnrichment/streamEvents.ts | 43 +++ packages/ai-powerups/tsconfig.build.json | 3 + packages/ai-powerups/tsconfig.json | 3 + .../src/createWebinyApiHandler.ts | 34 +- .../api-event-handler-aws-ddb-os/src/index.ts | 1 + .../src/createWebinyApiHandler.ts | 31 +- .../api-event-handler-aws-ddb/src/index.ts | 1 + .../src/createWebinyApiHandler.ts | 101 +---- .../src/createWebinyStreamApiHandler.ts | 56 +++ .../ApiGatewayIdentityLoaderDecorator.ts | 35 +- ...unctionUrlStreamIdentityLoaderDecorator.ts | 37 ++ .../FunctionUrlStreamTenantLoaderDecorator.ts | 38 ++ .../src/handlers/extractRequestAuth.ts | 74 ++++ .../src/handlers/index.ts | 3 + packages/api-event-handler-aws/src/index.ts | 3 + .../src/registerWebinyApi.ts | 98 +++++ .../NodeHttpIdentityLoaderDecorator.ts | 20 +- .../app-admin/src/base/createRootContainer.ts | 5 + .../ApiStreamClientDecorator.ts | 40 ++ .../security/AuthenticationContext/feature.ts | 2 + .../tenancy/ApiStreamClientDecorator.ts | 29 ++ .../app-admin/src/features/tenancy/feature.ts | 2 + .../AiEnrichment/ReenrichFileGateway.ts | 25 ++ .../modules/AiEnrichment/ReenrichWithAi.tsx | 128 +++++++ .../src/modules/AiEnrichment/abstractions.ts | 57 +++ .../src/modules/AiEnrichment/feature.ts | 8 + .../src/presentation/FileActions/index.tsx | 2 + packages/app/src/exports/admin.ts | 5 + .../apiStreamClient/FetchApiStreamClient.ts | 94 +++++ .../__tests__/FetchApiStreamClient.test.ts | 158 ++++++++ .../__tests__/readServerSentEvents.test.ts | 128 +++++++ .../features/apiStreamClient/abstractions.ts | 43 +++ .../src/features/apiStreamClient/feature.ts | 15 + .../app/src/features/apiStreamClient/index.ts | 4 + .../apiStreamClient/readServerSentEvents.ts | 52 +++ .../functionUrlEventToHttpRequest.test.ts | 136 +++++++ .../__tests__/functionUrlStreaming.test.ts | 348 ++++++++++++++++++ .../httpResponseToApiGatewayResult.test.ts | 150 ++++++++ .../src/AwsLambdaStreamTransport.ts | 33 ++ .../src/abstractions/LambdaResponseStream.ts | 24 ++ .../handlers/FunctionUrlStreamEventHandler.ts | 20 + .../src/abstractions/handlers/index.ts | 1 + .../src/abstractions/index.ts | 1 + .../src/createStreamLambdaHandler.ts | 54 +++ .../eventTypes/FunctionUrlStreamEventType.ts | 27 ++ .../event-handler-aws/src/eventTypes/index.ts | 1 + .../src/features/FunctionUrlStreamFeature.ts | 27 ++ .../FunctionUrlStreamRouterHandler.ts | 128 +++++++ .../event-handler-aws/src/handlers/index.ts | 1 + packages/event-handler-aws/src/index.ts | 9 + .../src/streaming/awslambda.ts | 54 +++ .../functionUrlEventToHttpRequest.ts | 86 +++++ .../httpResponseToApiGatewayResult.ts | 37 +- .../__tests__/HttpStreamBody.test.ts | 146 ++++++++ .../src/features/http/HttpStreamBody.ts | 78 ++++ .../src/features/http/index.ts | 1 + .../__tests__/streaming.test.ts | 221 +++++++++++ .../src/createServerHandler.ts | 32 +- .../appTemplates/api/graphql/src/index.ts | 13 +- .../OpenSearch/api/graphql/src/index.ts | 10 +- .../src/pulumi/apps/api/ApiCloudfront.ts | 88 ++++- .../src/pulumi/apps/api/ApiGraphqlStream.ts | 92 +++++ .../src/pulumi/apps/api/createApiPulumiApp.ts | 55 +-- .../project-aws/src/pulumi/apps/api/index.ts | 1 + yarn.lock | 2 + 74 files changed, 3829 insertions(+), 270 deletions(-) create mode 100644 packages/ai-powerups/__tests__/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.test.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/ApplyImageEnrichmentUseCase.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/PrepareImageEnrichmentUseCase.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/abstractions.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/errors.ts create mode 100644 packages/ai-powerups/src/api/features/AiImageEnrichment/streamEvents.ts create mode 100644 packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts create mode 100644 packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts create mode 100644 packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts create mode 100644 packages/api-event-handler-aws/src/handlers/extractRequestAuth.ts create mode 100644 packages/api-event-handler-aws/src/registerWebinyApi.ts create mode 100644 packages/app-admin/src/features/security/AuthenticationContext/ApiStreamClientDecorator.ts create mode 100644 packages/app-admin/src/features/tenancy/ApiStreamClientDecorator.ts create mode 100644 packages/app-file-manager/src/modules/AiEnrichment/ReenrichFileGateway.ts create mode 100644 packages/app-file-manager/src/modules/AiEnrichment/ReenrichWithAi.tsx create mode 100644 packages/app-file-manager/src/modules/AiEnrichment/abstractions.ts create mode 100644 packages/app/src/features/apiStreamClient/FetchApiStreamClient.ts create mode 100644 packages/app/src/features/apiStreamClient/__tests__/FetchApiStreamClient.test.ts create mode 100644 packages/app/src/features/apiStreamClient/__tests__/readServerSentEvents.test.ts create mode 100644 packages/app/src/features/apiStreamClient/abstractions.ts create mode 100644 packages/app/src/features/apiStreamClient/feature.ts create mode 100644 packages/app/src/features/apiStreamClient/index.ts create mode 100644 packages/app/src/features/apiStreamClient/readServerSentEvents.ts create mode 100644 packages/event-handler-aws/__tests__/functionUrlEventToHttpRequest.test.ts create mode 100644 packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts create mode 100644 packages/event-handler-aws/__tests__/httpResponseToApiGatewayResult.test.ts create mode 100644 packages/event-handler-aws/src/AwsLambdaStreamTransport.ts create mode 100644 packages/event-handler-aws/src/abstractions/LambdaResponseStream.ts create mode 100644 packages/event-handler-aws/src/abstractions/handlers/FunctionUrlStreamEventHandler.ts create mode 100644 packages/event-handler-aws/src/createStreamLambdaHandler.ts create mode 100644 packages/event-handler-aws/src/eventTypes/FunctionUrlStreamEventType.ts create mode 100644 packages/event-handler-aws/src/features/FunctionUrlStreamFeature.ts create mode 100644 packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts create mode 100644 packages/event-handler-aws/src/streaming/awslambda.ts create mode 100644 packages/event-handler-aws/src/translators/functionUrlEventToHttpRequest.ts create mode 100644 packages/event-handler-core/__tests__/HttpStreamBody.test.ts create mode 100644 packages/event-handler-core/src/features/http/HttpStreamBody.ts create mode 100644 packages/event-handler-server/__tests__/streaming.test.ts create mode 100644 packages/project-aws/src/pulumi/apps/api/ApiGraphqlStream.ts diff --git a/packages/ai-powerups/__tests__/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.test.ts b/packages/ai-powerups/__tests__/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.test.ts new file mode 100644 index 00000000000..367119e40a6 --- /dev/null +++ b/packages/ai-powerups/__tests__/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Container } from "@webiny/di"; +import { Result } from "@webiny/feature/api"; +import { HttpRoute, HttpStreamBody, RequestContainer } from "@webiny/event-handler-core"; +import type { IHttpRequest, IHttpResponse } from "@webiny/event-handler-core"; +import { Ai } from "@webiny/api-core/features/ai/index.js"; +import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { AiImageEnrichmentStreamRoute } from "~/api/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.js"; +import { + ApplyImageEnrichmentUseCase, + PrepareImageEnrichmentUseCase +} from "~/api/features/AiImageEnrichment/abstractions.js"; +import type { IPreparedImageEnrichment } from "~/api/features/AiImageEnrichment/abstractions.js"; +import { + EnrichmentFileNotFoundError, + EnrichmentNoProviderError, + EnrichmentNotAnImageError, + EnrichmentPersistError +} from "~/api/features/AiImageEnrichment/errors.js"; + +const prepared: IPreparedImageEnrichment = { + fileId: "file-1", + existingTags: ["existing"], + imageBase64: "aGVsbG8=", + imageMediaType: "image/png", + model: "anthropic/claude-sonnet-4-5", + connection: { sdkName: "anthropic", apiKey: "key" } +}; + +function request(fileId = "file-1"): IHttpRequest { + return { + method: "POST", + path: `/stream/fm/files/${fileId}/enrich`, + headers: {}, + query: {}, + pathParameters: { fileId }, + body: undefined + }; +} + +async function* partials(values: any[]) { + for (const value of values) { + yield value; + } +} + +/** Drain a streaming response into the parsed SSE event objects it emitted. */ +async function collectEvents(response: IHttpResponse) { + expect(HttpStreamBody.is(response.body)).toBe(true); + const text = new TextDecoder().decode(await (response.body as HttpStreamBody).collect()); + + return text + .split("\n\n") + .filter(Boolean) + .map(record => JSON.parse(record.replace(/^data: /, ""))); +} + +describe("AiImageEnrichmentStreamRoute", () => { + let container: Container; + let route: HttpRoute.Interface; + let prepare: { execute: ReturnType }; + let apply: { execute: ReturnType }; + let ai: { streamText: ReturnType }; + let canUse: boolean; + + beforeEach(() => { + canUse = true; + prepare = { execute: vi.fn().mockResolvedValue(Result.ok(prepared)) }; + apply = { + execute: vi.fn().mockImplementation(async (params: any) => + Result.ok({ + fileId: params.fileId, + tags: [...new Set([...params.existingTags, ...params.tags])], + description: params.description + }) + ) + }; + ai = { + streamText: vi.fn().mockResolvedValue({ + partialOutputStream: partials([ + { tags: ["cat"] }, + { tags: ["cat", "sofa"], description: "A cat" }, + { tags: ["cat", "sofa"], description: "A cat on a sofa." } + ]) + }) + }; + + container = new Container(); + container.registerInstance(RequestContainer, container); + container.registerInstance(WcpContext, { + canUseAiImageEnrichment: () => canUse + } as any); + container.registerInstance(PrepareImageEnrichmentUseCase, prepare as any); + container.registerInstance(ApplyImageEnrichmentUseCase, apply as any); + container.registerInstance(Ai, ai as any); + container.register(AiImageEnrichmentStreamRoute); + + route = container.resolveAll(HttpRoute)[0]; + }); + + it("should be a POST route with a file-scoped path", () => { + expect(route.method).toBe("POST"); + expect(route.path).toBe("/stream/fm/files/:fileId/enrich"); + }); + + it("should respond with SSE headers that defeat proxy buffering", async () => { + const response = await route.handle(request()); + + expect(response.statusCode).toBe(200); + expect(response.headers?.["content-type"]).toBe("text/event-stream"); + // `no-transform` is what stops CloudFront compressing (and thus buffering) the body. + expect(response.headers?.["cache-control"]).toContain("no-transform"); + expect(response.headers?.["x-accel-buffering"]).toBe("no"); + }); + + it("should stream start, each partial, and done", async () => { + const events = await collectEvents(await route.handle(request())); + + expect(events[0]).toEqual({ + type: "start", + fileId: "file-1", + model: "anthropic/claude-sonnet-4-5" + }); + expect(events.slice(1, 4)).toEqual([ + { type: "partial", tags: ["cat"], description: "" }, + { type: "partial", tags: ["cat", "sofa"], description: "A cat" }, + { type: "partial", tags: ["cat", "sofa"], description: "A cat on a sofa." } + ]); + expect(events[4]).toEqual({ + type: "done", + fileId: "file-1", + tags: ["existing", "cat", "sofa"], + description: "A cat on a sofa." + }); + }); + + it("should persist the final output merged with the file's existing tags", async () => { + await collectEvents(await route.handle(request())); + + expect(apply.execute).toHaveBeenCalledWith({ + fileId: "file-1", + existingTags: ["existing"], + tags: ["cat", "sofa"], + description: "A cat on a sofa." + }); + }); + + it("should tolerate holes in a partial tag array", async () => { + ai.streamText.mockResolvedValue({ + partialOutputStream: partials([{ tags: ["cat", undefined] }, { tags: ["cat", "sofa"] }]) + }); + + const events = await collectEvents(await route.handle(request())); + + expect(events[1]).toEqual({ type: "partial", tags: ["cat"], description: "" }); + }); + + it("should not start the AI call until preparation succeeded", async () => { + prepare.execute.mockResolvedValue(Result.fail(new EnrichmentFileNotFoundError("nope"))); + + await route.handle(request("nope")); + + expect(ai.streamText).not.toHaveBeenCalled(); + }); + + describe("failures detectable before the stream opens", () => { + it("should answer 404 for a missing file", async () => { + prepare.execute.mockResolvedValue(Result.fail(new EnrichmentFileNotFoundError("nope"))); + + const response = await route.handle(request("nope")); + + expect(response.statusCode).toBe(404); + expect(HttpStreamBody.is(response.body)).toBe(false); + expect(response.body.code).toBe("ENRICHMENT_FILE_NOT_FOUND"); + }); + + it("should answer 400 for a non-image", async () => { + prepare.execute.mockResolvedValue( + Result.fail(new EnrichmentNotAnImageError("application/pdf")) + ); + + const response = await route.handle(request()); + + expect(response.statusCode).toBe(400); + expect(response.body.code).toBe("ENRICHMENT_NOT_AN_IMAGE"); + }); + + it("should answer 500 when no AI provider is configured", async () => { + prepare.execute.mockResolvedValue(Result.fail(new EnrichmentNoProviderError())); + + const response = await route.handle(request()); + + expect(response.statusCode).toBe(500); + expect(response.body.code).toBe("ENRICHMENT_NO_AI_PROVIDER"); + }); + + it("should answer 403 when the license does not allow enrichment", async () => { + canUse = false; + + const response = await route.handle(request()); + + expect(response.statusCode).toBe(403); + expect(prepare.execute).not.toHaveBeenCalled(); + }); + + it("should answer 400 when no file ID was matched", async () => { + const response = await route.handle({ ...request(), pathParameters: {} }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("failures after the stream opened", () => { + it("should emit an error event when the AI call throws", async () => { + ai.streamText.mockRejectedValue(new Error("rate limited")); + + const events = await collectEvents(await route.handle(request())); + + expect(events[0].type).toBe("start"); + expect(events[1]).toEqual({ + type: "error", + message: "AI enrichment failed: rate limited" + }); + expect(apply.execute).not.toHaveBeenCalled(); + }); + + it("should emit an error event when persisting fails", async () => { + apply.execute.mockResolvedValue(Result.fail(new EnrichmentPersistError("no access"))); + + const events = await collectEvents(await route.handle(request())); + + expect(events[events.length - 1]).toEqual({ + type: "error", + message: "Failed to update file: no access" + }); + }); + + it("should emit no done event after an error", async () => { + ai.streamText.mockRejectedValue(new Error("boom")); + + const events = await collectEvents(await route.handle(request())); + + expect(events.some(e => e.type === "done")).toBe(false); + }); + }); +}); diff --git a/packages/ai-powerups/package.json b/packages/ai-powerups/package.json index e7f996c056c..aa859b7fdb4 100644 --- a/packages/ai-powerups/package.json +++ b/packages/ai-powerups/package.json @@ -29,6 +29,8 @@ "@webiny/app-website-builder": "0.0.0", "@webiny/app-websockets": "0.0.0", "@webiny/background-tasks": "0.0.0", + "@webiny/di": "^1.0.2", + "@webiny/event-handler-core": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/icons": "0.0.0", "@webiny/project": "0.0.0", diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.ts new file mode 100644 index 00000000000..48e6c62a04c --- /dev/null +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentStreamRoute.ts @@ -0,0 +1,179 @@ +import { Output } from "ai"; +import type { Container } from "@webiny/di"; +import { HttpRoute, HttpStreamBody, RequestContainer } from "@webiny/event-handler-core"; +import type { IHttpRequest, IHttpResponse } from "@webiny/event-handler-core"; +import { Ai } from "@webiny/api-core/features/ai/index.js"; +import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { + AI_ENRICHMENT_PROMPT, + aiEnrichmentSchema, + ApplyImageEnrichmentUseCase, + PrepareImageEnrichmentUseCase +} from "./abstractions.js"; +import type { IPreparedImageEnrichment } from "./abstractions.js"; +import { EnrichmentFileNotFoundError, EnrichmentNotAnImageError } from "./errors.js"; +import type { ImageEnrichmentError } from "./errors.js"; +import { toSseFrame } from "./streamEvents.js"; + +const SSE_HEADERS = { + "content-type": "text/event-stream", + // `no-transform` matters as much as `no-cache`: it tells CloudFront (and any other proxy) not to + // compress or otherwise buffer the body, which would defeat incremental delivery. + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + // Nginx-family proxies buffer responses by default; this opts out. Harmless elsewhere. + "x-accel-buffering": "no" +}; + +const JSON_HEADERS = { "content-type": "application/json" }; + +function errorStatusCode(error: ImageEnrichmentError): number { + if (error instanceof EnrichmentFileNotFoundError) { + return 404; + } + if (error instanceof EnrichmentNotAnImageError) { + return 400; + } + return 500; +} + +/** + * Re-runs AI enrichment for a single file, streaming progress to the caller as server-sent events. + * + * Preparation (file lookup, image read, provider resolution) happens BEFORE the response opens, so + * anything knowable up front — missing file, wrong type, no provider — comes back as a real status + * code. Only the AI call itself streams; once the first byte is out the status is committed to 200 + * and failures can only be reported as an `error` event. + */ +class AiImageEnrichmentStreamRouteImpl implements HttpRoute.Interface { + readonly method = "POST"; + readonly path = "/stream/fm/files/:fileId/enrich"; + + // Collaborators are resolved lazily in handle(), not injected: HttpRouter constructs EVERY + // registered route on every request just to path-match, and the file-manager use cases pull in + // repositories that aren't registered outside a file-manager request. See the TODO in HttpRouter. + constructor(private container: Container) {} + + async handle(request: IHttpRequest): Promise { + const fileId = request.pathParameters.fileId; + + if (!fileId) { + return { + statusCode: 400, + headers: JSON_HEADERS, + body: { message: "Missing file ID." } + }; + } + + // Same gate as the after-create handler, and for the same reason it lives at request time: + // the WCP license is loaded per request, so a registration-time check always reads + // NullLicense. + const wcp = this.container.resolve(WcpContext); + if (!wcp.canUseAiImageEnrichment()) { + return { + statusCode: 403, + headers: JSON_HEADERS, + body: { message: "AI image enrichment is not available on this license." } + }; + } + + const prepare = this.container.resolve(PrepareImageEnrichmentUseCase); + const preparedResult = await prepare.execute(fileId); + + if (preparedResult.isFail()) { + const error = preparedResult.error; + return { + statusCode: errorStatusCode(error), + headers: JSON_HEADERS, + body: { message: error.message, code: error.code } + }; + } + + const ai = this.container.resolve(Ai); + const apply = this.container.resolve(ApplyImageEnrichmentUseCase); + + return { + statusCode: 200, + headers: SSE_HEADERS, + body: new HttpStreamBody(this.enrich(preparedResult.value, ai, apply)) + }; + } + + private async *enrich( + prepared: IPreparedImageEnrichment, + ai: Ai.Interface, + apply: ApplyImageEnrichmentUseCase.Interface + ): AsyncGenerator { + yield toSseFrame({ type: "start", fileId: prepared.fileId, model: prepared.model }); + + let tags: string[] = []; + let description = ""; + + try { + const stream = await ai.streamText({ + model: prepared.model, + output: Output.object({ schema: aiEnrichmentSchema }), + connection: prepared.connection, + messages: [ + { + role: "user", + content: [ + { + type: "file", + data: prepared.imageBase64, + mediaType: prepared.imageMediaType + }, + { + type: "text", + text: AI_ENRICHMENT_PROMPT + } + ] + } + ] + }); + + for await (const partial of stream.partialOutputStream) { + // Partial output is exactly that — mid-stream the array can hold holes/undefined + // entries, so filter to the strings that have actually arrived. + tags = (partial?.tags ?? []).filter( + (tag: unknown): tag is string => typeof tag === "string" + ); + description = partial?.description ?? ""; + yield toSseFrame({ type: "partial", tags, description }); + } + } catch (error) { + yield toSseFrame({ + type: "error", + message: `AI enrichment failed: ${ + error instanceof Error ? error.message : String(error) + }` + }); + return; + } + + const appliedResult = await apply.execute({ + fileId: prepared.fileId, + existingTags: prepared.existingTags, + tags, + description + }); + + if (appliedResult.isFail()) { + yield toSseFrame({ type: "error", message: appliedResult.error.message }); + return; + } + + const applied = appliedResult.value; + yield toSseFrame({ + type: "done", + fileId: applied.fileId, + tags: applied.tags, + description: applied.description + }); + } +} + +export const AiImageEnrichmentStreamRoute = HttpRoute.createImplementation({ + implementation: AiImageEnrichmentStreamRouteImpl, + dependencies: [RequestContainer] +}); diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentTask.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentTask.ts index e5dd62c832d..b5f73217c14 100644 --- a/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentTask.ts +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/AiImageEnrichmentTask.ts @@ -1,31 +1,25 @@ import { Output } from "ai"; -import { z } from "zod"; import { TaskDefinition } from "@webiny/api-core/features/task/TaskDefinition/index.js"; import { Ai } from "@webiny/api-core/features/ai/index.js"; -import { Encryption } from "@webiny/api-core/features/encryption/index.js"; -import { GetFileUseCase } from "@webiny/api-file-manager/features/file/GetFile/index.js"; -import { GetFileContentsByIdUseCase } from "@webiny/api-file-manager/features/file/GetFileContentsById/abstractions.js"; -import { UpdateFileUseCase } from "@webiny/api-file-manager/features/file/UpdateFile/index.js"; -import { WebsocketsSendToIdentityUseCase } from "@webiny/api-websockets/features/SendToIdentity/abstractions.js"; -import { IdentityContext } from "@webiny/api-core/exports/api/security.js"; -import { GetSettingsUseCase } from "~/api/features/GetSettings/index.js"; +import { + AI_ENRICHMENT_PROMPT, + aiEnrichmentSchema, + ApplyImageEnrichmentUseCase, + PrepareImageEnrichmentUseCase +} from "./abstractions.js"; +import { EnrichmentNotAnImageError } from "./errors.js"; export const AI_IMAGE_ENRICHMENT_TASK_ID = "fmAiImageEnrichment"; -const AI_PROMPT = - "Analyze this image and return up to 5 lowercase descriptive tags and one short sentence describing the image."; - -const aiOutputSchema = Output.object({ - schema: z.object({ - tags: z.array(z.string()), - description: z.string() - }) -}); - export interface IAiImageEnrichmentTaskInput { fileId: string; } +/** + * Background enrichment, triggered after a file is created. Shares its preparation and persistence + * with the streaming HTTP route (`AiImageEnrichmentStreamRoute`); the only difference is that this + * one waits for the whole AI response, because a background task has no one to stream to. + */ class AiImageEnrichmentTaskImpl implements TaskDefinition.Interface { id = AI_IMAGE_ENRICHMENT_TASK_ID; title = "File Manager - AI Image Enrichment"; @@ -37,14 +31,9 @@ class AiImageEnrichmentTaskImpl implements TaskDefinition.Interface> { + const tags = [...new Set([...params.existingTags, ...params.tags])]; + const { description, fileId } = params; + + const updateResult = await this.updateFile.execute({ + id: fileId, + tags, + description + }); + + if (updateResult.isFail()) { + return Result.fail(new EnrichmentPersistError(updateResult.error.message)); + } + + // Push to the identity's open sockets even when the caller is streaming: the browser tab that + // triggered this isn't necessarily the only one showing the file, and the client-side list + // cache updates off this message (see AiImageEnrichmentEventHandler in @webiny/app-file-manager). + const identity = this.identityContext.getIdentity(); + await this.sendToIdentity.execute( + { id: identity.id }, + { + action: FILE_ENRICHMENT_WEBSOCKET_ACTION, + data: { id: fileId, tags, description } + } + ); + + return Result.ok({ fileId, tags, description }); + } +} + +export const ApplyImageEnrichmentUseCase = UseCaseAbstraction.createImplementation({ + implementation: ApplyImageEnrichmentUseCaseImpl, + dependencies: [UpdateFileUseCase, IdentityContext, WebsocketsSendToIdentityUseCase] +}); diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/PrepareImageEnrichmentUseCase.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/PrepareImageEnrichmentUseCase.ts new file mode 100644 index 00000000000..68fe6853e70 --- /dev/null +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/PrepareImageEnrichmentUseCase.ts @@ -0,0 +1,74 @@ +import { Result } from "@webiny/feature/api"; +import { Encryption } from "@webiny/api-core/features/encryption/index.js"; +import { GetFileUseCase } from "@webiny/api-file-manager/features/file/GetFile/index.js"; +import { GetFileContentsByIdUseCase } from "@webiny/api-file-manager/features/file/GetFileContentsById/abstractions.js"; +import { + PrepareImageEnrichmentUseCase as UseCaseAbstraction, + type IPreparedImageEnrichment +} from "./abstractions.js"; +import { GetSettingsUseCase } from "~/api/features/GetSettings/index.js"; +import { + EnrichmentFileContentsError, + EnrichmentFileNotFoundError, + EnrichmentNoProviderError, + EnrichmentNotAnImageError +} from "./errors.js"; +import type { ImageEnrichmentError } from "./errors.js"; + +class PrepareImageEnrichmentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getFile: GetFileUseCase.Interface, + private getFileContents: GetFileContentsByIdUseCase.Interface, + private getSettings: GetSettingsUseCase.Interface, + private encryption: Encryption.Interface + ) {} + + async execute(fileId: string): Promise> { + const fileResult = await this.getFile.execute(fileId); + if (fileResult.isFail()) { + return Result.fail(new EnrichmentFileNotFoundError(fileId)); + } + + const file = fileResult.value; + + if (!file.type.startsWith("image/")) { + return Result.fail(new EnrichmentNotAnImageError(file.type)); + } + + // Read the image bytes and send them to the AI as base64, NOT a URL. A URL forces the + // provider to fetch the file — which fails for private/access-controlled files, leaks the + // domain, and can't reach a non-public origin (e.g. local dev). base64 is a portable standard + // that works on every setup. (Also resolves the AI-SDK "image part" deprecation.) + const contentsResult = await this.getFileContents.execute(fileId); + if (contentsResult.isFail()) { + return Result.fail(new EnrichmentFileContentsError(contentsResult.error.message)); + } + + const aiSettingsResult = await this.getSettings.execute(); + if (aiSettingsResult.isFail()) { + return Result.fail(new EnrichmentNoProviderError()); + } + + const firstProvider = aiSettingsResult.value.providers.presets[0]; + if (!firstProvider) { + return Result.fail(new EnrichmentNoProviderError()); + } + + return Result.ok({ + fileId: file.id, + existingTags: file.tags, + imageBase64: contentsResult.value.buffer.toString("base64"), + imageMediaType: contentsResult.value.contentType, + model: firstProvider.model, + connection: { + sdkName: firstProvider.model.split("/")[0], + apiKey: await this.encryption.decrypt(firstProvider.apiKeyEncrypted) + } + }); + } +} + +export const PrepareImageEnrichmentUseCase = UseCaseAbstraction.createImplementation({ + implementation: PrepareImageEnrichmentUseCaseImpl, + dependencies: [GetFileUseCase, GetFileContentsByIdUseCase, GetSettingsUseCase, Encryption] +}); diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/abstractions.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/abstractions.ts new file mode 100644 index 00000000000..eb3ef1fe4f5 --- /dev/null +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/abstractions.ts @@ -0,0 +1,76 @@ +import { createAbstraction, Result } from "@webiny/feature/api"; +import { z } from "zod"; +import type { ImageEnrichmentError } from "./errors.js"; + +export const AI_ENRICHMENT_PROMPT = + "Analyze this image and return up to 5 lowercase descriptive tags and one short sentence describing the image."; + +/** + * The shape both entry points ask the model for. Exported as the bare zod schema rather than a + * ready-made `Output.object(...)`: the AI SDK's `Output` type can't be named in emitted declarations + * (TS4023), so each call site wraps this locally instead. + */ +export const aiEnrichmentSchema = z.object({ + tags: z.array(z.string()), + description: z.string() +}); + +/** + * Everything needed to run the AI call, gathered up front: the image bytes, the resolved provider, + * and the file's current tags (needed to merge rather than overwrite). + * + * Deliberately split from the AI call itself so both entry points share the same preparation and the + * same failure modes — and so the streaming route can answer with a real HTTP status code for + * anything knowable before the stream opens, instead of burying it in a 200 response. + */ +export interface IPreparedImageEnrichment { + fileId: string; + existingTags: string[]; + imageBase64: string; + imageMediaType: string; + model: string; + connection: { + sdkName: string; + apiKey: string; + }; +} + +export interface IPrepareImageEnrichmentUseCase { + execute(fileId: string): Promise>; +} + +export const PrepareImageEnrichmentUseCase = createAbstraction( + "PrepareImageEnrichmentUseCase" +); + +export namespace PrepareImageEnrichmentUseCase { + export type Interface = IPrepareImageEnrichmentUseCase; +} + +export interface IApplyImageEnrichmentParams { + fileId: string; + /** The file's tags before enrichment; AI tags are merged into these, never replacing them. */ + existingTags: string[]; + tags: string[]; + description: string; +} + +export interface IAppliedImageEnrichment { + fileId: string; + tags: string[]; + description: string; +} + +export interface IApplyImageEnrichmentUseCase { + execute( + params: IApplyImageEnrichmentParams + ): Promise>; +} + +export const ApplyImageEnrichmentUseCase = createAbstraction( + "ApplyImageEnrichmentUseCase" +); + +export namespace ApplyImageEnrichmentUseCase { + export type Interface = IApplyImageEnrichmentUseCase; +} diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/errors.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/errors.ts new file mode 100644 index 00000000000..986a7bdda00 --- /dev/null +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/errors.ts @@ -0,0 +1,51 @@ +/** + * Errors shared by every image-enrichment entry point (the background task and the streaming HTTP + * route). Each carries a `code` so callers can map it to their own transport: the task turns them + * into task results, the route into HTTP status codes. + */ +export class EnrichmentFileNotFoundError extends Error { + readonly code = "ENRICHMENT_FILE_NOT_FOUND" as const; + + constructor(fileId: string) { + super(`File not found: ${fileId}`); + } +} + +export class EnrichmentNotAnImageError extends Error { + readonly code = "ENRICHMENT_NOT_AN_IMAGE" as const; + + constructor(type: string) { + super(`File is not an image (received "${type}"); skipping AI enrichment.`); + } +} + +export class EnrichmentFileContentsError extends Error { + readonly code = "ENRICHMENT_FILE_CONTENTS_UNREADABLE" as const; + + constructor(reason: string) { + super(`Unable to read file contents: ${reason}`); + } +} + +export class EnrichmentNoProviderError extends Error { + readonly code = "ENRICHMENT_NO_AI_PROVIDER" as const; + + constructor() { + super("No AI provider configured. Add a provider in AI Power Ups settings."); + } +} + +export class EnrichmentPersistError extends Error { + readonly code = "ENRICHMENT_PERSIST_FAILED" as const; + + constructor(reason: string) { + super(`Failed to update file: ${reason}`); + } +} + +export type ImageEnrichmentError = + | EnrichmentFileNotFoundError + | EnrichmentNotAnImageError + | EnrichmentFileContentsError + | EnrichmentNoProviderError + | EnrichmentPersistError; diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/feature.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/feature.ts index 8009add8314..6ed8e5d4185 100644 --- a/packages/ai-powerups/src/api/features/AiImageEnrichment/feature.ts +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/feature.ts @@ -1,11 +1,21 @@ import { createFeature } from "@webiny/feature/api"; import { AiImageEnrichmentAfterCreateHandler } from "./AiImageEnrichmentAfterCreateHandler.js"; import { AiImageEnrichmentTask } from "./AiImageEnrichmentTask.js"; +import { AiImageEnrichmentStreamRoute } from "./AiImageEnrichmentStreamRoute.js"; +import { PrepareImageEnrichmentUseCase } from "./PrepareImageEnrichmentUseCase.js"; +import { ApplyImageEnrichmentUseCase } from "./ApplyImageEnrichmentUseCase.js"; export const AiImageEnrichmentFeature = createFeature({ name: "AiPowerUps/AiImageEnrichment", register(container) { + container.register(PrepareImageEnrichmentUseCase); + container.register(ApplyImageEnrichmentUseCase); + container.register(AiImageEnrichmentAfterCreateHandler); container.register(AiImageEnrichmentTask); + + // On-demand re-enrichment, streamed. The WCP gate lives inside the route (request time), + // same as in the after-create handler. + container.register(AiImageEnrichmentStreamRoute); } }); diff --git a/packages/ai-powerups/src/api/features/AiImageEnrichment/streamEvents.ts b/packages/ai-powerups/src/api/features/AiImageEnrichment/streamEvents.ts new file mode 100644 index 00000000000..f7929031ff1 --- /dev/null +++ b/packages/ai-powerups/src/api/features/AiImageEnrichment/streamEvents.ts @@ -0,0 +1,43 @@ +/** + * Server-sent event payloads emitted by `AiImageEnrichmentStreamRoute`. + * + * This is a small domain-specific protocol rather than the AI SDK's UI message stream: the client + * doesn't render a chat transcript, it renders a progressively-completing `{ tags, description }` + * object, which is exactly what `streamText`'s `partialOutputStream` produces. The admin app mirrors + * these types (it can't import an api-side package), so keep the two in sync. + */ +export interface EnrichmentStreamStartEvent { + type: "start"; + fileId: string; + model: string; +} + +export interface EnrichmentStreamPartialEvent { + type: "partial"; + tags: string[]; + description: string; +} + +export interface EnrichmentStreamDoneEvent { + type: "done"; + fileId: string; + /** The persisted tags — AI tags merged with the file's existing ones. */ + tags: string[]; + description: string; +} + +export interface EnrichmentStreamErrorEvent { + type: "error"; + message: string; +} + +export type EnrichmentStreamEvent = + | EnrichmentStreamStartEvent + | EnrichmentStreamPartialEvent + | EnrichmentStreamDoneEvent + | EnrichmentStreamErrorEvent; + +/** Frame a single event as an SSE `data:` record. */ +export function toSseFrame(event: EnrichmentStreamEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} diff --git a/packages/ai-powerups/tsconfig.build.json b/packages/ai-powerups/tsconfig.build.json index d3520735236..56b70bfcd1d 100644 --- a/packages/ai-powerups/tsconfig.build.json +++ b/packages/ai-powerups/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "../app-website-builder/tsconfig.build.json" }, { "path": "../app-websockets/tsconfig.build.json" }, { "path": "../background-tasks/tsconfig.build.json" }, + { "path": "../event-handler-core/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../project/tsconfig.build.json" }, { "path": "../project-aws/tsconfig.build.json" }, @@ -49,6 +50,8 @@ "@webiny/app-websockets": ["../app-websockets/src"], "@webiny/background-tasks/*": ["../background-tasks/src/*"], "@webiny/background-tasks": ["../background-tasks/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/feature/*": ["../feature/src/*"], "@webiny/feature": ["../feature/src"], "@webiny/project/*": ["../project/src/*"], diff --git a/packages/ai-powerups/tsconfig.json b/packages/ai-powerups/tsconfig.json index ab1ace20602..5a3b1c5d4f8 100644 --- a/packages/ai-powerups/tsconfig.json +++ b/packages/ai-powerups/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../app-website-builder" }, { "path": "../app-websockets" }, { "path": "../background-tasks" }, + { "path": "../event-handler-core" }, { "path": "../feature" }, { "path": "../project" }, { "path": "../project-aws" }, @@ -49,6 +50,8 @@ "@webiny/app-websockets": ["../app-websockets/src"], "@webiny/background-tasks/*": ["../background-tasks/src/*"], "@webiny/background-tasks": ["../background-tasks/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/feature/*": ["../feature/src/*"], "@webiny/feature": ["../feature/src"], "@webiny/project/*": ["../project/src/*"], diff --git a/packages/api-event-handler-aws-ddb-os/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws-ddb-os/src/createWebinyApiHandler.ts index c3ec5f0b841..4e873c6c904 100644 --- a/packages/api-event-handler-aws-ddb-os/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws-ddb-os/src/createWebinyApiHandler.ts @@ -9,6 +9,7 @@ */ import { createWebinyApiHandler as createBaseHandler, + createWebinyStreamApiHandler as createBaseStreamHandler, type CreateWebinyApiHandlerConfig as BaseConfig } from "@webiny/api-event-handler-aws"; import { ApiCoreDdbFeature } from "@webiny/api-core-ddb"; @@ -53,13 +54,24 @@ const openSearchClientFromEnv = () => { return createAwsOpenSearchClient(openSearchClientOptions); }; -export function createAwsDdbOsApiHandler(config: CreateAwsDdbOsApiHandlerConfig) { - const openSearchClient = config.openSearchClient ?? openSearchClientFromEnv(); - - return createBaseHandler({ +/** + * The storage half of the composition, shared by the buffered and the response-streaming entry points + * so the two Lambda functions built from this bundle cannot drift. + */ +function storageConfig( + config: CreateAwsDdbOsApiHandlerConfig +): Pick< + BaseConfig, + "extensions" | "documentClient" | "registerRootStorage" | "registerRequestStorage" +> { + return { extensions: config.extensions, documentClient: config.documentClient, registerRootStorage: (container, { documentClient }) => { + // Built here rather than at factory time: one bundle exports both the buffered and the + // streaming handler, so an eager client would be created twice per cold start. + const openSearchClient = config.openSearchClient ?? openSearchClientFromEnv(); + // ── OpenSearch core (client + query-builder operators + fields + index registries) ── // The DDB+ES CMS storage factory resolves all of these. OpenSearchClientFeature.register(container, openSearchClient); @@ -80,5 +92,17 @@ export function createAwsDdbOsApiHandler(config: CreateAwsDdbOsApiHandlerConfig) // (its beforeInit registers into it). Must be registered before HeadlessCmsFeature builds. DbRegistryFeature.register(container); } - }); + }; +} + +export function createAwsDdbOsApiHandler(config: CreateAwsDdbOsApiHandlerConfig) { + return createBaseHandler(storageConfig(config)); +} + +/** + * Response-streaming counterpart, for the Lambda function whose Function URL uses + * `InvokeMode: RESPONSE_STREAM`. Identical storage; only the transport differs. + */ +export function createAwsDdbOsStreamApiHandler(config: CreateAwsDdbOsApiHandlerConfig) { + return createBaseStreamHandler(storageConfig(config)); } diff --git a/packages/api-event-handler-aws-ddb-os/src/index.ts b/packages/api-event-handler-aws-ddb-os/src/index.ts index 90f7ace3d55..fdd40191422 100644 --- a/packages/api-event-handler-aws-ddb-os/src/index.ts +++ b/packages/api-event-handler-aws-ddb-os/src/index.ts @@ -1,2 +1,3 @@ export { createAwsDdbOsApiHandler } from "./createWebinyApiHandler.js"; +export { createAwsDdbOsStreamApiHandler } from "./createWebinyApiHandler.js"; export type { CreateAwsDdbOsApiHandlerConfig } from "./createWebinyApiHandler.js"; diff --git a/packages/api-event-handler-aws-ddb/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws-ddb/src/createWebinyApiHandler.ts index bce2e329c07..17628e204b3 100644 --- a/packages/api-event-handler-aws-ddb/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws-ddb/src/createWebinyApiHandler.ts @@ -3,9 +3,13 @@ * * Thin variant over the storage-agnostic base (@webiny/api-event-handler-aws): supplies the DynamoDB * storage wiring (core + CMS + audit-logs + ACO + websockets storage operations). No OpenSearch. + * + * Two entry points share that wiring: the buffered API Gateway handler and the response-streaming + * Function URL handler. They are separate Lambda functions built from the same bundle. */ import { createWebinyApiHandler as createBaseHandler, + createWebinyStreamApiHandler as createBaseStreamHandler, type CreateWebinyApiHandlerConfig as BaseConfig } from "@webiny/api-event-handler-aws"; import { ApiCoreDdbFeature } from "@webiny/api-core-ddb"; @@ -16,15 +20,22 @@ import { WebsocketsDdbFeature } from "@webiny/api-websockets-ddb"; export type CreateAwsDdbApiHandlerConfig = Pick; +const registerRootStorage: BaseConfig["registerRootStorage"] = (container, { documentClient }) => { + ApiCoreDdbFeature.register(container, { documentClient }); + HeadlessCmsDdbFeature.register(container); + AuditLogsDdbFeature.register(container, {}); + AcoDdbFeature.register(container); + WebsocketsDdbFeature.register(container); +}; + export function createAwsDdbApiHandler(config: CreateAwsDdbApiHandlerConfig) { - return createBaseHandler({ - ...config, - registerRootStorage: (container, { documentClient }) => { - ApiCoreDdbFeature.register(container, { documentClient }); - HeadlessCmsDdbFeature.register(container); - AuditLogsDdbFeature.register(container, {}); - AcoDdbFeature.register(container); - WebsocketsDdbFeature.register(container); - } - }); + return createBaseHandler({ ...config, registerRootStorage }); +} + +/** + * Response-streaming counterpart, for the Lambda function whose Function URL uses + * `InvokeMode: RESPONSE_STREAM`. Identical storage; only the transport differs. + */ +export function createAwsDdbStreamApiHandler(config: CreateAwsDdbApiHandlerConfig) { + return createBaseStreamHandler({ ...config, registerRootStorage }); } diff --git a/packages/api-event-handler-aws-ddb/src/index.ts b/packages/api-event-handler-aws-ddb/src/index.ts index 90cba8dd313..73d0cd60768 100644 --- a/packages/api-event-handler-aws-ddb/src/index.ts +++ b/packages/api-event-handler-aws-ddb/src/index.ts @@ -1,2 +1,3 @@ export { createAwsDdbApiHandler } from "./createWebinyApiHandler.js"; +export { createAwsDdbStreamApiHandler } from "./createWebinyApiHandler.js"; export type { CreateAwsDdbApiHandlerConfig } from "./createWebinyApiHandler.js"; diff --git a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index 06c7c692b55..624357a1ace 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -2,14 +2,14 @@ * DI-native Webiny API handler for the AWS Lambda transport — storage-agnostic BASE. * * The ROOT container wires the AWS transport (API Gateway HTTP + auth/tenant loaders, background-task - * and WebSocket Lambda invocations, DynamoDB, Cognito, storage). The per-request feature stack is the - * transport-AGNOSTIC `registerApiRequestStack` from `@webiny/api-event-handler-core`, with the two - * AWS-specific interleave points supplied as hooks (real-time WebSockets transport + scheduler - * transport). The storage variant is injected via `registerRootStorage` / `registerRequestStorage` - * by a thin variant package (`@webiny/api-event-handler-aws-ddb`, `-aws-ddb-os`). Keeping the wiring - * in real packages (not an app template) is what makes it unit/integration testable. + * and WebSocket Lambda invocations, DynamoDB, Cognito, storage). Everything that is not + * transport-specific — database, identity providers, storage, and the transport-AGNOSTIC per-request + * feature stack — lives in `registerWebinyApi.ts`, shared with the response-streaming handler + * (`createWebinyStreamApiHandler`) so the two roots cannot drift. The storage variant is injected via + * `registerRootStorage` / `registerRequestStorage` by a thin variant package + * (`@webiny/api-event-handler-aws-ddb`, `-aws-ddb-os`). Keeping the wiring in real packages (not an app + * template) is what makes it unit/integration testable. */ -import type { Container } from "@webiny/di"; import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createLambdaHandler, @@ -18,54 +18,17 @@ import { WebSocketEventType } from "@webiny/event-handler-aws"; import { BackgroundTasksAwsFeature } from "@webiny/background-tasks-aws"; -import { registerExtensions } from "@webiny/handler"; -import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; -import { registerApiRequestStack } from "@webiny/api-event-handler-core"; -import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; -import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; -import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; import { WebSocketLambdaHandler } from "@webiny/api-websockets"; -// CognitoIdpFeature must be in the root container so the request auth step -// (ApiGatewayIdentityLoaderDecorator → RequestIdentityLoader) sees CognitoIdentityProvider -// when it is first instantiated. Extensions register in the child/request container — too late. -import { CognitoIdpFeature } from "@webiny/cognito/api/features/CognitoIdp/feature.js"; import { ApiGatewayIdentityLoaderDecorator } from "~/handlers/ApiGatewayIdentityLoaderDecorator.js"; import { ApiGatewayTenantLoaderDecorator } from "~/handlers/ApiGatewayTenantLoaderDecorator.js"; +import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/registerWebinyApi.js"; +import type { WebinyApiCompositionConfig } from "~/registerWebinyApi.js"; -export interface RegisterRootStorageContext { - documentClient: ReturnType; -} +export type { RegisterRootStorageContext } from "~/registerWebinyApi.js"; -export interface CreateWebinyApiHandlerConfig { - /** - * Project-defined extensions, applied at register() time. This is the one project-specific - * input; everything else is standard AWS/env wiring owned by this package. - */ - extensions: () => Parameters[1]; - /** - * DynamoDB document client. Defaults to the standard AWS client (`getDocumentClient()`). - * Injectable so integration tests can point the handler at a local (dynalite) DynamoDB. - */ - documentClient?: ReturnType; - /** - * Register the storage-variant features in the ROOT container: the CMS storage operations, the - * DDB storage registries, and (for the OpenSearch variant) the OpenSearch core. Supplied by the - * variant package. - */ - registerRootStorage: ( - container: Container, - ctx: RegisterRootStorageContext - ) => void | Promise; - /** - * Register any request-phase storage features that must run BEFORE `HeadlessCmsFeature` builds - * its storage — e.g. `DbRegistryFeature` for the DDB+ES variant. Optional (DDB-only needs none). - */ - registerRequestStorage?: (container: Container) => void | Promise; -} +export type CreateWebinyApiHandlerConfig = WebinyApiCompositionConfig; export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { - const documentClient = config.documentClient ?? getDocumentClient(); - return createLambdaHandler({ root: async container => { // ── Transport ────────────────────────────────────────────── @@ -92,42 +55,18 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { container.register(WebSocketEventType); container.register(WebSocketLambdaHandler); - // ── Database ─────────────────────────────────────────────── - DynamoDBCoreFeature.register(container, { - documentClient - }); - - // ── Identity providers ───────────────────────────────────── - // Must be in root so the request auth step can authenticate - // requests before the GraphQL engine runs. - CognitoIdpFeature.register(container); - - // ── Storage (variant-specific: CMS storage ops, DDB registries, OpenSearch core) ── - await config.registerRootStorage(container, { documentClient }); + // Resolved here rather than at factory time: one bundle exports BOTH this handler and the + // streaming one, so building the client eagerly would open a second DynamoDB client on + // every cold start. `root` runs once, lazily. + await registerWebinyApiRoot( + container, + config, + config.documentClient ?? getDocumentClient() + ); }, request: async container => { - // The per-request feature stack is transport-agnostic (shared with the server transport). - // The AWS-specific interleave points are supplied as the `transports` adapters. - await registerApiRequestStack(container, { - extensions: config.extensions, - registerRequestStorage: config.registerRequestStorage, - transports: { - // Real AWS WebSocket transport (API Gateway Management API), registered right after - // WebsocketsFeature so it overrides the NullWebsocketsTransport. - realtime: c => { - WebsocketsAwsFeature.register(c); - }, - // Scheduler transport: the scheduler-aws extension (EventBridge Scheduler). - scheduler: c => { - SchedulerAwsFeature.register(c); - }, - // File-manager storage transport: S3 (asset delivery + S3 file operations + schema). - fileManager: c => { - FileManagerS3Feature.register(c, {}); - } - } - }); + await registerWebinyApiRequest(container, config); } }); } diff --git a/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts new file mode 100644 index 00000000000..bb56ab1db05 --- /dev/null +++ b/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts @@ -0,0 +1,56 @@ +/** + * DI-native Webiny API handler for AWS Lambda **response streaming** — storage-agnostic BASE. + * + * This is a SECOND Lambda function sharing the buffered function's code bundle, not a second entry on + * the same function: a Lambda's handler entry is fixed per function, and a `streamifyResponse` handler + * is only meaningful under `InvokeMode: RESPONSE_STREAM`. It is reached through a Lambda Function URL + * (fronted by CloudFront), because API Gateway buffers the entire Lambda response and therefore cannot + * stream at all. + * + * Root/request registration is shared with `createWebinyApiHandler` via `registerWebinyApi.ts`; only + * the transport differs. Notably NOT registered here: the background-task and WebSocket event types + * plus their Lambda handlers. Those are inbound invocation paths that only ever target the buffered + * function, so registering them would add cold-start cost for events this function never receives. + * The websockets *outbound* transport IS present — it comes from the shared per-request stack, and + * streaming routes rely on it to push completion messages. + */ +import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { createStreamLambdaHandler, FunctionUrlStreamFeature } from "@webiny/event-handler-aws"; +import { FunctionUrlStreamIdentityLoaderDecorator } from "~/handlers/FunctionUrlStreamIdentityLoaderDecorator.js"; +import { FunctionUrlStreamTenantLoaderDecorator } from "~/handlers/FunctionUrlStreamTenantLoaderDecorator.js"; +import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/registerWebinyApi.js"; +import type { WebinyApiCompositionConfig } from "~/registerWebinyApi.js"; + +export type CreateWebinyStreamApiHandlerConfig = WebinyApiCompositionConfig; + +export function createWebinyStreamApiHandler(config: CreateWebinyStreamApiHandlerConfig) { + return createStreamLambdaHandler({ + root: async container => { + // ── Transport ────────────────────────────────────────────── + // Registers the Function URL event type + router + the streaming terminal handler. + // Deliberately NOT alongside ApiGatewayFeature: both event types match the same payload + // shape, so they must never share a container. + FunctionUrlStreamFeature.register(container); + + // ── Auth + tenant (extract → shared load) ────────────────── + // registerDecorator applies LATER registrations as the OUTER wrapper (whose execute() runs + // first), so register tenant first (inner) and identity last (outer) → identity runs, then + // tenant, then the router. + container.registerDecorator(FunctionUrlStreamTenantLoaderDecorator); + container.registerDecorator(FunctionUrlStreamIdentityLoaderDecorator); + + // Resolved here rather than at factory time: one bundle exports BOTH this handler and the + // buffered one, so building the client eagerly would open a second DynamoDB client on every + // cold start of whichever function isn't streaming. `root` runs once, lazily. + await registerWebinyApiRoot( + container, + config, + config.documentClient ?? getDocumentClient() + ); + }, + + request: async container => { + await registerWebinyApiRequest(container, config); + } + }); +} diff --git a/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts index 3f226003f84..8a1013bc56d 100644 --- a/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts +++ b/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts @@ -6,21 +6,12 @@ import { } from "@webiny/api-core/features/requestContext/index.js"; import type { IRequestIdentityLoader } from "@webiny/api-core/features/requestContext/abstractions.js"; import type { EventContext, NextFunction } from "@webiny/event-handler-core"; - -function parseCookieHeader(cookieHeader: string): Record { - return cookieHeader.split(";").reduce>((acc, pair) => { - const idx = pair.indexOf("="); - if (idx > 0) { - acc[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim()); - } - return acc; - }, {}); -} +import { extractAuthToken } from "./extractRequestAuth.js"; /** - * EXTRACT (transport-specific): reads the auth token from an API Gateway event — bearer - * `Authorization` header preferred, then the `wby-id-token` cookie — into RawAuthToken, then invokes - * the shared LOAD step (RequestIdentityLoader) which authenticates it and sets IdentityContext. + * EXTRACT (transport-specific): reads the auth token from an API Gateway event — see + * {@link extractAuthToken} for the header precedence — into RawAuthToken, then invokes the shared LOAD + * step (RequestIdentityLoader) which authenticates it and sets IdentityContext. * * A missing token leaves RawAuthToken null → the loader authenticates as anonymous. * Registered BEFORE ApiGatewayTenantLoaderDecorator so identity is established before tenant @@ -34,26 +25,10 @@ class ApiGatewayIdentityLoaderDecoratorImpl implements ApiGatewayEventHandler.In ) {} async execute(ctx: EventContext, next: NextFunction): Promise { - this.rawAuthToken.set(this.extractToken(ctx.event)); + this.rawAuthToken.set(extractAuthToken(ctx.event?.headers as Record)); await this.identityLoader.establish(); return this.decoratee.execute(ctx, next); } - - private extractToken(event: APIGatewayProxyEvent): string | null { - const headers = event?.headers; - if (!headers) { - return null; - } - const bearer = (headers["authorization"] ?? headers["Authorization"] ?? "").replace( - /^Bearer\s+/i, - "" - ); - if (bearer) { - return bearer; - } - const cookieHeader = headers["cookie"] ?? headers["Cookie"] ?? ""; - return parseCookieHeader(cookieHeader)["wby-id-token"] ?? null; - } } export const ApiGatewayIdentityLoaderDecorator = ApiGatewayEventHandler.createDecorator({ diff --git a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts new file mode 100644 index 00000000000..4794c35b6ba --- /dev/null +++ b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts @@ -0,0 +1,37 @@ +import { FunctionUrlStreamEventHandler } from "@webiny/event-handler-aws"; +import { + RawAuthToken, + RequestIdentityLoader +} from "@webiny/api-core/features/requestContext/index.js"; +import type { IRequestIdentityLoader } from "@webiny/api-core/features/requestContext/abstractions.js"; +import type { EventContext, NextFunction } from "@webiny/event-handler-core"; +import { extractAuthToken, headersFromFunctionUrlEvent } from "./extractRequestAuth.js"; + +/** + * EXTRACT (transport-specific): the Function URL streaming counterpart of + * `ApiGatewayIdentityLoaderDecorator`. Same shared LOAD step; the only difference is that Function URL + * events carry cookies as an array, so the headers are normalised first. + * + * Registered BEFORE FunctionUrlStreamTenantLoaderDecorator so identity is established before tenant. + */ +class FunctionUrlStreamIdentityLoaderDecoratorImpl + implements FunctionUrlStreamEventHandler.Interface +{ + constructor( + private rawAuthToken: RawAuthToken.Interface, + private identityLoader: IRequestIdentityLoader, + private decoratee: FunctionUrlStreamEventHandler.Interface + ) {} + + async execute(ctx: EventContext, next: NextFunction): Promise { + this.rawAuthToken.set(extractAuthToken(headersFromFunctionUrlEvent(ctx.event))); + await this.identityLoader.establish(); + return this.decoratee.execute(ctx, next); + } +} + +export const FunctionUrlStreamIdentityLoaderDecorator = + FunctionUrlStreamEventHandler.createDecorator({ + decorator: FunctionUrlStreamIdentityLoaderDecoratorImpl, + dependencies: [RawAuthToken, RequestIdentityLoader] + }); diff --git a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts new file mode 100644 index 00000000000..b8c2afa0d57 --- /dev/null +++ b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts @@ -0,0 +1,38 @@ +import { FunctionUrlStreamEventHandler } from "@webiny/event-handler-aws"; +import { + RawTenantId, + RequestTenantLoader +} from "@webiny/api-core/features/requestContext/index.js"; +import type { IRequestTenantLoader } from "@webiny/api-core/features/requestContext/abstractions.js"; +import type { EventContext, NextFunction } from "@webiny/event-handler-core"; +import { extractTenantId, headersFromFunctionUrlEvent } from "./extractRequestAuth.js"; + +/** + * EXTRACT (transport-specific): reads the tenant id from the `x-tenant` header of a Function URL event + * into RawTenantId, then invokes the shared LOAD step (RequestTenantLoader). A missing header leaves + * RawTenantId null → the loader defaults to the "root" tenant. + * + * Registered AFTER FunctionUrlStreamIdentityLoaderDecorator. + */ +class FunctionUrlStreamTenantLoaderDecoratorImpl + implements FunctionUrlStreamEventHandler.Interface +{ + constructor( + private rawTenantId: RawTenantId.Interface, + private tenantLoader: IRequestTenantLoader, + private decoratee: FunctionUrlStreamEventHandler.Interface + ) {} + + async execute(ctx: EventContext, next: NextFunction): Promise { + this.rawTenantId.set(extractTenantId(headersFromFunctionUrlEvent(ctx.event))); + await this.tenantLoader.establish(); + return this.decoratee.execute(ctx, next); + } +} + +export const FunctionUrlStreamTenantLoaderDecorator = FunctionUrlStreamEventHandler.createDecorator( + { + decorator: FunctionUrlStreamTenantLoaderDecoratorImpl, + dependencies: [RawTenantId, RequestTenantLoader] + } +); diff --git a/packages/api-event-handler-aws/src/handlers/extractRequestAuth.ts b/packages/api-event-handler-aws/src/handlers/extractRequestAuth.ts new file mode 100644 index 00000000000..fd0ab94eb01 --- /dev/null +++ b/packages/api-event-handler-aws/src/handlers/extractRequestAuth.ts @@ -0,0 +1,74 @@ +/** + * Header parsing shared by the API Gateway and Function URL auth/tenant decorators, so the two + * transports can't drift on how a caller authenticates. + */ + +export const WEBINY_AUTHORIZATION_HEADER = "x-webiny-authorization"; + +function parseCookieHeader(cookieHeader: string): Record { + return cookieHeader.split(";").reduce>((acc, pair) => { + const idx = pair.indexOf("="); + if (idx > 0) { + acc[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim()); + } + return acc; + }, {}); +} + +function getHeader(headers: Record | undefined, name: string): string { + if (!headers) { + return ""; + } + // Header names are case-insensitive, and the casing that reaches us depends on the transport and + // on the client, so match on the lowercased key rather than guessing a spelling. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) { + return headers[key] ?? ""; + } + } + return ""; +} + +/** + * Reads the auth token, preferring `x-webiny-authorization` over `Authorization`. + * + * That order exists for CloudFront → Lambda Function URL with Origin Access Control: OAC signs the + * request with SigV4, which occupies the `Authorization` header, so a viewer's bearer token cannot + * survive to the origin. Clients that may traverse an OAC-signed origin send the token in the + * `x-webiny-authorization` header instead. Plain `Authorization` stays supported for every other + * caller, and the `wby-id-token` cookie remains the last resort. + */ +export function extractAuthToken(headers: Record | undefined): string | null { + const webinyAuth = getHeader(headers, WEBINY_AUTHORIZATION_HEADER).replace(/^Bearer\s+/i, ""); + if (webinyAuth) { + return webinyAuth; + } + + const bearer = getHeader(headers, "authorization").replace(/^Bearer\s+/i, ""); + if (bearer) { + return bearer; + } + + const cookieHeader = getHeader(headers, "cookie"); + return parseCookieHeader(cookieHeader)["wby-id-token"] ?? null; +} + +export function extractTenantId(headers: Record | undefined): string | null { + return getHeader(headers, "x-tenant") || null; +} + +/** + * Function URL events (payload format 2.0) deliver cookies as a `cookies` ARRAY rather than a `cookie` + * header. Fold them back so the extractors above see a normal header map. + */ +export function headersFromFunctionUrlEvent(event: any): Record { + const headers: Record = { + ...((event?.headers as Record) || {}) + }; + + if (Array.isArray(event?.cookies) && event.cookies.length > 0) { + headers.cookie = event.cookies.join("; "); + } + + return headers; +} diff --git a/packages/api-event-handler-aws/src/handlers/index.ts b/packages/api-event-handler-aws/src/handlers/index.ts index 85d67aa0c42..931555f4a86 100644 --- a/packages/api-event-handler-aws/src/handlers/index.ts +++ b/packages/api-event-handler-aws/src/handlers/index.ts @@ -1,3 +1,6 @@ export * from "./ApiGatewayIdentityLoaderDecorator.js"; +export * from "./FunctionUrlStreamIdentityLoaderDecorator.js"; +export * from "./FunctionUrlStreamTenantLoaderDecorator.js"; +export * from "./extractRequestAuth.js"; export * from "./ApiGatewayTenantLoaderDecorator.js"; export * from "./S3TenantLoaderDecorator.js"; diff --git a/packages/api-event-handler-aws/src/index.ts b/packages/api-event-handler-aws/src/index.ts index 65ad47750f8..713c4a32b1d 100644 --- a/packages/api-event-handler-aws/src/index.ts +++ b/packages/api-event-handler-aws/src/index.ts @@ -3,4 +3,7 @@ export type { CreateWebinyApiHandlerConfig, RegisterRootStorageContext } from "./createWebinyApiHandler.js"; +export { createWebinyStreamApiHandler } from "./createWebinyStreamApiHandler.js"; +export type { CreateWebinyStreamApiHandlerConfig } from "./createWebinyStreamApiHandler.js"; +export type { WebinyApiCompositionConfig } from "./registerWebinyApi.js"; export * from "./handlers/index.js"; diff --git a/packages/api-event-handler-aws/src/registerWebinyApi.ts b/packages/api-event-handler-aws/src/registerWebinyApi.ts new file mode 100644 index 00000000000..85b6cc08b18 --- /dev/null +++ b/packages/api-event-handler-aws/src/registerWebinyApi.ts @@ -0,0 +1,98 @@ +/** + * Registration shared by the two AWS Lambda composition roots — the buffered API Gateway handler + * (`createWebinyApiHandler`) and the response-streaming Function URL handler + * (`createWebinyStreamApiHandler`). + * + * Everything that is NOT transport-specific lives here, so the two entry points cannot drift on + * storage, identity providers, or the per-request feature stack. + */ +import type { Container } from "@webiny/di"; +import type { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { registerExtensions } from "@webiny/handler"; +import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; +import { registerApiRequestStack } from "@webiny/api-event-handler-core"; +import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; +import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; +import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; +// CognitoIdpFeature must be in the root container so the request auth step +// (identity loader decorator → RequestIdentityLoader) sees CognitoIdentityProvider when it is first +// instantiated. Extensions register in the child/request container — too late. +import { CognitoIdpFeature } from "@webiny/cognito/api/features/CognitoIdp/feature.js"; + +export interface RegisterRootStorageContext { + documentClient: ReturnType; +} + +export interface WebinyApiCompositionConfig { + /** + * Project-defined extensions, applied at register() time. This is the one project-specific + * input; everything else is standard AWS/env wiring owned by this package. + */ + extensions: () => Parameters[1]; + /** + * DynamoDB document client. Defaults to the standard AWS client (`getDocumentClient()`). + * Injectable so integration tests can point the handler at a local (dynalite) DynamoDB. + */ + documentClient?: ReturnType; + /** + * Register the storage-variant features in the ROOT container: the CMS storage operations, the + * DDB storage registries, and (for the OpenSearch variant) the OpenSearch core. Supplied by the + * variant package. + */ + registerRootStorage: ( + container: Container, + ctx: RegisterRootStorageContext + ) => void | Promise; + /** + * Register any request-phase storage features that must run BEFORE `HeadlessCmsFeature` builds + * its storage — e.g. `DbRegistryFeature` for the DDB+ES variant. Optional (DDB-only needs none). + */ + registerRequestStorage?: (container: Container) => void | Promise; +} + +/** Database, identity providers, and the storage variant. Transport-agnostic. */ +export async function registerWebinyApiRoot( + container: Container, + config: WebinyApiCompositionConfig, + documentClient: ReturnType +): Promise { + // ── Database ─────────────────────────────────────────────── + DynamoDBCoreFeature.register(container, { documentClient }); + + // ── Identity providers ───────────────────────────────────── + // Must be in root so the request auth step can authenticate + // requests before the GraphQL engine runs. + CognitoIdpFeature.register(container); + + // ── Storage (variant-specific: CMS storage ops, DDB registries, OpenSearch core) ── + await config.registerRootStorage(container, { documentClient }); +} + +/** + * The per-request feature stack, which is transport-agnostic (shared with the server transport). The + * AWS-specific interleave points are supplied as the `transports` adapters. + */ +export async function registerWebinyApiRequest( + container: Container, + config: WebinyApiCompositionConfig +): Promise { + await registerApiRequestStack(container, { + extensions: config.extensions, + registerRequestStorage: config.registerRequestStorage, + transports: { + // Real AWS WebSocket transport (API Gateway Management API), registered right after + // WebsocketsFeature so it overrides the NullWebsocketsTransport. + realtime: c => { + WebsocketsAwsFeature.register(c); + }, + // Scheduler transport: the scheduler-aws extension (EventBridge Scheduler). + scheduler: c => { + SchedulerAwsFeature.register(c); + }, + // File-manager storage transport: S3 (asset delivery + S3 file operations + schema). + fileManager: c => { + FileManagerS3Feature.register(c, {}); + } + } + }); +} diff --git a/packages/api-event-handler-server/src/handlers/NodeHttpIdentityLoaderDecorator.ts b/packages/api-event-handler-server/src/handlers/NodeHttpIdentityLoaderDecorator.ts index f611c627487..ff4d1eff044 100644 --- a/packages/api-event-handler-server/src/handlers/NodeHttpIdentityLoaderDecorator.ts +++ b/packages/api-event-handler-server/src/handlers/NodeHttpIdentityLoaderDecorator.ts @@ -25,10 +25,16 @@ function parseCookieHeader(cookieHeader: string): Record { } /** - * EXTRACT (transport-specific): reads the auth token from a Node `IncomingMessage` — bearer - * `Authorization` header preferred, then the `wby-id-token` cookie — into RawAuthToken, then invokes - * the shared LOAD step (RequestIdentityLoader) which authenticates it (via the registered identity - * provider, e.g. the self-hosted JWT IdP) and sets IdentityContext. + * EXTRACT (transport-specific): reads the auth token from a Node `IncomingMessage` — + * `x-webiny-authorization` first, then the bearer `Authorization` header, then the `wby-id-token` + * cookie — into RawAuthToken, then invokes the shared LOAD step (RequestIdentityLoader) which + * authenticates it (via the registered identity provider, e.g. the self-hosted JWT IdP) and sets + * IdentityContext. + * + * `x-webiny-authorization` exists for AWS parity: behind CloudFront with Origin Access Control, SigV4 + * occupies the `Authorization` header, so clients that may traverse such an origin send the token in + * that header instead. Self-hosted has no such constraint, but it accepts the same header so one + * client works against both deployments. * * Node mirror of ApiGatewayIdentityLoaderDecorator. Registered BEFORE the tenant loader so identity * is established before tenant. @@ -51,6 +57,12 @@ class NodeHttpIdentityLoaderDecoratorImpl implements NodeHttpEventHandler.Interf if (!headers) { return null; } + const webinyAuth = headerValue( + headers["x-webiny-authorization"] ?? headers["X-Webiny-Authorization"] + ).replace(/^Bearer\s+/i, ""); + if (webinyAuth) { + return webinyAuth; + } const bearer = headerValue(headers["authorization"] ?? headers["Authorization"]).replace( /^Bearer\s+/i, "" diff --git a/packages/app-admin/src/base/createRootContainer.ts b/packages/app-admin/src/base/createRootContainer.ts index 3dbb23a451c..469027f1aaf 100644 --- a/packages/app-admin/src/base/createRootContainer.ts +++ b/packages/app-admin/src/base/createRootContainer.ts @@ -7,6 +7,7 @@ import { HistoryRouterGateway } from "@webiny/app/features/router/HistoryRouterG import { EnvConfigFeature } from "@webiny/app/features/envConfig/feature.js"; import { GraphQLClientFeature } from "@webiny/app/features/graphqlClient/feature.js"; import { MainGraphQLClientFeature } from "@webiny/app/features/mainGraphQLClient/feature.js"; +import { ApiStreamClientFeature } from "@webiny/app/features/apiStreamClient/feature.js"; import { LocalStorageFeature } from "@webiny/app/features/localStorage/feature.js"; import { EventPublisherFeature } from "@webiny/app/features/eventPublisher/feature.js"; import { NotificationsFeature } from "~/features/notifications/feature.js"; @@ -58,6 +59,10 @@ export function createRootContainer() { MainGraphQLClientFeature.register(container); + // Registered before TenancyFeature / AuthenticationContextFeature, which decorate it to add the + // tenant and auth headers. + ApiStreamClientFeature.register(container); + LocalStorageFeature.register(container, { prefix: `webiny/${deploymentId}` }); TenancyFeature.register(container); diff --git a/packages/app-admin/src/features/security/AuthenticationContext/ApiStreamClientDecorator.ts b/packages/app-admin/src/features/security/AuthenticationContext/ApiStreamClientDecorator.ts new file mode 100644 index 00000000000..01af6691853 --- /dev/null +++ b/packages/app-admin/src/features/security/AuthenticationContext/ApiStreamClientDecorator.ts @@ -0,0 +1,40 @@ +import { ApiStreamClient } from "@webiny/app/features/apiStreamClient/index.js"; +import { InternalIdTokenProvider } from "~/features/security/AuthenticationContext/abstractions.js"; + +export const WEBINY_AUTHORIZATION_HEADER = "x-webiny-authorization"; + +/** + * Attaches the admin id token to streaming API requests. Mirrors `GraphQLClientDecorator` — the two + * clients are separate abstractions, so neither decorator covers the other. + * + * Uses `x-webiny-authorization` rather than `Authorization`, because on AWS the streaming route is + * served by a Lambda Function URL behind CloudFront with Origin Access Control: OAC signs the request + * with SigV4, which occupies the `Authorization` header, so a bearer token there would not survive to + * the origin. Both the AWS and the self-hosted identity extractors read this header first. + */ +class ApiStreamClientWithIdToken implements ApiStreamClient.Interface { + constructor( + private idTokenProvider: InternalIdTokenProvider.Interface, + private decoratee: ApiStreamClient.Interface + ) {} + + async execute(params: ApiStreamClient.Request): Promise { + if (params.headers?.[WEBINY_AUTHORIZATION_HEADER]) { + return this.decoratee.execute(params); + } + + const idToken = await this.idTokenProvider.getTokenProvider()(); + + const authHeaders = idToken ? { [WEBINY_AUTHORIZATION_HEADER]: `Bearer ${idToken}` } : {}; + + return this.decoratee.execute({ + ...params, + headers: { ...params.headers, ...authHeaders } + }); + } +} + +export const ApiStreamClientDecorator = ApiStreamClient.createDecorator({ + decorator: ApiStreamClientWithIdToken, + dependencies: [InternalIdTokenProvider] +}); diff --git a/packages/app-admin/src/features/security/AuthenticationContext/feature.ts b/packages/app-admin/src/features/security/AuthenticationContext/feature.ts index 1f1f01c5202..8aab1f2732c 100644 --- a/packages/app-admin/src/features/security/AuthenticationContext/feature.ts +++ b/packages/app-admin/src/features/security/AuthenticationContext/feature.ts @@ -2,6 +2,7 @@ import { createFeature } from "@webiny/feature/admin"; import { AuthenticationContext as AuthenticationContextAbstraction } from "./abstractions.js"; import { AuthenticationContext } from "./AuthenticationContext.js"; import { GraphQLClientDecorator } from "./GraphQLClientDecorator.js"; +import { ApiStreamClientDecorator } from "./ApiStreamClientDecorator.js"; import { InternalIdTokenProvider } from "./InternalIdTokenProvider.js"; export const AuthenticationContextFeature = createFeature({ @@ -10,6 +11,7 @@ export const AuthenticationContextFeature = createFeature({ container.register(InternalIdTokenProvider).inSingletonScope(); container.register(AuthenticationContext).inSingletonScope(); container.registerDecorator(GraphQLClientDecorator); + container.registerDecorator(ApiStreamClientDecorator); }, resolve(container) { return { diff --git a/packages/app-admin/src/features/tenancy/ApiStreamClientDecorator.ts b/packages/app-admin/src/features/tenancy/ApiStreamClientDecorator.ts new file mode 100644 index 00000000000..b187a73040c --- /dev/null +++ b/packages/app-admin/src/features/tenancy/ApiStreamClientDecorator.ts @@ -0,0 +1,29 @@ +import { ApiStreamClient } from "@webiny/app/features/apiStreamClient/index.js"; +import { TenantContext } from "~/features/tenancy/abstractions.js"; + +/** + * Adds the current tenant to streaming API requests, so a streaming route resolves the same tenant + * as an equivalent GraphQL call. Mirrors the tenancy `GraphQLClientDecorator`. + */ +class ApiStreamClientWithTenantId implements ApiStreamClient.Interface { + constructor( + private context: TenantContext.Interface, + private decoratee: ApiStreamClient.Interface + ) {} + + async execute(params: ApiStreamClient.Request): Promise { + const tenant = this.context.getCurrentTenant(); + + const tenantHeaders = tenant ? { "x-tenant": tenant } : {}; + + return this.decoratee.execute({ + ...params, + headers: { ...params.headers, ...tenantHeaders } + }); + } +} + +export const ApiStreamClientDecorator = ApiStreamClient.createDecorator({ + decorator: ApiStreamClientWithTenantId, + dependencies: [TenantContext] +}); diff --git a/packages/app-admin/src/features/tenancy/feature.ts b/packages/app-admin/src/features/tenancy/feature.ts index 2f1dd2f86f8..97fc6437e3c 100644 --- a/packages/app-admin/src/features/tenancy/feature.ts +++ b/packages/app-admin/src/features/tenancy/feature.ts @@ -4,12 +4,14 @@ import { LocalStorageFeature } from "@webiny/app/features/localStorage/feature.j import { TenantContext as TenantContextAbstraction } from "./abstractions.js"; import { TenantContext } from "./TenantContext.js"; import { GraphQLClientDecorator } from "./GraphQLClientDecorator.js"; +import { ApiStreamClientDecorator } from "./ApiStreamClientDecorator.js"; export const TenancyFeature = createFeature({ name: "Tenancy", register(container: Container) { container.register(TenantContext).inSingletonScope(); container.registerDecorator(GraphQLClientDecorator); + container.registerDecorator(ApiStreamClientDecorator); }, resolve(container: Container) { const service = container.resolve(TenantContextAbstraction); diff --git a/packages/app-file-manager/src/modules/AiEnrichment/ReenrichFileGateway.ts b/packages/app-file-manager/src/modules/AiEnrichment/ReenrichFileGateway.ts new file mode 100644 index 00000000000..c2ae01c7a98 --- /dev/null +++ b/packages/app-file-manager/src/modules/AiEnrichment/ReenrichFileGateway.ts @@ -0,0 +1,25 @@ +import { ApiStreamClient, readServerSentEvents } from "@webiny/app/exports/admin.js"; +import { ReenrichFileGateway as GatewayAbstraction } from "./abstractions.js"; +import type { EnrichmentStreamEvent, IReenrichFileOptions } from "./abstractions.js"; + +class ReenrichFileGatewayImpl implements GatewayAbstraction.Interface { + constructor(private client: ApiStreamClient.Interface) {} + + async *execute( + fileId: string, + options: IReenrichFileOptions = {} + ): AsyncGenerator { + const response = await this.client.execute({ + path: `/stream/fm/files/${encodeURIComponent(fileId)}/enrich`, + method: "POST", + signal: options.signal + }); + + yield* readServerSentEvents(response); + } +} + +export const ReenrichFileGateway = GatewayAbstraction.createImplementation({ + implementation: ReenrichFileGatewayImpl, + dependencies: [ApiStreamClient] +}); diff --git a/packages/app-file-manager/src/modules/AiEnrichment/ReenrichWithAi.tsx b/packages/app-file-manager/src/modules/AiEnrichment/ReenrichWithAi.tsx new file mode 100644 index 00000000000..9f12eb01940 --- /dev/null +++ b/packages/app-file-manager/src/modules/AiEnrichment/ReenrichWithAi.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { ReactComponent as AiIcon } from "@webiny/icons/auto_awesome.svg"; +import { Dialog } from "@webiny/admin-ui"; +import { useFeature } from "@webiny/app"; +import { FileManagerViewConfig, useFile } from "~/index.js"; +import { AiEnrichmentFeature } from "./feature.js"; + +const { FileDetails } = FileManagerViewConfig; + +type Status = "idle" | "running" | "done" | "error"; + +const STATUS_LABEL: Record = { + idle: "", + running: "Analyzing image…", + done: "Saved.", + error: "Failed." +}; + +/** + * Test-bed for HTTP response streaming: re-runs AI enrichment for the open file and renders the + * model's output as it arrives, rather than waiting for the whole response. + * + * The list cache and the success toast are NOT handled here — the api side also pushes the + * `fm.file.enrichment` websocket message on completion, which `AiImageEnrichmentEventHandler` + * already reacts to. This component only shows the live progress. + */ +export const ReenrichWithAi = () => { + const { file } = useFile(); + const { reenrichFile } = useFeature(AiEnrichmentFeature); + + const [open, setOpen] = useState(false); + const [status, setStatus] = useState("idle"); + const [tags, setTags] = useState([]); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + + const abortRef = useRef(null); + + // Abort an in-flight stream if the drawer/component goes away, so the read loop doesn't keep + // running against an unmounted component. + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + const start = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setOpen(true); + setStatus("running"); + setTags([]); + setDescription(""); + setError(null); + + try { + for await (const event of reenrichFile.execute(file.id, { + signal: controller.signal + })) { + if (event.type === "partial") { + setTags(event.tags); + setDescription(event.description); + } else if (event.type === "done") { + setTags(event.tags); + setDescription(event.description); + setStatus("done"); + } else if (event.type === "error") { + setError(event.message); + setStatus("error"); + } + } + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + return; + } + setError(err instanceof Error ? err.message : String(err)); + setStatus("error"); + } + }, [file.id, reenrichFile]); + + const onOpenChange = useCallback((nextOpen: boolean) => { + if (!nextOpen) { + abortRef.current?.abort(); + } + setOpen(nextOpen); + }, []); + + return ( + <> + } + onAction={start} + data-testid={"fm.file-details.action.reenrich-with-ai"} + /> + +
+
+
{"Tags"}
+ {tags.length ? ( +
+ {tags.map(tag => ( + + {tag} + + ))} +
+ ) : ( +
{"—"}
+ )} +
+
+
{"Description"}
+
{description || "—"}
+
+
+
+ + ); +}; diff --git a/packages/app-file-manager/src/modules/AiEnrichment/abstractions.ts b/packages/app-file-manager/src/modules/AiEnrichment/abstractions.ts new file mode 100644 index 00000000000..c08ee81e962 --- /dev/null +++ b/packages/app-file-manager/src/modules/AiEnrichment/abstractions.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/admin"; + +/** + * Mirrors the server-sent event payloads emitted by the api-side `AiImageEnrichmentStreamRoute` + * (see `streamEvents.ts` in `@webiny/ai-powerups`). Duplicated rather than imported: the admin app + * must not depend on an api-side package. Keep the two in sync. + */ +export interface EnrichmentStreamStartEvent { + type: "start"; + fileId: string; + model: string; +} + +export interface EnrichmentStreamPartialEvent { + type: "partial"; + tags: string[]; + description: string; +} + +export interface EnrichmentStreamDoneEvent { + type: "done"; + fileId: string; + tags: string[]; + description: string; +} + +export interface EnrichmentStreamErrorEvent { + type: "error"; + message: string; +} + +export type EnrichmentStreamEvent = + | EnrichmentStreamStartEvent + | EnrichmentStreamPartialEvent + | EnrichmentStreamDoneEvent + | EnrichmentStreamErrorEvent; + +export interface IReenrichFileOptions { + signal?: AbortSignal; +} + +export interface IReenrichFileGateway { + /** + * Re-runs AI enrichment for a file, yielding progress events as they arrive. + * + * Failures the server detects before streaming starts (unknown file, non-image, no provider, + * license) reject instead of yielding an `error` event — they come back as HTTP status codes. + */ + execute(fileId: string, options?: IReenrichFileOptions): AsyncGenerator; +} + +export const ReenrichFileGateway = createAbstraction("ReenrichFileGateway"); + +export namespace ReenrichFileGateway { + export type Interface = IReenrichFileGateway; + export type Event = EnrichmentStreamEvent; +} diff --git a/packages/app-file-manager/src/modules/AiEnrichment/feature.ts b/packages/app-file-manager/src/modules/AiEnrichment/feature.ts index 85c678002a9..e4ea80470ef 100644 --- a/packages/app-file-manager/src/modules/AiEnrichment/feature.ts +++ b/packages/app-file-manager/src/modules/AiEnrichment/feature.ts @@ -1,9 +1,17 @@ import { createFeature } from "@webiny/feature/admin"; import { AiImageEnrichmentEventHandler } from "./AiImageEnrichmentEventHandler.js"; +import { ReenrichFileGateway } from "./ReenrichFileGateway.js"; +import { ReenrichFileGateway as ReenrichFileGatewayAbstraction } from "./abstractions.js"; export const AiEnrichmentFeature = createFeature({ name: "FileManager/AiEnrichment", register(container) { container.register(AiImageEnrichmentEventHandler); + container.register(ReenrichFileGateway).inSingletonScope(); + }, + resolve(container) { + return { + reenrichFile: container.resolve(ReenrichFileGatewayAbstraction) + }; } }); diff --git a/packages/app-file-manager/src/presentation/FileActions/index.tsx b/packages/app-file-manager/src/presentation/FileActions/index.tsx index 314b5c62943..c723f610a00 100644 --- a/packages/app-file-manager/src/presentation/FileActions/index.tsx +++ b/packages/app-file-manager/src/presentation/FileActions/index.tsx @@ -8,6 +8,7 @@ import { Download as FileDetailsDownload } from "./FileDetails/Download.js"; import { MoveToFolder as FileDetailsMoveToFolder } from "./FileDetails/MoveToFolder.js"; import { CopyUrl as FileDetailsCopyUrl } from "./FileDetails/CopyUrl.js"; import { DeleteImage as FileDetailsDeleteImage } from "./FileDetails/DeleteImage.js"; +import { ReenrichWithAi } from "~/modules/AiEnrichment/ReenrichWithAi.js"; const { Browser, FileDetails } = FileManagerViewConfig; @@ -24,6 +25,7 @@ export const FileActions = () => { } /> } /> } /> + } /> ); }; diff --git a/packages/app/src/exports/admin.ts b/packages/app/src/exports/admin.ts index 834785ce155..114ac16e6e4 100644 --- a/packages/app/src/exports/admin.ts +++ b/packages/app/src/exports/admin.ts @@ -1,4 +1,9 @@ export { MainGraphQLClient } from "~/features/mainGraphQLClient/index.js"; +export { + ApiStreamClient, + ApiStreamRequestError, + readServerSentEvents +} from "~/features/apiStreamClient/index.js"; export { useFeature } from "~/shared/di/useFeature.js"; export { NetworkErrorEventHandler } from "~/errors/index.js"; export { createProviderPlugin } from "~/core/createProviderPlugin.js"; diff --git a/packages/app/src/features/apiStreamClient/FetchApiStreamClient.ts b/packages/app/src/features/apiStreamClient/FetchApiStreamClient.ts new file mode 100644 index 00000000000..d3da0c22d04 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/FetchApiStreamClient.ts @@ -0,0 +1,94 @@ +import { createImplementation } from "@webiny/di"; +import { ApiStreamClient, ApiStreamRequestError } from "./abstractions.js"; +import { EnvConfig } from "~/features/envConfig/index.js"; + +function toFetchHeaders(headers: ApiStreamClient.Headers = {}): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) { + result[key] = String(value); + } + } + return result; +} + +function joinUrl(base: string, path: string): string { + return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; +} + +class ApiStreamClientImpl implements ApiStreamClient.Interface { + private readonly apiUrl: string; + + constructor(envConfig: EnvConfig.Interface) { + // The API root, not `graphqlApiUrl` — streaming routes live beside /graphql, not under it. + this.apiUrl = envConfig.get("apiUrl"); + } + + async execute(params: ApiStreamClient.Request): Promise { + const method = params.method ?? "POST"; + const hasBody = params.body !== undefined && method !== "GET"; + + let response: Response; + try { + response = await fetch(joinUrl(this.apiUrl, params.path), { + method, + headers: { + accept: "text/event-stream", + ...(hasBody ? { "content-type": "application/json" } : {}), + ...toFetchHeaders(params.headers) + }, + body: hasBody ? JSON.stringify(params.body) : undefined, + signal: params.signal + }); + } catch (err) { + // Preserve an abort: it isn't a network failure and callers need to tell them apart. + if (err instanceof DOMException && err.name === "AbortError") { + throw err; + } + throw new Error(`Network error: ${(err as Error).message}`); + } + + if (!response.ok) { + throw await this.toError(response); + } + + if (!response.body) { + throw new ApiStreamRequestError( + "The response carried no readable body.", + response.status + ); + } + + return response; + } + + /** + * Streaming routes answer with a normal JSON error for anything they detect BEFORE opening the + * stream (unknown file, no permission, bad input), which is why those arrive here as a non-2xx + * rather than as an in-stream event. + */ + private async toError(response: Response): Promise { + let message = `Request failed with status ${response.status}.`; + let code: string | undefined; + + try { + const json = await response.json(); + if (json?.message) { + message = json.message; + } + if (json?.code) { + code = json.code; + } + } catch { + // Non-JSON error body — keep the status-based message. + } + + return new ApiStreamRequestError(message, response.status, code); + } +} + +export const FetchApiStreamClient = createImplementation({ + abstraction: ApiStreamClient, + implementation: ApiStreamClientImpl, + dependencies: [EnvConfig] +}); diff --git a/packages/app/src/features/apiStreamClient/__tests__/FetchApiStreamClient.test.ts b/packages/app/src/features/apiStreamClient/__tests__/FetchApiStreamClient.test.ts new file mode 100644 index 00000000000..53400a733e9 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/__tests__/FetchApiStreamClient.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Container } from "@webiny/di"; +import { ApiStreamClient, ApiStreamRequestError } from "../abstractions.js"; +import { FetchApiStreamClient } from "../FetchApiStreamClient.js"; +import { EnvConfig } from "~/features/envConfig/index.js"; + +function okResponse() { + return { + ok: true, + status: 200, + body: new ReadableStream({ + start(controller) { + controller.close(); + } + }) + }; +} + +describe("FetchApiStreamClient", () => { + let container: Container; + let client: ApiStreamClient.Interface; + + beforeEach(() => { + container = new Container(); + container.registerInstance(EnvConfig, { + get: vi.fn((key: string) => (key === "apiUrl" ? "https://api.example.com/" : undefined)) + } as any); + container.register(FetchApiStreamClient).inSingletonScope(); + client = container.resolve(ApiStreamClient); + }); + + it("should POST to the API root joined with the path", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + + await client.execute({ path: "/stream/fm/files/abc/enrich" }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/stream/fm/files/abc/enrich", + expect.objectContaining({ method: "POST" }) + ); + }); + + it("should not produce a double slash when joining", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + + await client.execute({ path: "stream/thing" }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/stream/thing", + expect.anything() + ); + }); + + it("should request an event stream and stringify the body", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + + await client.execute({ path: "/stream/x", body: { hello: "world" } }); + + const init = (global.fetch as any).mock.calls[0][1]; + expect(init.headers.accept).toBe("text/event-stream"); + expect(init.headers["content-type"]).toBe("application/json"); + expect(init.body).toBe(JSON.stringify({ hello: "world" })); + }); + + it("should pass through caller headers and drop undefined ones", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + + await client.execute({ + path: "/stream/x", + headers: { Authorization: "Bearer t", "x-tenant": "root", "x-skip": undefined } + }); + + const { headers } = (global.fetch as any).mock.calls[0][1]; + expect(headers.Authorization).toBe("Bearer t"); + expect(headers["x-tenant"]).toBe("root"); + expect("x-skip" in headers).toBe(false); + }); + + it("should omit a body on GET", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + + await client.execute({ path: "/stream/x", method: "GET", body: { ignored: true } }); + + const init = (global.fetch as any).mock.calls[0][1]; + expect(init.body).toBeUndefined(); + expect(init.headers["content-type"]).toBeUndefined(); + }); + + it("should surface a JSON error body as ApiStreamRequestError", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ + message: "File not found: abc", + code: "ENRICHMENT_FILE_NOT_FOUND" + }) + }); + + const error = await client.execute({ path: "/stream/x" }).catch(e => e); + + expect(error).toBeInstanceOf(ApiStreamRequestError); + expect(error.message).toBe("File not found: abc"); + expect(error.statusCode).toBe(404); + expect(error.code).toBe("ENRICHMENT_FILE_NOT_FOUND"); + }); + + it("should fall back to a status message when the error body isn't JSON", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 502, + json: async () => { + throw new Error("not json"); + } + }); + + const error = await client.execute({ path: "/stream/x" }).catch(e => e); + + expect(error).toBeInstanceOf(ApiStreamRequestError); + expect(error.message).toBe("Request failed with status 502."); + expect(error.statusCode).toBe(502); + }); + + it("should reject a 2xx response that carries no body", async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, body: null }); + + await expect(client.execute({ path: "/stream/x" })).rejects.toThrow( + "The response carried no readable body." + ); + }); + + it("should wrap a network failure", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("connection reset")); + + await expect(client.execute({ path: "/stream/x" })).rejects.toThrow( + "Network error: connection reset" + ); + }); + + it("should rethrow an abort untouched", async () => { + // Callers distinguish "user cancelled" from "request failed"; wrapping an abort in a generic + // network error would erase that. + global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError")); + + const error = await client.execute({ path: "/stream/x" }).catch(e => e); + + expect(error).toBeInstanceOf(DOMException); + expect(error.name).toBe("AbortError"); + }); + + it("should forward the abort signal to fetch", async () => { + global.fetch = vi.fn().mockResolvedValue(okResponse()); + const controller = new AbortController(); + + await client.execute({ path: "/stream/x", signal: controller.signal }); + + expect((global.fetch as any).mock.calls[0][1].signal).toBe(controller.signal); + }); +}); diff --git a/packages/app/src/features/apiStreamClient/__tests__/readServerSentEvents.test.ts b/packages/app/src/features/apiStreamClient/__tests__/readServerSentEvents.test.ts new file mode 100644 index 00000000000..d3c6d5b35e5 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/__tests__/readServerSentEvents.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { readServerSentEvents } from "../readServerSentEvents.js"; + +const encoder = new TextEncoder(); + +function responseFrom(chunks: (string | Uint8Array)[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk); + } + controller.close(); + } + }); + + return { body: stream } as Response; +} + +async function collect(response: Response): Promise { + const events: T[] = []; + for await (const event of readServerSentEvents(response)) { + events.push(event); + } + return events; +} + +describe("readServerSentEvents", () => { + it("should parse one event per record", async () => { + const events = await collect( + responseFrom(['data: {"type":"start"}\n\n', 'data: {"type":"done"}\n\n']) + ); + + expect(events).toEqual([{ type: "start" }, { type: "done" }]); + }); + + it("should parse multiple records arriving in a single chunk", async () => { + const events = await collect( + responseFrom(['data: {"n":1}\n\ndata: {"n":2}\n\ndata: {"n":3}\n\n']) + ); + + expect(events).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + }); + + it("should parse a record split across chunks", async () => { + const events = await collect(responseFrom(['data: {"ty', 'pe":"partial"}', "\n\n"])); + + expect(events).toEqual([{ type: "partial" }]); + }); + + it("should handle CRLF line endings", async () => { + const events = await collect(responseFrom(['data: {"ok":true}\r\n\r\n'])); + + expect(events).toEqual([{ ok: true }]); + }); + + it("should ignore comments and non-data fields", async () => { + const events = await collect( + responseFrom([ + ": heartbeat\n\n", + 'event: message\nid: 7\nretry: 500\ndata: {"kept":true}\n\n' + ]) + ); + + expect(events).toEqual([{ kept: true }]); + }); + + it("should join multi-line data fields", async () => { + const events = await collect(responseFrom(['data: {"a":1,\ndata: "b":2}\n\n'])); + + expect(events).toEqual([{ a: 1, b: 2 }]); + }); + + it("should preserve multi-byte characters split across chunks", async () => { + const payload = encoder.encode('data: {"text":"café"}\n\n'); + const split = 18; + + const events = await collect(responseFrom([payload.slice(0, split), payload.slice(split)])); + + expect(events).toEqual([{ text: "café" }]); + }); + + it("should drop a trailing record that never terminated", async () => { + // A truncated stream (server died mid-record) must not yield a half-parsed event. + const events = await collect(responseFrom(['data: {"complete":true}\n\ndata: {"trunc'])); + + expect(events).toEqual([{ complete: true }]); + }); + + it("should yield events as they arrive rather than after the stream closes", async () => { + let released!: () => void; + const gate = new Promise(resolve => { + released = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('data: {"n":1}\n\n')); + await gate; + controller.enqueue(encoder.encode('data: {"n":2}\n\n')); + controller.close(); + } + }); + + const iterator = readServerSentEvents<{ n: number }>({ body: stream } as Response); + + // Resolving before the gate opens proves events aren't buffered until close. + expect((await iterator.next()).value).toEqual({ n: 1 }); + released(); + expect((await iterator.next()).value).toEqual({ n: 2 }); + expect((await iterator.next()).done).toBe(true); + }); + + it("should throw when the response has no body", async () => { + await expect(collect({ body: null } as Response)).rejects.toThrow( + "The response carried no readable body." + ); + }); + + it("should propagate a stream error", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error("stream broke")); + } + }); + + await expect(collect({ body: stream } as Response)).rejects.toThrow("stream broke"); + }); +}); diff --git a/packages/app/src/features/apiStreamClient/abstractions.ts b/packages/app/src/features/apiStreamClient/abstractions.ts new file mode 100644 index 00000000000..2de438c1e27 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/abstractions.ts @@ -0,0 +1,43 @@ +import { createAbstraction } from "@webiny/feature/admin"; + +type IHeaders = Record; + +export interface IApiStreamRequest { + /** Path relative to the API root, e.g. `/stream/fm/files/abc/enrich`. */ + path: string; + method?: "GET" | "POST"; + /** Serialized as JSON when present. */ + body?: unknown; + headers?: IHeaders; + signal?: AbortSignal; +} + +/** + * Client for API endpoints that stream their response. + * + * Deliberately separate from `GraphQLClient`: that abstraction returns `Promise` — a + * buffered contract by type — and Webiny's GraphQL layer (graphql-js 16) has no incremental + * delivery, so a streaming response can't travel through it. This returns the raw `Response` so the + * caller owns the read loop and can hand `response.body` to any stream consumer. + */ +export interface IApiStreamClient { + execute(params: IApiStreamRequest): Promise; +} + +export const ApiStreamClient = createAbstraction("ApiStreamClient"); + +export namespace ApiStreamClient { + export type Headers = IHeaders; + export type Interface = IApiStreamClient; + export type Request = IApiStreamRequest; +} + +export class ApiStreamRequestError extends Error { + constructor( + message: string, + readonly statusCode: number, + readonly code?: string + ) { + super(message); + } +} diff --git a/packages/app/src/features/apiStreamClient/feature.ts b/packages/app/src/features/apiStreamClient/feature.ts new file mode 100644 index 00000000000..0f8fb278f53 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/feature.ts @@ -0,0 +1,15 @@ +import { ApiStreamClient } from "./abstractions.js"; +import { FetchApiStreamClient } from "./FetchApiStreamClient.js"; +import { createFeature } from "~/shared/di/createFeature.js"; + +export const ApiStreamClientFeature = createFeature({ + name: "ApiStreamClient", + register(container) { + container.register(FetchApiStreamClient).inSingletonScope(); + }, + resolve(container) { + return { + client: container.resolve(ApiStreamClient) + }; + } +}); diff --git a/packages/app/src/features/apiStreamClient/index.ts b/packages/app/src/features/apiStreamClient/index.ts new file mode 100644 index 00000000000..42a4cebbe62 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/index.ts @@ -0,0 +1,4 @@ +export * from "./abstractions.js"; +export * from "./FetchApiStreamClient.js"; +export * from "./readServerSentEvents.js"; +export * from "./feature.js"; diff --git a/packages/app/src/features/apiStreamClient/readServerSentEvents.ts b/packages/app/src/features/apiStreamClient/readServerSentEvents.ts new file mode 100644 index 00000000000..7d45b637f76 --- /dev/null +++ b/packages/app/src/features/apiStreamClient/readServerSentEvents.ts @@ -0,0 +1,52 @@ +/** + * Read a `text/event-stream` response as a sequence of parsed JSON events. + * + * Written against `Response.body` rather than `EventSource` on purpose: `EventSource` can only issue + * GET requests and cannot set an `Authorization` header, both of which the API requires. + * + * Only the `data:` field is interpreted — enough for Webiny's streaming routes, which frame one JSON + * object per record. Comment lines (`:` heartbeats), `event:`, `id:` and `retry:` are ignored. + */ +export async function* readServerSentEvents(response: Response): AsyncGenerator { + const body = response.body; + if (!body) { + throw new Error("The response carried no readable body."); + } + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + // `stream: true` keeps a multi-byte character split across chunks intact. + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); + + let separator = buffer.indexOf("\n\n"); + while (separator !== -1) { + const record = buffer.slice(0, separator); + buffer = buffer.slice(separator + 2); + + const data = record + .split("\n") + .filter(line => line.startsWith("data:")) + .map(line => line.slice("data:".length).trim()) + .join("\n"); + + if (data) { + yield JSON.parse(data) as TEvent; + } + + separator = buffer.indexOf("\n\n"); + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/event-handler-aws/__tests__/functionUrlEventToHttpRequest.test.ts b/packages/event-handler-aws/__tests__/functionUrlEventToHttpRequest.test.ts new file mode 100644 index 00000000000..11b0c1c7aa4 --- /dev/null +++ b/packages/event-handler-aws/__tests__/functionUrlEventToHttpRequest.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { functionUrlEventToHttpRequest } from "~/translators/functionUrlEventToHttpRequest.js"; + +function event(overrides: Record = {}) { + return { + version: "2.0", + routeKey: "$default", + rawPath: "/stream/fm/files/abc/enrich", + rawQueryString: "", + headers: { "content-type": "application/json" }, + requestContext: { + http: { method: "POST", path: "/stream/fm/files/abc/enrich" }, + requestId: "req-1", + stage: "$default" + }, + isBase64Encoded: false, + ...overrides + }; +} + +describe("functionUrlEventToHttpRequest", () => { + it("should read the method and path", () => { + const request = functionUrlEventToHttpRequest(event()); + + expect(request.method).toBe("POST"); + expect(request.path).toBe("/stream/fm/files/abc/enrich"); + }); + + it("should not strip a stage prefix", () => { + // A Function URL always serves `$default`, so `rawPath` is the real path. Stripping the way the + // API Gateway translator does would corrupt a route that legitimately starts with the stage name. + const request = functionUrlEventToHttpRequest( + event({ rawPath: "/$default/stream/x", requestContext: { http: { method: "GET" } } }) + ); + + expect(request.path).toBe("/$default/stream/x"); + }); + + it("should default the path when rawPath is empty", () => { + expect(functionUrlEventToHttpRequest(event({ rawPath: "" })).path).toBe("/"); + }); + + describe("cookies", () => { + it("should fold the cookies array into a cookie header", () => { + const request = functionUrlEventToHttpRequest( + event({ cookies: ["wby-id-token=abc", "other=1"] }) + ); + + expect(request.headers.cookie).toBe("wby-id-token=abc; other=1"); + }); + + it("should leave headers alone when there are no cookies", () => { + expect(functionUrlEventToHttpRequest(event()).headers.cookie).toBeUndefined(); + expect( + functionUrlEventToHttpRequest(event({ cookies: [] })).headers.cookie + ).toBeUndefined(); + }); + }); + + describe("query string", () => { + it("should prefer the pre-parsed map", () => { + const request = functionUrlEventToHttpRequest( + event({ queryStringParameters: { a: "1" }, rawQueryString: "b=2" }) + ); + + expect(request.query).toEqual({ a: "1" }); + }); + + it("should parse rawQueryString when the map is absent", () => { + const request = functionUrlEventToHttpRequest(event({ rawQueryString: "a=1&b=two" })); + + expect(request.query).toEqual({ a: "1", b: "two" }); + }); + + it("should return an empty object for a query-less request", () => { + expect(functionUrlEventToHttpRequest(event()).query).toEqual({}); + }); + }); + + describe("body", () => { + it("should parse a JSON body", () => { + const request = functionUrlEventToHttpRequest(event({ body: '{"a":1}' })); + + expect(request.body).toEqual({ a: 1 }); + }); + + it("should fall back to the raw string when the body isn't JSON", () => { + const request = functionUrlEventToHttpRequest(event({ body: "not json" })); + + expect(request.body).toBe("not json"); + }); + + it("should be undefined when there is no body", () => { + expect(functionUrlEventToHttpRequest(event()).body).toBeUndefined(); + }); + + it("should decode a base64 JSON body", () => { + const request = functionUrlEventToHttpRequest( + event({ + body: Buffer.from('{"a":1}').toString("base64"), + isBase64Encoded: true + }) + ); + + expect(request.body).toEqual({ a: 1 }); + }); + + it("should decode a base64 text body", () => { + const request = functionUrlEventToHttpRequest( + event({ + headers: { "content-type": "text/plain" }, + body: Buffer.from("hello").toString("base64"), + isBase64Encoded: true + }) + ); + + expect(request.body).toBe("hello"); + }); + + it("should keep a base64 binary body as raw bytes", () => { + // Decoding arbitrary bytes as utf8 would corrupt them, so anything not declared as text + // stays a Buffer. + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const request = functionUrlEventToHttpRequest( + event({ + headers: { "content-type": "application/octet-stream" }, + body: bytes.toString("base64"), + isBase64Encoded: true + }) + ); + + expect(Buffer.isBuffer(request.body)).toBe(true); + expect([...(request.body as Buffer)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + }); +}); diff --git a/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts new file mode 100644 index 00000000000..3065ce8488f --- /dev/null +++ b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { HttpRoute, HttpStreamBody } from "@webiny/event-handler-core"; +import type { IHttpRequest, IHttpResponse } from "@webiny/event-handler-core"; +import { createStreamLambdaHandler } from "~/createStreamLambdaHandler.js"; +import { FunctionUrlStreamFeature } from "~/features/FunctionUrlStreamFeature.js"; +import type { IRawResponseStream, IResponseStreamMetadata } from "~/streaming/awslambda.js"; + +const decoder = new TextDecoder(); + +class FakeResponseStream implements IRawResponseStream { + chunks: string[] = []; + ended = false; + destroyed = false; + destroyedWith?: Error; + /** Set to a number to make that many writes report a full buffer. */ + backPressureFor = 0; + private drainListeners: (() => void)[] = []; + + write(chunk: Uint8Array | string): boolean { + this.chunks.push(typeof chunk === "string" ? chunk : decoder.decode(chunk)); + + if (this.backPressureFor > 0) { + this.backPressureFor--; + // Release on the next tick, the way a real socket would. + setTimeout(() => { + const listeners = this.drainListeners; + this.drainListeners = []; + listeners.forEach(listener => listener()); + }, 0); + return false; + } + + return true; + } + + end(): void { + this.ended = true; + } + + destroy(error?: Error): void { + this.destroyed = true; + this.destroyedWith = error; + } + + once(event: string, listener: () => void): unknown { + if (event === "drain") { + this.drainListeners.push(listener); + } + return this; + } + + get body(): string { + return this.chunks.join(""); + } +} + +function functionUrlEvent(method = "POST", path = "/stream/test") { + return { + version: "2.0", + routeKey: "$default", + rawPath: path, + rawQueryString: "", + headers: {}, + requestContext: { + http: { method, path }, + requestId: "req-1", + stage: "$default" + }, + isBase64Encoded: false + }; +} + +function makeRoute(handle: (request: IHttpRequest) => Promise) { + class TestRouteImplementation implements HttpRoute.Interface { + readonly method = "POST"; + readonly path = "/stream/test"; + handle = handle; + } + + return HttpRoute.createImplementation({ + implementation: TestRouteImplementation, + dependencies: [] + }); +} + +function makeHandler(route: ReturnType) { + return createStreamLambdaHandler({ + root: container => { + FunctionUrlStreamFeature.register(container); + container.register(route); + } + }); +} + +describe("Lambda Function URL response streaming", () => { + let prelude: IResponseStreamMetadata | null; + let streamified: unknown[]; + + beforeEach(() => { + prelude = null; + streamified = []; + + (globalThis as any).awslambda = { + streamifyResponse: (handler: unknown) => { + streamified.push(handler); + return handler; + }, + HttpResponseStream: { + from: (stream: IRawResponseStream, metadata: IResponseStreamMetadata) => { + prelude = metadata; + return stream; + } + } + }; + }); + + afterEach(() => { + delete (globalThis as any).awslambda; + }); + + it("should mark the handler as streaming at creation time", () => { + // The runtime inspects the EXPORTED handler for streamifyResponse's mark, so the wrap has to + // happen when the handler is built — not lazily on first invocation. + makeHandler(makeRoute(async () => ({ statusCode: 200, body: "ok" }))); + + expect(streamified).toHaveLength(1); + }); + + it("should send the status code and headers as the prelude", async () => { + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 201, + headers: { "content-type": "text/event-stream" }, + body: "ok" + })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(prelude!.statusCode).toBe(201); + expect(prelude!.headers!["content-type"]).toBe("text/event-stream"); + // SecureHeadersDecorator runs on this transport too (HttpFeature registers it), so a browser + // reading the stream cross-origin gets the same CORS treatment as a GraphQL call. + expect(prelude!.headers!["access-control-allow-origin"]).toBe("*"); + }); + + it("should write stream chunks in order and end the stream", async () => { + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + yield "data: one\n\n"; + yield "data: two\n\n"; + } + }) + })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.chunks).toEqual(["data: one\n\n", "data: two\n\n"]); + expect(stream.ended).toBe(true); + }); + + it("should write each chunk separately rather than concatenating first", async () => { + // One write per chunk is what makes delivery incremental; a single joined write would arrive + // as one lump. + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + yield "a"; + yield "b"; + yield "c"; + } + }) + })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.chunks).toHaveLength(3); + }); + + it("should honour back-pressure", async () => { + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + yield "one"; + yield "two"; + yield "three"; + } + }) + })) + ); + const stream = new FakeResponseStream(); + stream.backPressureFor = 2; + + await handler(functionUrlEvent(), stream); + + expect(stream.body).toBe("onetwothree"); + expect(stream.ended).toBe(true); + }); + + it("should stop pulling from the producer once the stream is destroyed", async () => { + let produced = 0; + const stream = new FakeResponseStream(); + + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + while (true) { + produced++; + // Simulate the client disconnecting after the first chunk. + stream.destroy(); + yield `chunk-${produced}`; + } + } + }) + })) + ); + + await handler(functionUrlEvent(), stream); + + expect(produced).toBe(1); + }); + + it("should write a buffered string body", async () => { + const handler = makeHandler(makeRoute(async () => ({ statusCode: 200, body: "plain" }))); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.body).toBe("plain"); + }); + + it("should JSON-stringify a buffered object body", async () => { + const handler = makeHandler( + makeRoute(async () => ({ statusCode: 200, body: { ok: true } })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.body).toBe(JSON.stringify({ ok: true })); + }); + + it("should send an empty body for null", async () => { + const handler = makeHandler(makeRoute(async () => ({ statusCode: 204, body: null }))); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.chunks).toHaveLength(0); + expect(stream.ended).toBe(true); + }); + + it("should hand the translated request to the route", async () => { + let received: IHttpRequest | null = null; + const handler = makeHandler( + makeRoute(async request => { + received = request; + return { statusCode: 200, body: "ok" }; + }) + ); + + const event = functionUrlEvent(); + event.headers = { "x-tenant": "root" }; + (event as any).cookies = ["wby-id-token=abc"]; + (event as any).body = '{"hello":"world"}'; + + await handler(event, new FakeResponseStream()); + + expect(received!.method).toBe("POST"); + expect(received!.path).toBe("/stream/test"); + expect(received!.headers["x-tenant"]).toBe("root"); + expect(received!.headers.cookie).toBe("wby-id-token=abc"); + expect(received!.body).toEqual({ hello: "world" }); + }); + + it("should answer 404 for an unmatched route", async () => { + const handler = makeHandler(makeRoute(async () => ({ statusCode: 200, body: "ok" }))); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent("POST", "/stream/nope"), stream); + + expect(prelude!.statusCode).toBe(404); + expect(JSON.parse(stream.body).message).toContain("Route not found"); + }); + + it("should answer 500 when the route throws before streaming", async () => { + const handler = makeHandler( + makeRoute(async () => { + throw new Error("route exploded"); + }) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(prelude!.statusCode).toBe(500); + expect(JSON.parse(stream.body).message).toBe("Internal server error"); + }); + + it("should destroy the stream when the producer fails mid-response", async () => { + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + yield "partial"; + throw new Error("producer exploded"); + } + }) + })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + // The prelude already claimed 200, so a truncated body is the only way to signal failure. + expect(prelude!.statusCode).toBe(200); + expect(stream.body).toBe("partial"); + expect(stream.destroyed).toBe(true); + expect(stream.destroyedWith?.message).toBe("producer exploded"); + expect(stream.ended).toBe(false); + }); + + it("should not require the awslambda global to build the handler", () => { + delete (globalThis as any).awslambda; + + // Importing a bundle that exports both the buffered and the streaming handler must not throw + // outside the streaming runtime. + expect(() => + makeHandler(makeRoute(async () => ({ statusCode: 200, body: "ok" }))) + ).not.toThrow(); + }); +}); diff --git a/packages/event-handler-aws/__tests__/httpResponseToApiGatewayResult.test.ts b/packages/event-handler-aws/__tests__/httpResponseToApiGatewayResult.test.ts new file mode 100644 index 00000000000..b30577316bd --- /dev/null +++ b/packages/event-handler-aws/__tests__/httpResponseToApiGatewayResult.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { HttpStreamBody } from "@webiny/event-handler-core"; +import { httpResponseToApiGatewayResult } from "~/translators/httpResponseToApiGatewayResult.js"; + +async function* sseChunks() { + yield "data: one\n\n"; + yield "data: two\n\n"; +} + +describe("httpResponseToApiGatewayResult", () => { + it("should pass a string body through unchanged", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + body: "plain" + }); + + expect(result.body).toBe("plain"); + expect(result.isBase64Encoded).toBeUndefined(); + }); + + it("should JSON-stringify an object body", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + body: { ok: true } + }); + + expect(result.body).toBe(JSON.stringify({ ok: true })); + }); + + it("should base64-encode a Buffer body", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "content-type": "image/png" }, + body: Buffer.from("PNG") + }); + + expect(result.isBase64Encoded).toBe(true); + expect(Buffer.from(result.body, "base64").toString()).toBe("PNG"); + }); + + it("should send an empty body for null/undefined", async () => { + await expect( + httpResponseToApiGatewayResult({ statusCode: 204, body: undefined }) + ).resolves.toMatchObject({ body: "" }); + + await expect( + httpResponseToApiGatewayResult({ statusCode: 204, body: null }) + ).resolves.toMatchObject({ body: "" }); + }); + + describe("streaming bodies", () => { + it("should drain an SSE stream into a text body", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: new HttpStreamBody(sseChunks()) + }); + + expect(result.statusCode).toBe(200); + expect(result.body).toBe("data: one\n\ndata: two\n\n"); + // Text must NOT be base64'd — a client reading the buffered response expects the same + // bytes it would have received incrementally. + expect(result.isBase64Encoded).toBeUndefined(); + }); + + it("should drain a JSON stream into a text body", async () => { + async function* jsonChunks() { + yield '{"a"'; + yield ":1}"; + } + + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "Content-Type": "application/json" }, + body: new HttpStreamBody(jsonChunks()) + }); + + expect(result.body).toBe('{"a":1}'); + }); + + it("should match the content-type header regardless of casing", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "CONTENT-TYPE": "text/event-stream; charset=utf-8" }, + body: new HttpStreamBody(sseChunks()) + }); + + expect(result.body).toBe("data: one\n\ndata: two\n\n"); + expect(result.isBase64Encoded).toBeUndefined(); + }); + + it("should base64-encode a binary stream", async () => { + async function* binaryChunks() { + yield new Uint8Array([0x89, 0x50]); + yield new Uint8Array([0x4e, 0x47]); + } + + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "content-type": "application/octet-stream" }, + body: new HttpStreamBody(binaryChunks()) + }); + + expect(result.isBase64Encoded).toBe(true); + expect([...Buffer.from(result.body, "base64")]).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + + it("should base64-encode a stream with no content type", async () => { + // No content-type means we cannot claim it is text, so the safe choice is binary — + // decoding arbitrary bytes as utf8 would corrupt them. + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + body: new HttpStreamBody(sseChunks()) + }); + + expect(result.isBase64Encoded).toBe(true); + expect(Buffer.from(result.body, "base64").toString()).toBe( + "data: one\n\ndata: two\n\n" + ); + }); + + it("should preserve the response headers", async () => { + const result = await httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "content-type": "text/event-stream", "x-custom": "kept" }, + body: new HttpStreamBody(sseChunks()) + }); + + expect(result.headers).toEqual({ + "content-type": "text/event-stream", + "x-custom": "kept" + }); + }); + + it("should propagate a producer error", async () => { + async function* failing() { + yield "data: partial\n\n"; + throw new Error("producer exploded"); + } + + await expect( + httpResponseToApiGatewayResult({ + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: new HttpStreamBody(failing()) + }) + ).rejects.toThrow("producer exploded"); + }); + }); +}); diff --git a/packages/event-handler-aws/src/AwsLambdaStreamTransport.ts b/packages/event-handler-aws/src/AwsLambdaStreamTransport.ts new file mode 100644 index 00000000000..1f16fa2a03b --- /dev/null +++ b/packages/event-handler-aws/src/AwsLambdaStreamTransport.ts @@ -0,0 +1,33 @@ +import type { Container } from "@webiny/di"; +import type { Transport } from "@webiny/event-handler-core"; +import type { Context } from "@webiny/aws-sdk/types/index.js"; +import { AwsLambdaEvent } from "./abstractions/AwsLambdaEvent.js"; +import { AwsLambdaContext, AwsLambdaContextValue } from "./abstractions/AwsLambdaContext.js"; +import { NullAwsLambdaContext } from "./abstractions/NullAwsLambdaContext.js"; +import { + LambdaResponseStream, + LambdaResponseStreamValue +} from "./abstractions/LambdaResponseStream.js"; +import type { IRawResponseStream } from "./streaming/awslambda.js"; + +/** + * AWS Lambda response-streaming transport. Same job as {@link awsLambdaTransport}, plus the third + * argument the streaming runtime supplies: the response stream, which the terminal handler resolves + * and writes to. + * + * The streaming runtime calls handlers as `(event, responseStream, context)` — note the context is + * the THIRD argument here, not the second. + */ +export const awsLambdaStreamTransport: Transport = { + bind(container: Container, event: any, responseStream: IRawResponseStream, context?: Context) { + container.registerInstance(AwsLambdaEvent, event); + container.registerInstance( + LambdaResponseStream, + new LambdaResponseStreamValue(responseStream) + ); + container.registerInstance( + AwsLambdaContext, + context ? new AwsLambdaContextValue(context) : new NullAwsLambdaContext() + ); + } +}; diff --git a/packages/event-handler-aws/src/abstractions/LambdaResponseStream.ts b/packages/event-handler-aws/src/abstractions/LambdaResponseStream.ts new file mode 100644 index 00000000000..06cfe2f7e74 --- /dev/null +++ b/packages/event-handler-aws/src/abstractions/LambdaResponseStream.ts @@ -0,0 +1,24 @@ +import { Abstraction } from "@webiny/di"; +import type { IRawResponseStream } from "~/streaming/awslambda.js"; + +export interface ILambdaResponseStream { + get(): IRawResponseStream; +} + +/** + * The per-invocation response stream handed over by the Lambda runtime, bound into the request + * container by {@link awsLambdaStreamTransport} so the terminal handler can write to it. + */ +export const LambdaResponseStream = new Abstraction("LambdaResponseStream"); + +export namespace LambdaResponseStream { + export type Interface = ILambdaResponseStream; +} + +export class LambdaResponseStreamValue implements ILambdaResponseStream { + constructor(private stream: IRawResponseStream) {} + + get(): IRawResponseStream { + return this.stream; + } +} diff --git a/packages/event-handler-aws/src/abstractions/handlers/FunctionUrlStreamEventHandler.ts b/packages/event-handler-aws/src/abstractions/handlers/FunctionUrlStreamEventHandler.ts new file mode 100644 index 00000000000..e2678902b43 --- /dev/null +++ b/packages/event-handler-aws/src/abstractions/handlers/FunctionUrlStreamEventHandler.ts @@ -0,0 +1,20 @@ +import { Abstraction } from "@webiny/di"; +import type { IEventHandler } from "@webiny/event-handler-core"; + +/** + * Terminal-handler abstraction for the Lambda Function URL response-streaming transport. + * + * Separate from `ApiGatewayEventHandler` even though the event payloads look alike, because the + * result contract is different: this handler writes to the response stream and returns nothing, + * whereas an API Gateway handler returns a buffered `APIGatewayProxyResult`. Keeping them apart also + * means the auth/tenant decorators for one transport can't silently apply to the other. + */ +export interface IFunctionUrlStreamEventHandler extends IEventHandler {} + +export const FunctionUrlStreamEventHandler = new Abstraction( + "FunctionUrlStreamEventHandler" +); + +export namespace FunctionUrlStreamEventHandler { + export type Interface = IFunctionUrlStreamEventHandler; +} diff --git a/packages/event-handler-aws/src/abstractions/handlers/index.ts b/packages/event-handler-aws/src/abstractions/handlers/index.ts index c87e7adfa4e..13aff9bbf0e 100644 --- a/packages/event-handler-aws/src/abstractions/handlers/index.ts +++ b/packages/event-handler-aws/src/abstractions/handlers/index.ts @@ -1,4 +1,5 @@ export * from "./ApiGatewayEventHandler.js"; +export * from "./FunctionUrlStreamEventHandler.js"; export * from "./SnsEventHandler.js"; export * from "./SqsEventHandler.js"; export * from "./S3EventHandler.js"; diff --git a/packages/event-handler-aws/src/abstractions/index.ts b/packages/event-handler-aws/src/abstractions/index.ts index 87a2f742ace..cacca8469a1 100644 --- a/packages/event-handler-aws/src/abstractions/index.ts +++ b/packages/event-handler-aws/src/abstractions/index.ts @@ -2,3 +2,4 @@ export * from "./handlers/index.js"; export * from "./AwsLambdaContext.js"; export * from "./NullAwsLambdaContext.js"; export * from "./AwsLambdaEvent.js"; +export * from "./LambdaResponseStream.js"; diff --git a/packages/event-handler-aws/src/createStreamLambdaHandler.ts b/packages/event-handler-aws/src/createStreamLambdaHandler.ts new file mode 100644 index 00000000000..2ac95f97b18 --- /dev/null +++ b/packages/event-handler-aws/src/createStreamLambdaHandler.ts @@ -0,0 +1,54 @@ +import { createHandler } from "@webiny/event-handler-core"; +import type { HandlerSetup } from "@webiny/event-handler-core"; +import type { Context } from "@webiny/aws-sdk/types/index.js"; +import { awsLambdaStreamTransport } from "./AwsLambdaStreamTransport.js"; +import { getAwsLambdaGlobal, isAwsLambdaStreamingRuntime } from "./streaming/awslambda.js"; +import type { IRawResponseStream } from "./streaming/awslambda.js"; + +export interface CreateStreamLambdaHandlerOptions { + root: HandlerSetup; + request?: HandlerSetup; +} + +export type StreamLambdaHandler = ( + event: any, + responseStream: IRawResponseStream, + context?: Context +) => Promise; + +/** + * Response-streaming counterpart to {@link createLambdaHandler}. The returned handler is wrapped in + * `awslambda.streamifyResponse`, which is what makes the Lambda runtime invoke it as + * `(event, responseStream, context)` and stream the response back. + * + * The wrap has to happen HERE, eagerly, and the result has to be what the module exports: + * `streamifyResponse` marks the function it returns, and the runtime inspects the exported handler for + * that mark. Deferring the wrap until first invocation would leave the export unmarked and the + * function would silently fall back to buffered responses. + * + * Only usable on a Lambda function whose Function URL has `InvokeMode: RESPONSE_STREAM`. API Gateway + * cannot stream — it buffers the whole Lambda response regardless of how it was produced — so a + * function fronted by API Gateway must keep using `createLambdaHandler`. + */ +export function createStreamLambdaHandler( + options: CreateStreamLambdaHandlerOptions +): StreamLambdaHandler { + const handle = createHandler({ + root: options.root, + request: options.request, + transport: awsLambdaStreamTransport + }); + + const handler: StreamLambdaHandler = async (event, responseStream, context) => { + await handle(event, responseStream, context); + }; + + if (!isAwsLambdaStreamingRuntime()) { + // Outside the Lambda runtime (tests, local dev) there is no `awslambda` global to mark the + // handler with. Return it unwrapped so merely importing the module doesn't throw — it still + // routes and writes to whatever stream it is handed. + return handler; + } + + return getAwsLambdaGlobal().streamifyResponse(handler); +} diff --git a/packages/event-handler-aws/src/eventTypes/FunctionUrlStreamEventType.ts b/packages/event-handler-aws/src/eventTypes/FunctionUrlStreamEventType.ts new file mode 100644 index 00000000000..1f6836ae775 --- /dev/null +++ b/packages/event-handler-aws/src/eventTypes/FunctionUrlStreamEventType.ts @@ -0,0 +1,27 @@ +import { EventType } from "@webiny/event-handler-core"; +import type { IEventType } from "@webiny/event-handler-core"; +import { FunctionUrlStreamEventHandler } from "~/abstractions/handlers/FunctionUrlStreamEventHandler.js"; + +/** + * Recognises a Lambda Function URL HTTP invocation (payload format 2.0). + * + * The payload is all but identical to API Gateway v2, so this MUST NOT be registered in the same + * container as `ApiGatewayEventType` — both would match and the first registration would win. That is + * not a real constraint in practice: response streaming requires its own Lambda function (the handler + * entry point is fixed per function and a streamified handler is only valid under RESPONSE_STREAM), + * so the streaming composition root registers this one and never `ApiGatewayFeature`. + */ +class FunctionUrlStreamEventTypeImpl implements IEventType { + canHandle(event: any): event is any { + return Boolean(event?.rawPath && event?.requestContext?.http?.method); + } + + getHandlerAbstraction() { + return FunctionUrlStreamEventHandler; + } +} + +export const FunctionUrlStreamEventType = EventType.createImplementation({ + implementation: FunctionUrlStreamEventTypeImpl, + dependencies: [] +}); diff --git a/packages/event-handler-aws/src/eventTypes/index.ts b/packages/event-handler-aws/src/eventTypes/index.ts index d1304cd1485..3c3a0db592f 100644 --- a/packages/event-handler-aws/src/eventTypes/index.ts +++ b/packages/event-handler-aws/src/eventTypes/index.ts @@ -1,4 +1,5 @@ export * from "./ApiGatewayEventType.js"; +export * from "./FunctionUrlStreamEventType.js"; export * from "./S3EventType.js"; export * from "./SqsEventType.js"; export * from "./SnsEventType.js"; diff --git a/packages/event-handler-aws/src/features/FunctionUrlStreamFeature.ts b/packages/event-handler-aws/src/features/FunctionUrlStreamFeature.ts new file mode 100644 index 00000000000..22bab6a8bf2 --- /dev/null +++ b/packages/event-handler-aws/src/features/FunctionUrlStreamFeature.ts @@ -0,0 +1,27 @@ +import { createFeature } from "@webiny/feature/api"; +import type { Container } from "@webiny/di"; +import { HttpFeature } from "@webiny/event-handler-core"; +import { FunctionUrlStreamEventType } from "~/eventTypes/FunctionUrlStreamEventType.js"; +import { FunctionUrlStreamRouterHandler } from "~/handlers/FunctionUrlStreamRouterHandler.js"; + +/** + * Registers the transport-only Lambda Function URL response-streaming infrastructure: + * - FunctionUrlStreamEventType (recognises a Function URL HTTP invocation) + * - HttpFeature (HttpRouter + RequestContextInitializerDecorator + SecureHeadersDecorator) + * - FunctionUrlStreamRouterHandler (terminal: routes via HttpRouter, writes to the response stream) + * + * Must NOT be combined with `ApiGatewayFeature` in the same container — the two event types match the + * same payload shape. Streaming needs its own Lambda function anyway, so the two live in separate + * composition roots. + * + * Auth/tenant establishment is NOT here; it lives in the composition layer + * (@webiny/api-event-handler-aws), same split as `ApiGatewayFeature`. + */ +export const FunctionUrlStreamFeature = createFeature({ + name: "FunctionUrlStream", + register(container: Container) { + container.register(FunctionUrlStreamEventType); + HttpFeature.register(container); + container.register(FunctionUrlStreamRouterHandler); + } +}); diff --git a/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts b/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts new file mode 100644 index 00000000000..2559297bbff --- /dev/null +++ b/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts @@ -0,0 +1,128 @@ +import { HttpRouter, HttpStreamBody, RouteNotFoundError } from "@webiny/event-handler-core"; +import type { EventContext, IHttpResponse, NextFunction } from "@webiny/event-handler-core"; +import { FunctionUrlStreamEventHandler } from "~/abstractions/handlers/FunctionUrlStreamEventHandler.js"; +import { LambdaResponseStream } from "~/abstractions/LambdaResponseStream.js"; +import { functionUrlEventToHttpRequest } from "~/translators/functionUrlEventToHttpRequest.js"; +import { getAwsLambdaGlobal } from "~/streaming/awslambda.js"; +import type { IRawResponseStream } from "~/streaming/awslambda.js"; + +const JSON_HEADERS = { "content-type": "application/json" }; + +/** + * Terminal handler for the Lambda Function URL response-streaming transport. Routes through the same + * `HttpRouter` every other transport uses, then writes the result to the invocation's response stream + * — incrementally when the route returned an {@link HttpStreamBody}, in one shot otherwise. + * + * Mirrors `ApiGatewayHttpRouterHandler`, except nothing is returned: the response leaves through the + * stream, so once the prelude is written the status code can no longer change. + */ +class FunctionUrlStreamRouterHandlerImpl implements FunctionUrlStreamEventHandler.Interface { + constructor( + private router: HttpRouter.Interface, + private responseStream: LambdaResponseStream.Interface + ) {} + + async execute(ctx: EventContext, _next: NextFunction): Promise { + const raw = this.responseStream.get(); + const request = functionUrlEventToHttpRequest(ctx.event); + + let response: IHttpResponse; + try { + response = await this.router.route(request); + } catch (e) { + await this.writeResponse(raw, this.errorResponse(e)); + return; + } + + try { + await this.writeResponse(raw, response); + } catch (e) { + // The prelude (and probably some body) is already out, so there is no way to turn this + // into a 500. Destroy the stream so the client sees a truncated response instead of a + // complete-looking one. + console.error("Streaming HTTP handler failed mid-response:", e); + if (raw.destroy) { + raw.destroy(e instanceof Error ? e : new Error(String(e))); + } else { + raw.end(); + } + } + } + + private errorResponse(e: unknown): IHttpResponse { + if (e instanceof RouteNotFoundError) { + return { statusCode: 404, headers: JSON_HEADERS, body: { message: e.message } }; + } + if (e && typeof e === "object" && (e as any).code) { + console.error("HTTP handler WebinyError:", (e as any).code, (e as any).message, e); + return { + statusCode: 500, + headers: JSON_HEADERS, + body: { + message: (e as any).message, + code: (e as any).code, + data: (e as any).data ?? null + } + }; + } + console.error("HTTP handler error:", e); + return { + statusCode: 500, + headers: JSON_HEADERS, + body: { message: "Internal server error" } + }; + } + + private async writeResponse(raw: IRawResponseStream, response: IHttpResponse): Promise { + const { HttpResponseStream } = getAwsLambdaGlobal(); + + // Sends the status code and headers as the stream prelude. Must happen before any write. + const stream = HttpResponseStream.from(raw, { + statusCode: response.statusCode, + headers: response.headers + }); + + const { body } = response; + + if (HttpStreamBody.is(body)) { + for await (const chunk of body.source) { + if (stream.destroyed) { + // Client went away mid-stream; stop pulling from the producer. + break; + } + await this.write(stream, chunk); + } + stream.end(); + return; + } + + if (body !== undefined && body !== null) { + await this.write(stream, this.serialize(body)); + } + + stream.end(); + } + + private serialize(body: any): Uint8Array | string { + if (Buffer.isBuffer(body) || body instanceof Uint8Array) { + return body; + } + return typeof body === "string" ? body : JSON.stringify(body); + } + + private async write(stream: IRawResponseStream, chunk: Uint8Array | string): Promise { + const flushed = stream.write(chunk); + + // The runtime hands over a Node Writable, so honour back-pressure when it signals a full + // buffer. Guarded by feature detection: `write` is typed as possibly returning void, and a + // stubbed stream (tests) need not implement `once`. + if (flushed === false && typeof stream.once === "function") { + await new Promise(resolve => stream.once!("drain", resolve)); + } + } +} + +export const FunctionUrlStreamRouterHandler = FunctionUrlStreamEventHandler.createImplementation({ + implementation: FunctionUrlStreamRouterHandlerImpl, + dependencies: [HttpRouter, LambdaResponseStream] +}); diff --git a/packages/event-handler-aws/src/handlers/index.ts b/packages/event-handler-aws/src/handlers/index.ts index 5570099195e..4226a97f446 100644 --- a/packages/event-handler-aws/src/handlers/index.ts +++ b/packages/event-handler-aws/src/handlers/index.ts @@ -1 +1,2 @@ export * from "./ApiGatewayHttpRouterHandler.js"; +export * from "./FunctionUrlStreamRouterHandler.js"; diff --git a/packages/event-handler-aws/src/index.ts b/packages/event-handler-aws/src/index.ts index 336e012e36e..7f8092af97e 100644 --- a/packages/event-handler-aws/src/index.ts +++ b/packages/event-handler-aws/src/index.ts @@ -1,6 +1,13 @@ export { createLambdaHandler } from "./createLambdaHandler.js"; export type { CreateLambdaHandlerOptions } from "./createLambdaHandler.js"; export { awsLambdaTransport } from "./AwsLambdaTransport.js"; +export { createStreamLambdaHandler } from "./createStreamLambdaHandler.js"; +export type { + CreateStreamLambdaHandlerOptions, + StreamLambdaHandler +} from "./createStreamLambdaHandler.js"; +export { awsLambdaStreamTransport } from "./AwsLambdaStreamTransport.js"; +export * from "./streaming/awslambda.js"; export * from "./abstractions/index.js"; export * from "./eventTypes/index.js"; @@ -15,6 +22,8 @@ export type { } from "@webiny/event-handler-core"; export * from "./translators/apiGatewayEventToHttpRequest.js"; export * from "./translators/httpResponseToApiGatewayResult.js"; +export * from "./translators/functionUrlEventToHttpRequest.js"; export * from "./handlers/index.js"; export * from "./features/S3Feature.js"; export * from "./features/ApiGatewayFeature.js"; +export * from "./features/FunctionUrlStreamFeature.js"; diff --git a/packages/event-handler-aws/src/streaming/awslambda.ts b/packages/event-handler-aws/src/streaming/awslambda.ts new file mode 100644 index 00000000000..ae862c9e2a0 --- /dev/null +++ b/packages/event-handler-aws/src/streaming/awslambda.ts @@ -0,0 +1,54 @@ +/** + * Minimal typings + accessor for the `awslambda` global, which the AWS Lambda Node.js runtime injects + * when a function is invoked with `InvokeMode: RESPONSE_STREAM`. It has no npm package and no ambient + * types, so it is declared here rather than as a global — that keeps the leakage contained and lets + * tests stub `globalThis.awslambda`. + */ + +export interface IRawResponseStream { + write(chunk: Uint8Array | string): boolean | void; + end(): void; + destroy?(error?: Error): void; + /** Present when the runtime hands over a Node `Writable`; used to await back-pressure. */ + once?(event: string, listener: () => void): unknown; + destroyed?: boolean; +} + +export interface IResponseStreamMetadata { + statusCode: number; + headers?: Record; + cookies?: string[]; +} + +export interface IAwsLambdaGlobal { + /** + * Marks a handler as streaming. The runtime then calls it as + * `(event, responseStream, context)` instead of `(event, context)`. + */ + streamifyResponse any>(handler: THandler): THandler; + HttpResponseStream: { + /** + * Wraps the raw stream so the status code and headers are sent as a prelude. Must be called + * before the first `write`, otherwise the response defaults to 200 with no headers. + */ + from(stream: IRawResponseStream, metadata: IResponseStreamMetadata): IRawResponseStream; + }; +} + +export function getAwsLambdaGlobal(): IAwsLambdaGlobal { + const global = (globalThis as any).awslambda as IAwsLambdaGlobal | undefined; + + if (!global) { + throw new Error( + "The `awslambda` global is not available. Response streaming only works in the AWS " + + "Lambda Node.js runtime, on a function invoked through a Function URL with " + + "`InvokeMode: RESPONSE_STREAM`." + ); + } + + return global; +} + +export function isAwsLambdaStreamingRuntime(): boolean { + return Boolean((globalThis as any).awslambda); +} diff --git a/packages/event-handler-aws/src/translators/functionUrlEventToHttpRequest.ts b/packages/event-handler-aws/src/translators/functionUrlEventToHttpRequest.ts new file mode 100644 index 00000000000..dc8e38b4f43 --- /dev/null +++ b/packages/event-handler-aws/src/translators/functionUrlEventToHttpRequest.ts @@ -0,0 +1,86 @@ +import type { IHttpRequest } from "@webiny/event-handler-core"; + +/** + * Translates a Lambda Function URL event (payload format 2.0) into the transport-agnostic + * IHttpRequest. + * + * Close to the API Gateway v2 payload, with two differences that matter: + * - cookies arrive as a `cookies` ARRAY, not a `cookie` header. They are folded back into a header so + * downstream code (e.g. the identity loader reading `wby-id-token`) needs no special case. + * - there is no stage prefix to strip: a Function URL always serves `$default`. + */ +export function functionUrlEventToHttpRequest(event: any): IHttpRequest { + const headers: Record = { + ...((event.headers as Record) || {}) + }; + + if (Array.isArray(event.cookies) && event.cookies.length > 0) { + headers.cookie = event.cookies.join("; "); + } + + return { + method: event.requestContext?.http?.method ?? "GET", + path: event.rawPath || "/", + headers, + query: parseQuery(event), + pathParameters: (event.pathParameters as Record) || {}, + body: parseBody(event, headers) + }; +} + +function parseQuery(event: any): Record { + // Prefer the pre-parsed map; fall back to rawQueryString, which is the only thing present when + // the runtime omits `queryStringParameters` (it is absent, not empty, for a query-less request). + if (event.queryStringParameters) { + return event.queryStringParameters as Record; + } + + if (!event.rawQueryString) { + return {}; + } + + const result: Record = {}; + new URLSearchParams(event.rawQueryString).forEach((value, key) => { + result[key] = value; + }); + return result; +} + +function parseBody(event: any, headers: Record): any { + if (event.body === undefined || event.body === null) { + return undefined; + } + + if (event.isBase64Encoded) { + const buffer = Buffer.from(event.body, "base64"); + const contentType = getContentType(headers); + + // Binary bodies stay raw; only decode when the content type says it is text. + if (contentType.includes("application/json")) { + return tryParseJson(buffer.toString("utf8")); + } + if (contentType.startsWith("text/")) { + return buffer.toString("utf8"); + } + return buffer; + } + + return tryParseJson(event.body); +} + +function getContentType(headers: Record): string { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "content-type") { + return (headers[key] ?? "").toLowerCase(); + } + } + return ""; +} + +function tryParseJson(value: string): any { + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/packages/event-handler-aws/src/translators/httpResponseToApiGatewayResult.ts b/packages/event-handler-aws/src/translators/httpResponseToApiGatewayResult.ts index 4bb63f05c3c..b55d006b134 100644 --- a/packages/event-handler-aws/src/translators/httpResponseToApiGatewayResult.ts +++ b/packages/event-handler-aws/src/translators/httpResponseToApiGatewayResult.ts @@ -1,15 +1,48 @@ import type { APIGatewayProxyResult } from "@webiny/aws-sdk/types/index.js"; +import { HttpStreamBody } from "@webiny/event-handler-core"; import type { IHttpResponse } from "@webiny/event-handler-core"; +const TEXT_CONTENT_TYPE = /^text\/|^application\/(json|javascript|xml)|\+json|event-stream/i; + +function getContentType(headers: Record | undefined): string { + if (!headers) { + return ""; + } + // Header names are case-insensitive and routes set them inconsistently ("Content-Type" vs + // "content-type"), so match on the lowercased key rather than indexing one spelling. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "content-type") { + return headers[key] ?? ""; + } + } + return ""; +} + /** * Translates the transport-agnostic IHttpResponse into an API Gateway Lambda result. * * Binary bodies (Buffer / Uint8Array — e.g. asset delivery) are base64-encoded with * `isBase64Encoded: true`; everything else is passed through as a string (JSON-stringified when * it's an object). + * + * Streaming bodies are drained first: API Gateway buffers the entire Lambda response regardless of + * how it was produced, so it cannot deliver incrementally. Draining keeps a streaming route working + * over this transport (as one buffered response) instead of failing — real streaming needs the + * Lambda Function URL transport. */ -export function httpResponseToApiGatewayResult(response: IHttpResponse): APIGatewayProxyResult { - const { body } = response; +export async function httpResponseToApiGatewayResult( + response: IHttpResponse +): Promise { + let { body } = response; + + if (HttpStreamBody.is(body)) { + const bytes = await body.collect(); + // Decode as text when the content type says so (SSE, JSON) — otherwise the Uint8Array falls + // through to the base64 branch below, which is what a binary stream wants. + body = TEXT_CONTENT_TYPE.test(getContentType(response.headers)) + ? new TextDecoder().decode(bytes) + : bytes; + } if (Buffer.isBuffer(body) || body instanceof Uint8Array) { return { diff --git a/packages/event-handler-core/__tests__/HttpStreamBody.test.ts b/packages/event-handler-core/__tests__/HttpStreamBody.test.ts new file mode 100644 index 00000000000..2068595a986 --- /dev/null +++ b/packages/event-handler-core/__tests__/HttpStreamBody.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { HttpStreamBody } from "~/features/http/HttpStreamBody.js"; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +async function* stringChunks() { + yield "a"; + yield "b"; + yield "c"; +} + +describe("HttpStreamBody", () => { + describe("is", () => { + it("should recognise a stream body", () => { + expect(HttpStreamBody.is(new HttpStreamBody(stringChunks()))).toBe(true); + }); + + it("should not mistake buffered bodies for streams", () => { + expect(HttpStreamBody.is("text")).toBe(false); + expect(HttpStreamBody.is({ ok: true })).toBe(false); + expect(HttpStreamBody.is(Buffer.from("bytes"))).toBe(false); + expect(HttpStreamBody.is(undefined)).toBe(false); + expect(HttpStreamBody.is(null)).toBe(false); + }); + + it("should not mistake a bare async iterable for a stream body", () => { + // The whole point of the marker class: opting into streaming has to be explicit, so an + // object that merely happens to be async-iterable must stay a buffered body. + expect(HttpStreamBody.is(stringChunks())).toBe(false); + }); + }); + + describe("collect", () => { + it("should concatenate string chunks", async () => { + const body = new HttpStreamBody(stringChunks()); + expect(decode(await body.collect())).toBe("abc"); + }); + + it("should concatenate byte chunks", async () => { + const encoder = new TextEncoder(); + async function* byteChunks() { + yield encoder.encode("hello "); + yield encoder.encode("world"); + } + + const body = new HttpStreamBody(byteChunks()); + expect(decode(await body.collect())).toBe("hello world"); + }); + + it("should concatenate mixed string and byte chunks", async () => { + async function* mixed() { + yield "one:"; + yield new TextEncoder().encode("two"); + } + + const body = new HttpStreamBody(mixed()); + expect(decode(await body.collect())).toBe("one:two"); + }); + + it("should return an empty result for an empty stream", async () => { + async function* empty() { + // no chunks + } + + const body = new HttpStreamBody(empty()); + const collected = await body.collect(); + expect(collected.byteLength).toBe(0); + }); + + it("should preserve multi-byte characters split across chunks", async () => { + // "é" is two UTF-8 bytes. Encoding per chunk and concatenating bytes must not corrupt it, + // which is why collect() joins bytes rather than decoding chunk by chunk. + const encoder = new TextEncoder(); + const bytes = encoder.encode("é"); + async function* split() { + yield bytes.slice(0, 1); + yield bytes.slice(1); + } + + const body = new HttpStreamBody(split()); + expect(decode(await body.collect())).toBe("é"); + }); + + it("should propagate a producer error", async () => { + async function* failing() { + yield "partial"; + throw new Error("producer exploded"); + } + + const body = new HttpStreamBody(failing()); + await expect(body.collect()).rejects.toThrow("producer exploded"); + }); + }); + + describe("fromWebStream", () => { + it("should drain a web ReadableStream", async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("chunk-1;")); + controller.enqueue(encoder.encode("chunk-2")); + controller.close(); + } + }); + + const body = HttpStreamBody.fromWebStream(stream); + expect(decode(await body.collect())).toBe("chunk-1;chunk-2"); + }); + + it("should propagate a web stream error", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error("stream broke")); + } + }); + + const body = HttpStreamBody.fromWebStream(stream); + await expect(body.collect()).rejects.toThrow("stream broke"); + }); + + it("should yield chunks lazily rather than buffering the whole stream", async () => { + const encoder = new TextEncoder(); + let pulled = 0; + const stream = new ReadableStream({ + pull(controller) { + pulled++; + if (pulled > 3) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(String(pulled))); + } + }); + + const received: string[] = []; + for await (const chunk of HttpStreamBody.fromWebStream(stream).source) { + received.push(decode(chunk as Uint8Array)); + // Each chunk must be observable before the stream finishes; if fromWebStream + // buffered, we would only get here after all pulls completed. + expect(received.length).toBeLessThanOrEqual(pulled); + } + + expect(received).toEqual(["1", "2", "3"]); + }); + }); +}); diff --git a/packages/event-handler-core/src/features/http/HttpStreamBody.ts b/packages/event-handler-core/src/features/http/HttpStreamBody.ts new file mode 100644 index 00000000000..34f3de43877 --- /dev/null +++ b/packages/event-handler-core/src/features/http/HttpStreamBody.ts @@ -0,0 +1,78 @@ +export type HttpStreamChunk = Uint8Array | string; + +export type HttpStreamSource = AsyncIterable; + +/** + * Marker for a streaming HTTP response body. + * + * `IHttpResponse.body` is untyped, and buffered bodies already carry meaning by their runtime type + * (string, Buffer/Uint8Array, plain object). Streaming has to be distinguishable from those WITHOUT + * duck-typing `Symbol.asyncIterator`: a plain object body could accidentally satisfy it, and + * `ReadableStream`'s async-iterator support (present in Node at runtime) isn't declared in the DOM + * types. So a route opts into streaming explicitly, by wrapping its source in this class. + * + * Transports that can stream (the Node HTTP server; AWS Lambda response streaming via a Function + * URL) write chunks as they are produced. Transports that cannot (API Gateway buffers the entire + * Lambda response no matter how it was produced) call {@link collect} and send one buffered body — + * so the same route still works there, just without incremental delivery. + */ +export class HttpStreamBody { + constructor(readonly source: HttpStreamSource) {} + + static is(value: unknown): value is HttpStreamBody { + return value instanceof HttpStreamBody; + } + + /** + * Wrap a web `ReadableStream` — what `fetch` and the AI SDK's `toUIMessageStreamResponse()` + * hand back. The reader is driven explicitly rather than relying on async iteration, because + * the DOM types don't declare it even though Node implements it. + */ + static fromWebStream(stream: ReadableStream): HttpStreamBody { + return new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value !== undefined) { + yield value; + } + } + } finally { + reader.releaseLock(); + } + } + }); + } + + /** + * Drain the whole stream into a single byte array, for transports that can't stream. + * + * Consumes the source — a stream can only be read once, so this must not be combined with + * writing the same body incrementally. + */ + async collect(): Promise { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + let total = 0; + + for await (const chunk of this.source) { + const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk; + chunks.push(bytes); + total += bytes.byteLength; + } + + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + + return result; + } +} diff --git a/packages/event-handler-core/src/features/http/index.ts b/packages/event-handler-core/src/features/http/index.ts index 0b90d9764fa..25b50f3f4b4 100644 --- a/packages/event-handler-core/src/features/http/index.ts +++ b/packages/event-handler-core/src/features/http/index.ts @@ -1,4 +1,5 @@ export * from "./abstractions.js"; export * from "./HttpRouter.js"; +export * from "./HttpStreamBody.js"; export * from "./decorators/index.js"; export * from "./feature.js"; diff --git a/packages/event-handler-server/__tests__/streaming.test.ts b/packages/event-handler-server/__tests__/streaming.test.ts new file mode 100644 index 00000000000..9ab2a8d58f6 --- /dev/null +++ b/packages/event-handler-server/__tests__/streaming.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { HttpRoute, HttpStreamBody } from "@webiny/event-handler-core"; +import type { IHttpRequest, IHttpResponse, HttpStreamSource } from "@webiny/event-handler-core"; +import { createServerHandler } from "~/createServerHandler.js"; +import { NodeHttpFeature } from "~/features/NodeHttpFeature.js"; + +const decoder = new TextDecoder(); + +function deferred() { + let resolve!: () => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +function makeRoute(response: () => IHttpResponse) { + class StreamRouteImplementation implements HttpRoute.Interface { + readonly method = "GET"; + readonly path = "/stream"; + async handle(_req: IHttpRequest): Promise { + return response(); + } + } + + return HttpRoute.createImplementation({ + implementation: StreamRouteImplementation, + dependencies: [] + }); +} + +function streamResponse(source: HttpStreamSource): IHttpResponse { + return { + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: new HttpStreamBody(source) + }; +} + +async function startServer(route: ReturnType) { + const server = await createServerHandler({ + root: container => { + NodeHttpFeature.register(container); + container.register(route); + } + }); + + await new Promise(resolve => { + server.listen(0, () => resolve()); + }); + + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + + return { + url: `http://127.0.0.1:${port}/stream`, + close: () => + new Promise(resolve => { + server.closeAllConnections(); + server.close(() => resolve()); + }) + }; +} + +describe("Node HTTP server streaming", () => { + it("should deliver chunks incrementally", async () => { + const firstChunkRead = deferred(); + + const route = makeRoute(() => + streamResponse({ + async *[Symbol.asyncIterator]() { + yield "data: first\n\n"; + // Blocks until the client has actually received the first chunk. If the + // transport buffered the body instead of streaming it, the client's first read + // could never complete and this test would time out — which is exactly the + // behaviour being asserted. + await firstChunkRead.promise; + yield "data: second\n\n"; + } + }) + ); + + const server = await startServer(route); + + try { + // Resolving at all proves the headers were flushed before the body finished: the + // producer is still parked on `firstChunkRead` at this point. + const response = await fetch(server.url); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + // Streamed responses are chunked — a content-length would mean the body was buffered. + expect(response.headers.get("content-length")).toBeNull(); + + const reader = response.body!.getReader(); + + const first = await reader.read(); + expect(decoder.decode(first.value)).toBe("data: first\n\n"); + + firstChunkRead.resolve(); + + const second = await reader.read(); + expect(decoder.decode(second.value)).toBe("data: second\n\n"); + + expect((await reader.read()).done).toBe(true); + } finally { + await server.close(); + } + }); + + it("should stream byte chunks", async () => { + const encoder = new TextEncoder(); + const route = makeRoute(() => + streamResponse({ + async *[Symbol.asyncIterator]() { + yield encoder.encode("bytes-"); + yield encoder.encode("through"); + } + }) + ); + + const server = await startServer(route); + + try { + const response = await fetch(server.url); + expect(await response.text()).toBe("bytes-through"); + } finally { + await server.close(); + } + }); + + it("should handle back-pressure without losing or reordering data", async () => { + // Enough data to fill the socket buffer and force the `drain` path. If back-pressure were + // mishandled the response would be truncated or the write would stall forever. + const chunkCount = 2000; + const chunk = "x".repeat(1024); + + const route = makeRoute(() => + streamResponse({ + async *[Symbol.asyncIterator]() { + for (let i = 0; i < chunkCount; i++) { + yield `${i}:${chunk}\n`; + } + } + }) + ); + + const server = await startServer(route); + + try { + const response = await fetch(server.url); + const text = await response.text(); + const lines = text.split("\n").filter(Boolean); + + expect(lines).toHaveLength(chunkCount); + expect(lines[0]).toBe(`0:${chunk}`); + expect(lines[chunkCount - 1]).toBe(`${chunkCount - 1}:${chunk}`); + } finally { + await server.close(); + } + }); + + it("should send an empty body for an empty stream", async () => { + const route = makeRoute(() => + streamResponse({ + async *[Symbol.asyncIterator]() { + // no chunks + } + }) + ); + + const server = await startServer(route); + + try { + const response = await fetch(server.url); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + } finally { + await server.close(); + } + }); + + it("should truncate the response when the producer fails mid-stream", async () => { + const route = makeRoute(() => + streamResponse({ + async *[Symbol.asyncIterator]() { + yield "data: partial\n\n"; + throw new Error("producer exploded"); + } + }) + ); + + const server = await startServer(route); + + try { + const response = await fetch(server.url); + // The status line already went out as 200 — there is no way to retroactively send a 500, + // so the client has to learn about the failure from an incomplete body. + expect(response.status).toBe(200); + await expect(response.text()).rejects.toThrow(); + } finally { + await server.close(); + } + }); + + it("should still serve buffered bodies", async () => { + const route = makeRoute(() => ({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { ok: true } + })); + + const server = await startServer(route); + + try { + const response = await fetch(server.url); + expect(await response.json()).toEqual({ ok: true }); + } finally { + await server.close(); + } + }); +}); diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index 221b1f582e5..a5d88907bb2 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -1,6 +1,7 @@ import http from "node:http"; +import { once } from "node:events"; import { Container } from "@webiny/di"; -import { createHandler } from "@webiny/event-handler-core"; +import { createHandler, HttpStreamBody } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -33,7 +34,26 @@ export async function createServerHandler( const response = (await handle(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; - if (body === undefined || body === null) { + if (HttpStreamBody.is(body)) { + // Streaming response (e.g. SSE from an AI text stream). Flush the headers now — + // Node otherwise holds them until the first write, so the client would see nothing + // until the producer emits its first chunk. + res.flushHeaders(); + for await (const chunk of body.source) { + if (res.destroyed) { + // Client went away mid-stream; stop pulling from the producer. + break; + } + // Respect back-pressure: `write` returning false means the socket buffer is + // full, and ignoring that would grow it without bound on a slow consumer. + if (!res.write(chunk)) { + await once(res, "drain"); + } + } + if (!res.destroyed) { + res.end(); + } + } else if (body === undefined || body === null) { res.end(); } else if (typeof body === "string") { res.end(body); @@ -48,6 +68,14 @@ export async function createServerHandler( } } catch (err) { console.error("Unhandled error:", err); + if (res.headersSent) { + // A streaming body failed after the status line went out, so there is no way to turn + // this into a 500 — writeHead would throw ERR_HTTP_HEADERS_SENT and mask the real + // error. Destroy the socket so the client sees a truncated response rather than a + // complete-looking one. + res.destroy(); + return; + } res.writeHead(500); res.end("Internal Server Error"); } diff --git a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts index fe752657100..94131926767 100644 --- a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts +++ b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts @@ -4,8 +4,19 @@ * The composition root lives in @webiny/api-event-handler-aws-ddb (`createAwsDdbApiHandler`) so the wiring * is a real, testable package rather than template code. This file only supplies project-specific * extensions. + * + * Two handlers are exported from this one bundle and deployed as two Lambda functions: + * - `handler` — behind API Gateway, buffered responses. Everything except streaming routes. + * - `streamHandler` — behind a Lambda Function URL with `InvokeMode: RESPONSE_STREAM`, for routes that + * stream (e.g. `/stream/*`). A separate function is required because a Lambda's handler entry is + * fixed per function, and API Gateway buffers the whole response so it cannot stream at all. */ -import { createAwsDdbApiHandler } from "@webiny/api-event-handler-aws-ddb"; +import { + createAwsDdbApiHandler, + createAwsDdbStreamApiHandler +} from "@webiny/api-event-handler-aws-ddb"; import { extensions } from "./extensions"; export const handler = createAwsDdbApiHandler({ extensions }); + +export const streamHandler = createAwsDdbStreamApiHandler({ extensions }); diff --git a/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts b/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts index 90277ba424a..93406e30cc5 100644 --- a/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts +++ b/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts @@ -5,8 +5,16 @@ * wiring is a real, testable package rather than template code. The OpenSearch extension's * ReplaceApiLambdaFnHandlers copies this file over the api workspace when OpenSearch is enabled. * This file only supplies project-specific extensions. + * + * Exports both the buffered (API Gateway) and the response-streaming (Function URL) handler; see the + * DynamoDB variant of this file for why streaming needs its own Lambda function. */ -import { createAwsDdbOsApiHandler } from "@webiny/api-event-handler-aws-ddb-os"; +import { + createAwsDdbOsApiHandler, + createAwsDdbOsStreamApiHandler +} from "@webiny/api-event-handler-aws-ddb-os"; import { extensions } from "./extensions"; export const handler = createAwsDdbOsApiHandler({ extensions }); + +export const streamHandler = createAwsDdbOsStreamApiHandler({ extensions }); diff --git a/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts b/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts index 81c9cc4ff2e..1654a1e6341 100644 --- a/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts +++ b/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts @@ -3,13 +3,17 @@ import type { PulumiApp, PulumiAppModule } from "@webiny/pulumi"; import { createAppModule } from "@webiny/pulumi"; import { ApiGateway } from "./ApiGateway.js"; +import { ApiGraphqlStream } from "./ApiGraphqlStream.js"; export type ApiCloudfront = PulumiAppModule; +const STREAM_ORIGIN_ID = "graphql-stream-function-url"; + export const ApiCloudfront = createAppModule({ name: "ApiCloudfront", config(app: PulumiApp) { const gateway = app.getModule(ApiGateway); + const graphqlStream = app.getModule(ApiGraphqlStream); const cookies = { forward: "whitelist", @@ -25,7 +29,19 @@ export const ApiCloudfront = createAppModule({ "X-Webiny-Sdk" ]; - return app.addResource(aws.cloudfront.Distribution, { + // Lets CloudFront sign requests to the Lambda Function URL with SigV4, so the URL itself can + // stay on AWS_IAM authorization instead of being reachable from the internet. + const streamOac = app.addResource(aws.cloudfront.OriginAccessControl, { + name: "api-stream-oac", + config: { + description: "Signs CloudFront requests to the streaming Lambda Function URL", + originAccessControlOriginType: "lambda", + signingBehavior: "always", + signingProtocol: "sigv4" + } + }); + + const distribution = app.addResource(aws.cloudfront.Distribution, { name: "api-cloudfront", config: { httpVersion: "http2and3", @@ -49,6 +65,46 @@ export const ApiCloudfront = createAppModule({ viewerProtocolPolicy: "allow-all" }, orderedCacheBehaviors: [ + { + // Streaming routes (SSE). Must come before the other behaviors so `/stream/*` + // never falls through to the API Gateway origin, which cannot stream. + // + // `compress: false` is load-bearing: CloudFront compression buffers small + // chunks, which defeats incremental delivery even though the origin streams. + compress: false, + allowedMethods: [ + "GET", + "HEAD", + "OPTIONS", + "PUT", + "POST", + "PATCH", + "DELETE" + ], + cachedMethods: ["GET", "HEAD"], + forwardedValues: { + cookies, + // `Authorization` is deliberately absent: OAC signing puts SigV4 in that + // header, so a viewer token there could not survive. Clients send the + // token in `X-Webiny-Authorization` instead, which both the AWS and the + // self-hosted identity extractors read first. + headers: [ + "Origin", + "Accept", + "Accept-Language", + "X-Tenant", + "X-Webiny-Sdk", + "X-Webiny-Authorization" + ], + queryString: true + }, + minTtl: 0, + defaultTtl: 0, + maxTtl: 0, + pathPattern: "/stream/*", + viewerProtocolPolicy: "https-only", + targetOriginId: STREAM_ORIGIN_ID + }, { compress: true, allowedMethods: [ @@ -145,6 +201,20 @@ export const ApiCloudfront = createAppModule({ originProtocolPolicy: "https-only", originSslProtocols: ["TLSv1.2"] } + }, + { + // Lambda Function URL origin for streaming routes. A Function URL is a plain + // HTTPS endpoint, hence customOriginConfig; the OAC is what makes CloudFront + // sign the request so the URL can require AWS_IAM. + domainName: graphqlStream.functionUrlDomain, + originId: STREAM_ORIGIN_ID, + originAccessControlId: streamOac.output.id, + customOriginConfig: { + httpPort: 80, + httpsPort: 443, + originProtocolPolicy: "https-only", + originSslProtocols: ["TLSv1.2"] + } } ], restrictions: { @@ -172,5 +242,21 @@ export const ApiCloudfront = createAppModule({ ignoreChanges: ["staging"] } }); + + // Allows THIS distribution — and nothing else — to invoke the streaming Function URL. Created + // after the distribution because it needs its ARN; the Function URL is useless until it exists. + app.addResource(aws.lambda.Permission, { + name: "graphql-stream-url-cloudfront-invoke", + config: { + action: "lambda:InvokeFunctionUrl", + function: graphqlStream.functions.graphqlStream.output.name, + principal: "cloudfront.amazonaws.com", + sourceArn: distribution.output.arn, + functionUrlAuthType: "AWS_IAM", + statementId: "allow-cloudfront-invoke-function-url" + } + }); + + return distribution; } }); diff --git a/packages/project-aws/src/pulumi/apps/api/ApiGraphqlStream.ts b/packages/project-aws/src/pulumi/apps/api/ApiGraphqlStream.ts new file mode 100644 index 00000000000..31429b36e5e --- /dev/null +++ b/packages/project-aws/src/pulumi/apps/api/ApiGraphqlStream.ts @@ -0,0 +1,92 @@ +import path from "path"; +import * as pulumi from "@pulumi/pulumi"; +import * as aws from "@pulumi/aws"; +import type { PulumiApp, PulumiAppModule } from "@webiny/pulumi"; +import { createAppModule } from "@webiny/pulumi"; +import { getCommonLambdaEnvVariables } from "../lambdaUtils.js"; +import { VpcConfig } from "~/pulumi/apps/index.js"; +import { LAMBDA_RUNTIME } from "~/pulumi/constants.js"; +import { ApiGraphql } from "./ApiGraphql.js"; + +interface GraphqlStreamParams { + env: Record; +} + +export type ApiGraphqlStream = PulumiAppModule; + +/** + * The response-streaming twin of {@link ApiGraphql}. + * + * Why a second Lambda function rather than a second route on the existing one: API Gateway buffers the + * entire Lambda response no matter how it was produced, so it cannot stream at all. Streaming requires + * a Lambda Function URL with `InvokeMode: RESPONSE_STREAM`, and the response has to be produced by an + * `awslambda.streamifyResponse` handler — but a Lambda's handler entry point is fixed per function. So: + * same code bundle, same IAM role, different entry point (`handler.streamHandler`), reached through a + * Function URL instead of API Gateway. + * + * The Function URL is NOT public. It uses `AWS_IAM` authorization and is invoked only by CloudFront, + * which signs each request using an Origin Access Control (see ApiCloudfront). + */ +export const ApiGraphqlStream = createAppModule({ + name: "ApiGraphqlStream", + config(app: PulumiApp, params: GraphqlStreamParams) { + // Reuse the buffered function's role and policy: identical permissions, and duplicating the + // policy would mean two places to keep in sync. + const { role } = app.getModule(ApiGraphql); + + const graphqlStream = app.addResource(aws.lambda.Function, { + name: "graphql-stream", + config: { + description: "Webiny's streaming HTTP routes", + runtime: LAMBDA_RUNTIME, + // Second export of the SAME bundle that backs the `graphql` function. + handler: "handler.streamHandler", + role: role.output.arn, + // Streaming exists for long-running work (AI token streams). API Gateway's hard 30s + // cap doesn't apply to a Function URL, so allow well beyond the buffered function's 30s. + timeout: 300, + memorySize: 1024, + code: new pulumi.asset.AssetArchive({ + ".": new pulumi.asset.FileArchive( + path.join(app.paths.workspace, "graphql/build") + ) + }), + environment: { + variables: getCommonLambdaEnvVariables().apply(value => ({ + ...value, + ...params.env, + AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1" + })) + }, + vpcConfig: app.getModule(VpcConfig).functionVpcConfig, + loggingConfig: { + logFormat: "JSON" + } + } + }); + + const functionUrl = app.addResource(aws.lambda.FunctionUrl, { + name: "graphql-stream-url", + config: { + functionName: graphqlStream.output.name, + // Locked to signed CloudFront requests; see the OAC + invoke permission below. + authorizationType: "AWS_IAM", + // The whole point: without RESPONSE_STREAM the runtime buffers the response and + // `streamifyResponse` has no effect. + invokeMode: "RESPONSE_STREAM" + } + }); + + return { + role, + functions: { + graphqlStream + }, + functionUrl, + /** Origin domain for CloudFront, e.g. `abc123.lambda-url.eu-central-1.on.aws`. */ + functionUrlDomain: functionUrl.output.functionUrl.apply( + (url: string) => new URL(url).hostname + ) + }; + } +}); diff --git a/packages/project-aws/src/pulumi/apps/api/createApiPulumiApp.ts b/packages/project-aws/src/pulumi/apps/api/createApiPulumiApp.ts index b402a342b8c..9636678c2ad 100644 --- a/packages/project-aws/src/pulumi/apps/api/createApiPulumiApp.ts +++ b/packages/project-aws/src/pulumi/apps/api/createApiPulumiApp.ts @@ -6,6 +6,7 @@ import { ApiFileManager, ApiGateway, ApiGraphql, + ApiGraphqlStream, ApiWebsocket, CoreOutput, VpcConfig @@ -173,28 +174,35 @@ export const createApiPulumiApp = () => { app.addModule(VpcConfig, { enabled: vpcEnabled }); - const graphql = app.addModule(ApiGraphql, { - env: { - COGNITO_REGION: getEnvVariableAwsRegion(), - COGNITO_USER_POOL_ID: core.cognitoUserPoolId, - DB_TABLE: core.primaryDynamodbTableName, - DB_TABLE_AUDIT_LOGS: core.auditLogsDynamodbTableName, - DB_TABLE_OPENSEARCH: core.opensearchDynamodbTableName, - OPENSEARCH_ENDPOINT: core.opensearchDomainEndpoint, - - // Not required. Useful for testing purposes / ephemeral environments. - // https://www.webiny.com/docs/key-topics/ci-cd/testing/slow-ephemeral-environments - OPENSEARCH_INDEX_PREFIX: process.env.OPENSEARCH_INDEX_PREFIX, - OPENSEARCH_SHARED_INDEXES: process.env.OPENSEARCH_SHARED_INDEXES, - OPENSEARCH_USERNAME: process.env.OPENSEARCH_USERNAME, - OPENSEARCH_PASSWORD: process.env.OPENSEARCH_PASSWORD, - - S3_BUCKET: core.fileManagerBucketId, - EVENT_BUS: core.eventBusArn, - // TODO: move to okta plugin - OKTA_ISSUER: process.env["OKTA_ISSUER"] - } - }); + // Shared by the buffered `graphql` function and its response-streaming twin: both run the + // same code bundle, so they must see the same environment. + const graphqlEnv = { + COGNITO_REGION: getEnvVariableAwsRegion(), + COGNITO_USER_POOL_ID: core.cognitoUserPoolId, + DB_TABLE: core.primaryDynamodbTableName, + DB_TABLE_AUDIT_LOGS: core.auditLogsDynamodbTableName, + DB_TABLE_OPENSEARCH: core.opensearchDynamodbTableName, + OPENSEARCH_ENDPOINT: core.opensearchDomainEndpoint, + + // Not required. Useful for testing purposes / ephemeral environments. + // https://www.webiny.com/docs/key-topics/ci-cd/testing/slow-ephemeral-environments + OPENSEARCH_INDEX_PREFIX: process.env.OPENSEARCH_INDEX_PREFIX, + OPENSEARCH_SHARED_INDEXES: process.env.OPENSEARCH_SHARED_INDEXES, + OPENSEARCH_USERNAME: process.env.OPENSEARCH_USERNAME, + OPENSEARCH_PASSWORD: process.env.OPENSEARCH_PASSWORD, + + S3_BUCKET: core.fileManagerBucketId, + EVENT_BUS: core.eventBusArn, + // TODO: move to okta plugin + OKTA_ISSUER: process.env["OKTA_ISSUER"] + }; + + const graphql = app.addModule(ApiGraphql, { env: graphqlEnv }); + + // Second Lambda function off the same bundle, fronted by a Function URL instead of API + // Gateway, because API Gateway buffers the whole response and cannot stream. Added before + // ApiCloudfront, which resolves this module to wire the `/stream/*` origin and behavior. + const graphqlStream = app.addModule(ApiGraphqlStream, { env: graphqlEnv }); const websocket = app.addModule(ApiWebsocket); @@ -278,6 +286,8 @@ export const createApiPulumiApp = () => { graphqlLambdaName: graphql.functions.graphql.output.name, graphqlLambdaRole: graphql.role.output.arn, graphqlLambdaRoleName: graphql.role.output.name, + graphqlStreamLambdaName: graphqlStream.functions.graphqlStream.output.name, + graphqlStreamFunctionUrl: graphqlStream.functionUrl.output.functionUrl, backgroundTaskLambdaArn: backgroundTask.backgroundTask.output.arn, backgroundTaskStepFunctionArn: backgroundTask.stepFunction.output.arn, fileManagerDownloadLambdaArn: fileManager.functions.download.output.arn, @@ -320,6 +330,7 @@ export const createApiPulumiApp = () => { return { fileManager, graphql, + graphqlStream, apiGateway, websocket, cloudfront, diff --git a/packages/project-aws/src/pulumi/apps/api/index.ts b/packages/project-aws/src/pulumi/apps/api/index.ts index 5c13f6a1f94..04aa811af9a 100644 --- a/packages/project-aws/src/pulumi/apps/api/index.ts +++ b/packages/project-aws/src/pulumi/apps/api/index.ts @@ -3,6 +3,7 @@ export * from "./ApiCloudfront.js"; export * from "./ApiFileManager.js"; export * from "./ApiGateway.js"; export * from "./ApiGraphql.js"; +export * from "./ApiGraphqlStream.js"; export * from "./createApiPulumiApp.js"; export * from "./ApiOutput.js"; export * from "./ApiWebsocket.js"; diff --git a/yarn.lock b/yarn.lock index 27aca490595..beba19162f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10243,6 +10243,8 @@ __metadata: "@webiny/app-websockets": "npm:0.0.0" "@webiny/background-tasks": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" + "@webiny/di": "npm:^1.0.2" + "@webiny/event-handler-core": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/icons": "npm:0.0.0" "@webiny/project": "npm:0.0.0" From 82aae3311e1fe42b548426e8aa7579bcc6fc522d Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 30 Jul 2026 10:55:40 +0200 Subject: [PATCH 02/77] fix(event-handler): allow x-webiny-authorization in CORS preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming client sends its auth token in `x-webiny-authorization`, but `SecureHeadersDecorator` never listed that header in `Access-Control-Allow-Headers`. The preflight returned 204 while failing the CORS check, so the browser blocked the actual request before sending it — surfacing as an opaque "Failed to fetch" with no response headers rather than a 4xx. Adds a test asserting every custom request header Webiny clients send is present in the allow-list, so the next one can't slip through the same way. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/SecureHeadersDecorator.test.ts | 21 +++++++++++++++++++ .../http/decorators/SecureHeadersDecorator.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/packages/event-handler-core/__tests__/SecureHeadersDecorator.test.ts b/packages/event-handler-core/__tests__/SecureHeadersDecorator.test.ts index 637be2a6cd3..44b54c6c37e 100644 --- a/packages/event-handler-core/__tests__/SecureHeadersDecorator.test.ts +++ b/packages/event-handler-core/__tests__/SecureHeadersDecorator.test.ts @@ -28,6 +28,27 @@ describe("SecureHeadersDecorator", () => { expect(result.headers?.["access-control-allow-methods"]).toContain("POST"); }); + it("should allow every custom request header Webiny clients send", async () => { + // A header missing from this list makes the browser fail the preflight CORS check and never + // send the actual request — which surfaces as an opaque "Failed to fetch", not a 4xx. + const container = new Container(); + container.register(HttpRouterImpl).inSingletonScope(); + container.registerDecorator(SecureHeadersDecorator); + const router = container.resolve(HttpRouter); + + const result = await router.route(req("OPTIONS", "/stream/x", "https://example.com")); + const allowed = result.headers?.["access-control-allow-headers"] ?? ""; + + for (const header of [ + "authorization", + "x-webiny-authorization", + "x-tenant", + "content-type" + ]) { + expect(allowed).toContain(header); + } + }); + it("should add CORS headers to normal responses", async () => { const container = new Container(); diff --git a/packages/event-handler-core/src/features/http/decorators/SecureHeadersDecorator.ts b/packages/event-handler-core/src/features/http/decorators/SecureHeadersDecorator.ts index d3e306bf3ea..d3b864a33ae 100644 --- a/packages/event-handler-core/src/features/http/decorators/SecureHeadersDecorator.ts +++ b/packages/event-handler-core/src/features/http/decorators/SecureHeadersDecorator.ts @@ -8,6 +8,10 @@ const ALLOWED_HEADERS = [ "content-type", "x-i18n-locale", "x-tenant", + // Streaming clients send the auth token here rather than in `Authorization`, which SigV4 occupies + // when a Lambda Function URL sits behind CloudFront with Origin Access Control. Omitting it makes + // the browser fail the preflight CORS check and never send the actual request. + "x-webiny-authorization", "x-apollo-tracing", "apollo-query-plan-experimental" ].join(", "); From a77d2102d98fb9e9f9d4c4e5cfd8b6f220369e2a Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 30 Jul 2026 12:37:38 +0200 Subject: [PATCH 03/77] fix(event-handler): make streamHandler survive into the deployed bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found by inspecting the built api artifact before deploying. The first two would each have broken the response-streaming Lambda; the third broke every non-Lambda import of the bundle. 1. rspack tree-shook `streamHandler` away Nothing imports an entry's exports, so the unused one was dropped along with every module reachable only from it — the bundle exported just `handler` and contained no streaming code at all. Declaring the entry a module library marks its exports as the public API and keeps them. `handler` survived only by accident of being first. 2. The WCP telemetry wrapper re-exported only `handler` `WcpInjectTelemetryClientAfterBuild` renames the bundle to `_handler.mjs` and puts a downloaded telemetry wrapper in its place. That wrapper knows only about `handler`, so `handler.streamHandler` — what the Pulumi config points the streaming function at — did not exist in the deployed artifact. The re-export is unwrapped: `streamHandler` carries the marker `streamifyResponse` attaches and the runtime inspects the exported function for it, so wrapping it would silently downgrade the function to buffered responses. It goes through a namespace import rather than a named re-export because this injection also runs for the self-hosted api build, whose bundle has no `streamHandler`, and a named re-export of a missing binding is a hard ESM error that took the whole handler down. 3. The streaming-runtime check tested the wrong thing `@aws/lambda-invoke-store`, transitive via the AWS SDK, runs `globalThis.awslambda = globalThis.awslambda || {}` at import time. In Lambda that preserves the runtime's real global, but everywhere else it leaves an EMPTY object — so checking the object's presence passed outside Lambda and `streamifyResponse` then threw at module load, failing the import of the entire bundle including the buffered handler. Checks for the function now. Verified on the built artifacts of both hosting types: AWS exports `handler` + `streamHandler` (2.83 MB minified, under the 4.5 MB cap), self-hosted exports `handler` with `streamHandler` undefined, and both import cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- .../bundling/function/createRsbuildConfig.js | 8 +++++++ .../__tests__/functionUrlStreaming.test.ts | 12 ++++++++++ .../src/streaming/awslambda.ts | 20 ++++++++++++---- .../WcpInjectTelemetryClientAfterBuild.ts | 24 ++++++++++++++++++- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/build-tools/bundling/function/createRsbuildConfig.js b/packages/build-tools/bundling/function/createRsbuildConfig.js index c9de7451fd4..8203daf7da0 100644 --- a/packages/build-tools/bundling/function/createRsbuildConfig.js +++ b/packages/build-tools/bundling/function/createRsbuildConfig.js @@ -57,6 +57,14 @@ export const createRsbuildConfig = async ({ cwd, enforceMaxBundleSize }) => { }, tools: { rspack: { + output: { + // Declares the entry's exports as the bundle's public API, so ALL of them survive. + // Without this, rspack tree-shakes any entry export nothing imports — which silently + // dropped `streamHandler` (and every module reachable only from it) from the api + // bundle, leaving the response-streaming Lambda with no handler to call. `handler` + // survived only by accident of being the first export. + library: { type: "module" } + }, ...(enforceMaxBundleSize && { performance: { hints: "error", diff --git a/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts index 3065ce8488f..37065d9c1e2 100644 --- a/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts +++ b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts @@ -345,4 +345,16 @@ describe("Lambda Function URL response streaming", () => { makeHandler(makeRoute(async () => ({ statusCode: 200, body: "ok" }))) ).not.toThrow(); }); + + it("should tolerate an awslambda global that has no streamifyResponse", () => { + // `@aws/lambda-invoke-store` (transitive via the AWS SDK) runs + // `globalThis.awslambda = globalThis.awslambda || {}` at import time. Outside Lambda that + // leaves an EMPTY object, so checking the object's presence isn't enough — doing so threw at + // module load and took down the whole bundle, buffered handler included. + (globalThis as any).awslambda = {}; + + expect(() => + makeHandler(makeRoute(async () => ({ statusCode: 200, body: "ok" }))) + ).not.toThrow(); + }); }); diff --git a/packages/event-handler-aws/src/streaming/awslambda.ts b/packages/event-handler-aws/src/streaming/awslambda.ts index ae862c9e2a0..ca7394d1b5c 100644 --- a/packages/event-handler-aws/src/streaming/awslambda.ts +++ b/packages/event-handler-aws/src/streaming/awslambda.ts @@ -38,17 +38,27 @@ export interface IAwsLambdaGlobal { export function getAwsLambdaGlobal(): IAwsLambdaGlobal { const global = (globalThis as any).awslambda as IAwsLambdaGlobal | undefined; - if (!global) { + if (typeof global?.streamifyResponse !== "function") { throw new Error( - "The `awslambda` global is not available. Response streaming only works in the AWS " + - "Lambda Node.js runtime, on a function invoked through a Function URL with " + - "`InvokeMode: RESPONSE_STREAM`." + "The `awslambda` global does not provide `streamifyResponse`. Response streaming only " + + "works in the AWS Lambda Node.js runtime, on a function invoked through a Function " + + "URL with `InvokeMode: RESPONSE_STREAM`." ); } return global; } +/** + * Whether the real streaming runtime is present. + * + * Tests for `streamifyResponse` rather than for the `awslambda` object, because the object's presence + * proves nothing: `@aws/lambda-invoke-store` (pulled in transitively by the AWS SDK) runs + * `globalThis.awslambda = globalThis.awslambda || {}` at import time. In Lambda that preserves the + * runtime's real global, but everywhere else it leaves an EMPTY object — so an object-presence check + * passes outside Lambda and then `streamifyResponse` blows up at module load, taking the whole bundle + * with it. + */ export function isAwsLambdaStreamingRuntime(): boolean { - return Boolean((globalThis as any).awslambda); + return typeof (globalThis as any).awslambda?.streamifyResponse === "function"; } diff --git a/packages/project/src/extensions/Project/WcpInjectTelemetryClientAfterBuild.ts b/packages/project/src/extensions/Project/WcpInjectTelemetryClientAfterBuild.ts index 9b35f51f525..7314a3761f5 100644 --- a/packages/project/src/extensions/Project/WcpInjectTelemetryClientAfterBuild.ts +++ b/packages/project/src/extensions/Project/WcpInjectTelemetryClientAfterBuild.ts @@ -50,6 +50,28 @@ class WcpInjectTelemetryClientAfterBuildImpl implements ApiAfterBuild.Interface const telemetryCodeAsString = await response.text(); + // The downloaded wrapper re-exports only `handler`, but the AWS api bundle also exports + // `streamHandler` — the entry point of the response-streaming Lambda. Without this + // re-export that function has no handler to load and fails at cold start. + // + // Two constraints shape how it's done: + // + // 1. UNWRAPPED. `streamHandler` carries the marker `awslambda.streamifyResponse` attaches, + // and the runtime inspects the exported function for it; routing it through a telemetry + // wrapper function would strip the marker and silently downgrade the function to + // buffered responses. Re-exporting the same function object keeps the marker — at the + // cost of no telemetry for that function, which is the right trade. + // + // 2. Via a NAMESPACE import, not `export { streamHandler } from ...`. This injection also + // runs for the self-hosted api build, whose bundle has no `streamHandler` (streaming is + // native there, with no Lambda involved), and a named re-export of a missing binding is + // a hard ESM error that would break the whole handler. A namespace access just yields + // `undefined`, which nothing on that path reads. + const wrapperCode = + telemetryCodeAsString + + '\nimport * as _webinyBuiltHandlers from "./_handler.mjs";\n' + + "export const streamHandler = _webinyBuiltHandlers.streamHandler;\n"; + // 2. Wrap the initially built code with the telemetry client code. for (let i = 0; i < handlersPaths.length; i++) { const current = handlersPaths[i]; @@ -60,7 +82,7 @@ class WcpInjectTelemetryClientAfterBuildImpl implements ApiAfterBuild.Interface fs.renameSync(builtHandlerPath, renamedHandlerPath); // 2.2 Write downloaded telemetry client code as a new `handler.js`. - fs.writeFileSync(builtHandlerPath, telemetryCodeAsString); + fs.writeFileSync(builtHandlerPath, wrapperCode); } logger.info("WCP telemetry client injected successfully."); From aa54305e50a72ac49d7eab4b93f594de7f5c14f3 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 31 Jul 2026 11:50:10 +0200 Subject: [PATCH 04/77] fix(event-handler): make Lambda response streaming work behind CloudFront MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all found by measuring a real deployment rather than reading the code. Response streaming now works end to end on AWS. 1. Missing IAM permission (403 on every request) CloudFront could not invoke the Function URL at all: Lambda's authorizer denied every signed request with AccessDeniedException and the function was never invoked. The OAC-for-Lambda docs require TWO permission statements — `lambda:InvokeFunctionUrl` AND `lambda:InvokeFunction` — and only the former was granted. 2. The prelude was never flushed (empty 200, no headers) The runtime emits the response prelude LAZILY, on the first write to the stream. A response that writes nothing — a CORS preflight is 204 with no body — therefore sent no prelude at all, and Lambda substituted a default 200 with `application/octet-stream` and none of the route's headers. It failed silently: status success, nothing logged, and a direct streaming invoke returned 0 bytes. Every path now guarantees one write, including a stream that yields no chunks. Verified against the deployed function: the same invoke now returns the prelude JSON plus its 8-byte delimiter. This is what made the preflight "succeed" while the browser still reported a CORS failure — it arrived header-less. 3. Legacy forwardedValues on the streaming behavior Replaced with Managed-CachingDisabled plus an origin request policy. The policy also forwards `Access-Control-Request-Method` and `Access-Control-Request-Headers`, without which the origin cannot build a correct preflight response. Also adds a loud warning when `streamifyResponse` is unavailable inside Lambda. That state is otherwise invisible: the handler is invoked buffered and returns header-less empty responses that look like a working stream. Note for future streaming routes: OAC does not sign the request body, so a route that POSTs a body needs the client to send `x-amz-content-sha256`. The enrich route sends no body. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/functionUrlStreaming.test.ts | 28 ++++++- .../src/createStreamLambdaHandler.ts | 15 ++++ .../FunctionUrlStreamRouterHandler.ts | 18 ++++- .../src/pulumi/apps/api/ApiCloudfront.ts | 75 ++++++++++++++----- 4 files changed, 111 insertions(+), 25 deletions(-) diff --git a/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts index 37065d9c1e2..c1c1d936c39 100644 --- a/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts +++ b/packages/event-handler-aws/__tests__/functionUrlStreaming.test.ts @@ -255,13 +255,37 @@ describe("Lambda Function URL response streaming", () => { expect(stream.body).toBe(JSON.stringify({ ok: true })); }); - it("should send an empty body for null", async () => { + it("should still write once for a body-less response so the prelude flushes", async () => { + // The runtime emits the prelude on the FIRST write. With zero writes it sends nothing at all + // and Lambda substitutes a default 200 + application/octet-stream, dropping every header — + // which is exactly how a CORS preflight silently lost its access-control-* headers. const handler = makeHandler(makeRoute(async () => ({ statusCode: 204, body: null }))); const stream = new FakeResponseStream(); await handler(functionUrlEvent(), stream); - expect(stream.chunks).toHaveLength(0); + expect(stream.chunks).toHaveLength(1); + expect(stream.body).toBe(""); + expect(stream.ended).toBe(true); + }); + + it("should write once when a stream yields no chunks", async () => { + const handler = makeHandler( + makeRoute(async () => ({ + statusCode: 200, + body: new HttpStreamBody({ + async *[Symbol.asyncIterator]() { + // no chunks + } + }) + })) + ); + const stream = new FakeResponseStream(); + + await handler(functionUrlEvent(), stream); + + expect(stream.chunks).toHaveLength(1); + expect(stream.body).toBe(""); expect(stream.ended).toBe(true); }); diff --git a/packages/event-handler-aws/src/createStreamLambdaHandler.ts b/packages/event-handler-aws/src/createStreamLambdaHandler.ts index 2ac95f97b18..45db3c0c8e7 100644 --- a/packages/event-handler-aws/src/createStreamLambdaHandler.ts +++ b/packages/event-handler-aws/src/createStreamLambdaHandler.ts @@ -47,6 +47,21 @@ export function createStreamLambdaHandler( // Outside the Lambda runtime (tests, local dev) there is no `awslambda` global to mark the // handler with. Return it unwrapped so merely importing the module doesn't throw — it still // routes and writes to whatever stream it is handed. + // + // Inside Lambda this is never expected, and it fails SILENTLY: an unmarked handler is invoked + // in buffered mode, returns undefined, and the caller gets an empty 200 with + // `content-type: application/octet-stream` and none of the headers the route set. Say so + // loudly, because nothing else in the response distinguishes it from a working stream. + if (process.env.AWS_LAMBDA_FUNCTION_NAME) { + const shape = (globalThis as any).awslambda; + console.error( + "[webiny] Response streaming is UNAVAILABLE in this Lambda: `awslambda" + + ".streamifyResponse` was not a function at module load, so the handler was not " + + "marked as streaming and will return buffered, header-less responses. " + + `awslambda=${typeof shape}, keys=${JSON.stringify(Object.keys(shape ?? {}))}` + ); + } + return handler; } diff --git a/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts b/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts index 2559297bbff..9932fdcc0d5 100644 --- a/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts +++ b/packages/event-handler-aws/src/handlers/FunctionUrlStreamRouterHandler.ts @@ -84,6 +84,13 @@ class FunctionUrlStreamRouterHandlerImpl implements FunctionUrlStreamEventHandle const { body } = response; + // The runtime emits the prelude LAZILY, on the first write to the stream (its + // `_onBeforeFirstWrite` hook). A response that writes nothing therefore sends no prelude at + // all, and Lambda falls back to a default 200 with `application/octet-stream` and none of the + // headers set above — silently, with no error. A CORS preflight (204, no body) and an empty + // stream both hit this, so every path below guarantees at least one write. + let wrote = false; + if (HttpStreamBody.is(body)) { for await (const chunk of body.source) { if (stream.destroyed) { @@ -91,13 +98,16 @@ class FunctionUrlStreamRouterHandlerImpl implements FunctionUrlStreamEventHandle break; } await this.write(stream, chunk); + wrote = true; } - stream.end(); - return; + } else if (body !== undefined && body !== null) { + await this.write(stream, this.serialize(body)); + wrote = true; } - if (body !== undefined && body !== null) { - await this.write(stream, this.serialize(body)); + if (!wrote) { + // Zero-length write: flushes the prelude without adding body bytes, so a 204 stays a 204. + await this.write(stream, ""); } stream.end(); diff --git a/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts b/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts index 1654a1e6341..48d1c1ead99 100644 --- a/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts +++ b/packages/project-aws/src/pulumi/apps/api/ApiCloudfront.ts @@ -41,6 +41,42 @@ export const ApiCloudfront = createAppModule({ } }); + // The streaming behavior uses a cache policy + origin request policy instead of the legacy + // `forwardedValues` the other behaviors use. That is not cosmetic: legacy forwarded values are + // deprecated and do not compose with OAC request signing — with them in place, Lambda rejected + // every signed request from CloudFront with `AccessDeniedException` and the function was never + // invoked. + const streamOriginRequestPolicy = app.addResource(aws.cloudfront.OriginRequestPolicy, { + name: "api-stream-origin-request-policy", + config: { + comment: "Headers/cookies forwarded to the streaming Lambda Function URL origin", + headersConfig: { + headerBehavior: "whitelist", + headers: { + items: [ + "Origin", + "Accept", + "Accept-Language", + // Required for CORS preflight: without these the origin can't build a + // correct 204, so the browser blocks the real request. + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + "X-Tenant", + "X-Webiny-Sdk", + // `Authorization` is deliberately ABSENT — OAC signing owns that header. + // Clients send their token in `X-Webiny-Authorization` instead. + "X-Webiny-Authorization" + ] + } + }, + cookiesConfig: { + cookieBehavior: "whitelist", + cookies: { items: ["wby-id-token"] } + }, + queryStringsConfig: { queryStringBehavior: "all" } + } + }); + const distribution = app.addResource(aws.cloudfront.Distribution, { name: "api-cloudfront", config: { @@ -82,25 +118,11 @@ export const ApiCloudfront = createAppModule({ "DELETE" ], cachedMethods: ["GET", "HEAD"], - forwardedValues: { - cookies, - // `Authorization` is deliberately absent: OAC signing puts SigV4 in that - // header, so a viewer token there could not survive. Clients send the - // token in `X-Webiny-Authorization` instead, which both the AWS and the - // self-hosted identity extractors read first. - headers: [ - "Origin", - "Accept", - "Accept-Language", - "X-Tenant", - "X-Webiny-Sdk", - "X-Webiny-Authorization" - ], - queryString: true - }, - minTtl: 0, - defaultTtl: 0, - maxTtl: 0, + // Managed-CachingDisabled. Nothing on a streaming route is cacheable, and a + // cache policy is also what replaces the legacy `forwardedValues` that OAC + // signing can't live with. TTLs must NOT be set alongside a cache policy. + cachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", + originRequestPolicyId: streamOriginRequestPolicy.output.id, pathPattern: "/stream/*", viewerProtocolPolicy: "https-only", targetOriginId: STREAM_ORIGIN_ID @@ -245,6 +267,10 @@ export const ApiCloudfront = createAppModule({ // Allows THIS distribution — and nothing else — to invoke the streaming Function URL. Created // after the distribution because it needs its ARN; the Function URL is useless until it exists. + // + // BOTH statements are required. The OAC-for-Lambda docs list `lambda:InvokeFunctionUrl` AND + // `lambda:InvokeFunction`; with only the former, Lambda's authorizer rejects every signed + // request from CloudFront with `AccessDeniedException` and the function is never invoked. app.addResource(aws.lambda.Permission, { name: "graphql-stream-url-cloudfront-invoke", config: { @@ -257,6 +283,17 @@ export const ApiCloudfront = createAppModule({ } }); + app.addResource(aws.lambda.Permission, { + name: "graphql-stream-cloudfront-invoke-function", + config: { + action: "lambda:InvokeFunction", + function: graphqlStream.functions.graphqlStream.output.name, + principal: "cloudfront.amazonaws.com", + sourceArn: distribution.output.arn, + statementId: "allow-cloudfront-invoke-function" + } + }); + return distribution; } }); From f55b0b0b57d1ce8b09f1bd2968bc96f376ceec95 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 20 Aug 2026 15:20:56 +0200 Subject: [PATCH 05/77] wip: vnqua --- ai-context/code-style/README.md | 1 + .../code-style/no-nested-call-arguments.md | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 ai-context/code-style/no-nested-call-arguments.md diff --git a/ai-context/code-style/README.md b/ai-context/code-style/README.md index 5a9ac0464e1..738e8adccac 100644 --- a/ai-context/code-style/README.md +++ b/ai-context/code-style/README.md @@ -15,5 +15,6 @@ Read every rule in this folder before writing or editing code. | [no-inline-class-in-create-implementation.md](./no-inline-class-in-create-implementation.md) | Declare implementation classes separately with an `implements` clause. | | [compose-css-class-names.md](./compose-css-class-names.md) | Compose class names with a `cn` helper, never `+` or template literals. | | [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. | +| [no-nested-call-arguments.md](./no-nested-call-arguments.md) | Name each step; don't nest calls as arguments to other calls. | When adding a new code-style rule, create a new `*.md` file here (one rule per file) and add it to the table above. diff --git a/ai-context/code-style/no-nested-call-arguments.md b/ai-context/code-style/no-nested-call-arguments.md new file mode 100644 index 00000000000..ed673bd5440 --- /dev/null +++ b/ai-context/code-style/no-nested-call-arguments.md @@ -0,0 +1,23 @@ +# No Nested Call Arguments + +Don't nest function calls as arguments to other calls. Each transformation step gets its own named +`const`, even when that costs a few more lines — the name says what the value IS, and a debugger can +show it. Prefer more lines of code over a dense one-liner. + +Applies to any call chained through another call's argument list, including setter calls. + +```ts +// Bad +this.rawTenantId.set(extractTenantId(headersFromFunctionUrlEvent(ctx.event))); +``` + +```ts +// Good +const headers = headersFromFunctionUrlEvent(ctx.event); +const tenantId = extractTenantId(headers); + +this.rawTenantId.set(tenantId); +``` + +Method chaining on a fluent API (`builder.a().b().c()`) is not affected — this is about passing the +result of one call straight into another call's parameters. From ff799f442604e7642d614a7d9265d4da058349dc Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 20 Aug 2026 15:21:08 +0200 Subject: [PATCH 06/77] wip: spe6o --- .../src/handlers/ApiGatewayIdentityLoaderDecorator.ts | 6 +++++- .../handlers/FunctionUrlStreamIdentityLoaderDecorator.ts | 6 +++++- .../src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts index 8a1013bc56d..60a25e951c9 100644 --- a/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts +++ b/packages/api-event-handler-aws/src/handlers/ApiGatewayIdentityLoaderDecorator.ts @@ -25,7 +25,11 @@ class ApiGatewayIdentityLoaderDecoratorImpl implements ApiGatewayEventHandler.In ) {} async execute(ctx: EventContext, next: NextFunction): Promise { - this.rawAuthToken.set(extractAuthToken(ctx.event?.headers as Record)); + const headers = ctx.event?.headers as Record; + const authToken = extractAuthToken(headers); + + this.rawAuthToken.set(authToken); + await this.identityLoader.establish(); return this.decoratee.execute(ctx, next); } diff --git a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts index 4794c35b6ba..ac32d8124ec 100644 --- a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts +++ b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamIdentityLoaderDecorator.ts @@ -24,7 +24,11 @@ class FunctionUrlStreamIdentityLoaderDecoratorImpl ) {} async execute(ctx: EventContext, next: NextFunction): Promise { - this.rawAuthToken.set(extractAuthToken(headersFromFunctionUrlEvent(ctx.event))); + const headers = headersFromFunctionUrlEvent(ctx.event); + const authToken = extractAuthToken(headers); + + this.rawAuthToken.set(authToken); + await this.identityLoader.establish(); return this.decoratee.execute(ctx, next); } diff --git a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts index b8c2afa0d57..bd2e82170d1 100644 --- a/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts +++ b/packages/api-event-handler-aws/src/handlers/FunctionUrlStreamTenantLoaderDecorator.ts @@ -24,7 +24,11 @@ class FunctionUrlStreamTenantLoaderDecoratorImpl ) {} async execute(ctx: EventContext, next: NextFunction): Promise { - this.rawTenantId.set(extractTenantId(headersFromFunctionUrlEvent(ctx.event))); + const headers = headersFromFunctionUrlEvent(ctx.event); + const tenantId = extractTenantId(headers); + + this.rawTenantId.set(tenantId); + await this.tenantLoader.establish(); return this.decoratee.execute(ctx, next); } From 5450a784b8769de39f2d1dd857477c26f282e7e0 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 20 Aug 2026 16:19:35 +0200 Subject: [PATCH 07/77] refactor: one public function per file in the AWS composition + server response writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on two files that had grown into grab-bags. `registerWebinyApi.ts` held two independent lifecycle steps plus their shared types. Split into `composition/registerWebinyApiRoot.ts`, `composition/registerWebinyApiRequest.ts`, `composition/types.ts`, and a barrel. Behavior unchanged; both composition roots import from the barrel. `createServerHandler` had the whole response-writing decision tree inline — a stream branch with its own back-pressure loop, four buffered-body branches, and an error path that has to know whether headers already went out. Extracted to `response/`: `writeHttpResponse` (dispatch), `writeStreamBody`, `writeBufferedBody`, `writeErrorResponse`. The request handler is now three lines and each piece is separately readable. Adds `ai-context/code-style/one-public-function-per-file.md` so this doesn't regress, with the narrow exception for a module of small pure helpers named for one concept — otherwise `extractRequestAuth.ts` would be a violation for no gain. --- ai-context/code-style/README.md | 1 + .../one-public-function-per-file.md | 32 ++++++ .../src/composition/index.ts | 3 + .../composition/registerWebinyApiRequest.ts | 35 +++++++ .../src/composition/registerWebinyApiRoot.ts | 29 ++++++ .../src/composition/types.ts | 40 ++++++++ .../src/createWebinyApiHandler.ts | 8 +- .../src/createWebinyStreamApiHandler.ts | 6 +- packages/api-event-handler-aws/src/index.ts | 2 +- .../src/registerWebinyApi.ts | 98 ------------------- .../src/createServerHandler.ts | 53 ++-------- .../src/response/index.ts | 4 + .../src/response/writeBufferedBody.ts | 27 +++++ .../src/response/writeErrorResponse.ts | 21 ++++ .../src/response/writeHttpResponse.ts | 25 +++++ .../src/response/writeStreamBody.ts | 31 ++++++ 16 files changed, 262 insertions(+), 153 deletions(-) create mode 100644 ai-context/code-style/one-public-function-per-file.md create mode 100644 packages/api-event-handler-aws/src/composition/index.ts create mode 100644 packages/api-event-handler-aws/src/composition/registerWebinyApiRequest.ts create mode 100644 packages/api-event-handler-aws/src/composition/registerWebinyApiRoot.ts create mode 100644 packages/api-event-handler-aws/src/composition/types.ts delete mode 100644 packages/api-event-handler-aws/src/registerWebinyApi.ts create mode 100644 packages/event-handler-server/src/response/index.ts create mode 100644 packages/event-handler-server/src/response/writeBufferedBody.ts create mode 100644 packages/event-handler-server/src/response/writeErrorResponse.ts create mode 100644 packages/event-handler-server/src/response/writeHttpResponse.ts create mode 100644 packages/event-handler-server/src/response/writeStreamBody.ts diff --git a/ai-context/code-style/README.md b/ai-context/code-style/README.md index 738e8adccac..cc691fc7019 100644 --- a/ai-context/code-style/README.md +++ b/ai-context/code-style/README.md @@ -16,5 +16,6 @@ Read every rule in this folder before writing or editing code. | [compose-css-class-names.md](./compose-css-class-names.md) | Compose class names with a `cn` helper, never `+` or template literals. | | [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. | | [no-nested-call-arguments.md](./no-nested-call-arguments.md) | Name each step; don't nest calls as arguments to other calls. | +| [one-public-function-per-file.md](./one-public-function-per-file.md) | One exported function per file; composition steps always get their own. | When adding a new code-style rule, create a new `*.md` file here (one rule per file) and add it to the table above. diff --git a/ai-context/code-style/one-public-function-per-file.md b/ai-context/code-style/one-public-function-per-file.md new file mode 100644 index 00000000000..8d068b9dbcc --- /dev/null +++ b/ai-context/code-style/one-public-function-per-file.md @@ -0,0 +1,32 @@ +# One Public Function Per File + +A file exports ONE function meant for other modules. Helpers used only by it live in the same file, +unexported. Don't group independent exported functions in one module because they feel related — +give each its own file and let a barrel (`index.ts`) group them. + +This mirrors [one-class-per-file.md](./one-class-per-file.md): the file name should tell you what +you get. + +```ts +// Bad — registerWebinyApi.ts: two independent lifecycle steps plus their shared types +export async function registerWebinyApiRoot(...) { /* ... */ } +export async function registerWebinyApiRequest(...) { /* ... */ } +``` + +```ts +// Good +// composition/registerWebinyApiRoot.ts +export async function registerWebinyApiRoot(...) { /* ... */ } + +// composition/registerWebinyApiRequest.ts +export async function registerWebinyApiRequest(...) { /* ... */ } + +// composition/types.ts — shared types +// composition/index.ts — barrel re-exporting the above +``` + +Composition steps, registration functions, and entry points ALWAYS get their own file. + +Narrow exception: a module named for one coherent concept may export a few small, pure, low-level +helpers on that concept (e.g. `extractRequestAuth.ts` exporting header-parsing helpers). If the +functions differ in lifecycle, side effects, or who calls them, that's not this exception — split it. diff --git a/packages/api-event-handler-aws/src/composition/index.ts b/packages/api-event-handler-aws/src/composition/index.ts new file mode 100644 index 00000000000..c5484a899b7 --- /dev/null +++ b/packages/api-event-handler-aws/src/composition/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./registerWebinyApiRoot.js"; +export * from "./registerWebinyApiRequest.js"; diff --git a/packages/api-event-handler-aws/src/composition/registerWebinyApiRequest.ts b/packages/api-event-handler-aws/src/composition/registerWebinyApiRequest.ts new file mode 100644 index 00000000000..da9d256798d --- /dev/null +++ b/packages/api-event-handler-aws/src/composition/registerWebinyApiRequest.ts @@ -0,0 +1,35 @@ +import type { Container } from "@webiny/di"; +import { registerApiRequestStack } from "@webiny/api-event-handler-core"; +import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; +import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; +import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; +import type { WebinyApiCompositionConfig } from "./types.js"; + +/** + * The per-request feature stack, which is transport-agnostic (shared with the server transport). + * The AWS-specific interleave points are supplied as the `transports` adapters. + */ +export async function registerWebinyApiRequest( + container: Container, + config: WebinyApiCompositionConfig +): Promise { + await registerApiRequestStack(container, { + extensions: config.extensions, + registerRequestStorage: config.registerRequestStorage, + transports: { + // Real AWS WebSocket transport (API Gateway Management API), registered right after + // WebsocketsFeature so it overrides the NullWebsocketsTransport. + realtime: c => { + WebsocketsAwsFeature.register(c); + }, + // Scheduler transport: the scheduler-aws extension (EventBridge Scheduler). + scheduler: c => { + SchedulerAwsFeature.register(c); + }, + // File-manager storage transport: S3 (asset delivery + S3 file operations + schema). + fileManager: c => { + FileManagerS3Feature.register(c, {}); + } + } + }); +} diff --git a/packages/api-event-handler-aws/src/composition/registerWebinyApiRoot.ts b/packages/api-event-handler-aws/src/composition/registerWebinyApiRoot.ts new file mode 100644 index 00000000000..7d52aec8b18 --- /dev/null +++ b/packages/api-event-handler-aws/src/composition/registerWebinyApiRoot.ts @@ -0,0 +1,29 @@ +import type { Container } from "@webiny/di"; +import type { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; +// CognitoIdpFeature must be in the root container so the request auth step +// (identity loader decorator → RequestIdentityLoader) sees CognitoIdentityProvider when it is first +// instantiated. Extensions register in the child/request container — too late. +import { CognitoIdpFeature } from "@webiny/cognito/api/features/CognitoIdp/feature.js"; +import type { WebinyApiCompositionConfig } from "./types.js"; + +/** + * ROOT container registration that is not transport-specific: database, identity providers, and the + * storage variant. Shared by both AWS Lambda composition roots. + */ +export async function registerWebinyApiRoot( + container: Container, + config: WebinyApiCompositionConfig, + documentClient: ReturnType +): Promise { + // ── Database ─────────────────────────────────────────────── + DynamoDBCoreFeature.register(container, { documentClient }); + + // ── Identity providers ───────────────────────────────────── + // Must be in root so the request auth step can authenticate requests before the GraphQL engine + // runs. + CognitoIdpFeature.register(container); + + // ── Storage (variant-specific: CMS storage ops, DDB registries, OpenSearch core) ── + await config.registerRootStorage(container, { documentClient }); +} diff --git a/packages/api-event-handler-aws/src/composition/types.ts b/packages/api-event-handler-aws/src/composition/types.ts new file mode 100644 index 00000000000..3895109c679 --- /dev/null +++ b/packages/api-event-handler-aws/src/composition/types.ts @@ -0,0 +1,40 @@ +import type { Container } from "@webiny/di"; +import type { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import type { registerExtensions } from "@webiny/handler"; + +export interface RegisterRootStorageContext { + documentClient: ReturnType; +} + +/** + * The transport-agnostic half of a Webiny AWS Lambda composition root, shared by the buffered API + * Gateway handler (`createWebinyApiHandler`) and the response-streaming Function URL handler + * (`createWebinyStreamApiHandler`) so the two cannot drift on storage, identity providers, or the + * per-request feature stack. + */ +export interface WebinyApiCompositionConfig { + /** + * Project-defined extensions, applied at register() time. This is the one project-specific + * input; everything else is standard AWS/env wiring owned by this package. + */ + extensions: () => Parameters[1]; + /** + * DynamoDB document client. Defaults to the standard AWS client (`getDocumentClient()`). + * Injectable so integration tests can point the handler at a local (dynalite) DynamoDB. + */ + documentClient?: ReturnType; + /** + * Register the storage-variant features in the ROOT container: the CMS storage operations, the + * DDB storage registries, and (for the OpenSearch variant) the OpenSearch core. Supplied by the + * variant package. + */ + registerRootStorage: ( + container: Container, + ctx: RegisterRootStorageContext + ) => void | Promise; + /** + * Register any request-phase storage features that must run BEFORE `HeadlessCmsFeature` builds + * its storage — e.g. `DbRegistryFeature` for the DDB+ES variant. Optional (DDB-only needs none). + */ + registerRequestStorage?: (container: Container) => void | Promise; +} diff --git a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index 2705d3f844c..5f139e4f8b4 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -4,7 +4,7 @@ * The ROOT container wires the AWS transport (API Gateway HTTP + auth/tenant loaders, background-task * and WebSocket Lambda invocations, DynamoDB, Cognito, storage). Everything that is not * transport-specific — database, identity providers, storage, and the transport-AGNOSTIC per-request - * feature stack — lives in `registerWebinyApi.ts`, shared with the response-streaming handler + * feature stack — lives in `composition/`, shared with the response-streaming handler * (`createWebinyStreamApiHandler`) so the two roots cannot drift. The storage variant is injected via * `registerRootStorage` / `registerRequestStorage` by a thin variant package * (`@webiny/api-event-handler-aws-ddb`, `-aws-ddb-os`). Keeping the wiring in real packages (not an app @@ -22,10 +22,10 @@ import { BackgroundTasksAwsFeature } from "@webiny/background-tasks-aws"; import { BulkActionsEventBridgeLambdaHandlerFeature } from "@webiny/api-headless-cms-bulk-actions-aws"; import { ApiGatewayIdentityLoaderDecorator } from "~/handlers/ApiGatewayIdentityLoaderDecorator.js"; import { ApiGatewayTenantLoaderDecorator } from "~/handlers/ApiGatewayTenantLoaderDecorator.js"; -import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/registerWebinyApi.js"; -import type { WebinyApiCompositionConfig } from "~/registerWebinyApi.js"; +import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/composition/index.js"; +import type { WebinyApiCompositionConfig } from "~/composition/index.js"; -export type { RegisterRootStorageContext } from "~/registerWebinyApi.js"; +export type { RegisterRootStorageContext } from "~/composition/index.js"; export type CreateWebinyApiHandlerConfig = WebinyApiCompositionConfig; diff --git a/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts index 02957d99e9e..d76745bedbc 100644 --- a/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyStreamApiHandler.ts @@ -7,7 +7,7 @@ * (fronted by CloudFront), because API Gateway buffers the entire Lambda response and therefore cannot * stream at all. * - * Root/request registration is shared with `createWebinyApiHandler` via `registerWebinyApi.ts`; only + * Root/request registration is shared with `createWebinyApiHandler` via `composition/`; only * the transport differs. Notably NOT registered here: the background-task and WebSocket event types * plus their Lambda handlers. Those are inbound invocation paths that only ever target the buffered * function, so registering them would add cold-start cost for events this function never receives. @@ -18,8 +18,8 @@ import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createStreamLambdaHandler, FunctionUrlStreamFeature } from "@webiny/event-handler-aws"; import { FunctionUrlStreamIdentityLoaderDecorator } from "~/handlers/FunctionUrlStreamIdentityLoaderDecorator.js"; import { FunctionUrlStreamTenantLoaderDecorator } from "~/handlers/FunctionUrlStreamTenantLoaderDecorator.js"; -import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/registerWebinyApi.js"; -import type { WebinyApiCompositionConfig } from "~/registerWebinyApi.js"; +import { registerWebinyApiRequest, registerWebinyApiRoot } from "~/composition/index.js"; +import type { WebinyApiCompositionConfig } from "~/composition/index.js"; export type CreateWebinyStreamApiHandlerConfig = WebinyApiCompositionConfig; diff --git a/packages/api-event-handler-aws/src/index.ts b/packages/api-event-handler-aws/src/index.ts index 713c4a32b1d..70ff0ea460c 100644 --- a/packages/api-event-handler-aws/src/index.ts +++ b/packages/api-event-handler-aws/src/index.ts @@ -5,5 +5,5 @@ export type { } from "./createWebinyApiHandler.js"; export { createWebinyStreamApiHandler } from "./createWebinyStreamApiHandler.js"; export type { CreateWebinyStreamApiHandlerConfig } from "./createWebinyStreamApiHandler.js"; -export type { WebinyApiCompositionConfig } from "./registerWebinyApi.js"; +export type { WebinyApiCompositionConfig } from "./composition/index.js"; export * from "./handlers/index.js"; diff --git a/packages/api-event-handler-aws/src/registerWebinyApi.ts b/packages/api-event-handler-aws/src/registerWebinyApi.ts deleted file mode 100644 index 85b6cc08b18..00000000000 --- a/packages/api-event-handler-aws/src/registerWebinyApi.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Registration shared by the two AWS Lambda composition roots — the buffered API Gateway handler - * (`createWebinyApiHandler`) and the response-streaming Function URL handler - * (`createWebinyStreamApiHandler`). - * - * Everything that is NOT transport-specific lives here, so the two entry points cannot drift on - * storage, identity providers, or the per-request feature stack. - */ -import type { Container } from "@webiny/di"; -import type { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import { registerExtensions } from "@webiny/handler"; -import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; -import { registerApiRequestStack } from "@webiny/api-event-handler-core"; -import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; -import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; -import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; -// CognitoIdpFeature must be in the root container so the request auth step -// (identity loader decorator → RequestIdentityLoader) sees CognitoIdentityProvider when it is first -// instantiated. Extensions register in the child/request container — too late. -import { CognitoIdpFeature } from "@webiny/cognito/api/features/CognitoIdp/feature.js"; - -export interface RegisterRootStorageContext { - documentClient: ReturnType; -} - -export interface WebinyApiCompositionConfig { - /** - * Project-defined extensions, applied at register() time. This is the one project-specific - * input; everything else is standard AWS/env wiring owned by this package. - */ - extensions: () => Parameters[1]; - /** - * DynamoDB document client. Defaults to the standard AWS client (`getDocumentClient()`). - * Injectable so integration tests can point the handler at a local (dynalite) DynamoDB. - */ - documentClient?: ReturnType; - /** - * Register the storage-variant features in the ROOT container: the CMS storage operations, the - * DDB storage registries, and (for the OpenSearch variant) the OpenSearch core. Supplied by the - * variant package. - */ - registerRootStorage: ( - container: Container, - ctx: RegisterRootStorageContext - ) => void | Promise; - /** - * Register any request-phase storage features that must run BEFORE `HeadlessCmsFeature` builds - * its storage — e.g. `DbRegistryFeature` for the DDB+ES variant. Optional (DDB-only needs none). - */ - registerRequestStorage?: (container: Container) => void | Promise; -} - -/** Database, identity providers, and the storage variant. Transport-agnostic. */ -export async function registerWebinyApiRoot( - container: Container, - config: WebinyApiCompositionConfig, - documentClient: ReturnType -): Promise { - // ── Database ─────────────────────────────────────────────── - DynamoDBCoreFeature.register(container, { documentClient }); - - // ── Identity providers ───────────────────────────────────── - // Must be in root so the request auth step can authenticate - // requests before the GraphQL engine runs. - CognitoIdpFeature.register(container); - - // ── Storage (variant-specific: CMS storage ops, DDB registries, OpenSearch core) ── - await config.registerRootStorage(container, { documentClient }); -} - -/** - * The per-request feature stack, which is transport-agnostic (shared with the server transport). The - * AWS-specific interleave points are supplied as the `transports` adapters. - */ -export async function registerWebinyApiRequest( - container: Container, - config: WebinyApiCompositionConfig -): Promise { - await registerApiRequestStack(container, { - extensions: config.extensions, - registerRequestStorage: config.registerRequestStorage, - transports: { - // Real AWS WebSocket transport (API Gateway Management API), registered right after - // WebsocketsFeature so it overrides the NullWebsocketsTransport. - realtime: c => { - WebsocketsAwsFeature.register(c); - }, - // Scheduler transport: the scheduler-aws extension (EventBridge Scheduler). - scheduler: c => { - SchedulerAwsFeature.register(c); - }, - // File-manager storage transport: S3 (asset delivery + S3 file operations + schema). - fileManager: c => { - FileManagerS3Feature.register(c, {}); - } - } - }); -} diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index a5d88907bb2..a1715d1a980 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -1,8 +1,9 @@ import http from "node:http"; -import { once } from "node:events"; import { Container } from "@webiny/di"; -import { createHandler, HttpStreamBody } from "@webiny/event-handler-core"; +import { createHandler } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; +import { writeHttpResponse } from "~/response/writeHttpResponse.js"; +import { writeErrorResponse } from "~/response/writeErrorResponse.js"; export interface CreateServerHandlerOptions { root: HandlerSetup; @@ -32,52 +33,10 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { const response = (await handle(req)) as IHttpResponse; - res.writeHead(response.statusCode, response.headers); - const { body } = response; - if (HttpStreamBody.is(body)) { - // Streaming response (e.g. SSE from an AI text stream). Flush the headers now — - // Node otherwise holds them until the first write, so the client would see nothing - // until the producer emits its first chunk. - res.flushHeaders(); - for await (const chunk of body.source) { - if (res.destroyed) { - // Client went away mid-stream; stop pulling from the producer. - break; - } - // Respect back-pressure: `write` returning false means the socket buffer is - // full, and ignoring that would grow it without bound on a slow consumer. - if (!res.write(chunk)) { - await once(res, "drain"); - } - } - if (!res.destroyed) { - res.end(); - } - } else if (body === undefined || body === null) { - res.end(); - } else if (typeof body === "string") { - res.end(body); - } else if (Buffer.isBuffer(body) || body instanceof Uint8Array) { - // Binary response (e.g. asset delivery returns an image Buffer). Write the raw bytes — - // JSON.stringify(buffer) would serialize it to `{"type":"Buffer","data":[...]}`, which - // the browser rejects (ERR_BLOCKED_BY_ORB) since it isn't the declared image content. - // The route's own Content-Type header (set via res.writeHead above) is preserved. - res.end(body); - } else { - res.end(JSON.stringify(body)); - } + + await writeHttpResponse(res, response); } catch (err) { - console.error("Unhandled error:", err); - if (res.headersSent) { - // A streaming body failed after the status line went out, so there is no way to turn - // this into a 500 — writeHead would throw ERR_HTTP_HEADERS_SENT and mask the real - // error. Destroy the socket so the client sees a truncated response rather than a - // complete-looking one. - res.destroy(); - return; - } - res.writeHead(500); - res.end("Internal Server Error"); + writeErrorResponse(res, err); } }); diff --git a/packages/event-handler-server/src/response/index.ts b/packages/event-handler-server/src/response/index.ts new file mode 100644 index 00000000000..35a95195d17 --- /dev/null +++ b/packages/event-handler-server/src/response/index.ts @@ -0,0 +1,4 @@ +export * from "./writeHttpResponse.js"; +export * from "./writeStreamBody.js"; +export * from "./writeBufferedBody.js"; +export * from "./writeErrorResponse.js"; diff --git a/packages/event-handler-server/src/response/writeBufferedBody.ts b/packages/event-handler-server/src/response/writeBufferedBody.ts new file mode 100644 index 00000000000..cba91e8ead7 --- /dev/null +++ b/packages/event-handler-server/src/response/writeBufferedBody.ts @@ -0,0 +1,27 @@ +import type { ServerResponse } from "node:http"; + +/** + * Writes a fully-materialized body in one shot, picking the encoding from the body's runtime type. + */ +export function writeBufferedBody(res: ServerResponse, body: unknown): void { + if (body === undefined || body === null) { + res.end(); + return; + } + + if (typeof body === "string") { + res.end(body); + return; + } + + if (Buffer.isBuffer(body) || body instanceof Uint8Array) { + // Binary response (e.g. asset delivery returns an image Buffer). Write the raw bytes — + // JSON.stringify(buffer) would serialize it to `{"type":"Buffer","data":[...]}`, which the + // browser rejects (ERR_BLOCKED_BY_ORB) since it isn't the declared image content. The route's + // own Content-Type header is preserved. + res.end(body); + return; + } + + res.end(JSON.stringify(body)); +} diff --git a/packages/event-handler-server/src/response/writeErrorResponse.ts b/packages/event-handler-server/src/response/writeErrorResponse.ts new file mode 100644 index 00000000000..5ac377a57e0 --- /dev/null +++ b/packages/event-handler-server/src/response/writeErrorResponse.ts @@ -0,0 +1,21 @@ +import type { ServerResponse } from "node:http"; + +/** + * Last-resort handler for a request that threw. + * + * Once the status line is out there is no way to turn the response into a 500 — `writeHead` would + * throw ERR_HTTP_HEADERS_SENT and mask the real error — so the socket is destroyed instead. That + * leaves the client with a truncated response rather than a complete-looking one, which is reachable + * whenever a streaming body fails mid-flight. + */ +export function writeErrorResponse(res: ServerResponse, err: unknown): void { + console.error("Unhandled error:", err); + + if (res.headersSent) { + res.destroy(); + return; + } + + res.writeHead(500); + res.end("Internal Server Error"); +} diff --git a/packages/event-handler-server/src/response/writeHttpResponse.ts b/packages/event-handler-server/src/response/writeHttpResponse.ts new file mode 100644 index 00000000000..703cf481475 --- /dev/null +++ b/packages/event-handler-server/src/response/writeHttpResponse.ts @@ -0,0 +1,25 @@ +import type { ServerResponse } from "node:http"; +import { HttpStreamBody } from "@webiny/event-handler-core"; +import type { IHttpResponse } from "@webiny/event-handler-core"; +import { writeStreamBody } from "./writeStreamBody.js"; +import { writeBufferedBody } from "./writeBufferedBody.js"; + +/** + * Writes an IHttpResponse to the Node response: status line and headers, then the body — streamed + * when the route opted in with an {@link HttpStreamBody}, buffered otherwise. + */ +export async function writeHttpResponse( + res: ServerResponse, + response: IHttpResponse +): Promise { + res.writeHead(response.statusCode, response.headers); + + const { body } = response; + + if (HttpStreamBody.is(body)) { + await writeStreamBody(res, body); + return; + } + + writeBufferedBody(res, body); +} diff --git a/packages/event-handler-server/src/response/writeStreamBody.ts b/packages/event-handler-server/src/response/writeStreamBody.ts new file mode 100644 index 00000000000..0d56470b2e0 --- /dev/null +++ b/packages/event-handler-server/src/response/writeStreamBody.ts @@ -0,0 +1,31 @@ +import { once } from "node:events"; +import type { ServerResponse } from "node:http"; +import type { HttpStreamBody } from "@webiny/event-handler-core"; + +/** + * Writes a streaming body chunk by chunk, so the client sees data as the producer emits it. + */ +export async function writeStreamBody(res: ServerResponse, body: HttpStreamBody): Promise { + // Flush the headers now — Node otherwise holds them until the first write, so the client would + // see nothing until the producer emits its first chunk. + res.flushHeaders(); + + for await (const chunk of body.source) { + if (res.destroyed) { + // Client went away mid-stream; stop pulling from the producer. + break; + } + + const flushed = res.write(chunk); + + // Respect back-pressure: `write` returning false means the socket buffer is full, and + // ignoring that would grow it without bound on a slow consumer. + if (!flushed) { + await once(res, "drain"); + } + } + + if (!res.destroyed) { + res.end(); + } +} From 6555ebb8d8167e4974a72a0ce87f8b312a405f23 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 21 Aug 2026 10:39:25 +0200 Subject: [PATCH 08/77] refactor: drop an unnecessary cast, document why the rspack entry is a library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createServerHandler` cast the awaited handler result, which required wrapping the expression in parens. `createHandler` already returns `Promise`, so the cast was never needed — a type annotation does the job and reads left to right. Adds `ai-context/code-style/prefer-type-annotation-over-cast.md`. Expands the `library: { type: "module" }` comment in createRsbuildConfig with the failure it prevents and how to verify it, since the reason isn't recoverable from the code: removing that line silently drops `streamHandler` from the bundle and still produces a green build. --- ai-context/code-style/README.md | 1 + .../prefer-type-annotation-over-cast.md | 23 +++++++++++++++++++ .../bundling/function/createRsbuildConfig.js | 15 ++++++++---- .../src/createServerHandler.ts | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 ai-context/code-style/prefer-type-annotation-over-cast.md diff --git a/ai-context/code-style/README.md b/ai-context/code-style/README.md index cc691fc7019..089518f16aa 100644 --- a/ai-context/code-style/README.md +++ b/ai-context/code-style/README.md @@ -17,5 +17,6 @@ Read every rule in this folder before writing or editing code. | [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. | | [no-nested-call-arguments.md](./no-nested-call-arguments.md) | Name each step; don't nest calls as arguments to other calls. | | [one-public-function-per-file.md](./one-public-function-per-file.md) | One exported function per file; composition steps always get their own. | +| [prefer-type-annotation-over-cast.md](./prefer-type-annotation-over-cast.md) | Annotate the variable; never add parens just to cast an expression. | When adding a new code-style rule, create a new `*.md` file here (one rule per file) and add it to the table above. diff --git a/ai-context/code-style/prefer-type-annotation-over-cast.md b/ai-context/code-style/prefer-type-annotation-over-cast.md new file mode 100644 index 00000000000..d37e24b2d57 --- /dev/null +++ b/ai-context/code-style/prefer-type-annotation-over-cast.md @@ -0,0 +1,23 @@ +# Prefer A Type Annotation Over An Inline Cast + +Never wrap an expression in parentheses just so you can cast it. If the value comes back as `any` or +a wider type, annotate the variable instead — the parens disappear and the intent reads left to +right. If a real cast is genuinely needed, give the expression its own `const` first. + +```ts +// Bad +const response = (await handle(req)) as IHttpResponse; +const port = (server.address() as AddressInfo).port; +``` + +```ts +// Good +const response: IHttpResponse = await handle(req); + +const address = server.address() as AddressInfo; +const port = address.port; +``` + +An annotation is also safer than a cast: assigning `any` to an annotated variable still type-checks, +but if the source type later narrows to something incompatible the compiler tells you, whereas `as` +silently keeps compiling. diff --git a/packages/build-tools/bundling/function/createRsbuildConfig.js b/packages/build-tools/bundling/function/createRsbuildConfig.js index 7671830df0d..9441ee506b6 100644 --- a/packages/build-tools/bundling/function/createRsbuildConfig.js +++ b/packages/build-tools/bundling/function/createRsbuildConfig.js @@ -59,10 +59,17 @@ export const createRsbuildConfig = async ({ cwd, enforceMaxBundleSize }) => { rspack: { output: { // Declares the entry's exports as the bundle's public API, so ALL of them survive. - // Without this, rspack tree-shakes any entry export nothing imports — which silently - // dropped `streamHandler` (and every module reachable only from it) from the api - // bundle, leaving the response-streaming Lambda with no handler to call. `handler` - // survived only by accident of being the first export. + // + // Nothing imports an entry's exports, so without this rspack treats any unused one + // as dead: it dropped `streamHandler` AND every module reachable only from it. The + // api bundle then exported just `handler` and contained zero streaming code, so the + // response-streaming Lambda (`handler.streamHandler`) had no handler to load and + // failed at cold start. `handler` survived only by accident of being first. + // + // To verify after changing anything here, grep the built bundle: + // .webiny/workspace/apps/api/graphql/build/_handler.mjs + // It must export BOTH `handler` and `streamHandler`. Removing this line takes the + // export count back to one — silently, with a green build. library: { type: "module" } }, ...(enforceMaxBundleSize && { diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index a1715d1a980..6c6adff1c24 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -32,7 +32,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await handle(req)) as IHttpResponse; + const response: IHttpResponse = await handle(req); await writeHttpResponse(res, response); } catch (err) { From bb8923aaa270ed4f2540495b02a0135a25e1024c Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 21 Aug 2026 12:20:25 +0200 Subject: [PATCH 09/77] feat(app-admin): AI mode in the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press space on an empty command palette query to enter AI mode and ask about the project's content in plain language. The assistant discovers content models and fields at call time, so it never needs model IDs or field names supplied. Backend: - `@webiny/api-ai-chat` adds `POST /ai/chat`. The agent loop runs server-side under the signed-in user's identity, so the browser holds no provider key and every tool call is checked against that user's own permissions. - Writes are gated. A tool that does not declare `readOnlyHint` is never run on the model's word alone: the loop pauses and returns the pending call for a human to approve. With no `WEBINY_API_AI_CHAT_APPROVAL_SECRET` configured, mutating tools are withheld entirely rather than gated on an approval that cannot be verified. - CMS tools (`api-headless-cms`): listContentModels, describeContentModel, queryEntries. Filters and sorts are accepted flat and split into the CMS's entry-meta vs `values` levels, since a flat field list is what describeContentModel hands the model. - Folder access tools (`api-aco`): listTeams, listFolders, and setFolderPermissions — the latter annotated destructive because it replaces rather than merges. - `IAiSdkTool` gains optional `title` and `annotations`; annotations drive the approval gate. Admin: - AI is a palette mode rather than a command detail view, so the input row stays in the same slot across modes. - Answers render through the existing `compileMarkdown`, with the tool chain shown beneath and a confirm block for proposed changes. Adds two code-style rules: routes delegate to use cases, and inject real dependencies rather than a container. Co-Authored-By: Claude Opus 5 (1M context) --- ai-context/code-style/README.md | 2 + .../inject-dependencies-not-the-container.md | 40 +++ .../routes-delegate-to-use-cases.md | 40 +++ packages/api-aco/package.json | 3 +- packages/api-aco/src/AcoFeature.ts | 2 + .../src/features/ai/ListFoldersTool.ts | 65 +++++ .../api-aco/src/features/ai/ListTeamsTool.ts | 52 ++++ .../features/ai/SetFolderPermissionsTool.ts | 103 ++++++++ packages/api-aco/src/features/ai/feature.ts | 17 ++ packages/api-aco/src/features/ai/index.ts | 4 + packages/api-ai-chat/package.json | 38 +++ packages/api-ai-chat/src/AiChatRoute.ts | 103 ++++++++ packages/api-ai-chat/src/AiChatUseCase.ts | 177 +++++++++++++ packages/api-ai-chat/src/abstractions.ts | 58 +++++ packages/api-ai-chat/src/approvals.ts | 70 ++++++ packages/api-ai-chat/src/index.ts | 55 ++++ packages/api-ai-chat/src/systemPrompt.ts | 27 ++ packages/api-ai-chat/tsconfig.build.json | 24 ++ packages/api-ai-chat/tsconfig.json | 24 ++ packages/api-ai-chat/vitest.config.ts | 10 + packages/api-ai-chat/webiny.config.js | 8 + .../api-core/src/features/ai/abstractions.ts | 20 ++ packages/api-core/src/features/ai/index.ts | 1 + packages/api-event-handler-core/package.json | 1 + .../src/registerApiRequestStack.ts | 6 + .../tsconfig.build.json | 3 + packages/api-event-handler-core/tsconfig.json | 3 + .../ListImagesByTagTool.ts | 2 + .../ai/ListContentModelsTool.test.ts | 74 ++++++ .../__tests__/ai/QueryEntriesTool.test.ts | 146 +++++++++++ .../src/HeadlessCmsFeature.ts | 2 + .../features/ai/DescribeContentModelTool.ts | 116 +++++++++ .../src/features/ai/ListContentModelsTool.ts | 80 ++++++ .../src/features/ai/QueryEntriesTool.ts | 229 +++++++++++++++++ .../src/features/ai/feature.ts | 18 ++ .../api-headless-cms/src/features/ai/index.ts | 4 + packages/api-websockets/package.json | 2 +- packages/api-websockets/tsconfig.build.json | 6 +- packages/api-websockets/tsconfig.json | 6 +- .../src/CommandPalette/CommandPalette.tsx | 238 +++++++++++++----- .../CommandPalette/components/AiModeBadge.tsx | 11 + .../components/AiSuggestions.tsx | 36 +++ .../src/CommandPalette/components/AiTurn.tsx | 96 +++++++ .../components/AnswerSkeleton.tsx | 14 ++ .../components/ApprovalPlan.tsx | 80 ++++++ .../components/CommandItemRow.tsx | 14 +- .../CommandPalette/components/NoResults.tsx | 26 ++ .../components/PaletteFooter.tsx | 32 +++ .../CommandPalette/components/ToolChip.tsx | 42 ++++ .../src/CommandPalette/components/index.ts | 18 +- .../src/CommandPalette/constants.ts | 3 + .../src/CommandPalette/useAiChat.ts | 129 ++++++++++ packages/app-admin/src/base/Admin.tsx | 2 + .../src/features/aiChat/AiChatGateway.ts | 65 +++++ .../src/features/aiChat/abstractions.ts | 64 +++++ .../app-admin/src/features/aiChat/feature.ts | 9 + .../app-admin/src/features/aiChat/index.ts | 12 + packages/app-admin/src/index.ts | 12 + .../commandPalette/commands/AskAiCommand.tsx | 30 +++ .../commandPalette/commands/feature.ts | 2 + packages/background-tasks/package.json | 2 +- packages/background-tasks/tsconfig.build.json | 6 +- packages/background-tasks/tsconfig.json | 6 +- packages/cli-core/files/references.json | 2 +- yarn.lock | 18 ++ 65 files changed, 2518 insertions(+), 92 deletions(-) create mode 100644 ai-context/code-style/inject-dependencies-not-the-container.md create mode 100644 ai-context/code-style/routes-delegate-to-use-cases.md create mode 100644 packages/api-aco/src/features/ai/ListFoldersTool.ts create mode 100644 packages/api-aco/src/features/ai/ListTeamsTool.ts create mode 100644 packages/api-aco/src/features/ai/SetFolderPermissionsTool.ts create mode 100644 packages/api-aco/src/features/ai/feature.ts create mode 100644 packages/api-aco/src/features/ai/index.ts create mode 100644 packages/api-ai-chat/package.json create mode 100644 packages/api-ai-chat/src/AiChatRoute.ts create mode 100644 packages/api-ai-chat/src/AiChatUseCase.ts create mode 100644 packages/api-ai-chat/src/abstractions.ts create mode 100644 packages/api-ai-chat/src/approvals.ts create mode 100644 packages/api-ai-chat/src/index.ts create mode 100644 packages/api-ai-chat/src/systemPrompt.ts create mode 100644 packages/api-ai-chat/tsconfig.build.json create mode 100644 packages/api-ai-chat/tsconfig.json create mode 100644 packages/api-ai-chat/vitest.config.ts create mode 100644 packages/api-ai-chat/webiny.config.js create mode 100644 packages/api-headless-cms/__tests__/ai/ListContentModelsTool.test.ts create mode 100644 packages/api-headless-cms/__tests__/ai/QueryEntriesTool.test.ts create mode 100644 packages/api-headless-cms/src/features/ai/DescribeContentModelTool.ts create mode 100644 packages/api-headless-cms/src/features/ai/ListContentModelsTool.ts create mode 100644 packages/api-headless-cms/src/features/ai/QueryEntriesTool.ts create mode 100644 packages/api-headless-cms/src/features/ai/feature.ts create mode 100644 packages/api-headless-cms/src/features/ai/index.ts create mode 100644 packages/app-admin-ui/src/CommandPalette/components/AiModeBadge.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/AiSuggestions.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/AiTurn.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/AnswerSkeleton.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/ApprovalPlan.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/NoResults.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/PaletteFooter.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/components/ToolChip.tsx create mode 100644 packages/app-admin-ui/src/CommandPalette/useAiChat.ts create mode 100644 packages/app-admin/src/features/aiChat/AiChatGateway.ts create mode 100644 packages/app-admin/src/features/aiChat/abstractions.ts create mode 100644 packages/app-admin/src/features/aiChat/feature.ts create mode 100644 packages/app-admin/src/features/aiChat/index.ts create mode 100644 packages/app-admin/src/presentation/commandPalette/commands/AskAiCommand.tsx diff --git a/ai-context/code-style/README.md b/ai-context/code-style/README.md index 5a9ac0464e1..ea224dd48cf 100644 --- a/ai-context/code-style/README.md +++ b/ai-context/code-style/README.md @@ -15,5 +15,7 @@ Read every rule in this folder before writing or editing code. | [no-inline-class-in-create-implementation.md](./no-inline-class-in-create-implementation.md) | Declare implementation classes separately with an `implements` clause. | | [compose-css-class-names.md](./compose-css-class-names.md) | Compose class names with a `cn` helper, never `+` or template literals. | | [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. | +| [routes-delegate-to-use-cases.md](./routes-delegate-to-use-cases.md) | Routes parse and map; feature logic lives in a use case. | +| [inject-dependencies-not-the-container.md](./inject-dependencies-not-the-container.md) | Declare real dependencies; never inject a container to resolve later. | When adding a new code-style rule, create a new `*.md` file here (one rule per file) and add it to the table above. diff --git a/ai-context/code-style/inject-dependencies-not-the-container.md b/ai-context/code-style/inject-dependencies-not-the-container.md new file mode 100644 index 00000000000..557a442c98a --- /dev/null +++ b/ai-context/code-style/inject-dependencies-not-the-container.md @@ -0,0 +1,40 @@ +# Inject Dependencies, Not The Container + +Declare the abstractions a class actually needs in its `dependencies` array. Never inject a container +(e.g. `RequestContainer`) to resolve things later — that hides the dependency graph, defeats +compile-time checking, and turns a missing registration into a runtime failure deep inside a method. + +```ts +// Good +class ThingUseCaseImpl implements ThingUseCase.Interface { + public constructor( + private readonly repository: ThingRepository.Interface, + private readonly logger: Logger.Interface + ) {} +} + +export const ThingUseCase = Abstraction.createImplementation({ + implementation: ThingUseCaseImpl, + dependencies: [ThingRepository, Logger] +}); +``` + +```ts +// Bad +class ThingUseCaseImpl implements ThingUseCase.Interface { + public constructor(private readonly container: Container) {} + + async execute() { + const repository = this.container.resolve(ThingRepository); + } +} + +export const ThingUseCase = Abstraction.createImplementation({ + implementation: ThingUseCaseImpl, + dependencies: [RequestContainer] +}); +``` + +Note: some existing routes inject `RequestContainer` to work around `HttpRouter` constructing every +registered route on each request. That is a known issue in `HttpRouter`, not a pattern to copy — fix +the router rather than spreading the workaround. diff --git a/ai-context/code-style/routes-delegate-to-use-cases.md b/ai-context/code-style/routes-delegate-to-use-cases.md new file mode 100644 index 00000000000..ba396a0367d --- /dev/null +++ b/ai-context/code-style/routes-delegate-to-use-cases.md @@ -0,0 +1,40 @@ +# Routes Delegate To Use Cases + +An `HttpRoute` is transport, not feature code. Its job is to parse the request, call one use case, and +map the outcome onto a status code. Business logic — auth checks, orchestration, calls to other +services — belongs in a use case behind its own abstraction, so it can be resolved, decorated and +tested without an HTTP request. + +```ts +// Good +class CreateThingRouteImpl implements HttpRoute.Interface { + public readonly method = "POST"; + public readonly path = "/things"; + + public constructor(private readonly createThing: CreateThingUseCase.Interface) {} + + public async handle(request: IHttpRequest): Promise { + const params = parseBody(request.body); + if (!params) { + return json(400, { error: "Invalid body." }); + } + return json(200, await this.createThing.execute(params)); + } +} +``` + +```ts +// Bad — the feature lives in the route, so nothing else can reuse or test it +class CreateThingRouteImpl implements HttpRoute.Interface { + public async handle(request: IHttpRequest): Promise { + const identity = this.identityContext.getIdentity(); + if (identity.isAnonymous()) { + return json(401, { error: "Authentication required." }); + } + const validated = validate(request.body); + const created = await this.repository.create(validated); + await this.eventPublisher.publish(new ThingCreatedEvent(created)); + return json(200, created); + } +} +``` diff --git a/packages/api-aco/package.json b/packages/api-aco/package.json index e7b871acf7b..84f5df0a5de 100644 --- a/packages/api-aco/package.json +++ b/packages/api-aco/package.json @@ -33,7 +33,8 @@ "@webiny/shared-aco": "0.0.0", "@webiny/utils": "0.0.0", "@webiny/validation": "0.0.0", - "lodash": "^4.18.1" + "lodash": "^4.18.1", + "zod": "4.4.3" }, "devDependencies": { "@webiny/api": "0.0.0", diff --git a/packages/api-aco/src/AcoFeature.ts b/packages/api-aco/src/AcoFeature.ts index 2705816aabc..20e3e5af3b8 100644 --- a/packages/api-aco/src/AcoFeature.ts +++ b/packages/api-aco/src/AcoFeature.ts @@ -49,6 +49,7 @@ import { GetFlpFeature } from "~/features/flp/GetFlp/feature.js"; import { ListFolderLevelPermissionsTargetsFeature } from "~/features/folder/ListFolderLevelPermissionsTargets/feature.js"; import { CreateFlpOnFolderCreatedFeature } from "~/features/flp/CreateFlpOnFolderCreated/index.js"; import { EnsureFolderIsEmptyFeature } from "~/features/folder/EnsureFolderIsEmpty/feature.js"; +import { AcoAiToolsFeature } from "~/features/ai/index.js"; class AcoSchemaFactoryImpl implements GraphQLSchemaFactory.Interface { async execute(builder: IGraphQLSchemaBuilder): Promise { @@ -69,6 +70,7 @@ export const AcoFeature = createFeature({ name: "Aco", register(container: Container) { container.register(FolderModel); + AcoAiToolsFeature.register(container); container.register(FilterPrivateModel); // Background task definitions — pure wiring, no tenant/identity needed. diff --git a/packages/api-aco/src/features/ai/ListFoldersTool.ts b/packages/api-aco/src/features/ai/ListFoldersTool.ts new file mode 100644 index 00000000000..b0354cfa51d --- /dev/null +++ b/packages/api-aco/src/features/ai/ListFoldersTool.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { ListFoldersUseCase } from "~/features/folder/ListFolders/index.js"; + +const inputSchema = z.object({ + type: z + .string() + .describe( + "Folder namespace, e.g. 'FmFile' for the file manager or 'cms:' for a content model's folders." + ) +}); + +type Input = z.infer; + +interface FolderSummary { + id: string; + title: string; + slug: string; + path: string; + parentId?: string | null; + permissions: { target: string; level: string; inherited: boolean }[]; +} + +/** + * Lists folders with their current access rules, so a permission change is proposed against a real + * folder id and the user can see what the access already is before approving a change to it. + */ +class ListFoldersToolImpl implements IAiSdkTool { + readonly name = "listFolders"; + readonly title = "List folders"; + readonly description = + "Lists folders of a given type with their ids, paths and current access permissions. Call this before changing folder permissions so the change targets a real folder."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true, idempotentHint: true }; + + constructor(private listFolders: ListFoldersUseCase.Interface) {} + + async execute(input: Input): Promise { + const result = await this.listFolders.execute({ where: { type: input.type } }); + + if (result.isFail()) { + throw new Error(`Could not list folders: ${result.error.message}`); + } + + return result.value.folders.map(folder => ({ + id: folder.id, + title: folder.title, + slug: folder.slug, + path: folder.path, + parentId: folder.parentId, + permissions: (folder.permissions ?? []).map(permission => ({ + target: permission.target, + level: permission.level, + // Inherited rules cannot be edited on this folder — only on the ancestor that set them. + inherited: Boolean(permission.inheritedFrom) + })) + })); + } +} + +export const ListFoldersTool = AiSdkTool.createImplementation({ + implementation: ListFoldersToolImpl, + dependencies: [ListFoldersUseCase] +}); diff --git a/packages/api-aco/src/features/ai/ListTeamsTool.ts b/packages/api-aco/src/features/ai/ListTeamsTool.ts new file mode 100644 index 00000000000..71c3663d611 --- /dev/null +++ b/packages/api-aco/src/features/ai/ListTeamsTool.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { ListTeamsUseCase } from "@webiny/api-core/features/security/teams/ListTeams/index.js"; + +const inputSchema = z.object({}); + +type Input = z.infer; + +interface TeamSummary { + id: string; + name: string; + slug: string; + description?: string; +} + +/** + * Resolves human names ("team A") to the ids folder permissions actually take. + * + * Kept separate from setFolderPermissions rather than accepting a team name there: the user approves + * the arguments of a write, so the id has to be looked up and shown, not guessed inside the write. + */ +class ListTeamsToolImpl implements IAiSdkTool { + readonly name = "listTeams"; + readonly title = "List teams"; + readonly description = + "Lists the teams in this project with their ids, names and slugs. Call this to resolve a team name to an id before changing folder permissions."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true, idempotentHint: true }; + + constructor(private listTeams: ListTeamsUseCase.Interface) {} + + async execute(): Promise { + const result = await this.listTeams.execute(); + + if (result.isFail()) { + throw new Error(`Could not list teams: ${result.error.message}`); + } + + return result.value.map(team => ({ + id: team.id, + name: team.name, + slug: team.slug, + ...(team.description ? { description: team.description } : {}) + })); + } +} + +export const ListTeamsTool = AiSdkTool.createImplementation({ + implementation: ListTeamsToolImpl, + dependencies: [ListTeamsUseCase] +}); diff --git a/packages/api-aco/src/features/ai/SetFolderPermissionsTool.ts b/packages/api-aco/src/features/ai/SetFolderPermissionsTool.ts new file mode 100644 index 00000000000..f6ed3df1ecb --- /dev/null +++ b/packages/api-aco/src/features/ai/SetFolderPermissionsTool.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { GetFolderUseCase } from "~/features/folder/GetFolder/index.js"; +import { UpdateFolderUseCase } from "~/features/folder/UpdateFolder/index.js"; +import type { FolderPermission } from "~/types.js"; + +const ACCESS_LEVELS = ["owner", "editor", "viewer", "public", "no-access"] as const; + +const permissionSchema = z.object({ + target: z + .string() + .describe( + "Who the rule applies to: 'team:' or 'admin:'. Resolve ids with listTeams first — do not guess." + ), + level: z + .enum(ACCESS_LEVELS) + .describe( + "owner = full control including permissions; editor = change content; viewer = read only; no-access = explicitly denied." + ) +}); + +const inputSchema = z.object({ + folderId: z.string().describe("Folder id as returned by listFolders."), + permissions: z + .array(permissionSchema) + .describe( + "The COMPLETE set of direct permissions for this folder. This REPLACES the folder's existing direct permissions — include every rule that should remain, or it will be removed." + ) +}); + +type Input = z.infer; + +interface SetFolderPermissionsResult { + folderId: string; + title: string; + path: string; + permissions: { target: string; level: string }[]; +} + +/** + * Replaces a folder's direct access rules. + * + * NOT read-only, and deliberately not annotated as such: it requires human approval before it runs. + * It is also marked destructive because the semantics are replace-not-merge — omitting an existing + * rule revokes it, which can lock people (including the caller) out of a folder. + * + * Inherited rules are filtered out before writing: they belong to an ancestor folder, and persisting + * a copy here would silently detach this folder from that inheritance. + */ +class SetFolderPermissionsToolImpl implements IAiSdkTool { + readonly name = "setFolderPermissions"; + readonly title = "Set folder permissions"; + readonly description = + "Replaces the direct access permissions on a folder. Pass the complete desired set — any existing direct rule you omit is removed. Call listFolders to see current permissions and listTeams to resolve team ids first. Requires user approval."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: false, destructiveHint: true }; + + constructor( + private getFolder: GetFolderUseCase.Interface, + private updateFolder: UpdateFolderUseCase.Interface + ) {} + + async execute(input: Input): Promise { + const existing = await this.getFolder.execute(input.folderId); + + if (existing.isFail()) { + throw new Error( + `Folder "${input.folderId}" not found: ${existing.error.message}. Call listFolders for valid ids.` + ); + } + + const permissions = input.permissions.map( + permission => + ({ + target: permission.target, + level: permission.level + }) as FolderPermission + ); + + const result = await this.updateFolder.execute(input.folderId, { permissions }); + + if (result.isFail()) { + throw new Error(`Could not update folder permissions: ${result.error.message}`); + } + + const folder = result.value; + + return { + folderId: folder.id, + title: folder.title, + path: folder.path, + permissions: (folder.permissions ?? []) + .filter(permission => !permission.inheritedFrom) + .map(permission => ({ target: permission.target, level: permission.level })) + }; + } +} + +export const SetFolderPermissionsTool = AiSdkTool.createImplementation({ + implementation: SetFolderPermissionsToolImpl, + dependencies: [GetFolderUseCase, UpdateFolderUseCase] +}); diff --git a/packages/api-aco/src/features/ai/feature.ts b/packages/api-aco/src/features/ai/feature.ts new file mode 100644 index 00000000000..83cc3d9911f --- /dev/null +++ b/packages/api-aco/src/features/ai/feature.ts @@ -0,0 +1,17 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListTeamsTool } from "./ListTeamsTool.js"; +import { ListFoldersTool } from "./ListFoldersTool.js"; +import { SetFolderPermissionsTool } from "./SetFolderPermissionsTool.js"; + +/** + * Folder access tools for AI callers. The two list tools are read-only and run unattended; setting + * permissions is annotated as a write and therefore requires explicit user approval. + */ +export const AcoAiToolsFeature = createFeature({ + name: "Aco/AiTools", + register(container) { + container.register(ListTeamsTool); + container.register(ListFoldersTool); + container.register(SetFolderPermissionsTool); + } +}); diff --git a/packages/api-aco/src/features/ai/index.ts b/packages/api-aco/src/features/ai/index.ts new file mode 100644 index 00000000000..4f1739597b1 --- /dev/null +++ b/packages/api-aco/src/features/ai/index.ts @@ -0,0 +1,4 @@ +export { AcoAiToolsFeature } from "./feature.js"; +export { ListTeamsTool } from "./ListTeamsTool.js"; +export { ListFoldersTool } from "./ListFoldersTool.js"; +export { SetFolderPermissionsTool } from "./SetFolderPermissionsTool.js"; diff --git a/packages/api-ai-chat/package.json b/packages/api-ai-chat/package.json new file mode 100644 index 00000000000..a3afcf0c1da --- /dev/null +++ b/packages/api-ai-chat/package.json @@ -0,0 +1,38 @@ +{ + "name": "@webiny/api-ai-chat", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./*": "./*" + }, + "description": "Server-side AI chat endpoint that answers questions using Webiny's registered AI tools", + "keywords": [ + "api-ai-chat:base" + ], + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/api-ai-chat" + }, + "license": "MIT", + "dependencies": { + "@webiny/api-core": "0.0.0", + "@webiny/event-handler-core": "0.0.0", + "@webiny/feature": "0.0.0", + "ai": "^7.0.58" + }, + "devDependencies": { + "@webiny/build-tools": "0.0.0", + "@webiny/di": "^1.0.2", + "typescript": "^7.0.2", + "vitest": "^4.1.10", + "zod": "4.4.3" + }, + "publishConfig": { + "access": "public" + }, + "webiny": { + "publishFrom": "dist" + } +} diff --git a/packages/api-ai-chat/src/AiChatRoute.ts b/packages/api-ai-chat/src/AiChatRoute.ts new file mode 100644 index 00000000000..75584b0d9b8 --- /dev/null +++ b/packages/api-ai-chat/src/AiChatRoute.ts @@ -0,0 +1,103 @@ +import { HttpRoute } from "@webiny/event-handler-core"; +import type { IHttpRequest } from "@webiny/event-handler-core"; +import type { IHttpResponse } from "@webiny/event-handler-core"; +import type { ModelMessage } from "ai"; +import { Logger } from "@webiny/api-core/features/logger/index.js"; +import { AiChatUseCase } from "./abstractions.js"; +import { parseDecisions } from "./approvals.js"; +import type { ApprovalDecision } from "./approvals.js"; + +const BAD_REQUEST_MESSAGE = + "Provide either a non-empty `prompt` string or a `messages` array of model messages."; + +interface ParsedBody { + messages: ModelMessage[]; + decisions: ApprovalDecision[]; +} + +const json = (statusCode: number, body: unknown): IHttpResponse => { + return { + statusCode, + headers: { "content-type": "application/json" }, + body + }; +}; + +const safeParse = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * Accepts either a fresh question or a continuation. A continuation replays `messages` verbatim — + * including the assistant message carrying the approval request — because the SDK matches an approval + * response to its request by id, and that request exists nowhere else. We keep no session. + */ +const parseBody = (body: unknown): ParsedBody | null => { + const raw = typeof body === "string" ? safeParse(body) : body; + const payload = raw as Record | undefined; + + if (!payload) { + return null; + } + + const decisions = parseDecisions(payload["approvals"]); + const prompt = payload["prompt"]; + + if (typeof prompt === "string" && prompt.trim()) { + return { messages: [{ role: "user", content: prompt }], decisions }; + } + + const messages = payload["messages"]; + + if (!Array.isArray(messages) || messages.length === 0) { + return null; + } + + return { messages: messages as ModelMessage[], decisions }; +}; + +/** + * `POST /ai/chat` — transport only. Parses the request, delegates to `AiChatUseCase`, and maps failures + * onto status codes. All assistant behaviour lives in the use case. + */ +class AiChatRouteImpl implements HttpRoute.Interface { + public readonly method = "POST"; + public readonly path = "/ai/chat"; + + public constructor( + private readonly aiChat: AiChatUseCase.Interface, + private readonly logger: Logger.Interface + ) {} + + public async handle(request: IHttpRequest): Promise { + const parsed = parseBody(request.body); + + if (!parsed) { + return json(400, { error: BAD_REQUEST_MESSAGE }); + } + + try { + return json(200, await this.aiChat.execute(parsed)); + } catch (error) { + const code = (error as { code?: string }).code; + + if (code === "NOT_AUTHORIZED") { + return json(401, { error: "Authentication required." }); + } + + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ error }, "AI chat request failed."); + + return json(500, { error: message }); + } + } +} + +export const AiChatRoute = HttpRoute.createImplementation({ + implementation: AiChatRouteImpl, + dependencies: [AiChatUseCase, Logger] +}); diff --git a/packages/api-ai-chat/src/AiChatUseCase.ts b/packages/api-ai-chat/src/AiChatUseCase.ts new file mode 100644 index 00000000000..72beea715be --- /dev/null +++ b/packages/api-ai-chat/src/AiChatUseCase.ts @@ -0,0 +1,177 @@ +import { stepCountIs } from "ai"; +import type { ModelMessage } from "ai"; +import { Ai } from "@webiny/api-core/features/ai/index.js"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { AiSdkTools } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js"; +import { AiChatConfig } from "./abstractions.js"; +import { AiChatUseCase as Abstraction } from "./abstractions.js"; +import type { AiChatParams } from "./abstractions.js"; +import type { AiChatResult } from "./abstractions.js"; +import { SYSTEM_PROMPT } from "./systemPrompt.js"; +import { isReadOnly } from "./approvals.js"; +import { toPendingApproval } from "./approvals.js"; +import type { ApprovalDecision } from "./approvals.js"; +import type { PendingApproval } from "./approvals.js"; + +interface ApprovalRequestPart { + approvalId: string; + toolCall: { + toolName: string; + input: unknown; + }; +} + +/** + * Turns the caller's approve/reject decisions into the tool message the SDK expects. Sent as its own + * message so it lands after the assistant message that requested it. + */ +const toApprovalMessage = (decisions: ApprovalDecision[]): ModelMessage => { + return { + role: "tool", + content: decisions.map(decision => { + const part: { + type: "tool-approval-response"; + approvalId: string; + approved: boolean; + reason?: string; + } = { + type: "tool-approval-response", + approvalId: decision.approvalId, + approved: decision.approved + }; + + if (decision.reason) { + part.reason = decision.reason; + } + + return part; + }) + }; +}; + +/** + * Answers a question about the project by running an agent loop over the registered `AiSdkTool`s. + * + * Writes are gated. A tool that does not declare `readOnlyHint` is never executed on the model's word + * alone — the loop pauses and the pending call is returned for a human to approve. Everything runs + * under the caller's identity, so each tool is checked against that user's own permissions. + */ +class AiChatUseCaseImpl implements Abstraction.Interface { + constructor( + private readonly ai: Ai.Interface, + private readonly aiSdkTools: AiSdkTools.Interface, + private readonly declarations: IAiSdkTool[], + private readonly identityContext: IdentityContext.Interface, + private readonly config: AiChatConfig.Interface + ) {} + + async execute(params: AiChatParams): Promise { + if (this.identityContext.getIdentity().isAnonymous()) { + throw new NotAuthorizedError(); + } + + const tools = this.aiSdkTools.getToolSet(); + const readOnlyNames = new Set( + this.declarations.filter(isReadOnly).map(declaration => declaration.name) + ); + + /* + * Without a signing secret we cannot prove an approval belongs to the call it was issued for, + * so mutating tools are withheld entirely rather than gated on an unverifiable claim. + */ + const writesEnabled = Boolean(this.config.approvalSecret); + const toolNames = Object.keys(tools); + const activeTools = writesEnabled + ? toolNames + : toolNames.filter(name => readOnlyNames.has(name)); + + const messages = [...params.messages]; + if (params.decisions.length > 0) { + messages.push(toApprovalMessage(params.decisions)); + } + + const [providerId] = this.config.model.split("/"); + + const request: Record = { + model: this.config.model, + /* + * No apiKey: the provider factory falls back to its environment variable, so the key never + * travels through a request or gets stored per tenant. + */ + connection: { sdkName: providerId }, + system: SYSTEM_PROMPT, + messages, + tools, + activeTools, + toolChoice: "auto", + toolApproval: ({ toolCall }: { toolCall: { toolName: string } }) => { + return readOnlyNames.has(toolCall.toolName) ? "not-applicable" : "user-approval"; + }, + stopWhen: stepCountIs(this.config.maxSteps) + }; + + if (this.config.approvalSecret) { + request["experimental_toolApprovalSecret"] = this.config.approvalSecret; + } + + const result = await this.ai.generateText(request as never); + + return { + text: this.extractText(result), + toolCalls: this.extractToolCalls(result), + steps: result.steps.length, + pendingApprovals: this.extractPendingApprovals(result), + messages: result.responseMessages, + writesEnabled + }; + } + + /** + * `result.text` is empty when the run ended on a tool call or an approval request, so fall back to + * the most recent step that produced prose. + */ + private extractText(result: { text: string; steps: { text: string }[] }): string { + if (result.text) { + return result.text; + } + + const lastWithText = result.steps.filter(step => step.text.length > 0).pop(); + + return lastWithText ? lastWithText.text : ""; + } + + private extractToolCalls(result: { + steps: { toolCalls: { toolName: string; input: unknown }[] }[]; + }): { name: string; input: unknown }[] { + return result.steps.flatMap(step => { + return step.toolCalls.map(call => ({ name: call.toolName, input: call.input })); + }); + } + + private extractPendingApprovals(result: { + steps: { content: unknown[] }[]; + }): PendingApproval[] { + return result.steps.flatMap(step => { + return step.content + .filter(part => (part as { type: string }).type === "tool-approval-request") + .map(part => { + const request = part as unknown as ApprovalRequestPart; + + return toPendingApproval( + request.approvalId, + request.toolCall.toolName, + request.toolCall.input, + this.declarations + ); + }); + }); + } +} + +export const AiChatUseCase = Abstraction.createImplementation({ + implementation: AiChatUseCaseImpl, + dependencies: [Ai, AiSdkTools, [AiSdkTool, { multiple: true }], IdentityContext, AiChatConfig] +}); diff --git a/packages/api-ai-chat/src/abstractions.ts b/packages/api-ai-chat/src/abstractions.ts new file mode 100644 index 00000000000..cd7434550f3 --- /dev/null +++ b/packages/api-ai-chat/src/abstractions.ts @@ -0,0 +1,58 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { ModelMessage } from "ai"; +import type { ApprovalDecision } from "./approvals.js"; +import type { PendingApproval } from "./approvals.js"; + +export interface IAiChatConfig { + /** Model id in `/` form, e.g. "anthropic/claude-sonnet-5". */ + readonly model: string; + /** + * Upper bound on agent loop steps. Each tool call plus the final answer is a step, so a + * three-tool question (list models, describe, query) needs at least four. + */ + readonly maxSteps: number; + /** + * HMAC secret used to sign approval requests. Without it a client could replay an approval issued + * for one tool call against a different, unshown one — so an unset secret disables write tools + * entirely rather than degrading to an unsigned confirm. + */ + readonly approvalSecret?: string; +} + +/** Which model the admin AI assistant runs on. Override to change provider or model. */ +export const AiChatConfig = createAbstraction("AiChatConfig"); + +export namespace AiChatConfig { + export type Interface = IAiChatConfig; +} + +export interface AiChatParams { + /** Conversation so far. Replayed verbatim, since approval requests live only in these messages. */ + messages: ModelMessage[]; + /** Approve or reject tool calls a previous run paused on. */ + decisions: ApprovalDecision[]; +} + +export interface AiChatResult { + text: string; + toolCalls: { name: string; input: unknown }[]; + steps: number; + pendingApprovals: PendingApproval[]; + /** Response messages the caller replays when resuming after an approval. */ + messages: ModelMessage[]; + /** False when no approval secret is configured, so mutating tools were withheld. */ + writesEnabled: boolean; +} + +export interface IAiChatUseCase { + execute(params: AiChatParams): Promise; +} + +/** Answer a question about the project using the registered AI tools, gating writes on approval. */ +export const AiChatUseCase = createAbstraction("AiChatUseCase"); + +export namespace AiChatUseCase { + export type Interface = IAiChatUseCase; + export type Params = AiChatParams; + export type Result = AiChatResult; +} diff --git a/packages/api-ai-chat/src/approvals.ts b/packages/api-ai-chat/src/approvals.ts new file mode 100644 index 00000000000..c5150a360df --- /dev/null +++ b/packages/api-ai-chat/src/approvals.ts @@ -0,0 +1,70 @@ +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; + +/** + * A tool call the assistant wants to make but has not made, because it mutates something. + * Returned to the client so a human can approve or reject it. + */ +export interface PendingApproval { + approvalId: string; + toolName: string; + /** Human-readable tool name where the tool supplied one. */ + title?: string; + input: unknown; + /** True when the tool declared `destructiveHint` — the UI should say so louder. */ + destructive: boolean; +} + +export interface ApprovalDecision { + approvalId: string; + approved: boolean; + reason?: string; +} + +/** + * A tool runs unattended only if it says, in its own declaration, that it does not change anything. + * + * The default is deliberately the safe one: a tool with no annotations requires approval. Tools are + * registered by extensions as well as by Webiny, so an author who forgets to annotate gets a confirm + * prompt rather than silent write access. + */ +export const isReadOnly = (tool: IAiSdkTool): boolean => tool.annotations?.readOnlyHint === true; + +export const toPendingApproval = ( + approvalId: string, + toolName: string, + input: unknown, + tools: IAiSdkTool[] +): PendingApproval => { + const tool = tools.find(candidate => candidate.name === toolName); + + return { + approvalId, + toolName, + title: tool?.title, + input, + destructive: tool?.annotations?.destructiveHint === true + }; +}; + +export const parseDecisions = (value: unknown): ApprovalDecision[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap(entry => { + if (!entry || typeof entry !== "object") { + return []; + } + const { approvalId, approved, reason } = entry as Record; + if (typeof approvalId !== "string" || typeof approved !== "boolean") { + return []; + } + return [ + { + approvalId, + approved, + ...(typeof reason === "string" ? { reason } : {}) + } + ]; + }); +}; diff --git a/packages/api-ai-chat/src/index.ts b/packages/api-ai-chat/src/index.ts new file mode 100644 index 00000000000..7cf4c31ead6 --- /dev/null +++ b/packages/api-ai-chat/src/index.ts @@ -0,0 +1,55 @@ +import { createFeature } from "@webiny/feature/api"; +import { AiChatConfig } from "./abstractions.js"; +import { AiChatRoute } from "./AiChatRoute.js"; +import { AiChatUseCase } from "./AiChatUseCase.js"; + +export { AiChatRoute }; +export { AiChatUseCase }; +export { AiChatConfig }; +export type { IAiChatConfig } from "./abstractions.js"; +export type { IAiChatUseCase } from "./abstractions.js"; +export type { AiChatParams } from "./abstractions.js"; +export type { AiChatResult } from "./abstractions.js"; +export { SYSTEM_PROMPT } from "./systemPrompt.js"; +export { isReadOnly } from "./approvals.js"; +export type { PendingApproval } from "./approvals.js"; +export type { ApprovalDecision } from "./approvals.js"; + +const DEFAULT_MODEL = "anthropic/claude-sonnet-5"; + +/** + * Enough steps for the deepest expected chain: list models, describe one, query it, answer — plus room + * for a corrected retry after a rejected filter. + */ +const DEFAULT_MAX_STEPS = 12; + +/** + * Registers `POST /ai/chat` and the use case behind it on the per-request container, alongside the + * tools it will call. + */ +export const AiChatFeature = createFeature({ + name: "AiChat", + register: container => { + const config: { + model: string; + maxSteps: number; + approvalSecret?: string; + } = { + model: process.env["WEBINY_API_AI_CHAT_MODEL"] || DEFAULT_MODEL, + maxSteps: Number(process.env["WEBINY_API_AI_CHAT_MAX_STEPS"]) || DEFAULT_MAX_STEPS + }; + + /* + * Unset means read-only: mutating tools are withheld rather than gated on an approval we cannot + * verify. Opt in by setting the secret. + */ + const approvalSecret = process.env["WEBINY_API_AI_CHAT_APPROVAL_SECRET"]; + if (approvalSecret) { + config.approvalSecret = approvalSecret; + } + + container.registerInstance(AiChatConfig, config); + container.register(AiChatUseCase); + container.register(AiChatRoute); + } +}); diff --git a/packages/api-ai-chat/src/systemPrompt.ts b/packages/api-ai-chat/src/systemPrompt.ts new file mode 100644 index 00000000000..cf6c0e124cb --- /dev/null +++ b/packages/api-ai-chat/src/systemPrompt.ts @@ -0,0 +1,27 @@ +/** + * The assistant is deliberately told to DISCOVER rather than guess. Model IDs and field IDs are + * project-specific — there is no way to know them from training data — so every concrete answer has to + * come from a tool call. Without this instruction models happily invent a plausible `modelId` and then + * report the resulting error as if the content did not exist. + */ +export const SYSTEM_PROMPT = `You are the Webiny admin assistant. You help editors and developers find, understand and change the content in their Webiny project. + +You have tools available. Use them — never answer a question about this project's content, models, or files from memory or assumption. + +How to work: +- Content model IDs and field IDs are specific to this project. Always discover them with listContentModels, then describeContentModel, before querying. +- Build filters with the field IDs describeContentModel returned. Do not guess field names. +- If a tool returns an error, read it and correct your call. A "not allowed" error means the user lacks permission — say so plainly rather than retrying. +- If a query returns nothing, say so. Do not present an empty result as if it were data. + +Changing things: +- Tools that change something are not executed until the user approves them. Propose the change by calling the tool; the user sees exactly what you asked for and confirms it. +- Read first, then propose. Look up the ids you need (teams, folders, entries) rather than guessing them, because the user is approving the arguments you supply. +- Propose one coherent change at a time. Do not bundle unrelated edits into a single step. +- If an approval is denied, do not retry the same call. Acknowledge it and stop. + +How to answer: +- Be brief. These answers appear in a command palette, not a chat window. +- Lead with the answer. No preamble, no restating the question. +- When you list entries, give the few fields that matter, not every field you received. +- State counts precisely ("3 products are on sale"), never vaguely.`; diff --git a/packages/api-ai-chat/tsconfig.build.json b/packages/api-ai-chat/tsconfig.build.json new file mode 100644 index 00000000000..aad01679704 --- /dev/null +++ b/packages/api-ai-chat/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api-core/tsconfig.build.json" }, + { "path": "../event-handler-core/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"] + } + } +} diff --git a/packages/api-ai-chat/tsconfig.json b/packages/api-ai-chat/tsconfig.json new file mode 100644 index 00000000000..66f416b8e79 --- /dev/null +++ b/packages/api-ai-chat/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api-core" }, + { "path": "../event-handler-core" }, + { "path": "../feature" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"] + } + } +} diff --git a/packages/api-ai-chat/vitest.config.ts b/packages/api-ai-chat/vitest.config.ts new file mode 100644 index 00000000000..77c26269023 --- /dev/null +++ b/packages/api-ai-chat/vitest.config.ts @@ -0,0 +1,10 @@ +import { createTestConfig } from "../../testing"; + +export default async () => { + return createTestConfig({ + path: import.meta.dirname, + vitestConfig: { + fileParallelism: true + } + }); +}; diff --git a/packages/api-ai-chat/webiny.config.js b/packages/api-ai-chat/webiny.config.js new file mode 100644 index 00000000000..4f5e3db04b9 --- /dev/null +++ b/packages/api-ai-chat/webiny.config.js @@ -0,0 +1,8 @@ +import { createWatchPackage, createBuildPackage } from "@webiny/build-tools"; + +export default { + commands: { + build: createBuildPackage({ cwd: import.meta.dirname }), + watch: createWatchPackage({ cwd: import.meta.dirname }) + } +}; diff --git a/packages/api-core/src/features/ai/abstractions.ts b/packages/api-core/src/features/ai/abstractions.ts index 6e957509725..375c7abe709 100644 --- a/packages/api-core/src/features/ai/abstractions.ts +++ b/packages/api-core/src/features/ai/abstractions.ts @@ -102,10 +102,29 @@ export namespace Ai { // AiSdkTool +/** + * Behavioural hints about a tool. Purely advisory — they never replace a permission check. + * Names mirror the MCP tool annotations so they can be forwarded verbatim to MCP clients, + * which use them to decide what to auto-approve and what to confirm with the user. + */ +export interface IAiSdkToolAnnotations { + /** Tool does not modify state. */ + readOnlyHint?: boolean; + /** Tool may perform destructive updates (only meaningful when not read-only). */ + destructiveHint?: boolean; + /** Repeated calls with the same arguments have no additional effect. */ + idempotentHint?: boolean; + /** Tool interacts with entities outside its own closed world. */ + openWorldHint?: boolean; +} + export interface IAiSdkTool { readonly name: string; readonly description: string; readonly inputSchema: FlexibleSchema; + /** Human-friendly display name. Falls back to `name` when omitted. */ + readonly title?: string; + readonly annotations?: IAiSdkToolAnnotations; execute(input: TInput): Promise; } @@ -114,6 +133,7 @@ export const AiSdkTool = createAbstraction("AiSdkTool"); export namespace AiSdkTool { export type Interface = IAiSdkTool; + export type Annotations = IAiSdkToolAnnotations; } // AiSdkTools diff --git a/packages/api-core/src/features/ai/index.ts b/packages/api-core/src/features/ai/index.ts index 581382010eb..e06cb0be97b 100644 --- a/packages/api-core/src/features/ai/index.ts +++ b/packages/api-core/src/features/ai/index.ts @@ -12,6 +12,7 @@ export type { AiModel, IAiSdkModel, IAiSdkTool, + IAiSdkToolAnnotations, IAiSdkTools } from "./abstractions.js"; export { AiOutputTool, AiOutputToolRegistry, AiToolPipelineRunner } from "./toolPipeline/index.js"; diff --git a/packages/api-event-handler-core/package.json b/packages/api-event-handler-core/package.json index 5ffbf22b8ff..3d273d6b2db 100644 --- a/packages/api-event-handler-core/package.json +++ b/packages/api-event-handler-core/package.json @@ -19,6 +19,7 @@ "license": "MIT", "dependencies": { "@webiny/api-aco": "0.0.0", + "@webiny/api-ai-chat": "0.0.0", "@webiny/api-audit-logs": "0.0.0", "@webiny/api-core": "0.0.0", "@webiny/api-file-manager": "0.0.0", diff --git a/packages/api-event-handler-core/src/registerApiRequestStack.ts b/packages/api-event-handler-core/src/registerApiRequestStack.ts index 7650bd88ee5..96c8c6663f1 100644 --- a/packages/api-event-handler-core/src/registerApiRequestStack.ts +++ b/packages/api-event-handler-core/src/registerApiRequestStack.ts @@ -1,6 +1,7 @@ import type { Container } from "@webiny/di"; import { registerExtensions } from "@webiny/handler"; import { GraphQLEngineFeature } from "@webiny/api-graphql"; +import { AiChatFeature } from "@webiny/api-ai-chat"; import { ApiCoreFeature } from "@webiny/api-core"; import { WcpLicenseInitializer } from "./WcpLicenseInitializer.js"; import { HeadlessCmsFeature } from "@webiny/api-headless-cms"; @@ -137,6 +138,11 @@ export async function registerApiRequestStack( // (e.g. ACO) lists + caches the per-request model set. await registerExtensions(container, config.extensions()); + // ── AI chat endpoint (in-admin assistant) ────────────────── + // Same tool registry as MCP, but the agent loop runs here instead of in an external harness, so the + // browser never needs a model or an API key. After extensions, for the same reason MCP is. + AiChatFeature.register(container); + // ── GraphQL engine (always last) ─────────────────────────── GraphQLEngineFeature.register(container); } diff --git a/packages/api-event-handler-core/tsconfig.build.json b/packages/api-event-handler-core/tsconfig.build.json index 4e3cf885b89..97077602684 100644 --- a/packages/api-event-handler-core/tsconfig.build.json +++ b/packages/api-event-handler-core/tsconfig.build.json @@ -3,6 +3,7 @@ "include": ["src"], "references": [ { "path": "../api-aco/tsconfig.build.json" }, + { "path": "../api-ai-chat/tsconfig.build.json" }, { "path": "../api-audit-logs/tsconfig.build.json" }, { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-file-manager/tsconfig.build.json" }, @@ -35,6 +36,8 @@ "~tests/*": ["./__tests__/*"], "@webiny/api-aco/*": ["../api-aco/src/*"], "@webiny/api-aco": ["../api-aco/src"], + "@webiny/api-ai-chat/*": ["../api-ai-chat/src/*"], + "@webiny/api-ai-chat": ["../api-ai-chat/src"], "@webiny/api-audit-logs/*": ["../api-audit-logs/src/*"], "@webiny/api-audit-logs": ["../api-audit-logs/src"], "@webiny/api-core/*": ["../api-core/src/*"], diff --git a/packages/api-event-handler-core/tsconfig.json b/packages/api-event-handler-core/tsconfig.json index 6c0ade86e73..3e99709c797 100644 --- a/packages/api-event-handler-core/tsconfig.json +++ b/packages/api-event-handler-core/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src", "__tests__"], "references": [ { "path": "../api-aco" }, + { "path": "../api-ai-chat" }, { "path": "../api-audit-logs" }, { "path": "../api-core" }, { "path": "../api-file-manager" }, @@ -35,6 +36,8 @@ "~tests/*": ["./__tests__/*"], "@webiny/api-aco/*": ["../api-aco/src/*"], "@webiny/api-aco": ["../api-aco/src"], + "@webiny/api-ai-chat/*": ["../api-ai-chat/src/*"], + "@webiny/api-ai-chat": ["../api-ai-chat/src"], "@webiny/api-audit-logs/*": ["../api-audit-logs/src/*"], "@webiny/api-audit-logs": ["../api-audit-logs/src"], "@webiny/api-core/*": ["../api-core/src/*"], diff --git a/packages/api-file-manager/src/features/file/ListImagesByTagTool/ListImagesByTagTool.ts b/packages/api-file-manager/src/features/file/ListImagesByTagTool/ListImagesByTagTool.ts index 7bebaab1e7e..27afb85b2a1 100644 --- a/packages/api-file-manager/src/features/file/ListImagesByTagTool/ListImagesByTagTool.ts +++ b/packages/api-file-manager/src/features/file/ListImagesByTagTool/ListImagesByTagTool.ts @@ -17,9 +17,11 @@ interface ImageItem { class ListImagesByTagToolImpl implements IAiSdkTool { readonly name = "listImagesByTag"; + readonly title = "List images by tag"; readonly description = "Lists images from the file manager filtered by a given tag. Returns name, type, and tags for each image."; readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true, idempotentHint: true }; constructor(private listFiles: ListFilesUseCase.Interface) {} diff --git a/packages/api-headless-cms/__tests__/ai/ListContentModelsTool.test.ts b/packages/api-headless-cms/__tests__/ai/ListContentModelsTool.test.ts new file mode 100644 index 00000000000..f14eb29cd52 --- /dev/null +++ b/packages/api-headless-cms/__tests__/ai/ListContentModelsTool.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { Container } from "@webiny/di"; +import { Result } from "@webiny/feature/api"; +import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { ListContentModelsTool } from "~/features/ai/ListContentModelsTool.js"; +import type { CmsModel } from "~/types/index.js"; + +const model = (modelId: string, overrides: Partial = {}): CmsModel => + ({ + modelId, + name: modelId, + description: null, + group: "ungrouped", + singularApiName: modelId, + pluralApiName: `${modelId}s`, + titleFieldId: "title", + fields: [], + ...overrides + }) as CmsModel; + +const resolveTool = (models: CmsModel[]) => { + const container = new Container(); + + container.registerInstance(ListModelsUseCase, { + execute: async () => Result.ok(models) + } as ListModelsUseCase.Interface); + + container.register(ListContentModelsTool); + + return container.resolveAll(AiSdkTool)[0]; +}; + +describe("listContentModels", () => { + const models = [ + model("product"), + // System models Webiny itself owns — flagged by the "hidden" group convention. + model("wbyLanguage", { group: "hidden" }), + model("backgroundTaskSettings", { group: "hidden" }), + // A model another app owns and manages in its own UI. + model("wbyPage", { isPrivate: true }) + ]; + + it("hides system and private models by default", async () => { + const result = (await resolveTool(models).execute({})) as { modelId: string }[]; + + expect(result.map(entry => entry.modelId)).toEqual(["product"]); + }); + + it("includes them when asked", async () => { + const result = (await resolveTool(models).execute({ includeSystem: true })) as { + modelId: string; + }[]; + + expect(result.map(entry => entry.modelId)).toEqual([ + "product", + "wbyLanguage", + "backgroundTaskSettings", + "wbyPage" + ]); + }); + + it("surfaces a use case failure as a thrown error the model can read", async () => { + const container = new Container(); + container.registerInstance(ListModelsUseCase, { + execute: async () => Result.fail(new Error("Not allowed to access content models.")) + } as ListModelsUseCase.Interface); + container.register(ListContentModelsTool); + + await expect(container.resolveAll(AiSdkTool)[0].execute({})).rejects.toThrow( + "Not allowed to access content models." + ); + }); +}); diff --git a/packages/api-headless-cms/__tests__/ai/QueryEntriesTool.test.ts b/packages/api-headless-cms/__tests__/ai/QueryEntriesTool.test.ts new file mode 100644 index 00000000000..dbe28012b37 --- /dev/null +++ b/packages/api-headless-cms/__tests__/ai/QueryEntriesTool.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { Container } from "@webiny/di"; +import { Result } from "@webiny/feature/api"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { ListLatestEntriesUseCase } from "~/features/contentEntry/ListEntries/index.js"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { QueryEntriesTool } from "~/features/ai/QueryEntriesTool.js"; +import type { CmsEntryListParams, CmsModel } from "~/types/index.js"; + +const productModel = { + modelId: "product", + name: "Product", + description: null, + group: "ungrouped", + singularApiName: "Product", + pluralApiName: "Products", + titleFieldId: "name", + fields: [ + { fieldId: "name", type: "text", label: "Name", validation: [] }, + { fieldId: "price", type: "number", label: "Price", validation: [] }, + // Deliberately prefixed by "price" — proves longest-match routing, not startsWith chaos. + { fieldId: "price_range", type: "text", label: "Price range", validation: [] }, + { fieldId: "onSale", type: "boolean", label: "On sale", validation: [] } + ] +} as unknown as CmsModel; + +/** + * Captures the params the tool hands to the CMS, which is the whole point: the entry-meta vs `values` + * split is invisible from the tool's own return value but is exactly what a real CMS rejects. + */ +const resolveTool = () => { + const captured: { params?: CmsEntryListParams } = {}; + const container = new Container(); + + container.registerInstance(GetModelUseCase, { + execute: async () => Result.ok(productModel) + } as GetModelUseCase.Interface); + + container.registerInstance(ListLatestEntriesUseCase, { + execute: async (_model: CmsModel, params?: CmsEntryListParams) => { + captured.params = params; + return Result.ok({ + entries: [], + meta: { totalCount: 0, hasMoreItems: false, cursor: null } + }); + } + } as unknown as ListLatestEntriesUseCase.Interface); + + container.register(QueryEntriesTool); + + return { tool: container.resolveAll(AiSdkTool)[0], captured }; +}; + +const whereFor = async (where: Record) => { + const { tool, captured } = resolveTool(); + await tool.execute({ modelId: "product", where }); + return captured.params?.where as Record; +}; + +describe("queryEntries where routing", () => { + it("nests the model's own fields under `values`", async () => { + expect(await whereFor({ onSale: true })).toEqual({ values: { onSale: true } }); + }); + + it("nests operator suffixes on model fields", async () => { + expect(await whereFor({ price_gt: 500, name_contains: "desk" })).toEqual({ + values: { price_gt: 500, name_contains: "desk" } + }); + }); + + it("keeps entry meta fields at the top level", async () => { + expect(await whereFor({ status: "draft", savedOn_gt: "2026-01-01" })).toEqual({ + status: "draft", + savedOn_gt: "2026-01-01" + }); + }); + + it("splits a mixed filter into both levels", async () => { + expect(await whereFor({ onSale: true, price_gt: 500, status: "draft" })).toEqual({ + status: "draft", + values: { onSale: true, price_gt: 500 } + }); + }); + + it("prefers the longest matching fieldId", async () => { + // "price_range" must win over "price" + "_range", which is not a real operator. + expect(await whereFor({ price_range: "high" })).toEqual({ + values: { price_range: "high" } + }); + }); + + it("passes AND/OR through untouched", async () => { + const where = { AND: [{ onSale: true }], OR: [{ status: "draft" }] }; + expect(await whereFor(where)).toEqual(where); + }); + + it("respects an explicitly nested `values` object", async () => { + expect(await whereFor({ values: { onSale: true }, status: "draft" })).toEqual({ + status: "draft", + values: { onSale: true } + }); + }); + + it("omits `values` entirely when no model field is filtered", async () => { + expect(await whereFor({ status: "draft" })).not.toHaveProperty("values"); + }); +}); + +describe("queryEntries sort mapping", () => { + const sortFor = async (sort: string[]) => { + const { tool, captured } = resolveTool(); + await tool.execute({ modelId: "product", sort }); + return captured.params?.sort; + }; + + it("prefixes model fields with `values_`", async () => { + expect(await sortFor(["price_DESC"])).toEqual(["values_price_DESC"]); + }); + + it("leaves entry meta fields alone", async () => { + expect(await sortFor(["savedOn_DESC"])).toEqual(["savedOn_DESC"]); + }); + + it("maps a mixed list per directive", async () => { + expect(await sortFor(["onSale_ASC", "createdOn_DESC"])).toEqual([ + "values_onSale_ASC", + "createdOn_DESC" + ]); + }); + + it("passes through anything without a direction suffix", async () => { + expect(await sortFor(["nonsense"])).toEqual(["nonsense"]); + }); +}); + +describe("queryEntries limits", () => { + it("defaults to 10 and caps at 50", async () => { + const a = resolveTool(); + await a.tool.execute({ modelId: "product" }); + expect(a.captured.params?.limit).toBe(10); + + const b = resolveTool(); + await b.tool.execute({ modelId: "product", limit: 5000 }); + expect(b.captured.params?.limit).toBe(50); + }); +}); diff --git a/packages/api-headless-cms/src/HeadlessCmsFeature.ts b/packages/api-headless-cms/src/HeadlessCmsFeature.ts index 31af6492e25..aa4665c774b 100644 --- a/packages/api-headless-cms/src/HeadlessCmsFeature.ts +++ b/packages/api-headless-cms/src/HeadlessCmsFeature.ts @@ -14,6 +14,7 @@ import { StorageFeature } from "~/features/storage/index.js"; import { CmsInstallerFeature } from "~/features/installer/feature.js"; import { ContentEntriesFeature } from "~/features/contentEntry/ContentEntriesFeature.js"; import { ContentModelFeature } from "~/features/contentModel/ContentModelFeature.js"; +import { CmsAiToolsFeature } from "~/features/ai/index.js"; import { ContentModelGroupFeature } from "~/features/contentModelGroup/ContentModelGroupFeature.js"; import { ModelBuilderFeature } from "~/features/modelBuilder/index.js"; import { CmsWhereMapperFeature } from "~/features/whereMapper/feature.js"; @@ -123,6 +124,7 @@ export const HeadlessCmsFeature = createFeature({ CmsInstallerFeature.register(container); ContentEntriesFeature.register(container); ContentModelFeature.register(container); + CmsAiToolsFeature.register(container); ContentModelGroupFeature.register(container); ModelBuilderFeature.register(container); CmsWhereMapperFeature.register(container); diff --git a/packages/api-headless-cms/src/features/ai/DescribeContentModelTool.ts b/packages/api-headless-cms/src/features/ai/DescribeContentModelTool.ts new file mode 100644 index 00000000000..ceffbd21ff7 --- /dev/null +++ b/packages/api-headless-cms/src/features/ai/DescribeContentModelTool.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import type { CmsModelField } from "~/types/index.js"; + +const inputSchema = z.object({ + modelId: z + .string() + .describe("Model ID as returned by listContentModels (e.g. 'product', not 'Products').") +}); + +type Input = z.infer; + +interface FieldDescription { + fieldId: string; + type: string; + label: string; + /** True when the field holds an array of values — affects which `where` operators apply. */ + list: boolean; + required: boolean; + description?: string; + /** Allowed values for enum-like fields, so a filter can be built without guessing. */ + predefinedValues?: string[]; + /** For `ref` fields: which models this field can point at. */ + refModels?: string[]; + /** For `object` fields: the nested field set. */ + fields?: FieldDescription[]; +} + +interface ModelDescription { + modelId: string; + name: string; + description: string | null; + singularApiName: string; + pluralApiName: string; + titleFieldId: string; + fields: FieldDescription[]; +} + +const isRequired = (field: CmsModelField): boolean => + field.validation.some(validator => validator.name === "required"); + +const describeField = (field: CmsModelField): FieldDescription => { + const described: FieldDescription = { + fieldId: field.fieldId, + type: field.type, + label: field.label, + list: Boolean(field.list), + required: isRequired(field) + }; + + if (field.description) { + described.description = field.description; + } + + const values = field.predefinedValues; + if (values?.enabled && values.values?.length) { + described.predefinedValues = values.values.map(entry => entry.value); + } + + const refModels = field.settings?.models; + if (refModels?.length) { + described.refModels = refModels.map(model => model.modelId); + } + + const nested = field.settings?.fields; + if (nested?.length) { + described.fields = nested.map(describeField); + } + + return described; +}; + +/** + * Supplies the field detail needed to build a valid `queryEntries` filter. Deliberately a separate + * call from `listContentModels`: returning full field sets for every model would be large and mostly + * unread, so the model pays for detail only on the model it actually cares about. + */ +class DescribeContentModelToolImpl implements IAiSdkTool { + readonly name = "describeContentModel"; + readonly title = "Describe content model"; + readonly description = + "Returns the fields of one content model — field IDs, types, whether each is a list, required flags, allowed values, and referenced models. Call this before queryEntries so filters use real field IDs."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true, idempotentHint: true }; + + constructor(private getModel: GetModelUseCase.Interface) {} + + async execute(input: Input): Promise { + const result = await this.getModel.execute(input.modelId); + + if (result.isFail()) { + throw new Error( + `Could not describe model "${input.modelId}": ${result.error.message}. Call listContentModels for valid model IDs.` + ); + } + + const model = result.value; + + return { + modelId: model.modelId, + name: model.name, + description: model.description, + singularApiName: model.singularApiName, + pluralApiName: model.pluralApiName, + titleFieldId: model.titleFieldId, + fields: model.fields.map(describeField) + }; + } +} + +export const DescribeContentModelTool = AiSdkTool.createImplementation({ + implementation: DescribeContentModelToolImpl, + dependencies: [GetModelUseCase] +}); diff --git a/packages/api-headless-cms/src/features/ai/ListContentModelsTool.ts b/packages/api-headless-cms/src/features/ai/ListContentModelsTool.ts new file mode 100644 index 00000000000..cbb3f9202a2 --- /dev/null +++ b/packages/api-headless-cms/src/features/ai/ListContentModelsTool.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; +import type { CmsModel } from "~/types/index.js"; + +const HIDDEN_MODEL_GROUP = "hidden"; + +const inputSchema = z.object({ + includeSystem: z + .boolean() + .optional() + .describe( + "Include system models that back Webiny itself (languages, tenants, task settings) and internal models belonging to other apps. Off by default — these are never what a user means by 'content'." + ) +}); + +type Input = z.infer; + +interface ModelSummary { + modelId: string; + name: string; + description: string | null; + group: string; + singularApiName: string; + pluralApiName: string; + titleFieldId: string; + fieldCount: number; +} + +/** + * Models Webiny itself owns. Two separate markers, both needed: + * - `group: "hidden"` — the convention system models use (languages, tenants, background-task + * settings). The admin UI filters the same way, see app-headless-cms useCmsData. + * - `isPrivate` — models another app owns and manages through its own UI (e.g. Website Builder). + */ +const isSystemModel = (model: CmsModel): boolean => + Boolean(model.isPrivate) || model.group === HIDDEN_MODEL_GROUP; + +/** + * Entry point for any content question. The CMS schema is defined per project (and per tenant), so a + * model list cannot be baked into a system prompt — it has to be discovered at call time. Returns a + * summary only; `describeContentModel` supplies the field detail needed to actually build a query. + */ +class ListContentModelsToolImpl implements IAiSdkTool { + readonly name = "listContentModels"; + readonly title = "List content models"; + readonly description = + "Lists the content models available in this project. Call this first when you need to find content — model IDs are project-specific and cannot be guessed. Returns a summary per model; use describeContentModel for field details."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true, idempotentHint: true }; + + constructor(private listModels: ListModelsUseCase.Interface) {} + + async execute(input: Input): Promise { + const result = await this.listModels.execute(); + + if (result.isFail()) { + throw new Error(`Could not list content models: ${result.error.message}`); + } + + return result.value + .filter(model => (input.includeSystem ? true : !isSystemModel(model))) + .map(model => ({ + modelId: model.modelId, + name: model.name, + description: model.description, + group: model.group, + singularApiName: model.singularApiName, + pluralApiName: model.pluralApiName, + titleFieldId: model.titleFieldId, + fieldCount: model.fields.length + })); + } +} + +export const ListContentModelsTool = AiSdkTool.createImplementation({ + implementation: ListContentModelsToolImpl, + dependencies: [ListModelsUseCase] +}); diff --git a/packages/api-headless-cms/src/features/ai/QueryEntriesTool.ts b/packages/api-headless-cms/src/features/ai/QueryEntriesTool.ts new file mode 100644 index 00000000000..1c9272b7cf0 --- /dev/null +++ b/packages/api-headless-cms/src/features/ai/QueryEntriesTool.ts @@ -0,0 +1,229 @@ +import { z } from "zod"; +import { AiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import type { IAiSdkTool } from "@webiny/api-core/features/ai/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { ListLatestEntriesUseCase } from "~/features/contentEntry/ListEntries/index.js"; +import type { CmsEntryListParams } from "~/types/index.js"; +import type { CmsEntryListSort } from "~/types/index.js"; +import type { CmsEntryListWhere } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * Hard ceiling on returned entries. A model asking for "all products" would otherwise pull an entire + * collection into the context window; the cursor in the response is the correct way to go deeper. + */ +const MAX_LIMIT = 50; +const DEFAULT_LIMIT = 10; + +const inputSchema = z.object({ + modelId: z.string().describe("Model ID as returned by listContentModels."), + where: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Flat filter object. Keys are `` for an exact match or `_` otherwise — e.g. { onSale: true, price_gt: 100, name_contains: 'desk', status: 'draft' }. Operators: _not, _in, _not_in, _lt, _lte, _gt, _gte, _contains, _not_contains, _startsWith, _not_startsWith, _between, _not_between. Mix the model's own fields with entry meta fields (id, entryId, status, createdOn, savedOn) freely — they are separated automatically. Use describeContentModel first so field IDs are real." + ), + sort: z + .array(z.string()) + .optional() + .describe( + "Sort directives as `_ASC` or `_DESC` — e.g. ['price_DESC'] for a model field or ['savedOn_DESC'] for entry meta. Both forms are accepted; the distinction is handled automatically." + ), + search: z + .string() + .optional() + .describe("Full-text search across the model's searchable fields."), + fields: z + .array(z.string()) + .optional() + .describe( + "Restrict returned values to these field IDs. Use it whenever you only need a few fields — entries can be large." + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + `Maximum entries to return. Defaults to ${DEFAULT_LIMIT}, capped at ${MAX_LIMIT}.` + ), + after: z.string().optional().describe("Pagination cursor from a previous call's meta.cursor.") +}); + +type Input = z.infer; + +interface EntrySummary { + id: string; + entryId: string; + status: string; + createdOn: string; + savedOn: string; + values: Record; +} + +interface QueryEntriesResult { + modelId: string; + entries: EntrySummary[]; + meta: { + totalCount: number; + hasMoreItems: boolean; + cursor: string | null; + }; +} + +/** + * The CMS splits `where` into two levels: entry meta fields (id, status, savedOn, ...) sit at the top, + * while the model's own fields must be nested under `values`. An LLM has no way to know that — and + * `describeContentModel` hands it a FLAT list of fieldIds, so a flat filter is exactly what it writes. + * Rather than documenting the split and hoping, we accept the flat form and route each key by whether + * its field belongs to the model. + * + * A key is `` or `_`; the longest matching fieldId wins, so a model with + * both `price` and `price_range` cannot be mis-routed. `AND`/`OR` are passed through untouched — they + * carry nested filter objects, not field references. + */ +const LOGICAL_KEYS = new Set(["AND", "OR"]); + +const splitWhere = (where: Record, model: CmsModel): Record => { + const fieldIds = model.fields.map(field => field.fieldId).sort((a, b) => b.length - a.length); + + const top: Record = {}; + const values: Record = {}; + + for (const [key, value] of Object.entries(where)) { + if (LOGICAL_KEYS.has(key)) { + top[key] = value; + continue; + } + + // An explicitly nested `values` object is respected as-is — a caller that already knows the + // shape should not be second-guessed. + if (key === "values" && typeof value === "object" && value !== null) { + Object.assign(values, value as Record); + continue; + } + + const matched = fieldIds.find(fieldId => key === fieldId || key.startsWith(`${fieldId}_`)); + + if (matched) { + values[key] = value; + } else { + top[key] = value; + } + } + + if (Object.keys(values).length > 0) { + top["values"] = values; + } + + return top; +}; + +/** + * Sort has the same two-level split as `where`, with a different spelling: the CMS sorter for a model + * field is `values__`, while entry meta fields sort as `_`. Callers give us + * the flat `_` form (that is what describeContentModel's field IDs invite), so prefix the + * ones that name a model field and leave the rest alone. + */ +const mapSort = (sort: string[], model: CmsModel): string[] => { + const fieldIds = new Set(model.fields.map(field => field.fieldId)); + + return sort.map(directive => { + const match = /^(.*)_(ASC|DESC)$/.exec(directive); + if (!match) { + return directive; + } + + const [, field, direction] = match; + return fieldIds.has(field) ? `values_${field}_${direction}` : directive; + }); +}; + +/** + * Reads entries for one model. + * + * Uses the LATEST revisions (the manage-API view), not published ones — an editor asking "which + * products are discounted" means the content as it currently stands in the admin app, including + * unpublished edits. Filter on `status` for a published-only view. + * + * `where` is passed through to the CMS rather than re-modelled as a Zod schema: the valid keys depend + * entirely on the model's fields, which are only known at runtime. An invalid filter surfaces as a + * tool error the model can correct, which is why `describeContentModel` is named in the description. + */ +class QueryEntriesToolImpl implements IAiSdkTool { + readonly name = "queryEntries"; + readonly title = "Query content entries"; + readonly description = + "Queries content entries for a model, with filtering, sorting, search and pagination. Returns the latest revision of each entry (including unpublished changes). Call describeContentModel first to learn the field IDs used in `where` and `sort`."; + readonly inputSchema = inputSchema; + readonly annotations = { readOnlyHint: true }; + + constructor( + private getModel: GetModelUseCase.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface + ) {} + + async execute(input: Input): Promise { + const modelResult = await this.getModel.execute(input.modelId); + + if (modelResult.isFail()) { + throw new Error( + `Unknown model "${input.modelId}": ${modelResult.error.message}. Call listContentModels for valid model IDs.` + ); + } + + const params: CmsEntryListParams = { + limit: Math.min(input.limit ?? DEFAULT_LIMIT, MAX_LIMIT) + }; + + if (input.where) { + params.where = splitWhere(input.where, modelResult.value) as CmsEntryListWhere; + } + + if (input.sort?.length) { + params.sort = mapSort(input.sort, modelResult.value) as CmsEntryListSort; + } + + if (input.search) { + params.search = input.search; + } + + if (input.fields?.length) { + params.fields = input.fields; + } + + if (input.after) { + params.after = input.after; + } + + const result = await this.listLatestEntries.execute(modelResult.value, params); + + if (result.isFail()) { + throw new Error(`Could not query "${input.modelId}" entries: ${result.error.message}`); + } + + const { entries, meta } = result.value; + + return { + modelId: input.modelId, + entries: entries.map(entry => ({ + id: entry.id, + entryId: entry.entryId, + status: entry.status, + createdOn: entry.createdOn, + savedOn: entry.savedOn, + values: entry.values + })), + meta: { + totalCount: meta.totalCount, + hasMoreItems: meta.hasMoreItems, + cursor: meta.cursor + } + }; + } +} + +export const QueryEntriesTool = AiSdkTool.createImplementation({ + implementation: QueryEntriesToolImpl, + dependencies: [GetModelUseCase, ListLatestEntriesUseCase] +}); diff --git a/packages/api-headless-cms/src/features/ai/feature.ts b/packages/api-headless-cms/src/features/ai/feature.ts new file mode 100644 index 00000000000..140ad4c0a22 --- /dev/null +++ b/packages/api-headless-cms/src/features/ai/feature.ts @@ -0,0 +1,18 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListContentModelsTool } from "./ListContentModelsTool.js"; +import { DescribeContentModelTool } from "./DescribeContentModelTool.js"; +import { QueryEntriesTool } from "./QueryEntriesTool.js"; + +/** + * Read-only CMS tools for AI callers. Registered as `AiSdkTool` implementations, so they are picked + * up both by in-process `generateText`/`streamText` calls and by the MCP endpoint, which resolves the + * same abstraction. + */ +export const CmsAiToolsFeature = createFeature({ + name: "HeadlessCms/AiTools", + register(container) { + container.register(ListContentModelsTool); + container.register(DescribeContentModelTool); + container.register(QueryEntriesTool); + } +}); diff --git a/packages/api-headless-cms/src/features/ai/index.ts b/packages/api-headless-cms/src/features/ai/index.ts new file mode 100644 index 00000000000..b652824d744 --- /dev/null +++ b/packages/api-headless-cms/src/features/ai/index.ts @@ -0,0 +1,4 @@ +export { CmsAiToolsFeature } from "./feature.js"; +export { ListContentModelsTool } from "./ListContentModelsTool.js"; +export { DescribeContentModelTool } from "./DescribeContentModelTool.js"; +export { QueryEntriesTool } from "./QueryEntriesTool.js"; diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json index 047e43a3b19..62a6ed3476b 100644 --- a/packages/api-websockets/package.json +++ b/packages/api-websockets/package.json @@ -20,7 +20,6 @@ "@webiny/api-core": "0.0.0", "@webiny/api-graphql": "0.0.0", "@webiny/error": "0.0.0", - "@webiny/event-handler-core": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", "type-fest": "^5.8.0" @@ -31,6 +30,7 @@ "@webiny/api-headless-cms": "0.0.0", "@webiny/build-tools": "0.0.0", "@webiny/di": "^1.0.2", + "@webiny/event-handler-core": "0.0.0", "@webiny/wcp": "0.0.0", "graphql": "^16.14.2", "rimraf": "^6.1.3", diff --git a/packages/api-websockets/tsconfig.build.json b/packages/api-websockets/tsconfig.build.json index 13d2db8a7e7..b45bb01e0c3 100644 --- a/packages/api-websockets/tsconfig.build.json +++ b/packages/api-websockets/tsconfig.build.json @@ -6,11 +6,11 @@ { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-graphql/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, - { "path": "../event-handler-core/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../api-core-testing/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../event-handler-core/tsconfig.build.json" }, { "path": "../wcp/tsconfig.build.json" } ], "compilerOptions": { @@ -28,8 +28,6 @@ "@webiny/api-graphql": ["../api-graphql/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], - "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], - "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/feature/*": ["../feature/src/*"], "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], @@ -38,6 +36,8 @@ "@webiny/api-core-testing": ["../api-core-testing/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/wcp/*": ["../wcp/src/*"], "@webiny/wcp": ["../wcp/src"] } diff --git a/packages/api-websockets/tsconfig.json b/packages/api-websockets/tsconfig.json index 2d17b0e9691..982f80454dc 100644 --- a/packages/api-websockets/tsconfig.json +++ b/packages/api-websockets/tsconfig.json @@ -6,11 +6,11 @@ { "path": "../api-core" }, { "path": "../api-graphql" }, { "path": "../error" }, - { "path": "../event-handler-core" }, { "path": "../feature" }, { "path": "../handler" }, { "path": "../api-core-testing" }, { "path": "../api-headless-cms" }, + { "path": "../event-handler-core" }, { "path": "../wcp" } ], "compilerOptions": { @@ -28,8 +28,6 @@ "@webiny/api-graphql": ["../api-graphql/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], - "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], - "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/feature/*": ["../feature/src/*"], "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], @@ -38,6 +36,8 @@ "@webiny/api-core-testing": ["../api-core-testing/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], + "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/wcp/*": ["../wcp/src/*"], "@webiny/wcp": ["../wcp/src"] } diff --git a/packages/app-admin-ui/src/CommandPalette/CommandPalette.tsx b/packages/app-admin-ui/src/CommandPalette/CommandPalette.tsx index 9a9c65c99b0..7c5c16278ff 100644 --- a/packages/app-admin-ui/src/CommandPalette/CommandPalette.tsx +++ b/packages/app-admin-ui/src/CommandPalette/CommandPalette.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Command } from "cmdk"; import { CommandPaletteFeature, @@ -8,22 +8,61 @@ import { } from "@webiny/app-admin"; import { useContainer, useFeature } from "@webiny/app"; import { RouterGateway } from "@webiny/app/features/router/abstractions.js"; -import { EmptyState, Icon, Text } from "@webiny/admin-ui"; +import { Icon } from "@webiny/admin-ui"; import { ReactComponent as SearchIcon } from "@webiny/icons/search.svg"; +import { ReactComponent as AiIcon } from "@webiny/icons/auto_awesome.svg"; import { ReactComponent as ReturnIcon } from "@webiny/icons/keyboard_return.svg"; import { ReactComponent as ArrowUpIcon } from "@webiny/icons/keyboard_arrow_up.svg"; import { ReactComponent as ArrowDownIcon } from "@webiny/icons/keyboard_arrow_down.svg"; -import { NAVIGATION_GROUP, PALETTE_HOTKEY_ZINDEX } from "./constants.js"; +import { ReactComponent as BackspaceIcon } from "@webiny/icons/backspace.svg"; +import { AI_COMMAND_NAME, NAVIGATION_GROUP, PALETTE_HOTKEY_ZINDEX } from "./constants.js"; import type { CommandGroup } from "./types.js"; import { commandVmsToGroups, deriveNavigationRows } from "./deriveRows.js"; -import { CommandDetail, CommandItemRow, GroupHeading, HintIcon, Kbd } from "./components/index.js"; +import { + AiModeBadge, + AiSuggestions, + AiTurn, + CommandDetail, + CommandItemRow, + GroupHeading, + HintIcon, + Kbd, + NoResults, + PaletteFooter, + type Hint +} from "./components/index.js"; +import { useAiChat } from "./useAiChat.js"; + +const COMMAND_HINTS: Hint[] = [ + { + keys: ( + <> + } /> + } /> + + ), + label: "Navigate" + }, + { keys: } />, label: "Select" }, + { keys: "space", label: "Ask AI" } +]; + +const AI_HINTS: Hint[] = [ + { keys: } />, label: "Ask" }, + { keys: } />, label: "Commands" }, + { keys: "esc", label: "Close" } +]; const CommandPaletteBase = () => { const [query, setQuery] = useState(""); + const [aiMode, setAiMode] = useState(false); const { presenter } = useFeature(CommandPaletteFeature); const { menus } = useAdminConfig(); const container = useContainer(); + const inputRef = useRef(null); + const scrollRef = useRef(null); const { vm } = presenter; + const ai = useAiChat(); useEffect(() => { presenter.init(); @@ -32,7 +71,29 @@ const CommandPaletteBase = () => { const close = useCallback(() => { presenter.close(); setQuery(""); - }, [presenter]); + setAiMode(false); + ai.reset(); + }, [presenter, ai]); + + const enterAiMode = useCallback( + (seed?: string) => { + setAiMode(true); + setQuery(""); + if (seed?.trim()) { + ai.ask(seed); + } + // The input is shared across modes, so focus has to be restored explicitly after the + // surrounding tree swaps. + requestAnimationFrame(() => inputRef.current?.focus()); + }, + [ai] + ); + + const exitAiMode = useCallback(() => { + setAiMode(false); + setQuery(""); + ai.reset(); + }, [ai]); const navigateTo = useCallback( (to: string) => { @@ -42,7 +103,18 @@ const CommandPaletteBase = () => { [container, close] ); - const runCommand = useCallback((name: string) => presenter.useCommand(name), [presenter]); + const runCommand = useCallback( + (name: string) => { + // The AI command is a palette MODE, not an action — it needs the shared input row, so the + // palette handles it here instead of letting the presenter open a detail view. + if (name === AI_COMMAND_NAME) { + enterAiMode(); + return; + } + presenter.useCommand(name); + }, + [presenter, enterAiMode] + ); // mod+k toggles; backspace backs out of a detail view; command shortcuts run directly. const keys = useMemo( @@ -51,21 +123,10 @@ const CommandPaletteBase = () => { e.preventDefault(); presenter.toggle(); setQuery(""); + setAiMode(false); }, backspace: (e: KeyboardEvent) => { - // This is a global (document-level) handler, so only act while the palette - // is actually open — otherwise it would swallow Backspace everywhere. - if (!presenter.vm.isOpen) { - return; - } - // Never intercept Backspace while the user is editing text: , - //