assets: Use new websocket client

This commit is contained in:
Tom Wiesing 2023-11-13 11:04:03 +01:00
parent e91f1f2413
commit 6a739df24b
No known key found for this signature in database
67 changed files with 437 additions and 562 deletions

View file

@ -0,0 +1,2 @@
This folder contains a websocket client to the API.
It works in both nodejs and the browser.

View file

@ -0,0 +1,86 @@
/** @file provides a list of websocket calls supported by the backend */
import type { CallSpec } from ".";
/** Backup backups everything */
export function Backup(): CallSpec {
return {
'call': 'backup',
'params': [],
}
}
type ProvisionParams = {
Slug: string;
Flavor?: "Drupal 10" | "Drupal 9",
IIPServer?: string,
System: SystemParams
}
type SystemParams = {
PHP: "Default (8.1)" | "8.0" | "8.1" | "8.2",
OpCacheDevelopment: boolean,
ContentSecurityPolicy: string,
}
/** Provision provisions a new instance */
export function Provision(params: ProvisionParams): CallSpec {
return {
'call': 'provision',
'params': [
JSON.stringify(params)
],
}
}
/** Snapshot makes a snapshot of an instance */
export function Snapshot(Slug: string): CallSpec {
return {
'call': 'snapshot',
'params': [Slug],
}
}
/** Rebuild rebuilds an instance */
export function Rebuild(Slug: string, params: SystemParams): CallSpec {
return {
'call': 'rebuild',
'params': [
Slug,
JSON.stringify(params)
],
}
}
/** Update updates a specific instance */
export function Update(Slug: string): CallSpec {
return {
'call': 'update',
'params': [Slug],
}
}
/** Start starts a specific instance */
export function Start(Slug: string): CallSpec {
return {
'call': 'start',
'params': [Slug],
}
}
/** Stop stops a specific instance */
export function Stop(Slug: string): CallSpec {
return {
'call': 'stop',
'params': [Slug],
}
}
/** Purge purges a specific instance */
export function Purge(Slug: string): CallSpec {
return {
'call': 'purge',
'params': [Slug],
}
}

View file

@ -0,0 +1,155 @@
/** @file implements the websocket protocol used by the distillery */
import { default as WebSocket } from "isomorphic-ws"
import { Buffer } from "buffer";
/** Call represents a specific WebSocket call */
export default class Call {
constructor(remote: Remote, spec: CallSpec) {
this.remote = remote
this.call = spec
}
public readonly remote: Readonly<Remote>
public readonly call: Readonly<CallSpec>
/** called right before sending the request */
public beforeCall?: (this: Call) => void
/** called right after the socket is closed */
public afterCall?: (this: Call, result: Result) => void
/** called when an error occurs before rejecting the promise */
public onError?: (this: Call, error: any) => void
/** called when a log line is received */
public onLogLine?: (this: Call, line: string) => void
/** connect checks if the connect method was called */
private connected: boolean = false
/** holds the websocket when the connection is alive */
private ws: WebSocket | null = null
/**
* Connect to the specified remote endpoint and perform the action
* @param remote Remote to connect to
*/
connect(): Promise<Result> {
// ensure that connect is only run once.
if (this.connected) {
throw new Error('connect() may only be called once')
}
this.connected = true
// and do the connection!
return new Promise((resolve, reject) => {
// create the websocket
const ws = new WebSocket(this.remote.url, this.remote.token ? { 'headers': { 'Authorization': 'Bearer ' + this.remote.token } } : undefined)
this.ws = ws // make it available to other things
// result is a promise, because some APIs in the browser are async
let result = Promise.resolve(JSON.stringify({'success': false, 'message': 'Unknown error'}))
ws.onopen = () => {
if (this.beforeCall) {
this.beforeCall.call(this)
}
ws.send(Buffer.from(JSON.stringify(this.call), 'utf8'))
}
ws.onmessage = ({ data }) => {
// if this is a string it is a log line
if (typeof data === 'string') {
if (this.onLogLine) {
this.onLogLine.call(this, data)
}
return
}
// decode the message
if (data instanceof Blob) {
result = data.text()
} else {
const decoder = new TextDecoder()
result = Promise.resolve(decoder.decode(data as ArrayBuffer))
}
}
ws.onerror = (err) => {
this.close()
// call the handler and reject
if (this.onError) {
this.onError.call(this, err)
}
reject(err)
}
ws.onclose = () => {
this.close()
// decode the result
result
.then(t => JSON.parse(t))
.then((res) => {
if (this.afterCall) {
this.afterCall.call(this, res)
}
resolve(res)
})
}
})
}
/** sendText sends some text to the server requests cancellation of an ongoing operation */
sendText(text: string) {
const ws = this.ws
if (ws == null) {
throw new Error('websocket not connected')
}
ws.send(text)
}
/** cancel requests cancellation of an ongoing operation */
cancel() {
const ws = this.ws
if (ws == null) {
throw new Error('websocket not connected')
}
ws.send(Buffer.from(JSON.stringify({ signal: 'cancel'}), 'utf8'))
}
/** close closes this websocket connection */
private close() {
const ws = this.ws
if (ws == null) {
throw new Error('websocket not connected')
}
ws.close()
this.ws = null;
}
}
/** specifies a remote endpoint */
export interface Remote {
url: string // the remote websocket url to talk to
token?: string // optional token
}
/** CallSpec represents the specification for a call*/
export interface CallSpec {
call: string
params: string[]
}
/** the result of a websocket call */
export interface Result {
success: boolean,
message: string,
}

View file

@ -1,5 +1,6 @@
import './index.css'
import callServerAction, { ResultMessage } from './proto'
import { Result } from '../apiclient/websocket'
import LocalCall from './local'
type Print = ((text: string, flush?: boolean) => void) & {
paintedFrames: number
@ -166,7 +167,7 @@ export function createModal (action: string, params: string[], opts: Partial<Mod
finishButton.className = 'pure-button pure-button-success'
finishButton.append(typeof opts?.onClose === 'function' ? 'Close & Finish' : 'Close')
let result: ResultMessage = { success: false, message: 'Nothing happened' }
let result: Result = { success: false, message: 'Nothing happened' }
finishButton.addEventListener('click', (event) => {
event.preventDefault()
@ -194,7 +195,7 @@ export function createModal (action: string, params: string[], opts: Partial<Mod
window.onbeforeunload = () => 'A remote session is in progress. Are you sure you want to leave?'
// when closing, add a button to the modal!
const close = (message: ResultMessage): void => {
const close = (message: Result): void => {
result = message
if (result.success) {
@ -214,32 +215,25 @@ export function createModal (action: string, params: string[], opts: Partial<Mod
print('Connecting ...', true)
// backendURL is the backend url to connect to
const backendURL = location.protocol.replace('http', 'ws') + '//' + location.host + '/api/v1/ws'
// connect to the socket and send the action
callServerAction(
backendURL,
{
call: action,
params
},
(
send: (text: string) => void,
cancel: () => void
) => {
cancelButton.removeAttribute('disabled')
const call = new LocalCall({
call: action,
params
});
call.beforeCall = function() {
cancelButton.removeAttribute('disabled')
cancelButton.addEventListener('click', (event) => {
event.preventDefault()
print('^C\n', true)
cancel()
this.cancel()
})
print(' Connected.\n', true)
},
print
).then(close)
.catch(() => {
close({ success: false, message: 'connection closed unexpectedly' })
})
}
call.onLogLine = print;
call.connect()
.then((result) => close(result))
.catch(() => close({ success: false, message: 'connection closed unexpectedly' }));
}

View file

@ -0,0 +1,8 @@
import { default as Call, CallSpec } from '../apiclient/websocket';
/** LocalCall is like Call, but uses the current page */
export default class LocalCall extends Call {
constructor(spec: CallSpec) {
super({ url: location.protocol.replace('http', 'ws') + '//' + location.host + '/api/v1/ws'}, spec);
}
}

View file

@ -1,83 +0,0 @@
import { Mutex } from 'async-mutex'
import { runMutexExclusive } from '~/src/lib/discard'
export interface CallMessage { call: string, params?: string[] | null }
export type ResultMessage = { success: true } | { success: false, message: string }
export interface SignalMessage { signal: string }
function isResultMessage (value: any): value is ResultMessage {
return typeof value === 'object' &&
Object.prototype.hasOwnProperty.call(value, 'success') &&
(
(value.success === true) ||
(value.success === false && Object.prototype.hasOwnProperty.call(value, 'message') && (typeof value.message === 'string'))
)
}
/**
* Opens a WebSocket connection and calls a server action
* @param endpoint Endpoint to call
* @param call Function to call
* @param onOpen callback for once the connection is opened. The send function can be used to send additional text to the server. It should include newlines.
* @param onText called when the connection receives some text, including newlines.
* @returns a promise that is resolved once the conneciton is closed. Rejected if the connection errors.
*/
export default async function callServerAction (
endpoint: string,
call: CallMessage,
onOpen: (send: (text: string) => void, cancel: () => void) => void,
onText: (text: string) => void
): Promise<ResultMessage> {
return await new Promise((resolve, reject) => {
const mutex = new Mutex()
const socket = new WebSocket(endpoint)
let result: ResultMessage
socket.onmessage = (msg) => {
runMutexExclusive(mutex, async () => {
if (typeof msg.data === 'string') {
onText(msg.data)
return
}
if (msg.data instanceof Blob) {
const object = JSON.parse(await msg.data.text())
if (isResultMessage(object)) {
if (object.success) {
result = { success: true }
} else {
result = { success: false, message: object.message }
}
return
}
}
console.warn('Unknown message', msg)
})
}
socket.onclose = () => {
mutex.runExclusive(() => resolve(result)).then(() => {}).catch(console.error.bind(console))
}
socket.onerror = reject // if an error occurs, close the socket
socket.onopen = () => {
const blob = new Blob([JSON.stringify(call)])
socket.send(blob)
onOpen(
(text: string) => {
if (typeof text !== 'string') {
console.warn('Ignoring send() call with unknown type', text)
return
}
socket.send(text)
},
() => {
const blob = new Blob([JSON.stringify({ signal: 'cancel' })])
socket.send(blob)
}
)
}
})
}