Podział na workery CLIENT i ADMIN
Architektura dwóch workerów Cloudflare budowanych z jednego codebase'u (AP-773).
👤 Dla kogo
Dokument techniczny (dla developerów i osób wdrażających). Opisuje, dlaczego aplikacja jest deployowana jako dwa osobne workery, jak dodać nową trasę, jak uruchomić każdy wariant lokalnie i jak wycofać zmianę. Jeśli dodajesz stronę lub endpoint API — przeczytaj sekcję Dodawanie nowej trasy.
🌍 Problem
Izolat Cloudflare Workers ma twardy limit 128 MB pamięci, którego nie da się podnieść na żadnym planie. Worker klient regularnie ginął z błędem „Worker exceeded memory limit”.
Przyczyna (audyt 2026-07-27): patch OpenNext load-manifest faktoryzuje wartości ze wszystkich manifestów client-reference i emituje je jako deklaracje const na najwyższym poziomie modułu. Skutek — dane wszystkich tras są materializowane w pamięci przy starcie izolatu, zanim obsłuży on jakikolwiek request. Baseline jest stały, wysoki i rośnie liniowo z liczbą tras:
| Pomiar (develop, przed podziałem) | Wartość |
|---|---|
| Manifesty client-reference | 227 plików / 19,39 MB |
Sekcja dashboard | 10,81 MB (56 %) |
| Zajętość heapu po załadowaniu | ~29,8 MB ze 128 MB |
Rozmiar skaluje się z liczbą tras, a panel administracyjny to zdecydowana większość tras — przy czym korzysta z niego kilkanaście osób, podczas gdy portal kliencki obsługuje cały ruch zewnętrzny.
Pętla wzmacniająca w obsłudze błędów
Osobny problem, usunięty razem z podziałem. OOM objawia się klientowi jako Connection closed — błąd strumienia RSC, czyli „serwer przyjął request i padł w trakcie odpowiedzi". Ten komunikat był na liście TRANSIENT_ERROR_PATTERNS w app/error.tsx, więc aplikacja:
- ponawiała request po 1,5 s — czyli powtarzała dokładnie to żądanie, które przed chwilą zabiło izolat,
- przy każdej próbie wysyłała automatyczne zgłoszenie błędu (mail + zgłoszenie w Jirze).
W trakcie incydentu to się samo napędzało i zwielokrotniało zarówno obciążenie, jak i szum.
connection closed został usunięty z listy (lib/utils/transient-error.ts). Auto-retry zostaje dla failed to fetch, load failed i networkerror — to błędy generowane przez przeglądarkę, gdy request w ogóle nie dotarł do serwera, i tam ponowienie ma sens. Crash serwera trafia teraz od razu na stronę błędu z przyciskiem ręcznego zgłoszenia, a diagnozę robi się z logów Cloudflare.
🧭 Rozwiązanie
Jeden codebase, dwa buildy, dwa workery:
Efekt pomiaru po podziale (develop, 2026-07-27, ten sam commit i ten sam entry point — różni się wyłącznie zestaw tras):
| Wariant | Pliki tras | Manifesty client-reference | handler.mjs |
|---|---|---|---|
| Pełna aplikacja (przed) | 232 | 227 / 19,39 MB | 41,62 MB |
| CLIENT | 104 | 101 / 5,05 MB | 21,71 MB |
| ADMIN | 195 | 193 / 15,19 MB | 35,50 MB |
Manifesty wariantu CLIENT spadają mocniej niż liczba tras (−74 % przy −55 % tras), bo kurczy się także suma modułów klienckich, z których manifesty są składane; handler.mjs spada o 48 %.
Panel odchudza się skromniej (−15 % na handler.mjs) i to jest oczekiwane: panel jest większością aplikacji, więc wycięcie tras klienckich nie może go zmniejszyć dramatycznie. Największy pojedynczy blok, jaki mu zostaje, to app/api/fakturownia/* — 43 trasy / 2,89 MB manifestów, czyli ~19 % wagi manifestów panelu. Konsolidacja tych proxy-endpointów do jednej trasy [...path] byłaby kolejną istotną dźwignią.
🗂️ Podział tras
Źródłem prawdy jest scripts/route-variants.json. Każda reguła przypisuje ścieżkę (plik albo katalog) do listy wariantów; wygrywa reguła o najdłuższym dopasowaniu segmentów ścieżki, dzięki czemu app/(dashboard)/dashboard/user nie „zjada” user-activities.
{
"path": "app/(dashboard)/dashboard/camps",
"variants": ["admin"],
"reason": "Camp management (seasons, terms, registrations…)"
},
{
"path": "app/(dashboard)/dashboard/camps/offer",
"variants": ["client", "admin"],
"reason": "Public camp offer — route_access grants it to CLIENT"
}
Punktem wyjścia do klasyfikacji była tabela route_access (rola → trasa). Uwaga: route_access jest edytowalna w czasie działania aplikacji (/dashboard/route-access), a podział wariantów jest ustalany w momencie buildu — dlatego plik JSON jest osobnym, jawnym źródłem prawdy, a nie odczytem z bazy.
Trasy wycięte z ADMIN
Panel też nie potrzebuje tras typowo klienckich — i to on jest cięższym z dwóch workerów, więc każde odchudzenie liczy się tam bardziej. Podstawą są wiersze route_access, w których allowed_roles nie zawiera żadnej roli pracowniczej (ADMIN / BACKOFFICE / INSTRUCTOR):
| Trasa | allowed_roles |
|---|---|
/dashboard/user-activities (całe poddrzewo, 5 wzorców) | ["CLIENT"] |
/dashboard/cancelled-activities | ["CLIENT"] |
/dashboard/reservations, /dashboard/reservations/add-game | ["CLIENT"] |
Do tego trasy publiczne, które z natury żyją tylko na domenie klienckiej: /pay/[token], /pay/success, /feedback/[token], /offline oraz całe app/api/public/* (webhooki Przelewy24 i SendGrid, linki z maili).
Wyjątek: /dashboard/reservations/cancel/[bookingId] ma w route_access ["CLIENT","ADMIN"] i jest linkowane z adminowego reservations-overview, więc zostaje w obu wariantach. Razem z nim muszą zostać dwa pliki, bez których segment się nie zbuduje: @modal/default.tsx (layout deklaruje slot modal) i @modal/(.)cancel-reservation/…/page.tsx (importowany wprost przez stronę anulowania). Natomiast reservations/default.tsx jest wycinany — to export { default } from './page', więc bez page.tsx się nie kompiluje.
Kolokowane komponenty w user-activities/ i reservations/ nie znikają — prune rusza wyłącznie pliki tras. Dlatego schedule/ i skill-assessment/, które importują user-activities/[playerId]/components/UserActivities, nadal się kompilują.
:::note Konsekwencja dla instruktorów uczestniczących w zajęciach
INSTRUCTOR z wantsToParticipateInClasses dziedziczy trasy CLIENT (lib/roles.ts), a ADMIN ma dostęp do wszystkiego z definicji. Po wycięciu tych tras z panelu obie te role trafiają na nie przez przekierowanie na domenę kliencką — bezszwowo, bo sesja przechodzi handoffem i drugie logowanie nie jest potrzebne.
:::
Trasy wycięte z obu wariantów
Reguła z "variants": [] usuwa trasę ze wszystkich buildów. Tak są oznaczone strony testowe dashboard/fakturownia-test i dashboard/notification-tests — kod zostaje w repo i działa pod yarn dev, ale nie trafia do żadnego workera (ich kafelki w panelu admina i wpis w nawigacji zostały usunięte).
🔧 Jak działa build wariantowy
node scripts/variants/build.mjs <client|admin> [--next]:
- Czyści wygenerowane stuby typów tras (
.next*/types) — pochodzą z poprzedniego buildu i wskazują na strony, które ten wariant wycina. - Przenosi poza
app/(do.route-variant-stash/) pliki wejściowe tras —page.*,route.*,default.*— nienależące do wariantu. Manifest przeniesień zapisywany jest przed pierwszym przeniesieniem. - Przenosi także osierocone pliki specjalne Next (
layout,loading,error,template…) z katalogów, w których po wycięciu nie została żadna trasa. Bez tegolayout.tsxdeklarujący slot@modalnie przechodzi type-checku, bo slot zniknął. - Uruchamia build z
APP_VARIANT=<wariant>. Buildy weryfikacyjne (--next) dostają osobnyNEXT_DIST_DIR=.next-<wariant>; buildy workera używają domyślnego.next—opennextjs-cloudflareszuka wyjścia standalone pod stałą ścieżką — i dlatego dwa buildy workerów muszą iść sekwencyjnie, nie równolegle. - Zawsze przywraca pliki — w bloku
finallyoraz naSIGINT/SIGTERM.
Kluczowa własność: przenoszone są wyłącznie pliki tras, nigdy katalogi. Dzięki temu współdzielone komponenty zostają na miejscu i importy typu user-activities → ../../../schedule/components/SlotDisplay nadal się rozwiązują.
:::warning Przerwany build
Jeśli build zostanie ubity twardo (kill -9, padnięcie systemu), katalog .route-variant-stash/ przetrwa. Kolejny build odmówi startu. Uruchom wtedy:
yarn variants:restore
W ostateczności: git checkout -- app.
:::
:::caution Nie uruchamiaj innych narzędzi w trakcie buildu wariantowego
Przez czas trwania buildu część plików tras fizycznie nie istnieje w app/. Równoległe yarn test, next dev czy indeksowanie w IDE zobaczą niekompletne drzewo i zgłoszą „Cannot find module …/route”. Poczekaj na zakończenie buildu.
:::
➕ Dodawanie nowej trasy
- Dodaj stronę lub endpoint jak zwykle.
- Uruchom
yarn variants:check. Jeśli trasa nie jest pokryta żadną regułą — check nie przejdzie (tak samo w CI, job „Route Variant Classification”). - Dopisz regułę w
scripts/route-variants.json. W razie wątpliwości wybierz["client", "admin"]— trasa brakująca w buildzie CLIENT zwraca 404 dla klientów, a nadmiarowa kosztuje tylko rozmiar bundle'a. - Uruchom
yarn variants:generatei zacommitujlib/variants/client-routes.generated.ts.
Trasa dziedziczy regułę katalogu nadrzędnego, więc nowa podstrona w dashboard/statistics/ nie wymaga żadnej akcji.
🔀 Routing między domenami
middleware.ts działa w obie strony: wykrywa ścieżki, których dany wariant nie serwuje (resolveServingVariant), i:
- nawigacja przeglądarki (
GET/HEADstrony) → 307 na origin drugiego workera z zachowaniem ścieżki i query, a gdy origin nie jest skonfigurowany — rewrite na stronę 404 aplikacji (app/not-found.tsx, status 404, URL w pasku zostaje), /api/*oraz metody inne niż GET/HEAD → JSON 404 z nagłówkiemx-app-variant(przekierowanie cross-origin i tak zostałoby zablokowane przez CORS).
Czyli: CLIENT → ADMIN_ORIGIN dla /dashboard/finances itp., ADMIN → CLIENT_ORIGIN dla /dashboard/user-activities, /pay/… itp. Bez APP_VARIANT middleware w ogóle nie ingeruje, więc pojedynczy worker działa jak dotąd.
Wyjątek od reguły POST: żądania z nagłówkiem Next-Action przechodzą do Next. Server Actions POST-ują na URL strony, z której zostały wywołane, ale Next rozwiązuje je po globalnym ID akcji — trasa nie musi istnieć. Dzięki temu przycisk „Zgłoś błąd” na stronie 404 wyciętej trasy działa; akcja obecna tylko w bundlu drugiego wariantu i tak nie figuruje w rejestrze tego builda i Next sam ją odrzuci.
Menu a wariant
route_access odpowiada na pytanie „czy ta rola może wejść”, a nie „czy trasa istnieje w tym buildzie” — a część stron (/dashboard/admin-panel, /dashboard/activity-types) w ogóle nie ma wiersza w tej tabeli. Dlatego root layout liczy osobną listę unavailableRoutes (collectUnavailableRoutes — kandydaci z navItems i z route_access, przepuszczeni przez te same wzorce co middleware) i RouteAccessProvider odrzuca je przed jakąkolwiek logiką ról. Menu pokazuje więc tylko to, co dany worker faktycznie serwuje; autoryzacja serwerowa pozostaje nietknięta.
Host w przekierowaniu pochodzi zawsze ze zmiennej środowiskowej, a z żądania brana jest tylko ścieżka i query — konstrukcja przez new URL() uniemożliwia open-redirect (test: should_never_let_the_path_change_the_host).
Trasa kliencka może leżeć pod adminowym segmentem dynamicznym (/dashboard/camps/offer vs /dashboard/camps/[seasonId]), dlatego ścieżka obsługiwana przez build CLIENT zawsze wygrywa.
app/page.tsx kieruje użytkownika po zalogowaniu wg roli: ADMIN/BACKOFFICE/INSTRUCTOR → /dashboard/schedule na domenie panelu, pozostali → /dashboard/user-activities na domenie klienckiej. Bez skonfigurowanych originów zwracana jest ścieżka względna, czyli zachowanie sprzed podziału.
🔐 Sesja między domenami
Better Auth trzyma sesję w cookie. Domyślnie cookie jest przypięte do jednego hosta, więc zalogowanie na dev.klient.acepark.pl nie daje sesji na dev.panel.acepark.pl.
Rozwiązane jest to handoffem sesji, nie współdzielonym cookie. Przekierowania między workerami nie prowadzą prosto na drugi host, tylko przez /api/auth/cross-worker-handoff na bieżącym origin:
klient.acepark.pl/dashboard/finances
→ 307 klient.acepark.pl/api/auth/cross-worker-handoff?to=admin&returnTo=…
→ 307 panel.acepark.pl/api/auth/mobile-session?code=…&returnTo=…
→ 307 panel.acepark.pl/dashboard/finances
Endpoint generuje jednorazowy kod w APP_CACHE — tym samym namespace KV, który współdzielą oba workery — a drugi worker wymienia go na własne cookie. To ten sam mechanizm, którego od dawna używa aplikacja mobilna (generateMobileAuthCode / consumeMobileAuthCode), tylko z innym celem przekierowania. Kod żyje 60 sekund i jest jednorazowy (odczyt kasuje wpis).
Zabezpieczenia: host celu pochodzi wyłącznie ze skonfigurowanych originów (getAdminOrigin / getClientOrigin), a returnTo przechodzi przez sanitizeReturnPath — akceptowane są tylko ścieżki same-origin, odrzucane //host, /\host i adresy absolutne. Bez sesji handoff odsyła na logowanie na bieżącej domenie.
Dzięki temu jedno logowanie wystarcza na obu domenach bez poszerzania zakresu ciasteczka. Alternatywa poniżej pozostaje dostępna, ale nie jest potrzebna.
Rozszerzenie zakresu cookie włącza zmienna AUTH_COOKIE_DOMAIN (lib/auth.ts → advanced.crossSubDomainCookies). Nieustawiona = mechanizm wyłączony.
:::danger Zanim ustawisz AUTH_COOKIE_DOMAIN na produkcji
domain=.acepark.pl wysyła cookie sesji do każdego hosta w tej domenie — łącznie z www.acepark.pl, na którym stoi WordPress. Każdy serwer i plugin pod tą domeną zobaczy token sesji w nagłówkach żądania; httpOnly chroni tylko przed odczytem z JavaScriptu, nie przed wyciekiem po stronie serwera.
Do świadomej decyzji przed wdrożeniem na prod:
- albo potwierdzić, że wszystkie subdomeny
acepark.plsą zaufane, - albo zamiast współdzielonego cookie zrobić jednorazowy podpisany handoff przy przekierowaniu CLIENT → ADMIN (wzorzec jak w
/api/auth/mobile-handoff).
Na develop koszt akceptowalny — dotyczy sesji testowych. :::
trustedOrigins w lib/auth.ts zawiera https://*.acepark.pl. Wildcard Better Auth dopasowuje wiele etykiet ([^/]*), więc dev.panel.acepark.pl jest już pokryty — nie trzeba nic dodawać.
baseURL (BETTER_AUTH_URL) musi być ustawiony osobno dla każdego workera, inaczej panel generowałby linki OAuth i resetu hasła wskazujące na domenę kliencką.
🧱 Durable Objects i crony
| Element | Gdzie |
|---|---|
Klasy CourtHardwareDO, RateLimiterDO | worker CLIENT (dev), worker.ts |
| Binding DO w workerze ADMIN | script_name = "dev" — te same obiekty |
[[migrations]] DO | tylko w wrangler.toml (skrypt deklarujący) |
Pi WebSocket /api/hardware/ws | domena kliencka — obsługa w worker-hardware.ts, przed Next |
Viewer WebSocket /api/hardware/viewer-ws | domena panelu — ten sam kod, ten sam DO |
Handler scheduled + crony | tylko worker ADMIN, worker.admin.ts; w wrangler.toml celowo pusta lista crons = [] |
Klasy DO zostały w workerze CLIENT — AP-776 rekomendował ADMIN, ale z zastrzeżeniem „jeśli Pi łączą się z klient.acepark.pl, bezpieczniej zostawić DO w workerze CLIENT”, i to zastrzeżenie tu obowiązuje. Powody:
- izolat DO ewaluuje cały skrypt, który deklaruje klasę — hosting w CLIENT kosztuje każdy izolat urządzenia ~21,7 MB baseline'u zamiast ~35,5 MB w ADMIN,
- każdy deploy skryptu deklarującego restartuje izolaty DO (Pi tracą WebSocket i się przełączają) — DO powinny mieszkać w skrypcie, który zmienia się rzadziej, a to portal kliencki,
- urządzenia Pi łączą się z domeną kliencką; zostawienie DO po tej stronie nie wymaga ich rekonfiguracji.
Gdyby placement trzeba było kiedyś zmienić: klasy DO da się przenieść między skryptami z zachowaniem stanu (migracja transferred_classes wranglera), a trwały stan tych DO jest minimalny — HardwareDeviceDO persystuje tylko pendingCommands i meta (resztę hydratuje z D1), RateLimiterDO trzyma jednorazowe liczniki. Naturalny kierunek przy rozroście modułu hardware to trzeci, malutki worker deklarujący wyłącznie DO, z bindingiem script_name z obu appów.
Crony przeniesiono na ADMIN zgodnie z ticketem. Dzięki temu kod przypomnień, fakturowania i importu Meta (worker-crons.ts → lib/reminders.ts i pokrewne) w ogóle nie trafia do bundle'a klienckiego.
getCloudflareContext() nie działa w cronie
OpenNext trzyma kontekst Cloudflare w AsyncLocalStorage, do którego wchodzi wyłącznie handler fetch (runWithCloudflareRequestContext w .open-next/cloudflare/init.js). Handler scheduled nigdy tam nie wchodzi, więc każde wywołanie getCloudflareContext() na ścieżce crona rzuca mylącym błędem:
ERROR: `getCloudflareContext` has been called without having called
`initOpenNextCloudflareForDev` from the Next.js config file.
To nie jest problem konfiguracji next.config.mjs — komunikat jest po prostu domyślnym fallbackiem biblioteki, gdy kontekstu nie ma w globalnym scope. Nie pomaga też { async: true }: tryb asynchroniczny sięga po wrangler tylko w procesie Node (dev), a w workerd rzuca tym samym błędem.
Skutek jest cichy, bo funkcje powiadomień łapią wyjątek i zwracają null/false — cron kończy się „sukcesem”, tylko SMS nie wychodzi, a miasto użytkownika (transformacje treści dla Legionowa i Lublina) jest puste.
Konwencja: każda funkcja wołana z crona przyjmuje db?: D1Database i przekazuje je dalej, a worker-crons.ts podaje env.DB. getCloudflareContext() zostaje tylko jako fallback dla ścieżki requestowej:
export const listUsersFromDb = async (
options: ListUsersOptions,
database?: D1Database
): Promise<User[]> => {
const db = database ?? getCloudflareContext().env.DB;
// ...
};
Odrzucona alternatywa: owinięcie handleScheduled w runWithCloudflareRequestContext ze sztucznym Request. Załatwiłoby całą klasę problemów naraz, ale init() z tego modułu odpala się raz na izolat i ustawia OPEN_NEXT_ORIGIN oraz __NEXT_PRIVATE_ORIGIN na podstawie URL-a tego żądania — cron ustawiłby fałszywy origin dla wszystkich kolejnych requestów panelu w tym izolacie.
:::note Dlaczego nie na workerze CLIENT
Rozważane i odrzucone. Pomiar wrangler deploy --dry-run na tym samym .open-next (różni się wyłącznie entry point):
| Entry | Bundle |
|---|---|
worker.ts (CLIENT, bez cronów) | 37,77 MB |
worker.admin.ts (ADMIN, z cronami) | 38,71 MB |
Crony to ~0,94 MB, czyli 2,5 % bundla. Przeniesienie ich na lżejszego workera klienckiego zdejmuje z admina niecały megabajt, a w zamian wpuszcza zadania wsadowe (sendAllOverduePaymentReminders, expirePendingPayments — ładują listy graczy i płatności, wysyłają setki maili) do tej samej puli izolatów, która obsługuje płacących klientów. Ich zużycie pamięci w trakcie działania jest znacznie mniej przewidywalne niż obsługa pojedynczego requestu. OOM w cronie na workerze ADMIN dotyka kilkunastu pracowników, na CLIENT — wszystkich klientów.
Gdyby jednak trzeba było to zmienić: przenieś listę crons między plikami wrangler (po stronie oddającej zostaw crons = [], żeby deploy wyczyścił stare schedule) i przenieś scheduled: handleScheduled między worker.admin.ts a worker.ts. Nic więcej.
:::
:::note Wycofana rekomendacja
AP-776 sugerował rozważenie wyłączenia [placement] mode = "smart" dla wariantu CLIENT. Rekomendacja została wycofana w korekcie diagnozy z 2026-07-27: przy stałym baseline rozpraszanie ruchu nic nie daje, bo każdy izolat i tak płaci ten sam koszt na starcie. smart zostaje w obu workerach.
:::
💾 Cache KV
Oba workery współdzielą NEXT_INC_CACHE_KV i APP_CACHE. Wpisy cache'u inkrementalnego rozdziela zmienna NEXT_INC_CACHE_KV_PREFIX (incremental-cache-client / incremental-cache-admin), więc różne build id nie kolidują i nie trzeba tworzyć osobnych namespace'ów.
💻 Development lokalny
yarn dev # cała aplikacja, bez wariantu — jak dotąd
yarn dev:client # port 3002, APP_VARIANT=client
yarn dev:admin # port 3001, APP_VARIANT=admin
Tryb dev nie wycina tras z dysku (kompiluje je leniwie, więc problem pamięci nie występuje), ale ustawia APP_VARIANT — i to wystarczy, żeby middleware zachowywało się jak docelowy worker. Wejście przeglądarką na /dashboard/user-activities pod yarn dev:admin renderuje stronę 404 aplikacji (status 404), a wywołanie wyciętego API zwraca:
{ "error": "route_not_served_by_this_worker", "variant": "admin" }
czyli dokładnie tak, jak zdeployowany worker ADMIN, w którym tych tras fizycznie nie ma.
Ustawienie ADMIN_ORIGIN/CLIENT_ORIGIN zamienia to 404 na przekierowanie 307 na drugi worker. Lokalnie ma to sens tylko przy dwóch działających serwerach:
# terminal 1
CLIENT_ORIGIN=http://localhost:3002 yarn dev:admin
# terminal 2
ADMIN_ORIGIN=http://localhost:3001 yarn dev:client
Bez APP_VARIANT (zwykłe yarn dev) middleware w ogóle nie ingeruje — cała aplikacja działa jak przed podziałem.
Testowanie handoffu sesji lokalnie
Handoff działa lokalnie w całości, bo initOpenNextCloudflareForDev() daje serwerom dev bindingi z miniflare, a oba configi wskazują ten sam preview_id dla APP_CACHE (2ae27f6f…) — stan leży w .wrangler/state, więc oba procesy czytają to samo KV.
Uruchom oba serwery, każdy ze wskazaniem na drugi:
# terminal 1
CLIENT_ORIGIN=http://localhost:3002 yarn dev:admin
# terminal 2
ADMIN_ORIGIN=http://localhost:3001 yarn dev:client
Zaloguj się na http://localhost:3002 kontem pracowniczym — po zalogowaniu / przerzuci Cię handoffem na localhost:3001/dashboard/schedule bez drugiego logowania.
Sam mechanizm da się też sprawdzić bez konta, atrapą ciasteczka (getSessionCookie tylko je odczytuje, nie waliduje):
# CLIENT mintuje kod i przekierowuje na ADMIN
curl -si -b "better-auth.session_token=TEST" \
"http://localhost:3002/api/auth/cross-worker-handoff?to=admin&returnTo=%2Fdashboard%2Ffinances" \
| grep -i location
# skopiuj `code` z URL-a i wymień go na ADMIN
curl -si "http://localhost:3001/api/auth/mobile-session?code=<KOD>&returnTo=%2Fdashboard%2Ffinances" \
| grep -iE "location|set-cookie"
Oczekiwane: location: http://localhost:3001/dashboard/finances (worker, który ustawił cookie — nie APP_BASE_URL), ustawione oba warianty cookie, a drugie użycie tego samego kodu → 401. returnTo=//evil.example.com musi wylądować na / bieżącego hosta.
Buildy weryfikacyjne:
yarn build:client # next build wariantu CLIENT
yarn build:admin # next build wariantu ADMIN
yarn build:worker:client # pełny build OpenNext
yarn variants:measure --dist-dir .next-client --label CLIENT
🚀 Deployment
Deploy na develop robi Cloudflare Workers Builds, uruchamiając scripts/build-and-migrate.sh. Skrypt wykrywa obecność wrangler.admin.toml i wtedy:
- buduje wariant CLIENT,
- sprawdza budżet
handler.mjs(limit 25 MB) — przed deployem, nie po, - deployuje worker
dev, - buduje wariant ADMIN,
- deployuje worker
dev-admin(-c wrangler.admin.toml), - uruchamia migracje D1.
Kolejność jest istotna. Worker ADMIN binduje Durable Objects przez script_name = "dev", więc przy pierwszym deployu worker CLIENT musi już istnieć.
Deploy ADMIN uruchamiany jest z odizolowanymi zmiennymi WRANGLER_CI_OVERRIDE_NAME / WRANGLER_CI_MATCH_TAG — Workers Builds przypina nimi każdy deploy do podpiętego workera, co po cichu przekierowałoby build ADMIN na workera klienckiego. Skrypt dodatkowo asertuje, że w outputcie padło Uploaded dev-admin, i przerywa pipeline, gdyby rename jednak nastąpił.
Preview deployments (PR-y) celowo zostają na jednym pełnym workerze — preview-deployment.yml przekazuje --var APP_VARIANT:none, co neutralizuje wariantowe [vars] z wrangler.toml i wyłącza przekierowania. Z tego samego powodu yarn dev:worker ma tę flagę wbudowaną.
:::caution Merge do prod_dev/main przenosi konfigurację
Skrypt przełącza się na ścieżkę dwuworkerową, gdy tylko widzi wrangler.admin.toml — a merge develop w górę przenosi ten plik automatycznie, razem z ID-kami bazy i KV środowiska dev. Przed pierwszym merge'em przygotuj wersje obu plików wrangler per środowisko (nazwy, D1/KV, domeny) albo usuń wrangler.admin.toml w merge commicie. Szczegóły w RELEASE_GUIDELINES.md.
:::
Czynności po stronie Cloudflare / dostawców
:::danger Nowy worker startuje pusty
Większość konfiguracji istniejącego workera nie jest w repozytorium — siedzi w dashboardzie Cloudflare i przetrwała dotąd tylko dzięki keep_vars = true. Widać to w logu deployu:
The local configuration ... differs from the remote configuration of your Worker
vars: {
- APP_BASE_URL: "https://klient.acepark.pl"
- AUTH0_API_CLIENT_ID, AUTH0_DOMAIN, GOOGLE_CLIENT_ID
- NEXT_PUBLIC_BASE_URL, P24_BASE_URL, P24_SANDBOX, FAKTUROWNIA_DOMAIN
}
routes: [ - { pattern: "klient.acepark.pl", custom_domain: true } ]
dev-admin to nowy skrypt — nie odziedziczy z tego nic. Bez przeniesienia kompletu zmiennych wystartuje i będzie zwracał błędy runtime dopiero przy pierwszym użyciu płatności, maili czy OAuth.
:::
Do wykonania ręcznie, poza repozytorium:
1. Worker i domena
- utworzyć worker
dev-admin, podpiąć custom domaindev.panel.acepark.pl - potwierdzić, że deploy
devnie zdejmie custom domaindev.klient.acepark.pl(wwrangler.tomlnie ma sekcjiroutes— domena jest przypięta z dashboardu)
:::danger Nie podpinaj dev-admin do Workers Builds
dev-admin deployuje pipeline workera dev — build-and-migrate.sh buduje i wypycha oba warianty w jednym przebiegu. Podpięcie dev-admin do tego samego repo jako osobny connected build daje dwa skutki, oba złe:
- każdy push deployowałby oba workery dwa razy,
- drugi przebieg startuje z
WRANGLER_CI_OVERRIDE_NAME=dev-admin, więc pierwszy deploy w skrypcie (wariant CLIENT) zostałby przekierowany na workera ADMIN i go nadpisał.
dev-admin ma pozostać workerem bez integracji z gitem. Jego jedyne źródło deployu to pipeline dev.
:::
Pierwsze utworzenie najwygodniej zrobić lokalnie z gałęzi AP-773 (worker dev z klasami DO już istnieje, więc binding script_name się rozwiąże):
yarn build:worker:admin
npx wrangler deploy -c wrangler.admin.toml
2. Zmienne różne dla każdego workera
| Zmienna | dev (CLIENT) | dev-admin (ADMIN) | Gdzie ustawić |
|---|---|---|---|
BETTER_AUTH_URL | https://dev.klient.acepark.pl | https://dev.panel.acepark.pl | runtime (dashboard) |
APP_BASE_URL | https://dev.klient.acepark.pl | https://dev.panel.acepark.pl | runtime (dashboard) |
NEXT_PUBLIC_BASE_URL | https://dev.klient.acepark.pl | https://dev.panel.acepark.pl | build-time — patrz niżej |
:::danger NEXT_PUBLIC_* ustawia się w chwili buildu, nie w dashboardzie
Zmienne z prefiksem NEXT_PUBLIC_ są wkompilowywane w bundle przeglądarkowy. Wpisanie ich w Variables and Secrets workera nie zadziała — runtime ich nie czyta.
NEXT_PUBLIC_BASE_URL jest tu krytyczne: klient Better Auth używa go jako baseURL dla wszystkich wywołań API. Zbudowany z błędną wartością panel wyświetli ekran logowania, a formularz uderzy pod cudzy adres — logowanie „nic nie robi", bez błędu w UI i bez śladu w logach workera, bo żądanie tam nigdy nie dociera.
Przy deployu przez Workers Builds jest to obsłużone (build dostaje zmienne z sekcji Build w ustawieniach workera). Problem dotyczy ręcznego buildu lokalnego, który zaciąga .env.local — a tam wartości wskazują na localhost. Buduj więc jawnie:
NEXT_PUBLIC_BASE_URL=https://dev.panel.acepark.pl \
APP_BASE_URL=https://dev.panel.acepark.pl \
yarn build:worker:admin
Zmienne z powłoki mają pierwszeństwo przed .env.local. Weryfikacja przed deployem:
grep -rl "localhost" .open-next/assets/_next/static | wc -l # oczekiwane 0
:::
APP_VARIANT, ADMIN_ORIGIN, CLIENT_ORIGIN i NEXT_INC_CACHE_KV_PREFIX są już w plikach wrangler — ich nie trzeba ustawiać ręcznie.
3. Zmienne, które muszą być IDENTYCZNE na obu workerach
Obie role dotykają płatności, faktur i powiadomień, a worker ADMIN dodatkowo uruchamia crony. Najbezpieczniej skopiować komplet — brak jednej zmiennej to błąd, który ujawni się dopiero w produkcyjnym flow.
Sekrety — wskazuj config, nie --name:
npx wrangler secret put <NAZWA> -c wrangler.admin.toml
--name dev-admin nadpisuje tylko nazwę workera; wrangler i tak wczyta domyślny wrangler.toml, a ten ma [env.preview] i wypisze ostrzeżenie „Multiple environments are defined… no target environment was specified”. wrangler.admin.toml nie ma środowisk i sam deklaruje name = 'dev-admin', więc niejednoznaczność znika u źródła. Do listowania sekretów istniejącego workera: npx wrangler secret list -c wrangler.toml --env="".
Lista do przeniesienia:
-
BETTER_AUTH_SECRET— ten sam co nadev, inaczej sesje nie będą wzajemnie ważne -
AUTH0_SECRET— nadal używany do podpisywania ticketów hardware WS (worker-hardware.ts) -
SENDGRID_API_KEY,SENDGRID_WEBHOOK_TOKEN -
P24_API_KEY,P24_CRC_KEY -
FAKTUROWNIA_API_KEY -
GOOGLE_CLIENT_SECRET,GOOGLE_API_KEY -
META_ADS_ACCESS_TOKEN,META_APP_SECRET,META_PAGE_ACCESS_TOKEN -
JIRA_API_TOKEN -
JUSTSEND_APP_KEY(SMS) -
GUS_KEY(wyszukiwanie NIP) -
OTP_SECRET -
PRIVATE_VAPID_KEY(push)
Zwykłe zmienne:
-
SENDGRID_FROM_EMAIL -
P24_MERCHANT_ID,P24_POS_ID,P24_BASE_URL,P24_SANDBOX -
FAKTUROWNIA_DOMAIN -
GOOGLE_CLIENT_ID -
NEXT_PUBLIC_VAPID_PUBLIC_KEY,VAPID_SUBJECT -
META_AD_ACCOUNT_ID,META_SPEND_CITY,META_FORM_CITY_MAP,META_VERIFY_TOKEN -
JIRA_HOST,JIRA_EMAIL,JIRA_PROJECT_KEY,JIRA_ISSUE_TYPE,JIRA_BOARD_ID -
ACEPARK_AUTH_FACTORY_URL -
DOOR_PIN_DELIVERY_ENABLED
Nazwy sekretów już ustawionych na istniejącym workerze sprawdzisz komendą (wartości nie da się odczytać — trzeba je wpisać ponownie):
npx wrangler secret list --name dev
4. Auth i dostawcy zewnętrzni
-
AUTH_COOKIE_DOMAINnie jest potrzebna — sesja przechodzi handoffem przez współdzielonyAPP_CACHE. Zostawić pustą, chyba że świadomie akceptujesz ryzyko opisane w sekcji o sesji - potwierdzić, że oba workery mają ten sam
APP_CACHE(handoff nie zadziała na osobnych namespace'ach) - dodać redirect URI
https://dev.panel.acepark.pl/api/auth/callback/googlew konsoli Google OAuth - potwierdzić, że webhooki (Przelewy24
/api/payments/webhook, SendGrid/api/public/sendgrid-events, Meta leads) wskazują na domenę kliencką —app/api/paymentsiapp/api/publicsą w wariancie CLIENT właśnie dlatego 5. Aplikacje mobilne
Obie apki ładują domenę kliencką (twa-manifest.json → host/fullScopeUrl, capacitor.config.ts → server.url). Dla klientów nic się nie zmienia. Pracownik, który zaloguje się w apce, zostanie przekierowany na panel — i tu potrzebna jest konfiguracja, inaczej Android wyrzuci go z powłoki TWA do Custom Taba z paskiem adresu.
- Android: dopisać domenę panelu do
additionalTrustedOriginswtwa-manifest.json(dziś[]), przebudować przez Bubblewrap, wydać w Google Play.public/.well-known/assetlinks.jsonma już oba fingerprinty i jest serwowany także z workera ADMIN, więc nie trzeba go generować od nowa - iOS:
allowNavigationzawiera już*.acepark.plw wydanej wersji — zweryfikować na urządzeniu, czy pokrywa domenę panelu; jeśli tak, nowe wydanie nie jest potrzebne - sesja przechodzi handoffem, więc pracownik loguje się raz także w apce
:::caution Kolejność wdrożenia na produkcji Zmiany w apce są wstecznie kompatybilne — dodanie zaufanego origin nie psuje niczego przy obecnym pojedynczym workerze. Wypuść je przed podziałem i odczekaj na aktualizacje u użytkowników. Odwrotna kolejność oznacza, że w oknie między splitem a zatwierdzeniem wydania w Google Play pracownicy tracą wygodny dostęp do panelu z telefonu.
Uwaga: deploy Androida bywał blokowany przez limit wydatków organizacji w Play Store — sprawdź to, zanim zaczniesz, bo teraz leży na ścieżce krytycznej. :::
⏪ Rollback
Powrót do pojedynczego workera nie wymaga cofania kodu — wariantowość jest opt-in:
- Natychmiast: w Cloudflare przywróć poprzednią wersję workera
dev(zakładka Deployments → Rollback). Workerdev-adminmożna zostawić; bez ruchu nie szkodzi. - W repozytorium: ustaw build command na
npm run deploy:worker(pojedynczy worker) albo usuńwrangler.admin.toml—build-and-migrate.shsam wróci wtedy do ścieżki jednoworkerowej. - Wyłączenie przekierowań bez rebuildu: usuń zmienną
ADMIN_ORIGINz workeradev. Middleware przestaje przekierowywać, a trasy adminowe zwracają zwykłe 404. - Przywrócenie cronów na
dev: podmień pustą listęcrons = []wwrangler.tomlna listę zwrangler.admin.tomli zdeployuj ponownie (pusta lista jest tam celowo — czyści stare schedule; nie usuwaj samej sekcji, bo wrangler zostawiłby je nietknięte). Handlerscheduledjest wworker.admin.ts— przy pełnym rollbacku wróć do wersjiworker.tssprzed podziału.
Durable Objects nie wymagają żadnej akcji przy rollbacku: klasy przez cały czas należą do workera dev.
📎 Powiązane
scripts/route-variants.json— klasyfikacja trasscripts/variants/— generator, prune/restore, build, pomiarlib/variants/routing.ts+ testy — logika przekierowańwrangler.toml/wrangler.admin.toml— konfiguracja workerów- AP-773 wraz z podzadaniami AP-774…AP-781