Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ses): Shim compatible with Hermes compiler #2334

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,41 @@ jobs:
- name: Run yarn test262
run: exit 0 # TODO remove test262 from required tests for CI

test-hermes:
name: test-hermes

# begin macro
leotm marked this conversation as resolved.
Show resolved Hide resolved

runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest]

steps:
- name: Checkout
uses: actions/checkout@v4
leotm marked this conversation as resolved.
Show resolved Hide resolved

# without this, setup-node errors on mismatched yarn versions
- run: corepack enable

- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: yarn

- name: Install dependencies
run: yarn install --immutable

# end macro

- name: Run yarn build
run: yarn build

- name: Run SES/Hermes smoke test
run: yarn test:hermes

viable-release:
name: viable-release

Expand Down
2 changes: 2 additions & 0 deletions packages/compartment-mapper/src/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const makeBundle = async (readPowers, moduleLocation, options) => {

const {
moduleTransforms,
syncModuleTransforms,
dev,
tags: tagsOption,
conditions: conditionsOption = tagsOption,
Expand Down Expand Up @@ -327,6 +328,7 @@ export const makeBundle = async (readPowers, moduleLocation, options) => {
resolve,
makeImportHook,
moduleTransforms,
syncModuleTransforms,
parserForLanguage,
});
await compartment.load(entryModuleSpecifier);
Expand Down
12 changes: 11 additions & 1 deletion packages/ses/package.json
leotm marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
"default": "./dist/ses.cjs"
}
},
"./hermes": {
"require": {
"types": "./dist/types.d.cts",
"default": "./dist/ses-hermes.cjs"
}
},
leotm marked this conversation as resolved.
Show resolved Hide resolved
"./tools.js": "./tools.js",
"./assert-shim.js": "./assert-shim.js",
"./lockdown-shim.js": "./lockdown-shim.js",
Expand All @@ -63,7 +69,9 @@
"./package.json": "./package.json"
},
"scripts": {
"build": "node scripts/bundle.js",
"build:vanilla": "node scripts/bundle.js",
"build:hermes": "node scripts/bundle.js hermes",
"build": "yarn build:vanilla && yarn build:hermes",
"clean": "rm -rf dist",
"cover": "c8 ava",
"demo": "python3 -m http.server",
Expand All @@ -74,6 +82,7 @@
"prepare": "npm run clean && npm run build",
"qt": "ava",
"test": "tsd && ava",
"test:hermes": "./scripts/hermes-test.sh",
"test:xs": "xst dist/ses.umd.js test/_lockdown-safe.js",
"postpack": "git clean -f '*.d.ts*' '*.tsbuildinfo'"
},
Expand All @@ -93,6 +102,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.29.1",
"hermes-engine-cli": "^0.12.0",
"prettier": "^3.3.3",
"terser": "^5.16.6",
"tsd": "^0.31.2",
Expand Down
61 changes: 43 additions & 18 deletions packages/ses/scripts/bundle.js
leotm marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'fs';
import { makeBundle } from '@endo/compartment-mapper/bundle.js';
import { minify } from 'terser';
import { fileURLToPath, pathToFileURL } from 'url';
import { hermesTransforms } from './hermes-transforms.js';

lockdown();

Expand All @@ -16,40 +17,59 @@ const write = async (target, content) => {
await fs.promises.writeFile(location, content);
};

const main = async () => {
/**
* @param {object} [options]
* @param {string} [options.buildType] Suffix used to build special bundles (e.g. 'hermes')
*/
const writeBundle = async ({ buildType } = {}) => {
const text = await fs.promises.readFile(
fileURLToPath(`${root}/package.json`),
'utf8',
);
const packageJson = JSON.parse(text);
const version = packageJson.version;

let syncModuleTransforms;

let bundleFilePaths = [
'dist/ses.cjs',
'dist/ses.mjs',
'dist/ses.umd.js',
'dist/lockdown.cjs',
'dist/lockdown.mjs',
'dist/lockdown.umd.js',
];
let terseFilePaths = ['dist/ses.umd.min.js', 'dist/lockdown.umd.min.js'];

if (buildType === 'hermes') {
leotm marked this conversation as resolved.
Show resolved Hide resolved
bundleFilePaths = ['dist/ses-hermes.cjs'];
terseFilePaths = [];
syncModuleTransforms = hermesTransforms;
}

const bundle = await makeBundle(
read,
pathToFileURL(resolve('../index.js', import.meta.url)).toString(),
{ syncModuleTransforms },
);
const versionedBundle = `// ses@${version}\n${bundle}`;

const { code: terse } = await minify(versionedBundle, {
mangle: false,
keep_classnames: true,
});
assert.string(terse);

console.log(`-- Building '${buildType}' version of SES --`);
console.log(`Bundle size: ${versionedBundle.length} bytes`);
console.log(`Minified bundle size: ${terse.length} bytes`);

await fs.promises.mkdir('dist', { recursive: true });
/** @type {string|undefined} */
let terse;
leotm marked this conversation as resolved.
Show resolved Hide resolved
if (terseFilePaths.length) {
const { code } = await minify(versionedBundle, {
mangle: false,
keep_classnames: true,
});
terse = code;
assert.string(terse);
console.log(`Minified bundle size: ${terse.length} bytes`);
}

const bundleFilePaths = [
'dist/ses.cjs',
'dist/ses.mjs',
'dist/ses.umd.js',
'dist/lockdown.cjs',
'dist/lockdown.mjs',
'dist/lockdown.umd.js',
];
const terseFilePaths = ['dist/ses.umd.min.js', 'dist/lockdown.umd.min.js'];
await fs.promises.mkdir('dist', { recursive: true });

await Promise.all([
...bundleFilePaths.map(dest => write(dest, versionedBundle)),
leotm marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -82,6 +102,11 @@ const main = async () => {
console.log(`Copied ${sourceDTS} to ${destDTS}`);
};

const main = async () => {
const buildType = process.argv[2];
await writeBundle({ buildType });
};

main().catch(err => {
console.error('Error running main:', err);
process.exitCode = 1;
Expand Down
55 changes: 55 additions & 0 deletions packages/ses/scripts/hermes-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash

# Creates a JS smoke test to be compiled into Hermes bytecode (hbc).
#
# Usage: scripts/hermes-test.sh

set -ueo pipefail

DIR=$(dirname -- "${BASH_SOURCE[0]}")
cd "$DIR/.."

OS="$(uname -s)"

case "$OS" in
Linux*)
OS_DIR="linux64-bin"
;;
Darwin*)
OS_DIR="osx-bin"
;;
CYGWIN*|MINGW*|MSYS*)
OS_DIR="win64-bin"
;;
*)
echo "Unsupported OS: $OS"
exit 1
;;
esac

HERMESC="../../node_modules/hermes-engine-cli/$OS_DIR/hermesc"
HERMES="../../node_modules/hermes-engine-cli/$OS_DIR/hermes"

echo "Concatenating: dist/ses-hermes.cjs + test/_hermes-smoke.js"
cat dist/ses-hermes.cjs test/_hermes-smoke.js > test/_hermes-smoke-dist.js
echo "Generated: test/_hermes-smoke-dist.js"

# Errors on async arrow functions and async generators
# Both are unsupported on Hermes
echo "Executing: test/_hermes-smoke-dist.js on Hermes compiler"
$HERMESC test/_hermes-smoke-dist.js -emit-binary -out test/_hermes-smoke-dist.hbc
echo "Generated: test/_hermes-smoke-dist.hbc"
echo "Hermes compiler done"

# TODO: Disabled until https://github.com/endojs/endo/issues/1891 complete
# echo "Executing generated bytecode file on Hermes VM"
# $HERMES -b test/_hermes-smoke-dist.hbc
# echo "Hermes VM done"
echo "Skipping: Hermes VM"

echo "Hermes tests complete"

echo "Removing: test/_hermes-smoke-dist.js"
rm test/_hermes-smoke-dist.js
echo "Removing: test/_hermes-smoke-dist.hbc"
rm test/_hermes-smoke-dist.hbc
89 changes: 89 additions & 0 deletions packages/ses/scripts/hermes-transforms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* eslint-disable import/no-extraneous-dependencies */
import { parse } from '@babel/parser';
import babelGenerate from '@agoric/babel-generator';
import babelTraverse from '@babel/traverse';
import * as t from '@babel/types';

// TODO The following is sufficient on Node.js, but for compatibility with
// `node -r esm`, we must use the pattern below.
// Remove after https://github.com/Agoric/agoric-sdk/issues/8671.
// OR, upgrading to Babel 8 probably addresses this defect.
const traverse = babelTraverse.default || babelTraverse;
const generate = babelGenerate.default || babelGenerate;

const decoder = new TextDecoder();
const encoder = new TextEncoder();

const asyncArrowEliminator = {
ArrowFunctionExpression(path) {
if (path.node.async) {
let body = path.node.body;

path.traverse({
ThisExpression(innerPath) {
const { start } = innerPath.node.loc;
// throw path.buildCodeFrameError("..."); // https://github.com/babel/babel/issues/8617
throw Error(
`Hermes makeBundle Babel transform doesn't support 'this' keyword in async arrow functions.
at this (${path.state.filename}:${start.line}:${start.column})`,
);
},
// No need for an Identifier traversal on nodes matching 'arguments' to error on
// Since only non-arrow functions can access the `arguments` array-like object
});

// In case it's a ()=>expression style arrow function
if (!t.isBlockStatement(body)) {
body = t.blockStatement([t.returnStatement(body)]);
}

const functionExpression = t.functionExpression(
undefined,
path.node.params,
body,
path.node.generator,
path.node.async,
);

path.replaceWith(functionExpression);
}
},
};

const destroyAsyncGenerators = path => {
if (path.node.async && path.node.generator) {
path.replaceWith(t.identifier('undefined'));
}
};

const asyncGeneratorDestroyer = {
FunctionExpression: destroyAsyncGenerators,
FunctionDeclaration: destroyAsyncGenerators,
};

export const hermesTransforms = {
mjs: (sourceBytes, specifier, location, _packageLocation, { sourceMap }) => {
const transforms = {
...asyncArrowEliminator,
...asyncGeneratorDestroyer,
// Some transforms might be added based on the specifier later
};

const sourceString = decoder.decode(sourceBytes);

const ast = parse(sourceString, {
sourceType: 'module',
});

traverse(ast, transforms, undefined, { filename: location });

const { code } = generate(ast, {
// Nothing being done with sourcemaps as this point
retainLines: true,
compact: true,
verbatim: true,
});

return { bytes: encoder.encode(code), parser: 'mjs', sourceMap };
},
};
35 changes: 35 additions & 0 deletions packages/ses/src/commons.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,38 @@ export const FERAL_STACK_GETTER = feralStackGetter;
* @type {((newValue: any) => void) | undefined}
*/
export const FERAL_STACK_SETTER = feralStackSetter;

const getAsyncGeneratorFunctionInstance = () => {
leotm marked this conversation as resolved.
Show resolved Hide resolved
// Test for async generator function syntax support.
try {
// Wrapping one in an new Function lets the `hermesc` binary file
// parse the Metro js bundle without SyntaxError, to generate the
// optimised Hermes bytecode bundle, when `gradlew` is called to
// assemble the release build APK for React Native prod Android apps.
// Delaying the error until runtime lets us customise lockdown behaviour.
return new FERAL_FUNCTION(
'return (async function* AsyncGeneratorFunctionInstance() {})',
)();
} catch (error) {
// Note: `Error.prototype.jsEngine` is only set by React Native runtime, not Hermes:
// https://github.com/facebook/react-native/blob/main/packages/react-native/ReactCommon/hermes/executor/HermesExecutorFactory.cpp#L224-L230
if (error.name === 'SyntaxError') {
// Swallows Hermes error `async generators are unsupported` at runtime.
// eslint-disable-next-line @endo/no-polymorphic-call
console && console.warn('SES: Skipping async generators');
// Note: `console` is not a JS built-in, so Hermes engine throws:
// Uncaught ReferenceError: Property 'console' doesn't exist
// See: https://github.com/facebook/hermes/issues/675
// However React Native provides a `console` implementation when setting up error handling:
// https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/InitializeCore.js
return undefined;
} else if (error.name === 'EvalError') {
// eslint-disable-next-line no-empty-function
return async function* AsyncGeneratorFunctionInstance() {};
} else {
throw error;
}
}
};
export const AsyncGeneratorFunctionInstance =
leotm marked this conversation as resolved.
Show resolved Hide resolved
getAsyncGeneratorFunctionInstance();
Loading
Loading