Skip to content

Commit

Permalink
Functional plugins
Browse files Browse the repository at this point in the history
- While this PR is in progress, the new runtime will exist in a separate package
- Before this PR is ready to be landed (compat layer has been implemented), it will replace fusion-core.

To do:
- [x] Static transform
- [x] Compatibility layer (implement old plugin API with new runtime)

This PR differs from the initial RFC in a crucial way: plugins are executed only once as opposed to every request.

Why this?
- Because a linchpin of the whole system is using return values for DI, having plugin functions executed per-request implies having a dynamic DI system. While theoretically possible, it ends up just being extremely complex and possibly very confusing.
- You could possibly have some Frankenstein model where some things are automatically memoized at startup (e.g. provided services) to have a static DI model but a per-request plugin execution model, but I think ultimately this just becomes confusing. In this scenario, you would simulate a "request" at startup and then memoize the DI system.
- Per-request execution is more of a performance footgun. Developers have to remember to hoist things into `withStartup`. Instead, I think it is better to just move per-request code into its own umbrella hook. This results in a teeny bit more boilerplate but in exchange has a much more coherent and simple execution model with better performance by default.

**As originally proposed**
```js

function MyPlugin() {
  const [a, b] = withDeps(["A", "B"]);
  const result = withPlugin.using("B", b + 1).using("C", "c")(Foo);
  const foobar = withStartup(async () => {
    return `Foo: ${foo.toString()} Bar: ${bar.toString()}`;
  });

  const [serialize, hydrate] = withUniversalValue("__STATE__");

  const state = __BROWSER__
    ? hydrate()
    : { num: Math.random(), id: process.env.APP_ID };

  withEndpoint("/foobar", async (ctx, next) => {
    ctx.body = foobar;
    return next();
  });

  withRender(() => {
    serialize(state);
  });

  withWrapper((el) => <MyProvider value={state}>{el}</MyProvider>);

  return foobar;
}
```

**As implemented in this PR**
```js
function MyPlugin() {
  const [a, b] = withDeps(["A", "B"]);
  const result = withPlugin.using("B", b + 1).using("C", "c")(Foo);
  const foobar = withStartup(async () => {
    return `Foo: ${foo.toString()} Bar: ${bar.toString()}`;
  });

 withEndpoint("/foobar", async (ctx, next) => {
    ctx.body = foobar;
    return next();
  });

  const [serialize, hydrate] = withUniversalValue("__STATE__");

  withRenderSetup(el => {
    const state = __BROWSER__
      ? hydrate()
      : { num: Math.random(), id: process.env.APP_ID };

    withSSREffect(() => {
      serialize(state);
    });

    return <MyProvider value={state}>{el}</MyProvider>;
  });

  return foobar;
}
```
  • Loading branch information
rtsao authored and fusionjs-sync-bot[bot] committed Apr 21, 2022
1 parent 825fe1f commit 608632b
Show file tree
Hide file tree
Showing 50 changed files with 1,744 additions and 311 deletions.
10 changes: 5 additions & 5 deletions flow-typed/npm/jest_v24.x.x.js
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ interface JestExpectType {
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
* matching the most recent snapshot when it is called.
*/
toThrowErrorMatchingSnapshot(): void;
toThrowErrorMatchingSnapshot(name?: string): void;
toThrowErrorMatchingInlineSnapshot(snapshot?: string): void;
}

Expand Down Expand Up @@ -916,7 +916,7 @@ type JestObjectType = {
spyOn(
object: Object,
methodName: string,
accessType?: 'get' | 'set'
accessType?: "get" | "set"
): JestMockFn<any, any>,
/**
* Set the default timeout interval for tests and before/after hooks in milliseconds.
Expand Down Expand Up @@ -1088,9 +1088,9 @@ type JestPrettyFormatColors = {
value: { close: string, open: string },
};

type JestPrettyFormatIndent = string => string;
type JestPrettyFormatIndent = (string) => string;
type JestPrettyFormatRefs = Array<any>;
type JestPrettyFormatPrint = any => string;
type JestPrettyFormatPrint = (any) => string;
type JestPrettyFormatStringOrNull = string | null;

type JestPrettyFormatOptions = {|
Expand Down Expand Up @@ -1121,7 +1121,7 @@ type JestPrettyFormatPlugin = {
opts: JestPrettyFormatOptions,
colors: JestPrettyFormatColors
) => string,
test: any => boolean,
test: (any) => boolean,
};

type JestPrettyFormatPlugins = Array<JestPrettyFormatPlugin>;
Expand Down
69 changes: 69 additions & 0 deletions fusion-cli-tests/test/e2e/universal-values/fixture/src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @noflow
import App, {createToken, withUniversalValue, withRenderSetup, withSSREffect} from 'fusion-core';

export default async function () {
const app = new App('element', (el) => el);
app.register(TestPlugin1);
app.register(TestPlugin2);
app.register(TestPlugin3);
return app;
}


function* TestPlugin1() {
const [serialize, hydrate] = withUniversalValue("__ID_1__");
if (__NODE__) {
serialize(process.env.SOME_ENV_VAR1);
}

if (__BROWSER__) {
const hydrated = hydrate();
const el = document.createElement("div");
el.id = "result1";
el.appendChild(document.createTextNode(hydrated));
document.body.appendChild(el);
}
}
// Hack until syntax transform exposed
TestPlugin1.__fplugin__ = true;


function* TestPlugin2() {
const [serialize, hydrate] = withUniversalValue("__ID_2__");
if (__NODE__) {
serialize(process.env.SOME_ENV_VAR2);
}

if (__BROWSER__) {
const hydrated = hydrate();
const el = document.createElement("div");
el.id = "result2";
el.appendChild(document.createTextNode(hydrated));
document.body.appendChild(el);
}
}
// Hack until syntax transform exposed
TestPlugin2.__fplugin__ = true;


function* TestPlugin3() {
const [serialize, hydrate] = withUniversalValue("__ID_3__");

withRenderSetup(() => {
if (__NODE__) {
withSSREffect(() => {
serialize("baz");
});
}
});

if (__BROWSER__) {
const hydrated = hydrate();
const el = document.createElement("div");
el.id = "result3";
el.appendChild(document.createTextNode(hydrated));
document.body.appendChild(el);
}
}
// Hack until syntax transform exposed
TestPlugin3.__fplugin__ = true;
54 changes: 54 additions & 0 deletions fusion-cli-tests/test/e2e/universal-values/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @flow
/* eslint-env node */

const t = require('assert');
const path = require('path');

const puppeteer = require('puppeteer');

const {cmd, start} = require('../utils.js');

const dir = path.resolve(__dirname, './fixture');

jest.setTimeout(20000);

test('universal values works', async () => {
await cmd(`build --dir=${dir}`);
const {proc, port} = await start(`--dir=${dir}`, {
env: {
...process.env,
SOME_ENV_VAR1: 'foo',
SOME_ENV_VAR2: 'bar',
},
});

const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();

page.on('error', (err) => {
// $FlowFixMe
t.fail(`Client-side error: ${err}`);
});

page.on('pageerror', (err) => {
// $FlowFixMe
t.fail(`Client-side error: ${err}`);
});

await page.goto(`http://localhost:${port}/`);

const [result1, result2, result3] = await Promise.all([
page.waitForSelector('#result1'),
page.waitForSelector('#result2'),
page.waitForSelector('#result3'),
]);

t.equal(await page.evaluate((el) => el.textContent, result1), 'foo');
t.equal(await page.evaluate((el) => el.textContent, result2), 'bar');
t.equal(await page.evaluate((el) => el.textContent, result3), 'baz');

browser.close();
proc.kill('SIGKILL');
}, 100000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/* @flow */
/* eslint-env node */

const createNamedModuleVisitor = require('../babel-plugin-utils/visit-named-module');
const {addNamed} = require('@babel/helper-module-imports');

const PURE_ANNOTATION = '#__PURE__';

const yieldHooks = new Set(['withDeps', 'withPlugin', 'withStartup']);

const nodeOnlyHooks = new Set([
'withMiddleware',
'withSSREffect',
'withEndpoint',
]);

const hooks = [
...yieldHooks,
...nodeOnlyHooks,
'withRenderSetup',
'withUniversalMiddleware',
'withUniversalValue',
];

module.exports = pureCreatePlugin;

function pureCreatePlugin(babel /*: Object */) {
const t = babel.types;
const visitor = createNamedModuleVisitor(
t,
hooks,
'fusion-core',
refsHandler
);
return {
visitor: {
...visitor,
Program: {
enter(path /*: any */, state /*: any */) {
// Track functions that need to be transformed
state.pluginFunctions = new Set();
},
exit(path /*: any */, state /*: any */) {
for (var pluginPath of state.pluginFunctions) {
addPluginDeclaration(t, pluginPath, state);
}
},
},
},
};
}

function refsHandler(t, state, refs = []) {
// Iterate in reverse as refs so we move up tree in nested cases
let i = refs.length;
while (i--) {
const refPath = refs[i];
const name = refPath.node.name;
const path = refPath.parentPath;

let hookPath = path;
if (name === 'withPlugin') {
hookPath = assertWithPlugin(t, refPath);
} else if (!t.isCallExpression(path)) {
throw path.buildCodeFrameError(`${name} hook must be invoked`);
}

const fnParent = path.getFunctionParent();

if (!fnParent) {
throw path.buildCodeFrameError(
`${name} hook must be invoked within a function`
);
}

// If originally written as generator syntax already, we do not want inject yield expressions
if (!fnParent.node.generator) {
if (yieldHooks.has(name)) {
hookPath.replaceWith(t.yieldExpression(hookPath.node));
}
}
if (nodeOnlyHooks.has(name)) {
path.replaceWith(
t.conditionalExpression(
t.identifier('__NODE__'),
path.node,
t.unaryExpression('void', t.numericLiteral(0))
)
);
}

state.pluginFunctions.add(fnParent);
}
}

function addPluginDeclaration(t, fnPath, state) {
if (!state.importId) {
state.importId = addNamed(fnPath, 'declarePlugin', 'fusion-core');
}

if (t.isArrowFunctionExpression(fnPath)) {
fnPath.arrowFunctionToExpression({});
}

fnPath.node.generator = true;

if (!fnPath.instrumented) {
fnPath.instrumented = true;
if (fnPath.isFunctionDeclaration()) {
// esbuild treats assignment as non-pure so assigning a magic property
// would prevent tree shaking of unused plugins
// https://github.com/evanw/esbuild/issues/2010
// Instead, we convert function decalarations to hoisted function expressions
const declaration = t.variableDeclaration('var', [
t.variableDeclarator(
fnPath.node.id,
t.addComment(
t.callExpression(state.importId, [t.toExpression(fnPath.node)]),
'leading',
PURE_ANNOTATION
)
),
]);
// Adapted from from @babel/plugin-transform-block-scoped-functions
// https://github.com/babel/babel/blob/master/packages/babel-plugin-transform-block-scoped-functions/src/index.js#L19
declaration._blockHoist = 2;
fnPath.replaceWith(declaration);
} else {
fnPath.replaceWith(
t.addComment(
t.callExpression(state.importId, [fnPath.node]),
'leading',
PURE_ANNOTATION
)
);
}
fnPath.skip();
}
}

function assertWithPlugin(t, path) {
let currentPath = path;
while (currentPath) {
const parentPath = currentPath.parentPath;
if (t.isCallExpression(parentPath)) {
break;
} else if (
t.isMemberExpression(parentPath) &&
parentPath.node.property.name === 'using' &&
t.isCallExpression(parentPath.parentPath)
) {
currentPath = currentPath.parentPath.parentPath;
continue;
} else {
throw parentPath.buildCodeFrameError('withPlugin must be invoked');
}
}
return currentPath.parentPath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`withPlugin errors: .using without invocation 1`] = `
"unknown: withPlugin must be invoked
1 |
2 | import {withPlugin} from \\"fusion-core\\";
> 3 | withPlugin.using(TokenA, TokenB);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
4 | "
`;

exports[`withPlugin errors: bare 1`] = `
"unknown: withPlugin must be invoked
1 |
2 | import {withPlugin} from \\"fusion-core\\";
> 3 | withPlugin;
| ^^^^^^^^^^^
4 | "
`;

exports[`withPlugin errors: property 1`] = `
"unknown: withPlugin must be invoked
1 |
2 | import {withPlugin} from \\"fusion-core\\";
> 3 | withPlugin.someProperty;
| ^^^^^^^^^^^^^^^^^^^^^^^
4 | "
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {withDeps} from "fusion-core";

function getPlugin() {
return () => {
console.log(this);
const [a] = withDeps([A]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { declarePlugin as _declarePlugin } from "fusion-core";
import { withDeps } from "fusion-core";

function getPlugin() {
var _this = this;

return /*#__PURE__*/_declarePlugin(function* () {
console.log(_this);
const [a] = yield withDeps([A]);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {withDeps} from "fusion-core";

const _declarePlugin = "collision";

function MyPluginA() {
const [a] = withDeps([A]);
return a;
}

function MyPluginB() {
const [b] = withDeps([B]);
return b;
}

0 comments on commit 608632b

Please sign in to comment.