13 KiB
Web Manager — Integration
How the three apps of Web Manager connect: contracts, flows, caching, env wiring, and cross-app extension checklists.
How the Three Apps Connect
┌───────────────────────┐ ┌──────────────────────────┐
│ Admin (Web Studio) │───────►│ Backend (AdonisJS) │
│ editor, drafts, │◄───────│ pages, components, │
│ publish, preview │ DTOs │ settings, transactions │
└───────────┬───────────┘ └────────────┬─────────────┘
│ │
│ iframe /preview?token=… │ POST /api/revalidate (tag purge)
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Public site (Next.js) │
│ server-renders GET /web-manager/public/*, tagged 60s cache │
└─────────────────────────────────────────────────────────────┘
The three never share code, only contracts:
| Contract | Defined by | Consumed by |
|---|---|---|
| Page / block payloads | types/web_content.ts, validator/web_content_validator.ts |
Admin DTOs, web-runtime/types.ts |
| Public read API | WebManager/routes.ts (/web-manager/public/*) |
web-runtime/api/web-runtime-api.ts |
| Cache tag names | services/web_revalidation_service.ts |
web-runtime/api/cache-tags.ts — must stay in sync |
| Preview token | services/preview_token_service.ts |
Admin preview sheet, app/[locale]/preview/ |
| Block / page types | types/web_content.ts |
WebStudio/config/blocks.ts, web-runtime/*/registry |
Save Flow (admin → backend)
editor submits React Hook Form values
PagePayloadSerializer → FormData: `payload` JSON string + one binary part per new file
POST /web-manager/pages | PUT /web-manager/pages/:id/draft (multipart/form-data)
PagePayloadService parses `payload`, validates, stores files through FileManager,
rewrites { name, fileKey } media values into { name, ref }
WebManagerService writes page + components in one transaction
Media inside hero and block data is either { name, ref } (already stored) or
{ name, fileKey } (new upload); only ref values are ever persisted. Part names are the
fileKeys (file-0, file-1, …). A request without a payload part is read as a plain JSON body,
so the endpoints stay usable from tests and scripts. The payload travels as one JSON string so
booleans, numbers, nulls and empty arrays survive the multipart transport.
Publish Flow (backend → public site)
publish/unpublish/settings commit
WebRevalidationService POSTs { tags } to WEB_APP_URL/api/revalidate with X-Revalidate-Secret
Next.js revalidateTag(tag, 'max') per tag
response meta.revalidation = purged | retrying | disabled → admin toast
Public Request Flow (visitor → web → backend)
GET /en/about-us
next-intl middleware resolves the locale segment
app/[locale]/[[...path]]/page.tsx → resolvePageRenderData(locale, path)
GET /web-manager/public/pages/resolve?locale=en&path=about-us (next: revalidate 60 + tags)
serializePublicPage returns page, components, settings, breadcrumbs, languageAlternatives
renderer.tsx → header + RegisteredPageLayout + footer; blocks via the block registry
Before it leaves the backend, PublicContentService.prepare resolves internal links to hrefs,
turns stored file refs into URLs, and sanitizes rich text (content_sanitizer.ts: allowlisted tags,
http/https/mailto/tel only, rel limited to nofollow/noopener/noreferrer). The web app
renders rich text as HTML and trusts that sanitization.
Caching and Revalidation
Every public fetch is tagged and time-boxed, so a purge that never lands costs about a minute of stale content rather than correctness.
| Fetch | revalidate |
Tags |
|---|---|---|
| page | 60s | web-manager, web-manager:page:{locale}:{path}, settings tag |
| settings | 60s | web-manager, web-manager:settings:{locale} |
| blog list | 60s | web-manager, web-manager:blog:{locale} |
| sitemap | 3600s | web-manager only — it spans every locale and page |
Tag builders are duplicated in web_revalidation_service.ts (backend) and cache-tags.ts (web) and
must stay in sync; both sides have tests over the names, because a drift stops purges from landing
without anything failing.
Delivery is one attempt inline with the request (so the outcome can be reported to the editor) and
attempts 2–3 in the background with exponential backoff and jitter. Publish, unpublish and settings
save therefore return meta.revalidation:
status |
Meaning |
|---|---|
purged |
The web app confirmed the purge. |
retrying |
The first attempt failed; background retries are running. |
disabled |
WEB_APP_URL or WEB_REVALIDATE_SECRET is unset, so nothing was sent. |
The admin shows a warning toast on anything but purged, so an editor is never told the site is up
to date when it is not. Retries live in memory only — a restart drops them onto the same 60s floor a
failed purge already has. Exhausting all attempts logs at error; publishing again sends a fresh
purge. A deployment missing either variable logs each skipped purge at warn in production.
Configuration
| App | Variable | Purpose |
|---|---|---|
| backend | APP_KEY |
Signs preview tokens. |
| backend | WEB_APP_URL |
Where to POST cache purges. Server-side URL (compose service name in Docker). |
| backend | WEB_REVALIDATE_SECRET |
Must equal the web app's REVALIDATE_SECRET. |
| web | BACKEND_API_URL |
Backend base URL, without the /web-manager/public/... part. Required. |
| web | REVALIDATE_SECRET |
Guards /api/revalidate. |
| web | SITE_URL |
Canonical origin for metadata. Required in production. |
| admin | VITE_WEB_URL |
Public site origin used to build preview and "view page" links. |
Backend tuning constants live in WebManager/config/general.ts — see
Backend → Tuning Constants.
Extending
Add a block type
- Backend: add the value to
BLOCK_TYPESand its data type inWebManager/types/web_content.ts, and its shape to the component union invalidator/web_content_validator.ts, so the payload is validated server-side. - Admin: add the enum value to
entities/BlockType.ts, the block shape underentities/block/, an editor underview/forms/blocks/<Name>Block/with itsschema.ts, and an entry inconfig/blocks.ts(label, description, preview image,createBlockfactory). Add it to theallowedBlockTypesof every page type that should offer it inconfig/pages.ts. - Web: add the renderer under
web-runtime/blocks/components/and register it inweb-runtime/blocks/registry.tsx, plus the type inweb-runtime/types.ts. - Translations: add the label and description keys to both
config/translation/en.jsonanduk.json— the two files are kept at exact key parity.
apps/web/tests/registry.test.ts keeps a hand-mirrored copy of BLOCK_TYPES and PAGE_TYPES and
asserts the registries match it exactly — add the type there too, and it becomes the tripwire for a
block the backend can publish but the site cannot draw.
Block preview images
Wireframe SVGs in apps/admin/public/media/web-studio/blocks/<type>.svg, referenced by
previewImage in config/blocks.ts and rendered by BlockPickerSheet as a 4:3 thumbnail. They are
schematic, not screenshots: viewBox="0 0 400 300", no width/height, content inside
x: 40–360 / y: 32–268, and no text — everything reads as bars and shapes at 112px wide.
| Token | Hex | Used for |
|---|---|---|
| background | #f7f4ec |
full-canvas rect, always the first element |
| surface | #fffdf8 |
cards, accordion rows, banner strip |
| surface border | #d9dacf |
1px stroke on surfaces and image placeholders |
| image fill | #e7e9dc |
image placeholder rects |
| ink | #1f291f |
titles, button inner bars, icons |
| text line | #ccd1c0 |
body copy bars |
| muted line | #b9bfab |
subtitles, captions, image glyphs |
| accent | #c7ff3f |
primary CTA pills, sun circle, active state |
| accent border | #a8c930 |
stroke marking an active or open element |
Reuse the shared vocabulary so previews read as one family: title bar (dark rect, height 14–18,
rx = height/2), body line (height 9–10, last line ~70% width), card (surface + border,
rx 10–14), CTA pill (accent, height 24–28, dark label bar inside), image placeholder (mountain
polygon plus accent sun). Draw the distinguishing structure only — three cards means "a list",
2–4 lines mean "a paragraph" — and keep at most one accent element per preview.
Add a page type
Add the value to PAGE_TYPES in WebManager/types/web_content.ts and entities/PageType.ts, a
WebStudioPageConfig in config/pages.ts (allowed blocks, default path, label keys), a layout
component in web-runtime/layouts/components/, its entry in PAGE_LAYOUT_REGISTRY, and the
mirrored list in registry.test.ts. An unregistered type still renders through the standard
layout, so the site cannot break mid-rollout.
Tests
| Suite | Command | Location | Covers |
|---|---|---|---|
| Backend | npm run test:backend |
apps/backend/tests/functional/web_manager/ |
Preview token, revalidation, public content, sanitizer, breadcrumbs; page lifecycle (web_manager_service.spec.ts: draft/clone/publish/unpublish, route reservation, purge tags); HTTP surface (pages_api.spec.ts: 401/403 per permission, multipart, public endpoints). |
| Admin | npm run test:admin |
apps/admin/tests/unit/core/WebStudio/ |
Menu, SEO helpers, list mapper, payload serializer, editor-value mapping and validation, toCreatePayload, block factory↔schema round-trips, field schemas, page paths, permission helper. |
| Web | npm run test:web |
apps/web/tests/ |
Normalization, cache tags, block/layout registries, page metadata, sitemap, site URL, structured data, safeHref, published-only gate, /api/revalidate, API client, blog pager. |
Notes:
- DB-backed backend specs run against a dedicated
adonis_testdatabase (.env.test): the runner migrates up/down and each test runs in a global transaction (fixtures intests/utils/web_manager.ts)..env.testleavesWEB_REVALIDATE_SECRETempty, so revalidation reportsdisabledwhile still exposing computed tags for assertions. apps/web/vitest.config.tsmaps#*to the app root (Vitest ignorestsconfigpaths) and stubsserver-only, which only resolves inside a Next build.- Not covered: rendering the block and layout components — needs jsdom and
@testing-library/react, neither a dependency yet.