Files
nexu-io__open-design/apps/web/next.config.ts
Caprika 8fb94d28eb feat(cms): 统一客户端活动点位接入与验收修复 (#7986)
* feat(cms): add verified desktop touchpoint hosts and Test acceptance

* fix(cms): keep Test modal scrim above app chrome

* fix(cms): load host styles and support hover packages

* fix(cms): report mounted host compatibility for restoration

* fix(cms): parse Vela actions in shared manifests without executing them

* fix(cms): preserve authorized actions and correct modal and hover behavior (#8003)

* fix(cms): harden touchpoint host interactions

* fix(cms): preserve modal actions and verify effective close controls

* test(cms): cover hover exit and above-anchor placement

* fix(cms): hide client test controls by default

* fix(cms): persist automatic campaign impressions per account and device

* fix(web): remove host campaign close button

* fix(cms): suppress campaign modals during onboarding

* fix(cms): unify test and production campaign lifecycle (#8118)

* fix(cms): enable safe campaign navigation in test and production

* fix(cms): enforce server-authorized touchpoint lifetimes

* fix(cms): refresh production touchpoints when client language changes

* fix(cms): stabilize hover refresh and synchronize Test locale

* fix(cms): preserve campaigns on focus and remember Test dismissal

* fix(cms): discover Test deployments without reloading

* fix(cms): keep granted touchpoint display authority through transport failures

A refresh that times out or fails in transport is not a withdrawal, but the
shared lifecycle treated every failure the same way: `status = "error"` then
`revoke()`, which tore down a lease the server had already granted and was
still inside its own deadline. One slow poll therefore blanked a running
campaign until the next 30s tick, and a second failure could not restore it
because the value had been dropped.

`abandonAttempt` now names the invariant: cancel the attempt, keep the lease,
and let `armExpiry` retire it at the deadline the server actually granted. The
lease length is the bound, so no retry budget is needed — Test's sixty-second
contract survives exactly one missed poll, and Production survives as many as
its authorization covers.

Three cases still clear, and one of them is new:

  - the failure carries the server's own withdrawal. A 410 is authoritative
    even when its receipt body is unreadable, so `ProductionTouchpointLoadError`
    now marks that one case and the lifecycle honours it. Without this, a
    malformed 410 would have been ridden out as if it were transport noise.
  - nothing is mounted, or the lease was already fenced by `wake`. Page
    recovery has no evidence the activity is still live, so it stays withdrawn.
  - the lease has run out on its own.

This does not fix OPEND-3237, which is a new activity failing its FIRST load:
there is no prior lease to keep and the display stays empty. It fixes the
adjacent class where a mounted activity is erased by a transient refresh.

* fix(cms): keep a failed refresh recoverable inside its own poll cycle

Three changes to how one refresh cycle behaves when it does not go cleanly.
None of them fixes OPEND-3237, whose first-load failure is still unexplained;
they remove the ways a working campaign is lost to an ordinary hiccup.

Bounded retries. A failed attempt used to get its next chance from the fixed
30s tick. For the sixty-second lease the Test runtime is granted, that chance
lands exactly when the lease expires, so a single failure put display on the
edge of going blank with nothing able to recover it. Retries at 1s and 3s give
a fast failure — transport error, 5xx, DNS — several chances while the lease is
still comfortably alive.

A retry must finish inside the cycle that spawned it. `refresh` declines to
start while a request is in flight, so a chain that ran past the next tick
would not merely be late: it would swallow that tick entirely. Worst case was
15+1+15+3+15 = 49s against a 30s interval. Scheduling now requires room for the
retry AND its full budget, which is also what makes "retries cover fast
failures" true in the code rather than only in a comment — an attempt that
burned the whole budget leaves no room by construction and is never retried.
The retry budget resets per cycle, not per success: otherwise the first cycle
to exhaust its retries would leave every later cycle with none.

Request budget 10s -> 15s. One refresh fetches a context and every enabled
placement, so the budget covers a round, not a request. A real round of 11.5s
whose placements all succeeded was being abandoned by the ten-second budget. It
stays well under the 30s interval on purpose.

Forward `accept-encoding` through the touchpoint runtime proxy. Decisions carry
base64 content and run to megabytes, and the proxy built its upstream headers
from scratch, dropping the caller's preference — every refresh pulled the
payload uncompressed, measured at 383KB against 214KB for the same decision.
Because the body is piped through verbatim, upstream's `content-encoding` has
to be handed back with it, or the caller decodes gzip as JSON; both halves land
together. Verified end to end through daemon and web: 2,149,685 -> 1,606,678
bytes on the wire, byte-identical after decode.

An authoritative withdrawal is never retried: a 410 that took a campaign off
the screen must not put it back.

* fix(cms): present each Test campaign modal once per account and device

Test modal presentations now share the production touchpoint-displayed:v1
impression: recorded once visible, read before any new automatic opening,
and persisted across reload, remount, and client restart.

* fix(cms): give the Test control readout an explicit ladder weight
2026-09-17 05:58:48 +00:00

257 lines
11 KiB
TypeScript

import type { NextConfig } from 'next';
import { existsSync, realpathSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { networkInterfaces } from 'node:os';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// Daemon port the local Express server binds to (see apps/daemon/src/cli.ts). The
// dev-all launcher overrides OD_PORT after probing for a free port; we read
// the same env so /api, /artifacts, and /frames always reach the right
// daemon instance during `next dev`.
const DAEMON_PORT = Number(process.env.OD_PORT) || 7456;
const DAEMON_ORIGIN = `http://127.0.0.1:${DAEMON_PORT}`;
// The regular CLI build still ships as a static export so the `od` daemon can
// serve a single-process production build. Packaged desktop builds opt into a
// server runtime with OD_WEB_OUTPUT_MODE=server; in that mode the web sidecar
// owns the Next.js SSR server and proxies daemon routes at runtime. The
// packaged-size standalone spike uses OD_WEB_OUTPUT_MODE=standalone to ask
// Next.js for a traced standalone server while keeping the sidecar-owned daemon
// proxy in front of it at runtime.
const isProd = process.env.NODE_ENV !== 'development';
const webOutputMode = process.env.OD_WEB_OUTPUT_MODE;
const isServerOutput = webOutputMode === 'server' || webOutputMode === 'standalone';
const shouldStaticExport = isProd && !isServerOutput;
const WEB_ROOT = dirname(fileURLToPath(import.meta.url));
function resolveWorkspaceRoot(): string {
const computed = dirname(dirname(WEB_ROOT));
const override = process.env.OD_WORKSPACE_ROOT;
if (override && override.trim()) {
const resolved = isAbsolute(override.trim()) ? override.trim() : resolve(WEB_ROOT, override.trim());
if (!existsSync(resolved)) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" which does not exist. ` +
`Fix the path or unset the variable to use the computed default.`,
);
}
// Canonicalize via realpathSync so that symlinked paths (e.g. macOS
// /tmp → /private/tmp) compare correctly against WEB_ROOT.
const canonicalResolved = realpathSync(resolved);
const canonicalWebRoot = realpathSync(WEB_ROOT);
const rel = relative(canonicalResolved, canonicalWebRoot);
// rel.startsWith('..') catches the non-ancestor case on POSIX.
// isAbsolute(rel) catches the Windows cross-drive case where relative()
// returns an absolute path (e.g. C:\repo\apps\web) instead of a ..-path.
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but WEB_ROOT "${canonicalWebRoot}" ` +
`is not inside it (relative path "${rel}"). ` +
`The override must be an ancestor of apps/web.`,
);
}
// Require the resolved path to be a real pnpm workspace root. Without this,
// an ancestor like `<repo>/apps` would pass the relative-path check but
// miss the sibling `packages/*` directory that `apps/web` imports from
// (for example `@open-design/contracts`), and Next would later fail deep
// inside file tracing / Turbopack with a much harder-to-diagnose error.
if (!existsSync(resolve(canonicalResolved, 'pnpm-workspace.yaml'))) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but no ` +
`pnpm-workspace.yaml was found there. The override must point at the ` +
`pnpm workspace root so outputFileTracingRoot and turbopack.root can ` +
`resolve sibling packages.`,
);
}
return canonicalResolved;
}
return computed;
}
const WORKSPACE_ROOT = resolveWorkspaceRoot();
const toPosixPath = (value: string) => value.replaceAll('\\', '/');
function resolveDistDir(defaultValue: string) {
if (process.env.OD_WEB_PROD === '1') return defaultValue;
const configured = process.env.OD_WEB_DIST_DIR;
if (!configured) return defaultValue;
return toPosixPath(isAbsolute(configured) ? relative(WEB_ROOT, configured) || '.' : configured);
}
const DIST_DIR = shouldStaticExport && !process.env.OD_WEB_DIST_DIR
? null
: resolveDistDir('.next');
function resolveDevTsconfigPath() {
const configured = process.env.OD_WEB_TSCONFIG_PATH;
if (!configured) return undefined;
return toPosixPath(isAbsolute(configured) ? relative(WEB_ROOT, configured) || 'tsconfig.json' : configured);
}
const DEV_TSCONFIG_PATH = resolveDevTsconfigPath();
function parseAllowedDevHost(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
try {
return new URL(`http://${trimmed}`).hostname.toLowerCase();
} catch {
return null;
}
}
}
function parseIpv4(value: string): [number, number, number, number] | null {
const parts = value.split('.');
if (parts.length !== 4) return null;
if (!parts.every((part) => /^\d+$/.test(part))) return null;
const octets = parts.map((part) => Number(part));
if (!octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) return null;
return octets as [number, number, number, number];
}
function isPrivateLanIpv4(value: string): boolean {
const octets = parseIpv4(value);
if (octets == null) return false;
const [a, b] = octets;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function localPrivateLanHosts(): string[] {
return Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => entry.family === 'IPv4' && !entry.internal && isPrivateLanIpv4(entry.address))
.map((entry) => entry.address);
}
function configuredAllowedDevHosts(): string[] {
const configured = (process.env.OD_ALLOWED_DEV_ORIGINS ?? '')
.split(',')
.map(parseAllowedDevHost)
.filter((host): host is string => host != null);
const allowedOrigins = (process.env.OD_ALLOWED_ORIGINS ?? '')
.split(',')
.map(parseAllowedDevHost)
.filter((host): host is string => host != null);
const bindHost = parseAllowedDevHost(process.env.OD_HOST ?? '');
return Array.from(new Set([
'127.0.0.1',
...localPrivateLanHosts(),
...(bindHost != null && bindHost !== '0.0.0.0' && bindHost !== '::' ? [bindHost] : []),
...configured,
...allowedOrigins,
]));
}
/**
* Direct inputs to the client host contract reported with CMS Test acceptances.
*
* Keep this closure at the host boundary: the touchpoint adapters and their
* presentation assets, the production decision loader, and the app/shell
* modules that mount or position those hosts. Broad shared application styles
* are intentionally excluded; including the whole app graph would turn an
* unrelated shell change into a CMS host release.
*/
export const CMS_HOST_RELEASE_INPUTS = [
'apps/web/app/layout.tsx',
'apps/web/src/App.tsx',
'apps/web/src/components/EntryNavRail.tsx',
'apps/web/src/components/EntryShell.tsx',
'apps/web/src/components/touchpoint-component.ts',
'apps/web/src/components/touchpoint-lifecycle.ts',
'apps/web/src/components/touchpoint-static-actions.ts',
'apps/web/src/components/touchpoint-navigation.ts',
'apps/web/src/components/TestCampaignModal.tsx',
'apps/web/src/components/TestCampaignModal.module.css',
'apps/web/src/components/ProductionCampaignModal.tsx',
'apps/web/src/components/ProductionCampaignBadge.tsx',
'apps/web/src/components/ProductionCampaignBadge.module.css',
'apps/web/src/components/ProductionCampaignHover.tsx',
'apps/web/src/components/HoverTouchpointOverlay.tsx',
'apps/web/src/components/HoverTouchpointOverlay.module.css',
'apps/web/src/components/production-touchpoint-loader.ts',
'packages/contracts/src/touchpoint-component-v2.ts',
] as const;
export function cmsHostReleaseFingerprint(
readFile: (file: (typeof CMS_HOST_RELEASE_INPUTS)[number]) => string | Buffer,
): string {
return `sha256:${CMS_HOST_RELEASE_INPUTS.reduce(
(hash, file) => hash.update(file).update('\0').update(readFile(file)),
createHash('sha256'),
).digest('hex')}`;
}
const nextConfig: NextConfig = {
env: {
// Embedded in the client at build time. Packaged servers use the already
// compiled client and need not retain source files to load this config.
NEXT_PUBLIC_CMS_HOST_RELEASE: existsSync(resolve(WEB_ROOT, 'src/components/touchpoint-component.ts'))
? cmsHostReleaseFingerprint((file) => readFileSync(resolve(WORKSPACE_ROOT, file)))
: undefined,
},
allowedDevOrigins: configuredAllowedDevHosts(),
outputFileTracingRoot: WORKSPACE_ROOT,
reactStrictMode: true,
// Emit browser sourcemaps so packaged-runtime exceptions can be symbolicated
// by PostHog. `tools/pack/src/web-sourcemaps.ts` runs after `next build`
// to inject chunk IDs, upload to PostHog, and ALWAYS delete the .map files
// before packaging so source never ships inside an installer.
productionBrowserSourceMaps: true,
transpilePackages: ['@open-design/components'],
turbopack: {
root: WORKSPACE_ROOT,
},
...(DEV_TSCONFIG_PATH ? { typescript: { tsconfigPath: DEV_TSCONFIG_PATH } } : {}),
// Static exports keep Next.js's default `out/` output directory so static
// hosts like Vercel can publish the generated site directly. Server runtimes
// still keep a predictable traced build directory for sidecar launchers.
...(DIST_DIR ? { distDir: DIST_DIR } : {}),
...(shouldStaticExport
? {
output: 'export' as const,
// `next export` skips trailing slashes by default; opting in keeps
// the daemon's static fallback simple (every directory has its own
// index.html on disk).
trailingSlash: true,
images: { unoptimized: true },
}
: webOutputMode === 'standalone'
? {
output: 'standalone' as const,
}
: !isProd
? {
async rewrites() {
// In dev we run the daemon on a sibling port; proxy the app API
// proxy so the SPA can hit /api, /artifacts, and /frames without
// CORS gymnastics. SSE on /api/chat works through this rewrite
// because Next.js's dev server streams responses unbuffered.
return [
{ source: '/api/:path*', destination: `${DAEMON_ORIGIN}/api/:path*` },
{ source: '/artifacts/:path*', destination: `${DAEMON_ORIGIN}/artifacts/:path*` },
{ source: '/frames/:path*', destination: `${DAEMON_ORIGIN}/frames/:path*` },
];
},
devIndicators: {
position: 'bottom-right',
},
}
: {}),
};
export default nextConfig;