Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

43 allow to run rimless in nodejs #44

Merged
merged 5 commits into from
Nov 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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