diff --git a/packages/api-event-handler-aws/package.json b/packages/api-event-handler-aws/package.json index 8d66e549a66..327cafc852e 100644 --- a/packages/api-event-handler-aws/package.json +++ b/packages/api-event-handler-aws/package.json @@ -20,6 +20,7 @@ "dependencies": { "@webiny/api-core": "0.0.0", "@webiny/api-event-handler-core": "0.0.0", + "@webiny/api-file-manager-s3": "0.0.0", "@webiny/api-scheduler-aws": "0.0.0", "@webiny/api-websockets": "0.0.0", "@webiny/api-websockets-aws": "0.0.0", diff --git a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index c8104b237c8..beadfdebc27 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -24,6 +24,7 @@ import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; import { registerApiRequestStack } from "@webiny/api-event-handler-core"; import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; import { registerSchedulerAwsExtension } 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 @@ -107,21 +108,27 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { }, request: async container => { - // The per-request feature stack is transport-agnostic (shared with the future server - // transport). The two AWS-specific interleave points are supplied as hooks. + // 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, - // Real AWS WebSocket transport (API Gateway Management API), registered right after - // WebsocketsFeature so it overrides the NullWebsocketsTransport. - registerRealtimeTransport: c => { - WebsocketsAwsFeature.register(c); - }, - // Scheduler transport: the scheduler-aws extension (EventBridge Scheduler). - registerSchedulerTransport: c => { - registerSchedulerAwsExtension(c, { - getClient: schedulerConfig => createSchedulerClient(schedulerConfig) - }); + 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 => { + registerSchedulerAwsExtension(c, { + getClient: schedulerConfig => createSchedulerClient(schedulerConfig) + }); + }, + // File-manager storage transport: S3 (asset delivery + S3 file operations + schema). + fileManager: c => { + FileManagerS3Feature.register(c, {}); + } } }); } diff --git a/packages/api-event-handler-aws/tsconfig.build.json b/packages/api-event-handler-aws/tsconfig.build.json index a8197e159cb..d885638bf5f 100644 --- a/packages/api-event-handler-aws/tsconfig.build.json +++ b/packages/api-event-handler-aws/tsconfig.build.json @@ -4,6 +4,7 @@ "references": [ { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-event-handler-core/tsconfig.build.json" }, + { "path": "../api-file-manager-s3/tsconfig.build.json" }, { "path": "../api-scheduler-aws/tsconfig.build.json" }, { "path": "../api-websockets/tsconfig.build.json" }, { "path": "../api-websockets-aws/tsconfig.build.json" }, @@ -26,6 +27,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-event-handler-core/*": ["../api-event-handler-core/src/*"], "@webiny/api-event-handler-core": ["../api-event-handler-core/src"], + "@webiny/api-file-manager-s3/*": ["../api-file-manager-s3/src/*"], + "@webiny/api-file-manager-s3": ["../api-file-manager-s3/src"], "@webiny/api-scheduler-aws/*": ["../api-scheduler-aws/src/*"], "@webiny/api-scheduler-aws": ["../api-scheduler-aws/src"], "@webiny/api-websockets/*": ["../api-websockets/src/*"], diff --git a/packages/api-event-handler-aws/tsconfig.json b/packages/api-event-handler-aws/tsconfig.json index 53aa88d06b2..03b07c4fe74 100644 --- a/packages/api-event-handler-aws/tsconfig.json +++ b/packages/api-event-handler-aws/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "../api-core" }, { "path": "../api-event-handler-core" }, + { "path": "../api-file-manager-s3" }, { "path": "../api-scheduler-aws" }, { "path": "../api-websockets" }, { "path": "../api-websockets-aws" }, @@ -26,6 +27,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-event-handler-core/*": ["../api-event-handler-core/src/*"], "@webiny/api-event-handler-core": ["../api-event-handler-core/src"], + "@webiny/api-file-manager-s3/*": ["../api-file-manager-s3/src/*"], + "@webiny/api-file-manager-s3": ["../api-file-manager-s3/src"], "@webiny/api-scheduler-aws/*": ["../api-scheduler-aws/src/*"], "@webiny/api-scheduler-aws": ["../api-scheduler-aws/src"], "@webiny/api-websockets/*": ["../api-websockets/src/*"], diff --git a/packages/api-event-handler-core/package.json b/packages/api-event-handler-core/package.json index 4342f55bad7..b63b961e973 100644 --- a/packages/api-event-handler-core/package.json +++ b/packages/api-event-handler-core/package.json @@ -23,7 +23,6 @@ "@webiny/api-core": "0.0.0", "@webiny/api-file-manager": "0.0.0", "@webiny/api-file-manager-aco": "0.0.0", - "@webiny/api-file-manager-s3": "0.0.0", "@webiny/api-headless-cms": "0.0.0", "@webiny/api-headless-cms-aco": "0.0.0", "@webiny/api-headless-cms-scheduler": "0.0.0", diff --git a/packages/api-event-handler-core/src/registerApiRequestStack.ts b/packages/api-event-handler-core/src/registerApiRequestStack.ts index 8a2aac5d0f0..15084a7c761 100644 --- a/packages/api-event-handler-core/src/registerApiRequestStack.ts +++ b/packages/api-event-handler-core/src/registerApiRequestStack.ts @@ -16,7 +16,6 @@ import { AcoFeature } from "@webiny/api-aco"; import { BackgroundTasksFeature } from "@webiny/background-tasks/api"; import { FileManagerAppFeature } from "@webiny/api-file-manager"; import { FileManagerAcoFeature } from "@webiny/api-file-manager-aco"; -import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; import { WebsiteBuilderFeature, setupWebsiteBuilderModels } from "@webiny/api-website-builder"; import { WebsiteBuilderWorkflowsFeature } from "@webiny/api-website-builder-workflows"; import { WebsiteBuilderSchedulerFeature } from "@webiny/api-website-builder-scheduler"; @@ -24,6 +23,9 @@ import { WebsocketsFeature } from "@webiny/api-websockets"; import { WorkflowsFeature } from "@webiny/api-workflows"; import { SchedulerFeature } from "@webiny/api-scheduler"; +/** Installs a flavour-specific transport adapter into the per-request container at its interleave point. */ +export type TransportRegistrar = (container: Container) => void | Promise; + export interface RegisterApiRequestStackConfig { /** * Project-defined extensions, applied at register() time (so extension features — including @@ -36,18 +38,30 @@ export interface RegisterApiRequestStackConfig { */ registerRequestStorage?: (container: Container) => void | Promise; /** - * Register the real-time (websockets) transport, run immediately AFTER the transport-agnostic - * `WebsocketsFeature`. On AWS this is `WebsocketsAwsFeature` (API Gateway Management API); it - * MUST register after WebsocketsFeature so it overrides the NullWebsocketsTransport - * (nearest-container-last-wins), otherwise every server→client send() is a silent no-op. - * Optional — omit for transports with no real-time push. - */ - registerRealtimeTransport?: (container: Container) => void | Promise; - /** - * Register the scheduler transport, run AFTER `SchedulerFeature` and BEFORE `CmsSchedulerFeature`. - * On AWS this bridges the scheduler-aws extension (EventBridge Scheduler). Optional. + * Flavour-specific transport adapters, each installed at its exact interleave point in the stack. + * Every entry follows the same shape: it runs immediately AFTER the transport-agnostic domain + * Feature has registered its NULL default, and overrides that default (nearest-container-last-wins) + * with the real adapter. Each is optional — omit one for a deployment/transport that lacks that + * capability. AWS supplies AWS adapters (API Gateway Management API / EventBridge / S3); the + * self-hosted server supplies in-process adapters (server WebSockets / Bree / local disk). */ - registerSchedulerTransport?: (container: Container) => void | Promise; + transports?: { + /** + * Real-time (WebSockets) transport, run right after `WebsocketsFeature`. Overrides the + * NullWebsocketsTransport, otherwise every server→client send() is a silent no-op. + */ + realtime?: TransportRegistrar; + /** + * Scheduler transport, run AFTER `SchedulerFeature` and BEFORE `CmsSchedulerFeature`. + */ + scheduler?: TransportRegistrar; + /** + * File-manager storage transport, run AFTER `FileManagerAppFeature` (which registers the + * AssetDeliveryRoute + NULL asset-delivery impls). Overrides those nulls with real impls and + * adds the file-operation features. + */ + fileManager?: TransportRegistrar; + }; } /** @@ -86,10 +100,10 @@ export async function registerApiRequestStack( AcoHcmsFeature.register(container); HcmsTasksFeature.register(container); - // ── File Manager ─────────────────────────────────────────── + // ── File Manager (domain) + storage transport ────────────── FileManagerAppFeature.register(container); FileManagerAcoFeature.register(container); - FileManagerS3Feature.register(container, {}); + await config.transports?.fileManager?.(container); // ── Website Builder ──────────────────────────────────────── WebsiteBuilderFeature.register(container); @@ -99,7 +113,7 @@ export async function registerApiRequestStack( // ── Websockets (domain) + real-time transport ────────────── WebsocketsFeature.register(container); - await config.registerRealtimeTransport?.(container); + await config.transports?.realtime?.(container); // ── Supporting services ──────────────────────────────────── MailerFeature.register(container); @@ -114,7 +128,7 @@ export async function registerApiRequestStack( // ── Scheduler + scheduler transport ──────────────────────── SchedulerFeature.register(container); - await config.registerSchedulerTransport?.(container); + await config.transports?.scheduler?.(container); CmsSchedulerFeature.register(container); // ── Extensions ───────────────────────────────────────────── diff --git a/packages/api-event-handler-core/tsconfig.build.json b/packages/api-event-handler-core/tsconfig.build.json index 28f45f17fd5..57cd71d374f 100644 --- a/packages/api-event-handler-core/tsconfig.build.json +++ b/packages/api-event-handler-core/tsconfig.build.json @@ -7,7 +7,6 @@ { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-file-manager/tsconfig.build.json" }, { "path": "../api-file-manager-aco/tsconfig.build.json" }, - { "path": "../api-file-manager-s3/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../api-headless-cms-aco/tsconfig.build.json" }, { "path": "../api-headless-cms-scheduler/tsconfig.build.json" }, @@ -44,8 +43,6 @@ "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/api-file-manager-aco/*": ["../api-file-manager-aco/src/*"], "@webiny/api-file-manager-aco": ["../api-file-manager-aco/src"], - "@webiny/api-file-manager-s3/*": ["../api-file-manager-s3/src/*"], - "@webiny/api-file-manager-s3": ["../api-file-manager-s3/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], "@webiny/api-headless-cms-aco/*": ["../api-headless-cms-aco/src/*"], diff --git a/packages/api-event-handler-core/tsconfig.json b/packages/api-event-handler-core/tsconfig.json index ae9c2d44d3a..ad03143207e 100644 --- a/packages/api-event-handler-core/tsconfig.json +++ b/packages/api-event-handler-core/tsconfig.json @@ -7,7 +7,6 @@ { "path": "../api-core" }, { "path": "../api-file-manager" }, { "path": "../api-file-manager-aco" }, - { "path": "../api-file-manager-s3" }, { "path": "../api-headless-cms" }, { "path": "../api-headless-cms-aco" }, { "path": "../api-headless-cms-scheduler" }, @@ -44,8 +43,6 @@ "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/api-file-manager-aco/*": ["../api-file-manager-aco/src/*"], "@webiny/api-file-manager-aco": ["../api-file-manager-aco/src"], - "@webiny/api-file-manager-s3/*": ["../api-file-manager-s3/src/*"], - "@webiny/api-file-manager-s3": ["../api-file-manager-s3/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], "@webiny/api-headless-cms-aco/*": ["../api-headless-cms-aco/src/*"], diff --git a/packages/api-event-handler-server/package.json b/packages/api-event-handler-server/package.json index 9de048f0a7e..1a9014a2012 100644 --- a/packages/api-event-handler-server/package.json +++ b/packages/api-event-handler-server/package.json @@ -20,7 +20,10 @@ "dependencies": { "@webiny/api-core": "0.0.0", "@webiny/api-event-handler-core": "0.0.0", + "@webiny/api-file-manager-server": "0.0.0", + "@webiny/api-scheduler-server": "0.0.0", "@webiny/api-websockets-server": "0.0.0", + "@webiny/background-tasks-server": "0.0.0", "@webiny/event-handler-core": "0.0.0", "@webiny/event-handler-server": "0.0.0", "@webiny/handler": "0.0.0" diff --git a/packages/api-event-handler-server/src/createWebinyApiHandler.ts b/packages/api-event-handler-server/src/createWebinyApiHandler.ts index 1076e3a12e4..682e4409655 100644 --- a/packages/api-event-handler-server/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-server/src/createWebinyApiHandler.ts @@ -5,9 +5,11 @@ * plus the auth/tenant loader decorators (extract token / x-tenant from the IncomingMessage → shared * RequestIdentityLoader / RequestTenantLoader). The per-request feature stack is the transport-agnostic * `registerApiRequestStack` from `@webiny/api-event-handler-core` — the SAME stack the AWS handler uses. - * It is called with NEITHER the realtime nor the scheduler hook: those are AWS-specific (API Gateway - * WebSockets / EventBridge Scheduler) and this transport has no equivalent — the hooks being optional - * is the point. The storage variant (and its identity provider) is injected via `registerRootStorage`. + * Both interleave hooks are supplied with SINGLE-PROCESS, in-process equivalents of the AWS transports: + * the realtime hook installs the server WebSockets transport (vs AWS's API Gateway Management API), and + * the scheduler hook installs the Bree/in-process scheduler (vs AWS's EventBridge Scheduler). Background + * tasks are wired in the ROOT container (below), mirroring how the AWS handler registers its background- + * task transport at root. The storage variant (and its identity provider) is injected via `registerRootStorage`. * * The identity provider (e.g. `@webiny/self-hosted-auth`'s JWT IdP) must be registered by the variant * in `registerRootStorage`, so the RequestIdentityLoader driven by the identity decorator can resolve it. @@ -23,6 +25,9 @@ import { WebsocketsConnectionManager, attachWebsocketsServer } from "@webiny/api-websockets-server"; +import { BackgroundTasksServerFeature } from "@webiny/background-tasks-server"; +import { registerSchedulerServerExtension } from "@webiny/api-scheduler-server"; +import { FileManagerServerFeature } from "@webiny/api-file-manager-server"; import { NodeHttpIdentityLoaderDecorator } from "~/handlers/NodeHttpIdentityLoaderDecorator.js"; import { NodeHttpTenantLoaderDecorator } from "~/handlers/NodeHttpTenantLoaderDecorator.js"; @@ -63,25 +68,55 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { // connection registry (ConnectionRegistry) is the storage variant's job (e.g. sql). container.register(ServerConnectionManager).inSingletonScope(); container.register(NodeWsAdapter).inSingletonScope(); + + // ── Background tasks (root) ──────────────────────────────── + // Mirrors the AWS handler registering its background-task transport at root. There is no + // Step Functions / Lambda re-invocation in a single process, so the transport is in-process: + // WorkerService (the BackgroundTasks/TaskService dispatch abstraction) runs each triggered + // task in a Node worker_thread that POSTs back to this server's `/background-task` HTTP route + // (BackgroundTaskRoute), which runs the task loop (continue/timeout/abort) in-process. Root, + // not per-request, is load-bearing: the shared InternalToken (registered here as a singleton) + // gates the route against the worker's callback, so dispatcher and route MUST see the SAME + // token — a per-request registration would mint a fresh token per request and always 403. + // The route is an HttpRoute; the per-request HttpRouter collects it via the parent chain. + BackgroundTasksServerFeature.register(container); }, request: async container => { // The transport-agnostic per-request stack. The realtime hook installs the server // WebSockets transport (overriding the domain's NullWebsocketsTransport); it resolves the - // shared connection manager + adapter from the root. No scheduler hook — that's AWS-only. + // shared connection manager + adapter from the root. The scheduler hook installs the + // Bree/in-process scheduler transport (the single-process equivalent of EventBridge). await registerApiRequestStack(container, { extensions: config.extensions, - // Why a hook (and not just registering the transport ourselves): `.register()` calls + // Why hooks (and not just registering the transports ourselves): `.register()` calls // are otherwise order-independent — you can register Features in any order, because // they only REGISTER, they don't RESOLVE during registration (resolution happens // later, in Initializers / SchemaFactories). The one thing that makes order matter is - // a DEFAULT registration: `WebsocketsFeature` registers `NullWebsocketsTransport` so - // the abstraction is always resolvable. Overriding it (last-registration-wins) means - // our transport MUST be registered AFTER `WebsocketsFeature`. This hook is the seam - // `registerApiRequestStack` provides for exactly that — it runs right after the Null + // a DEFAULT registration: e.g. `WebsocketsFeature` registers `NullWebsocketsTransport` + // so the abstraction is always resolvable. Overriding it (last-registration-wins) means + // our transport MUST be registered AFTER that Feature. These hooks are the seams + // `registerApiRequestStack` provides for exactly that — each runs right after its Null // default, so the override is guaranteed without the caller knowing the internal order. - registerRealtimeTransport: requestContainer => { - requestContainer.register(ServerWebsocketsTransport); + transports: { + // Server WebSockets transport; resolves the shared connection manager + adapter + // from the root (registered as singletons above). + realtime: requestContainer => { + requestContainer.register(ServerWebsocketsTransport); + }, + // Scheduler transport: the Bree/in-process extension. Where AWS bridges EventBridge + // Scheduler, the single-process server drives delayed/scheduled action triggers with + // in-process timers (Bree). + scheduler: requestContainer => { + registerSchedulerServerExtension(requestContainer); + }, + // File-manager storage transport: local disk. Where AWS uses S3 (+ a separate asset- + // delivery Lambda), the single-process server stores files on disk and serves them + // in-process — FileManagerServerFeature registers local asset delivery (overriding + // the domain's null impls), the upload/multipart HTTP routes, and disk file ops. + fileManager: requestContainer => { + FileManagerServerFeature.register(requestContainer); + } } }); }, diff --git a/packages/api-event-handler-server/tsconfig.build.json b/packages/api-event-handler-server/tsconfig.build.json index 3cb48dd9cba..0be429d65ca 100644 --- a/packages/api-event-handler-server/tsconfig.build.json +++ b/packages/api-event-handler-server/tsconfig.build.json @@ -4,7 +4,10 @@ "references": [ { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-event-handler-core/tsconfig.build.json" }, + { "path": "../api-file-manager-server/tsconfig.build.json" }, + { "path": "../api-scheduler-server/tsconfig.build.json" }, { "path": "../api-websockets-server/tsconfig.build.json" }, + { "path": "../background-tasks-server/tsconfig.build.json" }, { "path": "../event-handler-core/tsconfig.build.json" }, { "path": "../event-handler-server/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" } @@ -20,8 +23,14 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-event-handler-core/*": ["../api-event-handler-core/src/*"], "@webiny/api-event-handler-core": ["../api-event-handler-core/src"], + "@webiny/api-file-manager-server/*": ["../api-file-manager-server/src/*"], + "@webiny/api-file-manager-server": ["../api-file-manager-server/src"], + "@webiny/api-scheduler-server/*": ["../api-scheduler-server/src/*"], + "@webiny/api-scheduler-server": ["../api-scheduler-server/src"], "@webiny/api-websockets-server/*": ["../api-websockets-server/src/*"], "@webiny/api-websockets-server": ["../api-websockets-server/src"], + "@webiny/background-tasks-server/*": ["../background-tasks-server/src/*"], + "@webiny/background-tasks-server": ["../background-tasks-server/src"], "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/event-handler-server/*": ["../event-handler-server/src/*"], diff --git a/packages/api-event-handler-server/tsconfig.json b/packages/api-event-handler-server/tsconfig.json index eaa9a406a6b..13bb9049870 100644 --- a/packages/api-event-handler-server/tsconfig.json +++ b/packages/api-event-handler-server/tsconfig.json @@ -4,7 +4,10 @@ "references": [ { "path": "../api-core" }, { "path": "../api-event-handler-core" }, + { "path": "../api-file-manager-server" }, + { "path": "../api-scheduler-server" }, { "path": "../api-websockets-server" }, + { "path": "../background-tasks-server" }, { "path": "../event-handler-core" }, { "path": "../event-handler-server" }, { "path": "../handler" } @@ -20,8 +23,14 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-event-handler-core/*": ["../api-event-handler-core/src/*"], "@webiny/api-event-handler-core": ["../api-event-handler-core/src"], + "@webiny/api-file-manager-server/*": ["../api-file-manager-server/src/*"], + "@webiny/api-file-manager-server": ["../api-file-manager-server/src"], + "@webiny/api-scheduler-server/*": ["../api-scheduler-server/src/*"], + "@webiny/api-scheduler-server": ["../api-scheduler-server/src"], "@webiny/api-websockets-server/*": ["../api-websockets-server/src/*"], "@webiny/api-websockets-server": ["../api-websockets-server/src"], + "@webiny/background-tasks-server/*": ["../background-tasks-server/src/*"], + "@webiny/background-tasks-server": ["../background-tasks-server/src"], "@webiny/event-handler-core/*": ["../event-handler-core/src/*"], "@webiny/event-handler-core": ["../event-handler-core/src"], "@webiny/event-handler-server/*": ["../event-handler-server/src/*"], diff --git a/packages/api-file-manager-server/package.json b/packages/api-file-manager-server/package.json index f685218507d..5c62c6e3b8b 100644 --- a/packages/api-file-manager-server/package.json +++ b/packages/api-file-manager-server/package.json @@ -20,10 +20,12 @@ "@webiny/event-handler-core": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", + "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", "@webiny/validation": "0.0.0", "exifreader": "^4.41.0", + "p-map": "^7.0.5", "sharp": "^0.35.3" }, "devDependencies": { diff --git a/packages/api-file-manager-server/src/FileManagerServerFeature.ts b/packages/api-file-manager-server/src/FileManagerServerFeature.ts index 250015912d0..2b6e07de546 100644 --- a/packages/api-file-manager-server/src/FileManagerServerFeature.ts +++ b/packages/api-file-manager-server/src/FileManagerServerFeature.ts @@ -1,6 +1,9 @@ -import { existsSync, mkdirSync } from "node:fs"; import { type Container, createFeature } from "@webiny/feature/api"; -import { FileManagerServerConfig } from "~/features/FileManagerServerConfig/abstractions.js"; +import { CoreGraphQLSchemaFactory } from "@webiny/handler-graphql/graphql/abstractions.js"; +import { ReadFileMetadataFeature } from "@webiny/api-file-manager/features/upload/ReadFileMetadata/feature.js"; +import { WriteFileMetadataFeature } from "@webiny/api-file-manager/features/upload/WriteFileMetadata/feature.js"; +import { createLocalAssetDeliveryFeature } from "~/assetDelivery/feature.js"; +import { createServerFileManagerGraphQLSchema } from "~/graphql/schema.js"; import { FileManagerServerConfigFeature } from "~/features/FileManagerServerConfig/feature.js"; import { UploadSingleFileRouteFeature } from "~/routes/UploadSingleFileRoute/feature.js"; import { UploadPartRouteFeature } from "~/routes/UploadPartRoute/feature.js"; @@ -17,12 +20,29 @@ import { CompleteMultiPartUploadFeature } from "~/features/CompleteMultiPartUplo export const FileManagerServerFeature = createFeature({ name: "FileManagerServer", register(container: Container) { + // NOTE: do NOT resolve FileManagerServerConfig here. This runs at the file-manager transport + // hook, BEFORE project extensions (which register the WEBINY_LOCAL_STORAGE_PATH / + // WEBINY_UPLOAD_SECRET build params) are applied later in the request stack. Config is resolved + // lazily at request time by the routes/use-cases below; it also ensures its storage dir on + // construction. Resolving it eagerly here would read the build params before they exist. FileManagerServerConfigFeature.register(container); - const config = container.resolve(FileManagerServerConfig); - if (!existsSync(config.storagePath)) { - mkdirSync(config.storagePath, { recursive: true }); - } + // Local (disk) asset-delivery implementations (LocalAssetResolver/OutputStrategy/SharpTransform) + // override the null delivery registered by AssetDeliveryFeature in FileManagerAppFeature — the + // same seam FileManagerS3Feature uses with createS3AssetDeliveryFeature. Without this the domain + // AssetDeliveryRoute resolves the null impls and serves nothing. + createLocalAssetDeliveryFeature().register(container); + + // Metadata reader (FileManager/Upload/MetadataReader) — the generic domain impl backed by the + // GlobalKeyValueStore. Required by the server's ExtractMetadata task + GetFileContentsById use + // cases; without it createFile fails with "No registration found for FileManager/Upload/MetadataReader". + ReadFileMetadataFeature.register(container); + + // Metadata writer — the generic domain WriteMetadataAfterCreate/BatchCreate event handlers write + // the asset metadata (id/tenant/size/contentType/bucketKey) to the GlobalKeyValueStore on file + // create. Asset delivery (LocalAssetResolver) reads that metadata to locate the file on disk; + // without it every delivered file 404s ("asset not found"). Mirrors S3's WriteFileMetadataFeature. + WriteFileMetadataFeature.register(container); FlushCacheFeature.register(container); DeleteFileFromDiskFeature.register(container); @@ -35,5 +55,22 @@ export const FileManagerServerFeature = createFeature({ CleanupStaleMultipartUploadsFeature.register(container); UploadSingleFileRouteFeature.register(container); UploadPartRouteFeature.register(container); + + // Upload GraphQL (getPreSignedPostPayload/getPreSignedPostPayloads + create/completeMultiPartUpload). + // Mirrors how FileManagerS3Feature contributes its schema — same query/mutation names so the SDK + // works unchanged; resolvers delegate to the server upload use cases registered above. + const serverSchema = createServerFileManagerGraphQLSchema(); + container.registerInstance(CoreGraphQLSchemaFactory, { + async execute(builder) { + const { schema } = serverSchema; + if (schema.typeDefs) { + builder.addTypeDefs(schema.typeDefs); + } + if (schema.resolvers) { + builder.addLegacyResolvers(schema.resolvers as Record); + } + return builder; + } + }); } }); diff --git a/packages/api-file-manager-server/src/features/CreateMultiPartUpload/CreateMultiPartUploadUseCase.ts b/packages/api-file-manager-server/src/features/CreateMultiPartUpload/CreateMultiPartUploadUseCase.ts index b644105485b..23963913448 100644 --- a/packages/api-file-manager-server/src/features/CreateMultiPartUpload/CreateMultiPartUploadUseCase.ts +++ b/packages/api-file-manager-server/src/features/CreateMultiPartUpload/CreateMultiPartUploadUseCase.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { mkdir } from "node:fs/promises"; import { mdbid } from "@webiny/utils"; import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; -import { Request } from "@webiny/handler/abstractions/Request.js"; import { CreateMultiPartUploadUseCase as CreateMultiPartUploadUseCaseAbstraction } from "@webiny/api-file-manager/features/upload/CreateMultiPartUpload/index.js"; import type { CreateMultiPartUploadResult } from "@webiny/api-file-manager/features/upload/types.js"; import { createUploadToken } from "~/utils/uploadToken.js"; @@ -14,7 +13,6 @@ class CreateMultiPartUploadUseCaseImpl { public constructor( private readonly tenantContext: TenantContext.Interface, - private readonly request: Request.Interface, private readonly config: FileManagerServerConfig.Interface ) {} @@ -24,7 +22,7 @@ class CreateMultiPartUploadUseCaseImpl const { file, numberOfParts } = params; const storagePath = this.config.storagePath; const tenant = this.tenantContext.getTenant(); - const serverUrl = await resolveServerUrl(this.request); + const serverUrl = resolveServerUrl(); const uploadId = mdbid(); @@ -60,5 +58,5 @@ class CreateMultiPartUploadUseCaseImpl export const CreateMultiPartUploadUseCase = CreateMultiPartUploadUseCaseAbstraction.createImplementation({ implementation: CreateMultiPartUploadUseCaseImpl, - dependencies: [TenantContext, Request, FileManagerServerConfig] + dependencies: [TenantContext, FileManagerServerConfig] }); diff --git a/packages/api-file-manager-server/src/features/FileManagerServerConfig/FileManagerServerConfig.ts b/packages/api-file-manager-server/src/features/FileManagerServerConfig/FileManagerServerConfig.ts index 2beef5d4928..5849f18a9eb 100644 --- a/packages/api-file-manager-server/src/features/FileManagerServerConfig/FileManagerServerConfig.ts +++ b/packages/api-file-manager-server/src/features/FileManagerServerConfig/FileManagerServerConfig.ts @@ -1,3 +1,4 @@ +import { existsSync, mkdirSync } from "node:fs"; import { BuildParams } from "@webiny/api-core/features/buildParams/index.js"; import { FileManagerServerConfig as FileManagerServerConfigAbstraction } from "./abstractions.js"; @@ -22,6 +23,15 @@ class FileManagerServerConfigImpl implements FileManagerServerConfigAbstraction. this.storagePath = storagePath; this.uploadSecret = uploadSecret; + + // Ensure the storage directory exists. Done here (on first resolve, at request time) rather + // than in FileManagerServerFeature.register(): build params like WEBINY_LOCAL_STORAGE_PATH are + // only registered once project extensions are applied, which happens LATER in the request + // stack than the file-manager transport hook — so resolving this config at register() time + // would read the param before it exists and throw. + if (!existsSync(this.storagePath)) { + mkdirSync(this.storagePath, { recursive: true }); + } } } diff --git a/packages/api-file-manager-server/src/features/GetUploadPayload/GetUploadPayloadUseCase.ts b/packages/api-file-manager-server/src/features/GetUploadPayload/GetUploadPayloadUseCase.ts index 9b088245fa5..f5f3b99b08f 100644 --- a/packages/api-file-manager-server/src/features/GetUploadPayload/GetUploadPayloadUseCase.ts +++ b/packages/api-file-manager-server/src/features/GetUploadPayload/GetUploadPayloadUseCase.ts @@ -1,6 +1,5 @@ import { validation } from "@webiny/validation"; import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; -import { Request } from "@webiny/handler/abstractions/Request.js"; import { GetUploadPayloadUseCase as GetUploadPayloadUseCaseAbstraction } from "@webiny/api-file-manager/features/upload/GetUploadPayload/index.js"; import type { FileData } from "@webiny/api-file-manager/features/upload/types.js"; import type { UploadPayloadResponse } from "@webiny/api-file-manager/features/upload/types.js"; @@ -23,7 +22,6 @@ const sanitizeFileSizeValue = (value: number, defaultValue: number): number => { class GetUploadPayloadUseCaseImpl implements GetUploadPayloadUseCaseAbstraction.Interface { public constructor( private readonly tenantContext: TenantContext.Interface, - private readonly request: Request.Interface, private readonly config: FileManagerServerConfig.Interface ) {} @@ -53,7 +51,7 @@ class GetUploadPayloadUseCaseImpl implements GetUploadPayloadUseCaseAbstraction. secret ); - const serverUrl = await resolveServerUrl(this.request); + const serverUrl = resolveServerUrl(); const data = { url: `${serverUrl}/webiny-file-upload`, @@ -72,5 +70,5 @@ class GetUploadPayloadUseCaseImpl implements GetUploadPayloadUseCaseAbstraction. export const GetUploadPayloadUseCase = GetUploadPayloadUseCaseAbstraction.createImplementation({ implementation: GetUploadPayloadUseCaseImpl, - dependencies: [TenantContext, Request, FileManagerServerConfig] + dependencies: [TenantContext, FileManagerServerConfig] }); diff --git a/packages/api-file-manager-server/src/graphql/schema.ts b/packages/api-file-manager-server/src/graphql/schema.ts new file mode 100644 index 00000000000..6a01dc1d71c --- /dev/null +++ b/packages/api-file-manager-server/src/graphql/schema.ts @@ -0,0 +1,225 @@ +import pMap from "p-map"; +import { createGraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { ErrorResponse, Response } from "@webiny/handler-graphql/responses.js"; +import { GetSettingsUseCase } from "@webiny/api-file-manager/features/settings/GetSettings/abstractions.js"; +import { GetUploadPayloadUseCase } from "@webiny/api-file-manager/features/upload/GetUploadPayload/index.js"; +import { CreateMultiPartUploadUseCase } from "@webiny/api-file-manager/features/upload/CreateMultiPartUpload/index.js"; +import { CompleteMultiPartUploadUseCase } from "@webiny/api-file-manager/features/upload/CompleteMultiPartUpload/index.js"; +import { + checkPermissions, + FileNormalizer +} from "@webiny/api-file-manager/features/upload/index.js"; +import type { FileData } from "@webiny/api-file-manager/features/upload/types.js"; + +// Build the normalizer WITHOUT the gql context: the domain's `createFileNormalizerFromContext` reads +// `context.plugins.byType(...)` (the legacy plugins registry), which the DI-native server gql context +// does not populate — calling it throws "Cannot read properties of undefined (reading 'byType')". The +// modifier is optional; FileNormalizer still generates the id/type/key. The server has no legacy +// FileUploadModifierPlugins, so an empty (no-modifier) normalizer is the correct behaviour here. +const createNormalizer = () => new FileNormalizer(); + +/** + * Upload GraphQL for the self-hosted (server) flavour. Mirrors the SDL that `api-file-manager-s3`'s + * `createS3GraphQLSchema` contributes (same query/mutation names + `PreSignedPostPayloadInput`), so the + * transport-agnostic SDK (`getPresignedPostPayload` + the generic multipart-form `uploadToS3`, and the + * multipart methods) works unchanged. The payload shape (`{ url, fields }`) is generic; only the + * resolvers differ from S3 — they resolve the server upload use cases from the container (local disk + + * the `/webiny-file-upload` HTTP routes) instead of constructing an S3 client. + */ +export const createServerFileManagerGraphQLSchema = () => { + return createGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type UploadFileResponseDataFile { + id: ID! + name: String! + type: String! + size: Long! + key: String! + } + + input PreSignedPostPayloadInput { + id: ID + name: String! + type: String! + size: Long! + key: String + keyPrefix: String + } + + type GetPreSignedPostPayloadResponseDataFile { + id: ID! + name: String! + type: String! + size: Long! + key: String! + } + + type GetPreSignedPostPayloadResponseData { + # Contains data that is necessary for initiating a file upload. + data: JSON! + file: UploadFileResponseDataFile! + } + + type GetPreSignedPostPayloadResponse { + error: FmError + data: GetPreSignedPostPayloadResponseData + } + + type MultiPartUploadFilePart { + partNumber: Int! + url: String! + } + + type CreateMultiPartUploadResponseData { + file: GetPreSignedPostPayloadResponseDataFile! + uploadId: String! + parts: [MultiPartUploadFilePart!]! + } + + type CompleteMultiPartUploadResponse { + data: Boolean + error: FmError + } + + type GetPreSignedPostPayloadsResponse { + error: FmError + data: [GetPreSignedPostPayloadResponseData!]! + } + + extend type FmQuery { + getPreSignedPostPayload( + data: PreSignedPostPayloadInput! + ): GetPreSignedPostPayloadResponse + getPreSignedPostPayloads( + data: [PreSignedPostPayloadInput]! + ): GetPreSignedPostPayloadsResponse + } + + type CreateMultiPartUploadResponse { + data: CreateMultiPartUploadResponseData + error: FmError + } + + input MultiPartUploadFilePartInput { + partNumber: Int! + etag: String! + } + + extend type FmMutation { + createMultiPartUpload( + data: PreSignedPostPayloadInput! + numberOfParts: Number! + ): CreateMultiPartUploadResponse + + completeMultiPartUpload( + fileKey: String! + uploadId: String! + ): CompleteMultiPartUploadResponse + } + `, + resolvers: { + FmQuery: { + getPreSignedPostPayload: async (_: unknown, args: any, context: any) => { + const identityContext = context.container.resolve(IdentityContext); + try { + await checkPermissions(identityContext, { rwd: "w" }); + + const getSettings = context.container.resolve(GetSettingsUseCase); + const settingsResult = await getSettings.execute(); + const settings = settingsResult.value; + + const normalizer = createNormalizer(); + const useCase = context.container.resolve(GetUploadPayloadUseCase); + + const payload = await useCase.execute( + (await normalizer.normalizeFile(args.data)) as FileData, + settings + ); + + return new Response(payload); + } catch (e) { + return new ErrorResponse({ + message: e.message, + code: e.code, + data: e.data + }); + } + }, + getPreSignedPostPayloads: async (_: unknown, args: any, context: any) => { + const identityContext = context.container.resolve(IdentityContext); + try { + await checkPermissions(identityContext, { rwd: "w" }); + + const getSettings = context.container.resolve(GetSettingsUseCase); + const settingsResult = await getSettings.execute(); + const settings = settingsResult.value; + + const normalizer = createNormalizer(); + const useCase = context.container.resolve(GetUploadPayloadUseCase); + + const files = args.data as any[]; + const payloads = await pMap(files, async (data: any) => { + return useCase.execute( + (await normalizer.normalizeFile(data)) as FileData, + settings + ); + }); + + return new Response(payloads); + } catch (e) { + return new ErrorResponse({ + message: e.message, + code: e.code, + data: e.data + }); + } + } + }, + FmMutation: { + createMultiPartUpload: async (_: unknown, args: any, context: any) => { + const identityContext = context.container.resolve(IdentityContext); + try { + await checkPermissions(identityContext, { rwd: "w" }); + + const normalizer = createNormalizer(); + const useCase = context.container.resolve(CreateMultiPartUploadUseCase); + + const result = await useCase.execute({ + file: (await normalizer.normalizeFile(args.data)) as FileData, + numberOfParts: args.numberOfParts + }); + + return new Response(result); + } catch (e) { + return new ErrorResponse({ + message: e.message, + code: e.code, + data: e.data + }); + } + }, + completeMultiPartUpload: async (_: unknown, args: any, context: any) => { + const identityContext = context.container.resolve(IdentityContext); + try { + await checkPermissions(identityContext, { rwd: "w" }); + + const useCase = context.container.resolve(CompleteMultiPartUploadUseCase); + await useCase.execute({ + fileKey: args.fileKey, + uploadId: args.uploadId + }); + + return new Response(true); + } catch (e) { + return new ErrorResponse({ + message: e.message, + code: e.code, + data: e.data + }); + } + } + } + } + }); +}; diff --git a/packages/api-file-manager-server/src/utils/resolveServerUrl.ts b/packages/api-file-manager-server/src/utils/resolveServerUrl.ts index 3d135d7cc4e..5e176978c42 100644 --- a/packages/api-file-manager-server/src/utils/resolveServerUrl.ts +++ b/packages/api-file-manager-server/src/utils/resolveServerUrl.ts @@ -1,16 +1,19 @@ -import { ServiceDiscovery } from "@webiny/api-core/features/serviceDiscovery/ServiceDiscovery.js"; -import type { Request } from "@webiny/handler/types.js"; - -export const resolveServerUrl = async (request: Request): Promise => { - const manifest = await ServiceDiscovery.load(); - const domain = manifest?.api?.cloudfront?.domain; - if (domain) { - return domain as string; +/** + * Resolve the base URL the file-upload endpoints (`/webiny-file-upload`) are reachable at. This URL is + * handed to the client (admin) in the upload payload, so it must be an origin the client can POST to. + * + * The DI-native server gql context exposes no request headers to resolvers (GraphQLRoute forwards only + * the body), so — unlike the AWS flavour — we cannot derive the host from the incoming request. Instead + * we use the configured API origin `WEBINY_API_URL` (the same value the admin app is pointed at via + * `Admin.ApiUrl`), falling back to `http://localhost:${PORT}` for local dev where the client and server + * share the machine (PORT is injected at runtime by runApiServer — the port the server listens on). + */ +export const resolveServerUrl = (): string => { + const configured = process.env.WEBINY_API_URL; + if (configured) { + return configured.replace(/\/+$/, ""); } - const headers = request.headers ?? {}; - const host = headers["host"] ?? headers["x-forwarded-host"] ?? "localhost"; - const protocol = headers["x-forwarded-proto"] ?? "https"; - - return `${protocol}://${host}`; + const port = process.env.PORT || "3002"; + return `http://localhost:${port}`; }; diff --git a/packages/api-file-manager-server/tsconfig.build.json b/packages/api-file-manager-server/tsconfig.build.json index d49750b2813..3d1bfa83ac6 100644 --- a/packages/api-file-manager-server/tsconfig.build.json +++ b/packages/api-file-manager-server/tsconfig.build.json @@ -8,6 +8,7 @@ { "path": "../event-handler-core/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, { "path": "../validation/tsconfig.build.json" } @@ -31,6 +32,8 @@ "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/api-file-manager-server/tsconfig.json b/packages/api-file-manager-server/tsconfig.json index ac02f1d4e04..832fc3b6727 100644 --- a/packages/api-file-manager-server/tsconfig.json +++ b/packages/api-file-manager-server/tsconfig.json @@ -8,6 +8,7 @@ { "path": "../event-handler-core" }, { "path": "../feature" }, { "path": "../handler" }, + { "path": "../handler-graphql" }, { "path": "../plugins" }, { "path": "../utils" }, { "path": "../validation" } @@ -31,6 +32,8 @@ "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts index f17a4c09f77..127e35778d5 100644 --- a/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts +++ b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts @@ -21,7 +21,11 @@ class SettingsInstallerImpl implements AppInstaller.Interface { // If no records in the database, `manifest` object is empty POJO. // That's why the heavy `?.` usage. - const domain = manifest?.api?.cloudfront.domain; + // + // The AWS flavour serves files from a CloudFront domain (in the manifest). The self-hosted + // (server) flavour has no CloudFront — files are served by the api's own `/files/*` route — so + // fall back to the configured API origin (WEBINY_API_URL), the same origin the client uses. + const domain = manifest?.api?.cloudfront?.domain ?? process.env.WEBINY_API_URL ?? ""; await this.updateSettings.execute({ srcPrefix: `${domain}/files` diff --git a/packages/api-scheduler-server/src/context.ts b/packages/api-scheduler-server/src/context.ts index 0b6b3fb725e..90a56aa0a34 100644 --- a/packages/api-scheduler-server/src/context.ts +++ b/packages/api-scheduler-server/src/context.ts @@ -1,5 +1,6 @@ import type { Container } from "@webiny/di"; import { RequestContextInitializer } from "@webiny/event-handler-core"; +import type { IRequestContextInitializer } from "@webiny/event-handler-core"; import { SchedulerService } from "@webiny/api-scheduler/shared/abstractions.js"; import { ExecuteScheduledActionUseCase } from "@webiny/api-scheduler/features/ExecuteScheduledAction/index.js"; import { ListScheduledActionsUseCase } from "@webiny/api-scheduler/features/ListScheduledActions/index.js"; @@ -8,58 +9,80 @@ import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/i import { BreeSchedulerService } from "~/BreeSchedulerService.js"; /** - * Registers the self-hosted (Bree, in-process) scheduler transport. Binds `SchedulerService` - * per-request via a RequestContextInitializer (post-tenant): builds the Bree service, wires the - * trigger to ExecuteScheduledActionUseCase, and starts it with the tenant's pending actions. + * Per-request scheduler initializer for the self-hosted (Bree, in-process) transport. Builds the Bree + * service, wires the trigger to ExecuteScheduledActionUseCase, binds `SchedulerService`, and starts it + * with the tenant's pending actions. + * + * IMPORTANT — this MUST be registered via `container.register()` (not `registerInstance()`), and hence + * runs in the LATER RequestContextInitializer bucket. Its `init()` lists scheduled actions, which needs + * `ScheduledActionModel` — and that model is registered by another RequestContextInitializer + * (`SchedulerModelContextualSchema`, registered by `SchedulerFeature` via `register()`). `@webiny/di` + * runs `registerInstance` initializers BEFORE `register` ones, so a `registerInstance` here would run + * before the model exists → "No registration found for ScheduledActionModel". Registering as an + * implementation keeps both in the same (register) bucket, ordered by registration: `SchedulerFeature` + * (which registers the model initializer) is registered before this transport hook, so the model is + * always available by the time this runs. */ -export const registerSchedulerServerExtension = (container: Container) => { - container.registerInstance(RequestContextInitializer, { - async init(ctx: Record) { - const requestContainer = ctx.container as Container; - const tenantContext = requestContainer.resolve(TenantContext); +class SchedulerServerContextInitializerImpl implements IRequestContextInitializer { + async init(ctx: Record): Promise { + const requestContainer = ctx.container as Container; + const tenantContext = requestContainer.resolve(TenantContext); - const tenant = tenantContext.getTenant(); - if (!tenant) { - return; - } + const tenant = tenantContext.getTenant(); + if (!tenant) { + return; + } - const logger = requestContainer.resolve(Logger); - const executeScheduledAction = requestContainer.resolve(ExecuteScheduledActionUseCase); + const logger = requestContainer.resolve(Logger); + const executeScheduledAction = requestContainer.resolve(ExecuteScheduledActionUseCase); - const service = new BreeSchedulerService({ - logger, - onTrigger: async (id, namespace) => { - const result = await executeScheduledAction.execute({ - id, - namespace, - tenant: tenant.id - }); + const service = new BreeSchedulerService({ + logger, + onTrigger: async (id, namespace) => { + const result = await executeScheduledAction.execute({ + id, + namespace, + tenant: tenant.id + }); - if (result.isFail()) { - logger.error( - `Scheduled action "${id}" execution failed: ${result.error.message}` - ); - } + if (result.isFail()) { + logger.error( + `Scheduled action "${id}" execution failed: ${result.error.message}` + ); } - }); + } + }); - requestContainer.registerInstance(SchedulerService, service); + requestContainer.registerInstance(SchedulerService, service); - const listScheduledActions = requestContainer.resolve(ListScheduledActionsUseCase); - const listResult = await listScheduledActions.execute({ - where: {}, - limit: 1000 - }); + const listScheduledActions = requestContainer.resolve(ListScheduledActionsUseCase); + const listResult = await listScheduledActions.execute({ + where: {}, + limit: 1000 + }); - const pendingActions = listResult.isOk() - ? listResult.value.items.map(action => ({ - id: action.id, - namespace: action.namespace, - scheduledFor: action.scheduledFor - })) - : undefined; + const pendingActions = listResult.isOk() + ? listResult.value.items.map(action => ({ + id: action.id, + namespace: action.namespace, + scheduledFor: action.scheduledFor + })) + : undefined; - await service.start(pendingActions); - } - }); + await service.start(pendingActions); + } +} + +const SchedulerServerContextInitializer = RequestContextInitializer.createImplementation({ + implementation: SchedulerServerContextInitializerImpl, + dependencies: [] +}); + +/** + * Registers the self-hosted (Bree, in-process) scheduler transport. Binds `SchedulerService` + * per-request via a RequestContextInitializer (post-tenant) — see the class doc for the ordering + * constraint that requires `register()` over `registerInstance()`. + */ +export const registerSchedulerServerExtension = (container: Container) => { + container.register(SchedulerServerContextInitializer); }; diff --git a/packages/background-tasks-server/src/service/WorkerTaskService.ts b/packages/background-tasks-server/src/service/WorkerTaskService.ts index 5813fbfdba5..3dae9d927f8 100644 --- a/packages/background-tasks-server/src/service/WorkerTaskService.ts +++ b/packages/background-tasks-server/src/service/WorkerTaskService.ts @@ -2,7 +2,6 @@ import { Worker } from "node:worker_threads"; import type { WorkerToParentMessage } from "~/worker/TaskOrchestratorMessage.js"; import { TaskService } from "@webiny/background-tasks/api/domain/TaskService.js"; import { TenantContext } from "@webiny/api-core/exports/api/tenancy.js"; -import { BuildParams } from "@webiny/api-core/exports/api.js"; import { InternalToken } from "~/domain/InternalToken.js"; const DEFAULT_SERVER_PORT = 3000; @@ -22,10 +21,13 @@ class WorkerServiceImpl implements TaskService.Interface { public constructor( private readonly tenantContext: TenantContext.Interface, - private readonly buildParams: BuildParams.Interface, private readonly internalToken: InternalToken.Interface ) { - const port = this.buildParams.get("SERVER_PORT") || DEFAULT_SERVER_PORT; + // Single-process server flavour: the worker POSTs the task back to THIS server's + // `/background-task` route. The port is the one the server actually listens on — injected at + // runtime as `process.env.PORT` by `runApiServer` (dynamic, chosen via findFreePort), NOT a + // build-time value. It cannot be a build param: the port isn't known until the process starts. + const port = process.env.PORT || DEFAULT_SERVER_PORT; this.serverUrl = `http://localhost:${port}/background-task`; } @@ -105,5 +107,5 @@ class WorkerServiceImpl implements TaskService.Interface { export const WorkerService = TaskService.createImplementation({ implementation: WorkerServiceImpl, - dependencies: [TenantContext, BuildParams, InternalToken] + dependencies: [TenantContext, InternalToken] }); diff --git a/packages/cli-core/files/references.json b/packages/cli-core/files/references.json index 594b61ec963..7d3eae85441 100644 --- a/packages/cli-core/files/references.json +++ b/packages/cli-core/files/references.json @@ -1718,9 +1718,11 @@ "version": "7.0.5", "files": [ "/packages/api-file-manager-s3/package.json", + "/packages/api-file-manager-server/package.json", "/packages/api-headless-cms/package.json", "/packages/sdk/package.json", "/packages/api-file-manager-s3/package.json", + "/packages/api-file-manager-server/package.json", "/packages/api-headless-cms/package.json", "/packages/sdk/package.json" ] @@ -6345,6 +6347,7 @@ "version": "7.0.5", "files": [ { "file": "/packages/api-file-manager-s3/package.json", "types": ["dependencies"] }, + { "file": "/packages/api-file-manager-server/package.json", "types": ["dependencies"] }, { "file": "/packages/api-headless-cms/package.json", "types": ["dependencies"] }, { "file": "/packages/sdk/package.json", "types": ["dependencies"] } ] diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index cc4d0eebb7d..221b1f582e5 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -37,6 +37,12 @@ export async function createServerHandler( 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)); } diff --git a/packages/event-handler-server/src/translators/NodeHttpTranslator.ts b/packages/event-handler-server/src/translators/NodeHttpTranslator.ts index 7c39d7812eb..814d4de2a6f 100644 --- a/packages/event-handler-server/src/translators/NodeHttpTranslator.ts +++ b/packages/event-handler-server/src/translators/NodeHttpTranslator.ts @@ -5,23 +5,33 @@ import type { IHttpRequest } from "@webiny/event-handler-core"; async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { - let raw = ""; + // Collect raw Buffer chunks — do NOT string-concat: `raw += chunk` utf8-decodes each chunk and + // corrupts binary bodies (e.g. multipart/form-data file uploads), making them unparseable. + const chunks: Buffer[] = []; req.on("data", chunk => { - raw += chunk; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); req.on("end", () => { - if (!raw) { + if (chunks.length === 0) { return resolve(undefined); } + const buffer = Buffer.concat(chunks); const ct = (req.headers["content-type"] || "").toLowerCase(); if (ct.includes("application/json")) { + const text = buffer.toString("utf8"); try { - resolve(JSON.parse(raw)); + resolve(JSON.parse(text)); } catch { - resolve(raw); + resolve(text); } + } else if (ct.startsWith("text/") || ct.includes("application/x-www-form-urlencoded")) { + // Known text bodies → decode as a string. + resolve(buffer.toString("utf8")); } else { - resolve(raw); + // Everything else (multipart/form-data file uploads, octet-stream, or an unlabeled raw + // PUT body like a multipart upload part) is binary — hand routes the raw bytes. Decoding + // these as utf8 would corrupt the payload. + resolve(buffer); } }); req.on("error", reject); diff --git a/packages/project-server/src/extensions/FileStorage.tsx b/packages/project-server/src/extensions/FileStorage.tsx new file mode 100644 index 00000000000..09f0ada656f --- /dev/null +++ b/packages/project-server/src/extensions/FileStorage.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import nodePath from "node:path"; +import { z } from "zod"; +import { defineExtension, BuildParam } from "@webiny/project/extensions/index.js"; + +/** + * Configure the self-hosted (server) flavour's local file storage (uploaded files) + upload secret. + * + * Emitted as the API build parameters `WEBINY_LOCAL_STORAGE_PATH` (the on-disk directory uploaded + * files are written to and served from) and `WEBINY_UPLOAD_SECRET` (used to sign upload tokens), + * which `FileManagerServerConfig` reads via `BuildParams`. Note this uses `BuildParam` (DI build + * params), NOT `EnvVar` like `Infra.Sqlite` — the SQLite connection reads `process.env` directly, + * whereas the file-manager server config resolves these through `BuildParams.get(...)`, so they must + * be registered as build params (same channel as `Infra.Crypto.Encryption`). + * + * A relative storage path is resolved against the project root (not the disposable app workspace), so + * uploaded files survive rebuilds — same rule as `Infra.Sqlite`. + * + * AWS-flavour counterpart concept: the S3 bucket (`ApiFileManager`). + */ +export const FileStorage = defineExtension({ + type: "Infra/FileStorage", + tags: { runtimeContext: "project" }, + description: "Configure the server flavour's local file storage directory and upload secret.", + paramsSchema: z.object({ + path: z + .string() + .describe( + "Path to the local file storage directory (absolute, or relative to the project root)." + ), + uploadSecret: z.string().describe("Secret string used to sign file upload tokens.") + }), + render({ path: storagePathParam, uploadSecret }) { + const storagePath = nodePath.isAbsolute(storagePathParam) + ? storagePathParam + : nodePath.join(process.cwd(), storagePathParam); + + return ( + <> + + + + ); + } +}); diff --git a/packages/project-server/src/infra.ts b/packages/project-server/src/infra.ts index 91d25a63d6c..0f332d123a4 100644 --- a/packages/project-server/src/infra.ts +++ b/packages/project-server/src/infra.ts @@ -12,6 +12,7 @@ import { import { CiIs, CiIsNot } from "@webiny/project/extensions/infra/index.js"; import { Sqlite } from "./extensions/Sqlite.js"; import { Postgres } from "./extensions/Postgres.js"; +import { FileStorage } from "./extensions/FileStorage.js"; /** * Server-flavour counterpart to project-aws's `Infra`, limited to what applies off-AWS. Omits the @@ -32,6 +33,8 @@ export const Infra = { // Server-flavour SQL storage (engine-named, like Infra.OpenSearch on AWS). Sqlite, Postgres, + // Server-flavour local file storage (counterpart to the AWS S3 bucket). + FileStorage, Ci: { Is: CiIs, IsNot: CiIsNot diff --git a/webiny.config.server.tsx b/webiny.config.server.tsx index e531606e113..5a2e5551c6d 100644 --- a/webiny.config.server.tsx +++ b/webiny.config.server.tsx @@ -16,6 +16,13 @@ export const ServerExtensions = () => { workspace), so data persists across builds/watch restarts. */} + {/* Local file storage (uploaded files) + upload signing secret. Storage path resolves like + the SQLite file — against the project root — so uploads persist across rebuilds. */} + + {/* Hand the admin app the API URL (AWS derives this from stack output; server has none). */} diff --git a/yarn.lock b/yarn.lock index dab52243c1b..8fc0782c668 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10777,6 +10777,7 @@ __metadata: dependencies: "@webiny/api-core": "npm:0.0.0" "@webiny/api-event-handler-core": "npm:0.0.0" + "@webiny/api-file-manager-s3": "npm:0.0.0" "@webiny/api-scheduler-aws": "npm:0.0.0" "@webiny/api-websockets": "npm:0.0.0" "@webiny/api-websockets-aws": "npm:0.0.0" @@ -10806,7 +10807,6 @@ __metadata: "@webiny/api-core": "npm:0.0.0" "@webiny/api-file-manager": "npm:0.0.0" "@webiny/api-file-manager-aco": "npm:0.0.0" - "@webiny/api-file-manager-s3": "npm:0.0.0" "@webiny/api-headless-cms": "npm:0.0.0" "@webiny/api-headless-cms-aco": "npm:0.0.0" "@webiny/api-headless-cms-scheduler": "npm:0.0.0" @@ -10865,7 +10865,10 @@ __metadata: dependencies: "@webiny/api-core": "npm:0.0.0" "@webiny/api-event-handler-core": "npm:0.0.0" + "@webiny/api-file-manager-server": "npm:0.0.0" + "@webiny/api-scheduler-server": "npm:0.0.0" "@webiny/api-websockets-server": "npm:0.0.0" + "@webiny/background-tasks-server": "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" @@ -10940,7 +10943,7 @@ __metadata: languageName: unknown linkType: soft -"@webiny/api-file-manager-server@workspace:packages/api-file-manager-server": +"@webiny/api-file-manager-server@npm:0.0.0, @webiny/api-file-manager-server@workspace:packages/api-file-manager-server": version: 0.0.0-use.local resolution: "@webiny/api-file-manager-server@workspace:packages/api-file-manager-server" dependencies: @@ -10951,10 +10954,12 @@ __metadata: "@webiny/event-handler-core": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" + "@webiny/handler-graphql": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/utils": "npm:0.0.0" "@webiny/validation": "npm:0.0.0" exifreader: "npm:^4.41.0" + p-map: "npm:^7.0.5" rimraf: "npm:^6.1.3" sharp: "npm:^0.35.3" typescript: "npm:^7.0.2" @@ -12483,7 +12488,7 @@ __metadata: languageName: unknown linkType: soft -"@webiny/background-tasks-server@workspace:packages/background-tasks-server": +"@webiny/background-tasks-server@npm:0.0.0, @webiny/background-tasks-server@workspace:packages/background-tasks-server": version: 0.0.0-use.local resolution: "@webiny/background-tasks-server@workspace:packages/background-tasks-server" dependencies: