Skip to content

Commit 6f3aaba

Browse files
committed
perf(oxfmt): Use worker_threads by tinypool for prettier formatting (#16618)
Part of #16606 - Use `worker_threads` via `tinypool` lib - Until now, the Rust side had been operating entirely in a multithreaded manner, while the JS side remained single-threaded, resulting in sequential execution and waiting - Number of threads are the same value for Rust side - Also can be applied to embedded formatting path - Since `tinypool` couldn't be bundled, defined as `dependencies` in the `npm/oxfmt/package.json`
1 parent feffe48 commit 6f3aaba

10 files changed

Lines changed: 130 additions & 69 deletions

File tree

apps/oxfmt/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"node": "^20.19.0 || >=22.12.0"
2121
},
2222
"dependencies": {
23-
"prettier": "3.7.4"
23+
"prettier": "3.7.4",
24+
"tinypool": "2.0.0"
2425
},
2526
"devDependencies": {
2627
"@types/node": "catalog:",

apps/oxfmt/src-js/bindings.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
*
1212
* Returns `true` if formatting succeeded without errors, `false` otherwise.
1313
*/
14-
export declare function format(args: Array<string>, setupConfigCb: (configJSON: string) => Promise<string[]>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (parserName: string, fileName: string, code: string) => Promise<string>): Promise<boolean>
14+
export declare function format(args: Array<string>, setupConfigCb: (configJSON: string, numThreads: number) => Promise<string[]>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (parserName: string, fileName: string, code: string) => Promise<string>): Promise<boolean>
Lines changed: 27 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,34 @@
1-
import type { Options } from "prettier";
1+
import Tinypool from "tinypool";
2+
import type { WorkerData, FormatEmbeddedCodeArgs, FormatFileArgs } from "./prettier-worker.ts";
23

3-
// Import Prettier lazily.
4-
// This helps to reduce initial load time if not needed.
5-
//
6-
// Also, this solves unknown issue described below...
7-
//
8-
// XXX: If import `prettier` directly here, it will add line like this to the output JS:
9-
// ```js
10-
// import process2 from 'process';
11-
// ```
12-
// Yes, this seems completely fine!
13-
// But actually, this makes `oxfmt --lsp` immediately stop with `Parse error` JSON-RPC error
14-
let prettierCache: typeof import("prettier");
15-
16-
// Cache for Prettier options.
17-
// Set by `setupConfig` function once.
18-
//
19-
// Read `.oxfmtrc.json(c)` directly does not work,
20-
// because our brand new defaults are not compatible with Prettier's defaults.
21-
// So we need to pass the config from Rust side after merging with our defaults.
22-
let configCache: Options = {};
4+
// Worker pool for parallel Prettier formatting
5+
let pool: Tinypool | null = null;
236

247
// ---
258

269
/**
2710
* Setup Prettier configuration.
2811
* NOTE: Called from Rust via NAPI ThreadsafeFunction with FnArgs
2912
* @param configJSON - Prettier configuration as JSON string
13+
* @param numThreads - Number of worker threads to use (same as Rayon thread count)
3014
* @returns Array of loaded plugin's `languages` info
3115
* */
32-
export async function setupConfig(configJSON: string): Promise<string[]> {
33-
// NOTE: `napi-rs` has ability to pass `Object` directly.
34-
// But since we don't know what options various plugins may specify,
35-
// we have to receive it as a JSON string and parse it.
36-
//
37-
// SAFETY: This is valid JSON string generated in Rust side
38-
configCache = JSON.parse(configJSON) as Options;
16+
export async function setupConfig(configJSON: string, numThreads: number): Promise<string[]> {
17+
const workerData: WorkerData = {
18+
// SAFETY: Always valid JSON constructed by Rust side
19+
prettierConfig: JSON.parse(configJSON),
20+
};
21+
22+
if (pool) throw new Error("`setupConfig()` has already been called");
23+
24+
// Initialize worker pool for parallel Prettier formatting
25+
// Pass config via workerData so all workers get it on initialization
26+
pool = new Tinypool({
27+
filename: new URL("./prettier-worker.js", import.meta.url).href,
28+
minThreads: numThreads,
29+
maxThreads: numThreads,
30+
workerData,
31+
});
3932

4033
// TODO: Plugins support
4134
// - Read `plugins` field
@@ -51,14 +44,11 @@ const TAG_TO_PARSER: Record<string, string> = {
5144
// CSS
5245
css: "css",
5346
styled: "css",
54-
5547
// GraphQL
5648
gql: "graphql",
5749
graphql: "graphql",
58-
5950
// HTML
6051
html: "html",
61-
6252
// Markdown
6353
md: "markdown",
6454
markdown: "markdown",
@@ -74,22 +64,14 @@ const TAG_TO_PARSER: Record<string, string> = {
7464
export async function formatEmbeddedCode(tagName: string, code: string): Promise<string> {
7565
const parser = TAG_TO_PARSER[tagName];
7666

67+
// Unknown tag, return original code
7768
if (!parser) {
78-
// Unknown tag, return original code
7969
return code;
8070
}
8171

82-
if (!prettierCache) {
83-
prettierCache = await import("prettier");
84-
}
85-
86-
return prettierCache
87-
.format(code, {
88-
...configCache,
89-
parser,
90-
})
91-
.then((formatted) => formatted.trimEnd())
92-
.catch(() => code);
72+
return pool!.run({ parser, code } satisfies FormatEmbeddedCodeArgs, {
73+
name: "formatEmbeddedCode",
74+
});
9375
}
9476

9577
// ---
@@ -107,13 +89,7 @@ export async function formatFile(
10789
fileName: string,
10890
code: string,
10991
): Promise<string> {
110-
if (!prettierCache) {
111-
prettierCache = await import("prettier");
112-
}
113-
114-
return prettierCache.format(code, {
115-
...configCache,
116-
parser: parserName,
117-
filepath: fileName,
92+
return pool!.run({ parserName, fileName, code } satisfies FormatFileArgs, {
93+
name: "formatFile",
11894
});
11995
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { workerData } from "node:worker_threads";
2+
import type { Options } from "prettier";
3+
4+
// Lazy load Prettier in each worker thread
5+
//
6+
// NOTE: In the past, statically importing caused issues with `oxfmt --lsp` not starting.
7+
// However, this issue has not been observed recently, possibly due to changes in the bundling configuration.
8+
// Nevertheless, we will keep it as lazy loading just in case.
9+
let prettierCache: typeof import("prettier");
10+
11+
export type WorkerData = {
12+
prettierConfig: Options;
13+
};
14+
15+
// Initialize config from `workerData` (passed during pool creation)
16+
// NOTE: The 1st element is thread id, passed by `tinypool`
17+
const [, { prettierConfig }] = workerData satisfies [unknown, WorkerData];
18+
19+
// ---
20+
21+
export type FormatEmbeddedCodeArgs = {
22+
parser: string;
23+
code: string;
24+
};
25+
26+
export async function formatEmbeddedCode({
27+
parser,
28+
code,
29+
}: FormatEmbeddedCodeArgs): Promise<string> {
30+
if (!prettierCache) {
31+
prettierCache = await import("prettier");
32+
}
33+
34+
return prettierCache
35+
.format(code, {
36+
...prettierConfig,
37+
parser,
38+
})
39+
.then((formatted) => formatted.trimEnd())
40+
.catch(() => code);
41+
}
42+
43+
// ---
44+
45+
export type FormatFileArgs = {
46+
parserName: string;
47+
fileName: string;
48+
code: string;
49+
};
50+
51+
export async function formatFile({ parserName, fileName, code }: FormatFileArgs): Promise<string> {
52+
if (!prettierCache) {
53+
prettierCache = await import("prettier");
54+
}
55+
56+
return prettierCache.format(code, {
57+
...prettierConfig,
58+
parser: parserName,
59+
filepath: fileName,
60+
});
61+
}

apps/oxfmt/src/cli/format.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ impl FormatRunner {
6060
let cwd = self.cwd;
6161
let FormatCommand { paths, output_options, basic_options, ignore_options, misc_options } =
6262
self.options;
63+
let num_of_threads = rayon::current_num_threads();
6364

6465
// Find config file
6566
// NOTE: Currently, we only load single config file.
@@ -90,7 +91,7 @@ impl FormatRunner {
9091
.external_formatter
9192
.as_ref()
9293
.expect("External formatter must be set when `napi` feature is enabled")
93-
.setup_config(&external_config.to_string())
94+
.setup_config(&external_config.to_string(), num_of_threads)
9495
{
9596
print_and_flush(
9697
stderr,
@@ -131,8 +132,6 @@ impl FormatRunner {
131132
print_and_flush(stdout, "\n");
132133
}
133134

134-
let num_of_threads = rayon::current_num_threads();
135-
136135
// Create `SourceFormatter` instance
137136
let source_formatter = SourceFormatter::new(num_of_threads, format_options);
138137
#[cfg(feature = "napi")]

apps/oxfmt/src/core/external_formatter.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ use tokio::task::block_in_place;
1010
use oxc_formatter::EmbeddedFormatterCallback;
1111

1212
/// Type alias for the setup config callback function signature.
13-
/// Takes config_json as argument and returns plugin languages.
13+
/// Takes (config_json, num_threads) as arguments and returns plugin languages.
1414
pub type JsSetupConfigCb = ThreadsafeFunction<
1515
// Input arguments
16-
FnArgs<(String,)>, // (config_json,)
16+
FnArgs<(String, u32)>, // (config_json, num_threads)
1717
// Return type (what JS function returns)
1818
Promise<Vec<String>>,
1919
// Arguments (repeated)
20-
FnArgs<(String,)>,
20+
FnArgs<(String, u32)>,
2121
// Error status
2222
Status,
2323
// CalleeHandled
@@ -59,8 +59,8 @@ pub type JsFormatFileCb = ThreadsafeFunction<
5959
type FormatFileCallback = Arc<dyn Fn(&str, &str, &str) -> Result<String, String> + Send + Sync>;
6060

6161
/// Callback function type for setup config.
62-
/// Takes config_json and returns plugin languages.
63-
type SetupConfigCallback = Arc<dyn Fn(&str) -> Result<Vec<String>, String> + Send + Sync>;
62+
/// Takes (config_json, num_threads) and returns plugin languages.
63+
type SetupConfigCallback = Arc<dyn Fn(&str, usize) -> Result<Vec<String>, String> + Send + Sync>;
6464

6565
/// External formatter that wraps a JS callback.
6666
#[derive(Clone)]
@@ -98,8 +98,12 @@ impl ExternalFormatter {
9898
}
9999

100100
/// Setup Prettier config using the JS callback.
101-
pub fn setup_config(&self, config_json: &str) -> Result<Vec<String>, String> {
102-
(self.setup_config)(config_json)
101+
pub fn setup_config(
102+
&self,
103+
config_json: &str,
104+
num_threads: usize,
105+
) -> Result<Vec<String>, String> {
106+
(self.setup_config)(config_json, num_threads)
103107
}
104108

105109
/// Convert this external formatter to the oxc_formatter::EmbeddedFormatter type
@@ -125,10 +129,13 @@ impl ExternalFormatter {
125129
/// Wrap JS `setupConfig` callback as a normal Rust function.
126130
// NOTE: Use `block_in_place()` because this is called from a sync context, unlike the others
127131
fn wrap_setup_config(cb: JsSetupConfigCb) -> SetupConfigCallback {
128-
Arc::new(move |config_json: &str| {
132+
Arc::new(move |config_json: &str, num_threads: usize| {
129133
block_in_place(|| {
130134
tokio::runtime::Handle::current().block_on(async {
131-
let status = cb.call_async(FnArgs::from((config_json.to_string(),))).await;
135+
#[expect(clippy::cast_possible_truncation)]
136+
let status = cb
137+
.call_async(FnArgs::from((config_json.to_string(), num_threads as u32)))
138+
.await;
132139
match status {
133140
Ok(promise) => match promise.await {
134141
Ok(languages) => Ok(languages),

apps/oxfmt/src/main_napi.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use crate::{
2929
#[napi]
3030
pub async fn format(
3131
args: Vec<String>,
32-
#[napi(ts_arg_type = "(configJSON: string) => Promise<string[]>")]
32+
#[napi(ts_arg_type = "(configJSON: string, numThreads: number) => Promise<string[]>")]
3333
setup_config_cb: JsSetupConfigCb,
3434
#[napi(ts_arg_type = "(tagName: string, code: string) => Promise<string>")]
3535
format_embedded_cb: JsFormatEmbeddedCb,

apps/oxfmt/tsdown.config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { defineConfig } from "tsdown";
22

33
export default defineConfig({
4-
entry: ["src-js/index.ts", "src-js/cli.ts"],
4+
// Build all entry points together to share Prettier chunks
5+
entry: ["src-js/index.ts", "src-js/cli.ts", "src-js/prettier-worker.ts"],
56
format: "esm",
67
platform: "node",
78
target: "node20",
@@ -10,5 +11,11 @@ export default defineConfig({
1011
outDir: "dist",
1112
shims: false,
1213
fixedExtension: false,
13-
noExternal: ["prettier"],
14+
noExternal: [
15+
// Bundle it to control version
16+
"prettier",
17+
// Cannot bundle: worker.js runs in separate thread and can't resolve bundled chunks
18+
// Be sure to add it to "dependencies" in `npm/oxfmt/package.json`!
19+
// "tinypool",
20+
],
1421
});

npm/oxfmt/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"funding": {
2222
"url": "https://github.com/sponsors/Boshen"
2323
},
24+
"dependencies": {
25+
"tinypool": "2.0.0"
26+
},
2427
"engines": {
2528
"node": "^20.19.0 || >=22.12.0"
2629
},

pnpm-lock.yaml

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)