Skip to content

Commit e965cfd

Browse files
authored
feature: add opt-in native TypeScript 7 backend (#736)
* feature: add opt-in native TypeScript 7 backend * fix: type preference mock arguments * fix: declare optional backends separately from required language servers * test: capture native TypeScript UI acceptance pending host release * fix: type native UI diagnostic expectations * fix: parse Windows archive listings with CRLF line endings * fix: register command dispatch before asynchronous backend selection * fix: register provider dispatch before backend selection * feature: validate native TypeScript against published host
1 parent 576bfe7 commit e965cfd

13 files changed

Lines changed: 527 additions & 140 deletions

File tree

‎.github/workflows/pr.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ jobs:
4040
THREEJS_ENFORCE_REUSE: 1
4141
- name: Build
4242
run: npm run build
43+
- name: Verify extension archive excludes native servers
44+
run: node scripts/test-extension-archive.mjs
4345
- name: Build static site
4446
run: npm run build:static
4547
env:
@@ -53,6 +55,12 @@ jobs:
5355
working-directory: ./packages/e2e
5456
run: sudo npx playwright install-deps chromium firefox webkit
5557
if: runner.os == 'Linux'
58+
- name: Prepare native TypeScript test host
59+
run: node scripts/prepare-native-test-host.mjs
60+
- name: Run native TypeScript regression
61+
run: npm run e2e:native --workspace=packages/e2e -- --trace-renderer-worker
62+
env:
63+
PLAYWRIGHT_BROWSERS_PATH: 0
5664
- name: Test persistent file cache and measure diagnostics
5765
run: node scripts/benchmark-file-cache.mjs
5866
env:

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ npm test
1212
## Credits
1313

1414
This extension is based on https://github.com/microsoft/vscode/tree/main/extensions/typescript-language-features by Microsoft (License MIT)
15+
16+
### TypeScript 7
17+
18+
Set `"typescript.useTs7": true` and reload the window to use LVCE's bundled native TypeScript 7 language server. The default remains the TypeScript web worker. The native server requires a desktop or server host; a static browser deployment cannot launch it. Missing or unsupported native hosts report a language-server error rather than starting a second backend.
19+
20+
The application owns the executable and its standard libraries. No native TypeScript executable is included in `extension.tar.br`. Native mode uses LSP completion, diagnostics, navigation, hover, signature help, rename, symbols, formatting and code actions when the server advertises support. Editor-specific brace, closing-tag, tab and selection providers are unavailable in this mode. Worker performance/cache commands apply only to the default backend. Reloading also disposes the previous server.

‎package-lock.json‎

Lines changed: 112 additions & 79 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/build/src/run-e2e.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,27 @@ import { root } from './root.ts'
77
const [manifestName, ...args] = process.argv.slice(2)
88
const e2ePath = join(root, 'packages', 'e2e')
99
const isSmartSelectionE2e = manifestName === 'smart-selection-e2e'
10+
const isNativeE2e = manifestName === 'native-e2e'
1011
const isThreeJsE2e = manifestName === 'threejs-e2e'
1112
const requireFromE2e = createRequire(join(e2ePath, 'package.json'))
1213
const runnerPath = requireFromE2e.resolve('@lvce-editor/test-with-playwright/bin/test-with-playwright.js')
1314
const testPath = join(root, '.tmp', 'e2e-manifests', manifestName)
1415
const testSourcePath = join(e2ePath, 'src')
1516
const allTestNames = (await readdir(testSourcePath)).filter((name) => !name.startsWith('_'))
16-
const testNames = isThreeJsE2e
17-
? allTestNames.filter((name) => name === 'typescript.threejs-warm-edits.ts')
18-
: isSmartSelectionE2e
19-
? allTestNames.filter(
20-
(name) => name.startsWith('typescript.grow-selection') || name === 'typescript.shrink-selection.ts',
21-
)
22-
: allTestNames.filter(
23-
(name) => name !== 'typescript.shrink-selection.ts' && name !== 'typescript.threejs-warm-edits.ts',
24-
)
17+
const testNames = isNativeE2e
18+
? allTestNames.filter((name) => name === 'typescript.native-ts7.ts')
19+
: isThreeJsE2e
20+
? allTestNames.filter((name) => name === 'typescript.threejs-warm-edits.ts')
21+
: isSmartSelectionE2e
22+
? allTestNames.filter(
23+
(name) => name.startsWith('typescript.grow-selection') || name === 'typescript.shrink-selection.ts',
24+
)
25+
: allTestNames.filter(
26+
(name) =>
27+
name !== 'typescript.shrink-selection.ts' &&
28+
name !== 'typescript.threejs-warm-edits.ts' &&
29+
name !== 'typescript.native-ts7.ts',
30+
)
2531

2632
await rm(testPath, { force: true, recursive: true })
2733
await mkdir(join(testPath, 'src'), { recursive: true })

‎packages/e2e/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "End-to-end tests for TypeScript language features",
55
"main": "index.js",
66
"scripts": {
7+
"e2e:native": "node ../build/src/run-e2e.ts native-e2e --headless",
78
"e2e": "node ../build/src/run-e2e.ts stable-e2e",
89
"e2e:headless": "node ../build/src/run-e2e.ts stable-e2e --headless",
910
"e2e:firefox:headless": "node ../build/src/run-e2e.ts stable-e2e --browser=firefox --headless",
@@ -20,7 +21,7 @@
2021
},
2122
"license": "MIT",
2223
"devDependencies": {
23-
"@lvce-editor/server-smart-selection": "npm:@lvce-editor/server@^0.122.17",
24+
"@lvce-editor/server-smart-selection": "npm:@lvce-editor/server@^0.125.8",
2425
"@lvce-editor/test-with-playwright": "^22.40.2"
2526
}
2627
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Test } from '@lvce-editor/test-with-playwright'
2+
3+
export const name = 'typescript.native-ts7'
4+
5+
export const test: Test = async ({ Command, Editor, expect, FileSystem, Locator, Main, Settings, Workspace }) => {
6+
const root = await FileSystem.getTmpDir({ scheme: 'file' })
7+
await FileSystem.writeFile(
8+
`${root}/tsconfig.json`,
9+
JSON.stringify({ compilerOptions: { strict: true }, files: ['main.ts'] }),
10+
)
11+
const uri = `${root}/main.ts`
12+
await FileSystem.writeFile(uri, 'const value: string = 123;\n')
13+
await Settings.update({ 'editor.diagnostics': true, 'typescript.useTs7': true })
14+
await Workspace.setUri(root)
15+
await Main.openUri(uri)
16+
await Command.execute('Editor.waitForDiagnostics')
17+
const expectedDiagnostics = [
18+
{
19+
code: 2322,
20+
columnIndex: 6,
21+
endColumnIndex: 11,
22+
endRowIndex: 0,
23+
message: "Type 'number' is not assignable to type 'string'.",
24+
rowIndex: 0,
25+
source: 'ts',
26+
type: 'error',
27+
uri,
28+
},
29+
] as const
30+
await Editor.shouldHaveDiagnostics(expectedDiagnostics)
31+
let workerCommandError = ''
32+
try {
33+
await Command.executeExtensionCommand('typescript.getProjectCacheStatistics')
34+
} catch (error) {
35+
workerCommandError = String(error)
36+
}
37+
if (!workerCommandError.includes('command typescript.getProjectCacheStatistics not found')) {
38+
throw new Error(
39+
`Native mode must dispose worker-only commands: ${workerCommandError || 'worker command succeeded'}`,
40+
)
41+
}
42+
await Editor.setText('const value: string = "valid";\n')
43+
await Command.execute('Editor.waitForDiagnostics')
44+
await Editor.shouldHaveDiagnostics([])
45+
await Editor.setText('const greeting = "hello";\ngreeting.toU')
46+
await Editor.setCursor(1, 12)
47+
await Editor.openCompletion()
48+
const items = Locator('#Completions')
49+
await expect(items).toContainText('toUpperCase')
50+
}

‎packages/extension/extension.json‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
"type": "array",
2929
"default": [],
3030
"description": "Prevalidated documents for static demos. Each entry must match the exact document URI, SHA-256 text hash, compiler configuration URI and SHA-256 configuration hash. Only use with fixed, validated project dependencies. Edits and missing evidence resume diagnostics; other language features are unaffected."
31+
},
32+
"typescript.useTs7": {
33+
"type": "boolean",
34+
"default": false,
35+
"description": "Use the application-bundled TypeScript 7 language server instead of the TypeScript web worker. Requires a native LVCE host. Reload the window after changing this setting."
3136
}
3237
},
3338
"rpc": [
@@ -91,6 +96,10 @@
9196
{
9297
"id": "typescript.provideCompletions.typescriptreact",
9398
"languageId": "typescriptreact"
99+
},
100+
{
101+
"id": "typescript.provideCompletions.javascriptreact",
102+
"languageId": "javascriptreact"
94103
}
95104
],
96105
"diagnosticProviders": [
@@ -105,6 +114,10 @@
105114
{
106115
"id": "typescript.provideDiagnostics.typescriptreact",
107116
"languageId": "typescriptreact"
117+
},
118+
{
119+
"id": "typescript.provideDiagnostics.javascriptreact",
120+
"languageId": "javascriptreact"
108121
}
109122
],
110123
"commands": [
@@ -129,6 +142,10 @@
129142
{
130143
"id": "typescript.provideHover.typescriptreact",
131144
"languageId": "typescriptreact"
145+
},
146+
{
147+
"id": "typescript.provideHover.javascriptreact",
148+
"languageId": "javascriptreact"
132149
}
133150
],
134151
"signatureHelpProviders": [
@@ -143,6 +160,10 @@
143160
{
144161
"id": "typescript.provideSignatureHelp.typescriptreact",
145162
"languageId": "typescriptreact"
163+
},
164+
{
165+
"id": "typescript.provideSignatureHelp.javascriptreact",
166+
"languageId": "javascriptreact"
146167
}
147168
],
148169
"languages": [
@@ -204,5 +225,31 @@
204225
"fileMatch": "jsconfig.*.json",
205226
"url": "./schemas/jsconfig.schema.json"
206227
}
228+
],
229+
"optionalLanguageServers": [
230+
{
231+
"id": "typescript.ts7.javascript",
232+
"connectionId": "typescript.ts7",
233+
"languageId": "javascript",
234+
"activationCommand": "typescript.awaitActivation"
235+
},
236+
{
237+
"id": "typescript.ts7.javascriptreact",
238+
"connectionId": "typescript.ts7",
239+
"languageId": "javascriptreact",
240+
"activationCommand": "typescript.awaitActivation"
241+
},
242+
{
243+
"id": "typescript.ts7.typescript",
244+
"connectionId": "typescript.ts7",
245+
"languageId": "typescript",
246+
"activationCommand": "typescript.awaitActivation"
247+
},
248+
{
249+
"id": "typescript.ts7.typescriptreact",
250+
"connectionId": "typescript.ts7",
251+
"languageId": "typescriptreact",
252+
"activationCommand": "typescript.awaitActivation"
253+
}
207254
]
208255
}

‎packages/extension/src/parts/Activate/Activate.ts‎

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { activate as activateExtensionApi, registerCommand } from '@lvce-editor/api'
1+
import {
2+
activate as activateExtensionApi,
3+
getPreference,
4+
registerCommand,
5+
registerLanguageServer,
6+
} from '@lvce-editor/api'
27
import * as DiskWatch from '../DiskWatch/DiskWatch.ts'
38
import * as Providers from '../Providers/Providers.ts'
49
import * as RegisterProviders from '../RegisterProviders/RegisterProviders.ts'
@@ -7,24 +12,65 @@ import * as ShowPerformanceTrace from '../ShowPerformanceTrace/ShowPerformanceTr
712

813
const state = {
914
isActivated: false,
15+
native: false,
16+
}
17+
const activation = Promise.withResolvers<void>()
18+
19+
const requireWorkerBackend = async (): Promise<void> => {
20+
await activation.promise
21+
if (state.native) {
22+
throw new Error('This command requires the TypeScript web-worker backend')
23+
}
1024
}
1125

1226
export const activate = async (): Promise<void> => {
1327
if (state.isActivated) {
14-
return
28+
return activation.promise
1529
}
1630
state.isActivated = true
17-
await activateExtensionApi()
18-
registerCommand({
19-
execute: ShowPerformanceTrace.showPerformanceTrace,
20-
id: 'typescript.showPerformanceTrace',
21-
})
22-
registerCommand({
23-
execute: (options?: { readonly waitForIdle?: boolean }) =>
24-
Rpc.invoke(options?.waitForIdle ? 'ProjectLifecycle.waitForIdle' : 'ProjectLifecycle.getStatistics'),
25-
id: 'typescript.getProjectCacheStatistics',
26-
})
27-
RegisterProviders.registerProviders(Object.values(Providers))
31+
// Observe rejection even when no host request is awaiting readiness yet.
32+
void activation.promise.catch(() => {})
33+
// Install dispatch before the API creates its initial RPC command map. The
34+
// preference round trip must not expose a partially registered extension.
35+
registerCommand({ execute: () => activation.promise, id: 'typescript.awaitActivation' })
36+
const workerCommands = [
37+
registerCommand({
38+
execute: async (...args: Parameters<typeof ShowPerformanceTrace.showPerformanceTrace>) => {
39+
await requireWorkerBackend()
40+
return ShowPerformanceTrace.showPerformanceTrace(...args)
41+
},
42+
id: 'typescript.showPerformanceTrace',
43+
}),
44+
registerCommand({
45+
execute: async (options?: { readonly waitForIdle?: boolean }) => {
46+
await requireWorkerBackend()
47+
return Rpc.invoke(options?.waitForIdle ? 'ProjectLifecycle.waitForIdle' : 'ProjectLifecycle.getStatistics')
48+
},
49+
id: 'typescript.getProjectCacheStatistics',
50+
}),
51+
]
52+
const workerProviders = RegisterProviders.registerProviders(Object.values(Providers), requireWorkerBackend)
53+
const nativeServers = ['javascript', 'javascriptreact', 'typescript', 'typescriptreact'].map((languageId) =>
54+
registerLanguageServer({
55+
argv: ['--lsp', '--stdio'],
56+
id: `typescript.ts7.${languageId}`,
57+
languageId,
58+
uri: 'lvce-language-server:typescript',
59+
}),
60+
)
61+
try {
62+
await activateExtensionApi()
63+
state.native = (await getPreference('typescript.useTs7')) === true
64+
if (state.native) {
65+
for (const registration of [...workerCommands, ...workerProviders]) registration.dispose()
66+
} else {
67+
for (const server of nativeServers) server.dispose()
68+
}
69+
activation.resolve()
70+
} catch (error) {
71+
activation.reject(error)
72+
throw error
73+
}
2874
}
2975

3076
export const deactivate = async (): Promise<void> => {

0 commit comments

Comments
 (0)