@brightspot/ui-builder 2.0.0 → 2.0.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.
@@ -13,6 +13,13 @@ export async function dev(opts) {
13
13
  const config = await createDevConfig({ builder, target, port: opts.port });
14
14
  const server = await createServer(config);
15
15
  await server.listen();
16
+ // Proxy root rarely renders useful HTML; land cmd-click on the app path.
17
+ const openPath = builder.dev?.openPath ?? '/cms/';
18
+ if (server.resolvedUrls && openPath !== '/') {
19
+ const append = (u) => new URL(openPath, u).href;
20
+ server.resolvedUrls.local = server.resolvedUrls.local.map(append);
21
+ server.resolvedUrls.network = server.resolvedUrls.network.map(append);
22
+ }
16
23
  server.printUrls();
17
24
  log.success('Dev server running — proxying to ' + target);
18
25
  }
@@ -8,6 +8,10 @@ export interface BuilderConfig {
8
8
  jsName: string;
9
9
  cssName: string;
10
10
  };
11
+ dev?: {
12
+ /** Path appended to printed dev URLs. "/" disables. */
13
+ openPath: string;
14
+ };
11
15
  }
12
16
  export interface ResolveResult {
13
17
  config: BuilderConfig;
@@ -14,6 +14,9 @@ const DEFAULTS = {
14
14
  jsName: 'bundle.js',
15
15
  cssName: 'style.css',
16
16
  },
17
+ dev: {
18
+ openPath: '/cms/',
19
+ },
17
20
  };
18
21
  export function resolveConfig() {
19
22
  const pkgPath = path.resolve(process.cwd(), 'package.json');
@@ -38,7 +41,17 @@ export function resolveConfig() {
38
41
  jsName: raw.output?.jsName ?? DEFAULTS.output.jsName,
39
42
  cssName: raw.output?.cssName ?? DEFAULTS.output.cssName,
40
43
  };
41
- return { config: { basePath, entry, format, name, output }, warnings };
44
+ const openPath = resolveOpenPath(raw.dev?.openPath);
45
+ return { config: { basePath, entry, format, name, output, dev: { openPath } }, warnings };
46
+ }
47
+ function resolveOpenPath(explicit) {
48
+ const value = explicit ?? DEFAULTS.dev.openPath;
49
+ // Reject protocol-relative ('//host/...') so a misconfigured openPath
50
+ // can't redirect the printed dev URL off-site.
51
+ if (!value.startsWith('/') || value.startsWith('//')) {
52
+ throw new ConfigError(`dev.openPath "${value}" must start with a single "/".`);
53
+ }
54
+ return value;
42
55
  }
43
56
  function resolveBasePath(explicit, pkgName, warnings) {
44
57
  if (explicit) {
@@ -0,0 +1,19 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /**
3
+ * Build a `proxyRes` handler that strips occurrences of the upstream
4
+ * origin from HTML response bodies. Brightspot embeds absolute URLs
5
+ * (e.g. `https://localhost/storage/resource_resource/foo.js`) for
6
+ * storage assets, generated from a CDN setting that ignores
7
+ * X-Forwarded-* headers. Without rewriting, the browser at the proxy
8
+ * origin (`https://localhost:5173`) would issue cross-origin requests
9
+ * back to the upstream — module scripts then fail CORS, and mixed
10
+ * content blocks plain-HTTP origins. Stripping the origin makes those
11
+ * URLs relative, so the browser routes them through the dev proxy.
12
+ *
13
+ * Non-HTML responses pass through untouched. Empty / no-content
14
+ * statuses pass through too.
15
+ *
16
+ * Requires `selfHandleResponse: true` on the proxy entry so the
17
+ * response stream isn't already piped to the client.
18
+ */
19
+ export declare function rewriteHtmlBody(target: string): (proxyRes: IncomingMessage, _req: IncomingMessage, res: ServerResponse) => void;
@@ -0,0 +1,125 @@
1
+ import zlib from 'node:zlib';
2
+ /**
3
+ * Build a `proxyRes` handler that strips occurrences of the upstream
4
+ * origin from HTML response bodies. Brightspot embeds absolute URLs
5
+ * (e.g. `https://localhost/storage/resource_resource/foo.js`) for
6
+ * storage assets, generated from a CDN setting that ignores
7
+ * X-Forwarded-* headers. Without rewriting, the browser at the proxy
8
+ * origin (`https://localhost:5173`) would issue cross-origin requests
9
+ * back to the upstream — module scripts then fail CORS, and mixed
10
+ * content blocks plain-HTTP origins. Stripping the origin makes those
11
+ * URLs relative, so the browser routes them through the dev proxy.
12
+ *
13
+ * Non-HTML responses pass through untouched. Empty / no-content
14
+ * statuses pass through too.
15
+ *
16
+ * Requires `selfHandleResponse: true` on the proxy entry so the
17
+ * response stream isn't already piped to the client.
18
+ */
19
+ export function rewriteHtmlBody(target) {
20
+ const originPatterns = buildOriginPatterns(target);
21
+ return (proxyRes, _req, res) => {
22
+ const status = proxyRes.statusCode ?? 200;
23
+ const headers = sanitizeHeaders(proxyRes.headers);
24
+ // selfHandleResponse: true disables the proxy library's built-in
25
+ // autoRewrite/protocolRewrite, so redirect Location headers come
26
+ // through pointing at the upstream origin. Strip the upstream
27
+ // origin so the browser follows the redirect via the proxy, not
28
+ // directly to the upstream (which would be cross-origin / wrong
29
+ // port / wrong scheme).
30
+ const loc = headers['location'];
31
+ if (typeof loc === 'string') {
32
+ let rewritten = loc;
33
+ for (const re of originPatterns)
34
+ rewritten = rewritten.replace(re, '');
35
+ if (rewritten !== loc)
36
+ headers['location'] = rewritten;
37
+ }
38
+ const ct = String(headers['content-type'] ?? '').toLowerCase();
39
+ const isHtml = ct.includes('text/html');
40
+ const isEmpty = status === 204 || status === 304;
41
+ if (!isHtml || isEmpty) {
42
+ res.writeHead(status, headers);
43
+ proxyRes.pipe(res);
44
+ return;
45
+ }
46
+ const stream = decodeStream(proxyRes, String(proxyRes.headers['content-encoding'] ?? ''));
47
+ const chunks = [];
48
+ stream.on('data', c => chunks.push(c));
49
+ stream.on('error', err => {
50
+ res.writeHead(502, { 'content-type': 'text/plain' });
51
+ res.end(`proxy decode error: ${err.message}`);
52
+ });
53
+ stream.on('end', () => {
54
+ let body = Buffer.concat(chunks).toString('utf8');
55
+ for (const re of originPatterns)
56
+ body = body.replace(re, '');
57
+ const out = Buffer.from(body, 'utf8');
58
+ headers['content-length'] = String(out.length);
59
+ res.writeHead(status, headers);
60
+ res.end(out);
61
+ });
62
+ };
63
+ }
64
+ // Vite serves over HTTP/2 (mkcert), but the upstream speaks HTTP/1.1.
65
+ // HTTP/2 forbids connection-specific headers and transfer-encoding;
66
+ // passing them through triggers ERR_HTTP2_INVALID_CONNECTION_HEADERS
67
+ // in node's writeHead. Also drop content-encoding because we always
68
+ // decompress and re-emit identity. content-length is recomputed by
69
+ // the caller after rewriting.
70
+ function sanitizeHeaders(input) {
71
+ const dropped = new Set([
72
+ 'connection',
73
+ 'transfer-encoding',
74
+ 'keep-alive',
75
+ 'proxy-authenticate',
76
+ 'proxy-authorization',
77
+ 'te',
78
+ 'trailer',
79
+ 'upgrade',
80
+ 'http2-settings',
81
+ 'content-encoding',
82
+ 'content-length',
83
+ ]);
84
+ const out = {};
85
+ for (const [k, v] of Object.entries(input)) {
86
+ if (v === undefined)
87
+ continue;
88
+ if (dropped.has(k.toLowerCase()))
89
+ continue;
90
+ out[k] = v;
91
+ }
92
+ return out;
93
+ }
94
+ function decodeStream(stream, encoding) {
95
+ switch (encoding.toLowerCase()) {
96
+ case 'gzip':
97
+ return stream.pipe(zlib.createGunzip());
98
+ case 'br':
99
+ return stream.pipe(zlib.createBrotliDecompress());
100
+ case 'deflate':
101
+ return stream.pipe(zlib.createInflate());
102
+ default:
103
+ return stream;
104
+ }
105
+ }
106
+ // Build regexes that match the upstream origin in a few common
107
+ // forms — both `http://` and `https://` so an http target still
108
+ // strips https variants the CMS may emit when X-Forwarded-Proto is
109
+ // honored, and an explicit-port form so e.g. http://localhost:8080
110
+ // strips both with and without :8080.
111
+ function buildOriginPatterns(target) {
112
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ const u = new URL(target);
114
+ const host = u.hostname;
115
+ const explicitPort = u.port;
116
+ // Form: scheme://host(:port)? — only strip when followed by a path
117
+ // boundary so we don't eat unrelated strings that happen to share
118
+ // the prefix.
119
+ const patterns = [
120
+ new RegExp(`https?://${escape(host)}(?=[/'"\\)\\s])`, 'g'),
121
+ new RegExp(`https?://${escape(host)}:\\d+(?=[/'"\\)\\s])`, 'g'),
122
+ ];
123
+ void explicitPort;
124
+ return patterns;
125
+ }
@@ -3,6 +3,11 @@ import { loadConfigFromFile, mergeConfig } from 'vite';
3
3
  import { log } from '../lib/logger.js';
4
4
  import { loadAutoPlugins } from './auto-plugins.js';
5
5
  import { devEntryPlugin } from './plugin-html-inject.js';
6
+ // Mask define.amd around the IIFE so UMD-wrapped deps (e.g. loglevel) take
7
+ // the global branch — host-page RequireJS rejects anonymous define() calls.
8
+ // Namespaced var name reduces collision risk with bundled UMD code.
9
+ const AMD_GUARD_INTRO = 'var __bsp_amd=typeof define!=="undefined"&&define&&define.amd;if(__bsp_amd)define.amd=void 0;try{';
10
+ const AMD_GUARD_OUTRO = '}finally{if(__bsp_amd)define.amd=__bsp_amd;}';
6
11
  export async function createDevConfig({ builder, target, port }) {
7
12
  const cwd = process.cwd();
8
13
  const { basePath, entry, output: { jsName, cssName }, } = builder;
@@ -13,6 +18,10 @@ export async function createDevConfig({ builder, target, port }) {
13
18
  // that case so /index.ts isn't proxied to the upstream application.
14
19
  const entryParts = entry.split('/');
15
20
  const entryDirAlt = entryParts.length > 1 ? `|${escapeRegex(entryParts[0])}/` : '';
21
+ // x-forwarded-proto must match the upstream's scheme — otherwise an
22
+ // http CMS canonicalises to its toolUrlPrefix and autoRewrite loops
23
+ // the 302 back through the dev origin.
24
+ const targetScheme = new URL(target).protocol === 'https:' ? 'https' : 'http';
16
25
  const [mkcert, autoPlugins] = await Promise.all([loadMkcertPlugin(), loadAutoPlugins(cwd)]);
17
26
  const base = {
18
27
  root: cwd,
@@ -27,14 +36,21 @@ export async function createDevConfig({ builder, target, port }) {
27
36
  },
28
37
  server: {
29
38
  port,
39
+ // Default HMR path '/' falls through the proxy to the upstream CMS
40
+ // and 404s; '/@vite/*' is in the regex exclusion list below.
41
+ hmr: { path: '/@vite/hmr' },
30
42
  proxy: {
31
43
  [`^/(?!(${escaped}${entryDirAlt}|@vite|@fs|node_modules))`]: {
32
44
  target,
45
+ // Local CMS dev servers use self-signed certs.
46
+ secure: false,
47
+ // protocolRewrite: Vite is mkcert-https only; http redirects would 404.
33
48
  autoRewrite: true,
49
+ protocolRewrite: 'https',
34
50
  changeOrigin: true,
35
51
  configure: proxy => {
36
52
  proxy.on('proxyReq', proxyReq => {
37
- proxyReq.setHeader('x-forwarded-proto', 'https');
53
+ proxyReq.setHeader('x-forwarded-proto', targetScheme);
38
54
  proxyReq.setHeader('x-brightspot-dev', '1');
39
55
  });
40
56
  },
@@ -96,6 +112,7 @@ export async function createBuildConfig({ builder }) {
96
112
  rollupOptions: {
97
113
  output: {
98
114
  assetFileNames: () => output.cssName,
115
+ ...(format === 'iife' ? { intro: AMD_GUARD_INTRO, outro: AMD_GUARD_OUTRO } : {}),
99
116
  },
100
117
  },
101
118
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brightspot/ui-builder",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "description": "Zero-config build toolkit for Brightspot CMS front-end development.",