Support module capture without serialization (#375)

This change adds first class support for capturing objects which are references to loaded Node modules.

If an object to be serialized is found as a loaded module which can be referenced as `require(<name>)`, then is is not serialized and is passed as a new kind of environment entry - `module` which will be de-serialized as a `require` statement.

Supports three cases:
1. built-in modules such as `http` and `path`
2. dependencies in the `node_modules` folder
3. other user-defined modules in the source folder

This allows natural use of `import`s with "inside" code.  For example - note the use of `$` in the outside scope only on the "inside".

```typescript
import * as cloud from "@pulumi/cloud";
import * as $ from "cheerio";
let queue = new pulumi.Topic<string>("sites_to_process");
queue.subscribe("foreachurl", async (url) => {
    let x = $("a", "<a href='foo'>hello</a>");
});
```

Also fixes free variable capture of `this` in arrow functions.

Fixes #342.
This commit is contained in:
Luke Hoban 2017-09-28 16:44:00 -07:00 committed by GitHub
parent 69d8e38dff
commit ad5ee5bc04
2 changed files with 102 additions and 4 deletions

View file

@ -1,9 +1,10 @@
// Copyright 2016-2017, Pulumi Corporation. All rights reserved.
import { debuggablePromise } from "./debuggable";
import { Log } from "./log";
import * as acorn from "acorn";
import * as estree from "estree";
import { relative as pathRelative } from "path";
import { debuggablePromise } from "./debuggable";
import { Log } from "./log";
const acornwalk = require("acorn/dist/walk");
const nativeruntime = require("./native/build/Release/nativeruntime.node");
@ -30,6 +31,7 @@ export interface EnvironmentEntry {
closure?: Closure; // a closure we are dependent on.
obj?: Environment; // an object which may contain nested closures.
arr?: EnvironmentEntry[]; // an array which may contain nested closures.
module?: string; // a reference to a requirable module name.
}
/**
@ -84,6 +86,9 @@ async function flattenEnvironmentEntry(entry: Promise<AsyncEnvironmentEntry>,
if (e.hasOwnProperty("json")) {
result.json = e.json;
}
else if (e.module) {
result.module = e.module;
}
else if (e.closure) {
result.closure = await flattenClosure(e.closure, flatCache);
}
@ -125,6 +130,7 @@ export interface AsyncEnvironmentEntry {
closure?: AsyncClosure; // a closure we are dependent on.
obj?: AsyncEnvironment; // an object which may contain nested closures.
arr?: Promise<AsyncEnvironmentEntry>[]; // an array which may contain nested closures.
module?: string; // a reference to a requirable module name.
}
/**
@ -168,11 +174,16 @@ function serializeCapturedObject(obj: any): Promise<AsyncEnvironmentEntry> {
* serializeCapturedObjectAsync is the work-horse that actually performs object serialization.
*/
function serializeCapturedObjectAsync(obj: any, resolve: (v: AsyncEnvironmentEntry) => void): void {
let moduleName = findRequirableModuleName(obj);
if (obj === undefined || obj === null ||
typeof obj === "boolean" || typeof obj === "number" || typeof obj === "string") {
// Serialize primitives as-is.
resolve({ json: obj });
}
else if (moduleName) {
// Serialize any value which was found as a requirable module name as a reference to the module
resolve({module: moduleName});
}
else if (obj instanceof Array) {
// Recursively serialize elements of an array.
let arr: Promise<AsyncEnvironmentEntry>[] = [];
@ -199,6 +210,46 @@ function serializeCapturedObjectAsync(obj: any, resolve: (v: AsyncEnvironmentEnt
}
}
// These modules are built-in to Node.js, and are available via `require(...)`
// but are not stored in the `require.cache`. They are guaranteed to be
// available at the unqualified names listed below. _Note_: This list is derived
// based on Node.js 6.x tree at: https://github.com/nodejs/node/tree/v6.x/lib
let builtInModuleNames = [
"assert", "buffer", "child_process", "cluster", "console", "constants", "crypto",
"dgram", "dns", "domain", "events", "fs", "http", "https", "module", "net", "os",
"path", "process", "punycode", "querystring", "readline", "repl", "stream", "string_decoder",
/* "sys" deprecated ,*/ "timers", "tls", "tty", "url", "util", "v8", "vm", "zlib",
];
let builtInModules = new Map<any, string>();
for (let name of builtInModuleNames) {
builtInModules.set(require(name), name);
}
// findRequirableModuleName attempts to find a global name bound to the object, which can
// be used as a stable reference across serialization.
function findRequirableModuleName(obj: any): string | undefined {
// First, check the built-in modules
let key = builtInModules.get(obj);
if (key) {
return key;
}
// Next, check the Node module require cache, which will store cached values
// of all non-built-in Node modules loaded by the program so far. _Note_: We
// don't pre-compute this because the require cache will get populated
// dynamically during execution.
for (let path of Object.keys(require.cache)) {
if (require.cache[path].exports === obj) {
// Rewrite the path to be a local module reference relative to the
// current working directory
let modPath = pathRelative(process.cwd(), path);
return "./" + modPath;
}
}
// Else, return that no global name is available for this object.
return undefined;
}
/**
* computeFreeVariables computes the set of free variables in a given function string. Note that this string is
* expected to be the usual V8-serialized function expression text.
@ -293,7 +344,7 @@ class FreeVariableComputer {
}
private visitThisExpression(node: estree.Identifier, state: any, cb: walkCallback): void {
// Mar references to the built-in 'this' variable as free.
// Mark references to the built-in 'this' variable as free.
this.frees["this"] = true;
}
@ -341,6 +392,12 @@ class FreeVariableComputer {
this.frees[v] = false;
}
// If the function is not an arrow, then its `this` is also a
// function-scoped variable and should be removed.
if ((node as estree.ArrowFunctionExpression).type !== "ArrowFunctionExpression") {
this.frees["this"] = false;
}
// Restore the prior context and merge our free list with the previous one.
this.scope.pop();
this.functionVars = oldFunctionVars;

View file

@ -177,6 +177,38 @@ describe("closure", () => {
runtime: "nodejs",
},
});
{
let os = require("os");
cases.push({
title: "Capture built-in modules as stable references, not serialized values",
func: () => os,
expect: {
code: `(() => os)`,
environment: {
os: {
module: "os",
},
},
runtime: "nodejs",
},
});
}
{
let util = require("../util");
cases.push({
title: "Capture user-defined modules as stable references, not serialized values",
func: () => util,
expect: {
code: `(() => util)`,
environment: {
util: {
module: "./bin/tests/util.js",
},
},
runtime: "nodejs",
},
});
}
cases.push({
title: "Don't capture catch variables",
// tslint:disable-next-line
@ -276,7 +308,7 @@ describe("closure", () => {
},
};
cases.push({
title: "Serializes `this` capturing closures",
title: "Serializes `this` capturing arrow functions",
func: cap.f,
expect: {
code: "(() => { console.log(this.x); })",
@ -285,6 +317,15 @@ describe("closure", () => {
},
});
}
cases.push({
title: "Don't serialize `this` in function expressions",
func: function() { return this; },
expect: {
code: `(function () { return this; })`,
environment: {},
runtime: "nodejs",
},
});
// Now go ahead and run the test cases, each as its own case.
for (let test of cases) {