@hummingbirdworks/proxy 0.2.0 → 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,43 +2,23 @@
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.
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.
5
+ local dev builds or a running dev server (e.g. Vite), so you can iterate
6
+ locally without deploying on every change.
16
7
 
17
8
  ## Prerequisites
18
9
 
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`.
10
+ - **Node.js** >= 16.
11
+ - **mitmproxy** install from <https://www.mitmproxy.org/> e.g. `pipx install mitmproxy`.
23
12
 
24
- ## Install
13
+ ## Install and Configure
25
14
 
26
15
  ```sh
27
16
  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
17
  npx hummingbird-proxy init
36
18
  ```
37
19
 
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`.)
20
+ This creates a proxy.config.toml in the current directory and adds a `proxy`
21
+ script (`"proxy": "hummingbird-proxy"`) to your `package.json`.
42
22
 
43
23
  The config is a TOML file. It's a table with a `rules` array-of-tables; each
44
24
  `[[rules]]` entry is one redirect:
@@ -76,25 +56,30 @@ Optional keys on any rule:
76
56
  - `domain`: a host string, or an array of host strings. Omit for all hosts.
77
57
  - `disabled`: `true` to skip the rule.
78
58
 
79
- ## Usage
80
-
81
- Run the proxy from the folder containing `proxy.config.toml`:
59
+ ### Combining several projects with `include`
82
60
 
83
- ```sh
84
- npm run proxy
85
- # or, without the added script:
86
- npx hummingbird-proxy
87
- ```
61
+ To proxy several projects against the same environment from one running proxy,
62
+ `include` their configs instead of switching proxies as you navigate:
88
63
 
89
- Or point it at a specific config file:
64
+ ```toml
65
+ [[rules]]
66
+ type = "include"
67
+ path = "../invoice-editor/proxy.config.toml"
90
68
 
91
- ```sh
92
- 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'
93
73
  ```
94
74
 
95
- 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):
96
79
 
97
80
  ```sh
81
+ npm run proxy
82
+ # or a specific config, forwarding args to mitmdump (e.g. change the port)
98
83
  npx hummingbird-proxy ./proxy.config.toml -p 8888
99
84
  ```
100
85
 
@@ -108,42 +93,29 @@ chrome.exe --proxy-server="http://localhost:8080"
108
93
 
109
94
  ### First-time setup
110
95
 
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
96
+ Install the mitmproxy root certificate so HTTPS interception works: with the
97
+ proxied browser open, visit <http://mitm.it/> and follow the instructions.
116
98
 
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:
99
+ ### ⚠️ **Notes**
120
100
 
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.
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").
129
107
 
130
108
  ## Using with Vite (HMR)
131
109
 
132
110
  A `devserver` rule routes a web resource's requests to a running Vite dev
133
- server, giving you hot module reload on the Dynamics-hosted page.
134
-
135
- The dev server can stay on plain HTTP — the browser only talks to the proxy, and
136
- the proxy relays requests (including the HMR websocket) to `localhost`. Because
137
- the Dynamics page is HTTPS, the browser would block an insecure `ws://` HMR
138
- socket as mixed content, so point the HMR client at `wss` and let the proxy
139
- forward it to the HTTP dev server. No dev-server cert (or `vite-plugin-mkcert`)
140
- 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.
141
114
 
142
115
  ### Vite config
143
116
 
144
- The package ships a Vite plugin that helps with the configuration of web resources.
145
- It sets the base path, configures settings to support HMR with the proxy, and turns
146
- 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).
147
119
 
148
120
  ```ts
149
121
  import { defineConfig } from 'vite'
@@ -192,5 +164,4 @@ npm run proxy
192
164
 
193
165
  Open the Dynamics page hosting the web resource. Edit → save → the page updates
194
166
  without a manual refresh. If HMR doesn't trigger, confirm Vite's port matches
195
- the rule `url`, that the HMR socket connects over `wss`, and that the service
196
- 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.0",
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,382 +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
- 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
- --------------------------------------------------------------------------------
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.
78
9
  """
79
10
 
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
- name = "/".join(components[i + 1:])
188
- # path_components drops a trailing slash; keep it so dev-server root
189
- # requests (e.g. the Vite HMR base URL) still match a rule prefix.
190
- if name and request.path.split("?", 1)[0].endswith("/"):
191
- name += "/"
192
- return name
193
- return None
194
-
195
-
196
- _pcf_patterns: dict[str, re.Pattern[str]] = {}
197
-
198
-
199
- def _pcf_match(path: str, name: str) -> tuple[str, bool] | None:
200
- """Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
201
- pattern = _pcf_patterns.get(name)
202
- if pattern is None:
203
- pattern = re.compile(
204
- r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
205
- + re.escape(name)
206
- + r"(?:\.|/)(?P<rest>[^?]*)",
207
- re.IGNORECASE,
208
- )
209
- _pcf_patterns[name] = pattern
210
- match = pattern.search(path)
211
- if match is None:
212
- return None
213
- return match.group("rest"), bool(match.group("css"))
214
-
215
-
216
- def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
217
- ctx.log.info(
218
- f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
219
- )
220
-
221
-
222
- def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str) -> None:
223
- absolute_path = _resolve_path(filepath)
224
- try:
225
- with open(absolute_path, "rb") as handle:
226
- content = handle.read()
227
- except (FileNotFoundError, IsADirectoryError):
228
- ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
229
- flow.response = http.Response.make(
230
- 404,
231
- f"dataverseproxy: local file not found: {absolute_path}".encode(),
232
- {"Content-Type": "text/plain"},
233
- )
234
- return
235
- content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
236
- flow.response = http.Response.make(200, content, {"Content-Type": content_type})
237
- _log_redirect(flow, rule_type, rule_name, absolute_path)
238
-
239
-
240
- def _proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
241
- target = urlsplit(url)
242
- query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
243
- new_path = "/webresources/" + web_resource
244
- if query:
245
- new_path += "?" + query
246
- flow.request.scheme = target.scheme
247
- flow.request.host = target.hostname or "localhost"
248
- flow.request.port = target.port or (443 if target.scheme == "https" else 80)
249
- flow.request.path = new_path
250
- _log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
251
-
252
-
253
- # How often (seconds) to poll the config file for changes.
254
- _CONFIG_POLL_INTERVAL = 1.0
255
-
256
-
257
- def _config_mtime(path: str) -> float | None:
258
- """Modification time of the config file, or None if it is missing."""
259
- try:
260
- return os.path.getmtime(path)
261
- except OSError:
262
- return None
263
-
264
-
265
- class DataverseProxy:
266
- def __init__(self, config: list[dict]) -> None:
267
- self.config = _validate_config(config)
268
- self._config_mtime = _config_mtime(CONFIG_PATH)
269
- self._watch_task: asyncio.Task | None = None
270
-
271
- def running(self) -> None:
272
- # Start watching the config file for changes once the event loop is up.
273
- if self._watch_task is None:
274
- self._watch_task = asyncio.ensure_future(self._watch_config())
275
-
276
- def done(self) -> None:
277
- if self._watch_task is not None:
278
- self._watch_task.cancel()
279
- self._watch_task = None
280
-
281
- async def _watch_config(self) -> None:
282
- while True:
283
- await asyncio.sleep(_CONFIG_POLL_INTERVAL)
284
- mtime = _config_mtime(CONFIG_PATH)
285
- if mtime is None or mtime == self._config_mtime:
286
- continue
287
- self._config_mtime = mtime
288
- self._reload_config()
289
-
290
- def _reload_config(self) -> None:
291
- try:
292
- config = _validate_config(_load_config(CONFIG_PATH))
293
- except (OSError, ValueError) as err:
294
- ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
295
- return
296
- self.config = config
297
- _pcf_patterns.clear()
298
- ctx.log.info(f"Reloaded proxy config from {CONFIG_PATH} ({len(config)} active rules)")
299
-
300
- def tls_start_server(self, data: tls.TlsData) -> None:
301
- # Provide a no-verify TLS context for localhost dev servers only. This runs
302
- # before the built-in TlsConfig addon; once data.ssl_conn is set, TlsConfig
303
- # skips the connection, so every other (real) upstream keeps full
304
- # certificate verification.
305
- server = data.conn
306
- if data.ssl_conn is not None or not isinstance(server, connection.Server):
307
- return
308
- if not server.address or server.address[0] not in LOOPBACK_HOSTS:
309
- return
310
-
311
- ssl_ctx = net_tls.create_proxy_server_context(
312
- method=net_tls.Method.TLS_CLIENT_METHOD,
313
- min_version=net_tls.Version[ctx.options.tls_version_server_min],
314
- max_version=net_tls.Version[ctx.options.tls_version_server_max],
315
- cipher_list=None,
316
- ecdh_curve=None,
317
- verify=net_tls.Verify.VERIFY_NONE,
318
- ca_path=None,
319
- ca_pemfile=None,
320
- client_cert=None,
321
- legacy_server_connect=False,
322
- )
323
- ssl_conn = SSL.Connection(ssl_ctx)
324
- sni = server.sni or server.address[0]
325
- try:
326
- ipaddress.ip_address(sni)
327
- except ValueError:
328
- ssl_conn.set_tlsext_host_name(sni.encode("idna"))
329
- alpn_offers = server.alpn_offers or data.context.client.alpn_offers
330
- if alpn_offers:
331
- ssl_conn.set_alpn_protos(list(alpn_offers))
332
- ssl_conn.set_connect_state()
333
- data.ssl_conn = ssl_conn
334
-
335
- def request(self, flow: http.HTTPFlow) -> None:
336
- host = flow.request.pretty_host
337
- web_resource = _web_resource_name(flow.request)
338
-
339
- for item in self.config:
340
- if not _domain_matches(item.get("domain"), host):
341
- continue
342
-
343
- item_type = item["type"]
344
-
345
- if item_type == "devserver":
346
- if web_resource is not None and web_resource.startswith(item["name"]):
347
- _proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
348
- return
349
-
350
- elif item_type == "single":
351
- if web_resource == item["name"]:
352
- _serve_file(flow, item["file"], item_type, item["name"])
353
- return
354
-
355
- elif item_type == "folder":
356
- if web_resource is not None and web_resource.startswith(item["name"]):
357
- relative = web_resource[len(item["name"]):]
358
- _serve_file(
359
- flow,
360
- os.path.join(item["folder"], *relative.split("/")),
361
- item_type,
362
- item["name"],
363
- )
364
- return
365
-
366
- elif item_type == "pcf":
367
- hit = _pcf_match(flow.request.path, item["name"])
368
- if hit is not None:
369
- relative, is_css = hit
370
- parts = [item["folder"]]
371
- if is_css:
372
- parts.append("css")
373
- parts.extend(segment for segment in relative.split("/") if segment)
374
- _serve_file(flow, os.path.join(*parts), item_type, item["name"])
375
- return
376
-
11
+ # Version 2026-08-21
377
12
 
378
- CONFIG_PATH = _config_path()
379
- CONFIG_DIR = os.path.dirname(CONFIG_PATH)
380
- CONFIG = _load_config(CONFIG_PATH)
13
+ from hummingbird_proxy import DataverseProxy, config_path
381
14
 
382
- addons = [DataverseProxy(CONFIG)]
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"