@ultimat3/cli 23.0.0 → 25.0.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/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/island-bundle.ts
CHANGED
|
@@ -15,26 +15,32 @@
|
|
|
15
15
|
// `globalThis` object created lazily by the first transport call or realtime hook. Measured before
|
|
16
16
|
// that decision on `examples/dummy`, a core-importing wrapper cost contact-sales 875 → 8,827 B.
|
|
17
17
|
// What IS prepended is the realtime install, and only where the island's graph reaches
|
|
18
|
-
// `@ultimat3/realtime` (`island-realtime.ts`)
|
|
19
|
-
//
|
|
18
|
+
// `@ultimat3/realtime` (`island-realtime.ts`); the page runtime it awaits is ONE more output,
|
|
19
|
+
// `page-runtime.<identity>.js` beside the entries (`island-runtime.ts`), never inlined into one (#505).
|
|
20
20
|
|
|
21
21
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
22
22
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
23
23
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
24
|
-
import { ISLAND_EXTENSION,
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
24
|
+
import { ISLAND_EXTENSION, islandModuleId } from '@ultimat3/render';
|
|
25
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
26
|
import { IslandBuildFailedError } from './errors';
|
|
27
|
+
import { refuseDuplicatedModules } from './island-duplicate-refusal';
|
|
28
|
+
import { entryMissing, onlyMissing } from './island-entry-missing';
|
|
28
29
|
import { describeBuildError, sourcesContentOf, stableChunk, stripDebugId } from './island-identity';
|
|
29
30
|
import type { BuiltOutput, LinkedFile } from './island-link';
|
|
30
31
|
import { linkOutputs, sharedChunkName } from './island-link';
|
|
32
|
+
import { frameworkDedupePlugins } from './island-package-dedupe';
|
|
31
33
|
import {
|
|
32
34
|
islandRealtimePlugin,
|
|
33
35
|
REALTIME_ISLAND_ENTRY,
|
|
34
36
|
reachesRealtime,
|
|
35
37
|
realtimeIslandEntry,
|
|
36
38
|
} from './island-realtime';
|
|
39
|
+
import type { Runtimes } from './island-runtime';
|
|
40
|
+
import { loadingRuntime, mergeBuilds, runtimeBuilder, runtimeShared } from './island-runtime';
|
|
37
41
|
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
42
|
+
import type { SourcePaths } from './island-sources';
|
|
43
|
+
import { sourcesOnDisk } from './island-sources';
|
|
38
44
|
import { islandStylesPlugin } from './island-styles';
|
|
39
45
|
import { hasPathSegment } from './path-segments';
|
|
40
46
|
import { solidJsxPlugin } from './solid-loader';
|
|
@@ -71,6 +77,8 @@ export interface IslandChunk {
|
|
|
71
77
|
* beyond `bytes`, what a precached page must carry with it. Empty for an island sharing nothing.
|
|
72
78
|
*/
|
|
73
79
|
readonly imports: readonly string[];
|
|
80
|
+
/** The files on disk it was built from (`island-sources.ts`). Absent on a chunk not built here. */
|
|
81
|
+
readonly sources?: SourcePaths;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** A module two or more islands import, served once beside them. */
|
|
@@ -81,6 +89,7 @@ export interface SharedChunk {
|
|
|
81
89
|
readonly bytes: number;
|
|
82
90
|
/** The islands (app-root-relative) that load it — for a finding that names what an author edits. */
|
|
83
91
|
readonly importers: readonly string[];
|
|
92
|
+
readonly sources?: SourcePaths;
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
export interface IslandBundle {
|
|
@@ -120,6 +129,7 @@ async function buildAll(
|
|
|
120
129
|
root: string,
|
|
121
130
|
files: readonly string[],
|
|
122
131
|
splitting: boolean,
|
|
132
|
+
runtimes: Runtimes,
|
|
123
133
|
): Promise<{ readonly chunks: readonly IslandChunk[]; readonly shared: readonly SharedChunk[] }> {
|
|
124
134
|
if (files.length === 0) return { chunks: [], shared: [] };
|
|
125
135
|
// Inside the refusal: the realtime probe PARSES the island's graph, and a file that will not
|
|
@@ -135,6 +145,7 @@ async function buildAll(
|
|
|
135
145
|
// Only an island whose own graph reaches `@ultimat3/realtime` is wrapped (`island-realtime.ts`);
|
|
136
146
|
// every other one is built from its own file, byte for byte what it was.
|
|
137
147
|
const live = files.filter((_, index) => realtime[index] === true);
|
|
148
|
+
const runtime = live[0] === undefined ? undefined : await runtimes(live[0]);
|
|
138
149
|
const entrypoints = files.map((file, index) =>
|
|
139
150
|
realtime[index] === true ? realtimeIslandEntry(file) : join(root, file),
|
|
140
151
|
);
|
|
@@ -162,10 +173,13 @@ async function buildAll(
|
|
|
162
173
|
//
|
|
163
174
|
// The dedupe goes FIRST: it answers `solid-js` specifiers before either plugin loads a file,
|
|
164
175
|
// so the `solid-js/web` helpers the JSX transform writes into a symlinked package resolve
|
|
165
|
-
// to the app's one copy. See `island-solid-dedupe.ts` for the measurement.
|
|
176
|
+
// to the app's one copy. See `island-solid-dedupe.ts` for the measurement. `@ultimat3/*`
|
|
177
|
+
// gets the same rule (`island-package-dedupe.ts`): a nested copy is a second module where no
|
|
178
|
+
// symlink folds it, which is every `file:` install on Windows.
|
|
166
179
|
plugins: [
|
|
167
|
-
...(
|
|
180
|
+
...(runtime === undefined ? [] : [islandRealtimePlugin(root, runtime)]),
|
|
168
181
|
solidDedupePlugin(root),
|
|
182
|
+
...frameworkDedupePlugins(root),
|
|
169
183
|
solidJsxPlugin,
|
|
170
184
|
islandStylesPlugin,
|
|
171
185
|
],
|
|
@@ -188,9 +202,13 @@ async function buildAll(
|
|
|
188
202
|
// output has. See `graphHash`. Measured on 1.4.0 against a 131 kB island: 277ms with it and
|
|
189
203
|
// 276ms without, so the map costs nothing worth naming.
|
|
190
204
|
sourcemap: 'external',
|
|
205
|
+
// The fifth: which inputs each output carries, so one module bundled twice is refused below
|
|
206
|
+
// rather than shipped (`island-duplicates.ts`). Measured on 1.4.2 against examples/dummy's
|
|
207
|
+
// feed island, five interleaved builds each way: 44 ms without, 48 ms with, noise-level.
|
|
208
|
+
metafile: true,
|
|
191
209
|
});
|
|
192
210
|
} catch (error) {
|
|
193
|
-
throw await blame(root, files, error, splitting);
|
|
211
|
+
throw await blame(root, files, error, splitting, runtimes);
|
|
194
212
|
}
|
|
195
213
|
if (!built.success) {
|
|
196
214
|
throw new IslandBuildFailedError({
|
|
@@ -198,9 +216,18 @@ async function buildAll(
|
|
|
198
216
|
logs: built.logs.map((log) => String(log)).join('; '),
|
|
199
217
|
});
|
|
200
218
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
219
|
+
if (built.metafile === undefined) {
|
|
220
|
+
throw new IslandBuildFailedError({
|
|
221
|
+
file: files.join(', '),
|
|
222
|
+
logs: 'the bundler returned no metafile',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
await refuseDuplicatedModules(files.join(', '), built.metafile);
|
|
226
|
+
const sources = new Map<string, SourcePaths>();
|
|
227
|
+
const linked = linkOutputs(await builtOutputs(root, files, built.outputs, sources));
|
|
228
|
+
const chunks = loadingRuntime(entryChunks(files, linked, sources), live, runtime);
|
|
229
|
+
const shared = sharedChunks(chunks, linked, sources);
|
|
230
|
+
return { chunks, shared: [...shared, ...runtimeShared(runtime, live)] };
|
|
204
231
|
}
|
|
205
232
|
|
|
206
233
|
/**
|
|
@@ -213,11 +240,12 @@ async function blame(
|
|
|
213
240
|
files: readonly string[],
|
|
214
241
|
error: unknown,
|
|
215
242
|
splitting: boolean,
|
|
243
|
+
runtimes: Runtimes,
|
|
216
244
|
): Promise<IslandBuildFailedError> {
|
|
217
245
|
if (files.length > 1) {
|
|
218
246
|
for (const file of files) {
|
|
219
247
|
try {
|
|
220
|
-
await buildAll(root, [file], splitting);
|
|
248
|
+
await buildAll(root, [file], splitting, runtimes);
|
|
221
249
|
} catch (alone) {
|
|
222
250
|
if (alone instanceof IslandBuildFailedError) return alone;
|
|
223
251
|
}
|
|
@@ -242,8 +270,10 @@ const withoutExtension = (file: string): string =>
|
|
|
242
270
|
|
|
243
271
|
/** Every code output paired with its source map's inputs and, for an entry, its island. */
|
|
244
272
|
async function builtOutputs(
|
|
273
|
+
root: string,
|
|
245
274
|
files: readonly string[],
|
|
246
275
|
artifacts: readonly Bun.BuildArtifact[],
|
|
276
|
+
sourcePaths: Map<string, SourcePaths>,
|
|
247
277
|
): Promise<readonly BuiltOutput[]> {
|
|
248
278
|
const byStem = new Map(files.map((file) => [withoutExtension(file), file]));
|
|
249
279
|
const maps = new Map<string, string>();
|
|
@@ -256,7 +286,9 @@ async function builtOutputs(
|
|
|
256
286
|
const key = outputKey(artifact.path);
|
|
257
287
|
const file = artifact.kind === 'entry-point' ? byStem.get(withoutExtension(key)) : undefined;
|
|
258
288
|
const map = maps.get(`${artifact.path}.map`);
|
|
259
|
-
const
|
|
289
|
+
const parsed: unknown = map === undefined ? undefined : JSON.parse(map);
|
|
290
|
+
const sources = parsed === undefined ? undefined : sourcesContentOf(parsed, true);
|
|
291
|
+
sourcePaths.set(key, await sourcesOnDisk(root, parsed));
|
|
260
292
|
if (sources === undefined || (artifact.kind === 'entry-point' && file === undefined)) {
|
|
261
293
|
throw new IslandBuildFailedError({
|
|
262
294
|
file: file ?? key,
|
|
@@ -277,6 +309,7 @@ async function builtOutputs(
|
|
|
277
309
|
function entryChunks(
|
|
278
310
|
files: readonly string[],
|
|
279
311
|
linked: readonly LinkedFile[],
|
|
312
|
+
sources: ReadonlyMap<string, SourcePaths>,
|
|
280
313
|
): readonly IslandChunk[] {
|
|
281
314
|
const shared = new Map(
|
|
282
315
|
linked
|
|
@@ -312,6 +345,7 @@ function entryChunks(
|
|
|
312
345
|
// different files at one address. `bytes` is measured on THAT code, never on this build's.
|
|
313
346
|
...stableChunk(file, entry.identity, entry.code),
|
|
314
347
|
imports: closure(entry.imports),
|
|
348
|
+
sources: sources.get(entry.path) ?? [],
|
|
315
349
|
});
|
|
316
350
|
}
|
|
317
351
|
return chunks;
|
|
@@ -321,6 +355,7 @@ function entryChunks(
|
|
|
321
355
|
function sharedChunks(
|
|
322
356
|
chunks: readonly IslandChunk[],
|
|
323
357
|
linked: readonly LinkedFile[],
|
|
358
|
+
sources: ReadonlyMap<string, SourcePaths>,
|
|
324
359
|
): readonly SharedChunk[] {
|
|
325
360
|
const out: SharedChunk[] = [];
|
|
326
361
|
for (const one of linked) {
|
|
@@ -333,6 +368,7 @@ function sharedChunks(
|
|
|
333
368
|
.filter((chunk) => chunk.imports.includes(url))
|
|
334
369
|
.map((chunk) => chunk.file)
|
|
335
370
|
.sort(),
|
|
371
|
+
sources: sources.get(one.path) ?? [],
|
|
336
372
|
});
|
|
337
373
|
}
|
|
338
374
|
return out.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
@@ -357,20 +393,9 @@ export interface BuildIslandsOptions {
|
|
|
357
393
|
readonly sharedChunks?: boolean;
|
|
358
394
|
}
|
|
359
395
|
|
|
360
|
-
/**
|
|
361
|
-
* `islands.sharedChunks` as `defineConfig` validated it — `false` for an app with no config file or
|
|
362
|
-
* none of it. Structural, never `instanceof`, for `loadNavigation`'s reason.
|
|
363
|
-
*/
|
|
396
|
+
/** `islands.sharedChunks` off the one loader (`app-config-load.ts`) — `false` with no config file. */
|
|
364
397
|
export async function loadSharedChunks(root: string): Promise<boolean> {
|
|
365
|
-
|
|
366
|
-
if (!(await Bun.file(configPath).exists())) return false;
|
|
367
|
-
const config: unknown = ((await import(configPath)) as Record<string, unknown>)[
|
|
368
|
-
APP_CONFIG_EXPORT
|
|
369
|
-
];
|
|
370
|
-
if (typeof config !== 'object' || config === null) return false;
|
|
371
|
-
const islands: unknown = (config as Record<string, unknown>)['islands'];
|
|
372
|
-
if (typeof islands !== 'object' || islands === null) return false;
|
|
373
|
-
return (islands as Record<string, unknown>)['sharedChunks'] === true;
|
|
398
|
+
return (await loadAppConfig(root))?.islands.sharedChunks ?? false;
|
|
374
399
|
}
|
|
375
400
|
|
|
376
401
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
|
@@ -385,43 +410,15 @@ export async function buildIslands(
|
|
|
385
410
|
// an empty bundle here would surface two steps later, as a chunk table with no entry for a file
|
|
386
411
|
// the caller can see on disk.
|
|
387
412
|
if (only !== undefined && files.length === 0) throw onlyMissing(only, discovered);
|
|
413
|
+
const runtimes = runtimeBuilder(root, ISLAND_BASE_PATH);
|
|
388
414
|
if (options.sharedChunks ?? (await loadSharedChunks(root))) {
|
|
389
|
-
const built = await buildAll(root, files, true);
|
|
415
|
+
const built = await buildAll(root, files, true, runtimes);
|
|
390
416
|
return islandBundle(built.chunks, built.shared);
|
|
391
417
|
}
|
|
392
|
-
// One build per island, splitting off: each chunk is self-contained
|
|
393
|
-
//
|
|
394
|
-
const solo = await Promise.all(files.map((file) => buildAll(root, [file], false)));
|
|
395
|
-
return islandBundle(solo
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Same code as an unbuildable `src`: "this path cannot become a client entry" is one condition.
|
|
400
|
-
*
|
|
401
|
-
* Two fixes, because there are two causes and only one of them can be repaired by naming a path.
|
|
402
|
-
* The line was `pass only: '<app-root-relative path>.island.tsx'` — a placeholder nobody can run,
|
|
403
|
-
* which no gate could see: `fixProblem` fails a fix only for ADVICE with no command token, and a
|
|
404
|
-
* sentence with neither is not advice. Both forms below are constructed from what the caller
|
|
405
|
-
* already handed in, so neither can name a path this app does not have.
|
|
406
|
-
*/
|
|
407
|
-
function onlyMissing(only: string, discovered: readonly string[]): IslandInvalidError {
|
|
408
|
-
const cause =
|
|
409
|
-
`buildIslands was asked for ${JSON.stringify(only)} alone, which is not one of the ` +
|
|
410
|
-
`${discovered.length} islands this app has (${discovered.length === 0 ? 'none' : discovered.join(', ')})`;
|
|
411
|
-
// The basename match first: a filter that misses normally missed on the PREFIX — a route-relative
|
|
412
|
-
// specifier where `discoverIslands`' app-root-relative path was wanted — and the filename
|
|
413
|
-
// survives that. Falling back to the first keeps the fix a real path rather than a shape.
|
|
414
|
-
const nearest =
|
|
415
|
-
discovered.find((file) => posix.basename(file) === posix.basename(only)) ?? discovered[0];
|
|
416
|
-
// An app with no islands cannot be pointed at one, so the fix WRITES the file that was asked
|
|
417
|
-
// for — the same command `entryMissing` hands back, split off the same path.
|
|
418
|
-
if (nearest === undefined) {
|
|
419
|
-
return new IslandInvalidError(
|
|
420
|
-
cause,
|
|
421
|
-
`x g island ${posix.basename(only, ISLAND_EXTENSION)} --at ${posix.dirname(only)}`,
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
return new IslandInvalidError(cause, `buildIslands(root, { only: '${nearest}' })`);
|
|
418
|
+
// One build per island, splitting off: each chunk is self-contained but for the page runtime,
|
|
419
|
+
// the ONE file beside it every realtime island loads, counted once however many load it.
|
|
420
|
+
const solo = await Promise.all(files.map((file) => buildAll(root, [file], false, runtimes)));
|
|
421
|
+
return islandBundle(...mergeBuilds(solo));
|
|
425
422
|
}
|
|
426
423
|
|
|
427
424
|
export function islandBundle(
|
|
@@ -449,25 +446,6 @@ export function islandBundle(
|
|
|
449
446
|
};
|
|
450
447
|
}
|
|
451
448
|
|
|
452
|
-
/**
|
|
453
|
-
* The specifier named no file the build could bundle. `X_ISLAND_INVALID` is render's and is
|
|
454
|
-
* borrowed rather than renamed here: "this src cannot become a client entry" is the condition that
|
|
455
|
-
* code already means, and a second name for it is a second thing to look up.
|
|
456
|
-
*/
|
|
457
|
-
function entryMissing(
|
|
458
|
-
routeFile: string,
|
|
459
|
-
src: string,
|
|
460
|
-
target: string,
|
|
461
|
-
chunks: readonly IslandChunk[],
|
|
462
|
-
): IslandInvalidError {
|
|
463
|
-
const known = chunks.map((chunk) => chunk.file);
|
|
464
|
-
return new IslandInvalidError(
|
|
465
|
-
`${routeFile} declares island src ${JSON.stringify(src)}, which resolves to ${target} — a ` +
|
|
466
|
-
`file this build did not bundle (${known.length === 0 ? 'it found no islands at all' : `it found ${known.join(', ')}`})`,
|
|
467
|
-
`x g island ${posix.basename(target, ISLAND_EXTENSION)} --at ${posix.dirname(target)}`,
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
449
|
/** Write every chunk under the static export, at the same URL the documents already carry. */
|
|
472
450
|
export async function writeIslands(bundle: IslandBundle, out: string): Promise<void> {
|
|
473
451
|
for (const chunk of [...bundle.chunks, ...bundle.shared]) {
|
package/src/island-capture.ts
CHANGED
|
@@ -265,7 +265,7 @@ export async function captureIslandState(
|
|
|
265
265
|
state: target.state,
|
|
266
266
|
theme: target.theme,
|
|
267
267
|
reason: `produced ${bytes.byteLength} bytes, under the ${floor}-byte floor — that is not an image`,
|
|
268
|
-
fix: `x shot --island ${target.name} --browser /usr/bin/chromium --json`,
|
|
268
|
+
fix: `x shot --island ${target.name} --browser /usr/bin/chromium --json # macOS: --browser "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows: --browser "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"`,
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
await Bun.write(join(options.outDir, target.file), bytes);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// What a module bundled twice costs the build. A framework bug refuses it: one file under two
|
|
2
|
+
// spellings, or a second `@ultimat3/*` copy, is the framework's resolver or dedupe failing. A
|
|
3
|
+
// third-party package installed twice with identical bytes is the app's lockfile, which the app
|
|
4
|
+
// did not write by hand — so it is a warning naming the bytes, never a broken build.
|
|
5
|
+
|
|
6
|
+
import { logger, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { DuplicatedModule, IslandMetafile, ModuleIdentityIo } from './island-duplicates';
|
|
8
|
+
import { duplicatedModules, hostIo } from './island-duplicates';
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
10
|
+
|
|
11
|
+
/** The framework's own scope: a second copy of one of these is ours to fold (`island-package-dedupe.ts`). */
|
|
12
|
+
const FRAMEWORK_SCOPE = '@ultimat3/';
|
|
13
|
+
|
|
14
|
+
/** A duplicate only the framework can have caused. */
|
|
15
|
+
export const isFrameworkDuplicate = (one: DuplicatedModule): boolean =>
|
|
16
|
+
one.sameFile || (one.packageName?.startsWith(FRAMEWORK_SCOPE) ?? false);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Its own code, not `X_BUILD_FAILED`: that one's fix re-runs `bun build`, which SUCCEEDS here — the
|
|
20
|
+
* build did not fail, it shipped one module twice and said `success: true`. Every refusal is a
|
|
21
|
+
* framework defect, so the fix is the report that gets it fixed.
|
|
22
|
+
*/
|
|
23
|
+
export class IslandModuleDuplicatedError extends UltimateError {
|
|
24
|
+
constructor(island: string, found: readonly DuplicatedModule[]) {
|
|
25
|
+
const wasted = found.reduce((sum, one) => sum + one.wastedBytes, 0);
|
|
26
|
+
const listed = found
|
|
27
|
+
.slice(0, 3)
|
|
28
|
+
.map((one) => `${one.package ?? 'one file'} as ${one.spellings.join(' and ')}`)
|
|
29
|
+
.join('; ');
|
|
30
|
+
super({
|
|
31
|
+
code: 'X_ISLAND_MODULE_DUPLICATED',
|
|
32
|
+
cause:
|
|
33
|
+
`${island} bundles ${found.length} module(s) more than once in ${found[0]?.output ?? 'its chunk'} ` +
|
|
34
|
+
`(${wasted} B shipped twice): ${listed}${found.length > 3 ? `; and ${found.length - 3} more` : ''}`,
|
|
35
|
+
fix: `gh issue create --repo developerz-ai/ultimate --title ${quoteArg(`X_ISLAND_MODULE_DUPLICATED in ${island}`)} — paste this error's --json as the body: the framework bundled one of its own modules twice`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The one line a third-party copy costs: the island, the package, the bytes, and what to run. */
|
|
41
|
+
export const thirdPartyWarning = (island: string, one: DuplicatedModule): string =>
|
|
42
|
+
`X_ISLAND_MODULE_DUPLICATED (warning): ${island} bundles ${one.package ?? 'a package'} twice ` +
|
|
43
|
+
`(${one.wastedBytes} B shipped twice, as ${one.spellings.join(' and ')}) — fix: bun why ` +
|
|
44
|
+
`${quoteArg(one.packageName ?? '<package>')}, then bun install --force to fold the copies`;
|
|
45
|
+
|
|
46
|
+
/** Refuse a framework duplicate; warn about each third-party one. */
|
|
47
|
+
export async function refuseDuplicatedModules(
|
|
48
|
+
island: string,
|
|
49
|
+
metafile: IslandMetafile,
|
|
50
|
+
io: ModuleIdentityIo = hostIo(),
|
|
51
|
+
warn: (line: string) => void = (line) => logger.warn(line),
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const found = await duplicatedModules(metafile, io);
|
|
54
|
+
const fatal = found.filter(isFrameworkDuplicate);
|
|
55
|
+
if (fatal.length > 0) throw new IslandModuleDuplicatedError(island, fatal);
|
|
56
|
+
for (const one of found) warn(thirdPartyWarning(island, one));
|
|
57
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// One module, one copy per island chunk — read off the build's metafile. Bun keys a module by its
|
|
2
|
+
// path STRING, so one file reached under two spellings ships twice: a drive letter in two cases, `\`
|
|
3
|
+
// beside `/`, a junction beside its target. Measured on the windows job, the scaffold's `/posts`
|
|
4
|
+
// island shipped 81,306 B against Linux's 59,849 B from the same source, with `success: true`.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API and no realpath; `win32`/`posix` let a Windows-shaped metafile be
|
|
7
|
+
// judged on any host, and `realpathSync` folds a link into the file it names.
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { posix, win32 } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** The part of `Bun.BuildMetafile` the guard reads — `Bun.BuildMetafile` satisfies it as is. */
|
|
12
|
+
export interface IslandMetafile {
|
|
13
|
+
readonly outputs: Readonly<
|
|
14
|
+
Record<
|
|
15
|
+
string,
|
|
16
|
+
{ readonly inputs: Readonly<Record<string, { readonly bytesInOutput: number }>> }
|
|
17
|
+
>
|
|
18
|
+
>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The filesystem the guard asks, injectable so a Windows tree can be judged on a Linux runner. */
|
|
22
|
+
export interface ModuleIdentityIo {
|
|
23
|
+
/** What Bun's metafile keys are relative to: the BUILDING process's cwd (`island-sources.ts`). */
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
/** win32 compares case-insensitively and treats `\` and `/` as one separator. */
|
|
26
|
+
readonly platform: NodeJS.Platform;
|
|
27
|
+
/** Throws for a path with no file behind it — a virtual module keeps its own spelling. */
|
|
28
|
+
readonly realpath: (path: string) => string;
|
|
29
|
+
/** A file's text, or `undefined` when there is none. */
|
|
30
|
+
readonly readText: (path: string) => Promise<string | undefined>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const hostIo = (): ModuleIdentityIo => ({
|
|
34
|
+
cwd: process.cwd(),
|
|
35
|
+
platform: process.platform,
|
|
36
|
+
realpath: (path) => realpathSync(path),
|
|
37
|
+
readText: async (path) => {
|
|
38
|
+
const file = Bun.file(path);
|
|
39
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `path` as `island-package-dedupe.ts` spells every framework file it answers: the realpath. What a
|
|
45
|
+
* resolver answered under another spelling (a Windows junction) is renamed through this before a
|
|
46
|
+
* plugin hands it to `Bun.build`, so one file is one module string. A path with no file stays.
|
|
47
|
+
*/
|
|
48
|
+
export function realSpelling(path: string): string {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One module an output carries more than once. */
|
|
57
|
+
export interface DuplicatedModule {
|
|
58
|
+
/** The output path as the metafile names it. */
|
|
59
|
+
readonly output: string;
|
|
60
|
+
/** Every spelling it was bundled under, absolute, in the metafile's order. */
|
|
61
|
+
readonly spellings: readonly string[];
|
|
62
|
+
/** Bytes the output carries beyond its largest copy. */
|
|
63
|
+
readonly wastedBytes: number;
|
|
64
|
+
/** One file under several spellings (`true`), or byte-identical copies of one package (`false`). */
|
|
65
|
+
readonly sameFile: boolean;
|
|
66
|
+
/** `name@version` when the copies are a package's. */
|
|
67
|
+
readonly package?: string;
|
|
68
|
+
/** The package's `name` alone — what `bun why` is asked about. */
|
|
69
|
+
readonly packageName?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Input {
|
|
73
|
+
readonly spelling: string;
|
|
74
|
+
readonly bytes: number;
|
|
75
|
+
readonly canonical: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PackageOf {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly subpath: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The realpath, `/`-separated, lowercased where the filesystem is case-insensitive. */
|
|
85
|
+
function canonicalOf(absolute: string, io: ModuleIdentityIo): string {
|
|
86
|
+
let real = absolute;
|
|
87
|
+
try {
|
|
88
|
+
real = io.realpath(absolute);
|
|
89
|
+
} catch {
|
|
90
|
+
// No file behind it: a virtual module, judged by its spelling alone.
|
|
91
|
+
}
|
|
92
|
+
const slashed = real.replace(/^\\\\\?\\/, '').replaceAll('\\', '/');
|
|
93
|
+
return io.platform === 'win32' ? slashed.toLowerCase() : slashed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The nearest `package.json` with a `name` above `file`, as `name@version` + the file under it. */
|
|
97
|
+
async function packageOf(
|
|
98
|
+
file: string,
|
|
99
|
+
io: ModuleIdentityIo,
|
|
100
|
+
manifests: Map<string, Promise<{ name: string; version: string } | null>>,
|
|
101
|
+
): Promise<PackageOf | undefined> {
|
|
102
|
+
const path = io.platform === 'win32' ? win32 : posix;
|
|
103
|
+
for (let dir = path.dirname(file); ; dir = path.dirname(dir)) {
|
|
104
|
+
let pending = manifests.get(dir);
|
|
105
|
+
if (pending === undefined) {
|
|
106
|
+
pending = io.readText(path.join(dir, 'package.json')).then(namedManifest);
|
|
107
|
+
manifests.set(dir, pending);
|
|
108
|
+
}
|
|
109
|
+
const found = await pending;
|
|
110
|
+
if (found !== null) {
|
|
111
|
+
const subpath = path.relative(dir, file).replaceAll('\\', '/');
|
|
112
|
+
const id = `${found.name}@${found.version}`;
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
name: found.name,
|
|
116
|
+
subpath: io.platform === 'win32' ? subpath.toLowerCase() : subpath,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (path.dirname(dir) === dir) return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function namedManifest(text: string | undefined): { name: string; version: string } | null {
|
|
124
|
+
if (text === undefined) return null;
|
|
125
|
+
try {
|
|
126
|
+
const parsed: unknown = JSON.parse(text);
|
|
127
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
128
|
+
const { name, version } = parsed as Record<string, unknown>;
|
|
129
|
+
return typeof name === 'string'
|
|
130
|
+
? { name, version: typeof version === 'string' ? version : '' }
|
|
131
|
+
: null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every module an output carries twice: first by canonical path, then — between inputs that are
|
|
139
|
+
* still distinct — by package. Two copies count as one module only when they are the same file of
|
|
140
|
+
* the same `name@version` AND hold the same bytes, so a patched or differently-versioned copy is
|
|
141
|
+
* left alone. Contents are read only for a package collision, never for every input.
|
|
142
|
+
*/
|
|
143
|
+
export async function duplicatedModules(
|
|
144
|
+
metafile: IslandMetafile,
|
|
145
|
+
io: ModuleIdentityIo,
|
|
146
|
+
): Promise<readonly DuplicatedModule[]> {
|
|
147
|
+
const path = io.platform === 'win32' ? win32 : posix;
|
|
148
|
+
const manifests = new Map<string, Promise<{ name: string; version: string } | null>>();
|
|
149
|
+
const found: DuplicatedModule[] = [];
|
|
150
|
+
for (const [output, { inputs }] of Object.entries(metafile.outputs)) {
|
|
151
|
+
const byPath = new Map<string, Input[]>();
|
|
152
|
+
for (const [key, { bytesInOutput }] of Object.entries(inputs)) {
|
|
153
|
+
// A namespaced key (`x-realtime-island:…`) is a virtual module: no file, no second spelling.
|
|
154
|
+
if (/^[a-z][\w-]+:(?![\\/])/i.test(key)) continue;
|
|
155
|
+
const spelling = path.resolve(io.cwd, key);
|
|
156
|
+
const canonical = canonicalOf(spelling, io);
|
|
157
|
+
const group = byPath.get(canonical) ?? [];
|
|
158
|
+
group.push({ spelling, bytes: bytesInOutput, canonical });
|
|
159
|
+
byPath.set(canonical, group);
|
|
160
|
+
}
|
|
161
|
+
for (const group of byPath.values()) {
|
|
162
|
+
if (group.length > 1) found.push(duplicate(output, group, true));
|
|
163
|
+
}
|
|
164
|
+
found.push(...(await packageCopies(output, [...byPath.values()], io, manifests)));
|
|
165
|
+
}
|
|
166
|
+
return found;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Distinct files that are one package's one file, byte for byte, under two install paths. */
|
|
170
|
+
async function packageCopies(
|
|
171
|
+
output: string,
|
|
172
|
+
groups: readonly (readonly Input[])[],
|
|
173
|
+
io: ModuleIdentityIo,
|
|
174
|
+
manifests: Map<string, Promise<{ name: string; version: string } | null>>,
|
|
175
|
+
): Promise<readonly DuplicatedModule[]> {
|
|
176
|
+
const byPackage = new Map<string, { pkg: PackageOf; inputs: Input[] }>();
|
|
177
|
+
for (const group of groups) {
|
|
178
|
+
const first = group[0];
|
|
179
|
+
if (first === undefined) continue;
|
|
180
|
+
const pkg = await packageOf(first.spelling, io, manifests);
|
|
181
|
+
if (pkg === undefined) continue;
|
|
182
|
+
const key = `${pkg.id}/${pkg.subpath}`;
|
|
183
|
+
const entry = byPackage.get(key) ?? { pkg, inputs: [] };
|
|
184
|
+
entry.inputs.push(first);
|
|
185
|
+
byPackage.set(key, entry);
|
|
186
|
+
}
|
|
187
|
+
const found: DuplicatedModule[] = [];
|
|
188
|
+
for (const { pkg, inputs } of byPackage.values()) {
|
|
189
|
+
if (inputs.length < 2) continue;
|
|
190
|
+
const byHash = new Map<string, Input[]>();
|
|
191
|
+
for (const input of inputs) {
|
|
192
|
+
const text = await io.readText(input.spelling);
|
|
193
|
+
if (text === undefined) continue;
|
|
194
|
+
const hash = String(Bun.hash(text));
|
|
195
|
+
byHash.set(hash, [...(byHash.get(hash) ?? []), input]);
|
|
196
|
+
}
|
|
197
|
+
for (const same of byHash.values()) {
|
|
198
|
+
if (same.length > 1) found.push(duplicate(output, same, false, pkg.id, pkg.name));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function duplicate(
|
|
205
|
+
output: string,
|
|
206
|
+
inputs: readonly Input[],
|
|
207
|
+
sameFile: boolean,
|
|
208
|
+
id?: string,
|
|
209
|
+
name?: string,
|
|
210
|
+
): DuplicatedModule {
|
|
211
|
+
const bytes = inputs.map((one) => one.bytes);
|
|
212
|
+
const wastedBytes = bytes.reduce((sum, one) => sum + one, 0) - Math.max(...bytes);
|
|
213
|
+
return {
|
|
214
|
+
output,
|
|
215
|
+
spellings: inputs.map((one) => one.spelling),
|
|
216
|
+
wastedBytes,
|
|
217
|
+
sameFile,
|
|
218
|
+
...(id === undefined ? {} : { package: id }),
|
|
219
|
+
...(name === undefined ? {} : { packageName: name }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The refusals for an island the build cannot answer: a `buildIslands({ only })` naming no island, and
|
|
2
|
+
// a route's `src` resolving to a file the build did not bundle. Split from `island-bundle.ts`, which
|
|
3
|
+
// keeps the build; both refusals are render's `X_ISLAND_INVALID` (see `entryMissing`).
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API; `posix` does the specifier arithmetic on app-relative paths.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import { ISLAND_EXTENSION, IslandInvalidError } from '@ultimat3/render';
|
|
8
|
+
import { quoteArg } from './shell-quote';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Same code as an unbuildable `src`: "this path cannot become a client entry" is one condition.
|
|
12
|
+
*
|
|
13
|
+
* Two fixes, because there are two causes and only one of them can be repaired by naming a path.
|
|
14
|
+
* The line was `pass only: '<app-root-relative path>.island.tsx'` — a placeholder nobody can run,
|
|
15
|
+
* which no gate could see: `fixProblem` fails a fix only for ADVICE with no command token, and a
|
|
16
|
+
* sentence with neither is not advice. Both forms below are constructed from what the caller
|
|
17
|
+
* already handed in, so neither can name a path this app does not have.
|
|
18
|
+
*/
|
|
19
|
+
export function onlyMissing(only: string, discovered: readonly string[]): IslandInvalidError {
|
|
20
|
+
const cause =
|
|
21
|
+
`buildIslands was asked for ${JSON.stringify(only)} alone, which is not one of the ` +
|
|
22
|
+
`${discovered.length} islands this app has (${discovered.length === 0 ? 'none' : discovered.join(', ')})`;
|
|
23
|
+
// The basename match first: a filter that misses normally missed on the PREFIX — a route-relative
|
|
24
|
+
// specifier where `discoverIslands`' app-root-relative path was wanted — and the filename
|
|
25
|
+
// survives that. Falling back to the first keeps the fix a real path rather than a shape.
|
|
26
|
+
const nearest =
|
|
27
|
+
discovered.find((file) => posix.basename(file) === posix.basename(only)) ?? discovered[0];
|
|
28
|
+
// An app with no islands cannot be pointed at one, so the fix WRITES the file that was asked
|
|
29
|
+
// for — the same command `entryMissing` hands back, split off the same path.
|
|
30
|
+
if (nearest === undefined) {
|
|
31
|
+
return new IslandInvalidError(cause, generateIslandFix(only));
|
|
32
|
+
}
|
|
33
|
+
return new IslandInvalidError(cause, `buildIslands(root, { only: '${nearest}' })`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The specifier named no file the build could bundle. `X_ISLAND_INVALID` is render's and is
|
|
38
|
+
* borrowed rather than renamed here: "this src cannot become a client entry" is the condition that
|
|
39
|
+
* code already means, and a second name for it is a second thing to look up.
|
|
40
|
+
*/
|
|
41
|
+
export function entryMissing(
|
|
42
|
+
routeFile: string,
|
|
43
|
+
src: string,
|
|
44
|
+
target: string,
|
|
45
|
+
chunks: readonly { readonly file: string }[],
|
|
46
|
+
): IslandInvalidError {
|
|
47
|
+
const known = chunks.map((chunk) => chunk.file);
|
|
48
|
+
return new IslandInvalidError(
|
|
49
|
+
`${routeFile} declares island src ${JSON.stringify(src)}, which resolves to ${target} — a ` +
|
|
50
|
+
`file this build did not bundle (${known.length === 0 ? 'it found no islands at all' : `it found ${known.join(', ')}`})`,
|
|
51
|
+
generateIslandFix(target),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `x g island <name> --at <dir>`, split off a path the caller or a route file supplied. Each half is
|
|
57
|
+
* quoted (security audit of plan 101 sweep 1c, M1), and one opening with `-` is the placeholder:
|
|
58
|
+
* quoting does not stop `x` reading `--json` as a flag (L3).
|
|
59
|
+
*/
|
|
60
|
+
function generateIslandFix(path: string): string {
|
|
61
|
+
const name = posix.basename(path, ISLAND_EXTENSION);
|
|
62
|
+
const dir = posix.dirname(path);
|
|
63
|
+
return `x g island ${quoteArg(name.startsWith('-') ? '<name>' : name)} --at ${quoteArg(dir.startsWith('-') ? '<dir>' : dir)}`;
|
|
64
|
+
}
|