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 +1 -1
- package/src/detect.js +25 -5
- package/src/wiring.js +112 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ports-manager",
|
|
3
|
-
"version": "1.0
|
|
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
|
-
/**
|
|
234
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,
|