@tokenoftrust/storefront-runner 2.2.86 → 2.2.87
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/config/declaredTenantConfig.ts +94 -0
- package/apps/storefront/src/middleware/renderChain.ts +14 -3
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/declared-tenant-config.d.mts +22 -0
- package/packages/public-runtime/src/declared-tenant-config.mjs +150 -0
- package/scripts/dev/port-lock.mjs +109 -0
- package/scripts/dev/standalone-graft-plan.mjs +35 -0
- package/scripts/tot-dev.mjs +49 -20
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render-time read of a tenant's declared platform config (`.tot/config.json`
|
|
3
|
+
* → siteType/compliance/capabilities/features; see
|
|
4
|
+
* `@tot/public-runtime/declared-tenant-config`). Every render applies it the same
|
|
5
|
+
* way, on top of the resolved tenant record:
|
|
6
|
+
*
|
|
7
|
+
* 1. the checkout's own file in the build tree — what `tot dev` grafts in and
|
|
8
|
+
* what the static-publish build materializes from the tenant repo;
|
|
9
|
+
* 2. the per-environment artifact the reconcile derived from the same file at the
|
|
10
|
+
* reconciled commit — what the hosted Worker serves preview and live from.
|
|
11
|
+
*
|
|
12
|
+
* Both are the same file at the same commit, so a tenant renders identically on
|
|
13
|
+
* every surface; a field honored by one is honored by all.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
applyDeclaredTenantConfig,
|
|
17
|
+
declaredTenantConfig,
|
|
18
|
+
declaredTenantConfigArtifactPath,
|
|
19
|
+
type DeclaredTenantConfig,
|
|
20
|
+
} from "@tot/public-runtime/declared-tenant-config";
|
|
21
|
+
import {
|
|
22
|
+
readPublishedArtifact,
|
|
23
|
+
readVersionArtifact,
|
|
24
|
+
tenantIdFromPath,
|
|
25
|
+
type CustomizationEnv,
|
|
26
|
+
type TenantConfig,
|
|
27
|
+
} from "@tot/public-runtime";
|
|
28
|
+
|
|
29
|
+
// The checkout's own `.tot/config.json`, read eagerly (tiny JSON). From src/config/
|
|
30
|
+
// the repo root is four levels up; both tenant tiers are globbed, and the explicit
|
|
31
|
+
// `.tot` literal matches the dotdir.
|
|
32
|
+
const checkoutConfigFiles = import.meta.glob<Record<string, unknown>>(
|
|
33
|
+
["../../../../tenants/*/.tot/config.json", "../../../../tenants/e2e/*/.tot/config.json"],
|
|
34
|
+
{ eager: true, import: "default" },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** dir id → that checkout's parsed `.tot/config.json`. */
|
|
38
|
+
export function checkoutTotConfigs(): Map<string, Record<string, unknown>> {
|
|
39
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
40
|
+
for (const [key, config] of Object.entries(checkoutConfigFiles)) {
|
|
41
|
+
const id = tenantIdFromPath(key);
|
|
42
|
+
if (id && config && typeof config === "object") map.set(id, config);
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The declaration in the checkout's own `.tot/config.json`, or `{}` when it has none. */
|
|
48
|
+
export function checkoutDeclaredTenantConfig(tenantId: string): DeclaredTenantConfig {
|
|
49
|
+
return declaredTenantConfig(checkoutConfigFiles[`../../../../tenants/${tenantId}/.tot/config.json`]
|
|
50
|
+
?? checkoutConfigFiles[`../../../../tenants/e2e/${tenantId}/.tot/config.json`]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The declaration the reconcile published for this environment, or `{}`. */
|
|
54
|
+
export async function publishedDeclaredTenantConfig(
|
|
55
|
+
kvGet: (key: string) => Promise<string | null>,
|
|
56
|
+
tenantId: string,
|
|
57
|
+
env: CustomizationEnv,
|
|
58
|
+
): Promise<DeclaredTenantConfig> {
|
|
59
|
+
const raw = await readPublishedArtifact(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
|
|
60
|
+
if (!raw) return {};
|
|
61
|
+
try {
|
|
62
|
+
return declaredTenantConfig(JSON.parse(raw));
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The declaration pinned to one immutable version (a candidate under review), or `{}`. */
|
|
69
|
+
export async function versionDeclaredTenantConfig(
|
|
70
|
+
kvGet: (key: string) => Promise<string | null>,
|
|
71
|
+
tenantId: string,
|
|
72
|
+
versionId: string,
|
|
73
|
+
): Promise<DeclaredTenantConfig> {
|
|
74
|
+
const raw = await readVersionArtifact(kvGet, tenantId, versionId, declaredTenantConfigArtifactPath(tenantId));
|
|
75
|
+
if (!raw) return {};
|
|
76
|
+
try {
|
|
77
|
+
return declaredTenantConfig(JSON.parse(raw));
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The tenant as it renders in `env`: its record with every declaration applied. */
|
|
84
|
+
export async function withDeclaredTenantConfig(
|
|
85
|
+
tenant: TenantConfig,
|
|
86
|
+
kvGet: (key: string) => Promise<string | null>,
|
|
87
|
+
env: CustomizationEnv,
|
|
88
|
+
): Promise<TenantConfig> {
|
|
89
|
+
const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
|
|
90
|
+
return applyDeclaredTenantConfig(
|
|
91
|
+
fromCheckout,
|
|
92
|
+
await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, env),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
import { recordActivity } from "@/lib/activity/recordActivity";
|
|
98
98
|
import { middlewareRewriteTargetV1 } from "./routeBranch.js";
|
|
99
99
|
import { declaredTenantOrigins } from "@/lib/tenantOrigins";
|
|
100
|
+
import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
|
|
100
101
|
import {
|
|
101
102
|
applyInternalContentResponsePolicy,
|
|
102
103
|
internalContentGrantMatchesPlan,
|
|
@@ -147,7 +148,7 @@ export interface RenderChainInput {
|
|
|
147
148
|
export const TENANT_ORIGINS_CAPTURE_HEADER = "x-tot-tenant-origins";
|
|
148
149
|
|
|
149
150
|
export async function runRenderChain(input: RenderChainInput): Promise<Response> {
|
|
150
|
-
const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
|
|
151
|
+
const { context, next, host, url, tenant: tenantRecord, basePath, renderPath, cache, kv } = input;
|
|
151
152
|
const { request, locals } = context;
|
|
152
153
|
const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
|
|
153
154
|
input.publicationRoute.manifest.visibility === "internal";
|
|
@@ -177,7 +178,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
177
178
|
// pin can only ever name its own version; a mismatched/expired/absent token
|
|
178
179
|
// falls through to the normal channel-resolved read below, same fail-closed
|
|
179
180
|
// posture as the `__preview` token.
|
|
180
|
-
const candidatePin = await resolveCandidateContentPin(url,
|
|
181
|
+
const candidatePin = await resolveCandidateContentPin(url, tenantRecord.tenant_id);
|
|
181
182
|
// Per-developer AI-edit draft: the signed
|
|
182
183
|
// tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
|
|
183
184
|
// cache below (a draft view must never be cached publicly); it's verified —
|
|
@@ -192,7 +193,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
192
193
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
193
194
|
const customization = await resolveCustomizationEnv({
|
|
194
195
|
isPreviewHost,
|
|
195
|
-
tenantId:
|
|
196
|
+
tenantId: tenantRecord.tenant_id,
|
|
196
197
|
token: previewToken,
|
|
197
198
|
secret: previewSecret,
|
|
198
199
|
nowSeconds,
|
|
@@ -230,6 +231,16 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
230
231
|
if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
|
|
231
232
|
return response;
|
|
232
233
|
}
|
|
234
|
+
|
|
235
|
+
// The tenant as it renders in THIS environment: its record with the declared
|
|
236
|
+
// `.tot/config.json` fields applied (compliance, siteType, capabilities,
|
|
237
|
+
// features). Everything below — static bundle, notices, gates, features,
|
|
238
|
+
// capabilities — reads this, never the bare record.
|
|
239
|
+
const tenant = await withDeclaredTenantConfig(
|
|
240
|
+
tenantRecord,
|
|
241
|
+
kv ? (key: string) => kv.get(key) : async () => null,
|
|
242
|
+
customization.env,
|
|
243
|
+
);
|
|
233
244
|
const publicationTimestamp = publicationSource.status === "selected"
|
|
234
245
|
? publicationSource.publicationTimestamp
|
|
235
246
|
: exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.87",
|
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./durable-assets": "./src/durable-assets.ts",
|
|
20
20
|
"./retention-holds": "./src/retention-holds.ts",
|
|
21
21
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
22
|
+
"./declared-tenant-config": "./src/declared-tenant-config.mjs",
|
|
22
23
|
"./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
|
|
23
24
|
"./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
|
|
24
25
|
"./integration/visitor-region": "./src/integration/visitor-region.mjs"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
|
|
2
|
+
import type { TenantConfig } from "./tenant.js";
|
|
3
|
+
|
|
4
|
+
export type DeclaredTenantConfigField = "siteType" | "compliance" | "capabilities" | "features";
|
|
5
|
+
export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
|
|
6
|
+
|
|
7
|
+
export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
|
|
8
|
+
export const TOT_CONFIG_KEYS: readonly string[];
|
|
9
|
+
export const COMPLIANCE_KEYS: readonly string[];
|
|
10
|
+
export const FEATURE_KEYS: readonly string[];
|
|
11
|
+
export const SITE_TYPES_LIST: readonly string[];
|
|
12
|
+
export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
|
|
13
|
+
export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
|
|
14
|
+
export function applyDeclaredTenantConfig<T extends Partial<Pick<TenantConfig, DeclaredTenantConfigField>>>(
|
|
15
|
+
tenant: T,
|
|
16
|
+
declared: DeclaredTenantConfig | null | undefined,
|
|
17
|
+
): T;
|
|
18
|
+
export function readDeclaredTenantConfigAt(
|
|
19
|
+
source: { readFile(path: string, ref: string): Promise<string | null> },
|
|
20
|
+
ref: string,
|
|
21
|
+
): Promise<DeclaredTenantConfig>;
|
|
22
|
+
export function declaredTenantConfigArtifactPath(tenantId: string): string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant's declared platform config: the `.tot/config.json` fields that become
|
|
3
|
+
* TenantConfig fields on every surface that renders the tenant.
|
|
4
|
+
*
|
|
5
|
+
* `.tot/config.json` is the ONE declared source for these fields. Every reader goes
|
|
6
|
+
* through this module:
|
|
7
|
+
* - `tot dev` and the static-publish build read the checkout's own file from the
|
|
8
|
+
* build tree (apps/storefront/src/config/resolver.ts `declaredConfigFor`);
|
|
9
|
+
* - the hosted Worker reads the per-environment artifact the reconcile derives from
|
|
10
|
+
* the same file at the reconciled commit (`declaredTenantConfigArtifactPath`);
|
|
11
|
+
* - `tot validate` refuses any top-level key not in `TOT_CONFIG_KEYS`, so a field
|
|
12
|
+
* no surface reads cannot pass validation.
|
|
13
|
+
* All of them apply it with `applyDeclaredTenantConfig`, so a field honored on one
|
|
14
|
+
* surface is honored on all of them.
|
|
15
|
+
*
|
|
16
|
+
* Plain JS on purpose (same reason as embed-catalog.mjs): plain-`node` tooling and
|
|
17
|
+
* the CLI's parity test read it alongside the TypeScript runtime.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** `.tot/config.json` keys that become TenantConfig fields. */
|
|
21
|
+
export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
|
|
22
|
+
"siteType",
|
|
23
|
+
"compliance",
|
|
24
|
+
"capabilities",
|
|
25
|
+
"features",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every top-level `.tot/config.json` key the platform reads: the reconcile mapping
|
|
30
|
+
* keys, the CLI's host hint, provenance markers (`sample` from `tot dev --sample`,
|
|
31
|
+
* `seed` from the tot-mcp repo seed), an editor's `$schema`, and the declared
|
|
32
|
+
* TenantConfig fields.
|
|
33
|
+
*/
|
|
34
|
+
export const TOT_CONFIG_KEYS = Object.freeze([
|
|
35
|
+
"$schema",
|
|
36
|
+
"tenant",
|
|
37
|
+
"scope",
|
|
38
|
+
"mappings",
|
|
39
|
+
"hostPlatform",
|
|
40
|
+
"sample",
|
|
41
|
+
"seed",
|
|
42
|
+
...DECLARED_TENANT_CONFIG_FIELDS,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Every `compliance` key the platform renders or enforces (ComplianceConfig, tenant.ts). */
|
|
46
|
+
export const COMPLIANCE_KEYS = Object.freeze([
|
|
47
|
+
"ruleProfile",
|
|
48
|
+
"minAge",
|
|
49
|
+
"nicotineWarning",
|
|
50
|
+
"shippingRestriction",
|
|
51
|
+
"pactAct",
|
|
52
|
+
"adultSignature",
|
|
53
|
+
"prop65",
|
|
54
|
+
"purchaseLimit",
|
|
55
|
+
"stateEligibility",
|
|
56
|
+
"exciseTax",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Every `features` key (TenantFeatures, tenant.ts). */
|
|
60
|
+
export const FEATURE_KEYS = Object.freeze([
|
|
61
|
+
"catalog",
|
|
62
|
+
"productSearch",
|
|
63
|
+
"savedItems",
|
|
64
|
+
"quickView",
|
|
65
|
+
"newsletter",
|
|
66
|
+
"commerceSitemap",
|
|
67
|
+
"productJsonLd",
|
|
68
|
+
"ageGate",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Every `siteType` value (SiteType, tenant.ts). */
|
|
72
|
+
export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
|
|
73
|
+
|
|
74
|
+
const SITE_TYPES = new Set(SITE_TYPES_LIST);
|
|
75
|
+
|
|
76
|
+
const isPlainObject = (value) =>
|
|
77
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
|
|
81
|
+
* value has the wrong shape is omitted, never coerced (`tot validate` reports it).
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} config
|
|
84
|
+
*/
|
|
85
|
+
export function declaredTenantConfig(config) {
|
|
86
|
+
/** @type {Record<string, unknown>} */
|
|
87
|
+
const out = {};
|
|
88
|
+
if (!isPlainObject(config)) return out;
|
|
89
|
+
const { siteType, compliance, capabilities, features } = /** @type {Record<string, unknown>} */ (config);
|
|
90
|
+
if (typeof siteType === "string" && SITE_TYPES.has(siteType)) out.siteType = siteType;
|
|
91
|
+
if (isPlainObject(compliance)) out.compliance = compliance;
|
|
92
|
+
if (isPlainObject(capabilities)) out.capabilities = capabilities;
|
|
93
|
+
if (isPlainObject(features)) out.features = features;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when the declaration sets nothing. */
|
|
98
|
+
export function isEmptyDeclaredTenantConfig(declared) {
|
|
99
|
+
return !declared || Object.keys(declared).length === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Overlay a declaration onto a resolved tenant. `siteType` is replaced; the object
|
|
104
|
+
* fields merge key by key, so a declaration adds to — and can set individual keys
|
|
105
|
+
* of — what the platform record already carries, but never erases a key it omits.
|
|
106
|
+
*
|
|
107
|
+
* @template T
|
|
108
|
+
* @param {T} tenant
|
|
109
|
+
* @param {Record<string, unknown> | null | undefined} declared
|
|
110
|
+
* @returns {T}
|
|
111
|
+
*/
|
|
112
|
+
export function applyDeclaredTenantConfig(tenant, declared) {
|
|
113
|
+
if (isEmptyDeclaredTenantConfig(declared)) return tenant;
|
|
114
|
+
const out = /** @type {Record<string, unknown>} */ ({ ...tenant });
|
|
115
|
+
for (const field of DECLARED_TENANT_CONFIG_FIELDS) {
|
|
116
|
+
const value = declared[field];
|
|
117
|
+
if (value === undefined) continue;
|
|
118
|
+
out[field] = isPlainObject(value) && isPlainObject(out[field])
|
|
119
|
+
? { ...out[field], ...value }
|
|
120
|
+
: value;
|
|
121
|
+
}
|
|
122
|
+
return /** @type {T} */ (out);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The declaration a tenant repo commits at `ref`, read through any source with the
|
|
127
|
+
* reconcile's `readFile(path, ref)` shape. `{}` when the file is absent or unreadable.
|
|
128
|
+
*
|
|
129
|
+
* @param {{ readFile(path: string, ref: string): Promise<string | null> }} source
|
|
130
|
+
* @param {string} ref
|
|
131
|
+
*/
|
|
132
|
+
export async function readDeclaredTenantConfigAt(source, ref) {
|
|
133
|
+
try {
|
|
134
|
+
const raw = await source.readFile(".tot/config.json", ref);
|
|
135
|
+
return raw ? declaredTenantConfig(JSON.parse(raw)) : {};
|
|
136
|
+
} catch {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Artifact path of the declaration the reconcile derives by reading `.tot/config.json`,
|
|
143
|
+
* in the same colocated `tenants/<id>/…` space as theme/capabilities so it is
|
|
144
|
+
* pinned per version and resolved per environment like every other artifact.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} tenantId
|
|
147
|
+
*/
|
|
148
|
+
export function declaredTenantConfigArtifactPath(tenantId) {
|
|
149
|
+
return `tenants/${tenantId}/tenant-config.json`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Records which pid `tot dev` last bound a given port under, so a later
|
|
4
|
+
* invocation that finds the port busy can tell a STALE occupant (our own
|
|
5
|
+
* earlier `tot dev` process, no longer running — the socket just hasn't been
|
|
6
|
+
* released yet, or the process crashed without a clean shutdown) from a
|
|
7
|
+
* genuinely live one (a different, healthy server — ours or someone else's).
|
|
8
|
+
*
|
|
9
|
+
* Without this, `firstFreePort` silently drifts to the next port either way —
|
|
10
|
+
* fine for "someone else's server is using 4321", actively misleading after a
|
|
11
|
+
* crash: the developer's browser is still pointed at the old port, and nothing
|
|
12
|
+
* tells them the server they expect there isn't the one that just started
|
|
13
|
+
* (fb-1791304494289-ussj8l). Dependency-free (node:fs/os/path only) so it
|
|
14
|
+
* copies into the standalone runner like port-check.mjs.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const PORT_LOCK_DIR = join(homedir(), ".tot", "run", "dev-ports");
|
|
21
|
+
|
|
22
|
+
/** Record that `pid` (default: this process) is bound to `port`. Best-effort. */
|
|
23
|
+
export function lockPort(port, { pid = process.pid, dir = PORT_LOCK_DIR } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(join(dir, String(port)), String(pid));
|
|
27
|
+
} catch {
|
|
28
|
+
/* best-effort — a failed lock must never block `tot dev` from starting */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Release a port lock (best-effort; safe to call more than once). */
|
|
33
|
+
export function unlockPort(port, { dir = PORT_LOCK_DIR } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
rmSync(join(dir, String(port)), { force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
/* best-effort */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The pid last recorded as bound to `port`, or null when no lock is on file / unreadable. */
|
|
42
|
+
export function readPortLock(port, { dir = PORT_LOCK_DIR } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
const pid = Number(readFileSync(join(dir, String(port)), "utf8").trim());
|
|
45
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
|
|
52
|
+
export function processAlive(pid) {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return /** @type {NodeJS.ErrnoException} */ (e)?.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Why is `requestedPort` unavailable?
|
|
63
|
+
* - `{ kind: "ours", pid }` — an EARLIER `tot dev` session still holds it and is
|
|
64
|
+
* still running. It may be wedged (a renderer crash leaves the server process
|
|
65
|
+
* alive and bound), so the developer must be told which process to stop.
|
|
66
|
+
* - `{ kind: "stale", pid }` — our own recorded occupant is gone; the socket is
|
|
67
|
+
* lingering past a crash/kill.
|
|
68
|
+
* - `{ kind: "other" }` — busy with no lock of ours: someone else's server.
|
|
69
|
+
* - `null` — the port isn't busy right now.
|
|
70
|
+
* @param {number} requestedPort
|
|
71
|
+
* @param {{ isFree: (port:number)=>Promise<boolean>, pidAlive?: (pid:number)=>boolean, dir?: string }} deps
|
|
72
|
+
* @returns {Promise<{kind:"ours"|"stale", pid:number}|{kind:"other"}|null>}
|
|
73
|
+
*/
|
|
74
|
+
export async function classifyBusyPort(requestedPort, { isFree, pidAlive = processAlive, dir = PORT_LOCK_DIR }) {
|
|
75
|
+
if (await isFree(requestedPort)) return null;
|
|
76
|
+
const pid = readPortLock(requestedPort, { dir });
|
|
77
|
+
if (pid == null) return { kind: "other" };
|
|
78
|
+
return { kind: pidAlive(pid) ? "ours" : "stale", pid };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The line `tot dev` prints when it starts on `resolvedPort` because
|
|
83
|
+
* `requestedPort` was busy. Every case involving an earlier `tot dev` session
|
|
84
|
+
* says so and names the process, so a browser tab left on the old port is never
|
|
85
|
+
* silently a different server.
|
|
86
|
+
* @param {number} requestedPort
|
|
87
|
+
* @param {number} resolvedPort
|
|
88
|
+
* @param {{kind:"ours"|"stale", pid:number}|{kind:"other"}|null} busy
|
|
89
|
+
*/
|
|
90
|
+
export function busyPortMessage(requestedPort, resolvedPort, busy) {
|
|
91
|
+
const tab = ` A browser tab on :${requestedPort} is NOT this session — use the URL below.`;
|
|
92
|
+
if (busy?.kind === "ours") {
|
|
93
|
+
return (
|
|
94
|
+
` ⚠ port ${requestedPort} is held by an earlier \`tot dev\` session that is still running ` +
|
|
95
|
+
`(pid ${busy.pid}) — starting on ${resolvedPort} instead.\n` +
|
|
96
|
+
` If that session crashed or stopped reloading, stop it (\`kill ${busy.pid}\`) and restart to get ${requestedPort} back.\n` +
|
|
97
|
+
tab
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (busy?.kind === "stale") {
|
|
101
|
+
return (
|
|
102
|
+
` ⚠ port ${requestedPort} is still held after an earlier \`tot dev\` session (pid ${busy.pid}) exited ` +
|
|
103
|
+
`— starting on ${resolvedPort} instead.\n` +
|
|
104
|
+
` Find the holder with \`lsof -nP -iTCP:${requestedPort} -sTCP:LISTEN\` and stop it to get ${requestedPort} back.\n` +
|
|
105
|
+
tab
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return ` ⚠ port ${requestedPort} was busy — starting on ${resolvedPort} instead.`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Pure planning for `tot dev --workspace` grafts: which checkout-relative paths
|
|
4
|
+
* get copied into the in-tree `tenants/<id>/` layout the app globs, and whether
|
|
5
|
+
* each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs does
|
|
6
|
+
* the actual fs copy/backup/watch effects per planned entry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Array<{workspace?: unknown, kind?: unknown}>} mappings the checkout's
|
|
11
|
+
* own `.tot/config.json` `mappings` array (content/, public/, theme.json, …)
|
|
12
|
+
* @returns {{ workspace: string, isDir: boolean }[]}
|
|
13
|
+
*/
|
|
14
|
+
export function standaloneGraftPlan(mappings) {
|
|
15
|
+
const plan = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const map of Array.isArray(mappings) ? mappings : []) {
|
|
18
|
+
const workspace = String(map?.workspace ?? "").replace(/\/+$/, "");
|
|
19
|
+
if (!workspace || seen.has(workspace)) continue;
|
|
20
|
+
seen.add(workspace);
|
|
21
|
+
plan.push({ workspace, isDir: map?.kind !== "file" });
|
|
22
|
+
}
|
|
23
|
+
// `.tot/config.json` is never itself one of the declared `mappings` (those map
|
|
24
|
+
// content/public/theme.json) but IS the tenant's own declared platform config
|
|
25
|
+
// — siteType/compliance/capabilities/features (see
|
|
26
|
+
// apps/storefront/src/config/devTenantSeed.ts TOT_CONFIG_TENANT_FIELDS and
|
|
27
|
+
// resolver.ts declaredConfigs()) — the same file
|
|
28
|
+
// scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
|
|
29
|
+
// tenant. Graft it unconditionally so a regulated store's mandated notices
|
|
30
|
+
// resolve from the SAME declared config locally as they would once published.
|
|
31
|
+
if (!seen.has(".tot/config.json")) {
|
|
32
|
+
plan.push({ workspace: ".tot/config.json", isDir: false });
|
|
33
|
+
}
|
|
34
|
+
return plan;
|
|
35
|
+
}
|
package/scripts/tot-dev.mjs
CHANGED
|
@@ -63,8 +63,10 @@ import { dirname, resolve, join } from "node:path";
|
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
|
|
65
65
|
import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
|
|
66
|
-
import { firstFreePort } from "./dev/port-check.mjs";
|
|
66
|
+
import { firstFreePort, isPortFree } from "./dev/port-check.mjs";
|
|
67
|
+
import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/port-lock.mjs";
|
|
67
68
|
import { resync, startWatch } from "./dev/checkout-watch.mjs";
|
|
69
|
+
import { standaloneGraftPlan } from "./dev/standalone-graft-plan.mjs";
|
|
68
70
|
import { findMissingCtaIds } from "./tenant/validate.mjs";
|
|
69
71
|
|
|
70
72
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -273,25 +275,18 @@ if (workspacePath) {
|
|
|
273
275
|
console.log(`\n tot dev — standalone (${tenant})`);
|
|
274
276
|
console.log(` checkout: ${wsRoot}`);
|
|
275
277
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
|
|
283
|
-
const wsRel = String(map.workspace ?? "").replace(/\/+$/, "");
|
|
284
|
-
if (!wsRel) continue;
|
|
278
|
+
/**
|
|
279
|
+
* Graft one checkout path (`wsRel`, checkout-relative) into the in-tree
|
|
280
|
+
* `tenants/<tenant>/` layout the app globs, with the same move-aside/restore +
|
|
281
|
+
* watch-and-resync machinery every mapping gets. Returns true if grafted.
|
|
282
|
+
*/
|
|
283
|
+
function graftOne(wsRel, isDir) {
|
|
285
284
|
const repoRel = join(tenantDirRelative(tenant), wsRel);
|
|
286
285
|
|
|
287
286
|
const source = resolve(wsRoot, wsRel); // real files in the checkout
|
|
288
287
|
const target = resolve(repoRoot, repoRel); // where the app globs them
|
|
289
|
-
const isDir = map.kind !== "file";
|
|
290
288
|
|
|
291
|
-
if (!existsSync(source))
|
|
292
|
-
console.warn(` ⚠ checkout has no ${wsRel} — skipping (${map.kind ?? "?"})`);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
289
|
+
if (!existsSync(source)) return false;
|
|
295
290
|
|
|
296
291
|
// Ensure the destination's parent exists (e.g. content/ or themes/tenants/).
|
|
297
292
|
mkdirSync(dirname(target), { recursive: true });
|
|
@@ -322,9 +317,31 @@ if (workspacePath) {
|
|
|
322
317
|
}
|
|
323
318
|
|
|
324
319
|
console.log(` ↪ ${repoRel} ⇐ ${wsRel}${backup ? " (in-tree content backed up)" : ""}`);
|
|
320
|
+
return true;
|
|
325
321
|
}
|
|
326
322
|
|
|
327
|
-
|
|
323
|
+
// `workspace` is the checkout-relative prefix (e.g. "content/", "theme.json").
|
|
324
|
+
// COLOCATION: a tenant IS the repo-root dir `tenants/<id>/`, and the checkout
|
|
325
|
+
// (a `git subtree split --prefix=tenants/<id>` of that dir) has the SAME layout
|
|
326
|
+
// at its root — so the graft is an identity map: content/ -> tenants/<id>/content/,
|
|
327
|
+
// public/ -> tenants/<id>/public/, theme.json -> tenants/<id>/theme.json. We map
|
|
328
|
+
// structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
|
|
329
|
+
// standaloneGraftPlan ALSO appends `.tot/config.json` unconditionally — see its
|
|
330
|
+
// own doc comment. Its success does NOT count toward "did we graft a usable
|
|
331
|
+
// site" below: a checkout with config but no content/public/theme is still
|
|
332
|
+
// nothing to serve.
|
|
333
|
+
let mappedCount = 0;
|
|
334
|
+
for (const { workspace, isDir } of standaloneGraftPlan(mappings)) {
|
|
335
|
+
const grafted = graftOne(workspace, isDir);
|
|
336
|
+
if (workspace === ".tot/config.json") {
|
|
337
|
+
if (!grafted) console.warn(` ⚠ checkout has no .tot/config.json — compliance/capabilities/siteType resolve undeclared.`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (grafted) mappedCount++;
|
|
341
|
+
else console.warn(` ⚠ checkout has no ${workspace} — skipping`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (mappedCount === 0) {
|
|
328
345
|
console.error(`✗ nothing to graft — the checkout had none of the mapped prefixes.`);
|
|
329
346
|
cleanupGrafts();
|
|
330
347
|
process.exit(1);
|
|
@@ -354,12 +371,19 @@ if (workspacePath) {
|
|
|
354
371
|
// which then mismatches the URL we (and the CLI's liveness poll) advertise.
|
|
355
372
|
// Instead we pick the first free port ourselves and PRINT it, so a busy default
|
|
356
373
|
// port "just works" (better UX than failing). See port-check.mjs.
|
|
374
|
+
//
|
|
375
|
+
// BEFORE drifting, classify WHY the requested port is busy (port-lock.mjs). When
|
|
376
|
+
// an earlier `tot dev` session holds it — still running but possibly wedged after
|
|
377
|
+
// a renderer crash, or exited with the socket lingering — the developer's browser
|
|
378
|
+
// tab on that port is NOT this session, so say so and name the process to stop.
|
|
357
379
|
const requestedPort = Number(flag("port", "4321"));
|
|
380
|
+
const busy = await classifyBusyPort(requestedPort, { isFree: isPortFree });
|
|
358
381
|
const resolvedPort = await firstFreePort(requestedPort);
|
|
359
382
|
const port = String(resolvedPort);
|
|
360
383
|
if (resolvedPort !== requestedPort) {
|
|
361
|
-
console.log(
|
|
384
|
+
console.log(busyPortMessage(requestedPort, resolvedPort, busy));
|
|
362
385
|
}
|
|
386
|
+
lockPort(resolvedPort);
|
|
363
387
|
|
|
364
388
|
// Land the developer on their store ROOT (plain save→reload HMR against their own
|
|
365
389
|
// site) — not the /dev cockpit, which is a hosted-only surface excluded from the
|
|
@@ -429,15 +453,20 @@ const child = spawn(
|
|
|
429
453
|
{ cwd: appDir, stdio: "inherit", env: spawnEnv },
|
|
430
454
|
);
|
|
431
455
|
|
|
432
|
-
// Clean up grafts on every exit path so we never leave
|
|
456
|
+
// Clean up grafts + the port lock on every exit path so we never leave
|
|
457
|
+
// symlinks in the tree, or a lock a LATER run would misread as still live.
|
|
433
458
|
function shutdown(signal) {
|
|
434
459
|
child.kill(signal);
|
|
435
460
|
}
|
|
436
|
-
|
|
461
|
+
function cleanupAll() {
|
|
437
462
|
cleanupGrafts();
|
|
463
|
+
unlockPort(resolvedPort);
|
|
464
|
+
}
|
|
465
|
+
child.on("exit", (code) => {
|
|
466
|
+
cleanupAll();
|
|
438
467
|
process.exit(code ?? 0);
|
|
439
468
|
});
|
|
440
469
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
441
470
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
442
471
|
// Belt-and-suspenders: if the parent dies without the child exit firing.
|
|
443
|
-
process.on("exit",
|
|
472
|
+
process.on("exit", cleanupAll);
|