assets: Use new websocket client
This commit is contained in:
parent
e91f1f2413
commit
6a739df24b
67 changed files with 437 additions and 562 deletions
|
|
@ -0,0 +1,2 @@
|
|||
This folder contains a websocket client to the API.
|
||||
It works in both nodejs and the browser.
|
||||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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' }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue