sveltekit-multizone-poc Svelte Themes

Sveltekit Multizone Poc

POC: SvelteKit-SSR apps as multi-zones in an NX monorepo, sharing a single first-party session cookie via a local edge proxy.

sveltekit-multizone-poc

Proof-of-concept that three SSR apps (two SvelteKit + one Astro) can run as multi-zones in an NX monorepo, sharing a single first-party session cookie across all three. Stand-in for the planned vertical split of a monolith, with Akamai routing replaced by a local Node reverse proxy.

What this proves (and what it doesn't)

Proves:

  1. NX manages three independent apps (two SvelteKit, one Astro) in a single workspace, with affected-detection and computation cache.
  2. Each app keeps its own SSR (no shared server, no module federation) — @sveltejs/adapter-node for the SvelteKit apps, @astrojs/node for shop.
  3. Three apps stitched at a path-prefix "edge" look like one site to the browser, regardless of framework.
  4. A single first-party session cookie (auth.session-token, Lucia-style) crosses all three apps automatically — same hostname, no Domain= attribute needed.
  5. Logging in via the identity zone causes both consumer zones (shop, public) to recognize the session — without either consumer app talking to OKTA.
  6. The shared Header/Footer ship as a built shared-ui package (svelte-packagedist/ with raw .svelte source + generated .d.ts + side-effect CSS). Same artifact is consumed by both the SvelteKit zones (vite-plugin-svelte) and the Astro zone (@astrojs/svelte) — no per-consumer build differences.

Does not prove: production performance, real OKTA integration, K8s vs Amplify deployment shape

Run

Requires Node ≥ 22 (uses built-in node:sqlite) and pnpm ≥ 10. Tested on Node 24 + pnpm 10.32 on Windows.

pnpm install
pnpm dev          # vite dev servers + proxy on :8080
# or
pnpm build && pnpm start   # production-shape Node SSR servers + proxy

Open http://localhost:8080. Click Log in. Use any username on the mock-IdP form. After redirect, you're logged in across all three zones.

Architecture

                                         localhost:8080
                                                 │
                                          ┌──────┴──────┐
                                          │  proxy.js   │  (Akamai stand-in)
                                          └──┬──┬──┬────┘
                       /auth/*, /mock-idp/* ──┘  │  └── *  (default)
                                       /shop/* ──┘
                                          │       │       │
                       ┌──────────────────┘       │       └─────────────────┐
                       ▼                          ▼                         ▼
                ┌────────────┐             ┌──────────────┐          ┌────────────┐
                │  identity  │             │     shop     │          │   public   │
                │ port 3001  │             │  port 3002   │          │ port 3003  │
                │ SvelteKit  │             │    Astro     │          │ SvelteKit  │
                │ adapter-   │             │  @astrojs/   │          │ adapter-   │
                │   node     │             │    node      │          │   node     │
                └─────┬──────┘             └──────┬───────┘          └──────┬─────┘
                      │                           │                         │
                      └───────────────────────────┼─────────────────────────┘
                                                  ▼
                                  ┌────────────────────────────────┐
                                  │ packages/shared-auth           │  (Lucia + node:sqlite)
                                  │ packages/shared-ui             │  (svelte-package built dist/)
                                  └───────────────┬────────────────┘
                                                  ▼
                                         data/sessions.sqlite
                                         (DynamoDB stand-in)

The browser sees a single origin (localhost:8080). The cookie set by identity is automatically attached to requests for any path, regardless of which upstream the proxy dispatches the request to. All three apps share the same Lucia config and read/write the same SQLite session store.

Mapping back to production

Each piece of this POC corresponds to a production component:

POC piece Production piece
proxy.js (Node http-proxy) Akamai property rules, path-prefix routing
localhost:8080 single hostname production single hostname
node:sqlite shared file DynamoDB session table (existing dynamodb-adapter.ts in real repo)
Mock IdP at /mock-idp/* Real OKTA via OAuthIdentityProvider (lives in apps/identity post-split)
@sveltejs/adapter-node (identity, public); @astrojs/node (shop) amplify-adapter for Amplify-deployed countries; .k8s/server.js for K8s tier
packages/shared-auth/ packages/shared/auth/ post-split
packages/shared-ui/ (svelte-package built dist/) packages/rendering-machine/ (built shared-layout package with ~12+ transitive deps)
Cookie auth.session-token Cookie auth.session-token — same name, same attributes
concurrently for local boot Three K8s deployments / Amplify apps
nx affected -t build nx affected -t build_ci in production CI

The POC and production share identical Lucia config, identical cookie semantics, identical session-validation logic. Differences live at the boundaries (IdP, store, adapter, edge), not in the cross-zone session architecture being tested.

Auth flow walkthrough

  1. User visits http://localhost:8080/shop/orders while logged out.
  2. proxy.js → shop zone (port 3002).
  3. shop's middleware.ts calls astroSessionMiddleware from shared-auth → no cookie → Astro.locals.user = null.
  4. apps/shop/src/pages/shop/orders.astro inline guard → Astro.redirect('/auth/login?return_to=/shop/orders').
  5. proxy.js → identity zone.
  6. /auth/login → 302 to /mock-idp/authorize?return_to=/auth/callback&state=....
  7. proxy.js → identity (mock-idp is on the identity zone).
  8. Mock-IdP renders a fake login form. User submits.
  9. POST issues a code, 302s to /auth/callback?code=...&state=....
  10. /auth/callback exchanges the code, calls findOrCreateUser + lucia.createSession. Sets auth.session-token cookie via lucia.createSessionCookie(...) with path: '/', HttpOnly, SameSite=Lax.
  11. 302s back to original return path.
  12. Browser sends cookie. proxy.js → shop zone. shop's astroSessionMiddleware validates the session via SQLite lookup → user resolved. Page SSRs with user info.
  13. Subsequent navigation to /account-area (public zone): same cookie attached, same session validated by public's sessionHook. No re-auth, no OKTA call, no inter-app HTTP.

DevTools verification

Open Chrome DevTools while logged in.

Application → Cookies → http://localhost:8080

You should see exactly one row:

Name Value Domain Path Expires HttpOnly Secure SameSite
auth.session-token <random> localhost / session+1h (false in dev) Lax

What to confirm:

  • No explicit Domain= attribute in the original Set-Cookie (look at the response header in the Network tab). Browser defaulted it to localhost. This is the whole reason the cookie crosses zones — it's scoped to the hostname, and all three zones share that hostname.
  • Path=/ — covers every URL on the host, regardless of which zone serves it.

Verification

scripts/verify-auth.mjs automates the full cross-zone check. Run it after pnpm dev or pnpm start:

node scripts/verify-auth.mjs

Expected output:

✓ unauthenticated identity zone reports authenticated=false
✓ /auth/login returns 302
✓ redirects to /mock-idp/authorize
✓ final response after redirects = 200 (landed on /auth/me)
✓ auth.session-token cookie set
✓ identity zone sees session
✓ identity zone resolves user.username = alice
✓ shop zone sees the same session
✓ shop zone resolves the same user
✓ public zone sees the same session
✓ public zone resolves the same user
✓ auth.session-token cookie cleared
✓ identity zone no longer sees session
All checks passed.

NX-specific checks

Cache hit:

pnpm exec nx run shop:build
pnpm exec nx run shop:build  # second run hits cache
# → "Nx read the output from the cache instead of running the command for 1 out of 1 tasks."

Affected detection (per-vertical change):

echo "// touch" >> apps/shop/src/components/ShopHomeView.tsx
pnpm exec nx affected -t build --base=HEAD
# → only `shop:build` runs
git checkout -- apps/shop/src/components/ShopHomeView.tsx

Affected detection (cross-cutting change):

echo "// touch" >> packages/shared-auth/src/lucia.ts
pnpm exec nx affected -t build --base=HEAD
# → all 3 apps build (identity, shop, public)
git checkout -- packages/shared-auth/src/lucia.ts

Project layout

sveltekit-multizone-poc/
├── apps/
│   ├── identity/           # SvelteKit SSR, port 3001 — owns mock IdP, /auth/login, /auth/callback, /auth/logout, /auth/me
│   ├── shop/               # Astro SSR, port 3002 — protected /shop/orders, /shop/api/me; React + Svelte islands
│   └── public/             # SvelteKit SSR, port 3003 — anonymous-with-personalization, /api/me
├── packages/
│   ├── shared-auth/        # Lucia + node:sqlite adapter + sessionHook (SvelteKit) + astroSessionMiddleware (Astro)
│   └── shared-ui/          # svelte-package built shared layout — Header/Footer + types + loader.
│                           # dist/ ships raw .svelte + generated .d.ts + side-effect CSS.
│                           # peer svelte via catalog; consumed identically by SvelteKit and Astro zones.
├── scripts/
│   └── verify-auth.mjs     # automated cross-zone verification
├── data/
│   └── sessions.sqlite     # gitignored — shared session store
├── proxy.js                # local edge router on port 8080
├── nx.json                 # NX task pipeline + named inputs + cache config
├── package.json            # root workspace, dev/build/start scripts
└── pnpm-workspace.yaml     # apps/*, packages/*, plus catalog: pinning shared devDeps
                            #   (svelte, typescript, @sveltejs/*, vite, astro)

Critical environment variables

  • ORIGIN=http://localhost:8080 — set on the SvelteKit apps (identity, public) in production mode (pnpm start) so SvelteKit's CSRF check accepts cross-zone form posts. Production equivalent: set per app deployment to the public origin.
  • HOST=127.0.0.1 — set on shop in production mode so Astro's Node adapter binds the loopback interface (the proxy is the only thing that should reach it).
  • PORT — set per app by the start script (3001/3002/3003).
  • SHARED_AUTH_DB — optional override for the SQLite path. Defaults to <cwd>/data/sessions.sqlite. In production, this would be DynamoDB credentials instead.

Top categories

Loading Svelte Themes