@data-fair/dev-server 2.6.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.6.4",
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';
@@ -111,22 +111,46 @@ app.get('/config', (req, res, next) => {
111
111
  debug('read dev config', devConfig);
112
112
  res.send(devConfig);
113
113
  });
114
- // the config with datasets enriched from the remote data-fair, so the dev-server UI
115
- // can display schemas/concepts and build concept & dataset filter params with the
116
- // 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)
117
117
  app.get('/config/enriched', async (req, res) => {
118
118
  try {
119
- 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));
120
121
  }
121
122
  catch (err) {
122
123
  res.status(500).send({ error: err.message });
123
124
  }
124
125
  });
126
+ // what the UI last wrote, to tell its own saves from a hand edit of the file
127
+ let lastWrittenConfig;
125
128
  app.put('/config', (req, res, next) => {
126
129
  debug('save dev config', req.body);
127
- writeFileSync('.dev-config.json', JSON.stringify(req.body, null, 2));
130
+ lastWrittenConfig = JSON.stringify(req.body, null, 2);
131
+ writeFileSync('.dev-config.json', lastWrittenConfig);
128
132
  res.send(req.body);
129
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
+ });
130
154
  app.post('/config/error', (req, res) => {
131
155
  console.log('Application sent an error', req.body);
132
156
  for (const ws of devServerWS) {
@@ -186,8 +210,16 @@ const localAppInfo = async () => {
186
210
  };
187
211
  // Enrich the datasets from the remote data-fair and rewrite every remote origin to ours
188
212
  // (pure helpers in enrich.ts, wired here to our remote api and local origins).
189
- const prepareConfig = (configuration) => prepareRemoteConfig(configuration, {
213
+ const prepareConfig = (configuration, extraSelect) => prepareRemoteConfig(configuration, {
190
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,
191
223
  localize: localizeConfig,
192
224
  remoteOrigin: new URL(config.dataFair.url).origin,
193
225
  localOrigin: `http://localhost:${config.port}`
@@ -318,6 +350,22 @@ const captureSimulationScript = (opts) => {
318
350
  })()
319
351
  `;
320
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');
321
369
  // extract the major.minor part of a version, versions on remote base apps look like "1.3"
322
370
  const minorVersion = (version) => version.split('.').slice(0, 2).join('.');
323
371
  // list configurations of the app under development that exist on the remote data-fair
@@ -505,6 +553,14 @@ app.use('/app', createProxyMiddleware({
505
553
  // reliable here as in production.
506
554
  headNode.childNodes.unshift(script);
507
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);
508
564
  // companion script that lets the embedded app report its height / sync params
509
565
  // to the parent <d-frame> ; injected for every mode so the UI mode toggle works
510
566
  // without requiring a df:overflow meta (same variant & version as data-fair prod)
@@ -562,9 +618,12 @@ app.use('/data-fair', createProxyMiddleware({
562
618
  proxyRes(proxyRes, req, res) {
563
619
  // console.log('DF RES', proxyRes)
564
620
  if (proxyRes.headers['content-type'] && proxyRes.headers['content-type'].startsWith('application/json')) {
565
- let body = '';
566
- 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); });
567
625
  proxyRes.on('end', () => {
626
+ const body = Buffer.concat(chunks).toString();
568
627
  const output = body.replace(new RegExp(escapeStringRegexp(config.dataFair.url), 'g'), `http://localhost:${config.port}/data-fair`);
569
628
  // proxyRes.headers['content-length'] = output.length
570
629
  delete proxyRes.headers['content-length'];
@@ -596,9 +655,12 @@ app.use('/tileserver', createProxyMiddleware({
596
655
  },
597
656
  proxyRes(proxyRes, req, res) {
598
657
  if (proxyRes.headers['content-type'] && proxyRes.headers['content-type'].startsWith('application/json')) {
599
- let body = '';
600
- 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); });
601
662
  proxyRes.on('end', () => {
663
+ const body = Buffer.concat(chunks).toString();
602
664
  // make all references to the tileserver url point to the local proxy
603
665
  const output = body.replace(new RegExp(escapeStringRegexp(tileserverUrl), 'g'), `http://localhost:${config.port}/tileserver`);
604
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.