From d68f57dba8267d67df1f3b13f1ca2172f170914c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 29 Jan 2020 10:46:38 -0800 Subject: [PATCH] chore: introduce internal protocol --- src/protocol/browser.ts | 116 ++++++++++ src/protocol/connection.ts | 205 ++++++++++++++++++ src/protocol/protocol.pdl | 231 ++++++++++++++++++++ src/protocol/protocol.ts | 270 ++++++++++++++++++++++++ utils/generateInternalProtocol.js | 2 + utils/protocol-types-generator/index.js | 194 ++++++++++++++++- 6 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 src/protocol/browser.ts create mode 100644 src/protocol/connection.ts create mode 100644 src/protocol/protocol.pdl create mode 100644 src/protocol/protocol.ts create mode 100644 utils/generateInternalProtocol.js diff --git a/src/protocol/browser.ts b/src/protocol/browser.ts new file mode 100644 index 0000000000..b81f8bb201 --- /dev/null +++ b/src/protocol/browser.ts @@ -0,0 +1,116 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { helper } from '../helper'; +import { ClientConnection } from './connection'; +import * as platform from '../platform'; +import { ConnectionTransport } from '../transport'; +import { Events } from '../events'; +import * as types from '../types'; + +export type BrowserContextOptions = { + viewport?: types.Viewport | null, + // ignoreHTTPSErrors?: boolean, + // javaScriptEnabled?: boolean, + // bypassCSP?: boolean, + // userAgent?: string, + // timezoneId?: string, + geolocation?: types.Geolocation, + // permissions?: { [key: string]: string[] }; +}; + +export class Browser extends platform.EventEmitter { + readonly _connection: ClientConnection; + readonly _browserContexts = new Map(); + + constructor(transport: ConnectionTransport) { + super(); + this._connection = new ClientConnection(transport, () => this.emit(Events.Browser.Disconnected)); + } + + async newContext(options: BrowserContextOptions = {}): Promise { + options = { ...options }; + if (!options.viewport && options.viewport !== null) + options.viewport = { width: 800, height: 600 }; + if (options.viewport) + options.viewport = { ...options.viewport }; + if (options.geolocation) + options.geolocation = verifyGeolocation(options.geolocation); + + const { contextId } = await this._connection.send('BrowserContext.create', { + viewportSize: options.viewport ? { width: options.viewport.width, height: options.viewport.height } : undefined, + geolocation: options.geolocation, + }); + return new BrowserContext(this, contextId, options); + } + + async disconnect() { + const disconnected = new Promise(f => this.once(Events.Browser.Disconnected, f)); + this._connection.close(); + await disconnected; + } + + isConnected(): boolean { + return !this._connection.isClosed(); + } +} + +export class BrowserContext { + readonly _browser: Browser; + readonly _browserContextId: string; + readonly _options: BrowserContextOptions; + + private _closed = false; + + constructor(browser: Browser, browserContextId: string, options: BrowserContextOptions) { + this._browser = browser; + this._browserContextId = browserContextId; + this._options = options; + this._browser._browserContexts.set(this._browserContextId, this); + } + + async setGeolocation(geolocation: types.Geolocation | null): Promise { + if (geolocation) + geolocation = verifyGeolocation(geolocation); + this._options.geolocation = geolocation || undefined; + await this._browser._connection.send('BrowserContext.setGeolocation', { + contextId: this._browserContextId, + geolocation: this._options.geolocation, + }); + } + + async close() { + if (this._closed) + return; + this._closed = true; + this._browser._browserContexts.delete(this._browserContextId); + await this._browser._connection.send('BrowserContext.destroy', { contextId: this._browserContextId }); + } +} + +function verifyGeolocation(geolocation: types.Geolocation): types.Geolocation { + const result = { ...geolocation }; + result.accuracy = result.accuracy || 0; + const { longitude, latitude, accuracy } = result; + if (!helper.isNumber(longitude) || longitude < -180 || longitude > 180) + throw new Error(`Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`); + if (!helper.isNumber(latitude) || latitude < -90 || latitude > 90) + throw new Error(`Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`); + if (!helper.isNumber(accuracy) || accuracy < 0) + throw new Error(`Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`); + return result; +} diff --git a/src/protocol/connection.ts b/src/protocol/connection.ts new file mode 100644 index 0000000000..d3e18b3eff --- /dev/null +++ b/src/protocol/connection.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from '../helper'; +import * as platform from '../platform'; +import { ConnectionTransport } from '../transport'; +import { Protocol } from './protocol'; + +const debugClient = platform.debug('pw:client'); +const debugServer = platform.debug('pw:server'); + +export interface ObjectTransport { + send(message: any): void; + close(): void; // Note: calling close is expected to issue onclose at some point. + onmessage?: (message: any) => void, + onclose?: () => void, +} + +export class JSONTransport implements ObjectTransport { + private readonly _transport: ConnectionTransport; + + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(transport: ConnectionTransport) { + this._transport = transport; + this._transport.onmessage = message => { + if (this.onmessage) + this.onmessage(JSON.parse(message)); + }; + this._transport.onclose = () => { + if (this.onclose) + this.onclose(); + }; + this.onmessage = undefined; + this.onclose = undefined; + } + + send(message: any) { + this._transport.send(JSON.stringify(message)); + } + + close() { + this._transport.close(); + } +} + +export class ClientConnection extends platform.EventEmitter { + private _lastId = 0; + private readonly _transport: ObjectTransport; + private _closed = false; + private _onDisconnect: () => void; + private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); + + on: (event: T, listener: (payload: Protocol.Events[T]) => void) => this; + addListener: (event: T, listener: (payload: Protocol.Events[T]) => void) => this; + off: (event: T, listener: (payload: Protocol.Events[T]) => void) => this; + removeListener: (event: T, listener: (payload: Protocol.Events[T]) => void) => this; + once: (event: T, listener: (payload: Protocol.Events[T]) => void) => this; + + constructor(transport: ObjectTransport, onDisconnect: () => void) { + super(); + this._transport = transport; + this._transport.onmessage = this._dispatchMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + this._onDisconnect = onDisconnect; + this.on = super.on; + this.off = super.removeListener; + this.addListener = super.addListener; + this.removeListener = super.removeListener; + this.once = super.once; + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + if (this._closed) + return Promise.reject(new Error(`Internal error (${method}): Connection has been closed.`)); + const id = ++this._lastId; + const message = { id, method, params }; + const result = new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + debugClient('SEND ► %o', message); + this._transport.send(message); + return result; + } + + private _dispatchMessage(message: any) { + debugClient('◀ RECV %o', message); + if (message.id && this._callbacks.has(message.id)) { + const callback = this._callbacks.get(message.id)!; + this._callbacks.delete(message.id); + if (message.error) + callback.reject(createInternalError(callback.error, callback.method, message)); + else + callback.resolve(message.result); + } else { + assert(!message.id); + Promise.resolve().then(() => this.emit(message.method, message.params)); + } + } + + _onClose() { + this._closed = true; + this._transport.onmessage = undefined; + this._transport.onclose = undefined; + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Internal error (${callback.method}): Connection has been closed.`)); + this._callbacks.clear(); + this._onDisconnect(); + } + + isClosed() { + return this._closed; + } + + close() { + if (!this._closed) + this._transport.close(); + } +} + +export class ServerConnection { + private readonly _transport: ObjectTransport; + private _closed = false; + private _onDisconnect: () => void; + private readonly _handlers = new Map Promise>(); + + constructor(transport: ObjectTransport, onDisconnect: () => void) { + this._transport = transport; + this._transport.onmessage = this._dispatchMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + this._onDisconnect = onDisconnect; + } + + on(command: T, handler: (payload: Protocol.CommandParameters[T]) => Promise) { + this._handlers.set(command, handler); + } + + send(event: T, params?: Protocol.Events[T]) { + if (this._closed) + return Promise.reject(new Error(`Internal error (${event}): Connection has been closed.`)); + const message = { event, params }; + debugServer('SEND ► %o', message); + this._transport.send(message); + } + + private async _dispatchMessage(message: any) { + debugServer('◀ RECV %o', message); + assert(message.id); + let result; + try { + const handler = this._handlers.get(message.method); + if (!handler) + throw new Error(`Unknown command "${message.method}"`); + result = { id: message.id, result: await handler(message.params) }; + } catch (e) { + result = { id: message.id, error: { message: e.message } }; + } + this._transport.send(result); + } + + _onClose() { + this._closed = true; + this._transport.onmessage = undefined; + this._transport.onclose = undefined; + this._onDisconnect(); + } + + isClosed() { + return this._closed; + } + + close() { + if (!this._closed) + this._transport.close(); + } +} + +function createInternalError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { + let message = `Internal error (${method}): ${object.error.message}`; + if ('data' in object.error) + message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} diff --git a/src/protocol/protocol.pdl b/src/protocol/protocol.pdl new file mode 100644 index 0000000000..78c731d25c --- /dev/null +++ b/src/protocol/protocol.pdl @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +domain Types + type Size extends object + properties + integer width + integer height + +domain BrowserContext + type BrowserContextId extends string + + type Geolocation extends object + properties + number longitude + number latitude + optional number accuracy + + command create + parameters + optional Types.Size viewportSize + optional Geolocation geolocation + returns + BrowserContextId contextId + + command destroy + parameters + BrowserContextId contextId + + command setGeolocation + parameters + BrowserContextId contextId + optional Geolocation geolocation + +domain Frame + type FrameId extends string + + type FrameTree extends object + properties + FrameId frameId + string url + optional array of FrameTree childFrames + + type Lifecycle extends string + enum + domcontentloaded + load + networkidle0 + networkidle2 + + command createPage + parameters + BrowserContext.BrowserContextId contextId + optional string url + returns + FrameId mainFrameId + + command destroyPage + parameters + FrameId mainFrameId + + event pageCreated + parameters + BrowserContext.BrowserContextId contextId + FrameTree frameTree + + event pageDestroyed + parameters + FrameId mainFrameId + + event attached + parameters + FrameId frameId + FrameId parentFrameId + + event navigated + parameters + FrameId frameId + string url + + event detached + parameters + FrameId frameId + + command navigate + parameters + FrameId frameId + string url + array of Lifecycle waitUntil + optional string referrer + returns + optional Network.RequestId requestId + +domain JS + type HandleId extends string + + type Handle extends object + properties + HandleId handleId + string asString + boolean isDOMElement + + type Value extends object + properties + optional string string + optional number number + optional boolean null + optional boolean undefined + optional boolean boolean + optional array of Value values + optional array of string keys + + type CallArgument extends object + properties + optional Value value + optional HandleId handleId + + command evaluateHandle + parameters + string functionExpression + array of CallArgument args + optional Frame.FrameId frameId + optional HandleId handleId + returns + Handle handle + + command evaluate + parameters + string functionExpression + array of CallArgument args + optional Frame.FrameId frameId + optional HandleId handleId + returns + Value value + + command handleAsValue + parameters + HandleId handleId + returns + Value value + + command disposeHandle + parameters + HandleId handleId + +domain DOM + command ownerFrame + parameters + JS.HandleId handleId + returns + optional Frame.FrameId frameId + + command contentFrame + parameters + JS.HandleId handleId + returns + optional Frame.FrameId frameId + + command scrollIntoViewIfNeeded + parameters + JS.HandleId handleId + +domain Network + type RequestId extends string + + type ResourceType extends string + enum + document + + type Header extends object + properties + string name + string value + + type Headers extends array of Header + + event requestStarted + parameters + RequestId requestId + Frame.FrameId frameId + string url + string method + ResourceType resourceType + Headers headers + boolean isIntercepted + boolean isNavigation + optional RequestId redirectedFrom + optional string postData + + event responseReceived + parameters + RequestId requestId + + event requestFailed + parameters + RequestId requestId + string errorText + + event requestFinished + parameters + RequestId requestId + + command abortRequest + parameters + RequestId requestId + string errorCode + + command fulfillRequest + parameters + RequestId requestId + integer status + Headers headers + string contentType + string base64body + + command continueRequest + parameters + RequestId requestId + optional Headers headers + diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts new file mode 100644 index 0000000000..293b78902e --- /dev/null +++ b/src/protocol/protocol.ts @@ -0,0 +1,270 @@ +// This is generated from /utils/protocol-types-generator/index.js +type binary = string; +export module Protocol { + export module Types { + export interface Size { + width: number; + height: number; + } + + + } + + export module BrowserContext { + export type BrowserContextId = string; + export interface Geolocation { + longitude: number; + latitude: number; + accuracy?: number; + } + + + export type createParameters = { + viewportSize?: Types.Size; + geolocation?: Geolocation; + } + export type createReturnValue = { + contextId: BrowserContextId; + } + export type destroyParameters = { + contextId: BrowserContextId; + } + export type destroyReturnValue = { + } + export type setGeolocationParameters = { + contextId: BrowserContextId; + geolocation?: Geolocation; + } + export type setGeolocationReturnValue = { + } + } + + export module Frame { + export type FrameId = string; + export interface FrameTree { + frameId: FrameId; + url: string; + childFrames?: FrameTree[]; + } + export type Lifecycle = "domcontentloaded"|"load"|"networkidle0"|"networkidle2"; + + export type pageCreatedPayload = { + contextId: BrowserContext.BrowserContextId; + frameTree: FrameTree; + } + export type pageDestroyedPayload = { + mainFrameId: FrameId; + } + export type attachedPayload = { + frameId: FrameId; + parentFrameId: FrameId; + } + export type navigatedPayload = { + frameId: FrameId; + url: string; + } + export type detachedPayload = { + frameId: FrameId; + } + + export type createPageParameters = { + contextId: BrowserContext.BrowserContextId; + url?: string; + } + export type createPageReturnValue = { + mainFrameId: FrameId; + } + export type destroyPageParameters = { + mainFrameId: FrameId; + } + export type destroyPageReturnValue = { + } + export type navigateParameters = { + frameId: FrameId; + url: string; + waitUntil: Lifecycle[]; + referrer?: string; + } + export type navigateReturnValue = { + requestId?: Network.RequestId; + } + } + + export module JS { + export type HandleId = string; + export interface Handle { + handleId: HandleId; + asString: string; + isDOMElement: boolean; + } + export interface Value { + string?: string; + number?: number; + null?: boolean; + undefined?: boolean; + boolean?: boolean; + values?: Value[]; + keys?: string[]; + } + export interface CallArgument { + value?: Value; + handleId?: HandleId; + } + + + export type evaluateHandleParameters = { + functionExpression: string; + args: CallArgument[]; + frameId?: Frame.FrameId; + handleId?: HandleId; + } + export type evaluateHandleReturnValue = { + handle: Handle; + } + export type evaluateParameters = { + functionExpression: string; + args: CallArgument[]; + frameId?: Frame.FrameId; + handleId?: HandleId; + } + export type evaluateReturnValue = { + value: Value; + } + export type handleAsValueParameters = { + handleId: HandleId; + } + export type handleAsValueReturnValue = { + value: Value; + } + export type disposeHandleParameters = { + handleId: HandleId; + } + export type disposeHandleReturnValue = { + } + } + + export module DOM { + + + export type ownerFrameParameters = { + handleId: JS.HandleId; + } + export type ownerFrameReturnValue = { + frameId?: Frame.FrameId; + } + export type contentFrameParameters = { + handleId: JS.HandleId; + } + export type contentFrameReturnValue = { + frameId?: Frame.FrameId; + } + export type scrollIntoViewIfNeededParameters = { + handleId: JS.HandleId; + } + export type scrollIntoViewIfNeededReturnValue = { + } + } + + export module Network { + export type RequestId = string; + export type ResourceType = "document"; + export interface Header { + name: string; + value: string; + } + export type Headers = Header[]; + + export type requestStartedPayload = { + requestId: RequestId; + frameId: Frame.FrameId; + url: string; + method: string; + resourceType: ResourceType; + headers: Headers; + isIntercepted: boolean; + isNavigation: boolean; + redirectedFrom?: RequestId; + postData?: string; + } + export type responseReceivedPayload = { + requestId: RequestId; + } + export type requestFailedPayload = { + requestId: RequestId; + errorText: string; + } + export type requestFinishedPayload = { + requestId: RequestId; + } + + export type abortRequestParameters = { + requestId: RequestId; + errorCode: string; + } + export type abortRequestReturnValue = { + } + export type fulfillRequestParameters = { + requestId: RequestId; + status: number; + headers: Headers; + contentType: string; + base64body: string; + } + export type fulfillRequestReturnValue = { + } + export type continueRequestParameters = { + requestId: RequestId; + headers?: Headers; + } + export type continueRequestReturnValue = { + } + } + + export interface Events { + "Frame.pageCreated": Frame.pageCreatedPayload; + "Frame.pageDestroyed": Frame.pageDestroyedPayload; + "Frame.attached": Frame.attachedPayload; + "Frame.navigated": Frame.navigatedPayload; + "Frame.detached": Frame.detachedPayload; + "Network.requestStarted": Network.requestStartedPayload; + "Network.responseReceived": Network.responseReceivedPayload; + "Network.requestFailed": Network.requestFailedPayload; + "Network.requestFinished": Network.requestFinishedPayload; + } + export interface CommandParameters { + "BrowserContext.create": BrowserContext.createParameters; + "BrowserContext.destroy": BrowserContext.destroyParameters; + "BrowserContext.setGeolocation": BrowserContext.setGeolocationParameters; + "Frame.createPage": Frame.createPageParameters; + "Frame.destroyPage": Frame.destroyPageParameters; + "Frame.navigate": Frame.navigateParameters; + "JS.evaluateHandle": JS.evaluateHandleParameters; + "JS.evaluate": JS.evaluateParameters; + "JS.handleAsValue": JS.handleAsValueParameters; + "JS.disposeHandle": JS.disposeHandleParameters; + "DOM.ownerFrame": DOM.ownerFrameParameters; + "DOM.contentFrame": DOM.contentFrameParameters; + "DOM.scrollIntoViewIfNeeded": DOM.scrollIntoViewIfNeededParameters; + "Network.abortRequest": Network.abortRequestParameters; + "Network.fulfillRequest": Network.fulfillRequestParameters; + "Network.continueRequest": Network.continueRequestParameters; + } + export interface CommandReturnValues { + "BrowserContext.create": BrowserContext.createReturnValue; + "BrowserContext.destroy": BrowserContext.destroyReturnValue; + "BrowserContext.setGeolocation": BrowserContext.setGeolocationReturnValue; + "Frame.createPage": Frame.createPageReturnValue; + "Frame.destroyPage": Frame.destroyPageReturnValue; + "Frame.navigate": Frame.navigateReturnValue; + "JS.evaluateHandle": JS.evaluateHandleReturnValue; + "JS.evaluate": JS.evaluateReturnValue; + "JS.handleAsValue": JS.handleAsValueReturnValue; + "JS.disposeHandle": JS.disposeHandleReturnValue; + "DOM.ownerFrame": DOM.ownerFrameReturnValue; + "DOM.contentFrame": DOM.contentFrameReturnValue; + "DOM.scrollIntoViewIfNeeded": DOM.scrollIntoViewIfNeededReturnValue; + "Network.abortRequest": Network.abortRequestReturnValue; + "Network.fulfillRequest": Network.fulfillRequestReturnValue; + "Network.continueRequest": Network.continueRequestReturnValue; + } +} diff --git a/utils/generateInternalProtocol.js b/utils/generateInternalProtocol.js new file mode 100644 index 0000000000..b72b008ec1 --- /dev/null +++ b/utils/generateInternalProtocol.js @@ -0,0 +1,2 @@ +const { generateInternalProtocol } = require('./protocol-types-generator'); +generateInternalProtocol(); diff --git a/utils/protocol-types-generator/index.js b/utils/protocol-types-generator/index.js index f2004d2ce9..e46197dfff 100644 --- a/utils/protocol-types-generator/index.js +++ b/utils/protocol-types-generator/index.js @@ -216,4 +216,196 @@ function firefoxTypeToString(type, indent=' ') { return type['$type']; } -module.exports = {generateChromiunProtocol, generateFirefoxProtocol, generateWebKitProtocol}; +function generateInternalProtocol() { + const pdlPath = path.join(__dirname, '..', '..', 'src', 'protocol', 'protocol.pdl'); + const tsPath = path.join(__dirname, '..', '..', 'src', 'protocol', 'protocol.ts'); + const json = pdlToJSON(fs.readFileSync(pdlPath, 'utf-8')); + fs.writeFileSync(tsPath, jsonToTS(json)); + console.log(`Wrote protocol.ts to ${path.relative(process.cwd(), tsPath)}`); +} + +function pdlToJSON(pdl) { + const lines = pdl.split('\n'); + let lineIndex = 0; + let current; + + function next() { + while (lineIndex < lines.length) { + let line = lines[lineIndex++]; + let indent = 0; + while (line[indent] === ' ') + indent++; + let commentIndex = line.indexOf('#'); + if (commentIndex !== -1) + line = line.substring(0, commentIndex); + line = line.trim(); + if (line) { + current = { line, indent }; + return current; + } + } + current = { indent: -1, line: '' }; + return current; + } + + function readDomains() { + const domains = []; + while (current.indent !== -1) + domains.push(readDomain()); + return domains; + } + + function readDomain() { + const indent = current.indent; + const match = current.line.match(/^domain\s+([a-zA-Z0-9_]+)$/); + next(); + const domain = { + domain: match[1], + commands: [], + events: [], + types: [], + }; + while (current.indent > indent) { + if (current.line.startsWith('command')) + domain.commands.push(readCommand()); + else if (current.line.startsWith('event')) + domain.events.push(readEvent()); + else if (current.line.startsWith('type')) + domain.types.push(readType()); + else + throw new Error(`Cannot parse: ${current.line}`); + } + return domain; + } + + function readCommand() { + const indent = current.indent; + const match = current.line.match(/^command\s+([a-zA-Z0-9_]+)$/); + next(); + const command = { + name: match[1], + parameters: [], + returns: [], + }; + while (current.indent > indent) { + if (current.line === 'parameters') { + const i = current.indent; + next(); + while (current.indent > i) + command.parameters.push(readProperty()); + } else if (current.line === 'returns') { + const i = current.indent; + next(); + while (current.indent > i) + command.returns.push(readProperty()); + } else { + throw new Error(`Cannot parse: ${current.line}`); + } + } + return command; + } + + function readEvent() { + const indent = current.indent; + const match = current.line.match(/^event\s+([a-zA-Z0-9_]+)$/); + next(); + const event = { + name: match[1], + parameters: [], + returns: [], + }; + while (current.indent > indent) { + if (current.line === 'parameters') { + const i = current.indent; + next(); + while (current.indent > i) + event.parameters.push(readProperty()); + } else { + throw new Error(`Cannot parse: ${current.line}`); + } + } + return event; + } + + function readType() { + const match = current.line.match(/^type\s+([a-zA-Z0-9_]+)\s+extends(.*)$/); + const type = { + id: match[1], + }; + let t = type; + const words = match[2].split(' ').filter(s => !!s); + while (words[0] === 'array' && words[1] === 'of') { + t.type = 'array'; + const items = {}; + t.items = items; + t = items; + words.shift(); + words.shift(); + } + if (words.length !== 1) + throw new Error(`Cannot parse: ${current.line}`); + if (['boolean', 'integer', 'number'].includes(words[0])) { + t.type = words[0]; + next(); + } else if (words[0] === 'object') { + t.type = 'object'; + next(); + if (current.line !== 'properties') + throw new Error(`Cannot parse: ${current.line}`); + const indent = current.indent; + next(); + t.properties = []; + while (current.indent > indent) + t.properties.push(readProperty()); + } else if (words[0] === 'string') { + t.type = 'string'; + next(); + if (current.line === 'enum') { + const indent = current.indent; + next(); + t.enum = []; + while (current.indent > indent) { + if (!current.line.match(/^[a-zA-Z0-9_]+$/)) + throw new Error(`Cannot parse: ${current.line}`); + t.enum.push(current.line); + next(); + } + } + } else { + t.$ref = words[0]; + next(); + } + return type; + } + + function readProperty() { + const match = current.line.match(/^(optional)?\s*(.*)\s+([a-zA-Z0-9_]+)$/); + const prop = { + optional: !!match[1], + name: match[3], + }; + let t = prop; + const words = match[2].split(' ').filter(s => !!s); + while (words[0] === 'array' && words[1] === 'of') { + t.type = 'array'; + const items = {}; + t.items = items; + t = items; + words.shift(); + words.shift(); + } + if (words.length !== 1) + throw new Error(`Cannot parse: ${current.line}`); + if (['string', 'boolean', 'integer', 'number'].includes(words[0])) + t.type = words[0]; + else + t.$ref = words[0]; + next(); + return prop; + } + + next(); + return { domains: readDomains() }; +} + +module.exports = {generateChromiunProtocol, generateFirefoxProtocol, generateWebKitProtocol, generateInternalProtocol};