@hummingbirdworks/proxy 0.1.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
@@ -127,14 +108,70 @@ instructions for your OS/browser.
127
108
  open DevTools → Application → Service Workers and check "Bypass for network"
128
109
  to disable the service worker cache.
129
110
 
130
- ## Config reference (CLI / env)
111
+ ## Using with Vite (HMR)
131
112
 
132
- - **Config path resolution:** the CLI uses the first non-flag argument as the
133
- config path, defaulting to `./proxy.config.toml` in the current directory. It
134
- passes the resolved absolute path to the addon via the
135
- `HUMMINGBIRD_PROXY_CONFIG` environment variable.
136
- - **Running the addon directly** (without the CLI) is also supported:
113
+ 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.
137
115
 
138
- ```sh
139
- HUMMINGBIRD_PROXY_CONFIG=/abs/path/proxy.config.toml mitmdump -s powerapp_dev_proxy.py
140
- ```
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.
122
+
123
+ ### Vite config
124
+
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.
128
+
129
+ ```ts
130
+ import { defineConfig } from 'vite'
131
+ import { svelte } from '@sveltejs/vite-plugin-svelte'
132
+ import { powerAppsWebResource } from '@hummingbirdworks/proxy/vite'
133
+
134
+ export default defineConfig({
135
+ plugins: [
136
+ svelte(), // or react(), vue(), etc.
137
+ powerAppsWebResource({ prefix: 'test_/myapp/' }),
138
+ ],
139
+ server: { port: 5173 },
140
+ })
141
+ ```
142
+
143
+ Options:
144
+
145
+ - `prefix` (required): the web resource path, e.g. `test_/myapp/`.
146
+ - `hmrClientPort` (default `443`): port the browser uses for the HMR socket.
147
+ - `stableFilenames` (default `true`): emit unhashed filenames and a single
148
+ stylesheet. Set `false` to keep Vite's defaults.
149
+
150
+ If your HTML entry isn't `index.html`, add it to `build.rollupOptions.input`.
151
+
152
+ ### Proxy rule
153
+
154
+ Point a `devserver` rule at the dev server. The `url` scheme/port must match
155
+ what Vite serves (`http://localhost:5173` by default here):
156
+
157
+ ```toml
158
+ [[rules]]
159
+ type = "devserver"
160
+ name = "test_/myapp/"
161
+ url = "http://localhost:5173"
162
+ domain = "myorg.crm.dynamics.com"
163
+ ```
164
+
165
+ ### Run
166
+
167
+ Start the dev server and the proxy, then browse through the proxy:
168
+
169
+ ```sh
170
+ npm run dev
171
+ npm run proxy
172
+ ```
173
+
174
+ Open the Dynamics page hosting the web resource. Edit → save → the page updates
175
+ 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).
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@hummingbirdworks/proxy",
3
- "version": "0.1.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"
7
7
  },
8
+ "exports": {
9
+ "./vite": {
10
+ "types": "./vite/index.d.ts",
11
+ "default": "./vite/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
8
15
  "files": [
9
16
  "bin/",
17
+ "vite/",
10
18
  "powerapp_dev_proxy.py",
11
19
  "proxy.config.example.toml",
12
20
  "README.md"
@@ -14,6 +22,14 @@
14
22
  "engines": {
15
23
  "node": ">=16"
16
24
  },
25
+ "peerDependencies": {
26
+ "vite": ">=4"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "vite": {
30
+ "optional": true
31
+ }
32
+ },
17
33
  "keywords": [
18
34
  "mitmproxy",
19
35
  "powerapps",
@@ -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):
@@ -184,7 +247,12 @@ def _web_resource_name(request: http.Request) -> str | None:
184
247
  components = request.path_components
185
248
  for i, component in enumerate(components):
186
249
  if component.lower() == "webresources":
187
- return "/".join(components[i + 1:])
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
188
256
  return None
189
257
 
190
258
 
@@ -214,8 +282,8 @@ def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destinati
214
282
  )
215
283
 
216
284
 
217
- def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str) -> None:
218
- 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)
219
287
  try:
220
288
  with open(absolute_path, "rb") as handle:
221
289
  content = handle.read()
@@ -257,14 +325,21 @@ def _config_mtime(path: str) -> float | None:
257
325
  return None
258
326
 
259
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
+
260
333
  class DataverseProxy:
261
- def __init__(self, config: list[dict]) -> None:
262
- self.config = _validate_config(config)
263
- 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)
264
339
  self._watch_task: asyncio.Task | None = None
265
340
 
266
341
  def running(self) -> None:
267
- # 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.
268
343
  if self._watch_task is None:
269
344
  self._watch_task = asyncio.ensure_future(self._watch_config())
270
345
 
@@ -276,21 +351,27 @@ class DataverseProxy:
276
351
  async def _watch_config(self) -> None:
277
352
  while True:
278
353
  await asyncio.sleep(_CONFIG_POLL_INTERVAL)
279
- mtime = _config_mtime(CONFIG_PATH)
280
- if mtime is None or mtime == self._config_mtime:
354
+ mtimes = _config_mtimes(self._config_paths)
355
+ if mtimes == self._config_mtimes:
281
356
  continue
282
- self._config_mtime = mtime
357
+ self._config_mtimes = mtimes
283
358
  self._reload_config()
284
359
 
285
360
  def _reload_config(self) -> None:
286
361
  try:
287
- config = _validate_config(_load_config(CONFIG_PATH))
362
+ rules, paths = _load_rules(self.config_path)
363
+ config = _validate_config(rules)
288
364
  except (OSError, ValueError) as err:
289
365
  ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
290
366
  return
291
367
  self.config = config
368
+ self._config_paths = paths
369
+ self._config_mtimes = _config_mtimes(paths)
292
370
  _pcf_patterns.clear()
293
- 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
+ )
294
375
 
295
376
  def tls_start_server(self, data: tls.TlsData) -> None:
296
377
  # Provide a no-verify TLS context for localhost dev servers only. This runs
@@ -344,7 +425,7 @@ class DataverseProxy:
344
425
 
345
426
  elif item_type == "single":
346
427
  if web_resource == item["name"]:
347
- _serve_file(flow, item["file"], item_type, item["name"])
428
+ _serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
348
429
  return
349
430
 
350
431
  elif item_type == "folder":
@@ -355,6 +436,7 @@ class DataverseProxy:
355
436
  os.path.join(item["folder"], *relative.split("/")),
356
437
  item_type,
357
438
  item["name"],
439
+ item["_base_dir"],
358
440
  )
359
441
  return
360
442
 
@@ -366,12 +448,10 @@ class DataverseProxy:
366
448
  if is_css:
367
449
  parts.append("css")
368
450
  parts.extend(segment for segment in relative.split("/") if segment)
369
- _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"])
370
452
  return
371
453
 
372
454
 
373
455
  CONFIG_PATH = _config_path()
374
- CONFIG_DIR = os.path.dirname(CONFIG_PATH)
375
- CONFIG = _load_config(CONFIG_PATH)
376
456
 
377
- addons = [DataverseProxy(CONFIG)]
457
+ addons = [DataverseProxy(CONFIG_PATH)]
@@ -0,0 +1,22 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ export interface WebResourceOptions {
4
+ /** Web resource path prefix, e.g. `test_/myapp/`. Leading/trailing slashes are optional. */
5
+ prefix: string;
6
+ /** Port the browser uses for the HMR websocket. Defaults to `443`. */
7
+ hmrClientPort?: number;
8
+ /**
9
+ * Emit unhashed filenames and a single stylesheet so solution components stay
10
+ * stable across builds. Defaults to `true`; set `false` to keep Vite's defaults.
11
+ */
12
+ stableFilenames?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Vite plugin that aligns the dev server and build output with a Dataverse /
17
+ * Dynamics 365 web resource served through `@hummingbirdworks/proxy`.
18
+ *
19
+ * Sets `base` (absolute in dev, relative in build) and a `wss` HMR socket, and
20
+ * optionally emits stable filenames.
21
+ */
22
+ export function powerAppsWebResource(options: WebResourceOptions): Plugin;
package/vite/index.js ADDED
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ // Vite plugin that configures a project so its dev server and build output line
4
+ // up with a Dataverse / Dynamics 365 web resource served through the proxy.
5
+
6
+ function powerAppsWebResource(options) {
7
+ if (!options || typeof options.prefix !== "string") {
8
+ throw new Error(
9
+ "powerAppsWebResource: 'prefix' is required, e.g. 'test_/myapp/'."
10
+ );
11
+ }
12
+ const prefix = options.prefix.replace(/^\/+|\/+$/g, "");
13
+ if (!prefix) {
14
+ throw new Error(
15
+ "powerAppsWebResource: 'prefix' must not be empty, e.g. 'test_/myapp/'."
16
+ );
17
+ }
18
+ const clientPort = options.hmrClientPort != null ? options.hmrClientPort : 443;
19
+ const stableFilenames = options.stableFilenames !== false;
20
+
21
+ return {
22
+ name: "@hummingbirdworks/proxy:webresource",
23
+ config(_config, env) {
24
+ const isServe = env.command === "serve";
25
+ // Dynamics pages are HTTPS, so the HMR socket must be wss to avoid
26
+ // mixed-content blocking; the proxy relays it to the HTTP dev server.
27
+ const config = {
28
+ base: isServe ? "/webresources/" + prefix + "/" : "./",
29
+ server: { hmr: { protocol: "wss", clientPort: clientPort } },
30
+ };
31
+ if (stableFilenames) {
32
+ config.build = {
33
+ cssCodeSplit: false,
34
+ rollupOptions: {
35
+ output: {
36
+ // Stable filenames — Dynamics does its own cache busting on publish.
37
+ entryFileNames: "[name].js",
38
+ chunkFileNames: "[name].js",
39
+ assetFileNames: "[name].[ext]",
40
+ },
41
+ },
42
+ };
43
+ }
44
+ return config;
45
+ },
46
+ };
47
+ }
48
+
49
+ exports.powerAppsWebResource = powerAppsWebResource;