ports-manager 1.0.1 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ports-manager",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "author": "Muhammad Saad Amin",
5
5
  "description": "Run paired frontend/backend projects on free ports, with the frontend automatically wired to wherever the backend landed and CORS handled for you.",
6
6
  "main": "./src/index.js",
package/src/detect.js CHANGED
@@ -230,10 +230,15 @@ function scanCorsEnvNames(project) {
230
230
  });
231
231
  }
232
232
 
233
- /** Frontend env names that carry a backend URL, e.g. VITE_API_BASE or REACT_APP_API_URL. */
234
- function scanFrontendApiEnvNames(project) {
233
+ /**
234
+ * Frontend env names carrying a backend URL, with the literal each usage falls back to.
235
+ * The fallback matters: `import.meta.env.VITE_API_URL || 'http://localhost:5000'` tells us
236
+ * which service that name refers to, so we can retarget the right one and leave a name
237
+ * pointing at some other service alone.
238
+ */
239
+ function scanFrontendApiCandidates(project) {
235
240
  const source = collectSources(project.directory).map((entry) => entry.source).join('\n');
236
- const names = new Set();
241
+ const found = new Map();
237
242
  const patterns = [
238
243
  /import\.meta\.env\.(VITE_[A-Z0-9_]+)/g,
239
244
  /import\.meta\.env\[['"`](VITE_[A-Z0-9_]+)['"`]\]/g,
@@ -241,10 +246,24 @@ function scanFrontendApiEnvNames(project) {
241
246
  ];
242
247
  for (const pattern of patterns) {
243
248
  for (const match of source.matchAll(pattern)) {
244
- if (/API|BACKEND|SERVER|BASE_URL/.test(match[1])) names.add(match[1]);
249
+ const name = match[1];
250
+ if (!/API|BACKEND|SERVER|BASE_URL/.test(name)) continue;
251
+ if (!found.has(name)) found.set(name, null);
245
252
  }
246
253
  }
247
- return [...names];
254
+ for (const name of found.keys()) {
255
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
256
+ const fallback = source.match(
257
+ new RegExp(`env(?:\\.${escaped}|\\[['"\`]${escaped}['"\`]\\])\\s*(?:\\|\\||\\?\\?)\\s*['"\`]([^'"\`]+)['"\`]`)
258
+ );
259
+ if (fallback) found.set(name, fallback[1]);
260
+ }
261
+ return [...found].map(([name, fallback]) => ({ name, fallback }));
262
+ }
263
+
264
+ /** Frontend env names that carry a backend URL, e.g. VITE_API_BASE or REACT_APP_API_URL. */
265
+ function scanFrontendApiEnvNames(project) {
266
+ return scanFrontendApiCandidates(project).map((entry) => entry.name);
248
267
  }
249
268
 
250
269
  /** Whether the frontend already proxies API calls itself (Vite server.proxy, CRA `proxy`). */
@@ -276,6 +295,7 @@ module.exports = {
276
295
  readDotEnv,
277
296
  readPackage,
278
297
  scanCorsEnvNames,
298
+ scanFrontendApiCandidates,
279
299
  scanFrontendApiEnvNames,
280
300
  validProject
281
301
  };
package/src/wiring.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const {
6
- findViteConfig, hasDevProxy, readDotEnv, scanCorsEnvNames, scanFrontendApiEnvNames
6
+ findViteConfig, hasDevProxy, readDotEnv, scanCorsEnvNames, scanFrontendApiCandidates
7
7
  } = require('./detect');
8
8
  const { alive } = require('./leases');
9
9
 
@@ -69,19 +69,73 @@ function retargetProxy(proxy, options) {
69
69
  return { proxy: next, changed, kept };
70
70
  }
71
71
 
72
+ /**
73
+ * Adds origins to a CSP's connect-src.
74
+ *
75
+ * A frontend that pins the backend origin in a `<meta http-equiv="Content-Security-Policy">`
76
+ * blocks the request in the browser before it is ever sent, so relocating the backend fails
77
+ * with no server-side symptom at all. Origins are only ever added, never removed.
78
+ *
79
+ * Self-contained: stringified into the generated Vite config.
80
+ */
81
+ function extendCspConnectSrc(policy, origins) {
82
+ const directives = String(policy).split(';').map((part) => part.trim()).filter(Boolean);
83
+ const index = directives.findIndex((part) => /^connect-src\b/i.test(part));
84
+ if (index === -1) {
85
+ if (!directives.some((part) => /^default-src\b/i.test(part))) return policy;
86
+ directives.push(`connect-src 'self' ${origins.join(' ')}`);
87
+ return directives.join('; ');
88
+ }
89
+ const existing = directives[index].split(/\s+/);
90
+ const missing = origins.filter((origin) => !existing.includes(origin));
91
+ if (!missing.length) return policy;
92
+ directives[index] = `${directives[index]} ${missing.join(' ')}`;
93
+ return directives.join('; ');
94
+ }
95
+
96
+ /** Loopback origins a browser may need to reach a backend on `port`. */
97
+ function devOriginsFor(port) {
98
+ return [
99
+ `http://localhost:${port}`, `http://127.0.0.1:${port}`,
100
+ `ws://localhost:${port}`, `ws://127.0.0.1:${port}`
101
+ ];
102
+ }
103
+
72
104
  /**
73
105
  * A Vite config that extends the project's own and repoints its API proxy at the allocated
74
106
  * backend port. Without this, relocating the backend leaves the frontend calling a dead port.
75
107
  */
76
- function viteOverrideSource({ userConfig, frontendPort, backendPort, naturalBackendPort, apiPrefix }) {
108
+ function viteOverrideSource({
109
+ userConfig, frontendPort, backendPort, naturalBackendPort, apiPrefix, cspOrigins = []
110
+ }) {
77
111
  return `// Generated by ports-manager. Recreated on every run; safe to delete.
78
112
  import { defineConfig, mergeConfig } from 'vite';
79
113
  import base from './${userConfig}';
80
114
 
81
115
  const OPTIONS = ${JSON.stringify({ backendPort, naturalBackendPort, apiPrefix })};
116
+ const CSP_ORIGINS = ${JSON.stringify(cspOrigins)};
82
117
 
83
118
  ${retargetProxy.toString()}
84
119
 
120
+ ${extendCspConnectSrc.toString()}
121
+
122
+ // Dev-only: the served HTML is rewritten in memory, index.html is never touched.
123
+ const cspPlugin = {
124
+ name: 'ports-manager-csp',
125
+ transformIndexHtml(html) {
126
+ if (!CSP_ORIGINS.length) return html;
127
+ return html.replace(
128
+ /(<meta\b[^>]*http-equiv=["']Content-Security-Policy["'][^>]*content=["'])([^"']*)(["'])/i,
129
+ (match, before, policy, after) => {
130
+ const updated = extendCspConnectSrc(policy, CSP_ORIGINS);
131
+ if (updated === policy) return match;
132
+ console.log('[ports-manager] CSP connect-src extended with ' + CSP_ORIGINS.join(' '));
133
+ return before + updated + after;
134
+ }
135
+ );
136
+ }
137
+ };
138
+
85
139
  export default defineConfig(async (env) => {
86
140
  const resolved = typeof base === 'function' ? await base(env) : await base;
87
141
  const server = (resolved && resolved.server) || {};
@@ -95,6 +149,7 @@ export default defineConfig(async (env) => {
95
149
  }
96
150
  const proxy = Object.keys(result.proxy).length ? result.proxy : undefined;
97
151
  return mergeConfig(resolved, {
152
+ plugins: [cspPlugin],
98
153
  server: { port: ${frontendPort}, strictPort: true, ...(proxy ? { proxy } : {}) }
99
154
  });
100
155
  });
@@ -124,6 +179,42 @@ function corsEnvFor(backend, origin) {
124
179
  return { env, names, notes };
125
180
  }
126
181
 
182
+ function pointsAtPort(value, port) {
183
+ if (typeof value !== 'string' || !Number.isInteger(port)) return false;
184
+ try {
185
+ const url = new URL(value);
186
+ return ['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]'].includes(url.hostname) &&
187
+ Number(url.port) === port;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Picks which frontend API variables to retarget, using the same rule as the proxy rewrite:
195
+ * only ones currently aimed at the backend's natural port. A project with both
196
+ * VITE_API_URL (this backend) and VITE_AI_API_URL (a different service) must not have the
197
+ * second one repointed at the first.
198
+ */
199
+ function resolveFrontendApiEnv(frontend, naturalBackendPort, backendUrl) {
200
+ const candidates = scanFrontendApiCandidates(frontend);
201
+ const configured = readDotEnv(frontend.directory);
202
+ const env = {};
203
+ const skipped = [];
204
+ for (const { name, fallback } of candidates) {
205
+ // What this variable resolves to today: the .env value, else the in-code fallback.
206
+ const current = configured[name] !== undefined ? configured[name] : fallback;
207
+ if (pointsAtPort(current, naturalBackendPort)) env[name] = backendUrl;
208
+ else if (current) skipped.push(name);
209
+ }
210
+ // A lone API variable with no value configured anywhere can only mean this backend.
211
+ // With two or more, guessing risks pointing one service at another, so we do not.
212
+ if (!Object.keys(env).length && candidates.length === 1 && !skipped.length) {
213
+ env[candidates[0].name] = backendUrl;
214
+ }
215
+ return { env, skipped, candidates: candidates.map((entry) => entry.name) };
216
+ }
217
+
127
218
  /**
128
219
  * How the frontend will reach the backend, given that both may have moved. Order of
129
220
  * preference: extend the frontend's own dev proxy (same-origin, native HMR), then a
@@ -165,10 +256,18 @@ function planWiring({ frontend, backend, ports, config, naturalBackendPort }) {
165
256
  : originFor(ports.backend);
166
257
 
167
258
  if (!sameOrigin) {
168
- const apiNames = scanFrontendApiEnvNames(frontend);
169
- for (const name of apiNames) frontendEnv[name] = backendUrl;
170
- if (apiNames.length) notes.push(`frontend API env: ${apiNames.map((n) => `${n}=${backendUrl}`).join(', ')}`);
171
- else notes.push('frontend has no dev proxy and no API env var; it may hardcode the backend URL');
259
+ const api = resolveFrontendApiEnv(frontend, naturalBackendPort, backendUrl);
260
+ Object.assign(frontendEnv, api.env);
261
+ const assigned = Object.keys(api.env);
262
+ if (assigned.length) {
263
+ notes.push(`frontend API env: ${assigned.map((n) => `${n}=${backendUrl}`).join(', ')}`);
264
+ }
265
+ for (const skipped of api.skipped) notes.push(`left ${skipped} alone (points elsewhere)`);
266
+ if (!assigned.length && !api.candidates.length) {
267
+ notes.push('frontend has no dev proxy and no API env var; it may hardcode the backend URL');
268
+ } else if (!assigned.length) {
269
+ notes.push(`no frontend API variable pointed at the backend; set one of ${api.candidates.join(', ')} manually`);
270
+ }
172
271
  }
173
272
 
174
273
  // CORS work is only meaningful when the browser will actually cross origins.
@@ -192,6 +291,8 @@ function planWiring({ frontend, backend, ports, config, naturalBackendPort }) {
192
291
  corsEnvNames: cors.names,
193
292
  notes,
194
293
  viteConfig,
294
+ // Only needed when the browser will actually reach a second origin.
295
+ cspOrigins: sameOrigin ? [] : devOriginsFor(ports.backend),
195
296
  overrideFile: mode === 'vite-retarget' ? path.join(frontend.directory, overrideName) : null,
196
297
  describe() {
197
298
  if (mode === 'proxy') return `same-origin proxy on ${ports.proxy} (${apiPrefix} -> backend)`;
@@ -234,7 +335,8 @@ function applyWiring(plan, frontend, ports, config) {
234
335
  frontendPort: ports.frontend,
235
336
  backendPort: ports.backend,
236
337
  naturalBackendPort: Number.isInteger(plan.naturalBackendPort) ? plan.naturalBackendPort : null,
237
- apiPrefix: config.apiPrefix
338
+ apiPrefix: config.apiPrefix,
339
+ cspOrigins: plan.cspOrigins
238
340
  });
239
341
  fs.writeFileSync(plan.overrideFile, source, 'utf8');
240
342
  return () => {
@@ -252,6 +354,9 @@ module.exports = {
252
354
  viteOverrideName,
253
355
  applyWiring,
254
356
  corsEnvFor,
357
+ devOriginsFor,
358
+ extendCspConnectSrc,
359
+ resolveFrontendApiEnv,
255
360
  planWiring,
256
361
  proxyAvailable,
257
362
  retargetProxy,