Skip to content

Commit 44e925c

Browse files
author
Teffen
authored
Merge branch 'microsoft:main' into main
2 parents a1d3f91 + 6123d78 commit 44e925c

File tree

15 files changed

+305
-248
lines changed

15 files changed

+305
-248
lines changed

extensions/json-language-features/client/src/browser/jsonClientMain.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55

66
import { ExtensionContext, Uri } from 'vscode';
77
import { LanguageClientOptions } from 'vscode-languageclient';
8-
import { startClient, LanguageClientConstructor } from '../jsonClient';
8+
import { startClient, LanguageClientConstructor, SchemaRequestService } from '../jsonClient';
99
import { LanguageClient } from 'vscode-languageclient/browser';
10-
import { RequestService } from '../requests';
1110

1211
declare const Worker: {
1312
new(stringUrl: string): any;
@@ -24,15 +23,16 @@ export function activate(context: ExtensionContext) {
2423
return new LanguageClient(id, name, clientOptions, worker);
2524
};
2625

27-
const http: RequestService = {
26+
const schemaRequests: SchemaRequestService = {
2827
getContent(uri: string) {
2928
return fetch(uri, { mode: 'cors' })
3029
.then(function (response: any) {
3130
return response.text();
3231
});
3332
}
3433
};
35-
startClient(context, newLanguageClient, { http });
34+
35+
startClient(context, newLanguageClient, { schemaRequests });
3636

3737
} catch (e) {
3838
console.log(e);

extensions/json-language-features/client/src/jsonClient.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
} from 'vscode-languageclient';
2121

2222
import { hash } from './utils/hash';
23-
import { RequestService, joinPath } from './requests';
2423
import { createLanguageStatusItem } from './languageStatus';
2524

2625
namespace VSCodeContentRequest {
@@ -96,10 +95,16 @@ export interface TelemetryReporter {
9695
export type LanguageClientConstructor = (name: string, description: string, clientOptions: LanguageClientOptions) => CommonLanguageClient;
9796

9897
export interface Runtime {
99-
http: RequestService;
98+
schemaRequests: SchemaRequestService;
10099
telemetry?: TelemetryReporter
101100
}
102101

102+
export interface SchemaRequestService {
103+
getContent(uri: string): Promise<string>;
104+
}
105+
106+
export const languageServerDescription = localize('jsonserver.name', 'JSON Language Server');
107+
103108
export function startClient(context: ExtensionContext, newLanguageClient: LanguageClientConstructor, runtime: Runtime) {
104109

105110
const toDispose = context.subscriptions;
@@ -198,7 +203,7 @@ export function startClient(context: ExtensionContext, newLanguageClient: Langua
198203
};
199204

200205
// Create the language client and start the client.
201-
const client = newLanguageClient('json', localize('jsonserver.name', 'JSON Language Server'), clientOptions);
206+
const client = newLanguageClient('json', languageServerDescription, clientOptions);
202207
client.registerProposedFeatures();
203208

204209
const disposable = client.start();
@@ -228,7 +233,7 @@ export function startClient(context: ExtensionContext, newLanguageClient: Langua
228233
*/
229234
runtime.telemetry.sendTelemetryEvent('json.schema', { schemaURL: uriPath });
230235
}
231-
return runtime.http.getContent(uriPath).catch(e => {
236+
return runtime.schemaRequests.getContent(uriPath).catch(e => {
232237
return Promise.reject(new ResponseError(4, e.toString()));
233238
});
234239
} else {
@@ -386,7 +391,7 @@ function getSchemaAssociations(_context: ExtensionContext): ISchemaAssociation[]
386391
if (Array.isArray(fileMatch) && typeof url === 'string') {
387392
let uri: string = url;
388393
if (uri[0] === '.' && uri[1] === '/') {
389-
uri = joinPath(extension.extensionUri, uri).toString();
394+
uri = Uri.joinPath(extension.extensionUri, uri).toString();
390395
}
391396
fileMatch = fileMatch.map(fm => {
392397
if (fm[0] === '%') {
@@ -507,7 +512,7 @@ function getSchemaId(schema: JSONSchemaSettings, folderUri?: Uri): string | unde
507512
url = schema.schema.id || `vscode://schemas/custom/${encodeURIComponent(hash(schema.schema).toString(16))}`;
508513
}
509514
} else if (folderUri && (url[0] === '.' || url[0] === '/')) {
510-
url = joinPath(folderUri, url).toString();
515+
url = Uri.joinPath(folderUri, url).toString();
511516
}
512517
return url;
513518
}

extensions/json-language-features/client/src/node/jsonClientMain.ts

Lines changed: 107 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,26 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { ExtensionContext } from 'vscode';
7-
import { startClient, LanguageClientConstructor } from '../jsonClient';
6+
import { ExtensionContext, OutputChannel, window, workspace } from 'vscode';
7+
import { startClient, LanguageClientConstructor, SchemaRequestService, languageServerDescription } from '../jsonClient';
88
import { ServerOptions, TransportKind, LanguageClientOptions, LanguageClient } from 'vscode-languageclient/node';
99

10-
import * as fs from 'fs';
11-
import { xhr, XHRResponse, getErrorStatusDescription } from 'request-light';
10+
import { promises as fs } from 'fs';
11+
import * as path from 'path';
12+
import { xhr, XHRResponse, getErrorStatusDescription, Headers } from 'request-light';
1213

1314
import TelemetryReporter from 'vscode-extension-telemetry';
14-
import { RequestService } from '../requests';
15+
import { JSONSchemaCache } from './schemaCache';
1516

1617
let telemetry: TelemetryReporter | undefined;
1718

1819
// this method is called when vs code is activated
19-
export function activate(context: ExtensionContext) {
20-
21-
const clientPackageJSON = getPackageInfo(context);
20+
export async function activate(context: ExtensionContext) {
21+
const clientPackageJSON = await getPackageInfo(context);
2222
telemetry = new TelemetryReporter(clientPackageJSON.name, clientPackageJSON.version, clientPackageJSON.aiKey);
2323

24+
const outputChannel = window.createOutputChannel(languageServerDescription);
25+
2426
const serverMain = `./server/${clientPackageJSON.main.indexOf('/dist/') !== -1 ? 'dist' : 'out'}/node/jsonServerMain`;
2527
const serverModule = context.asAbsolutePath(serverMain);
2628

@@ -35,10 +37,15 @@ export function activate(context: ExtensionContext) {
3537
};
3638

3739
const newLanguageClient: LanguageClientConstructor = (id: string, name: string, clientOptions: LanguageClientOptions) => {
40+
clientOptions.outputChannel = outputChannel;
3841
return new LanguageClient(id, name, serverOptions, clientOptions);
3942
};
43+
const log = getLog(outputChannel);
44+
context.subscriptions.push(log);
4045

41-
startClient(context, newLanguageClient, { http: getHTTPRequestService(), telemetry });
46+
const schemaRequests = await getSchemaRequestService(context, log);
47+
48+
startClient(context, newLanguageClient, { schemaRequests, telemetry });
4249
}
4350

4451
export function deactivate(): Promise<any> {
@@ -52,32 +59,113 @@ interface IPackageInfo {
5259
main: string;
5360
}
5461

55-
function getPackageInfo(context: ExtensionContext): IPackageInfo {
62+
async function getPackageInfo(context: ExtensionContext): Promise<IPackageInfo> {
5663
const location = context.asAbsolutePath('./package.json');
5764
try {
58-
return JSON.parse(fs.readFileSync(location).toString());
65+
return JSON.parse((await fs.readFile(location)).toString());
5966
} catch (e) {
6067
console.log(`Problems reading ${location}: ${e}`);
6168
return { name: '', version: '', aiKey: '', main: '' };
6269
}
6370
}
6471

65-
function getHTTPRequestService(): RequestService {
72+
interface Log {
73+
trace(message: string): void;
74+
dispose(): void;
75+
}
76+
77+
const traceSetting = 'json.trace.server';
78+
function getLog(outputChannel: OutputChannel): Log {
79+
let trace = workspace.getConfiguration().get(traceSetting) === 'verbose';
80+
const configListener = workspace.onDidChangeConfiguration(e => {
81+
if (e.affectsConfiguration(traceSetting)) {
82+
trace = workspace.getConfiguration().get(traceSetting) === 'verbose';
83+
}
84+
});
6685
return {
67-
getContent(uri: string, _encoding?: string): Promise<string> {
68-
const headers = { 'Accept-Encoding': 'gzip, deflate' };
69-
return xhr({ url: uri, followRedirects: 5, headers }).then(response => {
70-
return response.responseText;
71-
}, (error: XHRResponse) => {
86+
trace(message: string) {
87+
if (trace) {
88+
outputChannel.appendLine(message);
89+
}
90+
},
91+
dispose: () => configListener.dispose()
92+
};
93+
}
94+
95+
const retryTimeoutInDays = 2; // 2 days
96+
const retryTimeoutInMs = retryTimeoutInDays * 24 * 60 * 60 * 1000;
97+
98+
async function getSchemaRequestService(context: ExtensionContext, log: Log): Promise<SchemaRequestService> {
99+
let cache: JSONSchemaCache | undefined = undefined;
100+
const globalStorage = context.globalStorageUri;
101+
if (globalStorage.scheme === 'file') {
102+
const schemaCacheLocation = path.join(globalStorage.fsPath, 'json-schema-cache');
103+
await fs.mkdir(schemaCacheLocation, { recursive: true });
104+
105+
cache = new JSONSchemaCache(schemaCacheLocation, context.globalState);
106+
log.trace(`[json schema cache] initial state: ${JSON.stringify(cache.getCacheInfo(), null, ' ')}`);
107+
}
108+
109+
const isXHRResponse = (error: any): error is XHRResponse => typeof error?.status === 'number';
110+
111+
const request = async (uri: string, etag?: string): Promise<string> => {
112+
const headers: Headers = { 'Accept-Encoding': 'gzip, deflate' };
113+
if (etag) {
114+
headers['If-None-Match'] = etag;
115+
}
116+
try {
117+
log.trace(`[json schema cache] Requesting schema ${uri} etag ${etag}...`);
118+
119+
const response = await xhr({ url: uri, followRedirects: 5, headers });
120+
if (cache) {
121+
const etag = response.headers['etag'];
122+
if (typeof etag === 'string') {
123+
log.trace(`[json schema cache] Storing schema ${uri} etag ${etag} in cache`);
124+
await cache.putSchema(uri, etag, response.responseText);
125+
} else {
126+
log.trace(`[json schema cache] Response: schema ${uri} no etag`);
127+
}
128+
}
129+
return response.responseText;
130+
} catch (error: unknown) {
131+
if (isXHRResponse(error)) {
132+
if (error.status === 304 && etag && cache) {
133+
134+
log.trace(`[json schema cache] Response: schema ${uri} unchanged etag ${etag}`);
135+
136+
const content = await cache.getSchema(uri, etag);
137+
if (content) {
138+
log.trace(`[json schema cache] Get schema ${uri} etag ${etag} from cache`);
139+
return content;
140+
}
141+
return request(uri);
142+
}
143+
72144
let status = getErrorStatusDescription(error.status);
73145
if (status && error.responseText) {
74146
status = `${status}\n${error.responseText.substring(0, 200)}`;
75147
}
76148
if (!status) {
77149
status = error.toString();
78150
}
79-
return Promise.reject(status);
80-
});
151+
log.trace(`[json schema cache] Respond schema ${uri} error ${status}`);
152+
153+
throw status;
154+
}
155+
throw error;
156+
}
157+
};
158+
159+
return {
160+
getContent: async (uri: string) => {
161+
if (cache && /^https?:\/\/json\.schemastore\.org\//.test(uri)) {
162+
const content = await cache.getSchemaIfAccessedSince(uri, retryTimeoutInMs);
163+
if (content) {
164+
log.trace(`[json schema cache] Schema ${uri} from cache without request (last accessed less than ${retryTimeoutInDays} days ago)`);
165+
return content;
166+
}
167+
}
168+
return request(uri, cache?.getETag(uri));
81169
}
82170
};
83171
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { promises as fs } from 'fs';
7+
import * as path from 'path';
8+
import { createHash } from 'crypto';
9+
import { Memento } from 'vscode';
10+
11+
interface CacheEntry {
12+
etag: string;
13+
fileName: string;
14+
accessTime: number;
15+
}
16+
17+
interface CacheInfo {
18+
[schemaUri: string]: CacheEntry;
19+
}
20+
21+
const MEMENTO_KEY = 'json-schema-cache';
22+
23+
export class JSONSchemaCache {
24+
private readonly cacheInfo: CacheInfo;
25+
26+
constructor(private readonly schemaCacheLocation: string, private readonly globalState: Memento) {
27+
this.cacheInfo = globalState.get<CacheInfo>(MEMENTO_KEY, {});
28+
}
29+
30+
getETag(schemaUri: string): string | undefined {
31+
return this.cacheInfo[schemaUri]?.etag;
32+
}
33+
34+
async putSchema(schemaUri: string, etag: string, schemaContent: string): Promise<void> {
35+
try {
36+
const fileName = getCacheFileName(schemaUri);
37+
await fs.writeFile(path.join(this.schemaCacheLocation, fileName), schemaContent);
38+
const entry: CacheEntry = { etag, fileName, accessTime: new Date().getTime() };
39+
this.cacheInfo[schemaUri] = entry;
40+
} catch (e) {
41+
delete this.cacheInfo[schemaUri];
42+
} finally {
43+
await this.updateMemento();
44+
}
45+
}
46+
47+
async getSchemaIfAccessedSince(schemaUri: string, expirationDuration: number): Promise<string | undefined> {
48+
const cacheEntry = this.cacheInfo[schemaUri];
49+
if (cacheEntry && cacheEntry.accessTime + expirationDuration >= new Date().getTime()) {
50+
return this.loadSchemaFile(schemaUri, cacheEntry);
51+
}
52+
return undefined;
53+
}
54+
55+
async getSchema(schemaUri: string, etag: string): Promise<string | undefined> {
56+
const cacheEntry = this.cacheInfo[schemaUri];
57+
if (cacheEntry) {
58+
if (cacheEntry.etag === etag) {
59+
return this.loadSchemaFile(schemaUri, cacheEntry);
60+
} else {
61+
this.deleteSchemaFile(schemaUri, cacheEntry);
62+
}
63+
}
64+
return undefined;
65+
}
66+
67+
private async loadSchemaFile(schemaUri: string, cacheEntry: CacheEntry): Promise<string | undefined> {
68+
const cacheLocation = path.join(this.schemaCacheLocation, cacheEntry.fileName);
69+
try {
70+
const content = (await fs.readFile(cacheLocation)).toString();
71+
cacheEntry.accessTime = new Date().getTime();
72+
return content;
73+
} catch (e) {
74+
delete this.cacheInfo[schemaUri];
75+
return undefined;
76+
} finally {
77+
await this.updateMemento();
78+
}
79+
}
80+
81+
private async deleteSchemaFile(schemaUri: string, cacheEntry: CacheEntry): Promise<void> {
82+
const cacheLocation = path.join(this.schemaCacheLocation, cacheEntry.fileName);
83+
delete this.cacheInfo[schemaUri];
84+
await this.updateMemento();
85+
try {
86+
await fs.rm(cacheLocation);
87+
} catch (e) {
88+
// ignore
89+
}
90+
}
91+
92+
93+
// for debugging
94+
public getCacheInfo() {
95+
return this.cacheInfo;
96+
}
97+
98+
private async updateMemento() {
99+
try {
100+
await this.globalState.update(MEMENTO_KEY, this.cacheInfo);
101+
} catch (e) {
102+
// ignore
103+
}
104+
}
105+
}
106+
function getCacheFileName(uri: string): string {
107+
return `${createHash('MD5').update(uri).digest('hex')}.schema.json`;
108+
}

0 commit comments

Comments
 (0)