Przejdź do głównej zawartości

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-reference227 plików / 19,39 MB
Sekcja dashboard10,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:

  1. ponawiała request po 1,5 s — czyli powtarzała dokładnie to żądanie, które przed chwilą zabiło izolat,
  2. 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):

WariantPliki trasManifesty client-referencehandler.mjs
Pełna aplikacja (przed)232227 / 19,39 MB41,62 MB
CLIENT104101 / 5,05 MB21,71 MB
ADMIN195193 / 15,19 MB35,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):

Trasaallowed_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]:

  1. Czyści wygenerowane stuby typów tras (.next*/types) — pochodzą z poprzedniego buildu i wskazują na strony, które ten wariant wycina.
  2. 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.
  3. 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 tego layout.tsx deklarujący slot @modal nie przechodzi type-checku, bo slot zniknął.
  4. Uruchamia build z APP_VARIANT=<wariant>. Buildy weryfikacyjne (--next) dostają osobny NEXT_DIST_DIR=.next-<wariant>; buildy workera używają domyślnego .nextopennextjs-cloudflare szuka wyjścia standalone pod stałą ścieżką — i dlatego dwa buildy workerów muszą iść sekwencyjnie, nie równolegle.
  5. Zawsze przywraca pliki — w bloku finally oraz na SIGINT/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

  1. Dodaj stronę lub endpoint jak zwykle.
  2. Uruchom yarn variants:check. Jeśli trasa nie jest pokryta żadną regułą — check nie przejdzie (tak samo w CI, job „Route Variant Classification”).
  3. 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.
  4. Uruchom yarn variants:generate i zacommituj lib/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/HEAD strony) → 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łówkiem x-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.

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.tsadvanced.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.pl są 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

ElementGdzie
Klasy CourtHardwareDO, RateLimiterDOworker CLIENT (dev), worker.ts
Binding DO w workerze ADMINscript_name = "dev" — te same obiekty
[[migrations]] DOtylko w wrangler.toml (skrypt deklarujący)
Pi WebSocket /api/hardware/wsdomena kliencka — obsługa w worker-hardware.ts, przed Next
Viewer WebSocket /api/hardware/viewer-wsdomena panelu — ten sam kod, ten sam DO
Handler scheduled + cronytylko 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.tslib/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):

EntryBundle
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:

  1. buduje wariant CLIENT,
  2. sprawdza budżet handler.mjs (limit 25 MB) — przed deployem, nie po,
  3. deployuje worker dev,
  4. buduje wariant ADMIN,
  5. deployuje worker dev-admin (-c wrangler.admin.toml),
  6. 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 workerzepreview-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 domain dev.panel.acepark.pl
  • potwierdzić, że deploy dev nie zdejmie custom domain dev.klient.acepark.pl (w wrangler.toml nie ma sekcji routes — domena jest przypięta z dashboardu)

:::danger Nie podpinaj dev-admin do Workers Builds dev-admin deployuje pipeline workera devbuild-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

Zmiennadev (CLIENT)dev-admin (ADMIN)Gdzie ustawić
BETTER_AUTH_URLhttps://dev.klient.acepark.plhttps://dev.panel.acepark.plruntime (dashboard)
APP_BASE_URLhttps://dev.klient.acepark.plhttps://dev.panel.acepark.plruntime (dashboard)
NEXT_PUBLIC_BASE_URLhttps://dev.klient.acepark.plhttps://dev.panel.acepark.plbuild-time — patrz niżej

:::danger NEXT_PUBLIC_* ustawia się w chwili buildu, nie w dashboardzie Zmienne z prefiksem NEXT_PUBLIC_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_SECRETten sam co na dev, 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_DOMAIN nie jest potrzebna — sesja przechodzi handoffem przez współdzielony APP_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/google w konsoli Google OAuth
  • potwierdzić, że webhooki (Przelewy24 /api/payments/webhook, SendGrid /api/public/sendgrid-events, Meta leads) wskazują na domenę klienckąapp/api/payments i app/api/public są w wariancie CLIENT właśnie dlatego 5. Aplikacje mobilne

Obie apki ładują domenę kliencką (twa-manifest.jsonhost/fullScopeUrl, capacitor.config.tsserver.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 additionalTrustedOrigins w twa-manifest.json (dziś []), przebudować przez Bubblewrap, wydać w Google Play. public/.well-known/assetlinks.json ma już oba fingerprinty i jest serwowany także z workera ADMIN, więc nie trzeba go generować od nowa
  • iOS: allowNavigation zawiera już *.acepark.pl w 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:

  1. Natychmiast: w Cloudflare przywróć poprzednią wersję workera dev (zakładka Deployments → Rollback). Worker dev-admin można zostawić; bez ruchu nie szkodzi.
  2. W repozytorium: ustaw build command na npm run deploy:worker (pojedynczy worker) albo usuń wrangler.admin.tomlbuild-and-migrate.sh sam wróci wtedy do ścieżki jednoworkerowej.
  3. Wyłączenie przekierowań bez rebuildu: usuń zmienną ADMIN_ORIGIN z workera dev. Middleware przestaje przekierowywać, a trasy adminowe zwracają zwykłe 404.
  4. Przywrócenie cronów na dev: podmień pustą listę crons = [] w wrangler.toml na listę z wrangler.admin.toml i zdeployuj ponownie (pusta lista jest tam celowo — czyści stare schedule; nie usuwaj samej sekcji, bo wrangler zostawiłby je nietknięte). Handler scheduled jest w worker.admin.ts — przy pełnym rollbacku wróć do wersji worker.ts sprzed 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 tras
  • scripts/variants/ — generator, prune/restore, build, pomiar
  • lib/variants/routing.ts + testy — logika przekierowań
  • wrangler.toml / wrangler.admin.toml — konfiguracja workerów
  • AP-773 wraz z podzadaniami AP-774…AP-781