@data-fair/dev-server 2.6.3 → 2.7.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
@@ -50,10 +50,9 @@ développement, dans la même version mineure. La recherche part du nom lu dans
50
50
  balise `<meta name="application-name">` de l'`index.html` local et de la version
51
51
  du `package.json`.
52
52
 
53
- Une application de base réservée à une organisation (`privateAccess`) est
54
- invisible à une requête anonyme. Le paramètre `privateAccess` la rend visible,
55
- mais data-fair répond 401 sans authentification — il n'est donc envoyé que si
56
- `DATAFAIR_API_KEY` est renseignée.
53
+ Une application de base réservée à une organisation (`privateAccess`) reste
54
+ invisible : data-fair ne lit pas la clé d'API sur `/base-applications` et répond
55
+ 401 au paramètre `privateAccess`, que le dev-server n'envoie donc pas.
57
56
 
58
57
  Ne pas la trouver n'arrête pas la recherche pour autant : les applications qui
59
58
  tournent dessus sont souvent publiques, et il suffit de connaître son URL pour
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.6.3",
3
+ "version": "2.7.0",
4
4
  "description": "A development server for optimal development experience of data-fair applications.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -10,7 +10,7 @@ import { WebSocket, WebSocketServer } from 'ws';
10
10
  import { createServer } from 'node:http';
11
11
  import express from 'express';
12
12
  import cors from 'cors';
13
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, readFileSync, writeFileSync, watchFile } from 'node:fs';
14
14
  import debugModule from 'debug';
15
15
  import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
16
16
  import * as parse5 from 'parse5';
@@ -30,42 +30,62 @@ let devServerWS = [];
30
30
  let dataFairWS = [];
31
31
  const dataFairWSChannels = new Map();
32
32
  const dfWsUrl = config.dataFair.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/';
33
- const dataFairOutputWS = new WebSocket(dfWsUrl);
34
- dataFairOutputWS.on('open', () => {
35
- debug('connect to external websocket server ' + dfWsUrl);
36
- });
37
- dataFairOutputWS.on('message', (data) => {
38
- const dataStr = data.toString();
39
- const message = JSON.parse(dataStr);
40
- debug('incoming message from data-fair socket', message);
41
- for (const ws of dataFairWS) {
42
- if (dataFairWSChannels.get(ws)?.includes(message.channel)) {
43
- ws.send(dataStr);
33
+ // the connection to data-fair is reopened whenever it closes (network change, data-fair restart),
34
+ // and the channels of the connected apps are subscribed again on it
35
+ let dataFairOutputWS;
36
+ const subscribe = (channel) => {
37
+ const message = { type: 'subscribe', channel };
38
+ if (config.dataFair.apiKey)
39
+ message.apiKey = config.dataFair.apiKey;
40
+ dataFairOutputWS.send(JSON.stringify(message));
41
+ };
42
+ const connectDataFairWS = () => {
43
+ dataFairOutputWS = new WebSocket(dfWsUrl);
44
+ dataFairOutputWS.on('open', () => {
45
+ debug('connect to external websocket server ' + dfWsUrl);
46
+ for (const channel of new Set([...dataFairWSChannels.values()].flat()))
47
+ subscribe(channel);
48
+ });
49
+ dataFairOutputWS.on('message', (data) => {
50
+ const dataStr = data.toString();
51
+ const message = JSON.parse(dataStr);
52
+ debug('incoming message from data-fair socket', message);
53
+ for (const ws of dataFairWS) {
54
+ if (dataFairWSChannels.get(ws)?.includes(message.channel)) {
55
+ ws.send(dataStr);
56
+ }
44
57
  }
45
- }
46
- });
47
- dataFairOutputWS.on('error', (err) => console.error('failed to connect to external websocket server', dfWsUrl, err));
48
- dataFairOutputWS.on('close', () => console.error('external websocket was closed', dfWsUrl));
58
+ });
59
+ dataFairOutputWS.on('error', (err) => console.error('failed to connect to external websocket server', dfWsUrl, err));
60
+ dataFairOutputWS.on('close', () => {
61
+ console.error('external websocket was closed, reconnecting in 2s', dfWsUrl);
62
+ setTimeout(connectDataFairWS, 2000);
63
+ });
64
+ };
65
+ connectDataFairWS();
49
66
  server.on('upgrade', function upgrade(req, socket, head) {
50
67
  wss.handleUpgrade(req, socket, head, (ws) => {
51
68
  debug('Opening websocket ' + req.url);
52
69
  if (req.url === '/data-fair' || req.url === '/data-fair/') {
53
70
  dataFairWS.push(ws);
54
- ws.on('close', () => { dataFairWS = dataFairWS.filter(_ws => _ws !== ws); });
71
+ ws.on('close', () => {
72
+ dataFairWS = dataFairWS.filter(_ws => _ws !== ws);
73
+ dataFairWSChannels.delete(ws);
74
+ });
55
75
  ws.on('message', (data) => {
56
- if (dataFairOutputWS) {
57
- const message = JSON.parse(data);
58
- debug('outgoing message to data-fair socket', message);
59
- if (message.type === 'subscribe') {
60
- dataFairWSChannels.set(ws, (dataFairWSChannels.get(ws) ?? []).concat([message.channel]));
61
- if (config.dataFair.apiKey)
62
- message.apiKey = config.dataFair.apiKey;
63
- }
64
- if (message.type === 'unsubscribe') {
65
- dataFairWSChannels.set(ws, (dataFairWSChannels.get(ws) ?? []).filter(c => c !== message.channel));
66
- }
67
- dataFairOutputWS.send(JSON.stringify(message));
76
+ const message = JSON.parse(data);
77
+ debug('outgoing message to data-fair socket', message);
78
+ if (message.type === 'subscribe') {
79
+ dataFairWSChannels.set(ws, (dataFairWSChannels.get(ws) ?? []).concat([message.channel]));
80
+ if (config.dataFair.apiKey)
81
+ message.apiKey = config.dataFair.apiKey;
82
+ }
83
+ if (message.type === 'unsubscribe') {
84
+ dataFairWSChannels.set(ws, (dataFairWSChannels.get(ws) ?? []).filter(c => c !== message.channel));
68
85
  }
86
+ // while data-fair is unreachable the subscriptions are only recorded, the open handler sends them
87
+ if (dataFairOutputWS.readyState === WebSocket.OPEN)
88
+ dataFairOutputWS.send(JSON.stringify(message));
69
89
  });
70
90
  }
71
91
  else if (req.url === '/') {
@@ -91,22 +111,46 @@ app.get('/config', (req, res, next) => {
91
111
  debug('read dev config', devConfig);
92
112
  res.send(devConfig);
93
113
  });
94
- // the config with datasets enriched from the remote data-fair, so the dev-server UI
95
- // can display schemas/concepts and build concept & dataset filter params with the
96
- // exact same data as the application receives in window.APPLICATION
114
+ // the config with datasets enriched from the remote data-fair, as the application receives it in
115
+ // window.APPLICATION ; ?select= adds dataset properties for a dev-server tool that needs them
116
+ // whatever the application selects (the filter tester and its schema fields)
97
117
  app.get('/config/enriched', async (req, res) => {
98
118
  try {
99
- res.send(await prepareConfig(readDevConfig()));
119
+ const extraSelect = typeof req.query.select === 'string' ? req.query.select.split(',') : undefined;
120
+ res.send(await prepareConfig(readDevConfig(), extraSelect));
100
121
  }
101
122
  catch (err) {
102
123
  res.status(500).send({ error: err.message });
103
124
  }
104
125
  });
126
+ // what the UI last wrote, to tell its own saves from a hand edit of the file
127
+ let lastWrittenConfig;
105
128
  app.put('/config', (req, res, next) => {
106
129
  debug('save dev config', req.body);
107
- writeFileSync('.dev-config.json', JSON.stringify(req.body, null, 2));
130
+ lastWrittenConfig = JSON.stringify(req.body, null, 2);
131
+ writeFileSync('.dev-config.json', lastWrittenConfig);
108
132
  res.send(req.body);
109
133
  });
134
+ // A hand edit of .dev-config.json (an editor, an agent) reloads the UI, otherwise the form state
135
+ // would overwrite it on its next save. Polled rather than fs.watch'ed: editors that save through
136
+ // a rename would detach a watcher. A file that does not parse yet is a save in progress, skipped.
137
+ watchFile('.dev-config.json', { interval: 500 }, () => {
138
+ if (!existsSync('.dev-config.json'))
139
+ return;
140
+ const content = readFileSync('.dev-config.json', 'utf8');
141
+ if (content === lastWrittenConfig)
142
+ return;
143
+ try {
144
+ JSON.parse(content);
145
+ }
146
+ catch {
147
+ return;
148
+ }
149
+ lastWrittenConfig = content;
150
+ debug('.dev-config.json edited outside of the UI');
151
+ for (const ws of devServerWS)
152
+ ws.send(JSON.stringify({ type: 'config-file-changed' }));
153
+ });
110
154
  app.post('/config/error', (req, res) => {
111
155
  console.log('Application sent an error', req.body);
112
156
  for (const ws of devServerWS) {
@@ -164,19 +208,18 @@ const localAppInfo = async () => {
164
208
  }
165
209
  return { name, version, packageName };
166
210
  };
167
- // The owner the dev-server works on behalf of, in the "type:id[:department]" form the
168
- // data-fair api expects for its owner and privateAccess filters.
169
- const ownerFilter = () => {
170
- const owner = config.dataFair.owner;
171
- let filter = `${owner.type}:${owner.id}`;
172
- if (owner.department)
173
- filter += ':' + owner.department;
174
- return filter;
175
- };
176
211
  // Enrich the datasets from the remote data-fair and rewrite every remote origin to ours
177
212
  // (pure helpers in enrich.ts, wired here to our remote api and local origins).
178
- const prepareConfig = (configuration) => prepareRemoteConfig(configuration, {
213
+ const prepareConfig = (configuration, extraSelect) => prepareRemoteConfig(configuration, {
179
214
  fetchJson: remoteFetch,
215
+ // the local file, as data-fair reads the one of the base application to deduce its selects
216
+ fetchConfigSchema: async () => {
217
+ const res = await fetch(appUrl.origin + appPrefix + '/config-schema.json');
218
+ if (!res.ok)
219
+ throw new Error('config-schema.json ' + res.status);
220
+ return res.json();
221
+ },
222
+ extraSelect,
180
223
  localize: localizeConfig,
181
224
  remoteOrigin: new URL(config.dataFair.url).origin,
182
225
  localOrigin: `http://localhost:${config.port}`
@@ -307,6 +350,22 @@ const captureSimulationScript = (opts) => {
307
350
  })()
308
351
  `;
309
352
  };
353
+ // Count what the previewed app writes to the console as error / warning and report it to the
354
+ // dev-server UI, which shows the counts as chips nudging toward the DevTools console.
355
+ // Failed requests are counted from their HTTP status (what Chrome logs as "Failed to load
356
+ // resource", caught by the app or not), not from the resource's error event, which also fires
357
+ // on a decoding failure Chrome never logs (an image answered by Vite's SPA fallback). Kept on a
358
+ // single line so that the inline source map (one "AAAA" segment) covers all of it and its
359
+ // x_google_ignoreList hides the wrapper: DevTools then locates console.error / console.warn at
360
+ // the app's call site instead of here.
361
+ const CONSOLE_COUNTER_SOURCE_MAP = Buffer.from(JSON.stringify({
362
+ version: 3, sources: ['df-dev-server-console-counter.js'], mappings: 'AAAA', x_google_ignoreList: [0]
363
+ })).toString('base64');
364
+ const CONSOLE_COUNTER_SCRIPT = [
365
+ ";(function () { if (window.parent === window) return; var post = function (level) { window.parent.postMessage({ type: 'df-dev-server:console', level: level }, '*') }; post('reset'); ['error', 'warn'].forEach(function (method) { var original = console[method]; console[method] = function () { original.apply(console, arguments); post(method === 'warn' ? 'warning' : 'error') } }); window.addEventListener('error', function (e) { if (!/^ResizeObserver loop/.test(e.message)) post('error') }); new PerformanceObserver(function (list) { list.getEntries().forEach(function (e) { if (e.responseStatus >= 400) post('error') }) }).observe({ type: 'resource', buffered: true }); window.addEventListener('unhandledrejection', function () { post('error') }) })()",
366
+ '//# sourceURL=df-dev-server-console-counter.js',
367
+ '//# sourceMappingURL=data:application/json;base64,' + CONSOLE_COUNTER_SOURCE_MAP
368
+ ].join('\n');
310
369
  // extract the major.minor part of a version, versions on remote base apps look like "1.3"
311
370
  const minorVersion = (version) => version.split('.').slice(0, 2).join('.');
312
371
  // list configurations of the app under development that exist on the remote data-fair
@@ -323,15 +382,12 @@ app.get('/configurations', async (req, res) => {
323
382
  return;
324
383
  }
325
384
  response.minorVersion = minorVersion(version);
326
- // a base application restricted to an organization is invisible to an anonymous request.
327
- // privateAccess is what makes it visible, and data-fair answers 401 to it without
328
- // credentials, so it is only sent when an api key is configured.
329
385
  response.authenticated = !!config.dataFair.apiKey;
330
386
  try {
331
- let baseAppsQuery = '/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false';
332
- if (response.authenticated)
333
- baseAppsQuery += '&privateAccess=' + encodeURIComponent(ownerFilter());
334
- const baseApps = await remoteFetch(baseAppsQuery);
387
+ // no privateAccess filter: data-fair reads no api key on /base-applications and answers 401
388
+ // to it, so a base application restricted to an organization stays invisible here and is
389
+ // reached by guessing its url below
390
+ const baseApps = await remoteFetch('/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false');
335
391
  const matchingBaseApps = (baseApps.results ?? []).filter((b) => typeof b.version === 'string' && minorVersion(b.version) === response.minorVersion);
336
392
  // Not finding the base application is not the end of the search: it may simply be private
337
393
  // while the applications running on it are public, and those are reachable as soon as its
@@ -497,6 +553,14 @@ app.use('/app', createProxyMiddleware({
497
553
  // reliable here as in production.
498
554
  headNode.childNodes.unshift(script);
499
555
  }
556
+ // right after <meta charset>, so that the byte offset of the charset (checked by the
557
+ // UI on this same proxied document) stays unaffected, and before the app's own scripts
558
+ // so that errors raised while it boots are counted too (vite's client script placed
559
+ // above is a module, it runs later anyway)
560
+ const consoleScript = createElement('script', { type: 'text/javascript' });
561
+ appendChild(consoleScript, createTextNode(CONSOLE_COUNTER_SCRIPT));
562
+ const charsetIndex = headNode.childNodes.findIndex(c => isElementNode(c) && c.tagName === 'meta' && c.attrs.some(a => a.name === 'charset'));
563
+ headNode.childNodes.splice(charsetIndex + 1, 0, consoleScript);
500
564
  // companion script that lets the embedded app report its height / sync params
501
565
  // to the parent <d-frame> ; injected for every mode so the UI mode toggle works
502
566
  // without requiring a df:overflow meta (same variant & version as data-fair prod)
@@ -554,9 +618,12 @@ app.use('/data-fair', createProxyMiddleware({
554
618
  proxyRes(proxyRes, req, res) {
555
619
  // console.log('DF RES', proxyRes)
556
620
  if (proxyRes.headers['content-type'] && proxyRes.headers['content-type'].startsWith('application/json')) {
557
- let body = '';
558
- proxyRes.on('data', (data) => { body += data.toString(); });
621
+ // decode the whole body once: decoding chunk by chunk turns a multi-byte character
622
+ // split across two chunks into U+FFFD (« Repr��sentation »)
623
+ const chunks = [];
624
+ proxyRes.on('data', (data) => { chunks.push(data); });
559
625
  proxyRes.on('end', () => {
626
+ const body = Buffer.concat(chunks).toString();
560
627
  const output = body.replace(new RegExp(escapeStringRegexp(config.dataFair.url), 'g'), `http://localhost:${config.port}/data-fair`);
561
628
  // proxyRes.headers['content-length'] = output.length
562
629
  delete proxyRes.headers['content-length'];
@@ -588,9 +655,12 @@ app.use('/tileserver', createProxyMiddleware({
588
655
  },
589
656
  proxyRes(proxyRes, req, res) {
590
657
  if (proxyRes.headers['content-type'] && proxyRes.headers['content-type'].startsWith('application/json')) {
591
- let body = '';
592
- proxyRes.on('data', (data) => { body += data.toString(); });
658
+ // decode the whole body once: decoding chunk by chunk turns a multi-byte character
659
+ // split across two chunks into U+FFFD (« Repr��sentation »)
660
+ const chunks = [];
661
+ proxyRes.on('data', (data) => { chunks.push(data); });
593
662
  proxyRes.on('end', () => {
663
+ const body = Buffer.concat(chunks).toString();
594
664
  // make all references to the tileserver url point to the local proxy
595
665
  const output = body.replace(new RegExp(escapeStringRegexp(tileserverUrl), 'g'), `http://localhost:${config.port}/tileserver`);
596
666
  delete proxyRes.headers['content-length'];
package/src/dev-env.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { createServer } from 'node:net';
4
4
  import { parseArgs } from 'node:util';
5
- import { MIN_BASE, MAX_BASE, renderEnv, findBase, readEnvVar } from './env-file.js';
5
+ import { MIN_BASE, MAX_BASE, renderEnv, mergeEnv, findBase, readEnvVar } from './env-file.js';
6
6
  const { values } = parseArgs({
7
7
  options: {
8
8
  force: { type: 'boolean', default: false },
@@ -35,5 +35,6 @@ const draw = () => MIN_BASE + Math.floor(Math.random() * (MAX_BASE - MIN_BASE +
35
35
  // application served at the root back under /app/ along the way.
36
36
  const appPath = values['app-path'] ?? storedAppPath ?? '/app/';
37
37
  const base = await findBase(isFree, draw);
38
- writeFileSync('.env', renderEnv(base, appPath));
38
+ const generated = renderEnv(base, appPath);
39
+ writeFileSync('.env', existing === undefined ? generated : mergeEnv(existing, generated));
39
40
  console.log(`.env généré — app ${base}, dev-server ${base + 1}, e2e ${base + 2}, chemin ${appPath || '/ (racine)'}`);
package/src/enrich.d.ts CHANGED
@@ -1,7 +1,13 @@
1
- export declare const INJECTED_DATASET_PROPS: readonly ["id", "href", "page", "title", "slug", "finalizedAt", "schema", "isRest", "userPermissions", "bbox", "timePeriod", "attachmentsAsImage", "count"];
2
- export declare const enrichDataset: (dataset: any, fetchJson: (path: string) => Promise<any>) => Promise<any>;
1
+ export interface DatasetFilter {
2
+ select?: string[];
3
+ properties?: Record<string, any>;
4
+ }
5
+ export declare const datasetsFiltersFromSchema: (configSchema: any) => DatasetFilter[];
6
+ export declare const enrichDataset: (dataset: any, fetchJson: (path: string) => Promise<any>, filter?: DatasetFilter) => Promise<any>;
3
7
  export interface PrepareConfigDeps {
4
8
  fetchJson: (path: string) => Promise<any>;
9
+ fetchConfigSchema: () => Promise<any>;
10
+ extraSelect?: string[];
5
11
  localize: (configuration: any, remoteOrigin: string, localOrigin: string) => any;
6
12
  remoteOrigin: string;
7
13
  localOrigin: string;
package/src/enrich.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // Rebuild the configuration dataset entries the same way data-fair does in production
2
2
  // (refreshConfigDatasetsRefs in api/src/applications/utils.ts): an application stores only
3
- // minimal dataset references, data-fair injects the full schema (with concepts), finalizedAt,
4
- // slug, isRest and the per-request userPermissions at serving time. We reproduce that so
5
- // applications following the skill contract read window.APPLICATION.configuration.datasets
6
- // identically in dev and in prod.
3
+ // minimal dataset references, data-fair refreshes them at serving time with the keys already
4
+ // stored in the entry, plus `finalizedAt` and `slug`, plus the `select` of the dataset selector
5
+ // declared in the application's config schema (baseApp.datasetsFilters, deduced by initBaseApp in
6
+ // api/src/base-applications/service.ts). Nothing more: an application that reads a property its
7
+ // selector does not ask for works here only if it works in production too.
7
8
  //
8
9
  // Pure module, no imports: it takes its remote accessor and origin rewrite as dependencies,
9
10
  // wired in app.ts — so it can be unit tested without a build (test/enrich.test.ts).
@@ -12,52 +13,123 @@
12
13
  // key would otherwise be re-fetched, and re-warned about, on every single reload.
13
14
  const datasetsCache = new Map();
14
15
  const DATASETS_CACHE_TTL = 30_000;
15
- // The set of properties data-fair injects in a configuration dataset entry. Sticking to it
16
- // matters in both directions: forwarding the whole remote dataset would let an application rely,
17
- // in dev, on a property production never sends — and omitting one production does send makes an
18
- // application work in production and not here, which is worse still.
19
- //
20
- // data-fair derives the set per request (refreshConfigDatasetsRefs, api/src/applications/utils.ts):
21
- // the keys already stored in the entry, plus `finalizedAt` and `slug`, plus the `select` of the
22
- // dataset selector declared in the application's own config schema. We cannot derive it without
23
- // parsing that schema, so this list mirrors it and has to track it: it is the union of the
24
- // selects used by the published applications. `isRest` gates whether rest line editing
25
- // (POST/PATCH/DELETE /lines) is offered at all; `bbox` and `timePeriod` drive the initial map
26
- // bounds and period pickers; `attachmentsAsImage` switches list apps to thumbnail mode.
27
- export const INJECTED_DATASET_PROPS = ['id', 'href', 'page', 'title', 'slug', 'finalizedAt', 'schema', 'isRest', 'userPermissions', 'bbox', 'timePeriod', 'attachmentsAsImage', 'count'];
28
- export const enrichDataset = async (dataset, fetchJson) => {
29
- if (!dataset?.id)
30
- return dataset;
31
- const cached = datasetsCache.get(dataset.id);
32
- if (cached && Date.now() - cached.fetchedAt < DATASETS_CACHE_TTL) {
33
- return cached.data ? { ...dataset, ...cached.data } : dataset;
16
+ // data-fair's resolveLocalRefs (api/src/misc/utils/json-refs.ts): "#/..." pointers replaced by
17
+ // their target, circular and unresolvable ones left as they are.
18
+ const resolveLocalRefs = (root) => {
19
+ const atPointer = (pointer) => pointer.split('/').slice(1)
20
+ .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
21
+ .reduce((o, k) => (o !== null && typeof o === 'object') ? o[k] : undefined, root);
22
+ const walk = (node, stack) => {
23
+ if (Array.isArray(node))
24
+ return node.map(n => walk(n, stack));
25
+ if (node === null || typeof node !== 'object')
26
+ return node;
27
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#')) {
28
+ if (stack.includes(node.$ref))
29
+ return node;
30
+ const target = atPointer(node.$ref);
31
+ if (target === undefined)
32
+ return node;
33
+ return walk(target, [...stack, node.$ref]);
34
+ }
35
+ const res = {};
36
+ for (const [k, v] of Object.entries(node))
37
+ res[k] = walk(v, stack);
38
+ return res;
39
+ };
40
+ return walk(root, []);
41
+ };
42
+ // data-fair's getFragmentFetchUrl (api/src/base-applications/operations.ts)
43
+ const fragmentFetchUrl = (fragment) => {
44
+ if (!fragment)
45
+ return null;
46
+ if (fragment['x-fromUrl'])
47
+ return fragment['x-fromUrl'];
48
+ const url = fragment.layout?.getItems?.url;
49
+ if (url)
50
+ return typeof url === 'string' ? url : url.expr;
51
+ return null;
52
+ };
53
+ // The datasetsFilters data-fair stores on the base application, one per dataset selector of the
54
+ // config schema, matched by position with configuration.datasets. Like data-fair, a schema that
55
+ // cannot be read gives no filter at all.
56
+ export const datasetsFiltersFromSchema = (configSchema) => {
57
+ try {
58
+ const schema = resolveLocalRefs(configSchema);
59
+ const definition = schema?.properties?.datasets ?? schema?.allOf?.[0]?.properties?.datasets;
60
+ if (!definition)
61
+ return [];
62
+ let fetches = [];
63
+ if (definition.items && fragmentFetchUrl(definition))
64
+ fetches = [{ fromUrl: fragmentFetchUrl(definition), properties: definition.items.properties }];
65
+ if (fragmentFetchUrl(definition.items))
66
+ fetches = [{ fromUrl: fragmentFetchUrl(definition.items), properties: definition.items.properties }];
67
+ if (Array.isArray(definition.items))
68
+ fetches = definition.items.filter((item) => fragmentFetchUrl(item)).map((item) => ({ fromUrl: fragmentFetchUrl(item), properties: item.properties }));
69
+ return fetches.map(({ fromUrl, properties }) => {
70
+ const select = new URL(fromUrl, 'http://localhost').searchParams.get('select');
71
+ return { ...(select ? { select: select.split(',') } : {}), ...(properties ? { properties } : {}) };
72
+ });
73
+ }
74
+ catch {
75
+ return [];
34
76
  }
77
+ };
78
+ const fetchFreshDataset = async (id, fetchJson) => {
79
+ const cached = datasetsCache.get(id);
80
+ if (cached && Date.now() - cached.fetchedAt < DATASETS_CACHE_TTL)
81
+ return cached.data;
35
82
  try {
36
- const fresh = await fetchJson('/datasets/' + encodeURIComponent(dataset.id));
37
- const data = {};
38
- for (const prop of INJECTED_DATASET_PROPS) {
39
- if (fresh[prop] !== undefined)
40
- data[prop] = fresh[prop];
41
- }
42
- data.userPermissions = fresh.userPermissions ?? [];
43
- datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
44
- return { ...dataset, ...data };
83
+ const fresh = await fetchJson('/datasets/' + encodeURIComponent(id));
84
+ datasetsCache.set(id, { data: fresh, fetchedAt: Date.now() });
85
+ return fresh;
45
86
  }
46
87
  catch (err) {
47
88
  // a private dataset without an api key, or a network failure: keep the raw
48
89
  // configuration entry so the app still loads, and warn in the dev-server UI
49
- console.warn('[dev-server] failed to enrich dataset ' + dataset.id + ', keeping raw configuration entry', err);
50
- datasetsCache.set(dataset.id, { data: null, fetchedAt: Date.now() });
90
+ console.warn('[dev-server] failed to enrich dataset ' + id + ', keeping raw configuration entry', err);
91
+ datasetsCache.set(id, { data: null, fetchedAt: Date.now() });
92
+ return null;
93
+ }
94
+ };
95
+ export const enrichDataset = async (dataset, fetchJson, filter = {}) => {
96
+ if (!dataset?.id)
51
97
  return dataset;
98
+ const fresh = await fetchFreshDataset(dataset.id, fetchJson);
99
+ const result = { ...dataset };
100
+ if (fresh) {
101
+ for (const key of [...Object.keys(dataset), 'finalizedAt', 'slug', ...(filter.select ?? [])]) {
102
+ if (key === 'userPermissions')
103
+ result.userPermissions = fresh.userPermissions ?? [];
104
+ else if (key in fresh)
105
+ result[key] = fresh[key];
106
+ else if (key === 'partOf')
107
+ delete result.partOf;
108
+ }
52
109
  }
110
+ for (const [key, prop] of Object.entries(filter.properties ?? {})) {
111
+ if (prop?.default && !(key in result))
112
+ result[key] = prop.default;
113
+ if (prop?.const)
114
+ result[key] = prop.const;
115
+ }
116
+ return result;
53
117
  };
54
118
  // Enrich the datasets from the remote data-fair, then rewrite every remote origin to ours.
55
119
  // The rewrite is applied even when there is no dataset to enrich: a configuration can carry
56
120
  // remote urls anywhere (logos, links, tileserver styles), not only in datasets[].href.
57
121
  export const prepareConfig = async (configuration, deps) => {
58
122
  const datasets = configuration?.datasets?.filter((d) => !!d);
59
- const enriched = datasets?.length
60
- ? { ...configuration, datasets: await Promise.all(datasets.map(d => enrichDataset(d, deps.fetchJson))) }
61
- : configuration;
123
+ let enriched = configuration;
124
+ if (datasets?.length) {
125
+ const filters = datasetsFiltersFromSchema(await deps.fetchConfigSchema().catch(() => null));
126
+ enriched = {
127
+ ...configuration,
128
+ datasets: await Promise.all(datasets.map((d, i) => {
129
+ const filter = filters[i] ?? {};
130
+ return enrichDataset(d, deps.fetchJson, deps.extraSelect ? { ...filter, select: [...(filter.select ?? []), ...deps.extraSelect] } : filter);
131
+ }))
132
+ };
133
+ }
62
134
  return deps.localize(enriched, deps.remoteOrigin, deps.localOrigin);
63
135
  };
package/src/env-file.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export declare const MIN_BASE = 20000;
2
2
  export declare const MAX_BASE = 29997;
3
3
  export declare const renderEnv: (base: number, appPath: string) => string;
4
+ export declare const mergeEnv: (existing: string, generated: string) => string;
4
5
  export declare const readEnvVar: (content: string, name: string) => string | undefined;
5
6
  export declare const findBase: (isFree: (port: number) => Promise<boolean>, draw: () => number, attempts?: number) => Promise<number>;
package/src/env-file.js CHANGED
@@ -10,6 +10,15 @@ DEV_SERVER_PORT=${base + 1}
10
10
  E2E_PORT=${base + 2}
11
11
  APP_PATH=${appPath}
12
12
  `;
13
+ // --force redraws our own lines and keeps every other one of an existing .env: the remote
14
+ // settings a developer added there (DATAFAIR_URL, DATAFAIR_API_KEY...) are not ours to drop.
15
+ const OWN_LINE = /^(# généré par df-dev-env|(APP_PORT|DEV_SERVER_PORT|E2E_PORT|APP_PATH)=)/;
16
+ export const mergeEnv = (existing, generated) => {
17
+ const kept = existing.split('\n').filter(l => !OWN_LINE.test(l));
18
+ while (kept.length && kept[kept.length - 1].trim() === '')
19
+ kept.pop();
20
+ return kept.length ? generated + kept.join('\n') + '\n' : generated;
21
+ };
13
22
  // Reads one variable out of an already written .env. Used to tell a .env that carries our ports
14
23
  // from one written for another purpose, and to keep the APP_PATH a developer chose when --force
15
24
  // redraws the ports. Deliberately naive: the file we read is the one we wrote.