Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/parallel-template-encode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lynx-js/template-webpack-plugin": patch
---

Run TASM template encoding in a shared `tinypool` worker pool so multi-entry builds encode in parallel and watch-mode rebuilds reuse warm workers.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as CSS from '@lynx-js/css-serializer';
import { cssChunksToMap } from '@lynx-js/css-serializer';
import { Plugins } from '@lynx-js/css-serializer';
import { SyncWaterfallHook } from '@rspack/lite-tapable';
import Tinypool from 'tinypool';

export { CSS }

Expand Down Expand Up @@ -68,6 +69,7 @@ export class LynxEncodePlugin {
static BEFORE_ENCODE_STAGE: number;
static defaultOptions: Readonly<Required<LynxEncodePluginOptions>>;
static ENCODE_STAGE: number;
static encodePool: Tinypool;
// (undocumented)
protected options?: LynxEncodePluginOptions | undefined;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/webpack/template-webpack-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"@lynx-js/webpack-runtime-globals": "workspace:^",
"@rspack/lite-tapable": "1.1.0",
"css-tree": "^3.1.0",
"object.groupby": "^1.0.3"
"object.groupby": "^1.0.3",
"tinypool": "^2.1.0"
},
"devDependencies": {
"@lynx-js/test-tools": "workspace:*",
Expand All @@ -56,6 +57,6 @@
"webpack": "^5.105.2"
},
"engines": {
"node": ">=18"
"node": "^18.14 || >=19.4"
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { createRequire } from 'node:module';
import { availableParallelism } from 'node:os';
import { pathToFileURL } from 'node:url';

import Tinypool from 'tinypool';
import type { Chunk, Compiler } from 'webpack';

import type { EncodeResult } from '@lynx-js/tasm';

import {
collectCSSSourceMapContents,
processTasmCSSDiagnostics,
} from './cssDiagnostics.js';
import { LynxTemplatePlugin } from './LynxTemplatePlugin.js';
import { getRequireModuleAsyncCachePolyfill } from './polyfill/requireModuleAsync.js';
import type { EncodeWorkerOptions } from './worker/encode.js';

const require = createRequire(import.meta.url);

const ENCODE_WORKER_PATH = require.resolve('../lib/worker/encode.js');

// https://github.com/web-infra-dev/rsbuild/blob/main/packages/core/src/types/config.ts#L1029
type InlineChunkTestFunction = (params: {
Expand Down Expand Up @@ -49,6 +60,17 @@ export class LynxEncodePlugin {
* The stage of the beforeEmit hook.
*/
static BEFORE_EMIT_STAGE = 256;
/**
* Shared TASM encode worker pool: multiple entries (and multiple
* `LynxEncodePlugin` instances in the same process) share its worker
* slots for parallel encode; watch-mode rebuilds keep the same workers
* warm. `availableParallelism()` honors cgroup CPU limits (containers,
* CI runners), so we don't need to subtract a core ourselves.
*/
static encodePool: Tinypool = new Tinypool({
filename: pathToFileURL(ENCODE_WORKER_PATH).href,
maxThreads: availableParallelism(),
});
Comment thread
upupming marked this conversation as resolved.
constructor(protected options?: LynxEncodePluginOptions | undefined) {}

/**
Expand Down Expand Up @@ -228,17 +250,18 @@ export class LynxEncodePluginImpl {
}, async (args) => {
const { encodeOptions } = args;

const { getEncodeMode } = await import('@lynx-js/tasm');

const encode = getEncodeMode();
// TODO: lynx-js/tasm should add css_diagnostics type
// @ts-expect-error ignore css_diagnostics type
const { buffer, lepus_debug, css_diagnostics } = await Promise.resolve(
encode(encodeOptions),
const { buffer, lepus_debug, css_diagnostics } = await (
LynxEncodePlugin.encodePool.run(
{ encodeOptions } as EncodeWorkerOptions,
) as Promise<EncodeResult>
);

return {
buffer,
// worker will serialize the buffer to a Uint8Array
// convert it back to a Buffer
buffer: Buffer.from(buffer),
debugInfo: lepus_debug,
cssDiagnostics: css_diagnostics as string,
};
Expand Down
26 changes: 26 additions & 0 deletions packages/webpack/template-webpack-plugin/src/worker/encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import type { EncodeResult } from '@lynx-js/tasm';

export interface EncodeWorkerOptions {
encodeBinary?: string | undefined;
encodeOptions: unknown;
tasmPkg?: string;
}

export default async function encode(
{
encodeBinary = undefined,
encodeOptions,
tasmPkg = '@lynx-js/tasm',
}: EncodeWorkerOptions,
): Promise<EncodeResult> {
const { getEncodeMode } =
(await import(tasmPkg)) as typeof import('@lynx-js/tasm');
// Napi will be used if supported
const encode = getEncodeMode(encodeBinary) as (
options: unknown,
) => Promise<EncodeResult>;
return encode(encodeOptions);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { dirname } from 'node:path';

import { describe, expect, test } from '@rstest/core';
import webpack from 'webpack';

import { LynxEncodePlugin, LynxTemplatePlugin } from '../src/index.js';

const context = dirname(new URL(import.meta.url).pathname);

function runWebpack(config: webpack.Configuration): Promise<webpack.Stats> {
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) return reject(err);
if (!stats) return reject(new Error('webpack returned empty stats'));
resolve(stats);
compiler.close(() => void 0);
});
});
}

describe('LynxEncodePlugin shared worker pool', () => {
// The static `LynxEncodePlugin.encodePool` is a process-wide singleton.
// These tests rely on rstest running each file in its own worker process,
// so the pool starts fresh for this file and the assertions below see
// monotonic state.

test('runs multi-entry encodes in parallel via the shared pool', async () => {
const completedBefore = LynxEncodePlugin.encodePool.completed;

const stats = await runWebpack({
context,
mode: 'development',
devtool: false,
output: { iife: false, filename: '[name].js' },
entry: {
a: './fixtures/basic.tsx',
b: './fixtures/basic.tsx',
},
plugins: [
new LynxTemplatePlugin(),
new LynxEncodePlugin(),
],
});

expect(stats.compilation.errors).toEqual([]);

// Two entries → two encode tasks went through the pool.
expect(LynxEncodePlugin.encodePool.completed - completedBefore).toBe(2);

// Pool grew to (or already had) at least two threads to run them in
// parallel rather than serializing on a single worker.
expect(LynxEncodePlugin.encodePool.threads.length).toBeGreaterThanOrEqual(
2,
);

const { assets } = stats.toJson({ all: false, assets: true });
expect(assets?.find(i => i.name === 'a.js')).not.toBeUndefined();
expect(assets?.find(i => i.name === 'b.js')).not.toBeUndefined();
});

test('subsequent compile reuses warm workers (no respawn)', async () => {
const warmIds = new Set(
LynxEncodePlugin.encodePool.threads.map(t => t.threadId),
);
expect(warmIds.size).toBeGreaterThan(0);

const completedBefore = LynxEncodePlugin.encodePool.completed;

// Simulate a watch-mode rebuild: a fresh compiler instance against the
// *same* process-wide pool.
await runWebpack({
context,
mode: 'development',
devtool: false,
output: { iife: false, filename: '[name].js' },
entry: { rebuild: './fixtures/basic.tsx' },
plugins: [
new LynxTemplatePlugin(),
new LynxEncodePlugin(),
],
});

// The rebuild ran a task through the pool…
expect(LynxEncodePlugin.encodePool.completed - completedBefore).toBe(1);

// …but every thread currently in the pool was already warm from the
// previous compile — none were newly spawned for the rebuild.
for (const { threadId } of LynxEncodePlugin.encodePool.threads) {
expect(warmIds.has(threadId)).toBe(true);
}
});
});
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading