Skip to content

Commit

Permalink
43 allow to run rimless in nodejs (#44)
Browse files Browse the repository at this point in the history
* fix docs

* replace integrations with storybook

* update helpers

* add nodejs checks

* fix various issues
  • Loading branch information
au-re authored Nov 30, 2024
1 parent bfe1588 commit c713aee
Show file tree
Hide file tree
Showing 14 changed files with 272 additions and 367 deletions.
23 changes: 23 additions & 0 deletions declarations.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
declare module "*?worker&inline" {
const WorkerFactory: {
new (): Worker;
};
export default WorkerFactory;
}

declare module "*?worker" {
const WorkerFactory: {
new (): Worker;
};
export default WorkerFactory;
}

declare module "*.html?raw" {
const content: string;
export default content;
}

declare module "*.html" {
const content: string;
export default content;
}
5 changes: 2 additions & 3 deletions docs/examples/iframe.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
<head>
<meta charset="utf-8">
<title>Rimless Guest</title>
<script src="../../lib/rimless.min.js"></script>
<style>
html,
body {
Expand Down Expand Up @@ -54,8 +53,8 @@ <h1>IFRAME</h1>
<button id="btn">call host function</button>
</div>

<script>
const { guest } = rimless;
<script type="module">
import { guest } from "/src/index.ts";

function makeRandomColor() {
const letters = "0123456789ABCDEF";
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import guest from "../../src/guest";
import { guest } from "../../src/index";

function createColor() {
const letters = "0123456789ABCDEF";
Expand Down
11 changes: 0 additions & 11 deletions docs/typings.d.ts

This file was deleted.

15 changes: 8 additions & 7 deletions src/guest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractMethods, isWorker } from "./helpers";
import { extractMethods, getEventData, isWorker } from "./helpers";
import { registerLocalMethods, registerRemoteMethods } from "./rpc";
import { actions, EventHandlers, events, IConnection, ISchema } from "./types";

Expand All @@ -8,16 +8,17 @@ function connect(schema: ISchema = {}, eventHandlers?: EventHandlers): Promise<I

// on handshake response
async function handleHandshakeResponse(event: any) {
if (event.data.action !== actions.HANDSHAKE_REPLY) return;
const eventData = getEventData(event);
if (eventData?.action !== actions.HANDSHAKE_REPLY) return;

// register local methods
const unregisterLocal = registerLocalMethods(schema, localMethods, event.data.connectionID);
const unregisterLocal = registerLocalMethods(schema, localMethods, eventData.connectionID);

// register remote methods
const { remote, unregisterRemote } = registerRemoteMethods(
event.data.schema,
event.data.methods,
event.data.connectionID,
eventData.schema,
eventData.methods,
eventData.connectionID,
event
);

Expand All @@ -26,7 +27,7 @@ function connect(schema: ISchema = {}, eventHandlers?: EventHandlers): Promise<I
// send a HANDSHAKE REPLY to the host
const payload = {
action: actions.HANDSHAKE_REPLY,
connectionID: event.data.connectionID,
connectionID: eventData.connectionID,
};

if (isWorker()) self.postMessage(payload);
Expand Down
94 changes: 69 additions & 25 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export const CONNECTION_TIMEOUT = 1000;

/**
* check if run in a webworker
*
Expand All @@ -9,6 +7,13 @@ export function isWorker(): boolean {
return typeof window === "undefined" && typeof self !== "undefined";
}

/**
* check if run in a Node.js environment
*/
export function isNodeEnv(): boolean {
return typeof window === "undefined";
}

/**
* we cannot send functions through postMessage
* extract the path to all functions in the schema
Expand Down Expand Up @@ -40,34 +45,19 @@ const ports: any = { "http:": "80", "https:": "443" };
* @param url
*/
export function getOriginFromURL(url: string | null) {
const { location } = document;

const regexResult = urlRegex.exec(url || "");
let protocol;
let hostname;
let port;

if (regexResult) {
// It's an absolute URL. Use the parsed info.
// regexResult[1] will be undefined if the URL starts with //
[, protocol = location.protocol, hostname, , port] = regexResult;
} else {
// It's a relative path. Use the current location's info.
protocol = location.protocol;
hostname = location.hostname;
port = location.port;
}
if (!url) return null;

const regexResult = urlRegex.exec(url);
if (!regexResult) return null;

const [, protocol = "http:", hostname, , port] = regexResult;

// If the protocol is file, the origin is "null"
// The origin of a document with file protocol is an opaque origin
// and its serialization "null" [1]
// [1] https://html.spec.whatwg.org/multipage/origin.html#origin
// If the protocol is file, return file://
if (protocol === "file:") {
return "null";
return "file://";
}

// If the port is the default for the protocol, we don't want to add it to the origin string
// or it won't match the message's event.origin.
const portSuffix = port && port !== ports[protocol] ? `:${port}` : "";
return `${protocol}//${hostname}${portSuffix}`;
}
Expand Down Expand Up @@ -117,3 +107,57 @@ export function generateId(length: number = 10): string {
}
return result;
}

export interface NodeWorker {
on(event: string, handler: any): void;
off(event: string, handler: any): void;
postMessage(message: any): void;
terminate(): void;
}

// Type that captures common properties between Web Workers and Node Workers
export type WorkerLike = Worker | NodeWorker;

let NodeWorkerClass: any = null;

if (isNodeEnv()) {
try {
const workerThreads = require('worker_threads');
NodeWorkerClass = workerThreads.Worker;
} catch {
}
}

export function isNodeWorker(target: any): target is NodeWorker {
return NodeWorkerClass !== null && target instanceof NodeWorkerClass;
}

export function isWorkerLike(target: any): target is WorkerLike {
return isNodeWorker(target) || target instanceof Worker;
}

export function addEventListener(target: Window | WorkerLike | HTMLIFrameElement, event: string, handler: any) {
if (isNodeWorker(target)) {
target.on(event, handler);
} else if ('addEventListener' in target) {
target.addEventListener(event, handler);
}
}

export function removeEventListener(target: Window | WorkerLike | HTMLIFrameElement, event: string, handler: any) {
if (isNodeWorker(target)) {
target.off(event, handler);
} else if ('removeEventListener' in target) {
target.removeEventListener(event, handler);
}
}

/**
* Normalize message event data across Web and Node.js environments
* In web, data is in event.data
* In Node.js, the event itself contains the data
*/
export function getEventData(event: any): any {
return event.data || event;
}

64 changes: 41 additions & 23 deletions src/host.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { extractMethods, generateId, getOriginFromURL } from "./helpers";
import { extractMethods, generateId, getOriginFromURL, isNodeEnv, addEventListener, removeEventListener, isNodeWorker, NodeWorker, getEventData } from "./helpers";
import { registerLocalMethods, registerRemoteMethods } from "./rpc";
import { actions, events, IConnection, IConnections, ISchema } from "./types";

const connections: IConnections = {};

function isValidTarget(iframe: HTMLIFrameElement, event: any) {
const childURL = iframe.getAttribute("src");
const childOrigin = getOriginFromURL(childURL);
const hasProperOrigin = event.origin === childOrigin;
const hasProperSource = event.source === iframe.contentWindow;

return hasProperOrigin && hasProperSource;
function isValidTarget(guest: HTMLIFrameElement | Worker | NodeWorker, event: any) {
// If it's a worker, we don't need to validate origin
if (isNodeWorker(guest) || (typeof Worker !== 'undefined' && guest instanceof Worker)) {
return true;
}

// For iframes, check origin and source
const iframe = guest as HTMLIFrameElement;
try {
const childURL = iframe.src;
const childOrigin = getOriginFromURL(childURL);
const hasProperOrigin = event.origin === childOrigin;
const hasProperSource = event.source === iframe.contentWindow;

return (hasProperOrigin && hasProperSource) || !childURL;
} catch (e) {
console.warn('Error checking iframe target:', e);
return false;
}
}

/**
Expand All @@ -21,36 +33,39 @@ function isValidTarget(iframe: HTMLIFrameElement, event: any) {
* @param schema
* @returns Promise
*/
function connect(guest: HTMLIFrameElement | Worker, schema: ISchema = {}): Promise<IConnection> {
function connect(guest: HTMLIFrameElement | Worker | NodeWorker, schema: ISchema = {}): Promise<IConnection> {
if (!guest) throw new Error("a target is required");

const guestIsWorker = (guest as Worker).onerror !== undefined && (guest as Worker).onmessage !== undefined;
const listeners = guestIsWorker ? guest : window;
const guestIsWorker = isNodeWorker(guest) || ((guest as Worker).onerror !== undefined && (guest as Worker).onmessage !== undefined);
const listeners = guestIsWorker || isNodeEnv() ? guest : window;

return new Promise((resolve) => {
const connectionID = generateId();

// on handshake request
function handleHandshake(event: any) {
if (!guestIsWorker && !isValidTarget(guest as HTMLIFrameElement, event)) return;
if (event.data.action !== actions.HANDSHAKE_REQUEST) return;

if (!guestIsWorker && !isNodeEnv() && !isValidTarget(guest, event)) return;

const eventData = getEventData(event);
if (eventData?.action !== actions.HANDSHAKE_REQUEST) return;

// register local methods
const localMethods = extractMethods(schema);
const unregisterLocal = registerLocalMethods(
schema,
localMethods,
connectionID,
guestIsWorker ? (guest as Worker) : undefined
guestIsWorker || isNodeEnv() ? (guest as Worker) : undefined
);

// register remote methods
const { remote, unregisterRemote } = registerRemoteMethods(
event.data.schema,
event.data.methods,
eventData.schema,
eventData.methods,
connectionID,
event,
guestIsWorker ? (guest as Worker) : undefined
guestIsWorker || isNodeEnv() ? (guest as Worker) : undefined
);

const payload = {
Expand All @@ -66,26 +81,29 @@ function connect(guest: HTMLIFrameElement | Worker, schema: ISchema = {}): Promi

// close the connection and all listeners when called
const close = () => {
listeners.removeEventListener(events.MESSAGE, handleHandshake);
removeEventListener(listeners, events.MESSAGE, handleHandshake);
unregisterRemote();
unregisterLocal();
if (guestIsWorker) (guest as Worker).terminate();
if (guestIsWorker) {
(guest as Worker).terminate();
}
};

const connection: IConnection = { remote, close };
connections[connectionID] = connection;
}

// subscribe to HANDSHAKE MESSAGES
listeners.addEventListener(events.MESSAGE, handleHandshake);
addEventListener(listeners, events.MESSAGE, handleHandshake);

// on handshake reply
function handleHandshakeReply(event: any) {
if (event.data.action !== actions.HANDSHAKE_REPLY) return;
return resolve(connections[event.data.connectionID]);
const eventData = getEventData(event);
if (eventData?.action !== actions.HANDSHAKE_REPLY) return;
return resolve(connections[eventData.connectionID]);
}

listeners.addEventListener(events.MESSAGE, handleHandshakeReply);
addEventListener(listeners, events.MESSAGE, handleHandshakeReply);
});
}

Expand Down
14 changes: 9 additions & 5 deletions src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { generateId, get, isWorker, set } from "./helpers";
import { generateId, get, isNodeEnv, isWorker, set } from "./helpers";
import { actions, events, IRPCRequestPayload, IRPCResolvePayload, ISchema } from "./types";

/**
Expand Down Expand Up @@ -105,12 +105,16 @@ export function createRPC(
connectionID: _connectionID,
};

if (guest) guest.addEventListener(events.MESSAGE, handleResponse);
else self.addEventListener(events.MESSAGE, handleResponse);
listeners.push(() => self.removeEventListener(events.MESSAGE, handleResponse));
if (guest || isNodeEnv()) {
guest?.addEventListener(events.MESSAGE, handleResponse);
listeners.push(() => guest?.removeEventListener(events.MESSAGE, handleResponse));
} else {
self.addEventListener(events.MESSAGE, handleResponse);
listeners.push(() => self.removeEventListener(events.MESSAGE, handleResponse));
}

if (guest) guest.postMessage(payload);
else if (isWorker()) (self as any).postMessage(payload);
else if (isWorker() || isNodeEnv()) (self as any).postMessage(payload);
else (event.source || event.target).postMessage(payload, event.origin);
});
};
Expand Down
Loading

0 comments on commit c713aee

Please sign in to comment.