@hummingbirdworks/proxy 0.2.0 → 0.2.1
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 +4 -23
- package/package.json +1 -1
- package/powerapp_dev_proxy.py +95 -20
package/README.md
CHANGED
|
@@ -6,14 +6,6 @@ local dev builds or a running dev server (e.g. Vite). This lets you iterate on
|
|
|
6
6
|
web resources and PCF controls locally without deploying to the environment on
|
|
7
7
|
every change.
|
|
8
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
9
|
## Prerequisites
|
|
18
10
|
|
|
19
11
|
- **Node.js** >= 16 (to run the CLI).
|
|
@@ -21,24 +13,15 @@ relative to the config file**, so the config is portable across machines.
|
|
|
21
13
|
(Python 3.11+, for stdlib TOML support).
|
|
22
14
|
Install from <https://www.mitmproxy.org/> — e.g. `pipx install mitmproxy`.
|
|
23
15
|
|
|
24
|
-
## Install
|
|
16
|
+
## Install and Configure
|
|
25
17
|
|
|
26
18
|
```sh
|
|
27
19
|
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
20
|
npx hummingbird-proxy init
|
|
36
21
|
```
|
|
37
22
|
|
|
38
|
-
This
|
|
39
|
-
script (`"proxy": "hummingbird-proxy"`) to your `package.json`.
|
|
40
|
-
to overwrite an existing config/script. (You can also copy the example manually
|
|
41
|
-
from `node_modules/@hummingbirdworks/proxy/proxy.config.example.toml`.)
|
|
23
|
+
This creates a proxy.config.toml in the current directory and adds a `proxy`
|
|
24
|
+
script (`"proxy": "hummingbird-proxy"`) to your `package.json`.
|
|
42
25
|
|
|
43
26
|
The config is a TOML file. It's a table with a `rules` array-of-tables; each
|
|
44
27
|
`[[rules]]` entry is one redirect:
|
|
@@ -82,8 +65,6 @@ Run the proxy from the folder containing `proxy.config.toml`:
|
|
|
82
65
|
|
|
83
66
|
```sh
|
|
84
67
|
npm run proxy
|
|
85
|
-
# or, without the added script:
|
|
86
|
-
npx hummingbird-proxy
|
|
87
68
|
```
|
|
88
69
|
|
|
89
70
|
Or point it at a specific config file:
|
|
@@ -112,7 +93,7 @@ On first use, install the mitmproxy root certificate so HTTPS interception
|
|
|
112
93
|
works: with the proxied browser open, visit <http://mitm.it/> and follow the
|
|
113
94
|
instructions for your OS/browser.
|
|
114
95
|
|
|
115
|
-
### Notes
|
|
96
|
+
### ⚠️ **Notes**
|
|
116
97
|
|
|
117
98
|
- Chrome and Edge share a single background process across all windows. Either
|
|
118
99
|
close all Chrome/Edge windows before starting the proxy, or use a separate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hummingbirdworks/proxy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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"
|
package/powerapp_dev_proxy.py
CHANGED
|
@@ -64,6 +64,14 @@ Redirect a PCF control to its local build output folder:
|
|
|
64
64
|
name = "test.BookingsEditor"
|
|
65
65
|
folder = "./bookings-editor/out/controls/BookingsEditor"
|
|
66
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
|
+
|
|
67
75
|
Optional keys on any entry:
|
|
68
76
|
"domain": omit for all hosts, a host string, or an array of host strings
|
|
69
77
|
"disabled": true to skip the entry
|
|
@@ -111,6 +119,17 @@ _CONFIG_SCHEMA: dict[str, set[str]] = {
|
|
|
111
119
|
}
|
|
112
120
|
_OPTIONAL_KEYS = {"type", "domain", "disabled"}
|
|
113
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
|
+
|
|
114
133
|
|
|
115
134
|
def _config_path() -> str:
|
|
116
135
|
"""Absolute path of the TOML config file to load."""
|
|
@@ -138,11 +157,55 @@ def _load_config(path: str) -> list[dict]:
|
|
|
138
157
|
return rules
|
|
139
158
|
|
|
140
159
|
|
|
141
|
-
def
|
|
142
|
-
"""
|
|
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."""
|
|
143
206
|
if os.path.isabs(path):
|
|
144
207
|
return path
|
|
145
|
-
return os.path.normpath(os.path.join(
|
|
208
|
+
return os.path.normpath(os.path.join(base_dir, path))
|
|
146
209
|
|
|
147
210
|
|
|
148
211
|
def _validate_config(config: list[dict]) -> list[dict]:
|
|
@@ -162,7 +225,7 @@ def _validate_config(config: list[dict]) -> list[dict]:
|
|
|
162
225
|
missing = required - item.keys()
|
|
163
226
|
if missing:
|
|
164
227
|
raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
|
|
165
|
-
unknown = item.keys() - (required | _OPTIONAL_KEYS)
|
|
228
|
+
unknown = item.keys() - (required | _OPTIONAL_KEYS | _INTERNAL_KEYS)
|
|
166
229
|
if unknown:
|
|
167
230
|
raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
|
|
168
231
|
if not item.get("disabled", False):
|
|
@@ -219,8 +282,8 @@ def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destinati
|
|
|
219
282
|
)
|
|
220
283
|
|
|
221
284
|
|
|
222
|
-
def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str) -> None:
|
|
223
|
-
absolute_path = _resolve_path(filepath)
|
|
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)
|
|
224
287
|
try:
|
|
225
288
|
with open(absolute_path, "rb") as handle:
|
|
226
289
|
content = handle.read()
|
|
@@ -262,14 +325,21 @@ def _config_mtime(path: str) -> float | None:
|
|
|
262
325
|
return None
|
|
263
326
|
|
|
264
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
|
+
|
|
265
333
|
class DataverseProxy:
|
|
266
|
-
def __init__(self,
|
|
267
|
-
self.
|
|
268
|
-
self.
|
|
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)
|
|
269
339
|
self._watch_task: asyncio.Task | None = None
|
|
270
340
|
|
|
271
341
|
def running(self) -> None:
|
|
272
|
-
# Start watching the config
|
|
342
|
+
# Start watching the config files for changes once the event loop is up.
|
|
273
343
|
if self._watch_task is None:
|
|
274
344
|
self._watch_task = asyncio.ensure_future(self._watch_config())
|
|
275
345
|
|
|
@@ -281,21 +351,27 @@ class DataverseProxy:
|
|
|
281
351
|
async def _watch_config(self) -> None:
|
|
282
352
|
while True:
|
|
283
353
|
await asyncio.sleep(_CONFIG_POLL_INTERVAL)
|
|
284
|
-
|
|
285
|
-
if
|
|
354
|
+
mtimes = _config_mtimes(self._config_paths)
|
|
355
|
+
if mtimes == self._config_mtimes:
|
|
286
356
|
continue
|
|
287
|
-
self.
|
|
357
|
+
self._config_mtimes = mtimes
|
|
288
358
|
self._reload_config()
|
|
289
359
|
|
|
290
360
|
def _reload_config(self) -> None:
|
|
291
361
|
try:
|
|
292
|
-
|
|
362
|
+
rules, paths = _load_rules(self.config_path)
|
|
363
|
+
config = _validate_config(rules)
|
|
293
364
|
except (OSError, ValueError) as err:
|
|
294
365
|
ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
|
|
295
366
|
return
|
|
296
367
|
self.config = config
|
|
368
|
+
self._config_paths = paths
|
|
369
|
+
self._config_mtimes = _config_mtimes(paths)
|
|
297
370
|
_pcf_patterns.clear()
|
|
298
|
-
ctx.log.info(
|
|
371
|
+
ctx.log.info(
|
|
372
|
+
f"Reloaded proxy config from {self.config_path} "
|
|
373
|
+
f"({len(config)} active rules across {len(paths)} files)"
|
|
374
|
+
)
|
|
299
375
|
|
|
300
376
|
def tls_start_server(self, data: tls.TlsData) -> None:
|
|
301
377
|
# Provide a no-verify TLS context for localhost dev servers only. This runs
|
|
@@ -349,7 +425,7 @@ class DataverseProxy:
|
|
|
349
425
|
|
|
350
426
|
elif item_type == "single":
|
|
351
427
|
if web_resource == item["name"]:
|
|
352
|
-
_serve_file(flow, item["file"], item_type, item["name"])
|
|
428
|
+
_serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
|
|
353
429
|
return
|
|
354
430
|
|
|
355
431
|
elif item_type == "folder":
|
|
@@ -360,6 +436,7 @@ class DataverseProxy:
|
|
|
360
436
|
os.path.join(item["folder"], *relative.split("/")),
|
|
361
437
|
item_type,
|
|
362
438
|
item["name"],
|
|
439
|
+
item["_base_dir"],
|
|
363
440
|
)
|
|
364
441
|
return
|
|
365
442
|
|
|
@@ -371,12 +448,10 @@ class DataverseProxy:
|
|
|
371
448
|
if is_css:
|
|
372
449
|
parts.append("css")
|
|
373
450
|
parts.extend(segment for segment in relative.split("/") if segment)
|
|
374
|
-
_serve_file(flow, os.path.join(*parts), item_type, item["name"])
|
|
451
|
+
_serve_file(flow, os.path.join(*parts), item_type, item["name"], item["_base_dir"])
|
|
375
452
|
return
|
|
376
453
|
|
|
377
454
|
|
|
378
455
|
CONFIG_PATH = _config_path()
|
|
379
|
-
CONFIG_DIR = os.path.dirname(CONFIG_PATH)
|
|
380
|
-
CONFIG = _load_config(CONFIG_PATH)
|
|
381
456
|
|
|
382
|
-
addons = [DataverseProxy(
|
|
457
|
+
addons = [DataverseProxy(CONFIG_PATH)]
|