Skip to content

Commit

Permalink
Browser plugin preview (#2748)
Browse files Browse the repository at this point in the history
* [WIP] webpack config setup for fast refresh + websocket server

* add prod/dev hmr webpack config option

* render immediately and handle AsyncMessage in startup app hook

* forward ui AsyncMessages to browser via WebSockets

* null check sentry transaction to fix browser error

* refactor AsyncMessageChannel code for browser implementation

* webpack use swc-loader for browser version + speed/bundle size plugin options

* prettify browser dev preview UI

* enable loading screen if startup params missing (for web serve + disconnected browser dev preview)

* attempt to fix webpack build for tests

* add SpeedMeasurePlugin package

* create AsyncMessageChannel dev docs

* replace web-preview.md ASCII data flow diagram with mermaid

* use radii/spacing tokens instead of px for web preview.tsx styles

Co-authored-by: Jan Six <[email protected]>

* remove commented out startup handler (handled in startup.tsx useEffect now)

Co-authored-by: Jan Six <[email protected]>

* remove commented out code

Co-authored-by: Jan Six <[email protected]>

* replace px values with tokens

Co-authored-by: Jan Six <[email protected]>

* conditional export for AsyncMessageChannel preview env

* add browser preview WEBSOCKETS_PORT env

* fix typescript issue with PreviewAsyncMessageChannel.isWsConnected

* add test coverage for AsyncMessageChannelPreview

* Browser preview debug UI (#2803)

* fix AsyncMessageChannelPreview undefined error + export WS URI

* browser preview CSS file for UI fixes

* create previewUtils for browser color scheme + setFigmaBrowserTheme

* browser preview URL params + fullscreen/theme/action modes

* two bug fixes for browser/plugin websocket preview bridge

* add preview dist folder for web preview builds

* [WIP] browser preview dev knowledge docs

* feat(dev): request startup on browser preview page open

* refactor(dev): use env vars for browser preview ws src

* fix(debug): remove console.log from asyncmessagechannelpreview

* fix(css): figmaloading full height css for browser preview

* refactor(dev): use enums for websockets src in browser preview tsx

* fix(dev): remove comments

* refactor: reuse htmlClassList variable

* remove unused package

---------

Co-authored-by: macintoshhelper <[email protected]>

---------

Co-authored-by: macintoshhelper <[email protected]>
Co-authored-by: Jan Six <[email protected]>
  • Loading branch information
3 people authored and cuserox committed Aug 22, 2024
1 parent d45c898 commit c96985e
Show file tree
Hide file tree
Showing 20 changed files with 1,378 additions and 28 deletions.
147 changes: 147 additions & 0 deletions developer-knowledgebase/async-message-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# AsyncMessageChannel

## `AsyncMessageChannel` Data Flow

```mermaid
graph TD
A["Figma Plugin Controller\n(Sandbox Env)\ncontroller.ts"]
B[AsyncMessageChannel\nPluginInstance\nEnvironment.CONTROLLER]
C[Figma Plugin UI\nUI entrypoint\napp/index.tsx]
D[AsyncMessageChannel\nReactInstance\nEnvironment.UI]
E[Web Browser Preview\nhttp://localhost:9000]
F[AsyncMessageChannel\nReactInstance\nEnvironment.BROWSER]
A -->|"PluginInstance.connect()"| B
B -->|"PluginInstance.handle(...)"| A
B -->|"ReactInstance.connect()"| C
C -->|"ReactInstance.handle(...)"| B
C -->|"sendMessageToUi\n(figma.ui.postMessage(...))"| D
D -->|"sendMessageToController\n(parent.postMessage({ pluginMessage: {...} }))"| C
D -->|"ReactInstance.connect()"| E
E -->|"ReactInstance.handle(...)"| D
E -->|"sendMessageToBrowser\n(ws.send(...))"| F
F -->|"sendMessageFromBrowser\n(ws.send(...))"| E
```

## Instances

Static instances of `AsyncMessageChannel` are initialised when the class is loaded:

- `PluginInstance` - used from inside of `controller` entrypoint
- `ReactInstance` - used from inside of `ui` entrypoint

```ts
class AsyncMessageChannel {
public static PluginInstance: AsyncMessageChannel = new AsyncMessageChannel(true);
public static ReactInstance: AsyncMessageChannel = new AsyncMessageChannel(false);

protected inFigmaSandbox = false;

constructor(inFigmaSandbox: boolean) {
this.inFigmaSandbox = inFigmaSandbox
}
}

```

-

## Environments

There are currently three environments:

```ts
enum Environment {
PLUGIN = 'PLUGIN',
UI = 'UI',
BROWSER = 'BROWSER',
```
- `Environment.PLUGIN``controller` entrypoint
- `Environment.UI``ui` entrypoint
- Has access to `parent.postMessage`
- `Environment.BROWSER``ui` entrypoint
- Need to use WebSockets to send messages to the plugin `ui`
## Lifecycle
**`.connect()`**
Example: `AsyncMessageChannel.PluginInstance.connect();` or `AsyncMessageChannel.ReactInstance.connect();`
If in a web preview environment (`Environment.BROWSER` or `Environment.UI`), a WebSocket client listener is registered here (`this.startWebSocketConnection();`)
Registers message listeners with `this.attachMessageListener(callback)`, where `callback` in this case is [`this.onMessageEvent`](#onmessageeventmsg)
**`.attachMessageListener(callback)`**
Conditionally registers message event handlers depending on the environment:
- `Environment.CONTROLLER`
- `figma.ui.on('message', listener)`
- `Environment.UI`
- `window.addEventListener('message', listener)` – listens to messages controller
- *IF process.env.PREVIEW_MODE IS SET*
- `this.ws?.addEventListener('message', listener)`
- Where if this condition is true, `UI` has two message listeners, one to listen
- `Environment.CONTROLLER`
- `this.ws?.addEventListener('message', listener)`

Where `listener` is a function that is wrapping `callback`:

### `.onMessageEvent(msg)`

If the environment is preview, and message is not async, the UI environment will forward the message to the browser. Else, non async messages are discarded with `return;`

Next, if the environment is `UI` and `PREVIEW_MODE` is truthy, the message is forwarded via WebSockets to the browser, or to the controller.

Then the handler is called; the function is retrieved from `$handlers[msg.message.type]`.

The result of the handler function is `await`ed and a message is sent back to the source with the same message type, and the payload from the handler function result.

## Message Handling

`AsyncMessageChannel` handles messages with `.message` to send messages and receives messages (say in a different instance) by registering a handler with `.handle()`. Each handler is stored in the class/instance in `$handlers`, keyed by the message type.

Example: `AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, asyncHandlers.startup)`.

### Startup Process

**`controller.ts`**

```ts
AsyncMessageChannel.PluginInstance.connect();
```


**`init.ts`**

```ts
// Creating the plugin UI instance (AsyncMessageChannel.ReactInstance)
figma.showUI(__html__, {
themeColors: true,
width: params.settings.width ?? DefaultWindowSize.width,
height: params.settings.height ?? DefaultWindowSize.height,
});
//
await AsyncMessageChannel.PluginInstance.message({
type: AsyncMessageTypes.STARTUP,
...params,
});
```

**`asyncMessageHandlers/startup.tsx` / `StartupApp`**

```tsx
useEffect(() => {
AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, async (startupParams) => {
setParams(startupParams);
});
return () => {
AsyncMessageChannel.ReactInstance.handle(AsyncMessageTypes.STARTUP, (() => {}) as any);
};
}, []);
```

21 changes: 21 additions & 0 deletions developer-knowledgebase/web-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Web Preview

## Getting Started

1. Open two Terminal windows/tabs

> Terminal 1 (Plugin)
```sh
npm run preview:plugin
```

> Terminal 2 (Browser)

```sh
npm run preview:browser
```




15 changes: 14 additions & 1 deletion packages/tokens-studio-for-figma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
"watch-transform": "webpack --mode=development --watch --config webpack-transform.config.js",
"build": "cross-env NODE_ENV=production webpack --mode=production",
"build:dev": "cross-env NODE_ENV=development webpack --mode=development",
"build:preview": "cross-env NODE_ENV=development webpack --mode=development --PREVIEW_ENV=browser",
"build:cy": "cross-env LAUNCHDARKLY_FLAGS=tokenThemes,gitBranchSelector,multiFileSync,tokenFlowButton yarn build",
"start": "cross-env webpack --mode=development --watch",
"preview:ws": "node preview-server.js",
"preview:plugin": "webpack --mode=development --PREVIEW_ENV=figma",
"preview:browser": "webpack-dev-server --mode=development --PREVIEW_ENV=browser",
"preview": "cross-env WEBSOCKETS_PORT=9001 run-p \"preview:*\"",
"build-transform": "webpack --mode=production --config webpack-transform.config.js",
"benchmark:build": "webpack --config webpack-benchmark.config.js",
"benchmark:run": "node benchmark/index.mjs",
Expand All @@ -24,6 +29,7 @@
"cy:open": "cypress open",
"cy:run": "cypress run --headless",
"serve": "serve dist -p 58630",
"serve:preview": "serve preview -p 58630",
"changeset": "changeset",
"translate": "node ./scripts/translate.mjs",
"lint": "eslint . --quiet --fix",
Expand Down Expand Up @@ -146,6 +152,7 @@
"@babel/preset-typescript": "^7.12.16",
"@changesets/cli": "^2.26.2",
"@figma/plugin-typings": "^1.96.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.13",
"@sentry/webpack-plugin": "^2.2.0",
"@storybook/addon-actions": "^6.5.8",
"@storybook/addon-docs": "^6.5.8",
Expand Down Expand Up @@ -200,10 +207,11 @@
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-validate-jsx-nesting": "^0.1.1",
"express": "^4.19.2",
"figma-api-stub": "^0.0.56",
"figma-plugin-ds": "^1.0.1",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^7.2.11",
"fork-ts-checker-webpack-plugin": "7.2.11",
"fs-extra": "^11.1.1",
"html-inline-script-webpack-plugin": "^3.2.0",
"html-webpack-plugin": "^5.0.0",
Expand All @@ -218,22 +226,27 @@
"postcss-cli": "^8.3.1",
"prettier": "^2.0.5",
"react-devtools": "^4.28.4",
"react-refresh-typescript": "^2.0.9",
"react-svg-loader": "^3.0.3",
"react-test-renderer": "17.0.0",
"round-to": "^6.0.0",
"serve": "^11.3.2",
"speed-measure-webpack-plugin": "^1.5.0",
"style-loader": "^3.3.2",
"svg-url-loader": "^7.1.1",
"swc-loader": "^0.2.3",
"translate": "^2.0.2",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.8.1",
"tslint": "^5.18.0",
"tslint-react": "^4.0.0",
"typescript": "~4.7.4",
"url-loader": "^2.1.0",
"webpack": "5",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^3.3.6",
"webpack-dev-server": "3.x",
"whatwg-fetch": "^3.6.2"
},
"husky": {
Expand Down
52 changes: 52 additions & 0 deletions packages/tokens-studio-for-figma/preview-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const express = require("express");
const http = require("http");
const WebSocket = require("ws");
const path = require("path");

const PORT = process.env.WEBSOCKETS_PORT || 9001;

const app = express();

app.use(express.static(__dirname + '/dist'))

app.get("/", (req, res) => {
// res.sendFile(path.join(__dirname, 'dist', 'index.html'));
res.status(200).send("working");
});

const server = http.createServer(app);

// initialize the WebSocket server instance
const wss = new WebSocket.Server({ server });
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true;
});

// connection is up, let's add a simple simple event
ws.on("message", (data, isBinary) => {
const message = isBinary ? data : data.toString();
// send back the message to the other clients
wss.clients.forEach((client) => {
if (client != ws) {
client.send(JSON.stringify({ message, src: 'server' }));
}
});
});

// send immediatly a feedback to the incoming connection
// ws.send('Hi there, I am a WebSocket server');
});

setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 10000);

server.listen(PORT, () => {
console.log(`Preview server started on port: ${PORT}`);
});
8 changes: 7 additions & 1 deletion packages/tokens-studio-for-figma/src/AsyncMessageChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
AsyncMessageResults, AsyncMessageResultsMap, AsyncMessages, AsyncMessagesMap, AsyncMessageTypes,
} from './types/AsyncMessages';

import { AsyncMessageChannelPreview } from './AsyncMessageChannelPreview';

// credits goes to https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887
type IsTypeOnlyObject<Obj extends Record<PropertyKey, unknown>> = [keyof Obj] extends ['type'] ? true : false;

Expand All @@ -26,7 +28,7 @@ export type AsyncMessageChannelHandlers = {
>
};

export class AsyncMessageChannel {
class AsyncMessageChannel {
public static PluginInstance: AsyncMessageChannel = new AsyncMessageChannel(true);

public static ReactInstance: AsyncMessageChannel = new AsyncMessageChannel(false);
Expand Down Expand Up @@ -142,3 +144,7 @@ export class AsyncMessageChannel {
return promise;
}
}

const ExportedAsyncMessageChannel = !process.env.PREVIEW_ENV ? AsyncMessageChannel : AsyncMessageChannelPreview;

export { ExportedAsyncMessageChannel as AsyncMessageChannel };
Loading

0 comments on commit c96985e

Please sign in to comment.