@tummycrypt/acuity-middleware 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-paper.yml +39 -0
- package/.github/workflows/ci.yml +37 -0
- package/Dockerfile +53 -0
- package/README.md +103 -0
- package/docs/blog-post.mdx +240 -0
- package/docs/paper/IEEEtran.bst +2409 -0
- package/docs/paper/IEEEtran.cls +6347 -0
- package/docs/paper/acuity-middleware-paper.tex +375 -0
- package/docs/paper/balance.sty +87 -0
- package/docs/paper/references.bib +231 -0
- package/docs/paper.md +400 -0
- package/flake.nix +32 -0
- package/modal-app.py +82 -0
- package/package.json +48 -0
- package/src/adapters/acuity-scraper.ts +543 -0
- package/src/adapters/types.ts +193 -0
- package/src/core/types.ts +325 -0
- package/src/index.ts +75 -0
- package/src/middleware/acuity-wizard.ts +456 -0
- package/src/middleware/browser-service.ts +183 -0
- package/src/middleware/errors.ts +70 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/remote-adapter.ts +246 -0
- package/src/middleware/selectors.ts +308 -0
- package/src/middleware/server.ts +372 -0
- package/src/middleware/steps/bypass-payment.ts +226 -0
- package/src/middleware/steps/extract.ts +174 -0
- package/src/middleware/steps/fill-form.ts +359 -0
- package/src/middleware/steps/index.ts +27 -0
- package/src/middleware/steps/navigate.ts +537 -0
- package/src/middleware/steps/read-availability.ts +399 -0
- package/src/middleware/steps/read-slots.ts +405 -0
- package/src/middleware/steps/submit.ts +168 -0
- package/src/server.ts +5 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Build LaTeX Paper
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
paths:
|
|
6
|
+
- 'docs/paper/**'
|
|
7
|
+
pull_request:
|
|
8
|
+
paths:
|
|
9
|
+
- 'docs/paper/**'
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build-pdf:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Setup Tectonic
|
|
19
|
+
uses: wtfjoke/setup-tectonic@v3
|
|
20
|
+
with:
|
|
21
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
22
|
+
|
|
23
|
+
- name: Build paper PDF
|
|
24
|
+
run: |
|
|
25
|
+
cd docs/paper
|
|
26
|
+
tectonic acuity-middleware-paper.tex
|
|
27
|
+
|
|
28
|
+
- name: Upload PDF artifact
|
|
29
|
+
uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: acuity-middleware-paper
|
|
32
|
+
path: docs/paper/acuity-middleware-paper.pdf
|
|
33
|
+
|
|
34
|
+
- name: Commit PDF (on push to main)
|
|
35
|
+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
36
|
+
uses: stefanzweifel/git-auto-commit-action@v5
|
|
37
|
+
with:
|
|
38
|
+
commit_message: "chore: rebuild acuity-middleware-paper.pdf"
|
|
39
|
+
file_pattern: docs/paper/acuity-middleware-paper.pdf
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
node-version: [20, 22]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install pnpm
|
|
20
|
+
uses: pnpm/action-setup@v4
|
|
21
|
+
with:
|
|
22
|
+
version: 9
|
|
23
|
+
|
|
24
|
+
- name: Setup Node.js ${{ matrix.node-version }}
|
|
25
|
+
uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: ${{ matrix.node-version }}
|
|
28
|
+
cache: pnpm
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: pnpm install --no-frozen-lockfile
|
|
32
|
+
|
|
33
|
+
- name: Typecheck
|
|
34
|
+
run: pnpm typecheck
|
|
35
|
+
|
|
36
|
+
- name: Build
|
|
37
|
+
run: pnpm build
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Dockerfile
|
|
2
|
+
# Standalone middleware server with Playwright + Chromium
|
|
3
|
+
# for running the Acuity wizard automation remotely.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# docker build -t acuity-middleware .
|
|
7
|
+
# docker run -p 3001:3001 \
|
|
8
|
+
# -e AUTH_TOKEN=... \
|
|
9
|
+
# -e ACUITY_BASE_URL=https://MassageIthaca.as.me \
|
|
10
|
+
# -e ACUITY_BYPASS_COUPON=... \
|
|
11
|
+
# acuity-middleware
|
|
12
|
+
#
|
|
13
|
+
# Modal Labs:
|
|
14
|
+
# modal deploy modal-app.py
|
|
15
|
+
|
|
16
|
+
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
|
17
|
+
|
|
18
|
+
# Install Node.js 22 LTS + pnpm
|
|
19
|
+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
|
20
|
+
apt-get install -y nodejs && \
|
|
21
|
+
corepack enable && corepack prepare pnpm@9.15.9 --activate && \
|
|
22
|
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
23
|
+
|
|
24
|
+
WORKDIR /app
|
|
25
|
+
|
|
26
|
+
# Copy package files for dependency install
|
|
27
|
+
COPY package.json ./
|
|
28
|
+
|
|
29
|
+
# Install production dependencies only (playwright comes from base image)
|
|
30
|
+
RUN pnpm install --no-frozen-lockfile --prod
|
|
31
|
+
|
|
32
|
+
# Copy source
|
|
33
|
+
COPY src/ ./src/
|
|
34
|
+
COPY tsconfig.json ./
|
|
35
|
+
|
|
36
|
+
# Pre-compile with tsx for faster startup
|
|
37
|
+
RUN pnpm add tsx
|
|
38
|
+
|
|
39
|
+
# Non-root user for security
|
|
40
|
+
RUN useradd -m -s /bin/bash middleware
|
|
41
|
+
USER middleware
|
|
42
|
+
|
|
43
|
+
EXPOSE 3001
|
|
44
|
+
|
|
45
|
+
ENV NODE_ENV=production
|
|
46
|
+
ENV PORT=3001
|
|
47
|
+
ENV PLAYWRIGHT_HEADLESS=true
|
|
48
|
+
ENV PLAYWRIGHT_TIMEOUT=30000
|
|
49
|
+
|
|
50
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
51
|
+
CMD node -e "require('http').get('http://localhost:3001/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
|
|
52
|
+
|
|
53
|
+
CMD ["node", "--import", "tsx/esm", "src/middleware/server.ts"]
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# acuity-middleware
|
|
2
|
+
|
|
3
|
+
Playwright-based Acuity Scheduling booking middleware. Proxies booking operations through browser automation, enabling programmatic access to Acuity's scheduling wizard without API access.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
An HTTP server wrapping Playwright wizard flows that automate the Acuity booking UI. The middleware uses Effect TS for resource lifecycle management (browser/page acquisition and release) and fp-ts for composable error handling.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
HTTP Request
|
|
11
|
+
-> server.ts (route matching, auth, JSON serialization)
|
|
12
|
+
-> steps/ (Effect TS programs for each wizard stage)
|
|
13
|
+
-> browser-service.ts (Playwright lifecycle via Effect Layer)
|
|
14
|
+
-> selectors.ts (CSS selector registry with fallback chains)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Key Components
|
|
18
|
+
|
|
19
|
+
- **server.ts** -- Standalone Node.js HTTP server with Bearer token auth
|
|
20
|
+
- **browser-service.ts** -- Effect TS Layer managing Playwright browser/page lifecycle
|
|
21
|
+
- **acuity-wizard.ts** -- Full `SchedulingAdapter` implementation (local Playwright or remote HTTP proxy)
|
|
22
|
+
- **remote-adapter.ts** -- HTTP client adapter for proxying to a remote middleware instance
|
|
23
|
+
- **selectors.ts** -- Single source of truth for all Acuity DOM selectors
|
|
24
|
+
- **steps/** -- Individual wizard step programs (navigate, fill-form, bypass-payment, submit, extract)
|
|
25
|
+
- **acuity-scraper.ts** -- Read-only scraper for services, dates, and time slots
|
|
26
|
+
|
|
27
|
+
## Endpoints
|
|
28
|
+
|
|
29
|
+
| Method | Path | Description |
|
|
30
|
+
|--------|------|-------------|
|
|
31
|
+
| GET | `/health` | Health check (no auth required) |
|
|
32
|
+
| GET | `/services` | List all appointment types |
|
|
33
|
+
| GET | `/services/:id` | Get a specific service |
|
|
34
|
+
| POST | `/availability/dates` | Available dates for a service |
|
|
35
|
+
| POST | `/availability/slots` | Time slots for a specific date |
|
|
36
|
+
| POST | `/availability/check` | Check if a slot is available |
|
|
37
|
+
| POST | `/booking/create` | Create a booking (standard) |
|
|
38
|
+
| POST | `/booking/create-with-payment` | Create booking with payment bypass (coupon) |
|
|
39
|
+
|
|
40
|
+
## Environment Variables
|
|
41
|
+
|
|
42
|
+
| Variable | Required | Default | Description |
|
|
43
|
+
|----------|----------|---------|-------------|
|
|
44
|
+
| `PORT` | No | `3001` | Server port |
|
|
45
|
+
| `ACUITY_BASE_URL` | No | `https://MassageIthaca.as.me` | Acuity scheduling page URL |
|
|
46
|
+
| `AUTH_TOKEN` | Recommended | -- | Bearer token for all endpoints (except /health) |
|
|
47
|
+
| `ACUITY_BYPASS_COUPON` | For payment bypass | -- | 100% gift certificate code |
|
|
48
|
+
| `PLAYWRIGHT_HEADLESS` | No | `true` | Run browser headless |
|
|
49
|
+
| `PLAYWRIGHT_TIMEOUT` | No | `30000` | Page operation timeout (ms) |
|
|
50
|
+
| `CHROMIUM_EXECUTABLE_PATH` | No | -- | Custom Chromium path (for Lambda/serverless) |
|
|
51
|
+
| `CHROMIUM_LAUNCH_ARGS` | No | -- | Comma-separated Chromium args |
|
|
52
|
+
|
|
53
|
+
## Deployment
|
|
54
|
+
|
|
55
|
+
### Standalone Node.js
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm install
|
|
59
|
+
pnpm dev # Development with tsx
|
|
60
|
+
# or
|
|
61
|
+
pnpm build && pnpm start # Production
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Docker
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
docker build -t acuity-middleware .
|
|
68
|
+
docker run -p 3001:3001 \
|
|
69
|
+
-e AUTH_TOKEN=your-secret-token \
|
|
70
|
+
-e ACUITY_BASE_URL=https://YourBusiness.as.me \
|
|
71
|
+
-e ACUITY_BYPASS_COUPON=your-coupon-code \
|
|
72
|
+
acuity-middleware
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Modal Labs
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Set secrets in Modal dashboard first:
|
|
79
|
+
# AUTH_TOKEN, ACUITY_BASE_URL, ACUITY_BYPASS_COUPON
|
|
80
|
+
modal deploy modal-app.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Nix
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nix develop # Enter dev shell with Node.js + Playwright
|
|
87
|
+
pnpm install
|
|
88
|
+
pnpm dev
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pnpm install # Install dependencies
|
|
95
|
+
pnpm dev # Start dev server with tsx
|
|
96
|
+
pnpm typecheck # Run TypeScript type checking
|
|
97
|
+
pnpm build # Compile TypeScript to dist/
|
|
98
|
+
pnpm test # Run tests
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Why Pay for an API When You Are the API"
|
|
3
|
+
date: "2026-03-24"
|
|
4
|
+
description: "Playwright-based middleware for migrating off Acuity Scheduling with zero downtime, because sometimes the best API is the one you puppeteer into existence."
|
|
5
|
+
tags:
|
|
6
|
+
- brute-force-functional-design
|
|
7
|
+
- effect-ts
|
|
8
|
+
- modal-labs
|
|
9
|
+
- no-api-no-problem
|
|
10
|
+
- work-harder-not-smarter
|
|
11
|
+
- playwright-in-production
|
|
12
|
+
- scheduling-lifecycle
|
|
13
|
+
published: true
|
|
14
|
+
category: "software"
|
|
15
|
+
source_repo: "Jesssullivan/acuity-middleware"
|
|
16
|
+
slug: "why-pay-for-an-api-when-you-are-the-api"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
*Puppeteering a React SPA into submission because Acuity wants $150/month for the privilege of reading your own appointment data.*
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
It started with a 403.
|
|
24
|
+
|
|
25
|
+
Not a dramatic 403- not the kind where you fat-fingered an auth header or forgot to rotate a key. The boring kind. The kind that means "we know you have the credentials, we know this is your data, but you haven't paid enough for the door we put in front of it." Acuity Scheduling gates their REST API behind the Powerhouse plan. Every endpoint. Every verb. HTTP 403 Forbidden, and the response body might as well say "please upgrade to read your own appointment history."
|
|
26
|
+
|
|
27
|
+
I sat with this for about thirty seconds before the obvious question formed: the scheduling page at `MassageIthaca.as.me` loads appointments, availability, and time slots in a browser just fine. No paywall on the browser. The data is right there, rendered into DOM nodes, waiting to be read by anyone with a copy of Chrome and an opinion about CSS selectors.
|
|
28
|
+
|
|
29
|
+
So I built an API out of a browser.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## The Shape of the Problem
|
|
34
|
+
|
|
35
|
+
Jen runs a massage therapy practice in Ithaca, NY. TMD specialist, intraoral work, the kind of practitioner who books 604 appointments across 62 weeks and then discovers that the scheduling platform she's been paying for has been quietly accumulating a hostage situation. Her appointment data, her client records, her availability configuration- all locked behind a vendor UI with no programmatic export path.
|
|
36
|
+
|
|
37
|
+
The standard advice is: migrate. Build your own thing. Accept two weeks of downtime and a spreadsheet-based data migration and a prayer that nothing falls through the cracks.
|
|
38
|
+
|
|
39
|
+
That advice is bad.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## The Architecture Nobody Asked For
|
|
44
|
+
|
|
45
|
+
Here is what I actually built:
|
|
46
|
+
|
|
47
|
+
A 16-method `SchedulingAdapter` interface- services, providers, availability, reservations, bookings, clients- with every method returning `TaskEither<SchedulingError, T>` from fp-ts. Monadic composition all the way down. You cannot accidentally ignore an error because the type system won't let you access the success value without folding over the Either first.
|
|
48
|
+
|
|
49
|
+
Then I implemented that interface twice.
|
|
50
|
+
|
|
51
|
+
**Path A**: Drive the Acuity booking wizard with headless Playwright. Click through the service selection page. Navigate the react-calendar. Fill the client form. Apply a 100% gift certificate coupon to bypass Square's payment integration. Click "PAY & CONFIRM" at $0. Scrape the confirmation page.
|
|
52
|
+
|
|
53
|
+
**Path B**: Direct PostgreSQL queries via Drizzle ORM against Neon serverless. Sub-100ms responses. No browser. No DOM. No opinions about CSS selectors.
|
|
54
|
+
|
|
55
|
+
A feature flag chooses which path serves traffic:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
function resolveBackend(): 'acuity' | 'homegrown' {
|
|
59
|
+
if (env.VERCEL_GIT_COMMIT_REF === 'dev/main') return 'acuity';
|
|
60
|
+
return (env.SCHEDULING_BACKEND as 'acuity' | 'homegrown') ?? 'acuity';
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That's the strangler fig. Both backends run simultaneously. Alpha gets the homegrown PG adapter. Beta gets the Acuity wizard. The adapter interface means neither the booking page nor the admin calendar nor any API consumer knows or cares which backend answered their call.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Effect TS and fp-ts Walk Into a Codebase
|
|
69
|
+
|
|
70
|
+
I am not proud of this part.
|
|
71
|
+
|
|
72
|
+
The adapter interface was defined in fp-ts terms before the browser middleware existed. `TaskEither` is the right type for "a lazy async computation that might fail"- it composes beautifully via `pipe`, `chain`, `map`. Consumers of the scheduling adapter get clean monadic composition with explicit error handling. Good.
|
|
73
|
+
|
|
74
|
+
Then I needed to manage a Playwright browser lifecycle. Launch browser, create page, run 7 sequential wizard steps, guarantee cleanup even when step 4 throws because Acuity decided to rearrange their radio buttons this week. This is a resource management problem. fp-ts does not do resource management. Effect TS does.
|
|
75
|
+
|
|
76
|
+
So the middleware layer uses Effect. Generator syntax, `Context.Tag` for dependency injection, `Layer.scoped` with `acquireRelease` for the browser process and page instance. Each wizard step is an Effect program that yields the `BrowserService` from context, wraps Playwright calls in `Effect.tryPromise`, and returns typed errors (`WizardStepError`, `SelectorError`, `CouponError`, `BrowserError`).
|
|
77
|
+
|
|
78
|
+
The bridge between the two worlds is a function called `runEffect`:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
const runEffect = <A>(
|
|
82
|
+
effect: Effect.Effect<A, MiddlewareError>,
|
|
83
|
+
): SchedulingResult<A> =>
|
|
84
|
+
() =>
|
|
85
|
+
Effect.runPromiseExit(effect.pipe(Effect.provide(layer))).then(
|
|
86
|
+
(exit) => {
|
|
87
|
+
if (Exit.isSuccess(exit)) return E.right(exit.value);
|
|
88
|
+
const failure = Cause.failureOption(exit.cause);
|
|
89
|
+
if (failure._tag === 'Some')
|
|
90
|
+
return E.left(toSchedulingError(failure.value));
|
|
91
|
+
return E.left(Errors.infrastructure('UNKNOWN', '...'));
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That last paragraph is doing a tremendous amount of heavy lifting. We run the Effect to an `Exit` value (not a Promise- an Exit, so we get the typed error channel instead of a FiberFailure wrapper), extract the first failure from the Cause tree, convert it from Effect's tagged error type to fp-ts's discriminated union type via `toSchedulingError`, and wrap the whole thing in a thunk that returns `Promise<Either<SchedulingError, A>>`.
|
|
97
|
+
|
|
98
|
+
Two functional effect systems. One bridge function. I have made my choices and I will live with them.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Puppeteering a React SPA
|
|
103
|
+
|
|
104
|
+
Acuity's booking wizard is a React SPA circa 2026 using Emotion CSS-in-JS. This means CSS class names are hash-generated (`css-1a2b3c4`) and unstable across deployments. The semantic class names are sparse. The form inputs for intake questions have no `name` or `id` attributes- they are purely React-controlled.
|
|
105
|
+
|
|
106
|
+
The selector registry is a map from logical names to fallback chains:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const Selectors = {
|
|
110
|
+
submitButton: [
|
|
111
|
+
'button[type="submit"]:not([disabled])',
|
|
112
|
+
'button.btn-primary:not([disabled])',
|
|
113
|
+
'[data-testid="submit-booking"]',
|
|
114
|
+
'button:has-text("Confirm")',
|
|
115
|
+
'button:has-text("Book")',
|
|
116
|
+
'button:has-text("Schedule")',
|
|
117
|
+
'button:has-text("PAY")',
|
|
118
|
+
'input[type="submit"]',
|
|
119
|
+
],
|
|
120
|
+
// ... 29 more keys
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Eight fallback selectors for the submit button. Eight. Because Acuity renders different button markup depending on whether you have a coupon applied, whether payment is required, and apparently what phase the moon is in. The `resolveSelector` function tries each candidate in order with a timeout, and the first one that matches wins. This is not elegant. This is the kind of code that exists because the alternative is the scheduling page going down every time Acuity ships a frontend deploy.
|
|
125
|
+
|
|
126
|
+
The intake radio buttons deserve special mention. They have no `name` attribute. No `id`. No `data-testid`. They are React-controlled inputs wrapped in `<label>` elements, and the only reliable way to interact with them is to click the label via Playwright's `locator().nth()` API, which dispatches OS-level mouse events that React's synthetic event delegation actually processes. I verified this against the live application on February 25th and 26th, 2026. (This cost me half a day to figure out, and I am not proud of it.)
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Modal Labs: Chromium in a Box
|
|
131
|
+
|
|
132
|
+
You cannot run Chromium in a Vercel Lambda. Well- you can, if you use @sparticuz/chromium's stripped 50MB binary and accept the cold start penalty and the memory pressure and the 120-second timeout that makes booking creation a coin flip. I tried this path. It is not good.
|
|
133
|
+
|
|
134
|
+
Modal Labs solves this by being a container runtime that does not pretend to be a function runtime. Under the hood it uses a FUSE-based lazy-loading filesystem- container images are treated as a 5 MB index, and file contents are served on demand through an OverlayFS layer. No size constraints. No stripped binaries. No praying that your dependencies fit in 50 megabytes of zip file. gVisor provides the isolation boundary (a userspace kernel, not a shared host kernel), which is the right security model for running untrusted browser content. The deployment:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
image = (
|
|
138
|
+
modal.Image.from_registry(
|
|
139
|
+
"mcr.microsoft.com/playwright:v1.58.2-noble"
|
|
140
|
+
)
|
|
141
|
+
.run_commands("rm -rf /usr/local/lib/nodejs", "...")
|
|
142
|
+
.run_commands("npm install -g corepack && corepack enable")
|
|
143
|
+
.copy_local_dir(".", "/app")
|
|
144
|
+
.run_commands("cd /app && pnpm install")
|
|
145
|
+
.run_commands(
|
|
146
|
+
"cd /app && npx esbuild src/middleware/server.ts "
|
|
147
|
+
"--bundle --platform=node --format=esm "
|
|
148
|
+
"--outfile=dist/server.mjs "
|
|
149
|
+
"--external:playwright-core"
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Start with Microsoft's official Playwright image (which includes Chromium). Swap Node 24 for Node 22 LTS. Install pnpm. Bundle everything into a single `server.mjs` with esbuild- all dependencies inlined except `playwright-core`, which the base image already provides.
|
|
155
|
+
|
|
156
|
+
The runtime config is 2 CPU cores, 2048 MB memory, `max_inputs=1` per container. That last bit is critical. Each browser session has page-level state- navigation history, cookies, form data. Concurrent requests on the same page instance would race. Modal scales horizontally by spawning additional containers, each with its own isolated browser. `min_containers=1` keeps one warm for low-latency reads.
|
|
157
|
+
|
|
158
|
+
The result is a standalone HTTP server at a Modal URL that accepts scheduling API calls and translates them into wizard automation. The `RemoteWizardAdapter` in the SvelteKit app is just an HTTP client:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
Client -> SvelteKit -> RemoteWizardAdapter -> Modal -> Playwright -> Acuity SPA
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Five hops to read an appointment calendar. This is not a permanent architecture.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## The Sticky Panel Bug
|
|
169
|
+
|
|
170
|
+
(And now for the war story.)
|
|
171
|
+
|
|
172
|
+
I was running the checkout automation- 604 appointments across 62 weeks, marking unpaid appointments as paid in the Acuity admin panel. The automation was clicking through appointments one by one, opening the side detail panel, checking the "isPaid" checkbox, saving.
|
|
173
|
+
|
|
174
|
+
On week 2025-02-10, appointment 1413986492 (Tess Legler) did something interesting. When the code clicked the *next* appointment (Liz Hartman), the detail panel still showed Tess Legler's data. The React SPA's state management had a race condition- the React state updated (showing a new panel), but the PHP form data from the previous appointment persisted in the DOM.
|
|
175
|
+
|
|
176
|
+
The automation did not know this. It read the old panel's price ($100), applied the $30 Liz Hartman discount (setting it to $70), and saved. But the form action URL still pointed to Tess Legler. So Tess Legler got her price changed to $70, and Liz Hartman was untouched.
|
|
177
|
+
|
|
178
|
+
Same mechanism hit Coleen Cleeve on the same week. $160 became $130.
|
|
179
|
+
|
|
180
|
+
Three appointments corrupted by a vendor's React state management bug, discovered only because I was auditing the output logs afterward. The fix was three changes:
|
|
181
|
+
|
|
182
|
+
1. `closeAppointmentDetail()` now waits for `form#appointment-details-page` to actually disappear from the DOM. Not "click close and hope." Wait until the element is gone.
|
|
183
|
+
2. `openAppointmentDetail()` now parses the form action URL and verifies it contains the expected appointment ID. Wrong ID? Close. Wait. Retry once.
|
|
184
|
+
3. All action functions call `verifyPanelId()` both before AND after entering edit mode. React may swap panels when you click the edit button.
|
|
185
|
+
|
|
186
|
+
I then wrote three targeted fix scripts to correct the specific wrong-priced appointments. Each script navigates to the affected week, opens the specific appointment by ID, verifies the current price, applies the correction, and verifies the result. The scripts read like legal depositions because at that point I was not trusting any DOM state that I had not personally interrogated twice.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## The Migration So Far
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
| Operation | Browser Middleware | Homegrown (PG) | Speedup |
|
|
194
|
+
|--------------------|-------------------|----------------|-----------|
|
|
195
|
+
| Get services | ~8s (DOM scrape) | <50ms | ~160x |
|
|
196
|
+
| Get available dates| ~15-20s (wizard) | <100ms | ~200x |
|
|
197
|
+
| Get time slots | ~15-20s (wizard) | <100ms | ~200x |
|
|
198
|
+
| Create booking | ~30-60s (wizard) | <200ms | ~300x |
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Two orders of magnitude. The browser path exists to maintain service continuity during migration. The homegrown adapter has full 16/16 method coverage. The wizard adapter has 12/16 (no `getBooking`, `cancelBooking`, or slot reservations through the public UI).
|
|
202
|
+
|
|
203
|
+
879 tests across the scheduling stack. 39 dedicated availability engine tests covering slot generation, DST transitions, overlap detection, buffer time, and the kind of edge cases that make you question whether `America/New_York` was a deliberate choice by the IANA or a cosmic prank.
|
|
204
|
+
|
|
205
|
+
Both backends have been running simultaneously in production since March 2026. The alpha environment serves real bookings through the homegrown PG adapter. The beta environment serves through the Acuity wizard. Nobody has noticed the difference, which is the whole point.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## What This Actually Is
|
|
210
|
+
|
|
211
|
+
It's the strangler fig pattern applied to a third-party SaaS dependency.
|
|
212
|
+
|
|
213
|
+
The scheduling adapter interface is the stable contract. The browser middleware is the vine growing around the tree. The homegrown PostgreSQL backend is the new tree. When the vine has fully covered the old tree- when the homegrown backend handles every operation that the business needs- the middleware gets deleted. Not deprecated. Not archived. Deleted. It was never meant to survive.
|
|
214
|
+
|
|
215
|
+
The adapter interface stays. The availability engine stays. The booking schema, the email templates, the admin calendar, the payment integration- all of that stays. The browser automation was the scaffolding. You don't preserve scaffolding.
|
|
216
|
+
|
|
217
|
+
Modal and Playwright and the selector registry and the coupon bypass and the `runEffect` bridge and the entire elaborate machinery for puppeteering a React SPA into pretending to be a REST API- all of it was built to be thrown away. That is the design. That is the point.
|
|
218
|
+
|
|
219
|
+
Why pay for an API to rebuild a closed-source wizard when you *are* a wizard?
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## The Stack
|
|
224
|
+
|
|
225
|
+
| Layer | Technology | Purpose |
|
|
226
|
+
|-------|-----------|---------|
|
|
227
|
+
| Adapter interface | fp-ts `TaskEither` | 16-method contract, monadic errors |
|
|
228
|
+
| Browser middleware | Effect TS + Playwright | Wizard step programs, resource management |
|
|
229
|
+
| Container runtime | Modal Labs | Serverless Chromium, warm pools |
|
|
230
|
+
| Selector registry | CSS fallback chains | DOM resilience against Emotion hash instability |
|
|
231
|
+
| Homegrown backend | Drizzle ORM + Neon PG | Direct queries, sub-100ms |
|
|
232
|
+
| Availability engine | Pure functions + Intl | DST-safe slot generation, 39 tests |
|
|
233
|
+
| Feature flag | `SCHEDULING_BACKEND` env var | Strangler fig routing |
|
|
234
|
+
| Payment bypass | Gift certificate coupon | Decouples scheduling from Square |
|
|
235
|
+
|
|
236
|
+
Source: [Jesssullivan/acuity-middleware](https://github.com/Jesssullivan/acuity-middleware)
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
-Jess
|