47710908a8
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).
### Why
The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.
### What changed
**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
escape hatches and stale-directive detection.
**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.
**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
got a per-IP rate limit, body cap, and timeout.
**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.
**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.
**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
`Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
(Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).
**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
optimization works.
### What's deliberately NOT in this PR
- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
did not introduce new ones.
### Test plan
- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
above if wired into the build target.
228 lines
6.0 KiB
JavaScript
228 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const SRC = path.join(ROOT, 'src');
|
|
|
|
const KNOWN_VIOLATIONS = new Set([]);
|
|
|
|
const ALLOW_DIRECTIVE_REGEX =
|
|
/\/\/\s*boundary-allow-next-line:([\w-]+)(?:\s+--\s+.+)?/;
|
|
|
|
const RULES = [
|
|
{
|
|
id: 'no-raw-webgl-renderer',
|
|
description:
|
|
'`new THREE.WebGLRenderer(...)` may only be instantiated inside `src/lib/visual-runtime/`.',
|
|
pattern: /new\s+(?:THREE\.)?WebGLRenderer\s*\(/,
|
|
appliesTo: (rel) =>
|
|
rel.startsWith('src/') && /\.(ts|tsx|mjs|js|jsx)$/.test(rel),
|
|
exempt: (rel) =>
|
|
rel.startsWith('src/lib/visual-runtime/') ||
|
|
rel.includes('__tests__') ||
|
|
rel.endsWith('.d.ts'),
|
|
help: [
|
|
'Use `createSiteWebGlRenderer` from `@/lib/visual-runtime/create-site-webgl-renderer`',
|
|
'instead. The factory enforces the site-wide context cap, attaches the kill',
|
|
'switch (NEXT_PUBLIC_DISABLE_HEAVY_VISUALS), and centralises GPU / power',
|
|
'preference defaults.',
|
|
].join('\n '),
|
|
},
|
|
];
|
|
|
|
const SKIP_DIRS = new Set([
|
|
'node_modules',
|
|
'.next',
|
|
'.turbo',
|
|
'dist',
|
|
'build',
|
|
'storybook-static',
|
|
'public',
|
|
'.git',
|
|
]);
|
|
|
|
async function* walk(dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
yield* walk(path.join(dir, entry.name));
|
|
} else if (entry.isFile()) {
|
|
yield path.join(dir, entry.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
function findMatches(contents, pattern, ruleId) {
|
|
const matches = [];
|
|
const directives = [];
|
|
const lines = contents.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
const directiveMatch = line.match(ALLOW_DIRECTIVE_REGEX);
|
|
if (directiveMatch && directiveMatch[1] === ruleId) {
|
|
directives.push({ line: i + 1, ruleId, suppressed: false });
|
|
}
|
|
|
|
const m = line.match(pattern);
|
|
if (m && m.index != null) {
|
|
const prevDirective = directives.find((d) => d.line === i);
|
|
const suppressed = prevDirective !== undefined;
|
|
if (prevDirective) prevDirective.suppressed = true;
|
|
matches.push({
|
|
line: i + 1,
|
|
column: m.index + 1,
|
|
snippet: line.trim(),
|
|
suppressed,
|
|
});
|
|
}
|
|
}
|
|
return { matches, directives };
|
|
}
|
|
|
|
async function main() {
|
|
const violations = [];
|
|
const staleDirectives = [];
|
|
|
|
for await (const absPath of walk(SRC)) {
|
|
const rel = path.relative(ROOT, absPath).split(path.sep).join('/');
|
|
|
|
for (const rule of RULES) {
|
|
if (!rule.appliesTo(rel)) continue;
|
|
if (rule.exempt(rel)) continue;
|
|
|
|
let contents;
|
|
try {
|
|
contents = await fs.readFile(absPath, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const { matches, directives } = findMatches(
|
|
contents,
|
|
rule.pattern,
|
|
rule.id,
|
|
);
|
|
for (const m of matches) {
|
|
if (m.suppressed) continue;
|
|
violations.push({
|
|
rule,
|
|
file: rel,
|
|
line: m.line,
|
|
column: m.column,
|
|
snippet: m.snippet,
|
|
});
|
|
}
|
|
for (const d of directives) {
|
|
if (!d.suppressed) {
|
|
staleDirectives.push({ rule, file: rel, line: d.line });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const newViolations = [];
|
|
const seenKnown = new Set();
|
|
|
|
for (const v of violations) {
|
|
const key = `${v.rule.id}:${v.file}`;
|
|
if (KNOWN_VIOLATIONS.has(key)) {
|
|
seenKnown.add(key);
|
|
} else {
|
|
newViolations.push(v);
|
|
}
|
|
}
|
|
|
|
const staleKnown = [];
|
|
for (const key of KNOWN_VIOLATIONS) {
|
|
if (!seenKnown.has(key)) staleKnown.push(key);
|
|
}
|
|
|
|
let exitCode = 0;
|
|
|
|
if (newViolations.length > 0) {
|
|
exitCode = 1;
|
|
const byRule = new Map();
|
|
for (const v of newViolations) {
|
|
if (!byRule.has(v.rule.id)) byRule.set(v.rule.id, []);
|
|
byRule.get(v.rule.id).push(v);
|
|
}
|
|
|
|
console.error('');
|
|
console.error(
|
|
`\u001b[31mcheck-boundaries: ${newViolations.length} new violation(s) found\u001b[0m`,
|
|
);
|
|
console.error('');
|
|
|
|
for (const [ruleId, list] of byRule.entries()) {
|
|
const rule = list[0].rule;
|
|
console.error(`\u001b[1m${ruleId}\u001b[0m — ${rule.description}`);
|
|
console.error('');
|
|
for (const v of list) {
|
|
console.error(` ${v.file}:${v.line}:${v.column}`);
|
|
console.error(` > ${v.snippet}`);
|
|
}
|
|
console.error('');
|
|
console.error(` help:`);
|
|
console.error(` ${rule.help}`);
|
|
console.error('');
|
|
}
|
|
}
|
|
|
|
if (staleKnown.length > 0) {
|
|
exitCode = 1;
|
|
console.error('');
|
|
console.error(
|
|
`\u001b[31mcheck-boundaries: ${staleKnown.length} stale entry/entries in KNOWN_VIOLATIONS\u001b[0m`,
|
|
);
|
|
console.error(
|
|
' Remove the following from KNOWN_VIOLATIONS in scripts/check-boundaries.mjs:',
|
|
);
|
|
console.error('');
|
|
for (const key of staleKnown) {
|
|
console.error(` - ${key}`);
|
|
}
|
|
console.error('');
|
|
}
|
|
|
|
if (staleDirectives.length > 0) {
|
|
exitCode = 1;
|
|
console.error('');
|
|
console.error(
|
|
`\u001b[31mcheck-boundaries: ${staleDirectives.length} stale boundary-allow-next-line directive(s)\u001b[0m`,
|
|
);
|
|
console.error(
|
|
' The directive on these lines no longer suppresses any violation. Remove it:',
|
|
);
|
|
console.error('');
|
|
for (const d of staleDirectives) {
|
|
console.error(` - ${d.file}:${d.line} (${d.rule.id})`);
|
|
}
|
|
console.error('');
|
|
}
|
|
|
|
if (exitCode === 0) {
|
|
if (KNOWN_VIOLATIONS.size > 0) {
|
|
console.error(
|
|
`check-boundaries: OK (${KNOWN_VIOLATIONS.size} grandfathered file-level violation(s) tolerated; drive to zero).`,
|
|
);
|
|
} else {
|
|
console.error('check-boundaries: OK');
|
|
}
|
|
}
|
|
|
|
return exitCode;
|
|
}
|
|
|
|
main()
|
|
.then((code) => process.exit(code))
|
|
.catch((err) => {
|
|
console.error('check-boundaries: unexpected error');
|
|
console.error(err);
|
|
process.exit(2);
|
|
});
|