Skip to content

Commit 21c7dca

Browse files
committed
Offload Oxide scanning to separate process
1 parent 59b34d2 commit 21c7dca

File tree

6 files changed

+131
-11
lines changed

6 files changed

+131
-11
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
},
1414
"homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme",
1515
"scripts": {
16-
"build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css",
16+
"build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:oxide && pnpm run _esbuild:css",
1717
"_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify",
18+
"_esbuild:oxide": "node ../../esbuild.mjs src/oxide-helper.ts --outfile=bin/oxide-helper.js --minify",
1819
"_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify",
1920
"clean": "rimraf bin",
2021
"prepublishOnly": "pnpm run build",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
3+
import * as rpc from 'vscode-jsonrpc/node'
4+
import { scan, type ScanOptions, type ScanResult } from './oxide'
5+
6+
let connection = rpc.createMessageConnection(
7+
new rpc.IPCMessageReader(process),
8+
new rpc.IPCMessageWriter(process),
9+
)
10+
11+
let scanRequest = new rpc.RequestType<ScanOptions, ScanResult, void>('scan')
12+
connection.onRequest<ScanOptions, ScanResult, void>(scanRequest, (options) => scan(options))
13+
14+
connection.listen()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as rpc from 'vscode-jsonrpc/node'
2+
import * as proc from 'node:child_process'
3+
import * as path from 'node:path'
4+
import { type ScanOptions, type ScanResult } from './oxide'
5+
6+
/**
7+
* This helper starts a session in which we can use Oxide in *another process*
8+
* to communicate content scanning results.
9+
*
10+
* Thie exists for two reasons:
11+
* - The Oxide API has changed over time so this function presents a unified
12+
* interface that works with all versions of the Oxide API. The results may
13+
* vary but the structure of the results will always be identical.
14+
*
15+
* - Requiring a native node module on Windows permanently keeps an open handle
16+
* to the binary for the duration of the process. This prevents unlinking the
17+
* file like happens when running `npm ci`. Running an ephemeral process lets
18+
* us sidestep the problem as the process will only be running as needed.
19+
*/
20+
export class OxideSession {
21+
helper: proc.ChildProcess | null = null
22+
connection: rpc.MessageConnection | null = null
23+
24+
public async scan(options: ScanOptions): Promise<ScanResult> {
25+
await this.startIfNeeded()
26+
27+
return await this.connection.sendRequest('scan', options)
28+
}
29+
30+
async startIfNeeded(): Promise<void> {
31+
if (this.connection) return
32+
33+
// TODO: Can we find a way to not require a build first?
34+
// let module = path.resolve(path.dirname(__filename), './oxide-helper.ts')
35+
let module = path.resolve(path.dirname(__filename), '../bin/oxide-helper.js')
36+
37+
let helper = proc.fork(module)
38+
let connection = rpc.createMessageConnection(
39+
new rpc.IPCMessageReader(helper),
40+
new rpc.IPCMessageWriter(helper),
41+
)
42+
43+
helper.on('disconnect', () => {
44+
connection.dispose()
45+
this.connection = null
46+
this.helper = null
47+
})
48+
49+
helper.on('exit', () => {
50+
connection.dispose()
51+
this.connection = null
52+
this.helper = null
53+
})
54+
55+
connection.listen()
56+
57+
this.helper = helper
58+
this.connection = connection
59+
}
60+
61+
async stop() {
62+
if (!this.helper) return
63+
64+
this.helper.disconnect()
65+
this.helper.kill()
66+
}
67+
}

packages/tailwindcss-language-server/src/oxide.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ interface SourceEntry {
111111
negated: boolean
112112
}
113113

114-
interface ScanOptions {
114+
export interface ScanOptions {
115115
oxidePath: string
116116
oxideVersion: string
117117
basePath: string
118118
sources: Array<SourceEntry>
119119
}
120120

121-
interface ScanResult {
121+
export interface ScanResult {
122122
files: Array<string>
123123
globs: Array<GlobEntry>
124124
}

packages/tailwindcss-language-server/src/project-locator.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils'
1616
import postcss from 'postcss'
1717
import * as oxide from './oxide'
1818
import { analyzeStylesheet, TailwindStylesheet } from './version-guesser'
19+
import { OxideSession } from './oxide-session'
1920

2021
export interface ProjectConfig {
2122
/** The folder that contains the project */
@@ -60,7 +61,10 @@ export class ProjectLocator {
6061
let configs = await this.findConfigs()
6162

6263
// Create a project for each of the config files
63-
let results = await Promise.allSettled(configs.map((config) => this.createProject(config)))
64+
let session = new OxideSession()
65+
let results = await Promise.allSettled(
66+
configs.map((config) => this.createProject(config, session)),
67+
)
6468
let projects: ProjectConfig[] = []
6569

6670
for (let result of results) {
@@ -71,6 +75,8 @@ export class ProjectLocator {
7175
}
7276
}
7377

78+
console.log(projects[0])
79+
7480
if (projects.length === 1) {
7581
projects[0].additionalSelectors.push({
7682
pattern: normalizePath(path.join(this.base, '**')),
@@ -98,6 +104,8 @@ export class ProjectLocator {
98104
}
99105
}
100106

107+
await session.stop()
108+
101109
return projects
102110
}
103111

@@ -148,7 +156,10 @@ export class ProjectLocator {
148156
}
149157
}
150158

151-
private async createProject(config: ConfigEntry): Promise<ProjectConfig | null> {
159+
private async createProject(
160+
config: ConfigEntry,
161+
session: OxideSession,
162+
): Promise<ProjectConfig | null> {
152163
let tailwind = await this.detectTailwindVersion(config)
153164

154165
let possibleVersions = config.entries.flatMap((entry) => entry.meta?.versions ?? [])
@@ -218,7 +229,12 @@ export class ProjectLocator {
218229
// Look for the package root for the config
219230
config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base)
220231

221-
let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver)
232+
let selectors = await calculateDocumentSelectors(
233+
config,
234+
tailwind.features,
235+
this.resolver,
236+
session,
237+
)
222238

223239
return {
224240
config,
@@ -520,10 +536,11 @@ function contentSelectorsFromConfig(
520536
entry: ConfigEntry,
521537
features: Feature[],
522538
resolver: Resolver,
539+
session: OxideSession,
523540
actualConfig?: any,
524541
): AsyncIterable<DocumentSelector> {
525542
if (entry.type === 'css') {
526-
return contentSelectorsFromCssConfig(entry, resolver)
543+
return contentSelectorsFromCssConfig(entry, resolver, session)
527544
}
528545

529546
if (entry.type === 'js') {
@@ -582,6 +599,7 @@ async function* contentSelectorsFromJsConfig(
582599
async function* contentSelectorsFromCssConfig(
583600
entry: ConfigEntry,
584601
resolver: Resolver,
602+
session: OxideSession,
585603
): AsyncIterable<DocumentSelector> {
586604
let auto = false
587605
for (let item of entry.content) {
@@ -606,6 +624,7 @@ async function* contentSelectorsFromCssConfig(
606624
entry.path,
607625
sources,
608626
resolver,
627+
session,
609628
)) {
610629
yield {
611630
pattern,
@@ -621,14 +640,15 @@ async function* detectContentFiles(
621640
inputFile: string,
622641
sources: SourcePattern[],
623642
resolver: Resolver,
643+
session: OxideSession,
624644
): AsyncIterable<string> {
625645
try {
626646
let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base)
627647
oxidePath = pathToFileURL(oxidePath).href
628648
let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base)
629649
let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8'))
630650

631-
let result = await oxide.scan({
651+
let result = await session.scan({
632652
oxidePath,
633653
oxideVersion: oxidePackageJson.version,
634654
basePath: base,
@@ -654,8 +674,8 @@ async function* detectContentFiles(
654674
base = normalizeDriveLetter(base)
655675
yield `${base}/${pattern}`
656676
}
657-
} catch {
658-
//
677+
} catch (err) {
678+
console.log({ err })
659679
}
660680
}
661681

@@ -812,8 +832,15 @@ export async function calculateDocumentSelectors(
812832
config: ConfigEntry,
813833
features: Feature[],
814834
resolver: Resolver,
835+
session?: OxideSession,
815836
actualConfig?: any,
816837
) {
838+
let hasTemporarySession = false
839+
if (!session) {
840+
hasTemporarySession = true
841+
session = new OxideSession()
842+
}
843+
817844
let selectors: DocumentSelector[] = []
818845

819846
// selectors:
@@ -834,7 +861,13 @@ export async function calculateDocumentSelectors(
834861
})
835862

836863
// - Content patterns from config
837-
for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) {
864+
for await (let selector of contentSelectorsFromConfig(
865+
config,
866+
features,
867+
resolver,
868+
session,
869+
actualConfig,
870+
)) {
838871
selectors.push(selector)
839872
}
840873

@@ -876,5 +909,9 @@ export async function calculateDocumentSelectors(
876909
return 0
877910
})
878911

912+
if (hasTemporarySession) {
913+
await session.stop()
914+
}
915+
879916
return selectors
880917
}

packages/tailwindcss-language-server/src/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@ export async function createProjectService(
964964
projectConfig.config,
965965
state.features,
966966
resolver,
967+
undefined,
967968
originalConfig,
968969
)
969970
}

0 commit comments

Comments
 (0)