From 17ee931fa54912f345e62a4b2d99399320e5da5a Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 12 Jun 2023 07:29:43 -0700 Subject: [PATCH] serve/simple: Use message passing instead of HTTP Implements a frameworks to support asynchronous requests using messages. Signed-off-by: Tyler Smalley --- package.json | 10 --- src/extension.ts | 9 --- src/serve-panel-provider.ts | 75 ++++++--------------- src/tailscale/cli.ts | 83 ++++++++---------------- src/types.ts | 77 +++++++++++----------- src/vscode-api.ts | 4 +- src/webviews/api.ts | 57 ++++++++++++++++ src/webviews/serve-panel/data.tsx | 32 --------- src/webviews/serve-panel/index.tsx | 13 ---- src/webviews/serve-panel/simple-view.tsx | 15 +++-- src/webviews/serve-panel/swr.ts | 19 ++++++ src/webviews/tsrelay.ts | 33 ++++++++++ 12 files changed, 202 insertions(+), 225 deletions(-) create mode 100644 src/webviews/api.ts delete mode 100644 src/webviews/serve-panel/data.tsx create mode 100644 src/webviews/serve-panel/swr.ts create mode 100644 src/webviews/tsrelay.ts diff --git a/package.json b/package.json index fa85659..b1e0e04 100644 --- a/package.json +++ b/package.json @@ -112,11 +112,6 @@ ], "menus": { "view/title": [ - { - "command": "tailscale.refreshServe", - "group": "overflow", - "when": "view == tailscale-serve-view" - }, { "command": "tailscale.resetServe", "group": "overflow", @@ -145,11 +140,6 @@ "title": "Reset", "category": "Tailscale" }, - { - "command": "tailscale.refreshServe", - "title": "Refresh", - "category": "Tailscale" - }, { "command": "tailscale.openFunnelPanel", "title": "Open Funnel Panel", diff --git a/src/extension.ts b/src/extension.ts index f51cc3d..abc42ba 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,19 +45,10 @@ export async function activate(context: vscode.ExtensionContext) { tailscaleInstance ); - context.subscriptions.push( - vscode.commands.registerCommand('tailscale.refreshServe', () => { - Logger.info('called tailscale.refreshServe', 'command'); - servePanelProvider.refreshState(); - }) - ); - context.subscriptions.push( vscode.commands.registerCommand('tailscale.resetServe', async () => { Logger.info('called tailscale.resetServe', 'command'); await tailscaleInstance.serveDelete(); - servePanelProvider.refreshState(); - vscode.window.showInformationMessage('Serve configuration reset'); }) ); diff --git a/src/serve-panel-provider.ts b/src/serve-panel-provider.ts index 7b4c3cd..73af3f8 100644 --- a/src/serve-panel-provider.ts +++ b/src/serve-panel-provider.ts @@ -18,12 +18,6 @@ export class ServePanelProvider implements vscode.WebviewViewProvider { this._view.webview.postMessage(message); } - public async refreshState() { - this.postMessage({ - type: 'refreshState', - }); - } - resolveWebviewView(webviewView: vscode.WebviewView) { this._view = webviewView; webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); @@ -33,69 +27,40 @@ export class ServePanelProvider implements vscode.WebviewViewProvider { }; webviewView.webview.onDidReceiveMessage(async (m: Message) => { - switch (m.type) { - case 'refreshState': { - Logger.info('Called refreshState', 'serve-panel'); - await this.refreshState(); - break; - } - - case 'deleteServe': { - Logger.info('Called deleteServe', 'serve-panel'); - try { - await this.ts.serveDelete(m.params); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - vscode.window.showErrorMessage('Unable to delete serve', e.message); - } - - await this.refreshState(); - break; - } - - case 'addServe': { - Logger.info('Called addServe', 'serve-panel'); - await this.ts.serveAdd(m.params); - await this.refreshState(); - break; - } - - case 'setFunnel': { - Logger.info('Called setFunnel', 'serve-panel'); - try { - await this.ts.setFunnel(parseInt(m.params.port), m.params.allow); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - vscode.window.showErrorMessage('Unable to toggle funnel', e.message); - } + const { type } = m; + Logger.info(`called ${type}`, 'serve-panel'); - await this.refreshState(); - break; - } + let response; - case 'resetServe': { - Logger.info('Called resetServe', 'serve-panel'); + switch (type) { + case 'relayRequest': { + const { id, endpoint, method } = m; + Logger.info(`${id}, ${endpoint}, ${method}`, 'serve-panel'); try { - await this.ts.serveDelete(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - vscode.window.showErrorMessage('Unable to delete serve', e.message); + response = await this.ts.performFetch(endpoint, method, m.data); + Logger.info(`response: ${JSON.stringify(response)}`, 'serve-panel'); + this.postMessage({ + id, + endpoint, + method, + type: 'relayResponse', + data: response, + }); + } catch (e) { + vscode.window.showErrorMessage(`${e}`); } - await this.refreshState(); break; } case 'writeToClipboard': { - Logger.info('Called writeToClipboard', 'serve-panel'); - vscode.env.clipboard.writeText(m.params.text); + vscode.env.clipboard.writeText(m.data.text); vscode.window.showInformationMessage('Copied to clipboard'); break; } case 'openLink': { - Logger.info(`Called openLink: ${m.params.url}`, 'serve-panel'); - vscode.env.openExternal(vscode.Uri.parse(m.params.url)); + vscode.env.openExternal(vscode.Uri.parse(m.data.url)); break; } diff --git a/src/tailscale/cli.ts b/src/tailscale/cli.ts index 637f652..7a6a309 100644 --- a/src/tailscale/cli.ts +++ b/src/tailscale/cli.ts @@ -221,83 +221,52 @@ export class Tailscale { } async serveStatus(): Promise { - if (!this.url) { - throw new Error('uninitialized client'); - } - try { - const resp = await fetch(`${this.url}/serve`, { - headers: { - Authorization: 'Basic ' + this.authkey, - }, - }); - - const status = (await resp.json()) as ServeStatus; - return status; - } catch (e) { - Logger.error(`error calling status: ${JSON.stringify(e, null, 2)}`); - throw e; - } + return (await this.performFetch('/serve')) as ServeStatus; } async serveAdd(p: ServeParams) { - if (!this.url) { - throw new Error('uninitialized client'); - } - try { - const resp = await fetch(`${this.url}/serve`, { - method: 'POST', - headers: { - Authorization: 'Basic ' + this.authkey, - }, - body: JSON.stringify(p), - }); - if (!resp.ok) { - throw new Error('/serve failed'); - } - } catch (e) { - Logger.info(`error adding serve: ${e}`); - throw e; - } + await this.performFetch('/serve', 'POST', p); } async serveDelete(p?: ServeParams) { - if (!this.url) { - throw new Error('uninitialized client'); - } - try { - const resp = await fetch(`${this.url}/serve`, { - method: 'DELETE', - headers: { - Authorization: 'Basic ' + this.authkey, - }, - body: JSON.stringify(p), - }); - if (!resp.ok) { - throw new Error('/serve failed'); - } - } catch (e) { - Logger.info(`error deleting serve: ${e}`); - throw e; - } + await this.performFetch('/serve', 'DELETE', p); } async setFunnel(port: number, on: boolean) { + await this.performFetch('/funnel', 'POST', { port, on }); + } + + async performFetch(endpoint: string, method = 'GET', body?: unknown) { if (!this.url) { throw new Error('uninitialized client'); } + try { - const resp = await fetch(`${this.url}/funnel`, { - method: 'POST', + const resp = await fetch(`${this.url}${endpoint}`, { + method, headers: { Authorization: 'Basic ' + this.authkey, }, - body: JSON.stringify({ port, on }), + body: body !== undefined && typeof body === 'object' ? JSON.stringify(body) : undefined, }); + if (!resp.ok) { - throw new Error('/serve failed'); + Logger.error(`${endpoint} failed: ${JSON.stringify(resp)}`); + throw new Error(`${endpoint} failed`); } + + const text = await resp.text(); + + try { + return JSON.parse(text); + // eslint-disable-next-line no-empty + } catch { + Logger.error(`failed to parse json: ${text}`); + } + + return text; } catch (e) { - Logger.info(`error deleting serve: ${e}`); + Logger.info(`error in ${method} ${endpoint}: ${e}`); throw e; } } diff --git a/src/types.ts b/src/types.ts index 419e99b..28da32e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,73 +80,69 @@ export interface Version { * Messages sent from the webview to the extension. */ -interface RefreshState { - type: 'refreshState'; +interface RequestBase { + id?: number; + type: string; + data?: unknown; } -interface DeleteServe { - type: 'deleteServe'; - params: ServeParams; +interface RelayRequestBase extends RequestBase { + type: 'relayRequest'; + endpoint: string; + method: string; } -interface AddServe { - type: 'addServe'; - params: ServeParams; +interface RelayServeRequest extends RelayRequestBase { + endpoint: '/serve'; + method: 'GET' | 'POST' | 'DELETE'; } -interface ResetServe { - type: 'resetServe'; -} - -interface SetFunnel { - type: 'setFunnel'; - params: { - port: string; - allow: boolean; - }; -} - -interface WriteToClipboard { +interface WriteToClipboard extends RequestBase { type: 'writeToClipboard'; - params: { + data: { text: string; }; } -interface OpenLink { +interface OpenLink extends RequestBase { type: 'openLink'; - params: { + data: { url: string; }; } -export type Message = - | RefreshState - | DeleteServe - | AddServe - | ResetServe - | SetFunnel - | WriteToClipboard - | OpenLink - | SudoPrompt; - interface SudoPrompt { + id?: number; type: 'sudoPrompt'; operation: 'add' | 'delete'; params?: ServeParams; } +export type Message = RelayServeRequest | WriteToClipboard | OpenLink | SudoPrompt; +export type MessageWithId = Omit & { id: number }; + /** * Messages sent from the extension to the webview. */ -interface UpdateState { - type: 'updateState'; - state: ServeConfig; +interface ResponseBase { + id?: number; + type: string; + data?: unknown; + error?: string; +} + +interface RelayResponseBase extends Omit, Omit { + id?: number; + endpoint: string; + method: string; + body?: unknown; + error?: string; } -interface RefreshState { - type: 'refreshState'; +export interface RelayServeResponse extends RelayResponseBase { + type: 'relayResponse'; + body?: ServeStatus; } interface WebpackOk { @@ -161,7 +157,8 @@ interface WebpackStillOk { type: 'webpackStillOk'; } -export type WebviewData = UpdateState | RefreshState | WebpackOk | WebpackInvalid | WebpackStillOk; +export type Responses = RelayServeResponse; +export type WebviewData = Responses | WebpackOk | WebpackInvalid | WebpackStillOk; export type WebviewEvent = Event & { data: WebviewData }; export interface NewPortNotification { diff --git a/src/vscode-api.ts b/src/vscode-api.ts index b59208a..9b298c2 100644 --- a/src/vscode-api.ts +++ b/src/vscode-api.ts @@ -11,7 +11,7 @@ class VSCodeWrapper { public writeToClipboard(text: string): void { this.postMessage({ type: 'writeToClipboard', - params: { + data: { text, }, }); @@ -20,7 +20,7 @@ class VSCodeWrapper { public openLink(url: string): void { this.postMessage({ type: 'openLink', - params: { + data: { url, }, }); diff --git a/src/webviews/api.ts b/src/webviews/api.ts new file mode 100644 index 0000000..d16268a --- /dev/null +++ b/src/webviews/api.ts @@ -0,0 +1,57 @@ +import { vsCodeAPI } from '../vscode-api'; +import type { Message, Responses } from '../types'; + +let currentMessageId = 1; +const messagePromises = new Map< + number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { resolve: (value: Responses) => void; reject: (reason?: any) => void } +>(); + +class MessageError extends Error { + constructor(message: string) { + super(message); + this.name = 'MessageError'; + } +} + +/** + * Sends a message to the VSCode extension and returns a promise that resolves with the response. + * @param message The message to send. + * @returns A promise that resolves with the response from the extension. + */ +export function postMessageWithResponse(message: Message): Promise { + return new Promise((resolve, reject) => { + message.id = currentMessageId++; + messagePromises.set(message.id, { resolve, reject }); + + vsCodeAPI.postMessage(message); + }); +} + +/** + * Handles incoming messages from the VSCode extension. + * @param event The message event. + */ +window.addEventListener('message', (event) => { + const message = event.data ?? {}; + + if (!message.id || !message.type) { + return; + } + + const promiseMethods = messagePromises.get(message.id); + + if (!promiseMethods) { + return; + } + + const { resolve, reject } = promiseMethods; + messagePromises.delete(message.id); + + if (message.error) { + reject(new MessageError(`Message error: ${message.error}`)); + } else { + resolve(message as Responses); + } +}); diff --git a/src/webviews/serve-panel/data.tsx b/src/webviews/serve-panel/data.tsx deleted file mode 100644 index 2fcf83f..0000000 --- a/src/webviews/serve-panel/data.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; -import { ServeStatus, ServeParams } from '../../types'; - -export function useServe() { - // TODO(tyler): implement cache provider using memento storage (context.globalstate) - return useSWR('/serve', fetchWithUser, { refreshInterval: 3000 }); -} - -export function useServeMutation() { - return useSWRMutation('/serve', (path: string, { arg }: { arg?: ServeParams }) => { - const requestOptions: RequestInit = { - method: 'POST', - body: arg ? JSON.stringify(arg) : undefined, - }; - - return fetchWithUser(path, requestOptions); - }); -} - -export async function fetchWithUser(path: string, options: RequestInit = {}) { - const { url, authkey } = window.tailscale; - - options.headers = options.headers || {}; - options.headers['Content-Type'] = 'application/json'; - options.headers['Authorization'] = `Basic ${authkey}`; - - console.time(path); - const res = await fetch(url + path, options); - console.timeEnd(path); - return res.json(); -} diff --git a/src/webviews/serve-panel/index.tsx b/src/webviews/serve-panel/index.tsx index bbdef59..20913bd 100644 --- a/src/webviews/serve-panel/index.tsx +++ b/src/webviews/serve-panel/index.tsx @@ -8,19 +8,6 @@ provideVSCodeDesignSystem().register(vsCodeButton()); import './index.css'; -window.addEventListener('message', (m: WebviewEvent) => { - switch (m.data.type) { - // ignored dev messages - case 'webpackOk': - case 'webpackInvalid': - case 'webpackStillOk': - break; - - default: - console.log('Unknown message type', m); - } -}); - if (module.hot) { module.hot.accept(); } diff --git a/src/webviews/serve-panel/simple-view.tsx b/src/webviews/serve-panel/simple-view.tsx index a5f4803..da2858e 100644 --- a/src/webviews/serve-panel/simple-view.tsx +++ b/src/webviews/serve-panel/simple-view.tsx @@ -5,10 +5,11 @@ import { vsCodeAPI } from '../../vscode-api'; import { PortInput } from './components/port-input'; import { Error } from './components/error'; import { KB_FUNNEL_USE_CASES } from '../../utils/url'; -import { useServe, useServeMutation, fetchWithUser } from './data'; +import { useServe, useServeMutation } from './swr'; import { Tooltip } from './components/tooltip'; import { errorForType } from '../../tailscale/error'; -import { ServeParams, WithErrors } from '../../types'; +import { serveReset } from '../tsrelay'; +import { ServeParams } from '../../types'; export const SimpleView = () => { const { data, mutate, isLoading } = useServe(); @@ -75,6 +76,7 @@ export const SimpleView = () => { + {/* TODO: handle data.Self being unset */} {data?.Self &&
} ); @@ -191,10 +193,8 @@ export const SimpleView = () => { setIsDeleting(true); - const resp = (await fetchWithUser('/serve', { - method: 'DELETE', - body: '{}', - })) as WithErrors; + const resp = await serveReset(); + if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { vsCodeAPI.postMessage({ type: 'sudoPrompt', @@ -227,7 +227,8 @@ export const SimpleView = () => { source: `http://127.0.0.1:${port}`, funnel: true, }; - const resp = (await trigger(params)) as WithErrors; + + const resp = await trigger(params); if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { vsCodeAPI.postMessage({ type: 'sudoPrompt', diff --git a/src/webviews/serve-panel/swr.ts b/src/webviews/serve-panel/swr.ts new file mode 100644 index 0000000..f981062 --- /dev/null +++ b/src/webviews/serve-panel/swr.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; +import { ServeParams } from '../../types'; +import { serveStatus, serveUpdate } from '../tsrelay'; + +export function useServe() { + return useSWR('serveStatus', serveStatus, { refreshInterval: 3000 }); +} + +export function useServeMutation() { + return useSWRMutation('serveStatus', (path: string, { arg }: { arg?: ServeParams }) => { + return serveUpdate(arg); + }); +} + +export async function fetchWithMessage(type: string, data: RequestInit = {}) { + return await serveStatus; +} diff --git a/src/webviews/tsrelay.ts b/src/webviews/tsrelay.ts new file mode 100644 index 0000000..645d5f5 --- /dev/null +++ b/src/webviews/tsrelay.ts @@ -0,0 +1,33 @@ +import { postMessageWithResponse } from './api'; +import type { RelayServeResponse, ServeParams, ServeStatus, WithErrors } from '../types'; + +export async function serveStatus() { + const reponse = (await postMessageWithResponse({ + type: 'relayRequest', + endpoint: '/serve', + method: 'GET', + })) as RelayServeResponse; + + return reponse.data as ServeStatus; +} + +export async function serveReset() { + const reponse = (await postMessageWithResponse({ + type: 'relayRequest', + endpoint: '/serve', + method: 'DELETE', + })) as RelayServeResponse; + + return reponse.data as WithErrors; +} + +export async function serveUpdate(arg?: ServeParams) { + const reponse = (await postMessageWithResponse({ + type: 'relayRequest', + endpoint: '/serve', + method: 'POST', + data: arg, + })) as RelayServeResponse; + + return reponse.data as WithErrors; +}