Backend-agnostic scheduling adapter hub. Currently bridges Acuity Scheduling via Playwright browser automation, with architecture designed to support additional scheduling backends.
Formerly
acuity-middleware. Historical GitHub URLs may redirect, but the canonical repo isJesssullivan/scheduling-bridge.
An HTTP server wrapping Playwright wizard flows that automate the Acuity booking UI. The bridge uses Effect TS for resource lifecycle management (browser/page acquisition and release).
HTTP Request
-> server/handler.ts (route matching, auth, JSON serialization)
-> acuity-service-catalog.ts (static env catalog -> BUSINESS -> scraper fallback)
-> steps/ (Effect TS programs for each wizard stage)
-> browser-service.ts (Playwright lifecycle via Effect Layer)
-> selectors.ts (CSS selector registry with fallback chains)
- server/handler.ts -- Standalone Node.js HTTP server with Bearer token auth
- acuity-service-catalog.ts -- Shared service source order and cache for static config, BUSINESS extraction, and scraper fallback
- browser-service.ts -- Effect TS Layers for a warm shared browser process plus request-scoped page sessions
- acuity-wizard.ts -- Full
SchedulingAdapterimplementation (local Playwright or remote HTTP proxy) - remote-adapter.ts -- HTTP client adapter for proxying to a remote middleware instance
- selectors.ts -- Single source of truth for all Acuity DOM selectors
- steps/ -- Individual wizard step programs plus BUSINESS extraction helpers
- acuity-scraper.ts -- Deprecated read fallback for services, dates, and time slots
| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check (no auth required) |
| GET | /services |
List appointment types via SERVICES_JSON -> BUSINESS -> scraper fallback |
| GET | /services/:id |
Get a specific service |
| POST | /availability/dates |
Available dates for a service |
| POST | /availability/slots |
Time slots for a specific date |
| POST | /availability/check |
Check if a slot is available |
| POST | /availability/refresh |
Enqueue async availability refresh |
| GET | /availability/snapshot |
Read latest durable availability snapshot |
| GET | /internal/availability/snapshot-canary |
Auth-gated durable snapshot layer proof |
| POST | /internal/availability/heartbeat |
Auth-gated bounded availability refresh heartbeat |
| POST | /internal/availability/readiness |
Auth-gated read-only snapshot/queue readiness check |
| POST | /internal/availability/wait-ready |
Auth-gated bounded heartbeat + readiness wait for deploy gates |
| POST | /booking/create |
Create a booking (standard) |
| POST | /booking/create-with-payment |
Deprecated sync paid booking endpoint; returns 410 ASYNC_REQUIRED |
| POST | /booking/jobs |
Enqueue async paid booking job |
| GET | /jobs/:operationId |
Read async job status |
Availability date/slot request handlers are snapshot-first after Redis read-cache misses: a fresh durable snapshot returns immediately, a stale-but-not-expired snapshot returns immediately and queues an async refresh, and an expired/missing snapshot falls through to the Acuity read path. Serving a stale snapshot does not re-stamp it as freshly observed; only successful Acuity reads or worker refresh jobs advance snapshot freshness.
GET /internal/availability/snapshot-canary?kind=dates|slots&serviceId=...&scope=...
is an operator canary for K8s/runtime proof. It is hidden unless AUTH_TOKEN
is configured, requires bearer auth when enabled, bypasses the Redis read cache,
reads the durable snapshot store directly, and returns only metadata, count, and
duration. Successful canary hits increment
acuity_availability_snapshot_served_total and
acuity_availability_snapshot_read_duration_seconds, so operators can prove the
bridge snapshot layer separately from app Redis hits and bridge Redis hits.
POST /internal/availability/heartbeat is the operator/cron entrypoint for
queue-driven availability refresh. It is hidden unless AUTH_TOKEN is
configured and requires bearer auth when enabled. The request body contains
weighted demand:
{
"maxJobs": 12,
"idempotencyWindowMs": 300000,
"demands": [
{
"serviceId": "53178494",
"serviceName": "TMD single session",
"weight": 10,
"months": ["2026-06", "2026-07"],
"dates": ["2026-06-15"]
}
]
}The heartbeat uses weighted fairness across service/request groups before
maxJobs is applied. A higher weight biases additional work toward that
demand, but every active demand group receives early representation before one
high-weight service can consume the whole enqueue budget. Equal-weight demand is
round-robin interleaved by request order. The handler skips fresh durable
snapshots, enqueues stale/expired/missing date and slot refresh jobs up to
maxJobs, and uses a time-windowed idempotency key so frequent cron runs do not
create duplicate job storms. It does not run browser automation on the HTTP
request path; the async worker owns the Acuity read. If an idempotency key
resolves to a retryable failed job, heartbeat requeues that existing operation
before reporting it as work; non-retryable terminal jobs are reported under
skipped instead of masquerading as newly enqueued refreshes.
Bridge 0.5.13 supports bounded worker drain concurrency through
BRIDGE_WORKER_CONCURRENCY. The package default remains 1; the MassageIthaca
K8s deployment currently opts into 2 through Blahaj/OpenTofu after proving the
datepicker readiness gate remains green.
Readiness and queue stats are related but not identical. A scoped datepicker
readiness check can be green while historical retryable refresh failures remain
in the async store until the store TTL expires. Live K8s sampling after the
0.5.9 rollout found fresh datepicker snapshots and no runnable backlog, but
also retained failed refresh records from transient browser/network failures:
NETWORK/PAGE_FAILEDon date and slot refresh jobsSCRAPE_FAILEDcalendar-load timeouts on date refresh jobs
Track this as queue-hygiene work, not a regression of the sustained datepicker
gate. Jesssullivan/scheduling-bridge#129 owns the next package pass: failed
refresh observability, retention/TTL configuration, retry/stat semantics, and
tests.
POST /internal/availability/readiness is the operator read path for
cutover/deploy proof. It accepts the same demand shape as heartbeat, does not
enqueue jobs, and returns 200 when every requested date/slot scope has a
snapshot newer than the configured freshness floor and the async queue is
healthy. It returns 409 with explicit blockers when any scope is missing,
stale, expired, retryable-failed, or when the oldest runnable queue item is too
old. Defaults are snapshotFreshnessFloorMs=90000 and
maxOldestQueuedAgeMs=120000.
POST /internal/availability/wait-ready is the bounded deploy/operator action.
It runs the existing heartbeat enqueue/requeue logic once, then polls the
read-only readiness evaluator until ready or timeout. It never runs Acuity
browser automation in the HTTP request; workers still own Acuity reads. Defaults
are timeoutMs=60000 and pollMs=1000.
GET /health is the stable downstream runtime-truth surface.
In addition to basic runtime data, it now publishes:
- release tuple:
releaseShareleaseRefreleaseVersionreleaseBuiltAt- nested
release.{ sha, ref, version, builtAt, modalEnvironment }
- protocol tuple:
protocolVersion- nested
protocol.version protocol.flowOwner = "scheduling-bridge"protocol.backend = "acuity"protocol.transport = "http-json"protocol.endpointsprotocol.capabilities
Downstream apps should use this tuple to assert which bridge release and protocol surface they are talking to during beta validation and rollout claims.
This tuple is the supported runtime truth surface for adopters. Downstream apps
should not infer bridge ownership from package metadata, branch names, or Modal
dashboard state when /health is available.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 3001 |
Server port |
ACUITY_BASE_URL |
No | https://example.as.me |
Acuity scheduling page URL |
BRIDGE_DATABASE_URL |
For strict async runtime | -- | Postgres queue/snapshot store for async jobs; takes precedence over Redis |
BRIDGE_DATABASE_SSL |
No | false |
Enable SSL for BRIDGE_DATABASE_URL |
BRIDGE_DATABASE_MIGRATE |
No | true |
Run async queue/snapshot schema creation at startup |
REDIS_URL |
For K8s read cache / Redis async runtime | -- | Redis read cache plus async queue/snapshot store when BRIDGE_DATABASE_URL is unset |
REDIS_PASSWORD |
No | -- | Password for REDIS_URL |
BRIDGE_REDIS_ASYNC_PREFIX |
No | bridge-async:v1 |
Redis key prefix for async jobs and snapshots |
BRIDGE_REDIS_ASYNC_JOB_TTL_SECONDS |
No | 604800 |
Redis TTL for async job and idempotency records; lower in K8s if historical retryable refresh failures make queue stats noisy |
BRIDGE_INLINE_WORKER_ENABLED |
No | true when Postgres or Redis is configured |
Drain async jobs inside the HTTP container; set false only when a separate worker deployment is active |
BRIDGE_WORKER_POLL_MS |
No | 1000 |
Worker queue poll interval |
BRIDGE_WORKER_BATCH_SIZE |
No | 5 |
Maximum jobs drained per worker poll |
BRIDGE_WORKER_CONCURRENCY |
No | 1 |
Maximum jobs executed concurrently within one worker poll; raise only when browser/page limiter and queue metrics support it |
BRIDGE_SNAPSHOT_STALE_MS |
No | 300000 |
Age after which a durable availability snapshot is served stale and refresh is queued |
BRIDGE_SNAPSHOT_EXPIRES_MS |
No | 1800000 |
Age after which a durable availability snapshot is ignored and a live Acuity read is required |
BRIDGE_HEARTBEAT_MAX_JOBS |
No | 12 |
Default max refresh jobs enqueued by one internal heartbeat request; request values are capped at 100 |
BRIDGE_HEARTBEAT_IDEMPOTENCY_WINDOW_MS |
No | 300000 |
Default time bucket for heartbeat idempotency keys |
BRIDGE_READINESS_FRESHNESS_FLOOR_MS |
No | 90000 |
Default required snapshot freshness for internal readiness gates |
BRIDGE_READINESS_MAX_OLDEST_QUEUED_AGE_MS |
No | 120000 |
Default maximum oldest runnable queue age before readiness fails |
BRIDGE_READINESS_WAIT_TIMEOUT_MS |
No | 60000 |
Default timeout for /internal/availability/wait-ready |
BRIDGE_READINESS_WAIT_POLL_MS |
No | 1000 |
Default poll interval for /internal/availability/wait-ready |
AUTH_TOKEN |
Recommended | -- | Bearer token for all endpoints (except /health) |
ACUITY_BYPASS_COUPON |
For payment bypass | -- | 100% gift certificate code |
PLAYWRIGHT_HEADLESS |
No | true |
Run browser headless |
PLAYWRIGHT_TIMEOUT |
No | 30000 |
Page operation timeout (ms) |
CHROMIUM_EXECUTABLE_PATH |
No | -- | Custom Chromium path (for Lambda/serverless) |
CHROMIUM_LAUNCH_ARGS |
No | -- | Comma-separated Chromium args |
SERVICES_JSON |
No | -- | Optional static service catalog to bypass live Acuity reads |
ACUITY_SERVICE_CACHE_TTL_MS |
No | 300000 |
TTL for cached live service catalogs before BUSINESS/scraper refresh |
ACUITY_URL_READ_NETWORK_IDLE_MS |
No | 1500 |
Bounded post-navigation network-idle settle for direct URL availability reads; set 0 to skip |
ACUITY_DATE_PREWARM_MONTHS |
No | 1 |
Number of future months queued for async date refresh after a successful date read; max 3, set 0 to disable |
ACUITY_SLOT_PREWARM_LIMIT |
No | 1 |
Number of first available dates to warm in the slots cache after a successful Acuity dates read; max 3, set 0 to disable |
SCHEDULING_BRIDGE_SLOT_PROFILE_THRESHOLD_MS |
No | 1500 |
Threshold in ms for logging long-tail slot-read profile events |
SCHEDULING_BRIDGE_PROFILE_SLOT_READS |
No | false |
Force logging of slot-read profile events even when under threshold |
MIDDLEWARE_RELEASE_SHA |
No | -- | Release commit SHA exposed via /health |
MIDDLEWARE_RELEASE_REF |
No | -- | Release ref/tag exposed via /health |
MIDDLEWARE_RELEASE_VERSION |
No | -- | Release version exposed via /health |
MIDDLEWARE_RELEASE_BUILT_AT |
No | -- | Build timestamp exposed via /health |
MIDDLEWARE_BUILD_TIMESTAMP |
No | -- | Legacy fallback build timestamp for /health |
The bridge emits NDJSON logs to stdout/stderr for runtime analysis.
/healthremains the authoritative runtime-truth surface for downstream apps- request handlers emit request-scoped structured events, including
requestId - long-tail slot reads emit
slot_read_profileevents with phase timings SCHEDULING_BRIDGE_PROFILE_SLOT_READS=1forces profile emission for all slot reads- internal readiness emits queue depth, oldest queue age, snapshot age, readiness result, and per-scope freshness metrics
The stable bridge contract is the Node HTTP server, protocol surface, and
/health tuple. Provider names are deployment details, not the consumer
contract.
- Accepted next-production route: K8s/container runtime managed from infrastructure.
- Legacy proofing provider: Modal. Automatic Modal deploys are disabled; the manual workflow is retained only for deliberate decommissioning or forensic fallback while TIN-981 closes the surface.
- Compatibility target: Docker image with the same
dist/server/handler.jsentrypoint. - Consumer apps should configure the remote bridge with
SCHEDULING_BRIDGE_URLandSCHEDULING_BRIDGE_AUTH_TOKEN; legacyMODAL_*names are transition aliases in consumer/infra repos.
The npm package supports active downstream consumer runtimes on Node 22 and Node 24. CI validates the host test suite on both majors.
The bridge-owned Bazel toolchain, Nix dev shell, Docker/K8s image, and publish workflow intentionally stay on Node 24. Downstream apps should not infer that they must also run Node 24 unless they deploy the bridge runtime itself.
pnpm install
pnpm dev # Development with tsx against src/server/handler.ts
# or
pnpm build && pnpm start # Materialize Bazel-derived dist/ and start itdocker build -t scheduling-bridge .
docker run -p 3001:3001 \
-e AUTH_TOKEN=your-secret-token \
-e ACUITY_BASE_URL=https://YourBusiness.as.me \
-e ACUITY_BYPASS_COUPON=your-coupon-code \
scheduling-bridge# Automatic Modal deploys are disabled. The manual workflow requires an
# explicit legacy-modal acknowledgement and should only be used for
# decommissioning or forensic fallback while TIN-981 is open.
# The Modal workflow materializes the Bazel-derived pkg/ before deploy.
modal deploy modal-app.pyThe Modal deployment path is legacy-only:
- manually dispatch
.github/workflows/deploy-modal.yml - type
legacy-modalinacknowledge_legacy_modal - inject
MIDDLEWARE_RELEASE_SHA,MIDDLEWARE_RELEASE_REF,MIDDLEWARE_RELEASE_VERSION, andMIDDLEWARE_RELEASE_BUILT_AT - verify the resulting bridge tuple via
GET /health
Operationally, this means:
- Modal deployment is not automatic release truth
- the live bridge should be identified by the
/healthrelease + protocol tuple - downstream apps should validate the tuple they expect before making rollout claims
nix develop # Enter dev shell with Node.js + Playwright
pnpm install
pnpm devCurrent release authority:
- canonical repo:
Jesssullivan/scheduling-bridge - npm package:
@tummycrypt/scheduling-bridge - GitHub Packages mirror:
@jesssullivan/scheduling-bridge
The current publish + deploy shape is:
- release metadata declared once
- Bazel validates/builds the publishable artifact
- CI dry-runs the extracted Bazel package surface before release
- GitHub Actions publishes that extracted artifact
- infrastructure can deploy the K8s/container runtime from the same package artifact and image entrypoint
- downstream apps consume the published package and verify the live runtime
tuple via
/health
This repo is the sole owner of Acuity automation concerns. App repos and shared packages may consume the bridge and assert its runtime tuple, but they should not duplicate bridge runtime ownership or release truth logic.
Package CI and publish currently use the shared js-bazel-package workflow with
runner_mode: shared and publish_mode: same_runner.
The concrete shared-runner labels come from repository Actions variables and must be proven by green workflow runs before they are treated as operational truth. Keep private runner topology and apply details out of this public repo.
pnpm install # Install dependencies
pnpm dev # Start dev server with tsx
pnpm typecheck # Run Bazel typecheck target
pnpm build # Materialize local pkg/ and dist/ from bazel-bin/pkg
pnpm test # Run Bazel test target
pnpm docs:generateMIT