mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(cli): commands run without esbuild, puppeteer-core or sharp until they need them (#5930)
* perf(cli): commands run without esbuild, puppeteer-core or sharp until they need them A host that bundles the CLI can now leave these packages out: each command loads them only where it uses them, and sharp installs once on first use when it is missing. The build check fails on any static import of them again. * fix(cli): use a host's sharp from NODE_PATH and let hosts turn first-use installs off * fix(cli): capture fails when sharp is missing, and one install serves every caller A missing sharp now stops the asset download up front instead of counting every image as unavailable. Concurrent first uses share one install, a failed install is not retried in the same run, and preview thumbnails report why. Hosts can turn off installs for a list of packages, e.g. HYPERFRAMES_NO_OPTIONAL_INSTALL=sharp. * fix(cli): snapshot --describe still works without sharp, resizing only when it is installed * style(cli): format the sharp loader and its tests * build(cli): sharp is an optional dependency, so a host can ship the CLI without it * build(studio-server): keep sharp out of the bundle now that it is optional
This commit is contained in:
@@ -330,6 +330,7 @@ jobs:
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- run: bun run build
|
||||
- run: bun run verify:packed-manifests
|
||||
- run: bun run check:cli-lazy-deps
|
||||
- name: Share the built CLI with the edit accuracy shards
|
||||
if: needs.changes.outputs.edit_accuracy == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
"open": "^10.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"puppeteer-core": "^25.10.0",
|
||||
"sharp": "^0.35.0",
|
||||
"yauzl": "^3.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -111,6 +110,9 @@
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.35.0",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
@@ -403,7 +405,6 @@
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
"sharp": "^0.35.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
@@ -412,6 +413,9 @@
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.35.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -442,7 +442,10 @@ after that. Peak inference RAM is about 1.5 GB.
|
||||
`--device auto` picks CoreML on Apple Silicon, CUDA where available, CPU
|
||||
otherwise. The first run installs the CPU build of `onnxruntime-node` into
|
||||
`~/.cache/hyperframes/optional/` (one line on stderr, once, no prompt); for CUDA, set
|
||||
`HYPERFRAMES_CUDA=1` and supply a GPU-enabled build.
|
||||
`HYPERFRAMES_CUDA=1` and supply a GPU-enabled build. With
|
||||
`HYPERFRAMES_NO_OPTIONAL_INSTALL=1` the CLI installs nothing on first use (this
|
||||
package, `@google/genai`, or a missing `sharp`) and the command fails, naming the package;
|
||||
a comma-separated list such as `HYPERFRAMES_NO_OPTIONAL_INSTALL=sharp` turns off only those.
|
||||
|
||||
<Tip>
|
||||
Chrome's `<video>` only honours the alpha plane when the WebM is `yuva420p` with
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@
|
||||
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
|
||||
"check:package-cycles": "node scripts/check-package-cycles.mjs",
|
||||
"check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs",
|
||||
"check:cli-lazy-deps": "node scripts/check-cli-lazy-deps.mjs",
|
||||
"check:studio-copy-dashes": "node scripts/check-studio-copy-dashes.mjs",
|
||||
"format": "oxfmt .",
|
||||
"test": "bun run test:unit",
|
||||
@@ -53,7 +54,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
|
||||
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
|
||||
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
|
||||
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"open": "^10.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"puppeteer-core": "^25.10.0",
|
||||
"sharp": "^0.35.0",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -80,6 +79,9 @@
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.35.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,3 @@ const iifeFileName = manifest.artifacts?.iife ?? "hyperframe.runtime.iife.js";
|
||||
// produces output without the trailing newline, causing a checksum mismatch.
|
||||
copyFileSync(resolve(coreDistDir, "hyperframe.manifest.json"), "dist/hyperframe.manifest.json");
|
||||
copyFileSync(resolve(coreDistDir, iifeFileName), `dist/${iifeFileName}`);
|
||||
|
||||
// Keep legacy name for backward compat (e.g. studio dev server)
|
||||
copyFileSync(resolve(coreDistDir, iifeFileName), "dist/hyperframe-runtime.js");
|
||||
|
||||
@@ -14,10 +14,17 @@ describe("background-removal native dependency compatibility", () => {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.dependencies).toMatchObject({
|
||||
"adm-zip": "^0.6.0",
|
||||
sharp: "^0.35.0",
|
||||
});
|
||||
expect(packageJson.dependencies).toMatchObject({ "adm-zip": "^0.6.0" });
|
||||
});
|
||||
|
||||
it("keeps sharp optional in the CLI and studio-server, so a host may ship without it", () => {
|
||||
for (const manifest of ["../../package.json", "../../../studio-server/package.json"]) {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL(manifest, import.meta.url)), "utf8"),
|
||||
) as { dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> };
|
||||
expect(packageJson.dependencies, manifest).not.toHaveProperty("sharp");
|
||||
expect(packageJson.optionalDependencies?.["sharp"], manifest).toBe("^0.35.0");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every Node server consumer above the patched security floor", () => {
|
||||
|
||||
@@ -136,19 +136,24 @@ describe("background-removal/inference — missing optional native modules", ()
|
||||
|
||||
it("createSession asks the optional-package loader for onnxruntime-node and surfaces its error", async () => {
|
||||
const loadOptionalPackage = vi.fn().mockRejectedValue(new Error("run npm install it yourself"));
|
||||
vi.doMock("../utils/optionalPackages.js", () => ({ loadOptionalPackage }));
|
||||
vi.doMock("../utils/optionalPackages.js", () => ({
|
||||
loadOptionalPackage,
|
||||
loadSharp: async () => ({}),
|
||||
}));
|
||||
const { createSession } = await import("./inference.js");
|
||||
await expect(createSession()).rejects.toThrow("run npm install it yourself");
|
||||
expect(loadOptionalPackage).toHaveBeenCalledWith("onnxruntime-node", "background removal");
|
||||
vi.doUnmock("../utils/optionalPackages.js");
|
||||
});
|
||||
|
||||
it("createSession throws an actionable error when sharp can't load", async () => {
|
||||
vi.doMock("sharp", () => {
|
||||
throw new Error("Could not load the sharp module");
|
||||
});
|
||||
it("createSession asks the loader for sharp first and surfaces its error", async () => {
|
||||
const loadSharp = vi.fn().mockRejectedValue(new Error("remove-background needs sharp"));
|
||||
const loadOptionalPackage = vi.fn();
|
||||
vi.doMock("../utils/optionalPackages.js", () => ({ loadOptionalPackage, loadSharp }));
|
||||
const { createSession } = await import("./inference.js");
|
||||
await expect(createSession()).rejects.toThrow(/sharp.*isn't available[\s\S]*npm i sharp/);
|
||||
vi.doUnmock("sharp");
|
||||
await expect(createSession()).rejects.toThrow("remove-background needs sharp");
|
||||
expect(loadSharp).toHaveBeenCalledWith("remove-background");
|
||||
expect(loadOptionalPackage).not.toHaveBeenCalled();
|
||||
vi.doUnmock("../utils/optionalPackages.js");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import type { InferenceSession, Tensor } from "onnxruntime-node";
|
||||
import type sharpType from "sharp";
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import { loadOptionalPackage, loadSharp } from "../utils/optionalPackages.js";
|
||||
import { ensureModel, selectProviders, type Device, type ModelId } from "./manager.js";
|
||||
|
||||
const INPUT_SIZE = 320;
|
||||
@@ -53,21 +53,8 @@ export interface CreateSessionOptions {
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
// sharp is an optional native module — its platform binary doesn't install
|
||||
// everywhere. Surface an actionable error instead of a raw "Cannot find module".
|
||||
async function loadNative<T>(name: string, load: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await load();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`remove-background needs the optional native module '${name}', which isn't available ` +
|
||||
`(${(err as Error).message}). Install it with \`npm i ${name}\`, or reinstall hyperframes with optional dependencies enabled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(options: CreateSessionOptions = {}): Promise<Session> {
|
||||
const sharp = (await loadNative("sharp", () => import("sharp"))).default as Sharp;
|
||||
const sharp: Sharp = await loadSharp("remove-background");
|
||||
const ort = (await loadOptionalPackage(
|
||||
"onnxruntime-node",
|
||||
"background removal",
|
||||
|
||||
@@ -23,8 +23,13 @@ import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
import sharp from "sharp";
|
||||
import { publicRequest } from "./publicNetwork.js";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
|
||||
vi.mock("./publicNetwork.js", () => ({ publicRequest: vi.fn() }));
|
||||
vi.mock("../utils/optionalPackages.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/optionalPackages.js")>();
|
||||
return { ...actual, loadSharp: vi.fn(actual.loadSharp) };
|
||||
});
|
||||
|
||||
// Exercise production URL/redirect checks while replacing only the socket transport.
|
||||
function mockRequest(implementation: (url: string, init?: RequestInit) => Promise<Response>) {
|
||||
@@ -837,3 +842,21 @@ describe("a planted directory link inside the capture directory (#4304)", () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadAssets without sharp", () => {
|
||||
afterEach(() => vi.mocked(publicRequest).mockReset());
|
||||
|
||||
it("fails the download instead of counting every image unavailable", async () => {
|
||||
vi.mocked(loadSharp).mockRejectedValueOnce(new Error("sharp is not installed"));
|
||||
await withTempDir(async (dir) => {
|
||||
const fetchMock = vi.fn();
|
||||
mockRequest(fetchMock);
|
||||
await expect(
|
||||
downloadAssets(tokensWithNoSvgs(), dir, [
|
||||
{ type: "Image", url: "https://cdn.example/hero.png", contexts: ["img[src]"] },
|
||||
] as never),
|
||||
).rejects.toThrow("sharp is not installed");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "./readBoundedResponse.js";
|
||||
import { captureFontExtension, captureFontFilename } from "./captureFontValidation.js";
|
||||
import { captureImageExtension } from "./captureImageValidation.js";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
|
||||
interface DownloadBudgetOptions {
|
||||
byteBudget?: DownloadByteBudget;
|
||||
@@ -323,6 +324,7 @@ export async function downloadAssets(
|
||||
faviconLinks?: IconCandidate[],
|
||||
options: DownloadBudgetOptions = {},
|
||||
): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts; icons: IconManifest }> {
|
||||
await loadSharp("website capture");
|
||||
options = { ...options, byteBudget: options.byteBudget ?? createCaptureDownloadBudget() };
|
||||
const assetsDir = join(outputDir, "assets");
|
||||
ensureCaptureDirSync(outputDir, assetsDir);
|
||||
|
||||
@@ -18,7 +18,10 @@ vi.mock("../browser/manager.js", () => ({
|
||||
findSystemBrowser: mocks.findSystemBrowser,
|
||||
}));
|
||||
|
||||
vi.mock("puppeteer-core", () => ({ default: { launch: mocks.launch } }));
|
||||
vi.mock("puppeteer-core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("puppeteer-core")>()),
|
||||
default: { launch: mocks.launch },
|
||||
}));
|
||||
|
||||
// captureCompositionFrame.js imports these two from ../browser/gpuPolicy.js,
|
||||
// which re-exports them from this mocked module — unmocked here, they'd read
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import sharp from "sharp";
|
||||
import { DOMParser } from "linkedom";
|
||||
import { parse, walk } from "css-tree";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
|
||||
const SVG_ELEMENTS = new Set(
|
||||
"svg g defs title desc metadata path rect circle ellipse line polyline polygon text tspan textPath use symbol image clipPath mask pattern marker linearGradient radialGradient stop filter feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting feSpotLight feTile feTurbulence style"
|
||||
@@ -107,6 +107,7 @@ function validIconEntry(
|
||||
async function validIconImage(bytes: Buffer): Promise<boolean> {
|
||||
const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
if (!png) return validIconBitmap(bytes);
|
||||
const sharp = await loadSharp("website capture");
|
||||
try {
|
||||
await sharp(bytes, { limitInputPixels: 65536 }).resize(1, 1).raw().toBuffer();
|
||||
return true;
|
||||
@@ -136,6 +137,7 @@ function validIconDimensions(width: number, height: number): boolean {
|
||||
/** Choose a canonical image extension from the content, never a remote URL suffix. */
|
||||
export async function captureImageExtension(bytes: Buffer): Promise<string | null> {
|
||||
if (await validIco(bytes)) return ".ico";
|
||||
const sharp = await loadSharp("website capture");
|
||||
try {
|
||||
const embedded = new Set<string>();
|
||||
// Reject active XML before passing it to an image decoder.
|
||||
@@ -165,6 +167,7 @@ export async function captureImageExtension(bytes: Buffer): Promise<string | nul
|
||||
}
|
||||
|
||||
async function validEmbeddedImages(references: Set<string>): Promise<boolean> {
|
||||
const sharp = await loadSharp("website capture");
|
||||
let remainingBytes = 10 * 1024 * 1024;
|
||||
let remainingPixels = 40_000_000;
|
||||
for (const reference of references) {
|
||||
|
||||
@@ -34,6 +34,15 @@ describe("isNavigationTimeoutError", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a page error or abort signal named TimeoutError as Puppeteer's", () => {
|
||||
const pageError = Object.assign(new Error("Navigation timeout of 30000 ms exceeded"), {
|
||||
name: "TimeoutError",
|
||||
});
|
||||
expect(isNavigationTimeoutError(pageError)).toBe(false);
|
||||
expect(isProtocolEvaluateTimeoutError(pageError)).toBe(false);
|
||||
expect(isProtocolEvaluateTimeoutError(new DOMException("aborted", "TimeoutError"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat plain Error as navigation timeout", () => {
|
||||
expect(isNavigationTimeoutError(new Error("Navigation timeout of 30000 ms exceeded"))).toBe(
|
||||
false,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TimeoutError } from "puppeteer-core";
|
||||
|
||||
export const PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS = 180_000;
|
||||
|
||||
export class StageBudgetTimeoutError extends Error {
|
||||
@@ -23,6 +21,10 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function isPuppeteerTimeoutError(err: unknown): err is Error {
|
||||
return err instanceof Error && err.constructor.name === "TimeoutError";
|
||||
}
|
||||
|
||||
function hasNavigationTimeoutMessage(msg: string): boolean {
|
||||
return /navigation timeout/i.test(msg);
|
||||
}
|
||||
@@ -32,7 +34,7 @@ function hasProtocolEvaluateTimeoutMessage(msg: string): boolean {
|
||||
}
|
||||
|
||||
export function isNavigationTimeoutError(err: unknown): boolean {
|
||||
if (err instanceof TimeoutError) {
|
||||
if (isPuppeteerTimeoutError(err)) {
|
||||
return hasNavigationTimeoutMessage(err.message);
|
||||
}
|
||||
// String path used by BLOCKED.md formatting.
|
||||
@@ -44,7 +46,7 @@ function isStageBudgetTimeoutError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export function isProtocolEvaluateTimeoutError(err: unknown): boolean {
|
||||
if (err instanceof TimeoutError) {
|
||||
if (isPuppeteerTimeoutError(err)) {
|
||||
return !hasNavigationTimeoutMessage(err.message);
|
||||
}
|
||||
return hasProtocolEvaluateTimeoutMessage(errorMessage(err));
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* Saves 50-65% tokens vs. AI agents reading images individually.
|
||||
*/
|
||||
|
||||
import sharp, { type OverlayOptions } from "sharp";
|
||||
import type { OverlayOptions } from "sharp";
|
||||
import { readdirSync, readFileSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname, basename, dirname } from "node:path";
|
||||
|
||||
@@ -46,6 +47,7 @@ export async function createContactSheet(
|
||||
|
||||
const files = imagePaths.slice(0, maxImages);
|
||||
if (files.length === 0) return null;
|
||||
const sharp = await loadSharp("contact sheets");
|
||||
|
||||
// Read first image to determine aspect ratio
|
||||
const firstMeta = await sharp(files[0]!).metadata();
|
||||
@@ -306,6 +308,7 @@ export async function createSvgContactSheet(
|
||||
}
|
||||
|
||||
if (svgPaths.length === 0) return [];
|
||||
const sharp = await loadSharp("contact sheets");
|
||||
|
||||
const svgFileNames = svgPaths.map((p) => p.split("/").pop()!);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock("sharp", () => {
|
||||
});
|
||||
|
||||
vi.mock("../utils/optionalPackages.js", () => ({
|
||||
loadSharp: async () => (await import("sharp")).default,
|
||||
loadOptionalPackage: vi.fn(async () => ({
|
||||
GoogleGenAI: class {
|
||||
models = { generateContent: generateContentMock };
|
||||
|
||||
@@ -22,7 +22,11 @@ import { basename, join } from "node:path";
|
||||
import type sharpType from "sharp";
|
||||
import type { CatalogedAsset } from "./assetCataloger.js";
|
||||
import type { DesignTokens } from "./types.js";
|
||||
import { loadOptionalPackage, type OptionalPackageModules } from "../utils/optionalPackages.js";
|
||||
import {
|
||||
loadOptionalPackage,
|
||||
loadSharp,
|
||||
type OptionalPackageModules,
|
||||
} from "../utils/optionalPackages.js";
|
||||
|
||||
const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -523,7 +527,7 @@ export async function captionImagesWithGemini(
|
||||
// than crashing the whole capture command on import.
|
||||
let sharp: typeof sharpType;
|
||||
try {
|
||||
sharp = (await import("sharp")).default as typeof sharpType;
|
||||
sharp = await loadSharp("SVG captions");
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` +
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* resize for raster. They answer "does this drawing carry its own backdrop", nothing more.
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
|
||||
/** What a downloaded icon is, as a drawing. `unknown` when the format cannot be inspected. */
|
||||
export type IconShape = "bare-mark" | "badge" | "unknown";
|
||||
@@ -151,6 +151,7 @@ const SAMPLE_EDGE = 32;
|
||||
export async function classifyRasterIcon(buffer: Buffer): Promise<IconVerdict> {
|
||||
let data: Buffer;
|
||||
let channels: number;
|
||||
const sharp = await loadSharp("website capture");
|
||||
try {
|
||||
const raw = await sharp(buffer)
|
||||
.resize(SAMPLE_EDGE, SAMPLE_EDGE, { fit: "fill" })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimeoutError, type CDPSession, type HTTPResponse, type Page } from "puppeteer-core";
|
||||
import type { CDPSession, HTTPResponse, Page } from "puppeteer-core";
|
||||
import {
|
||||
isDegradableEvaluateTimeoutError,
|
||||
isNavigationTimeoutError,
|
||||
@@ -140,6 +140,7 @@ export async function gotoWhenSent(
|
||||
timeoutMs: number,
|
||||
coldChromeFirstRequestTimeoutMs = PUPPETEER_LAUNCH_DEFAULT_TIMEOUT_MS,
|
||||
): Promise<HTTPResponse | null> {
|
||||
const { TimeoutError } = await import("puppeteer-core");
|
||||
const cdp = await page.createCDPSession();
|
||||
try {
|
||||
await cdp.send("Network.enable");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { DEFAULT_MAX_SCREENSHOTS } from "./types.js";
|
||||
import { CaptureDirRefusedError } from "./captureErrors.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { ConnectionClosedError, type Page } from "puppeteer-core";
|
||||
import type { Page } from "puppeteer-core";
|
||||
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
|
||||
import { waitForPageReady } from "./navigateForCapture.js";
|
||||
import { join } from "node:path";
|
||||
@@ -60,7 +60,8 @@ function shotFailure(err: unknown, file: string, scrollY: number | null): Screen
|
||||
const message = normalizeErrorMessage(err);
|
||||
// puppeteer-core 25 exports no class for a closed page or session, only these messages.
|
||||
const closed =
|
||||
err instanceof ConnectionClosedError || /\b(target|session) closed\b/i.test(message);
|
||||
(err instanceof Error && err.constructor.name === "ConnectionClosedError") ||
|
||||
/\b(target|session) closed\b/i.test(message);
|
||||
if (closed || err instanceof CaptureDirRefusedError || isDegradableEvaluateTimeoutError(err))
|
||||
throw err;
|
||||
return { file, scrollY, message };
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
} from "@hyperframes/core";
|
||||
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
|
||||
import { defineCommand } from "citty";
|
||||
import sharp from "sharp";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { openSettledCompositionPage, runFfmpegOnce } from "../capture/captureCompositionFrame.js";
|
||||
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { loadSharp } from "../utils/optionalPackages.js";
|
||||
import { displayPathFromBase, readOptionalString, resolveFromBase } from "../utils/pathArgs.js";
|
||||
import { trackCompareSheet } from "../telemetry/events.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
@@ -527,6 +527,7 @@ async function loadReferenceFrame(framePath: string): Promise<ReferenceFrame> {
|
||||
throw new Error(`Could not extract a frame from video: ${framePath}`);
|
||||
}
|
||||
|
||||
const sharp = await loadSharp("grade-compare");
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error(`Could not read reference frame dimensions: ${framePath}`);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("snapshot contact sheet failures", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../capture/contactSheet.js");
|
||||
vi.doUnmock("sharp");
|
||||
vi.doUnmock("../utils/optionalPackages.js");
|
||||
vi.restoreAllMocks();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
});
|
||||
@@ -97,8 +97,9 @@ describe("snapshot contact sheet failures", () => {
|
||||
throw new Error(message);
|
||||
});
|
||||
if (failure === "import") {
|
||||
vi.doMock("sharp", () => ({
|
||||
get default() {
|
||||
vi.doMock("../utils/optionalPackages.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../utils/optionalPackages.js")>()),
|
||||
loadSharp: async () => {
|
||||
throw new Error(message);
|
||||
},
|
||||
}));
|
||||
@@ -193,6 +194,49 @@ describe("snapshot contact sheet failures", () => {
|
||||
await held.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("describes frames without resizing when sharp is absent and installs are off", async () => {
|
||||
vi.stubEnv("GEMINI_API_KEY", "test-key");
|
||||
vi.stubEnv("HYPERFRAMES_NO_OPTIONAL_INSTALL", "sharp");
|
||||
const generateContent = vi.fn(async (_request: unknown) => ({ text: "a described frame" }));
|
||||
const loadSharp = vi.fn(async () => {
|
||||
throw new Error(
|
||||
"sharp is not installed, and HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it",
|
||||
);
|
||||
});
|
||||
vi.doMock("../utils/optionalPackages.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../utils/optionalPackages.js")>()),
|
||||
loadInstalledSharp: () => null,
|
||||
loadSharp,
|
||||
loadOptionalPackage: async () => ({
|
||||
GoogleGenAI: class {
|
||||
models = { generateContent };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const { default: command } = await import("./snapshot.js");
|
||||
|
||||
try {
|
||||
await runCommand(command, {
|
||||
rawArgs: [
|
||||
project,
|
||||
...["--at", "0.5,1", "--no-end", "--describe", "true"],
|
||||
...["--output", join(project, "snapshots")],
|
||||
],
|
||||
});
|
||||
|
||||
expect(generateContent).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(generateContent.mock.calls[0]?.[0])).toContain(
|
||||
'"mimeType":"image/png"',
|
||||
);
|
||||
expect(readFileSync(join(project, "snapshots", "descriptions.md"), "utf8")).toContain(
|
||||
"a described frame",
|
||||
);
|
||||
expect(loadSharp).not.toHaveBeenCalledWith("--describe");
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot --keep", () => {
|
||||
|
||||
@@ -57,7 +57,7 @@ import { runCancellableProcess } from "../utils/cancellableProcess.js";
|
||||
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||
import { parseAngle, type Camera } from "./motionShotLayout.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import { loadInstalledSharp, loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
|
||||
@@ -1248,17 +1248,13 @@ export default defineCommand({
|
||||
];
|
||||
|
||||
// Scale down PNGs before sending to stay under Gemini's 4 MB inline
|
||||
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if
|
||||
// available; otherwise skip files over the limit.
|
||||
type SharpFn = (buf: Buffer) => {
|
||||
resize: (w: number) => { jpeg: () => { toBuffer: () => Promise<Buffer> } };
|
||||
};
|
||||
let sharpFn: SharpFn | null = null;
|
||||
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if it is
|
||||
// already there (never installed for this); otherwise skip files over the limit.
|
||||
let sharp: ReturnType<typeof loadInstalledSharp> = null;
|
||||
try {
|
||||
const s = await import("sharp");
|
||||
sharpFn = (s.default ?? s) as unknown as SharpFn;
|
||||
sharp = loadInstalledSharp("--describe");
|
||||
} catch {
|
||||
/* sharp not installed — fall back to size check */
|
||||
/* a copy that cannot load — fall back to size check */
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -1269,8 +1265,8 @@ export default defineCommand({
|
||||
const raw = readFileSync(filePath);
|
||||
let imageData: Buffer;
|
||||
let mimeType = "image/png";
|
||||
if (sharpFn) {
|
||||
imageData = await sharpFn(raw).resize(960).jpeg().toBuffer();
|
||||
if (sharp) {
|
||||
imageData = await sharp(raw).resize(960).jpeg().toBuffer();
|
||||
mimeType = "image/jpeg";
|
||||
} else {
|
||||
if (raw.length > 3_800_000)
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
type BrowserGpuMode,
|
||||
type ResolvedBrowserGpuMode,
|
||||
} from "../browser/gpuPolicy.js";
|
||||
import { loadSharpInBackground } from "../utils/optionalPackages.js";
|
||||
|
||||
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
|
||||
|
||||
@@ -490,6 +491,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
const adapter: PreviewApiAdapter = {
|
||||
history: () => projectHistory(),
|
||||
loadSharp: () => loadSharpInBackground("image thumbnails"),
|
||||
// Explicit option wins (preview's resolved --proxy/--no-proxy + config);
|
||||
// otherwise honor the project's hyperframes.json media.autoProxy so every
|
||||
// createStudioServer caller (e.g. the background preview child) gets the
|
||||
|
||||
@@ -37,6 +37,7 @@ function helperDir(): string {
|
||||
export function resolveRuntimePath(): string | null {
|
||||
const d = helperDir();
|
||||
const candidates = [
|
||||
resolve(d, "hyperframe.runtime.iife.js"),
|
||||
resolve(d, "hyperframe-runtime.js"),
|
||||
resolve(d, "..", "hyperframe-runtime.js"),
|
||||
// Monorepo dev: src/<dir>/ → src/ → cli/ → packages/ then into core/dist/
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { satisfies } from "compare-versions";
|
||||
import { buildSync } from "esbuild";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
loadBesideCli,
|
||||
loadInstalledOptionalPackage,
|
||||
loadOptionalPackage,
|
||||
loadSharp,
|
||||
loadSharpInBackground,
|
||||
optionalPackageDir,
|
||||
requireSharpBesideCli,
|
||||
SHARP_VERSION,
|
||||
type OptionalPackageDeps,
|
||||
} from "./optionalPackages.js";
|
||||
|
||||
@@ -130,6 +136,221 @@ describe("loadOptionalPackage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSharp", () => {
|
||||
const sharp = { fake: "sharp" };
|
||||
const absent = () => null;
|
||||
|
||||
it("uses the installed copy, whatever its version, without installing", async () => {
|
||||
const { deps, install } = fakeDeps();
|
||||
|
||||
expect(await loadSharp("compare", deps, () => sharp as never)).toBe(sharp);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs once when the host left sharp out, then loads it from the cache", async () => {
|
||||
const { deps, install, log } = fakeDeps();
|
||||
|
||||
const first = await loadSharp("compare", deps, absent);
|
||||
const second = await loadSharp("compare", deps, absent);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
optionalPackageDir("sharp", "/cache"),
|
||||
"sharp",
|
||||
SHARP_VERSION,
|
||||
);
|
||||
expect(log.mock.calls).toEqual([["installing sharp for compare, once"]]);
|
||||
});
|
||||
|
||||
it("runs one install for concurrent callers, and does not retry a failed one", async () => {
|
||||
const { deps, install } = fakeDeps();
|
||||
const loaded = await Promise.all([1, 2, 3].map(() => loadSharp("compare", deps, absent)));
|
||||
expect(new Set(loaded).size).toBe(1);
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
|
||||
const offline = vi.fn(async () => {
|
||||
throw new Error("npm error code ENOTFOUND");
|
||||
});
|
||||
const failing = fakeDeps({ install: offline }).deps;
|
||||
await expect(loadSharp("compare", failing, absent)).rejects.toThrow(/ENOTFOUND/);
|
||||
await expect(loadSharp("compare", failing, absent)).rejects.toThrow(/ENOTFOUND/);
|
||||
expect(offline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offline with no cached copy, fails with the command to run", async () => {
|
||||
const { deps } = fakeDeps({
|
||||
install: async () => {
|
||||
throw new Error("npm error code ENOTFOUND\nnpm error syscall getaddrinfo");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(loadSharp("compare", deps, absent)).rejects.toThrow(
|
||||
`compare needs sharp, and installing it failed (npm error code ENOTFOUND). ` +
|
||||
`Check your network connection, then retry, or install it yourself: ` +
|
||||
`npm install sharp@${SHARP_VERSION} --prefix "${optionalPackageDir("sharp", "/cache")}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["1", " sharp , onnxruntime-node "])(
|
||||
"with HYPERFRAMES_NO_OPTIONAL_INSTALL=%s, fails fast with a stable message instead of installing",
|
||||
async (value) => {
|
||||
vi.stubEnv("HYPERFRAMES_NO_OPTIONAL_INSTALL", value);
|
||||
try {
|
||||
const { deps, install, log } = fakeDeps();
|
||||
|
||||
await expect(loadSharp("compare", deps, absent)).rejects.toThrow(
|
||||
"sharp is not installed, and HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it; compare needs it.",
|
||||
);
|
||||
await expect(
|
||||
loadOptionalPackage("onnxruntime-node", "background removal", deps),
|
||||
).rejects.toThrow("onnxruntime-node is not installed, and HYPERFRAMES_NO_OPTIONAL_INSTALL");
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("with a package list, still installs the packages it does not name", async () => {
|
||||
vi.stubEnv("HYPERFRAMES_NO_OPTIONAL_INSTALL", "sharp");
|
||||
try {
|
||||
const { deps, install } = fakeDeps();
|
||||
|
||||
await expect(loadSharp("compare", deps, absent)).rejects.toThrow(
|
||||
"HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it",
|
||||
);
|
||||
await loadOptionalPackage("@google/genai", "--describe", deps);
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
optionalPackageDir("@google/genai", "/cache"),
|
||||
"@google/genai",
|
||||
OPTIONAL_PACKAGES["@google/genai"],
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a version the CLI's own dependency range accepts", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
expect(satisfies(SHARP_VERSION, manifest.optionalDependencies.sharp)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSharpInBackground", () => {
|
||||
it("rejects at once, installs once in the background, then serves the installed copy", async () => {
|
||||
const sharp = { fake: "sharp" };
|
||||
const { deps, installed } = fakeDeps();
|
||||
let finish = () => {};
|
||||
const install = vi.fn(
|
||||
(dir: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = () => {
|
||||
installed.set(dir, sharp);
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
deps.install = install;
|
||||
const load = () => loadSharpInBackground("image thumbnails", deps, () => null);
|
||||
|
||||
await expect(load()).rejects.toThrow("installing it once for image thumbnails");
|
||||
await expect(load()).rejects.toThrow("installing it once for image thumbnails");
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
|
||||
finish();
|
||||
expect(await load()).toBe(sharp);
|
||||
});
|
||||
|
||||
it("says why when installing is off, instead of promising an install", async () => {
|
||||
vi.stubEnv("HYPERFRAMES_NO_OPTIONAL_INSTALL", "1");
|
||||
try {
|
||||
const { deps, install, log } = fakeDeps();
|
||||
const load = () => loadSharpInBackground("image thumbnails", deps, () => null);
|
||||
|
||||
await expect(load()).rejects.toThrow(
|
||||
"HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it",
|
||||
);
|
||||
await expect(load()).rejects.toThrow(
|
||||
"HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it",
|
||||
);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSharpBesideCli", () => {
|
||||
function sharpPackage(dir: string, body: string) {
|
||||
const pkg = join(dir, "node_modules", "sharp");
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({ version: "0.1.0", main: "index.js" }),
|
||||
);
|
||||
writeFileSync(join(pkg, "index.js"), body);
|
||||
}
|
||||
|
||||
it("loads whatever version is installed beside the CLI, and null when there is none", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-sharp-beside-"));
|
||||
const cliUrl = pathToFileURL(join(root, "node_modules", "hyperframes", "dist", "cli.js")).href;
|
||||
try {
|
||||
expect(requireSharpBesideCli("compare", cliUrl)).toBeNull();
|
||||
sharpPackage(root, `module.exports = { copy: "beside" };`);
|
||||
expect(requireSharpBesideCli("compare", cliUrl)).toEqual({ copy: "beside" });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("names the feature when the installed copy cannot load", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-sharp-broken-"));
|
||||
sharpPackage(root, `throw new Error("libvips was blocked");`);
|
||||
const cliUrl = pathToFileURL(join(root, "node_modules", "hyperframes", "dist", "cli.js")).href;
|
||||
try {
|
||||
expect(() => requireSharpBesideCli("remove-background", cliUrl)).toThrow(
|
||||
"remove-background needs sharp, which is installed but could not load (libvips was blocked). ",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds a host's runtime pack on NODE_PATH", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-sharp-node-path-"));
|
||||
const pack = join(root, "pack");
|
||||
sharpPackage(pack, `module.exports = { copy: "pack" };`);
|
||||
// Bundled into a folder with no node_modules above it, so only NODE_PATH can supply sharp.
|
||||
const bundle = join(root, "cli", "optionalPackages.mjs");
|
||||
buildSync({
|
||||
entryPoints: [fileURLToPath(new URL("./optionalPackages.ts", import.meta.url))],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: bundle,
|
||||
});
|
||||
const script = `const m = await import(${JSON.stringify(pathToFileURL(bundle).href)});
|
||||
console.log(JSON.stringify(await m.loadSharp("compare", { cacheDir: "/no-cache", loadBesideCli: () => null,
|
||||
loadInstalled: () => null, install: async () => { throw new Error("installed"); }, log() {} })));`;
|
||||
try {
|
||||
const child = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, NODE_PATH: join(pack, "node_modules") },
|
||||
});
|
||||
expect(child.stderr).toBe("");
|
||||
expect(JSON.parse(child.stdout)).toEqual({ copy: "pack" });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a copy installed beside the CLI", () => {
|
||||
// node_modules/hyperframes/dist/cli.js with onnxruntime-node installed next to hyperframes.
|
||||
function layout(version: string, name = "onnxruntime-node") {
|
||||
|
||||
@@ -39,9 +39,14 @@ type PinnedPackage = keyof typeof PINNED_PACKAGES;
|
||||
|
||||
export const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "optional");
|
||||
|
||||
/** Installed on first use only when a host bundled the CLI without its sharp dependency. */
|
||||
export const SHARP_VERSION = "0.35.3";
|
||||
|
||||
type FirstUsePackage = OptionalPackage | "sharp";
|
||||
|
||||
export interface OptionalPackageDeps {
|
||||
cacheDir: string;
|
||||
loadBesideCli(name: OptionalPackage): unknown | null;
|
||||
loadBesideCli(name: FirstUsePackage): unknown | null;
|
||||
/** The package's exports when already installed in `dir`, else null. */
|
||||
loadInstalled(dir: string, name: string): unknown | null;
|
||||
/** Install `name@version` into `dir`; rejects with npm's output on failure. */
|
||||
@@ -50,8 +55,12 @@ export interface OptionalPackageDeps {
|
||||
}
|
||||
|
||||
/** One directory per package and version, so a version bump never reads a stale install. */
|
||||
export function optionalPackageDir(name: OptionalPackage, cacheDir = CACHE_DIR): string {
|
||||
return join(cacheDir, `${name.replace("/", "__")}@${OPTIONAL_PACKAGES[name]}`);
|
||||
export function optionalPackageDir(name: FirstUsePackage, cacheDir = CACHE_DIR): string {
|
||||
return join(cacheDir, `${name.replace("/", "__")}@${versionOf(name)}`);
|
||||
}
|
||||
|
||||
function versionOf(name: FirstUsePackage): string {
|
||||
return name === "sharp" ? SHARP_VERSION : OPTIONAL_PACKAGES[name];
|
||||
}
|
||||
|
||||
/** Installed version, or null. Reads the manifest only, so it never loads a native binding. */
|
||||
@@ -71,9 +80,13 @@ export function loadInstalledOptionalPackage<N extends OptionalPackage>(
|
||||
name: N,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
): OptionalPackageModules[N] | null {
|
||||
const found =
|
||||
deps.loadBesideCli(name) ?? deps.loadInstalled(optionalPackageDir(name, deps.cacheDir), name);
|
||||
return found as OptionalPackageModules[N] | null;
|
||||
return loadPresent(name, deps) as OptionalPackageModules[N] | null;
|
||||
}
|
||||
|
||||
function loadPresent(name: FirstUsePackage, deps: OptionalPackageDeps): unknown | null {
|
||||
return (
|
||||
deps.loadBesideCli(name) ?? deps.loadInstalled(optionalPackageDir(name, deps.cacheDir), name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +98,111 @@ export async function loadOptionalPackage<N extends OptionalPackage>(
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
): Promise<OptionalPackageModules[N]> {
|
||||
const present = loadInstalledOptionalPackage(name, deps);
|
||||
return (await loadOrInstall(name, feature, deps)) as OptionalPackageModules[N];
|
||||
}
|
||||
|
||||
type Sharp = typeof import("sharp").default;
|
||||
type FindSharp = (feature: string) => Sharp | null;
|
||||
|
||||
export async function loadSharp(
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
besideCli: FindSharp = requireSharpBesideCli,
|
||||
): Promise<Sharp> {
|
||||
return besideCli(feature) ?? sharpExport(await loadOrInstall("sharp", feature, deps));
|
||||
}
|
||||
|
||||
let backgroundSharpInstall: Promise<unknown> | undefined;
|
||||
|
||||
/** The installed sharp; else rejects now and installs it once in the background for a later call. */
|
||||
export async function loadSharpInBackground(
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
besideCli: FindSharp = requireSharpBesideCli,
|
||||
): Promise<Sharp> {
|
||||
const present = loadInstalledSharp(feature, deps, besideCli);
|
||||
if (present !== null) return present;
|
||||
const install = loadOrInstall("sharp", feature, deps);
|
||||
if (backgroundSharpInstall !== install) {
|
||||
backgroundSharpInstall = install;
|
||||
install.catch((err: unknown) => deps.log((err as Error).message));
|
||||
}
|
||||
const notYet = Symbol("installing");
|
||||
const settled = await Promise.race([install, notYet]);
|
||||
if (settled === notYet)
|
||||
throw new Error(`sharp is not installed yet; installing it once for ${feature}`);
|
||||
return sharpExport(settled);
|
||||
}
|
||||
|
||||
export function loadInstalledSharp(
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
besideCli: FindSharp = requireSharpBesideCli,
|
||||
): Sharp | null {
|
||||
const found = besideCli(feature) ?? loadPresent("sharp", deps);
|
||||
return found === null ? null : sharpExport(found);
|
||||
}
|
||||
|
||||
/** Any version counts, found as require finds it (NODE_PATH included). A copy that fails to load throws. */
|
||||
export function requireSharpBesideCli(feature: string, cliUrl = import.meta.url): Sharp | null {
|
||||
const req = createRequire(cliUrl);
|
||||
let entry: string;
|
||||
try {
|
||||
entry = req.resolve("sharp");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return sharpExport(req(entry));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`${feature} needs sharp, which is installed but could not load (${(err as Error).message}). ` +
|
||||
"Reinstall hyperframes with optional dependencies enabled, or run: npm i sharp",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sharpExport(module: unknown): Sharp {
|
||||
const m = module as Sharp & { default?: Sharp };
|
||||
return m.default ?? m;
|
||||
}
|
||||
|
||||
/** `1` turns off every first-use install; a comma-separated list turns off just those packages. */
|
||||
function installTurnedOff(name: FirstUsePackage): boolean {
|
||||
const value = process.env["HYPERFRAMES_NO_OPTIONAL_INSTALL"]?.trim() ?? "";
|
||||
return value === "1" || value.split(",").some((entry) => entry.trim() === name);
|
||||
}
|
||||
|
||||
const installs = new WeakMap<OptionalPackageDeps, Map<FirstUsePackage, Promise<unknown>>>();
|
||||
|
||||
/** One install per package per process: concurrent and later callers share its result, failure included. */
|
||||
function loadOrInstall(
|
||||
name: FirstUsePackage,
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps,
|
||||
): Promise<unknown> {
|
||||
const present = loadPresent(name, deps);
|
||||
if (present !== null) return Promise.resolve(present);
|
||||
let pending = installs.get(deps);
|
||||
if (!pending) installs.set(deps, (pending = new Map()));
|
||||
let install = pending.get(name);
|
||||
if (!install) pending.set(name, (install = installAndLoad(name, feature, deps)));
|
||||
return install;
|
||||
}
|
||||
|
||||
async function installAndLoad(
|
||||
name: FirstUsePackage,
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps,
|
||||
): Promise<unknown> {
|
||||
const dir = optionalPackageDir(name, deps.cacheDir);
|
||||
|
||||
const version = OPTIONAL_PACKAGES[name];
|
||||
const version = versionOf(name);
|
||||
if (installTurnedOff(name)) {
|
||||
throw new Error(
|
||||
`${name} is not installed, and HYPERFRAMES_NO_OPTIONAL_INSTALL turns off installing it; ${feature} needs it.`,
|
||||
);
|
||||
}
|
||||
deps.log(`installing ${name} for ${feature}, once`);
|
||||
try {
|
||||
await deps.install(dir, name, version);
|
||||
@@ -107,7 +220,7 @@ export async function loadOptionalPackage<N extends OptionalPackage>(
|
||||
if (loaded === null) {
|
||||
throw new Error(`${name}@${version} installed into ${dir} but could not be loaded.`);
|
||||
}
|
||||
return loaded as OptionalPackageModules[N];
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const NETWORK_FAILURE = /ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|ECONNREFUSED|network/i;
|
||||
@@ -253,7 +366,7 @@ export async function install(
|
||||
|
||||
const defaultDeps: OptionalPackageDeps = {
|
||||
cacheDir: CACHE_DIR,
|
||||
loadBesideCli: (name) => loadBesideCli(name),
|
||||
loadBesideCli: (name) => (name === "sharp" ? null : loadBesideCli(name)),
|
||||
loadInstalled,
|
||||
install,
|
||||
log: (line) => console.error(line),
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
splitUrlSuffix,
|
||||
} from "@hyperframes/parsers/asset-paths";
|
||||
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
|
||||
import { transform } from "esbuild";
|
||||
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
import {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
@@ -833,6 +832,7 @@ async function stripJsCommentsParserSafe(
|
||||
provenance?: BundleOptions["scriptProvenance"],
|
||||
): Promise<string> {
|
||||
if (!source) return source;
|
||||
const { transform } = await import("esbuild");
|
||||
try {
|
||||
const result = await transform(source, {
|
||||
loader: "js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildSync } from "esbuild";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -31,6 +31,7 @@ export function buildHyperframesRuntimeScript(
|
||||
const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts");
|
||||
if (!existsSync(entryPath)) return null;
|
||||
|
||||
const { buildSync } = createRequire(import.meta.url)("esbuild") as typeof import("esbuild");
|
||||
const result = buildSync({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
CDPSession,
|
||||
type Browser,
|
||||
type Page,
|
||||
type Protocol,
|
||||
@@ -1421,6 +1420,7 @@ async function constructCaptureSession(
|
||||
// Cap the existing session's inspection buffer; new sessions have separate buffers.
|
||||
const frame = page.mainFrame();
|
||||
// Frame.client is runtime-only, so keep capture working when it is absent.
|
||||
const { CDPSession } = await import("puppeteer-core");
|
||||
if ("client" in frame && frame.client instanceof CDPSession) {
|
||||
await frame.client.send("Network.enable", {
|
||||
maxTotalBufferSize: 32 * 1024 * 1024,
|
||||
|
||||
@@ -189,8 +189,7 @@
|
||||
"hono": "^4.0.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
"sharp": "^0.35.0"
|
||||
"postcss-selector-parser": "^7.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
@@ -198,5 +197,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.35.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,26 @@ import { ThumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator
|
||||
const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_CACHE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
let sharpLoad: Promise<typeof import("sharp").default> | undefined;
|
||||
function loadSharp() {
|
||||
sharpLoad ??= import("sharp").then(
|
||||
(module) => module.default,
|
||||
(error: unknown) => {
|
||||
console.warn("[Studio] JPEG thumbnails are off: sharp could not load:", error);
|
||||
type Sharp = typeof import("sharp").default;
|
||||
|
||||
function retryingSharpLoader(adapter: StudioApiAdapter): () => Promise<Sharp> {
|
||||
let loaded: Promise<Sharp> | undefined;
|
||||
let warned = false;
|
||||
const load = () => adapter.loadSharp?.() ?? import("sharp").then((module) => module.default);
|
||||
return () =>
|
||||
(loaded ??= load().catch((error: unknown) => {
|
||||
loaded = undefined;
|
||||
if (!warned) console.warn("[Studio] JPEG thumbnails are off: sharp could not load:", error);
|
||||
warned = true;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return sharpLoad;
|
||||
}));
|
||||
}
|
||||
|
||||
async function generateThumbnail(path: string, signal: AbortSignal): Promise<Buffer> {
|
||||
async function generateThumbnail(
|
||||
loadSharp: () => Promise<Sharp>,
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Buffer> {
|
||||
const sharp = await loadSharp();
|
||||
const file = await open(path, "r");
|
||||
try {
|
||||
@@ -69,6 +76,7 @@ async function resolveSource(adapter: StudioApiAdapter, id: string, url: string)
|
||||
|
||||
export function registerImageThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const coordinator = new ThumbnailGenerationCoordinator(2);
|
||||
const loadSharp = retryingSharpLoader(adapter);
|
||||
const cache = new Map<string, Buffer>();
|
||||
let cacheBytes = 0;
|
||||
const remember = (key: string, image: Buffer) => {
|
||||
@@ -100,7 +108,7 @@ export function registerImageThumbnailRoutes(api: Hono, adapter: StudioApiAdapte
|
||||
if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429);
|
||||
result =
|
||||
(await coordinator.acquire(key, c.req.raw.signal, async (signal) => {
|
||||
const image = await generateThumbnail(path, signal);
|
||||
const image = await generateThumbnail(loadSharp, path, signal);
|
||||
remember(key, image);
|
||||
return image;
|
||||
})) ?? undefined;
|
||||
|
||||
@@ -40,4 +40,37 @@ describe("studio-server where sharp cannot load", () => {
|
||||
expect((await app.request(url("b.jpg"))).status).toBe(422);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves thumbnails once the host's sharp arrives, warning once before", async () => {
|
||||
const { default: realSharp } = await vi.importActual<typeof import("sharp")>("sharp");
|
||||
const jpeg = await realSharp({
|
||||
create: { width: 64, height: 48, channels: 3, background: "#6495ed" },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const { registerImageThumbnailRoutes } = await import("./imageThumbnail.js");
|
||||
dir = await mkdtemp(join(tmpdir(), "hf-image-late-sharp-"));
|
||||
await writeFile(join(dir, "a.jpg"), jpeg);
|
||||
await writeFile(join(dir, "b.jpg"), jpeg);
|
||||
await writeFile(join(dir, "c.jpg"), jpeg);
|
||||
let installed = false;
|
||||
const adapter = {
|
||||
resolveProject: (id: string) => (id === "p" ? { id, dir } : null),
|
||||
loadSharp: async () => {
|
||||
if (!installed) throw new Error("sharp is not installed yet");
|
||||
return realSharp;
|
||||
},
|
||||
};
|
||||
const app = new Hono();
|
||||
registerImageThumbnailRoutes(app, adapter as unknown as StudioApiAdapter);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const url = (name: string) => `http://localhost/projects/p/image-thumbnail/${name}`;
|
||||
expect((await app.request(url("a.jpg"))).status).toBe(422);
|
||||
expect((await app.request(url("b.jpg"))).status).toBe(422);
|
||||
installed = true;
|
||||
const served = await app.request(url("c.jpg"));
|
||||
expect(served.status).toBe(200);
|
||||
expect(served.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,8 @@ export interface StudioApiAdapter {
|
||||
getProjectSignature?: (projectDir: string) => string;
|
||||
invalidateProjectSignature?: (projectDir: string) => void;
|
||||
|
||||
loadSharp?: () => Promise<typeof import("sharp").default>;
|
||||
|
||||
/** Lint a single HTML string. */
|
||||
lint(
|
||||
html: string,
|
||||
|
||||
@@ -31,4 +31,6 @@ export default defineConfig({
|
||||
splitting: true,
|
||||
clean: true,
|
||||
dts: true,
|
||||
// An optional dependency, so tsup would otherwise inline it.
|
||||
external: ["sharp"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..");
|
||||
|
||||
/** Loaded on first use, so a host may ship the CLI without them and commands that never need them still run. */
|
||||
export const LAZY_PACKAGES = ["esbuild", "puppeteer-core", "sharp"];
|
||||
|
||||
function lazyPackage(specifier) {
|
||||
return LAZY_PACKAGES.find((name) => specifier === name || specifier.startsWith(`${name}/`));
|
||||
}
|
||||
|
||||
export function listStaticLazyImports(source, filename = "chunk.js") {
|
||||
const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true);
|
||||
return sourceFile.statements.flatMap((statement) => {
|
||||
const isImportOrExport = ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement);
|
||||
const specifier = isImportOrExport ? statement.moduleSpecifier : undefined;
|
||||
if (!specifier || !ts.isStringLiteral(specifier) || statement.importClause?.isTypeOnly)
|
||||
return [];
|
||||
const name = lazyPackage(specifier.text);
|
||||
if (!name) return [];
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
|
||||
return [`${filename}:${line + 1} imports ${specifier.text} statically`];
|
||||
});
|
||||
}
|
||||
|
||||
export function checkCliLazyDeps(root = ROOT) {
|
||||
const dist = join(root, "packages/cli/dist");
|
||||
if (!existsSync(dist)) return [`${relative(root, dist)} is missing; build the CLI first`];
|
||||
return readdirSync(dist, { recursive: true })
|
||||
.filter((name) => /\.m?js$/.test(name))
|
||||
.flatMap((name) =>
|
||||
listStaticLazyImports(
|
||||
readFileSync(join(dist, name), "utf8"),
|
||||
relative(root, join(dist, name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const issues = checkCliLazyDeps();
|
||||
if (issues.length > 0) {
|
||||
console.error(`Load ${LAZY_PACKAGES.join(", ")} with a dynamic import where they are used:`);
|
||||
issues.forEach((issue) => console.error(`- ${issue}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`CLI build loads ${LAZY_PACKAGES.join(", ")} only on first use.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { checkCliLazyDeps, listStaticLazyImports } from "./check-cli-lazy-deps.mjs";
|
||||
|
||||
test("flags static imports and re-exports of a lazy package, across lines", () => {
|
||||
const source = [
|
||||
'import sharp from "sharp";',
|
||||
"import {",
|
||||
" CDPSession",
|
||||
'} from "puppeteer-core";',
|
||||
'export { transform } from "esbuild";',
|
||||
'import "esbuild/lib/main.js";',
|
||||
].join("\n");
|
||||
assert.deepEqual(listStaticLazyImports(source, "chunk.js"), [
|
||||
"chunk.js:1 imports sharp statically",
|
||||
"chunk.js:2 imports puppeteer-core statically",
|
||||
"chunk.js:5 imports esbuild statically",
|
||||
"chunk.js:6 imports esbuild/lib/main.js statically",
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows dynamic imports, type-only imports, look-alike names and quoted code", () => {
|
||||
const source = [
|
||||
'const { transform } = await import("esbuild");',
|
||||
'import type { Page } from "puppeteer-core";',
|
||||
'import sharpish from "sharp-ish";',
|
||||
'const doc = `import sharp from "sharp";`;',
|
||||
].join("\n");
|
||||
assert.deepEqual(listStaticLazyImports(source), []);
|
||||
});
|
||||
|
||||
test("scans every built file, nested ones too, and refuses a missing build", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-lazy-deps-"));
|
||||
try {
|
||||
assert.match(checkCliLazyDeps(root)[0], /missing; build the CLI first/);
|
||||
mkdirSync(join(root, "packages/cli/dist/commands"), { recursive: true });
|
||||
writeFileSync(join(root, "packages/cli/dist/cli.js"), 'await import("sharp");');
|
||||
writeFileSync(join(root, "packages/cli/dist/commands/x.js"), 'import sharp from "sharp";');
|
||||
assert.deepEqual(checkCliLazyDeps(root), [
|
||||
"packages/cli/dist/commands/x.js:1 imports sharp statically",
|
||||
]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user