Browse Source

New JavaScript server

reboot
Nicolas Petton 3 years ago
parent
commit
69f0d71df1
No known key found for this signature in database GPG Key ID: E8BCD7866AFCF978
26 changed files with 3853 additions and 0 deletions
  1. +13
    -0
      server/.eslintrc.json
  2. +230
    -0
      server/README.md
  3. +260
    -0
      server/adapters/cdp/helpers.js
  4. +472
    -0
      server/adapters/cdp/index.js
  5. +9
    -0
      server/bin/indium
  6. +19
    -0
      server/helpers/async.js
  7. +42
    -0
      server/helpers/queue.js
  8. +92
    -0
      server/helpers/sourcemap.js
  9. +90
    -0
      server/helpers/workspace.js
  10. +1
    -0
      server/index.js
  11. +1552
    -0
      server/package-lock.json
  12. +40
    -0
      server/package.json
  13. +22
    -0
      server/server/configurations.js
  14. +64
    -0
      server/server/connection.js
  15. +69
    -0
      server/server/index.js
  16. +21
    -0
      server/server/respond.js
  17. +174
    -0
      server/server/runtime.js
  18. +14
    -0
      server/server/version.js
  19. +189
    -0
      server/spec/adapters/cdp/helpers-spec.js
  20. +109
    -0
      server/spec/helpers/queue-spec.js
  21. +89
    -0
      server/spec/helpers/sourcemap-spec.js
  22. +201
    -0
      server/spec/helpers/workspace-spec.js
  23. +12
    -0
      server/spec/server/connection-spec.js
  24. +38
    -0
      server/spec/server/respond-spec.js
  25. +20
    -0
      server/spec/server/version-spec.js
  26. +11
    -0
      server/spec/support/jasmine.json

+ 13
- 0
server/.eslintrc.json View File

@ -0,0 +1,13 @@
{
"env": {
"es6": true,
"browser": true,
"node": true
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"quotes": [2, "double"]
}
}

+ 230
- 0
server/README.md View File

@ -0,0 +1,230 @@
# Indium server
This documentation describes the version 2.0 of the server component of
[Indium](https://indium.readthedocs.org).
The Indium server exposes a common and simple interface to communicate with a JS
runtime for debugging. It currently supports Chrome/Chromium (debug protocol
1.2), and NodeJS >= 8.
## Installation
npm install -g indium
## Protocol
Communication is done through stdin/stdout using the JSON format.
Each message, from the server or the client, is terminated with line feed.
### 1. Overview
The client communicates with the server through JSON messages, referred to as
requests. The server answers with corresponding JSON messages, referred to as
responses.
The response order is not guaranteed to follow the request order. In order to
identify what request a specific response is emitted for, each request must
contain a unique `"id"` field. Its response will also contain an `"id"` field
with the same value.
For each request, the client is guaranteed to receive at some point in time a
response. The response can either be successful, or unsuccessful.
Responses contain a `"type"` field to identify whether they the
request completed successfully or not. Successful responses have their `"type"`
feild set to `"success"`, and unsuccessful responses to `"error"`.
#### Requests
Requests must follow the following general structure:
id `<string>`
: Id of the request
type `<string>`
: Type of request. The type specifies the category of the request. Valid types
are `"version"`, `"connection"`, `"configurations"` and `"runtime"`.
payload `<object>`
: Payload of the request. Contains the details specific to the request.
`payload` objects must specify an `action` key. The `action` key determines the
action to be executed by the server. Valid actions depend the on `type` of
request.
#### Responses
Each response contains the `id` of the corresponding request and follows the
following structure:
id `<string>`
: Id of the request the response is for
type `<string>`
: Type of response, either `"success"` or `"error"`
payload `<object>` *(optional)*
: Optional payload of the response
### 2. `configurations` requests
The server is responsible for looking up the `.indium.json` configuration file
from a project directory.
#### `list` actions
The `list` action fetches the available configurations for a directory.
**Request payload:**
action `"list"`
: Action type
directory `<string>`
: Directory where the `.indium.json` project file look up is started.
**Successful response payload:**
Array of configurations.
### 3. `connection` request type
`connection` requests are used to open/close connections to a runtime.
#### `connect` actions
**Request payload:**
action `"connect"`
: Action type
directory `<string>`
: Directory where the `.indium.json` project file look up is started.
name `<string>`
: Name of the configuration to choose for the runtime connection.
**Successful response payload:**
No payload.
#### `close` actions
**Request payload:**
action `"connect"`
: Action type
**Successful response payload:**
No payload, but the server exits.
### 4. `runtime` request type
The `runtime` request type is the most important type of request as evaluation,
inspection and debugging is done through runtime requests.
#### `evaluate` actions
Evaluation is context-sensitive. During a debugging session when the runtime is
paused, evaluation is done in the context of the current stack frame, with full
access to all locals.
**Request payload:**
action `"evaluate"`
: Action type
expression `<string>`
: Expression to evaluate
**Successful response payload:**
A [remote object](#remote-object).
#### `getCompletion` actions
**Request payload:**
**Successful response payload:**
#### `activateBreakpoints` actions
**Request payload:**
**Successful response payload:**
#### `deactivateBreakpoints` actions
**Request payload:**
**Successful response payload:**
#### `addBreakpoint` actions
**Request payload:**
**Successful response payload:**
#### `removeBreakpoint` actions
**Request payload:**
**Successful response payload:**
#### `resume` actions
**Request payload:**
**Successful response payload:**
#### `stepInto` actions
**Request payload:**
**Successful response payload:**
#### `stepOut` actions
**Request payload:**
**Successful response payload:**
#### `stepOver` actions
**Request payload:**
**Successful response payload:**
#### `getSource` actions
**Request payload:**
**Successful response payload:**
### 5. server notifications
#### `breakpointResolved` notifications
**Request payload:**
**Successful response payload:**
#### `paused` notifications
**Request payload:**
**Successful response payload:**
#### `resumed` notifications
**Request payload:**
**Successful response payload:**
### 6. Objects in payloads
#### <a name="remote-object"></a> Remote object
### 7. Examples
In the the following example, `>>` represents stdin and `<<` represents stdout.
> indium
>> {"id": "1", "type": "configurations", "payload": {"action": "list", "directory": "/home/user/proj/"}}
<< {"id": "1", "type": "success", "payload": [{"type": "chrome", "name": "Web Project", "root": "./src"}]}
>> {"id": "2", "type": "connection", "payload": {"action": "connect", "name": "Web Project", "directory": "/home/user/proj/""}}
<< {"id": "2", "type": "success"}
>> {"id": "3", "type": "runtime", "payload": {"action": "evaluate", "expression": "1+2"}}
<< {"id": "3", "type": "success", "payload": {"description": "3"}}
>> {"id": "4", "type": "connection", "payload": {"action": "close"}}
>

+ 260
- 0
server/adapters/cdp/helpers.js View File

@ -0,0 +1,260 @@
const { resolveSourceMap } = require("../../helpers/sourcemap");
const { resolveUrl, isFile } = require("../../helpers/workspace");
const { URL } = require("url");
const send = data => {
require("../../server").send(data);
};
const error = data => {
require("../../server").error()(data);
};
const convertRemoteObject = ({ objectId, value, type, subtype, description, preview }) => {
if (objectId) {
return {
id: objectId,
type: subtype || type,
description: convertDescription(description),
preview: preview ? convertPreview(preview) : ""
};
}
return { description: convertValue({ value, type, subtype }) };
};
const convertDescription = (description) => {
return description.length > 200
? `${description.substring(0, 200)}`
: description;
};
const convertValue = ({ value, type, subtype }) => {
if (type === "undefined") {
return "undefined";
}
if (type === "string") {
return `"${value}"`;
}
if (value === null) {
return "null";
}
if (type === "function") {
return "function";
}
return `${value}`;
};
const convertPreview = preview => {
if (preview.subtype === "array") {
return convertArrayPreview(preview);
}
return convertObjectPreview(preview);
};
const convertArrayPreview = preview => {
let properties = preview.properties
.map(convertPreviewProperty)
.join(", ");
return `[ ${properties}${preview.overflow ? ", …" : ""} ]`;
};
const convertObjectPreview = preview => {
let properties = preview.properties
.map(prop => `${prop.name}: ${convertPreviewProperty(prop)}`)
.join(", ");
return `{ ${properties}${preview.overflow ? ", …" : ""} }`;
};
const convertPreviewProperty = property => {
if (property.type === "string") {
return `"${property.value}"`;
}
if (property.value !== "") {
return property.value;
}
return property.type;
};
const convertCompletionResult = result => {
return Object.keys(result.result.value);
};
const convertConsoleEventType = type => {
switch(type) {
case "error":
return "error";
case "warning":
return "warning";
default:
return "log";
}
};
const convertCallFrames = async (frames, conf, scripts) => {
let result = [];
for (let frame of frames) {
result.push(await convertCallFrame(frame, conf, scripts));
}
return result;
};
const convertCallFrame = async (
{ functionName, location, scopeChain },
conf,
scripts
) => {
let fileLocation = await resolveScriptLocation({
scriptId: location.scriptId,
line: ++location.lineNumber,
column: location.columnNumber
}, conf, scripts);
return {
functionName,
scriptId: location.scriptId,
location: fileLocation,
scopeChain: scopeChain
.filter(s => s.type !== "global")
.map(s => ({
type: s.type,
name: s.name,
id: convertRemoteObject(s.object).id
}))
};
};
const resolveFileLocation = async (location, conf, scripts = []) => {
return await resolveFileLocationWithSourceMaps(location, conf, scripts)
|| resolveFileLocationWithScriptUrls(location, conf, scripts);
};
const resolveFileLocationWithSourceMaps = async ({ file, line }, conf, scripts) => {
for (let script of scripts) {
let sourcemap = await getScriptSourceMap(script, conf);
if (sourcemap) {
let position = sourcemap.generatedPositionFor({
source: file, line, column: 0
});
if (position.line) {
return {
url: script.url,
line: position.line,
column: position.column
};
}
}
}
return null;
};
const resolveFileLocationWithScriptUrls = ({ file, line }, conf, scripts) => {
for (let script of scripts) {
if (script.url) {
let scriptFile = resolveUrl(script.url, conf);
if (file === scriptFile) {
return {
url: script.url,
line,
column: 0
};
}
}
}
return null;
};
const resolveScriptLocation = async (location, conf, scripts = []) => {
let script = scripts.find(s => s && s.id === location.scriptId);
if (!script) {
return { ...location, url: "" };
}
return await resolveUrlLocation(
{
...location,
url: script.url
},
conf,
scripts
);
};
const resolveUrlLocation = async (location, conf, scripts = []) => {
return await resolveUrlLocationWithSourceMaps(location, conf, scripts)
|| await resolveUrlLocationWithConfiguration(location, conf, scripts);
};
const resolveUrlLocationWithSourceMaps = async (location, conf, scripts) => {
let script = scripts.find(s => s.url === location.url);
if (script && await getScriptSourceMap(script, conf)) {
let { source, line, column } = script.sourceMap.originalPositionFor(location);
if (isFile(source) && line) {
return { file: source, line, column };
}
}
return null;
};
const resolveUrlLocationWithConfiguration = ({ url, line, column = 0 }, conf) => {
return {
line,
column,
file: resolveUrl(url, conf)
};
};
const getScriptSourceMap = async (script, conf) => {
if (script.sourceMap) return script.sourceMap;
if (script.sourceMapURL) {
try {
script.sourceMap = await resolveSourceMap(script, conf);
} catch(e) {
send({
type: "log",
payload: {
type: "log",
result: {
description: `Soucemap parsing failed for ${script.url}: ${e.message}`
}
}
});
} finally {
// Delete the URL so we won't try to get the source map again.
delete script.sourceMapURL;
}
return script.sourceMap;
}
};
const completionFunction = "function getCompletions(type)\n{var object;if(type==='string')\nobject=new String('');else if(type==='number')\nobject=new Number(0);else if(type==='boolean')\nobject=new Boolean(false);else\nobject=this;var resultSet={};for(var o=object;o;o=o.__proto__){try{if(type==='array'&&o===object&&ArrayBuffer.isView(o)&&o.length>9999)\ncontinue;var names=Object.getOwnPropertyNames(o);for(var i=0;i<names.length;++i)\nresultSet[names[i]]=true;}catch(e){}}\nreturn resultSet;}";
module.exports = {
send,
error,
convertRemoteObject,
convertCompletionResult,
convertConsoleEventType,
convertCallFrames,
completionFunction,
resolveFileLocation,
resolveUrlLocation,
resolveScriptLocation,
getScriptSourceMap
};

+ 472
- 0
server/adapters/cdp/index.js View File

@ -0,0 +1,472 @@
const cdp = require("chrome-remote-interface");
const { resolveUrl } = require("../../helpers/workspace");
const { queued } = require("../../helpers/queue");
const { doOrRetry } = require("../../helpers/async");
const {
send, error,
convertRemoteObject,
convertCompletionResult,
convertConsoleEventType,
convertCallFrames,
resolveFileLocation,
resolveScriptLocation,
completionFunction,
getScriptSourceMap
} = require("./helpers");
let state = {
client: null,
configuration: null,
scripts: [],
breakpoints: {},
currentCallFrameId: null,
isChrome: false
};
const enableListeners = () => {
if (state.isChrome) {
state.client.Log.entryAdded(log);
}
state.client.Runtime.exceptionThrown(logException);
state.client.Runtime.consoleAPICalled(consoleToLog);
state.client.Debugger.scriptParsed(scriptAdded);
state.client.Debugger.paused(debuggerPaused);
state.client.Debugger.resumed(debuggerResumed);
};
const enableDomains = () => {
if (state.isChrome) {
state.client.Log.enable();
state.client.Network.enable();
}
state.client.Debugger.enable();
state.client.Debugger.setPauseOnExceptions({
state: "uncaught"
});
state.client.Runtime.enable();
state.client.Runtime.runIfWaitingForDebugger();
};
// API
const connect = async (options = {}) => {
if (state.client) {
throw new Error("Already connected, please close the current connection first");
}
state.configuration = options;
state.client = await doOrRetry(async () => await cdp(state.configuration));
state.isChrome = !!state.client.Network;
enableListeners();
enableDomains();
return state.client;
};
const disconnect = async () => {
if (state.client) {
await state.client.close();
state.client = null;
}
};
const evaluate = async expression => {
ensureConnected();
let method = state.currentCallFrameId
? state.client.Debugger.evaluateOnCallFrame
: state.client.Runtime.evaluate;
let response = await method({
generatePreview: true,
expression,
callFrameId: state.currentCallFrameId
});
return convertRemoteObject(response.result);
};
const getProperties = async (id) => {
ensureConnected();
let response = await state.client.Runtime.getProperties({
objectId: id,
generatePreview: true
});
return response.result.map(({ name, value, get }) => ({
name,
value: convertRemoteObject(value || get)
}));
return response.result;
};
const getCompletion = async (expression) => {
ensureConnected();
let response = await state.client.Runtime.evaluate({
expression,
objectGroup: "completion"
});
let { objectId, type } = response.result;
if (objectId) {
return getCompletionByReference(objectId);
} else {
return getCompletionByType(type);
}
};
const addBreakpoint = breakpoint => {
ensureConnected();
if (hasBreakpointAt(breakpoint)) {
throw new Error(`Breakpoint at location already set with id ${breakpoint.id}`);
}
state.breakpoints[breakpoint.id] = breakpoint;
registerBreakpoint(breakpoint);
};
const removeBreakpoint = async ({ id }) => {
let breakpoint = state.breakpoints[id];
delete state.breakpoints[id];
unregisterBreakpoint(breakpoint);
};
const activateBreakpoints = () => {
state.client.Debugger.setBreakpointsActive({
active: true
});
};
const deactivateBreakpoints = () => {
state.client.Debugger.setBreakpointsActive({
active: false
});
};
const getSource = async id => {
let { scriptSource } = await state.client.Debugger.getScriptSource({scriptId: id});
return scriptSource;
};
const resume = () => {
state.client.Debugger.resume();
};
const stepInto = () => {
state.client.Debugger.stepInto();
};
const stepOut = () => {
state.client.Debugger.stepOut();
};
const stepOver = () => {
state.client.Debugger.stepOver();
};
const continueToLocation = async fileLocation => {
let location = await resolveFileLocation(
fileLocation,
state.configuration,
state.scripts
);
if (location) {
let script = state.scripts.find(s => s.url === location.url);
await state.client.Debugger.continueToLocation({
location: {
scriptId: script.id,
lineNumber: --location.line,
columnNumber: location.column
}
});
} else {
error(`Invalid location to jump to ${fileLocation.file}:${fileLocation.line}`);
}
};
// Events
const scriptAdded = ({ scriptId, url, sourceMapURL }) => {
try {
let script = { id: scriptId, url, sourceMapURL };
// Remove any previous version of the same script first
for (let id in state.scripts) {
if (state.scripts[id].url === url) {
delete state.scripts[id];
}
}
state.scripts.push(script);
// We've got new script, so let's try to resolve unresolved breakpoints
if (script.url || script.sourceMapURL) {
resolveAllBreakpoints();
}
} catch(e) {
error(e.message);
}
};
const debuggerPaused = async ({ callFrames, reason, data = {} }) => {
try {
if (state.isChrome) {
state.client.Overlay.setPausedInDebuggerMessage({
message: "Paused in Indium"
});
}
state.currentCallFrameId = callFrames[0].callFrameId;
notify({
type: "paused",
frames: await convertCallFrames(
callFrames,
state.configuration,
Object.values(state.scripts)
),
reason: reason === "exception"
? "Exception occured"
: "Breakpoint hit",
description: data.description
});
} catch(e) {
error(e.message);
}
};
const debuggerResumed = () => {
try {
state.currentCallFrameId = null;
if (state.isChrome) {
state.client.Overlay.setPausedInDebuggerMessage();
}
notify({ type: "resumed" });
} catch (e) {
error(e.message);
}
};
// Async operations
const registerBreakpoint = async breakpoint => {
try {
let urlLocation = await resolveFileLocation(
breakpoint,
state.configuration,
Object.values(state.scripts)
);
// The breakpoint doesn't resolve to any location. The script might not
// have been parsed yet, to we'll try again later.
if (!urlLocation) {
return;
}
let { url, line } = urlLocation;
let result = await state.client.Debugger.setBreakpointByUrl({
url,
condition: breakpoint.condition,
// lines are 1-based in NPM's source-map package and Emacs, but 0-based
// in the CDP.
lineNumber: --line,
columnNumber: 0
});
let { locations: [ location ], breakpointId } = result;
breakpoint.remoteId = breakpointId;
if (location) {
let { line, column } = await resolveScriptLocation(
{
scriptId: location.scriptId,
line: ++location.lineNumber,
column: location.columnNumber
},
state.configuration,
Object.values(state.scripts)
);
breakpointResolved(breakpoint, line);
}
} catch(e) {
error(e.message);
}
};
/**
* Resolve all breakpoints that are not yet resolved.
*/
const resolveAllBreakpoints = queued(async () => {
let unresolved = Object.values(state.breakpoints).filter(
brk => !brk.resolved
);
for (let brk of unresolved) {
await reRegisterBreakpoint(brk);
}
});
/**
* Some breakpoint registrations might have failed in the past for two reasons:
* - no parsed script could be found for the breakpoint
* - the breakpoint resolution itself failed
*
* Try to register it again, removing it from the runtime first if it was
* registered.
*/
const reRegisterBreakpoint = async (breakpoint) => {
await unregisterBreakpoint(breakpoint);
await registerBreakpoint(breakpoint);
};
const unregisterBreakpoint = async (breakpoint) => {
let remoteId = breakpoint.remoteId;
delete breakpoint.remoteId;
if (remoteId) {
try {
await state.client.Debugger.removeBreakpoint({ breakpointId: remoteId });
} catch(e) {
error(e.message);
}
}
};
const breakpointResolved = (breakpoint, line) => {
breakpoint.line = line;
breakpoint.resolved = true;
notify({
type: "breakpointResolved",
line,
id: breakpoint.id
});
};
const hasBreakpointAt = ({ id, file, line }) => {
if (state.breakpoints[id]) {
return true;
}
return Object.values(state.breakpoints).some(brk =>
brk.file === file && brk.line === line
);
};
const getSourcemapSources = async () => {
let sources = [];
for (let script of state.scripts) {
let sourcemap = script && await getScriptSourceMap(
script,
state.configuration
);
sourcemap && sourcemap.eachMapping(mapping => {
sources.push(mapping.source);
});
}
return [ ... new Set(sources) ];
};
const getScriptSources = async () => {
let sources = [];
return state.scripts
.filter(s => s.url)
.map(s => resolveUrl(s.url, state.configuration));
};
// Helpers
const getCompletionByReference = async objectId => {
let result = await state.client.Runtime.callFunctionOn({
functionDeclaration: completionFunction,
returnByValue: true,
objectId
});
return convertCompletionResult(result);
};
const getCompletionByType = async type => {
let expression = `(${completionFunction})("${type}")`;
let result = await state.client.Runtime.evaluate({
expression,
returnByValue: true
});
return convertCompletionResult(result);
};
const logException = ({ exceptionDetails: { exception, url } }) => {
send({
type: "log",
payload: { type: "error", result: convertRemoteObject(exception) , url }
});
};
const log = ({ entry: { level, text, url, line }}) => {
const filter = [ "verbose" ];
if (!filter.includes(level)) {
send({
type: "log",
payload: { type: level, result: { description: text }, url, line }
});
}
};
const consoleToLog = ({ type, args }) => {
args.forEach(remoteObj => {
send({
type: "log",
payload: {
type: convertConsoleEventType(type),
result: convertRemoteObject(remoteObj)
}
});
});
};
const notify = (payload) => {
send({
type: "notification",
payload
});
};
const ensureConnected = () => {
if (!state.client) {
throw new Error("Not connected");
}
};
module.exports = {
connect,
disconnect,
evaluate,
getCompletion,
getProperties,
activateBreakpoints,
deactivateBreakpoints,
addBreakpoint,
removeBreakpoint,
resume,
stepOver,
stepInto,
stepOut,
continueToLocation,
getSource,
getSourcemapSources,
getScriptSources
};

+ 9
- 0
server/bin/indium View File

@ -0,0 +1,9 @@
#!/usr/bin/env node
const port = process.argv[2];
if(!port) {
throw new Error("Missing port argument");
}
require("../index.js")(port);

+ 19
- 0
server/helpers/async.js View File

@ -0,0 +1,19 @@
const timeout = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const doOrRetry = async (f, retries = 10, delay = 500) => {
try {
return await f();
} catch(e) {
if (retries > 0) {
await timeout(delay);
return doOrRetry(f, --retries, delay);
} else {
throw(e);
}
}
};
module.exports = {
timeout,
doOrRetry
};

+ 42
- 0
server/helpers/queue.js View File

@ -0,0 +1,42 @@
/**
* Return a queued version of the function `f`.
*/
// TODO: Return a promise
const queued = f => (q => () => q(f))(queue());
/**
* Async queue.
*
* Define a queue and register workers with:
*
* let q = queue();
* q(() => {...});
* q(async () => {...});
* q(() => {...});
*
* Registered workers are automatically evaluated in sequence.
*/
const queue = () => {
let workers = [];
let running = false;
const run = async () => {
if (running) return;
running = true;
try {
while (workers.length) {
await workers.shift()();
}
}
finally { running = false; }
};
// TODO: Return a promise, to make it async and enable promise error handling
return f => {
workers.push(f);
setTimeout(run, 0);
};
};
module.exports = { queued, queue };

+ 92
- 0
server/helpers/sourcemap.js View File

@ -0,0 +1,92 @@
const { SourceMapConsumer } = require("source-map");
const { readFileSync } = require("fs");
const { join } = require("path");
const { URL, resolve } = require("url");
const fetch = require("node-fetch");
const { expandRoot, resolveRoot } = require("./workspace");
const defaultSourceMapPathOverrides = {
"webpack:///./~/": "${root}/node_modules/",
"webpack:///src/": "${root}/",
"webpack:///./": "${root}/",
"webpack:///": "/"
};
const resolveSourceMap = async ({ url = "", sourceMapURL } = {}, conf) => {
let path = resolve(url, sourceMapURL);
let contents = await getSourceMapContents(path);
let json = JSON.parse(contents);
json.sources = json.sources.map(s =>
sanitizePath(applySourceMapPathOverrides(s, conf))
);
return await new SourceMapConsumer(json);
};
/**
* `path` can either reference an URL, inline data (base64 or uri-encoded), or a
* file path on disk.
*/
const getSourceMapContents = async path => {
if (path.indexOf("data:application/json") >= 0) {
return getInlineSourceMapContents(path);
} else {
return getUrlSourceMapContents(path);
}
};
const getInlineSourceMapContents = uri => {
const firstCommaPos = uri.indexOf(",");
const header = uri.substr(0, firstCommaPos);
const data = uri.substr(firstCommaPos + 1);
if (header.indexOf(";base64") !== -1) {
const buffer = new Buffer(data, "base64");
return buffer.toString();
} else {
// URI encoded.
return decodeURI(data);
}
};
const getUrlSourceMapContents = async path => {
let url = new URL(path);
if (url.protocol === "file:") {
return readFileSync(url).toString();
} else {
return await (await fetch(url)).text();
}
};
const getSourceMapPathOverrides = ({ sourceMapPathOverrides } = {}) =>
sourceMapPathOverrides || defaultSourceMapPathOverrides;
/**
* Return a path with "sourceMapPathOverrides" applied from `conf`.
*/
const applySourceMapPathOverrides = (path, conf) => {
let overrides = getSourceMapPathOverrides(conf);
let root = resolveRoot(conf);
return Object.keys(overrides).reduce((acc, key) => {
return acc.replace(new RegExp(key), expandRoot(overrides[key], root));
}, path);
};
const sanitizePath = path => {
// Remove query params
if (path.indexOf("?") >= 0) {
path = path.split("?")[0];
}
return path;
};
const absoluteSourceMapPath = ({ url, sourceMapUrl }) => {
return resolve(url, sourceMapUrl);
};
module.exports = {
resolveSourceMap,
applySourceMapPathOverrides,
absoluteSourceMapPath
};

+ 90
- 0
server/helpers/workspace.js View File

@ -0,0 +1,90 @@
const { existsSync, readFileSync, readdirSync, statSync } = require("fs");
const { join, resolve, basename, dirname } = require("path");
const { URL, parse } = require("url");
const projectFilename = ".indium.json";
const lookupProjectFile = dir => {
dir = resolve(dir);
if (!isDirectory(dir)) {
throw new Error(`Not a directory "${dir}"`);
}
let projectFile = getFiles(dir).find(isProjectFile);
if (projectFile) {
return projectFile;
}
let parent = getParentDirectory(dir);
if (parent !== dir) {
return lookupProjectFile(parent);
}
throw new Error(`No ${projectFilename} found`);
};
const isDirectory = path =>
existsSync(path) && statSync(path).isDirectory();
const isFile = path =>
existsSync(path) && statSync(path).isFile();
const isProjectFile = path =>
basename(path) === projectFilename;
const getFiles = dir =>
readdirSync(dir).map(name => join(dir, name)).filter(isFile);
const getParentDirectory = path =>
dirname(resolve(path));
const readConfigurations = dir => {
let file = lookupProjectFile(dir);
let configurations = JSON.parse(readFileSync(file)).configurations;
configurations.forEach(conf => conf.projectFile = file);
configurations.forEach(conf => conf.resolvedRoot = resolveRoot(conf));
return configurations;
};
const getConfiguration = (dir, name) => {
let configurations = readConfigurations(dir);
return configurations.find(conf => conf.name === name);
};
// webRoot is an alias for root.
const getRoot = ({ root, webRoot = "" }) => root || webRoot;
const resolveRoot = conf => {
let dir = dirname(conf.projectFile || "");
if (getRoot(conf)) {
dir = join(dir, getRoot(conf));
}
return resolve(dir);
};
const resolveUrl = (url, conf) => {
// In Node, script urls can be file paths
if (!parse(url).protocol) {
return url;
}
let root = resolveRoot(conf);
let { pathname } = new URL(url);
return resolve(`${root}/${pathname}`);
};
const expandRoot = (path, root = "") => {
return path.replace(/\${(root|webRoot)}/, root);
};
module.exports = {
readConfigurations,
lookupProjectFile,
getConfiguration,
resolveRoot,
resolveUrl,
expandRoot,
isFile
};

+ 1
- 0
server/index.js View File

@ -0,0 +1 @@
module.exports = require("./server").start;

+ 1552
- 0
server/package-lock.json
File diff suppressed because it is too large
View File


+ 40
- 0
server/package.json View File

@ -0,0 +1,40 @@
{
"name": "indium",
"version": "2.0.1",
"description": "Indium server",
"main": "index.js",
"scripts": {
"start": "node .",
"test": "jasmine"
},
"bin": {
"indium": "./bin/indium"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NicolasPetton/Indium.git"
},
"keywords": [
"javascript",
"cdp",
"debugger"
],
"author": "Nicolas Petton",
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/NicolasPetton/Indium/issues"
},
"homepage": "https://github.com/NicolasPetton/Indium#readme",
"dependencies": {
"chrome-remote-interface": "^0.26.0",
"node-fetch": "^2.2.0",
"semver": "^5.5.0",
"source-map": "^0.7.3"
},
"devDependencies": {
"eslint": "^5.2.0",
"jasmine": "^3.1.0",
"mock-fs": "^4.5.0",
"proxyquire": "^2.0.1"
}
}

+ 22
- 0
server/server/configurations.js View File

@ -0,0 +1,22 @@
const fs = require("fs");
const { readConfigurations } = require("../helpers/workspace");
const configurations = (data = {}, { success, error }) => {
switch(data.action) {
case "list":
return listConfigurations(data, { success, error });
default:
return error(`Unknown action ${data.action}`);
}
};
const listConfigurations = (data, { success, error }) => {
try {
let configurations = readConfigurations(data.directory);
success(configurations);
} catch(e) {
error(e.message);
}
};
module.exports = configurations;

+ 64
- 0
server/server/connection.js View File

@ -0,0 +1,64 @@
const adapter = require("../adapters/cdp");
const { getConfiguration } = require("../helpers/workspace");
const connection = (data = {}, { success, error, stop }) => {
switch(data.action) {
case "connect":
return connect(data, { success, error });
case "close":
return stop();
default:
return error(`Unknown connection action ${data.action}`);
}
};
const connect = (data = {}, { success, error }) => {
let { directory, name } = data;
let configuration;
try {
configuration = getConfiguration(data.directory, name);
} catch(e) {
return error(e);
}
if (!configuration) {
return error(`No configuration named ${name}`);
}
connectWithConfiguration(configuration, { success, error });
};
const connectWithConfiguration = (data = {}, { success, error }) => {
switch(data.type) {
case "chrome":
return connectToChrome(data, { success, error });
case "node":
return connectToNode(data, { success, error });
default:
error(`Unknown connection type ${data.type}`);
}
};
const connectToChrome = async (options = {}, { success, error }) => {
try {
await adapter.connect(options, { success, error });
} catch(e) {
return error(e.message);
}
success("Connected to Chrome!");
};
const connectToNode = async (options = {}, { success, error }) => {
try {
await adapter.connect({
...options,
port: options.port || 9229 // The default port is 9229 in NodeJS.
}, { success, error });
} catch(e) {
error(e.message);
}
success("Connected to Node!");
};
module.exports = connection;

+ 69
- 0
server/server/index.js View File

@ -0,0 +1,69 @@
const { createServer } = require("net");
const respond = require("./respond");
let socket;
const start = port => {
let server = createServer(sock => {
socket = sock;
sock.on("error", (e) => { throw e; });
sock.on("data", receive);
});
server.listen(port, () => {
console.log(`Indium server listening on ${port}`);
});
};
const stop = () => {
send({message: "closing"});
process.exit();
};
let input = "";
const receive = (str) => {
input += str;
let linefeedPos = input.indexOf("\n");
while (linefeedPos >= 0) {
let data;
try {
data = JSON.parse(input.substring(0, linefeedPos));
input = input.substring(++linefeedPos);
linefeedPos = input.indexOf("\n");
} catch(e) {
error()(`Invalid JSON data received: "${input}"`);
return;
}
if (!data.id) {
error()("Missing id from JSON data");
return;
}
respond(data, {
success: success(data.id),
error: error(data.id),
stop
});
}
};
const send = (data) => {
socket.write(`${JSON.stringify(data)}\n`);
};
const error = (id) => (error) => {
send({id, type: "error", payload: { error }});
};
const success = (id) => (payload = {}) => {
send({id, type: "success", payload});
};
module.exports = { start, stop, success, error, send };

+ 21
- 0
server/server/respond.js View File

@ -0,0 +1,21 @@
const version = require("./version");
const configurations = require("./configurations");
const connection = require("./connection");
const runtime = require("./runtime");
const respond = (data = {}, server) => {
switch(data.type) {
case "version":
return version(data, server);
case "configurations":
return configurations(data.payload, server);
case "connection":
return connection(data.payload, server);
case "runtime":
return runtime(data.payload, server);
default:
return server.error(`Unknown data type ${data.type}`);
}
};
module.exports = respond;

+ 174
- 0
server/server/runtime.js View File

@ -0,0 +1,174 @@
const adapter = require("../adapters/cdp");
const runtime = (data = {}, { success, error, stop }) => {
switch(data.action) {
case "evaluate":
return evaluate(data, { success, error });
case "getCompletion":
return getCompletion(data, { success, error });
case "getProperties":
return getProperties(data, { success, error });
case "activateBreakpoints":
return activateBreakpoints(data, { success, error });
case "deactivateBreakpoints":
return deactivateBreakpoints(data, { success, error });
case "addBreakpoint":
return addBreakpoint(data, { success, error });
case "removeBreakpoint":
return removeBreakpoint(data, { success, error });
case "resume":
return resume(data, { success, error });
case "stepInto":
return stepInto(data, { success, error });
case "stepOut":
return stepOut(data, { success, error });
case "stepOver":
return stepOver(data, { success, error });
case "continueToLocation":
return continueToLocation(data, { success, error });
case "getSource":
return getSource(data, { success, error });
case "getSourcemapSources":
return getSourcemapSources(data, { success, error });
case "getScriptSources":
return getScriptSources(data, { success, error });
default:
return error(`Unknown runtime action ${data.action}`);
}
};
const evaluate = async ({ expression } = {}, { success, error }) => {
if (!expression) {
error("No expression to evaluate");
}
try {
let result = await adapter.evaluate(expression);
success(result);
} catch(e) {
error(e.message);
}
};
const getCompletion = async ({ expression } = {}, { success, error }) => {
try {
success(await adapter.getCompletion(expression));
} catch(e) {
error(e.message);
}
};
const getProperties = async ({ id } = {}, { success, error }) => {
try {
success(await adapter.getProperties(id));
} catch(e) {
error(e.message);
}
};
const activateBreakpoints = (_, { success, error }) => {
try {
adapter.activateBreakpoints();
success();
} catch(e) {
error(e.message);
}
};
const deactivateBreakpoints = (_, { success, error }) => {
try {
adapter.deactivateBreakpoints();
success();
} catch(e) {
error(e.message);
}
};
const addBreakpoint = (data, { success, error }) => {
try {
adapter.addBreakpoint(data);
success();
} catch(e) {
error(e.message);
}
};
const removeBreakpoint = async (data, { success, error }) => {
try {
await adapter.removeBreakpoint(data);
success();
} catch(e) {
error(e.message);
}
};
const resume = (_, { success, error }) => {
try {
adapter.resume();
success();
} catch(e) {
error(e.message);
}
};
const stepInto = (_, { success, error }) => {
try {
adapter.stepInto();
success();
} catch(e) {
error(e.message);
}
};
const stepOut = (_, { success, error }) => {
try {
adapter.stepOut();
success();
} catch(e) {
error(e.message);
}
};
const stepOver = (_, { success, error }) => {
try {
adapter.stepOver();
success();
} catch(e) {
error(e.message);
}
};
const continueToLocation = async ({ location }, { success, error }) => {
try {
await adapter.continueToLocation(location);
success();
} catch(e) {
error(e.message);
}
};
const getSource = async ({ id }, { success, error }) => {
try {
success(await adapter.getSource(id));
} catch(e) {
error(e.message);
}
};
const getSourcemapSources = async ({ }, { success, error }) => {
try {
success(await adapter.getSourcemapSources());
} catch(e) {
error(e.message);
}
};
const getScriptSources = async ({ }, { success, error }) => {
try {
success(await adapter.getScriptSources());
} catch(e) {
error(e.message);
}
};
module.exports = runtime;

+ 14
- 0
server/server/version.js View File

@ -0,0 +1,14 @@
const package = require("../package.json");
const semver = require("semver");
const version = (_data, { success }) => {
success({
version: {
major: semver.major(package.version),
minor: semver.minor(package.version),
patch: semver.patch(package.version)
}
});
};
module.exports = version;

+ 189
- 0
server/spec/adapters/cdp/helpers-spec.js View File

@ -0,0 +1,189 @@
const {
convertRemoteObject,
resolveFileLocation,
resolveUrlLocation
} = require("../../../adapters/cdp/helpers");
describe("Converting remote objects", () => {
it("returns the description when not a reference", () => {
expect(convertRemoteObject({
value: 3,
type: "number"
})).toEqual({
description: "3"
});
expect(convertRemoteObject({
value: "Hello",
type: "string"
})).toEqual({
description: "\"Hello\""
});
expect(convertRemoteObject({
value: true,
type: "boolean"
})).toEqual({
description: "true"
});
expect(convertRemoteObject({
value: null,
type: "object"
})).toEqual({
description: "null"
});
expect(convertRemoteObject({
value: null,
type: "object",
type: "undefined"
})).toEqual({
description: "undefined"
});
});
it("converts object previews", () => {
let result = {
objectId: "1",
description: "Object",
preview: {
type: "object",
subtype: "object",
properties: [
{ name: "a", type: "number", value: "3"},
{ name: "b", type: "string", value: "hello"},
{ name: "c", type: "boolean", value: "true"},
{ name: "d", type: "object", subtype: "Location", value: "Location"}
]