@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 +3 -4
- package/package.json +1 -1
- package/src/app.js +125 -55
- package/src/dev-env.js +3 -2
- package/src/enrich.d.ts +8 -2
- package/src/enrich.js +109 -37
- package/src/env-file.d.ts +1 -0
- package/src/env-file.js +9 -0
- package/ui/dist/assets/{index-D8biKNGI.js → index-BvfgViF2.js} +25 -25
- package/ui/dist/index.html +4 -1
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`)
|
|
54
|
-
invisible
|
|
55
|
-
|
|
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
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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('
|
|
48
|
-
|
|
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', () => {
|
|
71
|
+
ws.on('close', () => {
|
|
72
|
+
dataFairWS = dataFairWS.filter(_ws => _ws !== ws);
|
|
73
|
+
dataFairWSChannels.delete(ws);
|
|
74
|
+
});
|
|
55
75
|
ws.on('message', (data) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const baseApps = await remoteFetch(
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
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 ' +
|
|
50
|
-
datasetsCache.set(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|