initial commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
# @types/bun alias
|
||||
|
||||
This is an internal alias of the DefinitelyTyped `@types/bun` package. This alias exists to skip downloading the @types/bun package in Bun's own CI.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="bun-types" />
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@types/bun",
|
||||
"version": "1.2.2",
|
||||
"description": "TypeScript definitions for bun",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/bun",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Jarred Sumner",
|
||||
"githubUsername": "Jarred-Sumner",
|
||||
"url": "https://github.com/Jarred-Sumner"
|
||||
},
|
||||
{
|
||||
"name": "Ashcon Partovi",
|
||||
"githubUsername": "electroid",
|
||||
"url": "https://github.com/electroid"
|
||||
},
|
||||
{
|
||||
"name": "Robobun",
|
||||
"githubUsername": "robobun",
|
||||
"url": "https://github.com/robobun"
|
||||
},
|
||||
{
|
||||
"name": "Dylan Conway",
|
||||
"githubUsername": "dylan-conway",
|
||||
"url": "https://github.com/dylan-conway"
|
||||
},
|
||||
{
|
||||
"name": "Meghan Denny",
|
||||
"githubUsername": "nektro",
|
||||
"url": "https://github.com/nektro"
|
||||
},
|
||||
{
|
||||
"name": "Michael H",
|
||||
"githubUsername": "RiskyMH",
|
||||
"url": "https://github.com/RiskyMH"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/bun"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"bun-types": "workspace:"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "caeb56f2d4753af08a8e57856ec5e9d86ad3542da2171a68e19698ec4539995a",
|
||||
"typeScriptVersion": "5.0"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/macos
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=macos
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/macos
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/windows
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=windows
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/windows
|
||||
|
||||
#Added by cargo
|
||||
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
*.node
|
||||
|
||||
dist/
|
||||
|
||||
index.js
|
||||
index.d.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
target
|
||||
Cargo.lock
|
||||
.cargo
|
||||
.github
|
||||
npm
|
||||
.eslintrc
|
||||
.prettierignore
|
||||
rustfmt.toml
|
||||
yarn.lock
|
||||
*.node
|
||||
.yarn
|
||||
__test__
|
||||
renovate.json
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
edition = "2021"
|
||||
name = "bun-mdx-rs"
|
||||
version = "0.0.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
|
||||
napi = { version = "2.12.2", default-features = false, features = ["napi4"] }
|
||||
napi-derive = "2.12.2"
|
||||
mdxjs = "0.2.11"
|
||||
bun-native-plugin = { path = "../bun-native-plugin-rs" }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.0.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,34 @@
|
||||
# bun-build-mdx-rs
|
||||
|
||||
This is a proof of concept for using a third-party native addon in `Bun.build()`.
|
||||
|
||||
This uses `mdxjs-rs` to convert MDX to JSX.
|
||||
|
||||
TODO: **This needs to be built & published to npm.**
|
||||
|
||||
## Building locally:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
```js
|
||||
import { build } from "bun";
|
||||
import mdx from "./index.js";
|
||||
|
||||
// TODO: This needs to be prebuilt for the current platform
|
||||
// Probably use a napi-rs template for this
|
||||
import addon from "./target/release/libmdx_bun.dylib" with { type: "file" };
|
||||
|
||||
const results = await build({
|
||||
entrypoints: ["./hello.jsx"],
|
||||
plugins: [mdx({ addon })],
|
||||
minify: true,
|
||||
outdir: "./dist",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(results);
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
import test from "ava";
|
||||
|
||||
import { sum } from "../index.js";
|
||||
|
||||
test("sum from native", t => {
|
||||
t.is(sum(1, 2), 3);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import page1 from "./page1.mdx";
|
||||
import page2 from "./page2.mdx";
|
||||
import page3 from "./page3.mdx";
|
||||
import page4 from "./page4.mdx";
|
||||
|
||||
console.log(page1, page2, page3, page4);
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hello World
|
||||
|
||||
This is a sample MDX file that demonstrates various MDX features.
|
||||
|
||||
## Components
|
||||
|
||||
You can use JSX components directly in MDX:
|
||||
|
||||
<Button onClick={() => alert("Hello!")}>Click me</Button>
|
||||
|
||||
## Code Blocks
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hello World
|
||||
|
||||
This is a sample MDX file that demonstrates various MDX features.
|
||||
|
||||
## Components
|
||||
|
||||
You can use JSX components directly in MDX:
|
||||
|
||||
<Button onClick={() => alert("Hello!")}>Click me</Button>
|
||||
|
||||
## Code Blocks
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hello World
|
||||
|
||||
This is a sample MDX file that demonstrates various MDX features.
|
||||
|
||||
## Components
|
||||
|
||||
You can use JSX components directly in MDX:
|
||||
|
||||
<Button onClick={() => alert("Hello!")}>Click me</Button>
|
||||
|
||||
## Code Blocks
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hello World
|
||||
|
||||
This is a sample MDX file that demonstrates various MDX features.
|
||||
|
||||
## Components
|
||||
|
||||
You can use JSX components directly in MDX:
|
||||
|
||||
<Button onClick={() => alert("Hello!")}>Click me</Button>
|
||||
|
||||
## Code Blocks
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-darwin-arm64`
|
||||
|
||||
This is the **aarch64-apple-darwin** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-darwin-arm64",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "bun-mdx-rs.darwin-arm64.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.darwin-arm64.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-darwin-x64`
|
||||
|
||||
This is the **x86_64-apple-darwin** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-darwin-x64",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"main": "bun-mdx-rs.darwin-x64.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.darwin-x64.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-linux-arm64-gnu`
|
||||
|
||||
This is the **aarch64-unknown-linux-gnu** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-linux-arm64-gnu",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "bun-mdx-rs.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"libc": [
|
||||
"glibc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-linux-arm64-musl`
|
||||
|
||||
This is the **aarch64-unknown-linux-musl** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-linux-arm64-musl",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "bun-mdx-rs.linux-arm64-musl.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.linux-arm64-musl.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"libc": [
|
||||
"musl"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-linux-x64-gnu`
|
||||
|
||||
This is the **x86_64-unknown-linux-gnu** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-linux-x64-gnu",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"main": "bun-mdx-rs.linux-x64-gnu.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"libc": [
|
||||
"glibc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-linux-x64-musl`
|
||||
|
||||
This is the **x86_64-unknown-linux-musl** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-linux-x64-musl",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"main": "bun-mdx-rs.linux-x64-musl.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.linux-x64-musl.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"libc": [
|
||||
"musl"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `bun-mdx-rs-win32-x64-msvc`
|
||||
|
||||
This is the **x86_64-pc-windows-msvc** binary for `bun-mdx-rs`
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "bun-mdx-rs-win32-x64-msvc",
|
||||
"version": "0.0.0",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"main": "bun-mdx-rs.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"bun-mdx-rs.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "bun-mdx-rs",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"napi": {
|
||||
"name": "bun-mdx-rs",
|
||||
"triples": {
|
||||
"additional": [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"x86_64-unknown-linux-musl"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.4",
|
||||
"ava": "^6.0.1"
|
||||
},
|
||||
"ava": {
|
||||
"timeout": "3m"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"scripts": {
|
||||
"artifacts": "napi artifacts",
|
||||
"build": "napi build --platform --release",
|
||||
"build:debug": "napi build --platform",
|
||||
"prepublishOnly": "napi prepublish -t npm",
|
||||
"test": "ava",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
tab_spaces = 2
|
||||
edition = "2021"
|
||||
@@ -0,0 +1,25 @@
|
||||
use bun_native_plugin::{anyhow, bun, define_bun_plugin, BunLoader, Result};
|
||||
use mdxjs::{compile, Options as CompileOptions};
|
||||
use napi_derive::napi;
|
||||
|
||||
define_bun_plugin!("bun-mdx-rs");
|
||||
|
||||
#[bun]
|
||||
pub fn bun_mdx_rs(handle: &mut OnBeforeParse) -> Result<()> {
|
||||
let source_str = handle.input_source_code()?;
|
||||
|
||||
let mut options = CompileOptions::gfm();
|
||||
|
||||
// Leave it as JSX for Bun to handle
|
||||
options.jsx = true;
|
||||
|
||||
let path = handle.path()?;
|
||||
options.filepath = Some(path.to_string());
|
||||
|
||||
let jsx = compile(&source_str, &options)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to compile MDX: {:?}", e))?;
|
||||
|
||||
handle.set_output_source_code(jsx, BunLoader::BUN_LOADER_JSX);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
protocol/*/protocol.json linguist-generated=true
|
||||
protocol/*/index.d.ts linguist-generated=true
|
||||
@@ -0,0 +1 @@
|
||||
protocol/*.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# bun-debug-adapter-protocol
|
||||
|
||||
https://microsoft.github.io/debug-adapter-protocol/overview
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-debug-adapter-protocol",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.4",
|
||||
"source-map-js": "^1.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./src/debugger/adapter";
|
||||
export * from "./src/debugger/signal";
|
||||
export type * from "./src/protocol";
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "bun-debug-adapter-protocol",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.4",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import type { Protocol, Type } from "../src/protocol/schema";
|
||||
|
||||
run().catch(console.error);
|
||||
|
||||
async function run() {
|
||||
const cwd = new URL("../src/protocol/", import.meta.url);
|
||||
const runner = "Bun" in globalThis ? "bunx" : "npx";
|
||||
const write = (name: string, data: string) => {
|
||||
const path = new URL(name, cwd);
|
||||
writeFileSync(path, data);
|
||||
spawnSync(runner, ["prettier", "--write", path.pathname], { cwd, stdio: "ignore" });
|
||||
};
|
||||
const schema: Protocol = await download(
|
||||
"https://microsoft.github.io/debug-adapter-protocol/debugAdapterProtocol.json",
|
||||
);
|
||||
write("protocol.json", JSON.stringify(schema));
|
||||
const types = formatProtocol(schema);
|
||||
write("index.d.ts", `// GENERATED - DO NOT EDIT\n${types}`);
|
||||
}
|
||||
|
||||
function formatProtocol(protocol: Protocol, extraTs?: string): string {
|
||||
const { definitions } = protocol;
|
||||
const requestMap = new Map();
|
||||
const responseMap = new Map();
|
||||
const eventMap = new Map();
|
||||
let body = `export namespace DAP {`;
|
||||
loop: for (const [key, definition] of Object.entries(definitions)) {
|
||||
if (/[a-z]+Request$/i.test(key)) {
|
||||
continue;
|
||||
}
|
||||
if (/[a-z]+Arguments$/i.test(key)) {
|
||||
const name = key.replace(/(Request)?Arguments$/, "");
|
||||
const requestName = `${name}Request`;
|
||||
requestMap.set(toMethod(name), requestName);
|
||||
body += formatType(definition, requestName);
|
||||
continue;
|
||||
}
|
||||
if ("allOf" in definition) {
|
||||
const { allOf } = definition;
|
||||
for (const type of allOf) {
|
||||
if (type.type !== "object") {
|
||||
continue;
|
||||
}
|
||||
const { description, properties = {} } = type;
|
||||
if (/[a-z]+Event$/i.test(key)) {
|
||||
const { event, body: type = {} } = properties;
|
||||
if (!event || !("enum" in event)) {
|
||||
continue;
|
||||
}
|
||||
const [eventKey] = event.enum ?? [];
|
||||
eventMap.set(eventKey, key);
|
||||
const eventType: Type = {
|
||||
type: "object",
|
||||
description,
|
||||
...type,
|
||||
};
|
||||
body += formatType(eventType, key);
|
||||
continue loop;
|
||||
}
|
||||
if (/[a-z]+Response$/i.test(key)) {
|
||||
const { body: type = {} } = properties;
|
||||
const bodyType: Type = {
|
||||
type: "object",
|
||||
description,
|
||||
...type,
|
||||
};
|
||||
const name = key.replace(/Response$/, "");
|
||||
responseMap.set(toMethod(name), key);
|
||||
body += formatType(bodyType, key);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += formatType(definition, key);
|
||||
}
|
||||
for (const [key, name] of responseMap) {
|
||||
if (requestMap.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const requestName = `${name.replace(/Response$/, "")}Request`;
|
||||
requestMap.set(key, requestName);
|
||||
body += formatType({ type: "object", properties: {} }, requestName);
|
||||
}
|
||||
body += formatMapType("RequestMap", requestMap);
|
||||
body += formatMapType("ResponseMap", responseMap);
|
||||
body += formatMapType("EventMap", eventMap);
|
||||
if (extraTs) {
|
||||
body += extraTs;
|
||||
}
|
||||
return body + "};";
|
||||
}
|
||||
|
||||
function formatMapType(key: string, typeMap: Map<string, string>): string {
|
||||
const type: Type = {
|
||||
type: "object",
|
||||
required: [...typeMap.keys()],
|
||||
properties: Object.fromEntries([...typeMap.entries()].map(([key, value]) => [key, { $ref: value }])),
|
||||
};
|
||||
return formatType(type, key);
|
||||
}
|
||||
|
||||
function formatType(type: Type, key?: string): string {
|
||||
const { description, type: kind } = type;
|
||||
let body = "";
|
||||
if (key) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}\n`;
|
||||
}
|
||||
body += `export type ${key}=`;
|
||||
}
|
||||
if (kind === "boolean") {
|
||||
body += "boolean";
|
||||
} else if (kind === "number" || kind === "integer") {
|
||||
body += "number";
|
||||
} else if (kind === "string") {
|
||||
const { enum: choices } = type;
|
||||
if (choices) {
|
||||
body += choices.map(value => `"${value}"`).join("|");
|
||||
} else {
|
||||
body += "string";
|
||||
}
|
||||
} else if (kind === "array") {
|
||||
const { items } = type;
|
||||
const itemType = items ? formatType(items) : "unknown";
|
||||
body += `${itemType}[]`;
|
||||
} else if (kind === "object") {
|
||||
const { properties, required } = type;
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
body += "{}";
|
||||
} else {
|
||||
body += "{";
|
||||
for (const [key, { description, ...type }] of Object.entries(properties)) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}`;
|
||||
}
|
||||
const delimit = required?.includes(key) ? ":" : "?:";
|
||||
body += `\n${key}${delimit}${formatType(type)};`;
|
||||
}
|
||||
body += "}";
|
||||
}
|
||||
} else if ("$ref" in type) {
|
||||
const { $ref: ref } = type;
|
||||
body += ref.split("/").pop() || "unknown";
|
||||
} else if ("allOf" in type) {
|
||||
const { allOf } = type;
|
||||
body += allOf.map(type => formatType(type)).join("&");
|
||||
} else {
|
||||
body += "unknown";
|
||||
}
|
||||
if (key) {
|
||||
body += ";";
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function toMethod(name: string): string {
|
||||
return `${name.substring(0, 1).toLowerCase()}${name.substring(1)}`;
|
||||
}
|
||||
|
||||
function toComment(description?: string): string {
|
||||
if (!description) {
|
||||
return "";
|
||||
}
|
||||
const lines = ["/**", ...description.split("\n").map(line => ` * ${line.trim()}`), "*/"];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function download<T>(url: string | URL): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
export default {
|
||||
fetch(request) {
|
||||
const animal = getAnimal(request.url);
|
||||
const voice = animal.talk();
|
||||
return new Response(voice);
|
||||
},
|
||||
};
|
||||
function getAnimal(query) {
|
||||
switch (query.split("/").pop()) {
|
||||
case "dog":
|
||||
return new Dog();
|
||||
case "cat":
|
||||
return new Cat();
|
||||
}
|
||||
return new Bird();
|
||||
}
|
||||
class Dog {
|
||||
name = "dog";
|
||||
talk() {
|
||||
return "woof";
|
||||
}
|
||||
}
|
||||
class Cat {
|
||||
name = "cat";
|
||||
talk() {
|
||||
return "meow";
|
||||
}
|
||||
}
|
||||
class Bird {
|
||||
name = "bird";
|
||||
talk() {
|
||||
return "chirp";
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZXMvYnVuLWRlYnVnLWFkYXB0ZXItcHJvdG9jb2wvZGVidWdnZXIvZml4dHVyZXMvd2l0aC1zb3VyY2VtYXAudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImV4cG9ydCBkZWZhdWx0IHtcbiAgZmV0Y2gocmVxdWVzdDogUmVxdWVzdCk6IFJlc3BvbnNlIHtcbiAgICBjb25zdCBhbmltYWwgPSBnZXRBbmltYWwocmVxdWVzdC51cmwpO1xuICAgIGNvbnN0IHZvaWNlID0gYW5pbWFsLnRhbGsoKTtcbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKHZvaWNlKTtcbiAgfSxcbn07XG5cbmZ1bmN0aW9uIGdldEFuaW1hbChxdWVyeTogc3RyaW5nKTogQW5pbWFsIHtcbiAgc3dpdGNoIChxdWVyeS5zcGxpdChcIi9cIikucG9wKCkpIHtcbiAgICBjYXNlIFwiZG9nXCI6XG4gICAgICByZXR1cm4gbmV3IERvZygpO1xuICAgIGNhc2UgXCJjYXRcIjpcbiAgICAgIHJldHVybiBuZXcgQ2F0KCk7XG4gIH1cbiAgcmV0dXJuIG5ldyBCaXJkKCk7XG59XG5cbmludGVyZmFjZSBBbmltYWwge1xuICByZWFkb25seSBuYW1lOiBzdHJpbmc7XG4gIHRhbGsoKTogc3RyaW5nO1xufVxuXG5jbGFzcyBEb2cgaW1wbGVtZW50cyBBbmltYWwge1xuICBuYW1lID0gXCJkb2dcIjtcblxuICB0YWxrKCk6IHN0cmluZyB7XG4gICAgcmV0dXJuIFwid29vZlwiO1xuICB9XG59XG5cbmNsYXNzIENhdCBpbXBsZW1lbnRzIEFuaW1hbCB7XG4gIG5hbWUgPSBcImNhdFwiO1xuXG4gIHRhbGsoKTogc3RyaW5nIHtcbiAgICByZXR1cm4gXCJtZW93XCI7XG4gIH1cbn1cblxuY2xhc3MgQmlyZCBpbXBsZW1lbnRzIEFuaW1hbCB7XG4gIG5hbWUgPSBcImJpcmRcIjtcblxuICB0YWxrKCk6IHN0cmluZyB7XG4gICAgcmV0dXJuIFwiY2hpcnBcIjtcbiAgfVxufVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFBLGVBQWU7QUFBQSxFQUNiLE1BQU0sU0FBNEI7QUFDaEMsVUFBTSxTQUFTLFVBQVUsUUFBUSxHQUFHO0FBQ3BDLFVBQU0sUUFBUSxPQUFPLEtBQUs7QUFDMUIsV0FBTyxJQUFJLFNBQVMsS0FBSztBQUFBLEVBQzNCO0FBQ0Y7QUFFQSxTQUFTLFVBQVUsT0FBdUI7QUFDeEMsVUFBUSxNQUFNLE1BQU0sR0FBRyxFQUFFLElBQUksR0FBRztBQUFBLElBQzlCLEtBQUs7QUFDSCxhQUFPLElBQUksSUFBSTtBQUFBLElBQ2pCLEtBQUs7QUFDSCxhQUFPLElBQUksSUFBSTtBQUFBLEVBQ25CO0FBQ0EsU0FBTyxJQUFJLEtBQUs7QUFDbEI7QUFPQSxNQUFNLElBQXNCO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7QUFFQSxNQUFNLElBQXNCO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7QUFFQSxNQUFNLEtBQXVCO0FBQUEsRUFDM0IsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
@@ -0,0 +1,46 @@
|
||||
export default {
|
||||
fetch(request: Request): Response {
|
||||
const animal = getAnimal(request.url);
|
||||
const voice = animal.talk();
|
||||
return new Response(voice);
|
||||
},
|
||||
};
|
||||
|
||||
function getAnimal(query: string): Animal {
|
||||
switch (query.split("/").pop()) {
|
||||
case "dog":
|
||||
return new Dog();
|
||||
case "cat":
|
||||
return new Cat();
|
||||
}
|
||||
return new Bird();
|
||||
}
|
||||
|
||||
interface Animal {
|
||||
readonly name: string;
|
||||
talk(): string;
|
||||
}
|
||||
|
||||
class Dog implements Animal {
|
||||
name = "dog";
|
||||
|
||||
talk(): string {
|
||||
return "woof";
|
||||
}
|
||||
}
|
||||
|
||||
class Cat implements Animal {
|
||||
name = "cat";
|
||||
|
||||
talk(): string {
|
||||
return "meow";
|
||||
}
|
||||
}
|
||||
|
||||
class Bird implements Animal {
|
||||
name = "bird";
|
||||
|
||||
talk(): string {
|
||||
return "chirp";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
fetch(request) {
|
||||
return new Response(a());
|
||||
},
|
||||
};
|
||||
|
||||
function a() {
|
||||
return b();
|
||||
}
|
||||
|
||||
function b() {
|
||||
return c();
|
||||
}
|
||||
|
||||
function c() {
|
||||
function d() {
|
||||
return "hello";
|
||||
}
|
||||
return d();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Socket } from "node:net";
|
||||
const enum FramerState {
|
||||
WaitingForLength,
|
||||
WaitingForMessage,
|
||||
}
|
||||
|
||||
let socketFramerMessageLengthBuffer: Buffer;
|
||||
export class SocketFramer {
|
||||
state: FramerState = FramerState.WaitingForLength;
|
||||
pendingLength: number = 0;
|
||||
sizeBuffer: Buffer = Buffer.alloc(4);
|
||||
sizeBufferIndex: number = 0;
|
||||
bufferedData: Buffer = Buffer.alloc(0);
|
||||
socket: Socket;
|
||||
private onMessage: (message: string | string[]) => void;
|
||||
|
||||
constructor(socket: Socket, onMessage: (message: string | string[]) => void) {
|
||||
this.socket = socket;
|
||||
this.onMessage = onMessage;
|
||||
|
||||
if (!socketFramerMessageLengthBuffer) {
|
||||
socketFramerMessageLengthBuffer = Buffer.alloc(4);
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = FramerState.WaitingForLength;
|
||||
this.bufferedData = Buffer.alloc(0);
|
||||
this.sizeBufferIndex = 0;
|
||||
this.sizeBuffer = Buffer.alloc(4);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
socketFramerMessageLengthBuffer.writeUInt32BE(Buffer.byteLength(data), 0);
|
||||
this.socket.write(socketFramerMessageLengthBuffer);
|
||||
this.socket.write(data);
|
||||
}
|
||||
|
||||
onData(data: Buffer): void {
|
||||
this.bufferedData = this.bufferedData.length > 0 ? Buffer.concat([this.bufferedData, data]) : data;
|
||||
|
||||
let messagesToDeliver: string[] = [];
|
||||
let position = 0;
|
||||
|
||||
while (position < this.bufferedData.length) {
|
||||
// Need 4 bytes for the length
|
||||
if (this.bufferedData.length - position < 4) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read the length prefix
|
||||
const messageLength = this.bufferedData.readUInt32BE(position);
|
||||
|
||||
// Validate message length
|
||||
if (messageLength <= 0 || messageLength > 1024 * 1024) {
|
||||
// 1MB max
|
||||
// Try to resync by looking for the next valid message
|
||||
let newPosition = position + 1;
|
||||
let found = false;
|
||||
|
||||
while (newPosition < this.bufferedData.length - 4) {
|
||||
const testLength = this.bufferedData.readUInt32BE(newPosition);
|
||||
|
||||
if (testLength > 0 && testLength <= 1024 * 1024) {
|
||||
// Verify we can read the full message
|
||||
if (this.bufferedData.length - newPosition - 4 >= testLength) {
|
||||
const testMessage = this.bufferedData.toString("utf-8", newPosition + 4, newPosition + 4 + testLength);
|
||||
|
||||
if (testMessage.startsWith('{"')) {
|
||||
position = newPosition;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newPosition++;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// Couldn't find a valid message, discard buffer up to this point
|
||||
this.bufferedData = this.bufferedData.slice(position + 4);
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have the complete message
|
||||
if (this.bufferedData.length - position - 4 < messageLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = this.bufferedData.toString("utf-8", position + 4, position + 4 + messageLength);
|
||||
if (message.startsWith('{"')) {
|
||||
messagesToDeliver.push(message);
|
||||
}
|
||||
|
||||
position += 4 + messageLength;
|
||||
}
|
||||
|
||||
if (position > 0) {
|
||||
this.bufferedData =
|
||||
position < this.bufferedData.length ? this.bufferedData.slice(position) : SocketFramer.emptyBuffer;
|
||||
}
|
||||
|
||||
if (messagesToDeliver.length === 1) {
|
||||
this.onMessage(messagesToDeliver[0]);
|
||||
} else if (messagesToDeliver.length > 1) {
|
||||
this.onMessage(messagesToDeliver);
|
||||
}
|
||||
}
|
||||
|
||||
private static emptyBuffer = Buffer.from([]);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Server, Socket } from "node:net";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const isDebug = process.env.NODE_ENV === "development";
|
||||
|
||||
export type UnixSignalEventMap = {
|
||||
"Signal.listening": [string];
|
||||
"Signal.error": [Error];
|
||||
"Signal.received": [string];
|
||||
"Signal.closed": [];
|
||||
"Signal.Socket.closed": [socket: Socket];
|
||||
"Signal.Socket.connect": [socket: Socket];
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a server that listens for signals on a UNIX domain socket.
|
||||
*/
|
||||
export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
|
||||
#path: string;
|
||||
#server: Server;
|
||||
#ready: Promise<void>;
|
||||
|
||||
constructor(path?: string | URL | undefined) {
|
||||
super();
|
||||
this.#path = path ? parseUnixPath(path) : randomUnixPath();
|
||||
this.#server = createServer();
|
||||
this.#server.on("listening", () => this.emit("Signal.listening", this.#path));
|
||||
this.#server.on("error", error => this.emit("Signal.error", error));
|
||||
this.#server.on("close", () => this.emit("Signal.closed"));
|
||||
this.#server.on("connection", socket => {
|
||||
this.emit("Signal.Socket.connect", socket);
|
||||
socket.on("data", data => {
|
||||
this.emit("Signal.received", data.toString());
|
||||
});
|
||||
socket.on("close", () => {
|
||||
this.emit("Signal.Socket.closed", socket);
|
||||
});
|
||||
});
|
||||
this.#ready = new Promise((resolve, reject) => {
|
||||
this.#server.on("listening", resolve);
|
||||
this.#server.on("error", reject);
|
||||
});
|
||||
this.#server.listen(this.#path);
|
||||
}
|
||||
|
||||
emit<E extends keyof UnixSignalEventMap>(event: E, ...args: UnixSignalEventMap[E]): boolean {
|
||||
if (isDebug) {
|
||||
console.log(event, ...args);
|
||||
}
|
||||
|
||||
return super.emit(event, ...(args as never));
|
||||
}
|
||||
|
||||
/**
|
||||
* The path to the UNIX domain socket.
|
||||
*/
|
||||
get url(): string {
|
||||
return `unix://${this.#path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the server is listening or rejects if an error occurs.
|
||||
*/
|
||||
get ready(): Promise<void> {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the server.
|
||||
*/
|
||||
close(): void {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function randomUnixPath(): string {
|
||||
return join(tmpdir(), `${randomBytes(16).toString("hex")}.sock`);
|
||||
}
|
||||
|
||||
function parseUnixPath(path: string | URL): string {
|
||||
if (typeof path === "string" && path.startsWith("/")) {
|
||||
return path;
|
||||
}
|
||||
try {
|
||||
const { pathname } = new URL(path);
|
||||
return pathname;
|
||||
} catch {
|
||||
throw new Error(`Invalid UNIX path: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type TCPSocketSignalEventMap = {
|
||||
"Signal.listening": [];
|
||||
"Signal.error": [Error];
|
||||
"Signal.closed": [];
|
||||
"Signal.received": [string];
|
||||
"Signal.Socket.closed": [socket: Socket];
|
||||
"Signal.Socket.connect": [socket: Socket];
|
||||
};
|
||||
|
||||
export class TCPSocketSignal extends EventEmitter {
|
||||
#port: number;
|
||||
#server: ReturnType<typeof createServer>;
|
||||
#ready: Promise<void>;
|
||||
|
||||
constructor(port: number) {
|
||||
super();
|
||||
this.#port = port;
|
||||
|
||||
this.#server = createServer((socket: Socket) => {
|
||||
this.emit("Signal.Socket.connect", socket);
|
||||
|
||||
socket.on("data", data => {
|
||||
this.emit("Signal.received", data.toString());
|
||||
});
|
||||
|
||||
socket.on("error", error => {
|
||||
this.emit("Signal.error", error);
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
this.emit("Signal.Socket.closed", socket);
|
||||
});
|
||||
});
|
||||
|
||||
this.#server.on("close", () => {
|
||||
this.emit("Signal.closed");
|
||||
});
|
||||
|
||||
this.#ready = new Promise((resolve, reject) => {
|
||||
this.#server.listen(this.#port, "127.0.0.1", () => {
|
||||
const address = this.#server.address();
|
||||
if (address && typeof address === "object") {
|
||||
this.#port = address.port;
|
||||
}
|
||||
this.emit("Signal.listening");
|
||||
resolve();
|
||||
});
|
||||
this.#server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
emit<E extends keyof TCPSocketSignalEventMap>(event: E, ...args: TCPSocketSignalEventMap[E]): boolean {
|
||||
if (isDebug) {
|
||||
console.log(event, ...args);
|
||||
}
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TCP port.
|
||||
*/
|
||||
get port(): number {
|
||||
return this.#port;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return `tcp://127.0.0.1:${this.#port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the server is listening or rejects if an error occurs.
|
||||
*/
|
||||
get ready(): Promise<void> {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the server.
|
||||
*/
|
||||
close(): void {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { getRandomId, WebSocketDebugAdapter } from "./adapter.js";
|
||||
import { TCPSocketSignal } from "./signal.js";
|
||||
import { SourceMap } from "./sourcemap.js";
|
||||
|
||||
test("works without source map", () => {
|
||||
const sourceMap = getSourceMap("without-sourcemap.js");
|
||||
expect(sourceMap.generatedLocation({ line: 7 })).toEqual({ line: 7, column: 0, verified: true });
|
||||
expect(sourceMap.generatedLocation({ line: 7, column: 2 })).toEqual({ line: 7, column: 2, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 11 })).toEqual({ line: 11, column: 0, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 11, column: 2 })).toEqual({ line: 11, column: 2, verified: true });
|
||||
});
|
||||
|
||||
test("works with source map", () => {
|
||||
const sourceMap = getSourceMap("with-sourcemap.js");
|
||||
// FIXME: Columns don't appear to be accurate for `generatedLocation`
|
||||
expect(sourceMap.generatedLocation({ line: 3 })).toMatchObject({ line: 4, verified: true });
|
||||
expect(sourceMap.generatedLocation({ line: 27 })).toMatchObject({ line: 20, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 32 })).toEqual({ line: 43, column: 4, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 13 })).toEqual({ line: 13, column: 6, verified: true });
|
||||
});
|
||||
|
||||
function getSourceMap(filename: string): SourceMap {
|
||||
const { pathname } = new URL(`./fixtures/${filename}`, import.meta.url);
|
||||
const source = readFileSync(pathname, "utf-8");
|
||||
const match = source.match(/\/\/# sourceMappingURL=(.*)$/m);
|
||||
if (match) {
|
||||
const [, url] = match;
|
||||
return SourceMap(url);
|
||||
}
|
||||
return SourceMap();
|
||||
}
|
||||
|
||||
test("only forwards inspector events from known protocol domains to the adapter", () => {
|
||||
const adapter = new WebSocketDebugAdapter();
|
||||
|
||||
// Replace the launch request handler so a (wrongly) forwarded event is observable
|
||||
// without spawning any process.
|
||||
const launchCalls: unknown[][] = [];
|
||||
(adapter as any).launch = (...args: unknown[]) => {
|
||||
launchCalls.push(args);
|
||||
};
|
||||
|
||||
const inspector = adapter.getInspector();
|
||||
|
||||
// The WebSocket inspector re-emits any message without an "id" using the method name
|
||||
// chosen by the remote peer. An event named after a DAP request must not be forwarded
|
||||
// to the adapter, where it would be dispatched to the matching request handler.
|
||||
(inspector as any).emit("launch", {
|
||||
runtime: "/bin/sh",
|
||||
runtimeArgs: ["-c", "echo unexpected"],
|
||||
program: "example.js",
|
||||
});
|
||||
expect(launchCalls).toHaveLength(0);
|
||||
|
||||
// A genuine inspector-domain event still reaches listeners registered on the adapter.
|
||||
const heapEvents: unknown[] = [];
|
||||
adapter.on("Heap.garbageCollected", event => {
|
||||
heapEvents.push(event);
|
||||
});
|
||||
(inspector as any).emit("Heap.garbageCollected", {
|
||||
collection: { type: "full", startTime: 0, endTime: 1 },
|
||||
});
|
||||
expect(heapEvents).toEqual([{ collection: { type: "full", startTime: 0, endTime: 1 } }]);
|
||||
});
|
||||
|
||||
test("getRandomId returns a distinct 32-character lowercase hex string on every call", () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const id = getRandomId();
|
||||
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
||||
ids.add(id);
|
||||
}
|
||||
expect(ids.size).toBe(256);
|
||||
});
|
||||
|
||||
test("TCPSocketSignal accepts connections only on the loopback interface", async () => {
|
||||
// Same construction the VS Code extension uses (diagnostics.ts createSignal).
|
||||
const signal = new TCPSocketSignal(0);
|
||||
await signal.ready;
|
||||
const port = signal.port;
|
||||
|
||||
try {
|
||||
// The legitimate local client connects over loopback and its payload is delivered.
|
||||
const received = new Promise<string>(resolve => signal.once("Signal.received", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = connect({ host: "127.0.0.1", port }, () => {
|
||||
client.end("hello");
|
||||
resolve();
|
||||
});
|
||||
client.on("error", reject);
|
||||
});
|
||||
expect(await received).toBe("hello");
|
||||
|
||||
// The same port is not reachable through a non-loopback interface address.
|
||||
let external: string | undefined;
|
||||
for (const addresses of Object.values(networkInterfaces())) {
|
||||
for (const { family, internal, address } of addresses ?? []) {
|
||||
if (family === "IPv4" && !internal) {
|
||||
external = address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (external) break;
|
||||
}
|
||||
|
||||
if (external) {
|
||||
const externalHost = external;
|
||||
const connectError = await new Promise<Error | null>(resolve => {
|
||||
const client = connect({ host: externalHost, port }, () => {
|
||||
client.end();
|
||||
resolve(null);
|
||||
});
|
||||
client.on("error", error => resolve(error));
|
||||
});
|
||||
expect(connectError).not.toBeNull();
|
||||
}
|
||||
} finally {
|
||||
signal.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { LineRange, MappedPosition } from "source-map-js";
|
||||
import { SourceMapConsumer } from "source-map-js";
|
||||
|
||||
export type LocationRequest = {
|
||||
line?: number;
|
||||
column?: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
line: number; // 0-based
|
||||
column: number; // 0-based
|
||||
} & (
|
||||
| {
|
||||
verified: true;
|
||||
}
|
||||
| {
|
||||
verified?: false;
|
||||
message?: string;
|
||||
}
|
||||
);
|
||||
|
||||
export interface SourceMap {
|
||||
/**
|
||||
* Converts a location in the original source to a location in the generated source.
|
||||
* @param request A request
|
||||
*/
|
||||
generatedLocation(request: LocationRequest): Location;
|
||||
/**
|
||||
* Converts a location in the generated source to a location in the original source.
|
||||
* @param request A request
|
||||
*/
|
||||
originalLocation(request: LocationRequest): Location;
|
||||
}
|
||||
|
||||
class ActualSourceMap implements SourceMap {
|
||||
#sourceMap: SourceMapConsumer;
|
||||
#sources: string[];
|
||||
|
||||
constructor(sourceMap: SourceMapConsumer) {
|
||||
this.#sourceMap = sourceMap;
|
||||
this.#sources = (sourceMap as any)._absoluteSources;
|
||||
}
|
||||
|
||||
#getSource(url?: string): string {
|
||||
const sources = this.#sources;
|
||||
if (!sources.length) {
|
||||
return "";
|
||||
}
|
||||
if (sources.length === 1 || !url) {
|
||||
return sources[0];
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (url.endsWith(source)) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
generatedLocation(request: LocationRequest): Location {
|
||||
const { line, column, url } = request;
|
||||
|
||||
let lineRange: LineRange;
|
||||
try {
|
||||
const source = this.#getSource(url);
|
||||
lineRange = this.#sourceMap.generatedPositionFor({
|
||||
line: lineTo1BasedLine(line),
|
||||
column: columnToColumn(column),
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
message: unknownToError(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!locationIsValid(lineRange)) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { line: gline, column: gcolumn } = lineRange;
|
||||
return {
|
||||
line: lineTo0BasedLine(gline),
|
||||
column: columnToColumn(gcolumn),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
originalLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
|
||||
let mappedPosition: MappedPosition;
|
||||
try {
|
||||
mappedPosition = this.#sourceMap.originalPositionFor({
|
||||
line: lineTo1BasedLine(line),
|
||||
column: columnToColumn(column),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
message: unknownToError(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!locationIsValid(mappedPosition)) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { line: oline, column: ocolumn } = mappedPosition;
|
||||
return {
|
||||
line: lineTo0BasedLine(oline),
|
||||
column: columnToColumn(ocolumn),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class NoopSourceMap implements SourceMap {
|
||||
generatedLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
originalLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSourceMap = new NoopSourceMap();
|
||||
|
||||
export function SourceMap(url?: string): SourceMap {
|
||||
if (!url) {
|
||||
return defaultSourceMap;
|
||||
}
|
||||
if (!url.startsWith("data:")) {
|
||||
const match = url.match(/\/\/[#@]\s*sourceMappingURL=(.*)$/m);
|
||||
if (!match) {
|
||||
return defaultSourceMap;
|
||||
}
|
||||
const [_, sourceMapUrl] = match;
|
||||
url = sourceMapUrl;
|
||||
}
|
||||
try {
|
||||
const [_, base64] = url.split(",", 2);
|
||||
const decoded = Buffer.from(base64, "base64url").toString("utf8");
|
||||
const schema = JSON.parse(decoded);
|
||||
const sourceMap = new SourceMapConsumer(schema);
|
||||
return new ActualSourceMap(sourceMap);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse source map URL", url);
|
||||
}
|
||||
return defaultSourceMap;
|
||||
}
|
||||
|
||||
function lineTo1BasedLine(line?: number): number {
|
||||
return numberIsValid(line) ? line + 1 : 1;
|
||||
}
|
||||
|
||||
function lineTo0BasedLine(line?: number): number {
|
||||
return numberIsValid(line) ? line - 1 : 0;
|
||||
}
|
||||
|
||||
function lineToLine(line?: number): number {
|
||||
return numberIsValid(line) ? line : 0;
|
||||
}
|
||||
|
||||
function columnToColumn(column?: number): number {
|
||||
return numberIsValid(column) ? column : 0;
|
||||
}
|
||||
|
||||
function locationIsValid(location: Location): location is Location {
|
||||
const { line, column } = location;
|
||||
return numberIsValid(line) && numberIsValid(column);
|
||||
}
|
||||
|
||||
function numberIsValid(number?: number): number is number {
|
||||
return typeof number === "number" && isFinite(number) && number >= 0;
|
||||
}
|
||||
|
||||
function unknownToError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const { message } = error;
|
||||
return message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
export type Protocol = {
|
||||
$schema: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "object";
|
||||
definitions: Record<string, Type>;
|
||||
};
|
||||
|
||||
export type Type = {
|
||||
description?: string;
|
||||
} & (
|
||||
| {
|
||||
type: "number" | "integer" | "boolean";
|
||||
}
|
||||
| {
|
||||
type: "string";
|
||||
enum?: string[];
|
||||
enumDescriptions?: string[];
|
||||
}
|
||||
| {
|
||||
type: "object";
|
||||
properties?: Record<string, Type>;
|
||||
required?: string[];
|
||||
}
|
||||
| {
|
||||
type: "array";
|
||||
items?: Type;
|
||||
}
|
||||
| {
|
||||
type?: undefined;
|
||||
$ref: string;
|
||||
}
|
||||
| {
|
||||
type?: undefined;
|
||||
allOf: Type[];
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
// "composite": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSourceMap": true,
|
||||
"allowJs": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "scripts", "../bun-types/index.d.ts", "../bun-inspector-protocol/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
:host {
|
||||
--bun-error-color: #e33737;
|
||||
--bun-error-monospace:
|
||||
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace",
|
||||
"Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
--bun-error-width: 512px;
|
||||
}
|
||||
|
||||
:host a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:host a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
#BunErrorOverlay-container {
|
||||
box-shadow:
|
||||
0px 16px 24px rgba(0, 0, 0, 0.06),
|
||||
0px 2px 6px rgba(0, 0, 0, 0.1),
|
||||
0px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: blur(42px);
|
||||
backface-visibility: visible;
|
||||
border: inset 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 17px;
|
||||
background-color: rgba(255, 255, 255, 0.92);
|
||||
width: var(--bun-error-width);
|
||||
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
right: 48px;
|
||||
z-index: 999999;
|
||||
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
.BunErrorRoot--FullPage #BunErrorOverlay-container {
|
||||
position: static;
|
||||
top: unset;
|
||||
right: unset;
|
||||
margin: 60px auto;
|
||||
}
|
||||
|
||||
.BunError-error-message--quoted {
|
||||
color: rgb(25, 46, 9);
|
||||
}
|
||||
|
||||
:host a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.BunError-Summary-ErrorIcon {
|
||||
content: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAgCAYAAAB3j6rJAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJiSURBVHgBzZdLUttAEIa7RwpVeKUD4Ip8hFSB7V3kG8AJgBPgG+CcAG4QcwPfIFpiigU5gRWcqmy1AVwVaTo9Y+yyiEeakc3jX7j0GEmfe7r/6QH4IELYUJNuN/LyPMwRU2g04lYcp1BDtUEYIPSJvvNhtHI5QcTB3vX1FThKQE0xxI8XEEohEQ2n+/sROKoWyP3BwYn6qHGA553DW4CgEFUfin6328fwmiDTbldBhFXjiHNlEkUBWMoJRCUoSNm3HB6Kx0fbsW4gnpQDQLT/l4hntlGxBlHR4NJ0mndW4D08WCWuNYgv5YXhVsJmNASi0bqbnNj9P2pKtwGiyxXxcM2tJNvd/bI3Hp82b26OJOK3dc9nc+PbHMRUrhyJgqV7QowMr4iqTK4SpNK8VvRXSnNiVphcKYhO0BLzIkvAZ5WaXCmID6AeDGFLYpO7NJWzEUSbF9EAHKTagYohgcnkjCDavKoVgqNMJrcWpKZ5AQlh46LBp6enCysQ36LuFy8tnBFZ2Tkn+cnLcv6vQ1PlypViC6I+PsiEUB3ZomMLLZ+Mm+Nxzwgy7XQmsMVKKVWe95q3t7E6LEyNi3ktRZSWrTWl8rxl5JcgVeZlUJI1Gq3FWsM+cQpuCn91Ov0CiEd0Bo7RkERXq2tNPps5R4UBzlU5axAdDQDrbmr5EsSvhQs7OxG4S5ucTtb7dntYxzeeNWLzi3MhWpwnxy4d3IpSX/0yxGeor0OGmPcqWHu/FixypNY2cZvSINxZ/YT3VTIHmc0u1Qm8k9R+WYO07u7SDLHH+1bnzfNGYjPkhfJIbdr/AfAS02LsKGiuAAAAAElFTkSuQmCC");
|
||||
width: 20px;
|
||||
height: 19px;
|
||||
|
||||
margin-right: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.BunError-Summary-CloseIcon {
|
||||
content: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAmCAYAAACoPemuAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAKKSURBVHgBxZjpTsMwEISnaTmLKLf4AX94/7fiFlRcJaUKl6exK8e1k7XTY6QVUbDjj/V4bdNBe2Uquio2nPdfKn6RqA7StKuir2JHxXZNO4LlKkb65zeWBLavYxdpelMxhABQCkaQI6QDuRrqCEoCdqjiFItXoeIagex1Gzqfo8zUMsSxByo+Vfz4fokaqH0sVx09xhxcCOwY5RSuQgbuHVZ58YGx0RlWK8LtoYT74wsXjEXyHM3eW4Y4JgFZ76ZV2xancAPrE+3T44MNRiCp2bnd5MK29A0rfyFsP7VRz3pxLOxoF8ctFZeYz7wRPfOgn/mHX6B5Rui1zP7gDmQaWc8TlEXytwGKKpy+dRoYMG41Um+52fHBuVBG0jH6ZpAtyMVV20MYLgRFq+xBpm1TFrjtbAo7dfUAI1SzxMr9ocF8UFIPUx2TsQxx4pTQ9G7migVAVYBiwagQnK0kKBsotdITblDz7eRDgAH7QZrop9CBj/67gbywVmTAxGdxVKEeGtoQKgkusz4QoxDUbK+zZOCibkzGW/yYtMawTNx73tPkJ/CXEj7zMEjPSY7zRWYNJtUwAGVWX2i1sgi/QabcgJn7X4p8JSEEJ139467TqS/oxAvup34+QrhOmR1ijHLV89uS2xY9+WjPN7N3hbRiu0hNj1U2BKfzGesVs/XCBzc7r0j32iLEbE1Xs2/aHtHivzQtxEzNTia+VUIoGnaA1Yl3iEptDC1fblGcb2nRbSNC3cGZpbq6MtGdCJf6f7Qm0c+38BwiJANKbzexeoJegT7FZOIA5SbdFjDXUJO6RrFTRChe82Jv7PQPbcFyMJZ0aOMd3qx47SMoyw5B6VnjF2aECyjXMFEl6B+GYop4atKAigAAAABJRU5ErkJggg==");
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.BunError-Summary-CloseIcon:hover {
|
||||
transform: scale(1.2);
|
||||
|
||||
background-color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.BunError-Summary {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto min-content min-content;
|
||||
grid-template-rows: 46px;
|
||||
align-items: center;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid rgb(220, 220, 220);
|
||||
}
|
||||
|
||||
.BunError-footer {
|
||||
display: flex;
|
||||
border-top: 1px solid rgb(220, 220, 220);
|
||||
align-items: center;
|
||||
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.BunError-footerItem {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.BunError-Summary-Title {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.BunError-ErrorTag,
|
||||
.BunError-error-code {
|
||||
color: rgb(165, 165, 165);
|
||||
font-weight: 500;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.BunError-ErrorTag {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.BunError-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5ch;
|
||||
}
|
||||
|
||||
.BunError-error-message {
|
||||
color: var(--bun-error-color);
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.BunError-list {
|
||||
margin-top: 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.BunError-error-subtitle,
|
||||
.BunError-error-header,
|
||||
.BunError-error-message {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.BunError-error-subtitle {
|
||||
font-size: 500;
|
||||
}
|
||||
|
||||
.BunError-NativeStackTrace {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.BunError-NativeStackTrace-filename {
|
||||
padding: 8px 18px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.36px;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.BunError-NativeStackTrace-filename:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.BunError-SourceLines-lines a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-text::selection,
|
||||
.BunError-SourceLine-text *::selection {
|
||||
background-color: #6437e3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-text:active:before {
|
||||
width: 61px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.BunError-SourceLines-lines {
|
||||
}
|
||||
|
||||
.BunError-SourceLine {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto;
|
||||
}
|
||||
|
||||
.BunError-SourceLines {
|
||||
font-size: 14px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.BunError-CopyButton {
|
||||
font-weight: 500;
|
||||
font-size: 1em;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
border-right: 1px solid #ccc;
|
||||
background: #fcfcfc;
|
||||
border-bottom-left-radius: 12px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.BunError-CopyButton,
|
||||
#BunError-poweredBy {
|
||||
padding: 12px 12px;
|
||||
}
|
||||
.BunError-Summary-help {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
gap: 9px;
|
||||
margin-right: 18px;
|
||||
|
||||
border-right: 1px solid rgb(220, 220, 220);
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-right: 18px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.BunError-Summary-help svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#BunErrorOverlay-container .BunError-Summary-help {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#BunErrorOverlay-container .BunError-Summary-help:hover {
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
#BunErrorOverlay-container .BunError-Summary-help:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.BunError-CopyButton svg {
|
||||
width: 18px;
|
||||
margin-right: 0.7em;
|
||||
opacity: 0.75;
|
||||
margin-left: 6px;
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.BunError-CopyButton:hover {
|
||||
background: #6437e3;
|
||||
color: white;
|
||||
|
||||
border-right-color: #6437e3;
|
||||
}
|
||||
|
||||
.BunError-CopyButton:active {
|
||||
transform: scale(1.03, 1.03);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-text {
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
font-family: var(--bun-error-monospace);
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number {
|
||||
font-variant: tabular-nums;
|
||||
display: block;
|
||||
border-left: 4px solid transparent;
|
||||
margin-left: -1px;
|
||||
cursor: pointer;
|
||||
padding-left: 14px;
|
||||
padding-right: 12px;
|
||||
text-align: right;
|
||||
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number,
|
||||
.BunError-SourceLine-number * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number:active + .BunError-SourceLine-text {
|
||||
background-color: #6437e3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number:hover {
|
||||
background-color: #7443fa;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.BunError-SourceLine:hover .BunError-SourceLine-number {
|
||||
border-left-color: #7443fa;
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number--empty {
|
||||
color: rgb(165, 165, 165);
|
||||
}
|
||||
|
||||
.BunError-SourceLine-number,
|
||||
.BunError-SourceLine-text {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.BunError-SourceLines-highlighter--0,
|
||||
.BunError-SourceLines-highlighter--1,
|
||||
.BunError-SourceLines-highlighter--2,
|
||||
.BunError-SourceLines-highlighter--3,
|
||||
.BunError-SourceLines-highlighter--4,
|
||||
.BunError-SourceLines-highlighter--5 {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#BunErrorOverlay-container .BunError-SourceLine-text--highlight {
|
||||
color: #e33737;
|
||||
}
|
||||
|
||||
#BunError-poweredBy {
|
||||
height: 16px;
|
||||
|
||||
content: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPMAAAAgCAYAAADHXE6+AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AYht+mSqVUHOwg4pChCooFURFHqWIRLJS2QqsOJpf+QZOGJMXFUXAtOPizWHVwcdbVwVUQBH9AHJ2cFF2kxO+SQosY7zju4b3vfbn7DhAaFaaaXROAqllGKh4Ts7lVMfCKAII0RzEmMVNPpBcz8Bxf9/Dx/S7Ks7zr/hy9St5kgE8knmO6YRFvEM9sWjrnfeIwK0kK8TnxuEEXJH7kuuzyG+eiwwLPDBuZ1DxxmFgsdrDcwaxkqMTTxBFF1ShfyLqscN7irFZqrHVP/sJQXltJc53WEOJYQgJJiJBRQxkVWIjSrpFiIkXnMQ//oONPkksmVxmMHAuoQoXk+MH/4HdvzcLUpJsUigHdL7b9MQwEdoFm3ba/j227eQL4n4Erre2vNoDZT9LrbS1yBPRtAxfXbU3eAy53gIEnXTIkR/LTEgoF4P2MvikH9N8CwTW3b61znD4AGerV8g1wcAiMFCl73ePdPZ19+7em1b8foeJyunuPxOcAAAAGYktHRAC0ABcAF4a+JZkAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfmBwoKLQocPFhsAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAACkNJREFUeNrtXT1sG0cW/rg2yMLcFKYAk2pMQDq6kXDCsQkNA3ETxYXTJC7uKl+TNGnuBzinuKuuSopclavjFEmKOI1VOK4UwLaaCLHBNBIkgGpIBhBd3PKKJWAyxb4Jn55mdmd3uTJp7wMWS3Jn3ryZfd+8n5ldFpBTThmT4ziF8XjsAigDqAK4BGBC38tUrAzggqb6L3Q+ANCnc04aKuRDkFNGdAvAKh1VBlpJk5h8hwCeAngM4EE+zDmYc8qeti3LTVK00QdwNwd1QOfyIcgpI/rzGRiUMoBr9PnZax/O5DqXUwZUfgkTx63XfdDP53qX0ysAZgC4Te72UMhRDYnZh3T06TBRlR0mHgeatsuUMyhrwgNo6nBv4/eivSF5H49MsuYxc05ZUBXANzHKT2bU7ucAvmXfPwbwjmXs/aeQ618bgMzpAYBP2PcbAO5E1HkM4J/s+yqAf1u29bmcCHLLPD9UArAGwAOwb1mnAcAF0AYwWsA+TxBkph8COBTKeYkAsRkj7q45juMAmIzH4zhxeiGibMFQhk9CruM4DmvXpTphE9WKmAD/Y+nV3ACwAeADPmY5mOeH/gBgnT53LMBZAXCdPo8I0IsG5GcA/h5iLZ8B+B7AZyGg4CC7QGAqhIBUC1wC4okZ4Ny5c4UXL14ULCeSsqheFtcnBg+mTIC0BTKvewfAv37rQ46hubLMus8mKho+LwqQbUO8p3TYAPMXS+CdovF4DLLqv5WNAHKcPoS1XaUYuZpgHK+Rhc7BnNPcA1mCNKre4xAQRbbJrfpJXMebGGLSKqbLa0nodu5m55QlzTqbvWJR5gFmu9VzllaZ85Tu9i3KDySlDeWqn2fB+jqALsVrALAMoEYu3/8AHFFyJspVrACok+vnARgwnpKaVOeJgVcTgA9gN+T6MfQJoxKA3wF4g2Q5juhDnfrcpjLL9JuOfwnAZQBLrJ89Gr+o8akRbxDvnsW4xnHVVZ/9kLGXfUWC8U0K5rgguE3WSwKiIOLrL2c5e8RMoKWZwFZEP8oJeG0AeKTAvEZHA0Ea/m2mcIquEqh2DQyXAbxFE4MkD8COUKwaKQsMCtMieUwJngbJrEsYrVNCqSTKX6V2nmgSTC2SXYGzya5x/jreinoItjF6hoTVpmZ81NikpSsA3tTI5QHYEjJtMpnuG/hdZuM7oGPWVIV56aiKYK11I4LHISWB+nPmjcQB5F0ES2pDFgt/FCOOrnI3u8QSKS0Cpg/gOQnlMkta1ChfHSeXENTNr9Dh0vVtBtqBmAgkmGuCf1vTplLWkbD2Cog+WeMik6PBlJjXc9m5wX7vCiC3xLUhyerS+SaAexreHMg+1RtRnespweKyPg8YX3XtpgB0j67XaGxGhsmBTwjICMx3EtYd0jjfXfBQQ66NA8HGEJXhtrbuupi5QcDZEZauxT4fCZeSK/gPAPYMda8yKzcSSiWV0xVWTSrdRaaYOqXWWaQWyVOhs87LqBEgnmh4r4XwVpOIq+HdZP0ZUF1fgK2WQnEaGpnUBFJhsu+wSajGQNvWjL+6vo/5W8MuMCu2gSDbvaj0yPD7U8oBrMa1zNJVlJa3TWBqMhesyxTJZTd+L6RuUShPh1k1lylijclykTyHJdZmjXkTHQEaRVsai7JD8pbobAoZHmrq1lg/dzXXd1meYR3TjRxFZuk94u0Lq7cN4N2USiH7q9p6j/p7hWRUIUszxOupiRAjKzoA8F+WVJLx6AqA9w3uZhXTTG4fwKcLDmpTCGENZl3+3bT5oC3cYunuhtXdN9TthrjVSpGO2ASiaMnAo8Ksn8k1PDJYfz6ZeSFu5ygkIdRl4YorZArj3UvpZpv4eqy/XKYR8zqUqw3hTan6WYJZPZtsOu4B+BtO71/WKfNnFvH1K03nDQqgoxFZlJK4+SURKyOC50VR3iMl43FzhYHDJ8u2pAF7T7iAFXY27bVNusGiyM42vEsiFkcEYAcC+HHoOOSaJ8ZHyfAj8wa4t+QyOXoJ5ZllMqqPYBfY+xZl/wHgQwb+oWUb5VcBzI4BtAgBtARw2oHoCMtcYS73QFwvigmhE5EU0h0lpuReAjCn4e1ndB+TxLQ9Jk/d4Gntzome2k4OVWGd44B54a36+ZiWy9Uo5ZD9XrRQrKEGzOsMEMvCKvBEWZ1AUtK42GCew4BctCzIQ7B8l4RKlpNFXHITgv5nip15VrsR4bq/DFqNUXaFJZXieAjvGWJuU8y+EJZ52VC2ZgDksSGWNdX1NO6lz8rVNVa3y6z2EuMzMEwUUcpdjwEA6SK7EcBbDnGt6yH1KinuYz1CHtPYd0ROgLvYe2egfzZe3Tuwe4xRx/OQzjYbVa6Rm76B6X7pOwjWe5N4D2e+7q2zzGt0I0eGpIi80R12rQn9LqgrBgVS1uI5pjujahqr2yXedQbYnsFlV8tY69An5NQSUlzr3WOgMS1rKd4jAF+IyYrv/upq6rkp7qOpv3w8dZPfQHg9CLlPWVncrxDsqVZv31TAu0Qg1rm/YdspOYjUw/+2oeANOpJu15w7N9sl1+IHlpxaw8lNGj2h5Hy9eJMUfcDq8qWZjgGENeHijTTxHc9A6/i0qT21FRGUVPOZLOsWySgd7VF9V/BWbj9/hNEzuLNAsEtOjY9ya5sM8EmpRaDuML5rFvFvh927MuvXWa0tVy2TW7aAlu8CO0gZD98Fe5hh0cDcJqW8aaizo1HWbSrvEujrhlhzK0ShWhq3mtMRTu/M0sWE2zSjq91sLYMscZM7IwTrtpsM0E0D74fiN74G7WL6HDLEBNlIeB/bNOYmmbowL6ftYbo9le8XWERSGy04fZkCzAfkvS0EmHUx88/Q7y/uItgC2QkB6j5OZ2x94vldSEKFW3vfoEx7QnlNluOIElQ6N1w9tKGTpafJAeji5i2DfD6NzZahn/epbV8zrlvMU/Bgl/XmuYaeQS7V362ISWpf3Ituhjo3yaj+EMHGER3A+wlluIfpO74W0jKrmXmfxZ9DRGc21U4m4OT2y6Glct4ny/XcUL5HIC1auMge8Sthuow1iqh3HyfXYaP6uc3iUdt+qgdVKkwBfTZBHVP/bVzcEU1KfDy2yXO6GHPsh+Lep6WzTv4MEWwuMbX7KYJNJVHxtuzD9xau+mTewYyEcWXaej0LIMUhH/E2PgxmLG/cduLy82bQZ4jYOmsXu5AiwTTRfH9GYO1HuN+fwO6hjglNDn9lvx1auOovPZudv2nk9SX1vPZNESvPam35QYRCT1IC+SkB7i+O4/Qt5fmjJp+hA/4HAHqO40yYZT6IAPKBeDvJ/yPce/WK32EE377FJHIANjteZ8mXL7CYb3rMyZ4u4/TarRcS76ehNxAsQa1g+qdxZXZ2LYHcJ6AdQv/uaO0bNB3HwXg8nigg0Yv7qkwmDoqfLPlWAYx18bR4Q6eiCzj5p3hJ4nDdSwv6UlBgmmHtYPHe8phTfOKPR6qHMdrIdsdXIcIiVwVoJgkVv2Bp/W3L2cqftnxOOeWUU0C/AoLy/OlRotLzAAAAAElFTkSuQmCC");
|
||||
}
|
||||
|
||||
#BunError-SourceLine-text-highlightExpression {
|
||||
font-weight: bold;
|
||||
text-decoration-style: wavy;
|
||||
}
|
||||
|
||||
.BunError-JSException--TypeError #BunError-SourceLine-text-highlightExpression {
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.BunError-Indented {
|
||||
display: inline-block;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.BunError-divet {
|
||||
vertical-align: bottom;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.BunError-error-typename {
|
||||
font-family: var(--bun-error-monospace);
|
||||
color: #e39437;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.BunError-error-muted {
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.BunError-error-muted,
|
||||
.BunError-StackFrame--muted {
|
||||
color: rgb(165, 165, 165);
|
||||
}
|
||||
|
||||
.BunError-NativeStackTrace .BunError-error-typename {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.BunError-StackFrame-link {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.BunError-StackFrame-link-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
gap: 0.25ch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.BunError-StackFrame {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.BunError-StackFrame:hover {
|
||||
border-left-color: #5865f2;
|
||||
}
|
||||
|
||||
.BunError-StackFrame-identifier {
|
||||
padding-right: 18px;
|
||||
font-size: 0.8em;
|
||||
font-family: var(--bun-error-monospace);
|
||||
letter-spacing: 0.49px;
|
||||
width: var(--max-length, auto);
|
||||
max-width: 48ch;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.BunError-error-message--mono {
|
||||
font-family: var(--bun-error-monospace);
|
||||
}
|
||||
.BunError-StackFrame-identifier,
|
||||
.BunError-StackFrame-link {
|
||||
display: table-cell;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.BunError-BuildError {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.BunError-StackFrame-link-content {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.BunError-StackFrames {
|
||||
display: table;
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
border-radius: 4px;
|
||||
line-height: 1.2;
|
||||
border-collapse: separate;
|
||||
border-spacing: 12px 6px;
|
||||
|
||||
background-color: rgb(244, 244, 244);
|
||||
}
|
||||
|
||||
.BunError-StackFrames-container {
|
||||
overflow-x: auto;
|
||||
max-width: var(--bun-error-width);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-error",
|
||||
"dependencies": {
|
||||
"preact": "^10.27.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"preact": ["[email protected]", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 757 B |
Binary file not shown.
|
After Width: | Height: | Size: 717 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
import { normalizedFilename, StackFrameIdentifier, thisCwd } from "./index";
|
||||
import { StackFrameScope, type JSException, type Message, type Problems } from "./schema";
|
||||
|
||||
export function problemsToMarkdown(problems: Problems) {
|
||||
var markdown = "";
|
||||
if (problems?.build?.msgs?.length) {
|
||||
markdown += messagesToMarkdown(problems.build.msgs);
|
||||
}
|
||||
|
||||
if (problems?.exceptions?.length) {
|
||||
markdown += exceptionsToMarkdown(problems.exceptions);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function messagesToMarkdown(messages: Message[]): string {
|
||||
return messages
|
||||
.map(messageToMarkdown)
|
||||
.map(a => a.trim())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function exceptionsToMarkdown(exceptions: JSException[]): string {
|
||||
return exceptions
|
||||
.map(exceptionToMarkdown)
|
||||
.map(a => a.trim())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function exceptionToMarkdown(exception: JSException): string {
|
||||
const { name: name_, message: message_, stack } = exception;
|
||||
|
||||
var name = String(name_).trim();
|
||||
var message = String(message_).trim();
|
||||
|
||||
// check both so if it turns out one of them was only whitespace, we don't count it
|
||||
const hasName = name_ && name_.length > 0 && name.length > 0;
|
||||
const hasMessage = message_ && message_.length > 0 && message.length > 0;
|
||||
|
||||
let markdown = "";
|
||||
|
||||
if (
|
||||
(name === "Error" ||
|
||||
name === "RangeError" ||
|
||||
name === "TypeError" ||
|
||||
name === "ReferenceError" ||
|
||||
name === "DOMException") &&
|
||||
hasMessage
|
||||
) {
|
||||
markdown += `**${message}**\n`;
|
||||
} else if (hasName && hasMessage) {
|
||||
markdown += `**${name}**\n${message}\n`;
|
||||
} else if (hasMessage) {
|
||||
markdown += `${message}\n`;
|
||||
} else if (hasName) {
|
||||
markdown += `**${name}**\n`;
|
||||
}
|
||||
|
||||
if (stack && stack.frames.length > 0) {
|
||||
var frames = stack.frames;
|
||||
if (stack.source_lines.length > 0) {
|
||||
const {
|
||||
file: _file = "",
|
||||
function_name = "",
|
||||
position: { line = -1, column = -1 } = { line: -1, column: -1 },
|
||||
scope = 0 as any,
|
||||
} = stack.frames[0];
|
||||
const file = normalizedFilename(_file, thisCwd);
|
||||
|
||||
if (file) {
|
||||
if (function_name.length > 0) {
|
||||
markdown += `In \`${function_name}\` – ${file}`;
|
||||
} else if (scope > 0 && scope < StackFrameScope.Constructor + 1) {
|
||||
markdown += `${StackFrameIdentifier({
|
||||
functionName: function_name,
|
||||
scope,
|
||||
markdown: true,
|
||||
})} ${file}`;
|
||||
} else {
|
||||
markdown += `In ${file}`;
|
||||
}
|
||||
|
||||
if (line > -1) {
|
||||
markdown += `:${line}`;
|
||||
if (column > -1) {
|
||||
markdown += `:${column}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.source_lines.length > 0) {
|
||||
// TODO: include loader
|
||||
const extnameI = file.lastIndexOf(".");
|
||||
const extname = extnameI > -1 ? file.slice(extnameI + 1) : "";
|
||||
|
||||
markdown += "\n```";
|
||||
markdown += extname;
|
||||
markdown += "\n";
|
||||
stack.source_lines.forEach(sourceLine => {
|
||||
const lineText = sourceLine.text.trimEnd();
|
||||
markdown += lineText + "\n";
|
||||
if (sourceLine.line === line && stack.source_lines.length > 1) {
|
||||
// the comment should start at the first non-whitespace character
|
||||
// ideally it should be length the original line
|
||||
// but it may not be
|
||||
var prefix = "".padStart(lineText.length - lineText.trimStart().length, " ");
|
||||
|
||||
prefix += "/* ".padEnd(column - 1 - prefix.length, " ") + "^ happened here ";
|
||||
markdown += prefix.padEnd(Math.max(lineText.length, 1) - 1, " ") + "*/\n";
|
||||
}
|
||||
});
|
||||
markdown = markdown.trimEnd() + "\n```";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length > 0) {
|
||||
markdown += "\nStack trace:\n";
|
||||
var padding = 0;
|
||||
// Limit to 8 frames because it may be a huge stack trace
|
||||
// and we want to not hit the message limit
|
||||
const framesToDisplay = frames.slice(0, Math.min(frames.length, 8));
|
||||
for (let frame of framesToDisplay) {
|
||||
const {
|
||||
function_name = "",
|
||||
position: { line = -1, column = -1 } = { line: -1, column: -1 },
|
||||
scope = 0 as any,
|
||||
} = frame;
|
||||
padding = Math.max(
|
||||
padding,
|
||||
StackFrameIdentifier({
|
||||
scope,
|
||||
functionName: function_name,
|
||||
markdown: true,
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
|
||||
markdown += "```js\n";
|
||||
|
||||
for (let frame of framesToDisplay) {
|
||||
const {
|
||||
file = "",
|
||||
function_name = "",
|
||||
position: { line = -1, column = -1 } = { line: -1, column: -1 },
|
||||
scope = 0 as any,
|
||||
} = frame;
|
||||
|
||||
markdown += `
|
||||
${StackFrameIdentifier({
|
||||
scope,
|
||||
functionName: function_name,
|
||||
markdown: true,
|
||||
}).padEnd(padding, " ")}`;
|
||||
|
||||
if (file) {
|
||||
markdown += ` ${normalizedFilename(file, thisCwd)}`;
|
||||
if (line > -1) {
|
||||
markdown += `:${line}`;
|
||||
if (column > -1) {
|
||||
markdown += `:${column}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdown += "\n```\n";
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
function messageToMarkdown(message: Message): string {
|
||||
var tag = "Error";
|
||||
if (message.on.build) {
|
||||
tag = "BuildError";
|
||||
}
|
||||
var lines = (message.data.text ?? "").split("\n");
|
||||
|
||||
var markdown = "";
|
||||
if (message?.on?.resolve) {
|
||||
markdown += `**ResolveError**: "${message.on.resolve}" failed to resolve\n`;
|
||||
} else {
|
||||
var firstLine = lines[0];
|
||||
lines = lines.slice(1);
|
||||
if (firstLine.length > 120) {
|
||||
const words = firstLine.split(" ");
|
||||
var end = 0;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (end + words[i].length >= 120) {
|
||||
firstLine = words.slice(0, i).join(" ");
|
||||
lines.unshift(words.slice(i).join(" "));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdown += `**${tag}**${firstLine.length > 0 ? ": " + firstLine : ""}\n`;
|
||||
}
|
||||
|
||||
if (message.data?.location?.file) {
|
||||
markdown += `In ${normalizedFilename(message.data.location.file, thisCwd)}`;
|
||||
if (message.data.location.line > -1) {
|
||||
markdown += `:${message.data.location.line}`;
|
||||
if (message.data.location.column > -1) {
|
||||
markdown += `:${message.data.location.column}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.data.location.line_text.length) {
|
||||
const extnameI = message.data.location.file.lastIndexOf(".");
|
||||
const extname = extnameI > -1 ? message.data.location.file.slice(extnameI + 1) : "";
|
||||
|
||||
markdown += "\n```" + extname + "\n" + message.data.location.line_text + "\n```\n";
|
||||
} else {
|
||||
markdown += "\n";
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
markdown += lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export const withBunInfo = text => {
|
||||
const bunInfo = getBunInfo();
|
||||
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (bunInfo && "then" in bunInfo) {
|
||||
return bunInfo.then(
|
||||
info => {
|
||||
const markdown = bunInfoToMarkdown(info).trim();
|
||||
return trimmed + "\n" + markdown + "\n";
|
||||
},
|
||||
() => trimmed + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (bunInfo) {
|
||||
const markdown = bunInfoToMarkdown(bunInfo).trim();
|
||||
|
||||
return trimmed + "\n" + markdown + "\n";
|
||||
}
|
||||
|
||||
return trimmed + "\n";
|
||||
};
|
||||
|
||||
function bunInfoToMarkdown(_info) {
|
||||
if (!_info) return;
|
||||
const info = { ..._info, platform: { ..._info.platform } };
|
||||
|
||||
var operatingSystemVersion = info.platform.version;
|
||||
|
||||
if (info.platform.os.toLowerCase() === "macos") {
|
||||
const [major, minor, patch] = operatingSystemVersion.split(".");
|
||||
switch (major) {
|
||||
case "22": {
|
||||
operatingSystemVersion = `13.${minor}.${patch}`;
|
||||
break;
|
||||
}
|
||||
case "21": {
|
||||
operatingSystemVersion = `12.${minor}.${patch}`;
|
||||
break;
|
||||
}
|
||||
case "20": {
|
||||
operatingSystemVersion = `11.${minor}.${patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "19": {
|
||||
operatingSystemVersion = `10.15.${patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "18": {
|
||||
operatingSystemVersion = `10.14.${patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "17": {
|
||||
operatingSystemVersion = `10.13.${patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "16": {
|
||||
operatingSystemVersion = `10.12.${patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "15": {
|
||||
operatingSystemVersion = `10.11.${patch}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
info.platform.os = "macOS";
|
||||
}
|
||||
|
||||
if (info.platform.arch === "arm" && info.platform.os === "macOS") {
|
||||
info.platform.arch = "Apple Silicon";
|
||||
} else if (info.platform.arch === "arm") {
|
||||
info.platform.arch = "aarch64";
|
||||
}
|
||||
|
||||
var base = `Info:
|
||||
> bun v${info.bun_version}
|
||||
`;
|
||||
|
||||
if (info.framework && info.framework_version) {
|
||||
base += `> framework: ${info.framework}@${info.framework_version}`;
|
||||
} else if (info.framework) {
|
||||
base += `> framework: ${info.framework}`;
|
||||
}
|
||||
|
||||
base =
|
||||
base.trim() +
|
||||
`
|
||||
> ${info.platform.os} ${operatingSystemVersion} (${info.platform.arch})
|
||||
> User-Agent: ${globalThis.navigator.userAgent}
|
||||
> Pathname: ${globalThis.location.pathname}
|
||||
`;
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
var bunInfoMemoized;
|
||||
function getBunInfo() {
|
||||
if (bunInfoMemoized) return bunInfoMemoized;
|
||||
if ("sessionStorage" in globalThis) {
|
||||
try {
|
||||
const bunInfoMemoizedString = sessionStorage.getItem("__bunInfo");
|
||||
if (bunInfoMemoizedString) {
|
||||
bunInfoMemoized = JSON.parse(bunInfoMemoized);
|
||||
return bunInfoMemoized;
|
||||
}
|
||||
} catch (exception) {}
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = 1000;
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
return fetch("/bun:info", {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(bunInfo => {
|
||||
clearTimeout(id);
|
||||
bunInfoMemoized = bunInfo;
|
||||
if ("sessionStorage" in globalThis) {
|
||||
try {
|
||||
sessionStorage.setItem("__bunInfo", JSON.stringify(bunInfo));
|
||||
} catch (exception) {}
|
||||
}
|
||||
|
||||
return bunInfo;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "bun-error",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bun build --production --define:process.env.NODE_ENV=\"'production'\" --minify index.tsx bun-error.css --outdir=dist --target=browser --format=esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.27.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# bun-error
|
||||
|
||||
<img width="687" alt="image" src="https://user-images.githubusercontent.com/709451/162382958-23614e8f-239c-4ba6-be75-b76ceef8227c.png">
|
||||
|
||||
`bun-error` is a frontend component for rendering error messages and stack traces from Bun. It's used in:
|
||||
|
||||
- `bun dev`
|
||||
- `Bun.serve`
|
||||
|
||||
It is heavily tied to the implementation details of Bun, and is not intended to be used outside of Bun.
|
||||
@@ -0,0 +1,148 @@
|
||||
// Based on https://github.com/stacktracejs/error-stack-parser/blob/master/error-stack-parser.js
|
||||
|
||||
import type { StackFramePosition, StackFrameScope, StackFrame as StackFrameType } from "./schema";
|
||||
|
||||
export class StackFrame implements StackFrameType {
|
||||
function_name: string;
|
||||
file: string;
|
||||
position: StackFramePosition;
|
||||
scope: StackFrameScope;
|
||||
lineText: string = "";
|
||||
remapped: boolean = false;
|
||||
|
||||
constructor({
|
||||
functionName: function_name = "",
|
||||
fileName: file = "",
|
||||
lineNumber: line = -1,
|
||||
columnNumber: column = -1,
|
||||
source = "",
|
||||
}) {
|
||||
this.function_name = function_name;
|
||||
this.file = file;
|
||||
if (source) this.lineText = source;
|
||||
this.scope = 3;
|
||||
this.position = {
|
||||
line: line,
|
||||
column: column,
|
||||
source_offset: -1,
|
||||
line_start: -1,
|
||||
line_stop: -1,
|
||||
column_start: column,
|
||||
column_stop: -1,
|
||||
expression_start: -1,
|
||||
expression_stop: -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FIREFOX_SAFARI_STACK_REGEXP = /(^|@)\S+:\d+/;
|
||||
const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
|
||||
const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code])?$/;
|
||||
|
||||
export default class RuntimeError {
|
||||
original: Error;
|
||||
stack: StackFrame[];
|
||||
|
||||
static from(error: Error): RuntimeError {
|
||||
const runtime = new RuntimeError();
|
||||
runtime.original = error;
|
||||
runtime.stack = this.parseStack(error);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an Error object, extract the most information from it.
|
||||
*
|
||||
* @param {Error} error object
|
||||
* @return {Array} of StackFrames
|
||||
*/
|
||||
static parseStack(error) {
|
||||
if (error.stack && error.stack.match(CHROME_IE_STACK_REGEXP)) {
|
||||
return this.parseV8OrIE(error);
|
||||
} else if (error.stack) {
|
||||
return this.parseFFOrSafari(error);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Separate line and column numbers from a string of the form: (URI:Line:Column)
|
||||
static extractLocation(urlLike) {
|
||||
// Fail-fast but return locations like "(native)"
|
||||
if (urlLike.indexOf(":") === -1) {
|
||||
return [urlLike];
|
||||
}
|
||||
|
||||
var regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/;
|
||||
var parts = regExp.exec(urlLike.replace(/[()]/g, ""));
|
||||
return [parts[1], parts[2] || undefined, parts[3] || undefined];
|
||||
}
|
||||
|
||||
static parseV8OrIE(error) {
|
||||
var filtered = error.stack.split("\n").filter(function (line) {
|
||||
return !!line.match(CHROME_IE_STACK_REGEXP);
|
||||
}, this);
|
||||
|
||||
return filtered.map(function (line) {
|
||||
if (line.indexOf("(eval ") > -1) {
|
||||
// Throw away eval information until we implement stacktrace.js/stackframe#8
|
||||
line = line.replace(/eval code/g, "eval").replace(/(\(eval at [^()]*)|(\),.*$)/g, "");
|
||||
}
|
||||
var sanitizedLine = line.replace(/^\s+/, "").replace(/\(eval code/g, "(");
|
||||
|
||||
// capture and preseve the parenthesized location "(/foo/my bar.js:12:87)" in
|
||||
// case it has spaces in it, as the string is split on \s+ later on
|
||||
var location = sanitizedLine.match(/ (\((.+):(\d+):(\d+)\)$)/);
|
||||
|
||||
// remove the parenthesized location from the line, if it was matched
|
||||
sanitizedLine = location ? sanitizedLine.replace(location[0], "") : sanitizedLine;
|
||||
|
||||
var tokens = sanitizedLine.split(/\s+/).slice(1);
|
||||
// if a location was matched, pass it to extractLocation() otherwise pop the last token
|
||||
var locationParts = this.extractLocation(location ? location[1] : tokens.pop());
|
||||
var functionName = tokens.join(" ") || undefined;
|
||||
var fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 ? undefined : locationParts[0];
|
||||
|
||||
return new StackFrame({
|
||||
functionName: functionName,
|
||||
fileName: fileName,
|
||||
lineNumber: locationParts[1],
|
||||
columnNumber: locationParts[2],
|
||||
source: line,
|
||||
});
|
||||
}, this);
|
||||
}
|
||||
|
||||
static parseFFOrSafari(error) {
|
||||
var filtered = error.stack.split("\n").filter(function (line) {
|
||||
return !line.match(SAFARI_NATIVE_CODE_REGEXP);
|
||||
}, this);
|
||||
|
||||
return filtered.map(function (line) {
|
||||
// Throw away eval information until we implement stacktrace.js/stackframe#8
|
||||
if (line.indexOf(" > eval") > -1) {
|
||||
line = line.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ":$1");
|
||||
}
|
||||
|
||||
if (line.indexOf("@") === -1 && line.indexOf(":") === -1) {
|
||||
// Safari eval frames only have function names and nothing else
|
||||
return new StackFrame({
|
||||
functionName: line,
|
||||
});
|
||||
} else {
|
||||
var functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/;
|
||||
var matches = line.match(functionNameRegex);
|
||||
var functionName = matches && matches[1] ? matches[1] : undefined;
|
||||
var locationParts = this.extractLocation(line.replace(functionNameRegex, ""));
|
||||
|
||||
return new StackFrame({
|
||||
functionName: functionName,
|
||||
fileName: locationParts[0],
|
||||
lineNumber: locationParts[1],
|
||||
columnNumber: locationParts[2],
|
||||
source: line,
|
||||
});
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Shape of the JSON payload the `Bun.serve({ development: true })` error page
|
||||
// embeds in `<script id="__bunfallback" type="application/json">`.
|
||||
// Produced by src/runtime/server/DevErrorPage.rs.
|
||||
|
||||
export enum StackFrameScope {
|
||||
Eval = 1,
|
||||
Module = 2,
|
||||
Function = 3,
|
||||
Global = 4,
|
||||
Wasm = 5,
|
||||
Constructor = 6,
|
||||
}
|
||||
|
||||
export interface StackFramePosition {
|
||||
/** 1-based; -1 when the frame has no source position */
|
||||
line: number;
|
||||
/** 1-based; -1 when the frame has no source position */
|
||||
column: number;
|
||||
// Only set on frames built client-side (runtime-error.ts, source-map remapping in index.tsx).
|
||||
source_offset?: number;
|
||||
line_start?: number;
|
||||
line_stop?: number;
|
||||
column_start?: number;
|
||||
column_stop?: number;
|
||||
expression_start?: number;
|
||||
expression_stop?: number;
|
||||
}
|
||||
|
||||
export interface StackFrame {
|
||||
function_name: string;
|
||||
file: string;
|
||||
position: StackFramePosition;
|
||||
scope: StackFrameScope;
|
||||
}
|
||||
|
||||
export interface SourceLine {
|
||||
/** 1-based */
|
||||
line: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface StackTrace {
|
||||
source_lines: SourceLine[];
|
||||
frames: StackFrame[];
|
||||
}
|
||||
|
||||
export interface JSException {
|
||||
name?: string;
|
||||
message?: string;
|
||||
/** `JSRuntimeType` flags */
|
||||
runtime_type?: number;
|
||||
/** `JSErrorCode` */
|
||||
code?: number;
|
||||
stack?: StackTrace;
|
||||
}
|
||||
|
||||
export const enum MessageLevel {
|
||||
err = 1,
|
||||
warn = 2,
|
||||
note = 3,
|
||||
info = 4,
|
||||
debug = 5,
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
file: string;
|
||||
namespace: string;
|
||||
line: number;
|
||||
column: number;
|
||||
line_text: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
text?: string;
|
||||
location?: Location;
|
||||
}
|
||||
|
||||
export interface MessageMeta {
|
||||
/** The specifier that failed to resolve, for resolve errors. */
|
||||
resolve?: string;
|
||||
build?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
level: MessageLevel;
|
||||
data: MessageData;
|
||||
notes: MessageData[];
|
||||
on: MessageMeta;
|
||||
}
|
||||
|
||||
export interface Log {
|
||||
warnings: number;
|
||||
errors: number;
|
||||
msgs: Message[];
|
||||
}
|
||||
|
||||
export interface Problems {
|
||||
exceptions: JSException[];
|
||||
build: Log;
|
||||
}
|
||||
|
||||
export interface FallbackMessageContainer {
|
||||
/** One-line summary of what failed, e.g. "GET /foo failed" */
|
||||
message?: string;
|
||||
problems?: Problems;
|
||||
cwd?: string;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Accelerate VLQ decoding with a lookup table
|
||||
const vlqTable = new Uint8Array(128);
|
||||
const vlqChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
vlqTable.fill(0xff);
|
||||
for (let i = 0; i < vlqChars.length; i++) vlqTable[vlqChars.charCodeAt(i)] = i;
|
||||
|
||||
export function parseSourceMap(json) {
|
||||
if (json.version !== 3) {
|
||||
throw new Error("Invalid source map");
|
||||
}
|
||||
|
||||
if (!(json.sources instanceof Array) || json.sources.some(x => typeof x !== "string")) {
|
||||
throw new Error("Invalid source map");
|
||||
}
|
||||
|
||||
if (typeof json.mappings !== "string") {
|
||||
throw new Error("Invalid source map");
|
||||
}
|
||||
|
||||
const { sources, sourcesContent, names, mappings } = json;
|
||||
const emptyData = new Int32Array(0);
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
sources[i] = {
|
||||
name: sources[i],
|
||||
content: (sourcesContent && sourcesContent[i]) || "",
|
||||
data: emptyData,
|
||||
dataLength: 0,
|
||||
};
|
||||
}
|
||||
const data = decodeMappings(mappings, sources.length);
|
||||
return { sources, names, data };
|
||||
}
|
||||
|
||||
// ripped from https://github.com/evanw/source-map-visualization/blob/gh-pages/code.js#L179
|
||||
export function decodeMappings(mappings, sourcesCount) {
|
||||
const n = mappings.length;
|
||||
let data = new Int32Array(1024);
|
||||
let dataLength = 0;
|
||||
let generatedLine = 0;
|
||||
let generatedLineStart = 0;
|
||||
let generatedColumn = 0;
|
||||
let originalSource = 0;
|
||||
let originalLine = 0;
|
||||
let originalColumn = 0;
|
||||
let originalName = 0;
|
||||
let needToSortGeneratedColumns = false;
|
||||
let i = 0;
|
||||
|
||||
function decodeError(text) {
|
||||
const error = `Invalid VLQ data at index ${i}: ${text}`;
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
function decodeVLQ() {
|
||||
let shift = 0;
|
||||
let vlq = 0;
|
||||
|
||||
// Scan over the input
|
||||
while (true) {
|
||||
// Read a byte
|
||||
if (i >= mappings.length) decodeError("Expected extra data");
|
||||
const c = mappings.charCodeAt(i);
|
||||
if ((c & 0x7f) !== c) decodeError("Invalid character");
|
||||
const index = vlqTable[c & 0x7f];
|
||||
if (index === 0xff) decodeError("Invalid character");
|
||||
i++;
|
||||
|
||||
// Decode the byte
|
||||
vlq |= (index & 31) << shift;
|
||||
shift += 5;
|
||||
|
||||
// Stop if there's no continuation bit
|
||||
if ((index & 32) === 0) break;
|
||||
}
|
||||
|
||||
// Recover the signed value
|
||||
return vlq & 1 ? -(vlq >> 1) : vlq >> 1;
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
let c = mappings.charCodeAt(i);
|
||||
|
||||
// Handle a line break
|
||||
if (c === 59 /* ; */) {
|
||||
// The generated columns are very rarely out of order. In that case,
|
||||
// sort them with insertion since they are very likely almost ordered.
|
||||
if (needToSortGeneratedColumns) {
|
||||
for (let j = generatedLineStart + 6; j < dataLength; j += 6) {
|
||||
const genL = data[j];
|
||||
const genC = data[j + 1];
|
||||
const origS = data[j + 2];
|
||||
const origL = data[j + 3];
|
||||
const origC = data[j + 4];
|
||||
const origN = data[j + 5];
|
||||
let k = j - 6;
|
||||
for (; k >= generatedLineStart && data[k + 1] > genC; k -= 6) {
|
||||
data[k + 6] = data[k];
|
||||
data[k + 7] = data[k + 1];
|
||||
data[k + 8] = data[k + 2];
|
||||
data[k + 9] = data[k + 3];
|
||||
data[k + 10] = data[k + 4];
|
||||
data[k + 11] = data[k + 5];
|
||||
}
|
||||
data[k + 6] = genL;
|
||||
data[k + 7] = genC;
|
||||
data[k + 8] = origS;
|
||||
data[k + 9] = origL;
|
||||
data[k + 10] = origC;
|
||||
data[k + 11] = origN;
|
||||
}
|
||||
}
|
||||
|
||||
generatedLine++;
|
||||
generatedColumn = 0;
|
||||
generatedLineStart = dataLength;
|
||||
needToSortGeneratedColumns = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore stray commas
|
||||
if (c === 44 /* , */) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read the generated column
|
||||
const generatedColumnDelta = decodeVLQ();
|
||||
if (generatedColumnDelta < 0) needToSortGeneratedColumns = true;
|
||||
generatedColumn += generatedColumnDelta;
|
||||
if (generatedColumn < 0) decodeError("Invalid generated column");
|
||||
|
||||
// It's valid for a mapping to have 1, 4, or 5 variable-length fields
|
||||
let isOriginalSourceMissing = true;
|
||||
let isOriginalNameMissing = true;
|
||||
if (i < n) {
|
||||
c = mappings.charCodeAt(i);
|
||||
if (c === 44 /* , */) {
|
||||
i++;
|
||||
} else if (c !== 59 /* ; */) {
|
||||
isOriginalSourceMissing = false;
|
||||
|
||||
// Read the original source
|
||||
const originalSourceDelta = decodeVLQ();
|
||||
originalSource += originalSourceDelta;
|
||||
if (originalSource < 0 || originalSource >= sourcesCount) decodeError("Invalid original source");
|
||||
|
||||
// Read the original line
|
||||
const originalLineDelta = decodeVLQ();
|
||||
originalLine += originalLineDelta;
|
||||
if (originalLine < 0) decodeError("Invalid original line");
|
||||
|
||||
// Read the original column
|
||||
const originalColumnDelta = decodeVLQ();
|
||||
originalColumn += originalColumnDelta;
|
||||
if (originalColumn < 0) decodeError("Invalid original column");
|
||||
|
||||
// Check for the optional name index
|
||||
if (i < n) {
|
||||
c = mappings.charCodeAt(i);
|
||||
if (c === 44 /* , */) {
|
||||
i++;
|
||||
} else if (c !== 59 /* ; */) {
|
||||
isOriginalNameMissing = false;
|
||||
|
||||
// Read the optional name index
|
||||
const originalNameDelta = decodeVLQ();
|
||||
originalName += originalNameDelta;
|
||||
if (originalName < 0) decodeError("Invalid original name");
|
||||
|
||||
// Handle the next character
|
||||
if (i < n) {
|
||||
c = mappings.charCodeAt(i);
|
||||
if (c === 44 /* , */) {
|
||||
i++;
|
||||
} else if (c !== 59 /* ; */) {
|
||||
decodeError("Invalid character after mapping");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append the mapping to the typed array
|
||||
if (dataLength + 6 > data.length) {
|
||||
const newData = new Int32Array(data.length << 1);
|
||||
newData.set(data);
|
||||
data = newData;
|
||||
}
|
||||
data[dataLength] = generatedLine;
|
||||
data[dataLength + 1] = generatedColumn;
|
||||
if (isOriginalSourceMissing) {
|
||||
data[dataLength + 2] = -1;
|
||||
data[dataLength + 3] = -1;
|
||||
data[dataLength + 4] = -1;
|
||||
} else {
|
||||
data[dataLength + 2] = originalSource;
|
||||
data[dataLength + 3] = originalLine;
|
||||
data[dataLength + 4] = originalColumn;
|
||||
}
|
||||
data[dataLength + 5] = isOriginalNameMissing ? -1 : originalName;
|
||||
dataLength += 6;
|
||||
}
|
||||
|
||||
return data.subarray(0, dataLength);
|
||||
}
|
||||
|
||||
export function remapPosition(decodedMappings: Int32Array, line: number, column: number) {
|
||||
if (!(decodedMappings instanceof Int32Array)) {
|
||||
throw new Error("decodedMappings must be an Int32Array");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(line)) {
|
||||
throw new Error("line must be a finite number");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(column)) {
|
||||
throw new Error("column must be a finite number");
|
||||
}
|
||||
|
||||
if (decodedMappings.length === 0 || line < 0 || column < 0) return null;
|
||||
|
||||
const index = indexOfMapping(decodedMappings, line, column);
|
||||
if (index === -1) return null;
|
||||
|
||||
return [decodedMappings[index + 3] + 1, decodedMappings[index + 4]];
|
||||
}
|
||||
|
||||
async function fetchRemoteSourceMap(file: string, signal) {
|
||||
const response = await globalThis.fetch(file + ".map", {
|
||||
signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Mappings-Only": "1",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export var sourceMappings = new Map();
|
||||
|
||||
export function fetchMappings(file, signal) {
|
||||
if (file.includes(".bun")) return null;
|
||||
if (sourceMappings.has(file)) {
|
||||
return sourceMappings.get(file);
|
||||
}
|
||||
|
||||
return fetchRemoteSourceMap(file, signal).then(json => {
|
||||
if (!json) return null;
|
||||
const { data } = parseSourceMap(json);
|
||||
sourceMappings.set(file, data);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// this batches duplicate requests
|
||||
export function fetchAllMappings(files, signal) {
|
||||
var results = new Array(files.length);
|
||||
var map = new Map();
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
const existing = map.get(files[i]);
|
||||
if (existing) {
|
||||
existing.push(i);
|
||||
} else map.set(files[i], [i]);
|
||||
}
|
||||
|
||||
for (const [file, indices] of [...map.entries()]) {
|
||||
const mapped = fetchMappings(file, signal);
|
||||
if (mapped?.then) {
|
||||
var resolvers = [];
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
results[indices[i]] = new Promise((resolve, reject) => {
|
||||
resolvers[i] = res => resolve(res ? [res, i] : null);
|
||||
});
|
||||
}
|
||||
|
||||
mapped.finally(a => {
|
||||
for (let resolve of resolvers) {
|
||||
try {
|
||||
resolve(a);
|
||||
} catch {
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
resolvers.length = 0;
|
||||
resolvers = null;
|
||||
});
|
||||
} else {
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
results[indices[i]] = mapped ? [mapped, indices[i]] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function indexOfMapping(mappings: Int32Array, line: number, column: number) {
|
||||
// the array is [generatedLine, generatedColumn, sourceIndex, sourceLine, sourceColumn, nameIndex]
|
||||
// 0 - generated line
|
||||
var count = mappings.length / 6;
|
||||
var index = 0;
|
||||
while (count > 0) {
|
||||
var step = (count / 2) | 0;
|
||||
var i = index + step;
|
||||
// this multiply is slow but it's okay for now
|
||||
var j = i * 6;
|
||||
if (mappings[j] < line || (mappings[j] == line && mappings[j + 1] <= column)) {
|
||||
index = i + 1;
|
||||
count -= step + 1;
|
||||
} else {
|
||||
count = step;
|
||||
}
|
||||
}
|
||||
|
||||
index = index | 0;
|
||||
|
||||
if (index > 0) {
|
||||
if (mappings[(index - 1) * 6] == line) {
|
||||
return (index - 1) * 6;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
const UNKNOWN_FUNCTION = "<unknown>";
|
||||
import type { StackFrame } from "./schema";
|
||||
|
||||
/**
|
||||
* This parses the different stack traces and puts them into one format
|
||||
* This borrows heavily from TraceKit (https://github.com/csnover/TraceKit)
|
||||
*/
|
||||
export function parse(stackString): StackFrame[] {
|
||||
const lines = stackString.split("\n");
|
||||
|
||||
return lines.reduce((stack, line) => {
|
||||
const parseResult = parseChrome(line) || parseWinjs(line) || parseGecko(line) || parseNode(line) || parseJSC(line);
|
||||
|
||||
if (parseResult) {
|
||||
stack.push(parseResult);
|
||||
}
|
||||
|
||||
return stack;
|
||||
}, []);
|
||||
}
|
||||
|
||||
const formatFile = file => {
|
||||
if (!file) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (file.startsWith("blob:")) {
|
||||
if (globalThis["__BUN"]?.client) {
|
||||
const replacement = globalThis["__BUN"]?.client.dependencies.getFilePathFromBlob(file);
|
||||
if (replacement) {
|
||||
file = replacement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _file = String(file);
|
||||
if (_file.startsWith(globalThis.location?.origin)) {
|
||||
_file = _file.substring(globalThis.location?.origin.length);
|
||||
}
|
||||
|
||||
while (_file.startsWith("/")) {
|
||||
_file = _file.substring(1);
|
||||
}
|
||||
|
||||
if (_file.endsWith(".bun")) {
|
||||
_file = "node_modules.bun";
|
||||
}
|
||||
|
||||
return _file;
|
||||
};
|
||||
|
||||
const chromeRe =
|
||||
/^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i;
|
||||
const chromeEvalRe = /\((\S*)(?::(\d+))(?::(\d+))\)/;
|
||||
|
||||
function parseChrome(line) {
|
||||
const parts = chromeRe.exec(line);
|
||||
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNative = parts[2] && parts[2].indexOf("native") === 0; // start of line
|
||||
const isEval = parts[2] && parts[2].indexOf("eval") === 0; // start of line
|
||||
|
||||
const submatch = chromeEvalRe.exec(parts[2]);
|
||||
if (isEval && submatch != null) {
|
||||
// throw out eval line/column and use top-most line/column number
|
||||
parts[2] = submatch[1]; // url
|
||||
parts[3] = submatch[2]; // line
|
||||
parts[4] = submatch[3]; // column
|
||||
}
|
||||
|
||||
return {
|
||||
file: formatFile(!isNative ? parts[2] : null),
|
||||
function_name: parts[1] || "",
|
||||
position: {
|
||||
line: parts[3] ? +parts[3] : null,
|
||||
column_start: parts[4] ? +parts[4] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const winjsRe =
|
||||
/^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i;
|
||||
|
||||
function parseWinjs(line) {
|
||||
const parts = winjsRe.exec(line);
|
||||
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file: formatFile(parts[2]),
|
||||
function_name: parts[1],
|
||||
position: {
|
||||
line: +parts[3],
|
||||
column_start: parts[4] ? +parts[4] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const geckoRe =
|
||||
/^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i;
|
||||
const geckoEvalRe = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i;
|
||||
|
||||
function parseGecko(line) {
|
||||
const parts = geckoRe.exec(line);
|
||||
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEval = parts[3] && parts[3].indexOf(" > eval") > -1;
|
||||
|
||||
const submatch = geckoEvalRe.exec(parts[3]);
|
||||
if (isEval && submatch != null) {
|
||||
// throw out eval line/column and use top-most line number
|
||||
parts[3] = submatch[1];
|
||||
parts[4] = submatch[2];
|
||||
parts[5] = null; // no column when eval
|
||||
}
|
||||
|
||||
return {
|
||||
file: formatFile(parts[3]),
|
||||
function_name: parts[1] || "",
|
||||
position: {
|
||||
line: parts[4] ? +parts[4] : null,
|
||||
column_start: parts[5] ? +parts[5] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const javaScriptCoreRe = /^\s*(?:([^@]*)(?:\((.*?)\))?@)?(\S.*?):(\d+)(?::(\d+))?\s*$/i;
|
||||
|
||||
function parseJSC(line) {
|
||||
const parts = javaScriptCoreRe.exec(line);
|
||||
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file: formatFile(parts[3]),
|
||||
function_name: parts[1] || "",
|
||||
position: {
|
||||
line: +parts[4],
|
||||
column_start: parts[5] ? +parts[5] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nodeRe = /^\s*at (?:((?:\[object object\])?[^\\/]+(?: \[as \S+\])?) )?\(?(.*?):(\d+)(?::(\d+))?\)?\s*$/i;
|
||||
|
||||
function parseNode(line) {
|
||||
const parts = nodeRe.exec(line);
|
||||
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file: formatFile(parts[2]),
|
||||
function_name: parts[1] || "",
|
||||
position: {
|
||||
line: +parts[3],
|
||||
column_start: parts[4] ? +parts[4] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
out
|
||||
@@ -0,0 +1,8 @@
|
||||
# bun-devtools-frontend
|
||||
|
||||
This is the WebKit Web Inspector bundled as standalone assets.
|
||||
|
||||
Two ways to use this:
|
||||
|
||||
- Set `?ws=hostname:port` to connect to a `bun --inspect`'d process.
|
||||
- Navigate to `/inspect/hostname:port`
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web-inspector-bun",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-tM8yLeYVe7pRyAu9VMi/Q7aunpLwD139EY1S99xbQkT4/q2qa6eA4ige/WJQYdJ8GBL1K33pPFhPfPdJ/WzT8Q=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-lsB65vAbe90I/Qe10OjkmrdxSX4UJDjosDgb8sZUKcg3oefEuW2OT2Vozz8ef7wrJbMcmhvCC+hciF8jY/uAkw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-qK/TpmHt2M/Hg82WXHRc/W/2SGo/l1thtDHZWqFq7oi24AjZ4O/CpPSu6ZuYKFkEgmZlFoa7CooAyYmuvnaG8w=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ora8JokrvrzEPEpZO18ZYXkH4asCdc1DLdcVy8TGf5eWtPO1Ie4WroEJzwI52ZGtpODy3+m0a2yEX9l+KUn0tA=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-tP+B5UuIbbFMj2hQaUr6EALlHOIOmlLM2FK7jeFBobPy2ERdohI4Ka6ZFjZ1ZYsrHE/hZimGuU90jusRE0pwDw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YbPY2kc0acfzL1VPVK6EnAlig4f+l8xmq36OZkU0jzBVHcOTyQDhnKQaLzZudNJQyymd9OqQezeaBgkTGdTGeQ=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nSO5uZT2clM6hosjWHAsS15hLrwCvIWx+b2e3lZ3MwbYSaXwvfO528OF+dLjas1g3bZonciivI8qKR/Hm7IWGw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Odalh8hICg7SOD7XCj0YLpYCEc+6mkoq63UnExDCiRA2wXEmGlK5JVrW50vZR9Qz4qkvqnHcpH+OFEggO3PgTg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ig2P7GeG//zWlU0AggA3pV1h5gdix0MA3wgB+NsnBXViwiGgY77fuN9Wr5uoCrs2YzaYfogXgsWZbm+HGr09xg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-mLfp0ziRPOLSTek0Gd9T5B8AtzKAkoZE70fneiiyPlSnUKKI4lp+mGEnQXcQEHLJAcIYDPSyBvsUbKUG2ri/XQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hn28+JNDTxxCpnYjdDYVMNTR3SKavyLlCHHkufHV91fkewpIyQchS1d8wSbmXhs1fiYDpNww8KTFlJ1dHsxeSw=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-KbXaC0Sejt7vD2fEgPoIKb6nxkfYW9OmFUK9XQE4//PvGIxNIfPk1NmlHmMg6f25x57rpmEFrn1OotASYIAaTg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dJ0kE8KTqbiHtA3Fc/zn7lCd7pqVr4JcT0JqOnbj4LLzYnp+7h8Qi4yjfq42ZlHfhOCM42rBh0EwHYLL6LEzcw=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-7Z/jKNFufZ/bbu4INqqCN6DDlrmOTmdw6D0gH+6Y7auok2r02Ur661qPuXidPOJ+FSgbEeQnnAGgsVynfLuOEw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-U+RinR6aXXABFCcAY4gSlv4CL1oOVvSSCdseQmGO66H+XyuQGZIUdhG56SZaDJQcLmrSfRmx5XZOWyCJPRqS7g=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oxzHTEv6VPm3XXNaHPyUTTte+3wGv7qVQtqaZCrgstI16gCuhNOtBXLEBkBREP57YTd68P0VgDgG73jSD8bwXQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-WNa5zZk1XpTTwMDompZmvQLHszDDDN7lYjEHCUmAGB83Bgs20EMs7ICD+oKeT6xt4phV4NDdSi/8OfjPbSbZfQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-S6kI1aT3S++Dedb7vxIuUOb3oAxqxk2Rh5rOXOTYnzN8JzW1VzBd+IqPiSpgitu45042SYD3HCoEyhLKQcDFDw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-VXSSMsmb+Z8LbsQGcBMiM+fYObDNRm8p7tkUDMPG/g4fhFX5DEFmjxIEa3N8Zr96SjsJ1woAhF0DUnS3MF3ARw=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-5NayUlSAyb5PQYFAU9x3bHdsqB88RC3aM9lKDAz4X1mo/EchMIT1Q+pSeBXNgkfNmRecLXA0O8xP+x8V+g/LKg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-47gL/ek1v36iN0wL9L4Q2MFdujR0poLZMJwhO2/N3gA89jgHp4MR8DKCmwYtGNksbfJb9JoTtbkoe6sDhg2QTA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-tcuhV7ncXBqbt/Ybf0IyrMcwVOAPDckMK9rXNHtF17UTK18OKLpg08glminN06pt2WCoALhXdLfSPbVvK/6fxw=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.0.29" } }, "sha512-E6UWZuN4ymAxzUBWVIGDHJ3Zey7I8cMzDZ+cB1BqhZsmd1uPb9iAQzpWMruY1mKzsuD3R+dZPoBkZz8QL1KhSA=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long=="],
|
||||
|
||||
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "~20.11.3", "@types/ws": "~8.5.10" } }, "sha512-Z+U1ORr/2UCwxelIZxE83pyPLclviYL9UewQCNEUmGeLObY8ao+3WF3D8N1+NMv2+S+hUWsdBJam+4GoPEz35g=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/android-arm": "0.19.2", "@esbuild/android-arm64": "0.19.2", "@esbuild/android-x64": "0.19.2", "@esbuild/darwin-arm64": "0.19.2", "@esbuild/darwin-x64": "0.19.2", "@esbuild/freebsd-arm64": "0.19.2", "@esbuild/freebsd-x64": "0.19.2", "@esbuild/linux-arm": "0.19.2", "@esbuild/linux-arm64": "0.19.2", "@esbuild/linux-ia32": "0.19.2", "@esbuild/linux-loong64": "0.19.2", "@esbuild/linux-mips64el": "0.19.2", "@esbuild/linux-ppc64": "0.19.2", "@esbuild/linux-riscv64": "0.19.2", "@esbuild/linux-s390x": "0.19.2", "@esbuild/linux-x64": "0.19.2", "@esbuild/netbsd-x64": "0.19.2", "@esbuild/openbsd-x64": "0.19.2", "@esbuild/sunos-x64": "0.19.2", "@esbuild/win32-arm64": "0.19.2", "@esbuild/win32-ia32": "0.19.2", "@esbuild/win32-x64": "0.19.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-G6hPax8UbFakEj3hWO0Vs52LQ8k3lnBhxZWomUJDxfz3rZTLqF5k/FCzuNdLx2RbpBiQQF9H9onlDDH1lZsnjg=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "web-inspector-bun",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { copyFileSync, mkdirSync, readdirSync, rmSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
try {
|
||||
const basePath = join(import.meta.dir, "../../../src/bun.js/WebKit/Source/WebInspectorUI/UserInterface");
|
||||
const htmlPath = join(basePath, "Main.html");
|
||||
const backendCommands = join(
|
||||
import.meta.dir,
|
||||
"../../../src/bun.js/WebKit/WebKitBuild/Release/JavaScriptCore/DerivedSources/inspector/InspectorBackendCommands.js",
|
||||
);
|
||||
const scriptsToBundle = [];
|
||||
const stylesToBundle = [];
|
||||
const jsReplacementId = crypto.randomUUID() + ".js";
|
||||
const cssReplacementId = crypto.randomUUID() + ".css";
|
||||
const html = new HTMLRewriter()
|
||||
.on("script", {
|
||||
element(element) {
|
||||
const src = element.getAttribute("src");
|
||||
if (
|
||||
src &&
|
||||
!src?.includes("External") &&
|
||||
!src?.includes("WebKitAdditions") &&
|
||||
!src.includes("DOMUtilities.js")
|
||||
) {
|
||||
if (scriptsToBundle.length === 0) {
|
||||
element.replace("<script>var WI = {};\n</script>", { html: true });
|
||||
} else {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
scriptsToBundle.push(src);
|
||||
}
|
||||
},
|
||||
})
|
||||
.on("script:not([src])", {
|
||||
element(element) {
|
||||
element.remove();
|
||||
},
|
||||
})
|
||||
.on("head", {
|
||||
element(element) {
|
||||
element.prepend(
|
||||
`
|
||||
<script type="text/javascript">
|
||||
if (!Element.prototype.scrollIntoViewIfNeeded) {
|
||||
Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded) {
|
||||
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
|
||||
|
||||
var parent = this.parentNode,
|
||||
parentComputedStyle = window.getComputedStyle(parent, null),
|
||||
parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')),
|
||||
parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')),
|
||||
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
|
||||
overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight),
|
||||
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
|
||||
overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth),
|
||||
alignWithTop = overTop && !overBottom;
|
||||
|
||||
if ((overTop || overBottom) && centerIfNeeded) {
|
||||
parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2;
|
||||
}
|
||||
|
||||
if ((overLeft || overRight) && centerIfNeeded) {
|
||||
parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2;
|
||||
}
|
||||
|
||||
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
|
||||
this.scrollIntoView(alignWithTop);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<base href="/" /> `,
|
||||
{ html: true },
|
||||
);
|
||||
|
||||
element.append(
|
||||
`
|
||||
<style>
|
||||
body {
|
||||
--undocked-title-area-height: 0px !important;
|
||||
}
|
||||
</style>
|
||||
<script src="${jsReplacementId}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
WI.sharedApp = new WI.AppController;
|
||||
WI.sharedApp.initialize();
|
||||
</script>`,
|
||||
{ html: true },
|
||||
);
|
||||
},
|
||||
})
|
||||
// .on("link[rel=stylesheet]", {
|
||||
// element(element) {
|
||||
// const href = element.getAttribute("href");
|
||||
// if (href && !href?.includes("External") && !href?.includes("WebKitAdditions")) {
|
||||
// element.remove();
|
||||
// stylesToBundle.push(href);
|
||||
// }
|
||||
// },
|
||||
// })
|
||||
.transform(new Response(Bun.file(htmlPath)));
|
||||
let htmlText = await html.text();
|
||||
rmSync(join(import.meta.dir, "out"), { recursive: true, force: true });
|
||||
mkdirSync(join(import.meta.dir, "out", "Protocol"), { recursive: true });
|
||||
|
||||
const javascript = scriptsToBundle.map(a => `import '${join(basePath, a)}';`).join("\n") + "\n";
|
||||
// const css = stylesToBundle.map(a => `@import "${join(basePath, a)}";`).join("\n") + "\n";
|
||||
await Bun.write(join(import.meta.dir, "out/manifest.js"), javascript);
|
||||
mkdirSync("out/WebKitAdditions/WebInspectorUI/", { recursive: true });
|
||||
await Bun.write(join(import.meta.dir, "out/WebKitAdditions/WebInspectorUI/WebInspectorUIAdditions.js"), "");
|
||||
await Bun.write(join(import.meta.dir, "out/WebKitAdditions/WebInspectorUI/WebInspectorUIAdditions.css"), "");
|
||||
// await Bun.write(join(import.meta.dir, "manifest.css"), css);
|
||||
const jsBundle = await Bun.build({
|
||||
entrypoints: [join(import.meta.dir, "out/manifest.js")],
|
||||
outdir: "out",
|
||||
minify: true,
|
||||
});
|
||||
const jsFilename = "manifest-" + jsBundle.outputs[0].hash + ".js";
|
||||
// const cssBundle = await build({
|
||||
// bundle: true,
|
||||
// minify: true,
|
||||
// write: false,
|
||||
// entryPoints: [join(import.meta.dir, "manifest.css")],
|
||||
// outdir: "out",
|
||||
// loader: {
|
||||
// ".css": "css",
|
||||
// ".svg": "dataurl",
|
||||
// },
|
||||
// external: ["*.png"],
|
||||
// plugins: [
|
||||
// {
|
||||
// name: "css",
|
||||
// setup(build) {
|
||||
// build.onResolve({ filter: new RegExp("/Images/Warning.svg") }, args => ({
|
||||
// path: join(basePath, "Images/Warning.svg"),
|
||||
// }));
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// const cssFilename = "manifest-" + cssBundle.outputFiles[0].hash.replaceAll("/", "_") + ".css";
|
||||
htmlText = htmlText.replace(jsReplacementId, jsFilename);
|
||||
// htmlText = htmlText.replace(cssReplacementId, cssFilename);
|
||||
await Bun.write(join(import.meta.dir, "out", jsFilename), jsBundle.outputs[0]);
|
||||
// await Bun.write(join(import.meta.dir, "out", cssFilename), cssBundle.outputFiles[0].text);
|
||||
await Bun.write(join(import.meta.dir, "out", "index.html"), htmlText);
|
||||
await Bun.write(join(import.meta.dir, "out", "index.html"), htmlText);
|
||||
await Bun.write(join(import.meta.dir, "out", "Protocol", "InspectorBackendCommands.js"), Bun.file(backendCommands));
|
||||
|
||||
function recursiveCopy(src, dest) {
|
||||
readdirSync(src).forEach(file => {
|
||||
const srcPath = join(src, file);
|
||||
const destPath = join(dest, file);
|
||||
if (statSync(srcPath).isDirectory()) {
|
||||
mkdirSync(destPath, { recursive: true });
|
||||
recursiveCopy(srcPath, destPath);
|
||||
} else {
|
||||
rmSync(destPath, { force: true });
|
||||
copyFileSync(srcPath, destPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recursiveCopy(basePath, join(import.meta.dir, "out"));
|
||||
} catch (e) {
|
||||
console.error("Failed to build. Please make sure you've ran `make jsc` locally.");
|
||||
throw e;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSourceMap": true,
|
||||
"allowJs": true,
|
||||
"noImplicitAny": false,
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [".", "../bun-types/index.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
src/protocol/*/protocol.json linguist-generated=true
|
||||
src/protocol/*/index.d.ts linguist-generated=true
|
||||
@@ -0,0 +1,3 @@
|
||||
index.js
|
||||
protocol/*.json
|
||||
protocol/v8
|
||||
@@ -0,0 +1,354 @@
|
||||
# bun-inspector-protocol
|
||||
|
||||
`bun-inspector-protocol` is a TypeScript library that provides a comprehensive interface for interacting with the WebKit Inspector Protocol. This package makes it easy to build debugging tools, IDE integrations, and other developer tools that communicate with Bun's JavaScript runtime.
|
||||
|
||||
You can use this library with Node.js or Bun.
|
||||
|
||||
## Overview
|
||||
|
||||
The WebKit Inspector Protocol is a JSON-based protocol similar to the Chrome DevTools Protocol. It allows external tools to interact with Bun's JavaScript runtime for debugging, profiling, and instrumentation purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- 🌐 **WebSocket communication**: Connect to Bun's debugging endpoint via WebSockets
|
||||
- 🔌 **Socket communication**: Connect via Unix/TCP sockets for local debugging
|
||||
- 🔄 **Full API typing**: Complete TypeScript definitions for the protocol
|
||||
- 📊 **Object preview utilities**: Format runtime objects for display
|
||||
- 🔄 **Event-driven architecture**: Subscribe to specific debugging events
|
||||
- 🧩 **Promise-based API**: Clean, modern async interface
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add bun-inspector-protocol
|
||||
# npm install bun-inspector-protocol
|
||||
# yarn add bun-inspector-protocol
|
||||
# pnpm add bun-inspector-protocol
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The first step is to spawn a Bun process with the inspector attached. There are a few different ways to do this.
|
||||
|
||||
The `--inspect-wait` flag is the easiest way to spawn a Bun process with the inspector attached.
|
||||
|
||||
```bash
|
||||
bun --inspect-wait my-script.ts
|
||||
```
|
||||
|
||||
From there, it will start a WebSocket server defaulting to port 9229 and you will need to read the output from stdout to get the URL of the inspector:
|
||||
|
||||
```bash
|
||||
bun --inspect-wait my-script.ts 2>&1 | grep -o '\sws://.*$'
|
||||
```
|
||||
|
||||
From there, you can connect to the inspector using the `WebSocketInspector` class:
|
||||
|
||||
```typescript
|
||||
import { WebSocketInspector } from "bun-inspector-protocol";
|
||||
|
||||
// Create a new inspector client
|
||||
const inspector = new WebSocketInspector("ws://localhost:9229/ws");
|
||||
```
|
||||
|
||||
### Connecting via WebSocket
|
||||
|
||||
```typescript
|
||||
import { WebSocketInspector } from "bun-inspector-protocol";
|
||||
|
||||
// Create a new inspector client
|
||||
const inspector = new WebSocketInspector("ws://localhost:9229/ws");
|
||||
|
||||
// Listen for connection events
|
||||
inspector.on("Inspector.connected", () => {
|
||||
console.log("Connected to debugger!");
|
||||
});
|
||||
|
||||
inspector.on("Inspector.error", error => {
|
||||
console.error("Inspector error:", error);
|
||||
});
|
||||
|
||||
// Connect to the debugger
|
||||
await inspector.start();
|
||||
|
||||
// Enable the Runtime domain
|
||||
await inspector.send("Runtime.enable");
|
||||
|
||||
// Execute some code in the target context
|
||||
const result = await inspector.send("Runtime.evaluate", {
|
||||
expression: "2 + 2",
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
console.log("Evaluation result:", result.result.value); // 4
|
||||
|
||||
// Close the connection
|
||||
inspector.close();
|
||||
```
|
||||
|
||||
### Connecting via Socket (for Local Debugging)
|
||||
|
||||
```typescript
|
||||
import { NodeSocketInspector } from "bun-inspector-protocol";
|
||||
import { Socket } from "node:net";
|
||||
|
||||
// Create a socket connection
|
||||
const socket = new Socket();
|
||||
socket.connect("/path/to/debug/socket");
|
||||
|
||||
// Create a new inspector client
|
||||
const inspector = new NodeSocketInspector(socket);
|
||||
|
||||
// Set up event listeners and use the API as with WebSocketInspector
|
||||
inspector.on("Inspector.connected", () => {
|
||||
console.log("Connected to debugger via socket!");
|
||||
});
|
||||
|
||||
await inspector.start();
|
||||
// Use the same API as WebSocketInspector from here...
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
The inspector emits various events you can listen for:
|
||||
|
||||
```typescript
|
||||
// Listen for specific protocol events
|
||||
inspector.on("Debugger.scriptParsed", params => {
|
||||
console.log("Script parsed:", params.url);
|
||||
});
|
||||
|
||||
// Listen for breakpoint hits
|
||||
inspector.on("Debugger.paused", params => {
|
||||
console.log("Execution paused at:", params.callFrames[0].location);
|
||||
});
|
||||
|
||||
// Listen for console messages
|
||||
inspector.on("Runtime.consoleAPICalled", params => {
|
||||
console.log(
|
||||
"Console message:",
|
||||
params.args
|
||||
.map(arg =>
|
||||
// Use the included utility to format objects
|
||||
remoteObjectToString(arg, true),
|
||||
)
|
||||
.join(" "),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Domains
|
||||
|
||||
The WebKit Inspector Protocol is organized into domains that group related functionality. Based on the JavaScriptCore protocol implementation, the following domains are available:
|
||||
|
||||
### Console Domain
|
||||
|
||||
- Console message capturing and monitoring
|
||||
- Support for different logging channels and levels (xml, javascript, network, etc.)
|
||||
- Methods: `enable`, `disable`, `clearMessages`, `setLoggingChannelLevel`, etc.
|
||||
- Events: `messageAdded`, `messageRepeatCountUpdated`, `messagesCleared`
|
||||
|
||||
### Debugger Domain
|
||||
|
||||
- Comprehensive debugging capabilities
|
||||
- Setting and managing breakpoints (conditional, URL-based, symbolic)
|
||||
- Execution control (pause, resume, step, etc.)
|
||||
- Stack frame inspection and manipulation
|
||||
- Methods: `enable`, `setBreakpoint`, `resume`, `stepInto`, `evaluateOnCallFrame`, etc.
|
||||
- Events: `scriptParsed`, `breakpointResolved`, `paused`, `resumed`
|
||||
|
||||
### Heap Domain
|
||||
|
||||
- Memory management and garbage collection monitoring
|
||||
- Heap snapshot creation and analysis
|
||||
- Memory leak detection with tracking
|
||||
- Methods: `enable`, `gc`, `snapshot`, `startTracking`, `stopTracking`
|
||||
- Events: `garbageCollected`, `trackingStart`, `trackingComplete`
|
||||
|
||||
### Inspector Domain
|
||||
|
||||
- Core inspector functionality
|
||||
- Methods: `enable`, `disable`, `initialized`
|
||||
- Events: `evaluateForTestInFrontend`, `inspect`
|
||||
|
||||
### LifecycleReporter Domain
|
||||
|
||||
- Process lifecycle management
|
||||
- Error reporting
|
||||
- Methods: `enable`, `preventExit`, `stopPreventingExit`
|
||||
- Events: `reload`, `error`
|
||||
|
||||
### Runtime Domain
|
||||
|
||||
- JavaScript runtime interaction
|
||||
- Expression evaluation
|
||||
- Object property inspection
|
||||
- Promise handling
|
||||
- Type profiling and control flow analysis
|
||||
- Methods: `evaluate`, `callFunctionOn`, `getProperties`, `awaitPromise`, etc.
|
||||
- Events: `executionContextCreated`
|
||||
|
||||
### ScriptProfiler Domain
|
||||
|
||||
- Script execution profiling
|
||||
- Performance tracking
|
||||
- Methods: `startTracking`, `stopTracking`
|
||||
- Events: `trackingStart`, `trackingUpdate`, `trackingComplete`
|
||||
|
||||
### TestReporter Domain
|
||||
|
||||
- Test execution monitoring
|
||||
- Test status reporting (pass, fail, timeout, skip, todo)
|
||||
- Methods: `enable`, `disable`
|
||||
- Events: `found`, `start`, `end`
|
||||
|
||||
Each domain has its own set of commands, events, and data types. Refer to the TypeScript definitions in this package for complete API details.
|
||||
|
||||
## Working with Remote Objects
|
||||
|
||||
When evaluating expressions, you'll often receive remote object references. Use the `remoteObjectToString` utility to convert these to string representations:
|
||||
|
||||
```typescript
|
||||
import { remoteObjectToString } from "bun-inspector-protocol";
|
||||
|
||||
const result = await inspector.send("Runtime.evaluate", {
|
||||
expression: "{ a: 1, b: { c: 'hello' } }",
|
||||
});
|
||||
|
||||
console.log(remoteObjectToString(result.result, true));
|
||||
// Output: {a: 1, b: {c: "hello"}}
|
||||
```
|
||||
|
||||
## Message Structure
|
||||
|
||||
The protocol uses a simple JSON-based message format:
|
||||
|
||||
### Requests
|
||||
|
||||
```typescript
|
||||
interface Request<T> {
|
||||
id: number; // Unique request identifier
|
||||
method: string; // Domain.method name format
|
||||
params: T; // Method-specific parameters
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
```typescript
|
||||
interface Response<T> {
|
||||
id: number; // Matching request identifier
|
||||
result?: T; // Method-specific result (on success)
|
||||
error?: {
|
||||
// Error information (on failure)
|
||||
code?: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```typescript
|
||||
interface Event<T> {
|
||||
method: string; // Domain.event name format
|
||||
params: T; // Event-specific parameters
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Breakpoints
|
||||
|
||||
```typescript
|
||||
// Set a breakpoint by URL
|
||||
const { breakpointId } = await inspector.send("Debugger.setBreakpointByUrl", {
|
||||
lineNumber: 42,
|
||||
url: "/app/foo.ts",
|
||||
condition: "x > 5", // Optional condition
|
||||
});
|
||||
|
||||
// Set a breakpoint with custom actions
|
||||
await inspector.send("Debugger.setBreakpoint", {
|
||||
location: { scriptId: "123", lineNumber: 10 },
|
||||
options: {
|
||||
condition: "count > 5",
|
||||
actions: [
|
||||
{ type: "log", data: "Breakpoint hit!" },
|
||||
{ type: "evaluate", data: "console.log('Custom breakpoint action')" },
|
||||
],
|
||||
autoContinue: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Remove a breakpoint
|
||||
await inspector.send("Debugger.removeBreakpoint", { breakpointId });
|
||||
```
|
||||
|
||||
### Memory Profiling
|
||||
|
||||
```typescript
|
||||
// Start heap tracking
|
||||
await inspector.send("Heap.enable");
|
||||
await inspector.send("Heap.startTracking");
|
||||
|
||||
// Listen for GC events
|
||||
inspector.on("Heap.garbageCollected", ({ collection }) => {
|
||||
console.log(
|
||||
`GC completed: ${collection.type} (${collection.endTime - collection.startTime}ms)`,
|
||||
);
|
||||
});
|
||||
|
||||
// ... perform operations to analyze ...
|
||||
|
||||
// Get heap snapshot
|
||||
const { snapshotData } = await inspector.send("Heap.stopTracking");
|
||||
// Process snapshotData to find memory leaks
|
||||
```
|
||||
|
||||
### Script Profiling
|
||||
|
||||
```typescript
|
||||
// Start script profiling with sampling
|
||||
await inspector.send("ScriptProfiler.startTracking", { includeSamples: true });
|
||||
|
||||
// Listen for profiling updates
|
||||
inspector.on("ScriptProfiler.trackingUpdate", event => {
|
||||
console.log("Profiling event:", event);
|
||||
});
|
||||
|
||||
// Stop profiling to get complete data
|
||||
inspector.on("ScriptProfiler.trackingComplete", data => {
|
||||
if (data.samples) {
|
||||
// Process stack traces
|
||||
console.log(`Collected ${data.samples.stackTraces.length} stack traces`);
|
||||
}
|
||||
});
|
||||
|
||||
await inspector.send("ScriptProfiler.stopTracking");
|
||||
```
|
||||
|
||||
## Protocol Differences from Upstream WebKit
|
||||
|
||||
Notable Bun-specific additions include:
|
||||
|
||||
- `LifecycleReporter` domain for process lifecycle management
|
||||
- Enhanced `TestReporter` domain for test framework integration
|
||||
- Additional utilities for script and heap profiling
|
||||
|
||||
## Building Tools with the Protocol
|
||||
|
||||
This library is ideal for building:
|
||||
|
||||
- IDE extensions and debuggers
|
||||
- Performance monitoring tools
|
||||
- Testing frameworks with runtime instrumentation
|
||||
- Hot module reloading systems
|
||||
- Custom REPL environments
|
||||
- Profiling and optimization tools
|
||||
|
||||
## Full API Reference
|
||||
|
||||
For complete API documentation, please refer to the TypeScript definitions included in this package. The definitions provide comprehensive information about all available commands, events, and their parameters.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-inspector-protocol",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type * from "./src/inspector/index";
|
||||
export * from "./src/inspector/node-socket";
|
||||
export * from "./src/inspector/websocket";
|
||||
export type * from "./src/protocol/index";
|
||||
export * from "./src/util/preview";
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "bun-inspector-protocol",
|
||||
"version": "0.0.2",
|
||||
"scripts": {
|
||||
"build": "bun build --target=node --outfile=index.js --minify-syntax ./index.ts --external=ws"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"main": "./index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Regenerates src/protocol/jsc/{protocol.json,index.d.ts} from the inspector
|
||||
// protocol of the WebKit build Bun links against.
|
||||
//
|
||||
// bun scripts/generate-protocol.ts [path/to/CombinedDomains.json] [--v8]
|
||||
//
|
||||
// CombinedDomains.json is what JavaScriptCore's build produces from
|
||||
// Source/JavaScriptCore/inspector/protocol/*.json. The bun-webkit prebuilt
|
||||
// tarball ships it at its top level, so after `bun bd` it can be found in the
|
||||
// build cache for the WEBKIT_VERSION pinned in scripts/build/deps/webkit.ts;
|
||||
// that is what is used when no path is given. A local WebKit build writes it
|
||||
// to <build>/JavaScriptCore/DerivedSources/CombinedDomains.json.
|
||||
//
|
||||
// Pass --v8 to also refresh src/protocol/v8 from the Chrome DevTools protocol
|
||||
// repository (requires network access).
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Domain, Property, Protocol } from "../src/protocol/schema";
|
||||
|
||||
function formatProtocol(protocol: Protocol, extraTs?: string): string {
|
||||
const { name, domains } = protocol;
|
||||
const eventMap = new Map();
|
||||
const commandMap = new Map();
|
||||
let body = `export namespace ${name} {`;
|
||||
for (const { domain, types = [], events = [], commands = [] } of domains) {
|
||||
body += `export namespace ${domain} {`;
|
||||
|
||||
for (const type of types) {
|
||||
body += formatProperty(type);
|
||||
}
|
||||
for (const { name, description, parameters = [] } of events) {
|
||||
const symbol = `${domain}.${name}`;
|
||||
const title = toTitle(name);
|
||||
eventMap.set(symbol, `${domain}.${title}`);
|
||||
body += formatProperty({
|
||||
id: `${title}Event`,
|
||||
type: "object",
|
||||
description: `${description}\n@event \`${symbol}\``,
|
||||
properties: parameters,
|
||||
});
|
||||
}
|
||||
for (const { name, description, parameters = [], returns = [] } of commands) {
|
||||
const symbol = `${domain}.${name}`;
|
||||
const title = toTitle(name);
|
||||
commandMap.set(symbol, `${domain}.${title}`);
|
||||
body += formatProperty({
|
||||
id: `${title}Request`,
|
||||
type: "object",
|
||||
description: `${description}\n@request \`${symbol}\``,
|
||||
properties: parameters,
|
||||
});
|
||||
body += formatProperty({
|
||||
id: `${title}Response`,
|
||||
type: "object",
|
||||
description: `${description}\n@response \`${symbol}\``,
|
||||
properties: returns,
|
||||
});
|
||||
}
|
||||
body += "};";
|
||||
}
|
||||
for (const type of ["Event", "Request", "Response"]) {
|
||||
const sourceMap = type === "Event" ? eventMap : commandMap;
|
||||
body += formatProperty({
|
||||
id: `${type}Map`,
|
||||
type: "object",
|
||||
properties: [...sourceMap.entries()].map(([name, title]) => ({
|
||||
name: `"${name}"`,
|
||||
type: undefined,
|
||||
$ref: `${title}${type}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (extraTs) {
|
||||
body += extraTs;
|
||||
}
|
||||
return body + "};";
|
||||
}
|
||||
|
||||
function formatProperty(property: Property): string {
|
||||
const { id, description, type, optional } = property;
|
||||
let body = "";
|
||||
if (id) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}\n`;
|
||||
}
|
||||
body += `export type ${id}=`;
|
||||
}
|
||||
if (type === "boolean") {
|
||||
body += "boolean";
|
||||
} else if (type === "number" || type === "integer") {
|
||||
body += "number";
|
||||
} else if (type === "string") {
|
||||
const { enum: choices } = property;
|
||||
if (choices) {
|
||||
body += choices.map(value => `"${value}"`).join("|");
|
||||
} else {
|
||||
body += "string";
|
||||
}
|
||||
} else if (type === "array") {
|
||||
const { items } = property;
|
||||
const itemType = items ? formatProperty(items) : "unknown";
|
||||
body += `${itemType}[]`;
|
||||
} else if (type === "object") {
|
||||
const { properties } = property;
|
||||
if (!properties) {
|
||||
body += "Record<string, unknown>";
|
||||
} else if (properties.length === 0) {
|
||||
body += "{}";
|
||||
} else {
|
||||
body += "{";
|
||||
for (const { name, description, ...property } of properties) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}`;
|
||||
}
|
||||
const delimit = property.optional ? "?:" : ":";
|
||||
body += `\n${name}${delimit}${formatProperty({ ...property, id: undefined })};`;
|
||||
}
|
||||
body += "}";
|
||||
}
|
||||
} else if ("$ref" in property) {
|
||||
body += property.$ref;
|
||||
} else {
|
||||
body += "unknown";
|
||||
}
|
||||
if (optional) {
|
||||
body += "|undefined";
|
||||
}
|
||||
if (id) {
|
||||
body += ";";
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @link https://github.com/ChromeDevTools/devtools-protocol/tree/master/json
|
||||
*/
|
||||
async function downloadV8(): Promise<Protocol> {
|
||||
const baseUrl = "https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/json";
|
||||
const domains = ["Runtime", "Console", "Debugger", "Memory", "HeapProfiler", "Profiler", "Network", "Inspector"];
|
||||
return Promise.all([
|
||||
download<Protocol>(`${baseUrl}/js_protocol.json`),
|
||||
download<Protocol>(`${baseUrl}/browser_protocol.json`),
|
||||
]).then(([js, browser]) => ({
|
||||
name: "V8",
|
||||
version: js.version,
|
||||
domains: [...js.domains, ...browser.domains]
|
||||
.filter(domain => !domains.includes(domain.domain))
|
||||
.sort((a, b) => a.domain.localeCompare(b.domain)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function download<V>(url: string): Promise<V> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function toTitle(name: string): string {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function toComment(description?: string): string {
|
||||
if (!description) {
|
||||
return "";
|
||||
}
|
||||
const lines = ["/**", ...description.split("\n").map(line => ` * ${line.trim()}`), "*/"];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
|
||||
|
||||
/** The CombinedDomains.json of the prebuilt WebKit pinned by scripts/build/deps/webkit.ts, if it has been downloaded. */
|
||||
function findPinnedCombinedDomains(): string | undefined {
|
||||
const webkitTs = readFileSync(path.join(repoRoot, "scripts", "build", "deps", "webkit.ts"), "utf-8");
|
||||
const version = /^export const WEBKIT_VERSION = "([^"]+)";/m.exec(webkitTs)?.[1];
|
||||
if (!version) {
|
||||
throw new Error("Could not find WEBKIT_VERSION in scripts/build/deps/webkit.ts");
|
||||
}
|
||||
// Mirrors prebuiltDestDir() in scripts/build/deps/webkit.ts:
|
||||
// <cache>/webkit-<version>[-<os>][-<arch>][-debug|-lto][-asan]/
|
||||
const dirVersion = version.startsWith("autobuild-") ? version.slice("autobuild-".length) : version.slice(0, 16);
|
||||
const bunInstall = process.env.BUN_INSTALL
|
||||
? path.resolve(repoRoot, process.env.BUN_INSTALL)
|
||||
: path.join(homedir(), ".bun");
|
||||
const cacheDir = path.join(bunInstall, "build-cache");
|
||||
if (!existsSync(cacheDir)) {
|
||||
return undefined;
|
||||
}
|
||||
const glob = new Bun.Glob(`webkit-${dirVersion}*/CombinedDomains.json`);
|
||||
const [match] = [...glob.scanSync({ cwd: cacheDir })].sort();
|
||||
return match && path.join(cacheDir, match);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains that Bun's WebKit fork declares as debuggable from JavaScript, but that bun registers no
|
||||
* agent for (see src/jsc/bindings/BunDebugger.cpp): bun answers every command in them with
|
||||
* "'<domain>' domain was not found". test/cli/inspect/bun-inspector-protocol.test.ts checks that
|
||||
* this is still true, so remove a domain from here once bun implements it.
|
||||
*/
|
||||
const domainsWithoutAgent = new Set(["File", "Process"]);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const includeV8 = args.includes("--v8");
|
||||
const combinedDomainsPath = args.find(arg => !arg.startsWith("--")) ?? findPinnedCombinedDomains();
|
||||
if (!combinedDomainsPath) {
|
||||
console.error(
|
||||
"Could not find CombinedDomains.json for the pinned WebKit version. " +
|
||||
"Run `bun bd` first or pass the path to a WebKit build's CombinedDomains.json.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Reading ${combinedDomainsPath}`);
|
||||
const combinedDomains: { domains: Domain[] } = await Bun.file(combinedDomainsPath).json();
|
||||
|
||||
const protocolDir = path.resolve(import.meta.dir, "..", "src", "protocol");
|
||||
const written: string[] = [];
|
||||
const write = (name: string, data: string) => {
|
||||
const filePath = path.join(protocolDir, name);
|
||||
writeFileSync(filePath, data);
|
||||
written.push(filePath);
|
||||
};
|
||||
const base = readFileSync(path.join(protocolDir, "protocol.d.ts"), "utf-8");
|
||||
const baseNoComments = base.replace(/\/\/.*/g, "");
|
||||
|
||||
const jsc: Protocol = {
|
||||
name: "JSC",
|
||||
version: {
|
||||
major: 1,
|
||||
minor: 4,
|
||||
},
|
||||
domains: combinedDomains.domains
|
||||
.filter(domain => domain.debuggableTypes?.includes("javascript") && !domainsWithoutAgent.has(domain.domain))
|
||||
.sort((a, b) => a.domain.localeCompare(b.domain)),
|
||||
};
|
||||
write("jsc/protocol.json", JSON.stringify(jsc, null, 2));
|
||||
write("jsc/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(jsc, baseNoComments));
|
||||
|
||||
if (includeV8) {
|
||||
const v8 = await downloadV8();
|
||||
write("v8/protocol.json", JSON.stringify(v8));
|
||||
write("v8/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(v8, baseNoComments));
|
||||
}
|
||||
|
||||
const { status } = spawnSync("bunx", ["prettier", "--write", ...written], { cwd: repoRoot, stdio: "inherit" });
|
||||
if (status !== 0) {
|
||||
process.exit(status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type { JSC } from "../protocol";
|
||||
|
||||
export type InspectorEventMap = {
|
||||
[E in keyof JSC.EventMap]: [JSC.EventMap[E]];
|
||||
} & {
|
||||
"Inspector.connecting": [string];
|
||||
"Inspector.connected": [];
|
||||
"Inspector.disconnected": [Error | undefined];
|
||||
"Inspector.error": [Error];
|
||||
"Inspector.pendingRequest": [JSC.Request];
|
||||
"Inspector.request": [JSC.Request];
|
||||
"Inspector.response": [JSC.Response];
|
||||
"Inspector.event": [JSC.Event];
|
||||
};
|
||||
|
||||
/**
|
||||
* A client that can send and receive messages to/from a debugger.
|
||||
*/
|
||||
export interface Inspector extends EventEmitter<InspectorEventMap> {
|
||||
/**
|
||||
* Starts the inspector.
|
||||
*/
|
||||
start(...args: unknown[]): Promise<boolean>;
|
||||
/**
|
||||
* Sends a request to the debugger.
|
||||
*/
|
||||
send<M extends keyof JSC.RequestMap & keyof JSC.ResponseMap>(
|
||||
method: M,
|
||||
params?: JSC.RequestMap[M],
|
||||
): Promise<JSC.ResponseMap[M]>;
|
||||
/**
|
||||
* If the inspector is closed.
|
||||
*/
|
||||
get closed(): boolean;
|
||||
/**
|
||||
* Closes the inspector.
|
||||
*/
|
||||
close(...args: unknown[]): void;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Socket } from "node:net";
|
||||
import { SocketFramer } from "../../../bun-debug-adapter-protocol/src/debugger/node-socket-framer.js";
|
||||
import type { JSC } from "../protocol";
|
||||
import type { Inspector, InspectorEventMap } from "./index";
|
||||
|
||||
/**
|
||||
* An inspector that communicates with a debugger over a (unix) socket.
|
||||
* This is used in the extension as follows:
|
||||
*
|
||||
* 1. Extension sets environment variable `BUN_INSPECT_NOTIFY` inside of all vscode terminals.
|
||||
* This is a path to a unix socket that the extension will listen on.
|
||||
* 2. Bun reads it and connects to the socket, setting up a reverse connection for sending DAP
|
||||
* messages.
|
||||
*/
|
||||
export class NodeSocketInspector extends EventEmitter<InspectorEventMap> implements Inspector {
|
||||
#ready: Promise<boolean> | undefined;
|
||||
#socket: Socket;
|
||||
#requestId: number;
|
||||
#pendingRequests: JSC.Request[];
|
||||
#pendingResponses: Map<
|
||||
number,
|
||||
{
|
||||
request: JSC.Request;
|
||||
done: (result: unknown) => void;
|
||||
}
|
||||
>;
|
||||
#framer: SocketFramer;
|
||||
|
||||
constructor(socket: Socket) {
|
||||
super();
|
||||
this.#socket = socket;
|
||||
this.#requestId = 1;
|
||||
this.#pendingRequests = [];
|
||||
this.#pendingResponses = new Map();
|
||||
|
||||
this.#framer = new SocketFramer(socket, message => {
|
||||
if (Array.isArray(message)) {
|
||||
for (const m of message) {
|
||||
this.#accept(m);
|
||||
}
|
||||
} else {
|
||||
this.#accept(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onConnectOrImmediately(cb: () => void) {
|
||||
const isAlreadyConnected = this.#socket.connecting === false;
|
||||
|
||||
if (isAlreadyConnected) {
|
||||
cb();
|
||||
} else {
|
||||
this.#socket.once("connect", cb);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.#ready) {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
this.close();
|
||||
const addressWithPort = this.#socket.remoteAddress + ":" + this.#socket.remotePort;
|
||||
this.emit("Inspector.connecting", addressWithPort);
|
||||
}
|
||||
|
||||
const socket = this.#socket;
|
||||
|
||||
this.onConnectOrImmediately(() => {
|
||||
this.emit("Inspector.connected");
|
||||
|
||||
for (let i = 0; i < this.#pendingRequests.length; i++) {
|
||||
const request = this.#pendingRequests[i];
|
||||
|
||||
if (this.#send(request)) {
|
||||
this.emit("Inspector.request", request);
|
||||
} else {
|
||||
this.#pendingRequests = this.#pendingRequests.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("data", data => this.#framer.onData(data));
|
||||
|
||||
socket.on("error", error => {
|
||||
this.#close(unknownToError(error));
|
||||
});
|
||||
|
||||
socket.on("close", hadError => {
|
||||
if (hadError) {
|
||||
this.#close(new Error("Socket closed due to a transmission error"));
|
||||
} else {
|
||||
this.#close();
|
||||
}
|
||||
});
|
||||
|
||||
const ready = new Promise<boolean>(resolve => {
|
||||
if (socket.connecting) {
|
||||
socket.on("connect", () => resolve(true));
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
socket.on("close", () => resolve(false));
|
||||
socket.on("error", () => resolve(false));
|
||||
}).finally(() => {
|
||||
this.#ready = undefined;
|
||||
});
|
||||
|
||||
this.#ready = ready;
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
send<M extends keyof JSC.RequestMap & keyof JSC.ResponseMap>(
|
||||
method: M,
|
||||
params?: JSC.RequestMap[M] | undefined,
|
||||
): Promise<JSC.ResponseMap[M]> {
|
||||
const id = this.#requestId++;
|
||||
const request = {
|
||||
id,
|
||||
method,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timerId: number | undefined;
|
||||
const done = (result: any) => {
|
||||
this.#pendingResponses.delete(id);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
if (result instanceof Error) {
|
||||
reject(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
this.#pendingResponses.set(id, {
|
||||
request: request,
|
||||
done: done,
|
||||
});
|
||||
|
||||
if (this.#send(request)) {
|
||||
timerId = +setTimeout(() => done(new Error(`Timed out: ${method}`)), 10_000);
|
||||
this.emit("Inspector.request", request);
|
||||
} else {
|
||||
this.emit("Inspector.pendingRequest", request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#send(request: JSC.Request): boolean {
|
||||
this.#framer.send(JSON.stringify(request));
|
||||
|
||||
if (!this.#pendingRequests.includes(request)) {
|
||||
this.#pendingRequests.push(request);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#accept(message: string): void {
|
||||
let data: JSC.Event | JSC.Response;
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
} catch (cause) {
|
||||
this.emit("Inspector.error", new Error(`Failed to parse message: ${message}`, { cause }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("id" in data)) {
|
||||
this.emit("Inspector.event", data);
|
||||
const { method, params } = data;
|
||||
this.emit(method, params);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("Inspector.response", data);
|
||||
|
||||
const { id } = data;
|
||||
const handle = this.#pendingResponses.get(id);
|
||||
if (!handle) {
|
||||
this.emit("Inspector.error", new Error(`Failed to find matching request for ID: ${id}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in data) {
|
||||
const { error } = data;
|
||||
const { message } = error;
|
||||
handle.done(new Error(message));
|
||||
} else {
|
||||
const { result } = data;
|
||||
handle.done(result);
|
||||
}
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return !this.#socket.writable;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#socket?.end();
|
||||
}
|
||||
|
||||
#close(error?: Error): void {
|
||||
for (const handle of this.#pendingResponses.values()) {
|
||||
handle.done(error ?? new Error("Socket closed while waiting for: " + handle.request.method));
|
||||
}
|
||||
|
||||
this.#pendingResponses.clear();
|
||||
|
||||
if (error) {
|
||||
this.emit("Inspector.error", error);
|
||||
}
|
||||
|
||||
this.emit("Inspector.disconnected", error);
|
||||
}
|
||||
}
|
||||
|
||||
function unknownToError(input: unknown): Error {
|
||||
if (input instanceof Error) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null && "message" in input) {
|
||||
const { message } = input;
|
||||
return new Error(`${message}`);
|
||||
}
|
||||
|
||||
return new Error(`${input}`);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket } from "ws";
|
||||
import type { JSC } from "../protocol";
|
||||
import type { Inspector, InspectorEventMap } from "./index";
|
||||
|
||||
/**
|
||||
* An inspector that communicates with a debugger over a WebSocket.
|
||||
*/
|
||||
export class WebSocketInspector extends EventEmitter<InspectorEventMap> implements Inspector {
|
||||
#url?: string;
|
||||
#webSocket?: WebSocket;
|
||||
#ready: Promise<boolean> | undefined;
|
||||
#requestId: number;
|
||||
#pendingRequests: JSC.Request[];
|
||||
#pendingResponses: Map<number, (result: unknown) => void>;
|
||||
|
||||
constructor(url?: string | URL) {
|
||||
super();
|
||||
this.#url = url ? String(url) : undefined;
|
||||
this.#requestId = 1;
|
||||
this.#pendingRequests = [];
|
||||
this.#pendingResponses = new Map();
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.#url!;
|
||||
}
|
||||
|
||||
async start(url?: string | URL): Promise<boolean> {
|
||||
if (url) {
|
||||
this.#url = String(url);
|
||||
}
|
||||
|
||||
if (!this.#url) {
|
||||
this.emit("Inspector.error", new Error("Inspector needs a URL, but none was provided"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.#connect(this.#url);
|
||||
}
|
||||
|
||||
async #connect(url: string): Promise<boolean> {
|
||||
if (this.#ready) {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
this.close(1001, "Restarting...");
|
||||
this.emit("Inspector.connecting", url);
|
||||
|
||||
let webSocket: WebSocket;
|
||||
try {
|
||||
// @ts-expect-error: Support both Bun and Node.js version of `headers`.
|
||||
webSocket = new WebSocket(url, {
|
||||
headers: {
|
||||
"Ref-Event-Loop": "0",
|
||||
},
|
||||
finishRequest: (request: import("http").ClientRequest) => {
|
||||
request.setHeader("Ref-Event-Loop", "0");
|
||||
request.end();
|
||||
},
|
||||
});
|
||||
} catch (cause) {
|
||||
this.#close(unknownToError(cause));
|
||||
return false;
|
||||
}
|
||||
|
||||
webSocket.addEventListener("open", () => {
|
||||
this.emit("Inspector.connected");
|
||||
|
||||
for (let i = 0; i < this.#pendingRequests.length; i++) {
|
||||
const request = this.#pendingRequests[i];
|
||||
|
||||
if (this.#send(request)) {
|
||||
this.emit("Inspector.request", request);
|
||||
} else {
|
||||
this.#pendingRequests = this.#pendingRequests.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
webSocket.addEventListener("message", ({ data }) => {
|
||||
if (typeof data === "string") {
|
||||
this.#accept(data);
|
||||
} else {
|
||||
this.emit("Inspector.error", new Error(`WebSocket received unexpected binary message: ${data.toString()}`));
|
||||
}
|
||||
});
|
||||
|
||||
webSocket.addEventListener("error", event => {
|
||||
this.#close(unknownToError(event));
|
||||
});
|
||||
|
||||
webSocket.addEventListener("unexpected-response", () => {
|
||||
this.#close(new Error("WebSocket upgrade failed"));
|
||||
});
|
||||
|
||||
webSocket.addEventListener("close", ({ code, reason }) => {
|
||||
if (code === 1001 || code === 1006) {
|
||||
this.#close();
|
||||
return;
|
||||
}
|
||||
this.#close(new Error(`WebSocket closed: ${code} ${reason}`.trimEnd()));
|
||||
});
|
||||
|
||||
this.#webSocket = webSocket;
|
||||
|
||||
const ready = new Promise<boolean>(resolve => {
|
||||
webSocket.addEventListener("open", () => resolve(true));
|
||||
webSocket.addEventListener("close", () => resolve(false));
|
||||
webSocket.addEventListener("error", () => resolve(false));
|
||||
}).finally(() => {
|
||||
this.#ready = undefined;
|
||||
});
|
||||
|
||||
this.#ready = ready;
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
send<M extends keyof JSC.RequestMap & keyof JSC.ResponseMap>(
|
||||
method: M,
|
||||
params?: JSC.RequestMap[M] | undefined,
|
||||
): Promise<JSC.ResponseMap[M]> {
|
||||
const id = this.#requestId++;
|
||||
const request = {
|
||||
id,
|
||||
method,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timerId: number | undefined;
|
||||
const done = (result: any) => {
|
||||
this.#pendingResponses.delete(id);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
if (result instanceof Error) {
|
||||
reject(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
this.#pendingResponses.set(id, done);
|
||||
if (this.#send(request)) {
|
||||
timerId = +setTimeout(() => done(new Error(`Timed out: ${method}`)), 10_000);
|
||||
this.emit("Inspector.request", request);
|
||||
} else {
|
||||
this.emit("Inspector.pendingRequest", request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#send(request: JSC.Request): boolean {
|
||||
if (this.#webSocket) {
|
||||
const { readyState } = this.#webSocket!;
|
||||
if (readyState === WebSocket.OPEN) {
|
||||
this.#webSocket.send(JSON.stringify(request));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#pendingRequests.includes(request)) {
|
||||
this.#pendingRequests.push(request);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#accept(message: string): void {
|
||||
let data: JSC.Event | JSC.Response;
|
||||
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
} catch (cause) {
|
||||
this.emit("Inspector.error", new Error(`Failed to parse message: ${message}`, { cause }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("id" in data)) {
|
||||
this.emit("Inspector.event", data);
|
||||
const { method, params } = data;
|
||||
this.emit(method, params);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("Inspector.response", data);
|
||||
|
||||
const { id } = data;
|
||||
const resolve = this.#pendingResponses.get(id);
|
||||
if (!resolve) {
|
||||
this.emit("Inspector.error", new Error(`Failed to find matching request for ID: ${id}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in data) {
|
||||
const { error } = data;
|
||||
const { message } = error;
|
||||
resolve(new Error(message));
|
||||
} else {
|
||||
const { result } = data;
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
if (!this.#webSocket) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { readyState } = this.#webSocket;
|
||||
switch (readyState) {
|
||||
case WebSocket.CLOSED:
|
||||
case WebSocket.CLOSING:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
this.#webSocket?.close(code ?? 1001, reason);
|
||||
}
|
||||
|
||||
#close(error?: Error): void {
|
||||
for (const resolve of this.#pendingResponses.values()) {
|
||||
resolve(error ?? new Error("WebSocket closed"));
|
||||
}
|
||||
this.#pendingResponses.clear();
|
||||
|
||||
if (error) {
|
||||
this.emit("Inspector.error", error);
|
||||
}
|
||||
this.emit("Inspector.disconnected", error);
|
||||
}
|
||||
}
|
||||
|
||||
function unknownToError(input: unknown): Error {
|
||||
if (input instanceof Error) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null && "message" in input) {
|
||||
const { message } = input;
|
||||
return new Error(`${message}`);
|
||||
}
|
||||
|
||||
return new Error(`${input}`);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { JSC } from "./jsc";
|
||||
+3371
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
// @ts-nocheck
|
||||
// The content of this file is included in each generated protocol file.
|
||||
|
||||
export type Event<T extends keyof EventMap = keyof EventMap> = {
|
||||
readonly method: T;
|
||||
readonly params: EventMap[T];
|
||||
};
|
||||
|
||||
export type Request<T extends keyof RequestMap = keyof RequestMap> = {
|
||||
readonly id: number;
|
||||
readonly method: T;
|
||||
readonly params: RequestMap[T];
|
||||
};
|
||||
|
||||
export type Response<T extends keyof ResponseMap = keyof ResponseMap> = {
|
||||
readonly id: number;
|
||||
} & (
|
||||
| {
|
||||
readonly method?: T;
|
||||
readonly result: ResponseMap[T];
|
||||
}
|
||||
| {
|
||||
readonly error: {
|
||||
readonly code?: string;
|
||||
readonly message: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Represents the schema of the protocol.json file.
|
||||
|
||||
export type Protocol = {
|
||||
readonly name: string;
|
||||
readonly version: {
|
||||
readonly major: number;
|
||||
readonly minor: number;
|
||||
};
|
||||
readonly domains: readonly Domain[];
|
||||
};
|
||||
|
||||
export type Domain = {
|
||||
readonly domain: string;
|
||||
readonly debuggableTypes?: readonly string[];
|
||||
readonly dependencies?: readonly string[];
|
||||
readonly types?: readonly Property[];
|
||||
readonly commands?: readonly Command[];
|
||||
readonly events?: readonly Event[];
|
||||
};
|
||||
|
||||
export type Command = {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly parameters?: readonly Property[];
|
||||
readonly returns?: readonly Property[];
|
||||
};
|
||||
|
||||
export type Event = {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly parameters?: readonly Property[];
|
||||
};
|
||||
|
||||
export type Property = {
|
||||
readonly id?: string;
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly optional?: boolean;
|
||||
} & (
|
||||
| {
|
||||
readonly type: "array";
|
||||
readonly items?: Property;
|
||||
}
|
||||
| {
|
||||
readonly type: "object";
|
||||
readonly properties?: readonly Property[];
|
||||
}
|
||||
| {
|
||||
readonly type: "string";
|
||||
readonly enum?: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly type: "boolean" | "number" | "integer";
|
||||
}
|
||||
| {
|
||||
readonly type: undefined;
|
||||
readonly $ref: string;
|
||||
}
|
||||
);
|
||||
+17939
File diff suppressed because it is too large
Load Diff
+14671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
import type { JSC } from "../protocol";
|
||||
|
||||
export function remoteObjectToString(remoteObject: JSC.Runtime.RemoteObject, topLevel?: boolean): string {
|
||||
const { type, subtype, value, description, className, preview } = remoteObject;
|
||||
switch (type) {
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "boolean":
|
||||
case "number":
|
||||
return description ?? JSON.stringify(value);
|
||||
case "string":
|
||||
if (topLevel) {
|
||||
return String(value ?? description);
|
||||
}
|
||||
return JSON.stringify(value ?? description);
|
||||
case "symbol":
|
||||
case "bigint":
|
||||
return description!;
|
||||
case "function":
|
||||
return description!.replace("function", "ƒ") || "ƒ";
|
||||
}
|
||||
switch (subtype) {
|
||||
case "null":
|
||||
return "null";
|
||||
case "regexp":
|
||||
case "date":
|
||||
case "error":
|
||||
return description!;
|
||||
}
|
||||
if (preview) {
|
||||
return objectPreviewToString(preview);
|
||||
}
|
||||
if (className) {
|
||||
return className;
|
||||
}
|
||||
return description || "Object";
|
||||
}
|
||||
|
||||
export function objectPreviewToString(objectPreview: JSC.Runtime.ObjectPreview): string {
|
||||
const { type, subtype, entries, properties, overflow, description, size } = objectPreview;
|
||||
if (type !== "object") {
|
||||
return remoteObjectToString(objectPreview);
|
||||
}
|
||||
let items: string[];
|
||||
if (entries) {
|
||||
items = entries.map(entryPreviewToString).sort();
|
||||
} else if (properties) {
|
||||
if (isIndexed(subtype)) {
|
||||
items = properties.map(indexedPropertyPreviewToString);
|
||||
if (subtype !== "array") {
|
||||
items.sort();
|
||||
}
|
||||
} else {
|
||||
items = properties.map(namedPropertyPreviewToString).sort();
|
||||
}
|
||||
} else {
|
||||
items = ["…"];
|
||||
}
|
||||
if (overflow) {
|
||||
items.push("…");
|
||||
}
|
||||
let label: string;
|
||||
if (description === "Object") {
|
||||
label = "";
|
||||
} else if (size === undefined) {
|
||||
label = description!;
|
||||
} else {
|
||||
label = `${description}(${size})`;
|
||||
}
|
||||
if (!items.length) {
|
||||
return label || "{}";
|
||||
}
|
||||
if (label) {
|
||||
label += " ";
|
||||
}
|
||||
if (isIndexed(subtype)) {
|
||||
return `${label}[${items.join(", ")}]`;
|
||||
}
|
||||
return `${label}{${items.join(", ")}}`;
|
||||
}
|
||||
|
||||
function propertyPreviewToString(propertyPreview: JSC.Runtime.PropertyPreview): string {
|
||||
const { type, value, ...preview } = propertyPreview;
|
||||
if (type === "accessor") {
|
||||
return "ƒ";
|
||||
}
|
||||
return remoteObjectToString({ ...preview, type, description: value });
|
||||
}
|
||||
|
||||
function entryPreviewToString(entryPreview: JSC.Runtime.EntryPreview): string {
|
||||
const { key, value } = entryPreview;
|
||||
if (key) {
|
||||
return `${objectPreviewToString(key)} => ${objectPreviewToString(value)}`;
|
||||
}
|
||||
return objectPreviewToString(value);
|
||||
}
|
||||
|
||||
function namedPropertyPreviewToString(propertyPreview: JSC.Runtime.PropertyPreview): string {
|
||||
const { name, valuePreview } = propertyPreview;
|
||||
if (valuePreview) {
|
||||
return `${name}: ${objectPreviewToString(valuePreview)}`;
|
||||
}
|
||||
return `${name}: ${propertyPreviewToString(propertyPreview)}`;
|
||||
}
|
||||
|
||||
function indexedPropertyPreviewToString(propertyPreview: JSC.Runtime.PropertyPreview): string {
|
||||
const { valuePreview } = propertyPreview;
|
||||
if (valuePreview) {
|
||||
return objectPreviewToString(valuePreview);
|
||||
}
|
||||
return propertyPreviewToString(propertyPreview);
|
||||
}
|
||||
|
||||
function isIndexed(type?: JSC.Runtime.RemoteObject["subtype"]): boolean {
|
||||
return type === "array" || type === "set" || type === "weakset";
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Server } from "bun";
|
||||
import { serve } from "bun";
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import { WebSocketInspector } from "../../src/inspector/websocket";
|
||||
|
||||
let server: Server;
|
||||
let url: URL;
|
||||
|
||||
describe("WebSocketInspector", () => {
|
||||
test("fails without a URL", () => {
|
||||
const ws = new WebSocketInspector();
|
||||
const fn = mock(error => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
ws.on("Inspector.error", fn);
|
||||
expect(ws.start()).resolves.toBeFalse();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails with invalid URL", () => {
|
||||
const ws = new WebSocketInspector("notaurl");
|
||||
const fn = mock(error => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
ws.on("Inspector.error", fn);
|
||||
expect(ws.start()).resolves.toBeFalse();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails with valid URL but no server", () => {
|
||||
const ws = new WebSocketInspector("ws://localhost:0/doesnotexist/");
|
||||
const fn = mock(error => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
ws.on("Inspector.error", fn);
|
||||
expect(ws.start()).resolves.toBeFalse();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails with invalid upgrade response", () => {
|
||||
const ws = new WebSocketInspector(new URL("/", url));
|
||||
const fn = mock(error => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
ws.on("Inspector.error", fn);
|
||||
expect(ws.start()).resolves.toBeFalse();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("can connect to a server", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn = mock(() => {
|
||||
expect(ws.closed).toBe(false);
|
||||
});
|
||||
ws.on("Inspector.connected", fn);
|
||||
expect(ws.start()).resolves.toBeTrue();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("can disconnect from a server", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn = mock(() => {
|
||||
expect(ws.closed).toBeTrue();
|
||||
});
|
||||
ws.on("Inspector.disconnected", fn);
|
||||
expect(ws.start()).resolves.toBeTrue();
|
||||
ws.close();
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("can connect to a server multiple times", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn0 = mock(() => {
|
||||
expect(ws.closed).toBeFalse();
|
||||
});
|
||||
ws.on("Inspector.connected", fn0);
|
||||
const fn1 = mock(() => {
|
||||
expect(ws.closed).toBeTrue();
|
||||
});
|
||||
ws.on("Inspector.disconnected", fn1);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(ws.start()).resolves.toBeTrue();
|
||||
ws.close();
|
||||
}
|
||||
expect(fn0).toHaveBeenCalledTimes(3);
|
||||
expect(fn1).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("can send a request", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn0 = mock(request => {
|
||||
expect(request).toStrictEqual({
|
||||
id: 1,
|
||||
method: "Debugger.setPauseOnAssertions",
|
||||
params: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
ws.on("Inspector.request", fn0);
|
||||
const fn1 = mock(response => {
|
||||
expect(response).toStrictEqual({
|
||||
id: 1,
|
||||
result: {
|
||||
ok: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
ws.on("Inspector.response", fn1);
|
||||
expect(ws.start()).resolves.toBeTrue();
|
||||
expect(ws.send("Debugger.setPauseOnAssertions", { enabled: true })).resolves.toMatchObject({ ok: true });
|
||||
expect(fn0).toHaveBeenCalled();
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("can send a request before connecting", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn0 = mock(request => {
|
||||
expect(request).toStrictEqual({
|
||||
id: 1,
|
||||
method: "Runtime.enable",
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
ws.on("Inspector.pendingRequest", fn0);
|
||||
ws.on("Inspector.request", fn0);
|
||||
const fn1 = mock(response => {
|
||||
expect(response).toStrictEqual({
|
||||
id: 1,
|
||||
result: {
|
||||
ok: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
ws.on("Inspector.response", fn1);
|
||||
const request = ws.send("Runtime.enable");
|
||||
expect(ws.start()).resolves.toBe(true);
|
||||
expect(request).resolves.toMatchObject({ ok: true });
|
||||
expect(fn0).toHaveBeenCalledTimes(2);
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("can receive an event", () => {
|
||||
const ws = new WebSocketInspector(url);
|
||||
const fn = mock(event => {
|
||||
expect(event).toStrictEqual({
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
ws.on("Inspector.event", fn);
|
||||
expect(ws.start()).resolves.toBeTrue();
|
||||
expect(ws.send("Debugger.enable")).resolves.toMatchObject({ ok: true });
|
||||
expect(fn).toHaveBeenCalled();
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = serve({
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (request.url.endsWith("/ws") && server.upgrade(request)) {
|
||||
return;
|
||||
}
|
||||
return new Response();
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const { id, method } = JSON.parse(String(message));
|
||||
ws.send(JSON.stringify({ id, result: { ok: true } }));
|
||||
|
||||
if (method === "Debugger.enable") {
|
||||
ws.send(JSON.stringify({ method: "Debugger.scriptParsed", params: { scriptId: "1" } }));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
const { hostname, port } = server;
|
||||
url = new URL(`ws://${hostname}:${port}/ws`);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
console.log(process.execArgv);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSourceMap": true,
|
||||
"allowJs": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".", "../bun-types/index.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.serverless/
|
||||
node_modules/
|
||||
bun-lambda-layer/
|
||||
*.zip
|
||||
@@ -0,0 +1,115 @@
|
||||
# bun-lambda
|
||||
|
||||
A custom runtime layer that runs Bun on AWS Lambda.
|
||||
|
||||
## Setup
|
||||
|
||||
First, you will need to deploy the layer to your AWS account. Clone this repository and run the `publish-layer` script to get started. Note: the `publish-layer` script also builds the layer.
|
||||
|
||||
```sh
|
||||
git clone --filter=blob:none --sparse https://github.com/oven-sh/bun.git
|
||||
git -C bun sparse-checkout set packages/bun-lambda
|
||||
cd bun/packages/bun-lambda
|
||||
bun install
|
||||
bun run publish-layer
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once you publish the layer to your AWS account, you can create a Lambda function that uses the layer.
|
||||
|
||||
### Step 1: Create a Bun Lambda handler function
|
||||
|
||||
In addition to providing the Bun runtime itself, the Bun Lambda Layer also provides an event transformation so you can write your Bun function in a classic Bun server format. This allows you to also run your Lambda function as a local Bun server with `bun run <handler-name>.ts`. Here are some examples of how to write a Bun Lambda function:
|
||||
|
||||
#### HTTP Event Example
|
||||
|
||||
When an event is triggered from [API Gateway](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html), the layer transforms the event payload into a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request). This means you can test your Lambda function locally using `bun run`, without any code changes.
|
||||
|
||||
```ts
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
console.log(request.headers.get("x-amzn-function-arn"));
|
||||
// ...
|
||||
return new Response("Hello from Lambda!", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### Non-HTTP Event Example
|
||||
|
||||
For non-HTTP events — S3, SQS, EventBridge, etc. — the event payload is the body of the [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request).
|
||||
|
||||
```ts
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const event = await request.json();
|
||||
// ...
|
||||
return new Response();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Build the Bun handler
|
||||
|
||||
The final step is to upload your Bun handler. You can technically write the handler directly in the console if you wish, but if you want a full development environment, use the Bun toolkit. There are several ways you can choose to build and manage your artifacts, but follow these steps for a simple approach:
|
||||
|
||||
1. Run `bun build <handler-entry>.[ts|js] --outfile ./dist/handler.js`
|
||||
2. Zip the `/dist` folder
|
||||
|
||||
### Step 3: Create the Lambda function on AWS
|
||||
|
||||
Once you've written your Lambda function, you need to configure a new Lambda function to use Bun. The following steps apply to configuring in the console, CloudFormation, CDK, Terraform, or any other configuration management option for AWS:
|
||||
|
||||
1. Create the Lambda function
|
||||
2. Set the Runtime to custom with Amazon Linux 2
|
||||
3. Set the handler to <handler-file-name>.fetch (e.g. if your bundled Bun handler is at `handler.js`, set the handler as `handler.fetch`)
|
||||
4. Set the architecture to whichever architecture you configured when you built/deployed the Lambda Layer
|
||||
5. Attach the Lambda Layer to your new function
|
||||
6. Upload the zip file from step 2. You can do this in the console directly, upload to S3 and set that as the location for the handler file in Lambda, or use something like CDK to manage this for you.
|
||||
|
||||
## API
|
||||
|
||||
### `bun run build-layer`
|
||||
|
||||
Builds a Lambda layer for Bun and saves it to a `.zip` file.
|
||||
|
||||
| Flag | Description | Default |
|
||||
| ----------- | -------------------------------------------------------------------- | ---------------------- |
|
||||
| `--arch` | The architecture, either: "x64" or "aarch64" | aarch64 |
|
||||
| `--release` | The release of Bun, either: "latest", "canary", or a release "x.y.z" | latest |
|
||||
| `--output` | The path to write the layer as a `.zip`. | ./bun-lambda-layer.zip |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
bun run build-layer -- \
|
||||
--arch x64 \
|
||||
--release canary \
|
||||
--output /path/to/layer.zip
|
||||
```
|
||||
|
||||
### `bun run publish-layer`
|
||||
|
||||
Builds a Lambda layer for Bun then publishes it to your AWS account.
|
||||
|
||||
| Flag | Description | Default |
|
||||
| ---------- | ----------------------------------------- | ------- |
|
||||
| `--layer` | The layer name. | bun |
|
||||
| `--region` | The region name, or "\*" for all regions. | |
|
||||
| `--public` | If the layer should be public. | false |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
bun run publish-layer -- \
|
||||
--arch aarch64 \
|
||||
--release latest \
|
||||
--output /path/to/layer.zip \
|
||||
--region us-east-1
|
||||
```
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#! /bin/sh
|
||||
export BUN_INSTALL_CACHE_DIR=/tmp/bun/cache
|
||||
exec /opt/bun --cwd $LAMBDA_TASK_ROOT /opt/runtime.ts
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, server: Server): Promise<Response | undefined> {
|
||||
console.log("Request", {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers.toJSON(),
|
||||
body: request.body ? await request.text() : null,
|
||||
});
|
||||
if (server.upgrade(request)) {
|
||||
console.log("WebSocket upgraded");
|
||||
return;
|
||||
}
|
||||
return new Response("Hello from Bun on Lambda!", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain;charset=utf-8",
|
||||
},
|
||||
});
|
||||
},
|
||||
websocket: {
|
||||
async open(ws: ServerWebSocket): Promise<void> {
|
||||
console.log("WebSocket opened");
|
||||
},
|
||||
async message(ws: ServerWebSocket, message: string): Promise<void> {
|
||||
console.log("WebSocket message", message);
|
||||
},
|
||||
async close(ws: ServerWebSocket, code: number, reason?: string): Promise<void> {
|
||||
console.log("WebSocket closed", { code, reason });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "bun-lambda",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@oclif/plugin-plugins": "^3.5.0",
|
||||
"bun-types": "^0.7.0",
|
||||
"jszip": "^3.10.1",
|
||||
"oclif": "^3.6.5",
|
||||
"prettier": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.17"
|
||||
},
|
||||
"scripts": {
|
||||
"build-layer": "bun scripts/build-layer.ts",
|
||||
"publish-layer": "bun scripts/publish-layer.ts",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"overrides": {
|
||||
"braces": "^3.0.3"
|
||||
}
|
||||
}
|
||||
Executable
+826
@@ -0,0 +1,826 @@
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
type Lambda = {
|
||||
fetch: (request: Request, server: Server) => Promise<Response | undefined>;
|
||||
error?: (error: unknown) => Promise<Response>;
|
||||
websocket?: {
|
||||
open?: (ws: ServerWebSocket) => Promise<void>;
|
||||
message?: (ws: ServerWebSocket, message: string) => Promise<void>;
|
||||
close?: (ws: ServerWebSocket, code: number, reason: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
let requestId: string | undefined;
|
||||
let traceId: string | undefined;
|
||||
let functionArn: string | undefined;
|
||||
let aws: AwsClient | undefined;
|
||||
|
||||
let logger = console.log;
|
||||
|
||||
function log(level: string, ...args: any[]): void {
|
||||
if (!args.length) {
|
||||
return;
|
||||
}
|
||||
const messages = args.map(arg => Bun.inspect(arg).replace(/\n/g, "\r"));
|
||||
if (requestId === undefined) {
|
||||
logger(level, ...messages);
|
||||
} else {
|
||||
logger(level, `RequestId: ${requestId}`, ...messages);
|
||||
}
|
||||
}
|
||||
|
||||
console.log = (...args: any[]) => log("INFO", ...args);
|
||||
console.info = (...args: any[]) => log("INFO", ...args);
|
||||
console.warn = (...args: any[]) => log("WARN", ...args);
|
||||
console.error = (...args: any[]) => log("ERROR", ...args);
|
||||
console.debug = (...args: any[]) => log("DEBUG", ...args);
|
||||
console.trace = (...args: any[]) => log("TRACE", ...args);
|
||||
|
||||
let warnings: Set<string> | undefined;
|
||||
|
||||
function warnOnce(message: string, ...args: any[]): void {
|
||||
if (warnings === undefined) {
|
||||
warnings = new Set();
|
||||
}
|
||||
if (warnings.has(message)) {
|
||||
return;
|
||||
}
|
||||
warnings.add(message);
|
||||
console.warn(message, ...args);
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
requestId = undefined;
|
||||
traceId = undefined;
|
||||
warnings = undefined;
|
||||
}
|
||||
|
||||
function exit(...cause: any[]): never {
|
||||
console.error(...cause);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function env(name: string, fallback?: string): string {
|
||||
const value = process.env[name] ?? fallback ?? null;
|
||||
if (value === null) {
|
||||
exit(`Runtime failed to find the '${name}' environment variable`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const runtimeUrl = new URL(`http://${env("AWS_LAMBDA_RUNTIME_API")}/2018-06-01/`);
|
||||
|
||||
async function fetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
const { href } = new URL(url, runtimeUrl);
|
||||
const response = await globalThis.fetch(href, {
|
||||
...options,
|
||||
timeout: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
exit(`Runtime failed to send request to Lambda [status: ${response.status}]`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function fetchAws(url: string, options?: RequestInit): Promise<Response> {
|
||||
if (aws === undefined) {
|
||||
aws = new AwsClient({
|
||||
accessKeyId: env("AWS_ACCESS_KEY_ID"),
|
||||
secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
|
||||
sessionToken: env("AWS_SESSION_TOKEN"),
|
||||
region: env("AWS_REGION"),
|
||||
});
|
||||
}
|
||||
return aws.fetch(url, options);
|
||||
}
|
||||
|
||||
type LambdaError = {
|
||||
readonly errorType: string;
|
||||
readonly errorMessage: string;
|
||||
readonly stackTrace?: string[];
|
||||
};
|
||||
|
||||
function formatError(error: unknown): LambdaError {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorType: error.name,
|
||||
errorMessage: error.message,
|
||||
stackTrace: error.stack?.split("\n").filter(line => !line.includes(" /opt/runtime.ts")),
|
||||
};
|
||||
}
|
||||
return {
|
||||
errorType: "Error",
|
||||
errorMessage: Bun.inspect(error),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendError(type: string, cause: unknown): Promise<void> {
|
||||
console.error(cause);
|
||||
await fetch(requestId === undefined ? "runtime/init/error" : `runtime/invocation/${requestId}/error`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.aws.lambda.error+json",
|
||||
"Lambda-Runtime-Function-Error-Type": `Bun.${type}`,
|
||||
},
|
||||
body: JSON.stringify(formatError(cause)),
|
||||
});
|
||||
}
|
||||
|
||||
async function throwError(type: string, cause: unknown): Promise<never> {
|
||||
await sendError(type, cause);
|
||||
exit();
|
||||
}
|
||||
|
||||
async function init(): Promise<Lambda> {
|
||||
const handlerName = env("_HANDLER");
|
||||
const index = handlerName.lastIndexOf(".");
|
||||
const fileName = handlerName.substring(0, index);
|
||||
const filePath = `${env("LAMBDA_TASK_ROOT")}/${fileName}`;
|
||||
let file;
|
||||
try {
|
||||
file = await import(filePath);
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message.startsWith("Cannot find module")) {
|
||||
return throwError("FileDoesNotExist", `Did not find a file named '${fileName}'`);
|
||||
}
|
||||
return throwError("InitError", cause);
|
||||
}
|
||||
const moduleName = handlerName.substring(index + 1) || "fetch";
|
||||
let module = file["default"] ?? file[moduleName] ?? {};
|
||||
if (typeof module === "function") {
|
||||
module = {
|
||||
fetch: module,
|
||||
};
|
||||
} else if (typeof module === "object" && moduleName !== "fetch") {
|
||||
module = {
|
||||
...module,
|
||||
fetch: module[moduleName],
|
||||
};
|
||||
}
|
||||
const { fetch, websocket } = module;
|
||||
if (typeof fetch !== "function") {
|
||||
return throwError(
|
||||
fetch === undefined ? "MethodDoesNotExist" : "MethodIsNotAFunction",
|
||||
`${fileName} does not have a default export with a function named '${moduleName}'`,
|
||||
);
|
||||
}
|
||||
if (websocket === undefined) {
|
||||
return module;
|
||||
}
|
||||
for (const name of ["open", "message", "close"]) {
|
||||
const method = websocket[name];
|
||||
if (method === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof method !== "function") {
|
||||
return throwError(
|
||||
"MethodIsNotAFunction",
|
||||
`${fileName} does not have a function named '${name}' on the default 'websocket' property`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
type LambdaRequest<E = any> = {
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly functionArn: string;
|
||||
readonly deadlineMs: number | null;
|
||||
readonly event: E;
|
||||
};
|
||||
|
||||
async function receiveRequest(): Promise<LambdaRequest> {
|
||||
const response = await fetch("runtime/invocation/next");
|
||||
requestId = response.headers.get("Lambda-Runtime-Aws-Request-Id") ?? undefined;
|
||||
if (requestId === undefined) {
|
||||
exit("Runtime received a request without a request ID");
|
||||
}
|
||||
traceId = response.headers.get("Lambda-Runtime-Trace-Id") ?? undefined;
|
||||
if (traceId === undefined) {
|
||||
exit("Runtime received a request without a trace ID");
|
||||
}
|
||||
process.env["_X_AMZN_TRACE_ID"] = traceId;
|
||||
functionArn = response.headers.get("Lambda-Runtime-Invoked-Function-Arn") ?? undefined;
|
||||
if (functionArn === undefined) {
|
||||
exit("Runtime received a request without a function ARN");
|
||||
}
|
||||
const deadlineMs = parseInt(response.headers.get("Lambda-Runtime-Deadline-Ms") ?? "0") || null;
|
||||
let event;
|
||||
try {
|
||||
event = await response.json();
|
||||
} catch (cause) {
|
||||
exit("Runtime received a request with invalid JSON", cause);
|
||||
}
|
||||
return {
|
||||
requestId,
|
||||
traceId,
|
||||
functionArn,
|
||||
deadlineMs,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
type LambdaResponse = {
|
||||
readonly statusCode: number;
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly isBase64Encoded?: boolean;
|
||||
readonly body?: string;
|
||||
readonly multiValueHeaders?: Record<string, string[]>;
|
||||
readonly cookies?: string[];
|
||||
};
|
||||
|
||||
async function formatResponse(response: Response): Promise<LambdaResponse> {
|
||||
const statusCode = response.status;
|
||||
const headers = response.headers.toJSON();
|
||||
if (statusCode === 101) {
|
||||
const protocol = headers["sec-websocket-protocol"];
|
||||
if (protocol === undefined) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": protocol,
|
||||
},
|
||||
};
|
||||
}
|
||||
const mime = headers["content-type"];
|
||||
const isBase64Encoded = !mime || (!mime.startsWith("text/") && !mime.startsWith("application/json"));
|
||||
const body = isBase64Encoded ? Buffer.from(await response.arrayBuffer()).toString("base64") : await response.text();
|
||||
delete headers["set-cookie"];
|
||||
const cookies = response.headers.getAll("Set-Cookie");
|
||||
if (cookies.length === 0) {
|
||||
return {
|
||||
statusCode,
|
||||
headers,
|
||||
isBase64Encoded,
|
||||
body,
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode,
|
||||
headers,
|
||||
cookies,
|
||||
multiValueHeaders: {
|
||||
"Set-Cookie": cookies,
|
||||
},
|
||||
isBase64Encoded,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendResponse(response: unknown): Promise<void> {
|
||||
if (requestId === undefined) {
|
||||
exit("Runtime attempted to send a response without a request ID");
|
||||
}
|
||||
await fetch(`runtime/invocation/${requestId}/response`, {
|
||||
method: "POST",
|
||||
body: response === null ? null : typeof response === "string" ? response : JSON.stringify(response),
|
||||
});
|
||||
}
|
||||
|
||||
function formatBody(body?: string, isBase64Encoded?: boolean): string | null {
|
||||
if (body === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!isBase64Encoded) {
|
||||
return body;
|
||||
}
|
||||
return Buffer.from(body, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
type HttpEventV1 = {
|
||||
readonly requestContext: {
|
||||
readonly requestId: string;
|
||||
readonly domainName: string;
|
||||
readonly httpMethod: string;
|
||||
readonly path: string;
|
||||
};
|
||||
readonly headers: Record<string, string>;
|
||||
readonly multiValueHeaders?: Record<string, string[]>;
|
||||
readonly queryStringParameters?: Record<string, string>;
|
||||
readonly multiValueQueryStringParameters?: Record<string, string[]>;
|
||||
readonly isBase64Encoded: boolean;
|
||||
readonly body?: string;
|
||||
};
|
||||
|
||||
function isHttpEventV1(event: any): event is HttpEventV1 {
|
||||
return !event.Records && event.version !== "2.0" && event.version !== "0" && typeof event.requestContext === "object";
|
||||
}
|
||||
|
||||
function formatHttpEventV1(event: HttpEventV1): Request {
|
||||
const request = event.requestContext;
|
||||
const headers = new Headers();
|
||||
for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) {
|
||||
for (const value of values) {
|
||||
headers.append(name, value);
|
||||
}
|
||||
}
|
||||
const hostname = request.domainName ?? headers.get("Host");
|
||||
const proto = headers.get("X-Forwarded-Proto") ?? "http";
|
||||
const url = new URL(`${proto}://${hostname}/`);
|
||||
url.pathname = request.path;
|
||||
for (const [name, values] of Object.entries(event.multiValueQueryStringParameters ?? {})) {
|
||||
for (const value of values ?? []) {
|
||||
url.searchParams.append(name, value);
|
||||
}
|
||||
}
|
||||
return new Request(url.toString(), {
|
||||
method: request.httpMethod,
|
||||
headers,
|
||||
body: formatBody(event.body, event.isBase64Encoded),
|
||||
});
|
||||
}
|
||||
|
||||
type HttpEventV2 = {
|
||||
readonly version: "2.0";
|
||||
readonly requestContext: {
|
||||
readonly requestId: string;
|
||||
readonly domainName: string;
|
||||
readonly http: {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
};
|
||||
};
|
||||
readonly headers: Record<string, string>;
|
||||
readonly queryStringParameters?: Record<string, string>;
|
||||
readonly cookies?: string[];
|
||||
readonly isBase64Encoded: boolean;
|
||||
readonly body?: string;
|
||||
};
|
||||
|
||||
function isHttpEventV2(event: any): event is HttpEventV2 {
|
||||
return !event.Records && event.version === "2.0" && typeof event.requestContext === "object";
|
||||
}
|
||||
|
||||
function formatHttpEventV2(event: HttpEventV2): Request {
|
||||
const request = event.requestContext;
|
||||
const headers = new Headers();
|
||||
for (const [name, values] of Object.entries(event.headers)) {
|
||||
for (const value of values.split(",")) {
|
||||
headers.append(name, value);
|
||||
}
|
||||
}
|
||||
for (const cookie of event.cookies ?? []) {
|
||||
headers.append("Set-Cookie", cookie);
|
||||
}
|
||||
const hostname = request.domainName ?? headers.get("Host");
|
||||
const proto = headers.get("X-Forwarded-Proto") ?? "http";
|
||||
const url = new URL(`${proto}://${hostname}/`);
|
||||
url.pathname = request.http.path;
|
||||
for (const [name, values] of Object.entries(event.queryStringParameters ?? {})) {
|
||||
url.searchParams.append(name, values);
|
||||
}
|
||||
return new Request(url.toString(), {
|
||||
method: request.http.method,
|
||||
headers,
|
||||
body: formatBody(event.body, event.isBase64Encoded),
|
||||
});
|
||||
}
|
||||
|
||||
function isHttpEvent(event: any): boolean {
|
||||
return isHttpEventV1(event) || isHttpEventV2(event);
|
||||
}
|
||||
|
||||
type WebSocketEvent = {
|
||||
readonly headers: Record<string, string>;
|
||||
readonly multiValueHeaders: Record<string, string[]>;
|
||||
readonly isBase64Encoded: boolean;
|
||||
readonly body?: string;
|
||||
readonly requestContext: {
|
||||
readonly apiId: string;
|
||||
readonly requestId: string;
|
||||
readonly connectionId: string;
|
||||
readonly domainName: string;
|
||||
readonly stage: string;
|
||||
readonly identity: {
|
||||
readonly sourceIp: string;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
readonly eventType: "CONNECT";
|
||||
}
|
||||
| {
|
||||
readonly eventType: "MESSAGE";
|
||||
}
|
||||
| {
|
||||
readonly eventType: "DISCONNECT";
|
||||
readonly disconnectStatusCode: number;
|
||||
readonly disconnectReason: string;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function isWebSocketEvent(event: any): event is WebSocketEvent {
|
||||
return typeof event.requestContext === "object" && typeof event.requestContext.connectionId === "string";
|
||||
}
|
||||
|
||||
function isWebSocketUpgrade(event: any): event is WebSocketEvent {
|
||||
return isWebSocketEvent(event) && event.requestContext.eventType === "CONNECT";
|
||||
}
|
||||
|
||||
function formatWebSocketUpgrade(event: WebSocketEvent): Request {
|
||||
const request = event.requestContext;
|
||||
const headers = new Headers();
|
||||
headers.set("Upgrade", "websocket");
|
||||
headers.set("x-amzn-connection-id", request.connectionId);
|
||||
for (const [name, values] of Object.entries(event.multiValueHeaders as any)) {
|
||||
for (const value of (values as any) ?? []) {
|
||||
headers.append(name, value);
|
||||
}
|
||||
}
|
||||
const hostname = request.domainName ?? headers.get("Host");
|
||||
const proto = headers.get("X-Forwarded-Proto") ?? "http";
|
||||
const url = new URL(`${proto}://${hostname}/${request.stage}`);
|
||||
return new Request(url.toString(), {
|
||||
headers,
|
||||
body: formatBody(event.body, event.isBase64Encoded),
|
||||
});
|
||||
}
|
||||
|
||||
function formatUnknownEvent(event: unknown): Request {
|
||||
return new Request("https://lambda/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(event),
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatRequest(input: LambdaRequest): Request | undefined {
|
||||
const { event, requestId, traceId, functionArn, deadlineMs } = input;
|
||||
let request: Request;
|
||||
if (isHttpEventV2(event)) {
|
||||
request = formatHttpEventV2(event);
|
||||
} else if (isHttpEventV1(event)) {
|
||||
request = formatHttpEventV1(event);
|
||||
} else if (isWebSocketEvent(event)) {
|
||||
if (!isWebSocketUpgrade(event)) {
|
||||
return undefined;
|
||||
}
|
||||
request = formatWebSocketUpgrade(event);
|
||||
} else {
|
||||
request = formatUnknownEvent(input);
|
||||
}
|
||||
request.headers.set("x-amzn-requestid", requestId);
|
||||
request.headers.set("x-amzn-trace-id", traceId);
|
||||
request.headers.set("x-amzn-function-arn", functionArn);
|
||||
if (deadlineMs !== null) {
|
||||
request.headers.set("x-amzn-deadline-ms", `${deadlineMs}`);
|
||||
}
|
||||
// @ts-ignore: Attach the original event to the Request
|
||||
request.aws = event;
|
||||
return request;
|
||||
}
|
||||
|
||||
class LambdaServer implements Server {
|
||||
#lambda: Lambda;
|
||||
#webSockets: Map<string, LambdaWebSocket>;
|
||||
#upgrade: Response | null;
|
||||
pendingRequests: number;
|
||||
pendingWebSockets: number;
|
||||
port: number;
|
||||
hostname: string;
|
||||
development: boolean;
|
||||
|
||||
constructor(lambda: Lambda) {
|
||||
this.#lambda = lambda;
|
||||
this.#webSockets = new Map();
|
||||
this.#upgrade = null;
|
||||
this.pendingRequests = 0;
|
||||
this.pendingWebSockets = 0;
|
||||
this.port = 80;
|
||||
this.hostname = "lambda";
|
||||
this.development = false;
|
||||
}
|
||||
|
||||
async accept(request: LambdaRequest): Promise<unknown> {
|
||||
const deadlineMs = request.deadlineMs === null ? Date.now() + 60_000 : request.deadlineMs;
|
||||
const durationMs = Math.max(1, deadlineMs - Date.now());
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
new Promise<undefined>(resolve => setTimeout(resolve, durationMs)),
|
||||
this.#acceptRequest(request),
|
||||
]);
|
||||
} catch (cause) {
|
||||
await sendError("RequestError", cause);
|
||||
return;
|
||||
}
|
||||
if (response === undefined) {
|
||||
await sendError("TimeoutError", "Function timed out");
|
||||
return;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async #acceptRequest(event: LambdaRequest): Promise<unknown> {
|
||||
const request = formatRequest(event);
|
||||
let response: Response | undefined;
|
||||
if (request === undefined) {
|
||||
await this.#acceptWebSocket(event.event);
|
||||
} else {
|
||||
response = await this.fetch(request);
|
||||
if (response.status === 101) {
|
||||
await this.#acceptWebSocket(event.event);
|
||||
}
|
||||
}
|
||||
if (response === undefined) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
if (!isHttpEvent(event.event)) {
|
||||
return response.text();
|
||||
}
|
||||
return formatResponse(response);
|
||||
}
|
||||
|
||||
async #acceptWebSocket(event: WebSocketEvent): Promise<void> {
|
||||
const request = event.requestContext;
|
||||
const { connectionId, eventType } = request;
|
||||
const webSocket = this.#webSockets.get(connectionId);
|
||||
if (webSocket === undefined || this.#lambda.websocket === undefined) {
|
||||
return;
|
||||
}
|
||||
const { open, message, close } = this.#lambda.websocket;
|
||||
switch (eventType) {
|
||||
case "CONNECT": {
|
||||
if (open) {
|
||||
await open(webSocket);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "MESSAGE": {
|
||||
if (message) {
|
||||
const body = formatBody(event.body, event.isBase64Encoded);
|
||||
if (body !== null) {
|
||||
await message(webSocket, body);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "DISCONNECT": {
|
||||
try {
|
||||
if (close) {
|
||||
const { disconnectStatusCode: code, disconnectReason: reason } = request;
|
||||
await close(webSocket, code, reason);
|
||||
}
|
||||
} finally {
|
||||
this.#webSockets.delete(connectionId);
|
||||
this.pendingWebSockets--;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
exit("Runtime exited because Server.stop() was called");
|
||||
}
|
||||
|
||||
reload(options: any): void {
|
||||
this.#lambda = {
|
||||
fetch: options.fetch ?? this.#lambda.fetch,
|
||||
error: options.error ?? this.#lambda.error,
|
||||
websocket: options.websocket ?? this.#lambda.websocket,
|
||||
};
|
||||
this.port =
|
||||
typeof options.port === "number"
|
||||
? options.port
|
||||
: typeof options.port === "string"
|
||||
? parseInt(options.port)
|
||||
: this.port;
|
||||
this.hostname = options.hostname ?? this.hostname;
|
||||
this.development = options.development ?? this.development;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
this.pendingRequests++;
|
||||
try {
|
||||
let response = await this.#lambda.fetch(request, this);
|
||||
if (response instanceof Response) {
|
||||
return response;
|
||||
}
|
||||
if (response === undefined && this.#upgrade !== null) {
|
||||
return this.#upgrade;
|
||||
}
|
||||
throw new Error("fetch() did not return a Response");
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
if (this.#lambda.error !== undefined) {
|
||||
try {
|
||||
return await this.#lambda.error(cause);
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 500 });
|
||||
} finally {
|
||||
this.pendingRequests--;
|
||||
this.#upgrade = null;
|
||||
}
|
||||
}
|
||||
|
||||
upgrade<T = undefined>(
|
||||
request: Request,
|
||||
options?: {
|
||||
headers?: HeadersInit;
|
||||
data?: T;
|
||||
},
|
||||
): boolean {
|
||||
if (request.method === "GET" && request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
|
||||
this.#upgrade = new Response(null, {
|
||||
status: 101,
|
||||
headers: options?.headers,
|
||||
});
|
||||
if ("aws" in request && isWebSocketUpgrade(request.aws)) {
|
||||
const { connectionId } = request.aws.requestContext;
|
||||
this.#webSockets.set(connectionId, new LambdaWebSocket(request.aws, options?.data));
|
||||
this.pendingWebSockets++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
publish(topic: string, data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
|
||||
let count = 0;
|
||||
for (const webSocket of this.#webSockets.values()) {
|
||||
count += webSocket.publish(topic, data, compress) ? 1 : 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
class LambdaWebSocket implements ServerWebSocket {
|
||||
#connectionId: string;
|
||||
#url: string;
|
||||
#invokeArn: string;
|
||||
#topics: Set<string> | null;
|
||||
remoteAddress: string;
|
||||
readyState: 0 | 2 | 1 | -1 | 3;
|
||||
binaryType?: "arraybuffer" | "uint8array";
|
||||
data: any;
|
||||
|
||||
constructor(event: WebSocketEvent, data?: any) {
|
||||
const request = event.requestContext;
|
||||
this.#connectionId = `${request.connectionId}`;
|
||||
this.#url = `https://${request.domainName}/${request.stage}/@connections/${this.#connectionId}`;
|
||||
const [region, accountId] = (functionArn ?? "").split(":").slice(3, 5);
|
||||
this.#invokeArn = `arn:aws:execute-api:${region}:${accountId}:${request.apiId}/${request.stage}/*`;
|
||||
this.#topics = null;
|
||||
this.remoteAddress = request.identity.sourceIp;
|
||||
this.readyState = 1; // WebSocket.OPEN
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
|
||||
if (typeof data === "string") {
|
||||
return this.sendText(data, compress);
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return this.sendBinary(new Uint8Array(data), compress);
|
||||
}
|
||||
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
return this.sendBinary(buffer, compress);
|
||||
}
|
||||
|
||||
sendText(data: string, compress?: boolean): number {
|
||||
fetchAws(this.#url, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
})
|
||||
.then(({ status }) => {
|
||||
if (status === 403) {
|
||||
warnOnce(
|
||||
"Failed to send WebSocket message due to insufficient IAM permissions",
|
||||
`Assign the following IAM policy to ${functionArn} to fix this issue:`,
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: ["execute-api:Invoke"],
|
||||
Resource: [this.#invokeArn],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
warnOnce(`Failed to send WebSocket message due to a ${status} error`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
warnOnce("Failed to send WebSocket message", error);
|
||||
});
|
||||
return data.length;
|
||||
}
|
||||
|
||||
sendBinary(data: Uint8Array, compress?: boolean): number {
|
||||
warnOnce(
|
||||
"Lambda does not support binary WebSocket messages",
|
||||
"https://docs.aws.amazon.com/apigateway/latest/developerguide/websocket-api-develop-binary-media-types.html",
|
||||
);
|
||||
const base64 = Buffer.from(data).toString("base64");
|
||||
return this.sendText(base64, compress);
|
||||
}
|
||||
|
||||
publish(topic: string, data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
|
||||
if (this.isSubscribed(topic)) {
|
||||
return this.send(data, compress);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
publishText(topic: string, data: string, compress?: boolean): number {
|
||||
if (this.isSubscribed(topic)) {
|
||||
return this.sendText(data, compress);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
publishBinary(topic: string, data: Uint8Array, compress?: boolean): number {
|
||||
if (this.isSubscribed(topic)) {
|
||||
return this.sendBinary(data, compress);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
// TODO: code? reason?
|
||||
fetchAws(this.#url, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(({ status }) => {
|
||||
if (status === 403) {
|
||||
warnOnce(
|
||||
"Failed to close WebSocket due to insufficient IAM permissions",
|
||||
`Assign the following IAM policy to ${functionArn} to fix this issue:`,
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: ["execute-api:Invoke"],
|
||||
Resource: [this.#invokeArn],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
warnOnce(`Failed to close WebSocket due to a ${status} error`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
warnOnce("Failed to close WebSocket", error);
|
||||
});
|
||||
this.readyState = 3; // WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
subscribe(topic: string): boolean {
|
||||
if (this.readyState === 3) {
|
||||
return false;
|
||||
}
|
||||
if (this.#topics === null) {
|
||||
this.#topics = new Set();
|
||||
}
|
||||
this.#topics.add(topic);
|
||||
return true;
|
||||
}
|
||||
|
||||
unsubscribe(topic: string): boolean {
|
||||
if (this.readyState === 3) {
|
||||
return false;
|
||||
}
|
||||
return this.#topics?.delete(topic) ?? false;
|
||||
}
|
||||
|
||||
isSubscribed(topic: string): boolean {
|
||||
return this.readyState !== 3 && this.#topics !== null && this.#topics.has(topic);
|
||||
}
|
||||
|
||||
cork(callback: (ws: ServerWebSocket<undefined>) => any): void | Promise<void> {
|
||||
// Lambda does not support sending multiple messages at a time.
|
||||
return callback(this);
|
||||
}
|
||||
}
|
||||
|
||||
const lambda = await init();
|
||||
const server = new LambdaServer(lambda);
|
||||
while (true) {
|
||||
try {
|
||||
const request = await receiveRequest();
|
||||
const response = await server.accept(request);
|
||||
if (response !== undefined) {
|
||||
await sendResponse(response);
|
||||
}
|
||||
} finally {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user