@hummingbirdworks/proxy 0.2.1 → 0.3.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/README.md CHANGED
@@ -2,16 +2,13 @@
2
2
 
3
3
  A [mitmproxy](https://www.mitmproxy.org/)-based dev proxy that redirects
4
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.
5
+ local dev builds or a running dev server (e.g. Vite), so you can iterate
6
+ locally without deploying on every change.
8
7
 
9
8
  ## Prerequisites
10
9
 
11
- - **Node.js** >= 16 (to run the CLI).
12
- - **mitmproxy** installed and `mitmdump` available on your `PATH`
13
- (Python 3.11+, for stdlib TOML support).
14
- Install from <https://www.mitmproxy.org/> — e.g. `pipx install mitmproxy`.
10
+ - **Node.js** >= 16.
11
+ - **mitmproxy** install from <https://www.mitmproxy.org/> e.g. `pipx install mitmproxy`.
15
12
 
16
13
  ## Install and Configure
17
14
 
@@ -59,23 +56,30 @@ Optional keys on any rule:
59
56
  - `domain`: a host string, or an array of host strings. Omit for all hosts.
60
57
  - `disabled`: `true` to skip the rule.
61
58
 
62
- ## Usage
63
-
64
- Run the proxy from the folder containing `proxy.config.toml`:
59
+ ### Combining several projects with `include`
65
60
 
66
- ```sh
67
- npm run proxy
68
- ```
61
+ To proxy several projects against the same environment from one running proxy,
62
+ `include` their configs instead of switching proxies as you navigate:
69
63
 
70
- Or point it at a specific config file:
64
+ ```toml
65
+ [[rules]]
66
+ type = "include"
67
+ path = "../invoice-editor/proxy.config.toml"
71
68
 
72
- ```sh
73
- npx hummingbird-proxy ./config/proxy.config.toml
69
+ [[rules]]
70
+ type = "include"
71
+ # Use single quotes to support backslashes in absolute paths
72
+ path = 'C:\Users\me\repos\myproject\proxy.config.toml'
74
73
  ```
75
74
 
76
- Any extra arguments are forwarded to `mitmdump` (e.g. change the port):
75
+ ## Usage
76
+
77
+ Run the proxy from the folder containing `proxy.config.toml` (or pass a config
78
+ path and any extra `mitmdump` args):
77
79
 
78
80
  ```sh
81
+ npm run proxy
82
+ # or a specific config, forwarding args to mitmdump (e.g. change the port)
79
83
  npx hummingbird-proxy ./proxy.config.toml -p 8888
80
84
  ```
81
85
 
@@ -89,42 +93,29 @@ chrome.exe --proxy-server="http://localhost:8080"
89
93
 
90
94
  ### First-time setup
91
95
 
92
- On first use, install the mitmproxy root certificate so HTTPS interception
93
- works: with the proxied browser open, visit <http://mitm.it/> and follow the
94
- instructions for your OS/browser.
96
+ Install the mitmproxy root certificate so HTTPS interception works: with the
97
+ proxied browser open, visit <http://mitm.it/> and follow the instructions.
95
98
 
96
99
  ### ⚠️ **Notes**
97
100
 
98
- - Chrome and Edge share a single background process across all windows. Either
99
- close all Chrome/Edge windows before starting the proxy, or use a separate
100
- profile for the proxied browser:
101
-
102
- ```sh
103
- msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"
104
- ```
105
-
106
- - Power Apps caches web resources and PCF controls in the browser. If changes
107
- don't show up, force a full refresh (`Ctrl+Shift+R`). You may also need to
108
- open DevTools → Application → Service Workers and check "Bypass for network"
109
- to disable the service worker cache.
101
+ - Chrome/Edge share one background process across windows. Close all
102
+ Chrome/Edge windows first, or use a separate profile:
103
+ `msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"`
104
+ - Power Apps caches assets in the browser. If changes don't show, hard refresh
105
+ (`Ctrl+Shift+R`); you may also need to bypass the service worker cache
106
+ (DevTools → Application → Service Workers → "Bypass for network").
110
107
 
111
108
  ## Using with Vite (HMR)
112
109
 
113
110
  A `devserver` rule routes a web resource's requests to a running Vite dev
114
- server, giving you hot module reload on the Dynamics-hosted page.
115
-
116
- The dev server can stay on plain HTTP — the browser only talks to the proxy, and
117
- the proxy relays requests (including the HMR websocket) to `localhost`. Because
118
- the Dynamics page is HTTPS, the browser would block an insecure `ws://` HMR
119
- socket as mixed content, so point the HMR client at `wss` and let the proxy
120
- forward it to the HTTP dev server. No dev-server cert (or `vite-plugin-mkcert`)
121
- is needed.
111
+ server for hot module reload on the Dynamics-hosted page. The dev server stays
112
+ on plain HTTP; the proxy relays everything (including the HMR websocket) to
113
+ `localhost`, so no dev-server cert is needed.
122
114
 
123
115
  ### Vite config
124
116
 
125
- The package ships a Vite plugin that helps with the configuration of web resources.
126
- It sets the base path, configures settings to support HMR with the proxy, and turns
127
- off cache busting since powerapps already has cache busting when you publish customizations.
117
+ The package ships a Vite plugin that sets the base path, enables HMR through the
118
+ proxy, and disables cache busting (Power Apps already busts caches on publish).
128
119
 
129
120
  ```ts
130
121
  import { defineConfig } from 'vite'
@@ -173,5 +164,4 @@ npm run proxy
173
164
 
174
165
  Open the Dynamics page hosting the web resource. Edit → save → the page updates
175
166
  without a manual refresh. If HMR doesn't trigger, confirm Vite's port matches
176
- the rule `url`, that the HMR socket connects over `wss`, and that the service
177
- worker cache is bypassed (see Notes above).
167
+ the rule `url` and that the service worker cache is bypassed (see Notes above).
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
4
  const { spawn, spawnSync } = require("node:child_process");
@@ -0,0 +1,10 @@
1
+ """Dev proxy addon package for Dataverse / Dynamics 365 web resources and PCF controls.
2
+
3
+ The mitmproxy entrypoint lives in ``powerapp_dev_proxy.py`` at the package root;
4
+ it imports :class:`DataverseProxy` from here. See README.md for usage.
5
+ """
6
+
7
+ from hummingbird_proxy.addon import DataverseProxy
8
+ from hummingbird_proxy.config import config_path
9
+
10
+ __all__ = ["DataverseProxy", "config_path"]
@@ -0,0 +1,99 @@
1
+ """The mitmproxy addon: routes matching requests to local files or dev servers."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from mitmproxy import ctx, http, tls
7
+
8
+ from hummingbird_proxy import config, matching, serving
9
+ from hummingbird_proxy.tls import start_dev_server_tls
10
+
11
+
12
+ class DataverseProxy:
13
+ def __init__(self, config_path: str) -> None:
14
+ self.config_path = config_path
15
+ rules, self._config_paths = config.load_rules(config_path)
16
+ self.config = config.validate_config(rules)
17
+ self._config_mtimes = config.config_mtimes(self._config_paths)
18
+ self._watch_task: asyncio.Task | None = None
19
+
20
+ def running(self) -> None:
21
+ # Start watching the config files for changes once the event loop is up.
22
+ if self._watch_task is None:
23
+ self._watch_task = asyncio.ensure_future(self._watch_config())
24
+
25
+ def done(self) -> None:
26
+ if self._watch_task is not None:
27
+ self._watch_task.cancel()
28
+ self._watch_task = None
29
+
30
+ async def _watch_config(self) -> None:
31
+ while True:
32
+ await asyncio.sleep(config.CONFIG_POLL_INTERVAL)
33
+ mtimes = config.config_mtimes(self._config_paths)
34
+ if mtimes == self._config_mtimes:
35
+ continue
36
+ self._config_mtimes = mtimes
37
+ self._reload_config()
38
+
39
+ def _reload_config(self) -> None:
40
+ try:
41
+ rules, paths = config.load_rules(self.config_path)
42
+ new_config = config.validate_config(rules)
43
+ except (OSError, ValueError) as err:
44
+ ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
45
+ return
46
+ self.config = new_config
47
+ self._config_paths = paths
48
+ self._config_mtimes = config.config_mtimes(paths)
49
+ matching.clear_pcf_cache()
50
+ ctx.log.info(
51
+ f"Reloaded proxy config from {self.config_path} "
52
+ f"({len(new_config)} active rules across {len(paths)} files)"
53
+ )
54
+
55
+ def tls_start_server(self, data: tls.TlsData) -> None:
56
+ start_dev_server_tls(data)
57
+
58
+ def request(self, flow: http.HTTPFlow) -> None:
59
+ host = flow.request.pretty_host
60
+ web_resource = matching.web_resource_name(flow.request)
61
+
62
+ for item in self.config:
63
+ if not matching.domain_matches(item.get("domain"), host):
64
+ continue
65
+
66
+ item_type = item["type"]
67
+
68
+ if item_type == "devserver":
69
+ if web_resource is not None and web_resource.startswith(item["name"]):
70
+ serving.proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
71
+ return
72
+
73
+ elif item_type == "single":
74
+ if web_resource == item["name"]:
75
+ serving.serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
76
+ return
77
+
78
+ elif item_type == "folder":
79
+ if web_resource is not None and web_resource.startswith(item["name"]):
80
+ relative = web_resource[len(item["name"]):]
81
+ serving.serve_file(
82
+ flow,
83
+ os.path.join(item["folder"], *relative.split("/")),
84
+ item_type,
85
+ item["name"],
86
+ item["_base_dir"],
87
+ )
88
+ return
89
+
90
+ elif item_type == "pcf":
91
+ hit = matching.pcf_match(flow.request.path, item["name"])
92
+ if hit is not None:
93
+ relative, is_css = hit
94
+ parts = [item["folder"]]
95
+ if is_css:
96
+ parts.append("css")
97
+ parts.extend(segment for segment in relative.split("/") if segment)
98
+ serving.serve_file(flow, os.path.join(*parts), item_type, item["name"], item["_base_dir"])
99
+ return
@@ -0,0 +1,142 @@
1
+ """Loading, validation and change-watching of the TOML proxy config."""
2
+
3
+ import os
4
+ import tomllib
5
+
6
+ # type -> the set of keys an entry of that type must contain.
7
+ _CONFIG_SCHEMA: dict[str, set[str]] = {
8
+ "single": {"name", "file"},
9
+ "folder": {"name", "folder"},
10
+ "devserver": {"name", "url"},
11
+ "pcf": {"name", "folder"},
12
+ }
13
+ _OPTIONAL_KEYS = {"type", "domain", "disabled"}
14
+
15
+ # An "include" entry pulls in the rules from another config file. It is expanded
16
+ # away at load time, so it never reaches validate_config / the request handler.
17
+ _INCLUDE_KEYS = {"path"}
18
+ _INCLUDE_OPTIONAL_KEYS = {"type", "disabled"}
19
+
20
+ # Keys the loader attaches to each rule internally; not user-provided.
21
+ _INTERNAL_KEYS = {"_base_dir"}
22
+
23
+ # Safety net against pathological include nesting (cycles are caught separately).
24
+ _MAX_INCLUDE_DEPTH = 20
25
+
26
+ # How often (seconds) to poll the config files for changes.
27
+ CONFIG_POLL_INTERVAL = 1.0
28
+
29
+
30
+ def config_path() -> str:
31
+ """Absolute path of the TOML config file to load."""
32
+ override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
33
+ if override:
34
+ return os.path.abspath(override)
35
+ return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
36
+
37
+
38
+ def _load_config(path: str) -> list[dict]:
39
+ """Load and parse the TOML config file into a list of rule dicts."""
40
+ try:
41
+ with open(path, "rb") as handle:
42
+ data = tomllib.load(handle)
43
+ except FileNotFoundError:
44
+ raise FileNotFoundError(
45
+ f"proxy config file not found: {path}. "
46
+ "Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
47
+ )
48
+ except tomllib.TOMLDecodeError as err:
49
+ raise ValueError(f"invalid TOML in proxy config {path}: {err}")
50
+ rules = data.get("rules", [])
51
+ if not isinstance(rules, list):
52
+ raise ValueError("proxy config 'rules' must be an array of tables")
53
+ return rules
54
+
55
+
56
+ def load_rules(path: str, _seen: frozenset[str] = frozenset(), _depth: int = 0) -> tuple[list[dict], set[str]]:
57
+ """Load rules from a config file, expanding any "include" entries.
58
+
59
+ Each returned rule carries a "_base_dir" key giving the directory its
60
+ relative paths resolve against, so rules pulled in from another config keep
61
+ resolving against that config's own location. Returns
62
+ (rules, config_paths) where config_paths is every config file touched, used
63
+ to watch them all for changes.
64
+ """
65
+ path = os.path.abspath(path)
66
+ if path in _seen:
67
+ raise ValueError(f"include cycle detected at config {path}")
68
+ if _depth > _MAX_INCLUDE_DEPTH:
69
+ raise ValueError(f"include nesting too deep (>{_MAX_INCLUDE_DEPTH}) at {path}")
70
+ _seen = _seen | {path}
71
+ base_dir = os.path.dirname(path)
72
+ rules: list[dict] = []
73
+ config_paths: set[str] = {path}
74
+
75
+ for index, item in enumerate(_load_config(path)):
76
+ if isinstance(item, dict) and item.get("type") == "include":
77
+ label = f"{path} include[{index}]"
78
+ missing = _INCLUDE_KEYS - item.keys()
79
+ if missing:
80
+ raise ValueError(f"{label}: missing keys {sorted(missing)}")
81
+ unknown = item.keys() - (_INCLUDE_KEYS | _INCLUDE_OPTIONAL_KEYS)
82
+ if unknown:
83
+ raise ValueError(f"{label}: unknown keys {sorted(unknown)}")
84
+ if item.get("disabled", False):
85
+ continue
86
+ target = item["path"]
87
+ if not os.path.isabs(target):
88
+ target = os.path.normpath(os.path.join(base_dir, target))
89
+ sub_rules, sub_paths = load_rules(target, _seen, _depth + 1)
90
+ rules.extend(sub_rules)
91
+ config_paths |= sub_paths
92
+ elif isinstance(item, dict):
93
+ rules.append({**item, "_base_dir": base_dir})
94
+ else:
95
+ rules.append(item)
96
+
97
+ return rules, config_paths
98
+
99
+
100
+ def resolve_path(path: str, base_dir: str) -> str:
101
+ """Convert a path to absolute, relative to its config file's directory."""
102
+ if os.path.isabs(path):
103
+ return path
104
+ return os.path.normpath(os.path.join(base_dir, path))
105
+
106
+
107
+ def validate_config(config: list[dict]) -> list[dict]:
108
+ """Validate CONFIG and return the entries that are not disabled."""
109
+ enabled: list[dict] = []
110
+ for index, item in enumerate(config):
111
+ label = f"CONFIG[{index}]"
112
+ if not isinstance(item, dict):
113
+ raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
114
+ item_type = item.get("type")
115
+ if item_type not in _CONFIG_SCHEMA:
116
+ raise ValueError(
117
+ f"{label}: unknown or missing 'type' {item_type!r}; "
118
+ f"expected one of {sorted(_CONFIG_SCHEMA)}"
119
+ )
120
+ required = _CONFIG_SCHEMA[item_type]
121
+ missing = required - item.keys()
122
+ if missing:
123
+ raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
124
+ unknown = item.keys() - (required | _OPTIONAL_KEYS | _INTERNAL_KEYS)
125
+ if unknown:
126
+ raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
127
+ if not item.get("disabled", False):
128
+ enabled.append(item)
129
+ return enabled
130
+
131
+
132
+ def _config_mtime(path: str) -> float | None:
133
+ """Modification time of the config file, or None if it is missing."""
134
+ try:
135
+ return os.path.getmtime(path)
136
+ except OSError:
137
+ return None
138
+
139
+
140
+ def config_mtimes(paths: set[str]) -> dict[str, float | None]:
141
+ """Modification times for every config file, keyed by path."""
142
+ return {path: _config_mtime(path) for path in paths}
@@ -0,0 +1,55 @@
1
+ """URL matching helpers for web resource, PCF and domain rules."""
2
+
3
+ import re
4
+
5
+ from mitmproxy import http
6
+
7
+ Domain = str | list[str] | None
8
+
9
+
10
+ def domain_matches(domain: Domain, host: str) -> bool:
11
+ if domain is None:
12
+ return True
13
+ host = host.lower()
14
+ if isinstance(domain, str):
15
+ return host == domain.lower()
16
+ return any(host == d.lower() for d in domain)
17
+
18
+
19
+ def web_resource_name(request: http.Request) -> str | None:
20
+ """Return the path following the ``webresources`` segment, or None."""
21
+ components = request.path_components
22
+ for i, component in enumerate(components):
23
+ if component.lower() == "webresources":
24
+ name = "/".join(components[i + 1:])
25
+ # path_components drops a trailing slash; keep it so dev-server root
26
+ # requests (e.g. the Vite HMR base URL) still match a rule prefix.
27
+ if name and request.path.split("?", 1)[0].endswith("/"):
28
+ name += "/"
29
+ return name
30
+ return None
31
+
32
+
33
+ _pcf_patterns: dict[str, re.Pattern[str]] = {}
34
+
35
+
36
+ def pcf_match(path: str, name: str) -> tuple[str, bool] | None:
37
+ """Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
38
+ pattern = _pcf_patterns.get(name)
39
+ if pattern is None:
40
+ pattern = re.compile(
41
+ r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
42
+ + re.escape(name)
43
+ + r"(?:\.|/)(?P<rest>[^?]*)",
44
+ re.IGNORECASE,
45
+ )
46
+ _pcf_patterns[name] = pattern
47
+ match = pattern.search(path)
48
+ if match is None:
49
+ return None
50
+ return match.group("rest"), bool(match.group("css"))
51
+
52
+
53
+ def clear_pcf_cache() -> None:
54
+ """Drop compiled PCF patterns; called when rules are reloaded."""
55
+ _pcf_patterns.clear()
@@ -0,0 +1,46 @@
1
+ """Response builders: serve local files and relay to a dev server."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ import mimetypes
6
+
7
+ from mitmproxy import ctx, http
8
+
9
+ from hummingbird_proxy.config import resolve_path
10
+
11
+
12
+ def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
13
+ ctx.log.info(
14
+ f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
15
+ )
16
+
17
+
18
+ def serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str, base_dir: str) -> None:
19
+ absolute_path = resolve_path(filepath, base_dir)
20
+ try:
21
+ with open(absolute_path, "rb") as handle:
22
+ content = handle.read()
23
+ except (FileNotFoundError, IsADirectoryError):
24
+ ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
25
+ flow.response = http.Response.make(
26
+ 404,
27
+ f"dataverseproxy: local file not found: {absolute_path}".encode(),
28
+ {"Content-Type": "text/plain"},
29
+ )
30
+ return
31
+ content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
32
+ flow.response = http.Response.make(200, content, {"Content-Type": content_type})
33
+ _log_redirect(flow, rule_type, rule_name, absolute_path)
34
+
35
+
36
+ def proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
37
+ target = urlsplit(url)
38
+ query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
39
+ new_path = "/webresources/" + web_resource
40
+ if query:
41
+ new_path += "?" + query
42
+ flow.request.scheme = target.scheme
43
+ flow.request.host = target.hostname or "localhost"
44
+ flow.request.port = target.port or (443 if target.scheme == "https" else 80)
45
+ flow.request.path = new_path
46
+ _log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
@@ -0,0 +1,52 @@
1
+ """TLS handling for upstream connections to local dev servers.
2
+
3
+ Verification is skipped only for loopback dev servers (self-signed / mkcert
4
+ certs); every real upstream keeps full certificate verification because the
5
+ built-in TlsConfig addon handles any connection we leave untouched.
6
+ """
7
+
8
+ import ipaddress
9
+
10
+ from mitmproxy import connection, ctx, tls
11
+ from mitmproxy.net import tls as net_tls
12
+ from OpenSSL import SSL
13
+
14
+ # Upstream hosts for which TLS verification is skipped.
15
+ LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
16
+
17
+
18
+ def start_dev_server_tls(data: tls.TlsData) -> None:
19
+ """Attach a no-verify TLS context when connecting to a loopback dev server.
20
+
21
+ Runs before the built-in TlsConfig addon; once ``data.ssl_conn`` is set,
22
+ TlsConfig skips the connection, so other upstreams keep full verification.
23
+ """
24
+ server = data.conn
25
+ if data.ssl_conn is not None or not isinstance(server, connection.Server):
26
+ return
27
+ if not server.address or server.address[0] not in LOOPBACK_HOSTS:
28
+ return
29
+
30
+ ssl_ctx = net_tls.create_proxy_server_context(
31
+ method=net_tls.Method.TLS_CLIENT_METHOD,
32
+ min_version=net_tls.Version[ctx.options.tls_version_server_min],
33
+ max_version=net_tls.Version[ctx.options.tls_version_server_max],
34
+ cipher_list=None,
35
+ ecdh_curve=None,
36
+ verify=net_tls.Verify.VERIFY_NONE,
37
+ ca_path=None,
38
+ ca_pemfile=None,
39
+ client_cert=None,
40
+ legacy_server_connect=False,
41
+ )
42
+ ssl_conn = SSL.Connection(ssl_ctx)
43
+ sni = server.sni or server.address[0]
44
+ try:
45
+ ipaddress.ip_address(sni)
46
+ except ValueError:
47
+ ssl_conn.set_tlsext_host_name(sni.encode("idna"))
48
+ alpn_offers = server.alpn_offers or data.context.client.alpn_offers
49
+ if alpn_offers:
50
+ ssl_conn.set_alpn_protos(list(alpn_offers))
51
+ ssl_conn.set_connect_state()
52
+ data.ssl_conn = ssl_conn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hummingbirdworks/proxy",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
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
5
  "bin": {
6
6
  "hummingbird-proxy": "bin/hummingbird-proxy.js"
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "bin/",
17
17
  "vite/",
18
+ "hummingbird_proxy/",
18
19
  "powerapp_dev_proxy.py",
19
20
  "proxy.config.example.toml",
20
21
  "README.md"
@@ -1,457 +1,15 @@
1
1
  r"""
2
2
  mitmproxy addon that redirects Dataverse / Dynamics 365 web resources and PCF
3
- control assets to local dev builds or a running dev server.
3
+ control assets to local dev builds or a running dev server. See README.md for
4
+ setup and config usage.
4
5
 
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
- Pull in the rules from another config file (so several projects can proxy the
68
- same Power Apps environment at once without switching proxies). Includes are
69
- resolved recursively; each included config's relative paths resolve against its
70
- own location, and every included file is watched for live reloads:
71
- [[rules]]
72
- type = "include"
73
- path = "../bookings-editor/proxy.config.toml"
74
-
75
- Optional keys on any entry:
76
- "domain": omit for all hosts, a host string, or an array of host strings
77
- "disabled": true to skip the entry
78
-
79
- Only redirect for a specific host:
80
- [[rules]]
81
- type = "single"
82
- name = "test_/ribbonscript/opportunity-from-bom.js"
83
- file = "./src/webresources/opportunity.js"
84
- domain = "myorg.crm.dynamics.com"
85
- --------------------------------------------------------------------------------
6
+ mitmdump loads this file via ``-s``; its directory is placed on ``sys.path``
7
+ during load, so the sibling ``hummingbird_proxy`` package resolves here. All
8
+ implementation lives in that package; this module is just the entrypoint.
86
9
  """
87
10
 
88
- # ==============================================================================
89
- # Proxy engine. Redirect rules are loaded from the TOML config file described
90
- # in the docstring above; you generally shouldn't need to edit anything below.
91
- # ==============================================================================
92
-
93
- # Version 2026-08-20
94
-
95
- import asyncio
96
- import ipaddress
97
- import mimetypes
98
- import os
99
- import re
100
- import tomllib
101
- from urllib.parse import urlsplit
102
-
103
- from mitmproxy import connection, ctx, http, tls
104
- from mitmproxy.net import tls as net_tls
105
- from OpenSSL import SSL
106
-
107
- Domain = str | list[str] | None
108
-
109
- # TLS verification is skipped only for upstream connections to these loopback
110
- # hosts (local dev servers with self-signed / mkcert certs).
111
- LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
112
-
113
- # type -> the set of keys an entry of that type must contain.
114
- _CONFIG_SCHEMA: dict[str, set[str]] = {
115
- "single": {"name", "file"},
116
- "folder": {"name", "folder"},
117
- "devserver": {"name", "url"},
118
- "pcf": {"name", "folder"},
119
- }
120
- _OPTIONAL_KEYS = {"type", "domain", "disabled"}
121
-
122
- # An "include" entry pulls in the rules from another config file. It is expanded
123
- # away at load time, so it never reaches _validate_config / the request handler.
124
- _INCLUDE_KEYS = {"path"}
125
- _INCLUDE_OPTIONAL_KEYS = {"type", "disabled"}
126
-
127
- # Keys the loader attaches to each rule internally; not user-provided.
128
- _INTERNAL_KEYS = {"_base_dir"}
129
-
130
- # Safety net against pathological include nesting (cycles are caught separately).
131
- _MAX_INCLUDE_DEPTH = 20
132
-
133
-
134
- def _config_path() -> str:
135
- """Absolute path of the TOML config file to load."""
136
- override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
137
- if override:
138
- return os.path.abspath(override)
139
- return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
140
-
141
-
142
- def _load_config(path: str) -> list[dict]:
143
- """Load and parse the TOML config file into a list of rule dicts."""
144
- try:
145
- with open(path, "rb") as handle:
146
- data = tomllib.load(handle)
147
- except FileNotFoundError:
148
- raise FileNotFoundError(
149
- f"proxy config file not found: {path}. "
150
- "Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
151
- )
152
- except tomllib.TOMLDecodeError as err:
153
- raise ValueError(f"invalid TOML in proxy config {path}: {err}")
154
- rules = data.get("rules", [])
155
- if not isinstance(rules, list):
156
- raise ValueError("proxy config 'rules' must be an array of tables")
157
- return rules
158
-
159
-
160
- def _load_rules(path: str, _seen: frozenset[str] = frozenset(), _depth: int = 0) -> tuple[list[dict], set[str]]:
161
- """Load rules from a config file, expanding any "include" entries.
162
-
163
- Each returned rule carries a "_base_dir" key giving the directory its
164
- relative paths resolve against, so rules pulled in from another config keep
165
- resolving against that config's own location. Returns
166
- (rules, config_paths) where config_paths is every config file touched, used
167
- to watch them all for changes.
168
- """
169
- path = os.path.abspath(path)
170
- if path in _seen:
171
- raise ValueError(f"include cycle detected at config {path}")
172
- if _depth > _MAX_INCLUDE_DEPTH:
173
- raise ValueError(f"include nesting too deep (>{_MAX_INCLUDE_DEPTH}) at {path}")
174
- _seen = _seen | {path}
175
- base_dir = os.path.dirname(path)
176
- rules: list[dict] = []
177
- config_paths: set[str] = {path}
178
-
179
- for index, item in enumerate(_load_config(path)):
180
- if isinstance(item, dict) and item.get("type") == "include":
181
- label = f"{path} include[{index}]"
182
- missing = _INCLUDE_KEYS - item.keys()
183
- if missing:
184
- raise ValueError(f"{label}: missing keys {sorted(missing)}")
185
- unknown = item.keys() - (_INCLUDE_KEYS | _INCLUDE_OPTIONAL_KEYS)
186
- if unknown:
187
- raise ValueError(f"{label}: unknown keys {sorted(unknown)}")
188
- if item.get("disabled", False):
189
- continue
190
- target = item["path"]
191
- if not os.path.isabs(target):
192
- target = os.path.normpath(os.path.join(base_dir, target))
193
- sub_rules, sub_paths = _load_rules(target, _seen, _depth + 1)
194
- rules.extend(sub_rules)
195
- config_paths |= sub_paths
196
- elif isinstance(item, dict):
197
- rules.append({**item, "_base_dir": base_dir})
198
- else:
199
- rules.append(item)
200
-
201
- return rules, config_paths
202
-
203
-
204
- def _resolve_path(path: str, base_dir: str) -> str:
205
- """Convert a path to absolute, relative to its config file's directory."""
206
- if os.path.isabs(path):
207
- return path
208
- return os.path.normpath(os.path.join(base_dir, path))
209
-
210
-
211
- def _validate_config(config: list[dict]) -> list[dict]:
212
- """Validate CONFIG and return the entries that are not disabled."""
213
- enabled: list[dict] = []
214
- for index, item in enumerate(config):
215
- label = f"CONFIG[{index}]"
216
- if not isinstance(item, dict):
217
- raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
218
- item_type = item.get("type")
219
- if item_type not in _CONFIG_SCHEMA:
220
- raise ValueError(
221
- f"{label}: unknown or missing 'type' {item_type!r}; "
222
- f"expected one of {sorted(_CONFIG_SCHEMA)}"
223
- )
224
- required = _CONFIG_SCHEMA[item_type]
225
- missing = required - item.keys()
226
- if missing:
227
- raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
228
- unknown = item.keys() - (required | _OPTIONAL_KEYS | _INTERNAL_KEYS)
229
- if unknown:
230
- raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
231
- if not item.get("disabled", False):
232
- enabled.append(item)
233
- return enabled
234
-
235
-
236
- def _domain_matches(domain: Domain, host: str) -> bool:
237
- if domain is None:
238
- return True
239
- host = host.lower()
240
- if isinstance(domain, str):
241
- return host == domain.lower()
242
- return any(host == d.lower() for d in domain)
243
-
244
-
245
- def _web_resource_name(request: http.Request) -> str | None:
246
- """Return the path following the ``webresources`` segment, or None."""
247
- components = request.path_components
248
- for i, component in enumerate(components):
249
- if component.lower() == "webresources":
250
- name = "/".join(components[i + 1:])
251
- # path_components drops a trailing slash; keep it so dev-server root
252
- # requests (e.g. the Vite HMR base URL) still match a rule prefix.
253
- if name and request.path.split("?", 1)[0].endswith("/"):
254
- name += "/"
255
- return name
256
- return None
257
-
258
-
259
- _pcf_patterns: dict[str, re.Pattern[str]] = {}
260
-
261
-
262
- def _pcf_match(path: str, name: str) -> tuple[str, bool] | None:
263
- """Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
264
- pattern = _pcf_patterns.get(name)
265
- if pattern is None:
266
- pattern = re.compile(
267
- r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
268
- + re.escape(name)
269
- + r"(?:\.|/)(?P<rest>[^?]*)",
270
- re.IGNORECASE,
271
- )
272
- _pcf_patterns[name] = pattern
273
- match = pattern.search(path)
274
- if match is None:
275
- return None
276
- return match.group("rest"), bool(match.group("css"))
277
-
278
-
279
- def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
280
- ctx.log.info(
281
- f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
282
- )
283
-
284
-
285
- def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str, base_dir: str) -> None:
286
- absolute_path = _resolve_path(filepath, base_dir)
287
- try:
288
- with open(absolute_path, "rb") as handle:
289
- content = handle.read()
290
- except (FileNotFoundError, IsADirectoryError):
291
- ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
292
- flow.response = http.Response.make(
293
- 404,
294
- f"dataverseproxy: local file not found: {absolute_path}".encode(),
295
- {"Content-Type": "text/plain"},
296
- )
297
- return
298
- content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
299
- flow.response = http.Response.make(200, content, {"Content-Type": content_type})
300
- _log_redirect(flow, rule_type, rule_name, absolute_path)
301
-
302
-
303
- def _proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
304
- target = urlsplit(url)
305
- query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
306
- new_path = "/webresources/" + web_resource
307
- if query:
308
- new_path += "?" + query
309
- flow.request.scheme = target.scheme
310
- flow.request.host = target.hostname or "localhost"
311
- flow.request.port = target.port or (443 if target.scheme == "https" else 80)
312
- flow.request.path = new_path
313
- _log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
314
-
315
-
316
- # How often (seconds) to poll the config file for changes.
317
- _CONFIG_POLL_INTERVAL = 1.0
318
-
319
-
320
- def _config_mtime(path: str) -> float | None:
321
- """Modification time of the config file, or None if it is missing."""
322
- try:
323
- return os.path.getmtime(path)
324
- except OSError:
325
- return None
326
-
327
-
328
- def _config_mtimes(paths: set[str]) -> dict[str, float | None]:
329
- """Modification times for every config file, keyed by path."""
330
- return {path: _config_mtime(path) for path in paths}
331
-
332
-
333
- class DataverseProxy:
334
- def __init__(self, config_path: str) -> None:
335
- self.config_path = config_path
336
- rules, self._config_paths = _load_rules(config_path)
337
- self.config = _validate_config(rules)
338
- self._config_mtimes = _config_mtimes(self._config_paths)
339
- self._watch_task: asyncio.Task | None = None
340
-
341
- def running(self) -> None:
342
- # Start watching the config files for changes once the event loop is up.
343
- if self._watch_task is None:
344
- self._watch_task = asyncio.ensure_future(self._watch_config())
345
-
346
- def done(self) -> None:
347
- if self._watch_task is not None:
348
- self._watch_task.cancel()
349
- self._watch_task = None
350
-
351
- async def _watch_config(self) -> None:
352
- while True:
353
- await asyncio.sleep(_CONFIG_POLL_INTERVAL)
354
- mtimes = _config_mtimes(self._config_paths)
355
- if mtimes == self._config_mtimes:
356
- continue
357
- self._config_mtimes = mtimes
358
- self._reload_config()
359
-
360
- def _reload_config(self) -> None:
361
- try:
362
- rules, paths = _load_rules(self.config_path)
363
- config = _validate_config(rules)
364
- except (OSError, ValueError) as err:
365
- ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
366
- return
367
- self.config = config
368
- self._config_paths = paths
369
- self._config_mtimes = _config_mtimes(paths)
370
- _pcf_patterns.clear()
371
- ctx.log.info(
372
- f"Reloaded proxy config from {self.config_path} "
373
- f"({len(config)} active rules across {len(paths)} files)"
374
- )
375
-
376
- def tls_start_server(self, data: tls.TlsData) -> None:
377
- # Provide a no-verify TLS context for localhost dev servers only. This runs
378
- # before the built-in TlsConfig addon; once data.ssl_conn is set, TlsConfig
379
- # skips the connection, so every other (real) upstream keeps full
380
- # certificate verification.
381
- server = data.conn
382
- if data.ssl_conn is not None or not isinstance(server, connection.Server):
383
- return
384
- if not server.address or server.address[0] not in LOOPBACK_HOSTS:
385
- return
386
-
387
- ssl_ctx = net_tls.create_proxy_server_context(
388
- method=net_tls.Method.TLS_CLIENT_METHOD,
389
- min_version=net_tls.Version[ctx.options.tls_version_server_min],
390
- max_version=net_tls.Version[ctx.options.tls_version_server_max],
391
- cipher_list=None,
392
- ecdh_curve=None,
393
- verify=net_tls.Verify.VERIFY_NONE,
394
- ca_path=None,
395
- ca_pemfile=None,
396
- client_cert=None,
397
- legacy_server_connect=False,
398
- )
399
- ssl_conn = SSL.Connection(ssl_ctx)
400
- sni = server.sni or server.address[0]
401
- try:
402
- ipaddress.ip_address(sni)
403
- except ValueError:
404
- ssl_conn.set_tlsext_host_name(sni.encode("idna"))
405
- alpn_offers = server.alpn_offers or data.context.client.alpn_offers
406
- if alpn_offers:
407
- ssl_conn.set_alpn_protos(list(alpn_offers))
408
- ssl_conn.set_connect_state()
409
- data.ssl_conn = ssl_conn
410
-
411
- def request(self, flow: http.HTTPFlow) -> None:
412
- host = flow.request.pretty_host
413
- web_resource = _web_resource_name(flow.request)
414
-
415
- for item in self.config:
416
- if not _domain_matches(item.get("domain"), host):
417
- continue
418
-
419
- item_type = item["type"]
420
-
421
- if item_type == "devserver":
422
- if web_resource is not None and web_resource.startswith(item["name"]):
423
- _proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
424
- return
425
-
426
- elif item_type == "single":
427
- if web_resource == item["name"]:
428
- _serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
429
- return
430
-
431
- elif item_type == "folder":
432
- if web_resource is not None and web_resource.startswith(item["name"]):
433
- relative = web_resource[len(item["name"]):]
434
- _serve_file(
435
- flow,
436
- os.path.join(item["folder"], *relative.split("/")),
437
- item_type,
438
- item["name"],
439
- item["_base_dir"],
440
- )
441
- return
442
-
443
- elif item_type == "pcf":
444
- hit = _pcf_match(flow.request.path, item["name"])
445
- if hit is not None:
446
- relative, is_css = hit
447
- parts = [item["folder"]]
448
- if is_css:
449
- parts.append("css")
450
- parts.extend(segment for segment in relative.split("/") if segment)
451
- _serve_file(flow, os.path.join(*parts), item_type, item["name"], item["_base_dir"])
452
- return
453
-
11
+ # Version 2026-08-21
454
12
 
455
- CONFIG_PATH = _config_path()
13
+ from hummingbird_proxy import DataverseProxy, config_path
456
14
 
457
- addons = [DataverseProxy(CONFIG_PATH)]
15
+ addons = [DataverseProxy(config_path())]
@@ -25,3 +25,10 @@ domain = "myorg.crm.dynamics.com"
25
25
  type = "pcf"
26
26
  name = "test.BookingsEditor"
27
27
  folder = "./bookings-editor/out/controls/BookingsEditor"
28
+
29
+ # Pull in the rules from another project's config so several projects can proxy
30
+ # the same Power Apps environment at once. The included config's relative paths
31
+ # resolve against its own location, and it is watched for live reloads too.
32
+ [[rules]]
33
+ type = "include"
34
+ path = "../bookings-editor/proxy.config.toml"