@elytracms/studio 0.0.87 → 0.0.90
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/convex/asset-revalidate.test.ts +35 -42
- package/convex/delivery.ts +8 -11
- package/convex/document-revalidate.test.ts +175 -0
- package/convex/redirect-revalidate.test.ts +36 -36
- package/convex/revalidate.ts +18 -30
- package/convex/triggers.ts +47 -38
- package/convex/webhooks.ts +77 -62
- package/dist/components/account/self-account-view-source.js +2 -1
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +9 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/app-views/default-modules.js +3 -3
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/assets/asset-grid.js +1 -1
- package/dist/components/assets/asset-grid.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.js +3 -2
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +11 -1
- package/dist/components/assets/assets-store.js +10 -0
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/focal-point.d.ts +0 -7
- package/dist/components/assets/focal-point.js +0 -10
- package/dist/components/assets/focal-point.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +12 -1
- package/dist/components/builder/canvas-render-context.js +20 -16
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/reuse-context.js +2 -2
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +6 -2
- package/dist/components/builder/reuse.js +12 -7
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +2 -1
- package/dist/components/cms/asset-field.js +27 -59
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +77 -40
- package/dist/components/cms/asset-picker-core.js +73 -46
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.d.ts +48 -19
- package/dist/components/cms/asset-picker.js +69 -30
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +3 -7
- package/dist/components/cms/cell-edit.js +7 -25
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +38 -47
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +14 -4
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +20 -21
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/duplicate-document.d.ts +3 -2
- package/dist/components/cms/duplicate-document.js +5 -5
- package/dist/components/cms/duplicate-document.js.map +1 -1
- package/dist/components/cms/editable-cell.js +3 -6
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.js +7 -6
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +27 -2
- package/dist/components/cms/gate-validation.js +19 -1
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/publish-clean.d.ts +13 -6
- package/dist/components/cms/publish-clean.js +14 -6
- package/dist/components/cms/publish-clean.js.map +1 -1
- package/dist/components/cms/rich-text/editor.js +2 -2
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +7 -0
- package/dist/components/cms/rich-text/insert.js +7 -0
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/use-publish-controller.js +3 -2
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +1 -0
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +10 -4
- package/dist/components/components/transfer.js +11 -9
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +3 -2
- package/dist/components/delivery-health-banner.js +3 -2
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -3
- package/dist/components/nav-issues.js +38 -2
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.js +3 -2
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/section-nav.js +26 -2
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/users/users-collection-view-source.js +2 -1
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/lib/ai/apply-content.js +32 -28
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +10 -4
- package/dist/lib/ai/tools.js +12 -4
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.js +11 -7
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +9 -5
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.js +10 -29
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/vite.d.ts +22 -2
- package/dist/lib/ai/vite.js +12 -3
- package/dist/lib/ai/vite.js.map +1 -1
- package/dist/lib/cms/published-documents.d.ts +14 -3
- package/dist/lib/cms/published-documents.js +12 -3
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +4 -4
- package/dist/lib/cms-views/view-model.js +16 -13
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +22 -1
- package/dist/lib/data/collection-content.js +51 -14
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/delivery/instant-publish.d.ts +0 -3
- package/dist/lib/delivery/instant-publish.js +0 -1
- package/dist/lib/delivery/instant-publish.js.map +1 -1
- package/dist/lib/engine-version.d.ts +3 -3
- package/dist/lib/engine-version.js +3 -3
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +4 -0
- package/dist/lib/i18n/messages.en.js +6 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/locale/fallback.d.ts +5 -19
- package/dist/lib/locale/fallback.js +18 -39
- package/dist/lib/locale/fallback.js.map +1 -1
- package/dist/lib/locale/locale-store.js +2 -1
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +24 -26
- package/dist/lib/operations/session-hydration.js +15 -53
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +4 -6
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/backend-mode.d.ts +5 -25
- package/dist/lib/persistence/backend-mode.js +13 -30
- package/dist/lib/persistence/backend-mode.js.map +1 -1
- package/dist/lib/references/reference-model.d.ts +17 -3
- package/dist/lib/references/reference-model.js +17 -23
- package/dist/lib/references/reference-model.js.map +1 -1
- package/dist/lib/references/references-store.js +7 -0
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/workspace/config-issues.d.ts +6 -0
- package/dist/lib/workspace/config-issues.js +8 -4
- package/dist/lib/workspace/config-issues.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -8
- package/dist/lib/workspace/index.js +1 -10
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/navigate-once.d.ts +19 -4
- package/dist/router/navigate-once.js +22 -5
- package/dist/router/navigate-once.js.map +1 -1
- package/package.json +3 -7
- package/dist/lib/webhooks/config.d.ts +0 -15
- package/dist/lib/webhooks/config.js +0 -65
- package/dist/lib/webhooks/config.js.map +0 -1
- package/dist/lib/webhooks/dispatcher.d.ts +0 -70
- package/dist/lib/webhooks/dispatcher.js +0 -61
- package/dist/lib/webhooks/dispatcher.js.map +0 -1
- package/dist/lib/webhooks/index.d.ts +0 -11
- package/dist/lib/webhooks/index.js +0 -10
- package/dist/lib/webhooks/index.js.map +0 -1
- package/dist/lib/webhooks/signing.d.ts +0 -15
- package/dist/lib/webhooks/signing.js +0 -24
- package/dist/lib/webhooks/signing.js.map +0 -1
- package/dist/lib/webhooks/tags.d.ts +0 -25
- package/dist/lib/webhooks/tags.js +0 -289
- package/dist/lib/webhooks/tags.js.map +0 -1
|
@@ -4,7 +4,7 @@ import type { SidebarNode } from '../../components/app-views/module.js';
|
|
|
4
4
|
import type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config.js';
|
|
5
5
|
import type { GeneratedPreviewLookup } from '../../components/builder/blocks-model.js';
|
|
6
6
|
import type { WorkspaceState } from './workspace-source.js';
|
|
7
|
-
export { PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
7
|
+
export { PROJECT_CONFIG_FILE, PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
8
8
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
9
9
|
export type { WorkspaceSources, WorkspaceState } from './workspace-source.js';
|
|
10
10
|
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
@@ -163,13 +163,6 @@ export declare function resolveDeploymentReuse(): StudioConfig['reuse'];
|
|
|
163
163
|
* declares none, or when the studio is wired by env alone with no config.
|
|
164
164
|
*/
|
|
165
165
|
export declare function resolveDeploymentRoutes(): RouteRecord[];
|
|
166
|
-
/**
|
|
167
|
-
* The configured locales of the active config project (AD-8). The same locale
|
|
168
|
-
* set the host stamps onto its per-locale cache tags — used by the revalidation
|
|
169
|
-
* webhook layer (EC-192) so a publish always invalidates EVERY configured locale
|
|
170
|
-
* (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.
|
|
171
|
-
*/
|
|
172
|
-
export declare function resolveDeploymentLocales(): string[];
|
|
173
166
|
/**
|
|
174
167
|
* The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
|
|
175
168
|
* structure and structure is code: when `elytra.config.ts` declares them, they
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { frontendUrlForEnvironment } from '@elytracms/core/studio-core';
|
|
2
2
|
import { provideStudioEnv } from '../persistence/backend-mode.js';
|
|
3
3
|
import { resolveWorkspaceState } from './workspace-source.js';
|
|
4
|
-
export { PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
4
|
+
export { PROJECT_CONFIG_FILE, PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
5
5
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
6
6
|
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
7
7
|
let cached = null;
|
|
@@ -242,15 +242,6 @@ export function resolveDeploymentRoutes() {
|
|
|
242
242
|
// Redirects are editorial DATA now (EC-303), managed in the studio Redirects rail
|
|
243
243
|
// and served dynamically — no longer resolved from config. `resolveDeploymentRedirects`
|
|
244
244
|
// is retired; static/infra redirects are the dev's job via native next.config/vercel.
|
|
245
|
-
/**
|
|
246
|
-
* The configured locales of the active config project (AD-8). The same locale
|
|
247
|
-
* set the host stamps onto its per-locale cache tags — used by the revalidation
|
|
248
|
-
* webhook layer (EC-192) so a publish always invalidates EVERY configured locale
|
|
249
|
-
* (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.
|
|
250
|
-
*/
|
|
251
|
-
export function resolveDeploymentLocales() {
|
|
252
|
-
return activeConfigProject()?.locales.locales ?? [];
|
|
253
|
-
}
|
|
254
245
|
/**
|
|
255
246
|
* The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
|
|
256
247
|
* structure and structure is code: when `elytra.config.ts` declares them, they
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/workspace/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { frontendUrlForEnvironment } from '@elytracms/core/studio-core'\nimport type {\n BreakpointConfig,\n ElytraConfigInput,\n ResolvedWorkspaceProject,\n} from '@elytracms/core/studio-core'\nimport type { CollectionDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport type { SidebarNode } from '../../components/app-views/module'\nimport type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config'\nimport type { GeneratedPreviewLookup } from '../../components/builder/blocks-model'\nimport { provideStudioEnv } from '../persistence/backend-mode'\nimport { resolveWorkspaceState } from './workspace-source'\nimport type { WorkspaceState } from './workspace-source'\n\nexport {\n PROJECT_CONFIG_ISSUE_KEYS,\n PROJECT_CONFIG_POINT,\n projectConfigIssues,\n} from './config-issues'\nexport { resolveWorkspaceState } from './workspace-source'\nexport type { WorkspaceSources, WorkspaceState } from './workspace-source'\nexport {\n isSingleProjectWorkspace,\n launcherAutoForwardTarget,\n persistedProjects,\n selectSwitcherProjects,\n soleWorkspaceProject,\n unprovisionedProjects,\n} from './switcher-source'\nexport type { SwitcherEntry, SwitcherSourceInput } from './switcher-source'\n\nlet cached: WorkspaceState | null = null\nlet studioConfig: StudioConfig | null = null\n\n/**\n * Mount the studio against a project config (EC-223 mount contract) and an\n * optional studio-only customization config (EC-236). The consumer's entry point\n * does `import config from './elytra.config'` + `import studio from\n * './studio.config'` and calls this — an explicit init, NOT an `import.meta.glob`\n * autoload that reaches into the consumer's filesystem (Oli, 2026-06-19: \"there's\n * always an entry point that can trigger a function call\").\n *\n * Two configs, deliberately: `config` (`elytra.config.ts`) is the project\n * definition, shared with the `frontend/` delivery bundle and projected to data;\n * `studio` (`studio.config.tsx` via `defineStudio`) is live builder UI code — the\n * section rail, custom field renderers, branding — that must NEVER reach delivery.\n * Keeping them separate is the customization boundary principle in the mount\n * signature. Seeds the workspace state + the studio-config holder every later\n * query (`getActiveProjectSlug`, `resolveDeployment*`) reads.\n *\n * EC-357 — `options.env`: the consumer's `import.meta.env`, captured HERE because\n * the entry module is code Vite always transforms live. The engine's own\n * `import.meta.env` reads are unreliable inside dep-optimized package code (Vite ≥8\n * omits custom `VITE_*` values there; Vite 7 froze them at optimize time), which\n * silently broke backend URL / mode / dev-login resolution in real consumers.\n * Always pass it: `createStudio(config, studioConfig, { env: import.meta.env })`.\n */\nexport interface StudioMountOptions {\n /** The consumer entry's `import.meta.env` (see `provideStudioEnv` for the contract). */\n env?: Record<string, unknown>\n}\n\nexport function createStudio(\n config: ElytraConfigInput,\n studio?: StudioConfig,\n options?: StudioMountOptions,\n): void {\n provideStudioEnv(options?.env)\n cached = resolveWorkspaceState({\n fileModule: { default: config },\n fileName: 'elytra.config.ts',\n })\n studioConfig = studio ?? null\n}\n\n/** Test seam: set/clear the studio customization config. Never used by app code. */\nexport function setStudioConfigForTests(next: StudioConfig | null): void {\n studioConfig = next\n}\n\n/**\n * The active project slug (EC-179, vision AD-8). One project per backend, so the\n * active project selects which Convex deployment the studio talks to. The root\n * layout sets this from the URL (`/<slug>`) — or, on the sign-in screen, from\n * the `redirect` target's slug — before the auth/persistence adapters are\n * selected, so the *initial* sign-in authenticates against the project the user\n * is heading for (Sanity-style: authenticate to one workspace, then the full\n * config-based switcher is available). `null` at the launcher → the default\n * (first) configured project's deployment.\n */\nlet activeProjectSlug: string | null = null\n\n/** Set the active project slug; returns whether the resolved deployment changed. */\nexport function setActiveProjectSlug(slug: string | null): boolean {\n const before = resolveDeploymentUrl('')\n activeProjectSlug = slug && slug.trim() !== '' ? slug : null\n return resolveDeploymentUrl('') !== before\n}\n\nexport function getActiveProjectSlug(): string | null {\n return activeProjectSlug\n}\n\n/** Test seam: reset the active project slug. Never used by app code. */\nexport function setActiveProjectSlugForTests(slug: string | null): void {\n activeProjectSlug = slug\n}\n\n/**\n * Reserved first-path segments that are NOT project slugs: the studio's own\n * root routes (EC-220). Project surfaces now live at `/<slug>/...` (no `/p/`\n * prefix), so the studio's root namespace is shared between project slugs and\n * these fixed routes — they must be excluded from slug extraction. Keep in\n * sync with the non-`$projectId` files in `src/routes/`; the launcher (`/`) is\n * the empty segment, excluded by the regex requiring one path char.\n */\nconst RESERVED_ROOT_SEGMENTS: ReadonlySet<string> = new Set(['sign-in', 'styleguide'])\n\n/** Extract a project slug from a studio path (`/<slug>/...`), else `null`. */\nexport function projectSlugFromPath(pathname: string): string | null {\n const match = /^\\/([^/?#]+)/.exec(pathname)\n if (!match) return null\n const segment = decodeURIComponent(match[1]!)\n return RESERVED_ROOT_SEGMENTS.has(segment) ? null : segment\n}\n\n/**\n * The project whose backend the studio should bind for a given location\n * (EC-179). A project path (`/<slug>`) binds that project; the sign-in screen\n * binds the project from its `redirect` target so the *initial* sign-in\n * authenticates against the backend the user is heading for (Sanity-style).\n * Anywhere else (the launcher) → `null`, i.e. the default/first project.\n */\nexport function activeSlugForLocation(pathname: string, redirect?: string): string | null {\n const direct = projectSlugFromPath(pathname)\n if (direct) return direct\n if (pathname === '/sign-in' && redirect) return projectSlugFromPath(redirect)\n return null\n}\n\n/** The active config project (by slug), or the first configured project. */\nfunction activeConfigProject() {\n const state = getWorkspaceState()\n if (state.kind !== 'config') return undefined\n if (activeProjectSlug) {\n const match = state.projects.find((p) => p.slug === activeProjectSlug)\n if (match) return match\n }\n return state.projects[0]\n}\n\n/**\n * The studio's resolved workspace state. Deterministic per boot: the config is\n * static input (bundled file or deployment env), so it is resolved once and\n * cached — every consumer (gate, switcher, launcher) sees the same state.\n */\nexport function getWorkspaceState(): WorkspaceState {\n // Seeded by `createStudio(config)` at the entry point. Until then — or when the\n // consumer wires no config — resolve to the empty `none` state (the single-project\n // golden path), rather than reaching for the filesystem.\n if (!cached) cached = resolveWorkspaceState({})\n return cached\n}\n\n/** Test seam: clear/replace the cached state. Never used by app code. */\nexport function setWorkspaceStateForTests(next: WorkspaceState | null): void {\n cached = next\n}\n\n/**\n * Resolve the Convex deployment URL the studio should connect to (vision AD-8:\n * the backend is wired in config). One project per backend, so the **active**\n * project (see {@link setActiveProjectSlug}) selects the deployment via its\n * `convex.deployment` (or its production environment's). At the launcher (no\n * active project) the first configured project is the default backend. Absent\n * config — or a project without a wired deployment — falls back to\n * `VITE_CONVEX_URL` (`fallbackUrl`) for local single-deployment development.\n */\nexport function resolveDeploymentUrl(fallbackUrl: string): string {\n return projectDeploymentUrl(activeConfigProject(), fallbackUrl)\n}\n\n/** The deployment URL for a specific project slug (per-backend auth checks). */\nexport function deploymentUrlForSlug(slug: string, fallbackUrl: string): string {\n const state = getWorkspaceState()\n const project = state.kind === 'config' ? state.projects.find((p) => p.slug === slug) : undefined\n return projectDeploymentUrl(project, fallbackUrl)\n}\n\n/** A project's wired deployment: per-environment ref → project ref → env fallback. */\nfunction projectDeploymentUrl(\n project: ResolvedWorkspaceProject | undefined,\n fallbackUrl: string,\n): string {\n if (!project) return fallbackUrl\n const envDeployment = project.environments.find((e) => e.convex?.deployment)?.convex?.deployment\n return envDeployment ?? project.convex?.deployment ?? fallbackUrl\n}\n\n/**\n * The slug of the project bound to the active deployment (EC-179). One project\n * per backend, so the active config project's slug identifies the deployment's\n * one project; the Convex adapter stamps it onto deployment-scoped records.\n * Absent config falls back to `fallbackId` (used only when wired by env alone).\n */\nexport function resolveDeploymentProjectId(fallbackId: string): string {\n return activeConfigProject()?.slug ?? fallbackId\n}\n\n/**\n * The CMS collections of the active config project (AD-11, EC-183). CMS\n * collection schemas are config-owned: the studio reads the model from\n * `elytra.config.ts` (served through the persistence port — the backend has no\n * `cmsSchemas` table). `[]` when the active project declares none, or when the\n * studio is wired by env alone with no config.\n */\nexport function resolveDeploymentCollections(): CollectionDef[] {\n return activeConfigProject()?.collections ?? []\n}\n\n/**\n * The canvas preview breakpoints of the active config project (EC-250).\n * Config-sourced (config-as-code, derived from the site's Tailwind `screens`);\n * `undefined` when the project declares none — the canvas then falls back to\n * Tailwind's default screens (see `resolveCanvasViewports`), never a baked-in triple.\n */\nexport function resolveDeploymentBreakpoints(): BreakpointConfig[] | undefined {\n return activeConfigProject()?.breakpoints\n}\n\n/**\n * The custom field renderers from the studio config (EC-238): control name →\n * component. Studio-only live code (from `defineStudio`, never the project schema\n * or the delivery bundle); `undefined` when none — the document editor then uses\n * its built-in control for every field. Studio-wide (not per-project): the rail and\n * renderers customize the studio, not one backend.\n */\nexport function resolveDeploymentFieldRenderers(): Record<string, CustomFieldRenderer> | undefined {\n return studioConfig?.fieldRenderers\n}\n\n/**\n * The studio-UI localization config from the studio config (EC-241): the default\n * locale + imported translation catalogs. Studio-only live code (from `defineStudio`,\n * never the project schema or the delivery bundle); `undefined` when none — the\n * studio then renders its built-in English chrome. Studio-wide, like the rail and\n * field renderers (localizes the studio, not one backend).\n */\nexport function resolveDeploymentI18n(): StudioConfig['i18n'] {\n return studioConfig?.i18n\n}\n\n/**\n * The AI platform config from the studio config (EC-331): provider, knowledge, and\n * limits. Studio-only live code (from `defineStudio`, never the delivery bundle);\n * `undefined` when no `ai` block is configured — the feature is then fully absent\n * (the AI subsystem never boots, so `@elytracms/studio/lib/ai`'s runtime — and the\n * `ai`/`@ai-sdk/*` SDK behind it — is never imported). Studio-wide, like the rail\n * and field renderers.\n */\nexport function resolveDeploymentAi(): StudioConfig['ai'] {\n return studioConfig?.ai\n}\n\n/**\n * The generated-preview lookup from the studio config (EC-439 AD-2, the seam\n * EC-440 fills): a pure `componentId + fixtureName → URL` function the blocks\n * panel threads into its tile model. Studio-only live code (from `defineStudio`,\n * never the delivery bundle); `undefined` when none — tiles then fall back from\n * a curated `manifest.previewImage` straight to the icon+name placeholder.\n * Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentGeneratedPreviews(): GeneratedPreviewLookup | undefined {\n return studioConfig?.previews?.generated\n}\n\n/**\n * The configurable section rail from the studio config (EC-237): an ordered list of\n * `nav.*` nodes (views / groups / separators). Studio-only live code (carries view\n * objects + icons); `undefined` when none — the studio then derives the default\n * rail from its built-in views. Invalid entries surface as visible issues at\n * resolution, never silent.\n */\nexport function resolveDeploymentSidebar(): SidebarNode[] | undefined {\n return studioConfig?.sidebar\n}\n\n/**\n * The canvas page-surface theme from the studio config (EC-389): body classes +\n * raw CSS for the preview iframe, so the canvas wears the SITE's chrome. Studio-only\n * (from `defineStudio`, never the delivery bundle); `undefined` when none — the\n * canvas keeps its built-in defaults (`bg-background text-foreground font-sans`\n * body). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentCanvasTheme(): StudioConfig['canvas'] {\n return studioConfig?.canvas\n}\n\n/**\n * The reusable-elements declaration from the studio config (EC-467): WHICH\n * component is the project's reuse embed. Studio-only (from `defineStudio`, never\n * the delivery bundle); `undefined` when none — the studio then INFERS the embed\n * from the manifests + collections exactly as it always has (absence means infer,\n * not off). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentReuse(): StudioConfig['reuse'] {\n return studioConfig?.reuse\n}\n\n/**\n * The resolved URL routes of the active config project (EC-207, \"routes as\n * code\"). Routes are config-sourced: the studio reads the materialized\n * `RouteRecord[]` from the active `elytra.config.ts` project — never from the\n * backend (the `routes` system collection is retired). The Routes surface and\n * the per-page URL preview both read through here. `[]` when the active project\n * declares none, or when the studio is wired by env alone with no config.\n */\nexport function resolveDeploymentRoutes(): RouteRecord[] {\n return activeConfigProject()?.routes ?? []\n}\n\n// Redirects are editorial DATA now (EC-303), managed in the studio Redirects rail\n// and served dynamically — no longer resolved from config. `resolveDeploymentRedirects`\n// is retired; static/infra redirects are the dev's job via native next.config/vercel.\n\n/**\n * The configured locales of the active config project (AD-8). The same locale\n * set the host stamps onto its per-locale cache tags — used by the revalidation\n * webhook layer (EC-192) so a publish always invalidates EVERY configured locale\n * (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.\n */\nexport function resolveDeploymentLocales(): string[] {\n return activeConfigProject()?.locales.locales ?? []\n}\n\n/**\n * The active config project's `LocaleConfig` (EC-357 finding #3). Locales are\n * structure and structure is code: when `elytra.config.ts` declares them, they\n * drive the studio's ambient locale switcher in EVERY mode — the CMS surface must\n * not fall back to the sample `en/de` fixture (playground, or a connected first\n * run with nothing persisted) when the config says otherwise. `undefined` when no\n * config project is active (env-only wiring keeps the existing kit/sample\n * fallbacks).\n *\n * EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`\n * pair into a `LocaleConfig`, and had grown its own repair rule — an absent or\n * out-of-set default fell back to `locales[0]`. That is a fourth place a locale\n * could be invented, in the surface furthest from the author. The resolver now\n * hands over the same `LocaleConfig` the delivery host takes, so there is nothing\n * to reassemble and nothing to repair: a default outside the set is a config\n * ERROR (`default-locale-not-in-locales`) and the project never resolves.\n */\nexport function resolveDeploymentLocaleConfig(): LocaleConfig | undefined {\n return activeConfigProject()?.locales\n}\n\n/**\n * The active project's components reference (EC-194, AD-11): where the consuming\n * repo's OWN real components live (`{ package }` or `{ dir }`). When set, the\n * studio registry composes with the repo's components instead of the built-in\n * demo fixtures — so the page builder edits the repo's actual component library.\n * `undefined` → the project ships no code components; the studio falls back to the\n * golden-path fixtures.\n */\nexport function resolveDeploymentComponents() {\n return activeConfigProject()?.components\n}\n\n/**\n * The active project's public site URL (EC-215): the live site's base URL the\n * studio links to (a document's live URL + open-live button, EC-216). Resolved\n * in lockstep with {@link resolveDeploymentUrl} — the environment bound to the\n * active deployment (the first with a wired deployment) provides the URL, falling\n * back to the project-level `frontendUrl`. No active-environment selector exists\n * yet, so the bound environment is the active one. `undefined` when the active\n * project (or env-only wiring) configures no frontend URL.\n */\nexport function resolveDeploymentFrontendUrl(): string | undefined {\n const project = activeConfigProject()\n if (!project) return undefined\n const boundEnv = project.environments.find((e) => e.convex?.deployment)\n return frontendUrlForEnvironment(project, boundEnv?.key)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/workspace/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAA;AAUvE,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAG1D,OAAO,EACL,mBAAmB,EACnB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAE1D,OAAO,EACL,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,sBAAsB,EACtB,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAG1B,IAAI,MAAM,GAA0B,IAAI,CAAA;AACxC,IAAI,YAAY,GAAwB,IAAI,CAAA;AA8B5C,MAAM,UAAU,YAAY,CAC1B,MAAyB,EACzB,MAAqB,EACrB,OAA4B;IAE5B,gBAAgB,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;IAC9B,MAAM,GAAG,qBAAqB,CAAC;QAC7B,UAAU,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE;QAC/B,QAAQ,EAAE,kBAAkB;KAC7B,CAAC,CAAA;IACF,YAAY,GAAG,MAAM,IAAI,IAAI,CAAA;AAC/B,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,uBAAuB,CAAC,IAAyB;IAC/D,YAAY,GAAG,IAAI,CAAA;AACrB,CAAC;AAED;;;;;;;;;GASG;AACH,IAAI,iBAAiB,GAAkB,IAAI,CAAA;AAE3C,oFAAoF;AACpF,MAAM,UAAU,oBAAoB,CAAC,IAAmB;IACtD,MAAM,MAAM,GAAG,oBAAoB,CAAC,EAAE,CAAC,CAAA;IACvC,iBAAiB,GAAG,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;IAC5D,OAAO,oBAAoB,CAAC,EAAE,CAAC,KAAK,MAAM,CAAA;AAC5C,CAAC;AAED,MAAM,UAAU,oBAAoB;IAClC,OAAO,iBAAiB,CAAA;AAC1B,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,4BAA4B,CAAC,IAAmB;IAC9D,iBAAiB,GAAG,IAAI,CAAA;AAC1B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,sBAAsB,GAAwB,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAA;AAEtF,8EAA8E;AAC9E,MAAM,UAAU,mBAAmB,CAAC,QAAgB;IAClD,MAAM,KAAK,GAAG,cAAc,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC3C,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,MAAM,OAAO,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAE,CAAC,CAAA;IAC7C,OAAO,sBAAsB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAA;AAC7D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,QAAgB,EAAE,QAAiB;IACvE,MAAM,MAAM,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAA;IAC5C,IAAI,MAAM;QAAE,OAAO,MAAM,CAAA;IACzB,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ;QAAE,OAAO,mBAAmB,CAAC,QAAQ,CAAC,CAAA;IAC7E,OAAO,IAAI,CAAA;AACb,CAAC;AAED,4EAA4E;AAC5E,SAAS,mBAAmB;IAC1B,MAAM,KAAK,GAAG,iBAAiB,EAAE,CAAA;IACjC,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAA;IAC7C,IAAI,iBAAiB,EAAE,CAAC;QACtB,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,iBAAiB,CAAC,CAAA;QACtE,IAAI,KAAK;YAAE,OAAO,KAAK,CAAA;IACzB,CAAC;IACD,OAAO,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAA;AAC1B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,gFAAgF;IAChF,mFAAmF;IACnF,yDAAyD;IACzD,IAAI,CAAC,MAAM;QAAE,MAAM,GAAG,qBAAqB,CAAC,EAAE,CAAC,CAAA;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,yBAAyB,CAAC,IAA2B;IACnE,MAAM,GAAG,IAAI,CAAA;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,WAAmB;IACtD,OAAO,oBAAoB,CAAC,mBAAmB,EAAE,EAAE,WAAW,CAAC,CAAA;AACjE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,oBAAoB,CAAC,IAAY,EAAE,WAAmB;IACpE,MAAM,KAAK,GAAG,iBAAiB,EAAE,CAAA;IACjC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IACjG,OAAO,oBAAoB,CAAC,OAAO,EAAE,WAAW,CAAC,CAAA;AACnD,CAAC;AAED,sFAAsF;AACtF,SAAS,oBAAoB,CAC3B,OAA6C,EAC7C,WAAmB;IAEnB,IAAI,CAAC,OAAO;QAAE,OAAO,WAAW,CAAA;IAChC,MAAM,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,CAAA;IAChG,OAAO,aAAa,IAAI,OAAO,CAAC,MAAM,EAAE,UAAU,IAAI,WAAW,CAAA;AACnE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B,CAAC,UAAkB;IAC3D,OAAO,mBAAmB,EAAE,EAAE,IAAI,IAAI,UAAU,CAAA;AAClD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B;IAC1C,OAAO,mBAAmB,EAAE,EAAE,WAAW,IAAI,EAAE,CAAA;AACjD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B;IAC1C,OAAO,mBAAmB,EAAE,EAAE,WAAW,CAAA;AAC3C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,+BAA+B;IAC7C,OAAO,YAAY,EAAE,cAAc,CAAA;AACrC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB;IACnC,OAAO,YAAY,EAAE,IAAI,CAAA;AAC3B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,YAAY,EAAE,EAAE,CAAA;AACzB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kCAAkC;IAChD,OAAO,YAAY,EAAE,QAAQ,EAAE,SAAS,CAAA;AAC1C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,wBAAwB;IACtC,OAAO,YAAY,EAAE,OAAO,CAAA;AAC9B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,4BAA4B;IAC1C,OAAO,YAAY,EAAE,MAAM,CAAA;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB;IACpC,OAAO,YAAY,EAAE,KAAK,CAAA;AAC5B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO,mBAAmB,EAAE,EAAE,MAAM,IAAI,EAAE,CAAA;AAC5C,CAAC;AAED,kFAAkF;AAClF,wFAAwF;AACxF,sFAAsF;AAEtF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,6BAA6B;IAC3C,OAAO,mBAAmB,EAAE,EAAE,OAAO,CAAA;AACvC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,mBAAmB,EAAE,EAAE,UAAU,CAAA;AAC1C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,4BAA4B;IAC1C,MAAM,OAAO,GAAG,mBAAmB,EAAE,CAAA;IACrC,IAAI,CAAC,OAAO;QAAE,OAAO,SAAS,CAAA;IAC9B,MAAM,QAAQ,GAAG,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IACvE,OAAO,yBAAyB,CAAC,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAA;AAC1D,CAAC","sourcesContent":["import { frontendUrlForEnvironment } from '@elytracms/core/studio-core'\nimport type {\n BreakpointConfig,\n ElytraConfigInput,\n ResolvedWorkspaceProject,\n} from '@elytracms/core/studio-core'\nimport type { CollectionDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport type { SidebarNode } from '../../components/app-views/module'\nimport type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config'\nimport type { GeneratedPreviewLookup } from '../../components/builder/blocks-model'\nimport { provideStudioEnv } from '../persistence/backend-mode'\nimport { resolveWorkspaceState } from './workspace-source'\nimport type { WorkspaceState } from './workspace-source'\n\nexport {\n PROJECT_CONFIG_FILE,\n PROJECT_CONFIG_ISSUE_KEYS,\n PROJECT_CONFIG_POINT,\n projectConfigIssues,\n} from './config-issues'\nexport { resolveWorkspaceState } from './workspace-source'\nexport type { WorkspaceSources, WorkspaceState } from './workspace-source'\nexport {\n isSingleProjectWorkspace,\n launcherAutoForwardTarget,\n persistedProjects,\n selectSwitcherProjects,\n soleWorkspaceProject,\n unprovisionedProjects,\n} from './switcher-source'\nexport type { SwitcherEntry, SwitcherSourceInput } from './switcher-source'\n\nlet cached: WorkspaceState | null = null\nlet studioConfig: StudioConfig | null = null\n\n/**\n * Mount the studio against a project config (EC-223 mount contract) and an\n * optional studio-only customization config (EC-236). The consumer's entry point\n * does `import config from './elytra.config'` + `import studio from\n * './studio.config'` and calls this — an explicit init, NOT an `import.meta.glob`\n * autoload that reaches into the consumer's filesystem (Oli, 2026-06-19: \"there's\n * always an entry point that can trigger a function call\").\n *\n * Two configs, deliberately: `config` (`elytra.config.ts`) is the project\n * definition, shared with the `frontend/` delivery bundle and projected to data;\n * `studio` (`studio.config.tsx` via `defineStudio`) is live builder UI code — the\n * section rail, custom field renderers, branding — that must NEVER reach delivery.\n * Keeping them separate is the customization boundary principle in the mount\n * signature. Seeds the workspace state + the studio-config holder every later\n * query (`getActiveProjectSlug`, `resolveDeployment*`) reads.\n *\n * EC-357 — `options.env`: the consumer's `import.meta.env`, captured HERE because\n * the entry module is code Vite always transforms live. The engine's own\n * `import.meta.env` reads are unreliable inside dep-optimized package code (Vite ≥8\n * omits custom `VITE_*` values there; Vite 7 froze them at optimize time), which\n * silently broke backend URL / mode / dev-login resolution in real consumers.\n * Always pass it: `createStudio(config, studioConfig, { env: import.meta.env })`.\n */\nexport interface StudioMountOptions {\n /** The consumer entry's `import.meta.env` (see `provideStudioEnv` for the contract). */\n env?: Record<string, unknown>\n}\n\nexport function createStudio(\n config: ElytraConfigInput,\n studio?: StudioConfig,\n options?: StudioMountOptions,\n): void {\n provideStudioEnv(options?.env)\n cached = resolveWorkspaceState({\n fileModule: { default: config },\n fileName: 'elytra.config.ts',\n })\n studioConfig = studio ?? null\n}\n\n/** Test seam: set/clear the studio customization config. Never used by app code. */\nexport function setStudioConfigForTests(next: StudioConfig | null): void {\n studioConfig = next\n}\n\n/**\n * The active project slug (EC-179, vision AD-8). One project per backend, so the\n * active project selects which Convex deployment the studio talks to. The root\n * layout sets this from the URL (`/<slug>`) — or, on the sign-in screen, from\n * the `redirect` target's slug — before the auth/persistence adapters are\n * selected, so the *initial* sign-in authenticates against the project the user\n * is heading for (Sanity-style: authenticate to one workspace, then the full\n * config-based switcher is available). `null` at the launcher → the default\n * (first) configured project's deployment.\n */\nlet activeProjectSlug: string | null = null\n\n/** Set the active project slug; returns whether the resolved deployment changed. */\nexport function setActiveProjectSlug(slug: string | null): boolean {\n const before = resolveDeploymentUrl('')\n activeProjectSlug = slug && slug.trim() !== '' ? slug : null\n return resolveDeploymentUrl('') !== before\n}\n\nexport function getActiveProjectSlug(): string | null {\n return activeProjectSlug\n}\n\n/** Test seam: reset the active project slug. Never used by app code. */\nexport function setActiveProjectSlugForTests(slug: string | null): void {\n activeProjectSlug = slug\n}\n\n/**\n * Reserved first-path segments that are NOT project slugs: the studio's own\n * root routes (EC-220). Project surfaces now live at `/<slug>/...` (no `/p/`\n * prefix), so the studio's root namespace is shared between project slugs and\n * these fixed routes — they must be excluded from slug extraction. Keep in\n * sync with the non-`$projectId` files in `src/routes/`; the launcher (`/`) is\n * the empty segment, excluded by the regex requiring one path char.\n */\nconst RESERVED_ROOT_SEGMENTS: ReadonlySet<string> = new Set(['sign-in', 'styleguide'])\n\n/** Extract a project slug from a studio path (`/<slug>/...`), else `null`. */\nexport function projectSlugFromPath(pathname: string): string | null {\n const match = /^\\/([^/?#]+)/.exec(pathname)\n if (!match) return null\n const segment = decodeURIComponent(match[1]!)\n return RESERVED_ROOT_SEGMENTS.has(segment) ? null : segment\n}\n\n/**\n * The project whose backend the studio should bind for a given location\n * (EC-179). A project path (`/<slug>`) binds that project; the sign-in screen\n * binds the project from its `redirect` target so the *initial* sign-in\n * authenticates against the backend the user is heading for (Sanity-style).\n * Anywhere else (the launcher) → `null`, i.e. the default/first project.\n */\nexport function activeSlugForLocation(pathname: string, redirect?: string): string | null {\n const direct = projectSlugFromPath(pathname)\n if (direct) return direct\n if (pathname === '/sign-in' && redirect) return projectSlugFromPath(redirect)\n return null\n}\n\n/** The active config project (by slug), or the first configured project. */\nfunction activeConfigProject() {\n const state = getWorkspaceState()\n if (state.kind !== 'config') return undefined\n if (activeProjectSlug) {\n const match = state.projects.find((p) => p.slug === activeProjectSlug)\n if (match) return match\n }\n return state.projects[0]\n}\n\n/**\n * The studio's resolved workspace state. Deterministic per boot: the config is\n * static input (bundled file or deployment env), so it is resolved once and\n * cached — every consumer (gate, switcher, launcher) sees the same state.\n */\nexport function getWorkspaceState(): WorkspaceState {\n // Seeded by `createStudio(config)` at the entry point. Until then — or when the\n // consumer wires no config — resolve to the empty `none` state (the single-project\n // golden path), rather than reaching for the filesystem.\n if (!cached) cached = resolveWorkspaceState({})\n return cached\n}\n\n/** Test seam: clear/replace the cached state. Never used by app code. */\nexport function setWorkspaceStateForTests(next: WorkspaceState | null): void {\n cached = next\n}\n\n/**\n * Resolve the Convex deployment URL the studio should connect to (vision AD-8:\n * the backend is wired in config). One project per backend, so the **active**\n * project (see {@link setActiveProjectSlug}) selects the deployment via its\n * `convex.deployment` (or its production environment's). At the launcher (no\n * active project) the first configured project is the default backend. Absent\n * config — or a project without a wired deployment — falls back to\n * `VITE_CONVEX_URL` (`fallbackUrl`) for local single-deployment development.\n */\nexport function resolveDeploymentUrl(fallbackUrl: string): string {\n return projectDeploymentUrl(activeConfigProject(), fallbackUrl)\n}\n\n/** The deployment URL for a specific project slug (per-backend auth checks). */\nexport function deploymentUrlForSlug(slug: string, fallbackUrl: string): string {\n const state = getWorkspaceState()\n const project = state.kind === 'config' ? state.projects.find((p) => p.slug === slug) : undefined\n return projectDeploymentUrl(project, fallbackUrl)\n}\n\n/** A project's wired deployment: per-environment ref → project ref → env fallback. */\nfunction projectDeploymentUrl(\n project: ResolvedWorkspaceProject | undefined,\n fallbackUrl: string,\n): string {\n if (!project) return fallbackUrl\n const envDeployment = project.environments.find((e) => e.convex?.deployment)?.convex?.deployment\n return envDeployment ?? project.convex?.deployment ?? fallbackUrl\n}\n\n/**\n * The slug of the project bound to the active deployment (EC-179). One project\n * per backend, so the active config project's slug identifies the deployment's\n * one project; the Convex adapter stamps it onto deployment-scoped records.\n * Absent config falls back to `fallbackId` (used only when wired by env alone).\n */\nexport function resolveDeploymentProjectId(fallbackId: string): string {\n return activeConfigProject()?.slug ?? fallbackId\n}\n\n/**\n * The CMS collections of the active config project (AD-11, EC-183). CMS\n * collection schemas are config-owned: the studio reads the model from\n * `elytra.config.ts` (served through the persistence port — the backend has no\n * `cmsSchemas` table). `[]` when the active project declares none, or when the\n * studio is wired by env alone with no config.\n */\nexport function resolveDeploymentCollections(): CollectionDef[] {\n return activeConfigProject()?.collections ?? []\n}\n\n/**\n * The canvas preview breakpoints of the active config project (EC-250).\n * Config-sourced (config-as-code, derived from the site's Tailwind `screens`);\n * `undefined` when the project declares none — the canvas then falls back to\n * Tailwind's default screens (see `resolveCanvasViewports`), never a baked-in triple.\n */\nexport function resolveDeploymentBreakpoints(): BreakpointConfig[] | undefined {\n return activeConfigProject()?.breakpoints\n}\n\n/**\n * The custom field renderers from the studio config (EC-238): control name →\n * component. Studio-only live code (from `defineStudio`, never the project schema\n * or the delivery bundle); `undefined` when none — the document editor then uses\n * its built-in control for every field. Studio-wide (not per-project): the rail and\n * renderers customize the studio, not one backend.\n */\nexport function resolveDeploymentFieldRenderers(): Record<string, CustomFieldRenderer> | undefined {\n return studioConfig?.fieldRenderers\n}\n\n/**\n * The studio-UI localization config from the studio config (EC-241): the default\n * locale + imported translation catalogs. Studio-only live code (from `defineStudio`,\n * never the project schema or the delivery bundle); `undefined` when none — the\n * studio then renders its built-in English chrome. Studio-wide, like the rail and\n * field renderers (localizes the studio, not one backend).\n */\nexport function resolveDeploymentI18n(): StudioConfig['i18n'] {\n return studioConfig?.i18n\n}\n\n/**\n * The AI platform config from the studio config (EC-331): provider, knowledge, and\n * limits. Studio-only live code (from `defineStudio`, never the delivery bundle);\n * `undefined` when no `ai` block is configured — the feature is then fully absent\n * (the AI subsystem never boots, so `@elytracms/studio/lib/ai`'s runtime — and the\n * `ai`/`@ai-sdk/*` SDK behind it — is never imported). Studio-wide, like the rail\n * and field renderers.\n */\nexport function resolveDeploymentAi(): StudioConfig['ai'] {\n return studioConfig?.ai\n}\n\n/**\n * The generated-preview lookup from the studio config (EC-439 AD-2, the seam\n * EC-440 fills): a pure `componentId + fixtureName → URL` function the blocks\n * panel threads into its tile model. Studio-only live code (from `defineStudio`,\n * never the delivery bundle); `undefined` when none — tiles then fall back from\n * a curated `manifest.previewImage` straight to the icon+name placeholder.\n * Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentGeneratedPreviews(): GeneratedPreviewLookup | undefined {\n return studioConfig?.previews?.generated\n}\n\n/**\n * The configurable section rail from the studio config (EC-237): an ordered list of\n * `nav.*` nodes (views / groups / separators). Studio-only live code (carries view\n * objects + icons); `undefined` when none — the studio then derives the default\n * rail from its built-in views. Invalid entries surface as visible issues at\n * resolution, never silent.\n */\nexport function resolveDeploymentSidebar(): SidebarNode[] | undefined {\n return studioConfig?.sidebar\n}\n\n/**\n * The canvas page-surface theme from the studio config (EC-389): body classes +\n * raw CSS for the preview iframe, so the canvas wears the SITE's chrome. Studio-only\n * (from `defineStudio`, never the delivery bundle); `undefined` when none — the\n * canvas keeps its built-in defaults (`bg-background text-foreground font-sans`\n * body). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentCanvasTheme(): StudioConfig['canvas'] {\n return studioConfig?.canvas\n}\n\n/**\n * The reusable-elements declaration from the studio config (EC-467): WHICH\n * component is the project's reuse embed. Studio-only (from `defineStudio`, never\n * the delivery bundle); `undefined` when none — the studio then INFERS the embed\n * from the manifests + collections exactly as it always has (absence means infer,\n * not off). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentReuse(): StudioConfig['reuse'] {\n return studioConfig?.reuse\n}\n\n/**\n * The resolved URL routes of the active config project (EC-207, \"routes as\n * code\"). Routes are config-sourced: the studio reads the materialized\n * `RouteRecord[]` from the active `elytra.config.ts` project — never from the\n * backend (the `routes` system collection is retired). The Routes surface and\n * the per-page URL preview both read through here. `[]` when the active project\n * declares none, or when the studio is wired by env alone with no config.\n */\nexport function resolveDeploymentRoutes(): RouteRecord[] {\n return activeConfigProject()?.routes ?? []\n}\n\n// Redirects are editorial DATA now (EC-303), managed in the studio Redirects rail\n// and served dynamically — no longer resolved from config. `resolveDeploymentRedirects`\n// is retired; static/infra redirects are the dev's job via native next.config/vercel.\n\n/**\n * The active config project's `LocaleConfig` (EC-357 finding #3). Locales are\n * structure and structure is code: when `elytra.config.ts` declares them, they\n * drive the studio's ambient locale switcher in EVERY mode — the CMS surface must\n * not fall back to the sample `en/de` fixture (playground, or a connected first\n * run with nothing persisted) when the config says otherwise. `undefined` when no\n * config project is active (env-only wiring keeps the existing kit/sample\n * fallbacks).\n *\n * EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`\n * pair into a `LocaleConfig`, and had grown its own repair rule — an absent or\n * out-of-set default fell back to `locales[0]`. That is a fourth place a locale\n * could be invented, in the surface furthest from the author. The resolver now\n * hands over the same `LocaleConfig` the delivery host takes, so there is nothing\n * to reassemble and nothing to repair: a default outside the set is a config\n * ERROR (`default-locale-not-in-locales`) and the project never resolves.\n */\nexport function resolveDeploymentLocaleConfig(): LocaleConfig | undefined {\n return activeConfigProject()?.locales\n}\n\n/**\n * The active project's components reference (EC-194, AD-11): where the consuming\n * repo's OWN real components live (`{ package }` or `{ dir }`). When set, the\n * studio registry composes with the repo's components instead of the built-in\n * demo fixtures — so the page builder edits the repo's actual component library.\n * `undefined` → the project ships no code components; the studio falls back to the\n * golden-path fixtures.\n */\nexport function resolveDeploymentComponents() {\n return activeConfigProject()?.components\n}\n\n/**\n * The active project's public site URL (EC-215): the live site's base URL the\n * studio links to (a document's live URL + open-live button, EC-216). Resolved\n * in lockstep with {@link resolveDeploymentUrl} — the environment bound to the\n * active deployment (the first with a wired deployment) provides the URL, falling\n * back to the project-level `frontendUrl`. No active-environment selector exists\n * yet, so the bound environment is the active one. `undefined` when the active\n * project (or env-only wiring) configures no frontend URL.\n */\nexport function resolveDeploymentFrontendUrl(): string | undefined {\n const project = activeConfigProject()\n if (!project) return undefined\n const boundEnv = project.environments.find((e) => e.convex?.deployment)\n return frontendUrlForEnvironment(project, boundEnv?.key)\n}\n"]}
|
|
@@ -23,7 +23,14 @@
|
|
|
23
23
|
* - one fire per intent per committed location — re-renders and store ticks
|
|
24
24
|
* while the target transition is pending are inert;
|
|
25
25
|
* - a NEW committed location re-arms the attempt, so navigating back to a URL
|
|
26
|
-
* that redirects (e.g. the bare project index) redirects again
|
|
26
|
+
* that redirects (e.g. the bare project index) redirects again;
|
|
27
|
+
* - a redirect rendered by a route the committed location no longer matches is
|
|
28
|
+
* inert (EC-611). While a transition is pending the router keeps rendering
|
|
29
|
+
* the PREVIOUS match set, so a route component outlives its own URL: the
|
|
30
|
+
* launcher (`/`) is still mounted at `/sign-in?redirect=/aurora` until the
|
|
31
|
+
* sign-in module has loaded. Left to fire, its auto-forward and the auth
|
|
32
|
+
* gate's sign-in bounce alternate, each commit re-arming the other — the
|
|
33
|
+
* signed-out boot hang on `@tanstack/react-router` ≥ 1.170.41 (NK-050 F3).
|
|
27
34
|
*/
|
|
28
35
|
export interface NavigateOnceIntent {
|
|
29
36
|
/** Target route path, addressed as a plain string (the router-seam contract). */
|
|
@@ -61,15 +68,23 @@ export interface RedirectAttemptState {
|
|
|
61
68
|
/**
|
|
62
69
|
* The pure fire decision (EC-441's loop breaker, node-tested):
|
|
63
70
|
*
|
|
64
|
-
* 1.
|
|
65
|
-
*
|
|
71
|
+
* 1. the rendering route is not matched by the committed location → no fire
|
|
72
|
+
* (a stale render during a pending transition, EC-611);
|
|
73
|
+
* 2. target already committed → no fire;
|
|
74
|
+
* 3. this intent already fired at this committed location → no fire (the
|
|
66
75
|
* per-render re-fire cycle, and any intent ping-pong at one location);
|
|
67
|
-
*
|
|
76
|
+
* 4. otherwise → fire, and record the intent for this location.
|
|
68
77
|
*/
|
|
69
78
|
export declare function nextRedirectAttempt(state: RedirectAttemptState | null, input: {
|
|
70
79
|
intentKey: string;
|
|
71
80
|
targetHref: string;
|
|
72
81
|
committedHref: string;
|
|
82
|
+
/**
|
|
83
|
+
* Whether the route this redirect renders in is part of the committed
|
|
84
|
+
* location's match set. Absent means "not a route-scoped render" (treated
|
|
85
|
+
* as matched) so the decision stays usable from the root layout.
|
|
86
|
+
*/
|
|
87
|
+
routeMatched?: boolean;
|
|
73
88
|
}): {
|
|
74
89
|
fire: boolean;
|
|
75
90
|
state: RedirectAttemptState;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
-
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router';
|
|
2
|
+
import { useLocation, useMatch, useNavigate, useRouter } from '@tanstack/react-router';
|
|
3
3
|
/**
|
|
4
4
|
* Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`
|
|
5
5
|
* objects (the upstream failure mode: new object literal every render) map to
|
|
@@ -35,15 +35,19 @@ export function isSameRedirectTarget(targetHref, committedHref) {
|
|
|
35
35
|
/**
|
|
36
36
|
* The pure fire decision (EC-441's loop breaker, node-tested):
|
|
37
37
|
*
|
|
38
|
-
* 1.
|
|
39
|
-
*
|
|
38
|
+
* 1. the rendering route is not matched by the committed location → no fire
|
|
39
|
+
* (a stale render during a pending transition, EC-611);
|
|
40
|
+
* 2. target already committed → no fire;
|
|
41
|
+
* 3. this intent already fired at this committed location → no fire (the
|
|
40
42
|
* per-render re-fire cycle, and any intent ping-pong at one location);
|
|
41
|
-
*
|
|
43
|
+
* 4. otherwise → fire, and record the intent for this location.
|
|
42
44
|
*/
|
|
43
45
|
export function nextRedirectAttempt(state, input) {
|
|
44
46
|
const base = state && state.committedHref === input.committedHref
|
|
45
47
|
? state
|
|
46
48
|
: { committedHref: input.committedHref, firedKeys: new Set() };
|
|
49
|
+
if (input.routeMatched === false)
|
|
50
|
+
return { fire: false, state: base };
|
|
47
51
|
if (isSameRedirectTarget(input.targetHref, input.committedHref))
|
|
48
52
|
return { fire: false, state: base };
|
|
49
53
|
if (base.firedKeys.has(input.intentKey))
|
|
@@ -67,6 +71,12 @@ export function NavigateOnce({ to, params, search, replace }) {
|
|
|
67
71
|
// stable across the pending-transition re-renders that used to re-fire
|
|
68
72
|
// `<Navigate>`, and a commit re-runs the decision exactly once.
|
|
69
73
|
const committedHref = useLocation({ select: (location) => location.href });
|
|
74
|
+
// The route this redirect renders in (EC-611). `strict: false` resolves the
|
|
75
|
+
// NEAREST match, so a redirect in the root layout (the auth gate) reads the
|
|
76
|
+
// root route, which every location matches, and a redirect in a route
|
|
77
|
+
// component reads that route — which the committed location stops matching
|
|
78
|
+
// the moment a transition away from it starts.
|
|
79
|
+
const routeId = useMatch({ strict: false, select: (match) => match.routeId });
|
|
70
80
|
const key = navigateIntentKey({ to, params, search, replace });
|
|
71
81
|
// Latest-value ref: the effect is keyed on the intent's VALUE (`key`), so it
|
|
72
82
|
// reads the current props through a ref instead of listing unstable object
|
|
@@ -81,10 +91,17 @@ export function NavigateOnce({ to, params, search, replace }) {
|
|
|
81
91
|
params: intent.params,
|
|
82
92
|
search: intent.search,
|
|
83
93
|
});
|
|
94
|
+
// Match the COMMITTED location against the route tree (no load, no state
|
|
95
|
+
// change): is the route rendering this redirect still on the URL? During
|
|
96
|
+
// a pending transition the previous match set keeps rendering, so this is
|
|
97
|
+
// the one fact that tells a live redirect from a stale one.
|
|
98
|
+
const routeMatched = routeId === undefined ||
|
|
99
|
+
router.matchRoutes(router.state.location).some((match) => match.routeId === routeId);
|
|
84
100
|
const decision = nextRedirectAttempt(attemptRef.current, {
|
|
85
101
|
intentKey: key,
|
|
86
102
|
targetHref: target.href,
|
|
87
103
|
committedHref,
|
|
104
|
+
routeMatched,
|
|
88
105
|
});
|
|
89
106
|
attemptRef.current = decision.state;
|
|
90
107
|
if (!decision.fire)
|
|
@@ -98,7 +115,7 @@ export function NavigateOnce({ to, params, search, replace }) {
|
|
|
98
115
|
search: intent.search,
|
|
99
116
|
replace: intent.replace,
|
|
100
117
|
});
|
|
101
|
-
}, [key, committedHref, navigate, router]);
|
|
118
|
+
}, [key, committedHref, navigate, router, routeId]);
|
|
102
119
|
return null;
|
|
103
120
|
}
|
|
104
121
|
//# sourceMappingURL=navigate-once.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigate-once.js","sourceRoot":"","sources":["../../src/router/navigate-once.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useEffect, useLayoutEffect, useRef } from 'react'\nimport { useLocation, useNavigate, useRouter } from '@tanstack/react-router'\n\n/**\n * Render-time redirect that fires its navigation ONCE per redirect intent per\n * committed location (EC-441). The studio's declarative redirects (launcher\n * auto-forward, project index → landing view, auth-gate bounces, deep-link\n * normalizations) must use THIS instead of TanStack Router's `<Navigate>`.\n *\n * Why: `<Navigate>` re-fires `navigate(props)` whenever its props OBJECT\n * IDENTITY changes — and JSX builds a fresh props object on every parent\n * render. While the redirect's target transition is still pending (slow route\n * modules on a cold dev boot, slow chunks in production), the redirecting\n * component stays mounted and re-renders on every router-store tick; each\n * re-render re-navigates, each `navigate()` re-enters `commitLocation`/`load`\n * and schedules another transition state update, and the cycle compounds until\n * React throws \"Maximum update depth exceeded\" inside `commitLocation` — an\n * unhandled rejection of the router's load promise (the EC-439 finding: 104\n * launcher re-navigations on one cold playground boot).\n *\n * The decision core (`nextRedirectAttempt`) is pure and value-keyed:\n *\n * - already at the target → never fire (idempotence — covers the EC-179 keyed\n * `AuthProvider` remount, which recreates the redirecting component with\n * fresh refs while the router has already committed the target);\n * - one fire per intent per committed location — re-renders and store ticks\n * while the target transition is pending are inert;\n * - a NEW committed location re-arms the attempt, so navigating back to a URL\n * that redirects (e.g. the bare project index) redirects again.\n */\nexport interface NavigateOnceIntent {\n /** Target route path, addressed as a plain string (the router-seam contract). */\n to: string\n /** Route params (`{ projectId, _splat }` for the studio splat). */\n params?: Record<string, string>\n /** Search params for the target location. */\n search?: Record<string, unknown>\n /** Replace the current history entry (a redirect must not create one). */\n replace?: boolean\n}\n\n/**\n * Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`\n * objects (the upstream failure mode: new object literal every render) map to\n * ONE key; top-level entries are order-normalized so key insertion order never\n * splits an intent. Values are JSON-serialized — the studio's redirect targets\n * are strings and flat search records, both deterministic under JSON.\n */\nexport function navigateIntentKey(intent: NavigateOnceIntent): string {\n const sorted = (record: Record<string, unknown> | undefined) =>\n record\n ? Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n : null\n return JSON.stringify([\n intent.to,\n sorted(intent.params),\n sorted(intent.search),\n intent.replace === true,\n ])\n}\n\n/**\n * Href equality for \"is this redirect already committed?\", trailing-slash\n * trimmed on the path segment (matching the router's own same-URL comparison)\n * while search and hash stay significant.\n */\nexport function isSameRedirectTarget(targetHref: string, committedHref: string): boolean {\n const trim = (href: string) => {\n const [pathAndSearch = '', hash = ''] = href.split('#', 2)\n const [path = '', search = ''] = pathAndSearch.split('?', 2)\n const trimmedPath = path.length > 1 ? path.replace(/\\/+$/, '') : path\n return `${trimmedPath}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`\n }\n return trim(targetHref) === trim(committedHref)\n}\n\n/**\n * Attempt bookkeeping for one mounted redirect: which intent keys have already\n * fired at the CURRENT committed location. A new committed href resets the set\n * (re-arm); the state is immutable so the caller can hold it in a ref.\n */\nexport interface RedirectAttemptState {\n committedHref: string\n firedKeys: ReadonlySet<string>\n}\n\n/**\n * The pure fire decision (EC-441's loop breaker, node-tested):\n *\n * 1. target already committed → no fire;\n * 2. this intent already fired at this committed location → no fire (the\n * per-render re-fire cycle, and any intent ping-pong at one location);\n * 3. otherwise → fire, and record the intent for this location.\n */\nexport function nextRedirectAttempt(\n state: RedirectAttemptState | null,\n input: { intentKey: string; targetHref: string; committedHref: string },\n): { fire: boolean; state: RedirectAttemptState } {\n const base: RedirectAttemptState =\n state && state.committedHref === input.committedHref\n ? state\n : { committedHref: input.committedHref, firedKeys: new Set<string>() }\n if (isSameRedirectTarget(input.targetHref, input.committedHref)) return { fire: false, state: base }\n if (base.firedKeys.has(input.intentKey)) return { fire: false, state: base }\n return {\n fire: true,\n state: {\n committedHref: base.committedHref,\n firedKeys: new Set(base.firedKeys).add(input.intentKey),\n },\n }\n}\n\n// Layout-effect timing on the client (start the redirect before paint, like\n// upstream `<Navigate>`); plain effect on the server, where layout effects warn\n// and redirects never run (the studio's redirect surfaces render null on SSR).\nconst useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect\n\nexport function NavigateOnce({ to, params, search, replace }: NavigateOnceIntent) {\n const router = useRouter()\n const navigate = useNavigate()\n // The COMMITTED location (not transient transition state): effect deps stay\n // stable across the pending-transition re-renders that used to re-fire\n // `<Navigate>`, and a commit re-runs the decision exactly once.\n const committedHref = useLocation({ select: (location) => location.href })\n const key = navigateIntentKey({ to, params, search, replace })\n // Latest-value ref: the effect is keyed on the intent's VALUE (`key`), so it\n // reads the current props through a ref instead of listing unstable object\n // identities as dependencies.\n const intentRef = useRef<NavigateOnceIntent>({ to, params, search, replace })\n intentRef.current = { to, params, search, replace }\n const attemptRef = useRef<RedirectAttemptState | null>(null)\n\n useIsomorphicLayoutEffect(() => {\n const intent = intentRef.current\n const target = router.buildLocation({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n })\n const decision = nextRedirectAttempt(attemptRef.current, {\n intentKey: key,\n targetHref: target.href,\n committedHref,\n })\n attemptRef.current = decision.state\n if (!decision.fire) return\n // Fire-and-forget, exactly like upstream `<Navigate>`: with the re-fire\n // cycle gone the transition settles normally, so nothing here swallows a\n // real navigation failure.\n void navigate({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n replace: intent.replace,\n })\n }, [key, committedHref, navigate, router])\n\n return null\n}\n"]}
|
|
1
|
+
{"version":3,"file":"navigate-once.js","sourceRoot":"","sources":["../../src/router/navigate-once.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useEffect, useLayoutEffect, useRef } from 'react'\nimport { useLocation, useMatch, useNavigate, useRouter } from '@tanstack/react-router'\n\n/**\n * Render-time redirect that fires its navigation ONCE per redirect intent per\n * committed location (EC-441). The studio's declarative redirects (launcher\n * auto-forward, project index → landing view, auth-gate bounces, deep-link\n * normalizations) must use THIS instead of TanStack Router's `<Navigate>`.\n *\n * Why: `<Navigate>` re-fires `navigate(props)` whenever its props OBJECT\n * IDENTITY changes — and JSX builds a fresh props object on every parent\n * render. While the redirect's target transition is still pending (slow route\n * modules on a cold dev boot, slow chunks in production), the redirecting\n * component stays mounted and re-renders on every router-store tick; each\n * re-render re-navigates, each `navigate()` re-enters `commitLocation`/`load`\n * and schedules another transition state update, and the cycle compounds until\n * React throws \"Maximum update depth exceeded\" inside `commitLocation` — an\n * unhandled rejection of the router's load promise (the EC-439 finding: 104\n * launcher re-navigations on one cold playground boot).\n *\n * The decision core (`nextRedirectAttempt`) is pure and value-keyed:\n *\n * - already at the target → never fire (idempotence — covers the EC-179 keyed\n * `AuthProvider` remount, which recreates the redirecting component with\n * fresh refs while the router has already committed the target);\n * - one fire per intent per committed location — re-renders and store ticks\n * while the target transition is pending are inert;\n * - a NEW committed location re-arms the attempt, so navigating back to a URL\n * that redirects (e.g. the bare project index) redirects again;\n * - a redirect rendered by a route the committed location no longer matches is\n * inert (EC-611). While a transition is pending the router keeps rendering\n * the PREVIOUS match set, so a route component outlives its own URL: the\n * launcher (`/`) is still mounted at `/sign-in?redirect=/aurora` until the\n * sign-in module has loaded. Left to fire, its auto-forward and the auth\n * gate's sign-in bounce alternate, each commit re-arming the other — the\n * signed-out boot hang on `@tanstack/react-router` ≥ 1.170.41 (NK-050 F3).\n */\nexport interface NavigateOnceIntent {\n /** Target route path, addressed as a plain string (the router-seam contract). */\n to: string\n /** Route params (`{ projectId, _splat }` for the studio splat). */\n params?: Record<string, string>\n /** Search params for the target location. */\n search?: Record<string, unknown>\n /** Replace the current history entry (a redirect must not create one). */\n replace?: boolean\n}\n\n/**\n * Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`\n * objects (the upstream failure mode: new object literal every render) map to\n * ONE key; top-level entries are order-normalized so key insertion order never\n * splits an intent. Values are JSON-serialized — the studio's redirect targets\n * are strings and flat search records, both deterministic under JSON.\n */\nexport function navigateIntentKey(intent: NavigateOnceIntent): string {\n const sorted = (record: Record<string, unknown> | undefined) =>\n record\n ? Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n : null\n return JSON.stringify([\n intent.to,\n sorted(intent.params),\n sorted(intent.search),\n intent.replace === true,\n ])\n}\n\n/**\n * Href equality for \"is this redirect already committed?\", trailing-slash\n * trimmed on the path segment (matching the router's own same-URL comparison)\n * while search and hash stay significant.\n */\nexport function isSameRedirectTarget(targetHref: string, committedHref: string): boolean {\n const trim = (href: string) => {\n const [pathAndSearch = '', hash = ''] = href.split('#', 2)\n const [path = '', search = ''] = pathAndSearch.split('?', 2)\n const trimmedPath = path.length > 1 ? path.replace(/\\/+$/, '') : path\n return `${trimmedPath}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`\n }\n return trim(targetHref) === trim(committedHref)\n}\n\n/**\n * Attempt bookkeeping for one mounted redirect: which intent keys have already\n * fired at the CURRENT committed location. A new committed href resets the set\n * (re-arm); the state is immutable so the caller can hold it in a ref.\n */\nexport interface RedirectAttemptState {\n committedHref: string\n firedKeys: ReadonlySet<string>\n}\n\n/**\n * The pure fire decision (EC-441's loop breaker, node-tested):\n *\n * 1. the rendering route is not matched by the committed location → no fire\n * (a stale render during a pending transition, EC-611);\n * 2. target already committed → no fire;\n * 3. this intent already fired at this committed location → no fire (the\n * per-render re-fire cycle, and any intent ping-pong at one location);\n * 4. otherwise → fire, and record the intent for this location.\n */\nexport function nextRedirectAttempt(\n state: RedirectAttemptState | null,\n input: {\n intentKey: string\n targetHref: string\n committedHref: string\n /**\n * Whether the route this redirect renders in is part of the committed\n * location's match set. Absent means \"not a route-scoped render\" (treated\n * as matched) so the decision stays usable from the root layout.\n */\n routeMatched?: boolean\n },\n): { fire: boolean; state: RedirectAttemptState } {\n const base: RedirectAttemptState =\n state && state.committedHref === input.committedHref\n ? state\n : { committedHref: input.committedHref, firedKeys: new Set<string>() }\n if (input.routeMatched === false) return { fire: false, state: base }\n if (isSameRedirectTarget(input.targetHref, input.committedHref)) return { fire: false, state: base }\n if (base.firedKeys.has(input.intentKey)) return { fire: false, state: base }\n return {\n fire: true,\n state: {\n committedHref: base.committedHref,\n firedKeys: new Set(base.firedKeys).add(input.intentKey),\n },\n }\n}\n\n// Layout-effect timing on the client (start the redirect before paint, like\n// upstream `<Navigate>`); plain effect on the server, where layout effects warn\n// and redirects never run (the studio's redirect surfaces render null on SSR).\nconst useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect\n\nexport function NavigateOnce({ to, params, search, replace }: NavigateOnceIntent) {\n const router = useRouter()\n const navigate = useNavigate()\n // The COMMITTED location (not transient transition state): effect deps stay\n // stable across the pending-transition re-renders that used to re-fire\n // `<Navigate>`, and a commit re-runs the decision exactly once.\n const committedHref = useLocation({ select: (location) => location.href })\n // The route this redirect renders in (EC-611). `strict: false` resolves the\n // NEAREST match, so a redirect in the root layout (the auth gate) reads the\n // root route, which every location matches, and a redirect in a route\n // component reads that route — which the committed location stops matching\n // the moment a transition away from it starts.\n const routeId = useMatch({ strict: false, select: (match) => match.routeId }) as\n | string\n | undefined\n const key = navigateIntentKey({ to, params, search, replace })\n // Latest-value ref: the effect is keyed on the intent's VALUE (`key`), so it\n // reads the current props through a ref instead of listing unstable object\n // identities as dependencies.\n const intentRef = useRef<NavigateOnceIntent>({ to, params, search, replace })\n intentRef.current = { to, params, search, replace }\n const attemptRef = useRef<RedirectAttemptState | null>(null)\n\n useIsomorphicLayoutEffect(() => {\n const intent = intentRef.current\n const target = router.buildLocation({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n })\n // Match the COMMITTED location against the route tree (no load, no state\n // change): is the route rendering this redirect still on the URL? During\n // a pending transition the previous match set keeps rendering, so this is\n // the one fact that tells a live redirect from a stale one.\n const routeMatched =\n routeId === undefined ||\n router.matchRoutes(router.state.location).some((match) => match.routeId === routeId)\n const decision = nextRedirectAttempt(attemptRef.current, {\n intentKey: key,\n targetHref: target.href,\n committedHref,\n routeMatched,\n })\n attemptRef.current = decision.state\n if (!decision.fire) return\n // Fire-and-forget, exactly like upstream `<Navigate>`: with the re-fire\n // cycle gone the transition settles normally, so nothing here swallows a\n // real navigation failure.\n void navigate({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n replace: intent.replace,\n })\n }, [key, committedHref, navigate, router, routeId])\n\n return null\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/studio",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.90",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.18.0"
|
|
7
7
|
},
|
|
@@ -67,10 +67,6 @@
|
|
|
67
67
|
"types": "./dist/lib/registry/index.d.ts",
|
|
68
68
|
"default": "./dist/lib/registry/index.js"
|
|
69
69
|
},
|
|
70
|
-
"./lib/webhooks": {
|
|
71
|
-
"types": "./dist/lib/webhooks/index.d.ts",
|
|
72
|
-
"default": "./dist/lib/webhooks/index.js"
|
|
73
|
-
},
|
|
74
70
|
"./lib/persistence": {
|
|
75
71
|
"types": "./dist/lib/persistence/index.d.ts",
|
|
76
72
|
"default": "./dist/lib/persistence/index.js"
|
|
@@ -153,7 +149,7 @@
|
|
|
153
149
|
"lucide-react": "^1.23.0",
|
|
154
150
|
"tailwind-merge": "^3.6.0",
|
|
155
151
|
"zod": "^4.4.3",
|
|
156
|
-
"@elytracms/core": "^0.0.
|
|
152
|
+
"@elytracms/core": "^0.0.90"
|
|
157
153
|
},
|
|
158
154
|
"peerDependencies": {
|
|
159
155
|
"@tanstack/react-router": "^1.170.0",
|
|
@@ -168,7 +164,7 @@
|
|
|
168
164
|
}
|
|
169
165
|
},
|
|
170
166
|
"devDependencies": {
|
|
171
|
-
"@tanstack/react-router": "^1.170.
|
|
167
|
+
"@tanstack/react-router": "^1.170.41",
|
|
172
168
|
"@types/node": "^26.1.0",
|
|
173
169
|
"@types/react": "^19.2.17",
|
|
174
170
|
"@types/react-dom": "^19.2.3",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { OperationEventBus } from '@elytracms/core/operations';
|
|
2
|
-
import type { WebhookDispatcherAttachment } from './dispatcher.js';
|
|
3
|
-
/**
|
|
4
|
-
* Attach the dispatcher from environment configuration. Returns `null` when
|
|
5
|
-
* no endpoint/secret is configured (the common local-dev case).
|
|
6
|
-
*/
|
|
7
|
-
export declare function attachConfiguredRevalidationWebhooks(events: OperationEventBus): WebhookDispatcherAttachment | null;
|
|
8
|
-
/**
|
|
9
|
-
* The locales the dispatcher emits per-collection tags for. The project's
|
|
10
|
-
* configured locales (the same set the host tags by) are always covered so no
|
|
11
|
-
* locale can be silently dropped (EC-192 — the `/de` freshness fix); the
|
|
12
|
-
* `VITE_REVALIDATE_LOCALES` env may only ADD to that set. Falls back to `en`
|
|
13
|
-
* only when neither config nor env supplies a locale (env-only wiring).
|
|
14
|
-
*/
|
|
15
|
-
export declare function revalidationLocales(): string[];
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { attachRevalidationWebhooks } from './dispatcher.js';
|
|
2
|
-
import { studioEnv } from '../persistence/backend-mode.js';
|
|
3
|
-
import { resolveDeploymentLocales } from '../workspace/index.js';
|
|
4
|
-
/**
|
|
5
|
-
* Environment wiring for the EC-146 revalidation webhooks. The dispatcher is
|
|
6
|
-
* attached only when a host endpoint is configured, so dev sessions and tests
|
|
7
|
-
* (no env) run with zero webhook overhead.
|
|
8
|
-
*
|
|
9
|
-
* Configuration (Vite client env; SSR falls back to `process.env`):
|
|
10
|
-
*
|
|
11
|
-
* - `VITE_REVALIDATE_ENDPOINT` — the host's revalidate route URL
|
|
12
|
-
* (e.g. `https://example-site.vercel.app/api/revalidate`)
|
|
13
|
-
* - `VITE_REVALIDATE_SECRET` — shared HMAC secret (must equal the host's
|
|
14
|
-
* `REVALIDATE_SECRET`). Browser-exposed by design in the v1 dispatch path —
|
|
15
|
-
* see the CORS/secret notes in `dispatcher.ts`.
|
|
16
|
-
* - `VITE_REVALIDATE_LOCALES` — optional comma-separated locale list that can ADD
|
|
17
|
-
* locales to the revalidation tag set. It can never NARROW it: the project's
|
|
18
|
-
* `elytra.config.ts` locales are always covered (EC-192), so a non-default
|
|
19
|
-
* locale like `/de` can never be the one left stale after a publish.
|
|
20
|
-
*
|
|
21
|
-
* The host must allow the studio origin via
|
|
22
|
-
* `createRevalidateRoute({ allowOrigin })` for the browser preflight to pass.
|
|
23
|
-
*/
|
|
24
|
-
// EC-357: env reads go through the ONE studio env surface (`studioEnv()` — the
|
|
25
|
-
// mount-provided env first, then the engine-visible Vite env over `process.env`).
|
|
26
|
-
// A direct `import.meta.env` read here would be dep-optimized away in a real
|
|
27
|
-
// consumer (pinned by `env-read-discipline.test.ts`).
|
|
28
|
-
function readEnv(name) {
|
|
29
|
-
return studioEnv()[name];
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Attach the dispatcher from environment configuration. Returns `null` when
|
|
33
|
-
* no endpoint/secret is configured (the common local-dev case).
|
|
34
|
-
*/
|
|
35
|
-
export function attachConfiguredRevalidationWebhooks(events) {
|
|
36
|
-
const url = readEnv('VITE_REVALIDATE_ENDPOINT');
|
|
37
|
-
const secret = readEnv('VITE_REVALIDATE_SECRET');
|
|
38
|
-
if (!url || !secret)
|
|
39
|
-
return null;
|
|
40
|
-
return attachRevalidationWebhooks(events, {
|
|
41
|
-
endpoints: [{ url, secret }],
|
|
42
|
-
locales: revalidationLocales(),
|
|
43
|
-
onError: (error, context) => {
|
|
44
|
-
// Visible, never fatal: a host outage must not break editing.
|
|
45
|
-
console.warn(`[elytra] revalidation webhook delivery failed (${context.url})`, error);
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* The locales the dispatcher emits per-collection tags for. The project's
|
|
51
|
-
* configured locales (the same set the host tags by) are always covered so no
|
|
52
|
-
* locale can be silently dropped (EC-192 — the `/de` freshness fix); the
|
|
53
|
-
* `VITE_REVALIDATE_LOCALES` env may only ADD to that set. Falls back to `en`
|
|
54
|
-
* only when neither config nor env supplies a locale (env-only wiring).
|
|
55
|
-
*/
|
|
56
|
-
export function revalidationLocales() {
|
|
57
|
-
const configLocales = resolveDeploymentLocales();
|
|
58
|
-
const envLocales = (readEnv('VITE_REVALIDATE_LOCALES') ?? '')
|
|
59
|
-
.split(',')
|
|
60
|
-
.map((locale) => locale.trim())
|
|
61
|
-
.filter((locale) => locale.length > 0);
|
|
62
|
-
const union = [...new Set([...configLocales, ...envLocales])];
|
|
63
|
-
return union.length > 0 ? union : ['en'];
|
|
64
|
-
}
|
|
65
|
-
//# sourceMappingURL=config.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/lib/webhooks/config.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAA;AAEzD,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAA;AACvD,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAA;AAEvD;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,+EAA+E;AAC/E,kFAAkF;AAClF,6EAA6E;AAC7E,sDAAsD;AACtD,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,SAAS,EAAE,CAAC,IAAI,CAAC,CAAA;AAC1B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oCAAoC,CAClD,MAAyB;IAEzB,MAAM,GAAG,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAAA;IAC/C,MAAM,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;IAChD,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAEhC,OAAO,0BAA0B,CAAC,MAAM,EAAE;QACxC,SAAS,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;QAC5B,OAAO,EAAE,mBAAmB,EAAE;QAC9B,OAAO,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;YAC1B,8DAA8D;YAC9D,OAAO,CAAC,IAAI,CAAC,kDAAkD,OAAO,CAAC,GAAG,GAAG,EAAE,KAAK,CAAC,CAAA;QACvF,CAAC;KACF,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB;IACjC,MAAM,aAAa,GAAG,wBAAwB,EAAE,CAAA;IAChD,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,yBAAyB,CAAC,IAAI,EAAE,CAAC;SAC1D,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;SAC9B,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACxC,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,aAAa,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,CAAA;IAC7D,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;AAC1C,CAAC","sourcesContent":["import type { OperationEventBus } from '@elytracms/core/operations'\nimport { attachRevalidationWebhooks } from './dispatcher'\nimport type { WebhookDispatcherAttachment } from './dispatcher'\nimport { studioEnv } from '../persistence/backend-mode'\nimport { resolveDeploymentLocales } from '../workspace'\n\n/**\n * Environment wiring for the EC-146 revalidation webhooks. The dispatcher is\n * attached only when a host endpoint is configured, so dev sessions and tests\n * (no env) run with zero webhook overhead.\n *\n * Configuration (Vite client env; SSR falls back to `process.env`):\n *\n * - `VITE_REVALIDATE_ENDPOINT` — the host's revalidate route URL\n * (e.g. `https://example-site.vercel.app/api/revalidate`)\n * - `VITE_REVALIDATE_SECRET` — shared HMAC secret (must equal the host's\n * `REVALIDATE_SECRET`). Browser-exposed by design in the v1 dispatch path —\n * see the CORS/secret notes in `dispatcher.ts`.\n * - `VITE_REVALIDATE_LOCALES` — optional comma-separated locale list that can ADD\n * locales to the revalidation tag set. It can never NARROW it: the project's\n * `elytra.config.ts` locales are always covered (EC-192), so a non-default\n * locale like `/de` can never be the one left stale after a publish.\n *\n * The host must allow the studio origin via\n * `createRevalidateRoute({ allowOrigin })` for the browser preflight to pass.\n */\n\n// EC-357: env reads go through the ONE studio env surface (`studioEnv()` — the\n// mount-provided env first, then the engine-visible Vite env over `process.env`).\n// A direct `import.meta.env` read here would be dep-optimized away in a real\n// consumer (pinned by `env-read-discipline.test.ts`).\nfunction readEnv(name: string): string | undefined {\n return studioEnv()[name]\n}\n\n/**\n * Attach the dispatcher from environment configuration. Returns `null` when\n * no endpoint/secret is configured (the common local-dev case).\n */\nexport function attachConfiguredRevalidationWebhooks(\n events: OperationEventBus,\n): WebhookDispatcherAttachment | null {\n const url = readEnv('VITE_REVALIDATE_ENDPOINT')\n const secret = readEnv('VITE_REVALIDATE_SECRET')\n if (!url || !secret) return null\n\n return attachRevalidationWebhooks(events, {\n endpoints: [{ url, secret }],\n locales: revalidationLocales(),\n onError: (error, context) => {\n // Visible, never fatal: a host outage must not break editing.\n console.warn(`[elytra] revalidation webhook delivery failed (${context.url})`, error)\n },\n })\n}\n\n/**\n * The locales the dispatcher emits per-collection tags for. The project's\n * configured locales (the same set the host tags by) are always covered so no\n * locale can be silently dropped (EC-192 — the `/de` freshness fix); the\n * `VITE_REVALIDATE_LOCALES` env may only ADD to that set. Falls back to `en`\n * only when neither config nor env supplies a locale (env-only wiring).\n */\nexport function revalidationLocales(): string[] {\n const configLocales = resolveDeploymentLocales()\n const envLocales = (readEnv('VITE_REVALIDATE_LOCALES') ?? '')\n .split(',')\n .map((locale) => locale.trim())\n .filter((locale) => locale.length > 0)\n const union = [...new Set([...configLocales, ...envLocales])]\n return union.length > 0 ? union : ['en']\n}\n"]}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { OperationEvent, OperationEventBus } from '@elytracms/core/operations';
|
|
2
|
-
/**
|
|
3
|
-
* Revalidation webhook dispatcher (EC-146): subscribes to the EC-139
|
|
4
|
-
* operations event bus and POSTs a signed `{projectId, event, tags,
|
|
5
|
-
* timestamp}` payload to every configured host revalidate endpoint whenever a
|
|
6
|
-
* command that can change **published** output succeeds (document
|
|
7
|
-
* publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus
|
|
8
|
-
* asset edits — EC-501 narrowed this from "any document write": a draft save
|
|
9
|
-
* cannot move published state, so it fires nothing; EC-510 added the redirect
|
|
10
|
-
* writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)
|
|
11
|
-
* verifies the HMAC and maps the tags onto `revalidateTag`.
|
|
12
|
-
*
|
|
13
|
-
* This runs **wherever the operations client runs** — in the v1 studio that
|
|
14
|
-
* is the browser, so deliveries are cross-origin `fetch` calls. CORS
|
|
15
|
-
* implications (documented honestly):
|
|
16
|
-
*
|
|
17
|
-
* - the custom signature header always triggers a preflight, so the host
|
|
18
|
-
* endpoint must answer `OPTIONS` and allow the studio origin —
|
|
19
|
-
* `createRevalidateRoute({ allowOrigin })` does both;
|
|
20
|
-
* - the shared secret necessarily ships in the studio bundle in this mode.
|
|
21
|
-
* Acceptable for v1 (the secret only gates cache invalidation, never
|
|
22
|
-
* content access); the server-side Convex action variant
|
|
23
|
-
* (`apps/builder/convex/webhooks.ts`) keeps the secret out of the browser
|
|
24
|
-
* for Convex-backed deployments.
|
|
25
|
-
*
|
|
26
|
-
* Deliveries are fire-and-forget on a serial queue (same pattern as the
|
|
27
|
-
* EC-155 reference index): a slow or unreachable host never blocks the
|
|
28
|
-
* command pipeline, failures surface through `onError` only, and tests await
|
|
29
|
-
* `flush()` for determinism.
|
|
30
|
-
*/
|
|
31
|
-
export interface WebhookEndpoint {
|
|
32
|
-
/** Absolute URL of the host's revalidate route, e.g. `https://site/api/revalidate`. */
|
|
33
|
-
url: string;
|
|
34
|
-
/** Shared HMAC secret for this endpoint. */
|
|
35
|
-
secret: string;
|
|
36
|
-
}
|
|
37
|
-
export interface WebhookDispatcherOptions {
|
|
38
|
-
endpoints: readonly WebhookEndpoint[];
|
|
39
|
-
/**
|
|
40
|
-
* All locales the project serves. Document events sweep the collection
|
|
41
|
-
* membership tag once per locale (`collection:<name>:<locale>`).
|
|
42
|
-
*/
|
|
43
|
-
locales: readonly string[];
|
|
44
|
-
/** Injectable transport (tests); defaults to global `fetch`. */
|
|
45
|
-
fetchFn?: typeof fetch;
|
|
46
|
-
/** Injectable clock (tests); payload `timestamp` is `now().toISOString()`. */
|
|
47
|
-
now?: () => Date;
|
|
48
|
-
/** Delivery failures land here (never thrown into the command pipeline). */
|
|
49
|
-
onError?: (error: unknown, context: {
|
|
50
|
-
url: string;
|
|
51
|
-
event: OperationEvent;
|
|
52
|
-
}) => void;
|
|
53
|
-
}
|
|
54
|
-
export interface WebhookDispatcherAttachment {
|
|
55
|
-
detach(): void;
|
|
56
|
-
/** Await all queued deliveries (tests / shutdown). */
|
|
57
|
-
flush(): Promise<void>;
|
|
58
|
-
}
|
|
59
|
-
/** The signed payload shape (mirror of `revalidatePayloadSchema` in @elytracms/next). */
|
|
60
|
-
export interface RevalidateWebhookPayload {
|
|
61
|
-
projectId: string;
|
|
62
|
-
event: string;
|
|
63
|
-
tags: string[];
|
|
64
|
-
timestamp: string;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Attach the dispatcher to an event bus; returns a detach/flush handle.
|
|
68
|
-
* With no endpoints configured this still subscribes but never queues work.
|
|
69
|
-
*/
|
|
70
|
-
export declare function attachRevalidationWebhooks(events: OperationEventBus, options: WebhookDispatcherOptions): WebhookDispatcherAttachment;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { WEBHOOK_SIGNATURE_HEADER, signWebhookBody } from './signing.js';
|
|
2
|
-
import { webhookTagsForEvent } from './tags.js';
|
|
3
|
-
async function deliver(endpoint, tagSet, timestamp, fetchFn) {
|
|
4
|
-
const payload = {
|
|
5
|
-
projectId: tagSet.projectId,
|
|
6
|
-
event: tagSet.event,
|
|
7
|
-
tags: tagSet.tags,
|
|
8
|
-
timestamp,
|
|
9
|
-
};
|
|
10
|
-
// Sign the exact string that goes on the wire.
|
|
11
|
-
const rawBody = JSON.stringify(payload);
|
|
12
|
-
const signature = await signWebhookBody(rawBody, endpoint.secret);
|
|
13
|
-
const response = await fetchFn(endpoint.url, {
|
|
14
|
-
method: 'POST',
|
|
15
|
-
headers: {
|
|
16
|
-
'content-type': 'application/json',
|
|
17
|
-
[WEBHOOK_SIGNATURE_HEADER]: signature,
|
|
18
|
-
},
|
|
19
|
-
body: rawBody,
|
|
20
|
-
});
|
|
21
|
-
if (!response.ok) {
|
|
22
|
-
throw new Error(`Revalidation webhook to ${endpoint.url} answered ${response.status}.`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Attach the dispatcher to an event bus; returns a detach/flush handle.
|
|
27
|
-
* With no endpoints configured this still subscribes but never queues work.
|
|
28
|
-
*/
|
|
29
|
-
export function attachRevalidationWebhooks(events, options) {
|
|
30
|
-
const fetchFn = options.fetchFn ?? fetch;
|
|
31
|
-
const now = options.now ?? (() => new Date());
|
|
32
|
-
let pending = Promise.resolve();
|
|
33
|
-
const listener = (event) => {
|
|
34
|
-
if (options.endpoints.length === 0)
|
|
35
|
-
return;
|
|
36
|
-
const tagSet = webhookTagsForEvent(event, { locales: options.locales });
|
|
37
|
-
if (!tagSet)
|
|
38
|
-
return;
|
|
39
|
-
const timestamp = now().toISOString();
|
|
40
|
-
for (const endpoint of options.endpoints) {
|
|
41
|
-
pending = pending
|
|
42
|
-
.then(() => deliver(endpoint, tagSet, timestamp, fetchFn))
|
|
43
|
-
.catch((error) => {
|
|
44
|
-
options.onError?.(error, { url: endpoint.url, event });
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
const unsubscribe = events.subscribe(listener);
|
|
49
|
-
return {
|
|
50
|
-
detach: unsubscribe,
|
|
51
|
-
flush: async () => {
|
|
52
|
-
// Drain until no new deliveries were queued while awaiting.
|
|
53
|
-
let current;
|
|
54
|
-
do {
|
|
55
|
-
current = pending;
|
|
56
|
-
await current;
|
|
57
|
-
} while (current !== pending);
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../src/lib/webhooks/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAqE5C,KAAK,UAAU,OAAO,CACpB,QAAyB,EACzB,MAAqB,EACrB,SAAiB,EACjB,OAAqB;IAErB,MAAM,OAAO,GAA6B;QACxC,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,SAAS;KACV,CAAA;IACD,+CAA+C;IAC/C,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvC,MAAM,SAAS,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,CAAC,wBAAwB,CAAC,EAAE,SAAS;SACtC;QACD,IAAI,EAAE,OAAO;KACd,CAAC,CAAA;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,CAAC,GAAG,aAAa,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;IACzF,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAyB,EACzB,OAAiC;IAEjC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,KAAK,CAAA;IACxC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAE7C,IAAI,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAA;IAE9C,MAAM,QAAQ,GAAG,CAAC,KAAqB,EAAQ,EAAE;QAC/C,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAC1C,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;QACvE,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,WAAW,EAAE,CAAA;QACrC,KAAK,MAAM,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACzC,OAAO,GAAG,OAAO;iBACd,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;iBACzD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;gBACxB,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAA;YACxD,CAAC,CAAC,CAAA;QACN,CAAC;IACH,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC9C,OAAO;QACL,MAAM,EAAE,WAAW;QACnB,KAAK,EAAE,KAAK,IAAI,EAAE;YAChB,4DAA4D;YAC5D,IAAI,OAAsB,CAAA;YAC1B,GAAG,CAAC;gBACF,OAAO,GAAG,OAAO,CAAA;gBACjB,MAAM,OAAO,CAAA;YACf,CAAC,QAAQ,OAAO,KAAK,OAAO,EAAC;QAC/B,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { OperationEvent, OperationEventBus } from '@elytracms/core/operations'\nimport { WEBHOOK_SIGNATURE_HEADER, signWebhookBody } from './signing'\nimport { webhookTagsForEvent } from './tags'\nimport type { WebhookTagSet } from './tags'\n\n/**\n * Revalidation webhook dispatcher (EC-146): subscribes to the EC-139\n * operations event bus and POSTs a signed `{projectId, event, tags,\n * timestamp}` payload to every configured host revalidate endpoint whenever a\n * command that can change **published** output succeeds (document\n * publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus\n * asset edits — EC-501 narrowed this from \"any document write\": a draft save\n * cannot move published state, so it fires nothing; EC-510 added the redirect\n * writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)\n * verifies the HMAC and maps the tags onto `revalidateTag`.\n *\n * This runs **wherever the operations client runs** — in the v1 studio that\n * is the browser, so deliveries are cross-origin `fetch` calls. CORS\n * implications (documented honestly):\n *\n * - the custom signature header always triggers a preflight, so the host\n * endpoint must answer `OPTIONS` and allow the studio origin —\n * `createRevalidateRoute({ allowOrigin })` does both;\n * - the shared secret necessarily ships in the studio bundle in this mode.\n * Acceptable for v1 (the secret only gates cache invalidation, never\n * content access); the server-side Convex action variant\n * (`apps/builder/convex/webhooks.ts`) keeps the secret out of the browser\n * for Convex-backed deployments.\n *\n * Deliveries are fire-and-forget on a serial queue (same pattern as the\n * EC-155 reference index): a slow or unreachable host never blocks the\n * command pipeline, failures surface through `onError` only, and tests await\n * `flush()` for determinism.\n */\n\nexport interface WebhookEndpoint {\n /** Absolute URL of the host's revalidate route, e.g. `https://site/api/revalidate`. */\n url: string\n /** Shared HMAC secret for this endpoint. */\n secret: string\n}\n\nexport interface WebhookDispatcherOptions {\n endpoints: readonly WebhookEndpoint[]\n /**\n * All locales the project serves. Document events sweep the collection\n * membership tag once per locale (`collection:<name>:<locale>`).\n */\n locales: readonly string[]\n /** Injectable transport (tests); defaults to global `fetch`. */\n fetchFn?: typeof fetch\n /** Injectable clock (tests); payload `timestamp` is `now().toISOString()`. */\n now?: () => Date\n /** Delivery failures land here (never thrown into the command pipeline). */\n onError?: (error: unknown, context: { url: string; event: OperationEvent }) => void\n}\n\nexport interface WebhookDispatcherAttachment {\n detach(): void\n /** Await all queued deliveries (tests / shutdown). */\n flush(): Promise<void>\n}\n\n/** The signed payload shape (mirror of `revalidatePayloadSchema` in @elytracms/next). */\nexport interface RevalidateWebhookPayload {\n projectId: string\n event: string\n tags: string[]\n timestamp: string\n}\n\nasync function deliver(\n endpoint: WebhookEndpoint,\n tagSet: WebhookTagSet,\n timestamp: string,\n fetchFn: typeof fetch,\n): Promise<void> {\n const payload: RevalidateWebhookPayload = {\n projectId: tagSet.projectId,\n event: tagSet.event,\n tags: tagSet.tags,\n timestamp,\n }\n // Sign the exact string that goes on the wire.\n const rawBody = JSON.stringify(payload)\n const signature = await signWebhookBody(rawBody, endpoint.secret)\n const response = await fetchFn(endpoint.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n [WEBHOOK_SIGNATURE_HEADER]: signature,\n },\n body: rawBody,\n })\n if (!response.ok) {\n throw new Error(`Revalidation webhook to ${endpoint.url} answered ${response.status}.`)\n }\n}\n\n/**\n * Attach the dispatcher to an event bus; returns a detach/flush handle.\n * With no endpoints configured this still subscribes but never queues work.\n */\nexport function attachRevalidationWebhooks(\n events: OperationEventBus,\n options: WebhookDispatcherOptions,\n): WebhookDispatcherAttachment {\n const fetchFn = options.fetchFn ?? fetch\n const now = options.now ?? (() => new Date())\n\n let pending: Promise<void> = Promise.resolve()\n\n const listener = (event: OperationEvent): void => {\n if (options.endpoints.length === 0) return\n const tagSet = webhookTagsForEvent(event, { locales: options.locales })\n if (!tagSet) return\n const timestamp = now().toISOString()\n for (const endpoint of options.endpoints) {\n pending = pending\n .then(() => deliver(endpoint, tagSet, timestamp, fetchFn))\n .catch((error: unknown) => {\n options.onError?.(error, { url: endpoint.url, event })\n })\n }\n }\n\n const unsubscribe = events.subscribe(listener)\n return {\n detach: unsubscribe,\n flush: async () => {\n // Drain until no new deliveries were queued while awaiting.\n let current: Promise<void>\n do {\n current = pending\n await current\n } while (current !== pending)\n },\n }\n}\n"]}
|