@tokenoftrust/storefront-runner 2.4.25 → 2.4.27
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/apps/storefront/src/lib/publication/mountedDiscovery.ts +6 -1
- package/apps/storefront/src/lib/publication/runtimeSource.ts +12 -2
- package/apps/storefront/src/lib/publication/servingMode.ts +52 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +7 -2
- package/package.json +1 -1
- package/packages/cli/src/publication-spaces.mjs +223 -0
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/publication-recovery.ts +132 -0
- package/scripts/dev/publication-spaces-loop.mjs +122 -0
- package/scripts/tot-dev.mjs +29 -11
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
PUBLICATION_DISCOVERY_ENTRY_KINDS_V1,
|
|
29
29
|
type PublicationDiscoveryEntryKindV1,
|
|
30
30
|
} from "@tot/public-runtime";
|
|
31
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
31
32
|
import { rawPageCanonicalUrl } from "../rawMarketingHtml.js";
|
|
32
33
|
|
|
33
34
|
export type DiscoverySurfaceV1 = "sitemap" | "feeds" | "publicSearch" | "llms";
|
|
@@ -135,14 +136,18 @@ export async function readMountedPublicationDiscoveryV1(input: {
|
|
|
135
136
|
if (mounts.size === 0 || !input.store) return [];
|
|
136
137
|
let projectionJson: string | null;
|
|
137
138
|
let stateJson: string | null;
|
|
139
|
+
let recoveryJson: string | null;
|
|
138
140
|
try {
|
|
139
|
-
[projectionJson, stateJson] = await Promise.all([
|
|
141
|
+
[projectionJson, stateJson, recoveryJson] = await Promise.all([
|
|
140
142
|
input.store.get(publicationMountedDiscoveryKeyV1(input.tenant.tenant_id)),
|
|
141
143
|
input.store.get(publicationVersionStateKey(input.tenant.tenant_id)),
|
|
144
|
+
input.store.get(publicationRecoveryKeyV1(input.tenant.tenant_id)),
|
|
142
145
|
]);
|
|
143
146
|
} catch {
|
|
144
147
|
return [];
|
|
145
148
|
}
|
|
149
|
+
// Under a whole-site recovery the mounts serve the whole-site release, which lists itself.
|
|
150
|
+
if (publicationRecoveryActiveV1(recoveryJson)) return [];
|
|
146
151
|
if (!projectionJson || !stateJson || projectionJson.length > MAX_PROJECTION_CHARS) return [];
|
|
147
152
|
const state = parsePublicationVersionStateV1Json(stateJson);
|
|
148
153
|
if (!state.ok) return [];
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PublicationRoutePlanV1,
|
|
13
13
|
type RouteTierIndexV1,
|
|
14
14
|
} from "@tot/public-runtime";
|
|
15
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
15
16
|
|
|
16
17
|
export interface PublicationStateStoreV1 {
|
|
17
18
|
get(key: string): Promise<string | null>;
|
|
@@ -110,7 +111,9 @@ export type PublicationRuntimeSourceV1 =
|
|
|
110
111
|
* Resolve one live request from the public KV state projection into its exact
|
|
111
112
|
* immutable artifact. A carried plan is checked against current route authority
|
|
112
113
|
* before state or object-store I/O. Only an unselected root keeps the legacy
|
|
113
|
-
* adapter; a mounted space never falls through to root content
|
|
114
|
+
* adapter; a mounted space never falls through to root content — except under a
|
|
115
|
+
* whole-site recovery (`publicationRecoveryKeyV1`), when every public space serves
|
|
116
|
+
* the tenant's whole-site release on `live`, exactly as before its spaces went live.
|
|
114
117
|
*/
|
|
115
118
|
export async function resolvePublicationRuntimeSourceV1(input: {
|
|
116
119
|
index: RouteTierIndexV1;
|
|
@@ -132,11 +135,18 @@ export async function resolvePublicationRuntimeSourceV1(input: {
|
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
let stateJson: string | null;
|
|
138
|
+
let recoveryJson: string | null = null;
|
|
135
139
|
try {
|
|
136
|
-
stateJson = await
|
|
140
|
+
[stateJson, recoveryJson] = await Promise.all([
|
|
141
|
+
input.store.get(publicationVersionStateKey(input.plan.manifest.tenant)),
|
|
142
|
+
input.channel === "live" && input.plan.manifest.visibility === "public"
|
|
143
|
+
? input.store.get(publicationRecoveryKeyV1(input.plan.manifest.tenant))
|
|
144
|
+
: Promise.resolve(null),
|
|
145
|
+
]);
|
|
137
146
|
} catch {
|
|
138
147
|
return { status: "unavailable", code: "publication_artifact_source_unavailable" };
|
|
139
148
|
}
|
|
149
|
+
if (publicationRecoveryActiveV1(recoveryJson)) return { status: "legacy-root" };
|
|
140
150
|
if (stateJson === null) {
|
|
141
151
|
return isRoot
|
|
142
152
|
? { status: "legacy-root" }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a tenant's ROOT is served by a publication space, which decides what a whole-site
|
|
3
|
+
* promotion (ship, rollback, hotfix, re-publish, platform reconcile) can do for it.
|
|
4
|
+
*
|
|
5
|
+
* While the root public space is live and no whole-site recovery holds, a whole-site promotion
|
|
6
|
+
* cannot reach visitors: on Cloudflare the selected root space shadows the whole-site live channel
|
|
7
|
+
* (the job would report success while visitors kept the space), and on CloudFront the aggregate
|
|
8
|
+
* pointer refuses a whole-site publish after minutes of rendering. So it refuses up front with
|
|
9
|
+
* `publication_root_is_space`, naming the root space and how its content goes live instead.
|
|
10
|
+
*
|
|
11
|
+
* Behaviour follows the declared topology and the editorial state, never the tenant id.
|
|
12
|
+
*/
|
|
13
|
+
import type { PublicationVersionStateV1, TenantConfig } from "@tot/public-runtime";
|
|
14
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
15
|
+
|
|
16
|
+
export const PUBLICATION_ROOT_IS_SPACE = "publication_root_is_space";
|
|
17
|
+
|
|
18
|
+
export type PublicationServingModeV1 =
|
|
19
|
+
| { mode: "whole-site"; reason: "no-root-space" | "root-not-live" | "recovered" }
|
|
20
|
+
| { mode: "spaces"; root: { spaceId: string; versionId: string; revision: number; source: string } };
|
|
21
|
+
|
|
22
|
+
export async function publicationServingModeV1(input: {
|
|
23
|
+
tenant: Pick<TenantConfig, "tenant_id" | "publicationSpaces">;
|
|
24
|
+
readState(): Promise<PublicationVersionStateV1>;
|
|
25
|
+
kvGet(key: string): Promise<string | null>;
|
|
26
|
+
}): Promise<PublicationServingModeV1> {
|
|
27
|
+
const root = input.tenant.publicationSpaces?.find((space) => space.tenant === input.tenant.tenant_id &&
|
|
28
|
+
space.visibility === "public" && space.routes.canonicalMount === "/");
|
|
29
|
+
if (!root) return { mode: "whole-site", reason: "no-root-space" };
|
|
30
|
+
if (publicationRecoveryActiveV1(await input.kvGet(publicationRecoveryKeyV1(input.tenant.tenant_id)))) {
|
|
31
|
+
return { mode: "whole-site", reason: "recovered" };
|
|
32
|
+
}
|
|
33
|
+
const pointer = (await input.readState()).pointers.find((candidate) =>
|
|
34
|
+
candidate.tenant === input.tenant.tenant_id && candidate.spaceId === root.id && candidate.channel === "live");
|
|
35
|
+
if (!pointer) return { mode: "whole-site", reason: "root-not-live" };
|
|
36
|
+
return { mode: "spaces", root: { spaceId: root.id, versionId: pointer.versionId, revision: pointer.revision,
|
|
37
|
+
source: `${root.source.forge}:${root.source.repo}` } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The refusal a whole-site promotion gets while the root is a live publication space. */
|
|
41
|
+
export function publicationRootIsSpaceRefusalV1(
|
|
42
|
+
tenantId: string,
|
|
43
|
+
mode: Extract<PublicationServingModeV1, { mode: "spaces" }>,
|
|
44
|
+
): string {
|
|
45
|
+
const { spaceId, versionId, revision, source } = mode.root;
|
|
46
|
+
return `${PUBLICATION_ROOT_IS_SPACE}: ${tenantId}'s root is served by publication space "${spaceId}" ` +
|
|
47
|
+
`(source ${source}, live ${versionId} at revision ${revision}), so a whole-site release would never reach visitors. ` +
|
|
48
|
+
`Put root content live by activating a root-space version: build it ` +
|
|
49
|
+
`(scripts/publish/build-publication-space-version.ts --tenant ${tenantId} --space ${spaceId} --source-sha <sha>), then ` +
|
|
50
|
+
`POST /api/publication-spaces/${spaceId}/review and /api/publication-spaces/${spaceId}/activate. ` +
|
|
51
|
+
`To serve the whole site instead, recover it first (scripts/publish/recover-publication-tenant.mjs recover).`;
|
|
52
|
+
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type R2BucketLike,
|
|
42
42
|
type TenantConfig,
|
|
43
43
|
} from "@tot/public-runtime";
|
|
44
|
+
import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
|
|
44
45
|
import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
|
|
45
46
|
import { readR2, readKv, readEnv } from "@/lib/env";
|
|
46
47
|
import {
|
|
@@ -95,8 +96,12 @@ async function tryServePublicationRootAsset(input: {
|
|
|
95
96
|
const tenant = input.tenant;
|
|
96
97
|
if (!tenant || tenant.tenant_id !== input.tenantId ||
|
|
97
98
|
!tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return null;
|
|
98
|
-
const stateJson = await
|
|
99
|
-
|
|
99
|
+
const [stateJson, recoveryJson] = await Promise.all([
|
|
100
|
+
input.kvGet(publicationVersionStateKey(input.tenantId)),
|
|
101
|
+
input.channel === "live" ? input.kvGet(publicationRecoveryKeyV1(input.tenantId)) : Promise.resolve(null),
|
|
102
|
+
]);
|
|
103
|
+
// A whole-site recovery serves the whole-site release's assets, through the legacy pointer.
|
|
104
|
+
if (stateJson === null || publicationRecoveryActiveV1(recoveryJson)) return null;
|
|
100
105
|
const state = parsePublicationVersionStateV1Json(stateJson);
|
|
101
106
|
if (!state.ok) return null;
|
|
102
107
|
const read = await readPublicationRootAssetV1({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.27",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tenant's publication topology as a developer's workspace sees it: one ROOT repository serving
|
|
3
|
+
* `/`, plus any MOUNTED spaces — each its own repository serving one mount (`/blog`, `/docs`).
|
|
4
|
+
*
|
|
5
|
+
* The platform declares the topology; the workspace only records what it was told, in
|
|
6
|
+
* `.tot/publication-spaces.json` (written by `tot clone`, never committed — the root repo's git
|
|
7
|
+
* excludes it, and the `spaces/` folder each mounted space is cloned into). Every command that needs
|
|
8
|
+
* to know which repository owns a URL or a file reads it through this module, so `tot validate`,
|
|
9
|
+
* `tot dev`, `tot clone` and the change router answer the same way.
|
|
10
|
+
*
|
|
11
|
+
* Mount matching mirrors packages/public-runtime/src/publication-space.ts
|
|
12
|
+
* (`publicationMountMatchesPath`): a mount owns itself and everything below it, segment-wise and
|
|
13
|
+
* case-sensitively; the longest matching mount wins and `/` owns what nothing else claims.
|
|
14
|
+
* apps/storefront/src/config/publicationSpaceOwnership.test.ts pins the two together.
|
|
15
|
+
*
|
|
16
|
+
* Dependency-free: the published CLI ships it as-is.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
|
|
22
|
+
export const SPACES_DIR = "spaces";
|
|
23
|
+
/** The workspace's record of the declared topology, relative to the root checkout. */
|
|
24
|
+
export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
|
|
25
|
+
export const TOPOLOGY_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
|
|
28
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ forge: string, repo: string, defaultBranch: string }} SpaceSource
|
|
32
|
+
* @typedef {{ id: string, mount: string, visibility: "public"|"internal", source: SpaceSource,
|
|
33
|
+
* aliases?: string[], collections?: Record<string, string> }} TopologySpace
|
|
34
|
+
* @typedef {{ version: 1, tenant: string, spaces: TopologySpace[] }} Topology
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** A mount in canonical form: leading `/`, no trailing `/` except the root itself. Null when not a mount. */
|
|
38
|
+
export function normalizeMount(value) {
|
|
39
|
+
if (typeof value !== "string" || !value.startsWith("/") || /[?#\\]/.test(value)) return null;
|
|
40
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
41
|
+
if (!trimmed) return "/";
|
|
42
|
+
if (trimmed.split("/").slice(1).some((seg) => !seg || seg === "." || seg === "..")) return null;
|
|
43
|
+
return trimmed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A request path reduced to what routing reads: no origin, query or fragment; NFC; single slashes. */
|
|
47
|
+
export function normalizeRequestPath(value) {
|
|
48
|
+
let path = String(value ?? "").trim();
|
|
49
|
+
try {
|
|
50
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) path = new URL(path).pathname;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
path = path.replace(/[?#].*$/, "");
|
|
55
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
56
|
+
try {
|
|
57
|
+
path = decodeURIComponent(path);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
path = path.normalize("NFC").replace(/\/{2,}/g, "/");
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Does `mount` own `path` (both normalized)? A mount owns itself and every path below it. */
|
|
66
|
+
export function mountMatchesPath(mount, path) {
|
|
67
|
+
if (mount === "/") return true;
|
|
68
|
+
return path === mount || path === `${mount}/` || path.startsWith(`${mount}/`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a recorded topology. Throws with the reason on anything malformed — a half-read topology
|
|
73
|
+
* would route a change to the wrong repository.
|
|
74
|
+
* @param {unknown} input
|
|
75
|
+
* @returns {Topology}
|
|
76
|
+
*/
|
|
77
|
+
export function parseTopology(input) {
|
|
78
|
+
const raw = /** @type {any} */ (input);
|
|
79
|
+
if (!isObject(raw) || raw.version !== TOPOLOGY_VERSION || typeof raw.tenant !== "string" || !raw.tenant ||
|
|
80
|
+
!Array.isArray(raw.spaces)) {
|
|
81
|
+
throw new Error(`${TOPOLOGY_FILE} is not a version-${TOPOLOGY_VERSION} topology`);
|
|
82
|
+
}
|
|
83
|
+
const ids = new Set();
|
|
84
|
+
const mounts = new Set();
|
|
85
|
+
const spaces = raw.spaces.map((space, index) => {
|
|
86
|
+
const at = `${TOPOLOGY_FILE} spaces[${index}]`;
|
|
87
|
+
if (!isObject(space) || typeof space.id !== "string" || !SPACE_ID.test(space.id)) throw new Error(`${at}: invalid id`);
|
|
88
|
+
const mount = normalizeMount(space.mount);
|
|
89
|
+
if (!mount) throw new Error(`${at}: invalid mount`);
|
|
90
|
+
if (space.visibility !== "public" && space.visibility !== "internal") throw new Error(`${at}: invalid visibility`);
|
|
91
|
+
const source = space.source;
|
|
92
|
+
if (!isObject(source) || typeof source.forge !== "string" || typeof source.repo !== "string" || !source.repo ||
|
|
93
|
+
typeof source.defaultBranch !== "string" || !source.defaultBranch) throw new Error(`${at}: invalid source`);
|
|
94
|
+
if (ids.has(space.id)) throw new Error(`${at}: duplicate id ${space.id}`);
|
|
95
|
+
if (mounts.has(mount)) throw new Error(`${at}: duplicate mount ${mount}`);
|
|
96
|
+
ids.add(space.id);
|
|
97
|
+
mounts.add(mount);
|
|
98
|
+
const aliases = Array.isArray(space.aliases) ? space.aliases.map(normalizeMount).filter(Boolean) : [];
|
|
99
|
+
return {
|
|
100
|
+
id: space.id,
|
|
101
|
+
mount,
|
|
102
|
+
visibility: space.visibility,
|
|
103
|
+
source: { forge: source.forge, repo: source.repo, defaultBranch: source.defaultBranch },
|
|
104
|
+
...(aliases.length ? { aliases } : {}),
|
|
105
|
+
...(isObject(space.collections) ? { collections: { ...space.collections } } : {}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
if (spaces.filter((space) => space.mount === "/").length !== 1) {
|
|
109
|
+
throw new Error(`${TOPOLOGY_FILE}: exactly one space must own "/"`);
|
|
110
|
+
}
|
|
111
|
+
return { version: TOPOLOGY_VERSION, tenant: raw.tenant, spaces };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a topology from the platform's declared-space list (`GET /api/publication-spaces`, whose
|
|
116
|
+
* rows carry `id`, `visibility`, `canonicalMount`, `source` and `routes`).
|
|
117
|
+
* @param {string} tenant
|
|
118
|
+
* @param {unknown} rows
|
|
119
|
+
* @returns {Topology}
|
|
120
|
+
*/
|
|
121
|
+
export function topologyFromDeclaredSpaces(tenant, rows) {
|
|
122
|
+
if (!Array.isArray(rows)) throw new Error("the platform returned no publication-space list");
|
|
123
|
+
return parseTopology({
|
|
124
|
+
version: TOPOLOGY_VERSION,
|
|
125
|
+
tenant,
|
|
126
|
+
spaces: rows.map((row) => ({
|
|
127
|
+
id: row?.id,
|
|
128
|
+
mount: row?.routes?.canonicalMount ?? row?.canonicalMount,
|
|
129
|
+
visibility: row?.visibility,
|
|
130
|
+
source: row?.source,
|
|
131
|
+
aliases: Array.isArray(row?.routes?.aliases) ? row.routes.aliases.map((alias) => alias?.from) : undefined,
|
|
132
|
+
collections: row?.routes?.collections,
|
|
133
|
+
})),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The root space (owns `/`; `parseTopology` guarantees exactly one). @param {Topology} topology */
|
|
138
|
+
export function rootSpace(topology) {
|
|
139
|
+
return /** @type {TopologySpace} */ (topology.spaces.find((space) => space.mount === "/"));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The mounted spaces, longest mount first. @param {Topology} topology */
|
|
143
|
+
export function mountedSpaces(topology) {
|
|
144
|
+
return topology.spaces.filter((space) => space.mount !== "/").sort((a, b) => b.mount.length - a.mount.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Which space owns a URL path: the longest canonical mount (or alias) that owns it, else the root.
|
|
149
|
+
* Returns null for a path that cannot be read as a URL path.
|
|
150
|
+
* @param {Topology} topology
|
|
151
|
+
* @param {string} urlPath
|
|
152
|
+
* @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
|
|
153
|
+
*/
|
|
154
|
+
export function owningSpace(topology, urlPath) {
|
|
155
|
+
const path = normalizeRequestPath(urlPath);
|
|
156
|
+
if (path === null) return null;
|
|
157
|
+
/** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
|
|
158
|
+
let best = null;
|
|
159
|
+
for (const space of topology.spaces) {
|
|
160
|
+
if (space.mount === "/") continue;
|
|
161
|
+
/** @type {Array<[string, "mount"|"alias"]>} */
|
|
162
|
+
const claims = [[space.mount, "mount"], ...(space.aliases ?? []).map((alias) => /** @type {[string, "alias"]} */ ([alias, "alias"]))];
|
|
163
|
+
for (const [mount, via] of claims) {
|
|
164
|
+
if (mountMatchesPath(mount, path) && (!best || mount.length > best.mount.length)) best = { space, via, mount };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (best) return { space: best.space, via: best.via };
|
|
168
|
+
return { space: rootSpace(topology), via: "root" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A mounted space's working tree, relative to the root checkout. @param {TopologySpace} space */
|
|
172
|
+
export function spaceDir(space) {
|
|
173
|
+
return space.mount === "/" ? "" : `${SPACES_DIR}/${space.id}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which space a workspace file belongs to, from its path relative to the root checkout:
|
|
178
|
+
* `spaces/<id>/...` is that space's, everything else is the root's. A file under `spaces/` naming
|
|
179
|
+
* no declared space belongs to none (null) — it must not be routed anywhere.
|
|
180
|
+
* @param {Topology} topology
|
|
181
|
+
* @param {string} relPath
|
|
182
|
+
* @returns {{ space: TopologySpace, path: string } | null}
|
|
183
|
+
*/
|
|
184
|
+
export function spaceForWorkspaceFile(topology, relPath) {
|
|
185
|
+
const rel = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
186
|
+
if (rel === SPACES_DIR || rel.startsWith(`${SPACES_DIR}/`)) {
|
|
187
|
+
const [, id, ...rest] = rel.split("/");
|
|
188
|
+
const space = topology.spaces.find((candidate) => candidate.mount !== "/" && candidate.id === id);
|
|
189
|
+
return space ? { space, path: rest.join("/") } : null;
|
|
190
|
+
}
|
|
191
|
+
return { space: rootSpace(topology), path: rel };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read a workspace's recorded topology, or null when the workspace records none. Throws when malformed. */
|
|
195
|
+
export function readWorkspaceTopology(workspace) {
|
|
196
|
+
const file = join(workspace, TOPOLOGY_FILE);
|
|
197
|
+
if (!existsSync(file)) return null;
|
|
198
|
+
return parseTopology(JSON.parse(readFileSync(file, "utf8")));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Record a topology in a root checkout, and keep it — and the cloned spaces — out of the root
|
|
203
|
+
* repository: both are listed in the checkout's own `.git/info/exclude`, never in a committed file.
|
|
204
|
+
*/
|
|
205
|
+
export function writeWorkspaceTopology(workspace, topology) {
|
|
206
|
+
const file = join(workspace, TOPOLOGY_FILE);
|
|
207
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
208
|
+
writeFileSync(file, `${JSON.stringify(parseTopology(topology), null, 2)}\n`);
|
|
209
|
+
excludeFromRootRepo(workspace, [`/${TOPOLOGY_FILE}`, `/${SPACES_DIR}/`]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Append patterns to `<workspace>/.git/info/exclude`, once each. No-op outside a git checkout. */
|
|
213
|
+
export function excludeFromRootRepo(workspace, patterns) {
|
|
214
|
+
const gitDir = join(workspace, ".git");
|
|
215
|
+
if (!existsSync(gitDir)) return;
|
|
216
|
+
const file = join(gitDir, "info", "exclude");
|
|
217
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
218
|
+
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
219
|
+
const lines = new Set(current.split("\n").map((line) => line.trim()));
|
|
220
|
+
const missing = patterns.filter((pattern) => !lines.has(pattern));
|
|
221
|
+
if (!missing.length) return;
|
|
222
|
+
appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
|
|
223
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./embed-catalog": "./src/embed-catalog.mjs",
|
|
22
22
|
"./retention-holds": "./src/retention-holds.ts",
|
|
23
23
|
"./publication-content-redirects": "./src/publication-content-redirects.ts",
|
|
24
|
+
"./publication-recovery": "./src/publication-recovery.ts",
|
|
24
25
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
25
26
|
"./declared-tenant-config": "./src/declared-tenant-config.mjs",
|
|
26
27
|
"./compliance-coherence": "./src/compliance-coherence.mjs",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A whole-site recovery: a tenant served by its public publication spaces is put back on a
|
|
3
|
+
* whole-site release, and later resumed onto its spaces, each in one reviewed action
|
|
4
|
+
* (scripts/publish/recover-publication-tenant.mjs).
|
|
5
|
+
*
|
|
6
|
+
* The Worker holds the record under `publicationRecoveryKeyV1(tenant)`. While it is present every
|
|
7
|
+
* public space of the tenant serves through the whole-site path instead (`legacy-root`), whatever
|
|
8
|
+
* its live pointer selects; the editorial state is untouched, so a resume serves exactly what was
|
|
9
|
+
* live before plus anything activated during the recovery. On CloudFront the same fence rides in
|
|
10
|
+
* the KVS `live` value (scripts/publish/lib/cloudfront-live-pointer.mjs).
|
|
11
|
+
*
|
|
12
|
+
* Every recover and resume also writes an immutable audit record under
|
|
13
|
+
* `publicationRecoveryAuditKeyV1`, which is never deleted.
|
|
14
|
+
*/
|
|
15
|
+
import type { PublicationSpaceManifestV1 } from "./publication-space.js";
|
|
16
|
+
import type { PublicationVersionStateV1 } from "./publication-version.js";
|
|
17
|
+
|
|
18
|
+
export const PUBLICATION_RECOVERY_ID_RE = /^rcv_[a-z0-9][a-z0-9-]{6,78}[a-z0-9]$/;
|
|
19
|
+
const SHA40_RE = /^[0-9a-f]{40}$/;
|
|
20
|
+
const DIGEST_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
21
|
+
const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/;
|
|
22
|
+
const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
23
|
+
|
|
24
|
+
/** One public space as it was serving when the recovery replaced it. */
|
|
25
|
+
export interface PublicationRecoverySpaceV1 {
|
|
26
|
+
spaceId: string;
|
|
27
|
+
revision: number;
|
|
28
|
+
versionId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PublicationRecoveryV1 {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
tenant: string;
|
|
34
|
+
recoveryId: string;
|
|
35
|
+
/**
|
|
36
|
+
* The whole-site release now served: its version (a tenant commit sha) and bundle digest, each
|
|
37
|
+
* null when the tenant has none on that plane (a tenant whose whole site is its committed
|
|
38
|
+
* baseline has neither).
|
|
39
|
+
*/
|
|
40
|
+
wholeSite: { versionId: string | null; digest: string | null };
|
|
41
|
+
/** The public spaces' live pointers it replaced, and their generation (the sum of revisions). */
|
|
42
|
+
fromSpaces: { generation: number; spaces: PublicationRecoverySpaceV1[] };
|
|
43
|
+
principal: string;
|
|
44
|
+
at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PublicationRecoveryEventV1 = "recover" | "resume";
|
|
48
|
+
|
|
49
|
+
export function publicationRecoveryKeyV1(tenant: string): string {
|
|
50
|
+
return `cust:publication-recovery:${tenant}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function publicationRecoveryAuditKeyV1(tenant: string, recoveryId: string, event: PublicationRecoveryEventV1): string {
|
|
54
|
+
if (!PUBLICATION_RECOVERY_ID_RE.test(recoveryId)) throw new Error("publication recovery id is malformed");
|
|
55
|
+
return `cust:publication-recovery-audit:${tenant}:${recoveryId}:${event}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exactKeys(value: object, keys: readonly string[]): boolean {
|
|
59
|
+
const actual = Object.keys(value).sort();
|
|
60
|
+
const expected = [...keys].sort();
|
|
61
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nullable(value: unknown, re: RegExp): value is string | null {
|
|
65
|
+
return value === null || (typeof value === "string" && re.test(value));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validatePublicationRecoveryV1(value: unknown): value is PublicationRecoveryV1 {
|
|
69
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
70
|
+
const record = value as Record<string, unknown>;
|
|
71
|
+
if (!exactKeys(record, ["schemaVersion", "tenant", "recoveryId", "wholeSite", "fromSpaces", "principal", "at"])) return false;
|
|
72
|
+
const wholeSite = record.wholeSite as Record<string, unknown> | null;
|
|
73
|
+
const fromSpaces = record.fromSpaces as Record<string, unknown> | null;
|
|
74
|
+
if (record.schemaVersion !== 1 || typeof record.tenant !== "string" || !record.tenant ||
|
|
75
|
+
typeof record.recoveryId !== "string" || !PUBLICATION_RECOVERY_ID_RE.test(record.recoveryId) ||
|
|
76
|
+
typeof record.principal !== "string" || !PRINCIPAL_RE.test(record.principal) ||
|
|
77
|
+
typeof record.at !== "string" || !INSTANT_RE.test(record.at) || new Date(record.at).toISOString() !== record.at ||
|
|
78
|
+
!wholeSite || typeof wholeSite !== "object" || !exactKeys(wholeSite, ["versionId", "digest"]) ||
|
|
79
|
+
!nullable(wholeSite.versionId, SHA40_RE) || !nullable(wholeSite.digest, DIGEST_RE) ||
|
|
80
|
+
!fromSpaces || typeof fromSpaces !== "object" || !exactKeys(fromSpaces, ["generation", "spaces"]) ||
|
|
81
|
+
!Array.isArray(fromSpaces.spaces) || fromSpaces.spaces.length === 0 ||
|
|
82
|
+
!Number.isSafeInteger(fromSpaces.generation)) return false;
|
|
83
|
+
let sum = 0;
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const space of fromSpaces.spaces as unknown[]) {
|
|
86
|
+
if (!space || typeof space !== "object" || !exactKeys(space, ["spaceId", "revision", "versionId"])) return false;
|
|
87
|
+
const { spaceId, revision, versionId } = space as Record<string, unknown>;
|
|
88
|
+
if (typeof spaceId !== "string" || !spaceId || ids.has(spaceId) || typeof versionId !== "string" || !versionId ||
|
|
89
|
+
!Number.isSafeInteger(revision) || (revision as number) < 1) return false;
|
|
90
|
+
ids.add(spaceId);
|
|
91
|
+
sum += revision as number;
|
|
92
|
+
}
|
|
93
|
+
return sum === fromSpaces.generation;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parsePublicationRecoveryV1Json(json: string | null): PublicationRecoveryV1 | null {
|
|
97
|
+
if (json === null) return null;
|
|
98
|
+
try {
|
|
99
|
+
const value = JSON.parse(json) as unknown;
|
|
100
|
+
return validatePublicationRecoveryV1(value) ? value : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The tenant's public spaces as they serve on `live`, sorted by space, and their generation. This
|
|
108
|
+
* is what a recovery replaces and what its caller must name (a compare-and-set): for a CloudFront
|
|
109
|
+
* tenant it is the aggregate generation its publish wrote.
|
|
110
|
+
*/
|
|
111
|
+
export function publicationServingSpacesV1(
|
|
112
|
+
tenant: { tenant_id: string; publicationSpaces?: readonly PublicationSpaceManifestV1[] },
|
|
113
|
+
state: PublicationVersionStateV1,
|
|
114
|
+
): { generation: number; spaces: PublicationRecoverySpaceV1[] } {
|
|
115
|
+
const spaces: PublicationRecoverySpaceV1[] = [];
|
|
116
|
+
for (const manifest of [...(tenant.publicationSpaces ?? [])].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
117
|
+
if (manifest.visibility !== "public" || manifest.tenant !== tenant.tenant_id) continue;
|
|
118
|
+
const pointer = state.pointers.find((candidate) =>
|
|
119
|
+
candidate.tenant === tenant.tenant_id && candidate.spaceId === manifest.id && candidate.channel === "live");
|
|
120
|
+
if (pointer) spaces.push({ spaceId: manifest.id, revision: pointer.revision, versionId: pointer.versionId });
|
|
121
|
+
}
|
|
122
|
+
return { generation: spaces.reduce((sum, space) => sum + space.revision, 0), spaces };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the tenant is recovered to its whole site, read fail-safe: a record that exists but does
|
|
127
|
+
* not validate still counts as recovered, because serving the whole site is the direction a
|
|
128
|
+
* recovery exists to guarantee. Only an absent record means the spaces serve.
|
|
129
|
+
*/
|
|
130
|
+
export function publicationRecoveryActiveV1(json: string | null): boolean {
|
|
131
|
+
return json !== null;
|
|
132
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot dev`'s mounted publication spaces: run `publication-spaces-dev.ts` (compose the tenant's
|
|
3
|
+
* declared spaces from their local working trees through the publish-path composition, then seed
|
|
4
|
+
* the dev server's local state) alongside the dev server, and again whenever a space's working tree
|
|
5
|
+
* changes — so `/docs`, `/blog`, ... render locally from the same route plan visitors get, and a
|
|
6
|
+
* save in a space shows on the next reload.
|
|
7
|
+
*
|
|
8
|
+
* The composition is the private control plane, so it runs only where the platform tree is present
|
|
9
|
+
* (the storefront monorepo). The public runner ships without it; there a checkout that has cloned
|
|
10
|
+
* spaces is told which paths will not render, instead of silently serving 404s under each mount.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { startWatch } from "./checkout-watch.mjs";
|
|
16
|
+
|
|
17
|
+
export const SEEDER = "scripts/dev/publication-spaces-dev.ts";
|
|
18
|
+
|
|
19
|
+
/** Can this tree compose publication spaces? (The public runner cannot: no control plane.) */
|
|
20
|
+
export function publicationSpacesComposable(repoRoot) {
|
|
21
|
+
return existsSync(join(repoRoot, SEEDER)) &&
|
|
22
|
+
existsSync(join(repoRoot, "packages", "private-controlplane")) &&
|
|
23
|
+
existsSync(join(repoRoot, "apps", "storefront", "node_modules", ".bin", "tsx"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The seeder argv for a tenant (and the developer's checkout, in workspace mode). Pure. */
|
|
27
|
+
export function seederArgs(repoRoot, tenant, workspace) {
|
|
28
|
+
return [join(repoRoot, SEEDER), "--tenant", tenant, ...(workspace ? ["--workspace", workspace] : [])];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the seeder's one-line JSON verdict from its stdout; null when it printed none. Pure. */
|
|
32
|
+
export function parseSeederVerdict(stdout) {
|
|
33
|
+
const line = String(stdout).trim().split("\n").filter(Boolean).pop();
|
|
34
|
+
if (!line) return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(line);
|
|
37
|
+
return parsed && typeof parsed.status === "string" ? parsed : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The developer-facing line for a verdict. Pure. */
|
|
44
|
+
export function describeVerdict(verdict, { first }) {
|
|
45
|
+
if (!verdict) return " ⚠ mounted publication spaces could not be composed — their paths will 404 locally.";
|
|
46
|
+
switch (verdict.status) {
|
|
47
|
+
case "seeded": {
|
|
48
|
+
const mounts = verdict.spaces.map((space) => `${space.mount}/ (${space.spaceId})`).join(", ");
|
|
49
|
+
return first ? ` ✓ mounted spaces render locally: ${mounts}` : ` ↻ re-composed mounted spaces: ${mounts} — reload to see it`;
|
|
50
|
+
}
|
|
51
|
+
case "incomplete":
|
|
52
|
+
return ` ⚠ mounted spaces not composed — ${verdict.missing.join("; ")}. Their paths 404 locally until cloned (tot clone).`;
|
|
53
|
+
case "refused":
|
|
54
|
+
return ` ✗ the publish-path composition refused ${verdict.spaceId ?? "this topology"}: ${verdict.code} — a publish would refuse it too.`;
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compose + seed once now, then again on every change under `watchDirs()`. Runs are serialized: a
|
|
62
|
+
* save during a run schedules exactly one more. Returns `{ close }`.
|
|
63
|
+
* @param {{ repoRoot: string, tenant: string, workspace: string|null, log?: (line: string) => void,
|
|
64
|
+
* spawnImpl?: typeof spawn, watch?: typeof startWatch, watchDirs?: (verdict: any) => string[] }} input
|
|
65
|
+
*/
|
|
66
|
+
export function startPublicationSpacesLoop({
|
|
67
|
+
repoRoot, tenant, workspace, log = console.log, spawnImpl = spawn, watch = startWatch,
|
|
68
|
+
watchDirs = (verdict) => verdict?.watch ?? [],
|
|
69
|
+
}) {
|
|
70
|
+
const appDir = join(repoRoot, "apps", "storefront");
|
|
71
|
+
const tsx = join(appDir, "node_modules", ".bin", "tsx");
|
|
72
|
+
const watchers = [];
|
|
73
|
+
let running = false;
|
|
74
|
+
let again = false;
|
|
75
|
+
let closed = false;
|
|
76
|
+
let first = true;
|
|
77
|
+
let watching = false;
|
|
78
|
+
|
|
79
|
+
const run = () => {
|
|
80
|
+
if (closed) return;
|
|
81
|
+
if (running) { again = true; return; }
|
|
82
|
+
running = true;
|
|
83
|
+
let stdout = "";
|
|
84
|
+
let stderr = "";
|
|
85
|
+
const child = spawnImpl(tsx, seederArgs(repoRoot, tenant, workspace), { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] });
|
|
86
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
87
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
88
|
+
child.on("close", () => {
|
|
89
|
+
running = false;
|
|
90
|
+
const verdict = parseSeederVerdict(stdout);
|
|
91
|
+
if (!verdict && stderr.trim()) log(` ⚠ ${stderr.trim().split("\n").pop()}`);
|
|
92
|
+
const line = describeVerdict(verdict, { first });
|
|
93
|
+
if (line && !(verdict?.status === "none")) log(line);
|
|
94
|
+
first = false;
|
|
95
|
+
if (!watching && verdict && verdict.status !== "none") {
|
|
96
|
+
watching = true;
|
|
97
|
+
for (const dir of watchDirs(verdict)) {
|
|
98
|
+
try {
|
|
99
|
+
watchers.push(watch(dir, true, () => run()));
|
|
100
|
+
} catch {
|
|
101
|
+
log(` ⚠ could not watch ${dir} — re-run tot dev to pick up its changes.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (again) { again = false; run(); }
|
|
106
|
+
});
|
|
107
|
+
child.on("error", (error) => {
|
|
108
|
+
running = false;
|
|
109
|
+
log(` ⚠ mounted publication spaces could not be composed: ${error.message}`);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
run();
|
|
114
|
+
return {
|
|
115
|
+
close() {
|
|
116
|
+
closed = true;
|
|
117
|
+
for (const watcher of watchers) {
|
|
118
|
+
try { watcher.close(); } catch { /* best effort */ }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/scripts/tot-dev.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/p
|
|
|
68
68
|
import { resync, startWatch } from "./dev/checkout-watch.mjs";
|
|
69
69
|
import { standaloneGraftPlan } from "./dev/standalone-graft-plan.mjs";
|
|
70
70
|
import { findMissingCtaIds } from "./tenant/validate.mjs";
|
|
71
|
+
import { publicationSpacesComposable, startPublicationSpacesLoop } from "./dev/publication-spaces-loop.mjs";
|
|
72
|
+
import { SPACES_DIR } from "../packages/cli/src/publication-spaces.mjs";
|
|
71
73
|
|
|
72
74
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
73
75
|
const repoRoot = resolve(here, ".."); // scripts/ -> repo root
|
|
@@ -415,14 +417,13 @@ console.log(` Private local preview — nothing is published. Server binds all
|
|
|
415
417
|
}
|
|
416
418
|
|
|
417
419
|
// --- boot astro dev (Vite HMR); --host so IPv4 `localhost`/127.0.0.1 both work -
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// stay correct. Monorepo mode is left untouched (current behavior).
|
|
420
|
+
// astro MUST stay in the FOREGROUND, attached to this process: Astro 7 auto-detects
|
|
421
|
+
// an agent-run terminal and forks the dev server to a detached daemon (`astro dev
|
|
422
|
+
// stop/status`), which would make THIS process exit immediately — firing our cleanup
|
|
423
|
+
// (tearing down the graft, stopping the publication-space loop) while the daemon
|
|
424
|
+
// keeps serving. Setting ASTRO_DEV_BACKGROUND tells Astro "you are already the
|
|
425
|
+
// managed child → run foreground" (see astro's cli/dev/{index,background}.js), so
|
|
426
|
+
// our SIGINT/exit lifecycle stays correct in both modes.
|
|
426
427
|
// TOT_DEV_TENANT tells the inline-AI edit loop (astro.config.mjs's
|
|
427
428
|
// inlineAiEditPlugin, scripts/dev/ai-edit.mjs) which tenant's content/theme to
|
|
428
429
|
// read+write: one `tot dev <tenant>` run always serves exactly one tenant, so
|
|
@@ -433,9 +434,7 @@ console.log(` Private local preview — nothing is published. Server binds all
|
|
|
433
434
|
// MUST read+write it, NOT the in-tree graft, or browser edits are silently lost
|
|
434
435
|
// on the next resync. MONOREPO: the in-tree tenants/<id>/ dir.
|
|
435
436
|
const devSourceRoot = resolveDevSourceRoot(workspacePath, repoRoot, tenant);
|
|
436
|
-
const spawnEnv =
|
|
437
|
-
? { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot }
|
|
438
|
-
: { ...process.env, TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
|
|
437
|
+
const spawnEnv = { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
|
|
439
438
|
// LAUNCHER-AGNOSTIC astro spawn: the published runner tree may have been
|
|
440
439
|
// installed by pnpm OR npm (npm ships with every Node — the runner must not
|
|
441
440
|
// require pnpm at RUNTIME), so resolve astro's real entry through Node's own
|
|
@@ -453,12 +452,31 @@ const child = spawn(
|
|
|
453
452
|
{ cwd: appDir, stdio: "inherit", env: spawnEnv },
|
|
454
453
|
);
|
|
455
454
|
|
|
455
|
+
// --- mounted publication spaces (/docs, /blog, ...) -----------------------------
|
|
456
|
+
// Composed from their local working trees through the publish-path composition and seeded into
|
|
457
|
+
// the dev server's local state while astro boots, then again on every save in a space (see
|
|
458
|
+
// scripts/dev/publication-spaces-loop.mjs). The public runner has no composition, so there a
|
|
459
|
+
// checkout with cloned spaces is told its mounted paths will not render rather than left to 404.
|
|
460
|
+
let spacesLoop = null;
|
|
461
|
+
if (publicationSpacesComposable(repoRoot)) {
|
|
462
|
+
spacesLoop = startPublicationSpacesLoop({
|
|
463
|
+
repoRoot,
|
|
464
|
+
tenant,
|
|
465
|
+
workspace: workspacePath ? resolve(workspacePath) : null,
|
|
466
|
+
});
|
|
467
|
+
} else if (workspacePath && existsSync(join(resolve(workspacePath), SPACES_DIR))) {
|
|
468
|
+
console.warn(
|
|
469
|
+
` ⚠ this checkout has mounted publication spaces (${SPACES_DIR}/) — the public preview engine renders the root only, so their paths 404 here.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
456
473
|
// Clean up grafts + the port lock on every exit path so we never leave
|
|
457
474
|
// symlinks in the tree, or a lock a LATER run would misread as still live.
|
|
458
475
|
function shutdown(signal) {
|
|
459
476
|
child.kill(signal);
|
|
460
477
|
}
|
|
461
478
|
function cleanupAll() {
|
|
479
|
+
spacesLoop?.close();
|
|
462
480
|
cleanupGrafts();
|
|
463
481
|
unlockPort(resolvedPort);
|
|
464
482
|
}
|