@data-fair/dev-server 2.4.1 → 2.5.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/README.md CHANGED
@@ -7,6 +7,41 @@ A development server for optimal development experience of data-fair application
7
7
  See [data-fair-charts](https://github.com/koumoul-dev/data-fair-charts/blob/master/package.json) for an example with nuxt.
8
8
  See [data-fair-minimal](https://github.com/koumoul-dev/data-fair-minimal/blob/master/package.json) for an example with a simple http server.
9
9
 
10
+ ## Ports de développement
11
+
12
+ `df-dev-env` génère un `.env` git-ignoré portant trois ports libres consécutifs,
13
+ tirés dans 20000–29999, pour que plusieurs applications tournent en parallèle :
14
+
15
+ ```
16
+ APP_PORT=24730 # le serveur de dev de l'application (Vite)
17
+ DEV_SERVER_PORT=24731 # df-dev-server
18
+ E2E_PORT=24732 # le webServer de Playwright
19
+ APP_PATH=/app/ # chemin sous lequel l'application est servie
20
+ ```
21
+
22
+ Le fichier est généré une fois, au premier `npm run dev`, puis laissé tel quel.
23
+ `df-dev-env --force` retire de nouveaux ports en cas de collision, en
24
+ conservant l'`APP_PATH` déjà choisi : le remède à une collision de ports ne
25
+ doit pas replacer en silence sous `/app/` une application servie à la racine.
26
+ `--app-path` explicite prime sur la valeur conservée.
27
+
28
+ Un `.env` déjà présent est laissé intact — mais `df-dev-env` prévient quand ce
29
+ silence n'est pas ce qu'on attendait : quand le fichier ne porte pas `APP_PORT`
30
+ (un `.env` écrit pour une autre raison, qui ne recevrait donc aucun port), et
31
+ quand un `--app-path` explicite diffère de celui déjà stocké.
32
+
33
+ `df-dev-server` lit ce `.env` (`dotenv`) : `DEV_SERVER_PORT` fixe son port, et
34
+ `app.url` est dérivée de `APP_PORT` + `APP_PATH`. `APP_URL` reste prioritaire
35
+ pour une application qui n'est pas servie sur `localhost`.
36
+
37
+ `npm run dev-test-app-minimal` relit `.env` lui-même (via `dotenv-cli`) pour
38
+ faire écouter `http-server` sur le bon `APP_PORT`, mais ne peut pas agir sur
39
+ `APP_PATH` : ce script et le processus `df-dev-server` déjà démarré ne
40
+ partagent rien d'autre que le fichier `.env`, écrit une seule fois par
41
+ `df-dev-env` avec `/app/` par défaut. Pour tester `test-apps/minimal` (servie
42
+ à la racine) il faut régénérer `.env` avant de lancer le dev-server :
43
+ `node src/dev-env.js --force --app-path=`.
44
+
10
45
  ## Development
11
46
 
12
47
  Run development server :
@@ -1,4 +1,10 @@
1
1
  export default {
2
+ port: {
3
+ __name: 'DEV_SERVER_PORT',
4
+ // __format, otherwise the config module hands over the raw string: the declared type is a
5
+ // number, and ajv cannot coerce it back since the config object is frozen before assertValid.
6
+ __format: 'json'
7
+ },
2
8
  dataFair: {
3
9
  url: 'DATAFAIR_URL',
4
10
  owner: {
package/config/default.js CHANGED
@@ -10,7 +10,12 @@ export default {
10
10
  apiKey: null
11
11
  },
12
12
  app: {
13
- url: 'http://localhost:3000',
13
+ // Derived from the app port so that a shifted dev setup cannot desynchronize the two.
14
+ // Path defaults to /app/: 27 of the 37 applications are Vite apps served under it, and
15
+ // this is the only value that works in a fresh clone that never ran df-dev-env. An
16
+ // application served at the root opts out with APP_PATH= (empty string is not nullish,
17
+ // so ?? keeps it). APP_URL still overrides it entirely, for apps not served on localhost.
18
+ url: `http://localhost:${process.env.APP_PORT ?? 3000}${process.env.APP_PATH ?? '/app/'}`,
14
19
  proxyPaths: ['/_nuxt/']
15
20
  },
16
21
  site: {
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "A development server for optimal development experience of data-fair applications.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
- "df-dev-server": "src/index.js"
7
+ "df-dev-server": "src/index.js",
8
+ "df-dev-env": "src/dev-env.js"
8
9
  },
9
10
  "type": "module",
10
11
  "scripts": {
11
12
  "build": "tsc",
12
13
  "build-watch": "tsc --watch",
13
14
  "dev": "NODE_ENV=development node --watch src/index.js",
14
- "dev-test-app-minimal": "http-server test-apps/minimal --port=3000 --cors --silent",
15
+ "dev-test-app-minimal": "dotenv -- sh -c 'http-server test-apps/minimal --port=${APP_PORT:-3000} --cors --silent'",
15
16
  "dev-test-app-vue3": "cd test-apps/vue3 && npm run dev",
16
17
  "dev-test-app-modern": "cd test-apps/modern && npm run dev",
17
- "dev-zellij": "zellij --layout .zellij.kdl",
18
- "prepublishOnly": "npm run lint && npm run build && npm -w ui run build",
18
+ "dev-zellij": "npm run build && node src/dev-env.js && dotenv -- zellij --layout .zellij.kdl",
19
+ "test": "node --test test/*.test.ts",
20
+ "prepublishOnly": "npm run lint && npm test && npm run build && npm -w ui run build",
19
21
  "lint": "eslint . && npm -w ui run lint",
20
22
  "lint-fix": "eslint --fix . && npm -w ui run lint-fix",
21
23
  "build-types": "df-build-types ."
@@ -46,6 +48,7 @@
46
48
  "@types/express": "^5.0.6",
47
49
  "@types/node": "^26.3.0",
48
50
  "@types/ws": "^8.18.1",
51
+ "dotenv-cli": "^11.0.0",
49
52
  "eslint": "^9.39.5",
50
53
  "eslint-plugin-vue": "^10.10.0",
51
54
  "eslint-plugin-vuetify": "^2.7.2",
package/src/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Express app for TaxMan own API and UI
2
2
  import config from './config.js';
3
3
  import uiConfig from './ui-config.js';
4
+ import { localizeConfig } from './localize.js';
4
5
  import { WebSocket, WebSocketServer } from 'ws';
5
6
  import { createServer } from 'node:http';
6
7
  import express from 'express';
@@ -91,7 +92,7 @@ app.get('/config', (req, res, next) => {
91
92
  // exact same data as the application receives in window.APPLICATION
92
93
  app.get('/config/enriched', async (req, res) => {
93
94
  try {
94
- res.send(await refreshConfigDatasets(readDevConfig()));
95
+ res.send(await prepareConfig(readDevConfig()));
95
96
  }
96
97
  catch (err) {
97
98
  res.status(500).send({ error: err.message });
@@ -168,14 +169,11 @@ const enrichDataset = async (dataset) => {
168
169
  }
169
170
  try {
170
171
  const fresh = await remoteFetch('/datasets/' + encodeURIComponent(dataset.id));
171
- const localBase = `http://localhost:${config.port}/data-fair`;
172
172
  const data = {};
173
173
  for (const prop of INJECTED_DATASET_PROPS) {
174
174
  if (fresh[prop] !== undefined)
175
175
  data[prop] = fresh[prop];
176
176
  }
177
- if (typeof data.href === 'string')
178
- data.href = data.href.replace(config.dataFair.url, localBase);
179
177
  data.userPermissions = fresh.userPermissions ?? [];
180
178
  datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
181
179
  return { ...dataset, ...data };
@@ -188,12 +186,15 @@ const enrichDataset = async (dataset) => {
188
186
  return dataset;
189
187
  }
190
188
  };
191
- const refreshConfigDatasets = async (configuration) => {
189
+ // Enrich the datasets from the remote data-fair, then rewrite every remote origin to ours.
190
+ // The rewrite is applied even when there is no dataset to enrich: a configuration can carry
191
+ // remote urls anywhere (logos, links, tileserver styles), not only in datasets[].href.
192
+ const prepareConfig = async (configuration) => {
192
193
  const datasets = configuration?.datasets?.filter((d) => !!d);
193
- if (!datasets?.length)
194
- return configuration;
195
- const enriched = await Promise.all(datasets.map(enrichDataset));
196
- return { ...configuration, datasets: enriched };
194
+ const enriched = datasets?.length
195
+ ? { ...configuration, datasets: await Promise.all(datasets.map(enrichDataset)) }
196
+ : configuration;
197
+ return localizeConfig(enriched, new URL(config.dataFair.url).origin, `http://localhost:${config.port}`);
197
198
  };
198
199
  // read the .dev-config.json file of the app under development
199
200
  const readDevConfig = () => existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
@@ -354,14 +355,13 @@ app.get('/configurations', async (req, res) => {
354
355
  res.status(500).send({ ...response, error: err.message });
355
356
  }
356
357
  });
357
- // copy a configuration from the remote data-fair, replacing all references to the
358
- // remote origin by the local one so that data goes through the local proxies
358
+ // copy a configuration from the remote data-fair, keeping its remote origins so that
359
+ // .dev-config.json stays portable — see localize.ts
359
360
  app.get('/configurations/:id', async (req, res) => {
360
361
  try {
361
- const configuration = await remoteFetch('/applications/' + encodeURIComponent(req.params.id) + '/configuration');
362
- const remoteOrigin = new URL(config.dataFair.url).origin;
363
- const localOrigin = `http://localhost:${config.port}`;
364
- res.send(JSON.parse(JSON.stringify(configuration).replaceAll(remoteOrigin, localOrigin)));
362
+ // Sent as-is, with its remote origins: the UI stores this straight into .dev-config.json,
363
+ // which must stay portable. Origins are rewritten on the read path, in prepareConfig.
364
+ res.send(await remoteFetch('/applications/' + encodeURIComponent(req.params.id) + '/configuration'));
365
365
  }
366
366
  catch (err) {
367
367
  res.status(500).send({ error: err.message });
@@ -407,7 +407,7 @@ app.use('/app', createProxyMiddleware({
407
407
  let output = rawBody.toString();
408
408
  if (output.includes('%APPLICATION%')) {
409
409
  try {
410
- configuration = await refreshConfigDatasets(configuration);
410
+ configuration = await prepareConfig(configuration);
411
411
  }
412
412
  catch (err) {
413
413
  console.warn('[dev-server] failed to enrich configuration datasets', err);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/src/dev-env.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import { parseArgs } from 'node:util';
5
+ import { MIN_BASE, MAX_BASE, renderEnv, findBase, readEnvVar } from './env-file.js';
6
+ const { values } = parseArgs({
7
+ options: {
8
+ force: { type: 'boolean', default: false },
9
+ // No default value: an absent --app-path must stay distinguishable from an explicit one, so
10
+ // that --force can redraw the ports without resetting a path the developer chose.
11
+ 'app-path': { type: 'string' }
12
+ }
13
+ });
14
+ const existing = existsSync('.env') ? readFileSync('.env', 'utf8') : undefined;
15
+ const storedAppPath = existing === undefined ? undefined : readEnvVar(existing, 'APP_PATH');
16
+ if (existing !== undefined && !values.force) {
17
+ // An existing .env is left untouched — but say so when leaving it untouched is not what the
18
+ // caller expected, rather than letting the application start on the wrong port or path.
19
+ if (readEnvVar(existing, 'APP_PORT') === undefined) {
20
+ console.warn('.env existe déjà mais ne porte pas APP_PORT : y ajouter les lignes de port à la main, ou df-dev-env --force');
21
+ }
22
+ else if (values['app-path'] !== undefined && values['app-path'] !== storedAppPath) {
23
+ console.warn(`.env existe déjà avec APP_PATH=${storedAppPath} : --app-path=${values['app-path']} est ignoré, df-dev-env --force pour l'appliquer`);
24
+ }
25
+ process.exit(0);
26
+ }
27
+ const isFree = (port) => new Promise(resolve => {
28
+ const server = createServer();
29
+ server.once('error', () => { resolve(false); });
30
+ server.once('listening', () => { server.close(() => { resolve(true); }); });
31
+ server.listen(port, '127.0.0.1');
32
+ });
33
+ const draw = () => MIN_BASE + Math.floor(Math.random() * (MAX_BASE - MIN_BASE + 1));
34
+ // --force is the documented remedy for a port collision: it must not silently move an
35
+ // application served at the root back under /app/ along the way.
36
+ const appPath = values['app-path'] ?? storedAppPath ?? '/app/';
37
+ const base = await findBase(isFree, draw);
38
+ writeFileSync('.env', renderEnv(base, appPath));
39
+ console.log(`.env généré — app ${base}, dev-server ${base + 1}, e2e ${base + 2}, chemin ${appPath || '/ (racine)'}`);
@@ -0,0 +1,5 @@
1
+ export declare const MIN_BASE = 20000;
2
+ export declare const MAX_BASE = 29997;
3
+ export declare const renderEnv: (base: number, appPath: string) => string;
4
+ export declare const readEnvVar: (content: string, name: string) => string | undefined;
5
+ export declare const findBase: (isFree: (port: number) => Promise<boolean>, draw: () => number, attempts?: number) => Promise<number>;
@@ -0,0 +1,28 @@
1
+ // Development ports live below the kernel ephemeral range (32768-60999 on Linux), where a port
2
+ // can already be held by an outgoing connection, and above the 191xx block used by the
3
+ // data-fair docker compose.
4
+ export const MIN_BASE = 20000;
5
+ // The base takes three consecutive ports, so it may not go past 29999.
6
+ export const MAX_BASE = 29997;
7
+ export const renderEnv = (base, appPath) => `# généré par df-dev-env — ne pas commiter
8
+ APP_PORT=${base}
9
+ DEV_SERVER_PORT=${base + 1}
10
+ E2E_PORT=${base + 2}
11
+ APP_PATH=${appPath}
12
+ `;
13
+ // Reads one variable out of an already written .env. Used to tell a .env that carries our ports
14
+ // from one written for another purpose, and to keep the APP_PATH a developer chose when --force
15
+ // redraws the ports. Deliberately naive: the file we read is the one we wrote.
16
+ export const readEnvVar = (content, name) => {
17
+ const line = content.split('\n').find(l => l.startsWith(name + '='));
18
+ return line === undefined ? undefined : line.slice(name.length + 1).trim();
19
+ };
20
+ export const findBase = async (isFree, draw, attempts = 10) => {
21
+ for (let i = 0; i < attempts; i++) {
22
+ const base = draw();
23
+ const free = await Promise.all([base, base + 1, base + 2].map(isFree));
24
+ if (free.every(Boolean))
25
+ return base;
26
+ }
27
+ throw new Error(`no free port range found after ${attempts} attempts`);
28
+ };
package/src/index.js CHANGED
File without changes
@@ -0,0 +1 @@
1
+ export declare const localizeConfig: <T>(configuration: T, remoteOrigin: string, localOrigin: string) => T;
@@ -0,0 +1,16 @@
1
+ // Configurations are stored with their remote (koumoul.com) origins so that .dev-config.json
2
+ // stays portable across developers, machines and regenerated ports. Origins are rewritten here,
3
+ // on the read path, on the way to the application — never on the write path.
4
+ // Paths the dev-server re-exposes under its own origin.
5
+ const PROXIED_PATHS = ['/data-fair', '/simple-directory', '/tileserver'];
6
+ // Transition rewrite: .dev-config.json files written before this change carry a hardcoded
7
+ // http://localhost:5888 origin. Only rewrite one when it stands on a path we actually proxy,
8
+ // and only when that path is complete — a deliberate http://localhost:8080/data-fair-lookalike
9
+ // typed by a developer must survive untouched.
10
+ const legacyLocalOrigin = new RegExp(`http://localhost:\\d+(?=(?:${PROXIED_PATHS.join('|')})(?:/|"))`, 'g');
11
+ export const localizeConfig = (configuration, remoteOrigin, localOrigin) => {
12
+ const json = JSON.stringify(configuration);
13
+ if (json === undefined)
14
+ return configuration;
15
+ return JSON.parse(json.replaceAll(remoteOrigin, localOrigin).replace(legacyLocalOrigin, localOrigin));
16
+ };