From 21c7dcab405b213805bd1cb68f8d3bd1957b4a49 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 3 Oct 2025 11:55:19 -0400 Subject: [PATCH 1/2] Offload Oxide scanning to separate process --- .../tailwindcss-language-server/package.json | 3 +- .../src/oxide-helper.ts | 14 ++++ .../src/oxide-session.ts | 67 +++++++++++++++++++ .../tailwindcss-language-server/src/oxide.ts | 4 +- .../src/project-locator.ts | 53 ++++++++++++--- .../src/projects.ts | 1 + 6 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 packages/tailwindcss-language-server/src/oxide-helper.ts create mode 100644 packages/tailwindcss-language-server/src/oxide-session.ts diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index a3c548046..68b51e03e 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -13,8 +13,9 @@ }, "homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme", "scripts": { - "build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css", + "build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:oxide && pnpm run _esbuild:css", "_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify", + "_esbuild:oxide": "node ../../esbuild.mjs src/oxide-helper.ts --outfile=bin/oxide-helper.js --minify", "_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify", "clean": "rimraf bin", "prepublishOnly": "pnpm run build", diff --git a/packages/tailwindcss-language-server/src/oxide-helper.ts b/packages/tailwindcss-language-server/src/oxide-helper.ts new file mode 100644 index 000000000..ec001562d --- /dev/null +++ b/packages/tailwindcss-language-server/src/oxide-helper.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import * as rpc from 'vscode-jsonrpc/node' +import { scan, type ScanOptions, type ScanResult } from './oxide' + +let connection = rpc.createMessageConnection( + new rpc.IPCMessageReader(process), + new rpc.IPCMessageWriter(process), +) + +let scanRequest = new rpc.RequestType('scan') +connection.onRequest(scanRequest, (options) => scan(options)) + +connection.listen() diff --git a/packages/tailwindcss-language-server/src/oxide-session.ts b/packages/tailwindcss-language-server/src/oxide-session.ts new file mode 100644 index 000000000..dbda80d47 --- /dev/null +++ b/packages/tailwindcss-language-server/src/oxide-session.ts @@ -0,0 +1,67 @@ +import * as rpc from 'vscode-jsonrpc/node' +import * as proc from 'node:child_process' +import * as path from 'node:path' +import { type ScanOptions, type ScanResult } from './oxide' + +/** + * This helper starts a session in which we can use Oxide in *another process* + * to communicate content scanning results. + * + * Thie exists for two reasons: + * - The Oxide API has changed over time so this function presents a unified + * interface that works with all versions of the Oxide API. The results may + * vary but the structure of the results will always be identical. + * + * - Requiring a native node module on Windows permanently keeps an open handle + * to the binary for the duration of the process. This prevents unlinking the + * file like happens when running `npm ci`. Running an ephemeral process lets + * us sidestep the problem as the process will only be running as needed. + */ +export class OxideSession { + helper: proc.ChildProcess | null = null + connection: rpc.MessageConnection | null = null + + public async scan(options: ScanOptions): Promise { + await this.startIfNeeded() + + return await this.connection.sendRequest('scan', options) + } + + async startIfNeeded(): Promise { + if (this.connection) return + + // TODO: Can we find a way to not require a build first? + // let module = path.resolve(path.dirname(__filename), './oxide-helper.ts') + let module = path.resolve(path.dirname(__filename), '../bin/oxide-helper.js') + + let helper = proc.fork(module) + let connection = rpc.createMessageConnection( + new rpc.IPCMessageReader(helper), + new rpc.IPCMessageWriter(helper), + ) + + helper.on('disconnect', () => { + connection.dispose() + this.connection = null + this.helper = null + }) + + helper.on('exit', () => { + connection.dispose() + this.connection = null + this.helper = null + }) + + connection.listen() + + this.helper = helper + this.connection = connection + } + + async stop() { + if (!this.helper) return + + this.helper.disconnect() + this.helper.kill() + } +} diff --git a/packages/tailwindcss-language-server/src/oxide.ts b/packages/tailwindcss-language-server/src/oxide.ts index 4dd529dfc..5c5e59e67 100644 --- a/packages/tailwindcss-language-server/src/oxide.ts +++ b/packages/tailwindcss-language-server/src/oxide.ts @@ -111,14 +111,14 @@ interface SourceEntry { negated: boolean } -interface ScanOptions { +export interface ScanOptions { oxidePath: string oxideVersion: string basePath: string sources: Array } -interface ScanResult { +export interface ScanResult { files: Array globs: Array } diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index bec102900..c9079c4e7 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -16,6 +16,7 @@ import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils' import postcss from 'postcss' import * as oxide from './oxide' import { analyzeStylesheet, TailwindStylesheet } from './version-guesser' +import { OxideSession } from './oxide-session' export interface ProjectConfig { /** The folder that contains the project */ @@ -60,7 +61,10 @@ export class ProjectLocator { let configs = await this.findConfigs() // Create a project for each of the config files - let results = await Promise.allSettled(configs.map((config) => this.createProject(config))) + let session = new OxideSession() + let results = await Promise.allSettled( + configs.map((config) => this.createProject(config, session)), + ) let projects: ProjectConfig[] = [] for (let result of results) { @@ -71,6 +75,8 @@ export class ProjectLocator { } } + console.log(projects[0]) + if (projects.length === 1) { projects[0].additionalSelectors.push({ pattern: normalizePath(path.join(this.base, '**')), @@ -98,6 +104,8 @@ export class ProjectLocator { } } + await session.stop() + return projects } @@ -148,7 +156,10 @@ export class ProjectLocator { } } - private async createProject(config: ConfigEntry): Promise { + private async createProject( + config: ConfigEntry, + session: OxideSession, + ): Promise { let tailwind = await this.detectTailwindVersion(config) let possibleVersions = config.entries.flatMap((entry) => entry.meta?.versions ?? []) @@ -218,7 +229,12 @@ export class ProjectLocator { // Look for the package root for the config config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base) - let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver) + let selectors = await calculateDocumentSelectors( + config, + tailwind.features, + this.resolver, + session, + ) return { config, @@ -520,10 +536,11 @@ function contentSelectorsFromConfig( entry: ConfigEntry, features: Feature[], resolver: Resolver, + session: OxideSession, actualConfig?: any, ): AsyncIterable { if (entry.type === 'css') { - return contentSelectorsFromCssConfig(entry, resolver) + return contentSelectorsFromCssConfig(entry, resolver, session) } if (entry.type === 'js') { @@ -582,6 +599,7 @@ async function* contentSelectorsFromJsConfig( async function* contentSelectorsFromCssConfig( entry: ConfigEntry, resolver: Resolver, + session: OxideSession, ): AsyncIterable { let auto = false for (let item of entry.content) { @@ -606,6 +624,7 @@ async function* contentSelectorsFromCssConfig( entry.path, sources, resolver, + session, )) { yield { pattern, @@ -621,6 +640,7 @@ async function* detectContentFiles( inputFile: string, sources: SourcePattern[], resolver: Resolver, + session: OxideSession, ): AsyncIterable { try { let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base) @@ -628,7 +648,7 @@ async function* detectContentFiles( let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base) let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8')) - let result = await oxide.scan({ + let result = await session.scan({ oxidePath, oxideVersion: oxidePackageJson.version, basePath: base, @@ -654,8 +674,8 @@ async function* detectContentFiles( base = normalizeDriveLetter(base) yield `${base}/${pattern}` } - } catch { - // + } catch (err) { + console.log({ err }) } } @@ -812,8 +832,15 @@ export async function calculateDocumentSelectors( config: ConfigEntry, features: Feature[], resolver: Resolver, + session?: OxideSession, actualConfig?: any, ) { + let hasTemporarySession = false + if (!session) { + hasTemporarySession = true + session = new OxideSession() + } + let selectors: DocumentSelector[] = [] // selectors: @@ -834,7 +861,13 @@ export async function calculateDocumentSelectors( }) // - Content patterns from config - for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) { + for await (let selector of contentSelectorsFromConfig( + config, + features, + resolver, + session, + actualConfig, + )) { selectors.push(selector) } @@ -876,5 +909,9 @@ export async function calculateDocumentSelectors( return 0 }) + if (hasTemporarySession) { + await session.stop() + } + return selectors } diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 1038d31bd..3f741e32d 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -964,6 +964,7 @@ export async function createProjectService( projectConfig.config, state.features, resolver, + undefined, originalConfig, ) } From 77586d113f685266addcf7d9c9973c31524340a8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 3 Oct 2025 12:14:24 -0400 Subject: [PATCH 2/2] wip --- .../src/oxide-session.ts | 28 ++++++++++++++++++- packages/vscode-tailwindcss/package.json | 2 +- .../vscode-tailwindcss/src/oxide-helper.ts | 1 + 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 packages/vscode-tailwindcss/src/oxide-helper.ts diff --git a/packages/tailwindcss-language-server/src/oxide-session.ts b/packages/tailwindcss-language-server/src/oxide-session.ts index dbda80d47..a99ae49aa 100644 --- a/packages/tailwindcss-language-server/src/oxide-session.ts +++ b/packages/tailwindcss-language-server/src/oxide-session.ts @@ -1,6 +1,7 @@ import * as rpc from 'vscode-jsonrpc/node' import * as proc from 'node:child_process' import * as path from 'node:path' +import * as fs from 'node:fs/promises' import { type ScanOptions, type ScanResult } from './oxide' /** @@ -32,7 +33,32 @@ export class OxideSession { // TODO: Can we find a way to not require a build first? // let module = path.resolve(path.dirname(__filename), './oxide-helper.ts') - let module = path.resolve(path.dirname(__filename), '../bin/oxide-helper.js') + + let modulePaths = [ + // Separate Language Server package + '../bin/oxide-helper.js', + + // Bundled with the VSCode extension + '../dist/oxide-helper.js', + ] + + let module: string | null = null + + for (let relativePath of modulePaths) { + let filepath = path.resolve(path.dirname(__filename), relativePath) + + if ( + await fs.access(filepath).then( + () => true, + () => false, + ) + ) { + module = filepath + break + } + } + + if (!module) throw new Error('unable to load') let helper = proc.fork(module) let connection = rpc.createMessageConnection( diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 9ba7ca76f..1eeea3c38 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -362,7 +362,7 @@ } }, "scripts": { - "_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts --outdir=dist", + "_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts src/oxide-helper.ts --outdir=dist", "dev": "concurrently --raw --kill-others \"pnpm run watch\" \"pnpm run check --watch\"", "watch": "pnpm run clean && pnpm run _esbuild --watch", "build": "pnpm run check && pnpm run clean && pnpm run _esbuild --minify && move-file dist/server.js dist/tailwindServer.js && move-file dist/cssServer.js dist/tailwindModeServer.js", diff --git a/packages/vscode-tailwindcss/src/oxide-helper.ts b/packages/vscode-tailwindcss/src/oxide-helper.ts new file mode 100644 index 000000000..4fb9869c6 --- /dev/null +++ b/packages/vscode-tailwindcss/src/oxide-helper.ts @@ -0,0 +1 @@ +import '@tailwindcss/language-server/src/oxide-helper'