@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 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"