Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/appkit/src/internal/ast-grep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createRequire } from "node:module";

/**
* Lazy, memoized loader for `@ast-grep/napi` (appkit side).
*
* See `packages/shared/src/cli/ast-grep.ts` for the full rationale on why this is
* loaded lazily via `require` rather than a top-level `import` (the native binary
* ships as an optionalDependency and can be absent on a remote-install that omits
* optional deps, in which case `require` throws `MODULE_NOT_FOUND`).
*
* In appkit, ast-grep only powers optional conveniences — serving-endpoint type
* auto-discovery and the dev-only source-location Vite plugin — so every caller
* DEGRADES (skips the feature) when the native binary is unavailable rather than
* failing. That keeps importing the `@databricks/appkit` barrel and booting a
* server safe even when the platform binary was never materialized, regardless of
* how the production server bundle is built.
*
* `createRequire` keeps the call sites synchronous (e.g. the sync serving
* extractor and the sync Vite `transform` hook).
*/
const _require = createRequire(import.meta.url);

let cached: typeof import("@ast-grep/napi") | null | undefined;

/**
* Load `@ast-grep/napi`, or return `null` if its native binary is unavailable on
* this platform. The underlying `require` runs at most once (memoized).
*/
export function tryLoadAstGrep(): typeof import("@ast-grep/napi") | null {
if (cached !== undefined) return cached;
let mod: typeof import("@ast-grep/napi") | null;
try {
mod = _require("@ast-grep/napi");
} catch {
mod = null;
}
Comment on lines +32 to +36
cached = mod;
return mod;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import path from "node:path";
import { Lang, parse, type SgNode } from "@ast-grep/napi";
import type { SgNode } from "@ast-grep/napi";
import MagicString from "magic-string";
import type { Plugin } from "vite";
import { tryLoadAstGrep } from "../../internal/ast-grep";

/** Warn at most once per process when ast-grep is unavailable (dev-only plugin). */
let warnedAstGrepUnavailable = false;

const JSX_ELEMENT_MATCHER = {
rule: {
Expand Down Expand Up @@ -68,6 +72,23 @@ export function reactSourceLocPlugin(
transform(code, id) {
if (!shouldTransform(id)) return;

// Lazy-load ast-grep. If its native binary is unavailable, degrade: skip
// source-location annotation (a dev convenience) instead of crashing the
// dev server. Warn once so the cause is visible without spamming the log.
const astGrep = tryLoadAstGrep();
if (!astGrep) {
if (!warnedAstGrepUnavailable) {
warnedAstGrepUnavailable = true;
console.warn(
"[appkit:react-source-loc] @ast-grep/napi's native binary is " +
`unavailable (${process.platform}-${process.arch}); skipping ` +
"data-source annotation for this dev session.",
);
}
return;
}
const { Lang, parse } = astGrep;

const cleanId = cleanModuleId(id);
const root = parse(Lang.Tsx, code).root();
const s = new MagicString(code);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// Force the lazy ast-grep loader to report the native binary as unavailable.
vi.mock("../../../internal/ast-grep", () => ({
tryLoadAstGrep: vi.fn(() => null),
}));

import { reactSourceLocPlugin } from "../react-source-loc-vite-plugin";

interface TestableHooks {
transform?: (code: string, id: string) => unknown;
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("reactSourceLocPlugin — ast-grep unavailable (degrade)", () => {
// The "warn once" guard is module-level state, so a single test exercises
// both behaviours: each call skips (returns undefined) but only the first
// emits a warning.
it("skips data-source annotation and warns exactly once across calls", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { transform } = reactSourceLocPlugin({
projectRoot: "/app",
}) as unknown as TestableHooks;

expect(transform?.("const a = <div />;", "/app/src/A.tsx")).toBeUndefined();
expect(
transform?.("const b = <span />;", "/app/src/B.tsx"),
).toBeUndefined();

expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0].join(" ")).toContain("native binary is");
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { Lang, parse, type SgNode } from "@ast-grep/napi";
import type { SgNode } from "@ast-grep/napi";
import { tryLoadAstGrep } from "../../internal/ast-grep";
import { createLogger } from "../../logging/logger";
import type { EndpointConfig } from "../../plugins/serving/types";

Expand Down Expand Up @@ -48,6 +49,23 @@ export function extractServingEndpoints(
return null;
}

// Lazy-load ast-grep. If its native binary is unavailable (e.g. an install
// that omitted optional deps), degrade gracefully: callers fall back to the
// explicit `endpoints` option or the DATABRICKS_SERVING_ENDPOINT_NAME env var.
const astGrep = tryLoadAstGrep();
if (!astGrep) {
logger.warn(
"@ast-grep/napi's native binary is unavailable (%s-%s); skipping " +
"serving-endpoint auto-discovery for %s. Pass endpoints explicitly via " +
"appKitServingTypesPlugin({ endpoints }) or set DATABRICKS_SERVING_ENDPOINT_NAME.",
process.platform,
process.arch,
serverFilePath,
);
return null;
}
const { Lang, parse } = astGrep;

const lang = serverFilePath.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript;
const ast = parse(lang, content);
const root = ast.root();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

// Force the lazy ast-grep loader to report the native binary as unavailable.
vi.mock("../../../internal/ast-grep", () => ({
tryLoadAstGrep: vi.fn(() => null),
}));

import { extractServingEndpoints } from "../server-file-extractor";

describe("extractServingEndpoints — ast-grep unavailable (degrade)", () => {
beforeEach(() => {
// Valid server file with inline endpoints; extraction would succeed if
// ast-grep were available, so a null result proves the degrade path ran.
vi.spyOn(fs, "readFileSync").mockReturnValue(
`import { createApp, serving } from "@databricks/appkit";
createApp({ plugins: [serving({ endpoints: { demo: { env: "X" } } })] });`,
);
});

afterEach(() => {
vi.restoreAllMocks();
});

test("returns null instead of throwing", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
expect(extractServingEndpoints("/app/server/index.ts")).toBeNull();
});

test("warns that serving-endpoint auto-discovery was skipped", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
extractServingEndpoints("/app/server/index.ts");
const logged = warn.mock.calls.map((args) => args.join(" ")).join("\n");
expect(logged).toContain("native binary is unavailable");
expect(logged).toContain("/app/server/index.ts");
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { tryLoadAstGrep } from "../../../internal/ast-grep";
import {
extractServingEndpoints,
findServerFile,
} from "../server-file-extractor";

// ast-grep is now loaded lazily (and memoized) the first time it is needed.
// Warm that cache with the real `fs` up front, because the tests below stub
// `fs.readFileSync` globally — and Node's underlying `require` reads module
// files through `fs`, so a stub active at first-load time would break the
// native-module require and, via memoization, poison every later test.
beforeAll(() => {
tryLoadAstGrep();
});

describe("findServerFile", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down
54 changes: 54 additions & 0 deletions packages/shared/src/cli/ast-grep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test, vi } from "vitest";

// Simulate a platform where @ast-grep/napi's native binary was never
// materialized (e.g. an Apps remote-install that omitted optional deps):
// createRequire hands back a require() that throws MODULE_NOT_FOUND for the
// package, exactly like the real failure on deploy.
vi.mock("node:module", async () => {
const actual =
await vi.importActual<typeof import("node:module")>("node:module");
return {
...actual,
createRequire: (...args: Parameters<typeof actual.createRequire>) => {
const realRequire = actual.createRequire(...args);
const fakeRequire = ((id: string) => {
if (id === "@ast-grep/napi") {
const err = new Error(
"Cannot find module '@ast-grep/napi-linux-x64-gnu'",
) as NodeJS.ErrnoException;
err.code = "MODULE_NOT_FOUND";
throw err;
}
return realRequire(id);
}) as NodeJS.Require;
return fakeRequire;
},
};
});

const { AstGrepUnavailableError, loadAstGrepOrThrow, tryLoadAstGrep } =
await import("./ast-grep");

describe("@ast-grep/napi lazy loader — native binary unavailable", () => {
test("tryLoadAstGrep() returns null instead of throwing", () => {
expect(tryLoadAstGrep()).toBeNull();
});

test("loadAstGrepOrThrow() throws an AstGrepUnavailableError", () => {
expect(() => loadAstGrepOrThrow()).toThrow(AstGrepUnavailableError);
});

test("the thrown message is actionable, not a raw MODULE_NOT_FOUND stack", () => {
let message = "";
try {
loadAstGrepOrThrow();
} catch (error) {
message = (error as Error).message;
}
// Names the package and the concrete remedy...
expect(message).toContain("@ast-grep/napi");
expect(message).toContain("--include=optional");
// ...and never surfaces the opaque native-binary failure to the user.
expect(message).not.toContain("MODULE_NOT_FOUND");
});
});
72 changes: 72 additions & 0 deletions packages/shared/src/cli/ast-grep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createRequire } from "node:module";

/**
* Lazy, memoized loader for `@ast-grep/napi` (shared CLI side).
*
* `@ast-grep/napi` is a native (N-API) module whose platform binary ships as an
* optionalDependency (`@ast-grep/napi-<platform>`). When that binary is not
* materialized — e.g. a Databricks Apps remote-install that omits optional
* dependencies — the parent package's `index.js` throws `MODULE_NOT_FOUND` the
* moment it is `require`d. A top-level static import of the package therefore
* crashes the whole CLI during module evaluation, before any command action can
* run (which is why a command-level try/catch never fires — you get a raw stack).
*
* Loading it lazily through `require` instead (a) keeps it off the module-eval
* path, so merely loading the CLI never touches the native binary, and (b) lets
* us catch a missing binary and turn it into either a graceful degrade
* ({@link tryLoadAstGrep}) or a clear, actionable error
* ({@link loadAstGrepOrThrow}) rather than an opaque stack trace.
*
* `createRequire` (rather than dynamic `await import()`) is deliberate: it keeps
* the call sites synchronous, so the sync parse-using functions stay sync with no
* async-propagation refactor.
*/
const _require = createRequire(import.meta.url);

let cached: typeof import("@ast-grep/napi") | null | undefined;

/**
* Thrown when `@ast-grep/napi`'s native binary is unavailable and the caller
* requires it. Carries an actionable message; CLI commands catch this to print
* the message (not a raw `MODULE_NOT_FOUND` stack) and exit non-zero.
*/
export class AstGrepUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "AstGrepUnavailableError";
}
}

/**
* Load `@ast-grep/napi`, or return `null` if its native binary is unavailable on
* this platform. The underlying `require` runs at most once (memoized).
*/
export function tryLoadAstGrep(): typeof import("@ast-grep/napi") | null {
if (cached !== undefined) return cached;
let mod: typeof import("@ast-grep/napi") | null;
try {
mod = _require("@ast-grep/napi");
} catch {
mod = null;
}
Comment on lines +47 to +51
cached = mod;
return mod;
}

/**
* Load `@ast-grep/napi`, or throw an {@link AstGrepUnavailableError} with an
* actionable message. Use from commands that cannot function without it
* (`lint`, `plugin sync`, `codemod`).
*/
export function loadAstGrepOrThrow(): typeof import("@ast-grep/napi") {
const mod = tryLoadAstGrep();
if (!mod) {
throw new AstGrepUnavailableError(
"@ast-grep/napi's native binary is unavailable for this platform " +
`(${process.platform}-${process.arch}). Reinstall with optional ` +
"dependencies enabled (e.g. `npm install --include=optional`) so the " +
"platform package (@ast-grep/napi-<platform>) is materialized.",
);
}
return mod;
}
Loading
Loading