@data-fair/dev-server 2.4.1 → 2.5.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 +68 -0
- package/config/custom-environment-variables.js +6 -0
- package/config/default.js +6 -1
- package/package.json +8 -5
- package/src/app.js +83 -19
- package/src/attachments.d.ts +19 -0
- package/src/attachments.js +125 -0
- package/src/dev-env.d.ts +2 -0
- package/src/dev-env.js +39 -0
- package/src/env-file.d.ts +5 -0
- package/src/env-file.js +28 -0
- package/src/index.js +0 -0
- package/src/localize.d.ts +1 -0
- package/src/localize.js +16 -0
- package/ui/dist/assets/{index-BgRDnJ6S.js → index-C93GrQ2l.js} +1 -1
- package/ui/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,74 @@ 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
|
+
|
|
45
|
+
## Configurations et pièces jointes distantes
|
|
46
|
+
|
|
47
|
+
Le bouton « Copier une configuration distante » liste les applications qui, sur
|
|
48
|
+
le data-fair distant, tournent sur l'application de base en cours de
|
|
49
|
+
développement, dans la même version mineure. La recherche part du nom lu dans la
|
|
50
|
+
balise `<meta name="application-name">` de l'`index.html` local et de la version
|
|
51
|
+
du `package.json`.
|
|
52
|
+
|
|
53
|
+
Une application de base réservée à une organisation (`privateAccess`) est
|
|
54
|
+
invisible à une requête anonyme : sans elle la liste est vide, quand bien même
|
|
55
|
+
les applications qui l'utilisent seraient publiques. Le paramètre
|
|
56
|
+
`privateAccess` la rend visible, mais data-fair répond 401 sans
|
|
57
|
+
authentification — il n'est donc envoyé que si `DATAFAIR_API_KEY` est
|
|
58
|
+
renseignée. Une clé d'API se crée depuis le compte ou l'organisation sur le
|
|
59
|
+
data-fair distant, et se met dans le `.env` de l'application :
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
DATAFAIR_API_KEY=xxx
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Ce `.env` est celui que `df-dev-env` génère, et qu'il laisse intact tant qu'on
|
|
66
|
+
ne passe pas `--force` — qui, lui, le réécrit entièrement et emporte la clé.
|
|
67
|
+
|
|
68
|
+
La copie ramène aussi les pièces jointes de l'application distante dans
|
|
69
|
+
`.dev-attachments/` (git-ignoré). Une configuration ne référence une pièce
|
|
70
|
+
jointe que par son nom et reconstruit son URL à l'affichage
|
|
71
|
+
(`application.href + '/attachments/' + name`) : sans les fichiers, une
|
|
72
|
+
configuration de production copiée s'affiche avec toutes ses images cassées.
|
|
73
|
+
Elles sont servies sous `/config/attachments/`, listées dans
|
|
74
|
+
`window.APPLICATION.attachments`, et proposées par le formulaire de
|
|
75
|
+
configuration comme data-fair le fait avec `context.attachments`. Un fichier
|
|
76
|
+
déposé à la main dans `.dev-attachments/` est listé et servi de la même façon.
|
|
77
|
+
|
|
10
78
|
## Development
|
|
11
79
|
|
|
12
80
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.5.1",
|
|
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
|
|
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
|
-
"
|
|
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,8 @@
|
|
|
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';
|
|
5
|
+
import { ATTACHMENTS_DIR, attachmentPath, copyAttachments, listAttachments } from './attachments.js';
|
|
4
6
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
5
7
|
import { createServer } from 'node:http';
|
|
6
8
|
import express from 'express';
|
|
@@ -14,7 +16,7 @@ import { isElementNode, createTextNode, createElement, appendChild } from '@pars
|
|
|
14
16
|
import escapeStringRegexp from 'escape-string-regexp';
|
|
15
17
|
import eventPromise from '@data-fair/lib-utils/event-promise.js';
|
|
16
18
|
import { createSpaMiddleware } from '@data-fair/lib-express/serve-spa.js';
|
|
17
|
-
import { resolve } from 'node:path';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
18
20
|
const debug = debugModule('df-dev-server');
|
|
19
21
|
const app = express();
|
|
20
22
|
const server = createServer(app);
|
|
@@ -91,7 +93,7 @@ app.get('/config', (req, res, next) => {
|
|
|
91
93
|
// exact same data as the application receives in window.APPLICATION
|
|
92
94
|
app.get('/config/enriched', async (req, res) => {
|
|
93
95
|
try {
|
|
94
|
-
res.send(await
|
|
96
|
+
res.send(await prepareConfig(readDevConfig()));
|
|
95
97
|
}
|
|
96
98
|
catch (err) {
|
|
97
99
|
res.status(500).send({ error: err.message });
|
|
@@ -109,6 +111,27 @@ app.post('/config/error', (req, res) => {
|
|
|
109
111
|
}
|
|
110
112
|
res.send();
|
|
111
113
|
});
|
|
114
|
+
// The attachments of the application under development, read by the dev-server UI to feed the
|
|
115
|
+
// `context.attachments` of the configuration form — data-fair feeds it with
|
|
116
|
+
// application.attachments, and an application configuration references an attachment by name.
|
|
117
|
+
app.get('/config/attachments', (req, res) => {
|
|
118
|
+
res.send(listAttachments());
|
|
119
|
+
});
|
|
120
|
+
// window.APPLICATION.href points at /config, so an application building an attachment url the
|
|
121
|
+
// production way — application.href + '/attachments/' + name — lands here. Same path shape as
|
|
122
|
+
// data-fair (applications/:id/attachments/*), same no-cache: a re-copied file shows up at once.
|
|
123
|
+
app.get('/config/attachments/*attachmentPath', (req, res) => {
|
|
124
|
+
const relFilePath = join(...req.params.attachmentPath);
|
|
125
|
+
const filePath = attachmentPath(ATTACHMENTS_DIR, relFilePath);
|
|
126
|
+
if (!filePath || !existsSync(filePath)) {
|
|
127
|
+
res.status(404).send({ error: `attachment "${relFilePath}" not found in ${ATTACHMENTS_DIR}` });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
131
|
+
// served from a root, and not from the absolute path: send() refuses any path holding a
|
|
132
|
+
// hidden segment, and the attachments directory is itself a dotted one
|
|
133
|
+
res.sendFile(relFilePath, { root: resolve(ATTACHMENTS_DIR) });
|
|
134
|
+
});
|
|
112
135
|
// Identification of the app under development: its name is read from the
|
|
113
136
|
// "application-name" meta tag of the local index.html and its version from the
|
|
114
137
|
// package.json of the current directory.
|
|
@@ -135,14 +158,33 @@ const localAppInfo = async () => {
|
|
|
135
158
|
}
|
|
136
159
|
return { name, version };
|
|
137
160
|
};
|
|
161
|
+
// The owner the dev-server works on behalf of, in the "type:id[:department]" form the
|
|
162
|
+
// data-fair api expects for its owner and privateAccess filters.
|
|
163
|
+
const ownerFilter = () => {
|
|
164
|
+
const owner = config.dataFair.owner;
|
|
165
|
+
let filter = `${owner.type}:${owner.id}`;
|
|
166
|
+
if (owner.department)
|
|
167
|
+
filter += ':' + owner.department;
|
|
168
|
+
return filter;
|
|
169
|
+
};
|
|
138
170
|
// Fetch a resource from the remote data-fair api
|
|
139
171
|
const remoteFetch = async (path) => {
|
|
172
|
+
const res = await remoteFetchRaw(path);
|
|
173
|
+
return res.json();
|
|
174
|
+
};
|
|
175
|
+
// Same, for an attachment: a file, never json, and never decoded as text — see the app proxy
|
|
176
|
+
// below, where decoding a binary body as utf8 destroys it.
|
|
177
|
+
const remoteFetchBuffer = async (path) => {
|
|
178
|
+
const res = await remoteFetchRaw(path);
|
|
179
|
+
return Buffer.from(await res.arrayBuffer());
|
|
180
|
+
};
|
|
181
|
+
const remoteFetchRaw = async (path) => {
|
|
140
182
|
const res = await fetch(config.dataFair.url + '/api/v1' + path, {
|
|
141
183
|
headers: config.dataFair.apiKey ? { 'x-apiKey': config.dataFair.apiKey } : {}
|
|
142
184
|
});
|
|
143
185
|
if (!res.ok)
|
|
144
186
|
throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
|
|
145
|
-
return res
|
|
187
|
+
return res;
|
|
146
188
|
};
|
|
147
189
|
// Short-lived cache for the dataset enrichment below, so that every preview reload does not
|
|
148
190
|
// hammer the remote data-fair API. Failures are cached too: a private dataset without an api
|
|
@@ -168,14 +210,11 @@ const enrichDataset = async (dataset) => {
|
|
|
168
210
|
}
|
|
169
211
|
try {
|
|
170
212
|
const fresh = await remoteFetch('/datasets/' + encodeURIComponent(dataset.id));
|
|
171
|
-
const localBase = `http://localhost:${config.port}/data-fair`;
|
|
172
213
|
const data = {};
|
|
173
214
|
for (const prop of INJECTED_DATASET_PROPS) {
|
|
174
215
|
if (fresh[prop] !== undefined)
|
|
175
216
|
data[prop] = fresh[prop];
|
|
176
217
|
}
|
|
177
|
-
if (typeof data.href === 'string')
|
|
178
|
-
data.href = data.href.replace(config.dataFair.url, localBase);
|
|
179
218
|
data.userPermissions = fresh.userPermissions ?? [];
|
|
180
219
|
datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
|
|
181
220
|
return { ...dataset, ...data };
|
|
@@ -188,12 +227,15 @@ const enrichDataset = async (dataset) => {
|
|
|
188
227
|
return dataset;
|
|
189
228
|
}
|
|
190
229
|
};
|
|
191
|
-
|
|
230
|
+
// Enrich the datasets from the remote data-fair, then rewrite every remote origin to ours.
|
|
231
|
+
// The rewrite is applied even when there is no dataset to enrich: a configuration can carry
|
|
232
|
+
// remote urls anywhere (logos, links, tileserver styles), not only in datasets[].href.
|
|
233
|
+
const prepareConfig = async (configuration) => {
|
|
192
234
|
const datasets = configuration?.datasets?.filter((d) => !!d);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return
|
|
235
|
+
const enriched = datasets?.length
|
|
236
|
+
? { ...configuration, datasets: await Promise.all(datasets.map(enrichDataset)) }
|
|
237
|
+
: configuration;
|
|
238
|
+
return localizeConfig(enriched, new URL(config.dataFair.url).origin, `http://localhost:${config.port}`);
|
|
197
239
|
};
|
|
198
240
|
// read the .dev-config.json file of the app under development
|
|
199
241
|
const readDevConfig = () => existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
|
|
@@ -337,9 +379,20 @@ app.get('/configurations', async (req, res) => {
|
|
|
337
379
|
return;
|
|
338
380
|
}
|
|
339
381
|
response.minorVersion = minorVersion(version);
|
|
382
|
+
// a base application restricted to an organization is invisible to an anonymous request, and
|
|
383
|
+
// without it the listing below finds nothing at all — the applications are then never reached,
|
|
384
|
+
// however public they are. privateAccess is what makes it visible, and data-fair answers 401
|
|
385
|
+
// to it without credentials, so it is only sent when an api key is configured.
|
|
386
|
+
response.authenticated = !!config.dataFair.apiKey;
|
|
340
387
|
try {
|
|
341
|
-
|
|
388
|
+
let baseAppsQuery = '/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false';
|
|
389
|
+
if (response.authenticated)
|
|
390
|
+
baseAppsQuery += '&privateAccess=' + encodeURIComponent(ownerFilter());
|
|
391
|
+
const baseApps = await remoteFetch(baseAppsQuery);
|
|
342
392
|
const matchingBaseApps = (baseApps.results ?? []).filter((b) => typeof b.version === 'string' && minorVersion(b.version) === response.minorVersion);
|
|
393
|
+
// told apart from "the base application exists but carries no application": the UI has no
|
|
394
|
+
// other way to explain an empty list, and a missing api key is by far the likeliest cause
|
|
395
|
+
response.baseAppFound = matchingBaseApps.length > 0;
|
|
343
396
|
response.results = [];
|
|
344
397
|
for (const baseApp of matchingBaseApps) {
|
|
345
398
|
const applications = await remoteFetch('/applications?base-application=' + encodeURIComponent(baseApp.url) + '&size=1000&count=false&select=id,title,owner');
|
|
@@ -354,14 +407,23 @@ app.get('/configurations', async (req, res) => {
|
|
|
354
407
|
res.status(500).send({ ...response, error: err.message });
|
|
355
408
|
}
|
|
356
409
|
});
|
|
357
|
-
// copy a configuration from the remote data-fair,
|
|
358
|
-
//
|
|
410
|
+
// copy a configuration from the remote data-fair, keeping its remote origins so that
|
|
411
|
+
// .dev-config.json stays portable — see localize.ts
|
|
359
412
|
app.get('/configurations/:id', async (req, res) => {
|
|
413
|
+
const id = encodeURIComponent(req.params.id);
|
|
360
414
|
try {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
415
|
+
// Sent as-is, with its remote origins: the UI stores this straight into .dev-config.json,
|
|
416
|
+
// which must stay portable. Origins are rewritten on the read path, in prepareConfig.
|
|
417
|
+
const configuration = await remoteFetch('/applications/' + id + '/configuration');
|
|
418
|
+
// The attachments come along: a configuration references them by name only, so without the
|
|
419
|
+
// files a copied production configuration renders with every image broken. Fetched from the
|
|
420
|
+
// application itself rather than from the configuration, which never lists them.
|
|
421
|
+
const application = await remoteFetch('/applications/' + id + '?select=attachments');
|
|
422
|
+
const attachments = await copyAttachments(application.attachments ?? [], (name) => remoteFetchBuffer('/applications/' + id + '/attachments/' + encodeURIComponent(name)));
|
|
423
|
+
if (attachments.failed.length) {
|
|
424
|
+
console.warn('[dev-server] failed to copy attachments ' + attachments.failed.join(', ') + ', images referencing them will be broken');
|
|
425
|
+
}
|
|
426
|
+
res.send({ configuration, attachments });
|
|
365
427
|
}
|
|
366
428
|
catch (err) {
|
|
367
429
|
res.status(500).send({ error: err.message });
|
|
@@ -407,7 +469,7 @@ app.use('/app', createProxyMiddleware({
|
|
|
407
469
|
let output = rawBody.toString();
|
|
408
470
|
if (output.includes('%APPLICATION%')) {
|
|
409
471
|
try {
|
|
410
|
-
configuration = await
|
|
472
|
+
configuration = await prepareConfig(configuration);
|
|
411
473
|
}
|
|
412
474
|
catch (err) {
|
|
413
475
|
console.warn('[dev-server] failed to enrich configuration datasets', err);
|
|
@@ -419,6 +481,8 @@ app.use('/app', createProxyMiddleware({
|
|
|
419
481
|
configuration,
|
|
420
482
|
exposedUrl: `http://localhost:${config.port}/app`,
|
|
421
483
|
href: `http://localhost:${config.port}/config`,
|
|
484
|
+
// data-fair serializes the whole application document, attachments included
|
|
485
|
+
attachments: listAttachments(),
|
|
422
486
|
apiUrl: `http://localhost:${config.port}/data-fair/api/v1`,
|
|
423
487
|
wsUrl: `ws://localhost:${config.port}/data-fair`,
|
|
424
488
|
owner: config.dataFair && config.dataFair.owner
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const ATTACHMENTS_DIR = ".dev-attachments";
|
|
2
|
+
export type Attachment = {
|
|
3
|
+
name: string;
|
|
4
|
+
title: string;
|
|
5
|
+
size: number;
|
|
6
|
+
mimetype: string;
|
|
7
|
+
updatedAt: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const listAttachments: (dir?: string) => Attachment[];
|
|
10
|
+
export declare const attachmentPath: (dir: string, name: string) => string | undefined;
|
|
11
|
+
export type CopyAttachmentsResult = {
|
|
12
|
+
copied: string[];
|
|
13
|
+
failed: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare const copyAttachments: (attachments: {
|
|
16
|
+
name?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
mimetype?: string;
|
|
19
|
+
}[], fetchAttachment: (name: string) => Promise<Buffer>, dir?: string) => Promise<CopyAttachmentsResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Attachments of the application under development.
|
|
2
|
+
//
|
|
3
|
+
// data-fair lets an application carry attached files: they are listed in the application
|
|
4
|
+
// object (window.APPLICATION.attachments), offered by the configuration form through the
|
|
5
|
+
// `context.attachments` of vjsf, and served under <application.href>/attachments/<name>.
|
|
6
|
+
// An application configuration therefore stores an attachment by name only — see the
|
|
7
|
+
// attachmentImage pattern used by app-eco-watt and friends — and rebuilds its url at
|
|
8
|
+
// render time. We mirror that whole contract locally, with the files sitting in a
|
|
9
|
+
// git-ignored directory, so an image referenced by a copied production configuration
|
|
10
|
+
// displays in dev exactly as it does in production.
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
13
|
+
export const ATTACHMENTS_DIR = '.dev-attachments';
|
|
14
|
+
// Titles are metadata of the remote application, nothing on disk holds them. They are kept
|
|
15
|
+
// beside the files, in a dotfile: hidden entries are excluded from the listing below, so the
|
|
16
|
+
// sidecar can never be mistaken for an attachment.
|
|
17
|
+
const METADATA_FILE = '.metadata.json';
|
|
18
|
+
// Enough to cover what an application attaches (images, and the occasional document). An
|
|
19
|
+
// unknown extension falls back to the mimetype the remote data-fair reported, then to the
|
|
20
|
+
// generic binary type — never to a guess that a browser would act upon.
|
|
21
|
+
const MIME_TYPES = {
|
|
22
|
+
'.avif': 'image/avif',
|
|
23
|
+
'.csv': 'text/csv',
|
|
24
|
+
'.gif': 'image/gif',
|
|
25
|
+
'.ico': 'image/vnd.microsoft.icon',
|
|
26
|
+
'.jpeg': 'image/jpeg',
|
|
27
|
+
'.jpg': 'image/jpeg',
|
|
28
|
+
'.json': 'application/json',
|
|
29
|
+
'.pdf': 'application/pdf',
|
|
30
|
+
'.png': 'image/png',
|
|
31
|
+
'.svg': 'image/svg+xml',
|
|
32
|
+
'.txt': 'text/plain',
|
|
33
|
+
'.webp': 'image/webp'
|
|
34
|
+
};
|
|
35
|
+
const readMetadata = (dir) => {
|
|
36
|
+
const path = join(dir, METADATA_FILE);
|
|
37
|
+
if (!existsSync(path))
|
|
38
|
+
return {};
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
if (!Array.isArray(parsed))
|
|
42
|
+
return {};
|
|
43
|
+
return Object.fromEntries(parsed.filter(a => a?.name).map(a => [a.name, a]));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// a hand-edited or truncated sidecar must not take the whole listing down with it:
|
|
47
|
+
// the files themselves are the source of truth, the metadata only decorates them
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
// The directory is the source of truth, not the sidecar: an image a developer drops in by
|
|
52
|
+
// hand is listed just like one copied from a remote application, and a file deleted by hand
|
|
53
|
+
// disappears. Size and date come from the file, since the local file is what is served.
|
|
54
|
+
export const listAttachments = (dir = ATTACHMENTS_DIR) => {
|
|
55
|
+
if (!existsSync(dir))
|
|
56
|
+
return [];
|
|
57
|
+
const metadata = readMetadata(dir);
|
|
58
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
59
|
+
.filter(entry => entry.isFile() && !entry.name.startsWith('.'))
|
|
60
|
+
.map(entry => {
|
|
61
|
+
const stats = statSync(join(dir, entry.name));
|
|
62
|
+
const known = metadata[entry.name];
|
|
63
|
+
return {
|
|
64
|
+
name: entry.name,
|
|
65
|
+
title: known?.title ?? entry.name,
|
|
66
|
+
size: stats.size,
|
|
67
|
+
mimetype: MIME_TYPES[extname(entry.name).toLowerCase()] ?? known?.mimetype ?? 'application/octet-stream',
|
|
68
|
+
updatedAt: stats.mtime.toISOString()
|
|
69
|
+
};
|
|
70
|
+
})
|
|
71
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
72
|
+
};
|
|
73
|
+
// The requested name reaches us from an application configuration copied from a remote
|
|
74
|
+
// data-fair, so it is never to be trusted: resolve it and refuse anything that lands outside
|
|
75
|
+
// the attachments directory. undefined means "no such attachment", never "here is /etc/passwd".
|
|
76
|
+
export const attachmentPath = (dir, name) => {
|
|
77
|
+
if (!name)
|
|
78
|
+
return undefined;
|
|
79
|
+
const dirPath = resolve(dir);
|
|
80
|
+
const filePath = resolve(dirPath, name);
|
|
81
|
+
if (!filePath.startsWith(dirPath + sep))
|
|
82
|
+
return undefined;
|
|
83
|
+
return filePath;
|
|
84
|
+
};
|
|
85
|
+
// Replace the whole local attachments directory with the attachments of a remote application.
|
|
86
|
+
// Replace, and not merge: copying a configuration replaces the current one entirely, so leaving
|
|
87
|
+
// behind the images of the previously copied application would only produce a directory whose
|
|
88
|
+
// content matches no configuration at all.
|
|
89
|
+
//
|
|
90
|
+
// Everything is downloaded before anything is written, then the directory is swapped in one
|
|
91
|
+
// rename: an interrupted or partly failing copy — a private file with no api key, a network
|
|
92
|
+
// hiccup — never leaves a half-written directory behind, and what did download is kept. A copy
|
|
93
|
+
// where nothing downloads does empty the directory, which is the point: the configuration it
|
|
94
|
+
// came with is being applied all the same, and its images are genuinely missing.
|
|
95
|
+
export const copyAttachments = async (attachments, fetchAttachment, dir = ATTACHMENTS_DIR) => {
|
|
96
|
+
const result = { copied: [], failed: [] };
|
|
97
|
+
const downloaded = [];
|
|
98
|
+
for (const attachment of attachments ?? []) {
|
|
99
|
+
// a name with a path separator would write outside the directory, and data-fair has no
|
|
100
|
+
// reason to send one: skip it rather than sanitize a name the configuration still refers to
|
|
101
|
+
if (!attachment?.name || attachmentPath(dir, attachment.name) === undefined) {
|
|
102
|
+
if (attachment?.name)
|
|
103
|
+
result.failed.push(attachment.name);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
downloaded.push({ attachment: { ...attachment, name: attachment.name }, body: await fetchAttachment(attachment.name) });
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
result.failed.push(attachment.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// written aside then swapped in, so an interrupted copy leaves the previous directory intact
|
|
114
|
+
const tmpDir = dir + '.tmp';
|
|
115
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
116
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
117
|
+
for (const { attachment, body } of downloaded) {
|
|
118
|
+
writeFileSync(join(tmpDir, attachment.name), body);
|
|
119
|
+
result.copied.push(attachment.name);
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(join(tmpDir, METADATA_FILE), JSON.stringify(downloaded.map(d => d.attachment), null, 2));
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
renameSync(tmpDir, dir);
|
|
124
|
+
return result;
|
|
125
|
+
};
|
package/src/dev-env.d.ts
ADDED
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>;
|
package/src/env-file.js
ADDED
|
@@ -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;
|
package/src/localize.js
ADDED
|
@@ -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
|
+
};
|