diff --git a/apps/oxfmt/src-js/bindings.d.ts b/apps/oxfmt/src-js/bindings.d.ts index 6b35cca5eb9ff..1047e3faebbd3 100644 --- a/apps/oxfmt/src-js/bindings.d.ts +++ b/apps/oxfmt/src-js/bindings.d.ts @@ -34,7 +34,7 @@ export declare const enum Severity { * # Panics * Panics if the current working directory cannot be determined. */ -export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise +export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise export interface FormatResult { /** The formatted code. */ @@ -49,7 +49,7 @@ export interface FormatResult { * This API is specialized for JS/TS snippets embedded in non-JS files. * Unlike `format()`, it is called only for js-in-xxx `textToDoc()` flow. */ -export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise +export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise /** * NAPI based JS CLI entry point. @@ -66,4 +66,4 @@ export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmt * - `mode`: If main logic will run in JS side, use this to indicate which mode * - `exitCode`: If main logic already ran in Rust side, return the exit code */ -export declare function runCli(args: Array, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindcssClassesCb: (options: Record, classes: string[]) => Promise): Promise<[string, number | undefined | null]> +export declare function runCli(args: Array, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>, formatFileCb: (options: Record, code: string) => Promise, sortTailwindcssClassesCb: (options: Record, classes: string[]) => Promise): Promise<[string, number | undefined | null]> diff --git a/apps/oxfmt/src-js/cli.ts b/apps/oxfmt/src-js/cli.ts index 6ab1489fb9664..e6a406a25639d 100644 --- a/apps/oxfmt/src-js/cli.ts +++ b/apps/oxfmt/src-js/cli.ts @@ -55,13 +55,4 @@ void (async () => { // `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies. // https://nodejs.org/api/process.html#processexitcode process.exitCode = exitCode!; - - // Node.js < 25.4.0 has a race condition with ThreadsafeFunction cleanup that causes - // crashes on large codebases. Add a small delay to allow pending NAPI operations - // to complete before exit. Fixed in Node.js 25.4.0+. - // See: https://github.com/nodejs/node/issues/55706 - const [major, minor] = process.versions.node.split(".").map(Number); - if (major < 25 || (major === 25 && minor < 4)) { - setTimeout(() => process.exit(), 50); - } })(); diff --git a/apps/oxfmt/src-js/cli/worker-proxy.ts b/apps/oxfmt/src-js/cli/worker-proxy.ts index affab989513fc..76d6747a39f3e 100644 --- a/apps/oxfmt/src-js/cli/worker-proxy.ts +++ b/apps/oxfmt/src-js/cli/worker-proxy.ts @@ -2,6 +2,7 @@ import Tinypool from "tinypool"; import { resolvePlugins } from "../libs/apis"; import type { FormatEmbeddedCodeParam, + FormatEmbeddedCodeResult, FormatFileParam, SortTailwindClassesArgs, } from "../libs/apis"; @@ -34,10 +35,13 @@ export async function disposeExternalFormatter(): Promise { export async function formatEmbeddedCode( options: FormatEmbeddedCodeParam["options"], code: string, -): Promise { +): Promise { return pool! - .run({ options, code } satisfies FormatEmbeddedCodeParam, { name: "formatEmbeddedCode" }) - .catch(rethrowAsError); + .run({ options, code } satisfies FormatEmbeddedCodeParam, { + name: "formatEmbeddedCode", + }) + .then((formatted) => ({ ok: true, code: formatted })) + .catch((err) => ({ ok: false, error: errorToMessage(err) })); } export async function formatFile( @@ -68,10 +72,19 @@ export async function sortTailwindClasses( function rethrowAsError(err: unknown): never { if (err instanceof Error) throw err; if (err !== null && typeof err === "object") { - const obj = err as { name: string; message: string }; - const newErr = new Error(obj.message); - newErr.name = obj.name; + const obj = err as { name?: unknown; message?: unknown }; + const newErr = new Error(errorToMessage(err)); + if (typeof obj.name === "string") newErr.name = obj.name; throw newErr; } throw new Error(String(err)); } + +function errorToMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err !== null && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return String(err); +} diff --git a/apps/oxfmt/src-js/index.ts b/apps/oxfmt/src-js/index.ts index 98ecd6fec9d09..9fe8fb3969771 100644 --- a/apps/oxfmt/src-js/index.ts +++ b/apps/oxfmt/src-js/index.ts @@ -1,5 +1,10 @@ import { format as napiFormat, jsTextToDoc as napiJsTextToDoc } from "./bindings"; -import { resolvePlugins, formatEmbeddedCode, formatFile, sortTailwindClasses } from "./libs/apis"; +import { + resolvePlugins, + formatEmbeddedCodeSafe, + formatFile, + sortTailwindClasses, +} from "./libs/apis"; import type { Options } from "prettier"; // napi-JS `oxfmt` API entry point @@ -17,7 +22,7 @@ export async function format(fileName: string, sourceText: string, options?: For sourceText, options ?? {}, resolvePlugins, - (options, code) => formatEmbeddedCode({ options, code }), + (options, code) => formatEmbeddedCodeSafe({ options, code }), (options, code) => formatFile({ options, code }), (options, classes) => sortTailwindClasses({ options, classes }), ); @@ -38,7 +43,7 @@ export async function jsTextToDoc( oxfmtPluginOptionsJson, parentContext, resolvePlugins, - (options, code) => formatEmbeddedCode({ options, code }), + (options, code) => formatEmbeddedCodeSafe({ options, code }), (_options, _code) => Promise.reject(/* Unreachable */), (options, classes) => sortTailwindClasses({ options, classes }), ); diff --git a/apps/oxfmt/src-js/libs/apis.ts b/apps/oxfmt/src-js/libs/apis.ts index 540fd6766c070..9e82a78ad971b 100644 --- a/apps/oxfmt/src-js/libs/apis.ts +++ b/apps/oxfmt/src-js/libs/apis.ts @@ -49,6 +49,8 @@ export type FormatEmbeddedCodeParam = { options: Options; }; +export type FormatEmbeddedCodeResult = { ok: true; code: string } | { ok: false; error: string }; + /** * Format xxx-in-js code snippets * @@ -72,6 +74,21 @@ export async function formatEmbeddedCode({ return prettier.format(code, options); } +/** + * `formatEmbeddedCode()` wrapper that never rejects. + * Rust side receives a resolved object and handles fallback behavior there. + */ +export async function formatEmbeddedCodeSafe( + args: FormatEmbeddedCodeParam, +): Promise { + try { + const code = await formatEmbeddedCode(args); + return { ok: true, code }; + } catch (err) { + return { ok: false, error: errorToMessage(err) }; + } +} + // --- export type FormatFileParam = { @@ -191,3 +208,12 @@ async function setupOxfmtPlugin(options: Options): Promise { options.plugins ??= []; options.plugins.push(oxcPlugin); } + +function errorToMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err !== null && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return String(err); +} diff --git a/apps/oxfmt/src/core/external_formatter.rs b/apps/oxfmt/src/core/external_formatter.rs index 31bbb8816c8b4..562937d698ff5 100644 --- a/apps/oxfmt/src/core/external_formatter.rs +++ b/apps/oxfmt/src/core/external_formatter.rs @@ -28,13 +28,13 @@ pub type JsInitExternalFormatterCb = ThreadsafeFunction< >; /// Type alias for the callback function signature. -/// Takes (options, code) as arguments and returns formatted code. +/// Takes (options, code) as arguments and returns a wrapped result. /// The `options` object includes `parser` field set by Rust side. pub type JsFormatEmbeddedCb = ThreadsafeFunction< // Input arguments FnArgs<(Value, String)>, // (options, code) // Return type (what JS function returns) - Promise, + Promise, // Arguments (repeated) FnArgs<(Value, String)>, // Error status @@ -141,7 +141,7 @@ impl std::fmt::Debug for ExternalFormatter { impl ExternalFormatter { /// Create an [`ExternalFormatter`] from JS callbacks. /// - /// The ThreadsafeFunctions are wrapped in `Arc>>` to allow + /// The ThreadsafeFunctions are wrapped in `Arc>>` to allow /// explicit cleanup via the `cleanup()` method. This prevents use-after-free /// crashes on Node.js exit when V8 cleans up global handles. pub fn new( @@ -351,7 +351,7 @@ fn wrap_format_embedded( let status = cb.call_async(FnArgs::from((options, code.to_string()))).await; match status { Ok(promise) => match promise.await { - Ok(formatted_code) => Ok(formatted_code), + Ok(result) => parse_embedded_callback_result(result), Err(err) => Err(err.reason.clone()), }, Err(err) => Err(err.reason.clone()), @@ -362,6 +362,39 @@ fn wrap_format_embedded( }) } +/// Parse embedded formatter callback result. +/// +/// New callback contract: +/// - `{ ok: true, code: string }` on success +/// - `{ ok: false, error: string }` on recoverable formatting failure +/// +/// Legacy fallback: +/// - plain `string` is also accepted as success. +fn parse_embedded_callback_result(value: Value) -> Result { + let invalid_response = + || "Invalid embedded formatter response (expected `{ ok, code|error }`)".to_string(); + + match value { + Value::String(code) => Ok(code), + Value::Object(map) => { + let Some(ok) = map.get("ok").and_then(Value::as_bool) else { + return Err(invalid_response()); + }; + if ok { + map.get("code") + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(invalid_response) + } else { + let err = + map.get("error").and_then(Value::as_str).unwrap_or("Unknown embedded error"); + Err(err.to_string()) + } + } + _ => Err(invalid_response()), + } +} + /// Wrap JS `formatFile` callback as a normal Rust function. /// The `options` Value is received with `parser` and `filepath` already set by the caller. fn wrap_format_file( diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index 722c5ab24547b..461d68ba9cdc5 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -36,7 +36,9 @@ pub async fn run_cli( args: Vec, #[napi(ts_arg_type = "(numThreads: number) => Promise")] init_external_formatter_cb: JsInitExternalFormatterCb, - #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] + #[napi( + ts_arg_type = "(options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>" + )] format_embedded_cb: JsFormatEmbeddedCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb, @@ -140,7 +142,9 @@ pub async fn format( options: Option, #[napi(ts_arg_type = "(numThreads: number) => Promise")] init_external_formatter_cb: JsInitExternalFormatterCb, - #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] + #[napi( + ts_arg_type = "(options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>" + )] format_embedded_cb: JsFormatEmbeddedCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb, @@ -176,7 +180,9 @@ pub async fn js_text_to_doc( parent_context: String, #[napi(ts_arg_type = "(numThreads: number) => Promise")] init_external_formatter_cb: JsInitExternalFormatterCb, - #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] + #[napi( + ts_arg_type = "(options: Record, code: string) => Promise<{ ok: boolean; code?: string; error?: string }>" + )] format_embedded_cb: JsFormatEmbeddedCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb,