@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 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 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`.)
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.0",
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"
@@ -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 _resolve_path(path: str) -> str:
142
- """Convert a path to absolute, relative to the config file directory."""
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(CONFIG_DIR, path))
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, config: list[dict]) -> None:
267
- self.config = _validate_config(config)
268
- self._config_mtime = _config_mtime(CONFIG_PATH)
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 file for changes once the event loop is up.
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
- mtime = _config_mtime(CONFIG_PATH)
285
- if mtime is None or mtime == self._config_mtime:
354
+ mtimes = _config_mtimes(self._config_paths)
355
+ if mtimes == self._config_mtimes:
286
356
  continue
287
- self._config_mtime = mtime
357
+ self._config_mtimes = mtimes
288
358
  self._reload_config()
289
359
 
290
360
  def _reload_config(self) -> None:
291
361
  try:
292
- config = _validate_config(_load_config(CONFIG_PATH))
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(f"Reloaded proxy config from {CONFIG_PATH} ({len(config)} active rules)")
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(CONFIG)]
457
+ addons = [DataverseProxy(CONFIG_PATH)]