├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docs ├── mdx-components.js ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── src │ ├── app │ │ ├── GlobalStylings.tsx │ │ ├── _meta.ts │ │ ├── docs │ │ │ └── [[...mdxPath]] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── opengraph-image.png │ │ └── page.tsx │ └── content │ │ ├── _meta.ts │ │ ├── callback-functions.mdx │ │ ├── index.mdx │ │ ├── queries-and-mutations.mdx │ │ ├── sending-requests-from-main-to-renderer.mdx │ │ ├── shared-values.mdx │ │ └── subscriptions-and-effects.mdx └── tsconfig.json ├── package.json ├── superbridge ├── .gitignore ├── README.md ├── client │ ├── createClient.ts │ ├── index.ts │ └── init.ts ├── main │ ├── BridgeHandler.ts │ ├── effect.ts │ ├── index.ts │ ├── init.ts │ ├── initializeBridge.ts │ ├── mutation.ts │ ├── query.ts │ ├── schema.ts │ └── sharedValue.ts ├── package.json ├── preload │ ├── index.ts │ └── init.ts ├── shared │ ├── channel.ts │ ├── defineMessage.ts │ ├── index.ts │ ├── init.ts │ ├── log.ts │ ├── messages.ts │ ├── serializer │ │ ├── abortSignal.ts │ │ ├── callbacks.ts │ │ ├── index.ts │ │ └── types.ts │ ├── superbridge.ts │ └── types.ts ├── tsconfig.json ├── tsconfig.scripts.json ├── utils │ ├── Signal.ts │ ├── cleanup.ts │ ├── controlledPromise.ts │ ├── id.ts │ ├── moduleCleanup.ts │ ├── nestedRecord.ts │ └── valueUpdater.ts └── vite.config.ts ├── testapp ├── .gitignore ├── index.html ├── package.json ├── src │ ├── App.tsx │ ├── bridge │ │ ├── client.ts │ │ ├── handler.ts │ │ ├── message.ts │ │ └── watchDisplays.ts │ ├── electron │ │ ├── main.ts │ │ └── preload.ts │ ├── main.tsx │ └── style.css ├── tsconfig.json └── vite.config.ts ├── tsconfig.json ├── vercel.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | /.pnp 4 | .pnp.js 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/sdks 10 | !.yarn/versions 11 | 12 | # Testing 13 | /coverage 14 | *.lcov 15 | 16 | # Production 17 | /build 18 | /dist 19 | /out 20 | 21 | # TypeScript 22 | *.tsbuildinfo 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # Environment variables 32 | .env 33 | .env.local 34 | .env.development.local 35 | .env.test.local 36 | .env.production.local 37 | 38 | # IDE - VSCode 39 | .vscode/* 40 | !.vscode/settings.json 41 | !.vscode/tasks.json 42 | !.vscode/launch.json 43 | !.vscode/extensions.json 44 | 45 | # IDE - JetBrains 46 | .idea/ 47 | *.iml 48 | *.iws 49 | .idea_modules/ 50 | 51 | # macOS 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | ._* 56 | 57 | # Windows 58 | Thumbs.db 59 | ehthumbs.db 60 | Desktop.ini 61 | 62 | # Misc 63 | *.swp 64 | *.swo 65 | .cache/ 66 | .temp/ 67 | .tmp/ 68 | 69 | .next -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | .yarn/* 4 | !.yarn/patches 5 | !.yarn/plugins 6 | !.yarn/releases 7 | !.yarn/sdks 8 | !.yarn/versions 9 | 10 | # Source and config files 11 | src/ 12 | .github/ 13 | .vscode/ 14 | .idea/ 15 | *.config.js 16 | *.config.ts 17 | tsconfig.json 18 | tsconfig.*.json 19 | .eslintrc* 20 | .prettierrc* 21 | .editorconfig 22 | 23 | # Test files 24 | __tests__/ 25 | test/ 26 | tests/ 27 | *.test.* 28 | *.spec.* 29 | coverage/ 30 | *.lcov 31 | 32 | # Build output 33 | dist/ 34 | build/ 35 | out/ 36 | .next/ 37 | 38 | # Documentation 39 | docs/ 40 | *.md 41 | !README.md 42 | 43 | # Development files 44 | .env* 45 | .git/ 46 | .gitignore 47 | .npmignore 48 | .cache/ 49 | .temp/ 50 | .tmp/ 51 | 52 | # OS files 53 | .DS_Store 54 | Thumbs.db 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | 60 | # Misc 61 | *.swp 62 | *.swo -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/*.js": true, 4 | "**/*.js.map": true, 5 | "**/node_modules": true, 6 | "**/bower_components": true, 7 | "**/*.code-search": true 8 | }, 9 | "files.exclude": { 10 | "**/*.js": { 11 | "when": "$(basename).ts" 12 | }, 13 | "**/*.js.map": true 14 | }, 15 | "spellright.language": ["en"], 16 | "spellright.documentTypes": ["latex", "plaintext"] 17 | } 18 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Pietrasiak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Superbridge 2 | 3 | `superbridge` is a powerful, type-safe, and easy-to-use Electron bridge with support for sending callback functions over the bridge. 4 | 5 | Visit [superbridge.dev](https://superbridge.dev) for full documentation. 6 | 7 | ```sh npm2yarn 8 | npm install superbridge 9 | ``` 10 | 11 | ## Setting up 12 | 13 | In order to set up `superbridge`, we need to: 14 | 15 | - Create a bridge (set of functions the client will be able to call) 16 | - Initialize it in the main process 17 | - Initialize the bridge in the preload 18 | - Create the client 19 | 20 | These are a bit of boilerplate, but the `superbridge` API is designed to make it as simple as possible. 21 | 22 | ### Router 23 | 24 | First, let's create a simple router. The router is a set of functions that will be available to the client. 25 | 26 | In this example, we only create a simple `ping` function that takes a message and returns a pong with it. 27 | 28 | There are many more powerful features, like subscriptions, effects, shared values, etc. We will cover them in the next sections. 29 | 30 | ```ts filename="router.ts" {3-7, 9} 31 | import { createRouter } from "superbridge/main"; 32 | 33 | export const appRouter = createRouter({ 34 | ping(message: string) { 35 | return `pong ${message}`; 36 | }, 37 | }); 38 | 39 | export type AppRouter = typeof appRouter; // Will be used to make the client type-safe 40 | ``` 41 | 42 | ### Main process 43 | 44 | Now, in the main process, we need to initialize the router. This needs to be done before we create the `BrowserWindow`. 45 | 46 | It is as simple as calling `initializeSuperbridgeMain` with our router. 47 | 48 | ```ts filename="electron/main.ts" {4} 49 | import { initializeSuperbridgeMain } from "superbridge/main"; 50 | import { appRouter } from "./router"; 51 | 52 | initializeSuperbridgeMain(appRouter); 53 | ``` 54 | 55 | ### Preload 56 | 57 | Now, we need to allow the client to call our router. 58 | 59 | We can do this by calling `initializeSuperbridgePreload` inside the preload script. 60 | 61 | ```ts filename="electron/preload.ts" {3} 62 | import { initializeSuperbridgePreload } from "superbridge/preload"; 63 | 64 | initializeSuperbridgePreload(); 65 | ``` 66 | 67 | ### Client 68 | 69 | Finally, let's create the client inside the renderer process. 70 | 71 | We do this by calling `createSuperbridgeClient` with our router type. 72 | 73 | ```ts filename="client.ts" 74 | import { type AppBridge } from "./handler"; 75 | import { createSuperbridgeClient } from "superbridge/client"; 76 | 77 | export const appClient = createSuperbridgeClient(); 78 | ``` 79 | 80 | > [!NOTE] 81 | > 82 | > It is important to explicitly import `AppBridge` as a type-only import. Otherwise, the entire router will be bundled into the client, which is also likely to crash, as the router is running in a Node environment, not a browser environment. 83 | 84 | ### Use the client 85 | 86 | Now, our client is ready to use! 87 | 88 | ```ts filename="client.ts" 89 | const pong = await appClient.ping("hello"); 90 | console.log(pong); // pong hello 91 | ``` 92 | 93 | ## License 94 | 95 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 96 | -------------------------------------------------------------------------------- /docs/mdx-components.js: -------------------------------------------------------------------------------- 1 | import { useMDXComponents as getDocsMDXComponents } from 'nextra-theme-docs' 2 | 3 | const docsComponents = getDocsMDXComponents() 4 | 5 | export const useMDXComponents = components => ({ 6 | ...docsComponents, 7 | ...components 8 | }) 9 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import nextra from "nextra"; 2 | 3 | const withNextra = nextra({ 4 | latex: true, 5 | search: { 6 | codeblocks: false, 7 | }, 8 | contentDirBasePath: "/docs", 9 | }); 10 | 11 | export default withNextra({ 12 | reactStrictMode: true, 13 | typescript: { 14 | ignoreBuildErrors: true, 15 | }, 16 | eslint: { 17 | ignoreDuringBuilds: true, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superbridge-docs", 3 | "license": "MIT", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next --turbopack", 8 | "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "next": "^15.0.2", 13 | "nextra": "^4.2.17", 14 | "nextra-theme-docs": "^4.2.17", 15 | "react": "18.3.1", 16 | "react-dom": "18.3.1" 17 | }, 18 | "devDependencies": { 19 | "pagefind": "^1.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/src/app/GlobalStylings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export function GlobalStylings() { 6 | useEffect(() => { 7 | // Reflect.set(window, "mobxmotion", mobxmotion); 8 | }); 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /docs/src/app/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: { 3 | display: "hidden", 4 | }, 5 | docs: { 6 | type: "page", 7 | title: "Documentation", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /docs/src/app/docs/[[...mdxPath]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateStaticParamsFor, importPage } from 'nextra/pages' 2 | import { useMDXComponents as getMDXComponents } from '../../../../mdx-components' 3 | 4 | export const generateStaticParams = generateStaticParamsFor('mdxPath') 5 | 6 | export async function generateMetadata(props) { 7 | const params = await props.params 8 | const { metadata } = await importPage(params.mdxPath) 9 | return metadata 10 | } 11 | 12 | const Wrapper = getMDXComponents().wrapper 13 | 14 | export default async function Page(props) { 15 | const params = await props.params 16 | const result = await importPage(params.mdxPath) 17 | const { default: MDXContent, toc, metadata } = result 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "nextra-theme-docs/style.css"; 2 | 3 | import { Banner, Head } from "nextra/components"; 4 | /* eslint-env node */ 5 | import { Footer, Layout, Navbar } from "nextra-theme-docs"; 6 | 7 | import { GlobalStylings } from "./GlobalStylings"; 8 | import { getPageMap } from "nextra/page-map"; 9 | 10 | export const metadata = { 11 | metadataBase: new URL("https://superbridge.dev"), 12 | title: { 13 | template: "%s - superbridge", 14 | }, 15 | description: 16 | "superbridge: a simple and powerful way to communicate between main and renderer processes in Electron", 17 | applicationName: "superbridge", 18 | generator: "Next.js", 19 | appleWebApp: { 20 | title: "superbridge", 21 | }, 22 | // other: { 23 | // "msapplication-TileImage": "/ms-icon-144x144.png", 24 | // "msapplication-TileColor": "#fff", 25 | // }, 26 | twitter: { 27 | site: "https://superbridge.dev", 28 | card: "summary_large_image", 29 | }, 30 | openGraph: { 31 | type: "website", 32 | url: "https://superbridge.dev", 33 | title: "superbridge", 34 | description: 35 | "superbridge: a simple and powerful way to communicate between main and renderer processes in Electron", 36 | images: [ 37 | { 38 | url: "https://superbridge.dev/opengraph-image.png", 39 | width: 1200, 40 | height: 630, 41 | alt: "superbridge", 42 | }, 43 | ], 44 | }, 45 | }; 46 | 47 | export default async function RootLayout({ children }) { 48 | const navbar = ( 49 | 52 | superbridge 53 | 54 | } 55 | projectLink="https://github.com/pie6k/superbridge" 56 | /> 57 | ); 58 | const pageMap = await getPageMap(); 59 | return ( 60 | 61 | 62 | 63 | Nextra 2 Alpha} 65 | navbar={navbar} 66 | footer={ 67 |
MIT {new Date().getFullYear()} © superbridge.
68 | } 69 | editLink="Edit this page on GitHub" 70 | docsRepositoryBase="https://github.com/pie6k/superbridge/blob/main/docs" 71 | sidebar={{ defaultMenuCollapseLevel: 1 }} 72 | pageMap={pageMap} 73 | > 74 | 75 | {children} 76 |
77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /docs/src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pie6k/superbridge/bc142d30368b8185064552f9397e4c30ee8c4942/docs/src/app/opengraph-image.png -------------------------------------------------------------------------------- /docs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default async function IndexPage() { 4 | redirect("/docs"); 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/content/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: "", 3 | "queries-and-mutations": "", 4 | "callback-functions": "", 5 | "subscriptions-and-effects": "", 6 | "shared-values": "", 7 | "sending-requests-from-main-to-renderer": "", 8 | }; 9 | -------------------------------------------------------------------------------- /docs/src/content/callback-functions.mdx: -------------------------------------------------------------------------------- 1 | # Callback functions 2 | 3 | All router operations can accept functions as arguments. 4 | 5 | Let's say we have some mutation that will report its progress. 6 | 7 | ```ts filename="router.ts" /onProgress/ 8 | import { createRouter, mutation } from "superbridge/main"; 9 | 10 | function wait(ms: number) { 11 | return new Promise((resolve) => setTimeout(resolve, ms)); 12 | } 13 | 14 | export const appRouter = createRouter({ 15 | processFile: mutation(async (filePath: string, onProgress: (progress: number) => void) => { 16 | for (let i = 0; i < 10; i++) { 17 | const progress = i / 10; 18 | onProgress(progress); 19 | await wait(1000); 20 | } 21 | 22 | return { 23 | result: "success", 24 | }; 25 | }), 26 | }); 27 | ``` 28 | 29 | Now, let's call this mutation from the client. 30 | 31 | ```ts filename="example.ts" 32 | const result = await appClient.processFile("path/to/file", (progress) => { 33 | console.log(`Progress: ${progress * 100}%`); 34 | }); 35 | 36 | console.log(result); // { result: "success" } 37 | ``` 38 | 39 | > [!NOTE] 40 | > 41 | > Callback functions can return values (including another callback function). 42 | > 43 | > However, the result is always returned as a promise, even if the callback itself is a synchronous function. Make sure to properly type your callback return value or always await the result. -------------------------------------------------------------------------------- /docs/src/content/index.mdx: -------------------------------------------------------------------------------- 1 | import { Steps } from 'nextra/components' 2 | 3 | # Superbridge 4 | 5 | `superbridge` is a powerful, type-safe, and easy-to-use Electron bridge with support for sending callback functions over the bridge. 6 | 7 | ```sh npm2yarn 8 | npm install superbridge 9 | ``` 10 | 11 | ## Setting up 12 | 13 | In order to set up `superbridge`, we need to: 14 | - Create a bridge (set of functions the client will be able to call) 15 | - Initialize it in the main process 16 | - Initialize the bridge in the preload 17 | - Create the client 18 | 19 | These are a bit of boilerplate, but the `superbridge` API is designed to make it as simple as possible. 20 | 21 | 22 | 23 | ### Router 24 | 25 | First, let's create a simple router. The router is a set of functions that will be available to the client. 26 | 27 | In this example, we only create a simple `ping` function that takes a message and returns a pong with it. 28 | 29 | There are many more powerful features, like subscriptions, effects, shared values, etc. We will cover them in the next sections. 30 | 31 | ```ts filename="router.ts" {3-7, 9} 32 | import { createRouter } from "superbridge/main"; 33 | 34 | export const appRouter = createRouter({ 35 | ping(message: string) { 36 | return `pong ${message}`; 37 | }, 38 | }); 39 | 40 | export type AppRouter = typeof appRouter; // Will be used to make the client type-safe 41 | ``` 42 | 43 | ### Main process 44 | 45 | Now, in the main process, we need to initialize the router. This needs to be done before we create the `BrowserWindow`. 46 | 47 | It is as simple as calling `initializeSuperbridgeMain` with our router. 48 | 49 | ```ts filename="electron/main.ts" {4} 50 | import { initializeSuperbridgeMain } from "superbridge/main"; 51 | import { appRouter } from "./router"; 52 | 53 | initializeSuperbridgeMain(appRouter); 54 | ``` 55 | 56 | ### Preload 57 | 58 | Now, we need to allow the client to call our router. 59 | 60 | We can do this by calling `initializeSuperbridgePreload` inside the preload script. 61 | 62 | ```ts filename="electron/preload.ts" {3} 63 | import { initializeSuperbridgePreload } from "superbridge/preload"; 64 | 65 | initializeSuperbridgePreload(); 66 | ``` 67 | 68 | ### Client 69 | 70 | Finally, let's create the client inside the renderer process. 71 | 72 | We do this by calling `createSuperbridgeClient` with our router type. 73 | 74 | ```ts filename="client.ts" 75 | import { type AppBridge } from "./handler"; 76 | import { createSuperbridgeClient } from "superbridge/client"; 77 | 78 | export const appClient = createSuperbridgeClient(); 79 | ``` 80 | 81 | > [!NOTE] 82 | > 83 | > It is important to explicitly import `AppBridge` as a type-only import. Otherwise, the entire router will be bundled into the client, which is also likely to crash, as the router is running in a Node environment, not a browser environment. 84 | 85 | 86 | 87 | ### Use the client 88 | 89 | Now, our client is ready to use! 90 | 91 | ```ts filename="client.ts" 92 | const pong = await appClient.ping("hello"); 93 | console.log(pong); // pong hello 94 | ``` -------------------------------------------------------------------------------- /docs/src/content/queries-and-mutations.mdx: -------------------------------------------------------------------------------- 1 | # Queries and Mutations 2 | 3 | Let's say we want to expose another function that will mutate the app state. 4 | 5 | ```ts filename="router.ts" {3, 6-13} 6 | import { createRouter, mutation } from "superbridge/main"; 7 | 8 | let counter = 0; 9 | 10 | export const appRouter = createRouter({ 11 | getCounter() { 12 | return counter; 13 | }, 14 | increment: mutation(async (by: number) => { 15 | // Let's say the mutation will take some time to complete 16 | await new Promise((resolve) => setTimeout(resolve, 1000)); 17 | counter += by; 18 | }), 19 | }); 20 | ``` 21 | 22 | Operations that mutate the app state are marked as mutations. 23 | 24 | If some mutations are running, all new queries will first wait for all pending mutations to finish in order to avoid queries returning stale data, especially if the mutation takes some time to complete. 25 | 26 | In terms of the client API, queries and mutations look and behave the same. 27 | 28 | ```ts filename="example.ts" 29 | const counter = await appClient.getCounter(); 30 | console.log(counter); // 0 31 | await appClient.increment(1); 32 | const counter2 = await appClient.getCounter(); 33 | console.log(counter2); // 1 34 | ``` -------------------------------------------------------------------------------- /docs/src/content/sending-requests-from-main-to-renderer.mdx: -------------------------------------------------------------------------------- 1 | import { Steps } from 'nextra/components' 2 | 3 | # Sending requests from main to renderer 4 | 5 | Sometimes we might want to send an explicit request from main to renderer, either to instruct the renderer to do something or to retrieve some data from the renderer. 6 | 7 | Let's say we want the main process to be able to get the HTML of the current page and to toggle a class on the body element. 8 | 9 | In order to do that, we will need to define two message types and then define their handlers. 10 | 11 | ```ts filename="message-types.ts" 12 | import { defineBridgeMessage } from "superbridge"; 13 | 14 | /** 15 | * $toggleBodyClass takes a class name and returns a boolean value indicating 16 | * whether the class is currently on the body element. 17 | */ 18 | export const $toggleBodyClass = defineBridgeMessage("$toggleBodyClass"); 19 | 20 | /** 21 | * $getPageHtml takes no input and returns the HTML of the current page. 22 | */ 23 | export const $getPageHtml = defineBridgeMessage("$getPageHtml"); 24 | ``` 25 | 26 | The `defineBridgeMessage` function accepts two type arguments: the input it takes and the output it produces. 27 | 28 | > [!NOTE] 29 | > 30 | > Messages need to be defined in a file that will be imported by both main and renderer. 31 | 32 | Now, we need to tell the renderer how to handle these messages. 33 | 34 | ```ts filename="renderer.ts" 35 | import { $toggleBodyClass, $getPageHtml } from "./message-types"; 36 | import { superbridge } from "superbridge"; 37 | 38 | superbridge.handle($toggleBodyClass, (className) => { 39 | document.body.classList.toggle(className); 40 | 41 | return document.body.classList.contains(className); 42 | }); 43 | 44 | superbridge.handle($getPageHtml, () => { 45 | return document.body.innerHTML; 46 | }); 47 | ``` 48 | 49 | Now, on the main side, we can send these messages. 50 | 51 | ```ts filename="main.ts" 52 | import { $toggleBodyClass, $getPageHtml } from "./message-types"; 53 | import { superbridge } from "superbridge"; 54 | 55 | superbridge.send($toggleBodyClass, "dark"); 56 | 57 | const html = await superbridge.send($getPageHtml); 58 | console.log(html); 59 | ``` 60 | 61 | > [!NOTE] 62 | > 63 | > If on the renderer side you only handle messages but never use the client from `superbridge/client`, somewhere early in your renderer code, add: 64 | > 65 | > ```ts 66 | > import "superbridge/client"; 67 | > ``` 68 | > 69 | > This will ensure that `superbridge` is properly initialized and able to connect to the main process. 70 | > 71 | > TLDR: `superbridge/client` must be imported at least once in your renderer code, before you send or handle any messages. 72 | 73 | ## Two-way communication 74 | 75 | The pattern above is symmetric. Either side of the bridge can send messages or handle them. 76 | 77 | It is possible for both sides to send and handle messages to each other. 78 | 79 | As with everything else, those messages can send data of any type supported by the client, including callback functions. -------------------------------------------------------------------------------- /docs/src/content/shared-values.mdx: -------------------------------------------------------------------------------- 1 | import { Steps } from 'nextra/components' 2 | 3 | # Shared values 4 | 5 | Sometimes we might want to share some data between the main and renderer processes. 6 | 7 | Let's say we have some app preferences. We might define them in a file in the *main* process. 8 | 9 | ```ts filename="preferences.ts" 10 | import { sharedValue } from "superbridge/main"; 11 | 12 | export const preferences = sharedValue({ 13 | theme: "light", 14 | }); 15 | ``` 16 | 17 | Now, let's expose this value inside the router. 18 | 19 | ```ts filename="router.ts" 20 | import { preferences } from "./preferences"; 21 | import { createRouter } from "superbridge/main"; 22 | 23 | export const appRouter = createRouter({ 24 | preferences, 25 | }); 26 | ``` 27 | 28 | Now, this value will be automatically synced between the main and renderer processes. 29 | 30 | ```ts filename="example.ts" 31 | const preferences = appClient.preferences(); 32 | console.log(await preferences.get()); 33 | await preferences.update({ theme: "dark" }); 34 | 35 | const stopWatching = preferences.watch((value) => { 36 | console.log("preferences changed", value); 37 | }); 38 | 39 | // After some time, we can stop watching 40 | stopWatching(); 41 | ``` 42 | 43 | On the main side, we can use the same API on the `preferences` object. 44 | 45 | ```ts filename="main.ts" 46 | import { preferences } from "./preferences"; 47 | import { watchSystemTheme } from "./utils"; 48 | 49 | watchSystemTheme((theme) => { 50 | preferences.update({ theme }); // Will be updated on the main side and instantly synced to the renderer 51 | }); 52 | ``` -------------------------------------------------------------------------------- /docs/src/content/subscriptions-and-effects.mdx: -------------------------------------------------------------------------------- 1 | import { Steps } from 'nextra/components' 2 | 3 | # Subscriptions and Effects 4 | 5 | In the previous example, we passed a callback function to the mutation. 6 | 7 | Now let's say we want to continuously report some data until the client decides to stop observing. 8 | 9 | To do that, we will be using handlers marked as `effect`. Effects are similar to queries and mutations, but they are required to return a cleanup function. 10 | 11 | ## Example - Watch Displays 12 | 13 | Let's say we want to watch displays connected to the machine. 14 | 15 | First, let's create a utility function that will be used to watch displays. 16 | 17 | ```ts filename="watchDisplays.ts" 18 | import { screen, Display } from "electron"; 19 | 20 | export function watchDisplays(onDisplaysChange: (displays: Display[]) => void) { 21 | function reportDisplays() { 22 | const displays = screen.getAllDisplays(); 23 | onDisplaysChange(displays); 24 | } 25 | 26 | reportDisplays(); 27 | 28 | screen.on("display-added", reportDisplays); 29 | screen.on("display-removed", reportDisplays); 30 | screen.on("display-metrics-changed", reportDisplays); 31 | 32 | return () => { 33 | screen.off("display-added", reportDisplays); 34 | screen.off("display-removed", reportDisplays); 35 | screen.off("display-metrics-changed", reportDisplays); 36 | }; 37 | } 38 | ``` 39 | 40 | Now, let's expose this function. 41 | 42 | ```ts filename="router.ts" 43 | import { Display } from "electron"; 44 | import { createRouter, effect } from "superbridge/main"; 45 | 46 | 47 | export const appRouter = createRouter({ 48 | watchDisplays: effect((onDisplaysChange: (displays: Display[]) => void) => { 49 | return watchDisplays(onDisplaysChange); 50 | }), 51 | }); 52 | ``` 53 | 54 | Now, let's use this effect in the client. 55 | 56 | ```ts filename="example.ts" 57 | const stopWatching = appClient.watchDisplays((displays) => { 58 | console.log(displays); 59 | }); 60 | 61 | // After some time, we can stop watching 62 | stopWatching(); 63 | ``` 64 | 65 | Let's write an example with React. 66 | 67 | ```tsx filename="App.tsx" 68 | import { useEffect, useState } from "react"; 69 | import { type Display } from "electron"; 70 | 71 | export function App() { 72 | const [displays, setDisplays] = useState([]); 73 | 74 | useEffect(() => { 75 | return appClient.watchDisplays(setDisplays); 76 | }, []); 77 | 78 | return
Displays: {displays.length}
; 79 | } 80 | ``` 81 | 82 | We've just created a subscription that will be automatically cleaned up when the component unmounts. 83 | 84 | ## Effects 85 | 86 | Sometimes we might want to create some effects that do not emit any data. 87 | 88 | Let's say we want to expose the ability to hide the Dock icon on macOS. 89 | 90 | ```ts filename="router.ts" 91 | import { createRouter, effect } from "superbridge/main"; 92 | import { app } from "electron"; 93 | 94 | 95 | export const appRouter = createRouter({ 96 | hideDockIcon: effect(() => { 97 | app.dock.hide(); 98 | 99 | return () => { 100 | app.dock.show(); 101 | }; 102 | }), 103 | }); 104 | ``` 105 | 106 | Now, let's use this effect in the client. 107 | 108 | ```ts filename="example.ts" 109 | const stopHiding = appClient.hideDockIcon(); 110 | 111 | // After some time, we can stop hiding 112 | stopHiding(); 113 | ``` 114 | 115 | Let's write an example with React. 116 | 117 | ```tsx filename="App.tsx" 118 | import { useEffect, useState } from "react"; 119 | 120 | export function App() { 121 | const [isHiding, setIsHiding] = useState(false); 122 | 123 | function toggleDockIcon() { 124 | setIsHiding((prev) => !prev); 125 | } 126 | 127 | useEffect(() => { 128 | if (!isHiding) return; 129 | 130 | return appClient.hideDockIcon(); 131 | }, [isHiding]); 132 | 133 | return ( 134 |
135 | 136 |
137 | ); 138 | } 139 | ``` -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "noEmit": true, 13 | "incremental": true, 14 | "module": "esnext", 15 | "esModuleInterop": true, 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | ".next/types/**/*.ts", 29 | "**/*.ts", 30 | "**/*.tsx" 31 | ], 32 | "exclude": [ 33 | "node_modules" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superbridge-root", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "private": true, 6 | "workspaces": { 7 | "packages": [ 8 | "superbridge", 9 | "testapp", 10 | "docs" 11 | ] 12 | }, 13 | "scripts": { 14 | "superbridge": "yarn workspace superbridge", 15 | "testapp": "yarn workspace testapp", 16 | "docs": "yarn workspace superbridge-docs", 17 | "dev": "yarn superbridge dev & yarn testapp dev & yarn docs dev" 18 | }, 19 | "packageManager": "yarn@4.4.1", 20 | "devDependencies": { 21 | "@types/node": "^22.14.0", 22 | "vite-plugin-dts": "^4.5.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /superbridge/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /superbridge/README.md: -------------------------------------------------------------------------------- 1 | # Superbridge 2 | 3 | `superbridge` is a powerful, type-safe, and easy-to-use Electron bridge with support for sending callback functions over the bridge. 4 | 5 | Visit [superbridge.dev](https://superbridge.dev) for full documentation. 6 | 7 | ```sh npm2yarn 8 | npm install superbridge 9 | ``` 10 | 11 | ## Setting up 12 | 13 | In order to set up `superbridge`, we need to: 14 | 15 | - Create a bridge (set of functions the client will be able to call) 16 | - Initialize it in the main process 17 | - Initialize the bridge in the preload 18 | - Create the client 19 | 20 | These are a bit of boilerplate, but the `superbridge` API is designed to make it as simple as possible. 21 | 22 | ### Router 23 | 24 | First, let's create a simple router. The router is a set of functions that will be available to the client. 25 | 26 | In this example, we only create a simple `ping` function that takes a message and returns a pong with it. 27 | 28 | There are many more powerful features, like subscriptions, effects, shared values, etc. We will cover them in the next sections. 29 | 30 | ```ts filename="router.ts" {3-7, 9} 31 | import { createRouter } from "superbridge/main"; 32 | 33 | export const appRouter = createRouter({ 34 | ping(message: string) { 35 | return `pong ${message}`; 36 | }, 37 | }); 38 | 39 | export type AppRouter = typeof appRouter; // Will be used to make the client type-safe 40 | ``` 41 | 42 | ### Main process 43 | 44 | Now, in the main process, we need to initialize the router. This needs to be done before we create the `BrowserWindow`. 45 | 46 | It is as simple as calling `initializeSuperbridgeMain` with our router. 47 | 48 | ```ts filename="electron/main.ts" {4} 49 | import { initializeSuperbridgeMain } from "superbridge/main"; 50 | import { appRouter } from "./router"; 51 | 52 | initializeSuperbridgeMain(appRouter); 53 | ``` 54 | 55 | ### Preload 56 | 57 | Now, we need to allow the client to call our router. 58 | 59 | We can do this by calling `initializeSuperbridgePreload` inside the preload script. 60 | 61 | ```ts filename="electron/preload.ts" {3} 62 | import { initializeSuperbridgePreload } from "superbridge/preload"; 63 | 64 | initializeSuperbridgePreload(); 65 | ``` 66 | 67 | ### Client 68 | 69 | Finally, let's create the client inside the renderer process. 70 | 71 | We do this by calling `createSuperbridgeClient` with our router type. 72 | 73 | ```ts filename="client.ts" 74 | import { type AppBridge } from "./handler"; 75 | import { createSuperbridgeClient } from "superbridge/client"; 76 | 77 | export const appClient = createSuperbridgeClient(); 78 | ``` 79 | 80 | > [!NOTE] 81 | > 82 | > It is important to explicitly import `AppBridge` as a type-only import. Otherwise, the entire router will be bundled into the client, which is also likely to crash, as the router is running in a Node environment, not a browser environment. 83 | 84 | ### Use the client 85 | 86 | Now, our client is ready to use! 87 | 88 | ```ts filename="client.ts" 89 | const pong = await appClient.ping("hello"); 90 | console.log(pong); // pong hello 91 | ``` 92 | 93 | ## License 94 | 95 | This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details. 96 | -------------------------------------------------------------------------------- /superbridge/client/createClient.ts: -------------------------------------------------------------------------------- 1 | import "./init"; 2 | import { type Router, RouterInput } from "../main/BridgeHandler"; 3 | import { Effect } from "../main/effect"; 4 | import { Mutation } from "../main/mutation"; 5 | import { Query } from "../main/query"; 6 | import { createLogger } from "../shared/log"; 7 | import { 8 | $execute, 9 | $getSharedValue, 10 | $reset, 11 | $setSharedValue, 12 | $watchSharedValue, 13 | } from "../shared/messages"; 14 | import { generateId } from "../utils/id"; 15 | import { unwrapNestedRecord } from "../utils/nestedRecord"; 16 | import { bridge } from "../shared/superbridge"; 17 | import { SharedValue } from "../main/sharedValue"; 18 | import { updateValue, ValueUpdater } from "../utils/valueUpdater"; 19 | import { createCleanup, Cleanup } from "../utils/cleanup"; 20 | import { Signal } from "../utils/Signal"; 21 | import { $moduleCleanup } from "../utils/moduleCleanup"; 22 | const CLIENT_SYMBOL = Symbol("superbridge-client"); 23 | 24 | const log = createLogger("superbridge/client"); 25 | 26 | type AnyFunction = (...args: any[]) => any; 27 | 28 | type QueryClient = ( 29 | ...args: Parameters 30 | ) => Promise>>; 31 | 32 | type MutationClient = ( 33 | ...args: Parameters 34 | ) => Promise>>; 35 | 36 | type EffectClient = (...args: Args) => VoidFunction; 37 | 38 | type SuperbridgeClientValue = T extends Query 39 | ? QueryClient 40 | : T extends Mutation 41 | ? MutationClient 42 | : T extends Effect 43 | ? EffectClient 44 | : T extends SharedValue 45 | ? SharedValueClient 46 | : T extends RouterInput 47 | ? SuperbridgeClient 48 | : T extends AnyFunction 49 | ? QueryClient 50 | : never; 51 | 52 | export type SuperbridgeClient = { 53 | [K in keyof T]: SuperbridgeClientValue; 54 | } & SuperbridgeClientMethods; 55 | 56 | interface SuperbridgeClientMethods { 57 | destroy: Cleanup; 58 | } 59 | 60 | function createQueryClient(path: string) { 61 | return async function query(...args: Args): Promise> { 62 | log.debug(`Query "${path}" with args`, args); 63 | await resetPromise; 64 | 65 | return bridge.send($execute, { 66 | id: generateId(), 67 | path, 68 | args, 69 | }) as Promise>; 70 | }; 71 | } 72 | 73 | function createMutationClient(path: string) { 74 | return async function mutation(...args: Args): Promise> { 75 | log.debug(`Mutation "${path}" with args`, args); 76 | await resetPromise; 77 | 78 | return bridge.send($execute, { 79 | id: generateId(), 80 | path, 81 | args, 82 | }) as Promise>; 83 | }; 84 | } 85 | 86 | function createEffectClient( 87 | path: string, 88 | destroy: Cleanup 89 | ) { 90 | return function effect(...args: Args) { 91 | log.debug(`Effect "${path}" with args`, args); 92 | const maybeCleanupPromise = resetPromise.then(() => 93 | bridge.send($execute, { 94 | id: generateId(), 95 | path, 96 | args, 97 | }) 98 | ); 99 | 100 | async function cleanup() { 101 | destroy.remove(cleanup); 102 | try { 103 | const cleanup = await maybeCleanupPromise; 104 | 105 | if (typeof cleanup === "function") { 106 | cleanup(); 107 | } 108 | } catch (error) { 109 | console.error(error); 110 | } 111 | } 112 | 113 | destroy.add(cleanup); 114 | 115 | return cleanup; 116 | }; 117 | } 118 | 119 | class SharedValueClient { 120 | private signal = new Signal(); 121 | 122 | get value() { 123 | if (this.signal.hasLastValue) { 124 | return this.signal.lastValue as T; 125 | } 126 | 127 | return this.initialValue; 128 | } 129 | 130 | private startWatching() { 131 | const stopWatchingPromise = bridge.send($watchSharedValue, { 132 | path: this.path, 133 | callback: (value: T) => { 134 | console.log("has from main", value); 135 | this.signal.emit(value); 136 | }, 137 | }); 138 | 139 | return () => { 140 | stopWatchingPromise.then((stop) => { 141 | stop(); 142 | }); 143 | }; 144 | } 145 | 146 | constructor( 147 | readonly path: string, 148 | readonly initialValue: T, 149 | private readonly destroy: Cleanup 150 | ) { 151 | this.destroy.next = this.startWatching(); 152 | } 153 | 154 | get() { 155 | return this.value; 156 | } 157 | 158 | set(value: T) { 159 | this.signal.emit(value); 160 | return bridge.send($setSharedValue, { path: this.path, value }); 161 | } 162 | 163 | watch(callback: (value: T) => void) { 164 | return this.signal.subscribe(callback); 165 | } 166 | 167 | update(updater: ValueUpdater) { 168 | this.set(updateValue(this.value, updater)); 169 | } 170 | } 171 | 172 | let resetPromise: Promise; 173 | 174 | export function createSuperbridgeClient< 175 | T extends Router 176 | >(): SuperbridgeClient { 177 | resetPromise = bridge.send($reset, undefined); 178 | 179 | const destroy = createCleanup(); 180 | 181 | const schema = window.$superbridgeinterface.schema; 182 | 183 | if (!schema) { 184 | throw new Error("Schema is not initialized"); 185 | } 186 | 187 | const flatClient: Record = {}; 188 | 189 | for (const [path, fieldSchema] of Object.entries(schema)) { 190 | if (fieldSchema.type === "query") { 191 | flatClient[path] = createQueryClient(path); 192 | } 193 | 194 | if (fieldSchema.type === "mutation") { 195 | flatClient[path] = createMutationClient(path); 196 | } 197 | 198 | if (fieldSchema.type === "effect") { 199 | flatClient[path] = createEffectClient(path, destroy); 200 | } 201 | 202 | if (fieldSchema.type === "sharedValue") { 203 | flatClient[path] = new SharedValueClient( 204 | path, 205 | fieldSchema.initialValue, 206 | destroy 207 | ); 208 | } 209 | } 210 | 211 | const client = unwrapNestedRecord(flatClient) as SuperbridgeClient< 212 | T["input"] 213 | >; 214 | 215 | Reflect.set(client, CLIENT_SYMBOL, true); 216 | 217 | client.destroy = destroy; 218 | 219 | $moduleCleanup[CLIENT_SYMBOL] = destroy; 220 | 221 | return client; 222 | } 223 | -------------------------------------------------------------------------------- /superbridge/client/index.ts: -------------------------------------------------------------------------------- 1 | import "./init"; 2 | 3 | export { 4 | type SuperbridgeClient, 5 | createSuperbridgeClient, 6 | } from "./createClient"; 7 | -------------------------------------------------------------------------------- /superbridge/client/init.ts: -------------------------------------------------------------------------------- 1 | import "../shared/init"; 2 | 3 | import { BridgeMessageType } from "../shared/defineMessage"; 4 | import { HandleResult } from "../shared/messages"; 5 | import { bridgeSerializer } from "../shared/serializer"; 6 | import { generateId } from "../utils/id"; 7 | import { initializeSuperbridge } from "../shared/superbridge"; 8 | 9 | const { $superbridgeinterface } = window; 10 | 11 | initializeSuperbridge({ 12 | async send( 13 | message: BridgeMessageType, 14 | payload: I, 15 | webId?: number 16 | ) { 17 | if (webId !== undefined) { 18 | console.warn( 19 | "Sending message to specific webContents is not supported in the client" 20 | ); 21 | webId = undefined; 22 | } 23 | 24 | const requestId = generateId(); 25 | 26 | const result = await $superbridgeinterface.send(message.type, { 27 | requestId, 28 | payload: bridgeSerializer.serialize(payload), 29 | }); 30 | 31 | return bridgeSerializer.deserialize(result) as O; 32 | }, 33 | handle( 34 | message: BridgeMessageType, 35 | handler: (payload: I) => Promise 36 | ) { 37 | return $superbridgeinterface.handle( 38 | message.type, 39 | async ({ requestId, payload }) => { 40 | try { 41 | const result = await handler( 42 | bridgeSerializer.deserialize(payload) as I 43 | ); 44 | 45 | await $superbridgeinterface.send("HANDLE_RESULT", { 46 | requestId, 47 | payload: bridgeSerializer.serialize({ 48 | requestId, 49 | type: "success", 50 | result, 51 | } as HandleResult), 52 | }); 53 | } catch (error) { 54 | await $superbridgeinterface.send("HANDLE_RESULT", { 55 | requestId, 56 | payload: bridgeSerializer.serialize({ 57 | requestId, 58 | type: "error", 59 | error, 60 | } as HandleResult), 61 | }); 62 | } 63 | } 64 | ); 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /superbridge/main/BridgeHandler.ts: -------------------------------------------------------------------------------- 1 | import { BridgeHandlerSchema, getBridgeHandlerSchema } from "./schema"; 2 | import { Effect, getIsEffect } from "./effect"; 3 | import { Mutation, getIsMutation } from "./mutation"; 4 | import { Query, getIsQuery } from "./query"; 5 | import { SharedValue, getIsSharedValue } from "./sharedValue"; 6 | 7 | import { MaybePromise } from "../shared/types"; 8 | import { Signal } from "../utils/Signal"; 9 | import { createLogger } from "../shared/log"; 10 | import { createNestedRecordPropertiesMap } from "../utils/nestedRecord"; 11 | 12 | const log = createLogger("superbridge/main/BridgeHandler"); 13 | 14 | export type BridgeNestedObject = { 15 | [key: string]: BridgeNestedObject | LeafType; 16 | }; 17 | 18 | type Cleanup = () => void; 19 | type AnyFunction = (...args: any[]) => any; 20 | // 21 | export type RouterSingleHandler = 22 | | Query 23 | | Mutation 24 | | Effect 25 | | SharedValue 26 | | AnyFunction; 27 | 28 | export type RouterInput = BridgeNestedObject; 29 | 30 | // Define the handlersMap type separately to ensure it's preserved in declarations 31 | export type RouterHandlersMap = Map; 32 | 33 | interface SharedValueState { 34 | value: T; 35 | initialValue: T; 36 | updates: Signal; 37 | } 38 | 39 | export class Router { 40 | private handlersMap: RouterHandlersMap = new Map(); 41 | public readonly schema: BridgeHandlerSchema; 42 | 43 | constructor(public readonly input: T) { 44 | this.handlersMap = 45 | createNestedRecordPropertiesMap(input); 46 | this.schema = getBridgeHandlerSchema(input); 47 | } 48 | 49 | private pendingMutations = new Set>(); 50 | private runningEffects = new Set>(); 51 | 52 | private async waitForPendingMutations() { 53 | while (this.pendingMutations.size) { 54 | const promises = [...this.pendingMutations]; 55 | 56 | for (const promise of promises) { 57 | try { 58 | await promise; 59 | } catch {} 60 | } 61 | } 62 | } 63 | 64 | private addPendingMutation(promise: Promise) { 65 | this.pendingMutations.add(promise); 66 | 67 | promise.finally(() => { 68 | this.pendingMutations.delete(promise); 69 | }); 70 | } 71 | 72 | private getHandler(path: string): RouterSingleHandler { 73 | const handler = this.handlersMap.get(path); 74 | 75 | if (!handler) { 76 | throw new Error(`Handler not found for path: ${path}`); 77 | } 78 | 79 | return handler; 80 | } 81 | 82 | async execute(path: string, args: unknown[]): Promise { 83 | const handler = this.getHandler(path); 84 | 85 | if (getIsSharedValue(handler)) { 86 | throw new Error("Shared values are not supported in execute"); 87 | } 88 | 89 | if (getIsMutation(handler)) { 90 | const promise = handler(...args); 91 | 92 | this.addPendingMutation(promise); 93 | 94 | return promise; 95 | } 96 | 97 | if (getIsEffect(handler)) { 98 | const cleanup = handler(...args); 99 | 100 | this.runningEffects.add(cleanup); 101 | 102 | return cleanup; 103 | } 104 | 105 | if (getIsQuery(handler) || typeof handler === "function") { 106 | return handler(...args); 107 | } 108 | 109 | throw new Error(`Unknown handler type: ${handler}`); 110 | } 111 | 112 | getSharedValue(path: string) { 113 | const handler = this.getHandler(path); 114 | 115 | if (!getIsSharedValue(handler)) { 116 | throw new Error("Shared values are not supported in getSharedValue"); 117 | } 118 | 119 | return handler.getValue(); 120 | } 121 | 122 | setSharedValue(path: string, value: unknown) { 123 | const handler = this.getHandler(path); 124 | 125 | if (!getIsSharedValue(handler)) { 126 | throw new Error("Shared values are not supported in setSharedValue"); 127 | } 128 | 129 | handler.setValue(value); 130 | } 131 | 132 | watchSharedValue(path: string, callback: (value: unknown) => void) { 133 | const handler = this.getHandler(path); 134 | 135 | if (!getIsSharedValue(handler)) { 136 | throw new Error("Shared values are not supported in watchSharedValue"); 137 | } 138 | 139 | return handler.watch(callback); 140 | } 141 | 142 | async cleanAllEffects(): Promise { 143 | const effects = [...this.runningEffects]; 144 | 145 | for (const effect of effects) { 146 | try { 147 | const cleanup = await effect; 148 | 149 | if (typeof cleanup === "function") { 150 | cleanup(); 151 | } 152 | } catch {} 153 | } 154 | 155 | this.runningEffects.clear(); 156 | } 157 | 158 | async reset(): Promise { 159 | await this.cleanAllEffects(); 160 | await this.waitForPendingMutations(); 161 | } 162 | } 163 | 164 | export function createRouter(input: T): Router { 165 | return new Router(input); 166 | } 167 | -------------------------------------------------------------------------------- /superbridge/main/effect.ts: -------------------------------------------------------------------------------- 1 | const EFFECT_SYMBOL = Symbol("effect"); 2 | 3 | type Cleanup = () => void; 4 | type MaybePromise = T | Promise; 5 | 6 | export interface Effect { 7 | (...args: Args): MaybePromise; 8 | [EFFECT_SYMBOL]: "effect"; 9 | } 10 | 11 | export function getIsEffect( 12 | value: unknown 13 | ): value is Effect { 14 | return ( 15 | typeof value === "function" && 16 | EFFECT_SYMBOL in value && 17 | value[EFFECT_SYMBOL] === "effect" 18 | ); 19 | } 20 | 21 | export function effect( 22 | handler: (...args: Args) => MaybePromise 23 | ): Effect { 24 | const effectFunction: Effect = async (...args: Args) => { 25 | return handler(...args); 26 | }; 27 | 28 | effectFunction[EFFECT_SYMBOL] = "effect"; 29 | 30 | return effectFunction; 31 | } 32 | -------------------------------------------------------------------------------- /superbridge/main/index.ts: -------------------------------------------------------------------------------- 1 | import "./init"; 2 | 3 | export { initializeSuperbridgeMain } from "./initializeBridge"; 4 | export { query, getIsQuery } from "./query"; 5 | export { effect, getIsEffect } from "./effect"; 6 | export { mutation, getIsMutation } from "./mutation"; 7 | export { sharedValue, getIsSharedValue } from "./sharedValue"; 8 | export { 9 | type Router, 10 | type RouterInput, 11 | type RouterHandlersMap, 12 | createRouter, 13 | } from "./BridgeHandler"; 14 | -------------------------------------------------------------------------------- /superbridge/main/init.ts: -------------------------------------------------------------------------------- 1 | import "../shared/init"; 2 | import "../shared/superbridge"; 3 | 4 | import { IpcMainInvokeEvent, ipcMain, webContents } from "electron"; 5 | import { 6 | PromiseController, 7 | createControlledPromise, 8 | } from "../utils/controlledPromise"; 9 | 10 | import { BridgeMessageType } from "../shared/defineMessage"; 11 | import { HandleResult } from "../shared/messages"; 12 | import { RawBridgeData } from "../shared/types"; 13 | import { bridgeSerializer } from "../shared/serializer"; 14 | import { createLogger } from "../shared/log"; 15 | import { generateId } from "../utils/id"; 16 | import { getIPCChannelName } from "../shared/channel"; 17 | import { initializeSuperbridge } from "../shared/superbridge"; 18 | 19 | const log = createLogger("superbridge/main/init"); 20 | 21 | const pendingRequests = new Map>(); 22 | 23 | ipcMain.handle( 24 | getIPCChannelName("HANDLE_RESULT"), 25 | (_event, payload: RawBridgeData) => { 26 | const result = bridgeSerializer.deserialize>( 27 | payload.payload 28 | ); 29 | 30 | const pendingRequestController = pendingRequests.get(result.requestId); 31 | 32 | if (!pendingRequestController) { 33 | throw new Error(`No controller found for requestId: ${result.requestId}`); 34 | } 35 | 36 | pendingRequests.delete(result.requestId); 37 | 38 | if (result.type === "success") { 39 | pendingRequestController.resolve(result.result); 40 | } else { 41 | pendingRequestController.reject(result.error); 42 | } 43 | } 44 | ); 45 | 46 | initializeSuperbridge({ 47 | send(message: BridgeMessageType, payload: I, webId?: number) { 48 | if (webId === undefined) { 49 | throw new Error("webId is required"); 50 | } 51 | 52 | const requestId = generateId(); 53 | 54 | const targetWebContents = webContents.fromId(webId); 55 | 56 | if (!targetWebContents) { 57 | throw new Error(`Target webContents not found for id: ${webId}`); 58 | } 59 | 60 | log.debug(`Send "${message.type}" with payload`, payload); 61 | 62 | const [promise, controller] = createControlledPromise(); 63 | 64 | pendingRequests.set(requestId, controller); 65 | 66 | targetWebContents.send(getIPCChannelName(message.type), { 67 | requestId, 68 | payload: bridgeSerializer.serialize(payload), 69 | } as RawBridgeData); 70 | 71 | return promise; 72 | }, 73 | handle( 74 | message: BridgeMessageType, 75 | handler: (payload: I) => Promise 76 | ) { 77 | async function handleMessage( 78 | _event: IpcMainInvokeEvent, 79 | payload: RawBridgeData 80 | ) { 81 | log.debug(`Handling "${message.type}" with payload`, payload); 82 | 83 | const result = await handler( 84 | bridgeSerializer.deserialize(payload.payload) 85 | ); 86 | 87 | return bridgeSerializer.serialize(result); 88 | } 89 | 90 | ipcMain.handle(getIPCChannelName(message.type), handleMessage); 91 | 92 | return () => { 93 | ipcMain.removeHandler(getIPCChannelName(message.type)); 94 | }; 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /superbridge/main/initializeBridge.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $execute, 3 | $getSharedValue, 4 | $reset, 5 | $setSharedValue, 6 | $watchSharedValue, 7 | } from "../shared/messages"; 8 | import { Router, RouterInput } from "./BridgeHandler"; 9 | 10 | import SuperJSON from "superjson"; 11 | import { bridge } from "../shared/superbridge"; 12 | import { createLogger } from "../shared/log"; 13 | 14 | const log = createLogger("superbridge/main/init"); 15 | 16 | export function initializeSuperbridgeMain( 17 | handler: Router 18 | ) { 19 | log.debug("Initialize Superbridge Main"); 20 | 21 | process.env.SUPERBRIDGE_SCHEMA = SuperJSON.stringify(handler.schema); 22 | 23 | bridge.handle($execute, async (payload) => { 24 | log.debug(`Handling execute "${payload.path}" with args`, payload.args); 25 | return handler.execute(payload.path, payload.args); 26 | }); 27 | 28 | bridge.handle($reset, async () => { 29 | log.debug("Handling reset"); 30 | await handler.reset(); 31 | }); 32 | 33 | bridge.handle($getSharedValue, async (payload) => { 34 | log.debug(`Handling getSharedValue "${payload.path}"`); 35 | return handler.getSharedValue(payload.path); 36 | }); 37 | 38 | bridge.handle($setSharedValue, async (payload) => { 39 | log.debug( 40 | `Handling setSharedValue "${payload.path}" with value`, 41 | payload.value 42 | ); 43 | await handler.setSharedValue(payload.path, payload.value); 44 | }); 45 | 46 | bridge.handle($watchSharedValue, async ({ path, callback }) => { 47 | log.debug(`Handling watchSharedValue "${path}" with callback`, callback); 48 | return handler.watchSharedValue(path, callback); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /superbridge/main/mutation.ts: -------------------------------------------------------------------------------- 1 | const MUTATION_SYMBOL = Symbol("mutation"); 2 | 3 | type AnyFunction = (...args: any[]) => any; 4 | export interface Mutation { 5 | (...args: Parameters): Promise>; 6 | [MUTATION_SYMBOL]: "mutation"; 7 | } 8 | 9 | export function getIsMutation( 10 | value: unknown 11 | ): value is Mutation { 12 | return ( 13 | typeof value === "function" && 14 | MUTATION_SYMBOL in value && 15 | value[MUTATION_SYMBOL] === "mutation" 16 | ); 17 | } 18 | 19 | export function mutation(handler: F): Mutation { 20 | const mutationFunction: Mutation = async (...args: Parameters) => { 21 | return handler(...args) as Promise>; 22 | }; 23 | 24 | mutationFunction[MUTATION_SYMBOL] = "mutation"; 25 | 26 | return mutationFunction; 27 | } 28 | -------------------------------------------------------------------------------- /superbridge/main/query.ts: -------------------------------------------------------------------------------- 1 | const QUERY_SYMBOL = Symbol("query"); 2 | 3 | type AnyFunction = (...args: any[]) => any; 4 | export interface Query { 5 | (...args: Parameters): Promise>; 6 | [QUERY_SYMBOL]: "query"; 7 | } 8 | 9 | export function getIsQuery( 10 | value: unknown 11 | ): value is Query { 12 | return ( 13 | typeof value === "function" && 14 | QUERY_SYMBOL in value && 15 | value[QUERY_SYMBOL] === "query" 16 | ); 17 | } 18 | 19 | export function query(handler: F): Query { 20 | const queryFunction: Query = async (...args: Parameters) => { 21 | return handler(...args) as Promise>; 22 | }; 23 | 24 | queryFunction[QUERY_SYMBOL] = "query"; 25 | 26 | return queryFunction; 27 | } 28 | -------------------------------------------------------------------------------- /superbridge/main/schema.ts: -------------------------------------------------------------------------------- 1 | import { RouterInput } from "./BridgeHandler"; 2 | import { createNestedRecordPropertiesMap } from "../utils/nestedRecord"; 3 | import { getIsEffect } from "./effect"; 4 | import { getIsMutation } from "./mutation"; 5 | import { getIsQuery } from "./query"; 6 | import { getIsSharedValue } from "./sharedValue"; 7 | 8 | export type BridgeFieldSchema = 9 | | { 10 | type: "query" | "mutation" | "effect"; 11 | } 12 | | { 13 | type: "sharedValue"; 14 | initialValue: any; 15 | }; 16 | 17 | export type BridgeHandlerSchema = Record; 18 | 19 | export function getBridgeHandlerSchema(input: RouterInput) { 20 | const map = createNestedRecordPropertiesMap(input); 21 | 22 | const schema: BridgeHandlerSchema = {}; 23 | 24 | for (const [key, value] of map.entries()) { 25 | console.log("key", key, value); 26 | if (getIsSharedValue(value)) { 27 | schema[key] = { 28 | type: "sharedValue", 29 | initialValue: value.initialValue, 30 | }; 31 | continue; 32 | } 33 | 34 | if (getIsQuery(value)) { 35 | schema[key] = { 36 | type: "query", 37 | }; 38 | continue; 39 | } 40 | 41 | if (getIsMutation(value)) { 42 | schema[key] = { 43 | type: "mutation", 44 | }; 45 | continue; 46 | } 47 | 48 | if (getIsEffect(value)) { 49 | schema[key] = { 50 | type: "effect", 51 | }; 52 | continue; 53 | } 54 | 55 | if (typeof value === "function") { 56 | schema[key] = { 57 | type: "query", 58 | }; 59 | continue; 60 | } 61 | 62 | console.warn(`Unknown field type: ${key}`, value); 63 | } 64 | 65 | return schema; 66 | } 67 | -------------------------------------------------------------------------------- /superbridge/main/sharedValue.ts: -------------------------------------------------------------------------------- 1 | import { ValueUpdater, updateValue } from "../utils/valueUpdater"; 2 | 3 | import { Signal } from "../utils/Signal"; 4 | 5 | export class SharedValue { 6 | private value: T; 7 | private updates: Signal = new Signal(); 8 | 9 | constructor(readonly initialValue: T) { 10 | this.value = initialValue; 11 | } 12 | 13 | getValue() { 14 | return this.value; 15 | } 16 | 17 | setValue(value: T) { 18 | this.value = value; 19 | this.updates.emit(value); 20 | } 21 | 22 | updateValue(updater: ValueUpdater) { 23 | this.setValue(updateValue(this.value, updater)); 24 | } 25 | 26 | watch(callback: (value: T) => void) { 27 | return this.updates.subscribe(callback); 28 | } 29 | } 30 | 31 | export function getIsSharedValue(value: unknown): value is SharedValue { 32 | if (!value || typeof value !== "object") { 33 | return false; 34 | } 35 | 36 | return value instanceof SharedValue; 37 | } 38 | 39 | export function sharedValue(initialValue: T): SharedValue { 40 | return new SharedValue(initialValue); 41 | } 42 | -------------------------------------------------------------------------------- /superbridge/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superbridge", 3 | "version": "1.0.5", 4 | "description": "A powerful, type-safe, and easy-to-use Electron bridge with support for sending callback functions over the bridge", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://superbridge.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/pie6k/superbridge.git" 11 | }, 12 | "keywords": [ 13 | "electron", 14 | "ipc", 15 | "bridge", 16 | "typescript", 17 | "type-safe", 18 | "callback", 19 | "communication" 20 | ], 21 | "exports": { 22 | "./preload": { 23 | "types": "./dist/preload/index.d.ts", 24 | "import": "./dist/preload/index.es.js", 25 | "require": "./dist/preload/index.cjs.js" 26 | }, 27 | "./main": { 28 | "types": "./dist/main/index.d.ts", 29 | "import": "./dist/main/index.es.js", 30 | "require": "./dist/main/index.cjs.js" 31 | }, 32 | "./client": { 33 | "types": "./dist/client/index.d.ts", 34 | "import": "./dist/client/index.es.js", 35 | "require": "./dist/client/index.cjs.js" 36 | }, 37 | ".": { 38 | "types": "./dist/shared/index.d.ts", 39 | "import": "./dist/shared/index.es.js", 40 | "require": "./dist/shared/index.cjs.js" 41 | } 42 | }, 43 | "main": "./dist/shared/index.cjs", 44 | "module": "./dist/shared/index.js", 45 | "types": "./dist/shared/index.d.ts", 46 | "files": [ 47 | "dist" 48 | ], 49 | "scripts": { 50 | "build": "vite build", 51 | "dev": "vite build --watch", 52 | "typecheck": "tsc --noEmit", 53 | "ts:types": "tsc --watch --project tsconfig.json" 54 | }, 55 | "dependencies": { 56 | "superjson": "^2.2.2" 57 | }, 58 | "peerDependencies": { 59 | "electron": "*" 60 | }, 61 | "devDependencies": { 62 | "execa": "^8.0.1", 63 | "ts-node-dev": "^2.0.0", 64 | "typescript": "^5.0.0", 65 | "vite": "^5.0.0", 66 | "vite-plugin-dts": "^3.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /superbridge/preload/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | initializeSuperbridgePreload, 3 | type SuperBridgeInterface, 4 | } from "./init"; 5 | -------------------------------------------------------------------------------- /superbridge/preload/init.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IpcRendererEvent, 3 | contextBridge, 4 | ipcRenderer, 5 | webFrame, 6 | } from "electron"; 7 | 8 | import { type BridgeHandlerSchema } from "../main/schema"; 9 | import { getIPCChannelName } from "../shared/channel"; 10 | import { createLogger } from "../shared/log"; 11 | import { RawBridgeData } from "../shared/types"; 12 | import SuperJSON from "superjson"; 13 | 14 | const log = createLogger("superbridge/preload"); 15 | 16 | /** 17 | * ! deserialization CANNOT happen here! 18 | * 19 | * This is because there is no way to share memory between preload and renderer (tried hard) 20 | * 21 | */ 22 | 23 | if (!process.env.SUPERBRIDGE_SCHEMA) { 24 | throw new Error( 25 | "Superbridge is not initialized. Make sure to call initializeSuperbridgeMain() in your main process before creating BrowserWindow." 26 | ); 27 | } 28 | 29 | const schema = SuperJSON.parse( 30 | process.env.SUPERBRIDGE_SCHEMA 31 | ) as BridgeHandlerSchema; 32 | 33 | function createSuperbridgeInterface() { 34 | return { 35 | send: async (type: string, payload: RawBridgeData) => { 36 | if (!type) throw new Error("Type is required"); 37 | 38 | log.debug(`Sending "${type}" with payload`, payload); 39 | 40 | return ipcRenderer.invoke(getIPCChannelName(type), payload); 41 | }, 42 | handle: ( 43 | type: string, 44 | handler: (payload: RawBridgeData, event: IpcRendererEvent) => void 45 | ) => { 46 | if (!type) throw new Error("Type is required"); 47 | 48 | function handleMessage( 49 | _event: Electron.IpcRendererEvent, 50 | payload: RawBridgeData 51 | ) { 52 | log.debug(`Handling "${type}" with payload`, payload); 53 | handler(payload, _event); 54 | } 55 | 56 | ipcRenderer.on(getIPCChannelName(type), handleMessage); 57 | 58 | return () => { 59 | ipcRenderer.off(getIPCChannelName(type), handleMessage); 60 | }; 61 | }, 62 | get schema() { 63 | return schema; 64 | }, 65 | get routingId() { 66 | return webFrame.routingId; 67 | }, 68 | }; 69 | } 70 | 71 | export type SuperBridgeInterface = ReturnType< 72 | typeof createSuperbridgeInterface 73 | >; 74 | 75 | export function initializeSuperbridgePreload() { 76 | contextBridge.exposeInMainWorld( 77 | "$superbridgeinterface", 78 | createSuperbridgeInterface() 79 | ); 80 | } 81 | 82 | declare global { 83 | interface Window { 84 | $superbridgeinterface: SuperBridgeInterface; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /superbridge/shared/channel.ts: -------------------------------------------------------------------------------- 1 | export function getIPCChannelName(name: string) { 2 | return `SUPERBRIDGE__${name}`; 3 | } 4 | -------------------------------------------------------------------------------- /superbridge/shared/defineMessage.ts: -------------------------------------------------------------------------------- 1 | export class BridgeMessageType { 2 | constructor(public readonly type: string) {} 3 | 4 | input!: I; 5 | output!: O; 6 | } 7 | 8 | export function defineBridgeMessage( 9 | name: string 10 | ): BridgeMessageType { 11 | return new BridgeMessageType(name); 12 | } 13 | -------------------------------------------------------------------------------- /superbridge/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { defineBridgeMessage } from "./defineMessage"; 2 | export { bridge } from "./superbridge"; 3 | -------------------------------------------------------------------------------- /superbridge/shared/init.ts: -------------------------------------------------------------------------------- 1 | import "./serializer"; 2 | -------------------------------------------------------------------------------- /superbridge/shared/log.ts: -------------------------------------------------------------------------------- 1 | type Log = (...args: any[]) => void; 2 | 3 | enum LogLevel { 4 | Debug = 0, 5 | Info = 1, 6 | Warn = 2, 7 | Error = 3, 8 | } 9 | 10 | const IS_ENABLED = false; 11 | 12 | const LOG_LEVEL = LogLevel.Debug; 13 | 14 | interface Logger { 15 | (...args: any[]): void; 16 | debug: Log; 17 | warn: Log; 18 | error: Log; 19 | rename: (name: string) => Logger; 20 | } 21 | 22 | function getShouldLog(level: LogLevel) { 23 | if (!IS_ENABLED) return false; 24 | 25 | return level >= LOG_LEVEL; 26 | } 27 | 28 | export function createLogger(name: string): Logger { 29 | const LOG_COLOR = "#808080"; 30 | const LOG_STYLE = `color: ${LOG_COLOR};`; 31 | 32 | const LABEL = `%c🌉 ${name}:%c`; 33 | 34 | const log: Logger = (...args) => { 35 | if (!getShouldLog(LogLevel.Info)) { 36 | return; 37 | } 38 | 39 | console.info(LABEL, LOG_STYLE, "", ...args); 40 | }; 41 | 42 | log.debug = (...args) => { 43 | if (!getShouldLog(LogLevel.Debug)) { 44 | return; 45 | } 46 | 47 | console.debug(LABEL, LOG_STYLE, "", ...args); 48 | }; 49 | 50 | log.warn = (...args) => { 51 | if (!getShouldLog(LogLevel.Warn)) { 52 | return; 53 | } 54 | 55 | console.warn(LABEL, LOG_STYLE, "", ...args); 56 | }; 57 | 58 | log.error = (...args) => { 59 | if (!getShouldLog(LogLevel.Error)) { 60 | return; 61 | } 62 | 63 | console.error(LABEL, LOG_STYLE, "", ...args); 64 | }; 65 | 66 | log.rename = (name: string) => { 67 | return createLogger(name); 68 | }; 69 | 70 | return log; 71 | } 72 | 73 | export const log = createLogger("superbridge"); 74 | -------------------------------------------------------------------------------- /superbridge/shared/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineBridgeMessage } from "./defineMessage"; 2 | 3 | export interface ExecuteMessageData { 4 | id: string; 5 | path: string; 6 | args: unknown[]; 7 | } 8 | 9 | export type HandleResult = 10 | | { 11 | requestId: string; 12 | type: "success"; 13 | result: T; 14 | } 15 | | { 16 | requestId: string; 17 | type: "error"; 18 | error: string; 19 | }; 20 | 21 | export const $execute = defineBridgeMessage( 22 | "$execute" 23 | ); 24 | 25 | export const $setSharedValue = defineBridgeMessage< 26 | { 27 | path: string; 28 | value: unknown; 29 | }, 30 | void 31 | >("$setSharedValue"); 32 | 33 | export const $getSharedValue = defineBridgeMessage< 34 | { 35 | path: string; 36 | }, 37 | unknown 38 | >("$getSharedValue"); 39 | 40 | export const $watchSharedValue = defineBridgeMessage< 41 | { 42 | path: string; 43 | callback: (value: unknown) => void; 44 | }, 45 | () => void 46 | >("$watchSharedValue"); 47 | 48 | export const $reset = defineBridgeMessage("$reset"); 49 | -------------------------------------------------------------------------------- /superbridge/shared/serializer/abortSignal.ts: -------------------------------------------------------------------------------- 1 | import { CustomTransfomer } from "./types"; 2 | import { bridge } from "../superbridge"; 3 | import { defineBridgeMessage } from "../defineMessage"; 4 | import { generateId } from "../../utils/id"; 5 | 6 | const signalRemoteController = new Map(); 7 | 8 | export const $abortRemoteSignal = defineBridgeMessage<{ 9 | signalId: string; 10 | }>("$abortRemoteSignal"); 11 | 12 | export function registerSignal(localSignal: AbortSignal) { 13 | const id = `$$signal-${generateId()}`; 14 | const remoteController = new AbortController(); 15 | 16 | signalRemoteController.set(id, remoteController); 17 | 18 | // If local signal is aborted, abort the remote one 19 | localSignal.addEventListener("abort", () => { 20 | bridge.send($abortRemoteSignal, { signalId: id }); 21 | signalRemoteController.delete(id); 22 | }); 23 | 24 | signalFinalizationRegistry.register(localSignal, id); 25 | 26 | return id; 27 | } 28 | 29 | bridge.handle($abortRemoteSignal, async ({ signalId }) => { 30 | const controller = signalRemoteController.get(signalId); 31 | 32 | if (!controller) return; 33 | 34 | controller.abort(); 35 | signalRemoteController.delete(signalId); 36 | }); 37 | 38 | const signalFinalizationRegistry = new FinalizationRegistry( 39 | (remoteSignalId) => { 40 | const controller = signalRemoteController.get(remoteSignalId); 41 | 42 | if (!controller) return; 43 | 44 | controller.abort(); 45 | signalRemoteController.delete(remoteSignalId); 46 | } 47 | ); 48 | 49 | function deserializeSignalId(signalId: string): AbortSignal { 50 | const controller = new AbortController(); 51 | 52 | signalRemoteController.set(signalId, controller); 53 | 54 | return controller.signal; 55 | } 56 | 57 | export const abortSignalSerializer: CustomTransfomer = { 58 | isApplicable: (value): value is AbortSignal => value instanceof AbortSignal, 59 | serialize: (signal: AbortSignal) => registerSignal(signal), 60 | deserialize: (signalId: string) => deserializeSignalId(signalId), 61 | }; 62 | -------------------------------------------------------------------------------- /superbridge/shared/serializer/callbacks.ts: -------------------------------------------------------------------------------- 1 | import { CustomTransfomer } from "./types"; 2 | import { bridge } from "../superbridge"; 3 | import { createLogger } from "../log"; 4 | import { defineBridgeMessage } from "../defineMessage"; 5 | import { generateId } from "../../utils/id"; 6 | 7 | const log = createLogger("superbridge/callbacks"); 8 | 9 | const callbacks = new Map(); 10 | 11 | export const $removeRemoteCallback = defineBridgeMessage<{ 12 | callbackId: string; 13 | }>("$removeRemoteCallback"); 14 | 15 | export const $triggerRemoteCallback = defineBridgeMessage< 16 | { 17 | callbackId: string; 18 | args: unknown[]; 19 | }, 20 | unknown 21 | >("$triggerRemoteCallback"); 22 | 23 | bridge.handle($removeRemoteCallback, async ({ callbackId }) => { 24 | log.debug(`Handling remove remote callback "${callbackId}"`); 25 | callbacks.delete(callbackId); 26 | }); 27 | 28 | bridge.handle($triggerRemoteCallback, async ({ callbackId, args }) => { 29 | log.debug( 30 | `Handling trigger remote callback "${callbackId}" with callId`, 31 | args 32 | ); 33 | const callback = callbacks.get(callbackId); 34 | 35 | if (!callback) { 36 | throw new Error(`Callback "${callbackId}" not found`); 37 | } 38 | 39 | return await callback(...args); 40 | }); 41 | 42 | function getCallbackId() { 43 | let id = `$$callback-${generateId()}`; 44 | 45 | if (typeof window !== "undefined") { 46 | id = `${id}-${window.$superbridgeinterface.routingId}`; 47 | } 48 | 49 | return id; 50 | } 51 | 52 | /** 53 | * $$callback-123-456 54 | */ 55 | function getCallbackRoutingId(callbackId: string) { 56 | const [_callbackLabel, _callbackId, routingId] = callbackId.split("-"); 57 | 58 | if (!routingId) return null; 59 | 60 | return parseInt(routingId, 10); 61 | } 62 | 63 | export function registerCallback(callback: Function) { 64 | const id = getCallbackId(); 65 | 66 | callbacks.set(id, callback); 67 | 68 | return id; 69 | } 70 | 71 | const callbackFinalizationRegistry = new FinalizationRegistry( 72 | (remoteCallbackId) => { 73 | bridge.send( 74 | $removeRemoteCallback, 75 | { callbackId: remoteCallbackId }, 76 | getCallbackRoutingId(remoteCallbackId) ?? undefined 77 | ); 78 | } 79 | ); 80 | 81 | function deserializeCallbackId(callbackId: string) { 82 | async function remoteCallbackInvoker(...args: unknown[]) { 83 | log.debug(`Invoking remote callback "${callbackId}" with args`, args); 84 | 85 | return await bridge.send( 86 | $triggerRemoteCallback, 87 | { 88 | callbackId: callbackId, 89 | args, 90 | }, 91 | getCallbackRoutingId(callbackId) ?? undefined 92 | ); 93 | } 94 | 95 | callbackFinalizationRegistry.register(remoteCallbackInvoker, callbackId); 96 | 97 | return remoteCallbackInvoker; 98 | } 99 | 100 | export const callbackSerializer: CustomTransfomer = { 101 | isApplicable: (value): value is Function => typeof value === "function", 102 | serialize: (callback: Function) => registerCallback(callback), 103 | deserialize: deserializeCallbackId, 104 | }; 105 | -------------------------------------------------------------------------------- /superbridge/shared/serializer/index.ts: -------------------------------------------------------------------------------- 1 | import SuperJSON from "superjson"; 2 | import { abortSignalSerializer } from "./abortSignal"; 3 | import { callbackSerializer } from "./callbacks"; 4 | 5 | export const bridgeSerializer = new SuperJSON(); 6 | 7 | bridgeSerializer.registerCustom(callbackSerializer, "superbridge-callback"); 8 | 9 | bridgeSerializer.registerCustom( 10 | abortSignalSerializer, 11 | "superbridge-abortSignal" 12 | ); 13 | -------------------------------------------------------------------------------- /superbridge/shared/serializer/types.ts: -------------------------------------------------------------------------------- 1 | export interface CustomTransfomer { 2 | isApplicable: (v: any) => v is I; 3 | serialize: (v: I) => O; 4 | deserialize: (v: O) => I; 5 | } 6 | -------------------------------------------------------------------------------- /superbridge/shared/superbridge.ts: -------------------------------------------------------------------------------- 1 | import { BridgeMessageType } from "./defineMessage"; 2 | import { Signal } from "../utils/Signal"; 3 | 4 | type Cancel = () => void; 5 | 6 | export interface SuperbridgeLink { 7 | send( 8 | message: BridgeMessageType, 9 | payload: I, 10 | webId?: number 11 | ): Promise; 12 | 13 | handle( 14 | message: BridgeMessageType, 15 | handler: (payload: I) => Promise 16 | ): Cancel; 17 | } 18 | 19 | const currentSuperbridgeChannel = new Signal(); 20 | 21 | export function initializeSuperbridge(superbridge: SuperbridgeLink) { 22 | currentSuperbridgeChannel.emit(superbridge); 23 | } 24 | 25 | export const bridge: SuperbridgeLink = { 26 | send(message: BridgeMessageType, payload: I, webId?: number) { 27 | const link = currentSuperbridgeChannel.assertLastValue( 28 | "Superbridge is not initialized" 29 | ); 30 | 31 | return link.send(message, payload, webId); 32 | }, 33 | handle( 34 | message: BridgeMessageType, 35 | handler: (payload: I) => Promise 36 | ) { 37 | if (!currentSuperbridgeChannel.hasLastValue) { 38 | Promise.resolve().then(() => { 39 | if (!currentSuperbridgeChannel.hasLastValue) { 40 | console.warn("Superbridge is not initialized"); 41 | } 42 | }); 43 | } 44 | return currentSuperbridgeChannel.effect((currentBridge) => { 45 | return currentBridge.handle(message, handler); 46 | }); 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /superbridge/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { SuperJSONResult } from "superjson"; 2 | 3 | export type MaybePromise = T | Promise; 4 | 5 | export interface RawBridgeData { 6 | requestId: string; 7 | payload: SuperJSONResult; 8 | } 9 | -------------------------------------------------------------------------------- /superbridge/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": false, 13 | "declaration": true, 14 | "emitDeclarationOnly": true, 15 | "outDir": "./dist", 16 | "jsx": "preserve" 17 | }, 18 | "include": ["**/*.ts"], 19 | "exclude": ["node_modules", "dist", "vite"] 20 | } 21 | -------------------------------------------------------------------------------- /superbridge/tsconfig.scripts.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "module": "CommonJS", 13 | "moduleResolution": "node", 14 | "downlevelIteration": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "react-jsx", 18 | "tsBuildInfoFile": "./buildinfo.tsbuild", 19 | "skipDefaultLibCheck": true, 20 | "incremental": true, 21 | "importHelpers": true, 22 | "typeRoots": ["./types", "./node_modules/@types"], 23 | "assumeChangesOnlyAffectDirectDependencies": true, 24 | "checkJs": false, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@s/*": ["./*"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /superbridge/utils/Signal.ts: -------------------------------------------------------------------------------- 1 | type Listener = (value: T) => void; 2 | 3 | type Cleanup = () => void; 4 | 5 | const NO_VALUE = Symbol("NO_VALUE"); 6 | 7 | export class Signal { 8 | private listeners = new Map>(); 9 | lastValue: T | typeof NO_VALUE = NO_VALUE; 10 | 11 | assertLastValue(error: Error | string) { 12 | if (this.lastValue === NO_VALUE) { 13 | throw typeof error === "string" ? new Error(error) : error; 14 | } 15 | 16 | return this.lastValue; 17 | } 18 | 19 | get hasLastValue() { 20 | return this.lastValue !== NO_VALUE; 21 | } 22 | 23 | get maybeLastValue() { 24 | return this.lastValue === NO_VALUE ? undefined : this.lastValue; 25 | } 26 | 27 | emit(value: T) { 28 | this.lastValue = value; 29 | 30 | const listeners = [...this.listeners.values()]; 31 | 32 | for (const listener of listeners) { 33 | try { 34 | listener(value); 35 | } catch (error) { 36 | console.error(error); 37 | } 38 | } 39 | } 40 | 41 | subscribe(listener: Listener) { 42 | const id = Symbol(); 43 | 44 | this.listeners.set(id, listener); 45 | 46 | return () => { 47 | this.listeners.delete(id); 48 | }; 49 | } 50 | 51 | subscribeWithCurrentValue(listener: Listener) { 52 | if (this.lastValue !== NO_VALUE) { 53 | listener(this.lastValue); 54 | } 55 | 56 | return this.subscribe(listener); 57 | } 58 | 59 | effect(initializer: (value: T) => Cleanup) { 60 | let currentCleanup: Cleanup | undefined; 61 | 62 | const cancelSubscription = this.subscribeWithCurrentValue((value) => { 63 | if (currentCleanup) { 64 | currentCleanup(); 65 | } 66 | 67 | currentCleanup = initializer(value); 68 | }); 69 | 70 | return () => { 71 | cancelSubscription(); 72 | 73 | if (currentCleanup) { 74 | currentCleanup(); 75 | } 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /superbridge/utils/cleanup.ts: -------------------------------------------------------------------------------- 1 | type SingleCleanup = () => void; 2 | 3 | class CleanupHolder { 4 | private cleanups = new Set(); 5 | 6 | add(cleanup: SingleCleanup) { 7 | this.cleanups.add(cleanup); 8 | 9 | return () => { 10 | this.cleanups.delete(cleanup); 11 | }; 12 | } 13 | 14 | remove(cleanup: SingleCleanup) { 15 | this.cleanups.delete(cleanup); 16 | } 17 | 18 | set next(cleanup: SingleCleanup) { 19 | this.add(cleanup); 20 | } 21 | 22 | clean() { 23 | const cleanups = [...this.cleanups]; 24 | 25 | this.cleanups.clear(); 26 | 27 | for (const cleanup of cleanups) { 28 | cleanup(); 29 | } 30 | } 31 | } 32 | 33 | export interface Cleanup extends CleanupHolder { 34 | (): void; 35 | holder: CleanupHolder; 36 | } 37 | 38 | const cleanupProxyHandler: ProxyHandler = { 39 | get(target, prop, receiver) { 40 | const holder = target.holder; 41 | 42 | return Reflect.get(holder, prop, holder); 43 | }, 44 | set(target, prop, value, receiver) { 45 | const holder = target.holder; 46 | 47 | return Reflect.set(holder, prop, value, holder); 48 | }, 49 | apply(target, thisArg, argArray) { 50 | const holder = target.holder; 51 | 52 | return holder.clean(); 53 | }, 54 | }; 55 | 56 | export function createCleanup() { 57 | const holder = new CleanupHolder(); 58 | 59 | const clean: Cleanup = (() => {}) as Cleanup; 60 | clean.holder = holder; 61 | 62 | return new Proxy(clean, cleanupProxyHandler); 63 | } 64 | -------------------------------------------------------------------------------- /superbridge/utils/controlledPromise.ts: -------------------------------------------------------------------------------- 1 | export interface PromiseController { 2 | resolve: (value: T) => void; 3 | reject: (reason?: any) => void; 4 | } 5 | 6 | export function createControlledPromise() { 7 | let controller: PromiseController | undefined; 8 | 9 | const promise = new Promise((_resolve, _reject) => { 10 | controller = { 11 | resolve: _resolve, 12 | reject: _reject, 13 | }; 14 | }); 15 | 16 | return [promise, controller!] as const; 17 | } 18 | -------------------------------------------------------------------------------- /superbridge/utils/id.ts: -------------------------------------------------------------------------------- 1 | const ALPHABET = 2 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 3 | 4 | export function generateId(length: number = 12) { 5 | let id = ""; 6 | 7 | for (let i = 0; i < length; i++) { 8 | id += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; 9 | } 10 | 11 | return id; 12 | } 13 | -------------------------------------------------------------------------------- /superbridge/utils/moduleCleanup.ts: -------------------------------------------------------------------------------- 1 | type ModuleCleanup = Record; 2 | 3 | declare global { 4 | var $$moduleCleanup: ModuleCleanup; 5 | } 6 | 7 | globalThis.$$moduleCleanup = {}; 8 | 9 | export const $moduleCleanup = new Proxy({} as ModuleCleanup, { 10 | set(target, prop: string, value) { 11 | const existingCleanup = globalThis.$$moduleCleanup[prop]; 12 | 13 | if (existingCleanup && typeof existingCleanup === "function") { 14 | existingCleanup(); 15 | } 16 | 17 | globalThis.$$moduleCleanup[prop] = value; 18 | 19 | return true; 20 | }, 21 | }); 22 | 23 | if (typeof window !== "undefined") { 24 | window.addEventListener("beforeunload", () => { 25 | for (const cleanup of Object.values(globalThis.$$moduleCleanup)) { 26 | cleanup(); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /superbridge/utils/nestedRecord.ts: -------------------------------------------------------------------------------- 1 | type PropertiesMapValue = unknown | NestedRecord; 2 | 3 | type NestedRecord = { 4 | [key: string]: PropertiesMapValue; 5 | }; 6 | 7 | export type PropertiesMap = Map; 8 | 9 | /** 10 | * Returns true only for plain, {} objects (not instances of classes, arrays, etc.) 11 | */ 12 | function getIsPlainObject(value: unknown): value is Record { 13 | return value?.constructor === Object; 14 | } 15 | 16 | function getPath(currentPath: string, key: string) { 17 | if (!currentPath) return key; 18 | 19 | return `${currentPath}.${key}`; 20 | } 21 | 22 | function buildPropertiesMap( 23 | currentPath: string, 24 | result: PropertiesMap, 25 | input: NestedRecord 26 | ) { 27 | for (const [key, value] of Object.entries(input)) { 28 | const path = getPath(currentPath, key); 29 | 30 | if (getIsPlainObject(value)) { 31 | buildPropertiesMap(path, result, value); 32 | } else { 33 | result.set(path, value as LeafType); 34 | } 35 | } 36 | } 37 | 38 | export function createNestedRecordPropertiesMap( 39 | input: NestedRecord 40 | ): PropertiesMap { 41 | const map = new Map(); 42 | 43 | buildPropertiesMap("", map, input); 44 | 45 | return map; 46 | } 47 | 48 | function innerMapNestedRecord( 49 | currentPath: string, 50 | input: NestedRecord, 51 | mapper: (value: unknown, path: string) => unknown 52 | ): NestedRecord { 53 | const result: NestedRecord = {}; 54 | 55 | for (const [key, value] of Object.entries(input)) { 56 | const path = getPath(currentPath, key); 57 | 58 | if (getIsPlainObject(value)) { 59 | result[key] = innerMapNestedRecord(path, value, mapper); 60 | } else { 61 | result[key] = mapper(value, path); 62 | } 63 | } 64 | 65 | return result; 66 | } 67 | 68 | export function mapNestedRecord( 69 | input: NestedRecord, 70 | mapper: (value: unknown, path: string) => unknown 71 | ): NestedRecord { 72 | return innerMapNestedRecord("", input, mapper); 73 | } 74 | 75 | export function unwrapNestedRecord( 76 | pathMap: Map | Record 77 | ): Record { 78 | const result: Record = {}; 79 | 80 | // Convert to array of entries if input is a Map 81 | const entries = 82 | pathMap instanceof Map 83 | ? Array.from(pathMap.entries()) 84 | : Object.entries(pathMap); 85 | 86 | for (const [path, value] of entries) { 87 | // Skip empty paths 88 | if (!path) continue; 89 | 90 | const keys = path.split("."); 91 | let current = result; 92 | 93 | // Navigate to the correct nesting level 94 | for (let i = 0; i < keys.length - 1; i++) { 95 | const key = keys[i]; 96 | 97 | // Create nested object if it doesn't exist 98 | if (!(key in current)) { 99 | current[key] = {}; 100 | } 101 | 102 | // If the current value isn't an object, it will be overwritten 103 | if (typeof current[key] !== "object" || current[key] === null) { 104 | current[key] = {}; 105 | } 106 | 107 | // Move to the next level 108 | current = current[key] as Record; 109 | } 110 | 111 | // Set the value at the final key 112 | const lastKey = keys[keys.length - 1]; 113 | current[lastKey] = value; 114 | } 115 | 116 | return result; 117 | } 118 | -------------------------------------------------------------------------------- /superbridge/utils/valueUpdater.ts: -------------------------------------------------------------------------------- 1 | export type ValueUpdater = Partial | ((current: T) => Partial); 2 | 3 | export function updateValue(value: T, updater: ValueUpdater): T { 4 | if (typeof updater === "function") { 5 | return { ...value, ...updater(value) }; 6 | } 7 | 8 | return { ...value, ...updater }; 9 | } 10 | -------------------------------------------------------------------------------- /superbridge/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import dts from "vite-plugin-dts"; 3 | import { resolve } from "path"; 4 | 5 | export default defineConfig({ 6 | build: { 7 | minify: false, 8 | lib: { 9 | entry: { 10 | preload: resolve(__dirname, "preload/index.ts"), 11 | main: resolve(__dirname, "main/index.ts"), 12 | client: resolve(__dirname, "client/index.ts"), 13 | shared: resolve(__dirname, "shared/index.ts"), 14 | }, 15 | formats: ["es", "cjs"], 16 | fileName: (format, entryName) => { 17 | const formatExt = format === "es" ? "js" : "cjs"; 18 | return `${entryName}/index.${formatExt}`; 19 | }, 20 | }, 21 | rollupOptions: { 22 | external: ["electron", "superjson"], 23 | output: { 24 | preserveModules: false, 25 | entryFileNames: "[name]/index.[format].js", 26 | chunkFileNames: (chunkInfo) => { 27 | const name = chunkInfo.name; 28 | return `utils/${name}.[format].js`; 29 | }, 30 | assetFileNames: "[name].[ext]", 31 | }, 32 | }, 33 | }, 34 | plugins: [ 35 | dts({ 36 | include: ["preload", "main", "client", "shared"], 37 | exclude: ["**/*.test.ts", "**/*.spec.ts"], 38 | rollupTypes: false, 39 | outDir: "dist", 40 | }), 41 | ], 42 | }); 43 | -------------------------------------------------------------------------------- /testapp/.gitignore: -------------------------------------------------------------------------------- 1 | dist-electron 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /testapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Electron + Vite + React 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /testapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testapp", 3 | "private": true, 4 | "version": "0.0.1", 5 | "main": "dist-electron/main.js", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "electron:dev": "vite --mode development", 12 | "electron:build": "vite build && electron-builder" 13 | }, 14 | "dependencies": { 15 | "electron": "^28.1.0", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "superbridge": "workspace:*" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20.11.0", 22 | "@types/react": "^18.2.43", 23 | "@types/react-dom": "^18.2.17", 24 | "@typescript-eslint/eslint-plugin": "^6.19.0", 25 | "@typescript-eslint/parser": "^6.19.0", 26 | "@vitejs/plugin-react": "^4.2.1", 27 | "electron-builder": "^24.9.1", 28 | "eslint": "^8.56.0", 29 | "eslint-plugin-react-hooks": "^4.6.0", 30 | "eslint-plugin-react-refresh": "^0.4.5", 31 | "typescript": "^5.2.2", 32 | "vite": "^5.0.12", 33 | "vite-plugin-electron": "^0.28.2", 34 | "vite-plugin-electron-renderer": "^0.14.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /testapp/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { type Display } from "electron/renderer"; 4 | import { appClient } from "./bridge/client"; 5 | 6 | console.log("appClient", appClient); 7 | 8 | appClient.foo.get().then((foo) => { 9 | console.log("foo", foo); 10 | }); 11 | 12 | appClient.foo.change("bar").then(() => { 13 | console.log("foo changed"); 14 | }); 15 | 16 | appClient.foo.get().then((foo) => { 17 | console.log("foo", foo); 18 | }); 19 | 20 | function App() { 21 | const [runPings, setRunPings] = useState(false); 22 | const [message, setMessage] = useState(""); 23 | const [displays, setDisplays] = useState([]); 24 | 25 | function togglePings() { 26 | setRunPings((prev) => !prev); 27 | } 28 | 29 | useEffect(() => { 30 | appClient.watchDisplays(setDisplays); 31 | }, []); 32 | 33 | useEffect(() => { 34 | if (!runPings) return; 35 | 36 | return appClient.pings(1, (date, main) => { 37 | console.log("date", date); 38 | main(`CLIENT MAIN ${date.toISOString()}`); 39 | setMessage(`${date.toISOString()}`); 40 | }); 41 | }, [runPings]); 42 | 43 | console.log("displays", displays); 44 | 45 | return ( 46 |
47 |

Superbridge Test App

48 |

Message from main process: {message}

49 | 50 |
51 | {displays.map((display) => ( 52 |
{display.bounds.height}
53 | ))} 54 |
55 |
56 | ); 57 | } 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /testapp/src/bridge/client.ts: -------------------------------------------------------------------------------- 1 | import { type AppBridge } from "./handler"; 2 | import { createSuperbridgeClient } from "superbridge/client"; 3 | import { bridge } from "superbridge"; 4 | import { $getBody } from "./message"; 5 | 6 | export const appClient = createSuperbridgeClient(); 7 | 8 | bridge.handle($getBody, async () => { 9 | return document.body.innerHTML; 10 | }); 11 | 12 | console.log("appClient", appClient); 13 | 14 | appClient.settings.watch(async (value) => { 15 | if (value.theme === "light") { 16 | setTimeout(() => { 17 | appClient.settings.set({ 18 | theme: "dark", 19 | }); 20 | }, 1000); 21 | } 22 | }); 23 | 24 | Reflect.set(window, "client", appClient); 25 | 26 | Reflect.set(window, "testReply", (value: any) => { 27 | appClient.reply(value).then((result) => { 28 | console.log("result", result); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /testapp/src/bridge/handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRouter, 3 | effect, 4 | mutation, 5 | query, 6 | sharedValue, 7 | } from "superbridge/main"; 8 | 9 | import { watchDisplays } from "./watchDisplays"; 10 | 11 | let foo = "foo"; 12 | 13 | const settings = sharedValue({ 14 | theme: "light", 15 | }); 16 | 17 | settings.watch((value) => { 18 | console.log("settings changed (main)", value); 19 | if (value.theme === "dark") { 20 | setTimeout(() => { 21 | settings.setValue({ 22 | theme: "light", 23 | }); 24 | }, 1000); 25 | } 26 | }); 27 | 28 | export const bridgeHandler = createRouter({ 29 | test() { 30 | return "test"; 31 | }, 32 | watchDisplays: effect(watchDisplays), 33 | ping: query(async (date: Date, onProgress?: (progress: number) => void) => { 34 | for (let i = 0; i < 10; i++) { 35 | onProgress?.(i); 36 | await new Promise((resolve) => setTimeout(resolve, 50)); 37 | } 38 | 39 | return `pong ${date.toISOString()}`; 40 | }), 41 | pings: effect( 42 | ( 43 | interval: number, 44 | callback: (date: Date, main: (main: string) => void) => void 45 | ) => { 46 | console.log("setting interval"); 47 | function main(main: string) { 48 | console.log("mainaaaa", main); 49 | } 50 | 51 | const intervalId = setInterval(() => { 52 | callback(new Date(), main); 53 | }, interval); 54 | 55 | return () => { 56 | console.log("clearing interval"); 57 | clearInterval(intervalId); 58 | }; 59 | } 60 | ), 61 | foo: { 62 | change: mutation(async (message: string) => { 63 | await new Promise((resolve) => setTimeout(resolve, 1000)); 64 | foo = message; 65 | }), 66 | get: query(async () => { 67 | return foo; 68 | }), 69 | }, 70 | settings, 71 | reply: query(async (message: T) => { 72 | return message; 73 | }), 74 | }); 75 | 76 | export type AppBridge = typeof bridgeHandler; 77 | -------------------------------------------------------------------------------- /testapp/src/bridge/message.ts: -------------------------------------------------------------------------------- 1 | import { defineBridgeMessage } from "superbridge"; 2 | 3 | export const $getBody = defineBridgeMessage("$getBodyId"); 4 | -------------------------------------------------------------------------------- /testapp/src/bridge/watchDisplays.ts: -------------------------------------------------------------------------------- 1 | import { Display, screen } from "electron"; 2 | 3 | export function watchDisplays(onDisplaysChange: (displays: Display[]) => void) { 4 | function reportDisplays() { 5 | const displays = screen.getAllDisplays(); 6 | onDisplaysChange(displays); 7 | } 8 | 9 | reportDisplays(); 10 | 11 | screen.on("display-added", reportDisplays); 12 | screen.on("display-removed", reportDisplays); 13 | screen.on("display-metrics-changed", reportDisplays); 14 | 15 | return () => { 16 | screen.off("display-added", reportDisplays); 17 | screen.off("display-removed", reportDisplays); 18 | screen.off("display-metrics-changed", reportDisplays); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /testapp/src/electron/main.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, app } from "electron"; 2 | 3 | import { $getBody } from "../bridge/message"; 4 | import { bridge } from "../../../superbridge/shared/superbridge"; 5 | import { bridgeHandler } from "../bridge/handler"; 6 | import { initializeSuperbridgeMain } from "superbridge/main"; 7 | import path from "path"; 8 | 9 | // Enable garbage collection exposure for debugging 10 | app.commandLine.appendSwitch("js-flags", "--expose-gc"); 11 | 12 | // The built directory structure 13 | // 14 | // ├─┬─┬ dist 15 | // │ │ └── index.html 16 | // │ │ 17 | // │ ├─┬ dist-electron 18 | // │ │ ├── main.js 19 | // │ │ └── preload.js 20 | // │ 21 | process.env.DIST = path.join(__dirname, "../.."); 22 | process.env.VITE_PUBLIC = app.isPackaged 23 | ? process.env.DIST 24 | : path.join(process.env.DIST, "../public"); 25 | 26 | process.env.SUPERBRIDGE_DEBUG = "true"; 27 | 28 | let win: BrowserWindow | null = null; 29 | // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x 30 | const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; 31 | 32 | function createWindow() { 33 | initializeSuperbridgeMain(bridgeHandler); 34 | 35 | win = new BrowserWindow({ 36 | width: 1200, 37 | height: 800, 38 | webPreferences: { 39 | nodeIntegration: true, 40 | contextIsolation: true, 41 | preload: path.join(__dirname, "preload.js"), 42 | }, 43 | }); 44 | 45 | // Test active push message to Renderer-process. 46 | win.webContents.once("did-finish-load", async () => { 47 | const body = await bridge.send($getBody, undefined, win?.webContents.id); 48 | console.log("body", body); 49 | }); 50 | 51 | if (VITE_DEV_SERVER_URL) { 52 | win.loadURL(VITE_DEV_SERVER_URL); 53 | } else { 54 | // win.loadFile('dist/index.html') 55 | win.loadFile(path.join(process.env.DIST as string, "index.html")); 56 | } 57 | } 58 | 59 | app.on("window-all-closed", () => { 60 | win = null; 61 | if (process.platform !== "darwin") { 62 | app.quit(); 63 | } 64 | }); 65 | 66 | app.whenReady().then(createWindow); 67 | 68 | app.on("activate", () => { 69 | if (BrowserWindow.getAllWindows().length === 0) { 70 | createWindow(); 71 | } 72 | }); 73 | -------------------------------------------------------------------------------- /testapp/src/electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { initializeSuperbridgePreload } from "superbridge/preload"; 2 | 3 | initializeSuperbridgePreload(); 4 | -------------------------------------------------------------------------------- /testapp/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | 3 | import App from "./App"; 4 | import React from "react"; 5 | import ReactDOM from "react-dom/client"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /testapp/src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: BlinkMacSystemFont, -apple-system, "SF Pro Display", "SF Pro", Inter, "Inter Fallback", sans-serif, sans-serif; 3 | background-color: #000; 4 | color: #fff; 5 | } 6 | -------------------------------------------------------------------------------- /testapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | // "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | } 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /testapp/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import electron from "vite-plugin-electron"; 3 | import react from "@vitejs/plugin-react"; 4 | import renderer from "vite-plugin-electron-renderer"; 5 | import { resolve } from "path"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | electron([ 11 | { 12 | entry: "src/electron/main.ts", 13 | vite: { 14 | build: { 15 | outDir: "dist-electron", 16 | sourcemap: true, 17 | rollupOptions: { 18 | external: ["electron"], 19 | }, 20 | target: "esnext", 21 | }, 22 | }, 23 | }, 24 | { 25 | entry: "src/electron/preload.ts", 26 | vite: { 27 | build: { 28 | outDir: "dist-electron", 29 | sourcemap: true, 30 | rollupOptions: { 31 | external: ["electron"], 32 | }, 33 | target: "esnext", 34 | }, 35 | }, 36 | }, 37 | ]), 38 | renderer(), 39 | ], 40 | resolve: { 41 | alias: { 42 | "@": resolve(__dirname, "src"), 43 | }, 44 | }, 45 | base: "./", 46 | }); 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | "moduleResolution": "node", 8 | "allowImportingTsExtensions": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "noEmit": true, 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "allowSyntheticDefaultImports": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "version": 2, 4 | "buildCommand": "yarn build", 5 | "outputDirectory": ".next", 6 | "installCommand": "yarn install", 7 | "framework": "nextjs" 8 | } 9 | --------------------------------------------------------------------------------