@hummingbirdworks/proxy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/hummingbird-proxy.js +148 -0
- package/package.json +34 -0
- package/powerapp_dev_proxy.py +377 -0
- package/proxy.config.example.toml +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ps Hummingbird
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @hummingbirdworks/proxy
|
|
2
|
+
|
|
3
|
+
A [mitmproxy](https://www.mitmproxy.org/)-based dev proxy that redirects
|
|
4
|
+
Dataverse / Dynamics 365 **web resources** and **PCF control** assets to your
|
|
5
|
+
local dev builds or a running dev server (e.g. Vite). This lets you iterate on
|
|
6
|
+
web resources and PCF controls locally without deploying to the environment on
|
|
7
|
+
every change.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
The package ships a Python mitmproxy addon plus a small Node CLI
|
|
12
|
+
(`hummingbird-proxy`) that launches `mitmdump` with the addon and points it at
|
|
13
|
+
your config file. Redirect rules live in a `proxy.config.toml` file that you
|
|
14
|
+
check into your own repo. **All relative paths in the config are resolved
|
|
15
|
+
relative to the config file**, so the config is portable across machines.
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- **Node.js** >= 16 (to run the CLI).
|
|
20
|
+
- **mitmproxy** installed and `mitmdump` available on your `PATH`
|
|
21
|
+
(Python 3.11+, for stdlib TOML support).
|
|
22
|
+
Install from <https://www.mitmproxy.org/> — e.g. `pipx install mitmproxy`.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install -D @hummingbirdworks/proxy
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configure
|
|
31
|
+
|
|
32
|
+
Create a config in your repo with the `init` command:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx hummingbird-proxy init
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This copies the bundled example to `./proxy.config.toml` and adds a `proxy`
|
|
39
|
+
script (`"proxy": "hummingbird-proxy"`) to your `package.json`. Pass `--force`
|
|
40
|
+
to overwrite an existing config/script. (You can also copy the example manually
|
|
41
|
+
from `node_modules/@hummingbirdworks/proxy/proxy.config.example.toml`.)
|
|
42
|
+
|
|
43
|
+
The config is a TOML file. It's a table with a `rules` array-of-tables; each
|
|
44
|
+
`[[rules]]` entry is one redirect:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
# Single web resource file -> local file
|
|
48
|
+
[[rules]]
|
|
49
|
+
type = "single"
|
|
50
|
+
name = "test_/ribbonscript/opportunity.js"
|
|
51
|
+
file = "./src/webresources/ribbonscript/opportunity.js"
|
|
52
|
+
|
|
53
|
+
# Folder of web resources -> local folder
|
|
54
|
+
[[rules]]
|
|
55
|
+
type = "folder"
|
|
56
|
+
name = "test_/custom-app/"
|
|
57
|
+
folder = "./src/webresources/custom-app"
|
|
58
|
+
|
|
59
|
+
# Folder of web resources -> local dev server (only for one host)
|
|
60
|
+
[[rules]]
|
|
61
|
+
type = "devserver"
|
|
62
|
+
name = "test_/bookings-editor/"
|
|
63
|
+
url = "https://localhost:5173"
|
|
64
|
+
domain = "myorg.crm.dynamics.com"
|
|
65
|
+
|
|
66
|
+
# PCF control -> local build output folder.
|
|
67
|
+
# Single-quoted literal strings keep Windows backslashes as-is.
|
|
68
|
+
[[rules]]
|
|
69
|
+
type = "pcf"
|
|
70
|
+
name = "test.BookingsEditor"
|
|
71
|
+
folder = 'C:\Users\me\repo\bookings-editor\out\controls\BookingsEditor'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Optional keys on any rule:
|
|
75
|
+
|
|
76
|
+
- `domain`: a host string, or an array of host strings. Omit for all hosts.
|
|
77
|
+
- `disabled`: `true` to skip the rule.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Run the proxy from the folder containing `proxy.config.toml`:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
npm run proxy
|
|
85
|
+
# or, without the added script:
|
|
86
|
+
npx hummingbird-proxy
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or point it at a specific config file:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npx hummingbird-proxy ./config/proxy.config.toml
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Any extra arguments are forwarded to `mitmdump` (e.g. change the port):
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
npx hummingbird-proxy ./proxy.config.toml -p 8888
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then launch a browser through the proxy:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
msedge.exe --proxy-server="http://localhost:8080"
|
|
105
|
+
# or
|
|
106
|
+
chrome.exe --proxy-server="http://localhost:8080"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### First-time setup
|
|
110
|
+
|
|
111
|
+
On first use, install the mitmproxy root certificate so HTTPS interception
|
|
112
|
+
works: with the proxied browser open, visit <http://mitm.it/> and follow the
|
|
113
|
+
instructions for your OS/browser.
|
|
114
|
+
|
|
115
|
+
### Notes
|
|
116
|
+
|
|
117
|
+
- Chrome and Edge share a single background process across all windows. Either
|
|
118
|
+
close all Chrome/Edge windows before starting the proxy, or use a separate
|
|
119
|
+
profile for the proxied browser:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- Power Apps caches web resources and PCF controls in the browser. If changes
|
|
126
|
+
don't show up, force a full refresh (`Ctrl+Shift+R`). You may also need to
|
|
127
|
+
open DevTools → Application → Service Workers and check "Bypass for network"
|
|
128
|
+
to disable the service worker cache.
|
|
129
|
+
|
|
130
|
+
## Config reference (CLI / env)
|
|
131
|
+
|
|
132
|
+
- **Config path resolution:** the CLI uses the first non-flag argument as the
|
|
133
|
+
config path, defaulting to `./proxy.config.toml` in the current directory. It
|
|
134
|
+
passes the resolved absolute path to the addon via the
|
|
135
|
+
`HUMMINGBIRD_PROXY_CONFIG` environment variable.
|
|
136
|
+
- **Running the addon directly** (without the CLI) is also supported:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
HUMMINGBIRD_PROXY_CONFIG=/abs/path/proxy.config.toml mitmdump -s powerapp_dev_proxy.py
|
|
140
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
9
|
+
const PY_SCRIPT = path.join(PKG_ROOT, "powerapp_dev_proxy.py");
|
|
10
|
+
const EXAMPLE_CONFIG = path.join(PKG_ROOT, "proxy.config.example.toml");
|
|
11
|
+
const DEFAULT_CONFIG = "proxy.config.toml";
|
|
12
|
+
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log(
|
|
15
|
+
[
|
|
16
|
+
"hummingbird-proxy - dev proxy for Dataverse / Dynamics 365 web resources and PCF controls",
|
|
17
|
+
"",
|
|
18
|
+
"Usage:",
|
|
19
|
+
" hummingbird-proxy [config-path] [-- mitmdump-args...]",
|
|
20
|
+
" hummingbird-proxy init [--force]",
|
|
21
|
+
"",
|
|
22
|
+
"Commands:",
|
|
23
|
+
` init Copy the example config to ./${DEFAULT_CONFIG} in the current`,
|
|
24
|
+
" directory. Use --force to overwrite an existing file.",
|
|
25
|
+
"",
|
|
26
|
+
"Arguments:",
|
|
27
|
+
` config-path Path to a TOML config file. Defaults to ./${DEFAULT_CONFIG}`,
|
|
28
|
+
" in the current working directory when omitted.",
|
|
29
|
+
"",
|
|
30
|
+
"Any extra arguments are forwarded to mitmdump, e.g.:",
|
|
31
|
+
" hummingbird-proxy ./proxy.config.jsonc -p 8888",
|
|
32
|
+
"",
|
|
33
|
+
"Prerequisite: mitmproxy must be installed and 'mitmdump' available on PATH.",
|
|
34
|
+
" https://www.mitmproxy.org/ (e.g. `pipx install mitmproxy`)",
|
|
35
|
+
].join("\n")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runInit(args) {
|
|
40
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
41
|
+
const target = path.resolve(process.cwd(), DEFAULT_CONFIG);
|
|
42
|
+
if (fs.existsSync(target) && !force) {
|
|
43
|
+
console.error(`hummingbird-proxy: ${DEFAULT_CONFIG} already exists: ${target}`);
|
|
44
|
+
console.error("Use `hummingbird-proxy init --force` to overwrite it.");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
fs.copyFileSync(EXAMPLE_CONFIG, target);
|
|
48
|
+
console.log(`Created ${target}`);
|
|
49
|
+
addProxyScript(force);
|
|
50
|
+
console.log("Edit the rules, then run `npm run proxy` from this directory.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addProxyScript(force) {
|
|
54
|
+
const pkgPath = path.resolve(process.cwd(), "package.json");
|
|
55
|
+
if (!fs.existsSync(pkgPath)) {
|
|
56
|
+
console.warn(
|
|
57
|
+
"hummingbird-proxy: no package.json found; skipped adding the `proxy` script."
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let pkg;
|
|
62
|
+
try {
|
|
63
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.warn(`hummingbird-proxy: could not parse package.json (${err.message}); skipped adding the \`proxy\` script.`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
pkg.scripts = pkg.scripts || {};
|
|
69
|
+
if (pkg.scripts.proxy && !force) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`hummingbird-proxy: a \`proxy\` script already exists (${pkg.scripts.proxy}); left unchanged.`
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
pkg.scripts.proxy = "hummingbird-proxy";
|
|
76
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
77
|
+
console.log(`Added \`proxy\` script to ${pkgPath}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasMitmdump() {
|
|
81
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
82
|
+
const res = spawnSync(probe, ["mitmdump"], { stdio: "ignore" });
|
|
83
|
+
return res.status === 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main() {
|
|
87
|
+
const argv = process.argv.slice(2);
|
|
88
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
89
|
+
printHelp();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (argv[0] === "init") {
|
|
94
|
+
runInit(argv.slice(1));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// First non-flag argument is the config path; everything else is forwarded to mitmdump.
|
|
99
|
+
let configArg;
|
|
100
|
+
const passthrough = [];
|
|
101
|
+
for (const arg of argv) {
|
|
102
|
+
if (configArg === undefined && !arg.startsWith("-")) {
|
|
103
|
+
configArg = arg;
|
|
104
|
+
} else {
|
|
105
|
+
passthrough.push(arg);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const configPath = path.resolve(process.cwd(), configArg || DEFAULT_CONFIG);
|
|
110
|
+
if (!fs.existsSync(configPath)) {
|
|
111
|
+
console.error(`hummingbird-proxy: config file not found: ${configPath}`);
|
|
112
|
+
console.error(
|
|
113
|
+
configArg
|
|
114
|
+
? "Check the path you passed."
|
|
115
|
+
: `Create a ${DEFAULT_CONFIG} in this directory, or pass a path: hummingbird-proxy ./path/to/${DEFAULT_CONFIG}`
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!hasMitmdump()) {
|
|
121
|
+
console.error("hummingbird-proxy: 'mitmdump' was not found on your PATH.");
|
|
122
|
+
console.error(
|
|
123
|
+
"mitmproxy is required. Install it from https://www.mitmproxy.org/ (e.g. `pipx install mitmproxy`)."
|
|
124
|
+
);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const args = ["-s", PY_SCRIPT, ...passthrough];
|
|
129
|
+
const child = spawn("mitmdump", args, {
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
env: { ...process.env, HUMMINGBIRD_PROXY_CONFIG: configPath },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
child.on("error", (err) => {
|
|
135
|
+
console.error(`hummingbird-proxy: failed to start mitmdump: ${err.message}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
child.on("exit", (code, signal) => {
|
|
140
|
+
if (signal) {
|
|
141
|
+
process.kill(process.pid, signal);
|
|
142
|
+
} else {
|
|
143
|
+
process.exit(code ?? 0);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hummingbirdworks/proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "mitmproxy-based dev proxy that redirects Dataverse / Dynamics 365 web resources and PCF control assets to local dev builds or a running dev server.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"hummingbird-proxy": "bin/hummingbird-proxy.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"powerapp_dev_proxy.py",
|
|
11
|
+
"proxy.config.example.toml",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=16"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mitmproxy",
|
|
19
|
+
"powerapps",
|
|
20
|
+
"dataverse",
|
|
21
|
+
"dynamics365",
|
|
22
|
+
"pcf",
|
|
23
|
+
"proxy",
|
|
24
|
+
"dev-server",
|
|
25
|
+
"web-resources"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node bin/hummingbird-proxy.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
mitmproxy addon that redirects Dataverse / Dynamics 365 web resources and PCF
|
|
3
|
+
control assets to local dev builds or a running dev server.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
--------------------------------------------------------------------------------
|
|
7
|
+
First time setup:
|
|
8
|
+
1. Install mitmproxy: https://www.mitmproxy.org/ (e.g. `pipx install mitmproxy`)
|
|
9
|
+
2. Run `npx hummingbird-proxy` from the folder containing proxy.config.toml
|
|
10
|
+
3. Run `msedge.exe --proxy-server="http://localhost:8080"` or `chrome.exe --proxy-server="http://localhost:8080"`
|
|
11
|
+
4. Visit http://mitm.it/ in the new Chrome window and follow the instructions to install the mitmproxy root certificate.
|
|
12
|
+
--------------------------------------------------------------------------------
|
|
13
|
+
Subsequent usage:
|
|
14
|
+
1. Run `npx hummingbird-proxy` from the folder containing proxy.config.toml
|
|
15
|
+
2. Run `msedge.exe --proxy-server="http://localhost:8080"` or `chrome.exe --proxy-server="http://localhost:8080"`
|
|
16
|
+
--------------------------------------------------------------------------------
|
|
17
|
+
NOTE: Chrome and Edge run a single background process for all windows.
|
|
18
|
+
You need to either close all Chrome or Edge windows before starting the proxy, or use a separate user profile for the proxied browser.
|
|
19
|
+
For example, run `msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"` to use a separate profile.
|
|
20
|
+
--------------------------------------------------------------------------------
|
|
21
|
+
NOTE: Power Apps will cache web resource and PCF controls in the browser. If you run into issues,
|
|
22
|
+
try ctrl+shift+r to force a full refresh of the page and clear the cache. Sometimes you may need
|
|
23
|
+
to open Chrome/Edge DevTools and open the Application tab, select "Service Workers" in the left menu,
|
|
24
|
+
and check "Bypass for network" to disable the service worker cache.
|
|
25
|
+
--------------------------------------------------------------------------------
|
|
26
|
+
Config:
|
|
27
|
+
|
|
28
|
+
Rules are loaded from a TOML config file. By default this is proxy.config.toml
|
|
29
|
+
in the current working directory; the `hummingbird-proxy` CLI (or the
|
|
30
|
+
HUMMINGBIRD_PROXY_CONFIG environment variable) can point at a different file.
|
|
31
|
+
All relative file/folder paths in the config are resolved relative to the
|
|
32
|
+
config file's own location, NOT this script.
|
|
33
|
+
|
|
34
|
+
The config is a table with a "rules" array-of-tables. Each [[rules]] entry is
|
|
35
|
+
one redirect. The config is validated at startup, so a malformed entry raises a
|
|
36
|
+
clear error.
|
|
37
|
+
|
|
38
|
+
Tip: use TOML single-quoted literal strings for Windows absolute paths so
|
|
39
|
+
backslashes are not treated as escapes, e.g. folder = 'C:\Users\me\out'.
|
|
40
|
+
|
|
41
|
+
Entry types:
|
|
42
|
+
|
|
43
|
+
Redirect a folder of web resources to a local Vite dev server:
|
|
44
|
+
[[rules]]
|
|
45
|
+
type = "devserver"
|
|
46
|
+
name = "test_/bookings-editor/"
|
|
47
|
+
url = "https://localhost:5173"
|
|
48
|
+
|
|
49
|
+
Redirect a single web resource file to a local file:
|
|
50
|
+
[[rules]]
|
|
51
|
+
type = "single"
|
|
52
|
+
name = "test_/ribbonscript/opportunity.js"
|
|
53
|
+
file = "./src/webresources/ribbonscript/opportunity.js"
|
|
54
|
+
|
|
55
|
+
Redirect a folder of web resources to a local folder:
|
|
56
|
+
[[rules]]
|
|
57
|
+
type = "folder"
|
|
58
|
+
name = "test_/custom-app/"
|
|
59
|
+
folder = "./src/webresources/custom-app"
|
|
60
|
+
|
|
61
|
+
Redirect a PCF control to its local build output folder:
|
|
62
|
+
[[rules]]
|
|
63
|
+
type = "pcf"
|
|
64
|
+
name = "test.BookingsEditor"
|
|
65
|
+
folder = "./bookings-editor/out/controls/BookingsEditor"
|
|
66
|
+
|
|
67
|
+
Optional keys on any entry:
|
|
68
|
+
"domain": omit for all hosts, a host string, or an array of host strings
|
|
69
|
+
"disabled": true to skip the entry
|
|
70
|
+
|
|
71
|
+
Only redirect for a specific host:
|
|
72
|
+
[[rules]]
|
|
73
|
+
type = "single"
|
|
74
|
+
name = "test_/ribbonscript/opportunity-from-bom.js"
|
|
75
|
+
file = "./src/webresources/opportunity.js"
|
|
76
|
+
domain = "myorg.crm.dynamics.com"
|
|
77
|
+
--------------------------------------------------------------------------------
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# ==============================================================================
|
|
81
|
+
# Proxy engine. Redirect rules are loaded from the TOML config file described
|
|
82
|
+
# in the docstring above; you generally shouldn't need to edit anything below.
|
|
83
|
+
# ==============================================================================
|
|
84
|
+
|
|
85
|
+
# Version 2026-08-20
|
|
86
|
+
|
|
87
|
+
import asyncio
|
|
88
|
+
import ipaddress
|
|
89
|
+
import mimetypes
|
|
90
|
+
import os
|
|
91
|
+
import re
|
|
92
|
+
import tomllib
|
|
93
|
+
from urllib.parse import urlsplit
|
|
94
|
+
|
|
95
|
+
from mitmproxy import connection, ctx, http, tls
|
|
96
|
+
from mitmproxy.net import tls as net_tls
|
|
97
|
+
from OpenSSL import SSL
|
|
98
|
+
|
|
99
|
+
Domain = str | list[str] | None
|
|
100
|
+
|
|
101
|
+
# TLS verification is skipped only for upstream connections to these loopback
|
|
102
|
+
# hosts (local dev servers with self-signed / mkcert certs).
|
|
103
|
+
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
104
|
+
|
|
105
|
+
# type -> the set of keys an entry of that type must contain.
|
|
106
|
+
_CONFIG_SCHEMA: dict[str, set[str]] = {
|
|
107
|
+
"single": {"name", "file"},
|
|
108
|
+
"folder": {"name", "folder"},
|
|
109
|
+
"devserver": {"name", "url"},
|
|
110
|
+
"pcf": {"name", "folder"},
|
|
111
|
+
}
|
|
112
|
+
_OPTIONAL_KEYS = {"type", "domain", "disabled"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _config_path() -> str:
|
|
116
|
+
"""Absolute path of the TOML config file to load."""
|
|
117
|
+
override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
|
|
118
|
+
if override:
|
|
119
|
+
return os.path.abspath(override)
|
|
120
|
+
return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_config(path: str) -> list[dict]:
|
|
124
|
+
"""Load and parse the TOML config file into a list of rule dicts."""
|
|
125
|
+
try:
|
|
126
|
+
with open(path, "rb") as handle:
|
|
127
|
+
data = tomllib.load(handle)
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
raise FileNotFoundError(
|
|
130
|
+
f"proxy config file not found: {path}. "
|
|
131
|
+
"Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
|
|
132
|
+
)
|
|
133
|
+
except tomllib.TOMLDecodeError as err:
|
|
134
|
+
raise ValueError(f"invalid TOML in proxy config {path}: {err}")
|
|
135
|
+
rules = data.get("rules", [])
|
|
136
|
+
if not isinstance(rules, list):
|
|
137
|
+
raise ValueError("proxy config 'rules' must be an array of tables")
|
|
138
|
+
return rules
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_path(path: str) -> str:
|
|
142
|
+
"""Convert a path to absolute, relative to the config file directory."""
|
|
143
|
+
if os.path.isabs(path):
|
|
144
|
+
return path
|
|
145
|
+
return os.path.normpath(os.path.join(CONFIG_DIR, path))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_config(config: list[dict]) -> list[dict]:
|
|
149
|
+
"""Validate CONFIG and return the entries that are not disabled."""
|
|
150
|
+
enabled: list[dict] = []
|
|
151
|
+
for index, item in enumerate(config):
|
|
152
|
+
label = f"CONFIG[{index}]"
|
|
153
|
+
if not isinstance(item, dict):
|
|
154
|
+
raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
|
|
155
|
+
item_type = item.get("type")
|
|
156
|
+
if item_type not in _CONFIG_SCHEMA:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"{label}: unknown or missing 'type' {item_type!r}; "
|
|
159
|
+
f"expected one of {sorted(_CONFIG_SCHEMA)}"
|
|
160
|
+
)
|
|
161
|
+
required = _CONFIG_SCHEMA[item_type]
|
|
162
|
+
missing = required - item.keys()
|
|
163
|
+
if missing:
|
|
164
|
+
raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
|
|
165
|
+
unknown = item.keys() - (required | _OPTIONAL_KEYS)
|
|
166
|
+
if unknown:
|
|
167
|
+
raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
|
|
168
|
+
if not item.get("disabled", False):
|
|
169
|
+
enabled.append(item)
|
|
170
|
+
return enabled
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _domain_matches(domain: Domain, host: str) -> bool:
|
|
174
|
+
if domain is None:
|
|
175
|
+
return True
|
|
176
|
+
host = host.lower()
|
|
177
|
+
if isinstance(domain, str):
|
|
178
|
+
return host == domain.lower()
|
|
179
|
+
return any(host == d.lower() for d in domain)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _web_resource_name(request: http.Request) -> str | None:
|
|
183
|
+
"""Return the path following the ``webresources`` segment, or None."""
|
|
184
|
+
components = request.path_components
|
|
185
|
+
for i, component in enumerate(components):
|
|
186
|
+
if component.lower() == "webresources":
|
|
187
|
+
return "/".join(components[i + 1:])
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_pcf_patterns: dict[str, re.Pattern[str]] = {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _pcf_match(path: str, name: str) -> tuple[str, bool] | None:
|
|
195
|
+
"""Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
|
|
196
|
+
pattern = _pcf_patterns.get(name)
|
|
197
|
+
if pattern is None:
|
|
198
|
+
pattern = re.compile(
|
|
199
|
+
r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
|
|
200
|
+
+ re.escape(name)
|
|
201
|
+
+ r"(?:\.|/)(?P<rest>[^?]*)",
|
|
202
|
+
re.IGNORECASE,
|
|
203
|
+
)
|
|
204
|
+
_pcf_patterns[name] = pattern
|
|
205
|
+
match = pattern.search(path)
|
|
206
|
+
if match is None:
|
|
207
|
+
return None
|
|
208
|
+
return match.group("rest"), bool(match.group("css"))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
|
|
212
|
+
ctx.log.info(
|
|
213
|
+
f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str) -> None:
|
|
218
|
+
absolute_path = _resolve_path(filepath)
|
|
219
|
+
try:
|
|
220
|
+
with open(absolute_path, "rb") as handle:
|
|
221
|
+
content = handle.read()
|
|
222
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
223
|
+
ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
|
|
224
|
+
flow.response = http.Response.make(
|
|
225
|
+
404,
|
|
226
|
+
f"dataverseproxy: local file not found: {absolute_path}".encode(),
|
|
227
|
+
{"Content-Type": "text/plain"},
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
|
|
231
|
+
flow.response = http.Response.make(200, content, {"Content-Type": content_type})
|
|
232
|
+
_log_redirect(flow, rule_type, rule_name, absolute_path)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
|
|
236
|
+
target = urlsplit(url)
|
|
237
|
+
query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
|
|
238
|
+
new_path = "/webresources/" + web_resource
|
|
239
|
+
if query:
|
|
240
|
+
new_path += "?" + query
|
|
241
|
+
flow.request.scheme = target.scheme
|
|
242
|
+
flow.request.host = target.hostname or "localhost"
|
|
243
|
+
flow.request.port = target.port or (443 if target.scheme == "https" else 80)
|
|
244
|
+
flow.request.path = new_path
|
|
245
|
+
_log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# How often (seconds) to poll the config file for changes.
|
|
249
|
+
_CONFIG_POLL_INTERVAL = 1.0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _config_mtime(path: str) -> float | None:
|
|
253
|
+
"""Modification time of the config file, or None if it is missing."""
|
|
254
|
+
try:
|
|
255
|
+
return os.path.getmtime(path)
|
|
256
|
+
except OSError:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DataverseProxy:
|
|
261
|
+
def __init__(self, config: list[dict]) -> None:
|
|
262
|
+
self.config = _validate_config(config)
|
|
263
|
+
self._config_mtime = _config_mtime(CONFIG_PATH)
|
|
264
|
+
self._watch_task: asyncio.Task | None = None
|
|
265
|
+
|
|
266
|
+
def running(self) -> None:
|
|
267
|
+
# Start watching the config file for changes once the event loop is up.
|
|
268
|
+
if self._watch_task is None:
|
|
269
|
+
self._watch_task = asyncio.ensure_future(self._watch_config())
|
|
270
|
+
|
|
271
|
+
def done(self) -> None:
|
|
272
|
+
if self._watch_task is not None:
|
|
273
|
+
self._watch_task.cancel()
|
|
274
|
+
self._watch_task = None
|
|
275
|
+
|
|
276
|
+
async def _watch_config(self) -> None:
|
|
277
|
+
while True:
|
|
278
|
+
await asyncio.sleep(_CONFIG_POLL_INTERVAL)
|
|
279
|
+
mtime = _config_mtime(CONFIG_PATH)
|
|
280
|
+
if mtime is None or mtime == self._config_mtime:
|
|
281
|
+
continue
|
|
282
|
+
self._config_mtime = mtime
|
|
283
|
+
self._reload_config()
|
|
284
|
+
|
|
285
|
+
def _reload_config(self) -> None:
|
|
286
|
+
try:
|
|
287
|
+
config = _validate_config(_load_config(CONFIG_PATH))
|
|
288
|
+
except (OSError, ValueError) as err:
|
|
289
|
+
ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
|
|
290
|
+
return
|
|
291
|
+
self.config = config
|
|
292
|
+
_pcf_patterns.clear()
|
|
293
|
+
ctx.log.info(f"Reloaded proxy config from {CONFIG_PATH} ({len(config)} active rules)")
|
|
294
|
+
|
|
295
|
+
def tls_start_server(self, data: tls.TlsData) -> None:
|
|
296
|
+
# Provide a no-verify TLS context for localhost dev servers only. This runs
|
|
297
|
+
# before the built-in TlsConfig addon; once data.ssl_conn is set, TlsConfig
|
|
298
|
+
# skips the connection, so every other (real) upstream keeps full
|
|
299
|
+
# certificate verification.
|
|
300
|
+
server = data.conn
|
|
301
|
+
if data.ssl_conn is not None or not isinstance(server, connection.Server):
|
|
302
|
+
return
|
|
303
|
+
if not server.address or server.address[0] not in LOOPBACK_HOSTS:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
ssl_ctx = net_tls.create_proxy_server_context(
|
|
307
|
+
method=net_tls.Method.TLS_CLIENT_METHOD,
|
|
308
|
+
min_version=net_tls.Version[ctx.options.tls_version_server_min],
|
|
309
|
+
max_version=net_tls.Version[ctx.options.tls_version_server_max],
|
|
310
|
+
cipher_list=None,
|
|
311
|
+
ecdh_curve=None,
|
|
312
|
+
verify=net_tls.Verify.VERIFY_NONE,
|
|
313
|
+
ca_path=None,
|
|
314
|
+
ca_pemfile=None,
|
|
315
|
+
client_cert=None,
|
|
316
|
+
legacy_server_connect=False,
|
|
317
|
+
)
|
|
318
|
+
ssl_conn = SSL.Connection(ssl_ctx)
|
|
319
|
+
sni = server.sni or server.address[0]
|
|
320
|
+
try:
|
|
321
|
+
ipaddress.ip_address(sni)
|
|
322
|
+
except ValueError:
|
|
323
|
+
ssl_conn.set_tlsext_host_name(sni.encode("idna"))
|
|
324
|
+
alpn_offers = server.alpn_offers or data.context.client.alpn_offers
|
|
325
|
+
if alpn_offers:
|
|
326
|
+
ssl_conn.set_alpn_protos(list(alpn_offers))
|
|
327
|
+
ssl_conn.set_connect_state()
|
|
328
|
+
data.ssl_conn = ssl_conn
|
|
329
|
+
|
|
330
|
+
def request(self, flow: http.HTTPFlow) -> None:
|
|
331
|
+
host = flow.request.pretty_host
|
|
332
|
+
web_resource = _web_resource_name(flow.request)
|
|
333
|
+
|
|
334
|
+
for item in self.config:
|
|
335
|
+
if not _domain_matches(item.get("domain"), host):
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
item_type = item["type"]
|
|
339
|
+
|
|
340
|
+
if item_type == "devserver":
|
|
341
|
+
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
342
|
+
_proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
elif item_type == "single":
|
|
346
|
+
if web_resource == item["name"]:
|
|
347
|
+
_serve_file(flow, item["file"], item_type, item["name"])
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
elif item_type == "folder":
|
|
351
|
+
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
352
|
+
relative = web_resource[len(item["name"]):]
|
|
353
|
+
_serve_file(
|
|
354
|
+
flow,
|
|
355
|
+
os.path.join(item["folder"], *relative.split("/")),
|
|
356
|
+
item_type,
|
|
357
|
+
item["name"],
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
elif item_type == "pcf":
|
|
362
|
+
hit = _pcf_match(flow.request.path, item["name"])
|
|
363
|
+
if hit is not None:
|
|
364
|
+
relative, is_css = hit
|
|
365
|
+
parts = [item["folder"]]
|
|
366
|
+
if is_css:
|
|
367
|
+
parts.append("css")
|
|
368
|
+
parts.extend(segment for segment in relative.split("/") if segment)
|
|
369
|
+
_serve_file(flow, os.path.join(*parts), item_type, item["name"])
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
CONFIG_PATH = _config_path()
|
|
374
|
+
CONFIG_DIR = os.path.dirname(CONFIG_PATH)
|
|
375
|
+
CONFIG = _load_config(CONFIG_PATH)
|
|
376
|
+
|
|
377
|
+
addons = [DataverseProxy(CONFIG)]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Redirect rules for @hummingbirdworks/proxy.
|
|
2
|
+
# https://github.com/ps-Hummingbird/powerapps-mitm-proxy-addon/tree/main#configure
|
|
3
|
+
|
|
4
|
+
# Single web resource file -> local file.
|
|
5
|
+
[[rules]]
|
|
6
|
+
type = "single"
|
|
7
|
+
name = "test_/testfolder/abc.html"
|
|
8
|
+
file = "./webresources/testfolder/abc.html"
|
|
9
|
+
|
|
10
|
+
# Folder of web resources -> local folder.
|
|
11
|
+
[[rules]]
|
|
12
|
+
type = "folder"
|
|
13
|
+
name = "test_/testfolder2/"
|
|
14
|
+
folder = "./webresources/testfolder2"
|
|
15
|
+
|
|
16
|
+
# Folder of web resources -> local Vite dev server, only for a specific host.
|
|
17
|
+
[[rules]]
|
|
18
|
+
type = "devserver"
|
|
19
|
+
name = "test_/my-folder/"
|
|
20
|
+
url = "https://localhost:5173"
|
|
21
|
+
domain = "myorg.crm.dynamics.com"
|
|
22
|
+
|
|
23
|
+
# PCF control -> local build output folder.
|
|
24
|
+
[[rules]]
|
|
25
|
+
type = "pcf"
|
|
26
|
+
name = "test.BookingsEditor"
|
|
27
|
+
folder = "./bookings-editor/out/controls/BookingsEditor"
|