Skip to content

feat(core): UDL as Production Data Layer (Epic #57)#70

Merged
dawidurbanski merged 46 commits into
mainfrom
57-epic-udl-as-production-data-layer
Dec 23, 2025
Merged

feat(core): UDL as Production Data Layer (Epic #57)#70
dawidurbanski merged 46 commits into
mainfrom
57-epic-udl-as-production-data-layer

Conversation

@dawidurbanski

@dawidurbanski dawidurbanski commented Dec 21, 2025

Copy link
Copy Markdown
Owner

Summary

This PR implements Epic #57: UDL as Production Data Layer, adding all the infrastructure needed to run UDL in production environments with proper health checks, graceful shutdown, webhook support, and partial sync capabilities.

Features

Phase 1: Health & Lifecycle

  • Health check endpoints (/health, /ready) for container orchestration and load balancer integration
  • Graceful shutdown with SIGTERM/SIGINT handling, in-flight request completion, and configurable grace period

Phase 2: Webhook Infrastructure

  • Plugin webhook registration API - plugins can register handlers via registerWebhook()
  • Webhook HTTP routing - incoming webhooks routed to correct plugin at POST /_webhooks/:plugin/:path
  • Webhook queue with debouncing - batches rapid webhooks to prevent N events → N rebuilds
  • Outbound webhook triggering - notify external systems (Vercel, CI) after batch processing

Phase 3: Partial Sync

  • Deletion log - tracks deleted nodes with timestamps for partial sync support
  • Clients can query "what was deleted since X?" to sync without full refetch
  • 30-day TTL with automatic cleanup

Related Issues

Closes #57, #58, #59, #60, #61, #62, #64

Type of Change

  • New feature (non-breaking change that adds functionality)

Breaking Changes

  • Webhook HTTP responses return 202 Accepted instead of 200 OK
  • Webhooks are processed asynchronously after debounce period

Example Configuration

export const { config } = defineConfig({
  remote: {
    webhooks: {
      debounceMs: 5000,
      maxQueueSize: 100,
      trigger: [
        {
          url: 'https://api.vercel.com/v1/deploy-hooks/xxx',
          headers: { 'x-api-key': process.env.VERCEL_TOKEN },
        },
      ],
      hooks: {
        onWebhookReceived: async ({ webhook }) => {
          if (!webhook.body?.sys?.publishedAt) return null;
          return webhook;
        },
        onAfterWebhookTriggered: async ({ batch }) => {
          console.log(`Processed ${batch.length} webhooks`);
        },
      },
    },
  },
});

Checklist

Code Quality

  • My code follows the project's style guidelines
  • I have run npm run lint and fixed all issues
  • I have run npm run typecheck with no errors
  • I have run npm run fix to format my code

Testing

  • I have run npm run test and all tests pass
  • I have added tests for my changes
  • My changes maintain or improve test coverage

Documentation & Changesets

  • I have created changesets for each feature
  • My commits follow Conventional Commits specification

Add /health (liveness) and /ready (readiness) endpoints to support
container orchestration and load balancer health checks.

- /health returns 200 when server is running
- /ready returns 200 when fully initialized, 503 during startup
- Includes timestamp and component status in responses

Closes #58
@dawidurbanski dawidurbanski linked an issue Dec 21, 2025 that may be closed by this pull request
18 tasks
@vercel

vercel Bot commented Dec 21, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
universal-data-layer-nextjs Ready Ready Preview, Comment Dec 23, 2025 2:08pm

Implement proper SIGTERM/SIGINT signal handlers to gracefully shut down
the UDL server, ensuring in-flight requests complete before exit and
resources are properly cleaned up.

- Add shutdown state management module
- Handle SIGTERM and SIGINT signals
- Complete in-flight requests before closing server
- Return 503 on /ready endpoint during shutdown
- Configurable grace period (default: 30 seconds)
- Clean up file watchers and debounce timers on shutdown
- Log shutdown progress to console

Closes #59
Extend SourceNodesContext with registerWebhook function, allowing plugins
to register webhook handlers for receiving payloads from external sources.

- Add WebhookRegistry class for managing webhook handlers
- Add WebhookRegistration, WebhookHandler, WebhookHandlerContext types
- Extend loadPlugins and loadConfigFile to pass registerWebhook to plugins
- Export webhook utilities from core package
- Add comprehensive unit tests (33 tests)

Closes #60
Add HTTP routing for incoming webhooks at POST /_webhooks/:plugin/:path.
Routes webhook payloads to the correct plugin handler registered via
the webhook registry.

Features:
- Route webhooks based on plugin name and path from URL
- Validate HTTP method (only POST allowed, returns 405 otherwise)
- Collect raw request body for signature verification
- Parse JSON body when content-type is application/json
- Provide WebhookHandlerContext with store, actions, rawBody, and body
- Enforce 1MB body size limit to prevent abuse (returns 413)
- Return appropriate HTTP status codes (405, 404, 401, 400, 500)
- Log webhook activity for debugging

Closes #61
Implement webhook queue system that batches incoming webhooks and
processes them after a configurable debounce period. This prevents
N rapid webhook events from triggering N separate processing cycles.

Features:
- WebhookQueue class with configurable debounceMs (default 5s)
- Maximum queue size before forced processing (default 100)
- Lifecycle hooks for custom processing:
  - onWebhookReceived: transform/filter webhooks before queuing
  - onBeforeWebhookTriggered: run before batch (e.g., cache invalidation)
  - onAfterWebhookTriggered: run after batch (e.g., trigger rebuild)
- Graceful shutdown flushes pending webhooks
- Config via remote.webhooks.debounceMs, hooks, maxQueueSize

Breaking changes:
- Webhook HTTP response changed from 200 to 202 Accepted
- Webhooks processed asynchronously after debounce period

Closes #62
Add OutboundWebhookManager to notify external systems (e.g., Vercel deploy
hooks, CI systems) after webhook batches are processed. This enables the
"30 webhooks → 1 build" optimization.

Features:
- Configurable outbound endpoints via remote.webhooks.trigger
- Retry logic with exponential backoff (default: 3 retries)
- Custom headers support for authentication
- Parallel triggering to multiple endpoints
- Payload includes batch summary (webhookCount, plugins, timestamp)
@dawidurbanski dawidurbanski changed the title feat(core): add health check endpoints for production deployments feat(core): UDL as Production Data Layer (Epic #57) Dec 22, 2025
Add a default webhook handler system that provides a standardized endpoint
for create/update/delete/upsert operations on nodes. This enables external
systems to sync data with UDL using a consistent payload format.

Key features:
- DefaultWebhookPayload type for standardized request format
- createDefaultWebhookHandler() factory with idField support for custom lookups
- registerDefaultWebhook/registerDefaultWebhooks utilities for auto-registration
- Per-plugin configuration with path overrides and disable options
- Handles both string and numeric ID lookups for flexibility

The handler supports looking up existing nodes by a custom idField (e.g.,
externalId, contentfulId) instead of internal IDs, enabling seamless
integration with external data sources.
Integrate the default webhook handler system into the plugin loading process:

- Add `idField` config option for plugins to specify their external ID field
- Auto-register default webhook handlers for plugins when `defaultWebhook`
  config is enabled
- Automatically index the idField for O(1) lookups
- Update UDLConfig interface with defaultWebhook configuration
- Re-export default webhook types and functions from core package

When a plugin specifies an idField (e.g., 'contentfulId'), the default
webhook handler uses this field to look up existing nodes, enabling
external systems to reference nodes by their source IDs.
Add MSW mock handlers for testing the default webhook functionality
with a remote todo API simulation. Includes manual test feature for
verifying webhook-based CRUD operations in development mode.
Add GET /_sync endpoint that enables clients to query for changes since
a given timestamp. Returns updated nodes and deleted node IDs for
efficient partial sync operations.

- Add defaultDeletionLog singleton following defaultStore pattern
- Create sync handler with SyncResponse interface
- Register /_sync route in server
- Export SyncResponse, defaultDeletionLog, setDefaultDeletionLog
- Add 23 unit tests and 10 integration tests

Closes #65
Implement an opt-in WebSocket server that broadcasts node changes to
connected clients immediately when nodes are created, updated, or deleted.

- Add WebSocketConfig to RemoteConfig with enabled, port, path, and
  heartbeatIntervalMs options
- Create node event emitter that fires on createNode, deleteNode, and
  extendNode actions
- Implement UDLWebSocketServer class with subscription filtering,
  heartbeat for connection health, and graceful shutdown
- Integrate WebSocket server initialization in start-server.ts
- Add 31 unit and integration tests for events and WebSocket server

Clients can subscribe to specific node types or all types (*) and
receive full node data in real-time, enabling local dev machines to
receive updates immediately when webhooks modify the data layer.

Closes #66
Add changeset documentation for:
- WebSocket server for real-time node change notifications
- Sync query API for partial updates
- Default webhook handler for standardized CRUD operations
Add `remote.url` config option that allows local UDL servers to sync
data from a remote production server instead of sourcing from plugins.

Features:
- Fetches all nodes from remote /_sync endpoint on startup
- Connects to remote WebSocket for real-time updates (if enabled)
- Gracefully handles WebSocket unavailability
- Skips local plugin loading when remote.url is configured

New files:
- packages/core/src/websocket/client.ts - WebSocket client
- packages/core/src/sync/remote.ts - Remote sync logic

New exports:
- UDLWebSocketClient, WebSocketClientConfig
- fetchRemoteNodes, tryConnectRemoteWebSocket, initRemoteSync

Documentation:
- Added deployment section with production, standalone, webhook, and
  remote sync guides
When remote.url points to the same host:port as the local server,
UDL now detects this and loads plugins instead of syncing from remote.

This allows sharing the same config file between production and local
development - production will source from plugins while local dev
will sync from the remote production server.

Self-detection handles:
- Exact host:port matches
- Local host aliases (localhost, 127.0.0.1, 0.0.0.0)
The previous approach compared host:port which didn't work for shared
configs where both production and local dev use the same URL.

New approach: check if the remote server is actually reachable.
- If reachable → sync from remote (we are local dev)
- If not reachable → load plugins (we are production)

This correctly handles:
- Same machine, same port (production starts first)
- Same machine, different ports
- Multi-tenant localhost setups
- Remote production servers
Let the config file determine the default port instead of hardcoding
it in CLI args and adapter commands. Port flag is now only passed
when explicitly specified by the user.
Introduce a simplified webhook registration system that allows plugins
to register handlers at convention-based URLs (/_webhooks/{plugin}/sync).

- WebhookRegistry class with register/getHandler/has methods
- WebhookRegistration interface with handler, description, verifySignature
- SignatureVerifier type for optional request signature validation
- Default singleton registry with setDefaultWebhookRegistry for testing
Route webhooks to handlers using convention-based URLs:
POST /_webhooks/{plugin-name}/sync

- isWebhookRequest() and getPluginFromWebhookUrl() for URL parsing
- Signature verification before queuing (returns 401 if invalid)
- JSON body parsing with 400 error for invalid JSON
- Request body collection with 1MB limit
- Integration tests for full webhook routing flow
Provide a built-in webhook handler that processes standardized payloads
for node CRUD operations, automatically registered for plugins.

- DefaultWebhookPayload with create/update/delete/upsert operations
- createDefaultWebhookHandler factory with idField support
- registerDefaultWebhook for auto-registration during plugin loading
- registerPluginWebhookHandler for custom plugin handlers
- Integration with webhook processor for batch execution
Trigger outbound webhooks after batch processing to notify external
systems (Vercel deploy hooks, CI) with the "30 webhooks → 1 build"
optimization.

- OutboundWebhookManager for parallel webhook triggering
- Configurable via remote.webhooks.outbound array
- HTTP method option (POST/GET, default POST)
- transformPayload callback for custom payload shaping
- Retry logic with exponential backoff (3 retries, 1s base delay)
- Default payload includes items array with webhook details
Update dev and start command tests to reflect that UDL now uses its
own default port from config rather than requiring explicit --port flag.
- Add unit tests for webhook processor, outbound, queue, and default handler
- Add websocket server and client unit tests with coverage ignore markers
- Add remote sync unit tests
- Add node actions unit tests
- Expand integration tests for loader
- Add coverage ignore comments for unreachable code paths
Introduces CacheManager module that provides a central point for
coordinating plugin cache updates. This enables webhook handlers
and remote sync to persist changes to disk after store modifications.

- registerPluginCache/initPluginCache for cache registration
- savePluginCache for saving individual plugin caches
- saveAffectedPlugins for batch webhook processing
- replaceAllCaches for remote sync persistence
getConfig() now checks for UDL_ENDPOINT env var when config hasn't
been explicitly initialized. This allows udl.query() client to
automatically use the correct endpoint in child processes.

- Export DEFAULT_UDL_PORT and UDL_ENDPOINT_ENV constants
- Add isConfigInitialized() to check if config was explicitly set
- Add resetConfig() for testing isolation
Enable instant webhook relay from production to local UDL instances
via WebSocket, eliminating wait time for batch debounce.

Server:
- Add broadcastWebhookReceived() method
- Add WebhookReceivedMessage type

Client:
- Add onWebhookReceived callback for instant webhook processing
- Handle webhook:received message type
- Skip node events when processing webhooks locally (avoid double processing)
- Save plugin cache after node updates

Export WebhookReceivedEvent interface for callback typing.
Wire up the instant webhook relay feature across remote sync,
loader, and start-server for complete local-first UDL workflow.

Remote sync:
- Cache fetched nodes via replaceAllCaches() for offline support
- Support onWebhookReceived callback for instant webhook processing

Loader:
- Use cache manager for plugin cache operations
- Add isLocal option to skip sourceNodes (data from remote)
- Initialize plugin caches even in local mode for persistence

Start-server:
- Set UDL_ENDPOINT env var for child processes
- Load plugins in local mode for webhook handler registration
- Process relayed webhooks locally using registered handlers
- Save affected plugin caches after webhook batch processing
- Wire up webhook:queued event to WebSocket broadcast
Emit webhook:queued event immediately when a webhook is queued,
before the debounce timer. This enables instant relay to WebSocket
subscribers without waiting for batch processing.
Add shared config utilities and update commands to read port from
udl.config.ts and inject UDL_ENDPOINT env var to Next.js processes.

New utils/config.ts:
- loadPortFromConfig() reads port from udl.config.ts/.js/.mjs
- resolveUdlPort() with priority: CLI > config > default
- createNextEnv() builds env with UDL_ENDPOINT set

Commands updated:
- dev: resolve port from config, inject UDL_ENDPOINT to Next.js
- build: resolve port from config, inject UDL_ENDPOINT to Next.js
- start: resolve port from config, inject UDL_ENDPOINT to Next.js

This allows udl.query() to automatically use the correct endpoint
in Next.js code without manual configuration.
Core tests:
- cache/manager.test.ts: cache manager registration and operations
- config.test.ts: UDL_ENDPOINT env var, resetConfig, isConfigInitialized
- sync/remote.test.ts: onWebhookReceived callback, cache persistence
- webhooks/queue.test.ts: webhook:queued event emission
- websocket/client.test.ts: webhook:received handling, onWebhookReceived
- websocket/server.test.ts: broadcastWebhookReceived
- loader.test.ts: isLocal option, cache manager integration

Adapter-nextjs tests:
- utils/config.test.ts: loadPortFromConfig, resolveUdlPort
- dev.test.ts: config loading, UDL_ENDPOINT injection
- build.test.ts: config loading, UDL_ENDPOINT injection
- start.test.ts: config loading, UDL_ENDPOINT injection
Add `updateStrategy` config option that allows plugins to specify how
incremental updates from webhooks should be handled:

- `'webhook'` (default): Process webhook payload directly via handler
- `'sync'`: Treat webhooks as notifications and re-run sourceNodes

This enables plugins with native sync APIs (like Contentful) to reuse
their existing sourceNodes logic for incremental updates, rather than
maintaining separate webhook transformation code.

Key changes:
- Add UpdateStrategy type and config option to UDLConfig
- Create PluginRegistry to store sourceNodes references
- Modify webhook processor to group by strategy and call sourceNodes
  once per sync-strategy plugin (not once per webhook)
- Update Contentful plugin to use updateStrategy: 'sync'
- Warn if both updateStrategy: 'sync' and registerWebhookHandler set
Add comprehensive tests for the new updateStrategy feature:

- Add registry.test.ts with 100% coverage for PluginRegistry class
- Add tests for updateStrategy: 'sync' in loader.test.ts
  - Verifies sync plugins register default webhook handler
  - Verifies warning when both sync strategy and custom handler provided
- Add tests for sync strategy in processor.test.ts
  - Tests invokeSourceNodes function (success, errors, missing plugin)
  - Tests deduplication (multiple webhooks -> one sync)
  - Tests mixed strategies (sync + webhook plugins in same batch)

All target files now have 100% test coverage.
Update webhook URL pattern to properly extract scoped package names like
@universal-data-layer/plugin-source-contentful. Add URL decoding to handle
encoded @ symbols (%40) in webhook paths.
Restructure cache directory to use `.udl/` as a parent directory for
all internal UDL files. This allows future expansion for storing other
library internals (logs, state, etc.) while keeping a single directory
to gitignore.

- Update FileCacheStorage to use .udl/cache/nodes.json
- Update FileSyncTokenStorage to use .udl/cache/contentful-sync-tokens.json
- Update all gitignore, prettierignore, and eslint ignore patterns
- Update documentation to reflect new paths
- Update all related tests
@dawidurbanski dawidurbanski merged commit 87b4f19 into main Dec 23, 2025
5 checks passed
@dawidurbanski dawidurbanski deleted the 57-epic-udl-as-production-data-layer branch December 23, 2025 14:10
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.

[Epic]: UDL as Production Data Layer

1 participant