├── .actrc ├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .vscode └── launch.json ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── automation ├── setup-server.ts ├── ts-node.env.js ├── tsconfig.json ├── webpack.common.ts ├── webpack.dev.ts ├── webpack.prod.ts └── webpack.unittest.ts ├── custom-typings ├── auth0-lock.d.ts ├── deserialize-error.d.ts ├── har-validator.d.ts ├── httpsnippet.d.ts ├── js-beautify.d.ts ├── lib-dom-extensions.d.ts ├── navigator.clipboard.d.ts ├── node-forge-files.d.ts ├── react-virtualized-auto-sizer.d.ts ├── resize-observer.d.ts ├── static-content.d.ts ├── swagger2openapi.d.ts ├── worker-loader.d.ts └── xml-beautifier.d.ts ├── extra-apis ├── ethereum.json └── ipfs.json ├── netlify.toml ├── package-lock.json ├── package.json ├── src ├── amiusing.html ├── components │ ├── account │ │ ├── checkout-spinner.tsx │ │ ├── modal-overlay.tsx │ │ ├── plan-picker.tsx │ │ └── pro-placeholders.tsx │ ├── app.tsx │ ├── common │ │ ├── card-error-banner.tsx │ │ ├── card.tsx │ │ ├── close-button.tsx │ │ ├── collapsible-section.tsx │ │ ├── collapsing-buttons.tsx │ │ ├── copy-button.tsx │ │ ├── docs-link.tsx │ │ ├── duration-pill.tsx │ │ ├── editable-headers.tsx │ │ ├── editable-pairs.tsx │ │ ├── editable-params.tsx │ │ ├── editable-status.tsx │ │ ├── empty-state.tsx │ │ ├── expand-shrink-button.tsx │ │ ├── format-button.tsx │ │ ├── http-version-pill.tsx │ │ ├── icon-button.tsx │ │ ├── inputs.tsx │ │ ├── loading-card.tsx │ │ ├── optional-image.tsx │ │ ├── pill.tsx │ │ ├── send-recieve-graph.tsx │ │ ├── source-icon.tsx │ │ ├── status-code.tsx │ │ ├── tabbed-options.tsx │ │ └── text-content.tsx │ ├── component-utils.tsx │ ├── editor │ │ ├── base-editor.tsx │ │ ├── body-card-components.tsx │ │ ├── content-viewer.tsx │ │ ├── editor-context-menu.ts │ │ ├── focus-wrapper.tsx │ │ ├── image-viewer.tsx │ │ └── monaco.ts │ ├── error-boundary.tsx │ ├── html-context-menu.tsx │ ├── intercept │ │ ├── config │ │ │ ├── android-adb-config.tsx │ │ │ ├── android-device-config.tsx │ │ │ ├── docker-attach-config.tsx │ │ │ ├── electron-config.tsx │ │ │ ├── existing-browser-config.tsx │ │ │ ├── existing-terminal-config.tsx │ │ │ ├── frida-config.tsx │ │ │ ├── intercept-target-list.tsx │ │ │ ├── jvm-config.tsx │ │ │ ├── manual-intercept-config.tsx │ │ │ └── manual-ios-config.tsx │ │ ├── connected-sources.tsx │ │ ├── intercept-option.tsx │ │ └── intercept-page.tsx │ ├── modify │ │ ├── handler-config.tsx │ │ ├── handler-selection.tsx │ │ ├── matcher-config.tsx │ │ ├── matcher-selection.tsx │ │ ├── modify-page.tsx │ │ ├── rule-drag-handle.tsx │ │ ├── rule-group.tsx │ │ ├── rule-icon-menu.tsx │ │ ├── rule-list.tsx │ │ ├── rule-row.tsx │ │ └── rule-title.tsx │ ├── send │ │ ├── request-pane.tsx │ │ ├── response-pane.tsx │ │ ├── send-card-section.tsx │ │ ├── send-page.tsx │ │ ├── send-request-body-card.tsx │ │ ├── send-request-headers-card.tsx │ │ ├── send-request-line.tsx │ │ ├── send-tabs.tsx │ │ ├── sent-response-body.tsx │ │ ├── sent-response-error.tsx │ │ ├── sent-response-headers.tsx │ │ └── sent-response-status.tsx │ ├── settings │ │ ├── api-settings-card.tsx │ │ ├── connection-settings-card.tsx │ │ ├── proxy-settings-card.tsx │ │ ├── settings-components.tsx │ │ ├── settings-page.tsx │ │ └── string-settings-list.tsx │ ├── sidebar.tsx │ ├── split-pane.tsx │ ├── style-provider.tsx │ └── view │ │ ├── filters │ │ ├── filter-input.tsx │ │ ├── filter-suggestion-row.tsx │ │ ├── filter-tag.tsx │ │ ├── save-filters-row.tsx │ │ └── search-filter.tsx │ │ ├── header-card.tsx │ │ ├── http │ │ ├── header-details.tsx │ │ ├── http-aborted-card.tsx │ │ ├── http-api-card.tsx │ │ ├── http-body-card.tsx │ │ ├── http-breakpoint-body-card.tsx │ │ ├── http-breakpoint-header.tsx │ │ ├── http-breakpoint-request-card.tsx │ │ ├── http-breakpoint-response-card.tsx │ │ ├── http-details-footer.tsx │ │ ├── http-details-pane.tsx │ │ ├── http-error-header.tsx │ │ ├── http-export-card.tsx │ │ ├── http-performance-card.tsx │ │ ├── http-request-card.tsx │ │ ├── http-response-card.tsx │ │ ├── http-trailers-card.tsx │ │ ├── matched-rule-pill.tsx │ │ ├── set-cookie-header-description.tsx │ │ ├── transform-card.tsx │ │ └── user-agent-header-description.tsx │ │ ├── rtc │ │ ├── rtc-connection-card.tsx │ │ ├── rtc-connection-details-pane.tsx │ │ ├── rtc-connection-header.tsx │ │ ├── rtc-data-channel-card.tsx │ │ ├── rtc-data-channel-details-pane.tsx │ │ ├── rtc-media-card.tsx │ │ ├── rtc-media-details-pane.tsx │ │ └── sdp-card.tsx │ │ ├── stream-message-list-card.tsx │ │ ├── stream-message-rows.tsx │ │ ├── tls │ │ ├── tls-failure-details-pane.tsx │ │ └── tls-tunnel-details-pane.tsx │ │ ├── url-breakdown.tsx │ │ ├── view-context-menu-builder.ts │ │ ├── view-details-pane.tsx │ │ ├── view-event-list-buttons.tsx │ │ ├── view-event-list-footer.tsx │ │ ├── view-event-list.tsx │ │ ├── view-page.tsx │ │ └── websocket-close-card.tsx ├── errors.ts ├── icons.tsx ├── images │ ├── arc-browser-logo.ts │ ├── brave-browser-logo.ts │ ├── custom-spinner.ts │ ├── loading-logo.png │ └── logo-icon.svg ├── index.html ├── index.tsx ├── metrics.ts ├── model │ ├── account │ │ └── account-store.ts │ ├── api │ │ ├── api-interfaces.ts │ │ ├── api-store.ts │ │ ├── build-api-metadata.ts │ │ ├── jsonrpc.ts │ │ ├── openapi-schema-3.0.ts │ │ ├── openapi-schema-3.1.ts │ │ └── openapi.ts │ ├── crypto.ts │ ├── events │ │ ├── bodies.ts │ │ ├── body-formatting.ts │ │ ├── categorization.ts │ │ ├── content-types.ts │ │ ├── event-base.ts │ │ ├── events-store.ts │ │ ├── observable-events-list.ts │ │ └── stream-message.ts │ ├── filters │ │ ├── filter-matching.ts │ │ ├── search-filters.ts │ │ ├── syntax-matching.ts │ │ └── syntax-parts.ts │ ├── http │ │ ├── api-detector.ts │ │ ├── caching.ts │ │ ├── cookies.ts │ │ ├── editable-body.ts │ │ ├── editable-request-parts.ts │ │ ├── error-types.ts │ │ ├── exchange-breakpoint.ts │ │ ├── har.ts │ │ ├── http-body.ts │ │ ├── http-docs.ts │ │ ├── http-exchange-views.ts │ │ ├── http-exchange.ts │ │ ├── methods.ts │ │ ├── sources.ts │ │ └── upstream-exchange.ts │ ├── interception │ │ ├── frida.ts │ │ ├── interceptor-store.ts │ │ └── interceptors.ts │ ├── network.ts │ ├── observable-cache.ts │ ├── proxy-store.ts │ ├── rules │ │ ├── definitions │ │ │ ├── ethereum-abi.ts │ │ │ ├── ethereum-rule-definitions.ts │ │ │ ├── http-rule-definitions.ts │ │ │ ├── ipfs-rule-definitions.ts │ │ │ ├── rtc-rule-definitions.ts │ │ │ └── websocket-rule-definitions.ts │ │ ├── rule-creation.ts │ │ ├── rule-descriptions.ts │ │ ├── rule-migrations.ts │ │ ├── rule-serialization.ts │ │ ├── rules-store.ts │ │ ├── rules-structure.ts │ │ └── rules.ts │ ├── send │ │ ├── send-request-model.ts │ │ ├── send-response-model.ts │ │ └── send-store.ts │ ├── serialization.ts │ ├── tls │ │ ├── failed-tls-connection.ts │ │ └── tls-tunnel.ts │ ├── ui │ │ ├── context-menu.ts │ │ ├── export.ts │ │ ├── markdown.ts │ │ └── ui-store.ts │ ├── webrtc │ │ ├── rtc-connection.ts │ │ ├── rtc-data-channel.ts │ │ └── rtc-media-track.ts │ └── websockets │ │ ├── upstream-websocket.ts │ │ ├── websocket-stream.ts │ │ └── websocket-views.ts ├── routing.tsx ├── services │ ├── desktop-api.ts │ ├── server-api-types.ts │ ├── server-api.ts │ ├── server-graphql-api.ts │ ├── server-rest-api.ts │ ├── service-versions.ts │ ├── ui-update-worker.ts │ ├── ui-worker-api.ts │ ├── ui-worker-formatters.ts │ ├── ui-worker.ts │ └── update-management.ts ├── styles.ts ├── types.d.ts └── util │ ├── buffer.ts │ ├── colors.ts │ ├── error.ts │ ├── headers.ts │ ├── index.ts │ ├── json-schema.ts │ ├── mobx-persist │ ├── LICENSE.md │ ├── README.md │ ├── persist-object.ts │ ├── persist.ts │ ├── storage.ts │ └── types.ts │ ├── observable.ts │ ├── promise.ts │ ├── protobuf.ts │ ├── streams.ts │ ├── text.ts │ ├── ui.ts │ └── url.ts ├── test ├── custom-typings │ ├── arraybuffer-loaded-data.d.ts │ ├── chai-deep-match.d.ts │ └── static-server.d.ts ├── fixtures │ ├── badssl.p12 │ ├── ca-cert-ecdsa.pem │ ├── ca-cert-rsa.pem │ ├── corrupt.pfx │ └── test.pfx ├── integration │ ├── smoke-test.spec.ts │ ├── ts-node.env │ └── tsconfig.json ├── test-setup.ts └── unit │ ├── components │ └── view │ │ └── headers │ │ ├── set-cookie-header-description.spec.tsx │ │ └── user-agent-header-description.spec.tsx │ ├── karma.conf.js │ ├── model │ ├── api │ │ └── openapi.spec.ts │ ├── crypto.spec.ts │ ├── filters │ │ ├── filters-matching.spec.ts │ │ ├── search-filter-integration.spec.ts │ │ └── syntax-parts.spec.ts │ ├── http │ │ ├── caching.spec.ts │ │ ├── content-types.spec.ts │ │ ├── cookies.spec.ts │ │ ├── editable-body.spec.ts │ │ ├── editable-request-parts.spec.ts │ │ └── sources.spec.ts │ ├── network.spec.ts │ ├── rules-store.spec.ts │ └── ui │ │ └── markdown.spec.ts │ ├── styles.spec.ts │ ├── tsconfig.json │ ├── unit-test-helpers.ts │ ├── util │ ├── buffer.spec.ts │ ├── json-schema.spec.ts │ ├── observable.spec.ts │ └── protobuf.spec.ts │ └── workers │ └── worker-decoding.spec.ts └── tsconfig.json /.actrc: -------------------------------------------------------------------------------- 1 | -P ubuntu-latest=httptoolkit/act-build-base -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store 2 | node_modules 3 | dist/ 4 | .env 5 | meta/ 6 | *.tsbuildinfo 7 | .httptoolkit-server/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome", 8 | "url": "http://local.httptoolkit.tech:8080", 9 | "webRoot": "${workspaceFolder}", 10 | "port": 9246 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | {$DOMAIN:http://localhost} { 2 | log 3 | 4 | encode zstd gzip 5 | 6 | header *.wasm Content-Type "application/wasm" 7 | 8 | rewrite /update-worker.js /ui-update-worker.js 9 | 10 | root * /site 11 | try_files {path} /index.html 12 | file_server 13 | 14 | # Cache responses for 1 minute (local)/1h (CDN - invalidated on deploy), validate async during the next 10 15 | # minutes, or continue using old data as-is for up to 24 hours if this server stops responding/returns errors. 16 | @get method GET 17 | header @get Cache-Control "public, max-age=60, s-maxage=3600, stale-while-revalidate=600, stale-if-error=86400" 18 | 19 | header Referrer-Policy "strict-origin" 20 | header X-Clacks-Overhead "GNU Terry Pratchett" # https://xclacksoverhead.org 21 | 22 | import /site/csp.caddyfile # Generated by webpack 23 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:2.9.1-alpine 2 | 3 | RUN mkdir /site 4 | 5 | WORKDIR /site 6 | 7 | COPY ./dist /site 8 | 9 | COPY ./Caddyfile /etc/caddy/Caddyfile 10 | RUN caddy validate --config /etc/caddy/Caddyfile -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HTTP Toolkit UI [![Build Status](https://github.com/httptoolkit/httptoolkit-ui/workflows/CI/badge.svg)](https://github.com/httptoolkit/httptoolkit-ui/actions) 2 | =================== 3 | 4 | This repo contains the UI for [HTTP Toolkit](https://httptoolkit.com), a beautiful, cross-platform & open-source HTTP(S) debugging proxy, analyzer & client. 5 | 6 | Looking to file bugs, request features or send feedback? File an issue or vote on existing ones at [github.com/httptoolkit/httptoolkit](https://github.com/httptoolkit/httptoolkit). 7 | 8 | ## What is this? 9 | 10 | HTTP Toolkit is built as a single-page web application (this repo), running on top of [a server](https://github.com/httptoolkit/httptoolkit-server) that provides access to non-web functionality (e.g. running a proxy server), typically run through [an electron desktop wrapper app](https://github.com/httptoolkit/httptoolkit-desktop). The core UI and the majority of HTTP Toolkit functionality all lives here, except for desktop-app specific behaviour & build configuration, or functionality that can't be implemented in a web app. 11 | 12 | The UI is built as a TypeScript React app, using MobX for state and Styled Components for styling, with [Mockttp](https://github.com/httptoolkit/mockttp) used to manage the HTTP interception itself. 13 | 14 | When running, the UI is typically used via [app.httptoolkit.tech](https://app.httptoolkit.tech), even in the desktop app (it's not embedded there, but hosted standalone). It can either be used through the desktop app (which starts its own server), or users can start their own server and open the UI in a browser directly. 15 | 16 | ## Contributing 17 | 18 | If you want to change the behaviour of the HTTP Toolkit in almost any way, except the low-level HTTP handling (see [Mockttp](https://github.com/httptoolkit/mockttp)), how interceptors start applications (see [httptoolkit-server](https://github.com/httptoolkit/httptoolkit-server)) or how it's packaged and distributed (see [httptoolkit-desktop](https://github.com/httptoolkit/httptoolkit-desktop)), then you're in the right place :+1:. 19 | 20 | To get started: 21 | 22 | * Clone this repo. 23 | * `npm install` 24 | * On an arm64 Mac, you may see a Puppeteer error saying "The chromium binary is not available for arm64". See https://github.com/puppeteer/puppeteer/issues/6622 for more details. As a quick fix you can run `export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true` and then re-run npm install (you may need to delete node_modules first). In this case, the integration tests won't work but that's rarely important for local development. 25 | * For pure UI development 26 | * Run `npm start` to start the UI along with a backing [httptoolkit server](https://github.com/httptoolkit/httptoolkit-server). 27 | * On an arm64 Mac, this may not work. You can run the server in development mode locally though (jump to the next section). 28 | * Open [`localhost:8080`](http://localhost:8080) to view the UI. 29 | * To develop the UI & server together 30 | * Start [a server](https://github.com/httptoolkit/httptoolkit-server) locally 31 | * Run `npm run start:web` to start the UI without running a separate HTTP Toolkit server 32 | * Open [`localhost:8080`](http://localhost:8080) to view the UI 33 | * `npm test` - run the tests 34 | -------------------------------------------------------------------------------- /automation/ts-node.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | TS_NODE_PROJECT: './automation/tsconfig.json', 3 | TS_NODE_FILES: true 4 | }; 5 | 6 | // WebPack 4 and some other details require old OpenSSL providers when 7 | // running in Node v17+: 8 | if (process.version.match(/^v(\d+)/)[1] > 16) { 9 | module.exports['NODE_OPTIONS'] = '--openssl-legacy-provider'; 10 | } -------------------------------------------------------------------------------- /automation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "types": [ 7 | "node", 8 | "webpack-dev-server" 9 | ], 10 | "lib": [ 11 | "ES2017", 12 | "ScriptHost" 13 | ] 14 | }, 15 | "files": [], 16 | "include": [ 17 | "../custom-typings/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /automation/webpack.dev.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as Webpack from 'webpack'; 3 | 4 | import type __ from 'webpack-dev-server'; 5 | 6 | import merge from 'webpack-merge'; 7 | import common from './webpack.common'; 8 | 9 | export default merge(common, { 10 | mode: 'development', 11 | 12 | devtool: 'eval-cheap-module-source-map' as any, 13 | 14 | devServer: { 15 | host: 'localhost', 16 | historyApiFallback: true, 17 | client: { 18 | overlay: { 19 | runtimeErrors: (error) => { 20 | const IGNORED_RUNTIME_ERRORS = [ 21 | 'ResizeObserver loop completed with undelivered notifications.', 22 | 'ResizeObserver loop limit exceeded' 23 | ]; 24 | 25 | return !IGNORED_RUNTIME_ERRORS.includes(error.message); 26 | } 27 | } 28 | } 29 | }, 30 | 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.js$/, 35 | enforce: 'pre', 36 | loader: 'source-map-loader', 37 | exclude: [ 38 | path.join(__dirname, '..', 'node_modules', 'monaco-editor'), 39 | path.join(__dirname, '..', 'node_modules', 'subscriptions-transport-ws'), 40 | path.join(__dirname, '..', '..', 'mockttp', 'node_modules', 'subscriptions-transport-ws'), 41 | path.join(__dirname, '..', 'node_modules', 'js-beautify'), 42 | path.join(__dirname, '..', 'node_modules', 'graphql-subscriptions'), 43 | ] 44 | } 45 | ] 46 | }, 47 | 48 | plugins: [ 49 | new Webpack.DefinePlugin({ 50 | 'process.env.DISABLE_UPDATES': 'true' 51 | }) 52 | ] 53 | }); 54 | -------------------------------------------------------------------------------- /automation/webpack.unittest.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as Webpack from 'webpack'; 3 | 4 | import * as tmp from 'tmp'; 5 | tmp.setGracefulCleanup(); 6 | 7 | import commonConfig from './webpack.common'; 8 | 9 | commonConfig.mode = 'development'; 10 | commonConfig.entry = undefined; 11 | commonConfig.plugins = [ 12 | new Webpack.ProvidePlugin({ 13 | 'process': 'process/browser.js', 14 | 'Buffer': ['buffer', 'Buffer'] 15 | }), 16 | ]; 17 | commonConfig.output = { 18 | path: tmp.dirSync().name, 19 | }; 20 | commonConfig.stats = 'errors-only'; 21 | 22 | export default commonConfig; -------------------------------------------------------------------------------- /custom-typings/auth0-lock.d.ts: -------------------------------------------------------------------------------- 1 | // Auth0-lock types don't include the passwordless constructor, so extend them: 2 | declare module 'auth0-lock' { 3 | interface Auth0LockPasswordlessConstructorOptions extends Auth0LockConstructorOptions { 4 | passwordlessMethod: 'code' 5 | } 6 | 7 | export type Auth0LockPasswordlessStatic = Auth0LockStatic & { 8 | new ( 9 | clientId: string, 10 | domain: string, 11 | options?: Auth0LockPasswordlessConstructorOptions 12 | ): Auth0LockStatic; 13 | } 14 | 15 | export const Auth0LockPasswordless: Auth0LockPasswordlessStatic; 16 | } -------------------------------------------------------------------------------- /custom-typings/deserialize-error.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'deserialize-error' { 2 | function deserializeError(obj: any): Error; 3 | export = deserializeError; 4 | } -------------------------------------------------------------------------------- /custom-typings/har-validator.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'har-validator' { 2 | import * as HarFormat from 'har-format'; 3 | import * as Ajv from 'ajv'; 4 | 5 | export type HarParseError = Partial; 6 | 7 | export function har(data: unknown): Promise; 8 | } -------------------------------------------------------------------------------- /custom-typings/httpsnippet.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@httptoolkit/httpsnippet' { 2 | import * as HARFormat from 'har-format'; 3 | 4 | namespace HTTPSnippet { 5 | export type Target = 6 | | "c" 7 | | "clojure" 8 | | "csharp" 9 | | "go" 10 | | "java" 11 | | "javascript" 12 | | "node" 13 | | "objc" 14 | | "ocaml" 15 | | "php" 16 | | "powershell" 17 | | "python" 18 | | "ruby" 19 | | "shell" 20 | | "swift"; 21 | 22 | export type Client = string; // Could be worth doing later, not for now 23 | 24 | export type TargetObject = { 25 | key: Target, 26 | title: string, 27 | extname: string, 28 | default: Client, 29 | clients: Array<{ 30 | key: Client, 31 | title: string, 32 | link: string, 33 | description: string 34 | }> 35 | }; 36 | 37 | export function availableTargets(): TargetObject[]; 38 | } 39 | 40 | class HTTPSnippet { 41 | constructor(source: HARFormat.Request); 42 | convert(target: HTTPSnippet.Target, client?: HTTPSnippet.Client, options?: {}): string; 43 | } 44 | 45 | export = HTTPSnippet; 46 | } -------------------------------------------------------------------------------- /custom-typings/js-beautify.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'js-beautify/*'; -------------------------------------------------------------------------------- /custom-typings/lib-dom-extensions.d.ts: -------------------------------------------------------------------------------- 1 | declare interface URLSearchParams { 2 | /** Make params iterable: */ 3 | [Symbol.iterator](): IterableIterator<[string, string]>; 4 | entries(): IterableIterator<[string, string]>; 5 | } -------------------------------------------------------------------------------- /custom-typings/navigator.clipboard.d.ts: -------------------------------------------------------------------------------- 1 | interface Clipboard { 2 | writeText(newClipText: string): Promise; 3 | } 4 | 5 | interface NavigatorClipboard { 6 | // Only available in a secure context. 7 | readonly clipboard?: Clipboard; 8 | } 9 | 10 | interface Navigator extends NavigatorClipboard {} -------------------------------------------------------------------------------- /custom-typings/node-forge-files.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-forge/lib/forge' { 2 | import * as forge from 'node-forge'; 3 | export = forge; 4 | } -------------------------------------------------------------------------------- /custom-typings/react-virtualized-auto-sizer.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-virtualized-auto-sizer' { 2 | import { PureComponent, Validator, Requireable } from "react"; 3 | import * as PropTypes from "prop-types"; 4 | 5 | type Size = { 6 | height: number; 7 | width: number; 8 | }; 9 | 10 | type Dimensions = Size; 11 | 12 | type AutoSizerProps = { 13 | /** 14 | * Function responsible for rendering children. 15 | * This function should implement the following signature: 16 | * ({ height, width }) => PropTypes.element 17 | */ 18 | children: (props: Size) => React.ReactNode; 19 | /** 20 | * Optional custom CSS class name to attach to root AutoSizer element. 21 | * This is an advanced property and is not typically necessary. 22 | */ 23 | className?: string; 24 | /** 25 | * Height passed to child for initial render; useful for server-side rendering. 26 | * This value will be overridden with an accurate height after mounting. 27 | */ 28 | defaultHeight?: number; 29 | /** 30 | * Width passed to child for initial render; useful for server-side rendering. 31 | * This value will be overridden with an accurate width after mounting. 32 | */ 33 | defaultWidth?: number; 34 | /** Disable dynamic :height property */ 35 | disableHeight?: boolean; 36 | /** Disable dynamic :width property */ 37 | disableWidth?: boolean; 38 | /** Nonce of the inlined stylesheet for Content Security Policy */ 39 | nonce?: string; 40 | /** Callback to be invoked on-resize: ({ height, width }) */ 41 | onResize?: (info: Size) => any; 42 | /** 43 | * Optional custom inline style to attach to root AutoSizer element. 44 | * This is an advanced property and is not typically necessary. 45 | */ 46 | style?: React.CSSProperties; 47 | /** 48 | * PLEASE NOTE 49 | * The [key: string]: any; line is here on purpose 50 | * This is due to the need of force re-render of PureComponent 51 | * Check the following link if you want to know more 52 | * https://github.com/bvaughn/react-virtualized#pass-thru-props 53 | */ 54 | [key: string]: any; 55 | }; 56 | /** 57 | * Decorator component that automatically adjusts the width and height of a single child. 58 | * Child component should not be declared as a child but should rather be specified by a `ChildComponent` property. 59 | * All other properties will be passed through to the child component. 60 | */ 61 | class AutoSizer extends PureComponent { 62 | static defaultProps: { 63 | onResize: () => void; 64 | disableHeight: false; 65 | disableWidth: false; 66 | style: {}; 67 | }; 68 | 69 | constructor(props: AutoSizerProps); 70 | 71 | componentDidMount(): void; 72 | 73 | componentWillUnmount(): void; 74 | 75 | render(): JSX.Element; 76 | } 77 | 78 | export = AutoSizer; 79 | } -------------------------------------------------------------------------------- /custom-typings/resize-observer.d.ts: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/que-etc/resize-observer-polyfill/blob/854554b/src/index.d.ts 2 | 3 | interface DOMRectReadOnly { 4 | readonly x: number; 5 | readonly y: number; 6 | readonly width: number; 7 | readonly height: number; 8 | readonly top: number; 9 | readonly right: number; 10 | readonly bottom: number; 11 | readonly left: number; 12 | } 13 | 14 | interface ResizeObserverCallback { 15 | (entries: ResizeObserverEntry[], observer: ResizeObserver): void 16 | } 17 | 18 | interface ResizeObserverEntry { 19 | readonly target: Element; 20 | readonly contentRect: DOMRectReadOnly; 21 | } 22 | 23 | interface ResizeObserver { 24 | observe(target: Element): void; 25 | unobserve(target: Element): void; 26 | disconnect(): void; 27 | } 28 | 29 | declare const ResizeObserver: { 30 | prototype: ResizeObserver; 31 | new(callback: ResizeObserverCallback): ResizeObserver; 32 | } 33 | 34 | interface ResizeObserver { 35 | observe(target: Element): void; 36 | unobserve(target: Element): void; 37 | disconnect(): void; 38 | } -------------------------------------------------------------------------------- /custom-typings/static-content.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.svg'; 3 | declare module '*.html'; 4 | declare module '*.json'; -------------------------------------------------------------------------------- /custom-typings/swagger2openapi.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'swagger2openapi' { 2 | import { OpenAPIObject } from "openapi-directory"; 3 | 4 | interface ConvertOptions { 5 | resolve?: boolean; 6 | patch?: boolean; 7 | } 8 | 9 | export function convertObj( 10 | swagger: object, 11 | options: ConvertOptions, 12 | callback: (err: Error | null, result: { 13 | warnings?: string[] 14 | openapi: OpenAPIObject 15 | }) => void 16 | ): void; 17 | } -------------------------------------------------------------------------------- /custom-typings/worker-loader.d.ts: -------------------------------------------------------------------------------- 1 | declare module "worker-loader!*" { 2 | class WebpackWorker extends Worker { 3 | constructor(); 4 | } 5 | 6 | export = WebpackWorker; 7 | } -------------------------------------------------------------------------------- /custom-typings/xml-beautifier.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'xml-beautifier'; -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | # Rewrite all requests that 404 to /, to support SPA history 3 | from = "/*" 4 | to = "/" 5 | status = 200 6 | 7 | [[headers]] 8 | for = "/*" 9 | [headers.values] 10 | Content-Security-Policy = "frame-ancestors 'none'" 11 | 12 | [[headers]] 13 | for = "/*.wasm" 14 | [headers.values] 15 | Content-Type = "application/wasm" -------------------------------------------------------------------------------- /src/amiusing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | Are you using HTTP Toolkit? Yes! 11 | 12 | 13 | 70 | 71 | 72 |
73 |

You're being intercepted by HTTP Toolkit

74 |

75 | This response came from HTTP Toolkit, which is currently intercepting this connection. 76 |

77 |

78 | All requests made by this browser will be recorded by HTTP Toolkit. 79 | Take a look at the 'View' tab there now to see the request & response 80 | that brought you this page, or start browsing elsewhere to collect more data. 81 |

82 |
83 | 84 | 85 | -------------------------------------------------------------------------------- /src/components/account/checkout-spinner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | 5 | import { Icon } from '../../icons'; 6 | import { ModalButton } from './modal-overlay'; 7 | 8 | export const CheckoutSpinner = styled((p: { className?: string, onCancel: () => void }) => ( 9 |
10 |

11 | The checkout has been opened in your browser. 12 |
13 | Please follow the steps there to complete your subscription. 14 |

15 |

16 | Have questions? Take a look at the FAQ or email billing@httptoolkit.com. 19 |

20 | 25 | 26 | Cancel checkout 27 | 28 |
29 | ))` 30 | > p { 31 | max-width: 500px; 32 | line-height: 1.2; 33 | } 34 | 35 | > p, > svg { 36 | color: #fff; 37 | margin: 20px auto; 38 | } 39 | 40 | a[href] { 41 | color: #6e8ff4; 42 | } 43 | 44 | text-align: center; 45 | 46 | position: absolute; 47 | top: 50%; 48 | left: 50%; 49 | transform: translate(-50%, -50%) scale(2); 50 | z-index: 100; 51 | `; -------------------------------------------------------------------------------- /src/components/account/modal-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../../styles"; 2 | import { SecondaryButton } from '../common/inputs'; 3 | 4 | export const ModalOverlay = styled.div<{ opacity?: number }>` 5 | position: absolute; 6 | top: 0; 7 | right: 0; 8 | bottom: 0; 9 | left: 0; 10 | 11 | background: ${p => p.theme.modalGradient}; 12 | 13 | z-index: 10; 14 | opacity: ${p => p.opacity || 0.9}; 15 | `; 16 | 17 | export const ModalButton = styled(SecondaryButton)` 18 | padding: 5px 20px; 19 | margin: 20px auto; 20 | 21 | &:not([disabled]) { 22 | color: ${p => p.theme.mainBackground}; 23 | border-color: ${p => p.theme.mainBackground}; 24 | } 25 | 26 | border-color: rgba(255, 255, 255, 0.6); 27 | font-size: ${p => p.theme.textSize}; 28 | `; -------------------------------------------------------------------------------- /src/components/common/card-error-banner.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { styled } from '../../styles'; 3 | 4 | import { Content } from './text-content'; 5 | 6 | export const CardErrorBanner = styled(Content)<{ 7 | direction?: 'left' | 'right' 8 | }>` 9 | ${p => p.direction === 'left' 10 | ? 'margin: 0 -20px 0 -15px;' 11 | : p.direction === 'right' 12 | ? 'margin: 0 -15px 0 -20px;' 13 | : 'margin: 0 -20px 0 -20px;' 14 | } 15 | 16 | padding: 10px 30px 0; 17 | 18 | font-size: ${p => p.theme.textSize}; 19 | color: ${p => p.theme.mainColor}; 20 | background-color: ${p => p.theme.warningBackground}; 21 | border-top: solid 1px ${p => p.theme.containerBorder}; 22 | 23 | svg { 24 | margin-left: 0; 25 | } 26 | `; -------------------------------------------------------------------------------- /src/components/common/close-button.tsx: -------------------------------------------------------------------------------- 1 | import { styled, Theme } from "../../styles"; 2 | import { Icon } from "../../icons"; 3 | import { clickOnEnter } from '../component-utils'; 4 | 5 | interface CloseButtonProps { 6 | onClose: () => void; 7 | top?: string; 8 | right?: string; 9 | 10 | theme?: Theme; 11 | } 12 | 13 | export const CloseButton = styled(Icon).attrs((props: CloseButtonProps) => ({ 14 | icon: ['fas', 'times'], 15 | size: '2x', 16 | 17 | tabIndex: 0, 18 | onClick: props.onClose, 19 | onKeyPress: clickOnEnter 20 | }))` 21 | position: absolute; 22 | z-index: 1; 23 | cursor: pointer; 24 | 25 | color: ${p => p.theme!.mainColor}; 26 | 27 | &:focus-visible { 28 | outline: none; 29 | color: ${p => p.theme!.popColor}; 30 | } 31 | 32 | top: ${(p: CloseButtonProps) => p.top || '10px'}; 33 | right: ${(p: CloseButtonProps) => p.right || '15px'}; 34 | 35 | &:hover { 36 | opacity: 0.6; 37 | } 38 | `; -------------------------------------------------------------------------------- /src/components/common/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Icon } from "../../icons"; 4 | import { styled } from '../../styles'; 5 | 6 | import { clickOnEnter } from '../component-utils'; 7 | import { PillButton } from './pill'; 8 | import { IconButton } from './icon-button'; 9 | import { copyToClipboard } from '../../util/ui'; 10 | 11 | const CopyIconButton = styled(IconButton)` 12 | color: ${p => p.theme.mainColor}; 13 | 14 | &:hover, &:focus { 15 | color: ${p => p.theme.popColor}; 16 | outline: none; 17 | } 18 | 19 | &:active { 20 | color: ${p => p.theme.mainColor}; 21 | } 22 | `; 23 | 24 | const useTemporaryFlag = () => { 25 | const [flagResetTimer, setFlagResetTimer] = React.useState(); 26 | const [flagged, setFlagged] = React.useState(); 27 | 28 | const triggerFlag = () => { 29 | setFlagged(true); 30 | 31 | if (flagResetTimer) { 32 | clearTimeout(flagResetTimer); 33 | setFlagResetTimer(undefined); 34 | } 35 | 36 | setFlagResetTimer(setTimeout(() => 37 | setFlagged(undefined), 38 | 2000 39 | ) as unknown as number); 40 | } 41 | 42 | return [flagged, triggerFlag] as const; 43 | } 44 | 45 | export const CopyButtonIcon = (p: { 46 | className?: string, 47 | content: string, 48 | onClick: () => void 49 | }) => { 50 | const [success, showSuccess] = useTemporaryFlag(); 51 | 52 | return { 58 | copyToClipboard(p.content); 59 | showSuccess(); 60 | p.onClick(); 61 | }} 62 | />; 63 | } 64 | 65 | export const CopyButtonPill = (p: { content: string, children?: React.ReactNode }) => { 66 | const [success, showSuccess] = useTemporaryFlag(); 67 | 68 | return { 71 | copyToClipboard(p.content); 72 | showSuccess(); 73 | }} 74 | > 75 | 79 | { p.children } 80 | ; 81 | } -------------------------------------------------------------------------------- /src/components/common/docs-link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from "../../styles"; 4 | import { Icon } from "../../icons"; 5 | 6 | const ExternalLinkIcon = styled(Icon).attrs(() => ({ 7 | icon: ['fas', 'external-link-alt'] 8 | }))` 9 | opacity: 0.5; 10 | margin-left: 5px; 11 | 12 | &:focus { 13 | outline: none; 14 | color: ${p => p.theme.popColor}; 15 | } 16 | `; 17 | 18 | const DocsA = styled.a` 19 | &[href] { 20 | color: ${p => p.theme.linkColor}; 21 | 22 | &:visited { 23 | color: ${p => p.theme.visitedLinkColor}; 24 | } 25 | } 26 | `; 27 | 28 | export const DocsLink = (p: { 29 | href?: string, 30 | children?: React.ReactNode 31 | }) => p.href ? 32 | 33 | { /* Whitespace after children, iff we have children */ } 34 | { p.children ? <>{ p.children } : null } 35 | 36 | 37 | : null; -------------------------------------------------------------------------------- /src/components/common/duration-pill.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | 4 | import { TimingEvents } from '../../types'; 5 | import { observableClock } from '../../util/observable'; 6 | 7 | import { Pill } from './pill'; 8 | 9 | function sigFig(num: number, figs: number): string { 10 | return num.toFixed(figs); 11 | } 12 | 13 | type DurationPillProps = { className?: string } & ( 14 | | { durationMs: number } 15 | | { timingEvents: Partial } 16 | ); 17 | 18 | const calculateDuration = (timingEvents: Partial) => { 19 | const doneTimestamp = timingEvents.responseSentTimestamp ?? timingEvents.abortedTimestamp; 20 | 21 | if (timingEvents.startTimestamp !== undefined && doneTimestamp !== undefined) { 22 | return doneTimestamp - timingEvents.startTimestamp; 23 | } 24 | 25 | if (timingEvents.startTime !== undefined) { 26 | // This may not be perfect - note that startTime comes from the server so we might be 27 | // mildly out of sync (ehhhh, in theory) but this is only for pending requests where 28 | // that's unlikely to be an issue - the final time will be correct regardless. 29 | return observableClock.getTime() - timingEvents.startTime; 30 | } 31 | } 32 | 33 | export const DurationPill = observer((p: DurationPillProps) => { 34 | let duration: number | undefined; 35 | 36 | if ('durationMs' in p) { 37 | duration = p.durationMs; 38 | } else if (p.timingEvents) { 39 | duration = calculateDuration(p.timingEvents); 40 | } 41 | 42 | if (duration === undefined) return null; 43 | 44 | return { 45 | duration < 100 ? sigFig(duration, 1) + 'ms' : // 22.3ms 46 | duration < 1000 ? sigFig(duration, 0) + 'ms' : // 999ms 47 | duration < 5000 ? sigFig(duration / 1000, 2) + ' seconds' : // 3.04 seconds 48 | duration < 9900 ? sigFig(duration / 1000, 1) + ' seconds' : // 8.2 seconds 49 | sigFig(duration / 1000, 0) + ' seconds' // 30 seconds 50 | }; 51 | }); -------------------------------------------------------------------------------- /src/components/common/editable-params.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ReadOnlyPairs } from './editable-pairs'; 4 | 5 | export const ReadOnlyParams = (props: { content: string, className?: string }) => { 6 | const params = new URLSearchParams(props.content); 7 | const paramPairs = [...params].map(([key, value]) => ({ key, value })); 8 | return ; 12 | } -------------------------------------------------------------------------------- /src/components/common/editable-status.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | import { HttpVersion } from '../../types'; 5 | import { styled } from '../../styles'; 6 | 7 | import { TextInput } from './inputs'; 8 | import { getStatusMessage } from '../../model/http/http-docs'; 9 | 10 | const StatusContainer = styled.div` 11 | margin-top: 5px; 12 | 13 | display: flex; 14 | flex-direction: row; 15 | align-items: stretch; 16 | 17 | > :not(:last-child) { 18 | margin-right: 5px; 19 | } 20 | 21 | > :last-child { 22 | flex-grow: 1; 23 | } 24 | `; 25 | 26 | function isDefaultMessage(statusMessage: string, statusCode: number | undefined) { 27 | return statusMessage.toLowerCase() === getStatusMessage(statusCode).toLowerCase() 28 | } 29 | 30 | export const EditableStatus = (props: { 31 | className?: string, 32 | httpVersion: HttpVersion, 33 | statusCode: number | undefined, 34 | statusMessage: string | undefined, 35 | onChange: (statusCode: number | undefined, statusMessage: string | undefined) => void 36 | }) => { 37 | const { statusCode } = props; 38 | 39 | // Undefined status message = use default. Note that the status 40 | // message can still be shown as _empty_, just not undefined. 41 | const statusMessage = props.statusMessage === undefined || props.httpVersion >= 2 42 | ? getStatusMessage(statusCode) 43 | : props.statusMessage; 44 | 45 | return 46 | { 53 | let newStatusCode = (event.target.value !== '') 54 | ? parseInt(event.target.value, 10) 55 | : undefined; 56 | 57 | if (_.isNaN(newStatusCode)) return; 58 | 59 | // If the status message was the default message, update it to 60 | // match the new status code 61 | const newStatusMessage = isDefaultMessage(statusMessage, statusCode) 62 | ? undefined 63 | : props.statusMessage; 64 | 65 | props.onChange(newStatusCode, newStatusMessage); 66 | }} 67 | /> 68 | 69 | = 2} 71 | value={statusMessage} 72 | onChange={(event) => { 73 | let newMessage: string | undefined = event.target.value; 74 | 75 | if (isDefaultMessage(newMessage, statusCode)) { 76 | newMessage = undefined; 77 | } 78 | 79 | props.onChange(statusCode, newMessage); 80 | }} 81 | /> 82 | ; 83 | } -------------------------------------------------------------------------------- /src/components/common/empty-state.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as _ from 'lodash'; 3 | 4 | import { styled } from '../../styles' 5 | import { IconKey, PhosphorIcon } from '../../icons'; 6 | 7 | export const EmptyState = styled((props: React.HTMLAttributes & { 8 | className?: string, 9 | icon: IconKey, 10 | children?: React.ReactNode 11 | }) => ( 12 |
13 | 14 | { props.children && <> 15 |
16 | { props.children } 17 | } 18 |
19 | ))` 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | justify-content: center; 24 | 25 | color: ${p => p.theme.containerWatermark}; 26 | font-size: ${p => p.theme.loudHeadingSize}; 27 | letter-spacing: -1px; 28 | 29 | text-align: center; 30 | 31 | box-sizing: border-box; 32 | padding: 20px; 33 | height: 100%; 34 | width: 100%; 35 | 36 | margin: auto 0; 37 | 38 | > svg { 39 | font-size: 150px; 40 | } 41 | `; -------------------------------------------------------------------------------- /src/components/common/expand-shrink-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { IconButton } from './icon-button'; 4 | import { ExpandState } from './card'; 5 | 6 | export const ExpandShrinkButton = (p: { 7 | expanded: ExpandState | undefined, 8 | onClick: () => void 9 | }) => 10 | -------------------------------------------------------------------------------- /src/components/common/format-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { 4 | Formatters, 5 | isEditorFormatter, 6 | EditorFormatter 7 | } from '../../model/events/body-formatting'; 8 | import { getContentEditorName } from '../../model/events/content-types'; 9 | 10 | import { IconButton } from './icon-button'; 11 | 12 | export const FormatButton = (props: { 13 | className?: string, 14 | format: keyof typeof Formatters, 15 | content: Buffer, 16 | onFormatted: (content: string) => void 17 | }) => { 18 | const { format, content, onFormatted } = props; 19 | 20 | const formatter = Formatters[format]; 21 | const canFormat = !!formatter && 22 | isEditorFormatter(formatter) && 23 | formatter.isEditApplicable; 24 | 25 | return { 34 | // This is often async, and in that case it'll have a race condition: if you 35 | // format content and then keep editing, when its formatted you lose your edits. 36 | // That gap should be very short though, and arguably this is expected, 37 | // so we ignore it. 38 | onFormatted( 39 | await (formatter as EditorFormatter).render(content) 40 | ); 41 | }} 42 | />; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/common/http-version-pill.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { HtkRequest } from '../../types'; 4 | import { styled } from '../../styles'; 5 | 6 | import { Pill } from './pill'; 7 | 8 | export const HttpVersionPill = styled(({ request, className }: { 9 | request: HtkRequest 10 | className?: string 11 | }) => request.httpVersion 12 | ? HTTP/{request.httpVersion.replace('.0', '')} 15 | : null 16 | )``; -------------------------------------------------------------------------------- /src/components/common/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles' 4 | import { Icon, IconProp, IconKey, PhosphorIcon } from '../../icons'; 5 | 6 | import { UnstyledButton, UnstyledButtonLink } from './inputs'; 7 | 8 | export const IconButton = styled((p: { 9 | className?: string, 10 | title: string, 11 | icon: IconProp | IconKey, 12 | iconSize?: string, 13 | disabled?: boolean, 14 | fixedWidth?: boolean, 15 | tabIndex?: number, 16 | onClick: (e: React.MouseEvent) => void, 17 | onKeyDown?: (e: React.KeyboardEvent) => void 18 | }) => 19 | 27 | { Array.isArray(p.icon) 28 | ? 33 | : 37 | } 38 | 39 | )` 40 | color: ${p => p.theme.mainColor}; 41 | font-size: ${p => p.theme.textSize}; 42 | padding: 5px 10px; 43 | 44 | &:disabled { 45 | opacity: 0.5; 46 | } 47 | 48 | &:not([disabled]) { 49 | &:hover, &:focus { 50 | outline: none; 51 | color: ${p => p.theme.popColor}; 52 | } 53 | } 54 | 55 | .phosphor-icon { 56 | margin: 0 -3px; /* Fix alignment with FontAwesome in rows e.g. View right footer */ 57 | } 58 | `; 59 | 60 | export const IconButtonLink = styled((p: { 61 | className?: string, 62 | title: string, 63 | icon: IconProp, 64 | fixedWidth?: boolean, 65 | href: string, 66 | target?: string, 67 | rel?: string 68 | }) => 69 | 76 | 80 | 81 | )` 82 | color: ${p => p.theme.mainColor}; 83 | font-size: ${p => p.theme.textSize}; 84 | padding: 5px 10px; 85 | 86 | &:hover, &:focus { 87 | outline: none; 88 | color: ${p => p.theme.popColor}; 89 | } 90 | `; -------------------------------------------------------------------------------- /src/components/common/loading-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled, css } from '../../styles'; 4 | import { Icon } from '../../icons'; 5 | 6 | import { 7 | CollapsibleCard, 8 | CollapsibleCardProps 9 | } from './card' 10 | 11 | export const LoadingCardContent = styled((props: { height?: string, className?: string }) => 12 |
13 | 14 |
15 | )` 16 | ${p => p.height && css` 17 | height: ${p.height}; 18 | `} 19 | 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | justify-content: center; 24 | `; 25 | 26 | export const LoadingCard = (props: 27 | CollapsibleCardProps & { 28 | height?: string, 29 | children?: React.ReactNode 30 | } 31 | ) => 32 | { props.children } 33 | 41 | ; -------------------------------------------------------------------------------- /src/components/common/optional-image.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function showOnceLoaded(e: React.SyntheticEvent) { 4 | const elem = e.target as HTMLImageElement; 5 | elem.style.display = 'initial'; 6 | } 7 | 8 | // An img element which isn't shown at all, until it successfully loads 9 | export const OptionalImage = (p: React.ImgHTMLAttributes) => 10 | 11 | -------------------------------------------------------------------------------- /src/components/common/source-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | import { SourceIcons, Icon, PhosphorIcon, IconKey } from '../../icons'; 5 | 6 | import { TrafficSource } from '../../model/http/sources'; 7 | 8 | export const SourceIcon = styled(({ source, className }: { 9 | source: TrafficSource, 10 | className?: string 11 | }) => { 12 | if (source.icon === SourceIcons.Unknown) return null; 13 | 14 | const iconId = source.icon.icon; 15 | 16 | if (Array.isArray(iconId)) { 17 | return 23 | } else { 24 | return 31 | } 32 | })` 33 | margin-left: 8px; 34 | `; -------------------------------------------------------------------------------- /src/components/common/status-code.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles' 4 | import { Icon } from '../../icons'; 5 | import { getStatusColor } from '../../model/events/categorization'; 6 | 7 | export const StatusCode = styled((props: { 8 | status: 9 | | undefined // Still in progress 10 | | 'aborted' // Failed before response 11 | | 'WS:open' // Accepted active websocket 12 | | 'WS:closed' // Accepted but now closed websocket 13 | | number, // Any other status 14 | message?: string, 15 | className?: string 16 | }) => ( 17 |
21 | { 22 | props.status === 'aborted' ? 23 | 24 | : props.status === 'WS:open' 25 | ? <> 26 | WS 30 | 31 | : props.status === 'WS:closed' 32 | ? 'WS' 33 | : ( 34 | props.status || 35 | 39 | ) 40 | } 41 |
42 | ))` 43 | font-weight: bold; 44 | 45 | display: flex; 46 | align-items: center; 47 | 48 | .fa-spinner { 49 | padding: 6px; 50 | } 51 | 52 | .fa-ban { 53 | padding: 5px; 54 | } 55 | 56 | color: ${props => getStatusColor( 57 | (props.status === 'WS:open' || props.status === 'WS:closed') 58 | ? undefined 59 | : props.status, 60 | props.theme 61 | )}; 62 | `; -------------------------------------------------------------------------------- /src/components/common/tabbed-options.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Icon, IconKey } from '../../icons'; 4 | import { styled, css } from '../../styles'; 5 | import { omit } from 'lodash'; 6 | import { UnstyledButton } from './inputs'; 7 | 8 | export const TabbedOptionsContainer = styled.div` 9 | display: flex; 10 | flex-direction: row; 11 | `; 12 | 13 | interface TabClickEvent extends React.MouseEvent { 14 | tabValue?: any; 15 | } 16 | 17 | export const TabsContainer = styled((p: { 18 | onClick: (tabValue: any) => void, 19 | isSelected: (value: any) => boolean, 20 | children: Array> 21 | }) => )` 36 | width: 80px; 37 | border-right: solid 2px ${p => p.theme.containerBackground}; 38 | 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | align-items: flex-start; 43 | `; 44 | 45 | export const Tab = styled((p: { 46 | className?: string, 47 | selected?: boolean, 48 | icon: IconKey, 49 | value: any, 50 | children: React.ReactNode 51 | }) => 52 | { 55 | // Attach our value to the event before it bubbles to the container 56 | event.tabValue = p.value; 57 | }} 58 | > 59 | 60 | { p.children } 61 | 62 | )` 63 | display: flex; 64 | flex-direction: column; 65 | text-align: center; 66 | align-items: center; 67 | 68 | width: 100%; 69 | font-size: ${p => p.theme.textSize}; 70 | box-sizing: border-box; 71 | 72 | padding: 10px 20px 10px 0; 73 | 74 | user-select: none; 75 | &:hover, &:focus { 76 | outline: none; 77 | color: ${p => p.theme.popColor}; 78 | } 79 | 80 | opacity: 0.6; 81 | ${p => p.selected && css` 82 | opacity: 1; 83 | font-weight: bold; 84 | border-right: solid 3px ${p.theme.popColor}; 85 | padding-right: 22px; 86 | position: relative; 87 | right: -2px; 88 | `} 89 | 90 | > svg { 91 | margin-bottom: 10px; 92 | width: 2em; 93 | height: auto; 94 | } 95 | `; -------------------------------------------------------------------------------- /src/components/component-utils.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | export function filterProps>( 5 | Component: C, 6 | ...keys: string[] 7 | ): C { 8 | return ((props: any) => ) as C; 9 | } 10 | 11 | // Trigger the click handler when Enter is pressed on this element 12 | export function clickOnEnter(e: React.KeyboardEvent) { 13 | if (e.target === e.currentTarget && e.key === 'Enter') { 14 | // Can't use .click(), as sometimes our target is an SVGElement, and they don't have it 15 | e.currentTarget.dispatchEvent(new MouseEvent('click', { bubbles: true })); 16 | } 17 | } 18 | 19 | export const noPropagation = ( 20 | callback: (event: E) => void 21 | ) => (event: E) => { 22 | event.stopPropagation(); 23 | callback(event); 24 | } 25 | 26 | export const inputValidation = ( 27 | checkFn: (input: string) => boolean, 28 | errorMessage: string 29 | ) => (input: HTMLInputElement) => { 30 | const inputValue = input.value; 31 | if (!inputValue || checkFn(inputValue)) { 32 | input.setCustomValidity(''); 33 | } else { 34 | input.setCustomValidity(errorMessage); 35 | } 36 | input.reportValidity(); 37 | return input.validity.valid; 38 | } -------------------------------------------------------------------------------- /src/components/editor/editor-context-menu.ts: -------------------------------------------------------------------------------- 1 | import type * as monacoTypes from 'monaco-editor'; 2 | 3 | import { copyToClipboard } from '../../util/ui'; 4 | 5 | import { UiStore } from '../../model/ui/ui-store'; 6 | import { ContextMenuItem } from '../../model/ui/context-menu'; 7 | 8 | export function buildContextMenuCallback( 9 | uiStore: UiStore, 10 | isReadOnly: boolean, 11 | // Anon base-editor type to avoid exporting this 12 | baseEditorRef: React.RefObject<{ 13 | editor: monacoTypes.editor.IStandaloneCodeEditor | undefined 14 | }> 15 | ) { 16 | return (mouseEvent: React.MouseEvent) => { 17 | const editor = baseEditorRef.current?.editor; 18 | if (!editor) return; 19 | const selection = editor.getSelection(); 20 | 21 | const items: ContextMenuItem[] = []; 22 | 23 | if (!isReadOnly) { 24 | items.push({ 25 | type: 'option', 26 | label: "Cut", 27 | enabled: !!selection && !selection.isEmpty(), 28 | callback: async () => { 29 | const selection = editor.getSelection(); 30 | if (!selection) return; 31 | const content = editor.getModel()?.getValueInRange(selection); 32 | if (!content) return; 33 | 34 | await copyToClipboard(content); 35 | 36 | editor.executeEdits("clipboard", [{ 37 | range: selection, 38 | text: "", 39 | forceMoveMarkers: true, 40 | }]); 41 | }, 42 | }); 43 | } 44 | 45 | if (selection && !selection.isEmpty()) { 46 | items.push({ 47 | type: 'option', 48 | label: "Copy", 49 | enabled: !!selection && !selection.isEmpty(), 50 | callback: () => { 51 | const selection = editor.getSelection(); 52 | if (!selection) return; 53 | const content = editor.getModel()?.getValueInRange(selection); 54 | if (!content) return; 55 | copyToClipboard(content); 56 | }, 57 | }); 58 | } 59 | 60 | if (selection && !!navigator.clipboard) { 61 | items.push({ 62 | type: 'option', 63 | label: "Paste", 64 | enabled: !isReadOnly, 65 | callback: async () => { 66 | const selection = editor.getSelection(); 67 | if (!selection) return; 68 | const text = await navigator.clipboard.readText(); 69 | 70 | editor.executeEdits("clipboard", [{ 71 | range: selection, 72 | text: text, 73 | forceMoveMarkers: true, 74 | }]); 75 | } 76 | }); 77 | } 78 | 79 | uiStore.handleContextMenuEvent(mouseEvent, items); 80 | } 81 | } -------------------------------------------------------------------------------- /src/components/editor/image-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | 5 | // The image viewer, as used in content viewer editors when the image format 6 | // is selected from the dropdown: 7 | export const ImageViewer = styled((p: { 8 | className?: string 9 | content: Buffer, 10 | rawContentType: string 11 | }) => )` 15 | display: block; 16 | max-width: 100%; 17 | max-height: 100%; 18 | margin: 0 auto; 19 | object-fit: scale-down; 20 | `; -------------------------------------------------------------------------------- /src/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import { observable, action } from 'mobx'; 4 | 5 | import { styled } from '../styles'; 6 | import { Sentry } from '../errors'; 7 | import { isErrorLike } from '../util/error'; 8 | import { trackEvent } from '../metrics'; 9 | 10 | import { Button, ButtonLink } from './common/inputs'; 11 | 12 | const ErrorOverlay = styled((props: { 13 | className?: string, 14 | children: React.ReactNode 15 | }) => 16 |
17 | { props.children } 18 |
19 | )` 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | justify-content: center; 30 | 31 | overflow-y: auto; 32 | 33 | color: ${p => p.theme.mainColor}; 34 | 35 | h1 { 36 | font-size: ${p => p.theme.screamingHeadingSize}; 37 | font-family: ${p => p.theme.titleTextFamily}; 38 | font-weight: bold; 39 | margin-bottom: 50px; 40 | } 41 | 42 | h2 { 43 | font-size: ${p => p.theme.loudHeadingSize}; 44 | margin-bottom: 50px; 45 | } 46 | 47 | button, a { 48 | display: block; 49 | margin: 40px 40px 0; 50 | 51 | padding: 20px; 52 | 53 | font-size: ${p => p.theme.loudHeadingSize}; 54 | font-weight: bolder; 55 | } 56 | `; 57 | 58 | const ButtonContainer = styled.div` 59 | display: flex; 60 | flex-wrap: wrap; 61 | `; 62 | 63 | const ErrorOutput = styled.code` 64 | font-family: ${p => p.theme.monoFontFamily}; 65 | white-space: preserve; 66 | `; 67 | 68 | @observer 69 | export class ErrorBoundary extends React.Component<{ 70 | children?: React.ReactNode 71 | }> { 72 | 73 | @observable 74 | private error: Error | undefined; 75 | 76 | @action 77 | componentDidCatch(error: Error, errorInfo: any) { 78 | this.error = error; 79 | 80 | Sentry.setExtras(errorInfo); 81 | Sentry.captureException(error); 82 | 83 | trackEvent({ 84 | category: 'Error', 85 | action: 'UI crashed' 86 | }); 87 | } 88 | 89 | render() { 90 | return this.error ? ( 91 | 92 |

93 | Oh no! 94 |

95 |

96 | Sorry, it's all gone wrong. 97 |

98 | { isErrorLike(this.error) && 99 | { this.error.stack ?? this.error.message } 100 | } 101 | 102 | 107 | Tell us what happened 108 | 109 | 112 | 113 |
114 | ) : this.props.children; 115 | } 116 | } -------------------------------------------------------------------------------- /src/components/html-context-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { autorun } from 'mobx'; 3 | import { disposeOnUnmount, observer } from 'mobx-react'; 4 | 5 | import { 6 | Menu, 7 | Submenu, 8 | Separator, 9 | Item, 10 | contextMenu 11 | } from 'react-contexify'; 12 | 13 | import { styled } from '../styles'; 14 | 15 | import { UnreachableCheck } from '../util/error'; 16 | 17 | import { ContextMenuItem, ContextMenuState } from '../model/ui/context-menu'; 18 | 19 | @observer 20 | export class HtmlContextMenu extends React.Component<{ 21 | menuState: ContextMenuState, 22 | onHidden: () => void 23 | }> { 24 | 25 | componentDidMount() { 26 | // Automatically show the menu when this is rendered: 27 | disposeOnUnmount(this, autorun(() => { 28 | const menuState = this.props.menuState; 29 | 30 | // Annoyingly, the menu is not listening immediately after the component 31 | // is mounted, so we have to delay this slightly: 32 | setTimeout(() => { 33 | contextMenu.show({ 34 | id: 'menu', 35 | event: menuState.event 36 | }); 37 | }, 10); 38 | })); 39 | } 40 | 41 | render() { 42 | return 46 | { this.props.menuState.items.map(this.renderItem) } 47 | 48 | } 49 | 50 | renderItem = (item: ContextMenuItem, i: number) => { 51 | if (item.type === 'separator') { 52 | return ; 53 | } else if (item.type === 'option') { 54 | return item.callback(this.props.menuState.data)} 57 | disabled={item.enabled === false} 58 | > 59 | { item.label } 60 | 61 | } else if (item.type === 'submenu') { 62 | return 67 | { item.items.map(this.renderItem) } 68 | 69 | } else throw new UnreachableCheck(item, i => i.type); 70 | } 71 | 72 | onVisibilityChange = (visible: boolean) => { 73 | if (!visible) this.props.onHidden(); 74 | }; 75 | 76 | } 77 | 78 | const ThemedMenu = styled(Menu)` 79 | --contexify-menu-bgColor: ${p => p.theme.mainLowlightBackground}; 80 | --contexify-item-color: ${p => p.theme.mainColor}; 81 | --contexify-separator-color: ${p => p.theme.containerBorder}; 82 | 83 | --contexify-rightSlot-color: ${p => p.theme.containerWatermark}; 84 | --contexify-activeRightSlot-color: ${p => p.theme.mainColor}; 85 | 86 | --contexify-arrow-color: ${p => p.theme.containerWatermark}; 87 | --contexify-activeArrow-color: ${p => p.theme.mainColor}; 88 | 89 | --contexify-activeItem-color: #fff; 90 | --contexify-activeItem-bgColor: #3498db; 91 | `; -------------------------------------------------------------------------------- /src/components/intercept/config/existing-browser-config.tsx: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | 3 | import { Interceptor } from '../../../model/interception/interceptors'; 4 | 5 | export async function onActivateExistingBrowser( 6 | interceptor: Interceptor, 7 | activateInterceptor: ( 8 | options: { closeConfirmed?: true }, 9 | shouldTrackEvent?: false 10 | ) => Promise, 11 | reportStarted: () => void, 12 | reportSuccess: (options?: { showRequests?: boolean }) => void, 13 | ) { 14 | try { 15 | // Try to activate, assuming the browser isn't currently open: 16 | await activateInterceptor({}, false); 17 | 18 | // Only it runs without confirmation does this count as an activation 19 | reportStarted(); 20 | } catch (error: any) { 21 | if (!error.metadata || error.metadata.closeConfirmRequired !== true) { 22 | // This is a real error, not a confirmation requirement. 23 | 24 | reportStarted(); // Track that this started, before it fails 25 | throw error; 26 | } 27 | 28 | // If the browser is open, confirm that we can kill & restart it first: 29 | const confirmed = confirm(dedent` 30 | Your browser is currently open, and needs to be 31 | restarted to enable interception. Restart it now? 32 | `.replace('\n', ' ')); 33 | 34 | // If cancelled, we silently do nothing 35 | if (!confirmed) return; 36 | 37 | reportStarted(); 38 | await activateInterceptor({ closeConfirmed: true }); 39 | } 40 | 41 | reportSuccess(); 42 | } -------------------------------------------------------------------------------- /src/components/intercept/config/manual-ios-config.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | import { inject, observer } from 'mobx-react'; 4 | import { when } from 'mobx'; 5 | 6 | import { Interceptor } from '../../../model/interception/interceptors'; 7 | import { EventsStore } from '../../../model/events/events-store'; 8 | import { SourceIcons } from '../../../icons'; 9 | 10 | @inject('eventsStore') 11 | @observer 12 | class ManualIOSConfig extends React.Component<{ 13 | eventsStore?: EventsStore, 14 | 15 | interceptor: Interceptor, 16 | 17 | activateInterceptor: () => Promise, 18 | reportStarted: () => void, 19 | reportSuccess: (options?: { showRequests?: boolean }) => void, 20 | closeSelf: () => void 21 | }> { 22 | 23 | async componentDidMount() { 24 | const { eventsStore, reportStarted, reportSuccess, closeSelf } = this.props; 25 | closeSelf(); // We immediately unmount, but continue activating: 26 | 27 | // Open the manual setup docs page: 28 | window.open( 29 | "https://httptoolkit.com/docs/guides/ios/", 30 | "_blank", 31 | "noreferrer noopener" 32 | ); 33 | 34 | reportStarted(); 35 | 36 | // When we receive the next iOS-appearing request, we consider this as successful 37 | // and then jump to the View page: 38 | const previousIOSRequestIds = getIOSRequestIds(eventsStore!); 39 | when(() => 40 | _.difference( 41 | getIOSRequestIds(eventsStore!), 42 | previousIOSRequestIds 43 | ).length > 0 44 | ).then(() => { 45 | reportSuccess() 46 | }); 47 | } 48 | 49 | render() { 50 | return null; // This never actually displays - we just mount, open the page, and close 51 | } 52 | 53 | } 54 | 55 | function getIOSRequestIds(eventsStore: EventsStore) { 56 | return eventsStore.exchanges.filter((exchange) => 57 | _.isEqual(exchange.request.source.icon, SourceIcons.iOS) 58 | ).map(e => e.id); 59 | } 60 | 61 | export const ManualIOSCustomUi = { 62 | columnWidth: 1, 63 | rowHeight: 1, 64 | configComponent: ManualIOSConfig 65 | }; -------------------------------------------------------------------------------- /src/components/intercept/connected-sources.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | import { TrafficSource } from '../../model/http/sources'; 5 | 6 | import { BigCard } from "../common/card"; 7 | import { Icon } from '../../icons'; 8 | 9 | const ConnectedSource = styled.div` 10 | &:not(:last-child) { 11 | margin-bottom: 30px; 12 | } 13 | 14 | font-size: ${p => p.theme.headingSize}; 15 | 16 | > svg { 17 | margin-right: 30px; 18 | } 19 | `; 20 | 21 | export const ConnectedSources = styled((props: { activeSources: ReadonlyArray, className?: string }) => 22 | 23 |

Connected Sources

24 | { 25 | props.activeSources.length ? 26 | props.activeSources.map((source) => 27 | 28 | 33 | { source.summary } 34 | 35 | ) 36 | : null 37 | } 38 |
39 | )``; // Styled here so that we can target this with selectors in SC styles elsewhere -------------------------------------------------------------------------------- /src/components/modify/rule-drag-handle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | import { Icon } from '../../icons'; 5 | 6 | const FloatingDragHandle = styled.div` 7 | position: absolute; 8 | left: -31px; 9 | top: calc(50% - 1px); 10 | transform: translateY(-50%); 11 | 12 | cursor: row-resize; 13 | 14 | opacity: 0; 15 | 16 | :focus, :active { 17 | outline: none; 18 | opacity: 0.5; 19 | color: ${p => p.theme.popColor}; 20 | } 21 | 22 | && svg { 23 | margin: 0; 24 | } 25 | `; 26 | 27 | export const DragHandle = styled((props: {}) => 28 | 29 | 30 | 31 | )``; -------------------------------------------------------------------------------- /src/components/modify/rule-icon-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | import { styled } from '../../styles'; 5 | import { IconProp } from '../../icons'; 6 | 7 | import { IconButton } from '../common/icon-button'; 8 | 9 | export const IconMenu = styled.div<{ topOffset: number }>` 10 | position: absolute; 11 | top: ${p => p.topOffset}px; 12 | right: 10px; 13 | 14 | display: none; /* Made flex by container, on hover/expand */ 15 | align-items: center; 16 | `; 17 | 18 | export const IconMenuButton = styled(React.memo((p: { 19 | className?: string, 20 | icon: IconProp, 21 | title: string, 22 | onClick: (event: React.MouseEvent) => void, 23 | disabled?: boolean 24 | }) => ))` 31 | padding: 5px; 32 | margin: 0 5px; 33 | 34 | z-index: 10; 35 | 36 | font-size: 1.2em; 37 | 38 | > svg { 39 | display: block; 40 | } 41 | 42 | :disabled { 43 | opacity: 1; 44 | color: ${p => p.theme.containerWatermark}; 45 | } 46 | 47 | :not(:disabled) { 48 | cursor: pointer; 49 | color: ${p => p.theme.secondaryInputColor}; 50 | 51 | &:hover, &:focus { 52 | outline: none; 53 | color: ${p => p.theme.popColor}; 54 | } 55 | } 56 | `; -------------------------------------------------------------------------------- /src/components/modify/rule-title.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | import { noPropagation } from '../component-utils'; 5 | 6 | import { TextInput } from '../common/inputs'; 7 | import { IconMenuButton } from './rule-icon-menu'; 8 | 9 | export const RuleTitle = styled.h2` 10 | white-space: nowrap; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | 14 | flex-basis: 100%; 15 | width: 100%; 16 | box-sizing: border-box; 17 | 18 | /* Required to avoid overflow trimming hanging chars */ 19 | padding: 5px; 20 | margin: -5px; 21 | 22 | font-style: italic; 23 | `; 24 | 25 | const EditableTitleContainer = styled.div` 26 | flex-basis: 100%; 27 | margin: -4px; 28 | `; 29 | 30 | const TitleInput = styled(TextInput)` 31 | width: 30%; 32 | margin-right: 5px; 33 | margin-bottom: 10px; 34 | `; 35 | 36 | const TitleEditButton = styled(IconMenuButton)` 37 | font-size: 1em; 38 | padding: 0; 39 | vertical-align: middle; 40 | `; 41 | 42 | export const EditableRuleTitle = (p: { 43 | value: string, 44 | onEditTitle: (newValue: string) => void, 45 | onSave: (event: React.UIEvent) => void, 46 | onCancel?: () => void 47 | }) => { 48 | const editTitle = (e: React.ChangeEvent) => { 49 | p.onEditTitle(e.target.value) 50 | }; 51 | 52 | return 53 | e.stopPropagation()} 59 | onKeyPress={(e) => { 60 | if (e.key === 'Enter') p.onSave(e); 61 | }} 62 | /> 63 | {}))} 68 | /> 69 | ; 70 | }; -------------------------------------------------------------------------------- /src/components/send/send-card-section.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { css, styled } from "../../styles"; 4 | 5 | import { CollapsibleCard, CollapsibleCardProps } from "../common/card"; 6 | import { LoadingCardContent } from '../common/loading-card'; 7 | import { EditorCardContent } from '../editor/body-card-components'; 8 | 9 | export const SendCardContainer = styled.section<{ 10 | hasExpandedChild: boolean 11 | }>` 12 | display: flex; 13 | flex-direction: column; 14 | height: 100%; 15 | 16 | ${p => p.hasExpandedChild && css` 17 | > :not(.ignores-expanded) { 18 | /* CollapsibleCard applies its own display property to override this for the expanded card */ 19 | display: none; 20 | } 21 | 22 | > .ignores-expanded { 23 | /* Some components (request line & response status) don't disappear, but they shrink */ 24 | transition: margin-bottom 0.1s; 25 | margin-bottom: -10px; 26 | z-index: 0; 27 | } 28 | `} 29 | `; 30 | 31 | export const SendCardSection = styled(CollapsibleCard)` 32 | border-radius: 0; 33 | margin-bottom: 0; 34 | 35 | flex-basis: auto; 36 | 37 | ${p => 38 | // Collapsed cards should not expand into unused space, or collapse to literally nothing 39 | p.collapsed 40 | ? css` 41 | flex-grow: 0; 42 | flex-shrink: 0; 43 | min-height: 50px; 44 | ` 45 | : css` 46 | flex-grow: 1; 47 | flex-shrink: 1; 48 | min-height: 0; 49 | ` 50 | }; 51 | 52 | box-shadow: 0 -2px 5px 0 rgba(0,0,0,${p => p.theme.boxShadowAlpha}); 53 | `; 54 | 55 | export const SentLoadingCard = (props: 56 | CollapsibleCardProps & { 57 | children?: React.ReactNode 58 | } 59 | ) => 60 | { props.children } 61 | 62 | ; 63 | 64 | export const SentLoadingBodyCard = styled(SentLoadingCard)` 65 | ${p => !p.collapsed && ` 66 | flex-basis: 50%; 67 | `} 68 | `; 69 | 70 | export const SendCardScrollableWrapper = styled.div` 71 | overflow-y: auto; 72 | 73 | flex-grow: 1; 74 | flex-shrink: 1; 75 | 76 | margin: 0 -20px -20px -20px; 77 | padding: 0 20px 20px 20px; 78 | `; 79 | 80 | export const SendBodyCardSection = styled(SendCardSection)` 81 | /* This is required to force the editor to shrink to fit, instead of going 82 | beyond the limits of the column when other item is expanded and pushes it down */ 83 | overflow-y: hidden; 84 | 85 | ${p => !p.collapsed && ` 86 | /* When we're open, we want space more than any siblings */ 87 | flex-grow: 9999999; 88 | flex-shrink: 0.1; 89 | 90 | /* If we're open, never let us get squeezed to nothing: */ 91 | min-height: 25vh; 92 | 93 | /* Fixed size required to avoid editor resize thrashing */ 94 | flex-basis: 60%; 95 | `} 96 | `; 97 | 98 | export const SendEditorCardContent = styled(EditorCardContent)` 99 | flex-shrink: 1; 100 | `; -------------------------------------------------------------------------------- /src/components/send/send-request-headers-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | 4 | import { RawHeaders } from '../../types'; 5 | 6 | import { 7 | CollapsibleCardHeading, 8 | ExpandableCardProps 9 | } from '../common/card'; 10 | import { 11 | SendCardSection, 12 | SendCardScrollableWrapper 13 | } from './send-card-section'; 14 | import { EditableRawHeaders } from '../common/editable-headers'; 15 | import { ExpandShrinkButton } from '../common/expand-shrink-button'; 16 | import { CollapsingButtons } from '../common/collapsing-buttons'; 17 | 18 | export interface SendRequestHeadersProps extends ExpandableCardProps { 19 | headers: RawHeaders; 20 | updateHeaders: (headers: RawHeaders) => void; 21 | } 22 | 23 | export const SendRequestHeadersCard = observer(({ 24 | headers, 25 | updateHeaders, 26 | ...cardProps 27 | }: SendRequestHeadersProps) => { 28 | return 32 |
33 | 34 | 38 | 39 | 40 | Request Headers 41 | 42 |
43 | 44 | 48 | 49 |
; 50 | }); -------------------------------------------------------------------------------- /src/components/send/sent-response-headers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { 4 | RawHeaders, 5 | HttpVersion 6 | } from '../../types'; 7 | 8 | import { 9 | CollapsibleCardHeading, 10 | ExpandableCardProps 11 | } from '../common/card'; 12 | 13 | import { HeaderDetails } from '../view/http/header-details'; 14 | import { 15 | SendCardSection, 16 | SentLoadingCard, 17 | SendCardScrollableWrapper 18 | } from './send-card-section'; 19 | import { CollapsingButtons } from '../common/collapsing-buttons'; 20 | import { ExpandShrinkButton } from '../common/expand-shrink-button'; 21 | 22 | export interface ResponseHeaderSectionProps extends ExpandableCardProps { 23 | httpVersion: HttpVersion; 24 | requestUrl: URL; 25 | headers: RawHeaders; 26 | } 27 | 28 | export const SentResponseHeaderSection = ({ 29 | httpVersion, 30 | requestUrl, 31 | headers, 32 | ...cardProps 33 | }: ResponseHeaderSectionProps) => { 34 | return 35 |
36 | 37 | 41 | 42 | 43 | Response Headers 44 | 45 |
46 | 47 | 52 | 53 |
; 54 | }; 55 | 56 | export const PendingResponseHeaderSection = ({ 57 | ...cardProps 58 | }: ExpandableCardProps) => { 59 | return 60 |
61 | 62 | 66 | 67 | 68 | Response Headers 69 | 70 |
71 |
; 72 | }; -------------------------------------------------------------------------------- /src/components/settings/settings-components.tsx: -------------------------------------------------------------------------------- 1 | import { styled, css } from '../../styles'; 2 | 3 | import { Button, ButtonLink } from '../common/inputs'; 4 | import { ContentLabel } from '../common/text-content'; 5 | 6 | const SettingsButtonCss = css` 7 | font-size: ${p => p.theme.textSize}; 8 | padding: 6px 16px; 9 | 10 | ${(p: { highlight?: boolean }) => p.highlight && css` 11 | &:not(:disabled) { 12 | background-color: ${p => p.theme.popColor}; 13 | } 14 | `} 15 | `; 16 | 17 | export const SettingsButton = styled(Button)`${SettingsButtonCss}`; 18 | export const SettingsButtonLink = styled(ButtonLink)<{ 19 | highlight?: boolean 20 | }>`${SettingsButtonCss}`; 21 | 22 | export const SettingsExplanation = styled.p` 23 | font-style: italic; 24 | line-height: 1.3; 25 | `; 26 | 27 | export const SettingsSubheading = styled(ContentLabel)` 28 | &:not(header + &) { 29 | margin-top: 40px; 30 | } 31 | `; -------------------------------------------------------------------------------- /src/components/settings/string-settings-list.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observable, action, computed } from 'mobx'; 3 | import { observer } from 'mobx-react'; 4 | 5 | import { styled } from '../../styles'; 6 | import { Icon } from '../../icons'; 7 | import { isValidHost } from '../../model/network'; 8 | 9 | import { inputValidation } from '../component-utils'; 10 | import { TextInput } from '../common/inputs'; 11 | import { SettingsButton } from './settings-components'; 12 | 13 | const StringListContainer = styled.div` 14 | width: 100%; 15 | 16 | display: grid; 17 | grid-template-columns: auto min-content; 18 | grid-gap: 10px; 19 | margin: 10px 0; 20 | 21 | align-items: baseline; 22 | 23 | ${TextInput} { 24 | align-self: stretch; 25 | } 26 | `; 27 | 28 | export const ConfigValueRow = styled.div` 29 | min-width: 300px; 30 | font-family: ${p => p.theme.monoFontFamily}; 31 | `; 32 | 33 | @observer 34 | export class StringSettingsList extends React.Component<{ 35 | values: string[], 36 | onDelete: (value: string) => void, 37 | onAdd: (value: string) => void, 38 | placeholder: string, 39 | 40 | validationFn: (input: HTMLInputElement) => void 41 | }> { 42 | 43 | @observable 44 | inputValue = ''; 45 | 46 | render() { 47 | const { 48 | values, 49 | onDelete, 50 | placeholder 51 | } = this.props; 52 | 53 | return 54 | { values.map((value) => [ 55 | 56 | { value } 57 | , 58 | onDelete(value)} 61 | > 62 | 63 | 64 | ]) } 65 | 66 | 71 | 78 | 79 | 80 | ; 81 | } 82 | 83 | @action.bound 84 | addHost(e: React.MouseEvent) { 85 | this.props.onAdd(this.inputValue); 86 | this.inputValue = ''; 87 | } 88 | 89 | @action.bound 90 | changeInput(e: React.ChangeEvent) { 91 | this.inputValue = e.target.value; 92 | this.props.validationFn(e.target); 93 | } 94 | } -------------------------------------------------------------------------------- /src/components/split-pane.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { default as ReactSplitPane } from 'react-split-pane'; 3 | 4 | import { styled } from '../styles'; 5 | 6 | export type SplitPane = ReactSplitPane & { 7 | pane1: HTMLElement | undefined; 8 | pane2: HTMLElement | undefined; 9 | }; 10 | 11 | // Styles original taken from 12 | // https://github.com/tomkp/react-split-pane/blob/master/README.md#example-styling 13 | export const SplitPane = styled(ReactSplitPane)<{ 14 | hiddenPane?: '1' | '2', 15 | children?: React.ReactNode // Missing in real types - causes issues in React 18 16 | }>` 17 | .Resizer { 18 | background: #000; 19 | opacity: .5; 20 | z-index: 100; 21 | -moz-box-sizing: border-box; 22 | -webkit-box-sizing: border-box; 23 | box-sizing: border-box; 24 | -moz-background-clip: padding; 25 | -webkit-background-clip: padding; 26 | background-clip: padding-box; 27 | } 28 | 29 | .Resizer:hover { 30 | -webkit-transition: all 1s ease; 31 | transition: all 1s ease; 32 | opacity: 0.9; 33 | } 34 | 35 | .Resizer.horizontal { 36 | height: 11px; 37 | margin: -5px 0; 38 | border-top: 5px solid rgba(255, 255, 255, 0); 39 | border-bottom: 5px solid rgba(255, 255, 255, 0); 40 | cursor: row-resize; 41 | width: 100%; 42 | } 43 | 44 | .Resizer.horizontal:hover { 45 | border-top: 5px solid rgba(0, 0, 0, 0.2); 46 | border-bottom: 5px solid rgba(0, 0, 0, 0.2); 47 | } 48 | 49 | .Resizer.vertical { 50 | width: 11px; 51 | margin: 0 -5px; 52 | border-left: 5px solid rgba(255, 255, 255, 0); 53 | border-right: 5px solid rgba(255, 255, 255, 0); 54 | cursor: col-resize; 55 | } 56 | 57 | .Resizer.vertical:hover { 58 | border-left: 5px solid rgba(0, 0, 0, 0.2); 59 | border-right: 5px solid rgba(0, 0, 0, 0.2); 60 | } 61 | 62 | .Resizer.disabled { 63 | cursor: not-allowed; 64 | } 65 | 66 | .Resizer.disabled:hover { 67 | border-color: transparent; 68 | } 69 | 70 | .Pane { 71 | min-width: 0; /* Don't let flexbox force panes to expand */ 72 | } 73 | 74 | ${({ hiddenPane }) => { 75 | if (!hiddenPane) return ''; 76 | else return ` 77 | .Resizer { 78 | display: none !important; 79 | } 80 | 81 | .Pane${hiddenPane} { 82 | display: none !important; 83 | } 84 | `; 85 | }} 86 | `; -------------------------------------------------------------------------------- /src/components/style-provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { inject, observer } from 'mobx-react'; 3 | 4 | import { ThemeProvider, StyleSheetManager } from '../styles'; 5 | import { WithInjected } from '../types'; 6 | 7 | import { UiStore } from '../model/ui/ui-store'; 8 | 9 | const StyleProvider = inject('uiStore')(observer((p: { 10 | uiStore: UiStore, 11 | children: JSX.Element 12 | }) => { 13 | return 14 | 15 | { p.children } 16 | 17 | 18 | })); 19 | 20 | const InjectedStyleProvider = StyleProvider as unknown as WithInjected< 21 | typeof StyleProvider, 22 | 'uiStore' 23 | >; 24 | export { InjectedStyleProvider as StyleProvider }; -------------------------------------------------------------------------------- /src/components/view/filters/filter-tag.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as polished from 'polished'; 3 | 4 | import { styled } from '../../../styles'; 5 | import { Icon } from '../../../icons'; 6 | 7 | import { Filter } from '../../../model/filters/search-filters'; 8 | 9 | const FilterTagDelete = styled(Icon).attrs(() => ({ 10 | icon: ['fas', 'times'] 11 | }))` 12 | position: absolute; 13 | right: -20px; 14 | top: 50%; 15 | transform: translateY(-50%); 16 | transition: right 0.1s; 17 | cursor: pointer; 18 | 19 | padding: 6px; 20 | background-image: radial-gradient( 21 | ${p => polished.rgba(p.theme.mainBackground, 0.9)} 50%, 22 | transparent 100% 23 | ); 24 | 25 | &:hover { 26 | color: ${p => p.theme.popColor}; 27 | } 28 | `; 29 | 30 | const FilterTagName = styled.span` 31 | white-space: pre; /* Nowrap + show spaces accurately */ 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | padding: 4px; 35 | ` 36 | 37 | const FilterTagContainer = styled.div` 38 | flex-shrink: 0; 39 | 40 | display: flex; 41 | align-items: center; 42 | 43 | position: relative; 44 | overflow: hidden; 45 | 46 | min-width: 0; 47 | max-width: 100%; 48 | box-sizing: border-box; 49 | 50 | margin-right: 5px; 51 | 52 | background-color: ${p => p.theme.mainBackground}; 53 | color: ${p => p.theme.mainColor}; 54 | 55 | border: 1px solid ${p => p.theme.containerWatermark}; 56 | box-shadow: 0 2px 4px 0 rgba(0,0,0,${p => p.theme.boxShadowAlpha}); 57 | border-radius: 3px; 58 | 59 | cursor: pointer; 60 | 61 | &:hover, &:focus-within { 62 | box-shadow: 0 2px 4px 0 rgba(0,0,0,${p => p.theme.boxShadowAlpha * 2}); 63 | } 64 | 65 | &:hover { 66 | > ${FilterTagDelete} { 67 | right: 0; 68 | } 69 | } 70 | 71 | &:focus-within { 72 | outline: none; 73 | border-color: ${p => p.theme.popColor}; 74 | } 75 | 76 | &.is-selected { 77 | background-color: ${p => p.theme.mainLowlightBackground}; 78 | box-shadow: inset 0 0 12px -8px #000; 79 | } 80 | 81 | & ::selection { 82 | background-color: transparent; 83 | } 84 | `; 85 | 86 | function ignoreTripleClick(event: React.MouseEvent) { 87 | if (event.detail === 3) event.preventDefault(); 88 | } 89 | 90 | export const FilterTag = React.forwardRef((props: { 91 | filter: Filter, 92 | isSelected: boolean, 93 | onDelete: () => void 94 | onKeyDown?: (event: React.KeyboardEvent) => void 95 | }, ref: React.Ref) => { 96 | return 104 | { props.filter.toString() } 105 | 106 | ; 107 | }); -------------------------------------------------------------------------------- /src/components/view/filters/save-filters-row.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled, css } from '../../../styles'; 4 | import { Icon } from '../../../icons'; 5 | 6 | export type SaveFiltersSuggestion = { 7 | saveFilters: true, 8 | filterCount: number, 9 | isPaidUser: boolean 10 | }; 11 | 12 | export const isSaveFiltersSuggestion = (suggestion: any): suggestion is SaveFiltersSuggestion => 13 | 'saveFilters' in suggestion && suggestion.saveFilters === true; 14 | 15 | const SaveFiltersContainer = styled.div<{ isHighlighted: boolean }>` 16 | background-color: ${p => p.isHighlighted 17 | ? p.theme.highlightBackground 18 | : p.theme.mainBackground 19 | }; 20 | 21 | :not(:first-child) { 22 | border-top: 1px solid ${p => p.theme.containerBorder}; 23 | } 24 | 25 | ${(p: { isHighlighted: boolean }) => p.isHighlighted && css` 26 | box-shadow: 0px -8px 10px -10px rgba(0,0,0,${p => p.theme.boxShadowAlpha * 2}); 27 | font-weight: bold; 28 | `} 29 | 30 | width: 100%; 31 | cursor: pointer; 32 | 33 | padding: 8px; 34 | box-sizing: border-box; 35 | 36 | font-size: ${p => p.theme.textSize}; 37 | white-space: nowrap; 38 | overflow: hidden; 39 | text-overflow: ellipsis; 40 | 41 | svg { 42 | margin-right: 5px; 43 | } 44 | `; 45 | 46 | export const SaveFiltersRow = (props: { 47 | filterCount: number, 48 | query: string, 49 | isHighlighted: boolean, 50 | isPaidUser: boolean 51 | }) => { 52 | return 53 | { 54 | props.isPaidUser 55 | ? <> 56 | 57 | Save { 58 | props.filterCount > 1 59 | ? `these ${props.filterCount} filters` 60 | : 'this filter' 61 | } as { `'${props.query}'` || '...' } 62 | 63 | : <> 64 | 65 | Get Pro to save { 66 | props.filterCount > 1 67 | ? `these ${props.filterCount} filters` 68 | : 'this filter' 69 | } as { `'${props.query}'` || '...' } 70 | 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /src/components/view/header-card.tsx: -------------------------------------------------------------------------------- 1 | import { styled, css } from '../../styles'; 2 | 3 | import { MediumCard } from '../common/card'; 4 | import { Button, SecondaryButton } from '../common/inputs'; 5 | 6 | export const HeaderCard = styled(MediumCard)` 7 | position: sticky; 8 | top: -10px; 9 | z-index: 2; 10 | 11 | display: flex; 12 | flex-wrap: wrap; 13 | flex-direction: row; 14 | align-items: center; 15 | justify-content: flex-end; 16 | 17 | flex-shrink: 0; 18 | `; 19 | 20 | const HeaderButtonStyles = css` 21 | padding: 10px 15px; 22 | font-weight: bold; 23 | font-size: ${p => p.theme.textSize}; 24 | 25 | margin: 10px 0 0 10px; 26 | align-self: stretch; 27 | `; 28 | 29 | export const HeaderText = styled.p` 30 | width: 100%; 31 | margin-bottom: 10px; 32 | line-height: 1.3; 33 | 34 | a[href] { 35 | color: ${p => p.theme.linkColor}; 36 | 37 | &:visited { 38 | color: ${p => p.theme.visitedLinkColor}; 39 | } 40 | } 41 | `; 42 | 43 | export const HeaderButton = styled(Button)`${HeaderButtonStyles}`; 44 | export const SecondaryHeaderButton = styled(SecondaryButton)`${HeaderButtonStyles}`; -------------------------------------------------------------------------------- /src/components/view/http/http-aborted-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer, inject } from 'mobx-react'; 3 | 4 | import { HttpExchangeView } from '../../../types'; 5 | import { styled } from '../../../styles'; 6 | 7 | import { UiStore } from '../../../model/ui/ui-store'; 8 | import { getStatusColor } from '../../../model/events/categorization'; 9 | 10 | import { Pill } from '../../common/pill'; 11 | import { 12 | CollapsibleCard, 13 | CollapsibleCardHeading, 14 | CollapsibleCardProps 15 | } from '../../common/card'; 16 | import { ContentMonoValue } from '../../common/text-content'; 17 | 18 | const ErrorContent = styled(ContentMonoValue)` 19 | margin-top: 10px; 20 | `; 21 | 22 | export const HttpAbortedResponseCard = inject('uiStore')(observer((p: { 23 | cardProps: CollapsibleCardProps, 24 | exchange: HttpExchangeView, 25 | uiStore?: UiStore 26 | }) => 27 | 28 |
29 | 30 | Aborted 31 | 32 | 33 | Response 34 | 35 |
36 |
37 | The connection failed before a response could be completed{ 38 | p.exchange.abortMessage 39 | ? <> with error: 40 | 41 | { p.exchange.abortMessage } 42 | 43 | 44 | : '.' 45 | } 46 |
47 |
48 | )); -------------------------------------------------------------------------------- /src/components/view/http/http-breakpoint-header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { WarningIcon } from '../../../icons'; 4 | 5 | import { versionSatisfies, serverVersion, CLOSE_IN_BREAKPOINT } from '../../../services/service-versions'; 6 | 7 | import { clickOnEnter } from '../../component-utils'; 8 | import { 9 | HeaderCard, 10 | HeaderText, 11 | HeaderButton, 12 | SecondaryHeaderButton 13 | } from '../header-card'; 14 | 15 | export const HttpRequestBreakpointHeader = (p: { 16 | onResume: () => void, 17 | onCreateResponse: () => void, 18 | onClose: () => void 19 | }) => 20 | 21 | 22 | This request is paused at a breakpoint 23 | 24 | 25 | { 26 | versionSatisfies(serverVersion.value as string, CLOSE_IN_BREAKPOINT) 27 | ? <> 28 | Edit the request and then resume to let your edited request continue to the target URL, 29 | respond directly to provide a response yourself, or close to immediately end the connection. 30 | 31 | : <> 32 | Respond directly to provide a response yourself, or edit the request as you'd like 33 | and then resume to let your edited request continue to the target URL. 34 | 35 | } 36 | 37 | 38 | 39 | Respond directly 40 | 41 | 42 | { versionSatisfies(serverVersion.value as string, CLOSE_IN_BREAKPOINT) 43 | ? 44 | Close 45 | 46 | : null 47 | } 48 | 49 | 50 | Resume 51 | 52 | ; 53 | 54 | export const HttpResponseBreakpointHeader = (p: { 55 | onResume: () => void, 56 | onClose: () => void 57 | }) => 58 | 59 | 60 | This response is paused at a breakpoint 61 | 62 | 63 | { 64 | versionSatisfies(serverVersion.value as string, CLOSE_IN_BREAKPOINT) 65 | ? <> 66 | Edit it as you'd like and resume to let the edited response continue back to the client, 67 | or close to immediately end the connection. 68 | 69 | : <> 70 | Edit it as you'd like, then resume to let the edited response continue back to the client. 71 | 72 | } 73 | 74 | 75 | { versionSatisfies(serverVersion.value as string, CLOSE_IN_BREAKPOINT) 76 | ? 77 | Close 78 | 79 | : null 80 | } 81 | 82 | 83 | Resume 84 | 85 | ; -------------------------------------------------------------------------------- /src/components/view/http/http-trailers-card.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | import { observer } from 'mobx-react'; 4 | 5 | import { HttpVersion, RawTrailers } from '../../../types'; 6 | 7 | import { 8 | CollapsibleCard, 9 | CollapsibleCardProps, 10 | CollapsibleCardHeading 11 | } from '../../common/card'; 12 | import { HeaderDetails } from './header-details'; 13 | 14 | interface HttpTrailersCardProps extends CollapsibleCardProps { 15 | type: 'request' | 'response'; 16 | httpVersion: HttpVersion; 17 | requestUrl: URL; 18 | trailers: RawTrailers; 19 | } 20 | 21 | export const HttpTrailersCard = observer((props: HttpTrailersCardProps) => { 22 | const { type, requestUrl, httpVersion, trailers } = props; 23 | 24 | return 25 |
26 | 27 | { type === 'request' ? 'Request' : 'Response' } Trailers 28 | 29 |
30 | 31 |
32 | 37 |
38 |
; 39 | }); -------------------------------------------------------------------------------- /src/components/view/http/matched-rule-pill.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { inject } from 'mobx-react'; 3 | 4 | import { styled } from '../../../styles'; 5 | import { PhosphorIcon } from '../../../icons'; 6 | 7 | import { UiStore } from '../../../model/ui/ui-store'; 8 | import { nameHandlerClass } from '../../../model/rules/rule-descriptions'; 9 | import { HandlerClassKey } from '../../../model/rules/rules'; 10 | import { getSummaryColor } from '../../../model/events/categorization'; 11 | 12 | import { aOrAn, uppercaseFirst } from '../../../util/text'; 13 | import { PillButton } from '../../common/pill'; 14 | 15 | export interface MatchedRuleData { 16 | stepTypes: HandlerClassKey[]; 17 | status: 'unchanged' | 'modified-types' | 'deleted'; 18 | } 19 | 20 | export const shouldShowRuleDetails = ( 21 | matchedRuleData: MatchedRuleData | undefined 22 | ): matchedRuleData is MatchedRuleData => { 23 | // We never bother showing rule details for pure-passthrough rules 24 | return !!matchedRuleData?.stepTypes.length && 25 | !matchedRuleData?.stepTypes.every( 26 | type => type === 'passthrough' || type === 'ws-passthrough' 27 | ); 28 | } 29 | 30 | export const MatchedRulePill = styled(inject('uiStore')((p: { 31 | className?: string, 32 | uiStore?: UiStore, 33 | ruleData: MatchedRuleData, 34 | onClick: () => void 35 | }) => { 36 | const { stepTypes } = p.ruleData; 37 | const stepDescription = stepTypes.length !== 1 38 | ? 'multi-step' 39 | : nameHandlerClass(stepTypes[0]); 40 | 41 | return 65 | 66 | { uppercaseFirst(stepDescription) } 67 | ; 68 | }))` 69 | margin-right: auto; 70 | 71 | text-decoration: none; 72 | word-spacing: 0; 73 | 74 | > svg { 75 | margin: -1px 5px 0 -1px; 76 | } 77 | `; -------------------------------------------------------------------------------- /src/components/view/http/transform-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { action } from 'mobx'; 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import { styled } from '../../../styles'; 6 | 7 | import { ContentPerspective, UiStore } from '../../../model/ui/ui-store'; 8 | 9 | import { PillSelect } from '../../common/pill'; 10 | import { MediumCard } from '../../common/card'; 11 | import { MatchedRuleData, MatchedRulePill, shouldShowRuleDetails } from './matched-rule-pill'; 12 | 13 | const DropdownContainer = styled.div` 14 | display: inline-block; 15 | float: right; 16 | user-select: none; 17 | `; 18 | 19 | const PerspectivesDropdown = styled(PillSelect)` 20 | font-size: ${p => p.theme.textSize}; 21 | padding: 1px 4px 1px 8px; 22 | `; 23 | 24 | const PerspectiveSelector = observer((p: { 25 | uiStore: UiStore 26 | }) => { 27 | const onSelect = React.useCallback(action((e: React.ChangeEvent) => { 28 | const value = e.target.value; 29 | p.uiStore.contentPerspective = value as ContentPerspective; 30 | }), [p.uiStore]); 31 | 32 | return 33 | 34 | 35 | 36 | 37 | 38 | 39 | ; 40 | }); 41 | 42 | export const TransformCard = (p: { 43 | matchedRuleData?: MatchedRuleData | undefined, 44 | onRuleClicked: () => void, 45 | uiStore: UiStore 46 | }) => { 47 | if (!shouldShowRuleDetails(p.matchedRuleData)) return null; 48 | 49 | return 50 | 54 | 55 | 58 | ; 59 | }; -------------------------------------------------------------------------------- /src/components/view/http/user-agent-header-description.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { parseSource } from '../../../model/http/sources'; 4 | import { getHeaderDocs } from '../../../model/http/http-docs'; 5 | 6 | import { Content } from '../../common/text-content'; 7 | 8 | export const UserAgentHeaderDescription = (p: { value: string }) => { 9 | const { description } = parseSource(p.value); 10 | 11 | if (!description) return

12 | { getHeaderDocs('user-agent')!.summary } 13 |

; 14 | 15 | return 16 |

{ description }

17 |
; 18 | }; -------------------------------------------------------------------------------- /src/components/view/rtc/rtc-connection-header.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { observer } from 'mobx-react'; 8 | 9 | import { RTCConnection } from '../../../model/webrtc/rtc-connection'; 10 | 11 | import { clickOnEnter } from '../../component-utils'; 12 | import { 13 | HeaderCard, 14 | HeaderText, 15 | HeaderButton, 16 | SecondaryHeaderButton 17 | } from '../header-card'; 18 | import { CopyableMonoValue } from '../../common/text-content'; 19 | 20 | export const RTCConnectionHeader = observer((p: { 21 | connection: RTCConnection, 22 | 23 | hideConnection: () => void, 24 | jumpToConnection: () => void 25 | }) => 26 | 27 | Part of a WebRTC Connection from { 28 | p.connection.clientURL 29 | } to { 30 | p.connection.remoteURL 31 | } 32 | 33 | 34 | 38 | Hide 39 | 40 | 41 | 45 | Jump to connection 46 | 47 | ); -------------------------------------------------------------------------------- /src/components/view/rtc/rtc-data-channel-card.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { observer } from 'mobx-react'; 8 | import * as portals from 'react-reverse-portal'; 9 | 10 | import { RTCDataChannel } from '../../../model/webrtc/rtc-data-channel'; 11 | 12 | import { ExpandableCardProps } from '../../common/card'; 13 | import { SelfSizedEditor } from '../../editor/base-editor'; 14 | import { StreamMessageListCard } from '../stream-message-list-card'; 15 | 16 | export const RTCDataChannelCard = observer(({ 17 | dataChannel, 18 | isPaidUser, 19 | streamMessageEditor, 20 | ...cardProps 21 | }: ExpandableCardProps & { 22 | dataChannel: RTCDataChannel, 23 | isPaidUser: boolean, 24 | streamMessageEditor: portals.HtmlPortalNode 25 | }) => ); -------------------------------------------------------------------------------- /src/components/view/rtc/rtc-data-channel-details-pane.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as _ from 'lodash'; 7 | import * as React from 'react'; 8 | import { action, observable } from 'mobx'; 9 | import { observer, inject } from 'mobx-react'; 10 | import * as portals from 'react-reverse-portal'; 11 | 12 | import { AccountStore } from '../../../model/account/account-store'; 13 | import { RTCDataChannel } from '../../../model/webrtc/rtc-data-channel'; 14 | 15 | import { SelfSizedEditor } from '../../editor/base-editor'; 16 | import { RTCDataChannelCard } from './rtc-data-channel-card'; 17 | import { RTCConnectionHeader } from './rtc-connection-header'; 18 | 19 | @inject('accountStore') 20 | @observer 21 | export class RTCDataChannelDetailsPane extends React.Component<{ 22 | dataChannel: RTCDataChannel, 23 | 24 | streamMessageEditor: portals.HtmlPortalNode, 25 | navigate: (path: string) => void, 26 | 27 | // Injected: 28 | accountStore?: AccountStore 29 | }> { 30 | 31 | @observable 32 | private isConnectionHidden = false; 33 | 34 | @action.bound 35 | hideConnection() { 36 | this.isConnectionHidden = true; 37 | } 38 | 39 | jumpToConnection = () => { 40 | const { rtcConnection } = this.props.dataChannel; 41 | this.props.navigate(`/view/${rtcConnection.id}`); 42 | } 43 | 44 | render() { 45 | const { 46 | dataChannel, 47 | streamMessageEditor, 48 | accountStore 49 | } = this.props; 50 | 51 | return <> 52 | { !this.isConnectionHidden && 53 | 58 | } 59 | 70 | ; 71 | 72 | } 73 | 74 | }; -------------------------------------------------------------------------------- /src/components/view/rtc/rtc-media-details-pane.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as _ from 'lodash'; 7 | import * as React from 'react'; 8 | import { action, observable } from 'mobx'; 9 | import { observer } from 'mobx-react'; 10 | 11 | import { RTCMediaTrack } from '../../../model/webrtc/rtc-media-track'; 12 | 13 | import { RTCMediaCard } from './rtc-media-card'; 14 | import { RTCConnectionHeader } from './rtc-connection-header'; 15 | 16 | @observer 17 | export class RTCMediaDetailsPane extends React.Component<{ 18 | mediaTrack: RTCMediaTrack, 19 | navigate: (path: string) => void 20 | }> { 21 | 22 | @observable 23 | private isConnectionHidden = false; 24 | 25 | @action.bound 26 | hideConnection() { 27 | this.isConnectionHidden = true; 28 | } 29 | 30 | jumpToConnection = () => { 31 | const { rtcConnection } = this.props.mediaTrack; 32 | this.props.navigate(`/view/${rtcConnection.id}`); 33 | } 34 | 35 | render() { 36 | const { 37 | mediaTrack 38 | } = this.props; 39 | 40 | return <> 41 | { !this.isConnectionHidden && 42 | 47 | } 48 | 61 | ; 62 | 63 | } 64 | 65 | }; -------------------------------------------------------------------------------- /src/components/view/rtc/sdp-card.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as _ from 'lodash'; 7 | import * as React from 'react'; 8 | import { observer } from 'mobx-react'; 9 | import { MockRTCSessionDescription } from 'mockrtc'; 10 | import * as portals from 'react-reverse-portal'; 11 | 12 | import { 13 | CollapsibleCard, 14 | CollapsibleCardHeading, 15 | ExpandableCardProps 16 | } from '../../common/card'; 17 | import { SelfSizedEditor } from '../../editor/base-editor'; 18 | import { ContentViewer } from '../../editor/content-viewer'; 19 | import { EditorCardContent } from '../../editor/body-card-components'; 20 | 21 | import { RTCConnection } from '../../../model/webrtc/rtc-connection'; 22 | 23 | interface RtcSdpCardProps extends ExpandableCardProps { 24 | connection: RTCConnection; 25 | type: 'local' | 'remote'; 26 | sessionDescription: MockRTCSessionDescription; 27 | editorNode: portals.HtmlPortalNode; 28 | } 29 | 30 | @observer 31 | export class SDPCard extends React.Component { 32 | 33 | render() { 34 | const { 35 | connection, 36 | type, 37 | sessionDescription, 38 | editorNode, 39 | ...cardProps 40 | } = this.props; 41 | 42 | return 43 |
44 | 45 | { 46 | type === 'local' ? 'Sent' : 'Received' 47 | } Session { 48 | _.capitalize(sessionDescription.type) 49 | } 50 | 51 |
52 | 53 | 54 | 61 | { sessionDescription.sdp } 62 | 63 | 64 |
; 65 | } 66 | } -------------------------------------------------------------------------------- /src/components/view/tls/tls-tunnel-details-pane.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | import { getReadableIP } from '../../../model/network'; 5 | import { TlsTunnel } from '../../../model/tls/tls-tunnel'; 6 | 7 | import { MediumCard } from '../../common/card'; 8 | import { ContentLabelBlock, Content, CopyableMonoValue } from '../../common/text-content'; 9 | import { PaneScrollContainer } from '../view-details-pane'; 10 | 11 | export class TlsTunnelDetailsPane extends React.Component<{ 12 | tunnel: TlsTunnel 13 | }> { 14 | render() { 15 | const { tunnel } = this.props; 16 | 17 | const sourceDetailParts = getReadableIP(tunnel.remoteIpAddress).split(' '); 18 | const sourceIp = sourceDetailParts[0]; 19 | const sourceDetails = sourceDetailParts.slice(1).join(' '); 20 | 21 | return 22 | 23 |
24 |

TLS Tunnel

25 |
26 | 27 | Details 28 | 29 |

30 | This TLS connection was not intercepted by HTTP Toolkit, as it matched 31 | a hostname that is configured for TLS passthrough in your settings. 32 |

33 |
34 | 35 |

36 | The connection was made from { 37 | sourceIp 38 | }:{ 39 | tunnel.remotePort 40 | } { sourceDetails } to { 41 | tunnel.upstreamHostname 42 | }:{ 43 | tunnel.upstreamPort 44 | }. 45 |

46 |
47 |
48 |
; 49 | } 50 | } -------------------------------------------------------------------------------- /src/components/view/url-breakdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | import { ContentLabel } from '../common/text-content'; 5 | 6 | const BreakdownContainer = styled.dl` 7 | display: grid; 8 | grid-template-columns: fit-content(50%) auto; 9 | grid-gap: 5px; 10 | `; 11 | 12 | const BreakdownKey = styled.dt` 13 | font-family: ${p => p.theme.monoFontFamily}; 14 | word-break: break-all; 15 | text-align: right; 16 | `; 17 | 18 | const BreakdownValue = styled.dd` 19 | font-family: ${p => p.theme.monoFontFamily}; 20 | word-break: break-all; 21 | white-space: pre-wrap; 22 | `; 23 | 24 | const ParameterSeparator = styled(ContentLabel)` 25 | margin-top: 10px; 26 | grid-column: 1 / span 2; 27 | `; 28 | 29 | export const UrlBreakdown = (p: { url: URL }) => { 30 | const params = [...p.url.searchParams]; 31 | 32 | let decodedPathname: string; 33 | try { 34 | decodedPathname = decodeURIComponent(p.url.pathname); 35 | } catch (e) { 36 | decodedPathname = p.url.pathname; 37 | } 38 | 39 | return 40 | Protocol: { p.url.protocol.slice(0, -1) } 41 | 42 | { (p.url.username || p.url.password) && <> 43 | Username: { p.url.username } 44 | Password: { p.url.password } 45 | } 46 | 47 | Host: { p.url.host } 48 | Path: { decodedPathname } 49 | 50 | { params.length ? Parameters : null } 51 | 52 | { params.map(([key, value], i) => [ 53 | { key }:, 54 | { value } 55 | ]) } 56 | ; 57 | } -------------------------------------------------------------------------------- /src/components/view/view-details-pane.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { styled } from '../../styles'; 4 | 5 | export const PaneOuterContainer = styled.div` 6 | height: 100%; 7 | width: 100%; 8 | display: flex; 9 | flex-direction: column; 10 | `; 11 | 12 | const PaneScrollOuterContainer = styled.div` 13 | position: relative; 14 | overflow-y: scroll; 15 | 16 | flex-grow: 1; 17 | padding: 0 20px 0 20px; 18 | 19 | background-color: ${p => p.theme.containerBackground}; 20 | `; 21 | 22 | const PaneScrollInnerContainer = styled.div` 23 | min-height: 100%; 24 | box-sizing: border-box; 25 | 26 | display: flex; 27 | flex-direction: column; 28 | 29 | /* 30 | * This padding could be padding on the scroll container, but doing so causes odd 31 | * behaviour where position: sticky headers don't take it into account, on OSX only. 32 | * Moving to the direct parent of the header makes that consistent, for some reason. Ew. 33 | */ 34 | padding-top: 20px; 35 | `; 36 | 37 | export const PaneScrollContainer = (p: { children: React.ReactNode }) => 38 | 39 | 40 | { p.children } 41 | 42 | ; -------------------------------------------------------------------------------- /src/images/arc-browser-logo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IconPrefix, 3 | IconDefinition, 4 | IconName 5 | } from '@fortawesome/fontawesome-svg-core'; 6 | 7 | export const arcBrowser: IconDefinition = { 8 | prefix: 'fac', 9 | iconName: 'arc-browser', 10 | icon: [ 11 | // height x width 12 | 56.4, 67.36, 13 | [], 14 | '', 15 | // SVG path 16 | 'm 58.458706,45.75 -3.5,-7.36 -6.63,-13.95 -0.01,0.01 c 0,0 0,-0.01 0.01,-0.01 l -9.64,-20.28 a 7.292,7.292 0 0 0 -6.58,-4.16 c -2.81,0 -5.37,1.62 -6.58,4.16 l -9.83,20.68 c 2.76,3.65 7.64,6.65 12.49,7.68 l 3.18,-6.68 c 0.3,-0.63 1.2,-0.63 1.5,0 l 3.11,6.54 h 0.02 -0.02 l 6.33,13.32 3.11,6.54 a 7.28,7.28 0 0 0 6.59,4.16 c 0.65,0 1.3,-0.09 1.94,-0.27 4.39,-1.21 6.47,-6.26 4.51,-10.38 m -22.49,-13.37 c -1.42,0.34 -2.87,0.52 -4.32,0.52 -1.13,0 -2.3,-0.13 -3.47,-0.38 -4.85,-1.03 -9.73,-4.03 -12.49,-7.68 -0.69,-0.91 -1.25,-1.86 -1.64,-2.83 -1.51,-3.73 -5.7600005,-5.53 -9.4900005,-4.03 -3.73000009,1.51 -5.53000009,5.76 -4.03000009,9.49 C 2.2387054,31.71 5.2587055,35.6 9.0487055,38.8 a 37.84,37.84 0 0 0 12.7700005,7.08 c 3.21,1.03 6.54,1.6 9.82,1.6 3.64,0 7.23,-0.63 10.65,-1.78 z m 25.54,-23.1800005 a 7.29,7.29 0 0 0 -8.58,5.7200005 c -0.7,3.5 -2.34,6.759999 -4.6,9.53 l 6.63,13.96 c 6.12,-5.31 10.64,-12.54 12.26,-20.63 0.79,-3.96 -1.77,-7.8000005 -5.71,-8.5800005 M 9.0487055,38.8 l -3.32,6.98 c -1.69,3.549999 -0.42,7.92 3.06,9.769999 3.6900005,1.96 8.2300005,0.43 10.0100005,-3.299999 l 3.03,-6.37 A 37.885,37.885 0 0 1 9.0487055,38.8' 17 | ] 18 | }; -------------------------------------------------------------------------------- /src/images/custom-spinner.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IconPrefix, 3 | IconDefinition, 4 | IconName 5 | } from '@fortawesome/fontawesome-svg-core'; 6 | 7 | export const customSpinnerArc: IconDefinition = { 8 | // Based on https://codepen.io/aurer/pen/jEGbA 9 | prefix: 'fac', 10 | iconName: 'spinner-arc', 11 | icon: [ 12 | // height x width 13 | 50, 50, 14 | [], 15 | '', 16 | // SVG path 17 | 'M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z' 18 | ] 19 | }; -------------------------------------------------------------------------------- /src/images/loading-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/httptoolkit-ui/8d5550c4b37468700c6b8ffd73fd6e506815fe4d/src/images/loading-logo.png -------------------------------------------------------------------------------- /src/images/logo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 14 | 16 | 18 | 19 | -------------------------------------------------------------------------------- /src/model/api/api-interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | OpenAPIObject, 3 | SchemaObject 4 | } from 'openapi-directory'; 5 | 6 | import type { 7 | HtkResponse, 8 | Html 9 | } from "../../types"; 10 | 11 | import type { OpenApiMetadata } from './build-api-metadata'; 12 | import type { OpenRpcDocument, OpenRpcMetadata } from './jsonrpc'; 13 | 14 | export type ApiMetadata = 15 | | OpenApiMetadata 16 | | OpenRpcMetadata; 17 | 18 | export type ApiSpec = 19 | | OpenAPIObject 20 | | OpenRpcDocument; 21 | 22 | export interface ApiExchange { 23 | 24 | readonly service: ApiService; 25 | readonly operation: ApiOperation; 26 | readonly request: ApiRequest; 27 | 28 | readonly response: ApiResponse | undefined; 29 | 30 | updateWithResponse(response: HtkResponse | 'aborted' | undefined): void; 31 | 32 | matchedOperation(): boolean; 33 | } 34 | 35 | export interface ApiService { 36 | readonly shortName: string; 37 | readonly name: string; 38 | readonly logoUrl?: string; 39 | readonly description?: Html; 40 | readonly docsUrl?: string; 41 | } 42 | 43 | export interface ApiOperation { 44 | readonly name: string; 45 | readonly description?: Html; 46 | readonly docsUrl?: string; 47 | 48 | readonly warnings: string[]; 49 | } 50 | 51 | export interface ApiRequest { 52 | parameters: ApiParameter[]; 53 | bodySchema?: SchemaObject; 54 | } 55 | 56 | export interface ApiParameter { 57 | name: string; 58 | description?: Html; 59 | value?: unknown; 60 | defaultValue?: unknown; 61 | enum?: unknown[]; 62 | type?: string; 63 | in: 64 | | 'cookie' 65 | | 'path' 66 | | 'header' 67 | | 'query' 68 | | 'body'; 69 | required: boolean; 70 | deprecated: boolean; 71 | warnings: string[]; 72 | } 73 | 74 | export interface ApiResponse { 75 | description?: Html; 76 | bodySchema?: SchemaObject; 77 | } -------------------------------------------------------------------------------- /src/model/events/bodies.ts: -------------------------------------------------------------------------------- 1 | import { IObservableValue, observable, action } from 'mobx'; 2 | 3 | import { testEncodingsAsync } from '../../services/ui-worker-api'; 4 | import { ExchangeMessage } from '../../types'; 5 | import { ObservableCache } from '../observable-cache'; 6 | 7 | const EncodedSizesCacheKey = Symbol('encoded-body-test'); 8 | type EncodedBodySizes = { [encoding: string]: number }; 9 | type EncodedSizesCache = ObservableCache<{ 10 | [EncodedSizesCacheKey]: IObservableValue | undefined 11 | }>; 12 | 13 | export function testEncodings(message: ExchangeMessage): EncodedBodySizes | undefined { 14 | if (!message.body.isDecoded()) return; 15 | 16 | const encodedSizesCache = message.cache; 17 | const existingObservable = encodedSizesCache.get(EncodedSizesCacheKey); 18 | 19 | if (existingObservable) return existingObservable.get(); 20 | else { 21 | const sizesObservable = observable.box(); 22 | encodedSizesCache.set(EncodedSizesCacheKey, sizesObservable); 23 | 24 | testEncodingsAsync(message.body.decodedData) 25 | .then(action((testResults: EncodedBodySizes) => { 26 | sizesObservable.set(testResults) 27 | })) 28 | // Ignore errors for now - we just never resolve if testing something unencodable 29 | .catch(() => {}); 30 | 31 | // Will be undefined, but ensures we're subscribed to the observable 32 | return sizesObservable.get(); 33 | } 34 | } 35 | 36 | export function decodingRequired(encodedBuffer: Buffer, encodings: string[]): boolean { 37 | return !( 38 | encodings.length === 0 || // No encoding 39 | (encodings.length === 1 && encodings[0] === 'identity') || // No-op only encoding 40 | encodedBuffer.length === 0 // Empty body (e.g. HEAD, 204, etc) 41 | ); 42 | } -------------------------------------------------------------------------------- /src/model/events/event-base.ts: -------------------------------------------------------------------------------- 1 | import { observable, computed } from 'mobx'; 2 | 3 | import { 4 | FailedTlsConnection, 5 | TlsTunnel, 6 | HttpExchangeView, 7 | RTCConnection, 8 | RTCDataChannel, 9 | RTCMediaTrack, 10 | WebSocketStream 11 | } from '../../types'; 12 | 13 | import { getEventCategory } from './categorization'; 14 | 15 | export abstract class HTKEventBase { 16 | 17 | abstract get id(): string; 18 | 19 | // These can be overriden by subclasses to allow easy type narrowing: 20 | isHttp(): this is HttpExchangeView { return false; } 21 | isWebSocket(): this is WebSocketStream { return false; } 22 | 23 | isTlsFailure(): this is FailedTlsConnection { return false; } 24 | isTlsTunnel(): this is TlsTunnel { return false; } 25 | 26 | isRTCConnection(): this is RTCConnection { return false; } 27 | isRTCDataChannel(): this is RTCDataChannel { return false; } 28 | isRTCMediaTrack(): this is RTCMediaTrack { return false; } 29 | 30 | @computed 31 | public get category() { 32 | return getEventCategory(this); 33 | } 34 | 35 | @observable 36 | private _searchIndex: string = ''; 37 | public get searchIndex(): string { return this._searchIndex; } 38 | public set searchIndex(value: string) { this._searchIndex = value; } 39 | 40 | @observable 41 | private _pinned: boolean = false; 42 | public get pinned(): boolean { return this._pinned; } 43 | public set pinned(value: boolean) { this._pinned = value; } 44 | 45 | } -------------------------------------------------------------------------------- /src/model/events/stream-message.ts: -------------------------------------------------------------------------------- 1 | import { computed, observable } from 'mobx'; 2 | 3 | import { InputStreamMessage } from "../../types"; 4 | import { asBuffer } from '../../util/buffer'; 5 | import { ObservableCache } from '../observable-cache'; 6 | 7 | export class StreamMessage { 8 | 9 | @observable 10 | private inputMessage: InputStreamMessage; 11 | 12 | public readonly cache = new ObservableCache(); 13 | 14 | constructor( 15 | inputMessage: InputStreamMessage, 16 | public readonly messageIndex: number, 17 | private readonly subprotocol?: string 18 | ) { 19 | this.inputMessage = inputMessage; 20 | } 21 | 22 | /** 23 | * The direction the message travelled. 24 | * 25 | * Note that this may seem reversed! msg.direction is from the perspective 26 | * of Mockttp, not the client. 27 | * 28 | * I.e. 'received' means the client sent it and the proxy received it. Sent 29 | * means Mockttp sent it to the client (which typically means an upstream 30 | * server sent it to Mockttp, but it depends on the rule setup). 31 | */ 32 | get direction() { 33 | return this.inputMessage.direction; 34 | } 35 | 36 | @computed 37 | get content() { 38 | return asBuffer(this.inputMessage.content); 39 | } 40 | 41 | get isBinary() { 42 | return this.inputMessage.isBinary; 43 | } 44 | 45 | get contentType() { 46 | if (this.inputMessage.isBinary) { 47 | if (this.subprotocol?.includes('proto')) { 48 | return 'protobuf'; 49 | } else { 50 | return 'raw'; 51 | } 52 | } 53 | 54 | // prefix+JSON is very common, so we try to parse anything JSON-ish optimistically: 55 | const startOfMessage = this.content.slice(0, 10).toString('utf-8').trim(); 56 | if ( 57 | startOfMessage.includes('{') || 58 | startOfMessage.includes('[') || 59 | this.subprotocol?.includes('json') 60 | ) return 'json'; 61 | 62 | else return 'text'; 63 | } 64 | 65 | get timestamp() { 66 | return this.inputMessage.eventTimestamp; 67 | } 68 | 69 | cleanup() { 70 | // As with Exchange & WebSocketStream - in some cases, browsers can keep references to 71 | // these messages, which causes issues with releasing memory, so we aggressively drop 72 | // internal references to potentially large data to compensate. 73 | this.inputMessage.content = Buffer.from([]); 74 | this.cache.clear(); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/model/http/api-detector.ts: -------------------------------------------------------------------------------- 1 | import { action, observable, runInAction, when } from 'mobx'; 2 | 3 | import { logError } from '../../errors'; 4 | import { UnreachableCheck } from '../../util/error'; 5 | 6 | import { HttpExchangeView } from '../../types'; 7 | 8 | import { OpenApiExchange } from '../api/openapi'; 9 | import { parseRpcApiExchange } from '../api/jsonrpc'; 10 | import { ApiExchange, ApiMetadata } from '../api/api-interfaces'; 11 | import { ApiStore } from '../api/api-store'; 12 | 13 | export class ApiDetector { 14 | 15 | constructor( 16 | private exchange: HttpExchangeView, 17 | apiStore: ApiStore 18 | ) { 19 | apiStore.getApi(exchange.request) 20 | .then(action((apiMetadata: ApiMetadata | undefined) => { 21 | this.apiMetadata = apiMetadata; 22 | })).catch(console.warn); 23 | } 24 | 25 | @observable.ref 26 | apiMetadata: ApiMetadata | undefined = undefined; 27 | 28 | _parsedApiPromise: Promise | undefined = undefined; 29 | 30 | @observable.ref 31 | _parsedApi: ApiExchange | undefined = undefined; 32 | 33 | /** 34 | * Reading this starts API parsing, if the API data is available. If this is observed when the API metadata 35 | * becomes available, it will trigger parsing as a side-effect. 36 | */ 37 | get parsedApi(): ApiExchange | undefined { 38 | if (!this.apiMetadata) return; 39 | 40 | if (!this._parsedApi && !this._parsedApiPromise) { 41 | this._parsedApiPromise = (async () => { 42 | // We load the spec, but we don't try to parse API requests until we've received 43 | // the whole thing (because e.g. JSON-RPC requests aren't parseable without the body) 44 | await when(() => this.exchange.isCompletedRequest()); 45 | 46 | // API metadata must be set - we check beforehand, and it's never cleared after setting 47 | const apiMetadata = this.apiMetadata!; 48 | const request = this.exchange.request; 49 | 50 | try { 51 | let apiExchange: ApiExchange | undefined; 52 | if (apiMetadata.type === 'openapi') { 53 | apiExchange = new OpenApiExchange(apiMetadata, this.exchange); 54 | } else if (apiMetadata.type === 'openrpc') { 55 | apiExchange = await parseRpcApiExchange(apiMetadata, this.exchange); 56 | } else { 57 | console.log('Unknown API metadata type for host', request.parsedUrl.hostname); 58 | console.log(apiMetadata); 59 | throw new UnreachableCheck(apiMetadata, m => m.type); 60 | } 61 | 62 | runInAction(() => { 63 | this._parsedApi = apiExchange; 64 | }); 65 | 66 | if (!this.exchange.isCompletedExchange()) { 67 | when(() => this.exchange.isCompletedExchange()).then(async () => { 68 | if (this.exchange.response) { 69 | apiExchange!.updateWithResponse(this.exchange.response); 70 | } 71 | }); 72 | } 73 | 74 | return apiExchange; 75 | } catch (e) { 76 | logError(e); 77 | throw e; 78 | } 79 | })(); 80 | } 81 | 82 | return this._parsedApi; 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /src/model/http/methods.ts: -------------------------------------------------------------------------------- 1 | import { Method } from 'mockttp'; 2 | 3 | export type MethodName = keyof typeof Method; 4 | export const MethodNames = Object.values(Method) 5 | .filter( 6 | value => typeof value === 'string' 7 | ) as Array; 8 | -------------------------------------------------------------------------------- /src/model/interception/frida.ts: -------------------------------------------------------------------------------- 1 | export interface FridaHost { 2 | id: string; 3 | name: string; 4 | type: string; 5 | state: 6 | | 'unavailable' 7 | | 'setup-required' 8 | | 'launch-required' 9 | | 'available' 10 | } 11 | 12 | export interface FridaTarget { 13 | id: string; 14 | name: string; 15 | } 16 | 17 | export type FridaActivationOptions = 18 | | { action: 'setup', hostId: string } 19 | | { action: 'launch', hostId: string } 20 | | { action: 'intercept', hostId: string, targetId: string }; -------------------------------------------------------------------------------- /src/model/interception/interceptor-store.ts: -------------------------------------------------------------------------------- 1 | import { observable, runInAction, flow, action } from "mobx"; 2 | 3 | import { lazyObservablePromise } from "../../util/observable"; 4 | 5 | import { ProxyStore } from "../proxy-store"; 6 | import { AccountStore } from "../account/account-store"; 7 | 8 | import { getInterceptors, activateInterceptor } from "../../services/server-api"; 9 | import { serverVersion as serverVersionPromise } from '../../services/service-versions'; 10 | import { Interceptor, getInterceptOptions } from "./interceptors"; 11 | 12 | export class InterceptorStore { 13 | 14 | constructor( 15 | private proxyStore: ProxyStore, 16 | private accountStore: AccountStore 17 | ) { 18 | this.interceptors = getInterceptOptions([], accountStore); 19 | } 20 | 21 | readonly initialized = lazyObservablePromise(async () => { 22 | await Promise.all([ 23 | this.proxyStore.initialized, 24 | this.accountStore.initialized 25 | ]); 26 | 27 | await this.refreshInterceptors(); 28 | 29 | const refreshInterceptorInterval = setInterval(() => 30 | this.refreshInterceptors() 31 | , 10000); 32 | 33 | window.addEventListener('beforeunload', () => { 34 | clearInterval(refreshInterceptorInterval); 35 | }); 36 | 37 | console.log('Interceptor store initialized'); 38 | }); 39 | 40 | @observable interceptors: _.Dictionary; 41 | 42 | async refreshInterceptors() { 43 | const serverInterceptors = await getInterceptors(this.proxyStore.httpProxyPort); 44 | const serverVersion = await serverVersionPromise; 45 | 46 | runInAction(() => { 47 | const supportedInterceptors = getInterceptOptions( 48 | serverInterceptors, 49 | this.accountStore, 50 | serverVersion 51 | ); 52 | 53 | // Quick patch for a bug in existing-chrome for server <= 1.1.2 which incorrectly 54 | // always reports existing Chrome as activable: 55 | if ( 56 | !supportedInterceptors['fresh-chrome'].isActivable && 57 | supportedInterceptors['existing-chrome'].isActivable 58 | ) { 59 | supportedInterceptors['existing-chrome'].isActivable = false; 60 | } 61 | 62 | this.interceptors = supportedInterceptors; 63 | }); 64 | } 65 | 66 | @action.bound 67 | activateInterceptor = (interceptorId: string, options?: any): Promise => { 68 | this.interceptors[interceptorId].inProgress = true; 69 | 70 | return activateInterceptor( 71 | interceptorId, 72 | this.proxyStore.httpProxyPort, 73 | options 74 | ).then( 75 | (metadata) => metadata || true 76 | ).finally(action(() => { 77 | this.interceptors[interceptorId].inProgress = false; 78 | this.refreshInterceptors(); 79 | })); 80 | }; 81 | } -------------------------------------------------------------------------------- /src/model/network.ts: -------------------------------------------------------------------------------- 1 | import * as ipaddr from 'ipaddr.js'; 2 | import { logError } from '../errors'; 3 | 4 | export function isValidPort(port: number): boolean { 5 | return port > 0 && port <= 65535; 6 | } 7 | 8 | export function isValidHost(host: string | undefined): boolean { 9 | return !!host?.match(/^[A-Za-z0-9\-.]+(:\d+)?$/); 10 | } 11 | 12 | export function isValidHostname(hostname: string | undefined): boolean { 13 | return !!hostname?.match(/^[A-Za-z0-9\-.]+$/); 14 | } 15 | 16 | function isIPv6(ip: ipaddr.IPv4 | ipaddr.IPv6): ip is ipaddr.IPv6 { 17 | return ip.kind() === 'ipv6'; 18 | } 19 | 20 | const subnetDescriptionOverrides: _.Dictionary = { 21 | 'unspecified': 'unknown', 22 | 'loopback': 'this machine', 23 | 'private': 'a local network device', 24 | 'uniqueLocal': 'a local network device', 25 | 'unicast': '', 26 | }; 27 | 28 | // Takes an IPv6 or IPv4 address, and makes it presentable 29 | export function getReadableIP(ip: string) { 30 | let parsedIp: ipaddr.IPv4 | ipaddr.IPv6 31 | try { 32 | parsedIp = ipaddr.parse(ip); 33 | } catch (e) { 34 | logError('Failed to parse IP', { ip: ip }); 35 | return ip; 36 | } 37 | 38 | if (isIPv6(parsedIp) && parsedIp.isIPv4MappedAddress()) { 39 | parsedIp = parsedIp.toIPv4Address(); 40 | } 41 | 42 | const subnetType = parsedIp.range(); 43 | const subnetDescription = ( 44 | subnetType in subnetDescriptionOverrides 45 | ? subnetDescriptionOverrides[subnetType] 46 | : subnetType 47 | ).replace(/([A-Z])/g, ' $1') // camelCase to separate Words 48 | .toLowerCase() // Lowercase everything 49 | .replace(/^rfc/, 'see RFC ') // Highlight RFCs; 50 | 51 | return parsedIp.toNormalizedString() + ( 52 | subnetDescription 53 | ? ` (${subnetDescription})` 54 | : '' 55 | ); 56 | } -------------------------------------------------------------------------------- /src/model/observable-cache.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | 3 | /** 4 | * An observable symbol-only cache, for storing calculated data 5 | * that is expensive to compute, and linking it to individual 6 | * messages (meaning messages can also actively clean it up). 7 | */ 8 | export class ObservableCache { 13 | 14 | // Due to the per-message per-exchange overhead of this, we avoid actually 15 | // instantiating each cache until somebody tries to use it. 16 | #lazyInstData: T | undefined = undefined; 17 | 18 | get #data(): T { 19 | if (this.#lazyInstData === undefined) { 20 | this.#lazyInstData = observable.object<{ 21 | [key: symbol]: any 22 | }>({}, {}, { deep: false }) as any as T; 23 | } 24 | return this.#lazyInstData; 25 | } 26 | 27 | get(key: K): T[K] | undefined { 28 | return this.#data[key] as T[K] | undefined; 29 | } 30 | 31 | set(key: K, value: T[K]): void { 32 | this.#data[key] = value; 33 | } 34 | 35 | clear(): void { 36 | for (let key of Object.getOwnPropertySymbols(this.#data)) { 37 | delete this.#data[key]; 38 | } 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /src/model/rules/definitions/ethereum-abi.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import * as _ from 'lodash'; 7 | import { defaultAbiCoder } from '@ethersproject/abi'; 8 | 9 | export const encodeAbi = defaultAbiCoder.encode.bind(defaultAbiCoder); 10 | 11 | export const NATIVE_ETH_TYPES = [ 12 | 'bool', 13 | 'int', 14 | 'uint', 15 | ...(_.flatMap(_.range(8, 257, 8), (bits) => [ 16 | `int${bits}`, 17 | `uint${bits}` 18 | ])), 19 | 'address', 20 | 'string', 21 | 'bytes', 22 | ...(_.range(1, 33).map((n) => `bytes${n}`)) 23 | ]; -------------------------------------------------------------------------------- /src/model/rules/definitions/rtc-rule-definitions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import { 7 | PluggableAdmin 8 | } from 'mockttp'; 9 | import { 10 | MatcherDefinitions, 11 | HandlerStepDefinitions 12 | } from 'mockrtc'; 13 | 14 | export const { Serializable } = PluggableAdmin.Serialization; 15 | 16 | export class RTCWildcardMatcher extends Serializable { 17 | 18 | readonly type = 'rtc-wildcard'; 19 | 20 | explain() { 21 | return 'WebRTC connections'; 22 | } 23 | 24 | } 25 | 26 | // Convenient re-export for various built-in matcher definitions: 27 | export const { 28 | HasDataChannelMatcherDefinition, 29 | HasVideoTrackMatcherDefinition, 30 | HasAudioTrackMatcherDefinition, 31 | HasMediaTrackMatcherDefinition 32 | } = MatcherDefinitions; 33 | export type HasDataChannelMatcherDefinition = MatcherDefinitions.HasDataChannelMatcherDefinition; 34 | export type HasVideoTrackMatcherDefinition = MatcherDefinitions.HasVideoTrackMatcherDefinition; 35 | export type HasAudioTrackMatcherDefinition = MatcherDefinitions.HasAudioTrackMatcherDefinition; 36 | export type HasMediaTrackMatcherDefinition = MatcherDefinitions.HasMediaTrackMatcherDefinition; 37 | 38 | export const RTCMatcherLookup = { 39 | ...MatcherDefinitions.MatcherDefinitionLookup, 40 | 41 | 'rtc-wildcard': RTCWildcardMatcher 42 | }; 43 | 44 | export const RTCInitialMatcherClasses = [ 45 | RTCWildcardMatcher 46 | ]; 47 | 48 | // Convenient re-export for various built-in step definitions: 49 | export const { 50 | DynamicProxyStepDefinition, 51 | EchoStepDefinition, 52 | CloseStepDefinition, 53 | WaitForMediaStepDefinition, 54 | WaitForDurationStepDefinition, 55 | WaitForChannelStepDefinition, 56 | WaitForMessageStepDefinition, 57 | CreateChannelStepDefinition, 58 | SendStepDefinition 59 | } = HandlerStepDefinitions; 60 | export type DynamicProxyStepDefinition = HandlerStepDefinitions.DynamicProxyStepDefinition; 61 | export type EchoStepDefinition = HandlerStepDefinitions.EchoStepDefinition; 62 | export type CloseStepDefinition = HandlerStepDefinitions.CloseStepDefinition; 63 | export type WaitForMediaStepDefinition = HandlerStepDefinitions.WaitForMediaStepDefinition; 64 | export type WaitForDurationStepDefinition = HandlerStepDefinitions.WaitForDurationStepDefinition; 65 | export type WaitForChannelStepDefinition = HandlerStepDefinitions.WaitForChannelStepDefinition; 66 | export type WaitForMessageStepDefinition = HandlerStepDefinitions.WaitForMessageStepDefinition; 67 | export type CreateChannelStepDefinition = HandlerStepDefinitions.CreateChannelStepDefinition; 68 | export type SendStepDefinition = HandlerStepDefinitions.SendStepDefinition; 69 | 70 | export const RTCStepLookup = { 71 | ...HandlerStepDefinitions.StepDefinitionLookup 72 | }; 73 | 74 | type RTCMatcherClass = typeof RTCMatcherLookup[keyof typeof RTCMatcherLookup]; 75 | export type RTCMatcher = InstanceType; 76 | export type RTCInitialMatcher = InstanceType; 77 | 78 | export type RTCStepClass = typeof RTCStepLookup[keyof typeof RTCStepLookup]; 79 | export type RTCStep = InstanceType; 80 | 81 | export interface RTCRule { 82 | id: string; 83 | type: 'webrtc'; 84 | activated: boolean; 85 | matchers: Array & { 0?: RTCInitialMatcher }; 86 | steps: RTCStep[]; 87 | }; -------------------------------------------------------------------------------- /src/model/rules/rule-migrations.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as dedent from 'dedent'; 3 | 4 | import { isRuleGroup } from './rules-structure'; 5 | import { buildDefaultGroupWrapper } from './rule-creation'; 6 | 7 | // Take some raw serialized rule data, exported from any version of the app since HTTP Mock was 8 | // launched, and convert it into raw modern rule data, ready to be deserialized. 9 | export function migrateRuleData(data: any) { 10 | if (!data) return data; 11 | 12 | // Right now all rule data is unversioned, but with this check we can safely 13 | // start versioning as soon as it's necessary 14 | if (data.version === undefined) { 15 | if (data.rules) { 16 | data.id = 'root'; 17 | data.title = "HTTP Toolkit Rules"; 18 | data.isRoot = true; 19 | 20 | const [defaultRules, otherRules] = _.partition(data.rules, (r) => r.id.startsWith('default-')); 21 | 22 | if (defaultRules.length) { 23 | data.items = [ 24 | ...otherRules, 25 | buildDefaultGroupWrapper(defaultRules) 26 | ]; 27 | } else { 28 | data.items = otherRules; 29 | } 30 | delete data.rules; 31 | } 32 | 33 | data.items = data.items.map(migrateRuleItem); 34 | } else { 35 | throw new Error(dedent` 36 | Could not migrate rules from unknown format (${data.version}). 37 | Please restart HTTP Toolkit to update. 38 | `); 39 | } 40 | 41 | return data; 42 | } 43 | 44 | function migrateRuleItem(item: any) { 45 | if (isRuleGroup(item)) { 46 | item.items = item.items.map(migrateRuleItem); 47 | } else { 48 | item = migrateRule(item); 49 | } 50 | 51 | return item; 52 | } 53 | 54 | function migrateRule(rule: any) { 55 | // Migrate rules from the HTTP-only days into a world with rules for other protocols: 56 | if (rule.type === undefined) rule.type = 'http'; 57 | 58 | const { handler } = rule; 59 | 60 | if (handler?.type === 'passthrough') { 61 | // Handle the targetHost -> forwarding object change from Mockttp 0.18.1: 62 | if (handler.forwardToLocation && !handler.forwarding) { 63 | handler.forwarding = { targetHost: handler.forwardToLocation, updateHostHeader: true }; 64 | } 65 | } 66 | 67 | return rule; 68 | } -------------------------------------------------------------------------------- /src/model/send/send-response-model.ts: -------------------------------------------------------------------------------- 1 | import { RawHeaders, RawTrailers } from "../../types"; 2 | 3 | export type ResponseStreamEvent = 4 | | RequestStartEvent 5 | | ResponseHeadEvent 6 | | ResponseBodyPartEvent 7 | | ResponseTrailersEvent 8 | | ResponseEndEvent 9 | | ErrorEvent; 10 | 11 | interface RequestStartEvent { 12 | type: 'request-start'; 13 | startTime: number; 14 | timestamp: number; 15 | } 16 | 17 | export interface ResponseHeadEvent { 18 | type: 'response-head'; 19 | statusCode: number; 20 | statusMessage?: string; 21 | headers: RawHeaders; 22 | timestamp: number; 23 | } 24 | 25 | interface ResponseBodyPartEvent { 26 | type: 'response-body-part'; 27 | rawBody: Buffer; 28 | timestamp: number; 29 | } 30 | 31 | interface ResponseTrailersEvent { 32 | type: 'response-trailers'; 33 | trailers: RawTrailers; 34 | timestamp: number; 35 | } 36 | 37 | interface ResponseEndEvent { 38 | type: 'response-end'; 39 | timestamp: number; 40 | } 41 | 42 | interface ErrorEvent { 43 | type: 'error'; 44 | timestamp: number; 45 | error: { 46 | code?: string, 47 | message?: string, 48 | stack?: string 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/model/serialization.ts: -------------------------------------------------------------------------------- 1 | import * as serializr from 'serializr'; 2 | import { recursiveMapValues } from '../util'; 3 | import { asBuffer } from '../util/buffer'; 4 | 5 | export const serializeAsTag = (getTag: (value: any) => any) => 6 | serializr.custom( 7 | getTag, 8 | () => serializr.SKIP 9 | ); 10 | 11 | export const serializeRegex = serializr.custom( 12 | (value: RegExp) => ({ source: value.source, flags: value.flags }), 13 | (value: { source: string, flags: string }) => new RegExp(value.source, value.flags) 14 | ); 15 | 16 | export const serializeBuffer = serializr.custom( 17 | (buffer: string | Buffer | Uint8Array | undefined): string | undefined => buffer !== undefined 18 | ? asBuffer(buffer).toString('base64') 19 | : undefined, 20 | (data: string | undefined): Buffer | undefined => data !== undefined 21 | ? Buffer.from(data, 'base64') 22 | : undefined 23 | ); 24 | 25 | const undefinedPlaceholder = "__http_toolkit_undefined_placeholder__"; 26 | export const serializeWithUndefineds = serializr.custom( 27 | (value: {}): string | undefined => value 28 | ? JSON.stringify(value, (k, v) => 29 | v === undefined ? undefinedPlaceholder : v 30 | ) 31 | : undefined, 32 | (data: string): unknown => !!data 33 | ? recursiveMapValues( 34 | JSON.parse(data), 35 | (v) => v === undefinedPlaceholder ? undefined : v // Can't do this in parse - it drops undef values 36 | ) 37 | : undefined 38 | ); 39 | 40 | // Bit of a hack to let us call propSchema.deserializer easily in sync code, 41 | // without having to fight to collate values from callbacks. Only works for 42 | // propSchemas that call the callback synchronously. 43 | function syncDeserialize( 44 | propSchema: serializr.PropSchema, 45 | value: any, 46 | context: any 47 | ) { 48 | let result: any; 49 | let error: any; 50 | 51 | propSchema.deserializer(value, (err, data) => { 52 | if (err) error = err; 53 | else result = data; 54 | }, context, undefined); 55 | 56 | // Requires that the callback was already called! 57 | if (error) { 58 | throw error; 59 | } else { 60 | return result; 61 | } 62 | } 63 | 64 | export const serializeMap = (keySchema: serializr.PropSchema, valueSchema: serializr.PropSchema) => 65 | serializr.custom( 66 | (map: Map) => Array.from(map.entries()).map((entry) => 67 | [ 68 | keySchema.serializer(entry[0]), 69 | valueSchema.serializer(entry[1]) 70 | ] 71 | ), 72 | ( 73 | mapAsArray: any[], 74 | context: any, 75 | _oldValue: any, 76 | callback: (err: any, result: any) => void 77 | ) => callback(null, 78 | new Map( 79 | mapAsArray.map((entry) => [ 80 | syncDeserialize(keySchema, entry[0], context), 81 | syncDeserialize(valueSchema, entry[1], context), 82 | ] 83 | ) 84 | )) 85 | ) 86 | 87 | export const hasSerializrSchema = (obj: any) => !!serializr.getDefaultModelSchema(obj); 88 | 89 | export const rawSchema = serializr.createSimpleSchema({ "*": true }); -------------------------------------------------------------------------------- /src/model/tls/failed-tls-connection.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid/v4'; 2 | 3 | import { InputTlsFailure } from '../../types'; 4 | import { HTKEventBase } from '../events/event-base'; 5 | 6 | export class FailedTlsConnection extends HTKEventBase { 7 | 8 | constructor( 9 | private failureEvent: InputTlsFailure 10 | ) { 11 | super(); 12 | 13 | this.searchIndex = [failureEvent.hostname, failureEvent.remoteIpAddress] 14 | .filter((x): x is string => !!x) 15 | .join('\n'); 16 | } 17 | 18 | readonly id = uuid(); 19 | 20 | readonly upstreamHostname = this.failureEvent.hostname; 21 | readonly remoteIpAddress = this.failureEvent.remoteIpAddress; 22 | readonly remotePort = this.failureEvent.remotePort; 23 | readonly failureCause = this.failureEvent.failureCause; 24 | readonly tags = this.failureEvent.tags; 25 | readonly timingEvents = this.failureEvent.timingEvents; 26 | readonly tlsMetadata = this.failureEvent.tlsMetadata; 27 | 28 | isTlsFailure(): this is FailedTlsConnection { 29 | return true; 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/model/tls/tls-tunnel.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | 3 | import { InputTlsPassthrough } from '../../types'; 4 | import { HTKEventBase } from '../events/event-base'; 5 | 6 | export class TlsTunnel extends HTKEventBase { 7 | 8 | constructor( 9 | private openEvent: InputTlsPassthrough 10 | ) { 11 | super(); 12 | 13 | this.searchIndex = [openEvent.hostname, openEvent.remoteIpAddress] 14 | .filter((x): x is string => !!x) 15 | .join('\n'); 16 | } 17 | 18 | readonly id = this.openEvent.id; 19 | 20 | readonly remoteIpAddress = this.openEvent.remoteIpAddress; 21 | readonly remotePort = this.openEvent.remotePort; 22 | 23 | readonly upstreamHostname = this.openEvent.hostname; 24 | readonly upstreamPort = this.openEvent.upstreamPort; 25 | 26 | readonly tags = this.openEvent.tags; 27 | readonly timingEvents = this.openEvent.timingEvents; 28 | 29 | isTlsTunnel(): this is TlsTunnel { 30 | return true; 31 | } 32 | 33 | @observable 34 | private open = true; 35 | 36 | markClosed(closeEvent: InputTlsPassthrough) { 37 | this.timingEvents.disconnectTimestamp = closeEvent.timingEvents.disconnectTimestamp; 38 | this.open = false; 39 | } 40 | 41 | isOpen() { 42 | return this.open; 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/model/ui/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { NativeContextMenuItem } from "../../services/desktop-api"; 2 | import { UnreachableCheck } from "../../util/error"; 3 | 4 | export interface ContextMenuState { 5 | data: T; 6 | event: React.MouseEvent; 7 | items: readonly ContextMenuItem[]; 8 | } 9 | 10 | export type ContextMenuItem = 11 | | ContextMenuOption 12 | | ContextMenuSubmenu 13 | | { type: 'separator' }; 14 | 15 | export interface ContextMenuOption { 16 | type: 'option'; 17 | label: string; 18 | enabled?: boolean; 19 | callback: (data: T) => void; 20 | } 21 | 22 | interface ContextMenuSubmenu { 23 | type: 'submenu'; 24 | label: string; 25 | enabled?: boolean; 26 | items: readonly ContextMenuItem[]; 27 | } 28 | 29 | export function buildNativeContextMenuItems( 30 | items: readonly ContextMenuItem[], 31 | path: Array = [] 32 | ): NativeContextMenuItem[] { 33 | return items.map((item, i) => { 34 | if (item.type === 'separator') return item; 35 | else if (item.type === 'submenu') return { 36 | ...item, 37 | items: buildNativeContextMenuItems(item.items, path.concat(`${i}.items`)) 38 | }; 39 | else if (item.type === 'option') return { 40 | ...item, 41 | callback: undefined, // Causes errors, as it can't be cloned 42 | id: path.concat(i).join('.') // Id is the path to the item (as an _.get key like "0.items.5") 43 | }; 44 | else throw new UnreachableCheck(item, i => i.type); 45 | }); 46 | } -------------------------------------------------------------------------------- /src/model/ui/markdown.ts: -------------------------------------------------------------------------------- 1 | import * as Remarkable from 'remarkable'; 2 | import * as DOMPurify from 'dompurify'; 3 | 4 | import { Html } from '../../types'; 5 | 6 | const linkedMarkdown = new Remarkable({ 7 | html: true, 8 | linkify: true, 9 | linkTarget: '_blank' // Links should always open elsewhere 10 | }); 11 | 12 | const linklessMarkdown = new Remarkable({ 13 | html: true, 14 | linkify: false 15 | }); 16 | 17 | // Add an extra hook to DOMPurify to enforce link target. Without this, DOMPurify strips 18 | // every link target entirely. 19 | DOMPurify.addHook('afterSanitizeAttributes', function (node: Element | HTMLElement) { 20 | // Closely based on example from https://github.com/cure53/DOMPurify/tree/main/demos#hook-to-open-all-links-in-a-new-window-link 21 | 22 | // Set all elements owning target to target=_blank 23 | if (node.hasAttribute('target') || 'target' in node) { 24 | node.setAttribute('target', '_blank'); 25 | node.setAttribute('rel', 'noreferrer'); // Disables both referrer & opener 26 | } 27 | 28 | // set non-HTML/MathML links to xlink:show=new 29 | if ( 30 | !node.hasAttribute('target') && 31 | (node.hasAttribute('xlink:href') || node.hasAttribute('href')) 32 | ) { 33 | node.setAttribute('xlink:show', 'new'); 34 | } 35 | }); 36 | 37 | // Add an extra hook to strip relative URLs (markdown largely comes from external sources, 38 | // and so should never include relative paths!) 39 | DOMPurify.addHook('afterSanitizeAttributes', function (node: Element | HTMLElement) { 40 | if (node.hasAttribute('href')) { 41 | const target = node.getAttribute('href'); 42 | if (target?.startsWith('/')) node.removeAttribute('href'); 43 | } 44 | }); 45 | 46 | export interface MarkdownRenderingOptions { 47 | linkify?: boolean // False by default 48 | } 49 | 50 | export function fromMarkdown(input: string, options?: MarkdownRenderingOptions): Html; 51 | export function fromMarkdown(input: string | undefined, options?: MarkdownRenderingOptions): Html | undefined; 52 | export function fromMarkdown(input: string | undefined, options?: MarkdownRenderingOptions): Html | undefined { 53 | if (!input) return undefined; 54 | else { 55 | const md = options?.linkify ? linkedMarkdown : linklessMarkdown; 56 | const unsafeMarkdown = md.render(input).replace(/\n$/, ''); 57 | const safeHtml = DOMPurify.sanitize(unsafeMarkdown); 58 | return { __html: safeHtml }; 59 | } 60 | } 61 | 62 | /** 63 | * Takes an input string, and turns it into a value that will appear the same when 64 | * rendered in markdown (and will not render any active HTML, e.g. links). This 65 | * goes further than DOMPurify above by disabling _all_ non-plain text content. 66 | * 67 | * Important notes: 68 | * - This escapes input for use as content, and doesn't cover cases like 69 | * escaping for HTML attribute values or similar. 70 | * - This cannot fully escape _closing_ backticks - it's impossible to do this in 71 | * markdown, as even \\\` will close a previous \` block. Use <code> instead. 72 | * - If linkify: true is used in later rendering, recognized URLs will still autolink. 73 | */ 74 | export function escapeForMarkdownEmbedding(input: string) { 75 | const htmlEscaped = input 76 | .replace(/&/g, '&') 77 | .replace(//g, '>'); 79 | const markdownEscaped = htmlEscaped.replace(/([\\`*_{}\[\]()#+\-.!~|])/g, '\\$1'); 80 | return markdownEscaped; 81 | } -------------------------------------------------------------------------------- /src/model/webrtc/rtc-data-channel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | import { action, observable } from 'mobx'; 7 | 8 | import { 9 | InputRTCDataChannelOpened, 10 | InputRTCMessage, 11 | InputRTCDataChannelClosed 12 | } from '../../types'; 13 | import { HTKEventBase } from '../events/event-base'; 14 | import { StreamMessage } from '../events/stream-message'; 15 | 16 | import { RTCConnection } from './rtc-connection'; 17 | 18 | export class RTCDataChannel extends HTKEventBase { 19 | 20 | constructor( 21 | private openEvent: InputRTCDataChannelOpened, 22 | private connection: RTCConnection 23 | ) { 24 | super(); 25 | } 26 | 27 | readonly id = this.sessionId + ':data:' + this.channelId; 28 | 29 | isRTCDataChannel(): this is RTCDataChannel { 30 | return true; 31 | } 32 | 33 | get rtcConnection() { 34 | return this.connection; 35 | } 36 | 37 | get sessionId() { 38 | return this.rtcConnection.id; 39 | } 40 | 41 | get channelId() { 42 | return this.openEvent.channelId; 43 | } 44 | 45 | get label() { 46 | return this.openEvent.channelLabel; 47 | } 48 | 49 | get protocol() { 50 | return this.openEvent.channelProtocol; 51 | } 52 | 53 | @observable 54 | readonly messages: Array = []; 55 | 56 | @action 57 | addMessage(message: InputRTCMessage) { 58 | this.messages.push(new StreamMessage(message, this.messages.length)); 59 | } 60 | 61 | @observable 62 | private closeData: InputRTCDataChannelClosed | undefined; 63 | 64 | @action 65 | markClosed(closeData: InputRTCDataChannelClosed) { 66 | this.closeData = closeData; 67 | } 68 | 69 | get closeState() { 70 | return this.closeData; 71 | } 72 | 73 | cleanup() { 74 | this.messages.forEach(msg => msg.cleanup()); 75 | this.messages.length = 0; 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/model/websockets/upstream-websocket.ts: -------------------------------------------------------------------------------- 1 | import { ApiStore } from "../api/api-store"; 2 | import { StreamMessage } from "../events/stream-message"; 3 | 4 | import { UpstreamHttpExchange } from "../http/upstream-exchange"; 5 | import { WebSocketStream } from "./websocket-stream"; 6 | import { WebSocketView } from "./websocket-views"; 7 | 8 | /** 9 | * This represents the upstream side of a proxied WebSocket connection. In the websocket 10 | * case, at the time of writing, the only modifications made during proxying are redirection 11 | * so this really just proxies through to the downstream side except for the initial 12 | * upstream connection parameters. 13 | * 14 | * By and large this is a minimal PoC & structure for future development - there's very little 15 | * real usage of this at the moment until we have more WebSocket transformations available. 16 | */ 17 | export class UpstreamWebSocket extends UpstreamHttpExchange implements WebSocketView { 18 | 19 | constructor(downstream: WebSocketStream, apiStore: ApiStore) { 20 | super(downstream, apiStore); 21 | } 22 | 23 | isWebSocket() { 24 | return true; 25 | } 26 | 27 | declare public readonly downstream: WebSocketStream; 28 | 29 | get upstream(): UpstreamWebSocket { 30 | return this; 31 | } 32 | 33 | get original(): WebSocketView { 34 | return this.downstream.original; 35 | } 36 | 37 | get transformed(): WebSocketView { 38 | return this.downstream.transformed; 39 | } 40 | 41 | get wasAccepted() { return this.downstream.wasAccepted; } 42 | get selectedSubprotocol() { return this.downstream.selectedSubprotocol; } 43 | get messages(): readonly StreamMessage[] { return this.downstream.messages; } 44 | get closeState() { return this.downstream.closeState; } 45 | 46 | } -------------------------------------------------------------------------------- /src/model/websockets/websocket-views.ts: -------------------------------------------------------------------------------- 1 | import { InputWebSocketClose, WebSocketStream } from '../../types'; 2 | import { ApiStore } from '../api/api-store'; 3 | 4 | import { StreamMessage } from '../events/stream-message'; 5 | import { 6 | HttpExchangeView, 7 | HttpExchangeOriginalView, 8 | HttpExchangeTransformedView 9 | } from '../http/http-exchange-views'; 10 | import { UpstreamWebSocket } from './upstream-websocket'; 11 | 12 | export interface WebSocketView extends HttpExchangeView { 13 | 14 | get downstream(): WebSocketStream; 15 | get upstream(): UpstreamWebSocket | undefined; 16 | 17 | get original(): WebSocketView; 18 | get transformed(): WebSocketView; 19 | 20 | get wasAccepted(): boolean; 21 | get selectedSubprotocol(): string | undefined; 22 | get messages(): ReadonlyArray; 23 | get closeState(): InputWebSocketClose | 'aborted' | undefined; 24 | 25 | } 26 | 27 | export class WebSocketOriginalView extends HttpExchangeOriginalView implements WebSocketView { 28 | 29 | constructor(downstreamWebSocket: WebSocketStream, apiStore: ApiStore) { 30 | super(downstreamWebSocket, apiStore); 31 | } 32 | 33 | declare public readonly downstream: WebSocketStream; 34 | 35 | isWebSocket() { 36 | return true; 37 | } 38 | 39 | get upstream(): UpstreamWebSocket | undefined { 40 | return this.downstream.upstream; 41 | } 42 | 43 | get original(): WebSocketView { return this; } 44 | get transformed(): WebSocketView { return this.downstream.transformed; } 45 | 46 | get wasAccepted() { return this.downstream.wasAccepted; } 47 | get selectedSubprotocol() { return this.downstream.selectedSubprotocol; } 48 | get messages() { return this.downstream.messages; } 49 | get closeState() { return this.downstream.closeState; } 50 | 51 | } 52 | 53 | export class WebSocketTransformedView extends HttpExchangeTransformedView implements WebSocketView { 54 | 55 | constructor(exchange: WebSocketStream, apiStore: ApiStore) { 56 | super(exchange, apiStore); 57 | } 58 | 59 | declare public readonly downstream: WebSocketStream; 60 | 61 | isWebSocket() { 62 | return true; 63 | } 64 | 65 | get upstream(): UpstreamWebSocket | undefined { 66 | return this.downstream.upstream; 67 | } 68 | 69 | get original(): WebSocketView { return this.downstream.original; } 70 | get transformed(): WebSocketView { return this; } 71 | 72 | get wasAccepted() { return this.downstream.wasAccepted; } 73 | get selectedSubprotocol() { return this.downstream.selectedSubprotocol; } 74 | get messages() { return this.downstream.messages; } 75 | get closeState() { return this.downstream.closeState; } 76 | 77 | } -------------------------------------------------------------------------------- /src/routing.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as querystring from 'querystring'; 3 | import { createHistory, WindowLocation, NavigateOptions } from "@reach/router"; 4 | 5 | // Whatever params we're given at the initial load, we want to save & preserve 6 | // them, so they persist across all future navigations. 7 | const INITIAL_PARAMS = querystring.parse( 8 | window.location.search.replace(/^\?/, '') 9 | ); 10 | 11 | // Builds a history source backed by the real browser history API, but throttling 12 | // updates to that API, and covering that up by also tracking the current location 13 | // in memory on top. 14 | const buildThrottledHistorySource = () => { 15 | let latestState = window.history.state; 16 | let latestLocation = window.location; 17 | 18 | window.addEventListener('popstate', () => { 19 | latestState = window.history.state; 20 | latestLocation = window.location; 21 | }); 22 | 23 | // Throttle the state update calls - this is important because Chrome will complain & 24 | // rate limit calls if we go too fast, and we can at times (when scrolling events). 25 | const throttledPushState = _.throttle( 26 | (...args: any) => window.history.pushState.apply(window.history, args), 27 | 250, 28 | { leading: true, trailing: true } 29 | ); 30 | const throttledReplaceState = _.throttle( 31 | (...args: any) => window.history.replaceState.apply(window.history, args), 32 | 250, 33 | { leading: true, trailing: true } 34 | ); 35 | 36 | return { 37 | get location() { 38 | return latestLocation as WindowLocation; 39 | }, 40 | addEventListener: window.addEventListener.bind(window), 41 | removeEventListener: window.removeEventListener.bind(window), 42 | history: { 43 | get state() { 44 | return latestState; 45 | }, 46 | pushState(state: any, title: string, uri: string) { 47 | throttledPushState(state, title, uri); 48 | let [pathname, search = ""] = uri.split("?"); 49 | latestLocation = Object.assign({}, window.location, { pathname, search }); 50 | }, 51 | replaceState(state: any, title: string, uri: string) { 52 | throttledReplaceState(state, title, uri); 53 | let [pathname, search = ""] = uri.split("?"); 54 | latestLocation = Object.assign({}, window.location, { pathname, search }); 55 | } 56 | } 57 | }; 58 | }; 59 | 60 | // Throttlesafe: even with Chrome's throttling us, it'll still work nicely. 61 | export const appHistory = createHistory(buildThrottledHistorySource()); 62 | 63 | // Wrap navigate(), to always preserve our query params: 64 | const navigate = appHistory.navigate.bind(appHistory); 65 | appHistory.navigate = function (to: string, options: NavigateOptions<{}> = {}) { 66 | const [pathString, searchString] = to.split("?"); 67 | const params = querystring.parse(searchString); 68 | 69 | return navigate(pathString + "?" + querystring.stringify({ 70 | ...params, 71 | ...INITIAL_PARAMS 72 | }), options); 73 | }; -------------------------------------------------------------------------------- /src/services/desktop-api.ts: -------------------------------------------------------------------------------- 1 | type DesktopInjectedKey = 2 | | 'httpToolkitDesktopVersion' 3 | | 'httpToolkitForwardingDefault'; 4 | 5 | export async function getDesktopInjectedValue(key: DesktopInjectedKey): Promise { 6 | // In the SW, it's tricky to check the desktop version, as we don't get it injected. 7 | // For now, just treat it as a different environment 8 | if (typeof window === 'undefined') return 'service-worker'; 9 | 10 | if (key in window) { 11 | // If it's already been set, just return it 12 | return window[key as keyof Window]; 13 | } else { 14 | return new Promise((resolve) => { 15 | // If not, it might still be coming (there's race here), so listen out 16 | window.addEventListener('message', (message) => { 17 | if (message.data[key]) resolve(message.data[key]); 18 | }); 19 | }); 20 | } 21 | // Note that if we're running in a browser, not the desktop shell, this _never_ resolves. 22 | } 23 | 24 | declare global { 25 | interface Window { 26 | desktopApi?: DesktopApi; 27 | } 28 | } 29 | 30 | interface DesktopApi { 31 | waitUntilDesktopApiReady?: () => Promise; 32 | 33 | getDesktopVersion?: () => string | undefined; 34 | getServerAuthToken?: () => string | undefined; 35 | getDeviceInfo?: () => { 36 | platform?: string; 37 | release?: string; 38 | runtimeArch?: string; 39 | realArch?: string; 40 | } | undefined; 41 | 42 | selectApplication?: () => Promise; 43 | selectFilePath?: () => Promise; 44 | 45 | openContextMenu?: (options: NativeContextMenuDefinition) => Promise; 46 | restartApp?: () => Promise; 47 | } 48 | 49 | interface NativeContextMenuDefinition { 50 | position: { x: number; y: number }; 51 | items: readonly NativeContextMenuItem[]; 52 | } 53 | 54 | export type NativeContextMenuItem = 55 | | NativeContextMenuOption 56 | | NativeContextMenuSubmenu 57 | | { type: 'separator' }; 58 | 59 | interface NativeContextMenuOption { 60 | type: 'option'; 61 | id: string; 62 | label: string; 63 | enabled?: boolean; 64 | } 65 | 66 | interface NativeContextMenuSubmenu { 67 | type: 'submenu'; 68 | label: string; 69 | enabled?: boolean; 70 | items: readonly NativeContextMenuItem[]; 71 | } 72 | 73 | // Quick fix to avoid this file crashing the update SW which doesn't have 'window' available, without 74 | // also breaking old Electron that doesn't have globalThis: 75 | const global = typeof globalThis !== 'undefined' 76 | ? globalThis as unknown as Window 77 | : typeof window !== 'undefined' 78 | ? window 79 | : {} as Window; 80 | 81 | export const DesktopApi: DesktopApi = global.desktopApi ?? {}; -------------------------------------------------------------------------------- /src/services/server-api-types.ts: -------------------------------------------------------------------------------- 1 | import { NetworkInterfaceInfo } from 'os'; 2 | import { ProxySetting } from 'mockttp'; 3 | import { ErrorLike } from '../util/error'; 4 | 5 | export interface ServerInterceptor { 6 | id: string; 7 | version: string; 8 | isActivable: boolean; 9 | isActive: boolean; 10 | metadata?: any; 11 | } 12 | 13 | export interface NetworkInterfaces { 14 | [index: string]: NetworkInterfaceInfo[]; 15 | } 16 | 17 | export interface ServerConfig { 18 | certificatePath: string; 19 | certificateContent?: string; 20 | certificateFingerprint?: string; 21 | networkInterfaces: NetworkInterfaces; 22 | systemProxy: ProxySetting | undefined; 23 | dnsServers: string[]; 24 | ruleParameterKeys: string[]; 25 | } 26 | 27 | export class ApiError extends Error { 28 | 29 | constructor( 30 | message: string, 31 | readonly operationName: string, 32 | readonly errorCode?: string | number, 33 | public apiError?: { 34 | message?: string, 35 | code?: string 36 | } 37 | ) { 38 | super(`API error during ${operationName}: ${message}`); 39 | if (apiError) { 40 | this.cause = new Error(apiError?.message ?? '[Unknown API error]'); 41 | this.cause.code = apiError?.code ?? 'unknown'; 42 | this.cause.stack = '(From server API)'; 43 | } 44 | } 45 | 46 | private cause?: ErrorLike; 47 | 48 | } 49 | 50 | export class ActivationFailure extends Error { 51 | constructor( 52 | readonly interceptorId: string, 53 | readonly failureMessage: string, 54 | readonly errorCode?: string, 55 | readonly cause?: ErrorLike 56 | ) { 57 | super(`Failed to activate interceptor ${interceptorId}: ${failureMessage}`); 58 | } 59 | } 60 | 61 | export class ActivationNonSuccess extends Error { 62 | constructor( 63 | readonly interceptorId: string, 64 | readonly metadata: unknown 65 | ) { 66 | super(`Interceptor ${interceptorId} activation ran unsuccessfully`); 67 | } 68 | } -------------------------------------------------------------------------------- /src/util/colors.ts: -------------------------------------------------------------------------------- 1 | import * as polished from 'polished'; 2 | 3 | /** 4 | * Pairs with getBackgroundColor to generate a pair of colors with reasonable contrast, 5 | * inspired by a single base color, but aiming to broadly match a given main color & 6 | * background color. 7 | */ 8 | export function getTextColor(baseColor: string, mainColor: string, contrastRatio: number) { 9 | // Calculate a color difference from 0 (black/white) - 1 (identical) 10 | const baseSimilarity = (polished.getContrast(baseColor, mainColor) - 1) / 20; 11 | 12 | // Mix the colors, using between 35% & 100% of the base color, depending on how far 13 | // off we are and how much contrast we're aiming for (i.e. more maincolor for HC theme) 14 | const contrastBias = 1 - contrastRatio; 15 | const baseWeighting = 0.35 + (baseSimilarity * contrastBias) * 0.65; 16 | // More similar => less mixing required => more base color weighting 17 | 18 | return polished.mix(baseWeighting, baseColor, mainColor); 19 | } 20 | 21 | /** 22 | * Pairs with getTextColor to generate a pair of colors with reasonable contrast, 23 | * inspired by a single base color, but aiming to broadly match a given main color & 24 | * background color. 25 | */ 26 | export function getBackgroundColor(baseColor: string, mainBackgroundColor: string) { 27 | return polished.mix(0.3, baseColor, mainBackgroundColor); 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/util/error.ts: -------------------------------------------------------------------------------- 1 | export type ErrorLike = Partial & { 2 | // Various properties we might want to look for on errors: 3 | code?: string; 4 | cmd?: string; 5 | signal?: string; 6 | statusCode?: number; 7 | statusMessage?: string; 8 | }; 9 | 10 | // Useful to easily cast and then examine errors that are otherwise 'unknown': 11 | export function isErrorLike(error: any): error is ErrorLike { 12 | return typeof error === 'object' && ( 13 | error instanceof Error || 14 | error.message || 15 | error.code || 16 | error.stack 17 | ) 18 | } 19 | 20 | export function asError(error: any): Error { 21 | if (isErrorLike(error)) return error as Error; 22 | else { 23 | return new Error(error.message || error.toString()); 24 | } 25 | } 26 | 27 | export class UnreachableCheck extends Error { 28 | 29 | // getValue is used to allow logging properties (e.g. v.type) on expected-unreachable 30 | // values, instead of just logging [object Object]. 31 | constructor(value: never, getValue: (v: any) => any = (x => x)) { 32 | super(`Unhandled case value: ${getValue(value)}`); 33 | } 34 | 35 | } 36 | 37 | // Sometimes useful when you need an expression (when you can't use a 'throws' statement): 38 | export const unreachableCheck = (value: never, getValue: (v: any) => any = (x => x)): never => { 39 | throw new UnreachableCheck(value, getValue); 40 | } 41 | 42 | // For cases where we want to type-safe check for unreachability, but not actually break (e.g. 43 | // APIs that might return new values in future). 44 | export const unreachableWarning = (value: never, getValue: (v: any) => any = (x => x)) => { 45 | console.warn(`Unhandled case value: ${getValue(value)}`); 46 | } -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | // Before imports to avoid circular import with server-api 4 | export const RUNNING_IN_WORKER = typeof window === 'undefined'; 5 | 6 | export type Empty = _.Dictionary; 7 | export function empty(): Empty { 8 | return {}; 9 | } 10 | 11 | type Case = [() => boolean, R | undefined]; 12 | 13 | export function firstMatch(...tests: Array | R | undefined>): R | undefined { 14 | for (let test of tests) { 15 | if (_.isArray(test) && _.isFunction(test[0])) { 16 | const [matcher, result] = test; 17 | if (matcher() && result) return result; 18 | } else { 19 | if (test) return test; 20 | } 21 | } 22 | } 23 | 24 | export function typeCheck(types: readonly T[]) { 25 | return (type: string): type is T => types.includes(type as T); 26 | } 27 | 28 | export function longestPrefix(baseString: string, ...strings: string[]) { 29 | let prefix = ""; 30 | const shortestLength = Math.min( 31 | baseString.length, 32 | ...strings.map(s => s.length) 33 | ); 34 | 35 | for (let i = 0; i < shortestLength; i++) { 36 | const char = baseString[i]; 37 | if (!strings.every(s => s[i] === char)) break; 38 | prefix += char; 39 | } 40 | 41 | return prefix; 42 | } 43 | 44 | export function tryParseJson(input: string): object | undefined { 45 | try { 46 | return JSON.parse(input); 47 | } catch (e) { 48 | return undefined; 49 | } 50 | } 51 | 52 | export function recursiveMapValues( 53 | input: unknown, 54 | fn: (value: unknown, key?: string) => unknown, 55 | key: string | undefined = undefined 56 | ): unknown { 57 | if (_.isArray(input)) { 58 | return input.map((innerObj) => recursiveMapValues(innerObj, fn)); 59 | } else if (_.isPlainObject(input)) { 60 | return _.mapValues(input as {}, (val, key) => recursiveMapValues(val, fn, key)); 61 | } else { 62 | return fn(input, key); 63 | } 64 | } -------------------------------------------------------------------------------- /src/util/json-schema.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as traverse from 'traverse'; 3 | import * as Ajv from 'ajv'; 4 | 5 | import { joinAnd, truncate } from './text'; 6 | 7 | type Ref = { $ref: string }; 8 | 9 | function isRef(node: any): node is Ref { 10 | return typeof node === 'object' && 11 | node !== null && 12 | // $ref can be { type: ... } if it's a real $ref-named field, as in the github API 13 | typeof node['$ref'] === 'string'; 14 | } 15 | 16 | function derefRef(root: any, node: Ref) { 17 | const ref = node.$ref; 18 | 19 | if (!ref.startsWith('#')) { 20 | throw new Error(`Cannot resolve external reference ${ref}`); 21 | } 22 | 23 | let refParts = ref.slice(1).split('/').filter(p => p.length); 24 | let refTarget: any = root; 25 | 26 | while (refParts.length) { 27 | const nextPart = refParts.shift()! 28 | // Handle JSON pointer escape chars: 29 | .replace(/~1/g, '/') 30 | .replace(/~0/g, '~'); 31 | refTarget = refTarget[nextPart]; 32 | if (!refTarget) { 33 | throw new Error(`Could not follow ref ${ref}, failed at ${nextPart}`); 34 | } 35 | } 36 | 37 | return refTarget; 38 | } 39 | /** 40 | * Removes almost all $refs from the given JS object. Mutates the input, 41 | * and returns it as well, just for convenience. 42 | * 43 | * This doesn't worry about where $ref is legal - treats it as a reference when 44 | * found anywhere. That could go wrong in theory, but in practice it's unlikely, 45 | * and easy for now. 46 | * 47 | * If this causes problems later, we need to build an OpenAPI-specific deref, 48 | * which understands where in OpenAPIv3 a $ref is legal, and only uses those. 49 | * For now though, we ignore all that for drastic simplification. 50 | */ 51 | export function dereference(root: T): T { 52 | traverse.forEach(root, function (this: traverse.TraverseContext, node) { 53 | let wasRef = false; 54 | 55 | while (isRef(node)) { 56 | wasRef = true; 57 | node = derefRef(root, node); 58 | } 59 | 60 | // No need to traverse into refs: 61 | const stopHere = wasRef; 62 | this.update(node, stopHere); 63 | }); 64 | return root; 65 | } 66 | 67 | const getValue = (root: any, path: string[]): any => { 68 | if (path.length === 0) return root; 69 | return getValue(root[path[0]], path.slice(1)); 70 | }; 71 | 72 | export function formatAjvError( 73 | data: any, 74 | e: Ajv.ErrorObject, 75 | pathTransform: (path: string) => string = _.identity 76 | ) { 77 | const value = e.instancePath?.length 78 | ? getValue(data, e.instancePath.slice(1).split('/')) 79 | : data; 80 | 81 | return (pathTransform(e.instancePath) || 'Document') + ` (${ 82 | truncate(JSON.stringify(value), 50) 83 | }) ${e.message!}${ 84 | e.keyword === 'enum' ? 85 | ` (${joinAnd( 86 | (e.params as any).allowedValues, ', ', ', or ') 87 | })` : 88 | '' 89 | }.` 90 | } -------------------------------------------------------------------------------- /src/util/mobx-persist/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Huang Qi 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /src/util/mobx-persist/README.md: -------------------------------------------------------------------------------- 1 | This is effectively an inline fork of mobx-persist, which seems now sadly unmaintained. 2 | 3 | Based on code from https://github.com/pinqy520/mobx-persist/, with a few changes, under the MIT license there (also included in this directory). -------------------------------------------------------------------------------- /src/util/mobx-persist/persist-object.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSimpleSchema, setDefaultModelSchema, 3 | } from 'serializr'; 4 | 5 | import { types } from './types'; 6 | 7 | export function persistObject(target: any, schema: any) { 8 | const model = createModel(schema); 9 | setDefaultModelSchema(target, model); 10 | return target; 11 | } 12 | 13 | function createModel(params: any) { 14 | const schema: { [key: string]: any } = {}; 15 | Object.keys(params).forEach(key => { 16 | if (typeof params[key] === 'object') { 17 | if (params[key].type in types) { 18 | if (typeof params[key].schema === 'object') { 19 | schema[key] = types[params[key].type](createModel(params[key].schema)); 20 | } else { 21 | schema[key] = types[params[key].type](params[key].schema); 22 | } 23 | } 24 | } else if (params[key] === true) { 25 | schema[key] = true; 26 | } 27 | }); 28 | 29 | return createSimpleSchema(schema); 30 | } -------------------------------------------------------------------------------- /src/util/mobx-persist/persist.ts: -------------------------------------------------------------------------------- 1 | // Based on mobx-persist: https://github.com/pinqy520/mobx-persist 2 | // Sadly now unmaintained. 3 | 4 | import * as _ from 'lodash'; 5 | import { 6 | reaction, 7 | runInAction 8 | } from 'mobx'; 9 | import { 10 | serialize, 11 | update, 12 | serializable, 13 | getDefaultModelSchema 14 | } from 'serializr'; 15 | import * as Storage from './storage'; 16 | import { types, Types } from './types'; 17 | import { persistObject } from './persist-object'; 18 | 19 | // @persist decorator: 20 | export function persist(type: Types, schema?: any): (target: Object, key: string, baseDescriptor?: PropertyDescriptor) => void // two 21 | export function persist(target: Object, key: string, baseDescriptor?: PropertyDescriptor): void // method decorator 22 | export function persist(schema: Object): (target: T) => T // object 23 | export function persist(...args: any[]): any { 24 | const [a, b] = args 25 | if (a in types) { 26 | return serializable(types[a](b)); 27 | } else if (args.length === 1) { 28 | return (target: any) => persistObject(target, a); 29 | } else { 30 | return serializable.apply(null, args as any); 31 | } 32 | } 33 | 34 | // Rehydration function: 35 | export async function hydrate(options: { 36 | key: string, 37 | store: T, 38 | storage?: typeof Storage, 39 | jsonify?: boolean, 40 | dataTransform?: (data: any) => any, 41 | customArgs?: any 42 | }): Promise { 43 | const { key, store, storage, jsonify, dataTransform, customArgs } = _.defaults(options, { 44 | customArgs: {}, 45 | storage: Storage, 46 | jsonify: true, 47 | dataTransform: _.identity 48 | }); 49 | 50 | const schema = getDefaultModelSchema(store as any); 51 | 52 | // Load existing data, if available, and apply it to the store 53 | const rawData = await storage.getItem(key); 54 | if (rawData) { 55 | const data = jsonify ? JSON.parse(rawData) : rawData; 56 | 57 | if (data && typeof data === 'object') { 58 | runInAction(() => { 59 | update(schema, store, dataTransform(data), undefined, customArgs); 60 | }); 61 | } 62 | } 63 | 64 | // Whenever the serialized result changes, store it: 65 | reaction( 66 | () => serialize(schema, store), 67 | (data: any) => storage.setItem( 68 | key, 69 | jsonify ? JSON.stringify(data) : data 70 | ) 71 | ); 72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/util/mobx-persist/storage.ts: -------------------------------------------------------------------------------- 1 | export function clear() { 2 | return new Promise((resolve, reject) => { 3 | try { 4 | window.localStorage.clear() 5 | resolve(null) 6 | } catch (err) { 7 | reject(err) 8 | } 9 | }) 10 | } 11 | 12 | export function getItem(key: string): Promise { 13 | return new Promise((resolve, reject) => { 14 | try { 15 | const value = window.localStorage.getItem(key) 16 | resolve(value) 17 | } catch (err) { 18 | reject(err) 19 | } 20 | }) 21 | } 22 | 23 | export function removeItem(key: string) { 24 | return new Promise((resolve, reject) => { 25 | try { 26 | window.localStorage.removeItem(key) 27 | resolve(null) 28 | } catch (err) { 29 | reject(err) 30 | } 31 | }) 32 | } 33 | 34 | export function setItem(key: string, value: string) { 35 | return new Promise((resolve, reject) => { 36 | try { 37 | window.localStorage.setItem(key, value) 38 | resolve(null) 39 | } catch (err) { 40 | reject(err) 41 | } 42 | }) 43 | } -------------------------------------------------------------------------------- /src/util/mobx-persist/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | list as _list, 3 | map as _map, 4 | object as _object, 5 | custom 6 | } from 'serializr' 7 | 8 | function _walk(v: any) { 9 | if (typeof v === 'object' && v) Object.keys(v).map(k => _walk(v[k])) 10 | return v 11 | } 12 | 13 | function _default() { 14 | return custom(_walk, (v: any) => v) 15 | } 16 | 17 | function object(s: any) { 18 | return s ? _object(s) : _default() 19 | } 20 | 21 | function list(s: any) { 22 | return _list(object(s)) 23 | } 24 | 25 | function map(s: any) { 26 | return _map(object(s)) 27 | } 28 | 29 | export type Types = 'object' | 'list' | 'map' 30 | export const types: { [key: string]: ((s?: any) => any) } = { object, list, map } -------------------------------------------------------------------------------- /src/util/promise.ts: -------------------------------------------------------------------------------- 1 | export function delay(numberMs: number) { 2 | return new Promise((resolve) => setTimeout(resolve, numberMs)); 3 | } 4 | 5 | export function attempt(fn: () => T): Promise { 6 | try { 7 | const result = fn(); 8 | return Promise.resolve(result); 9 | } catch (e) { 10 | return Promise.reject(e); 11 | } 12 | } 13 | 14 | export interface Deferred { 15 | resolve: (arg: T) => void, 16 | reject: (e?: Error) => void, 17 | promise: Promise 18 | } 19 | 20 | export function getDeferred(): Deferred { 21 | let resolve: undefined | ((arg: T) => void) = undefined; 22 | let reject: undefined | ((e?: Error) => void) = undefined; 23 | 24 | let promise = new Promise((resolveCb, rejectCb) => { 25 | resolve = resolveCb; 26 | reject = rejectCb; 27 | }); 28 | 29 | // TS thinks we're using these before they're assigned, which is why 30 | // we need the undefined types, and the any here. 31 | return { resolve, reject, promise } as any; 32 | } -------------------------------------------------------------------------------- /src/util/streams.ts: -------------------------------------------------------------------------------- 1 | export function byteStreamToLines(stream: ReadableStream) { 2 | const newlineMatcher = /\r?\n/; 3 | const decoder = new TextDecoder(); 4 | 5 | let reader: ReadableStreamDefaultReader; 6 | let currentLine = ''; 7 | 8 | return new ReadableStream({ 9 | start() { 10 | reader = stream.getReader(); 11 | }, 12 | async pull(controller) { 13 | const { done, value } = await reader.read(); 14 | 15 | if (done) { 16 | // If the stream closes cleanly, all data up to the end 17 | // (if there is any) is our final line: 18 | if (currentLine.length > 0) { 19 | controller.enqueue(currentLine); 20 | } 21 | controller.close(); 22 | } 23 | 24 | const chunk = decoder.decode(value, { stream: true }); 25 | currentLine += chunk; 26 | 27 | const parts = currentLine.split(newlineMatcher); 28 | 29 | // The last part is incomplete, so becomes our current line: 30 | currentLine = parts.pop() ?? ''; 31 | 32 | // Every other part is a complete line: 33 | for (const part of parts) controller.enqueue(part); 34 | }, 35 | cancel(reason) { 36 | reader.cancel(reason); 37 | } 38 | }); 39 | } 40 | 41 | export function parseJsonLinesStream(stream: ReadableStream) { 42 | let reader: ReadableStreamDefaultReader; 43 | 44 | return new ReadableStream({ 45 | start() { 46 | reader = stream.getReader(); 47 | }, 48 | async pull(controller) { 49 | const { done, value } = await reader.read(); 50 | 51 | if (done) return controller.close(); 52 | else controller.enqueue(JSON.parse(value)); 53 | }, 54 | cancel(reason) { 55 | reader.cancel(reason); 56 | } 57 | }); 58 | } 59 | 60 | export const emptyStream = () => new ReadableStream({ 61 | start(controller) { 62 | controller.close(); 63 | } 64 | }); -------------------------------------------------------------------------------- /src/util/text.ts: -------------------------------------------------------------------------------- 1 | export function truncate(str: string, length: number) { 2 | if (str.length <= length) { 3 | return str; 4 | } else { 5 | return str.slice(0, length - 3) + "..."; 6 | } 7 | } 8 | 9 | export function joinAnd(val: string[], initialSep = ', ', finalSep = ' and ') { 10 | if (val.length === 1) return val[0]; 11 | 12 | return val.slice(0, -1).join(initialSep) + finalSep + val[val.length - 1]; 13 | } 14 | 15 | const VOWEL_ISH = ['a', 'e', 'i', 'o', 'u', 'y']; 16 | export function aOrAn(value: string) { 17 | if (VOWEL_ISH.includes(value[0].toLowerCase())) return 'an'; 18 | else return 'a'; 19 | } 20 | 21 | export function uppercaseFirst(value: string) { 22 | return value[0].toUpperCase() + value.slice(1); 23 | } -------------------------------------------------------------------------------- /src/util/url.ts: -------------------------------------------------------------------------------- 1 | export type ParsedUrl = URL & { parseable: boolean }; 2 | 3 | export const getEffectivePort = (url: { protocol: string | null, port: string | null }) => { 4 | if (url.port) { 5 | return parseInt(url.port, 10); 6 | } else if (url.protocol === 'https:' || url.protocol === 'wss:') { 7 | return 443; 8 | } else { 9 | return 80; 10 | } 11 | } -------------------------------------------------------------------------------- /test/custom-typings/arraybuffer-loaded-data.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'arraybuffer-loader!*' { 2 | const data: ArrayBuffer; 3 | export = data; 4 | } -------------------------------------------------------------------------------- /test/custom-typings/chai-deep-match.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'chai-deep-match' { 2 | // Required to allow import * as ... 3 | namespace chaiDeepMatch { } 4 | 5 | function chaiDeepMatch(chai: any, utils: any): void; 6 | 7 | export = chaiDeepMatch; 8 | } 9 | 10 | declare namespace Chai { 11 | interface Deep { 12 | match(expected: any): Assertion; 13 | } 14 | } -------------------------------------------------------------------------------- /test/custom-typings/static-server.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'static-server'; -------------------------------------------------------------------------------- /test/fixtures/badssl.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/httptoolkit-ui/8d5550c4b37468700c6b8ffd73fd6e506815fe4d/test/fixtures/badssl.p12 -------------------------------------------------------------------------------- /test/fixtures/ca-cert-ecdsa.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBtTCCAVugAwIBAgIUV8F3L4Vdhcb7+wbBwg0kRgziXw0wCgYIKoZIzj0EAwIw 3 | MDEZMBcGA1UEAwwQSHRrRWNkc2FUZXN0Q2VydDETMBEGA1UECgwKSHRrVGVzdE9y 4 | ZzAeFw0yMzA2MjExNzA0NDBaFw0zMzA2MTgxNzA0NDBaMDAxGTAXBgNVBAMMEEh0 5 | a0VjZHNhVGVzdENlcnQxEzARBgNVBAoMCkh0a1Rlc3RPcmcwWTATBgcqhkjOPQIB 6 | BggqhkjOPQMBBwNCAAR7CWRjmi4661Y7DpKj+v+7JvCrUx6hjR/ET/178t/bCL0U 7 | qvkgG0J6q0RjNw5QyRP4fuCiBDs1mdqUp2yDzuxto1MwUTAdBgNVHQ4EFgQUIddw 8 | bTaZ8MMXagwCjGjjwHdfJ+kwHwYDVR0jBBgwFoAUIddwbTaZ8MMXagwCjGjjwHdf 9 | J+kwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiB7aSa3jrfGghdN 10 | IzcBSKM1Gklj4um9L0W0DdB6C1Oi6AIhANXKY+xLGfUktQ3oE1HFeFr288gsCeKg 11 | DVoK3lEbx5Fb 12 | -----END CERTIFICATE----- 13 | -------------------------------------------------------------------------------- /test/fixtures/ca-cert-rsa.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPTCCAiWgAwIBAgIUb61A6g6gwR5S2OhlS7IsW6lAQhwwDQYJKoZIhvcNAQEL 3 | BQAwLjEXMBUGA1UEAwwOSHRrUnNhVGVzdENlcnQxEzARBgNVBAoMCkh0a1Rlc3RP 4 | cmcwHhcNMjMwNjIxMTcwNDMyWhcNMzMwNjE4MTcwNDMyWjAuMRcwFQYDVQQDDA5I 5 | dGtSc2FUZXN0Q2VydDETMBEGA1UECgwKSHRrVGVzdE9yZzCCASIwDQYJKoZIhvcN 6 | AQEBBQADggEPADCCAQoCggEBAKtQsJWkiJ0q7LX1cXZH/CRAASMxHI1hSJC/daGX 7 | 5wMs2bYUmQ3XgsKAkxBbq3uZ3BuX0M7G3stWJyzfZmkN3YhSV6sNK9zl9qoU8oP2 8 | Wo6POUqBIgxwAeuhOeqSQwOsMEpk6uMBHIS8KVkZ7O4CzkTOxSE0+FY9Oh4w3prD 9 | BlGYjyABR/SAQ0VMtFAHlbI9JZrZyeELcGU+zyRGcUUEHY48hLWebTDT9gnrU3wK 10 | /JMlRr+vv1rQRw37zLz8DeQ+Z+R937b5C6OFzwBVBpAQ2y3q9584sKyc2wWcHCO/ 11 | U8VBxRprgPA4Ig/VSBxo5HANkq5n5KoXwQSrsvwK2xEl4fUCAwEAAaNTMFEwHQYD 12 | VR0OBBYEFDUSt6kZuwboBY6QpEYpSycSZF2SMB8GA1UdIwQYMBaAFDUSt6kZuwbo 13 | BY6QpEYpSycSZF2SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB 14 | ACXm0Tdf5Y882Zd2MbylBY820+Hes65DqtGDYIpAA7f5yKQqTNN3mq8hSMfsR81T 15 | 9Z6qSQeY3o1EDX/SWA/G4KHw/rUb3TIzKtvtcyxjuNFGL/fce2BvgEZ2RsdMvSI/ 16 | qI0fj815QdsTmqpt2Hx9Qj3qNPrmNVxTKTKOlEheEYEqWjYQJ0J6I4id/3XAo5CJ 17 | fdP4MmH/PvpMBw7XZPhvHaKPbixUnIy+UeQOx9pNKvC0i6bwjgY2eTA7rBspzIf8 18 | B9Niv5wHdSGGzKlHXKixQb9kBZc0XTc4clcXJTaHf1wRMVshHTddNXtzaIskVjfV 19 | zlFNXYsANqAtssCroiCbij0= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /test/fixtures/corrupt.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/httptoolkit-ui/8d5550c4b37468700c6b8ffd73fd6e506815fe4d/test/fixtures/corrupt.pfx -------------------------------------------------------------------------------- /test/fixtures/test.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/httptoolkit-ui/8d5550c4b37468700c6b8ffd73fd6e506815fe4d/test/fixtures/test.pfx -------------------------------------------------------------------------------- /test/integration/ts-node.env: -------------------------------------------------------------------------------- 1 | TS_NODE_PROJECT=./test/integration/tsconfig.json 2 | TS_NODE_FILES=true -------------------------------------------------------------------------------- /test/integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "types": [ 6 | "node", 7 | "mocha", 8 | "chai" 9 | ] 10 | }, 11 | "files": [], 12 | "include": [ 13 | "../../custom-typings/*.d.ts", 14 | "../custom-typings/*.d.ts", 15 | "./**/*.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as chaiAsPromised from 'chai-as-promised'; 3 | import * as chaiDeepMatch from 'chai-deep-match'; 4 | import * as chaiEnzyme from 'chai-enzyme'; 5 | chai.use(chaiAsPromised); 6 | chai.use(chaiDeepMatch); 7 | chai.use(chaiEnzyme()); 8 | 9 | import Enzyme from 'enzyme'; 10 | import Adapter from 'enzyme-adapter-react-16'; 11 | 12 | if (Enzyme) { 13 | // Not defined in node-based (e.g. integration) tests 14 | Enzyme.configure({ adapter: new Adapter() }); 15 | } 16 | 17 | export const expect = chai.expect; -------------------------------------------------------------------------------- /test/unit/components/view/headers/user-agent-header-description.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { expect } from '../../../../test-setup'; 5 | import { 6 | UserAgentHeaderDescription 7 | } from '../../../../../src/components/view/http/user-agent-header-description'; 8 | 9 | const WINDOWS_CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'; 10 | 11 | describe('User agent header description', () => { 12 | it('should show detailed info for known clients', () => { 13 | const description = shallow( 14 | 15 | ); 16 | 17 | expect( 18 | description.find('p').at(0).text() 19 | ).to.equal( 20 | 'This request came from Chrome 60, based on the ' + 21 | 'Blink engine. The device is running Windows 10, with ' + 22 | 'an amd64 CPU.' 23 | ); 24 | }); 25 | 26 | it('should show the default HTTP docs for unknown clients', () => { 27 | const description = shallow(); 30 | 31 | expect( 32 | description.find('p').at(0).text() 33 | ).to.equal( 34 | 'The User-Agent request header is a characteristic string that lets ' + 35 | 'servers and network peers identify the application, operating system, ' + 36 | 'vendor, and/or version of the requesting user agent.' 37 | ); 38 | }); 39 | }); -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('../../automation/webpack.unittest').default; 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | frameworks: ['mocha', 'chai', 'webpack'], 6 | 7 | files: [ 8 | './**/*.spec.ts', 9 | './**/*.spec.tsx', 10 | 11 | { 12 | // Required due to https://github.com/codymikol/karma-webpack/issues/450 13 | pattern: `${webpackConfig.output.path}/**/*`, 14 | watched: false, 15 | included: false 16 | } 17 | ], 18 | mime: { 'text/x-typescript': ['ts', 'tsx'] }, 19 | webpack: webpackConfig, 20 | webpackMiddleware: { 21 | stats: 'errors-only' 22 | }, 23 | preprocessors: { 24 | './**/*.ts': ['webpack', 'sourcemap'], 25 | './**/*.tsx': ['webpack', 'sourcemap'], 26 | '../../src/**/*.ts': ['webpack', 'sourcemap'], 27 | '../../src/**/*.tsx': ['webpack', 'sourcemap'], 28 | }, 29 | reporters: ['mocha'], 30 | mochaReporter: { 31 | showDiff: true 32 | }, 33 | port: 9876, 34 | logLevel: config.LOG_INFO, 35 | 36 | browsers: ['ChromeHeadlessNoSandbox'], 37 | customLaunchers: { 38 | ChromeHeadlessNoSandbox: { 39 | base: 'ChromeHeadless', 40 | flags: ['--no-sandbox'] 41 | } 42 | }, 43 | 44 | autoWatch: false, 45 | singleRun: true, 46 | concurrency: Infinity 47 | }); 48 | }; -------------------------------------------------------------------------------- /test/unit/model/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '../../test-setup'; 2 | 3 | import { validatePKCS12, parseCert } from "../../../src/model/crypto"; 4 | 5 | // A manually generated PFX (using the Mockttp test cert+key) 6 | import * as goodPfxData from 'arraybuffer-loader!../../fixtures/test.pfx'; 7 | 8 | // The same PFX, with some random corruption added 9 | import * as corruptPfxData from 'arraybuffer-loader!../../fixtures/corrupt.pfx'; 10 | 11 | // The published p12 from badssl.com 12 | import * as badSslPfxData from 'arraybuffer-loader!../../fixtures/badssl.p12'; 13 | 14 | import * as rsaCaCert from 'arraybuffer-loader!../../fixtures/ca-cert-rsa.pem'; 15 | import * as ecdsaCaCert from 'arraybuffer-loader!../../fixtures/ca-cert-ecdsa.pem'; 16 | 17 | describe("validatePfx", function () { 18 | 19 | // Occasionally the success-case tests can time out in CI. Very unclear why, I suspect 20 | // some kind of data/lib loading delay somewhere? No apparent errors... 21 | 22 | this.retries(3); 23 | 24 | it("should validate successfully with the right passphrase", () => { 25 | expect( 26 | validatePKCS12(goodPfxData, 'test-passphrase') 27 | ).to.equal('valid'); 28 | }); 29 | 30 | it("should fail to validate with the wrong passphrase", () => { 31 | expect( 32 | validatePKCS12(goodPfxData, 'wrong-passphrase') 33 | ).to.equal('invalid-passphrase'); 34 | }); 35 | 36 | it("should fail to validate with no passphrase", () => { 37 | expect( 38 | validatePKCS12(goodPfxData, undefined) 39 | ).to.equal('invalid-passphrase'); 40 | }); 41 | 42 | it("should fail to validate corrupted data", () => { 43 | expect( 44 | validatePKCS12(corruptPfxData, 'test-passphrase') 45 | ).to.equal('invalid-format'); 46 | }); 47 | 48 | it("should load real p12 from badssl.com", () => { 49 | expect( 50 | validatePKCS12(badSslPfxData, 'badssl.com') 51 | ).to.equal('valid'); 52 | }); 53 | }); 54 | 55 | describe("parseCert", () => { 56 | it("can parse an X509 RSA CA certification", () => { 57 | const cert = parseCert(rsaCaCert); 58 | 59 | expect(cert.subject.name).to.equal('HtkRsaTestCert'); 60 | expect(cert.subject.org).to.equal('HtkTestOrg'); 61 | expect(cert.serial).to.equal('6fad40ea0ea0c11e52d8e8654bb22c5ba940421c'); 62 | expect(cert.rawPEM).to.match(/^-----BEGIN CERTIFICATE-----/); 63 | }); 64 | 65 | it("can parse an X509 ECDSA CA certificate", () => { 66 | const cert = parseCert(ecdsaCaCert); 67 | 68 | expect(cert.subject.name).to.equal('HtkEcdsaTestCert'); 69 | expect(cert.subject.org).to.equal('HtkTestOrg'); 70 | expect(cert.serial).to.equal('57c1772f855d85c6fbfb06c1c20d24460ce25f0d'); 71 | expect(cert.rawPEM).to.match(/^-----BEGIN CERTIFICATE-----/); 72 | }); 73 | }); -------------------------------------------------------------------------------- /test/unit/model/network.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "../../test-setup"; 2 | 3 | import { getReadableIP } from "../../../src/model/network"; 4 | 5 | describe("IP formatting", () => { 6 | it("should return unrecognized ips untouched", () => { 7 | expect(getReadableIP('93.184.216.34')).to.equal('93.184.216.34'); 8 | }); 9 | 10 | it("should mark local network IPv4s", () => { 11 | expect(getReadableIP('10.0.0.1')).to.equal('10.0.0.1 (a local network device)'); 12 | }); 13 | 14 | it("should mark IPv4 localhost addresses", () => { 15 | expect(getReadableIP('127.1.1.1')).to.equal('127.1.1.1 (this machine)'); 16 | expect(getReadableIP('127.0.0.1')).to.equal('127.0.0.1 (this machine)'); 17 | }); 18 | 19 | it("should mark IPv6 localhost addresses", () => { 20 | expect(getReadableIP('::1')).to.equal('0:0:0:0:0:0:0:1 (this machine)'); 21 | expect(getReadableIP('::ffff:127.0.0.1')).to.equal('127.0.0.1 (this machine)'); 22 | }); 23 | 24 | it("should return IPv6 mapped address as IPv4", () => { 25 | expect(getReadableIP('::ffff:10.0.0.1')).to.equal('10.0.0.1 (a local network device)'); 26 | }); 27 | 28 | it("should return full IPv6 addresses untouched", () => { 29 | expect( 30 | getReadableIP('fe80:1:2:3:42:abff:fe08:7e77') 31 | ).to.equal('fe80:1:2:3:42:abff:fe08:7e77 (link local)'); 32 | }); 33 | 34 | it("should normalize shortened IPv6 addresses", () => { 35 | expect( 36 | getReadableIP('fc00::7e77') 37 | ).to.equal('fc00:0:0:0:0:0:0:7e77 (a local network device)'); 38 | }); 39 | }); -------------------------------------------------------------------------------- /test/unit/model/ui/markdown.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "../../../test-setup"; 2 | 3 | import { escapeForMarkdownEmbedding, fromMarkdown } from "../../../../src/model/ui/markdown"; 4 | 5 | const escapeAndRender = (input: string) => { 6 | return fromMarkdown(escapeForMarkdownEmbedding(input)).__html 7 | } 8 | 9 | describe("Markdown content escaping", () => { 10 | it("should do nothing with plain text", () => { 11 | expect( 12 | escapeAndRender("Hello world! Do Markdown chars hy-phens and . still render OK?") 13 | ).to.equal("

Hello world! Do Markdown chars hy-phens and . still render OK?

") 14 | }); 15 | 16 | it("should escape HTML tags", () => { 17 | expect( 18 | escapeAndRender("Hello") 19 | ).to.equal("

<i>Hello</i>

"); 20 | }); 21 | 22 | it("should escape ampersands", () => { 23 | expect( 24 | escapeAndRender("This & that") 25 | ).to.equal("

This & that

"); 26 | }); 27 | 28 | it("should escape Markdown bold/italic syntax", () => { 29 | expect( 30 | escapeAndRender("*italic* and **bold**") 31 | ).to.equal("

*italic* and **bold**

"); 32 | }); 33 | 34 | it("should escape Markdown link syntax", () => { 35 | expect( 36 | escapeAndRender("[link](/abc)") 37 | ).to.equal("

[link](/abc)

"); 38 | }); 39 | 40 | it("should escape Markdown code syntax", () => { 41 | expect( 42 | escapeAndRender("`code` and ```multiline code```") 43 | ).to.equal("

`code` and ```multiline code```

"); 44 | }); 45 | 46 | it("should escape Markdown headers", () => { 47 | expect( 48 | escapeAndRender("# Header") 49 | ).to.equal("

# Header

"); 50 | }); 51 | 52 | it("should escape Markdown numerical lists", () => { 53 | expect( 54 | escapeAndRender("1. Item") 55 | ).to.equal("

1. Item

"); 56 | }); 57 | 58 | it("should escape Markdown blockquotes", () => { 59 | expect( 60 | escapeAndRender("> Blockquote") 61 | ).to.equal("

> Blockquote

"); 62 | }); 63 | 64 | it("should escape Markdown horizontal rules", () => { 65 | expect( 66 | escapeAndRender("---") 67 | ).to.equal("

---

"); 68 | }); 69 | 70 | it("should handle mixed HTML and Markdown", () => { 71 | expect( 72 | escapeAndRender("*Click* me") 73 | ).to.equal("

<a href='#'>*Click* me</a>

"); 74 | }); 75 | 76 | it("should handle already escaped characters", () => { 77 | expect( 78 | escapeAndRender("\\*already escaped\\*") 79 | ).to.equal("

\\*already escaped\\*

"); 80 | }); 81 | 82 | it("should handle Unicode characters", () => { 83 | expect( 84 | escapeAndRender("Unicode: ♥ λ") 85 | ).to.equal("

Unicode: ♥ λ

"); 86 | }); 87 | 88 | }); -------------------------------------------------------------------------------- /test/unit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "node", 6 | "mocha", 7 | "chai" 8 | ] 9 | }, 10 | "files": [], 11 | "include": [ 12 | "../../custom-typings/*.d.ts", 13 | "../custom-typings/*.d.ts", 14 | "./**/*.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /test/unit/util/buffer.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { isProbablyUtf8 } from "../../../src/util/buffer"; 3 | import { expect } from "../../test-setup"; 4 | 5 | describe("Buffer utils", () => { 6 | describe("isProbablyUtf8", () => { 7 | it("returns true for empty string", () => { 8 | expect( 9 | isProbablyUtf8(Buffer.from("")) 10 | ).to.equal(true); 11 | }); 12 | 13 | it("returns true for a short UTF-8 string", () => { 14 | expect( 15 | isProbablyUtf8(Buffer.from("hello world")) 16 | ).to.equal(true); 17 | }); 18 | 19 | it("returns false for a short binary string", () => { 20 | expect( 21 | isProbablyUtf8(Buffer.from([ 22 | // The header for a PNG 23 | 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A 24 | ])) 25 | ).to.equal(false); 26 | }); 27 | 28 | it("returns true for >1KB ASCII string", () => { 29 | expect( 30 | isProbablyUtf8(Buffer.alloc(4096).fill( 31 | 'hello' 32 | )) 33 | ).to.equal(true); 34 | }); 35 | 36 | it("returns true for >1KB multibyte UTF-8 string", () => { 37 | expect( 38 | isProbablyUtf8(Buffer.alloc(4096).fill( 39 | '你好' // Hello in chinese (2x 3-byte chars) 40 | // N.b. 1024 is not divisible by 3, so this 41 | // tests char-split detection 42 | )) 43 | ).to.equal(true); 44 | }); 45 | 46 | it("returns true for an exactly 1026 byte multibyte UTF-8 string", () => { 47 | expect( 48 | isProbablyUtf8(Buffer.alloc(1026).fill( 49 | '你好' // Hello in chinese (2x 3-byte chars) 50 | // 1026 is divisible by 3, but this means we run out of 51 | // buffer looking for the next UTF-8 char from 1024+ 52 | )) 53 | ).to.equal(true); 54 | }); 55 | 56 | it("returns false for >1KB binary string", () => { 57 | expect( 58 | isProbablyUtf8(Buffer.alloc(4096).fill( 59 | Buffer.from([ 60 | // The header for a PNG 61 | 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A 62 | ]) 63 | )) 64 | ).to.equal(false); 65 | }); 66 | 67 | it("returns false for >1KB binary string of continuation bytes", () => { 68 | expect( 69 | isProbablyUtf8(Buffer.from( 70 | Buffer.alloc(4096).fill(Buffer.from([ 71 | 0xba // All continuation bytes 72 | ])) 73 | )) 74 | ).to.equal(false); 75 | }); 76 | }); 77 | }) -------------------------------------------------------------------------------- /test/unit/util/json-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '../../test-setup'; 2 | 3 | import { 4 | dereference 5 | } from '../../../src/util/json-schema'; 6 | 7 | describe("JSON dereferencing", () => { 8 | it("does nothing given no references", () => { 9 | expect( 10 | dereference({ 11 | a: { b: [{ c: 1 }] } 12 | }) 13 | ).to.deep.equal({ 14 | a: { b: [{ c: 1 }] } 15 | }); 16 | }); 17 | 18 | it("looks up content using $refs", () => { 19 | expect( 20 | dereference({ 21 | a: { b: [{ c: { $ref: '#/c' } }] }, 22 | c: 1 23 | }) 24 | ).to.deep.equal({ 25 | a: { b: [{ c: 1 }] }, 26 | c: 1 27 | }); 28 | }); 29 | 30 | it("handles sequences of backward $refs", () => { 31 | expect( 32 | dereference({ 33 | d: 1, 34 | c: { $ref: '#/d' }, 35 | a: { b: [{ c: { $ref: '#/c' } }] } 36 | }) 37 | ).to.deep.equal({ 38 | d: 1, 39 | c: 1, 40 | a: { b: [{ c: 1 }] }, 41 | }); 42 | }); 43 | 44 | it("handles sequences of forward $refs", () => { 45 | expect( 46 | dereference({ 47 | a: { b: [{ c: { $ref: '#/c' } }] }, 48 | c: { $ref: '#/d' }, 49 | d: 1 50 | }) 51 | ).to.deep.equal({ 52 | a: { b: [{ c: 1 }] }, 53 | c: 1, 54 | d: 1 55 | }); 56 | }); 57 | 58 | it("handles circular $refs in place", () => { 59 | const result = dereference({ 60 | a: { b: { $ref: '#/a' } } 61 | }) 62 | 63 | expect(result.a.b).to.equal(result.a); 64 | }); 65 | }); -------------------------------------------------------------------------------- /test/unit/util/observable.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | import { observable, reaction } from 'mobx'; 3 | 4 | import { expect } from '../../test-setup'; 5 | import { debounceComputed } from '../../../src/util/observable'; 6 | 7 | describe("Deferred observables", () => { 8 | 9 | let clock: sinon.SinonFakeTimers; 10 | 11 | beforeEach(() => { 12 | clock = sinon.useFakeTimers(); 13 | }); 14 | 15 | afterEach(() => { 16 | clock.restore(); 17 | }); 18 | 19 | it("calls the computed immediately initially", () => { 20 | let counter = observable.box(0); 21 | const slowComputed = debounceComputed(() => counter.get() + 1, 100); 22 | expect(slowComputed.get()).to.equal(1); 23 | }); 24 | 25 | it("doesn't rerun the computed for subsequent calls", () => { 26 | let counter = observable.box(0); 27 | 28 | const slowComputed = debounceComputed(() => counter.get() + 1, 100); 29 | expect(slowComputed.get()).to.equal(1); 30 | 31 | counter.set(5); 32 | expect(slowComputed.get()).to.equal(1); 33 | }); 34 | 35 | it("does rerun the computed after a delay", () => { 36 | let counter = observable.box(0); 37 | 38 | const slowComputed = debounceComputed(() => counter.get() + 1, 100); 39 | expect(slowComputed.get()).to.equal(1); 40 | 41 | counter.set(5); 42 | expect(slowComputed.get()).to.equal(1); 43 | 44 | clock.tick(100); 45 | expect(slowComputed.get()).to.equal(6); 46 | }); 47 | 48 | it("immediately updates subscribers of the first change", () => { 49 | let counter = observable.box(0); 50 | const slowComputed = debounceComputed(() => counter.get() + 1, 100); 51 | 52 | let seenUpdates = 0; 53 | reaction(() => slowComputed.get(), () => { seenUpdates += 1; }); 54 | 55 | clock.tick(100); 56 | expect(seenUpdates).to.equal(0); 57 | counter.set(2); 58 | expect(seenUpdates).to.equal(1); 59 | }); 60 | 61 | it("updates subscribers about subsequent pending changes after a delay", () => { 62 | let counter = observable.box(0); 63 | const slowComputed = debounceComputed(() => counter.get() + 1, 100); 64 | 65 | let seenUpdates = 0; 66 | reaction(() => slowComputed.get(), () => { seenUpdates += 1; }); 67 | 68 | clock.tick(100); 69 | counter.set(2); 70 | expect(seenUpdates).to.equal(1); 71 | 72 | counter.set(3); 73 | counter.set(4); 74 | counter.set(5); 75 | expect(seenUpdates).to.equal(1); // No update yet 76 | 77 | clock.tick(100); 78 | expect(seenUpdates).to.equal(2); // Updates once, 100ms later 79 | }); 80 | }); -------------------------------------------------------------------------------- /test/unit/workers/worker-decoding.spec.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from 'zlib'; 2 | 3 | import { expect } from '../../test-setup'; 4 | 5 | import { decodeBody } from '../../../src/services/ui-worker-api'; 6 | 7 | describe('Worker decoding', function () { 8 | this.timeout(500); // Decoding this should be pretty quick - let's enforce that 9 | 10 | before(async function () { 11 | this.timeout(10000); 12 | // First worker request can be slow seemingly though (~2s), not sure why, might be a 13 | // karma issue? Not noticeable in real use, and subsequent calls seem to be very 14 | // quick (~1ms) so it's not an issue in practice. 15 | await decodeBody(Buffer.from(zlib.gzipSync('Warmup content')), ['gzip']); 16 | }); 17 | 18 | it('should decode a response with no encoding', async () => { 19 | const body = Buffer.from('hello world'); 20 | const { decoded, encoded } = await decodeBody(body, []); 21 | 22 | expect(decoded.toString('utf8')).to.equal('hello world'); 23 | expect(encoded.toString('utf8')).to.equal('hello world'); 24 | }); 25 | 26 | it('should decode a response with an encoding', async () => { 27 | const gzippedContent = zlib.gzipSync('Gzipped response'); 28 | const body = Buffer.from(gzippedContent); 29 | 30 | const { decoded, encoded } = await decodeBody(body, ['gzip']); 31 | 32 | expect(decoded.toString('utf8')).to.equal('Gzipped response'); 33 | expect(encoded.toString('utf8')).to.equal(gzippedContent.toString('utf8')); 34 | }); 35 | 36 | it('should fail to decode a response with the wrong encoding', () => { 37 | return expect( 38 | decodeBody(Buffer.from('hello world'), ['randomized']) 39 | ).to.be.rejectedWith('Unsupported encoding'); 40 | }); 41 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "sourceMap": true, 8 | 9 | "jsx": "react", 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | 13 | "strict": true, 14 | "incremental": true, 15 | 16 | "lib": [ 17 | "dom", 18 | "ES2017", 19 | "ScriptHost", 20 | "WebWorker" 21 | ], 22 | 23 | // Limit the global types included, to fix https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33015 24 | "types": [ 25 | "node", 26 | "webpack-env" 27 | ], 28 | 29 | "plugins": [ 30 | { "name": "typescript-styled-plugin" } 31 | ] 32 | }, 33 | "files": [ 34 | "src/index.tsx" 35 | ], 36 | "include": [ 37 | "custom-typings/**/*.d.ts" 38 | ] 39 | } 40 | --------------------------------------------------------------------------------