diff --git a/.github/actions/create-artifact/action.yaml b/.github/actions/create-artifact/action.yaml
deleted file mode 100644
index bcfbc16..0000000
--- a/.github/actions/create-artifact/action.yaml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: Create Artifact
-description: Upload an artifact to S3.
-inputs:
- artifact:
- description: The raw file contents.
- required: true
- aws-access-key-id:
- description: AWS access key ID.
- required: true
- aws-secret-access-key:
- description: AWS secret access key.
- required: true
- cache-control:
- description: HTTP cache control directive for the S3 object
- required: false
- public:
- description: Make this artifact public
- required: false
- default: "false"
- s3-bucket-name:
- description: The name of the S3 bucket.
- required: true
- s3-object-key:
- description: The object key used to store the file in S3.
- required: true
-outputs:
- success:
- description: The value will be 'true' if the operation succeeded, otherwise
- 'false'.
-runs:
- using: node16
- main: main.mjs
diff --git a/.github/actions/create-artifact/main.mjs b/.github/actions/create-artifact/main.mjs
deleted file mode 100644
index 81009c1..0000000
--- a/.github/actions/create-artifact/main.mjs
+++ /dev/null
@@ -1,46 +0,0 @@
-import core from "@actions/core";
-import aws from "aws-sdk";
-import mime from "mime-types";
-
-const run = async () => {
- const artifact = core.getInput("artifact", { required: true });
- const awsAccessKeyId = core.getInput("aws-access-key-id", { required: true });
- const awsSecretAccessKey = core.getInput("aws-secret-access-key", {
- required: true,
- });
- const cacheControl = core.getInput("cache-control");
- const isPublic = core.getBooleanInput("public");
- const s3BucketName = core.getInput("s3-bucket-name", { required: true });
- const s3ObjectKey = core.getInput("s3-object-key", { required: true });
-
- const s3 = new aws.S3({
- accessKeyId: awsAccessKeyId,
- secretAccessKey: awsSecretAccessKey,
- });
-
- await s3
- .putObject({
- ACL: isPublic ? "public-read" : undefined,
- Body: artifact,
- Bucket: s3BucketName,
- CacheControl: cacheControl,
- ContentType: mime.lookup(s3ObjectKey),
- Key: s3ObjectKey,
- })
- .promise()
- .then(() => {
- core.info(`Artifact saved using key ${s3ObjectKey}`);
- core.setOutput("success", "true");
- })
- .catch(
- /**
- * @param {aws.AWSError | NodeJS.ErrnoException} error
- */
- (error) => {
- core.setOutput("success", "false");
- throw error;
- }
- );
-};
-
-run();
diff --git a/.github/actions/install-dependencies/action.yaml b/.github/actions/install-dependencies/action.yaml
index 996859e..661cc2e 100644
--- a/.github/actions/install-dependencies/action.yaml
+++ b/.github/actions/install-dependencies/action.yaml
@@ -1,13 +1,13 @@
-name: Install Workspace Dependencies
-description: Installs all workspace dependencies.
+name: Install Dependencies
+description: Install all dependencies.
runs:
using: "composite"
steps:
- name: Install asdf
- uses: asdf-vm/actions/setup@v1
+ uses: asdf-vm/actions/setup@v2
- name: Hydrate asdf cache
id: hydrate-asdf-cache
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
path: ${{ env.ASDF_DIR }}
key: ${{ runner.os }}-${{ hashFiles('.tool-versions') }}
@@ -15,10 +15,10 @@ runs:
if: steps.hydrate-asdf-cache.outputs.cache-hit != 'true'
uses: asdf-vm/actions/install@v1
- name: Hydrate node modules cache
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
path: "**/node_modules"
- key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+ key: ${{ runner.os }}-${{ hashFiles('**/bun.lockb') }}
- name: Install node modules
- run: yarn install --frozen-lockfile
+ run: bun install --frozen-lockfile
shell: bash
diff --git a/.github/actions/install-playwright-dependencies/action.yaml b/.github/actions/install-playwright-dependencies/action.yaml
new file mode 100644
index 0000000..ef35c03
--- /dev/null
+++ b/.github/actions/install-playwright-dependencies/action.yaml
@@ -0,0 +1,28 @@
+name: Install Playwright Dependencies
+description: Install playwright dependencies and cache browser binaries.
+runs:
+ using: "composite"
+ steps:
+ - name: Get playwright version
+ id: playwright-info
+ run: |
+ version=$(
+ npm ls @playwright/test --json \
+ | jq --raw-output '.dependencies["@playwright/test"].version'
+ )
+ echo "version=$version" >> $GITHUB_OUTPUT
+ shell: bash
+ - name: Cache playwright browser binaries
+ uses: actions/cache@v3
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ steps.playwright-info.outputs.version }}
+ - name: Install playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: npx playwright install --with-deps
+ shell: bash
+ - name: Install playwright system dependencies
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ run: npx playwright install-deps
+ shell: bash
diff --git a/.github/actions/jest-coverage-calculator/action.yaml b/.github/actions/jest-coverage-calculator/action.yaml
deleted file mode 100644
index 9cba2c7..0000000
--- a/.github/actions/jest-coverage-calculator/action.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: Jest Coverage Calculator
-description: Computes the mean percent coverage
-inputs:
- coverage:
- description: The json-summary coverage report from Jest
- required: true
-outputs:
- percent:
- description: The mean percent coverage
-runs:
- using: node16
- main: main.mjs
diff --git a/.github/actions/jest-coverage-calculator/main.mjs b/.github/actions/jest-coverage-calculator/main.mjs
deleted file mode 100644
index 2b4acd7..0000000
--- a/.github/actions/jest-coverage-calculator/main.mjs
+++ /dev/null
@@ -1,18 +0,0 @@
-import core from "@actions/core";
-
-const coverageJson = core.getInput("coverage", { required: true });
-const coverage = JSON.parse(coverageJson);
-
-const sumBy = (list, callback) =>
- list.reduce((acc, item) => acc + callback(item), 0);
-
-const percent = [Object.values(coverage.total)]
- .map((group) => [
- sumBy(group, ({ covered }) => covered),
- sumBy(group, ({ total }) => total),
- ])
- .map(([covered, total]) => (total > 0 ? covered / total : 0))
- .map((percent) => parseFloat((percent * 100).toFixed(2)))
- .shift();
-
-core.setOutput("percent", percent);
diff --git a/.github/actions/jest/action.yaml b/.github/actions/jest/action.yaml
deleted file mode 100644
index a211d76..0000000
--- a/.github/actions/jest/action.yaml
+++ /dev/null
@@ -1,60 +0,0 @@
-name: Jest
-description: Run Jest tests and collect coverage data
-inputs:
- aws-access-key-id:
- description: AWS access key ID
- required: true
- aws-secret-access-key:
- description: AWS secret access key
- required: true
- generate-artifacts:
- description: Generate coverage artifacts and upload them to S3
- required: false
- default: "false"
- s3-bucket-name:
- description: The S3 bucket to upload artifacts to
- required: true
- s3-object-path:
- description: Where the artifacts will be stored
- required: true
-runs:
- using: "composite"
- steps:
- - name: Run Jest tests
- run: yarn test --collectCoverage
- shell: bash
- - name: Read Jest coverage output
- id: read-jest-coverage
- run: |
- json=$(cat coverage/coverage-summary.json)
- echo "::set-output name=json::${json//$'\n'/'%0A'}"
- shell: bash
- - name: Calculate percent coverage
- id: calculate-percent-coverage
- uses: ./.github/actions/jest-coverage-calculator
- with:
- coverage: ${{ steps.read-jest-coverage.outputs.json }}
- - name: Create coverage badge artifact
- env:
- percent: ${{ steps.calculate-percent-coverage.outputs.percent }}
- if: inputs.generate-artifacts == 'true'
- uses: ./.github/actions/create-artifact
- with:
- artifact: |
- {
- "schemaVersion": 1,
- "label": "coverage",
- "message": "${{ env.percent }}%",
- "color": "${{
- (env.percent < 25 && 'red') ||
- (env.percent < 50 && 'orange') ||
- (env.percent < 75 && 'yellow') ||
- 'brightgreen'
- }}"
- }
- aws-access-key-id: ${{ inputs.aws-access-key-id }}
- aws-secret-access-key: ${{ inputs.aws-secret-access-key }}
- cache-control: no-cache
- public: true
- s3-bucket-name: ${{ inputs.s3-bucket-name }}
- s3-object-key: ${{ inputs.s3-object-path }}/coverage-shield.json
diff --git a/.github/workflows/ci-browser.yaml b/.github/workflows/ci-browser.yaml
new file mode 100644
index 0000000..af07448
--- /dev/null
+++ b/.github/workflows/ci-browser.yaml
@@ -0,0 +1,57 @@
+name: CI
+on:
+ pull_request:
+ paths:
+ - packages/browser/**
+ - packages/core/**
+ paths-ignore:
+ - packages/**/.gitignore
+ - packages/**/README.md
+ push:
+ branches:
+ - main
+ paths:
+ - packages/browser/**
+ - packages/core/**
+ paths-ignore:
+ - packages/**/.gitignore
+ - packages/**/README.md
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Build package
+ run: bun --cwd packages/browser build
+ eslint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Check code style
+ run: bun --cwd packages/browser eslint
+ prettier:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Check code style
+ run: bun --cwd packages/browser prettier
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Install playwright dependencies
+ uses: ./.github/actions/install-playwright-dependencies
+ - name: Run tests
+ run: bun --cwd packages/browser test
diff --git a/.github/workflows/ci-core.yaml b/.github/workflows/ci-core.yaml
new file mode 100644
index 0000000..13862b6
--- /dev/null
+++ b/.github/workflows/ci-core.yaml
@@ -0,0 +1,53 @@
+name: CI
+on:
+ pull_request:
+ paths:
+ - packages/core/**
+ paths-ignore:
+ - packages/core/.gitignore
+ - packages/core/README.md
+ push:
+ branches:
+ - main
+ paths:
+ - packages/core/**
+ paths-ignore:
+ - packages/core/.gitignore
+ - packages/core/README.md
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Build package
+ run: bun --cwd packages/core build
+ eslint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Check code style
+ run: bun --cwd packages/core eslint
+ prettier:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Check code style
+ run: bun --cwd packages/core prettier
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@v3
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+ - name: Run tests
+ run: bun --cwd packages/core test
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
deleted file mode 100644
index 198a24b..0000000
--- a/.github/workflows/ci.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: CI
-on:
- pull_request:
- paths-ignore:
- - .gitignore
- - .prettierignore
- - LICENSE
- - package.json
- - README.md
- push:
- branches:
- - main
- paths-ignore:
- - .gitignore
- - .prettierignore
- - LICENSE
- - package.json
- - README.md
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout commit
- uses: actions/checkout@v2
- - name: Install dependencies
- uses: ./.github/actions/install-dependencies
- - name: Build package
- run: yarn build
- check:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout commit
- uses: actions/checkout@v2
- - name: Install dependencies
- uses: ./.github/actions/install-dependencies
- - name: Check code style
- run: yarn code:check
- test:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout commit
- uses: actions/checkout@v2
- - name: Install dependencies
- uses: ./.github/actions/install-dependencies
- - name: Run tests
- uses: ./.github/actions/jest
- with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- generate-artifacts: ${{ github.ref == 'refs/heads/main' }}
- s3-bucket-name: blvd-corp-github-ci-artifacts
- s3-object-path: ${{ github.repository }}/workflows/ci
diff --git a/.gitignore b/.gitignore
index 00543e5..e194fa6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,3 @@
-build
-coverage
+.DS_Store
+.vscode
node_modules
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index 5498e0f..0000000
--- a/.prettierignore
+++ /dev/null
@@ -1,2 +0,0 @@
-build
-coverage
diff --git a/.tool-versions b/.tool-versions
index d00cddc..2263cc3 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1 @@
-nodejs 14.19.0
-yarn 1.22.5
+bun 1.0.17
diff --git a/LICENSE b/LICENSE
index 367375b..85bf3e6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,201 @@
-MIT License
-
-Copyright (c) 2022 Boulevard
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright 2023 Daniel Nagy
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/README.md b/README.md
index 3ed3b6b..10120de 100644
--- a/README.md
+++ b/README.md
@@ -1,360 +1,59 @@
-# Transporter
-
-![](https://img.shields.io/endpoint?url=https%3A%2F%2Fblvd-corp-github-ci-artifacts.s3.amazonaws.com%2FBoulevard%2Ftransporter%2Fworkflows%2Fci%2Fcoverage-shield.json)
-
-Transporter is a framework for inter-process communication. The Transporter API was influenced by [comlink](https://github.com/GoogleChromeLabs/comlink), [OIS](https://en.wikipedia.org/wiki/OSI_protocols), [OpenRPC](https://github.com/open-rpc), and [rxjs](https://github.com/ReactiveX/rxjs).
-
-![image](https://user-images.githubusercontent.com/1622446/163908100-bb2f24e3-e393-43bf-a656-0e182da41a0e.png)
-
-#### Contents
-
-- [Introduction](#introduction)
-- [Install](#install)
-- [API](#api)
- - [Functions](#functions)
- - [Types](#types)
-- [Memory Management](#memory-management)
-- [Examples](#examples-2)
+
+
+
+
+ Typesafe distributed computing in TypeScript.
+
## Introduction
-Transporter simplifies the implementation of inter-process communication. It provides structure on top of primitive message passing that improves semantics, maintenance, and productivity so you can focus on code and not on boilerplate.
-
-Let's look at an example. Suppose we have a worker that contains some reusable math functions and we want to use those math functions in our application. First let's look at the worker code.
-
-```typescript
-import { createServer, createService } from "@boulevard/transporter";
-import { createSessionManager } from "@boulevard/transporter/worker";
-
-const add = (...values) => values.reduce((sum, num) => sum + num, 0);
-
-const subtract = (value, ...values) =>
- values.reduce((diff, num) => diff - num, value);
-
-const math = createService({ add, subtract });
-
-createServer({
- router: [{ path: "math", provide: math }],
- scheme: "blvd",
- sessionManagers: [createSessionManager()],
-});
-```
-
-Our worker creates a math service as well as a server that can accept incoming requests and route them to the math service. Next let's look at how our application uses this service.
-
-```typescript
-import { createSession } from "@boulevard/transporter/worker";
-
-const { link } = createSession(new Worker("math.js", { type: "module" }));
-const { add, subtract } = link("blvd:math");
-
-const main = async () => {
- const sum = await add(1, 2);
- const diff = await subtract(2, 1);
-};
-
-main();
-```
-
-Our application establishes a connection to the worker and then links the math service using a URI. Notice when our application calls a function provided by the math service it must `await` the response. When we call a function provided by the math service that function will be evaluated inside the worker. If we want the return value of the function we must wait for the result to be returned from the worker thread.
-
-## Install
-
-Transporter is available from the npm registry.
-
-> Transporter is currently in beta. Expect breaking API changes.
-
-```
-npm add @boulevard/transporter
-```
-
-## API
-
-### Functions
-
-#### `createClient`
+Transporter is an RPC library for typesafe distributed computing. The Transporter API was influenced by [comlink](https://github.com/GoogleChromeLabs/comlink) and [rxjs](https://github.com/ReactiveX/rxjs).
-```typescript
-function createClient(from: { port: SessionPort; timeout?: number }): Client;
-```
-
-Creates a client that is able to link to services.
-
-> You know my fourth rule? Never make a promise you can't keep. โ Frank
-
-Whenever a response is required Transporter will send a message to the server to validate the connection. The server must respond within the timeout limit. This validation is independent of the time it takes to fulfill the request. Once the connection is validated there is no time limit to fulfill the request.
-
-#### `createServer`
-
-```typescript
-function createServer(from: {
- router: Router;
- scheme: string;
- sessionManagers: [SessionManager, ...SessionManager[]];
- timeout?: number;
-}): Server;
-```
-
-Creates a server that can manage client sessions and route incoming requests to the correct service. Transporter is connection-oriented and transport agnostic. However, a duplex transport is required to support observables and callback arguments.
-
-The scheme is similar to custom URL schemes on iOS and Android. The scheme acts as a namespace. It is used to disambiguate multiple servers running on the same host.
-
-#### `createService`
-
-```typescript
-function createService(provide: T): Service;
-```
-
-Creates a service. Services may provide functions or observables. Functions act as a pull mechanism and observables act as a push mechanism. If a service provides a value that is not an observable it will be wrapped in an observable that emits the value and then completes.
-
-##### Examples
+Message passing can quickly grow in complexity, cause race conditions, and make apps difficult to maintain. Transporter eliminates the cognitive overhead associated with message passing by enabling the use of functions as a means of communication between distributed systems.
-```typescript
-const list = createService({ concat: (left, right) => [...left, ...right] });
-const string = createService({ concat: (left, right) => `${left}${right}` });
+### Features
-createServer({
- router: [
- { path: "list", provide: list },
- { path: "string", provide: string },
- ],
- scheme: "blvd",
- sessionManagers: [createSessionManager()],
-});
-```
+- ๐ Typesaftey without code generation.[^1]
+- ๐ Support for generic functions.
+- ๐คฉ The core API works in any JavaScript runtime.[^2][^3]
+- ๐ Easily integrates into your existing codebase.
+- ๐ No schema builders required, though you may still use them.
+- ๐ฅน Dependency injection.
+- ๐ซถ FP friendly.
+- ๐ค Memoization of remote functions.
+- ๐ซก Small footprint with 0 dependencies.
+- ๐ Configurable subprotocols.
+- ๐ฐ Flow control and protocol stacking using Observables.
+- ๐คฏ Recursive RPC for select subprotocols.
+- ๐ถ๏ธ PubSub using Observables for select subprotocols.
+- ๐ Resource management.
+- ๐ฅณ No globals.[^4]
+- ๐งช Easy to test.
-Pub/Sub communication is possible using observables.
+[^1]: Transporter is tested using the latest version of TypeScript with strict typechecking enabled.
+[^2]: Transporter works in Node, Bun, Deno, Chrome, Safari, Firefox, Edge, and React Native.
+[^3]: Hermes, a popular JavaScript runtime for React Native apps, does not support `FinalizationRegistry`. It also requires a polyfill for `crypto.randomUUID`.
+[^4]: Transporter has a global `AddressBook` that is used to ensure every server has a unique address.
-```typescript
-const darkMode = new BehaviorSubject(false);
-createService({ darkMode: darkMode.asObservable() });
-darkMode.next(true);
-```
+### Practical Usage
-The client can get the value of an observable imperatively using the `firstValueFrom` function exported by Transporter. It is advised to only use `firstValueFrom` if you know the observable will emit a value, otherwise your program may hang indefinitely.
+Transporter may be used to build typesafe APIs for fullstack TypeScript applications.
-```typescript
-const { definitelyEmits } = session.link("org:example");
-const value = await firstValueFrom(definitelyEmits);
-```
+Transporter may be used in the browser to communicate with other browsing contexts (windows, tabs, iframes) or workers (dedicated workers, shared workers, service workers). The browser is ripe for distributed computing and parallel processing but not many developers take advantage of this because the `postMessage` API is very primitive.
-The client can subscribe to an observable to receive new values overtime.
+Transporter may also be used in React Native apps to communicate with webviews. You could take this to the extreme and build your entire native app as a Web app that is wrapped in a React Native shell. The Web app could use Transporter to call out to the React Native app to access native APIs not available in the browser.
-```typescript
-const { darkMode } = session.link("org:client/preferences");
-darkMode.subscribe(onDarkModeChange);
-```
+## Getting Started
-### Types
+To get started using Transporter install the package from the npm registry using your preferred package manager.
-#### `Client`
-
-```typescript
-type Client = {
- link(uri: string): RemoteService;
-};
```
-
-A client is connected to a host. A client is able to link to services provided by a server running on the host.
-
-##### Example
-
-```typescript
-import { createSession as createBrowserSession } from "@boulevard/transporter/browser";
-import { createSession as createWorkerSession } from "@boulevard/transporter/worker";
-
-const browserSession = createBrowserSession({
- origin: "https://trusted.com",
- window: self.parent,
-});
-
-const workerSession = createWorkerSession(new Worker("crypto.0beec7b.js"));
-
-type Auth = {
- login(userName: string, password: string): { apiToken: string };
-};
-
-type Crypto = {
- encrypt(value: string): string;
-};
-
-const auth = browserSession.link("blvd:auth");
-const crypto = workerSession.link("blvd:crypto");
-
-const { apiToken } = await auth.login("chow", await crypto.encrypt("bologna1"));
+bun add @daniel-nagy/transporter
```
-#### `RemoteService`
+As of beta 3 Transporter is nearing API stability but there may still be some breaking changes to the API. For API docs see the README for each package.
-```typescript
-type RemoteService = {
- [name: string]: ObservableLike | TransportedFunction;
-};
-```
-
-A remote service is a group of remote functions or observables. It is a service but from a client's perspective. While a remote service looks like an object it does not have an object's prototype. You can think of it as an object with a `null` prototype. Notably the remote service's properties are not iterable.
-
-#### `Router`
-
-```typescript
-type Router = { path: string; provide: Service }[];
-```
-
-A router is a data structure for mapping paths to services.
-
-#### `Server`
-
-```typescript
-export type Server = {
- stop(): void;
-};
-```
-
-A server is able to accept connections from clients and route incoming requests to the correct service.
-
-#### `Service`
-
-```typescript
-export type Service = {
- [name: string]: ObservableLike | TransportableFunction;
-};
-```
-
-A service is a group of functions or observables. A service can be thought of as a secondary router.
-
-#### `SessionManager`
-
-```typescript
-export type SessionManager = {
- connect: ObservableLike;
-};
-```
-
-A session manager is the glue between a server and a client. It is responsible for monitoring incoming requests and creating a connection between the server and the client.
-
-A session manager sits between the server and the transport layer. Transporter provides session managers for browser windows, Web workers, React Native, and React Native Webviews. However, it is possible to create your own session managers. This allows Transporter to be agnostic of the transport layer. Keep in mind that callback arguments and observables do require a duplex transport layer though.
-
-##### Example
-
-```typescript
-createServer({
- ...,
- sessionManagers: [
- createBrowserSessionManager(),
- createWebViewSessionManager()
- ]
-});
-```
-
-The session manager factory functions provided by Transporter allow you to intercept the connection before it is created. This enables proxying the session port or rejecting the connection. To prevent the connection from being created return `null` from the `connect` function.
-
-```typescript
-createBrowserSessionManager({
- ...,
- connect({ delegate, origin }) {
- return new URL(origin).hostname.endsWith("trusted.com") ? delegate() : null;
- }
-});
-```
-
-#### `SessionPort`
-
-```typescript
-type SessionPort = {
- receive: ObservableLike;
- send(message: string): void;
-};
-```
-
-A session port represents a connection between a server and a client.
-
-#### `Transportable`
-
-```typescript
-type Transportable =
- | boolean
- | null
- | number
- | string
- | undefined
- | Transportable[]
- | { [key: string]: Transportable }
- | (...args: Transported[]) => (Transportable | Promise);
-```
-
-A transportable value may be transported between processes. If the value is serializable it will be cloned. If it is not serializable it will be proxied. If the return value of a function is a promise then the response will be sent once the promise settles.
-
-## Memory Management
-
-If a value cannot be serialized, such as a function, the value is proxied. However, if the proxy is garbage collected this would continue to hold a strong reference to the value, thus creating a memory leak. Transporter uses `FinalizationRegistry` to receive a notification when a proxy is garbage collected. When a proxy is garbage collected a message is sent to release the value, allowing it to be garbage collected as well.
-
-## Examples
-
-### Composing React Apps
-
-Transporter can be used to easily compose React applications in iframes or React Native Webviews. Here is an example app that has a reusable `` component that renders a React app that provides an app service with a `render` method inside an iframe.
-
-```tsx
-import { createSession } from "@boulevard/transporter/browser";
-import { useEffect, useState } from "react";
-import { createRoot } from "react-dom/client";
-
-const MicroApp = ({ src, uri, ...props }: MicroApp.Props) => {
- const [app, setApp] = useState(null);
-
- const onLoad = ({ currentTarget: frame }) =>
- setApp(() => createSession(frame.contentWindow).link(uri));
-
- useEffect(() => {
- app?.render(props);
- });
-
- return ;
-};
-
-const App = () => {
- const [count, setCount] = useState(0);
-
- return (
-
- count={count}
- increment={() => setCount((count) => count + 1)}
- src="./counter.html"
- uri="counter:app"
- />
- );
-};
-
-createRoot(document.getElementById("root")).render();
-```
-
-Notice that we called `setApp` with a function, otherwise React would attempt to call our service as a function.
-
-And here is the implementation of the micro app.
-
-```tsx
-import { createServer, createService } from "@boulevard/transporter";
-import { createSessionManager } from "@boulevard/transporter/browser";
-import { createRoot } from "react-dom/client";
-
-const App = ({ count, increment }) => (
- <>
-
current count: {count}
-
- >
-);
-
-const Root = createRoot(document.getElementById("root"));
-
-const app = createService({
- render: (props) => Root.render(),
-});
-
-createServer({
- router: [{ path: "app", provide: app }],
- scheme: "counter",
- sessionManagers: [createSessionManager()],
-});
-```
+### Packages
-Notice that we provide an inline function to `onClick` that calls `increment` with no arguments. Otherwise the click event would be passed to `increment` which is not transportable.
+- [core](/packages/core) - Core APIs that are designed to work in any JavaScript runtime.
+- [browser](/packages/browser) - Wrappers around the browser's messaging APIs that provide normalized interfaces and additional semantics.
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..2fa52ab
Binary files /dev/null and b/bun.lockb differ
diff --git a/jest.config.ts b/jest.config.ts
deleted file mode 100644
index 3a4cead..0000000
--- a/jest.config.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * For a detailed explanation regarding each configuration property and type check, visit:
- * https://jestjs.io/docs/configuration
- */
-
-export default {
- clearMocks: true,
- collectCoverageFrom: ["./**/!(*.d).ts"],
- coverageProvider: "v8",
- coverageReporters: ["json-summary", "lcov"],
- globals: {
- "ts-jest": {
- tsconfig: "tsconfig.test.json",
- },
- },
- modulePaths: ["/src/"],
- preset: "ts-jest",
-};
diff --git a/package.json b/package.json
index 2b4b157..be33692 100644
--- a/package.json
+++ b/package.json
@@ -1,47 +1,9 @@
{
- "name": "@boulevard/transporter",
- "version": "1.0.0-beta.2",
- "description": "Transporter abstracts message passing into function calls.",
- "author": "Daniel Nagy ",
- "repository": "github:Boulevard/transporter",
- "license": "MIT",
- "main": "index.js",
- "module": "index.js",
- "keywords": [
- "client",
- "postmessage",
- "remote",
- "rpc",
- "server",
- "service",
- "soa",
- "transport"
+ "private": true,
+ "workspaces": [
+ "packages/*"
],
- "publishConfig": {
- "access": "public",
- "registry": "https://registry.npmjs.org"
- },
"scripts": {
- "prebuild": "yarn clean",
- "build": "tsc",
- "postbuild": "cp package.json build",
- "predistribute": "yarn build",
- "distribute": "npm publish build",
- "clean": "rm -rf build",
- "code:check": "prettier --check .",
- "code:fix": "prettier --write .",
- "test": "node --expose-gc $(yarn bin)/jest"
- },
- "devDependencies": {
- "@actions/core": "^1.8.2",
- "@types/jest": "^27.4.1",
- "@types/mime-types": "^2.1.1",
- "aws-sdk": "^2.1133.0",
- "jest": "^27.5.1",
- "mime-types": "^2.1.35",
- "prettier": "^2.6.1",
- "ts-jest": "^27.1.4",
- "ts-node": "^10.7.0",
- "typescript": "^4.9.3"
+ "clean": "rm -rf node_modules"
}
}
diff --git a/packages/browser/.eslintrc.cjs b/packages/browser/.eslintrc.cjs
new file mode 100644
index 0000000..934f3b1
--- /dev/null
+++ b/packages/browser/.eslintrc.cjs
@@ -0,0 +1,31 @@
+module.exports = {
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:expect-type/recommended",
+ "plugin:require-extensions/recommended"
+ ],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ EXPERIMENTAL_useProjectService: true,
+ project: true,
+ tsconfigRootDir: __dirname
+ },
+ plugins: [
+ "@typescript-eslint",
+ "eslint-plugin-expect-type",
+ "require-extensions"
+ ],
+ root: true,
+ rules: {
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_"
+ }
+ ],
+ "expect-type/expect": "error"
+ }
+};
diff --git a/packages/browser/.gitignore b/packages/browser/.gitignore
new file mode 100644
index 0000000..c795b05
--- /dev/null
+++ b/packages/browser/.gitignore
@@ -0,0 +1 @@
+build
\ No newline at end of file
diff --git a/packages/browser/.prettierrc b/packages/browser/.prettierrc
new file mode 100644
index 0000000..36b3563
--- /dev/null
+++ b/packages/browser/.prettierrc
@@ -0,0 +1,3 @@
+{
+ "trailingComma": "none"
+}
diff --git a/packages/browser/README.md b/packages/browser/README.md
new file mode 100644
index 0000000..f6d8ed2
--- /dev/null
+++ b/packages/browser/README.md
@@ -0,0 +1,1072 @@
+# Browser
+
+The browser package contains APIs designed to work in the browser.
+
+```
+npm add @daniel-nagy/transporter @daniel-nagy/transporter-browser
+```
+
+Transporter is distributed as ES modules. Transporter may also be imported directly in the browser from a URL.
+
+## API
+
+The browser package contains the following modules.
+
+- [BroadcastSubject](#BroadcastSubject)
+- [BrowserClient](#BrowserClient)
+- [BrowserRequest](#BrowserRequest)
+- [BrowserResponse](#BrowserResponse)
+- [BrowserServer](#MessaBrowserServerge)
+- [BrowserSocket](#BrowserSocket)
+- [BrowserSocket.Message](#BrowserSocket.Message)
+- [BrowserSocket.State](#BrowserSocket.State)
+- [BrowserSocketServer](#BrowserSocketServer)
+- [StructuredCloneable](#StructuredCloneable)
+
+### BroadcastSubject
+
+_Module_
+
+A `BroadcastSubject` can be used to synchronize state between same-origin browsing contexts or workers.
+
+###### Types
+
+- [BroadcastSubject](#BroadcastSubject)
+
+###### Constructors
+
+- [fromChannel](#FromChannel)
+
+#### BroadcastSubject
+
+_Type_
+
+```ts
+class BroadcastSubject extends Subject.t {}
+```
+
+A `BroadcastSubject` is a `Subject` that broadcasts emitted values over a [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel).
+
+#### FromChannel
+
+_Constructor_
+
+```ts
+function fromChannel(
+ name: string
+): BroadcastSubject;
+```
+
+Creates a `BroadcastSubject` from a broadcast channel name.
+
+##### Example
+
+```ts
+import * as BroadcastSubject from "@daniel-nagy/transporter-browser/BroadcastSubject";
+const darkMode = BroadcastSubject.fromChannel("darkMode");
+darkMode.subscribe(console.log);
+```
+
+### BrowserClient
+
+_Module_
+
+An interface for making requests to a browsing context or worker.
+
+###### Types
+
+- [BrowserClient](#BrowserClient)
+- [Options](#Options)
+
+###### Constructors
+
+- [from](#From)
+
+###### Methods
+
+- [fetch](Fetch)
+
+#### BrowserClient
+
+_Type_
+
+```ts
+class BrowserClient {
+ /**
+ * The address of the server. An address is like a port number, except an
+ * address can be any string instead of a meaningless number.
+ */
+ public readonly serverAddress: string;
+ /**
+ * If the window and the origin do not match the connection will fail. The
+ * origin is only relevant when connecting to a window since the browser
+ * will require worker URLs to be same-origin.
+ */
+ public readonly origin: string;
+ /**
+ * The message target. A message target is like a server host.
+ */
+ public readonly target: Window | Worker | SharedWorker | ServiceWorker;
+}
+```
+
+An object that may be used to make fetch requests to a browsing context or worker.
+
+#### Options
+
+_Type_
+
+```ts
+type Options = {
+ /**
+ * The address of the server. The default is the empty string.
+ */
+ address?: string;
+ /**
+ * When connecting to a `Window` you may specify the allowed origin. If the
+ * window and the origin do not match the connection will fail. The origin is
+ * passed directly to the `targetOrigin` parameter of `postMessage` when
+ * connecting to the window. The default is `"*"`, which allows any origin.
+ */
+ origin?: string;
+};
+```
+
+Options when creating a `BrowserClient`.
+
+#### From
+
+_Constructor_
+
+```ts
+function from(
+ target: Window | Worker | SharedWorker | ServiceWorker,
+ options?: Options
+): BrowserClient;
+```
+
+Creates a new `BrowserClient`.
+
+##### Example
+
+```ts
+import * as BrowserClient from "@daniel-nagy/transporter-browser/BrowserClient";
+
+const worker = new Worker("/worker.js", { type: "module" });
+const client = BrowserClient.from(worker);
+```
+
+#### Fetch
+
+_Method_
+
+```ts
+fetch(body: StructuredCloneable.t): Promise;
+```
+
+Makes a request to a `BrowserServer`.
+
+##### Example
+
+```ts
+import * as BrowserClient from "@daniel-nagy/transporter-browser/BrowserClient";
+
+const worker = new Worker("/worker.js", { type: "module" });
+const client = BrowserClient.from(worker);
+const response = await client.fetch("๐");
+```
+
+### BrowserRequest
+
+_Module_
+
+A server receives a `Request` object when a client makes a request.
+
+###### Types
+
+- [Request](#Request)
+
+###### Functions
+
+- [isRequest](#IsRequest)
+
+#### Request
+
+_Type_
+
+```ts
+type Request = {
+ address: string;
+ /**
+ * Contains the value sent by the client.
+ */
+ body: StructuredCloneable.t;
+ id: string;
+ /**
+ * The origin of the client making the request. The origin will be set
+ * securely on the server using `MessageEvent.origin`.
+ */
+ origin: string;
+ type: "Request";
+};
+```
+
+A `Request` is created when a client makes a fetch request.
+
+#### IsRequest
+
+_Function_
+
+```ts
+function isRequest(event: MessageEvent): event is MessageEvent;
+```
+
+Returns `true` if the message event contains a `Request` object.
+
+### BrowserResponse
+
+_Module_
+
+A server sends a `Response` to a client in response to a request.
+
+###### Types
+
+- [Response](#Response)
+
+###### Functions
+
+- [isResponse](#IsResponse)
+
+#### Response
+
+_Type_
+
+```ts
+type Response = {
+ /**
+ * The payload of the response. This is the value the client will receive.
+ */
+ body: StructuredCloneable.t;
+ id: string;
+ type: "Response;
+};
+```
+
+A `Response` is created from the value returned by the server's request handler.
+
+#### IsResponse
+
+_Function_
+
+```ts
+function isResponse(event: MessageEvent): event is MessageEvent;
+```
+
+Returns `true` if the message event contains a `Response` object.
+
+### BrowserServer
+
+_Module_
+
+A `BrowserServer` provides request/response semantics on top of `postMessage`. It also normalizes the interface for connecting to different types of processes in the browser.
+
+###### Types
+
+- [BrowserServer](#BrowserServer)
+- [Options](#Options)
+- [RequestHandler](#RequestHandler)
+- [State](#State)
+
+###### Constructors
+
+- [listen](#listen)
+
+###### Methods
+
+- [stop](#Stop)
+
+#### BrowserServer
+
+_Type_
+
+```ts
+class BrowserServer {
+ public readonly address: string;
+ public readonly handle: RequestHandler;
+ public readonly state: State;
+ public readonly stateChange: Observable.t;
+ public readonly stopped: Observable.t;
+}
+```
+
+A `BrowserServer` listens for incoming requests from clients.
+
+#### Options
+
+_Type_
+
+```ts
+type Options = {
+ /**
+ * The address of the server. The default is the empty string. All servers
+ * must have a globally unique address.
+ */
+ address?: string;
+ /**
+ * Called whenever a request is received from a client. The request handler
+ * may return anything that is structured cloneable.
+ *
+ * The request object will contain the origin of the client. The origin can be
+ * used to validate the client before fulfilling the request.
+ */
+ handle: RequestHandler;
+};
+```
+
+Options when creating a `BrowserServer`.
+
+#### RequestHandler
+
+_Type_
+
+```ts
+type RequestHandler = (
+ request: Readonly
+) => StructuredCloneable.t | Promise;
+```
+
+A `RequestHandler` receives a `Request` from a client and returns the body of the `Response` that will be sent back to the client.
+
+#### State
+
+_Type_
+
+```ts
+enum State {
+ Listening = "Listening",
+ Stopped = "Stopped"
+}
+```
+
+An enumerable of the different server states.
+
+#### Listen
+
+_Constructor_
+
+```ts
+function listen(options: Options): BrowserServer;
+```
+
+Creates a new `BrowserServer` in the global scope. A `UniqueAddressError` will
+be thrown if the address is already taken.
+
+#### Example
+
+```ts
+import * as BrowserServer from "@daniel-nagy/transporter/BrowserServer";
+
+const server = BrowserServer.listen({
+ handle(request) {
+ // Message received from client. Return any response.
+ return "๐";
+ }
+});
+```
+
+#### Stop
+
+_Method_
+
+```ts
+stop(): void;
+```
+
+Stops the server. Once stopped the server will no longer receive requests.
+
+#### Example
+
+```ts
+import * as BrowserServer from "@daniel-nagy/transporter/BrowserServer";
+
+const server = BrowserServer.listen({
+ handle(request) {}
+});
+
+server.stop();
+```
+
+### BrowserSocket
+
+_Module_
+
+Provides a socket API on top of `postMessage` that is similar to the WebSocket API. A `BrowserSocket` is connection-oriented, duplex, and unicast. Any data that is structured cloneable can be passed through a browser socket.
+
+###### Types
+
+- [BrowserSocket](#BrowserSocket)
+- [ConnectionError](#ConnectionError)
+- [ConnectTimeoutError](#ConnectTimeoutError)
+- [DisconnectTimeoutError](#DisconnectTimeoutError)
+- [HeartbeatTimeoutError](#HeartbeatTimeoutError)
+- [Options](#Options)
+- [WindowOptions](#WindowOptions)
+
+###### Constructors
+
+- [connect](#connect)
+
+###### Methods
+
+- [close](#Close)
+- [ping](#Ping)
+- [send](#Send)
+
+#### BrowserSocket
+
+_Type_
+
+```ts
+class BrowserSocket {
+ /**
+ * Emits when the socket's state changes to `Closed` and then completes.
+ */
+ public readonly closed: Observable.t;
+ /**
+ * Emits when the socket's state changes to `Closing` and then completes.
+ */
+ public readonly closing: Observable.t;
+ /**
+ * Emits if the socket's state changes to `Connected` and then completes. If
+ * the socket errors during connection it will complete without emitting.
+ */
+ public readonly connected: Observable.t;
+ /**
+ * Emits when the socket receives data.
+ */
+ public readonly receive: Observable.t;
+ /**
+ * The current state of the socket.
+ */
+ public readonly state: State;
+ /**
+ * Emits when the socket's state changes. Completes when the socket state
+ * becomes `Closed`.
+ */
+ public readonly stateChange: Observable.t;
+}
+```
+
+A `BrowserSocket` is used to create a connection between browsing contexts or a browsing context and a worker context.
+
+#### ConnectionError
+
+_Type_
+
+```ts
+type ConnectionError =
+ | Observable.BufferOverflowError
+ | ConnectTimeoutError
+ | HeartbeatTimeoutError;
+```
+
+A variant type for the different reasons a socket may transition to a closing state with an error.
+
+#### ConnectTimeoutError
+
+_Type_
+
+```ts
+class ConnectTimeoutError extends Error {}
+```
+
+Used to indicate that the connection failed because the server did not complete the connection in the allotted time.
+
+#### DisconnectTimeoutError
+
+_Type_
+
+```ts
+class DisconnectTimeoutError extends Error {}
+```
+
+Used to indicate that an acknowledgement was not received when closing the connection in the allotted time.
+
+#### HeartbeatTimeoutError
+
+_Type_
+
+```ts
+class HeartbeatTimeoutError extends Error {}
+```
+
+Used to indicate that a response to a health-check was not received in the allotted time.
+
+#### Options
+
+_Type_
+
+```ts
+interface Options {
+ /**
+ * The maximum number of messages to buffer before the socket is connected.
+ * The default is `Infinity`.
+ */
+ bufferLimit?: number;
+ /**
+ * What to do incase there is a buffer overflow. The default is to error.
+ */
+ bufferOverflowStrategy?: Observable.BufferOverflowStrategy;
+ /**
+ * The maximum amount of time to wait for a connection in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ connectTimeout?: number;
+ /**
+ * The maximum amount of time to wait for a disconnection in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ disconnectTimeout?: number;
+ /**
+ * The frequency at which to request heartbeats in milliseconds. The default
+ * is `1000` or 1 second.
+ */
+ heartbeatInterval?: number;
+ /**
+ * The maximum amount of time to wait for a heartbeat in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ heartbeatTimeout?: number;
+ /**
+ * The address of the socket server.
+ */
+ serverAddress?: string;
+}
+```
+
+Options when creating a `BrowserSocket`.
+
+#### WindowOptions
+
+_Type_
+
+```ts
+interface WindowOptions extends Options {
+ /**
+ * When connecting to a `Window` you may specify the allowed origin. If the
+ * window and the origin do not match the connection will fail. The origin is
+ * passed directly to the `targetOrigin` parameter of `postMessage` when
+ * connecting to the window. The default is `"*"`, which allows any origin.
+ */
+ origin?: string;
+}
+```
+
+Additional options when connecting to a browsing context.
+
+#### Connect
+
+_Constructor_
+
+```ts
+function connect(
+ target: SharedWorker | Window | Worker,
+ options?: Options | WindowOptions
+): BrowserSocket;
+```
+
+Creates a new `BrowserSocket`. The socket will start in a `Connecting` state.
+
+##### Example
+
+```ts
+import * as BrowserSocket from "@daniel-nagy/transporter/BrowserSocket";
+using socket = BrowserSocket.connect(self.parent);
+```
+
+#### Close
+
+_Method_
+
+```ts
+close(): void;
+```
+
+Closes the socket causing its state to transition to `Closing`.
+
+##### Example
+
+```ts
+import * as BrowserSocket from "@daniel-nagy/transporter/BrowserSocket";
+const socket = BrowserSocket.connect(self.parent);
+socket.close();
+```
+
+#### Ping
+
+_Method_
+
+```ts
+ping(timeout: number = 2000): Promise;
+```
+
+Sends a ping to a connected socket and waits for a pong to be sent back. Returns a promise that resolves when a pong is received or rejects if a pong is not received in the allotted time.
+
+##### Example
+
+```ts
+import * as BrowserSocket from "@daniel-nagy/transporter/BrowserSocket";
+using socket = BrowserSocket.connect(self.parent);
+await socket.ping();
+```
+
+#### Send
+
+_Method_
+
+```ts
+send(message: StructuredCloneable.t): void;
+```
+
+Sends data through the socket. Data will automatically be buffered until the socket connects.
+
+##### Example
+
+```ts
+import * as BrowserSocket from "@daniel-nagy/transporter/BrowserSocket";
+const socket = BrowserSocket.connect(self.parent);
+socket.send("๐");
+```
+
+### BrowserSocket.Message
+
+_Module_
+
+Internal messages to facilitate the socket API. These messages are filtered from the data received from the socket.
+
+###### Types
+
+- [Connect](#Connect)
+- [Connected](#Connected)
+- [Disconnect](#Disconnect)
+- [Disconnected](#Disconnected)
+- [Message](#Message)
+- [Ping](#Ping)
+- [Pong](#Pong)
+- [Type](#Type)
+
+###### Functions
+
+- [isMessage](#IsMessage)
+- [isType](#IsType)
+- [typeOf](#typeOf)
+
+#### Connect
+
+_Type_
+
+```ts
+type Connect = {
+ address: string;
+ type: Type.Connect;
+};
+```
+
+Sent when a connection is initiated. This starts the "handshake".
+
+#### Connect
+
+_Type_
+
+```ts
+type Connected = {
+ type: Type.Connected;
+};
+```
+
+A message indicating the connection is complete and was successful. This concludes the "handshake".
+
+#### Disconnect
+
+_Type_
+
+```ts
+type Disconnect = {
+ type: Type.Disconnect;
+};
+```
+
+Sent when a socket is closing so that the other endpoint may preform some cleanup logic or otherwise close the connection gracefully. This starts the "closing handshake".
+
+#### Disconnected
+
+_Type_
+
+```ts
+type Disconnected = {
+ type: Type.Disconnected;
+};
+```
+
+A message that acknowledges the disconnection. If this message is received then the disconnect was graceful. This concludes the "closing handshake".
+
+#### Message
+
+_Type_
+
+```ts
+export type Message =
+ | Connect
+ | Connected
+ | Disconnect
+ | Disconnected
+ | Ping
+ | Pong;
+```
+
+A variant type for the different types of messages.
+
+#### Ping
+
+_Type_
+
+```ts
+type Ping = {
+ id: string;
+ type: Type.Ping;
+};
+```
+
+A ping message may be sent to solicit a response from the other endpoint.
+
+#### Pong
+
+_Type_
+
+```ts
+type Pong = {
+ id: string;
+ type: Type.Pong;
+};
+```
+
+A pong message must always be sent in response to a ping message.
+
+#### Type
+
+_Type_
+
+```ts
+enum Type {
+ Connect = "Connect",
+ Connected = "Connected",
+ Disconnect = "Disconnect",
+ Disconnected = "Disconnected",
+ Ping = "Ping",
+ Pong = "Pong"
+}
+```
+
+An enumerable of the different types of socket messages.
+
+#### IsMessage
+
+_Function_
+
+```ts
+function isMessage(message: StructuredCloneable.t): message is Message;
+```
+
+Returns `true` if the message is a socket message.
+
+#### IsType
+
+_Function_
+
+```ts
+function isType(
+ message: StructuredCloneable.t,
+ type: T
+): message is {
+ [Type.Connect]: Connect;
+ [Type.Connected]: Connected;
+ [Type.Disconnect]: Disconnect;
+ [Type.Disconnected]: Disconnected;
+ [Type.Ping]: Ping;
+ [Type.Pong]: Pong;
+}[T];
+```
+
+Returns `true` if the message is of the specified type.
+
+#### TypeOf
+
+_Function_
+
+```ts
+function typeOf(message: StructuredCloneable.t): Type | null;
+```
+
+Returns the message `Type` if the message is a socket message. Returns `null` otherwise.
+
+### BrowserSocket.State
+
+_Module_
+
+A socket's state.
+
+###### Types
+
+- [Closed](#Closed)
+- [Closing](#Closing)
+- [Connected](#Connected)
+- [Connecting](#Connecting)
+- [State](#State)
+- [Type](#Type)
+
+#### Closed
+
+_Type_
+
+```ts
+type Closed = {
+ error?: E;
+ type: Type.Closed;
+};
+```
+
+The socket is closed, possibly with an error.
+
+#### Closing
+
+_Type_
+
+```ts
+type Closing = {
+ error?: E;
+ type: Type.Closing;
+};
+```
+
+The socket is closing, possibly with an error.
+
+#### Connected
+
+_Type_
+
+```ts
+type Connected = {
+ type: Type.Connected;
+};
+```
+
+The socket is connected.
+
+#### Connecting
+
+_Type_
+
+```ts
+type Connecting = {
+ type: Type.Connecting;
+};
+```
+
+The socket is connecting.
+
+#### State
+
+_Type_
+
+```ts
+type State =
+ | Connecting
+ | Connected
+ | Closing
+ | Closed;
+```
+
+A variant type for the different socket states.
+
+### BrowserSocketServer
+
+_Module_
+
+A `BrowserSocketServer` listens for socket connect requests. When a request is received it will create a corresponding socket server side and complete the handshake.
+
+###### Types
+
+- [BrowserSocketServer](#BrowserSocketServer)
+- [Options](#Options)
+- [SocketOptions](#SocketOptions)
+- [State](#State)
+
+###### Constructors
+
+- [listen](#Listen)
+
+###### Methods
+
+- [stop](#Stop)
+
+#### BrowserSocketServer
+
+_Type_
+
+```ts
+class BrowserSocketServer {
+ public readonly address: string;
+ public readonly connect: Observable.t;
+ public readonly state: State;
+ public readonly stateChange: Observable.t;
+ public readonly stopped: Observable.t;
+}
+```
+
+Creates socket connections as requests come in.
+
+#### Options
+
+_Type_
+
+```ts
+type Options = {
+ /**
+ * The address of the server. The default is an empty string.
+ */
+ address?: string;
+ /**
+ * Allows intercepting connection requests and denying the request if
+ * necessary.
+ */
+ connectFilter?(message: MessageEvent): boolean;
+ /**
+ * Forwarded to the socket that is created on connection.
+ */
+ socketOptions?: SocketOptions;
+};
+```
+
+Options when creating a `BrowserSocketServer`.
+
+#### SocketOptions
+
+_Type_
+
+```ts
+type SocketOptions = {
+ disconnectTimeout?: number;
+ heartbeatInterval?: number;
+ heartbeatTimeout?: number;
+};
+```
+
+Options forwarded to the `BrowserSocket` when it is created.
+
+#### State
+
+_Type_
+
+```ts
+enum State {
+ Listening = "Listening",
+ Stopped = "Stopped"
+}
+```
+
+An enumerable of the different server states.
+
+#### Listen
+
+_Constructor_
+
+```ts
+function listen(options?: Options): BrowserSocketServer;
+```
+
+Creates a new `BrowserSocketServer`. Throws a `UniqueAddressError` if the address is already taken.
+
+#### Example
+
+```ts
+import * as BrowserSocketServer from "@daniel-nagy/transporter/BrowserSocketServer";
+
+const server = BrowserSocketServer.listen();
+server.connect.subscribe((socket) => socket.send("๐"));
+```
+
+#### Stop
+
+_Method_
+
+```ts
+function stop(): void;
+```
+
+Stops the server. A disconnect message will be sent to all connected clients.
+
+#### Example
+
+```ts
+import * as BrowserSocketServer from "@daniel-nagy/transporter/BrowserSocketServer";
+
+const server = BrowserSocketServer.listen();
+server.stop();
+```
+
+### StructuredCloneable
+
+_Module_
+
+A `StructuredCloneable` type can be passed between processes in the browser.
+
+###### Types
+
+- [StructuredCloneable](#StructuredCloneable)
+- [TypedArray](#TypedArray)
+
+#### StructuredCloneable
+
+_Type_
+
+```ts
+type StructuredCloneable =
+ | void
+ | null
+ | undefined
+ | boolean
+ | number
+ | bigint
+ | string
+ | Date
+ | ArrayBuffer
+ | RegExp
+ | TypedArray
+ | Array
+ | Map
+ | Set
+ | { [key: string]: StructuredCloneable };
+```
+
+A value that can be cloned using the [structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone).
+
+#### TypedArray
+
+_Type_
+
+```ts
+type TypedArray =
+ | BigInt64Array
+ | BigUint64Array
+ | Float32Array
+ | Float64Array
+ | Int8Array
+ | Int16Array
+ | Int32Array
+ | Uint8Array
+ | Uint8ClampedArray
+ | Uint16Array
+ | Uint32Array;
+```
+
+A `TypedArray` object describes an array-like view of an underlying binary data buffer.
diff --git a/packages/browser/package.json b/packages/browser/package.json
new file mode 100644
index 0000000..58b7994
--- /dev/null
+++ b/packages/browser/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@daniel-nagy/transporter-browser",
+ "type": "module",
+ "version": "1.0.0-beta.3",
+ "description": "Typesafe distributed computing in the browser.",
+ "author": "Daniel Nagy <1622446+daniel-nagy@users.noreply.github.com>",
+ "repository": "github:daniel-nagy/transporter",
+ "license": "Apache-2.0",
+ "keywords": [
+ "client",
+ "message",
+ "observable",
+ "postMessage",
+ "proxy",
+ "pubsub",
+ "rpc",
+ "server",
+ "socket",
+ "soa"
+ ],
+ "files": [
+ "build",
+ "!build/.tsinfo"
+ ],
+ "exports": {
+ "./*": "./build/*.js",
+ "./*.js": "./build/*.js",
+ "./BrowserSocket": "./build/BrowserSocket/index.js",
+ "./BrowserSocket/index.js": "./build/BrowserSocket/index.js"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org"
+ },
+ "scripts": {
+ "prebuild": "bun clean",
+ "build": "tsc --project tsconfig-build.json",
+ "postbuild": "cp ../../LICENSE build",
+ "clean": "rm -rf node_modules build",
+ "eslint": "eslint src",
+ "eslint-fix": "eslint src --fix",
+ "prettier": "prettier --check --ignore-path .gitignore .",
+ "prettier-fix": "prettier --write --ignore-path .gitignore .",
+ "test": "web-test-runner"
+ },
+ "devDependencies": {
+ "@types/mocha": "^10.0.3",
+ "@types/sinon": "^17.0.1",
+ "@typescript-eslint/eslint-plugin": "^6.7.5",
+ "@typescript-eslint/parser": "^6.7.5",
+ "@web/dev-server-esbuild": "^1.0.0",
+ "@web/test-runner": "^0.18.0",
+ "@web/test-runner-playwright": "^0.11.0",
+ "eslint": "^8.51.0",
+ "eslint-plugin-expect-type": "^0.2.3",
+ "eslint-plugin-require-extensions": "^0.1.3",
+ "prettier": "^3.0.3",
+ "sinon": "^17.0.1",
+ "typescript": "^5.3.3"
+ },
+ "peerDependencies": {
+ "@daniel-nagy/transporter": "1.0.0-beta.3"
+ }
+}
diff --git a/packages/browser/src/BroadcastSubject.test.ts b/packages/browser/src/BroadcastSubject.test.ts
new file mode 100644
index 0000000..7b3200d
--- /dev/null
+++ b/packages/browser/src/BroadcastSubject.test.ts
@@ -0,0 +1,30 @@
+import { firstValueFrom } from "@daniel-nagy/transporter/Observable/index.js";
+import { assert, spy } from "sinon";
+
+import * as BroadcastSubject from "./BroadcastSubject.js";
+import * as Test from "./Test.js";
+
+const { test } = Test;
+
+test("using a broadcast subject to synchronize state between two same-origin browsing contexts", async () => {
+ const darkMode = BroadcastSubject.fromChannel("darkMode");
+ const next = spy();
+
+ darkMode.subscribe(next);
+
+ await Test.createIframe(
+ /* html */ `
+
+ `,
+ { crossOrigin: false }
+ );
+
+ await firstValueFrom(darkMode);
+
+ assert.calledOnce(next);
+ assert.calledWith(next, true);
+});
diff --git a/packages/browser/src/BroadcastSubject.ts b/packages/browser/src/BroadcastSubject.ts
new file mode 100644
index 0000000..864ee9c
--- /dev/null
+++ b/packages/browser/src/BroadcastSubject.ts
@@ -0,0 +1,105 @@
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+import * as Session from "@daniel-nagy/transporter/Session.js";
+import * as Subject from "@daniel-nagy/transporter/Subject.js";
+import * as Subprotocol from "@daniel-nagy/transporter/Subprotocol.js";
+
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+export { BroadcastSubject as t };
+
+type Observer = Required>;
+
+const protocol = Subprotocol.init({
+ connectionMode: Subprotocol.ConnectionMode.Connectionless,
+ operationMode: Subprotocol.OperationMode.Broadcast,
+ protocol: Subprotocol.Protocol(),
+ transmissionMode: Subprotocol.TransmissionMode.Simplex
+});
+
+/**
+ * A `BroadcastSubject` can be used to synchronize state between same-origin
+ * browsing contexts or workers.
+ *
+ * @example
+ *
+ * const darkMode = new BroadcastSubject("darkMode");
+ *
+ * darkMode.subscribe(value => console.log(value));
+ */
+export class BroadcastSubject<
+ T extends StructuredCloneable.t
+> extends Subject.t {
+ constructor(public readonly name: string) {
+ super();
+
+ const observer = {
+ complete: () => this.#complete(),
+ error: (error: StructuredCloneable.t) => this.#error(error),
+ next: (value: T) => this.#next(value)
+ };
+
+ const resource = Session.Resource();
+
+ this.#client = Session.client({ protocol, resource });
+ this.#proxy = this.#client.createProxy();
+ this.#receiver = new BroadcastChannel(name);
+ this.#server = Session.server({ protocol, provide: observer });
+ this.#transmitter = new BroadcastChannel(name);
+
+ Observable.fromEvent(this.#receiver, "message")
+ .pipe(Observable.map((message) => message.data))
+ .subscribe(this.#server.input);
+
+ this.#client.output.subscribe((message) =>
+ this.#transmitter.postMessage(message)
+ );
+ }
+
+ #client: Session.ClientSession>;
+ #proxy: Observer;
+ #receiver: BroadcastChannel;
+ #server: Session.ServerSession>;
+ #transmitter: BroadcastChannel;
+
+ complete() {
+ if (this.state === Observable.State.NotComplete) this.#proxy.complete();
+ this.#complete();
+ }
+
+ error(error: unknown) {
+ if (this.state === Observable.State.NotComplete) this.#proxy.error(error);
+ this.#error(error);
+ }
+
+ next(value: T) {
+ if (this.state === Observable.State.NotComplete) this.#proxy.next(value);
+ this.#next(value);
+ }
+
+ #complete() {
+ this.#dispose();
+ super.complete();
+ }
+
+ #dispose() {
+ this.#client.terminate();
+ this.#receiver.close();
+ this.#server.terminate();
+ this.#transmitter.close();
+ }
+
+ #error(error: unknown) {
+ this.#dispose();
+ super.error(error);
+ }
+
+ #next(value: T) {
+ super.next(value);
+ }
+}
+
+export function fromChannel(
+ name: string
+): BroadcastSubject {
+ return new BroadcastSubject(name);
+}
diff --git a/packages/browser/src/BrowserClient.sw.test.ts b/packages/browser/src/BrowserClient.sw.test.ts
new file mode 100644
index 0000000..c7d9748
--- /dev/null
+++ b/packages/browser/src/BrowserClient.sw.test.ts
@@ -0,0 +1,31 @@
+import { assert } from "sinon";
+
+import * as BrowserClient from "./BrowserClient.js";
+import * as Test from "./Test.js";
+
+const { test } = Test;
+
+afterEach(async () => {
+ await navigator.serviceWorker
+ .getRegistrations()
+ .then((registrations) =>
+ Promise.all(registrations.map((r) => r.unregister()))
+ );
+});
+
+test("a frame making a request to a service worker", async () => {
+ const [worker] = await Test.createServiceWorker(/* ts */ `
+ import * as BrowserServer from "http://localhost:8000/packages/browser/src/BrowserServer.ts";
+
+ const server = BrowserServer.listen({
+ handle(_request) {
+ return "hi from the worker";
+ }
+ });
+ `);
+
+ const client = BrowserClient.from(worker);
+ const response = await client.fetch();
+
+ assert.match(response, "hi from the worker");
+});
diff --git a/packages/browser/src/BrowserClient.test.ts b/packages/browser/src/BrowserClient.test.ts
new file mode 100644
index 0000000..f056bab
--- /dev/null
+++ b/packages/browser/src/BrowserClient.test.ts
@@ -0,0 +1,129 @@
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+import { assert } from "sinon";
+
+import * as BrowserClient from "./BrowserClient.js";
+import * as BrowserServer from "./BrowserServer.js";
+import * as Test from "./Test.js";
+
+const { test } = Test;
+
+test("a child frame making a request to a parent frame", async () => {
+ BrowserServer.listen({
+ handle(_request) {
+ return "hi from the parent";
+ }
+ });
+
+ const srcDoc = /* html */ `
+
+ `;
+
+ const messageStream = Observable.fromEvent(self, "message");
+ await Test.createIframe(srcDoc);
+
+ const response = await Observable.firstValueFrom(
+ messageStream.pipe(
+ Observable.filter((message) => message.data.type === "received")
+ )
+ );
+
+ assert.match(response.data.response, "hi from the parent");
+});
+
+test("a parent frame making a request to a child frame", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const frame = await Test.createIframe(srcDoc);
+ const client = BrowserClient.from(frame.contentWindow);
+ const response = await client.fetch();
+
+ assert.match(response, "hi from the child");
+});
+
+test("a frame making a request to a dedicated worker", async () => {
+ const worker = await Test.createWorker(/* ts */ `
+ import * as BrowserServer from "http://localhost:8000/packages/browser/src/BrowserServer.ts";
+
+ const server = BrowserServer.listen({
+ handle(_request) {
+ return "hi from the worker";
+ }
+ });
+ `);
+
+ const client = BrowserClient.from(worker);
+ const response = await client.fetch();
+
+ assert.match(response, "hi from the worker");
+});
+
+test("a frame making a request to a shared worker", async () => {
+ const worker = await Test.createSharedWorker(/* ts */ `
+ import * as BrowserServer from "http://localhost:8000/packages/browser/src/BrowserServer.ts";
+
+ const server = BrowserServer.listen({
+ handle(_request) {
+ return "hi from the worker";
+ }
+ });
+ `);
+
+ const client = BrowserClient.from(worker);
+ const response = await client.fetch();
+
+ assert.match(response, "hi from the worker");
+});
+
+test("using an address", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const frame = await Test.createIframe(srcDoc);
+
+ const clientV1 = BrowserClient.from(frame.contentWindow, {
+ address: "app:api?v=1"
+ });
+
+ const clientV2 = BrowserClient.from(frame.contentWindow, {
+ address: "app:api?v=2"
+ });
+
+ assert.match(await clientV1.fetch(), "hi from api v1");
+ assert.match(await clientV2.fetch(), "hi from api v2");
+});
diff --git a/packages/browser/src/BrowserClient.ts b/packages/browser/src/BrowserClient.ts
new file mode 100644
index 0000000..f84afb1
--- /dev/null
+++ b/packages/browser/src/BrowserClient.ts
@@ -0,0 +1,128 @@
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+
+import * as Request from "./BrowserRequest.js";
+import * as Response from "./BrowserResponse.js";
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+export { BrowserClient as t };
+
+export type Options = {
+ /**
+ * The address of the server. The default is the empty string.
+ */
+ address?: string;
+ /**
+ * When connecting to a `Window` you may specify the allowed origin. If the
+ * window and the origin do not match the connection will fail. The origin is
+ * passed directly to the `targetOrigin` parameter of `postMessage` when
+ * connecting to the window. The default is `"*"`, which allows any origin.
+ */
+ origin?: string;
+};
+
+export class BrowserClient {
+ /**
+ * The address of the server. An address is like a port number, except an
+ * address can be any string instead of a meaningless number.
+ */
+ public readonly serverAddress: string;
+
+ /**
+ * If the window and the origin do not match the connection will fail. The
+ * origin is only relevant when connecting to a window since the browser
+ * will require worker URLs to be same-origin.
+ */
+ public readonly origin: string;
+
+ /**
+ * The message target. A message target is like a server host.
+ */
+ public readonly target: Window | Worker | SharedWorker | ServiceWorker;
+
+ constructor({
+ address = "",
+ origin = "*",
+ target
+ }: {
+ address?: string;
+ origin?: string;
+ target: Window | Worker | SharedWorker | ServiceWorker;
+ }) {
+ this.serverAddress = address;
+ this.origin = origin;
+ this.target = target;
+ }
+
+ /**
+ * Makes a request to the server. Returns a promise that resolves with the
+ * response from the server.
+ */
+ async fetch(body: StructuredCloneable.t): Promise {
+ const messageSink = getMessageSink(this.target);
+ const messageSource = getMessageSource(this.target);
+ const request = Request.t({ address: this.serverAddress, body });
+
+ const response = Observable.fromEvent(
+ messageSource,
+ "message"
+ ).pipe(
+ Observable.filter(
+ (message): message is MessageEvent =>
+ Response.isResponse(message) && message.data.id === request.id
+ ),
+ Observable.map((message) => message.data.body)
+ );
+
+ // Not sure it this is necessary or useful.
+ if (this.target instanceof ServiceWorker)
+ await navigator.serviceWorker.ready;
+
+ messageSink.postMessage(request, { targetOrigin: this.origin });
+
+ return Observable.firstValueFrom(response);
+ }
+}
+
+/**
+ * Creates a new `BrowserClient`.
+ *
+ * @example
+ *
+ * const worker = new Worker("/worker.js", { type: "module" });
+ * const client = BrowserClient.from(worker);
+ *
+ * const response = await client.fetch("๐");
+ */
+export function from(
+ target: Window | Worker | SharedWorker | ServiceWorker,
+ options: Options = {}
+) {
+ if (target instanceof SharedWorker) target.port.start();
+ return new BrowserClient({ ...options, target });
+}
+
+function getMessageSink(
+ target: Window | Worker | SharedWorker | ServiceWorker
+) {
+ switch (true) {
+ case target instanceof SharedWorker:
+ return target.port;
+ default:
+ return target;
+ }
+}
+
+function getMessageSource(
+ target: Window | Worker | SharedWorker | ServiceWorker
+) {
+ switch (true) {
+ case target instanceof ServiceWorker:
+ return navigator.serviceWorker;
+ case target instanceof SharedWorker:
+ return target.port;
+ case target instanceof Worker:
+ return target;
+ default:
+ return self;
+ }
+}
diff --git a/packages/browser/src/BrowserRequest.ts b/packages/browser/src/BrowserRequest.ts
new file mode 100644
index 0000000..6a6fb39
--- /dev/null
+++ b/packages/browser/src/BrowserRequest.ts
@@ -0,0 +1,50 @@
+import * as JsObject from "@daniel-nagy/transporter/JsObject.js";
+
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+export { Request as t };
+
+const Type = "Request";
+
+export type Request = {
+ address: string;
+ /**
+ * Contains the value sent by the client.
+ */
+ body: StructuredCloneable.t;
+ id: string;
+ /**
+ * The origin of the client making the request. The origin will be set
+ * securely on the server using `MessageEvent.origin`.
+ */
+ origin: string;
+ type: typeof Type;
+};
+
+/**
+ * A `Request` is created when a client makes a fetch request.
+ */
+export const Request = ({
+ address,
+ body
+}: {
+ address: string;
+ body: StructuredCloneable.t;
+}): Request => ({
+ address,
+ body,
+ id: crypto.randomUUID(),
+ origin: "",
+ type: Type
+});
+
+/**
+ * Returns `true` if the message contains a request object.
+ */
+export function isRequest(event: MessageEvent): event is MessageEvent {
+ return (
+ JsObject.isObject(event.data) &&
+ JsObject.has(event.data, "type") &&
+ event.data.type === Type
+ );
+}
diff --git a/packages/browser/src/BrowserResponse.ts b/packages/browser/src/BrowserResponse.ts
new file mode 100644
index 0000000..db56ac9
--- /dev/null
+++ b/packages/browser/src/BrowserResponse.ts
@@ -0,0 +1,45 @@
+import * as JsObject from "@daniel-nagy/transporter/JsObject.js";
+
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+export { Response as t };
+
+const Type = "Response";
+
+export type Response = {
+ /**
+ * The payload of the response. This is the value the client will receive.
+ */
+ body: StructuredCloneable.t;
+ id: string;
+ type: typeof Type;
+};
+
+/**
+ * A `Response` is created from the value returned by the server's request
+ * handler.
+ */
+export const Response = ({
+ body,
+ id
+}: {
+ body: StructuredCloneable.t;
+ id: string;
+}): Response => ({
+ body,
+ id,
+ type: Type
+});
+
+/**
+ * Returns `true` if the message contains a response object.
+ */
+export function isResponse(
+ event: MessageEvent
+): event is MessageEvent {
+ return (
+ JsObject.isObject(event.data) &&
+ JsObject.has(event.data, "type") &&
+ event.data.type === Type
+ );
+}
diff --git a/packages/browser/src/BrowserServer.test.ts b/packages/browser/src/BrowserServer.test.ts
new file mode 100644
index 0000000..39c1f0a
--- /dev/null
+++ b/packages/browser/src/BrowserServer.test.ts
@@ -0,0 +1,40 @@
+import * as AddressBook from "@daniel-nagy/transporter/AddressBook.js";
+import { assert, spy } from "sinon";
+
+import * as BrowserServer from "./BrowserServer.js";
+import * as Test from "./Test.js";
+
+const { test } = Test;
+
+test("A server must have a globally unique address", () => {
+ using _server = BrowserServer.listen({
+ address: "",
+ handle() {}
+ });
+
+ const listenSpy = spy(BrowserServer.listen);
+
+ try {
+ listenSpy({ address: "", handle() {} });
+ } catch (e) {
+ // empty
+ }
+
+ assert.threw(listenSpy, AddressBook.UniqueAddressError.name);
+});
+
+test("An address is released when the server is stopped", () => {
+ const server = BrowserServer.listen({
+ address: "",
+ handle() {}
+ });
+
+ server.stop();
+
+ using _server = BrowserServer.listen({
+ address: "",
+ handle() {}
+ });
+
+ assert.pass(true);
+});
diff --git a/packages/browser/src/BrowserServer.ts b/packages/browser/src/BrowserServer.ts
new file mode 100644
index 0000000..653610c
--- /dev/null
+++ b/packages/browser/src/BrowserServer.ts
@@ -0,0 +1,156 @@
+import * as AddressBook from "@daniel-nagy/transporter/AddressBook.js";
+import * as BehaviorSubject from "@daniel-nagy/transporter/BehaviorSubject.js";
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+
+import * as Request from "./BrowserRequest.js";
+import * as Response from "./BrowserResponse.js";
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+export { BrowserServer as t };
+
+const ADDRESS_SPACE = "BrowserServer";
+
+declare class SharedWorkerGlobalScope extends EventTarget {}
+
+interface ConnectEvent extends MessageEvent {
+ ports: [MessagePort, ...MessagePort[]];
+}
+
+export enum State {
+ Listening = "Listening",
+ Stopped = "Stopped"
+}
+
+/**
+ * Takes a request as input and returns a response that will be sent back to the
+ * client, completing the request/response cycle.
+ */
+export type RequestHandler = (
+ request: Readonly
+) => StructuredCloneable.t | Promise;
+
+export type Options = {
+ /**
+ * The address of the server. The default is the empty string. All servers
+ * must have a globally unique address.
+ */
+ address?: string;
+ /**
+ * Called whenever a request is received from a client. The request handler
+ * may return anything that is structured cloneable.
+ *
+ * The request object will contain the origin of the client. The origin can be
+ * used to validate the client before fulfilling the request.
+ */
+ handle: RequestHandler;
+};
+
+/**
+ * Provides request/response semantics on top of `postMessage`. It also
+ * normalizes the interface for connecting to different types of processes in
+ * the browser.
+ */
+export class BrowserServer {
+ /**
+ * The address of the server.
+ */
+ public readonly address: string;
+ public readonly handle: RequestHandler;
+
+ constructor({ address = "", handle }: Options) {
+ this.address = address;
+ this.handle = handle;
+
+ AddressBook.add(ADDRESS_SPACE, this.address);
+
+ const sharedWorker = typeof SharedWorkerGlobalScope !== "undefined";
+
+ if (sharedWorker) {
+ Observable.fromEvent(self, "connect")
+ .pipe(
+ Observable.map((event) => event.ports[0]),
+ Observable.tap((port) => port.start())
+ )
+ .subscribe((port) => this.#onConnect(port));
+ } else {
+ this.#onConnect(self);
+ }
+ }
+
+ #state = BehaviorSubject.of(State.Listening);
+
+ /**
+ * Returns the current state of the server.
+ */
+ get state() {
+ return this.#state.getValue();
+ }
+
+ /**
+ * Emits when the server's state changes. Completes after the server is
+ * stopped.
+ */
+ readonly stateChange = this.#state.asObservable();
+
+ /**
+ * Emits when the server is stopped and then completes.
+ */
+ readonly stopped = this.stateChange.pipe(
+ Observable.filter((state) => state === State.Stopped),
+ Observable.take(1)
+ );
+
+ /**
+ * Stops the server. Once stopped the server will no longer receive requests.
+ */
+ stop() {
+ this.#state.next(State.Stopped);
+ this.#state.complete();
+ AddressBook.release(ADDRESS_SPACE, this.address);
+ }
+
+ #onConnect(target: MessagePort | Window | Worker | ServiceWorker) {
+ Observable.fromEvent>(target, "message")
+ .pipe(
+ Observable.takeUntil(this.stopped),
+ Observable.filter(
+ (message): message is MessageEvent =>
+ Request.isRequest(message) && message.data.address === this.address
+ )
+ )
+ .subscribe(async (message) => {
+ const messageSink = message.source ?? target;
+ const request = { ...message.data, origin: message.origin };
+
+ messageSink.postMessage(
+ Response.t({
+ body: await this.handle(request),
+ id: message.data.id
+ }),
+ { targetOrigin: "*" }
+ );
+ });
+ }
+
+ [Symbol.dispose]() {
+ if (this.state === State.Listening) this.stop();
+ }
+}
+
+/**
+ * Creates a new `BrowserServer`.
+ *
+ * @throws {UniqueAddressError} If the address is already taken.
+ *
+ * @example
+ *
+ * const server = BrowserServer.listen({
+ * handle(request) {
+ * // Message received from client. Return any response.
+ * return "๐";
+ * }
+ * });
+ */
+export function listen(options: Options) {
+ return new BrowserServer(options);
+}
diff --git a/packages/browser/src/BrowserSocket/BrowserSocket.test.ts b/packages/browser/src/BrowserSocket/BrowserSocket.test.ts
new file mode 100644
index 0000000..09041d0
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/BrowserSocket.test.ts
@@ -0,0 +1,565 @@
+import { type SinonSpy, assert, match, spy, useFakeTimers } from "sinon";
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+
+import * as BrowserSocket from "./BrowserSocket.js";
+import * as BrowserSocketServer from "../BrowserSocketServer.js";
+import * as Error from "./Error.js";
+import * as Message from "./Message.js";
+import * as State from "./State.js";
+import * as Test from "../Test.js";
+
+const { test } = Test;
+
+test("a child frame connecting to a parent frame", async () => {
+ using server = BrowserSocketServer.listen();
+
+ const message = server.connect.pipe(
+ Observable.flatMap((socket) => socket.receive),
+ Observable.firstValueFrom
+ );
+
+ const srcDoc = /* html */ `
+
+ `;
+
+ await Test.createIframe(srcDoc);
+ assert.match(await message, "hi");
+});
+
+test("a parent frame connecting to a child frame", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ using socket = BrowserSocket.connect(iframe.contentWindow);
+ const message = socket.receive.pipe(Observable.firstValueFrom);
+
+ socket.send("What's up?");
+ assert.match(await message, "Not much. You?");
+});
+
+test("a frame connecting to a dedicated worker", async () => {
+ const worker = await Test.createWorker(/* ts */ `
+ import * as BrowserSocketServer from "http://localhost:8000/packages/browser/src/BrowserSocketServer.ts";
+
+ BrowserSocketServer.listen().connect.subscribe((socket) => {
+ socket.receive.subscribe((message) => {
+ switch (message) {
+ case "What's up?":
+ socket.send("Not much. You?");
+ }
+ });
+ });
+ `);
+
+ using socket = BrowserSocket.connect(worker);
+ const message = socket.receive.pipe(Observable.firstValueFrom);
+
+ socket.send("What's up?");
+ assert.match(await message, "Not much. You?");
+});
+
+test("a frame connecting to a shared worker", async () => {
+ const worker = await Test.createSharedWorker(/* ts */ `
+ import * as BrowserSocketServer from "http://localhost:8000/packages/browser/src/BrowserSocketServer.ts";
+
+ BrowserSocketServer.listen().connect.subscribe((socket) => {
+ socket.receive.subscribe((message) => {
+ switch (message) {
+ case "What's up?":
+ socket.send("Not much. You?");
+ }
+ });
+ });
+ `);
+
+ using socket = BrowserSocket.connect(worker);
+ const message = socket.receive.pipe(Observable.firstValueFrom);
+
+ socket.send("What's up?");
+ assert.match(await message, "Not much. You?");
+});
+
+describe("socket state transitions", () => {
+ test("a socket starts in a connected state", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ using socket = BrowserSocket.connect(iframe.contentWindow);
+ assert.match(socket.state, State.Connecting());
+ });
+
+ test("a socket transitions from a connecting state to a connected state once connected", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ using socket = BrowserSocket.connect(iframe.contentWindow);
+
+ await socket.connected.pipe(Observable.firstValueFrom);
+ assert.match(socket.state, State.Connected());
+ });
+
+ test("a socket transitions from a connecting state to a closing state if close is called before it is connected", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ using socket = BrowserSocket.connect(iframe.contentWindow);
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ socket.close();
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [State.Connecting(), State.Closing()]);
+ assert.match(socket.state, State.Closing());
+ });
+
+ test("a socket transitions from a connecting state to a closing state if the connection times out", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ const clock = useFakeTimers();
+
+ using socket = BrowserSocket.connect(iframe.contentWindow, {
+ connectTimeout: 1000
+ });
+
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+
+ clock.tick(1000);
+ clock.restore();
+
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ const closingState = {
+ ...State.Closing(),
+ error: match.instanceOf(Error.ConnectTimeoutError)
+ };
+
+ assert.match(seenStates, [State.Connecting(), closingState]);
+ assert.match(socket.state, closingState);
+ });
+
+ test("a socket transitions from a connecting state to a closing state if the buffer overflows", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+ using socket = BrowserSocket.connect(iframe.contentWindow, {
+ bufferLimit: 0
+ });
+
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ socket.send("hi");
+
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ const closingState = {
+ ...State.Closing(),
+ error: match.instanceOf(Observable.BufferOverflowError)
+ };
+
+ assert.match(seenStates, [State.Connecting(), closingState]);
+ assert.match(socket.state, closingState);
+ });
+
+ test("a socket transitions from a connecting state to a closing state if the socket is disposed", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+
+ let closing: Promise;
+ const seenStates: State.State[] = [];
+
+ {
+ using socket = BrowserSocket.connect(iframe.contentWindow);
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ closing = socket.closing.pipe(Observable.firstValueFrom);
+ }
+
+ await closing;
+
+ assert.match(seenStates, [State.Connecting(), State.Closing()]);
+ });
+
+ test("a socket transitions from a connected state to a closing state if the close method is called", async () => {
+ using _server = BrowserSocketServer.listen();
+ using socket = BrowserSocket.connect(self);
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ await socket.connected.pipe(Observable.firstValueFrom);
+ socket.close();
+
+ assert.match(seenStates, [
+ State.Connecting(),
+ State.Connected(),
+ State.Closing()
+ ]);
+ });
+
+ test("a socket transitions from a connected state to a closing state if a disconnect message is received", async () => {
+ const channel = new MessageChannel();
+ using socket = BrowserSocket.fromPort(channel.port1);
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ channel.port2.postMessage(Message.Disconnect());
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [
+ State.Connected(),
+ State.Closing(),
+ State.Closed()
+ ]);
+ });
+
+ test("a socket transitions from a connected state to a closing state if it is disposed", async () => {
+ const seenStates: State.State[] = [];
+ using _server = BrowserSocketServer.listen();
+
+ {
+ using socket = BrowserSocket.connect(self);
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ await socket.connected.pipe(Observable.firstValueFrom);
+ }
+
+ assert.match(seenStates, [
+ State.Connecting(),
+ State.Connected(),
+ State.Closing()
+ ]);
+ });
+
+ test("a socket transitions from a connected state to a closing state if a heartbeat times out.", async () => {
+ const clock = useFakeTimers({ shouldClearNativeTimers: true });
+ const channel = new MessageChannel();
+
+ const socket = BrowserSocket.fromPort(channel.port1, {
+ heartbeatInterval: 1000,
+ heartbeatTimeout: 1000
+ });
+
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+
+ clock.tick(2000);
+ clock.restore();
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [
+ State.Connected(),
+ {
+ ...State.Closing(),
+ error: match.instanceOf(Error.HeartbeatTimeoutError)
+ }
+ ]);
+ });
+
+ test("a socket transitions from a closing state to a closed state if a disconnected message is received", async () => {
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1);
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ socket.close();
+ channel.port2.postMessage(Message.Disconnected());
+ await socket.closed.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [
+ State.Connected(),
+ State.Closing(),
+ State.Closed()
+ ]);
+ });
+
+ test("a socket transitions from a closing state to a closed state if disconnecting times out", async () => {
+ const clock = useFakeTimers();
+ const channel = new MessageChannel();
+
+ const socket = BrowserSocket.fromPort(channel.port1, {
+ disconnectTimeout: 1000
+ });
+
+ const seenStates: State.State[] = [];
+
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ socket.close();
+ clock.tick(1000);
+ clock.restore();
+
+ assert.match(seenStates, [
+ State.Connected(),
+ State.Closing(),
+ {
+ ...State.Closed(),
+ error: match.instanceOf(Error.DisconnectTimeoutError)
+ }
+ ]);
+ });
+});
+
+test("the socket state completes once closed", async () => {
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1);
+ const complete = spy();
+
+ socket.stateChange.subscribe({ complete });
+ socket.close();
+ channel.port2.postMessage(Message.Disconnected());
+ await socket.closed.pipe(Observable.firstValueFrom);
+
+ assert.calledOnce(complete);
+});
+
+test("the message port is closed when a socket is closed", async () => {
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1);
+ const close = spy(channel.port1, "close");
+
+ socket.close();
+ channel.port2.postMessage(Message.Disconnected());
+ await socket.closed.pipe(Observable.firstValueFrom);
+
+ assert.calledOnce(close);
+});
+
+test("passing an origin when connecting to a window", async () => {
+ const srcDoc = /* html */ `
+
+ `;
+
+ const iframe = await Test.createIframe(srcDoc);
+
+ // The iframe will be cross origin so the connection will fail.
+ using socket = BrowserSocket.connect(iframe.contentWindow, {
+ // 10 is somewhat arbitrary but it seems to be long enough to avoid false
+ // positives.
+ connectTimeout: 10,
+ origin: location.origin
+ });
+
+ const seenStates: State.State[] = [];
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+ await socket.closing.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [
+ State.Connecting(),
+ { ...State.Closing(), error: match.instanceOf(Error.ConnectTimeoutError) }
+ ]);
+});
+
+test("the socket heartbeat is delayed until connection", async () => {
+ const clock = useFakeTimers();
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1, {
+ connected: false,
+ connectTimeout: 1000,
+ heartbeatInterval: 250,
+ heartbeatTimeout: 250
+ });
+
+ const seenStates: State.State[] = [];
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+
+ clock.tick(500);
+ clock.restore();
+ channel.port2.postMessage(Message.Connected());
+
+ await socket.connected.pipe(Observable.firstValueFrom);
+
+ assert.match(seenStates, [State.Connecting(), State.Connected()]);
+});
+
+test("the socket heartbeat is unsubscribed when the socket is closing", async () => {
+ const clock = useFakeTimers();
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1, {
+ heartbeatInterval: 1000,
+ heartbeatTimeout: 1000
+ });
+
+ const seenStates: State.State[] = [];
+ socket.stateChange.subscribe((state) => seenStates.push(state));
+
+ clock.tick(1500);
+ socket.close();
+ await socket.closing.pipe(Observable.firstValueFrom);
+ clock.tick(500);
+ clock.restore();
+
+ assert.match(seenStates, [State.Connected(), State.Closing()]);
+});
+
+test("no more messages can be sent once the socket is closing", async () => {
+ const port = new MessageChannel().port1;
+ const socket = BrowserSocket.fromPort(port);
+ const postMessage = spy(port, "postMessage");
+
+ socket.send("๐");
+ socket.close();
+ socket.send("๐");
+
+ assert.callCount(postMessage, 2);
+ assert.calledWith(postMessage.firstCall, "๐");
+ assert.calledWith(postMessage.secondCall, Message.Disconnect());
+ assert.neverCalledWith(postMessage, "๐");
+});
+
+test("a pong message is sent when a ping message is received", async () => {
+ const channel = new MessageChannel();
+ using _socket = BrowserSocket.fromPort(channel.port1);
+ const ping = Message.Ping();
+ const message = Observable.fromEvent(
+ channel.port2,
+ "message"
+ ).pipe(Observable.firstValueFrom);
+ channel.port2.start();
+ channel.port2.postMessage(ping);
+ assert.match((await message).data, Message.Pong({ id: ping.id }));
+});
+
+test("ping returns a promise that resolves when a pong message is received", async () => {
+ const channel = new MessageChannel();
+ using _socket1 = BrowserSocket.fromPort(channel.port1);
+ using socket2 = BrowserSocket.fromPort(channel.port2);
+ assert.match(await socket2.ping(), undefined);
+});
+
+test("ping rejects if a pong is not received before timing out", async () => {
+ const channel = new MessageChannel();
+ using socket = BrowserSocket.fromPort(channel.port1);
+ assert.match(
+ await socket.ping(10).catch((error) => error),
+ match.instanceOf(Observable.TimeoutError)
+ );
+});
+
+test("close is not called on dispose if the socket is already closed", async () => {
+ let close: SinonSpy<[error?: Error.ConnectionError | undefined], void>;
+ let closing: Promise;
+
+ {
+ using socket = BrowserSocket.fromPort(new MessageChannel().port1);
+ close = spy(socket, "close");
+ closing = socket.closing.pipe(Observable.firstValueFrom);
+ socket.close();
+ }
+
+ await closing;
+ assert.callCount(close, 1);
+});
+
+test("the socket unsubscribes from messages after it is closed", async () => {
+ const channel = new MessageChannel();
+ using socket = BrowserSocket.fromPort(channel.port1);
+ const complete = spy();
+
+ socket.receive.subscribe({ complete });
+ socket.close();
+
+ assert.calledOnce(complete);
+});
+
+test("internal messages are filtered from the public receive observable", async () => {
+ const channel = new MessageChannel();
+ const socket = BrowserSocket.fromPort(channel.port1, { connected: false });
+ const receive = spy();
+ socket.receive.subscribe(receive);
+
+ channel.port2.postMessage("๐");
+ channel.port2.postMessage(Message.Connected());
+
+ await socket.connected.pipe(Observable.firstValueFrom);
+
+ assert.callCount(receive, 1);
+ assert.calledWith(receive, "๐");
+ assert.neverCalledWith(receive, Message.Connected());
+});
+
+test("connecting to a server with an explicit address", async () => {
+ using server1 = BrowserSocketServer.listen();
+ using server2 = BrowserSocketServer.listen({ address: "๐" });
+ const server1Connect = spy();
+ const server2Connect = spy();
+ server1.connect.subscribe(server1Connect);
+ server2.connect.subscribe(server2Connect);
+ const socket = BrowserSocket.connect(self, { serverAddress: "๐" });
+
+ await socket.connected.pipe(Observable.firstValueFrom);
+
+ assert.callCount(server1Connect, 0);
+ assert.callCount(server2Connect, 1);
+});
+
+test("using a buffer overflow strategy", async () => {
+ using server = BrowserSocketServer.listen();
+
+ const message = server.connect.pipe(
+ Observable.flatMap((socket) => socket.receive),
+ Observable.firstValueFrom
+ );
+
+ using socket = BrowserSocket.connect(self, {
+ bufferLimit: 1,
+ bufferOverflowStrategy: Observable.BufferOverflowStrategy.DropOldest
+ });
+
+ socket.send("๐");
+ socket.send("๐ญ");
+ await socket.connected.pipe(Observable.firstValueFrom);
+
+ assert.match(await message, "๐ญ");
+});
diff --git a/packages/browser/src/BrowserSocket/BrowserSocket.ts b/packages/browser/src/BrowserSocket/BrowserSocket.ts
new file mode 100644
index 0000000..f640b24
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/BrowserSocket.ts
@@ -0,0 +1,386 @@
+import * as BehaviorSubject from "@daniel-nagy/transporter/BehaviorSubject.js";
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+import * as Subject from "@daniel-nagy/transporter/Subject.js";
+
+import * as Error from "./Error.js";
+import * as Message from "./Message.js";
+import * as State from "./State.js";
+import * as StructuredCloneable from "../StructuredCloneable.js";
+
+export { BrowserSocket as t };
+
+export interface Options {
+ /**
+ * The maximum number of messages to buffer before the socket is connected.
+ * The default is `Infinity`.
+ */
+ bufferLimit?: number;
+ /**
+ * What to do incase there is a buffer overflow. The default is to error.
+ */
+ bufferOverflowStrategy?: Observable.BufferOverflowStrategy;
+ /**
+ * The maximum amount of time to wait for a connection in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ connectTimeout?: number;
+ /**
+ * The maximum amount of time to wait for a disconnection in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ disconnectTimeout?: number;
+ /**
+ * The frequency at which to request heartbeats in milliseconds. The default
+ * is `1000` or 1 second.
+ */
+ heartbeatInterval?: number;
+ /**
+ * The maximum amount of time to wait for a heartbeat in milliseconds. The
+ * default is `2000` or 2 seconds.
+ */
+ heartbeatTimeout?: number;
+ /**
+ * The address of the socket server.
+ */
+ serverAddress?: string;
+}
+
+export interface PortOptions extends Options {
+ /**
+ * When creating a socket from a `MessagePort` you may specify if the socket
+ * is connected, bypassing the handshake and synchronously transitioning the
+ * socket to a connected state. The default is `true`.
+ */
+ connected?: boolean;
+}
+
+export interface WindowOptions extends Options {
+ /**
+ * When connecting to a `Window` you may specify the allowed origin. If the
+ * window and the origin do not match the connection will fail. The origin is
+ * passed directly to the `targetOrigin` parameter of `postMessage` when
+ * connecting to the window. The default is `"*"`, which allows any origin.
+ */
+ origin?: string;
+}
+
+/**
+ * A `BrowserSocket` is a connection between browsing contexts or a browsing
+ * context and a worker context.
+ *
+ * A socket is connection-oriented, duplex, and unicast. Any data that is
+ * structured cloneable can be passed through a browser socket.
+ */
+export class BrowserSocket {
+ constructor({
+ bufferLimit = Infinity,
+ bufferOverflowStrategy = Observable.BufferOverflowStrategy.Error,
+ connectTimeout = 2000,
+ disconnectTimeout = 2000,
+ heartbeatInterval = 1000,
+ heartbeatTimeout = 2000,
+ receive,
+ send
+ }: {
+ bufferLimit?: number;
+ bufferOverflowStrategy?: Observable.BufferOverflowStrategy;
+ connectTimeout?: number;
+ disconnectTimeout?: number;
+ heartbeatInterval?: number;
+ heartbeatTimeout?: number;
+ receive: Observable.t>;
+ send(message: StructuredCloneable.t): void;
+ }) {
+ this.connected = this.stateChange.pipe(
+ Observable.filter((state) => state.type === State.Type.Connected),
+ Observable.takeUntil(this.closing),
+ Observable.timeout(connectTimeout, () =>
+ Observable.fail(new Error.ConnectTimeoutError())
+ ),
+ Observable.take(1)
+ );
+
+ this.receive = receive.pipe(
+ Observable.map((message) => message.data),
+ Observable.filter((message) => !Message.isMessage(message)),
+ Observable.takeUntil(this.closing)
+ );
+
+ this.#disconnectTimeout = disconnectTimeout;
+
+ this.#receive = receive.pipe(
+ Observable.map((message) => message.data),
+ Observable.filter((message) => Message.isMessage(message)),
+ Observable.takeUntil(this.closed)
+ );
+
+ this.#send
+ .asObservable()
+ .pipe(
+ Observable.bufferUntil(this.connected, {
+ limit: bufferLimit,
+ overflowStrategy: bufferOverflowStrategy
+ })
+ )
+ .subscribe({
+ error: (
+ error: Observable.BufferOverflowError | Error.ConnectTimeoutError
+ ) => this.#close(error),
+ next: (message) => send(message)
+ });
+
+ this.connected.subscribe(() => {
+ Observable.cron(heartbeatInterval, () => this.ping(heartbeatTimeout))
+ .pipe(Observable.takeUntil(this.closing))
+ .subscribe({
+ error: () => this.#close(new Error.HeartbeatTimeoutError())
+ });
+ });
+
+ this.#receive.subscribe((message) => {
+ switch (true) {
+ case Message.isType(message, Message.Type.Connected):
+ this.#onConnected();
+ break;
+ case Message.isType(message, Message.Type.Disconnect): {
+ this.#onDisconnect();
+ break;
+ }
+ case Message.isType(message, Message.Type.Disconnected):
+ this.#onDisconnected();
+ break;
+ case Message.isType(message, Message.Type.Ping):
+ this.#onPing(message);
+ break;
+ }
+ });
+ }
+
+ #disconnectTimeout: number;
+ #receive: Observable.t;
+ #send = Subject.init();
+ #state = BehaviorSubject.of(State.Connecting());
+
+ /**
+ * Returns the current state of the socket.
+ */
+ get state() {
+ return this.#state.getValue();
+ }
+
+ /**
+ * Emits whenever the socket's state changes. Completes after the socket
+ * transitions to a closed state.
+ */
+ readonly stateChange = this.#state.asObservable();
+
+ /**
+ * Emits when the socket transitions to a closing state and then completes.
+ */
+ readonly closing = this.stateChange.pipe(
+ Observable.filter((state) => state.type === State.Type.Closing),
+ Observable.take(1)
+ );
+
+ /**
+ * Emits when the socket transitions to a closed state and then completes.
+ */
+ readonly closed = this.stateChange.pipe(
+ Observable.filter((state) => state.type === State.Type.Closed),
+ Observable.take(1)
+ );
+
+ /**
+ * Emits if the socket becomes connected and then completes. If the
+ * socket errors during connection it will complete without emitting.
+ */
+ readonly connected: Observable.t;
+
+ /**
+ * Emits whenever the socket receives a message. Internal messages are
+ * filtered from the observable stream.
+ */
+ readonly receive: Observable.t;
+
+ /**
+ * Closes the socket causing its state to transition to closing.
+ */
+ close() {
+ this.#close();
+ }
+
+ /**
+ * Sends a ping to a connected socket and waits for a pong to be sent back.
+ * The default timeout for a pong is `2000` milliseconds or 2 seconds.
+ *
+ * @returns A promise that resolves when a pong is received or rejects if a
+ * pong is not received in the allotted time.
+ */
+ ping(timeout: number = 2000): Promise {
+ const ping = Message.Ping();
+ this.#send.next(ping);
+
+ return this.#receive.pipe(
+ Observable.filter(
+ (message) =>
+ Message.isType(message, Message.Type.Pong) && message.id === ping.id
+ ),
+ Observable.timeout(timeout),
+ Observable.map(() => {}),
+ Observable.firstValueFrom
+ );
+ }
+
+ /**
+ * Sends data through the socket.
+ */
+ send(message: StructuredCloneable.t) {
+ if (this.state.type === State.Type.Closing && !Message.isMessage(message))
+ return;
+
+ this.#send.next(message);
+ }
+
+ #close(error?: Error.ConnectionError) {
+ this.#state.next(State.Closing(error));
+ this.#send.next(Message.Disconnect());
+
+ this.closed
+ .pipe(
+ Observable.timeout(this.#disconnectTimeout, () =>
+ Observable.fail(new Error.DisconnectTimeoutError())
+ )
+ )
+ .subscribe({
+ error: (error: Error.DisconnectTimeoutError) => {
+ this.#onDisconnected(error);
+ }
+ });
+ }
+
+ #onConnected() {
+ this.#state.next(State.Connected());
+ }
+
+ #onDisconnect() {
+ this.#state.next(State.Closing());
+ this.#onDisconnected();
+ }
+
+ #onDisconnected(error?: Error.DisconnectTimeoutError) {
+ this.#send.next(Message.Disconnected());
+ this.#send.complete();
+ this.#state.next(State.Closed(error));
+ this.#state.complete();
+ }
+
+ #onPing(message: Message.Ping) {
+ this.#send.next(Message.Pong({ id: message.id }));
+ }
+
+ [Symbol.dispose]() {
+ switch (this.state.type) {
+ case State.Type.Connecting:
+ case State.Type.Connected:
+ this.close();
+ break;
+ default:
+ // no default
+ }
+ }
+}
+
+/**
+ * Creates a new `BrowserSocket` and attempts to connect to a `Window`, a
+ * `Worker`, or a `SharedWorker`.
+ *
+ * @example
+ *
+ * using socket = BrowserSocket.connect(self.parent);
+ */
+export function connect(target: Window, options?: WindowOptions): BrowserSocket;
+export function connect(
+ target: SharedWorker | Worker,
+ options?: Options
+): BrowserSocket;
+export function connect(
+ target: SharedWorker | Window | Worker,
+ options?: Options | WindowOptions
+): BrowserSocket {
+ if (target instanceof SharedWorker)
+ return connectSharedWorker(target, options);
+
+ if (target instanceof Worker) return connectWorker(target, options);
+
+ return connectWindow(target, options);
+}
+
+/**
+ * Creates a new `BrowserSocket` from a `MessagePort`. The resulting socket is
+ * assumed to be connected, unless specified otherwise, bypassing the handshake
+ * and transitioning the socket to a connected state synchronously.
+ */
+export function fromPort(
+ port: MessagePort,
+ { connected = true, ...options }: PortOptions = {}
+) {
+ port.start();
+
+ const socket = new BrowserSocket({
+ ...options,
+ receive: Observable.fromEvent>(
+ port,
+ "message"
+ ),
+ send: (message: StructuredCloneable.t) => port.postMessage(message)
+ });
+
+ if (connected)
+ port.dispatchEvent(
+ new MessageEvent("message", { data: Message.Connected() })
+ );
+
+ socket.closed.subscribe(() => port.close());
+
+ return socket;
+}
+
+function connectSharedWorker(
+ worker: SharedWorker,
+ { serverAddress = "", ...options }: WindowOptions = {}
+) {
+ const channel = new MessageChannel();
+ const socket = fromPort(channel.port1, {
+ ...options,
+ connected: false
+ });
+ worker.port.start();
+ worker.port.postMessage(Message.Connect(serverAddress), [channel.port2]);
+ return socket;
+}
+
+function connectWindow(
+ window: Window,
+ { origin = "*", serverAddress = "", ...options }: WindowOptions = {}
+) {
+ const channel = new MessageChannel();
+ const socket = fromPort(channel.port1, {
+ ...options,
+ connected: false
+ });
+ window.postMessage(Message.Connect(serverAddress), origin, [channel.port2]);
+ return socket;
+}
+
+function connectWorker(
+ worker: Worker,
+ { serverAddress = "", ...options }: WindowOptions = {}
+) {
+ const channel = new MessageChannel();
+ const socket = fromPort(channel.port1, {
+ ...options,
+ connected: false
+ });
+ worker.postMessage(Message.Connect(serverAddress), [channel.port2]);
+ return socket;
+}
diff --git a/packages/browser/src/BrowserSocket/Error.ts b/packages/browser/src/BrowserSocket/Error.ts
new file mode 100644
index 0000000..4f87d6b
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/Error.ts
@@ -0,0 +1,22 @@
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+
+export class ConnectTimeoutError extends Error {
+ readonly name = "ConnectTimeoutError";
+}
+
+export class DisconnectTimeoutError extends Error {
+ readonly name = "DisconnectTimeoutError";
+}
+
+export class HeartbeatTimeoutError extends Error {
+ readonly name = "HeartbeatTimeoutError";
+}
+
+/**
+ * A variant type for the different reasons a socket may transition to a closing
+ * state with an error.
+ */
+export type ConnectionError =
+ | Observable.BufferOverflowError
+ | ConnectTimeoutError
+ | HeartbeatTimeoutError;
diff --git a/packages/browser/src/BrowserSocket/Message.ts b/packages/browser/src/BrowserSocket/Message.ts
new file mode 100644
index 0000000..f17e901
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/Message.ts
@@ -0,0 +1,157 @@
+import * as JsObject from "@daniel-nagy/transporter/JsObject.js";
+import * as StructuredCloneable from "../StructuredCloneable.js";
+
+export enum Type {
+ Connect = "Connect",
+ Connected = "Connected",
+ Disconnect = "Disconnect",
+ Disconnected = "Disconnected",
+ Ping = "Ping",
+ Pong = "Pong"
+}
+
+export type Connect = {
+ address: string;
+ type: Type.Connect;
+};
+
+/**
+ * Creates a new `Connect` message.
+ */
+export const Connect = (address: string = ""): Connect => ({
+ address,
+ type: Type.Connect
+});
+
+export type Connected = {
+ type: Type.Connected;
+};
+
+/**
+ * Creates a new `Connected` message.
+ */
+export const Connected = (): Connected => ({
+ type: Type.Connected
+});
+
+export type Disconnect = {
+ type: Type.Disconnect;
+};
+
+/**
+ * Creates a new `Disconnect` message.
+ */
+export const Disconnect = (): Disconnect => ({
+ type: Type.Disconnect
+});
+
+export type Disconnected = {
+ type: Type.Disconnected;
+};
+
+/**
+ * Creates a new `Disconnected` message.
+ */
+export const Disconnected = (): Disconnected => ({
+ type: Type.Disconnected
+});
+
+export type Ping = {
+ id: string;
+ type: Type.Ping;
+};
+
+/**
+ * Creates a new `Ping` message.
+ */
+export const Ping = ({
+ id = crypto.randomUUID()
+}: { id?: string } = {}): Ping => ({
+ id,
+ type: Type.Ping
+});
+
+export type Pong = {
+ id: string;
+ type: Type.Pong;
+};
+
+/**
+ * Creates a new `Pong` message.
+ */
+export const Pong = ({ id }: { id: string }): Pong => ({
+ id,
+ type: Type.Pong
+});
+
+/**
+ * A variant type for the different types of messages a socket may send.
+ */
+export type Message =
+ | Connect
+ | Connected
+ | Disconnect
+ | Disconnected
+ | Ping
+ | Pong;
+
+export type { Message as t };
+
+/**
+ * Returns `true` if the message is a socket message.
+ */
+export function isMessage(message: StructuredCloneable.t): message is Message {
+ return (
+ isType(message, Type.Connect) ||
+ isType(message, Type.Connected) ||
+ isType(message, Type.Disconnect) ||
+ isType(message, Type.Disconnected) ||
+ isType(message, Type.Ping) ||
+ isType(message, Type.Pong)
+ );
+}
+
+/**
+ * Returns `true` if the message is of the specified type, allowing its type to
+ * be narrowed.
+ */
+export function isType(
+ message: StructuredCloneable.t,
+ type: T
+): message is {
+ [Type.Connect]: Connect;
+ [Type.Connected]: Connected;
+ [Type.Disconnect]: Disconnect;
+ [Type.Disconnected]: Disconnected;
+ [Type.Ping]: Ping;
+ [Type.Pong]: Pong;
+}[T] {
+ return (
+ JsObject.isObject(message) &&
+ JsObject.has(message, "type") &&
+ message.type === type
+ );
+}
+
+/**
+ * Returns the type of the message or `null` if the message is not a socket
+ * message.
+ */
+export function typeOf(message: StructuredCloneable.t): Type | null {
+ switch (true) {
+ case isType(message, Type.Connect):
+ return Type.Connect;
+ case isType(message, Type.Connected):
+ return Type.Connected;
+ case isType(message, Type.Disconnect):
+ return Type.Disconnect;
+ case isType(message, Type.Disconnected):
+ return Type.Disconnected;
+ case isType(message, Type.Ping):
+ return Type.Ping;
+ case isType(message, Type.Pong):
+ return Type.Pong;
+ default:
+ return null;
+ }
+}
diff --git a/packages/browser/src/BrowserSocket/State.ts b/packages/browser/src/BrowserSocket/State.ts
new file mode 100644
index 0000000..15795e7
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/State.ts
@@ -0,0 +1,93 @@
+import * as Error from "./Error.js";
+
+export enum Type {
+ Connecting = "Connecting",
+ Connected = "Connected",
+ Closing = "Closing",
+ Closed = "Closed"
+}
+
+export type Connecting = {
+ type: Type.Connecting;
+};
+
+/**
+ * Creates a new `Connecting` state.
+ */
+export const Connecting = (): Connecting => ({
+ type: Type.Connecting
+});
+
+export type Connected = {
+ type: Type.Connected;
+};
+
+/**
+ * Creates a new `Connected` state.
+ */
+export const Connected = (): Connected => ({
+ type: Type.Connected
+});
+
+export type Closing = {
+ error?: E;
+ type: Type.Closing;
+};
+
+/**
+ * Creates a new `Closing` state.
+ */
+export const Closing = (error?: E): Closing => ({
+ error,
+ type: Type.Closing
+});
+
+export type Closed = {
+ error?: E;
+ type: Type.Closed;
+};
+
+/**
+ * Creates a new `Closed` state.
+ */
+export const Closed = (error?: E): Closed => ({
+ error,
+ type: Type.Closed
+});
+
+/*
+ * โโโโโโโโโโโโโโโโโโโโโโโโโ
+ * โ State.Connecting โโโโโโโโโโโโโ
+ * โโโโโโโโโโโโโฌโโโโโโโโโโโโ โ BufferOverflowError
+ * โ Message.Connected โ ConnectTimeoutError
+ * โโโโโโโโโโโโโผโโโโโโโโโโโโ โ close(error?)
+ * โ State.Connected โ โ Symbol.dispose
+ * โโโโโโโโโโโโโฌโโโโโโโโโโโโ โ
+ * โ Message.Disconnect โ
+ * โ HeartbeatTimeoutError โ
+ * โ close(error?) โ
+ * โ Symbol.dispose โ
+ * โโโโโโโโโโโโโผโโโโโโโโโโโโโ โ
+ * โ State.Closing(error?) โโโโโโโโโโโโ
+ * โโโโโโโโโโโโโฌโโโโโโโโโโโโโ
+ * โ Message.Disconnected
+ * โ DisconnectTimeoutError
+ * โโโโโโโโโโโโโผโโโโโโโโโโโโโ
+ * โ State.Closed(error?) โ
+ * โโโโโโโโโโโโโโโโโโโโโโโโโโ
+ */
+
+/**
+ * A variant type describing the state of a socket.
+ *
+ * A socket starts in a connecting state. From a connecting state the socket may
+ * transition to a connected state, either synchronously or asynchronously, or
+ * to a closing state if there is an error connecting. From a closing state a
+ * socket will transition to a closed state, either synchronously or
+ * asynchronously. The closed state is a terminal state.
+ */
+export type State =
+ | Connecting
+ | Connected
+ | Closing
+ | Closed;
diff --git a/packages/browser/src/BrowserSocket/index.ts b/packages/browser/src/BrowserSocket/index.ts
new file mode 100644
index 0000000..02bd6da
--- /dev/null
+++ b/packages/browser/src/BrowserSocket/index.ts
@@ -0,0 +1,4 @@
+export * from "./BrowserSocket.js";
+export * from "./Error.js";
+export * as Message from "./Message.js";
+export * as State from "./State.js";
diff --git a/packages/browser/src/BrowserSocketServer.ts b/packages/browser/src/BrowserSocketServer.ts
new file mode 100644
index 0000000..3c2c380
--- /dev/null
+++ b/packages/browser/src/BrowserSocketServer.ts
@@ -0,0 +1,185 @@
+import * as AddressBook from "@daniel-nagy/transporter/AddressBook.js";
+import * as BehaviorSubject from "@daniel-nagy/transporter/BehaviorSubject.js";
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+import * as Subject from "@daniel-nagy/transporter/Subject.js";
+
+import * as BrowserSocket from "./BrowserSocket/index.js";
+import * as StructuredCloneable from "./StructuredCloneable.js";
+
+import Message = BrowserSocket.Message;
+
+const ADDRESS_SPACE = "BrowserSocketServer";
+
+export { BrowserSocketServer as t };
+
+declare class SharedWorkerGlobalScope extends EventTarget {}
+
+interface ConnectEvent extends MessageEvent {
+ ports: [MessagePort, ...MessagePort[]];
+}
+
+export enum State {
+ Listening = "Listening",
+ Stopped = "Stopped"
+}
+
+export type SocketOptions = {
+ disconnectTimeout?: number;
+ heartbeatInterval?: number;
+ heartbeatTimeout?: number;
+};
+
+export type Options = {
+ /**
+ * The address of the server. The default is an empty string.
+ */
+ address?: string;
+ /**
+ * Allows intercepting connection requests and denying the request if
+ * necessary.
+ */
+ connectFilter?(message: MessageEvent): boolean;
+ /**
+ * Forwarded to the socket that is created on connection.
+ * See {@link BrowserSocket.Options}.
+ */
+ socketOptions?: SocketOptions;
+};
+
+/**
+ * A `BrowserSocketServer` listens for incoming connection requests and creates
+ * a socket to communicate with a client.
+ */
+export class BrowserSocketServer {
+ constructor({
+ address = "",
+ connectFilter = () => true,
+ receive,
+ socketOptions
+ }: {
+ address?: string;
+ connectFilter?(message: ConnectEvent): boolean;
+ receive: Observable.t>;
+ socketOptions?: SocketOptions;
+ }) {
+ this.address = address;
+
+ AddressBook.add(ADDRESS_SPACE, this.address);
+
+ receive
+ .pipe(
+ Observable.takeUntil(this.stopped),
+ Observable.filter(
+ (message): message is ConnectEvent =>
+ Message.isType(message.data, Message.Type.Connect) &&
+ message.data.address === address
+ ),
+ Observable.filter(connectFilter)
+ )
+ .subscribe((message) => this.#onConnect(message, socketOptions));
+ }
+
+ #clients: BrowserSocket.t[] = [];
+ #connect = Subject.init();
+ #state = BehaviorSubject.of(State.Listening);
+
+ /**
+ * The address of the socket server.
+ */
+ readonly address: string;
+
+ /**
+ * Emits whenever a connection is established with a client. Completes when
+ * the server is stopped.
+ */
+ readonly connect: Observable.t =
+ this.#connect.asObservable();
+
+ /**
+ * Returns the current state of the socket server.
+ */
+ get state() {
+ return this.#state.getValue();
+ }
+
+ /**
+ * Emits when the server's state changes. Completes after the socket
+ * transitions to a stopped state.
+ */
+ readonly stateChange = this.#state.asObservable();
+
+ /**
+ * Emits when the server is stopped and then completes.
+ */
+ readonly stopped = this.stateChange.pipe(
+ Observable.filter((state) => state === State.Stopped),
+ Observable.take(1)
+ );
+
+ /**
+ * Stops the server. A disconnect message will be sent to all connected
+ * clients.
+ */
+ stop() {
+ this.#state.next(State.Stopped);
+ this.#connect.complete();
+ this.#state.complete();
+ this.#clients.forEach((client) => client.send(Message.Disconnect()));
+ AddressBook.release(ADDRESS_SPACE, this.address);
+ }
+
+ #onConnect(
+ message: ConnectEvent,
+ socketOptions?: SocketOptions
+ ) {
+ const port = message.ports[0];
+ const socket = BrowserSocket.fromPort(port, socketOptions);
+
+ this.#clients.push(socket);
+ this.#connect.next(socket);
+
+ socket.closed.subscribe(() => {
+ this.#clients = this.#clients.filter((client) => client !== socket);
+ });
+
+ socket.send(Message.Connected());
+ }
+
+ [Symbol.dispose]() {
+ if (this.state === State.Listening) this.stop();
+ }
+}
+
+/**
+ * Creates a new `SocketServer` in the current browsing context or worker context.
+ *
+ * @throws {UniqueAddressError} If the address is already taken.
+ *
+ * @example
+ *
+ * const socketServer = BrowserSocketServer.listen();
+ *
+ * socketServer.connect.subscribe(socket => socket.send("๐"));
+ */
+export function listen(options?: Options) {
+ const sharedWorker = typeof SharedWorkerGlobalScope !== "undefined";
+
+ if (sharedWorker) {
+ return new BrowserSocketServer({
+ ...options,
+ receive: Observable.fromEvent(self, "connect").pipe(
+ Observable.map((event) => event.ports[0]),
+ Observable.tap((port) => port.start()),
+ Observable.flatMap((port) => Observable.fromEvent(port, "message"))
+ )
+ });
+ }
+
+ return new BrowserSocketServer({
+ ...options,
+ receive: Observable.fromEvent>(
+ self,
+ "message"
+ )
+ });
+}
diff --git a/packages/browser/src/StructuredCloneable.test.ts b/packages/browser/src/StructuredCloneable.test.ts
new file mode 100644
index 0000000..85d04b8
--- /dev/null
+++ b/packages/browser/src/StructuredCloneable.test.ts
@@ -0,0 +1,420 @@
+import { assert, match, spy } from "sinon";
+
+import * as Cache from "@daniel-nagy/transporter/Cache.js";
+import * as Injector from "@daniel-nagy/transporter/Injector.js";
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+import * as PubSub from "@daniel-nagy/transporter/PubSub.js";
+import * as Session from "@daniel-nagy/transporter/Session.js";
+import * as StructuredCloneable from "./StructuredCloneable.js";
+import * as Subprotocol from "@daniel-nagy/transporter/Subprotocol.js";
+
+import { test } from "./Test.js";
+
+describe("remote function call", () => {
+ test("calling a function", async () => {
+ const func = spy(async () => {});
+ const { proxy, dispose } = expose(func);
+ assert.match(await proxy(), undefined);
+ assert.match(func.callCount, 1);
+ assert.calledWithExactly(func);
+ dispose();
+ });
+
+ test("calling a function that returns type null", async () => {
+ const { proxy, dispose } = expose(async () => null);
+ assert.match(await proxy(), null);
+ dispose();
+ });
+
+ test("calling a function that returns type string", async () => {
+ const { proxy, dispose } = expose(async () => "๐");
+ assert.match(await proxy(), "๐");
+ dispose();
+ });
+
+ test("calling a function that returns type number", async () => {
+ const { proxy, dispose } = expose(async () => 13);
+ assert.match(await proxy(), 13);
+ dispose();
+ });
+
+ test("calling a function that returns type boolean", async () => {
+ const { proxy, dispose } = expose(async () => true);
+ assert.match(await proxy(), true);
+ dispose();
+ });
+
+ test("calling a function that returns type array", async () => {
+ const { proxy, dispose } = expose(async () => []);
+ assert.match(await proxy(), []);
+ dispose();
+ });
+
+ test("calling a function that returns type object", async () => {
+ const { proxy, dispose } = expose(async () => ({}));
+ assert.match(await proxy(), {});
+ dispose();
+ });
+
+ test("calling a function that returns a date", async () => {
+ const now = Date.now();
+ const { proxy, dispose } = expose(async () => new Date(now));
+ const date = await proxy();
+ assert.match(date, new Date(now));
+ dispose();
+ });
+
+ test("calling a function that returns a regex", async () => {
+ const { proxy, dispose } = expose(async () => /๐/);
+ const regex = await proxy();
+ assert.match(regex.test("๐"), true);
+ dispose();
+ });
+
+ test("calling a function that returns a bigint", async () => {
+ const { proxy, dispose } = expose(async () => 1n);
+ const big = await proxy();
+ assert.match(big, 1n);
+ dispose();
+ });
+
+ test("calling a function that returns an array buffer", async () => {
+ const { proxy, dispose } = expose(async () => new ArrayBuffer(8));
+ const buffer = await proxy();
+ assert.match(buffer, new ArrayBuffer(8));
+ dispose();
+ });
+
+ test("calling a function that returns a typed array", async () => {
+ const { proxy, dispose } = expose(async () => new Uint8Array(8));
+ const typedArray = await proxy();
+ assert.match(typedArray, new Uint8Array(8));
+ dispose();
+ });
+
+ test("calling a function that returns a map", async () => {
+ const { proxy, dispose } = expose(async () => new Map([["ok", "๐"]]));
+ const map = await proxy();
+ assert.match(map.get("ok"), "๐");
+ dispose();
+ });
+
+ test("calling a function that returns a set", async () => {
+ const { proxy, dispose } = expose(async () => new Set(["๐"]));
+ const set = await proxy();
+ assert.match(set.has("๐"), true);
+ dispose();
+ });
+
+ test("calling a function that returns a promise", async () => {
+ const { proxy, dispose } = expose(async () => Promise.resolve("๐"));
+ assert.match(await proxy(), "๐");
+ dispose();
+ });
+
+ test("calling a function that returns a function", async () => {
+ const { proxy, dispose } = expose(async () => async () => "๐");
+ assert.match(await (await proxy())(), "๐");
+ dispose();
+ });
+
+ test("calling a function that rejects", async () => {
+ const { proxy, dispose } = expose(async () => Promise.reject("๐ฃ"));
+ assert.match(await proxy().catch((e) => e), "๐ฃ");
+ dispose();
+ });
+
+ test("calling a function that throws", async () => {
+ const { proxy, dispose } = expose(async () => {
+ throw "๐ฃ";
+ });
+
+ assert.match(await proxy().catch((e) => e), "๐ฃ");
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type undefined", async () => {
+ const func = spy(async (_arg: undefined) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy(undefined);
+ assert.calledOnceWithExactly(func, undefined);
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type null", async () => {
+ const func = spy(async (_arg: null) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy(null);
+ assert.calledOnceWithExactly(func, null);
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type string", async () => {
+ const func = spy(async (_arg: string) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy("๐");
+ assert.calledOnceWithExactly(func, "๐");
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type number", async () => {
+ const func = spy(async (_arg: number) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy(13);
+ assert.calledOnceWithExactly(func, 13);
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type boolean", async () => {
+ const func = spy(async (_arg: boolean) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy(false);
+ assert.calledOnceWithExactly(func, false);
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type array", async () => {
+ const func = spy(async (_arg: []) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy([]);
+ assert.calledOnceWithExactly(func, []);
+ dispose();
+ });
+
+ test("calling a function that takes an argument of type object", async () => {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const func = spy(async (_arg: {}) => {});
+ const { proxy, dispose } = expose(func);
+ await proxy({});
+ assert.calledOnceWithExactly(func, {});
+ dispose();
+ });
+
+ test("calling a function that takes many arguments of different type", async () => {
+ const func = spy(
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ async (..._args: [undefined, null, string, number, boolean, [], {}]) => {}
+ );
+ const { proxy, dispose } = expose(func);
+ await proxy(undefined, null, "๐", 13, true, [], {});
+ assert.calledOnceWithExactly(func, undefined, null, "๐", 13, true, [], {});
+ dispose();
+ });
+
+ test("calling a function that takes a callback function", async () => {
+ const func = spy(async (callback: () => Promise) => callback());
+ const callback = spy(async () => "๐");
+ const { proxy, dispose } = expose(func);
+ const result = await proxy(callback);
+ assert.calledOnceWithExactly(func, match.func);
+ assert.calledOnceWithExactly(callback);
+ assert.match(result, "๐");
+ dispose();
+ });
+
+ test("callback chaining", async () => {
+ const callback1 = spy(async () => "๐");
+
+ const callback2 = spy(async (callback: () => Promise) =>
+ callback()
+ );
+
+ const func = spy(
+ async (callback: (callback: () => Promise) => Promise) =>
+ callback(callback1)
+ );
+
+ const { proxy, dispose } = expose(func);
+ const result = await proxy(callback2);
+ assert.calledOnceWithExactly(func, match.func);
+ assert.calledOnceWithExactly(callback2, match.func);
+ assert.calledOnceWithExactly(callback1);
+ assert.match(result, "๐");
+ dispose();
+ });
+
+ test("calling a function that returns a function", async () => {
+ const returnFunc = spy(async () => "๐");
+ const func = spy(async () => returnFunc);
+ const { proxy, dispose } = expose(func);
+ const result = await proxy();
+ assert.match(await result(), "๐");
+ assert.calledOnceWithExactly(func);
+ assert.calledOnceWithExactly(returnFunc);
+ dispose();
+ });
+
+ test("return function chaining", async () => {
+ const return1 = spy(async () => "๐");
+ const return2 = spy(async () => return1);
+
+ const func = spy(async () => return2);
+
+ const { proxy, dispose } = expose(func);
+ const result1 = await proxy();
+ const result2 = await result1();
+ assert.match(await result2(), "๐");
+ assert.calledOnceWithExactly(func);
+ assert.calledOnceWithExactly(return1);
+ assert.calledOnceWithExactly(return2);
+ dispose();
+ });
+
+ test("passing a proxy as an argument to a function", async () => {
+ const obj = spy({
+ a: async (callback: () => Promise) => callback(),
+ b: async () => "๐"
+ });
+
+ const { proxy, dispose } = expose(obj);
+ const result = await proxy.a(proxy.b);
+ assert.calledOnceWithExactly(obj.a, match.func);
+ assert.calledOnceWithExactly(obj.b);
+ assert.match(result, "๐");
+ dispose();
+ });
+
+ test("calling a function from a proxied object", async () => {
+ const { proxy, dispose } = expose({
+ add: async (a: number, b: number) => a + b
+ });
+
+ assert.match(await proxy.add(2, 2), 4);
+ dispose();
+ });
+
+ test("calling a function using the apply prototype method", async () => {
+ const { proxy, dispose } = expose(async (a: number, b: number) => a + b);
+ assert.match(await proxy.apply({}, [2, 2]), 4);
+ dispose();
+ });
+
+ test("calling a function using the apply prototype method with a different this arg", async () => {
+ const { proxy, dispose } = expose(async function (this: number, a: number) {
+ return this + a;
+ });
+
+ assert.match(await proxy.apply(3, [2]), 5);
+ dispose();
+ });
+
+ test("calling a function using the call prototype method", async () => {
+ const { proxy, dispose } = expose(async (a: number, b: number) => a + b);
+ assert.match(await proxy.call({}, 2, 2), 4);
+ dispose();
+ });
+
+ test("calling a function using the call prototype method with a different this arg", async () => {
+ const { proxy, dispose } = expose(async function (this: number, a: number) {
+ return this + a;
+ });
+ assert.match(await proxy.call(3, 2), 5);
+ dispose();
+ });
+
+ test("calling a function that returns a complex value", async () => {
+ const { proxy, dispose } = expose(async () => ({
+ a: "๐",
+ b: [12, true],
+ c: { c0: [null, { c1: "๐" }, undefined] },
+ d: undefined,
+ e: async () => "๐"
+ }));
+
+ const result = await proxy();
+
+ assert.match(result, {
+ a: "๐",
+ b: [12, true],
+ c: { c0: [null, { c1: "๐" }, undefined] },
+ d: undefined,
+ e: match.func
+ });
+
+ assert.match(await result.e(), "๐");
+ dispose();
+ });
+});
+
+test("using an observable", async () => {
+ const { proxy, dispose } = expose(PubSub.from(Observable.of(1, 2, 3)));
+ const complete = spy();
+ const next = spy();
+
+ await proxy.subscribe({ complete, next });
+ assert.match(next.callCount, 3);
+ assert.match(next.firstCall.args, [1]);
+ assert.match(next.secondCall.args, [2]);
+ assert.match(next.thirdCall.args, [3]);
+ assert.match(complete.callCount, 1);
+ dispose();
+});
+
+test("injecting a dependency", async () => {
+ type Service = { id: string };
+ const Service = Injector.Tag();
+ const injector = Injector.empty().add(Service, { id: "๐" });
+ const func = spy(async (_service: Service) => {});
+
+ const { proxy, dispose } = expose(Injector.provide([Service], func), {
+ server: { injector }
+ });
+
+ await proxy();
+ assert.calledOnceWithExactly(func, { id: "๐" });
+ dispose();
+});
+
+test("memoizing a proxied function", async () => {
+ const fn = spy(async () => ({ ok: "๐" }));
+ const { proxy, dispose } = expose(fn);
+ const memo = Cache.init().memo(proxy);
+
+ assert.match(await memo(), match.same(await memo()));
+ assert.match(fn.callCount, 1);
+ dispose();
+});
+
+const protocol = Subprotocol.init({
+ connectionMode: Subprotocol.ConnectionMode.ConnectionOriented,
+ operationMode: Subprotocol.OperationMode.Unicast,
+ protocol: Subprotocol.Protocol(),
+ transmissionMode: Subprotocol.TransmissionMode.Duplex
+});
+
+function connect(client: Session.t, server: Session.t) {
+ client.output.subscribe(server.input);
+ server.output.subscribe(client.input);
+}
+
+function expose(
+ value: T,
+ {
+ server: serverConfig
+ }: {
+ server?: { injector?: Injector.t };
+ } = {}
+) {
+ const client = Session.client({
+ protocol: protocol,
+ resource: Session.Resource()
+ });
+
+ const server = Session.server({
+ ...serverConfig,
+ protocol: protocol,
+ provide: value
+ });
+
+ connect(client, server);
+
+ return {
+ client,
+ dispose: () => {
+ client.terminate();
+ server.terminate();
+ },
+ proxy: client.createProxy(),
+ server
+ };
+}
diff --git a/packages/browser/src/StructuredCloneable.ts b/packages/browser/src/StructuredCloneable.ts
new file mode 100644
index 0000000..cc1f65d
--- /dev/null
+++ b/packages/browser/src/StructuredCloneable.ts
@@ -0,0 +1,40 @@
+/**
+ * A `TypedArray` object describes an array-like view of an underlying binary
+ * data buffer.
+ */
+export type TypedArray =
+ | BigInt64Array
+ | BigUint64Array
+ | Float32Array
+ | Float64Array
+ | Int8Array
+ | Int16Array
+ | Int32Array
+ | Uint8Array
+ | Uint8ClampedArray
+ | Uint16Array
+ | Uint32Array;
+
+/**
+ * A value that can be cloned using the structured clone algorithm.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
+ */
+export type StructuredCloneable =
+ | void
+ | null
+ | undefined
+ | boolean
+ | number
+ | bigint
+ | string
+ | Date
+ | ArrayBuffer
+ | RegExp
+ | TypedArray
+ | Array
+ | Map
+ | Set
+ | { [key: string]: StructuredCloneable };
+
+export type { StructuredCloneable as t };
diff --git a/packages/browser/src/StructuredCloneable.typetest.ts b/packages/browser/src/StructuredCloneable.typetest.ts
new file mode 100644
index 0000000..6b87b27
--- /dev/null
+++ b/packages/browser/src/StructuredCloneable.typetest.ts
@@ -0,0 +1,225 @@
+// These path imports are a workaround for https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type/issues/101.
+import * as Injector from "../../../node_modules/@daniel-nagy/transporter/src/Injector.js";
+import * as Session from "../../../node_modules/@daniel-nagy/transporter/src/Session.js";
+import * as StructuredCloneable from "./StructuredCloneable.js";
+import * as Subprotocol from "../../../node_modules/@daniel-nagy/transporter/src/Subprotocol.js";
+
+const protocol = Subprotocol.init({
+ connectionMode: Subprotocol.ConnectionMode.ConnectionOriented,
+ operationMode: Subprotocol.OperationMode.Unicast,
+ protocol: Subprotocol.Protocol(),
+ transmissionMode: Subprotocol.TransmissionMode.Duplex
+});
+
+protocol;
+// ^? const protocol: Subprotocol.t, Promise>>
+
+test("session types", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise>()
+ });
+
+ session;
+ // ^? const session: Session.ClientSession Promise>
+
+ const { input, output } = session;
+
+ input;
+ // ^? const input: Required>>
+ output;
+ // ^? const output: Observable>
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise
+});
+
+describe("Remote function call", () => {
+ test("a function that returns undefined", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise
+ });
+
+ test("a function that returns null", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise
+ });
+
+ test("a function that returns a string", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise<"๐">>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise<"๐">
+ });
+
+ test("a function that returns a number", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise<13>>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise<13>
+ });
+
+ test("a function that returns a boolean", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise
+ });
+
+ test("a function that returns an array", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise<[]>>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise<[]>
+ });
+
+ test("a function that returns an object", () => {
+ const session = Session.client({
+ protocol,
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ resource: Session.Resource<() => Promise<{}>>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise<{}>
+ });
+
+ test("a function that returns a function", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise<() => Promise>>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise<() => Promise>
+ });
+
+ test("function apply", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<(arg: number) => Promise>()
+ });
+
+ const proxy = session.createProxy();
+
+ const _result = proxy.apply(null, [3]);
+ // ^? const _result: Promise
+ });
+
+ test("function call", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<(arg: number) => Promise>()
+ });
+
+ const proxy = session.createProxy();
+
+ const _result = proxy.call(null, 3);
+ // ^? const _result: Promise
+ });
+
+ test("A callback parameter must return a promise", () => {
+ Session.client({
+ // @ts-expect-error Type 'void' is not assignable to type
+ // 'Promise>'.
+ protocol,
+ resource: Session.Resource<() => void>()
+ });
+ });
+
+ test("function bind", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<() => Promise>()
+ });
+
+ const proxy = session.createProxy();
+ const _value = proxy.bind(4);
+ // ^? const _value: () => Promise
+ });
+
+ test("proxied object", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<{ foo: () => Promise<"๐ฅธ"> }>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: {
+ // readonly foo: () => Promise<"๐ฅธ">;
+ // }
+ });
+
+ test("nested proxied objects", () => {
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource<{ foo: { bar: () => Promise<"๐ฅธ"> } }>()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: {
+ // readonly foo: {
+ // readonly bar: () => Promise<"๐ฅธ">;
+ // };
+ // }
+ });
+});
+
+describe("dependency injection", () => {
+ test("injected dependencies are omitted from the type", () => {
+ type Service = { id: string };
+ const Service = Injector.Tag();
+ const func = Injector.provide([Service], async (_service: Service) => {});
+
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: () => Promise
+ });
+
+ test("extra args are kept", () => {
+ type Service = { id: string };
+ const Service = Injector.Tag();
+
+ const func = Injector.provide(
+ [Service],
+ async (_service: Service, _a: number) => {}
+ );
+
+ const session = Session.client({
+ protocol,
+ resource: Session.Resource()
+ });
+
+ const _proxy = session.createProxy();
+ // ^? const _proxy: (_a: number) => Promise
+ });
+});
+
+declare function describe(message: string, callback: () => void): void;
+declare function test(message: string, callback: () => void): void;
diff --git a/packages/browser/src/Test.ts b/packages/browser/src/Test.ts
new file mode 100644
index 0000000..e736d99
--- /dev/null
+++ b/packages/browser/src/Test.ts
@@ -0,0 +1,165 @@
+import * as Observable from "@daniel-nagy/transporter/Observable/index.js";
+
+/**
+ * This module contains utilities for testing.
+ */
+
+type WithNonNullable = T & {
+ [P in K]: NonNullable;
+};
+
+/**
+ * An alias for `Mocha.it`.
+ */
+export const test = it;
+
+/**
+ * Creates an iframe from an HTML document. Any scripts in the document with the
+ * `data-transpile` attribute will be transpiled using TypeScript.
+ *
+ * By default the iframe will be cross-origin. To make the iframe same-origin
+ * you can set `crossOrigin` to `false`. This is intended mostly for debugging
+ * purposes.
+ *
+ * @returns A promise that resolves with the iframe element once the iframe has
+ * loaded.
+ */
+export async function createIframe(
+ srcDoc: string,
+ { crossOrigin = true }: { crossOrigin?: boolean } = {}
+) {
+ const iframe = document.createElement("iframe");
+
+ // makes the iframe cross origin
+ iframe.sandbox.add("allow-scripts");
+
+ if (!crossOrigin) {
+ iframe.sandbox.add("allow-same-origin");
+ }
+
+ iframe.srcdoc = /* html */ `
+
+
+ ${await transpileHtml(srcDoc)}
+ `;
+
+ const load = Observable.fromEvent(iframe, "load").pipe(
+ Observable.firstValueFrom
+ );
+
+ document.body.append(iframe);
+
+ return load.then(
+ () => iframe as WithNonNullable
+ );
+}
+
+/**
+ * Creates a `SharedWorker` from a script. The script will be transpiled using
+ * TypeScript.
+ */
+export async function createSharedWorker(src: string) {
+ const url = `data:text/javascript;charset=utf-8;base64,${btoa(
+ await transpile(src)
+ )}`;
+
+ return new SharedWorker(url, { type: "module" });
+}
+
+/**
+ * Creates a `ServiceWorker` from a script. The script will be transpiled using
+ * TypeScript.
+ *
+ * @returns A promise that resolves with the service worker and the registration
+ * after the service worker has been installed.
+ */
+export async function createServiceWorker(src: string) {
+ const fileName = "/serviceWorker.js";
+ await createScript(fileName, src);
+
+ const registration = await navigator.serviceWorker.register(fileName, {
+ type: "module"
+ });
+
+ const worker = registration.installing;
+
+ if (!worker)
+ throw new Error(
+ "Service worker is not installing. Is the test environment clean?"
+ );
+
+ worker.onerror = (error) => console.log(error.message);
+
+ await Observable.fromEvent(worker, "statechange").pipe(
+ Observable.filter(() => worker.state === "installed"),
+ Observable.firstValueFrom
+ );
+
+ return [worker, registration] as const;
+}
+
+/**
+ * Creates a `Worker` from a script. The script will be transpiled using
+ * TypeScript.
+ */
+export async function createWorker(src: string) {
+ const url = `data:text/javascript;charset=utf-8;base64,${btoa(
+ await transpile(src)
+ )}`;
+
+ return new Worker(url, { type: "module" });
+}
+
+/**
+ * Calls out to the dev server to create a script with the given filename.
+ */
+async function createScript(fileName: string, src: string): Promise {
+ return fetch("/create_script", {
+ body: JSON.stringify({ fileName, src: src }),
+ headers: { "Content-Type": "text/json" },
+ method: "POST"
+ }).then((result) => result.ok);
+}
+
+/**
+ * Calls out to the dev server to transpile a script using TypeScript.
+ */
+function transpile(src: string): Promise {
+ const polyfills = /* js */ `
+ Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
+ Symbol.dispose ??= Symbol("Symbol.dispose");
+ `;
+
+ return fetch("/transpile", {
+ body: /* js */ `
+ import "data:text/javascript;charset=utf-8;base64,${btoa(polyfills)}";
+ ${src}
+ `,
+ headers: { "Content-Type": "text/javascript" },
+ method: "POST"
+ }).then((result) => result.text());
+}
+
+/**
+ * Given an HTML document, transpiles the content of all script tags with the
+ * `data-transpile` attribute and returns the new document.
+ */
+function transpileHtml(html: string): Promise {
+ const container = Object.assign(document.createElement("div"), {
+ innerHTML: html
+ });
+
+ return Promise.all(
+ Array.from(container.querySelectorAll(`script[data-transpile]`)).map(
+ (script) =>
+ transpile(script.textContent!).then((text) => {
+ script.textContent = text;
+ })
+ )
+ ).then(() => container.innerHTML);
+}
diff --git a/packages/browser/testSetup.js b/packages/browser/testSetup.js
new file mode 100644
index 0000000..d2ba097
--- /dev/null
+++ b/packages/browser/testSetup.js
@@ -0,0 +1,12 @@
+Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
+Symbol.dispose ??= Symbol("Symbol.dispose");
+
+/**
+ * @type Mocha.MochaOptions
+ */
+const options = {};
+
+/**
+ * @see https://github.com/modernweb-dev/web/issues/1462
+ */
+globalThis["__WTR_CONFIG__"].testFrameworkConfig = options;
diff --git a/tsconfig.json b/packages/browser/tsconfig-base.json
similarity index 63%
rename from tsconfig.json
rename to packages/browser/tsconfig-base.json
index 4600456..9e9915c 100644
--- a/tsconfig.json
+++ b/packages/browser/tsconfig-base.json
@@ -2,22 +2,17 @@
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
- "declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
- "lib": ["es2018"],
- "moduleResolution": "node",
"noUncheckedIndexedAccess": true,
- "outDir": "build",
"resolveJsonModule": true,
+ "rootDir": "src",
"skipLibCheck": true,
- "sourceMap": true,
"strict": true,
- "target": "es2018",
"types": [],
"useDefineForClassFields": true
},
- "exclude": ["src/**/*.test.ts"],
- "include": ["src/**/*"]
+ "exclude": [],
+ "include": []
}
diff --git a/packages/browser/tsconfig-build.json b/packages/browser/tsconfig-build.json
new file mode 100644
index 0000000..d51126a
--- /dev/null
+++ b/packages/browser/tsconfig-build.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "declarationMap": true,
+ "lib": ["DOM", "ES2022", "ESNext.Disposable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmitOnError": true,
+ "outDir": "build",
+ "target": "ES2018",
+ "tsBuildInfoFile": "build/.tsinfo"
+ },
+ "exclude": ["src/Test.ts", "src/**/*.test.ts", "src/**/*.typetest.ts"],
+ "extends": "./tsconfig-base.json",
+ "include": ["src"]
+}
diff --git a/packages/browser/tsconfig-test.json b/packages/browser/tsconfig-test.json
new file mode 100644
index 0000000..12586ea
--- /dev/null
+++ b/packages/browser/tsconfig-test.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "ES2022", "ESNext.Disposable"],
+ "target": "ES2022",
+ "types": ["mocha"]
+ },
+ "extends": "./tsconfig-base.json",
+ "include": ["src/Test.ts", "src/**/*.test.ts"],
+ "references": [{ "path": "./tsconfig-build.json" }]
+}
diff --git a/packages/browser/tsconfig-typetest.json b/packages/browser/tsconfig-typetest.json
new file mode 100644
index 0000000..968b771
--- /dev/null
+++ b/packages/browser/tsconfig-typetest.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "extends": "./tsconfig-build.json",
+ "exclude": ["src/Test.ts", "src/**/*.test.ts"],
+ "include": ["src", "src/**/*.typetest.ts"]
+}
diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json
new file mode 100644
index 0000000..ffffdef
--- /dev/null
+++ b/packages/browser/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "files": [],
+ "include": [],
+ "references": [
+ { "path": "./tsconfig-build.json" },
+ { "path": "./tsconfig-test.json" },
+ { "path": "./tsconfig-typetest.json" }
+ ]
+}
diff --git a/packages/browser/web-test-runner.config.js b/packages/browser/web-test-runner.config.js
new file mode 100644
index 0000000..b0d4c1c
--- /dev/null
+++ b/packages/browser/web-test-runner.config.js
@@ -0,0 +1,127 @@
+import { esbuildPlugin } from "@web/dev-server-esbuild";
+import { playwrightLauncher } from "@web/test-runner-playwright";
+import ts from "typescript";
+
+import tsConfigBase from "./tsconfig-base.json" assert { type: "json" };
+import tsConfigTest from "./tsconfig-test.json" assert { type: "json" };
+
+/**
+ * @type import("@web/test-runner").TestRunnerConfig
+ */
+const config = {
+ browsers: [
+ playwrightLauncher({ product: "chromium" }),
+ playwrightLauncher({ product: "firefox" }),
+ playwrightLauncher({ product: "webkit" })
+ ],
+ files: ["src/**/*.test.ts", "!src/**/*.sw.test.ts"],
+ groups: [
+ // Unfortunately, Firefox does not support service worker scripts of type
+ // module. See https://github.com/mdn/browser-compat-data/issues/17023.
+ {
+ browsers: [
+ playwrightLauncher({ product: "chromium" }),
+ playwrightLauncher({ product: "webkit" })
+ ],
+ files: "src/**/*.sw.test.ts",
+ name: "service-worker"
+ }
+ ],
+ middleware: [cors, createScript, serveScript, transpile],
+ nodeResolve: true,
+ plugins: [esbuildPlugin({ target: "es2022", ts: true })],
+ rootDir: "../../",
+ testFramework: {
+ /**
+ * @type Mocha.MochaOptions
+ */
+ config: {}
+ },
+ // Workaround to this issue https://github.com/modernweb-dev/web/issues/1462
+ testRunnerHtml: (testFramework) => `
+
+
+
+
+
+
+
+ `
+};
+
+export default config;
+
+const scriptCache = new Map();
+
+/**
+ * Allows cross origin iframes to download scripts.
+ *
+ * @type import("@web/dev-server-core").Middleware
+ */
+function cors(context, next) {
+ context.set("Access-Control-Allow-Origin", "*");
+ return next();
+}
+
+/**
+ * @type import("@web/dev-server-core").Middleware
+ */
+async function createScript(context, next) {
+ if (context.url !== "/create_script") return next();
+
+ const body = await readableToString(context.req);
+ const data = JSON.parse(body);
+
+ scriptCache.set(data.fileName, data.src);
+ context.response.status = 200;
+
+ return next();
+}
+
+/**
+ * @type import("@web/dev-server-core").Middleware
+ */
+async function serveScript(context, next) {
+ const script = scriptCache.get(context.url);
+
+ if (!script) return next();
+
+ context.response.status = 200;
+ context.response.body = script;
+ context.set("Content-Type", "text/javascript");
+
+ return next();
+}
+
+/**
+ * @type import("@web/dev-server-core").Middleware
+ */
+async function transpile(context, next) {
+ if (context.url !== "/transpile") return next();
+
+ const body = await readableToString(context.req);
+
+ context.response.status = 200;
+ context.response.body = ts.transpile(body, {
+ ...tsConfigBase.compilerOptions,
+ ...tsConfigTest.compilerOptions
+ });
+
+ return next();
+}
+
+/**
+ * @type {(stream: ReadableStream) => Promise}
+ */
+function readableToString(readable) {
+ return new Promise((resolve, reject) => {
+ let data = "";
+
+ readable.on("data", (chunk) => {
+ data += chunk;
+ });
+
+ readable.on("end", () => resolve(data));
+ readable.on("error", (err) => reject(err));
+ });
+}
diff --git a/packages/core/.eslintrc.cjs b/packages/core/.eslintrc.cjs
new file mode 100644
index 0000000..af8d13a
--- /dev/null
+++ b/packages/core/.eslintrc.cjs
@@ -0,0 +1,32 @@
+module.exports = {
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:expect-type/recommended",
+ "plugin:require-extensions/recommended"
+ ],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ EXPERIMENTAL_useProjectService: true,
+ project: true,
+ tsconfigRootDir: __dirname
+ },
+ plugins: [
+ "@typescript-eslint",
+ "eslint-plugin-expect-type",
+ "require-extensions"
+ ],
+ root: true,
+ rules: {
+ "@typescript-eslint/no-namespace": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_"
+ }
+ ],
+ "expect-type/expect": "error"
+ }
+};
diff --git a/packages/core/.gitignore b/packages/core/.gitignore
new file mode 100644
index 0000000..c795b05
--- /dev/null
+++ b/packages/core/.gitignore
@@ -0,0 +1 @@
+build
\ No newline at end of file
diff --git a/packages/core/.prettierrc b/packages/core/.prettierrc
new file mode 100644
index 0000000..36b3563
--- /dev/null
+++ b/packages/core/.prettierrc
@@ -0,0 +1,3 @@
+{
+ "trailingComma": "none"
+}
diff --git a/packages/core/README.md b/packages/core/README.md
new file mode 100644
index 0000000..0304a52
--- /dev/null
+++ b/packages/core/README.md
@@ -0,0 +1,2196 @@
+# Core
+
+The core package contains APIs designed to work in any JavaScript runtime.
+
+```
+npm add @daniel-nagy/transporter
+```
+
+Transporter is distributed as ES modules. Generally speaking, modules encapsulate a type and export functions that act as either a constructor or operator on that type. The module has the same name as the type it encapsulates. You will often see this type reexported with the alias `t`. This is a common convention found in functional programming languages that allows dereferencing the type from the module without typing out the name twice, which feels icky. This makes using namespace imports with Transporter modules a bit nicer. For example,
+
+```typescript
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+// To access the type you need to type Observable twice ๐คฎ.
+const observable: Observable.Observable = Observable.of(1, 2, 3);
+
+// Equivalent to the above. Not perfect but better.
+const observable: Observable.t = Observable.of(1, 2, 3);
+```
+
+Transporter makes heavy use of namespace imports internally. If that makes you concerned about tree-shaking then don't be. Webpack, Rollup, and esbuild all handle namespace imports fine. It is namespace exports that may be problematic when it comes to tree-shaking. Though both webpack and Rollup seem to handle those as well, making esbuild the standout.
+
+## API
+
+Transporter contains the following modules.
+
+- [BehaviorSubject](#BehaviorSubject)
+- [Cache](#Cache)
+- [Injector](#Injector)
+- [Json](#Json)
+- [Message](#Message)
+- [Metadata](#Metadata)
+- [Observable](#Observable)
+- [Proxy](#Proxy)
+- [PubSub](#Pubsub)
+- [Session](#Session)
+- [Subject](#Subject)
+- [Subprotocol](#Subprotocol)
+- [SuperJson](#Superjson)
+
+### BehaviorSubject
+
+_Module_
+
+A `BehaviorSubject` is a `Subject` that replays the most recent value when subscribed to.
+
+###### Types
+
+- [BehaviorSubject](#BehaviorSubject)
+
+###### Constructors
+
+- [of](#Of)
+
+###### Methods
+
+- [getValue](#GetValue)
+
+#### BehaviorSubject
+
+_Type_
+
+```ts
+class BehaviorSubject extends Subject {}
+```
+
+A `Subject` that replays the last emitted value.
+
+#### Of
+
+_Constructor_
+
+```ts
+function of(value: T): BehaviorSubject;
+```
+
+Creates a new `BehaviorSubject` with an initial value of type `T`.
+
+##### Example
+
+```ts
+import * as BehaviorSubject from "@daniel-nagy/transporter/BehaviorSubject";
+
+BehaviorSubject.of("๐").subscribe(console.log);
+```
+
+#### GetValue
+
+_Method_
+
+```ts
+getValue(): T;
+```
+
+The `getValue` method can be used to synchronously retrieve the value held by the `BehaviorSubject`. If the `BehaviorSubject` is in an error state then `getValue` will throw the error.
+
+##### Example
+
+```ts
+import * as BehaviorSubject from "@daniel-nagy/transporter/BehaviorSubject";
+
+BehaviorSubject.of("๐").getValue();
+```
+
+### Cache
+
+_Module_
+
+A `Cache` may be used to memoize remote function calls. Transporter guarantees that proxies are referentially stable so other memoization APIs are likely compatible with Transporter as well.
+
+In order to memoize a function its arguments must be serializable. A stable algorithm is used to serialize a function's arguments and index the cache. The cache supports any arguments of type `SuperJson`.
+
+###### Types
+
+- [Cache](#Cache)
+
+###### Constructors
+
+- [init](#Init)
+
+###### Methods
+
+- [add](#Add)
+- [get](#Get)
+- [has](#Has)
+- [memo](#Memo)
+- [remove](#Remove)
+- [update](#Update)
+
+#### Cache
+
+_Type_
+
+```ts
+class Cache {}
+```
+
+A `Cache` is used to memoize remote function calls. A `Cache` is double-keyed by a function and its arguments.
+
+#### Init
+
+_Constructor_
+
+```ts
+function init(): Cache;
+```
+
+Creates a new `Cache`.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const cache = Cache.init();
+```
+
+#### Add
+
+_Method_
+
+```ts
+add(func: JsFunction.t, args: SuperJson.t[], value: unknown): void;
+```
+
+Adds the value to the cache for the specified function and arguments. Used internally by the `memo` method, which is the preferred way to add a value to the cache.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+
+Cache.init().add(identity, "๐ฅธ", "๐ฅธ");
+```
+
+#### Get
+
+_Method_
+
+```ts
+get(
+ func: (...args: Args) => Return,
+ args: Args
+): Return | NotFound;
+```
+
+Get a value from the cache. Returns `NotFound` if the value does not exist.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+
+Cache.init().get(identity, "๐ฅธ"); // NotFound
+```
+
+#### Has
+
+_Method_
+
+```ts
+has(func: JsFunction.t, args?: SuperJson.t[]): boolean
+```
+
+Checks if the value is in the cache. If no arguments are provided then it will return `true` if any value is cached for the function.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+
+Cache.init().has(identity, "๐ฅธ"); // false
+```
+
+#### Memo
+
+_Method_
+
+```ts
+memo(
+ func: (...args: Args) => Return
+): (...args: Args) => Return
+```
+
+Takes a function as input and returns a memoized version of the same function as output. Using a memoized function is the preferred way of adding values to the cache.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+const cache = Cache.init();
+const memo = Cache.memo(identity);
+memo("๐ฅธ");
+cache.has(identity, "๐ฅธ"); // true
+```
+
+#### Remove
+
+_Method_
+
+```ts
+remove(func: JsFunction.t, args?: SuperJson.t[]): boolean
+```
+
+Removes a value from the cache. Returns `true` if the value was found and removed. If no arguments are provided then all values for that function will be removed.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+const cache = Cache.init();
+const memo = Cache.memo(identity);
+memo("๐ฅธ");
+cache.remove(identity, "๐ฅธ"); // true
+```
+
+#### Update
+
+_Method_
+
+```ts
+update(
+ func: (...args: Args) => Return,
+ args: Args,
+ callback: (value: Return) => Return
+): void
+```
+
+Updates a value in the cache. The callback function will receive the current value in the cache. Does nothing if there is a cache miss on the value.
+
+##### Example
+
+```ts
+import * as Cache from "@daniel-nagy/transporter/Cache";
+
+const identity = (value) => value;
+const cache = Cache.init();
+const memo = Cache.memo(identity);
+memo("๐ฅธ");
+cache.update(identity, "๐ฅธ", () => "๐ค");
+```
+
+### Injector
+
+_Module_
+
+An `Injector` is IoC container and can be used to inject dependencies into functions invoked by Transporter.
+
+###### Types
+
+- [Injector](#Injector)
+- [Tag](#Tag)
+
+###### Constructors
+
+- [Tag](#Tag)
+- [empty](#Empty)
+
+###### Methods
+
+- [add](#Add)
+- [get](#Get)
+
+###### Functions
+
+- [getTags](#GetTags)
+- [provide](#Provide)
+
+#### Injector
+
+_Type_
+
+```ts
+class Injector {}
+```
+
+An `Injector` is a dependency container. Values may be added or read from the container using tags.
+
+#### Tag
+
+_Type_
+
+```ts
+type Tag {}
+```
+
+A `Tag` is a value that is bound to a single dependency type and is used to index the container.
+
+#### Empty
+
+_Constructor_
+
+```ts
+function empty(): Injector;
+```
+
+Creates a new empty `Injector`.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+const injector = Injector.empty();
+```
+
+#### Tag
+
+_Constructor_
+
+```ts
+function Tag(): Tag;
+```
+
+Creates a new `Tag`.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+type Session = {
+ userId?: string;
+};
+
+const SessionTag = Injector.Tag();
+```
+
+#### Add
+
+_Method_
+
+```ts
+function add(tag: Tag, value: Value): Injector;
+```
+
+Adds a value to the container.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+type Session = {
+ userId?: string;
+};
+
+const SessionTag = Injector.Tag();
+const Session: Session = { userId: "User_123" };
+Injector.empty().add(SessionTag, Session);
+```
+
+#### Get
+
+_Method_
+
+```ts
+function get(tag: Tag): unknown;
+```
+
+Gets a value from the container using a `Tag`.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+const tag = Injector.Tag();
+Injector.empty().get(tag);
+```
+
+#### GetTags
+
+_Function_
+
+```ts
+function getTags(func: JsFunction.t): : Tag[];
+```
+
+Returns a list of tags from a function returned by `provide`. If the function does not have DI metadata an empty list is returned.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+const getUser = Injector.provide([Prisma, Session], (prisma, session) =>
+ prisma.user.findUnique({ where: { id: session.userId } })
+);
+
+Injector.getTags(getUser);
+```
+
+#### Provide
+
+_Function_
+
+```ts
+function provide<
+ const Tags extends readonly Tag[],
+ const Args extends [...Values, ...unknown[]],
+ const Return
+>(
+ tags: Tags,
+ func: (...args: Args) => Return
+): (...args: JsArray.DropFirst>) => Return;
+```
+
+Returns a new function that has a list of tags stored as metadata. The call signature of the new function will omit any injected dependencies. Type parameters of generic functions will be propagated to the new function.
+
+##### Example
+
+```ts
+import * as Injector from "@daniel-nagy/transporter/Injector";
+
+const getUser = Injector.provide(
+ [Prisma, Session],
+ (prisma, session, select: S) =>
+ prisma.user.findUnique({ where: { id: session.userId }, select })
+);
+
+// $ExpectType
+// const getUser = (
+// select: S
+// ) => Prisma.Prisma__User | null;
+```
+
+### Json
+
+_Module_
+
+A `Json` type may be used as a subprotocol. If both ends of your communication channel are JavaScript runtimes then you may use the [SuperJson](#Superjson) module instead for a much larger set of types.
+
+###### Types
+
+- [Json](#Json)
+
+###### Functions
+
+- [serialize](#Serialize)
+- [sortDeep](#SortDeep)
+
+#### Json
+
+_type_
+
+```ts
+export type Json =
+ | null
+ | number
+ | string
+ | boolean
+ | { [key: string]: Json }
+ | Json[];
+```
+
+Represents a JSON value.
+
+#### Serialize
+
+```ts
+function serialize(value: Json): string;
+```
+
+Serializes a JSON value in a way that is deterministic, such that 2 strings are equal if they encode the same value.
+
+##### Example
+
+```ts
+import * as Json from "@daniel-nagy/transporter/Json";
+
+Json.serialize({ name: "Jane Doe" });
+```
+
+#### sortDeep
+
+```ts
+function sortDeep(value: Json): Json;
+```
+
+Recursively sorts the properties of an object. Array values retain their sort order.
+
+##### Example
+
+```ts
+import * as Json from "@daniel-nagy/transporter/Json";
+
+Json.sortDeep({
+ c: "c",
+ b: [{ f: "f", e: "e" }, 12],
+ a: "a"
+});
+
+// $ExpectType
+// {
+// a: "a",
+// b: [{ e: "e", f: "f" }, 12],
+// c: "c"
+// }
+```
+
+### Message
+
+_Module_
+
+Defines the Transporter message protocol. The creation and interpretation of messages should be considered internal. However, it is ok to intercept message and perform your own logic or encoding.
+
+###### Types
+
+- [CallFunction](#CallFunction)
+- [Error](#Error)
+- [GarbageCollect](#GarbageCollect)
+- [Message](#Message)
+- [SetValue](#SetValue)
+- [Type](#Type)
+- [Version](#Version)
+
+###### Constants
+
+- [protocol](#Protocol)
+- [version](#Version)
+
+###### Functions
+
+- [isCompatible](#IsCompatible)
+- [isMessage](#IsMessage)
+- [parseVersion](#ParseVersion)
+
+#### CallFunction
+
+_Type_
+
+```ts
+type CallFunction = {
+ readonly address: string;
+ readonly args: Args;
+ readonly id: string;
+ readonly noReply: boolean;
+ readonly path: string[];
+ readonly protocol: "transporter";
+ readonly type: Type.Call;
+ readonly version: Version;
+};
+```
+
+A `Call` message is sent to the server to call a remote function.
+
+#### Error
+
+_Type_
+
+```ts
+type Error = {
+ readonly address: string;
+ readonly error: Error;
+ readonly id: string;
+ readonly protocol: "transporter";
+ readonly type: Type.Error;
+ readonly version: Version;
+};
+```
+
+An `Error` message is sent to the client when calling a remote function throws or rejects.
+
+#### GarbageCollect
+
+_Type_
+
+```ts
+type GarbageCollect = {
+ readonly address: string;
+ readonly id: string;
+ readonly protocol: "transporter";
+ readonly type: Type.GarbageCollect;
+ readonly version: Version;
+};
+```
+
+A `GarbageCollect` message is sent to the server when a proxy is disposed on the client.
+
+#### Message
+
+_Type_
+
+```ts
+type Message =
+ | CallFunction
+ | Error
+ | GarbageCollect
+ | SetValue;
+```
+
+A discriminated union of the different message types.
+
+#### SetValue
+
+_Type_
+
+```ts
+type SetValue = {
+ readonly address: string;
+ readonly id: string;
+ readonly protocol: "transporter";
+ readonly type: Type.Set;
+ readonly value: Value;
+ readonly version: Version;
+};
+```
+
+A `Set` message is sent to the client after calling a remote function.
+
+#### Type
+
+_Type_
+
+```ts
+enum Type {
+ Call = "Call",
+ Error = "Error",
+ GarbageCollect = "GarbageCollect",
+ Set = "Set"
+}
+```
+
+An enumerable of the different message types.
+
+#### Version
+
+_Type_
+
+```ts
+type Version = `${number}.${number}.${number}`;
+```
+
+A semantic version string.
+
+#### Protocol
+
+_Constant_
+
+```ts
+const protocol = "transporter";
+```
+
+The name of the protocol.
+
+#### Version
+
+_Constant_
+
+```ts
+const version: Version;
+```
+
+The version of the protocol.
+
+#### IsCompatible
+
+_Function_
+
+```ts
+function isCompatible(messageVersion: Version): boolean;
+```
+
+Returns true if a message is compatible with the current protocol version. A
+message is considered compatible if its major and minor versions are the same.
+
+##### Example
+
+```ts
+import * as Message from "@daniel-nagy/transporter/Message";
+
+Message.isCompatible(message);
+```
+
+#### IsMessage
+
+_Function_
+
+```ts
+function isMessage(
+ message: T | Message
+): message is Message;
+```
+
+Returns true if the value is a Transporter message.
+
+##### Example
+
+```ts
+import * as Message from "@daniel-nagy/transporter/Message";
+
+Message.isMessage(value);
+```
+
+#### ParseVersion
+
+_Function_
+
+```ts
+function parseVersion(
+ version: Version
+): [major: string, minor: string, patch: string];
+```
+
+Parses a semantic version string and returns a tuple of the version segments.
+
+##### Example
+
+```ts
+import * as Message from "@daniel-nagy/transporter/Message";
+
+Message.parseVersion(message.version);
+```
+
+### Metadata
+
+_Module_
+
+The Metadata module allows information to be extracted from a proxy.
+
+###### Types
+
+- [Metadata](#Metadata)
+
+###### Functions
+
+- [get](#Get)
+
+#### Metadata
+
+_Type_
+
+```ts
+type Metadata = {
+ /**
+ * The address of the server that provides the value.
+ */
+ address: string;
+ /**
+ * The path to the value in the original object.
+ */
+ objectPath: string[];
+};
+```
+
+Contains information about a proxy object.
+
+#### Get
+
+```ts
+function get(proxy: Proxy): Metadata | null;
+```
+
+Returns metadata about a proxy. If the object is not a proxy it returns `null`.
+
+##### Example
+
+```ts
+import * as Metadata from "@daniel-nagy/transporter/Metadata";
+
+const metadata = Metadata.get(obj);
+```
+
+### Observable
+
+_Module_
+
+The Observable module provides [ReactiveX](https://reactivex.io/) APIs similar to [rxjs](https://rxjs.dev/). If you make heavy use of Observables then you may decide to use rxjs instead.
+
+Transporter observables should have interop with rxjs observables. If you encounter issues transforming to or from rxjs observables then you may report those issues.
+
+Transporter operators may behave differently than rxjs operators of the same name.
+
+###### Types
+
+- [BufferOverflowError](#BufferOverflowError)
+- [BufferOverflowStrategy](#BufferOverflowStrategy)
+- [BufferOptions](#BufferOptions)
+- [EmptyError](#EmptyError)
+- [Event](#Event)
+- [EventTarget](#EventTarget)
+- [Observable](#Observable)
+- [ObservableLike](#ObservableLike)
+- [Observer](#Observer)
+- [Operator](#Operator)
+- [Subscription](#Subscription)
+- [State](#Subscription)
+- [TimeoutError](#TimeoutError)
+
+###### Constructors
+
+- [cron](#Cron)
+- [fail](#Fail)
+- [from](#From)
+- [fromEvent](#FromEvent)
+- [of](#Of)
+
+###### Methods
+
+- [pipe](#Pipe)
+- [subscribe](#Subscribe)
+
+###### Functions
+
+- [bufferUntil](#BufferUntil)
+- [catchError](#CatchError)
+- [filter](#Filter)
+- [firstValueFrom](#FirstValueFrom)
+- [flatMap](#FlatMap)
+- [map](#Map)
+- [merge](#Merge)
+- [take](#Take)
+- [takeUntil](#TakeUntil)
+- [tap](#Tap)
+- [timeout](#Timeout)
+- [toObserver](#ToObserver)
+
+#### BufferOverflowError
+
+_Type_
+
+```ts
+class BufferOverflowError extends Error {}
+```
+
+Thrown if a buffer overflow occurs and the buffer overflow strategy is `Error`.
+
+#### BufferOverflowStrategy
+
+_Type_
+
+```ts
+enum BufferOverflowStrategy {
+ /**
+ * Discard new values as they arrive.
+ */
+ DropLatest = "DropLatest",
+ /**
+ * Discard old values making room for new values.
+ */
+ DropOldest = "DropOldest",
+ /**
+ * Error if adding a new value to the buffer will cause an overflow.
+ */
+ Error = "Error"
+}
+```
+
+Specifies what to do in the event of a buffer overflow.
+
+#### BufferOptions
+
+_Type_
+
+```ts
+type BufferOptions = {
+ /**
+ * The max capacity of the buffer. The default is `Infinity`.
+ */
+ limit?: number;
+ /**
+ * How to handle a buffer overflow scenario. The default is `Error`.
+ */
+ overflowStrategy?: BufferOverflowStrategy;
+};
+```
+
+Options for operators that perform buffering.
+
+#### EmptyError
+
+_Type_
+
+```ts
+class EmptyError extends Error {}
+```
+
+May be thrown by operators that expect a value to be emitted if the observable completes before emitting a single value.
+
+#### Event
+
+_Type_
+
+```ts
+interface Event {
+ type: string;
+}
+```
+
+Represents a JavaScript event. Necessary since Transporter does not include types for a specific runtime.
+
+#### EventTarget
+
+_Type_
+
+```ts
+interface EventTarget {
+ addEventListener(type: string, callback: (event: Event) => void): void;
+ dispatchEvent(event: Event): boolean;
+ removeEventListener(type: string, callback: (event: Event) => void): void;
+}
+```
+
+Represents a JavaScript event target. Necessary since Transporter does not include types for a specific runtime.
+
+#### Observable
+
+_Type_
+
+```ts
+class Observable implements ObservableLike {}
+```
+
+Observables are lazy push data structures that can emit values both synchronously and asynchronously. Observables are unicast and, unlike promises, may never emit a value or may emit many values.
+
+#### ObservableLike
+
+_Type_
+
+```ts
+interface ObservableLike {
+ subscribe(observerOrNext?: Observer | ((value: T) => void)): Subscription;
+}
+```
+
+A value is `ObservableLike` if it has a `subscribe` method that takes a function or `Observer` as input and returns a `Subscription`.
+
+#### Observer
+
+_Type_
+
+```ts
+type Observer = {
+ next?(value: T): void;
+ error?(error: unknown): void;
+ complete?(): void;
+};
+```
+
+An `Observer` subscribes to an observable.
+
+#### Operator
+
+_Type_
+
+```ts
+type Operator = (observable: ObservableLike) => ObservableLike;
+```
+
+An `Operator` is a function that takes an observable as input and returns a new observable as output.
+
+#### Subscription
+
+_Type_
+
+```ts
+type Subscription = {
+ unsubscribe(): void;
+};
+```
+
+A `Subscription` is returned when an observer subscribes to an observable.
+
+#### State
+
+_Type_
+
+```ts
+enum State {
+ Complete = "Complete",
+ Error = "Error",
+ NotComplete = "NotComplete",
+ Unsubscribed = "Unsubscribed"
+}
+```
+
+A discriminated type for the different states of an observable.
+
+#### TimeoutError
+
+_Type_
+
+```ts
+class TimeoutError extends Error {}
+```
+
+Thrown by the `timeout` operator if a value is not emitted within the specified amount of time.
+
+#### Cron
+
+_Constructor_
+
+```ts
+function cron(
+ interval: number,
+ callback: () => T | Promise
+): Observable;
+```
+
+Creates an observable that calls a function at a regular interval and emits the value returned by that function.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.cron(1000, () => Math.random()).subscribe(console.log);
+```
+
+#### Fail
+
+_Constructor_
+
+```ts
+function fail(errorOrCallback: E | (() => E)): Observable;
+```
+
+Creates an observable that will immediately error with the provided value. If the value is a function then the function will be called to get the value.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.fail("๐ฉ");
+```
+
+#### From
+
+_Constructor_
+
+```ts
+function from(observable: ObservableLike | PromiseLike): Observable;
+```
+
+Creates a new `Observable` from an object that is observable like or promise like.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.from(Promise.resolve("๐"));
+```
+
+#### FromEvent
+
+_Constructor_
+
+```ts
+function function fromEvent(target: EventTarget, type: string): Observable;
+```
+
+Creates a hot observable from an event target and an event type.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.fromEvent(button, "click");
+```
+
+#### Of
+
+_Constructor_
+
+```ts
+function of(...values: [T, ...T[]]): Observable;
+```
+
+Creates a new `Observable` that emits each argument synchronously and then completes.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).subscribe(console.log);
+```
+
+#### Pipe
+
+_Method_
+
+```ts
+pipe(
+ ...operations: [Operator, ..., Operator]
+ ): Observable
+```
+
+Allows chaining operators to perform flow control.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, "2", 3, 4.5).pipe(
+ filter(Number.isInteger),
+ map((num) => num * 2)
+);
+```
+
+#### Subscribe
+
+_Method_
+
+```ts
+subscribe(observerOrNext?: Observer | ((value: T) => void)): Subscription
+```
+
+Causes an `Observer` to start receiving values from an observable as they are emitted.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).subscribe(console.log);
+```
+
+#### BufferUntil
+
+_Function_
+
+```ts
+function bufferUntil(
+ signal: ObservableLike,
+ options?: BufferOptions
+): (observable: ObservableLike) => Observable;
+```
+
+Buffers emitted values until a signal emits or completes. Once the signal emits or completes the buffered values will be emitted synchronously.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+import * as Subject from "@daniel-nagy/transporter/Subject";
+
+const signal = Subject.init();
+Observable.of(1, 2, 3).pipe(bufferUntil(signal)).subscribe(console.log);
+setTimeout(() => signal.next(), 2000);
+```
+
+#### CatchError
+
+_Function_
+
+```ts
+function catchError(
+ callback: (error: E) => ObservableLike
+): (observable: ObservableLike) => Observable;
+```
+
+Catches an error emitted by an upstream observable. The callback function can return a new observable to recover from the error. The new observable will completely replace the old one.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3)
+ .pipe(
+ Observable.flatMap(() => Observable.fail("๐ฉ")),
+ Observable.catchError(() => Observable.of(4, 5, 6))
+ )
+ .subscribe(console.log);
+```
+
+#### Filter
+
+_Function_
+
+```ts
+function filter(
+ callback: ((value: T) => value is S) | ((value: T) => boolean)
+): (observable: ObservableLike) => Observable;
+```
+
+Selectively keeps values for which the callback returns `true`. All other values are discarded.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3)
+ .pipe(Observable.filter((num) => num % 2 === 0))
+ .subscribe(console.log);
+```
+
+#### FirstValueFrom
+
+_Function_
+
+```ts
+function firstValueFrom(observable: ObservableLike): Promise;
+```
+
+Transforms an observable into a promise that resolves with the first emitted value from the observable. If the observable errors the promise is rejected. If the observable completes without ever emitting a value the promise is rejected with an `EmptyError`.
+
+**WARNING**
+
+If the observable never emits the promise will never resolve.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+await Observable.firstValueFrom(Observable.of(1, 2, 3));
+```
+
+#### FlatMap
+
+_Function_
+
+```ts
+function flatMap(
+ callback: (value: T) => ObservableLike | PromiseLike
+): (observable: ObservableLike) => Observable;
+```
+
+Calls the callback function for each value emitted by the observable. The callback function returns a new observable that is flattened to avoid creating an observable of observables.
+
+The observable completes when the source observable and all inner observables complete.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).pipe(
+ Observable.flatMap((num) => Observable.of(num * 2))
+);
+```
+
+#### Map
+
+_Function_
+
+```ts
+function map(
+ callback: (value: T) => U
+): (observable: ObservableLike) => Observable;
+```
+
+Calls the callback function for each value emitted by the observable and emits the value returned by the callback function.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).pipe(Observable.map((num) => num * 2));
+```
+
+#### Merge
+
+_Function_
+
+```ts
+function merge(...observables: ObservableLike[]): Observable;
+```
+
+Merges 2 or more observables into a single observable. The resulting observable does not complete until all merged observables complete.
+
+Values will be emitted synchronously from each observable in the order provided. Any asynchronous values will be emitted in the order they arrive.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.merge(Observable.of(1, 2, 3), Observable.of(4, 5, 6)).subscribe(
+ console.log
+);
+```
+
+#### Take
+
+_Function_
+
+```ts
+function take(
+ amount: number
+): (observable: ObservableLike) => Observable;
+```
+
+Takes the first `n` values from an observable and then completes.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).pipe(Observable.take(2)).subscribe(console.log);
+```
+
+#### TakeUntil
+
+_Function_
+
+```ts
+function takeUntil(
+ signal: ObservableLike
+): (observable: ObservableLike) => Observable;
+```
+
+Takes values from an observable until a signal emits or completes.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+import * as Subject from "@daniel-nagy/transporter/Subject";
+
+const signal = Subject.init();
+Observable.cron(1000, () => Math.random()).pipe(Observable.takeUntil(signal));
+setTimeout(() => signal.next(), 2000);
+```
+
+#### Tap
+
+_Function_
+
+```ts
+function tap(
+ callback: (value: T) => unknown
+): (observable: ObservableLike) => Observable;
+```
+
+Allows performing effects when a value is emitted without altering the value that is emitted.
+
+##### Example
+
+```ts
+import * as Observable from "@daniel-nagy/transporter/Observable";
+
+Observable.of(1, 2, 3).pipe(Observable.tap(console.log)).subscribe();
+```
+
+#### Timeout
+
+_Function_
+
+```ts
+function timeout(
+ milliseconds: number,
+ callback?: (error: TimeoutError) => ObservableLike
+): (observable: ObservableLike) => Observable