diff --git a/src/Sandbox.js b/src/Sandbox.js index 9ec702b..e9142cb 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -23,7 +23,7 @@ const { normalizeSize, apiRequest } = require('./utils') -const { SANDBOX_SIZES } = require('./constants') +const { SANDBOX_SIZES, PROTOCOL_VERSION, API_PREFIX } = require('./constants') const { SandboxSocket } = require('./ws') /** @@ -44,6 +44,7 @@ class Sandbox { this.region = options.region this.idleTimeout = options.idleTimeout this.maxLifetime = options.maxLifetime + this.protocolVersion = options.protocolVersion || PROTOCOL_VERSION this.namespace = options.namespace this.apiHost = options.apiHost @@ -98,7 +99,7 @@ class Sandbox { if (options.policy !== undefined) body.policy = options.policy if (options.ports !== undefined) body.ports = options.ports - const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes` + const url = `${creds.apiHost}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes` const payload = await apiRequest('POST', url, creds.apiKey, body) const sandboxId = payload.sandboxId @@ -112,6 +113,7 @@ class Sandbox { region: payload.region, idleTimeout: payload.idleTimeout, maxLifetime: payload.maxLifetime, + protocolVersion: payload.protocolVersion || PROTOCOL_VERSION, previewUrls: parsePreviewUrls(payload.previewUrls), managementEndpoint: payload.managementEndpoint || null, namespace: creds.namespace, @@ -146,7 +148,7 @@ class Sandbox { console.warn('[aio-lib-sandbox] alpha — APIs may change without notice') const creds = resolveCredentials(options) const base = options.managementEndpoint || creds.apiHost - const url = `${base}/api/v1/namespaces/${creds.namespace}/sandboxes/${sandboxId}` + const url = `${base}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes/${sandboxId}` const payload = await apiRequest('GET', url, creds.apiKey) return new Sandbox({ @@ -157,6 +159,7 @@ class Sandbox { region: payload.region, idleTimeout: payload.idleTimeout, maxLifetime: payload.maxLifetime, + protocolVersion: payload.protocolVersion || PROTOCOL_VERSION, managementEndpoint: payload.managementEndpoint || options.managementEndpoint || null, previewUrls: parsePreviewUrls(payload.previewUrls), namespace: creds.namespace, @@ -175,6 +178,15 @@ class Sandbox { return SANDBOX_SIZES } + /** + * Sandbox wire protocol major bundled with this SDK. + * + * @type {string} + */ + static get protocolVersion () { + return PROTOCOL_VERSION + } + /** * Exposes `resolveCredentials` as a static helper (useful for testing). * @@ -507,7 +519,7 @@ class Sandbox { */ async destroy () { const base = this.managementEndpoint || this.apiHost - const url = `${base}/api/v1/namespaces/${this.namespace}/sandboxes/${this.id}` + const url = `${base}${API_PREFIX}/namespaces/${this.namespace}/sandboxes/${this.id}` this.ws?.beginIntentionalClose() let payload diff --git a/src/constants.js b/src/constants.js index f0479c1..a4fca07 100644 --- a/src/constants.js +++ b/src/constants.js @@ -16,4 +16,7 @@ const SANDBOX_SIZES = Object.freeze({ XLARGE: { cpu: '8000m', memory: '32Gi', gpu: 1 } }) -module.exports = { SANDBOX_SIZES } +const PROTOCOL_VERSION = '1' +const API_PREFIX = `/api/v${PROTOCOL_VERSION}` + +module.exports = { SANDBOX_SIZES, PROTOCOL_VERSION, API_PREFIX } diff --git a/src/errors.js b/src/errors.js index f17014a..8900f4b 100644 --- a/src/errors.js +++ b/src/errors.js @@ -28,6 +28,8 @@ class SandboxWebSocketError extends SandboxSDKError {} class SandboxCommandNotFoundError extends SandboxSDKError {} class SandboxPortNotProvisionedError extends SandboxSDKError {} class SandboxInvalidPortError extends SandboxClientError {} +class ProtocolVersionMismatchError extends SandboxClientError {} +class SandboxMalformedFrameError extends SandboxClientError {} module.exports = { SandboxSDKError, @@ -39,5 +41,7 @@ module.exports = { SandboxWebSocketError, SandboxCommandNotFoundError, SandboxPortNotProvisionedError, - SandboxInvalidPortError + SandboxInvalidPortError, + ProtocolVersionMismatchError, + SandboxMalformedFrameError } diff --git a/src/index.js b/src/index.js index 50f97af..5b600f7 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ governing permissions and limitations under the License. */ const Sandbox = require('./Sandbox') +const { PROTOCOL_VERSION } = require('./constants') const { SandboxSDKError, SandboxInitializationError, @@ -20,11 +21,14 @@ const { SandboxWebSocketError, SandboxCommandNotFoundError, SandboxPortNotProvisionedError, - SandboxInvalidPortError + SandboxInvalidPortError, + ProtocolVersionMismatchError, + SandboxMalformedFrameError } = require('./errors') module.exports = { Sandbox, + SANDBOX_PROTOCOL_VERSION: PROTOCOL_VERSION, SandboxSDKError, SandboxInitializationError, SandboxClientError, @@ -34,5 +38,7 @@ module.exports = { SandboxWebSocketError, SandboxCommandNotFoundError, SandboxPortNotProvisionedError, - SandboxInvalidPortError + SandboxInvalidPortError, + ProtocolVersionMismatchError, + SandboxMalformedFrameError } diff --git a/src/utils.js b/src/utils.js index d9d732f..7925fab 100644 --- a/src/utils.js +++ b/src/utils.js @@ -16,7 +16,7 @@ const { SandboxUnauthorizedError, SandboxTimeoutError } = require('./errors') -const { SANDBOX_SIZES } = require('./constants') +const { SANDBOX_SIZES, API_PREFIX } = require('./constants') /** * Builds a Basic authorization header from a Runtime API key. @@ -72,7 +72,7 @@ function normalizeApiHost (host) { function buildWebSocketEndpoint (apiHost, namespace, sandboxId) { const url = new URL(apiHost) url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:' - url.pathname = `/api/v1/namespaces/${namespace}/sandboxes/${sandboxId}/exec` + url.pathname = `${API_PREFIX}/namespaces/${namespace}/sandboxes/${sandboxId}/exec` url.search = '' return url.toString() } diff --git a/src/ws.js b/src/ws.js index 55d4aa7..213c6f4 100644 --- a/src/ws.js +++ b/src/ws.js @@ -13,6 +13,8 @@ const WebSocket = require('ws') const { SandboxClientError, SandboxCommandNotFoundError, + ProtocolVersionMismatchError, + SandboxMalformedFrameError, SandboxUnauthorizedError, SandboxWebSocketError } = require('./errors') @@ -355,6 +357,16 @@ class SandboxSocket { `Sandbox '${this.id}' rejected the WebSocket authentication token` ) } + if (code === 4003) { + return new ProtocolVersionMismatchError( + `Sandbox '${this.id}' WebSocket protocol version does not match this SDK` + ) + } + if (code === 4004) { + return new SandboxMalformedFrameError( + `Sandbox '${this.id}' rejected a malformed WebSocket frame` + ) + } return new SandboxWebSocketError( `Sandbox '${this.id}' WebSocket closed with code ${code}` ) diff --git a/test/Sandbox.test.js b/test/Sandbox.test.js index 555b127..9805aa2 100644 --- a/test/Sandbox.test.js +++ b/test/Sandbox.test.js @@ -17,11 +17,13 @@ const { SandboxCommandNotFoundError, SandboxInitializationError, SandboxNotFoundError, + ProtocolVersionMismatchError, SandboxPortNotProvisionedError, SandboxInvalidPortError, SandboxTimeoutError, SandboxUnauthorizedError, - SandboxWebSocketError + SandboxWebSocketError, + SandboxMalformedFrameError } = require('../src/errors') jest.mock('ws') @@ -199,6 +201,12 @@ describe('Sandbox', () => { }) }) + describe('protocolVersion', () => { + test('exposes the bundled sandbox protocol major', () => { + expect(Sandbox.protocolVersion).toBe('1') + }) + }) + // ------------------------------------------------------------------------- // Static factories // ------------------------------------------------------------------------- @@ -213,6 +221,7 @@ describe('Sandbox', () => { status: 'ready', token: 'tok-new', maxLifetime: 3600, + protocolVersion: '1', previewUrls: { 3000: 'https://sb-new-3000.preview.example.net' } @@ -236,6 +245,7 @@ describe('Sandbox', () => { expect(sandbox.id).toBe('sb-new') expect(sandbox.status).toBe('ready') + expect(sandbox.protocolVersion).toBe('1') expect(sandbox.previewUrls).toEqual(new Map([ [3000, 'https://sb-new-3000.preview.example.net'] ])) @@ -471,7 +481,8 @@ describe('Sandbox', () => { sandboxId: 'sb-get', status: 'running', cluster: 'cluster-b', - region: 'va6' + region: 'va6', + protocolVersion: '1' }) }) @@ -484,6 +495,7 @@ describe('Sandbox', () => { expect(sandbox.id).toBe('sb-get') expect(sandbox.status).toBe('running') expect(sandbox.cluster).toBe('cluster-b') + expect(sandbox.protocolVersion).toBe('1') }) test('stores idleTimeout from the get response on the instance', async () => { @@ -602,6 +614,22 @@ describe('Sandbox', () => { await expect(p).rejects.toThrow(SandboxUnauthorizedError) }) + test('rejects on protocol mismatch close code 4003 with ProtocolVersionMismatchError', async () => { + const sandbox = new Sandbox(BASE_OPTIONS) + const p = sandbox.connect() + sockets[0].open() + sockets[0].closeWith(4003) + await expect(p).rejects.toThrow(ProtocolVersionMismatchError) + }) + + test('rejects on malformed frame close code 4004 with SandboxMalformedFrameError', async () => { + const sandbox = new Sandbox(BASE_OPTIONS) + const p = sandbox.connect() + sockets[0].open() + sockets[0].closeWith(4004) + await expect(p).rejects.toThrow(SandboxMalformedFrameError) + }) + test('rejects on unexpected socket close', async () => { const sandbox = new Sandbox(BASE_OPTIONS) const p = sandbox.connect()