@motujs/cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// The lagoon's Vite configuration, assembled IN THE FRAMEWORK.
|
|
2
|
+
//
|
|
3
|
+
// Every line of this used to be scaffolded into each adopting project as a `roots/lagoon/vite.config.ts`
|
|
4
|
+
// — ~125 lines of build wiring, a package.json, a lockfile and a node_modules, none of which encoded a
|
|
5
|
+
// project decision. The alias table is host-adapter knowledge; the `__MOTU_*` defines are a contract
|
|
6
|
+
// with `motu island verify` (which sets the matching env when it boots this server) that the generated
|
|
7
|
+
// file could only warn you not to break. Owning it here means an improvement to the lagoon arrives with
|
|
8
|
+
// the framework instead of needing every project's copy regenerated.
|
|
9
|
+
//
|
|
10
|
+
// A project declares its posture in motu.config.json and its lagoon in lagoon.config.json. That is all.
|
|
11
|
+
// `motu lagoon eject` writes the old files back out for a project that genuinely needs to fork.
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { injectDock } from './dock-inject.mjs';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { resolveLagoonRoot } from './lagoon-materialize.mjs';
|
|
18
|
+
import { writeIfChanged } from './write-if-changed.mjs';
|
|
19
|
+
import { motuProvenance } from './provenance-plugin.mjs';
|
|
20
|
+
import { unbundlableIslands } from './bundlability.mjs';
|
|
21
|
+
import { hostAliasEntries } from './util.mjs';
|
|
22
|
+
import { BUILD_DEPS_ROOT, CLI_PACKAGE_ROOT, INSTALLED, frameworkFile } from './framework.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where build deps are resolved from first: the motu checkout this CLI runs from, or — installed —
|
|
26
|
+
* `@motujs/cli` itself, which declares vite, the plugins and Tailwind as its own dependencies for
|
|
27
|
+
* exactly this. See lib/framework.mjs.
|
|
28
|
+
*/
|
|
29
|
+
const FRAMEWORK_ROOT = BUILD_DEPS_ROOT;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a package's ESM entry point from a given root.
|
|
33
|
+
*
|
|
34
|
+
* `createRequire().resolve()` always picks the `require` condition, which for Vite hands back the CJS
|
|
35
|
+
* build: it prints a deprecation warning and has no `build` on it, so the failure surfaces a long way
|
|
36
|
+
* from its cause. Read the package's own `exports` map instead and take the import condition, falling
|
|
37
|
+
* back through `module`/`main` for the packages that are still CJS-only (Tailwind 3), which Node
|
|
38
|
+
* interops on import anyway.
|
|
39
|
+
*/
|
|
40
|
+
function esmEntry(root, name) {
|
|
41
|
+
const req = createRequire(resolve(root, 'package.json'));
|
|
42
|
+
// Not every package exports './package.json' (an `exports` map without it makes this throw
|
|
43
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED), so fall back to the require-resolved entry and let Node interop it.
|
|
44
|
+
let pkgJsonPath;
|
|
45
|
+
try {
|
|
46
|
+
pkgJsonPath = req.resolve(`${name}/package.json`);
|
|
47
|
+
} catch {
|
|
48
|
+
return pathToFileURL(req.resolve(name)).href;
|
|
49
|
+
}
|
|
50
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
51
|
+
const pick = (node) => {
|
|
52
|
+
if (!node) return null;
|
|
53
|
+
if (typeof node === 'string') return node;
|
|
54
|
+
return pick(node.import) ?? pick(node.default) ?? pick(node.require) ?? null;
|
|
55
|
+
};
|
|
56
|
+
const entry = pick(pkg.exports?.['.']) ?? pkg.module ?? pkg.main ?? 'index.js';
|
|
57
|
+
return pathToFileURL(resolve(dirname(pkgJsonPath), entry)).href;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Try each root in order, returning the first namespace that imports. */
|
|
61
|
+
async function importFrom(name, roots) {
|
|
62
|
+
for (const root of roots.filter(Boolean)) {
|
|
63
|
+
try {
|
|
64
|
+
return await import(esmEntry(root, name));
|
|
65
|
+
} catch {
|
|
66
|
+
/* try the next root */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The directory a package resolves to from `root`, or null. Aliases need a path, not a namespace. */
|
|
73
|
+
function packageDir(root, name) {
|
|
74
|
+
try {
|
|
75
|
+
return dirname(createRequire(resolve(root, 'package.json')).resolve(`${name}/package.json`));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* REACT, when the project has none.
|
|
83
|
+
*
|
|
84
|
+
* The lagoon deliberately installs no React and dedupes onto the host application's copy — two Reacts
|
|
85
|
+
* break hooks the moment an island renders a component from the host's own library. That is right
|
|
86
|
+
* whenever there IS a host copy, and fatal when there is not: a freshly `motu init`-ed project has no
|
|
87
|
+
* node_modules at all, so every `@motujs/react` module failed to resolve `react`, and the lagoon served
|
|
88
|
+
* 500s. `island verify` reported it as "island tag did not upgrade" — the true cause named nowhere.
|
|
89
|
+
*
|
|
90
|
+
* So: the host's copy wins wherever it exists, and the framework's own is the fallback that makes a
|
|
91
|
+
* project with nothing installed still able to render. Same reasoning that already lets the framework
|
|
92
|
+
* own vite and the build plugins, applied to the one dependency the lagoon genuinely shares with the
|
|
93
|
+
* app it frames.
|
|
94
|
+
*
|
|
95
|
+
* Returns aliases only in the fallback case — adding them when the host HAS React would defeat the
|
|
96
|
+
* dedupe this exists alongside.
|
|
97
|
+
*/
|
|
98
|
+
function reactFallbackAliases(paths) {
|
|
99
|
+
const projectRoots = [paths.hostRoot, paths.root, paths.lagoonDir].filter(Boolean);
|
|
100
|
+
if (projectRoots.some((r) => packageDir(r, 'react'))) return [];
|
|
101
|
+
// NOT the checkout root: in a pnpm workspace React is a devDependency of `packages/cli`, and the
|
|
102
|
+
// root resolves nothing. Ask from a package that actually declares it — which is also where a
|
|
103
|
+
// published install would find it.
|
|
104
|
+
const frameworkRoots = INSTALLED
|
|
105
|
+
? [CLI_PACKAGE_ROOT]
|
|
106
|
+
: [resolve(FRAMEWORK_ROOT, 'packages/react'), resolve(FRAMEWORK_ROOT, 'packages/cli'), FRAMEWORK_ROOT];
|
|
107
|
+
const fromFramework = (name) => {
|
|
108
|
+
for (const r of frameworkRoots) {
|
|
109
|
+
const dir = packageDir(r, name);
|
|
110
|
+
if (dir) return dir;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const name of ['react-dom', 'react']) {
|
|
116
|
+
const dir = fromFramework(name);
|
|
117
|
+
// Anchored, and the SUBPATH form first: `react-dom/client` must not be swallowed by `react-dom`,
|
|
118
|
+
// and `react` must not swallow `react-dom` — the exact alias-ordering trap noInstallAliases documents.
|
|
119
|
+
if (dir) {
|
|
120
|
+
out.push({ find: new RegExp(`^${name}/(.*)$`), replacement: `${dir}/$1` });
|
|
121
|
+
out.push({ find: new RegExp(`^${name}$`), replacement: dir });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Vite's own API (named exports: `build`, `createServer`, `preview`).
|
|
129
|
+
*
|
|
130
|
+
* Separate from `resolveBuildDep` below, which unwraps a default export for plugins — Vite has none,
|
|
131
|
+
* it is a namespace.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveVite(paths) {
|
|
134
|
+
// THE HOST'S VITE FIRST, ON A VITE HOST — because on that host the lagoon runs the HOST'S OWN
|
|
135
|
+
// PLUGINS, and a plugin is written against the Vite its project installed.
|
|
136
|
+
//
|
|
137
|
+
// Framework-first was right while the lagoon only ran motu's own pipeline. It stopped being right
|
|
138
|
+
// the moment the vite adapter began borrowing the host's plugins and resolve options: measured on
|
|
139
|
+
// Mastodon, the app runs Vite 8.2.1 and resolves its bare `mastodon/…` imports with
|
|
140
|
+
// `resolve.tsconfigPaths`, a Vite 6+ option that motu's bundled Vite 5.4.21 accepts and SILENTLY
|
|
141
|
+
// IGNORES. `tsc` and the app's own dev server resolved those imports; the lagoon 500'd on every one
|
|
142
|
+
// of them, and the adopter had to hand-write an alias table into a forked config to work around a
|
|
143
|
+
// version skew nothing reported.
|
|
144
|
+
//
|
|
145
|
+
// Other hosts keep framework-first: a Next or AngularJS project's Vite, if it has one at all, is not
|
|
146
|
+
// the build the lagoon is imitating.
|
|
147
|
+
const hostFirst = paths.host === 'vite';
|
|
148
|
+
const roots = hostFirst
|
|
149
|
+
? [paths.hostRoot, FRAMEWORK_ROOT, paths.lagoonDir]
|
|
150
|
+
: [FRAMEWORK_ROOT, paths.lagoonDir];
|
|
151
|
+
const mod = await importFrom('vite', roots);
|
|
152
|
+
if (!mod?.build) throw new Error(`motu: cannot resolve vite. Looked in: ${roots.join(', ')}.`);
|
|
153
|
+
return mod;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a build dependency (plugins, Tailwind) and unwrap its default export.
|
|
158
|
+
*
|
|
159
|
+
* Framework first, project second. The framework owning its own build deps is what lets a project drop
|
|
160
|
+
* the lagoon's package.json entirely; the project fallback keeps every EXISTING project working during
|
|
161
|
+
* the migration, and keeps `eject` honest afterwards.
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveBuildDep(name, ...extraRoots) {
|
|
164
|
+
const roots = [FRAMEWORK_ROOT, ...extraRoots];
|
|
165
|
+
const mod = await importFrom(name, roots);
|
|
166
|
+
if (!mod) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`motu: cannot resolve build dependency '${name}'. Looked in: ${roots.filter(Boolean).join(', ')}.\n` +
|
|
169
|
+
(INSTALLED
|
|
170
|
+
? `It is a dependency of @motujs/cli, so the install is incomplete — reinstall it (\`npm i -D @motujs/cli\`).`
|
|
171
|
+
: `Install it in the motu checkout (${FRAMEWORK_ROOT}) so every project gets it without its own install.`),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return mod?.default?.default ?? mod?.default ?? mod;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The lagoon's own declaration. Describes the LAGOON; motu.config.json describes the project. */
|
|
178
|
+
export function loadLagoonJson(paths) {
|
|
179
|
+
const p = resolve(paths.lagoonDir, 'lagoon.config.json');
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
182
|
+
} catch {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolving @motujs/* and the project's own island package BY PATH.
|
|
189
|
+
*
|
|
190
|
+
* The @motujs/* packages are unpublished and ship as raw TypeScript, so a project that has not installed
|
|
191
|
+
* them (and should not have to) reaches them through these aliases. A workspace-linked project
|
|
192
|
+
* (the reference ocean) resolves them through node_modules instead and contributes nothing here.
|
|
193
|
+
*
|
|
194
|
+
* Array form with anchored patterns, not the object form: Vite's alias matcher is
|
|
195
|
+
* exact-or-prefix-with-a-slash, which gets two things wrong — 'pkg/styles.css?inline' never matches
|
|
196
|
+
* 'pkg/styles.css' (the query is part of the id), and '@motujs/runtime' happily swallows
|
|
197
|
+
* '@motujs/runtime/mock'. Anchored regexes make each mapping mean exactly what it says.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* The host's workspace root — where a monorepo hoists the dependencies the host imports.
|
|
201
|
+
*
|
|
202
|
+
* Walks up for the markers a workspace leaves (a lockfile, a pnpm/yarn workspace declaration) rather
|
|
203
|
+
* than importing Vite's `searchForWorkspaceRoot`: this module is loaded before Vite is resolved, and
|
|
204
|
+
* the rule is simple enough that a dependency for it would be the more surprising choice.
|
|
205
|
+
*/
|
|
206
|
+
function workspaceRootOf(hostRoot) {
|
|
207
|
+
const MARKERS = ['pnpm-workspace.yaml', 'yarn.lock', 'pnpm-lock.yaml', 'package-lock.json', 'bun.lock', 'lerna.json'];
|
|
208
|
+
let dir = hostRoot;
|
|
209
|
+
let found = null;
|
|
210
|
+
for (let up = 0; up < 10; up++) {
|
|
211
|
+
if (MARKERS.some((m) => existsSync(resolve(dir, m)))) found = dir;
|
|
212
|
+
const parent = dirname(dir);
|
|
213
|
+
if (parent === dir) break;
|
|
214
|
+
dir = parent;
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function noInstallAliases(paths) {
|
|
220
|
+
const motu = (p) => resolve(paths.motuRoot, p);
|
|
221
|
+
const pkg = paths.appPackage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
222
|
+
// The project's OWN package, in both modes: it is never installed, it is the app's source.
|
|
223
|
+
const project = [
|
|
224
|
+
{ find: new RegExp(`^${pkg}/styles\\.css`), replacement: resolve(paths.sharedDir, 'styles.css') },
|
|
225
|
+
{ find: new RegExp(`^${pkg}$`), replacement: paths.barrel },
|
|
226
|
+
];
|
|
227
|
+
// INSTALLED, `@motujs/*` are not aliased at all. They are real packages in the app's node_modules,
|
|
228
|
+
// and their `exports` point at `dist/` — every path below would name `<motuRoot>/packages/...`, a
|
|
229
|
+
// directory an install does not have, and the lagoon 500'd on the first `@motujs/react` import.
|
|
230
|
+
// Resolving them normally is also what makes the HOST's react the only react: `dist/` walks up the
|
|
231
|
+
// app's own tree.
|
|
232
|
+
if (paths.frameworkMode === 'installed') return project;
|
|
233
|
+
return [
|
|
234
|
+
// Plain ESM, no TypeScript: @motujs/core imports the palette from here, and so does the lagoon
|
|
235
|
+
// HOST, which runs under bare node. One package both can read is the point of it.
|
|
236
|
+
{ find: /^@motujs\/chrome$/, replacement: motu('packages/chrome/src/index.mjs') },
|
|
237
|
+
// The React kit. It is the one COMPILED corner of an as-authored package, so `@motujs/chrome/react`
|
|
238
|
+
// resolves to `dist/react` through the exports map — which would make every kit edit invisible
|
|
239
|
+
// until someone re-ran the package build. Aliased to source like every other @motujs package here,
|
|
240
|
+
// for the same reason: the lagoon is where motu's own chrome is looked at while it is being
|
|
241
|
+
// changed.
|
|
242
|
+
{ find: /^@motujs\/chrome\/react$/, replacement: motu('packages/chrome/src/react/index.tsx') },
|
|
243
|
+
{ find: /^@motujs\/core$/, replacement: motu('packages/core/src/index.ts') },
|
|
244
|
+
{ find: /^@motujs\/coverage$/, replacement: motu('packages/coverage/src/index.ts') },
|
|
245
|
+
{ find: /^@motujs\/react$/, replacement: motu('packages/react/src/index.ts') },
|
|
246
|
+
{ find: /^@motujs\/react\/define$/, replacement: motu('packages/react/src/defineReactElement.ts') },
|
|
247
|
+
{ find: /^@motujs\/runtime\/mock$/, replacement: motu('packages/runtime/src/mock.ts') },
|
|
248
|
+
// MISSING FOR A WHILE, and invisibly: without it `@motujs/runtime/postgrest-fetch` is a bare
|
|
249
|
+
// specifier, so it resolves through the exports map to `dist/` and Vite pre-bundles it — every
|
|
250
|
+
// edit to the wire fake was then invisible in the lagoon until someone rebuilt AND cleared
|
|
251
|
+
// `node_modules/.vite`. Found while testing a new check that kept passing against stale code.
|
|
252
|
+
{ find: /^@motujs\/runtime\/postgrest-fetch$/, replacement: motu('packages/runtime/src/postgrest-fetch.ts') },
|
|
253
|
+
{ find: /^@motujs\/runtime$/, replacement: motu('packages/runtime/src/index.ts') },
|
|
254
|
+
{ find: /^@motujs\/debug-overlay$/, replacement: motu('packages/debug-overlay/src/index.ts') },
|
|
255
|
+
{ find: /^@motujs\/adapter-next$/, replacement: motu('packages/adapters/next/src/index.ts') },
|
|
256
|
+
...project,
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The host adapter's Vite contribution, if it ships one. */
|
|
261
|
+
async function hostContribution(ctx) {
|
|
262
|
+
const dir = ['angularjs', 'next', 'vite'].includes(ctx.paths.host) ? ctx.paths.host : null;
|
|
263
|
+
if (!dir) return {};
|
|
264
|
+
// `frameworkFile`, not `motuRoot`: installed, the adapters' Vite contributions ship inside
|
|
265
|
+
// @motujs/cli (`bundled/`), and `motuRoot` has no `packages/` under it. The silent `return {}` below
|
|
266
|
+
// is what made that invisible — a Next lagoon with no `next/*` stubs and no Tailwind, not an error.
|
|
267
|
+
const mod = INSTALLED ? frameworkFile(`packages/adapters/${dir}/vite.mjs`) : resolve(ctx.paths.motuRoot, `packages/adapters/${dir}/vite.mjs`);
|
|
268
|
+
if (!existsSync(mod)) {
|
|
269
|
+
if (INSTALLED) throw new Error(`motu: @motujs/cli is missing its ${dir} adapter (${mod}) — reinstall it.`);
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
const { contribute } = await import(pathToFileURL(mod).href);
|
|
273
|
+
return (await contribute(ctx)) ?? {};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Assemble the lagoon's Vite config.
|
|
278
|
+
*
|
|
279
|
+
* `env` is the MOTU_* surface `motu island verify` sets when it boots this server — passed explicitly
|
|
280
|
+
* rather than read from process.env, so a programmatic caller (verify, publish, serve) states what it
|
|
281
|
+
* wants instead of mutating the environment and hoping.
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Keep ONE island's unbundlable import graph from taking the whole lagoon with it.
|
|
286
|
+
*
|
|
287
|
+
* The lagoon builds every archipelago as one chunk, so a module rollup cannot resolve — a Next server
|
|
288
|
+
* action reaching `node:async_hooks`, say — is not a broken preview for that island, it is a dead
|
|
289
|
+
* build for every island in the project. Measured: a green `motu check --runtime` and not one
|
|
290
|
+
* openable URL.
|
|
291
|
+
*
|
|
292
|
+
* `load()` rather than an alias, because the registry imports islands by RELATIVE path and an alias
|
|
293
|
+
* matches specifier strings. Intercepting the resolved file means rollup never parses the real
|
|
294
|
+
* module, so it never walks into the graph that cannot be bundled.
|
|
295
|
+
*
|
|
296
|
+
* WHAT THIS DOES NOT DO: it does not make the island work, and it must never be mistaken for that.
|
|
297
|
+
* The island still fails `rsc-boundary` in `motu island verify`, its slot renders a card saying why,
|
|
298
|
+
* and the build prints what it dropped. Only the blast radius changes.
|
|
299
|
+
*/
|
|
300
|
+
function excludeUnbundlable(excluded) {
|
|
301
|
+
const byFile = new Map(excluded.map((e) => [resolve(e.element), e]));
|
|
302
|
+
if (!byFile.size) return null;
|
|
303
|
+
return {
|
|
304
|
+
name: 'motu:exclude-unbundlable',
|
|
305
|
+
enforce: 'pre',
|
|
306
|
+
load(id) {
|
|
307
|
+
const hit = byFile.get(resolve(id.split('?')[0]));
|
|
308
|
+
if (!hit) return null;
|
|
309
|
+
const tag = JSON.stringify(hit.tag ?? hit.kebab);
|
|
310
|
+
const reason = JSON.stringify(hit.reason);
|
|
311
|
+
const name = JSON.stringify(hit.kebab);
|
|
312
|
+
return `// Generated by motu: this island was left OUT of the lagoon build.
|
|
313
|
+
import { islandElement } from '@motujs/react';
|
|
314
|
+
import { createElement as h } from 'react';
|
|
315
|
+
|
|
316
|
+
const Unbundlable = () =>
|
|
317
|
+
h('div', { 'data-motu-unbundlable': ${name}, style: { padding: '12px', border: '1px dashed #b45309', borderRadius: '6px', color: '#b45309', font: '12px/1.5 ui-monospace, monospace' } },
|
|
318
|
+
h('strong', null, ${name} + ' could not be bundled'),
|
|
319
|
+
h('div', null, ${reason}),
|
|
320
|
+
h('div', { style: { opacity: 0.8, marginTop: '6px' } }, 'Every other island in this lagoon is unaffected. Run: motu island verify ' + ${name}));
|
|
321
|
+
|
|
322
|
+
export const element = islandElement({ tag: ${tag}, component: Unbundlable });
|
|
323
|
+
`;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Which chunk a module belongs to when the lagoon is published as chunks.
|
|
330
|
+
*
|
|
331
|
+
* The lagoon imports every region EAGERLY, so the bundler on its own would make one chunk and every
|
|
332
|
+
* edit would change all of it. The cut follows how the project changes:
|
|
333
|
+
*
|
|
334
|
+
* vendor every dependency — changes when the lockfile does
|
|
335
|
+
* motu the framework — changes when motu is upgraded
|
|
336
|
+
* region-<id> one archipelago's folder
|
|
337
|
+
* island-<kebab> one island (its folder, or its flat `<kebab>.*.ts` files)
|
|
338
|
+
* host-<dir> the application's own code, one directory at a time
|
|
339
|
+
* (the entry) the generated lagoon entry
|
|
340
|
+
*
|
|
341
|
+
* so an edit inside one region re-sends that region's chunk and the small manifest, not the lagoon.
|
|
342
|
+
*
|
|
343
|
+
* NO TWO CHUNKS MAY IMPORT EACH OTHER. Chunks evaluate as whole units, so a cycle between two of them
|
|
344
|
+
* runs one before what it needs exists (`Cannot access 'n' before initialization`, first seen on the
|
|
345
|
+
* demo). Directories are therefore only the NAMES; the CUT follows evaluation order:
|
|
346
|
+
*
|
|
347
|
+
* 1. FOUNDATIONS. vendor and motu import nothing of the application. When they do — Peps aliases
|
|
348
|
+
* `next/navigation` and its Supabase client to stubs in the lagoon's own source, so dependencies
|
|
349
|
+
* import app code — what they import is pulled DOWN into them.
|
|
350
|
+
* 2. THE APPLICATION, in the order the browser would evaluate it (depth-first, imports in source
|
|
351
|
+
* order). A new chunk starts where the directory changes, and never inside a module cycle. Every
|
|
352
|
+
* import then points at an EARLIER chunk, so the chunk graph is acyclic by construction.
|
|
353
|
+
*
|
|
354
|
+
* Merging whole directories on a cycle was tried first, and is wrong for an integrated app: Peps'
|
|
355
|
+
* pages import its regions and its regions import the pages' components through different files, so
|
|
356
|
+
* every directory sat on one cycle and became one 600 kB chunk. Cutting by evaluation order keeps a
|
|
357
|
+
* region's code in its own chunk(s) however the directories interleave. A directory evaluated in
|
|
358
|
+
* several runs gets several chunks (`region-x`, `region-x.2`); a run under 4 kB folds into its
|
|
359
|
+
* neighbour, so the count stays near the number of directories.
|
|
360
|
+
*/
|
|
361
|
+
export function chunkOf(paths) {
|
|
362
|
+
const norm = (p) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
363
|
+
const motuPkgs = norm(resolve(paths.motuRoot, 'packages')) + '/';
|
|
364
|
+
const archipelagos = norm(paths.archipelagosDir) + '/';
|
|
365
|
+
const islands = norm(paths.islandsDir) + '/';
|
|
366
|
+
const host = norm(paths.hostRoot) + '/';
|
|
367
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'x';
|
|
368
|
+
const ENTRY = 'entry';
|
|
369
|
+
const MIN_RUN = 4 * 1024;
|
|
370
|
+
const firstGuess = (rawId) => {
|
|
371
|
+
const id = norm(rawId.split('?')[0]);
|
|
372
|
+
// Installed, motu IS a dependency — but it is upgraded on its own schedule, not the lockfile's
|
|
373
|
+
// whole, so it keeps its own chunk rather than invalidating `vendor` on every motu release.
|
|
374
|
+
if (id.includes('/node_modules/@motujs/')) return 'motu';
|
|
375
|
+
if (rawId.startsWith('\0') || id.includes('/node_modules/')) return 'vendor';
|
|
376
|
+
if (id.startsWith(motuPkgs)) return 'motu';
|
|
377
|
+
if (id.startsWith(archipelagos)) return 'region-' + safe(id.slice(archipelagos.length).split('/')[0].replace(/\.[^.]*$/, ''));
|
|
378
|
+
if (id.startsWith(islands)) return 'island-' + safe(id.slice(islands.length).split('/')[0].split('.')[0]);
|
|
379
|
+
if (id.startsWith(host) && !id.includes('/.motu/')) {
|
|
380
|
+
const dirs = id.slice(host.length).split('/').slice(0, -1);
|
|
381
|
+
if (dirs.length) return 'host-' + safe(dirs.join('-'));
|
|
382
|
+
}
|
|
383
|
+
return ENTRY;
|
|
384
|
+
};
|
|
385
|
+
const foundation = (g) => g === 'vendor' || g === 'motu';
|
|
386
|
+
|
|
387
|
+
let plan = null;
|
|
388
|
+
const planFor = (getModuleIds, getModuleInfo) => {
|
|
389
|
+
const ids = [...getModuleIds()];
|
|
390
|
+
const known = new Set(ids);
|
|
391
|
+
const deps = (id) => (getModuleInfo(id)?.importedIds ?? []).filter((d) => known.has(d));
|
|
392
|
+
const group = new Map(ids.map((id) => [id, firstGuess(id)]));
|
|
393
|
+
|
|
394
|
+
// 1. FOUNDATIONS pull down what they import: motu first, then vendor, which wins a module both want.
|
|
395
|
+
for (const f of ['motu', 'vendor']) {
|
|
396
|
+
const work = ids.filter((id) => group.get(id) === f);
|
|
397
|
+
while (work.length) {
|
|
398
|
+
const id = work.pop();
|
|
399
|
+
for (const d of deps(id)) {
|
|
400
|
+
const h = group.get(d);
|
|
401
|
+
if (h === f || h === 'vendor') continue;
|
|
402
|
+
group.set(d, f);
|
|
403
|
+
work.push(d);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 2. THE APPLICATION, in evaluation order: depth-first post-order from each entry.
|
|
409
|
+
const app = (id) => !foundation(group.get(id));
|
|
410
|
+
const order = [];
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
const visit = (root) => {
|
|
413
|
+
const stack = [[root, 0]];
|
|
414
|
+
seen.add(root);
|
|
415
|
+
while (stack.length) {
|
|
416
|
+
const top = stack[stack.length - 1];
|
|
417
|
+
const ds = deps(top[0]);
|
|
418
|
+
if (top[1] < ds.length) {
|
|
419
|
+
const d = ds[top[1]++];
|
|
420
|
+
if (!seen.has(d) && app(d)) {
|
|
421
|
+
seen.add(d);
|
|
422
|
+
stack.push([d, 0]);
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
stack.pop();
|
|
426
|
+
order.push(top[0]);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
for (const id of ids) if (app(id) && getModuleInfo(id)?.isEntry) visit(id);
|
|
431
|
+
for (const id of ids) if (app(id) && !seen.has(id)) visit(id);
|
|
432
|
+
const pos = new Map(order.map((id, i) => [id, i]));
|
|
433
|
+
|
|
434
|
+
// Module cycles: a cut may not fall inside one. For each module, the last position of its cycle.
|
|
435
|
+
const spanEnd = new Map(order.map((id) => [id, pos.get(id)]));
|
|
436
|
+
{
|
|
437
|
+
const index = new Map();
|
|
438
|
+
const low = new Map();
|
|
439
|
+
const on = new Set();
|
|
440
|
+
const st = [];
|
|
441
|
+
let n = 0;
|
|
442
|
+
const strong = (v) => {
|
|
443
|
+
index.set(v, n);
|
|
444
|
+
low.set(v, n++);
|
|
445
|
+
st.push(v);
|
|
446
|
+
on.add(v);
|
|
447
|
+
for (const w of deps(v)) {
|
|
448
|
+
if (!pos.has(w)) continue;
|
|
449
|
+
if (!index.has(w)) {
|
|
450
|
+
strong(w);
|
|
451
|
+
low.set(v, Math.min(low.get(v), low.get(w)));
|
|
452
|
+
} else if (on.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
453
|
+
}
|
|
454
|
+
if (low.get(v) === index.get(v)) {
|
|
455
|
+
const members = [];
|
|
456
|
+
let w;
|
|
457
|
+
do {
|
|
458
|
+
w = st.pop();
|
|
459
|
+
on.delete(w);
|
|
460
|
+
members.push(w);
|
|
461
|
+
} while (w !== v);
|
|
462
|
+
if (members.length > 1) {
|
|
463
|
+
const end = Math.max(...members.map((m) => pos.get(m)));
|
|
464
|
+
for (const m of members) spanEnd.set(m, end);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
for (const id of order) if (!index.has(id)) strong(id);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Runs: cut where the directory changes, unless a module cycle is still open.
|
|
472
|
+
const runs = [];
|
|
473
|
+
let open = -1;
|
|
474
|
+
for (const id of order) {
|
|
475
|
+
const g = group.get(id);
|
|
476
|
+
const last = runs[runs.length - 1];
|
|
477
|
+
if (last && (last.group === g || pos.get(id) <= open)) last.ids.push(id);
|
|
478
|
+
else runs.push({ group: g, ids: [id] });
|
|
479
|
+
open = Math.max(open, spanEnd.get(id));
|
|
480
|
+
}
|
|
481
|
+
// Small runs fold into the previous one (contiguity is what keeps the graph acyclic).
|
|
482
|
+
const sizeOf = (run) => run.ids.reduce((n, id) => n + (getModuleInfo(id)?.code?.length ?? 0), 0);
|
|
483
|
+
const folded = [];
|
|
484
|
+
for (const run of runs) {
|
|
485
|
+
const prev = folded[folded.length - 1];
|
|
486
|
+
if (prev && sizeOf(run) < MIN_RUN) prev.ids.push(...run.ids);
|
|
487
|
+
else folded.push(run);
|
|
488
|
+
}
|
|
489
|
+
// The FIRST run has nothing before it, so a small one folds forward instead.
|
|
490
|
+
if (folded.length > 1 && sizeOf(folded[0]) < MIN_RUN) folded[1].ids.unshift(...folded.shift().ids);
|
|
491
|
+
// The run holding an entry module IS the entry chunk; every other run is named.
|
|
492
|
+
plan = new Map();
|
|
493
|
+
for (const id of ids) if (foundation(group.get(id))) plan.set(id, group.get(id));
|
|
494
|
+
const used = new Map();
|
|
495
|
+
for (const run of folded) {
|
|
496
|
+
const holdsEntry = run.ids.some((id) => getModuleInfo(id)?.isEntry);
|
|
497
|
+
let name;
|
|
498
|
+
if (holdsEntry) name = undefined;
|
|
499
|
+
else {
|
|
500
|
+
const k = (used.get(run.group) ?? 0) + 1;
|
|
501
|
+
used.set(run.group, k);
|
|
502
|
+
name = k === 1 ? run.group : `${run.group}.${k}`;
|
|
503
|
+
}
|
|
504
|
+
for (const id of run.ids) plan.set(id, name);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
return (rawId, meta) => {
|
|
509
|
+
if (!plan && meta?.getModuleIds) planFor(meta.getModuleIds, meta.getModuleInfo);
|
|
510
|
+
if (plan?.has(rawId)) return plan.get(rawId);
|
|
511
|
+
const g = firstGuess(rawId);
|
|
512
|
+
return foundation(g) ? g : undefined;
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function buildLagoonViteConfig(paths, env = process.env) {
|
|
517
|
+
// `paths` must be loadMotuConfig()'s result, NOT util.mjs's curated `paths` export — that one has no
|
|
518
|
+
// `host`, so the host contribution would silently return nothing and the lagoon would build with no
|
|
519
|
+
// aliases at all. That failure surfaces as "@motujs/react could not be resolved", which points nowhere
|
|
520
|
+
// near the cause. Fail here instead.
|
|
521
|
+
for (const key of ['host', 'motuRoot', 'lagoonDir', 'appRoot', 'hostRoot', 'cacheDir', 'isolation']) {
|
|
522
|
+
if (paths?.[key] === undefined) {
|
|
523
|
+
throw new Error(`motu: buildLagoonViteConfig needs loadMotuConfig()'s config — missing '${key}'.`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const lagoonJson = loadLagoonJson(paths);
|
|
527
|
+
const react = await resolveBuildDep('@vitejs/plugin-react', paths.lagoonDir);
|
|
528
|
+
// The project's own lagoon dir when it owns its entries; a freshly rendered `.motu/cache/lagoon`
|
|
529
|
+
// when it does not. See lib/lagoon-materialize.mjs for why this is ownership rather than generation.
|
|
530
|
+
const root = resolveLagoonRoot(paths);
|
|
531
|
+
|
|
532
|
+
// MOTU_SINGLEFILE=lagoon|main builds ONE entry as ONE chunk so `motu lagoon publish` can inline the
|
|
533
|
+
// whole app into a single HTML file — nothing serves /assets/* behind a static artifact.
|
|
534
|
+
const singleFile = env.MOTU_SINGLEFILE === 'lagoon' ? 'lagoon' : env.MOTU_SINGLEFILE === 'main' ? 'main' : '';
|
|
535
|
+
// MOTU_CHUNKED=lagoon|main builds ONE entry as STABLY NAMED CHUNKS, for a host that stores them by
|
|
536
|
+
// content (`motu lagoon publish --remote`, `serve --live-push`). See `chunkOf` for why the names
|
|
537
|
+
// are stable and how they are cut.
|
|
538
|
+
const chunked = !singleFile && (env.MOTU_CHUNKED === 'lagoon' ? 'lagoon' : env.MOTU_CHUNKED === 'main' ? 'main' : '');
|
|
539
|
+
const oneEntry = singleFile || chunked;
|
|
540
|
+
|
|
541
|
+
// `writeIfChanged` rather than the adapter reaching for `writeFileSync`: an adapter that generates
|
|
542
|
+
// into `.motu/cache` is generating into a directory Vite WATCHES, and the write policy for that
|
|
543
|
+
// directory belongs to whoever owns it. See lib/write-if-changed.mjs — an identical write still
|
|
544
|
+
// reloads every open lagoon tab.
|
|
545
|
+
const ctx = { paths, lagoonJson, env, writeIfChanged, resolveBuildDep: (n) => resolveBuildDep(n, paths.lagoonDir) };
|
|
546
|
+
|
|
547
|
+
// Islands whose import graph cannot be bundled, stubbed rather than allowed to kill the build.
|
|
548
|
+
// MOTU_NO_EXCLUDE=1 restores the old all-or-nothing behaviour for anyone who needs to see the raw
|
|
549
|
+
// rollup failure.
|
|
550
|
+
const excluded = env.MOTU_NO_EXCLUDE === '1' ? [] : await unbundlableIslands({ aliased: Object.keys(lagoonJson.alias ?? {}) });
|
|
551
|
+
const excludedPlugin = excludeUnbundlable(excluded);
|
|
552
|
+
// ONE MARKED LINE PER ISLAND, because this config is often built in a CHILD PROCESS whose output is
|
|
553
|
+
// piped and surfaced only on FAILURE (`lagoon.mjs`'s `buildSingleFile`). A plain console.warn here
|
|
554
|
+
// was emitted, captured and discarded on every successful build — so the feature worked and said
|
|
555
|
+
// nothing, which is the exact failure it was written to prevent. The parent greps for this prefix.
|
|
556
|
+
for (const e of excluded) console.warn(`[motu:excluded] ${e.kebab} — ${e.reason}`);
|
|
557
|
+
|
|
558
|
+
// HOST PLUGINS THAT COULD NOT RUN HERE. Filled by the vite adapter's guard as the hooks execute, so
|
|
559
|
+
// it is read after the config is built — printed on the same marked channel as island exclusions
|
|
560
|
+
// because a silently dropped transform is the same class of lie: a build that quietly did less.
|
|
561
|
+
process.on('exit', () => {
|
|
562
|
+
for (const d of host.droppedHostPlugins ?? []) {
|
|
563
|
+
console.warn(`[motu:excluded] host vite plugin "${d.name}" (${d.hook}) — ${d.message}`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
const host = await hostContribution(ctx);
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
root,
|
|
570
|
+
// RELATIVE when chunked: the page and its chunks are served under an address only the server
|
|
571
|
+
// knows (`/__motu_assets/<manifest>/` on a host), and chunks find each other relative to
|
|
572
|
+
// themselves. The HTML's own references are rewritten by the CLI to a placeholder the server fills.
|
|
573
|
+
...(chunked ? { base: './' } : {}),
|
|
574
|
+
// The whole point: no vite.config.ts on disk in the project.
|
|
575
|
+
configFile: false,
|
|
576
|
+
// The host's React transform wins when it has one: two JSX transforms in one pipeline is a
|
|
577
|
+
// duplicated runtime, and it surfaces as hooks failing with nothing to point at.
|
|
578
|
+
plugins: [
|
|
579
|
+
...(host.ownsReactTransform ? [] : [react()]),
|
|
580
|
+
// Framework-level, not the adapter's: every host that aliases a module wants its calls visible,
|
|
581
|
+
// and one copy per adapter would be three copies of one decision.
|
|
582
|
+
...(env.MOTU_DEBUG === '0' ? [] : [motuProvenance(lagoonJson.alias, paths.lagoonDir)]),
|
|
583
|
+
...(excludedPlugin ? [excludedPlugin] : []),
|
|
584
|
+
motuDevDock(),
|
|
585
|
+
...(host.plugins ?? []),
|
|
586
|
+
],
|
|
587
|
+
// These defines are the contract with `motu island verify`, which sets the matching MOTU_* env
|
|
588
|
+
// when it boots this server. Dropping one silently weakens verification — keep them in sync.
|
|
589
|
+
define: {
|
|
590
|
+
__MOTU_TRANSPORT__: JSON.stringify(env.MOTU_TRANSPORT ?? ''),
|
|
591
|
+
__MOTU_TARGET__: JSON.stringify(env.MOTU_TARGET ?? ''),
|
|
592
|
+
__MOTU_FIT__: JSON.stringify(env.MOTU_FIT ?? ''),
|
|
593
|
+
__MOTU_FORCE_ERROR__: JSON.stringify(env.MOTU_FORCE_ERROR ?? ''),
|
|
594
|
+
// The lagoon is the sandbox — the overlay is present by default; MOTU_DEBUG=0 strips it.
|
|
595
|
+
__MOTU_DEBUG__: JSON.stringify(env.MOTU_DEBUG !== '0'),
|
|
596
|
+
__MOTU_ISOLATION__: JSON.stringify(paths.isolation),
|
|
597
|
+
// WHETHER A LEGACY FIT EXISTS TO SWITCH TO. Injected for the same reason isolation is: it is a
|
|
598
|
+
// fact about the PROJECT (its host), and the lagoon config cannot restate it without the two
|
|
599
|
+
// drifting. A React-only host has no legacy skin, so the fit chip there offers a choice with
|
|
600
|
+
// one option — `motu island verify` already says as much ("no legacy fit on host 'next' —
|
|
601
|
+
// nothing to fit to") while the dock went on showing the switch.
|
|
602
|
+
__MOTU_LEGACY_FIT__: JSON.stringify(Boolean(paths.legacyFit)),
|
|
603
|
+
// THE CORPUS, BAKED IN — what the region has actually been in, so the lens can say whether the
|
|
604
|
+
// state on screen is one production reaches.
|
|
605
|
+
//
|
|
606
|
+
// Read from FILES that `motu archipelago coverage --save` wrote, never fetched here. That is what
|
|
607
|
+
// keeps the promise a published lagoon has to keep: the page is reachable by anyone, so it may
|
|
608
|
+
// carry the rows and must not carry the address they came from or the credential that opened
|
|
609
|
+
// it. A build that fetched would put both one env var away from the bundle; a build that reads
|
|
610
|
+
// a sanitised file cannot leak what is not in the file.
|
|
611
|
+
__MOTU_CORPUS__: JSON.stringify(savedCorpora(paths)),
|
|
612
|
+
...(host.define ?? {}),
|
|
613
|
+
},
|
|
614
|
+
resolve: {
|
|
615
|
+
// One React, always. Two copies break hooks in any island rendering a host component.
|
|
616
|
+
dedupe: ['react', 'react-dom'],
|
|
617
|
+
// THE FRAMEWORK'S OWN ALIASES ARE NOT THE HOST'S TO SUPPLY.
|
|
618
|
+
//
|
|
619
|
+
// These used to come only from the host adapter's contribution, and both adapters that exist
|
|
620
|
+
// start by spreading `noInstallAliases`. Which meant the "no install step" promise held for
|
|
621
|
+
// exactly the two hosts that ship an adapter: with `host: "none"`, the lagoon booted with an
|
|
622
|
+
// empty alias list and died on `@motujs/react could not be resolved` — the very error the guard
|
|
623
|
+
// above warns about, from a different cause. A project's lagoon must not depend on motu having
|
|
624
|
+
// written an adapter for its framework.
|
|
625
|
+
// The host's tsconfig `paths` LAST, so anything the host's own vite config or an adapter
|
|
626
|
+
// declared still wins — this is a floor, not an override. See `hostAliasEntries`.
|
|
627
|
+
alias: [...noInstallAliases(paths), ...(host.alias ?? []), ...hostAliasEntries(), ...reactFallbackAliases(paths)],
|
|
628
|
+
// e.g. `tsconfigPaths: true` — often the ONLY thing mapping a Vite app's `@/…` imports.
|
|
629
|
+
...(host.resolveExtra ?? {}),
|
|
630
|
+
},
|
|
631
|
+
...(host.css ? { css: host.css } : {}),
|
|
632
|
+
build: {
|
|
633
|
+
...(singleFile ? { cssCodeSplit: false, assetsInlineLimit: 100_000_000 } : {}),
|
|
634
|
+
...(chunked ? { cssCodeSplit: false, modulePreload: { polyfill: false } } : {}),
|
|
635
|
+
rollupOptions: {
|
|
636
|
+
input: oneEntry
|
|
637
|
+
? { [oneEntry]: resolve(root, oneEntry === 'lagoon' ? 'lagoon.html' : 'index.html') }
|
|
638
|
+
: {
|
|
639
|
+
main: resolve(root, 'index.html'),
|
|
640
|
+
lagoon: resolve(root, 'lagoon.html'),
|
|
641
|
+
},
|
|
642
|
+
...(singleFile ? { output: { inlineDynamicImports: true } } : {}),
|
|
643
|
+
...(chunked
|
|
644
|
+
? {
|
|
645
|
+
output: {
|
|
646
|
+
// NO HASH IN A CHUNK'S NAME. Content addressing happens on the host, per file; a hash
|
|
647
|
+
// in the name would also be written into every IMPORTER of that chunk, so one edit
|
|
648
|
+
// would change the bytes of the whole chain above it and re-upload it. Stable names
|
|
649
|
+
// keep an edit in one region inside that region's chunk.
|
|
650
|
+
entryFileNames: 'assets/[name].js',
|
|
651
|
+
chunkFileNames: 'assets/[name].js',
|
|
652
|
+
// Assets import nothing, so hashing their names costs nothing and avoids collisions.
|
|
653
|
+
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
654
|
+
// Mangled export names shift when a chunk gains an export, rewriting every importer.
|
|
655
|
+
minifyInternalExports: false,
|
|
656
|
+
manualChunks: chunkOf(paths),
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
: {}),
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
server: {
|
|
663
|
+
port: 5173,
|
|
664
|
+
strictPort: false,
|
|
665
|
+
// With a generated root under .motu/cache, everything real — the project's islands, the host
|
|
666
|
+
// application's components, the motu checkout — is OUTSIDE it. Vite's default fs allow-list is
|
|
667
|
+
// inferred from the root, so say explicitly what may be served or the dev server 403s the very
|
|
668
|
+
// modules the lagoon exists to render.
|
|
669
|
+
// THE HOST'S DEPENDENCIES ARE NOT UNDER THE HOST. In a monorepo the app is a package and its
|
|
670
|
+
// node_modules is hoisted to the workspace root, so a stylesheet the app imports (`twenty-ui/
|
|
671
|
+
// style.css`, a @fontsource font file) resolves to a path OUTSIDE every root listed here and
|
|
672
|
+
// Vite refuses to serve it — which surfaces as a lagoon rendering the app's components with
|
|
673
|
+
// none of the app's theme. `searchForWorkspaceRoot` is Vite's own answer to the same question.
|
|
674
|
+
fs: {
|
|
675
|
+
allow: [
|
|
676
|
+
...new Set(
|
|
677
|
+
[
|
|
678
|
+
root,
|
|
679
|
+
paths.root,
|
|
680
|
+
paths.hostRoot,
|
|
681
|
+
paths.motuRoot,
|
|
682
|
+
workspaceRootOf(paths.hostRoot),
|
|
683
|
+
// A BASE CHECKOUT borrows the working tree's node_modules (`motu diff`), whose real
|
|
684
|
+
// path is outside it — so the tree they really live in has to be servable too.
|
|
685
|
+
...String(env.MOTU_FS_ALLOW ?? '').split(':'),
|
|
686
|
+
].filter(Boolean),
|
|
687
|
+
),
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
// WHO IS ALLOWED TO ASK. Vite 5.4 rejects a request whose Host header it does not recognise —
|
|
691
|
+
// DNS-rebinding protection, and correct for a dev server on a laptop. Behind a tunnel every
|
|
692
|
+
// request arrives with the PUBLIC hostname, so the lagoon answered 403 to the one audience it
|
|
693
|
+
// was exposed for, with nothing in the CLI to say so. `hosts` in lagoon.config.json names the
|
|
694
|
+
// hostnames a project expects; `true` (or `--allow-any-host`) accepts all of them, which is
|
|
695
|
+
// what a throwaway tunnel wants and what a shared machine should not have by default.
|
|
696
|
+
...(lagoonJson.hosts !== undefined
|
|
697
|
+
? { allowedHosts: lagoonJson.hosts }
|
|
698
|
+
: env.MOTU_ALLOW_ANY_HOST
|
|
699
|
+
? { allowedHosts: true }
|
|
700
|
+
: {}),
|
|
701
|
+
// WHAT THE LAGOON WATCHES, because on a large host it runs out of the machine's watches first.
|
|
702
|
+
//
|
|
703
|
+
// Vite watches its root and everything it serves — and the lagoon serves the WHOLE host
|
|
704
|
+
// application, so on a monorepo it registers an inotify watch per source file across every
|
|
705
|
+
// package. Measured on Formbricks: the dev server booted, then died with
|
|
706
|
+
// `ENOSPC: System limit for number of file watchers reached` at the default 65536. It blocked
|
|
707
|
+
// work in three separate bench rounds and an experiment, was read as a machine problem each
|
|
708
|
+
// time, and is why `ENOSPC` had to be taught to the exit-2 classifier.
|
|
709
|
+
//
|
|
710
|
+
// Two mitigations, because the ignore list alone was not enough on a host whose own source tree
|
|
711
|
+
// exceeds the budget:
|
|
712
|
+
// - ignore what the lagoon never re-renders for;
|
|
713
|
+
// - `MOTU_WATCH_POLL=1` switches to POLLING, which uses no inotify watches at all. Slower and
|
|
714
|
+
// hungrier for CPU, and the difference between a lagoon that runs on a big repository and
|
|
715
|
+
// one that cannot start. A machine where the limit can be raised should raise it instead:
|
|
716
|
+
// `sudo sysctl -w fs.inotify.max_user_watches=524288`.
|
|
717
|
+
watch: {
|
|
718
|
+
ignored: [
|
|
719
|
+
'**/node_modules/**',
|
|
720
|
+
'**/.git/**',
|
|
721
|
+
'**/dist/**',
|
|
722
|
+
'**/.next/**',
|
|
723
|
+
'**/.turbo/**',
|
|
724
|
+
'**/coverage/**',
|
|
725
|
+
'**/target/**',
|
|
726
|
+
...(host.server?.watch?.ignored ?? []),
|
|
727
|
+
],
|
|
728
|
+
...(env.MOTU_WATCH_POLL === '1' ? { usePolling: true, interval: 400 } : {}),
|
|
729
|
+
...(host.server?.watch ?? {}),
|
|
730
|
+
},
|
|
731
|
+
...(host.server ? Object.fromEntries(Object.entries(host.server).filter(([k]) => k !== 'watch')) : {}),
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The corpora `motu archipelago coverage <id> --save` has written, keyed by region.
|
|
738
|
+
*
|
|
739
|
+
* Nothing here is required: a project with no coverage has no directory, and the lens simply has
|
|
740
|
+
* nothing to compare against. Unreadable or malformed files are SKIPPED rather than fatal — a preview
|
|
741
|
+
* that will not boot because a coverage artifact is stale is a worse failure than one that shows less.
|
|
742
|
+
*/
|
|
743
|
+
function savedCorpora(paths) {
|
|
744
|
+
const dir = resolve(paths.lagoonDir, 'src/coverage');
|
|
745
|
+
if (!existsSync(dir)) return {};
|
|
746
|
+
const out = {};
|
|
747
|
+
for (const name of readdirSync(dir)) {
|
|
748
|
+
if (!name.endsWith('.json')) continue;
|
|
749
|
+
try {
|
|
750
|
+
const corpus = JSON.parse(readFileSync(resolve(dir, name), 'utf8'));
|
|
751
|
+
if (corpus?.regionId && Array.isArray(corpus.entries)) out[corpus.regionId] = corpus;
|
|
752
|
+
} catch {
|
|
753
|
+
// skipped on purpose; see above
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* THE DOCK, IN THE DEV LOOP.
|
|
761
|
+
*
|
|
762
|
+
* `motu lagoon serve` injects it (`injectDock`) and the motu host injects it around a published
|
|
763
|
+
* artifact — so the dock was present everywhere a lagoon is LOOKED at, and absent from the one place
|
|
764
|
+
* it is WORKED in. The documentation said `lagoon dev` drew it; it never did, which is a claim that
|
|
765
|
+
* survived because nobody checks for a control they are not currently missing.
|
|
766
|
+
*
|
|
767
|
+
* That gap is what makes a view addressable-but-invisible: the region/mountpoints/page control lives
|
|
768
|
+
* in the dock, so a region declaring a `page` had no way to be switched to except by editing the URL.
|
|
769
|
+
*
|
|
770
|
+
* Injected into the dev server's HTML rather than bundled, exactly as the other two do it — change
|
|
771
|
+
* the dock and nothing needs rebuilding.
|
|
772
|
+
*/
|
|
773
|
+
function motuDevDock() {
|
|
774
|
+
return {
|
|
775
|
+
name: 'motu:dev-dock',
|
|
776
|
+
// THE DEV SERVER ONLY. `transformIndexHtml` also runs in `vite build`, so without this every BUILT
|
|
777
|
+
// artifact carried a dock already — and `lagoon serve` and the host then injected theirs on top.
|
|
778
|
+
// Two docks, both marked hosted, stacked on the same edge: a gallery with two identical "profile"
|
|
779
|
+
// tabs, one of which swallows the other's clicks.
|
|
780
|
+
apply: 'serve',
|
|
781
|
+
// `post` so the app's own transforms have finished. The dock appends to the document and reads
|
|
782
|
+
// the page's published control surface at runtime, so it does not care what else is in there.
|
|
783
|
+
transformIndexHtml: {
|
|
784
|
+
order: 'post',
|
|
785
|
+
handler: (html) => injectDock(html),
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
}
|