Skip to content

feat(self-hosted): background tasks, scheduler & real-time on the server flavour#5414

Open
adrians5j wants to merge 33 commits into
nextfrom
adrian/self-hosted-scheduler-boot-singleton
Open

feat(self-hosted): background tasks, scheduler & real-time on the server flavour#5414
adrians5j wants to merge 33 commits into
nextfrom
adrian/self-hosted-scheduler-boot-singleton

Conversation

@adrians5j

@adrians5j adrians5j commented Jul 15, 2026

Copy link
Copy Markdown
Member

Single PR for the remaining self-hosted (server) flavour work — wiring the server-specific packages into the Node HTTP handler and fixing the gaps found end-to-end. (#5412 build-param + transport-surface work and #5420 are folded in here.)

What changed

The self-hosted server flavour now runs the capabilities that previously worked only on AWS:

  • Background tasks actually run. Uploading an image kicks off its background task (e.g. AI image enrichment) to completion, instead of the task sitting stuck forever.
  • Scheduled actions fire. The scheduler is now one long-lived instance started once at boot (for all tenants), and re-arms persisted schedules on restart — rather than being rebuilt per request.
  • Real-time notifications reach the admin. WebSocket connections are now authenticated, so targeted server→client messages (like "your image was enriched") are delivered to the right signed-in user. Previously every connection was anonymous, so nothing was ever delivered.
  • AI image enrichment works off-cloud. The image is sent to the AI provider as raw bytes instead of a URL, so it no longer fails when the file lives on a private/local origin the provider can't fetch.

Changelog

Self-hosted server flavour: background tasks, scheduler, and real-time notifications

The self-hosted (non-AWS) server now runs background jobs, scheduled actions, and live notifications end-to-end. Background tasks such as AI image enrichment run to completion, scheduled actions fire on time and survive restarts, and real-time updates are delivered to the correct signed-in user in the admin app.

Squash Merge Commit

feat(self-hosted): run background tasks, scheduler & realtime on server (#5414)
fix(self-hosted): authenticate websockets, run bg tasks & scheduler (#5414)

adrians5j and others added 18 commits July 13, 2026 16:42
… to the server flavour

Wire the AWS-only background-tasks, scheduler, and file-manager capabilities into the
self-hosted Node server flavour, plus the transport fixes needed to run them in a single
long-running process.

Background tasks:
- Register BackgroundTasksServerFeature in the server handler root (in-process worker_thread
  runner + /background-task route). Root, not per-request, so the shared InternalToken gate
  is a stable singleton.
- WorkerService reads the runtime PORT env instead of the (unset) SERVER_PORT build param.

Scheduler:
- Wire registerSchedulerServerExtension via the registerSchedulerTransport hook.
- Register its RequestContextInitializer via register() (not registerInstance) so it runs
  after SchedulerModelContextualSchema registers ScheduledActionModel.

File manager:
- Add a registerFileManagerTransport hook to the shared request stack (replaces the
  hardcoded FileManagerS3Feature); AWS passes S3, server passes FileManagerServerFeature.
- FileManagerServerFeature: register local asset delivery, the upload GraphQL schema
  (mirrors S3 query names so the SDK is unchanged), and the domain Read/WriteFileMetadata
  features; resolve config lazily (build params are not registered at register() time).
- Add Infra.FileStorage (bakes WEBINY_LOCAL_STORAGE_PATH + WEBINY_UPLOAD_SECRET build params).
- resolveServerUrl derives the URL from WEBINY_API_URL/PORT (the DI-native gql context
  exposes no request headers to resolvers).
- SettingsInstaller falls back to WEBINY_API_URL for srcPrefix when there is no CloudFront
  manifest (AWS-safe).

Transport (event-handler-server):
- Read binary request bodies as raw Buffers (multipart uploads were utf8-corrupted).
- Write Buffer response bodies raw (image delivery was JSON-serialized -> ORB-blocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsports`

Collapse the three structurally-identical hooks (registerRealtimeTransport /
registerSchedulerTransport / registerFileManagerTransport) into a single
`transports: { realtime?, scheduler?, fileManager? }` object on
RegisterApiRequestStackConfig, plus a shared `TransportRegistrar` type. Behaviour
unchanged — the adapters still run at the same interleave points. Makes "these are all
transport adapters" one named concept and adding a future request-seam transport a single
key rather than a new top-level param. Updates the AWS and server handlers to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `assetDelivery` config knob was never set (both S3 and server callers pass no
config), so it was speculative parity. Remove the interface + param; register with
`createLocalAssetDeliveryFeature()` defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rver-bg-scheduler-fm

# Conflicts:
#	packages/project-server/src/infra.ts
…ess.env

API runtime code must not read build-time config from process.env. Add Infra.ApiUrl (bakes
the WEBINY_API_URL build param) and consume it via BuildParams:

- FileManagerServerConfig gains `apiUrl` (from BuildParams); the upload use cases use it, and
  the process.env-reading `resolveServerUrl` helper is deleted.
- SettingsInstaller reads the build param (falling back after the CloudFront manifest) for
  the file srcPrefix instead of process.env.

process.env stays only in webiny.config (build-time, where it belongs) and for genuinely
runtime values (PORT).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three `transports` entries read inconsistently because each package exposed a different
surface (raw impl / function / Feature). Give each a `createFeature` Feature so every entry
in the handlers' `transports: {}` reads identically as `XFeature.register(c)`:

- api-websockets-server: add WebsocketsServerFeature (registers ServerWebsocketsTransport)
- api-scheduler-server: add SchedulerServerFeature (wraps registerSchedulerServerExtension)
- api-scheduler-aws: add SchedulerAwsFeature (wraps registerSchedulerAwsExtension with the
  default scheduler client)

Both the AWS and server handlers updated. The underlying functions/impls stay exported for
custom wiring. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…api-scheduler-server

The Bree scheduler was rebuilt per request (new instance + re-arm each request) and
api-scheduler-server imported event-handler-core (RequestContextInitializer) — a transport
coupling in what should be a pure adapter. Rework it into one long-lived root singleton, and
move the transport glue to the composition layer.

api-scheduler-server (now PURE — no event-handler-core import):
- BreeSchedulerService tracks tenant per job and fires onTrigger(id, namespace, tenant), so a
  single instance serves all tenants (timers fire outside any request, so tenant can't be read
  from context). `recover()` is public for boot re-arming.
- Remove context.ts (the per-request RequestContextInitializer) and SchedulerServerFeature; the
  package now exports only BreeSchedulerService. Drop the event-handler-core + feature deps.

api-event-handler-server (composition layer owns the transport wiring):
- registerSchedulerServer (root): one BreeSchedulerService, registered as SchedulerService (so
  per-request create/update/delete hit the one live timer set) + as SchedulerSingleton; plus the
  internal token and the run/recover routes. Counterpart of the WebSockets connection manager.
- ScheduledActionRunRoute: the singleton POSTs here when a timer fires; it rebuilds the tenant's
  full request context (initializers + contextual schemas) and runs ExecuteScheduledAction —
  mirrors the background-task run route.
- ScheduledActionRecoverRoute: re-arms a tenant's persisted pending actions at boot (root tenant;
  multi-tenant boot recovery is a noted follow-up).
- onServer starts the timers and defers the recover POST until the server is listening.
- Scheduler leaves the per-request `transports` block (it's now root + onServer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ildparams-transport-surface

# Conflicts:
#	packages/api-event-handler-aws/src/createWebinyApiHandler.ts
#	packages/api-event-handler-server/src/createWebinyApiHandler.ts
#	packages/api-file-manager-server/src/features/CreateMultiPartUpload/CreateMultiPartUploadUseCase.ts
#	packages/api-file-manager-server/src/features/FileManagerServerConfig/FileManagerServerConfig.ts
#	packages/api-file-manager-server/src/features/GetUploadPayload/GetUploadPayloadUseCase.ts
#	packages/api-file-manager-server/src/utils/resolveServerUrl.ts
#	packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts
#	packages/project-server/src/infra.ts
#	webiny.config.server.tsx
#5409's optimization skips the cache→dist restore when a dist marker
(`.webiny-build-hash`) matches the package's source hash. The marker can go
stale when something writes dist out of band (e.g. `webiny watch`, or a
dependency changes while the dependent's own source hash is unchanged), which
surfaces as a stale dist / dangling imports.

Until that class of staleness is fully addressed, make the optimization
opt-in. Default behavior reverts to always restoring from cache (the original,
always-correct behavior). Enable with `WEBINY_EXPERIMENTAL_BUILD_CACHE=true`.

When disabled, the orchestrator neither reads nor writes the marker, so any
markers left on disk are inert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…skip' into adrian/self-hosted-scheduler-boot-singleton
…e publicPath asset

The task worker was spawned via `new Worker(new URL("../worker/workerEntry.js", import.meta.url))`.
The app bundler rewrites that `new URL(...)` to a publicPath-based asset URL
("/static/assets/workerEntry.<hash>.js"), which `new Worker()` then tries to load as a filesystem
path from root and fails ("Cannot find module '/static/assets/...'") — so every background task on
the server flavour stayed stuck "pending" (the worker never started).

`import.meta.url` resolves to this module inside its own dist (the bundler preserves it and does not
inline the package), and the compiled worker + its deps live beside it under dist/worker with only
relative/node-builtin imports. So resolve the worker with `path.join` off `import.meta.url` — an
expression the bundler leaves untouched — pointing straight at the real, node-resolvable dist worker.

Verified end-to-end on the server flavour: image upload → the worker spawns, POSTs the background-task
route, and the AI-image-enrichment + metadata-extract tasks run to completion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… shared runner validates + surface worker errors
…heduler-boot-singleton

# Conflicts:
#	packages/api-event-handler-server/src/createWebinyApiHandler.ts
#	packages/api-scheduler-server/package.json
#	packages/api-scheduler-server/src/index.ts
#	packages/api-scheduler-server/tsconfig.build.json
#	packages/api-scheduler-server/tsconfig.json
#	yarn.lock
@adrians5j adrians5j changed the title refactor(self-hosted): scheduler as a boot-time root singleton (pure api-scheduler-server) feat(self-hosted): in-process scheduler singleton + background-task fixes Jul 16, 2026
@adrians5j
adrians5j changed the base branch from adrian/self-hosted-buildparams-transport-surface to next July 16, 2026 11:30
adrians5j and others added 4 commits July 16, 2026 13:30
… so targeted sends route

Server-flavour WebSocket connections were registered with an empty identity and were never
persisted to the ConnectionRegistry, so SendToIdentity (which looks connections up by identity
id) matched nothing — targeted server→client notifications (e.g. AI image enrichment) never
reached the admin.

- ServerConnectionManager.add now registers the connection in the ConnectionRegistry
  (connectionId → identity/tenant): the row SendToIdentity/ListConnections query by identity.
- WebsocketsServer decodes the `?token` JWT + `?tenant` from the upgrade URL and registers the
  connection under the real identity via a new `authenticate` callback (guarded; a failure
  falls back to an anonymous, unaddressable connection rather than crashing the upgrade).
- The server handler supplies `authenticate` via a per-connection request-scoped child
  container — AuthenticationContext lives in the per-request stack, not at root; the
  token→identity step is tenant-independent, so it's safe to run at connection time.

Also folds into this PR:
- fix(ai-powerups): send image bytes as base64 to the AI provider instead of a URL; a URL
  forces the provider to fetch private/local files, which fails off-cloud.
- fix(app-websockets): on the server flavour, derive the ws:// URL from the admin API URL
  when REACT_APP_WEBSOCKET_URL is unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…heduler-boot-singleton

# Conflicts:
#	packages/cli-core/files/references.json
…enant-aware API

Align the unit tests with this branch's redesign:
- BreeSchedulerService: onTrigger now receives (id, namespace, tenant); recovery
  moved from start(actions) to start() + recover(actions); internal `namespaces`
  map is now `jobs` keyed with { namespace, tenant }.
- ServerConnectionManager: add() now registers the connection in the
  ConnectionRegistry, so the test asserts register() is called (not skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adrians5j
adrians5j force-pushed the adrian/self-hosted-scheduler-boot-singleton branch from 22ed432 to e593b28 Compare July 16, 2026 13:15
@adrians5j adrians5j changed the title feat(self-hosted): in-process scheduler singleton + background-task fixes feat(self-hosted): background tasks, scheduler & real-time on the server flavour Jul 16, 2026
adrians5j and others added 2 commits July 16, 2026 17:12
…tAuthorization at boot

Boot recovery runs outside any request, so it has no identity. ListScheduledActions
performs a permission check, which rejected the anonymous boot identity with
"Not authorized!" — so persisted schedules were never re-armed after a restart. Wrap the
list in identityContext.withoutAuthorization(), the same escape hatch the run route
(ExecuteScheduledActionUseCase) already uses.

Also:
- Add boot/trigger observability to the scheduler: log the recovered count and surface
  non-ok run/recover responses (fetch only rejects on network errors, so a 403/500 was
  previously invisible).
- Inline the per-request stack back into the handler's `request` hook and give the
  WebSocket auth child a transport-less registerApiRequestStack (it only needs the core
  auth stack) — removes the shared helper without duplicating the transport config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adrians5j and others added 3 commits July 16, 2026 18:03
…-box

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ct currentDir

Extract currentDir in WorkerTaskService for readability, and add pointers to issue #5429
(standalone server build must externalize the worker-file packages) next to both runtime
sidecar-file loaders (background-tasks worker, scheduler bree pollWorker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adrians5j
adrians5j force-pushed the adrian/self-hosted-scheduler-boot-singleton branch from 5e2df0a to 207820e Compare July 17, 2026 06:37
adrians5j and others added 4 commits July 17, 2026 12:25
…in.WebsocketsUrl

Add an `Admin.WebsocketsUrl` extension (mirrors `Admin.ApiUrl`) that bakes a dedicated WebSocket
URL into the admin bundle as `WEBINY_ADMIN_WS_API_URL`. getUrl now resolves, in order: the AWS
API-Gateway URL (REACT_APP_WEBSOCKET_URL), this dedicated server URL (if configured), else derives
ws(s):// from the API URL as before. The dedicated URL is optional — only needed when WebSockets
are served from a different origin than the API — and, like the API URL, is a build param populated
from any env the config chooses, so users aren't tied to a fixed env var name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ss.env in app code

Move all WebSocket-URL env resolution to resolveWebsocketUrl() at the admin composition root
(the sanctioned process.env spot, alongside resolveApiUrl), feed it into EnvConfig.websocketUrl,
and have WebsocketsContextProvider read it via useFeature(EnvConfigFeature). Removes the
process.env reads from getUrl.ts (deleted) so app code depends on the config abstraction instead
of raw env vars. Resolution order is unchanged: REACT_APP_WEBSOCKET_URL (AWS) -> WEBINY_ADMIN_WS_API_URL
(dedicated server) -> derived from the API URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…visibility only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant