From c56a14bfa33cd9b4734d6db71867968c9efd4b85 Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:01:20 +0900 Subject: [PATCH 1/6] refactor: replace httpgd-js with built-in transports --- package.json | 3 +- pnpm-lock.yaml | 87 ----------- src/helpViewer/cran.ts | 2 +- src/helpViewer/helpProvider.ts | 2 +- src/{helpViewer => }/http.ts | 45 ++++-- src/plotViewer/httpgdClient.ts | 241 +++++++++++++++++++++++++++++ src/plotViewer/httpgdTypes.d.ts | 5 +- src/plotViewer/httpgdViewer.ts | 55 ++++--- src/test/node/cranHttp.test.ts | 2 +- src/test/node/httpText.test.ts | 30 +++- src/test/node/httpgdClient.test.ts | 204 ++++++++++++++++++++++++ 11 files changed, 549 insertions(+), 127 deletions(-) rename src/{helpViewer => }/http.ts (65%) create mode 100644 src/plotViewer/httpgdClient.ts create mode 100644 src/test/node/httpgdClient.test.ts diff --git a/package.json b/package.json index 4e598aef8..434d22176 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "R Markdown" ], "engines": { - "vscode": "^1.94.0" + "vscode": "^1.98.2" }, "activationEvents": [ "workspaceContains:**/*.{rproj,Rproj,r,R,rd,Rd,rmd,Rmd}", @@ -2567,7 +2567,6 @@ "cheerio": "1.0.0-rc.12", "ejs": "^3.1.10", "highlight.js": "^11.11.1", - "httpgd": "0.1.6", "js-yaml": "^4.3.2", "vscode-languageclient": "^10.1.2", "winreg": "^1.2.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c3bbdc9f..3d53f790a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 - httpgd: - specifier: 0.1.6 - version: 0.1.6 js-yaml: specifier: ^4.3.2 version: 4.3.2 @@ -719,9 +716,6 @@ packages: '@types/winreg@1.2.36': resolution: {integrity: sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1113,9 +1107,6 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1480,9 +1471,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - httpgd@0.1.6: - resolution: {integrity: sha512-HyozzYjOq+rGi3P+YZtLnvBPAWvdn2tiCfUuB4tSUradRtOoKAvwcZ+yvOYxusMzaZIGkf02s/BTkcDzj+XS/w==} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1591,11 +1579,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-ws@4.0.1: - resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} - peerDependencies: - ws: '*' - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1810,15 +1793,6 @@ packages: node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -2233,9 +2207,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2321,12 +2292,6 @@ packages: vscode-languageserver-types@3.18.4: resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2349,18 +2314,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.22.0: - resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2912,10 +2865,6 @@ snapshots: '@types/winreg@1.2.36': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.20.5 - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3292,12 +3241,6 @@ snapshots: core-util-is@1.0.3: {} - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3696,17 +3639,6 @@ snapshots: transitivePeerDependencies: - supports-color - httpgd@0.1.6: - dependencies: - '@types/ws': 8.18.1 - cross-fetch: 3.2.0 - isomorphic-ws: 4.0.1(ws@8.22.0) - ws: 8.22.0 - transitivePeerDependencies: - - bufferutil - - encoding - - utf-8-validate - https-proxy-agent@7.0.6(supports-color@8.1.1): dependencies: agent-base: 7.1.4 @@ -3782,10 +3714,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@8.22.0): - dependencies: - ws: 8.22.0 - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4028,10 +3956,6 @@ snapshots: node-addon-api@4.3.0: optional: true - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -4516,8 +4440,6 @@ snapshots: dependencies: is-number: 7.0.0 - tr46@0.0.3: {} - tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -4609,13 +4531,6 @@ snapshots: vscode-languageserver-types@3.18.4: {} - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -4639,8 +4554,6 @@ snapshots: wrappy@1.0.2: optional: true - ws@8.22.0: {} - wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/src/helpViewer/cran.ts b/src/helpViewer/cran.ts index a1f707431..26b76c064 100644 --- a/src/helpViewer/cran.ts +++ b/src/helpViewer/cran.ts @@ -1,7 +1,7 @@ import * as cheerio from 'cheerio'; import { Package} from './packages'; -import { getHttpText } from './http'; +import { getHttpText } from '../http'; type ParseFunction = (html: string, baseUrl: string) => Package[]; diff --git a/src/helpViewer/helpProvider.ts b/src/helpViewer/helpProvider.ts index 01163fcc3..fe42baa0f 100644 --- a/src/helpViewer/helpProvider.ts +++ b/src/helpViewer/helpProvider.ts @@ -2,7 +2,7 @@ import { Memento, window } from 'vscode'; import * as cp from 'child_process'; import * as rHelp from '.'; -import { getHttpText } from './http'; +import { getHttpText } from '../http'; import { extensionContext } from '../extension'; import { catchAsError, config, DisposableProcess, getRLibPaths, spawn, spawnAsync } from '../util'; diff --git a/src/helpViewer/http.ts b/src/http.ts similarity index 65% rename from src/helpViewer/http.ts rename to src/http.ts index c22482bc7..ba551fb20 100644 --- a/src/helpViewer/http.ts +++ b/src/http.ts @@ -9,34 +9,54 @@ export interface HttpTextResponse { text: string; } +export interface HttpResponse { + status: number; + url: string; + body: Buffer; +} + +export interface HttpRequestOptions { + headers?: Record; + signal?: AbortSignal; + timeoutMs?: number; +} + const decompressGzip = promisify(gunzip); const decompressDeflate = promisify(inflate); const decompressRawDeflate = promisify(inflateRaw); const decompressBrotli = promisify(brotliDecompress); -// Both R help servers and private CRAN repositories can use Fetch-blocked ports +// R help/httpgd servers and private CRAN repositories can use Fetch-blocked ports // or Basic Auth URLs. Node's HTTP(S) transport preserves access to those URLs. export async function getHttpText(input: URL | string, redirectsLeft = 20): Promise { + const response = await getHttpResponse(input, {}, redirectsLeft); + return { status: response.status, url: response.url, text: response.body.toString('utf8') }; +} + +export async function getHttpResponse(input: URL | string, options: HttpRequestOptions = {}, redirectsLeft = 20): Promise { let current = new URL(input); - let authorization: string | undefined; + const headers: Record = { accept: '*/*', 'accept-encoding': 'gzip, deflate, br' }; + for (const [name, value] of Object.entries(options.headers ?? {})) { + headers[name.toLowerCase()] = value; + } while (true) { + options.signal?.throwIfAborted(); if (!['http:', 'https:'].includes(current.protocol)) { throw new Error('Expected an HTTP or HTTPS URL'); } if (current.username || current.password) { const credentials = `${decodeURIComponent(current.username)}:${decodeURIComponent(current.password)}`; - authorization = `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`; + headers.authorization = `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`; current.username = ''; current.password = ''; } - const headers: Record = { Accept: '*/*', 'Accept-Encoding': 'gzip, deflate, br' }; - if (authorization) { - headers.Authorization = authorization; - } const response = await new Promise((resolve, reject) => { const transport = current.protocol === 'https:' ? https : http; - const request = transport.get(current, { headers }, resolve); + const request = transport.get(current, { headers, signal: options.signal }, resolve); request.on('error', reject); + if (options.timeoutMs) { + request.setTimeout(options.timeoutMs, () => request.destroy(new Error('HTTP request timed out'))); + } }); const status = response.statusCode ?? 0; const location = response.headers.location; @@ -46,10 +66,12 @@ export async function getHttpText(input: URL | string, redirectsLeft = 20): Prom throw new Error('Too many HTTP redirects'); } const next = new URL(location, current); - // Retain Basic Auth only within an origin; never forward it to a + // Retain credentials only within an origin; never forward them to a // different host/port or on an HTTPS-to-HTTP redirect. if (next.origin !== current.origin) { - authorization = undefined; + delete headers.authorization; + delete headers.cookie; + delete headers['x-httpgd-token']; } current = next; redirectsLeft--; @@ -77,6 +99,7 @@ export async function getHttpText(input: URL | string, redirectsLeft = 20): Prom break; } } - return { status, url: current.href, text: body.toString('utf8') }; + options.signal?.throwIfAborted(); + return { status, url: current.href, body }; } } diff --git a/src/plotViewer/httpgdClient.ts b/src/plotViewer/httpgdClient.ts new file mode 100644 index 000000000..b3d7a2ec9 --- /dev/null +++ b/src/plotViewer/httpgdClient.ts @@ -0,0 +1,241 @@ +import { getHttpResponse } from '../http'; + +export type HttpgdPlotId = string; +export type HttpgdRendererId = string; +export interface HttpgdIdResponse { id: HttpgdPlotId } +export interface HttpgdState { upid: number; hsize: number; active: boolean } +export interface HttpgdPlotsResponse { state: HttpgdState; plots: HttpgdIdResponse[] } +export interface HttpgdRendererResponse { id: string; name: string; ext: string; descr: string } +export interface HttpgdPlotRequest { + id?: string; + renderer?: string; + width?: number; + height?: number; + zoom?: number; +} + +interface ClientOptions { + pollIntervalMs?: number; + retryIntervalMs?: number; + webSocketTimeoutMs?: number; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function state(value: unknown): HttpgdState { + if (!record(value) || typeof value.upid !== 'number' || !Number.isFinite(value.upid) + || typeof value.hsize !== 'number' || !Number.isFinite(value.hsize) || typeof value.active !== 'boolean') { + throw new Error('Invalid httpgd state'); + } + return { upid: value.upid, hsize: value.hsize, active: value.active }; +} + +// Protocol client only: rendering and viewer commands remain in HttpgdViewer. +export class HttpgdClient { + private readonly base: URL; + private readonly headers: Record; + private readonly pollInterval: number; + private readonly retryInterval: number; + private readonly webSocketTimeout: number; + private controller?: AbortController; + private socket?: WebSocket; + private pollTimer?: NodeJS.Timeout; + private polling = false; + private socketTimer?: NodeJS.Timeout; + private retryAt = 0; + private snapshot?: HttpgdPlotsResponse; + private renderers: HttpgdRendererResponse[] = []; + private renderersLoaded = false; + private refreshTask?: Promise; + private refreshPending = false; + private readonly listeners = new Set<(value: HttpgdPlotsResponse) => void>(); + + constructor(host: string, token?: string, options: ClientOptions = {}) { + this.base = new URL(host.includes('://') ? host : `http://${host}`); + this.headers = token ? { 'X-HTTPGD-TOKEN': token } : {}; + this.pollInterval = options.pollIntervalMs ?? 500; + this.retryInterval = options.retryIntervalMs ?? 15000; + this.webSocketTimeout = options.webSocketTimeoutMs ?? 5000; + } + + public async connect(): Promise { + if (this.controller) { return; } + this.controller = new AbortController(); + try { + await this.loadRenderers(); + await this.refreshPlots(); + } finally { + if (this.controller && !this.controller.signal.aborted) { + this.openWebSocket(); + this.schedulePoll(); + } + } + } + + public disconnect(): void { + this.controller?.abort(); + this.controller = undefined; + clearTimeout(this.pollTimer); + this.closeWebSocket(); + } + + public onPlotsChanged(listener: (value: HttpgdPlotsResponse) => void): void { + this.listeners.add(listener); + } + + public getPlots(): HttpgdIdResponse[] { return this.snapshot?.plots ?? []; } + public getRenderers(): HttpgdRendererResponse[] { return this.renderers; } + + public async getPlotText(request: HttpgdPlotRequest): Promise { + return (await this.getPlotBytes(request)).toString('utf8'); + } + + public async getPlotBytes(request: HttpgdPlotRequest): Promise { + const url = this.url('/plot'); + for (const [name, value] of Object.entries(request) as [string, string | number | undefined][]) { + if (value !== undefined) { + const parameter = name === 'width' || name === 'height' ? Math.round(Number(value)) : value; + url.searchParams.set(name, String(parameter)); + } + } + return this.request(url); + } + + public async removePlot(request: { id: string }): Promise { + const url = this.url('/remove'); + url.searchParams.set('id', request.id); + state(await this.json(url)); + await this.refreshPlots(); + } + + private url(path: string): URL { return new URL(path, this.base); } + + private async request(url: URL): Promise { + const signal = this.controller?.signal; + if (!signal || signal.aborted) { throw new Error('httpgd client is disconnected'); } + const response = await getHttpResponse(url, { headers: this.headers, signal, timeoutMs: 10000 }); + if (response.status !== 200) { throw new Error(`httpgd request failed (${response.status})`); } + return response.body; + } + + private async json(url: URL): Promise { + return JSON.parse((await this.request(url)).toString('utf8')) as unknown; + } + + private async loadRenderers(): Promise { + const value = await this.json(this.url('/renderers')); + if (!record(value) || !Array.isArray(value.renderers)) { throw new Error('Invalid httpgd renderers'); } + this.renderers = value.renderers.map((renderer: unknown) => { + if (!record(renderer) || typeof renderer.id !== 'string' || typeof renderer.name !== 'string' + || typeof renderer.ext !== 'string' || typeof renderer.descr !== 'string') { + throw new Error('Invalid httpgd renderer'); + } + return { id: renderer.id, name: renderer.name, ext: renderer.ext, descr: renderer.descr }; + }); + this.renderersLoaded = true; + } + + private refreshPlots(): Promise { + this.refreshPending = true; + if (!this.refreshTask) { + this.refreshTask = this.drainPlots().finally(() => { this.refreshTask = undefined; }); + } + return this.refreshTask; + } + + private async drainPlots(): Promise { + while (this.refreshPending && this.controller) { + this.refreshPending = false; + const value = await this.json(this.url('/plots')); + if (!record(value) || !Array.isArray(value.plots)) { throw new Error('Invalid httpgd plots'); } + const plots = value.plots.map((plot: unknown) => { + if (!record(plot) || typeof plot.id !== 'string') { throw new Error('Invalid httpgd plot ID'); } + return { id: plot.id }; + }); + this.snapshot = { state: state(value.state), plots }; + // TODO: Revisit this notification boundary if httpgd and JGD viewer + // updates are unified. Their transports remain separate for now. + for (const listener of this.listeners) { listener(this.snapshot); } + } + } + + private async updateState(value: unknown): Promise { + const next = state(value); + const previous = this.snapshot?.state; + if (!previous || previous.upid !== next.upid || previous.hsize !== next.hsize || previous.active !== next.active) { + await this.refreshPlots(); + } + } + + private schedulePoll(delay = this.pollInterval): void { + clearTimeout(this.pollTimer); + if (this.controller && this.socket?.readyState !== 1) { + this.pollTimer = setTimeout(() => { void this.poll(); }, delay); + } + } + + private async poll(): Promise { + if (!this.controller || this.polling) { return; } + this.polling = true; + let delay = this.pollInterval; + try { + if (!this.renderersLoaded) { await this.loadRenderers(); } + await this.updateState(await this.json(this.url('/state'))); + if (!this.socket && Date.now() >= this.retryAt) { this.openWebSocket(); } + } catch { + delay = this.retryInterval; + } finally { + this.polling = false; + this.schedulePoll(delay); + } + } + + private openWebSocket(): void { + // TODO: Remove this missing-WebSocket compatibility path when the minimum + // extension host provides Node 22. Keep polling for connection failures. + if (typeof globalThis.WebSocket !== 'function' || !this.controller || this.socket) { return; } + this.retryAt = Date.now() + this.retryInterval; + const url = this.url('/'); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = this.headers['X-HTTPGD-TOKEN']; + if (token) { url.searchParams.set('token', token); } + try { + const socket = new WebSocket(url); + this.socket = socket; + const fallback = () => { + if (this.socket !== socket) { return; } + this.closeWebSocket(); + this.schedulePoll(); + }; + this.socketTimer = setTimeout(fallback, this.webSocketTimeout); + socket.onopen = () => { + clearTimeout(this.socketTimer); + clearTimeout(this.pollTimer); + // Resync after every connection: changes during reconnect may have been missed. + void this.refreshPlots().catch(fallback); + }; + socket.onmessage = event => { + if (typeof event.data !== 'string') { return; } + try { + void this.updateState(JSON.parse(event.data) as unknown).catch(fallback); + } catch { fallback(); } + }; + socket.onclose = fallback; + socket.onerror = fallback; + } catch { + this.schedulePoll(); + } + } + + private closeWebSocket(): void { + clearTimeout(this.socketTimer); + const socket = this.socket; + this.socket = undefined; + if (socket) { + socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null; + socket.close(); + } + } +} diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 9d713eae0..3753b1ba3 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,7 +1,6 @@ -import { Httpgd } from 'httpgd'; -import { HttpgdPlotId } from 'httpgd/lib/types'; +import { HttpgdClient, HttpgdPlotId } from './httpgdClient'; import * as vscode from 'vscode'; import { HttpgdManager } from '.'; import { PreviewPlotLayout } from './webviewMessages'; @@ -43,7 +42,7 @@ export class IHttpgdViewer { webviewPanel?: vscode.WebviewPanel; // Api that provides plot contents etc. - api: Httpgd; + api: HttpgdClient; // active plots plots: HttpgdPlot[]; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 8e12afd8b..ff071d86e 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import { Httpgd } from 'httpgd'; +import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient'; import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; @@ -9,7 +9,6 @@ import * as ejs from 'ejs'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; import { FocusPlotMessage, InMessage, OutMessage, ToggleStyleMessage, UpdatePlotMessage, HidePlotMessage, AddPlotMessage, PreviewPlotLayout, PreviewPlotLayoutMessage, ToggleFullWindowMessage } from './webviewMessages'; -import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from 'httpgd/lib/types'; import { PlotViewer } from './types'; export class HttpgdManager { @@ -107,7 +106,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { readonly host: string; readonly token?: string; webviewPanel?: vscode.WebviewPanel; - readonly api: Httpgd; + readonly api: HttpgdClient; + private disposed = false; plots: HttpgdPlot[] = []; activePlot?: HttpgdPlotId; hiddenPlots: HttpgdPlotId[] = []; @@ -157,9 +157,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.token = options.token; this.parent = options.parent; - this.api = new Httpgd(this.host, this.token, true); + this.api = new HttpgdClient(this.host, this.token); this.api.onPlotsChanged((newState) => { - void this.refreshPlotsDelayed(newState.plots); + void this.refreshPlotsDelayed(newState.plots).catch(err => { + this.reportRefreshError(err); + }); }); const conf = config(); this.customOverwriteCssPath = conf.get('plot.customStyleOverwrites', ''); @@ -186,7 +188,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.fullWindow = options.fullWindow ?? this.defaultFullWindow; this.resizeTimeoutLength = options.refreshTimeoutLength ?? this.resizeTimeoutLength; this.refreshTimeoutLength = options.refreshTimeoutLength ?? this.refreshTimeoutLength; - void this.api.connect(); + void this.api.connect().catch(err => { + if (!this.disposed) { void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`); } + }); } public handleCommand(command: string, ...args: unknown[]): void | Promise { @@ -270,7 +274,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { public resetPlots(): void { this.hiddenPlots = []; this.zoom = this.zoom0; - void this.refreshPlots(this.api.getPlots(), true, true); + void this.refreshPlots(this.api.getPlots(), true, true).catch(err => this.reportRefreshError(err)); } public hidePlot(id?: HttpgdPlotId): void { @@ -339,13 +343,13 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { public zoomOut(): void { if (this.zoom > 0.1) { this.zoom -= 0.1; - void this.resizePlot(); + void this.resizePlot().catch(err => this.reportRefreshError(err)); } } public zoomIn(): void { this.zoom += 0.1; - void this.resizePlot(); + void this.resizePlot().catch(err => this.reportRefreshError(err)); } public async setContextValues(mightBeInBackground: boolean = false): Promise { @@ -381,20 +385,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { clearTimeout(this.resizeTimeout); } this.resizeTimeout = undefined; - void this.resizePlot(); + void this.resizePlot().catch(err => this.reportRefreshError(err)); } else if (!this.resizeTimeout) { this.resizeTimeout = setTimeout(() => { - void this.resizePlot().then(() => + void this.resizePlot().catch(err => this.reportRefreshError(err)).then(() => this.resizeTimeout = undefined ); }, this.resizeTimeoutLength); } } + private reportRefreshError(err: unknown): void { + if (!this.disposed) { void vscode.window.showErrorMessage(`Plot refresh failed: ${String(err)}`); } + } + protected async resizePlot(id?: HttpgdPlotId): Promise { id ??= this.activePlot; if (!id) { return; } const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); + if (this.disposed) { return; } this.plotWidth = plt.width; this.plotHeight = plt.height; this.updatePlot(plt); @@ -406,7 +415,9 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } else{ clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(() => { - void this.refreshPlots(plotsIdResponse, redraw, force).then(() => + void this.refreshPlots(plotsIdResponse, redraw, force).catch(err => { + this.reportRefreshError(err); + }).then(() => this.refreshTimeout = undefined ); }, this.refreshTimeoutLength); @@ -414,6 +425,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected async refreshPlots(plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false): Promise { + if (this.disposed) { return; } const nPlots = this.plots.length; let plotIds = plotsIdResponse.map((x) => x.id); plotIds = plotIds.filter((id) => !this.hiddenPlots.includes(id)); @@ -426,6 +438,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } }); const newPlots = await Promise.all(newPlotPromises); + if (this.disposed) { return; } const oldPlotIds = this.plots.map(plt => plt.id); this.plots = newPlots; if (this.plots.length !== nPlots) { @@ -475,8 +488,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { zoom: zoom, renderer: 'svgp' }; - const plotContent = await this.api.getPlot(args); - const svg = await plotContent?.text() || ''; + const svg = await this.api.getPlotText(args); const plt: HttpgdPlot = { id: id, data: svg, @@ -607,14 +619,19 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { outFile = outUri.fsPath; } else {return;} } - const plt = await this.api.getPlot({ id: this.activePlot, renderer: rendererId }) as unknown as { body: NodeJS.ReadableStream }; - const dest = fs.createWriteStream(outFile); - dest.on('error', (err) => void vscode.window.showErrorMessage(`Export failed: ${err.message}`)); - dest.on('close', () => void vscode.window.showInformationMessage(`Export done: ${outFile || ''}`)); - plt.body.pipe(dest); + try { + const bytes = await this.api.getPlotBytes({ id, renderer: rendererId }); + await fs.promises.writeFile(outFile, bytes); + void vscode.window.showInformationMessage(`Export done: ${outFile}`); + } catch (err) { + if (!this.disposed) { void vscode.window.showErrorMessage(`Export failed: ${String(err)}`); } + } } public dispose(): void { + this.disposed = true; + clearTimeout(this.resizeTimeout); + clearTimeout(this.refreshTimeout); this.api.disconnect(); } } diff --git a/src/test/node/cranHttp.test.ts b/src/test/node/cranHttp.test.ts index 83b0e8e5a..d7699dec7 100644 --- a/src/test/node/cranHttp.test.ts +++ b/src/test/node/cranHttp.test.ts @@ -2,7 +2,7 @@ import * as assert from 'node:assert'; import { createServer, Server } from 'node:http'; import { AddressInfo } from 'node:net'; import { gzipSync } from 'node:zlib'; -import { getHttpText } from '../../helpViewer/http'; +import { getHttpText } from '../../http'; import { getPackagesFromCran } from '../../helpViewer/cran'; function listen(server: Server, port = 0): Promise { diff --git a/src/test/node/httpText.test.ts b/src/test/node/httpText.test.ts index e5f03a03b..497e9c55e 100644 --- a/src/test/node/httpText.test.ts +++ b/src/test/node/httpText.test.ts @@ -5,7 +5,7 @@ import { gzipSync, deflateSync, deflateRawSync, brotliCompressSync } from 'node: import http from 'node:http'; import https from 'node:https'; import * as sinon from 'sinon'; -import { getHttpText } from '../../helpViewer/http'; +import { getHttpText, getHttpResponse } from '../../http'; function listen(server: Server, port = 0): Promise { return new Promise((resolve, reject) => { @@ -32,7 +32,10 @@ suite('HTTP(S) help transport', () => { setup(async () => { server = createServer((request, response) => { const pathname = new URL(request.url ?? '/', origin).pathname; - if (pathname.startsWith('/redirect/')) { + if (pathname === '/stall') { + response.writeHead(200); + response.write('partial'); + } else if (pathname.startsWith('/redirect/')) { response.writeHead(Number(pathname.split('/')[2]), { Location: '../help?topic=mean' }); response.end(); } else if (pathname === '/loop') { @@ -72,6 +75,29 @@ suite('HTTP(S) help transport', () => { }); teardown(async () => { await close(server); }); + test('aborts a pending response body and enforces the request timeout', async () => { + const controller = new AbortController(); + const request = getHttpResponse(new URL('/stall', origin), { signal: controller.signal }); + const timer = setTimeout(() => controller.abort(), 20); + try { await assert.rejects(request, /abort/i); } + finally { clearTimeout(timer); } + await assert.rejects(getHttpResponse(new URL('/stall', origin), { timeoutMs: 20 }), /aborted|timed out/i); + await assert.rejects(getHttpResponse(new URL('/help', origin), { signal: controller.signal }), /abort/i); + }); + test('does not forward the httpgd token or cookies to another origin', async () => { + let headers: http.IncomingHttpHeaders = {}; + const external = createServer((request, response) => { headers = request.headers; response.end('ok'); }); + await listen(external); + externalUrl = `http://127.0.0.1:${(external.address() as AddressInfo).port}/help`; + try { + const response = await getHttpResponse(new URL('/external', origin), { + headers: { 'X-HTTPGD-TOKEN': 'secret', Cookie: 'session=secret' } + }); + assert.strictEqual(response.body.toString(), 'ok'); + assert.strictEqual(headers['x-httpgd-token'], undefined); + assert.strictEqual(headers.cookie, undefined); + } finally { await close(external); } + }); test('collects UTF-8 HTML even when a character spans response chunks', async () => { const url = new URL('/help', origin); assert.deepStrictEqual(await getHttpText(url), { status: 200, url: url.href, text: html }); diff --git a/src/test/node/httpgdClient.test.ts b/src/test/node/httpgdClient.test.ts new file mode 100644 index 000000000..3e2a9dc07 --- /dev/null +++ b/src/test/node/httpgdClient.test.ts @@ -0,0 +1,204 @@ +import * as assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { Duplex } from 'node:stream'; +import { createServer, Server, ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { HttpgdClient, HttpgdState } from '../../plotViewer/httpgdClient'; + +const nativeWebSocket = globalThis.WebSocket; + +class TestWebSocket { + static instances: TestWebSocket[] = []; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor(readonly url: URL) { TestWebSocket.instances.push(this); } + open(): void { this.readyState = 1; this.onopen?.(); } + message(value: unknown): void { this.onmessage?.({ data: JSON.stringify(value) }); } + close(): void { this.readyState = 3; this.onclose?.(); } +} + +async function until(condition: () => boolean): Promise { + const deadline = Date.now() + 2000; + while (!condition()) { + if (Date.now() > deadline) { throw new Error('Timed out waiting for httpgd client'); } + await new Promise(resolve => setTimeout(resolve, 5)); + } +} + +suite('Internal httpgd client', () => { + let server: Server; + let upgradedSocket: Duplex | undefined; + let client: HttpgdClient; + let origin: string; + let remote: HttpgdState; + let ids: string[]; + let requests: { path: string; token?: string }[]; + let websocketDescriptor: PropertyDescriptor | undefined; + let held: ServerResponse[]; + let holdPlots: boolean; + let invalidPlots: boolean; + let notifications: string[][]; + const binary = Buffer.from([0, 255, 128, 13, 10]); + + setup(async () => { + upgradedSocket = undefined; + remote = { upid: 1, hsize: 1, active: true }; + ids = ['1']; + requests = []; + held = []; + holdPlots = invalidPlots = false; + notifications = []; + TestWebSocket.instances = []; + websocketDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + Object.defineProperty(globalThis, 'WebSocket', { value: undefined, configurable: true, writable: true }); + server = createServer((request, response) => { + const url = new URL(request.url ?? '/', origin); + requests.push({ path: url.pathname + url.search, token: request.headers['x-httpgd-token'] as string | undefined }); + if (request.headers['x-httpgd-token'] !== 'secret') { response.writeHead(401); response.end(); return; } + response.setHeader('Content-Type', 'application/json'); + if (url.pathname === '/state') { response.end(JSON.stringify(remote)); } + else if (url.pathname === '/plots') { + if (holdPlots) { held.push(response); } + else { response.end(JSON.stringify(invalidPlots ? {} : { state: remote, plots: ids.map(id => ({ id })) })); } + } else if (url.pathname === '/renderers') { + response.end(JSON.stringify({ renderers: [{ id: 'svgp', name: 'SVG', ext: '.svg', descr: 'SVG plot' }] })); + } else if (url.pathname === '/plot') { + response.end(url.searchParams.get('renderer') === 'svgp' ? '日本語' : binary); + } else if (url.pathname === '/remove') { + ids = ids.filter(id => id !== url.searchParams.get('id')); + remote = { ...remote, upid: remote.upid + 1, hsize: ids.length }; + response.end(JSON.stringify(remote)); + } else { response.writeHead(404); response.end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + client = new HttpgdClient(origin, 'secret', { pollIntervalMs: 20, retryIntervalMs: 80, webSocketTimeoutMs: 60 }); + client.onPlotsChanged(value => notifications.push(value.plots.map(plot => plot.id))); + }); + teardown(async () => { + client.disconnect(); + upgradedSocket?.destroy(); + if (websocketDescriptor) { Object.defineProperty(globalThis, 'WebSocket', websocketDescriptor); } + else { Reflect.deleteProperty(globalThis, 'WebSocket'); } + for (const response of held) { response.destroy(); } + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); + + test('loads the initial snapshot, SVG, binary exports and renderers with authentication', async () => { + await client.connect(); + assert.deepStrictEqual(client.getPlots(), [{ id: '1' }]); + assert.strictEqual(client.getRenderers()[0].id, 'svgp'); + assert.strictEqual(await client.getPlotText({ id: '1', renderer: 'svgp', width: 10.6, height: 20.4, zoom: 2 }), '日本語'); + assert.deepStrictEqual(await client.getPlotBytes({ id: '1', renderer: 'png' }), binary); + assert.ok(requests.some(request => request.path === '/plot?id=1&renderer=svgp&width=11&height=20&zoom=2')); + assert.ok(requests.every(request => request.token === 'secret')); + await client.removePlot({ id: '1' }); + assert.deepStrictEqual(client.getPlots(), []); + assert.deepStrictEqual(notifications, [['1'], []]); + }); + test('polls a server on port 10080, including when native WebSocket rejects it', async () => { + if (typeof nativeWebSocket === 'function') { globalThis.WebSocket = nativeWebSocket; } + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(10080, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); + }); + origin = 'http://127.0.0.1:10080'; + client = new HttpgdClient(origin, 'secret', { pollIntervalMs: 20, retryIntervalMs: 80, webSocketTimeoutMs: 60 }); + await client.connect(); + ids = ['2']; remote = { ...remote, upid: 2 }; + await until(() => client.getPlots()[0]?.id === '2'); + assert.deepStrictEqual(await client.getPlotBytes({ id: '2', renderer: 'png' }), binary); + }); + test('polls without WebSocket and only fetches plots after a state change', async () => { + await client.connect(); + await until(() => requests.filter(request => request.path === '/state').length >= 2); + assert.strictEqual(requests.filter(request => request.path === '/plots').length, 1); + ids = ['1', '2']; remote = { ...remote, upid: 2, hsize: 2 }; + await until(() => client.getPlots().length === 2); + assert.deepStrictEqual(notifications, [['1'], ['1', '2']]); + }); + test('uses native WebSocket notifications and falls back after a disconnect', async () => { + globalThis.WebSocket = TestWebSocket as unknown as typeof WebSocket; + await client.connect(); + const socket = TestWebSocket.instances[0]; + assert.strictEqual(socket.url.protocol, 'ws:'); + assert.strictEqual(socket.url.searchParams.get('token'), 'secret'); + socket.open(); + await until(() => requests.filter(request => request.path === '/plots').length === 2); + ids = ['2']; remote = { ...remote, upid: 2 }; + socket.message(remote); + await until(() => client.getPlots()[0]?.id === '2'); + socket.close(); + ids = ['3']; remote = { ...remote, upid: 3 }; + await until(() => client.getPlots()[0]?.id === '3'); + await until(() => TestWebSocket.instances.length === 2); + TestWebSocket.instances[1].open(); + await until(() => requests.filter(request => request.path === '/plots').length >= 5); + }); + test('receives notifications through an actual native WebSocket connection', async function () { + if (typeof nativeWebSocket !== 'function') { this.skip(); } + globalThis.WebSocket = nativeWebSocket; + server.on('upgrade', (request, socket) => { + assert.strictEqual(new URL(request.url ?? '/', origin).searchParams.get('token'), 'secret'); + const key = request.headers['sec-websocket-key']; + assert.strictEqual(typeof key, 'string'); + const accept = createHash('sha1').update(`${String(key)}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + upgradedSocket = socket; + }); + await client.connect(); + await until(() => notifications.length === 2); + ids = ['2']; remote = { ...remote, upid: 2 }; + const message = Buffer.from(JSON.stringify(remote)); + // The fixture's state JSON is a short, unmasked server text frame. + assert.ok(message.length < 126); + assert.ok(upgradedSocket); + upgradedSocket.write(Buffer.concat([Buffer.from([0x81, message.length]), message])); + await until(() => client.getPlots()[0]?.id === '2'); + }); + test('falls back when WebSocket cannot open', async () => { + globalThis.WebSocket = TestWebSocket as unknown as typeof WebSocket; + await client.connect(); + await until(() => TestWebSocket.instances[0].readyState === 3); + ids = ['2']; remote = { ...remote, upid: 2 }; + await until(() => client.getPlots()[0]?.id === '2'); + }); + test('serializes plot refreshes and retains updates received during a request', async () => { + globalThis.WebSocket = TestWebSocket as unknown as typeof WebSocket; + await client.connect(); + const socket = TestWebSocket.instances[0]; + socket.open(); + await until(() => requests.filter(request => request.path === '/plots').length === 2); + holdPlots = true; + remote = { ...remote, upid: 2 }; socket.message(remote); + await until(() => held.length === 1); + const stale = { state: remote, plots: [{ id: '2' }] }; + ids = ['3']; remote = { ...remote, upid: 3 }; socket.message(remote); + assert.strictEqual(held.length, 1); + holdPlots = false; + held[0].end(JSON.stringify(stale)); + await until(() => client.getPlots()[0]?.id === '3'); + }); + test('aborts pending requests and stops notifications and timers on disconnect', async () => { + await client.connect(); + holdPlots = true; + remote = { ...remote, upid: 2 }; + await until(() => held.length === 1); + client.disconnect(); + const count = requests.length; + await new Promise(resolve => setTimeout(resolve, 100)); + assert.strictEqual(requests.length, count); + assert.deepStrictEqual(notifications, [['1']]); + await assert.rejects(client.getPlotBytes({ id: '1' }), /disconnected/); + }); + test('rejects malformed protocol responses and recovers on polling', async () => { + invalidPlots = true; + await assert.rejects(client.connect(), /Invalid httpgd plots/); + invalidPlots = false; + await until(() => client.getPlots().length === 1); + }); +}); From 4490fc6629a0272a74b078ddf834375980d1332a Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:24:11 +0900 Subject: [PATCH 2/6] fix: preserve httpgd requests without a timeout --- src/plotViewer/httpgdClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plotViewer/httpgdClient.ts b/src/plotViewer/httpgdClient.ts index b3d7a2ec9..eb266ce3c 100644 --- a/src/plotViewer/httpgdClient.ts +++ b/src/plotViewer/httpgdClient.ts @@ -115,7 +115,9 @@ export class HttpgdClient { private async request(url: URL): Promise { const signal = this.controller?.signal; if (!signal || signal.aborted) { throw new Error('httpgd client is disconnected'); } - const response = await getHttpResponse(url, { headers: this.headers, signal, timeoutMs: 10000 }); + // Rendering and exports may be slow; preserve httpgd-js's lack of a + // request timeout while still cancelling requests on disconnect. + const response = await getHttpResponse(url, { headers: this.headers, signal }); if (response.status !== 200) { throw new Error(`httpgd request failed (${response.status})`); } return response.body; } From 8ddef366e68b7e1ea298f8f3cee7e07635785013 Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:12:27 +0900 Subject: [PATCH 3/6] fix: require httpgd 2.0.1 for the plot backend --- CHANGELOG.md | 6 ++++ README.md | 2 +- package.json | 4 +-- sess/DESCRIPTION | 2 +- sess/R/hooks.R | 10 ++++--- sess/README.md | 2 +- sess/inst/tinytest/test-httpgd-version.R | 36 ++++++++++++++++++++++++ 7 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 sess/inst/tinytest/test-httpgd-version.R diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6aee686..b48b7a1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Breaking changes + +- The httpgd plot backend now requires the `httpgd` R package version 2.0.1 or later. Older versions are treated as unavailable when choosing the plot backend. (#1855) + ## 3.0.1 - 2026-09-28 - Bump version of both the extension and `sess` to `3.0.1` so users who installed an earlier `3.0.0` pre-release build of `sess` (before its connection protocol was finalized) are prompted to reinstall `sess`. (#1794) diff --git a/README.md b/README.md index 05523c9cf..16867b189 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Steps 1–3 above are all that's required to get started with R in VS Code. But * Install an interactive plotting backend for a better R graphics experience: * [jgd](https://github.com/grantmcdermott/jgd): Lightweight JSON graphics device with native vscode-R integration. - * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets. + * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets (version 2.0.1 or later). * [arf](https://github.com/eitsupi/arf): A modern R console with many features: syntax highlighting, multiline editing, vi/emacs keybindings, R version switching, etc. Successor to [radian](https://github.com/randy3k/radian) written in Rust. diff --git a/package.json b/package.json index 434d22176..90ee353e5 100644 --- a/package.json +++ b/package.json @@ -2216,7 +2216,7 @@ "default": false, "scope": "window", "ignoreSync": true, - "markdownDescription": "Legacy compatibility setting for selecting the httpgd plot viewer. When `#r.plot.backend#` is `auto`, setting this to `true` selects the `httpgd` backend. Workspace settings take precedence over user settings. Within the same scope, an explicit `#r.plot.backend#` value takes precedence.\n\nRequires the `httpgd` R package version 1.2.0 or later.", + "markdownDescription": "Legacy compatibility setting for selecting the httpgd plot viewer. When `#r.plot.backend#` is `auto`, setting this to `true` selects the `httpgd` backend. Workspace settings take precedence over user settings. Within the same scope, an explicit `#r.plot.backend#` value takes precedence.\n\nRequires the `httpgd` R package version 2.0.1 or later.", "markdownDeprecationMessage": "Deprecated: use `#r.plot.backend#` instead. This setting is retained for compatibility with existing configurations but will be removed in a future release.", "deprecationMessage": "Deprecated: use r.plot.backend instead. This setting is retained for compatibility with existing configurations but will be removed in a future release." }, @@ -2235,7 +2235,7 @@ "markdownEnumDescriptions": [ "Automatic: tries JGD first (if installed), then httpgd, then standard. Existing `#r.plot.useHttpgd#` configurations remain supported.", "Standard static plot viewer (PNG/SVG)", - "httpgd-based interactive plot viewer (requires `httpgd` R package)", + "httpgd-based interactive plot viewer (requires `httpgd` R package version 2.0.1 or later)", "JGD-based interactive plot viewer (requires `jgd` R package)", "Use R's configured graphics device without plot integration or an automatic VS Code plot viewer" ], diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index f8d47c3ea..99e8b17c7 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -36,7 +36,7 @@ Suggests: htmltools, htmlwidgets, systemfonts, - httpgd, + httpgd (>= 2.0.1), jgd, svglite, tinytest diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 11692bfa7..0d207ea44 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -215,8 +215,10 @@ runtime_start <- function(use_rstudioapi = TRUE, .sess_env$runtime_start_phase <- "plot" has_jgd <- plot_backend %in% c("auto", "jgd") && nzchar(Sys.getenv("JGD_SOCKET")) && requireNamespace("jgd", quietly = TRUE) + # The built-in client requires the 2.x renderer schema, including descr. has_httpgd <- plot_backend %in% c("auto", "httpgd") && - requireNamespace("httpgd", quietly = TRUE) + requireNamespace("httpgd", quietly = TRUE) && + package_version(getNamespaceVersion("httpgd")) >= "2.0.1" selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd) if (selected_backend == "jgd") { .runtime_set_option("device", function(...) { @@ -270,9 +272,9 @@ runtime_start <- function(use_rstudioapi = TRUE, "connection is available. Falling back to the standard plot ", "viewer.", call. = FALSE) } else if (plot_backend == "httpgd") { - warning("[sess] Plot backend \"httpgd\" was requested but the httpgd ", - "package is not installed. Falling back to the standard plot ", - "viewer. Install httpgd, or change the r.plot.backend setting.", + warning("[sess] Plot backend \"httpgd\" requires httpgd >= 2.0.1. ", + "Falling back to the standard plot viewer. ", + "Install or update httpgd, or change the r.plot.backend setting.", call. = FALSE) } } diff --git a/sess/README.md b/sess/README.md index 19d69e7fe..a1e070d68 100644 --- a/sess/README.md +++ b/sess/README.md @@ -142,7 +142,7 @@ For displaying R plots, `sess` chooses a graphics device in this order when 1. **jgd**, if `JGD_SOCKET` is set and the [jgd](https://cran.r-project.org/package=jgd) package is installed. 2. **httpgd**, if the [httpgd](https://cran.r-project.org/package=httpgd) - package is installed. + package version 2.0.1 or later is installed. 3. **Standard**: plots are recorded on a null device and re-rendered by the client on demand at the viewer's size (as SVG via [svglite](https://cran.r-project.org/package=svglite) if installed, diff --git a/sess/inst/tinytest/test-httpgd-version.R b/sess/inst/tinytest/test-httpgd-version.R new file mode 100644 index 000000000..f4c8fd627 --- /dev/null +++ b/sess/inst/tinytest/test-httpgd-version.R @@ -0,0 +1,36 @@ +# Exercise the real backend selection without installing old httpgd binaries. +local({ + sess_env <- sess:::.sess_env + old_plot_path <- sess_env$latest_plot_path + sess_env$latest_plot_path <- tempfile(fileext = ".png") + on.exit({ + sess:::runtime_stop() + unlink(sess_env$latest_plot_path) + sess_env$latest_plot_path <- old_plot_path + }) + + env <- new.env(parent = asNamespace("sess")) + start <- sess:::runtime_start + environment(start) <- env + env$requireNamespace <- function(package, ...) { + identical(package, "httpgd") && !is.null(version) + } + env$getNamespaceVersion <- function(package) version + versions <- list(NULL, "1.2.0", "2.0.0", "2.0.1", "2.1.4", "10.0.0") + supported <- c(FALSE, FALSE, FALSE, TRUE, TRUE, TRUE) + for (i in seq_along(versions)) { + version <- versions[[i]] + for (backend in c("auto", "httpgd")) { + warnings <- character() + withCallingHandlers(start(use_rstudioapi = FALSE, plot_backend = backend), + warning = function(w) { + warnings <<- c(warnings, conditionMessage(w)) + invokeRestart("muffleWarning") + }) + expect_equal("sess.plot" %in% getTaskCallbackNames(), !supported[i]) + expect_length(warnings, if (backend == "httpgd" && !supported[i]) 1L else 0L) + if (length(warnings)) expect_true(grepl("httpgd >= 2.0.1", warnings, fixed = TRUE)) + sess:::runtime_stop() + } + } +}) From 74d8344692f787392a8b454116979e571bef48be Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:13:16 +0900 Subject: [PATCH 4/6] docs: list the httpgd minimum version as a regular change --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48b7a1b5..b04aade31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,6 @@ ## Unreleased -### Breaking changes - - The httpgd plot backend now requires the `httpgd` R package version 2.0.1 or later. Older versions are treated as unavailable when choosing the plot backend. (#1855) ## 3.0.1 - 2026-09-28 From 55d0bf6bb9901f82d697b8a8e156c34e296bc353 Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:19:27 +0900 Subject: [PATCH 5/6] docs: limit httpgd version edits to existing requirements --- README.md | 2 +- package.json | 2 +- sess/DESCRIPTION | 2 +- sess/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 16867b189..05523c9cf 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Steps 1–3 above are all that's required to get started with R in VS Code. But * Install an interactive plotting backend for a better R graphics experience: * [jgd](https://github.com/grantmcdermott/jgd): Lightweight JSON graphics device with native vscode-R integration. - * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets (version 2.0.1 or later). + * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets. * [arf](https://github.com/eitsupi/arf): A modern R console with many features: syntax highlighting, multiline editing, vi/emacs keybindings, R version switching, etc. Successor to [radian](https://github.com/randy3k/radian) written in Rust. diff --git a/package.json b/package.json index 90ee353e5..e2c3c2dde 100644 --- a/package.json +++ b/package.json @@ -2235,7 +2235,7 @@ "markdownEnumDescriptions": [ "Automatic: tries JGD first (if installed), then httpgd, then standard. Existing `#r.plot.useHttpgd#` configurations remain supported.", "Standard static plot viewer (PNG/SVG)", - "httpgd-based interactive plot viewer (requires `httpgd` R package version 2.0.1 or later)", + "httpgd-based interactive plot viewer (requires `httpgd` R package)", "JGD-based interactive plot viewer (requires `jgd` R package)", "Use R's configured graphics device without plot integration or an automatic VS Code plot viewer" ], diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 99e8b17c7..f8d47c3ea 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -36,7 +36,7 @@ Suggests: htmltools, htmlwidgets, systemfonts, - httpgd (>= 2.0.1), + httpgd, jgd, svglite, tinytest diff --git a/sess/README.md b/sess/README.md index a1e070d68..19d69e7fe 100644 --- a/sess/README.md +++ b/sess/README.md @@ -142,7 +142,7 @@ For displaying R plots, `sess` chooses a graphics device in this order when 1. **jgd**, if `JGD_SOCKET` is set and the [jgd](https://cran.r-project.org/package=jgd) package is installed. 2. **httpgd**, if the [httpgd](https://cran.r-project.org/package=httpgd) - package version 2.0.1 or later is installed. + package is installed. 3. **Standard**: plots are recorded on a null device and re-rendered by the client on demand at the viewer's size (as SVG via [svglite](https://cran.r-project.org/package=svglite) if installed, From 196b3135ae9abf9b674453596721b3a493e781e2 Mon Sep 17 00:00:00 2001 From: eitsupi <50911393+eitsupi@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:29:18 +0900 Subject: [PATCH 6/6] style: fix httpgd version test indentation --- sess/inst/tinytest/test-httpgd-version.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sess/inst/tinytest/test-httpgd-version.R b/sess/inst/tinytest/test-httpgd-version.R index f4c8fd627..e551b2bba 100644 --- a/sess/inst/tinytest/test-httpgd-version.R +++ b/sess/inst/tinytest/test-httpgd-version.R @@ -22,11 +22,13 @@ local({ version <- versions[[i]] for (backend in c("auto", "httpgd")) { warnings <- character() - withCallingHandlers(start(use_rstudioapi = FALSE, plot_backend = backend), + withCallingHandlers( + start(use_rstudioapi = FALSE, plot_backend = backend), warning = function(w) { warnings <<- c(warnings, conditionMessage(w)) invokeRestart("muffleWarning") - }) + } + ) expect_equal("sess.plot" %in% getTaskCallbackNames(), !supported[i]) expect_length(warnings, if (backend == "httpgd" && !supported[i]) 1L else 0L) if (length(warnings)) expect_true(grepl("httpgd >= 2.0.1", warnings, fixed = TRUE))