@data-fair/dev-server 2.5.2 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
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
@@ -2,6 +2,8 @@
2
2
  import config from './config.js';
3
3
  import uiConfig from './ui-config.js';
4
4
  import { localizeConfig } from './localize.js';
5
+ import { remoteFetch, remoteFetchBuffer } from './remote.js';
6
+ import { prepareConfig as prepareRemoteConfig } from './enrich.js';
5
7
  import { ATTACHMENTS_DIR, attachmentPath, copyAttachments, listAttachments } from './attachments.js';
6
8
  import { candidateBaseAppUrls } from './base-app-urls.js';
7
9
  import { WebSocket, WebSocketServer } from 'ws';
@@ -171,76 +173,14 @@ const ownerFilter = () => {
171
173
  filter += ':' + owner.department;
172
174
  return filter;
173
175
  };
174
- // Fetch a resource from the remote data-fair api
175
- const remoteFetch = async (path) => {
176
- const res = await remoteFetchRaw(path);
177
- return res.json();
178
- };
179
- // Same, for an attachment: a file, never json, and never decoded as text — see the app proxy
180
- // below, where decoding a binary body as utf8 destroys it.
181
- const remoteFetchBuffer = async (path) => {
182
- const res = await remoteFetchRaw(path);
183
- return Buffer.from(await res.arrayBuffer());
184
- };
185
- const remoteFetchRaw = async (path) => {
186
- const res = await fetch(config.dataFair.url + '/api/v1' + path, {
187
- headers: config.dataFair.apiKey ? { 'x-apiKey': config.dataFair.apiKey } : {}
188
- });
189
- if (!res.ok)
190
- throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
191
- return res;
192
- };
193
- // Short-lived cache for the dataset enrichment below, so that every preview reload does not
194
- // hammer the remote data-fair API. Failures are cached too: a private dataset without an api
195
- // key would otherwise be re-fetched, and re-warned about, on every single reload.
196
- const datasetsCache = new Map();
197
- const DATASETS_CACHE_TTL = 30_000;
198
- // The exact set of properties data-fair injects in a configuration dataset entry. Sticking to
199
- // it matters: forwarding the whole remote dataset would let an application rely, in dev, on a
200
- // property that production never sends.
201
- const INJECTED_DATASET_PROPS = ['id', 'href', 'page', 'title', 'slug', 'finalizedAt', 'schema', 'userPermissions'];
202
- // Rebuild a configuration dataset entry the same way data-fair does in production
203
- // (refreshConfigDatasetsRefs in api/src/applications/utils.ts): the app stores only
204
- // minimal references, data-fair injects the full schema (with concepts), finalizedAt,
205
- // slug and userPermissions at request time. We reproduce that so applications
206
- // following the skill contract read window.APPLICATION.configuration.datasets
207
- // identically in dev and in prod.
208
- const enrichDataset = async (dataset) => {
209
- if (!dataset?.id)
210
- return dataset;
211
- const cached = datasetsCache.get(dataset.id);
212
- if (cached && Date.now() - cached.fetchedAt < DATASETS_CACHE_TTL) {
213
- return cached.data ? { ...dataset, ...cached.data } : dataset;
214
- }
215
- try {
216
- const fresh = await remoteFetch('/datasets/' + encodeURIComponent(dataset.id));
217
- const data = {};
218
- for (const prop of INJECTED_DATASET_PROPS) {
219
- if (fresh[prop] !== undefined)
220
- data[prop] = fresh[prop];
221
- }
222
- data.userPermissions = fresh.userPermissions ?? [];
223
- datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
224
- return { ...dataset, ...data };
225
- }
226
- catch (err) {
227
- // a private dataset without an api key, or a network failure: keep the raw
228
- // configuration entry so the app still loads, and warn in the dev-server UI
229
- console.warn('[dev-server] failed to enrich dataset ' + dataset.id + ', keeping raw configuration entry', err);
230
- datasetsCache.set(dataset.id, { data: null, fetchedAt: Date.now() });
231
- return dataset;
232
- }
233
- };
234
- // Enrich the datasets from the remote data-fair, then rewrite every remote origin to ours.
235
- // The rewrite is applied even when there is no dataset to enrich: a configuration can carry
236
- // remote urls anywhere (logos, links, tileserver styles), not only in datasets[].href.
237
- const prepareConfig = async (configuration) => {
238
- const datasets = configuration?.datasets?.filter((d) => !!d);
239
- const enriched = datasets?.length
240
- ? { ...configuration, datasets: await Promise.all(datasets.map(enrichDataset)) }
241
- : configuration;
242
- return localizeConfig(enriched, new URL(config.dataFair.url).origin, `http://localhost:${config.port}`);
243
- };
176
+ // Enrich the datasets from the remote data-fair and rewrite every remote origin to ours
177
+ // (pure helpers in enrich.ts, wired here to our remote api and local origins).
178
+ const prepareConfig = (configuration) => prepareRemoteConfig(configuration, {
179
+ fetchJson: remoteFetch,
180
+ localize: localizeConfig,
181
+ remoteOrigin: new URL(config.dataFair.url).origin,
182
+ localOrigin: `http://localhost:${config.port}`
183
+ });
244
184
  // read the .dev-config.json file of the app under development
245
185
  const readDevConfig = () => existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
246
186
  // Reproduce the waiting strategy of the capture service (capture/api/utils/page.ts) so that a
@@ -420,13 +360,16 @@ app.get('/configurations', async (req, res) => {
420
360
  app.get('/configurations/:id', async (req, res) => {
421
361
  const id = encodeURIComponent(req.params.id);
422
362
  try {
423
- // Sent as-is, with its remote origins: the UI stores this straight into .dev-config.json,
424
- // which must stay portable. Origins are rewritten on the read path, in prepareConfig.
425
- const configuration = await remoteFetch('/applications/' + id + '/configuration');
426
- // The attachments come along: a configuration references them by name only, so without the
427
- // files a copied production configuration renders with every image broken. Fetched from the
428
- // application itself rather than from the configuration, which never lists them.
429
- const application = await remoteFetch('/applications/' + id + '?select=attachments');
363
+ // The configuration is sent as-is, with its remote origins: the UI stores it straight into
364
+ // .dev-config.json, which must stay portable. Origins are rewritten on the read path, in
365
+ // prepareConfig. The attachments come along, since a configuration references them by name
366
+ // only and without the files a copied production configuration renders with every image
367
+ // broken; they are read from the application, which is where data-fair lists them.
368
+ // Neither read depends on the other, so they leave together instead of one after the other.
369
+ const [configuration, application] = await Promise.all([
370
+ remoteFetch('/applications/' + id + '/configuration'),
371
+ remoteFetch('/applications/' + id + '?select=attachments')
372
+ ]);
430
373
  const attachments = await copyAttachments(application.attachments ?? [], (name) => remoteFetchBuffer('/applications/' + id + '/attachments/' + encodeURIComponent(name)));
431
374
  if (attachments.failed.length) {
432
375
  console.warn('[dev-server] failed to copy attachments ' + attachments.failed.join(', ') + ', images referencing them will be broken');
@@ -82,6 +82,11 @@ export const attachmentPath = (dir, name) => {
82
82
  return undefined;
83
83
  return filePath;
84
84
  };
85
+ // Attachments used to be downloaded one after the other, each waiting for a full round trip
86
+ // to the remote data-fair before the next one started, so an application carrying a dozen
87
+ // images cost a dozen round trips to copy. They go in parallel now, bounded so that a
88
+ // configuration with many attachments does not open as many connections at once.
89
+ const DOWNLOAD_CONCURRENCY = 5;
85
90
  // Replace the whole local attachments directory with the attachments of a remote application.
86
91
  // Replace, and not merge: copying a configuration replaces the current one entirely, so leaving
87
92
  // behind the images of the previously copied application would only produce a directory whose
@@ -93,32 +98,43 @@ export const attachmentPath = (dir, name) => {
93
98
  // where nothing downloads does empty the directory, which is the point: the configuration it
94
99
  // came with is being applied all the same, and its images are genuinely missing.
95
100
  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;
101
+ const entries = attachments ?? [];
102
+ // indexed rather than appended: downloads no longer finish in the order they started, and
103
+ // both lists below are read by a developer against the configuration they came from
104
+ const downloaded = new Array(entries.length);
105
+ const failures = new Array(entries.length);
106
+ let next = 0;
107
+ const worker = async () => {
108
+ while (next < entries.length) {
109
+ const index = next++;
110
+ const attachment = entries[index];
111
+ // a name with a path separator would write outside the directory, and data-fair has no
112
+ // reason to send one: skip it rather than sanitize a name the configuration still refers to
113
+ if (!attachment?.name || attachmentPath(dir, attachment.name) === undefined) {
114
+ if (attachment?.name)
115
+ failures[index] = attachment.name;
116
+ continue;
117
+ }
118
+ try {
119
+ downloaded[index] = { attachment: { ...attachment, name: attachment.name }, body: await fetchAttachment(attachment.name) };
120
+ }
121
+ catch (err) {
122
+ failures[index] = attachment.name;
123
+ }
105
124
  }
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
- }
125
+ };
126
+ await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, entries.length) }, worker));
127
+ const kept = downloaded.filter(entry => entry !== undefined);
128
+ const result = { copied: [], failed: failures.filter(name => name !== undefined) };
113
129
  // written aside then swapped in, so an interrupted copy leaves the previous directory intact
114
130
  const tmpDir = dir + '.tmp';
115
131
  rmSync(tmpDir, { recursive: true, force: true });
116
132
  mkdirSync(tmpDir, { recursive: true });
117
- for (const { attachment, body } of downloaded) {
133
+ for (const { attachment, body } of kept) {
118
134
  writeFileSync(join(tmpDir, attachment.name), body);
119
135
  result.copied.push(attachment.name);
120
136
  }
121
- writeFileSync(join(tmpDir, METADATA_FILE), JSON.stringify(downloaded.map(d => d.attachment), null, 2));
137
+ writeFileSync(join(tmpDir, METADATA_FILE), JSON.stringify(kept.map(d => d.attachment), null, 2));
122
138
  rmSync(dir, { recursive: true, force: true });
123
139
  renameSync(tmpDir, dir);
124
140
  return result;
@@ -0,0 +1,9 @@
1
+ export declare const INJECTED_DATASET_PROPS: readonly ["id", "href", "page", "title", "slug", "finalizedAt", "schema", "isRest", "userPermissions"];
2
+ export declare const enrichDataset: (dataset: any, fetchJson: (path: string) => Promise<any>) => Promise<any>;
3
+ export interface PrepareConfigDeps {
4
+ fetchJson: (path: string) => Promise<any>;
5
+ localize: (configuration: any, remoteOrigin: string, localOrigin: string) => any;
6
+ remoteOrigin: string;
7
+ localOrigin: string;
8
+ }
9
+ export declare const prepareConfig: (configuration: any, deps: PrepareConfigDeps) => Promise<any>;
package/src/enrich.js ADDED
@@ -0,0 +1,57 @@
1
+ // Rebuild the configuration dataset entries the same way data-fair does in production
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.
7
+ //
8
+ // Pure module, no imports: it takes its remote accessor and origin rewrite as dependencies,
9
+ // wired in app.ts — so it can be unit tested without a build (test/enrich.test.ts).
10
+ // Short-lived cache for the dataset enrichment below, so that every preview reload does not
11
+ // hammer the remote data-fair API. Failures are cached too: a private dataset without an api
12
+ // key would otherwise be re-fetched, and re-warned about, on every single reload.
13
+ const datasetsCache = new Map();
14
+ const DATASETS_CACHE_TTL = 30_000;
15
+ // The set of properties data-fair injects in a configuration dataset entry. Sticking to it
16
+ // matters: forwarding the whole remote dataset would let an application rely, in dev, on a
17
+ // property that production never sends. data-fair refreshes the keys stored in the entry plus
18
+ // the `select` of the app's config schema dataset selector — this list covers that contract,
19
+ // including `isRest`, carried by the select of editing apps and used as the gate deciding
20
+ // whether rest line editing (POST/PATCH/DELETE /lines) is offered at all.
21
+ export const INJECTED_DATASET_PROPS = ['id', 'href', 'page', 'title', 'slug', 'finalizedAt', 'schema', 'isRest', 'userPermissions'];
22
+ export const enrichDataset = async (dataset, fetchJson) => {
23
+ if (!dataset?.id)
24
+ return dataset;
25
+ const cached = datasetsCache.get(dataset.id);
26
+ if (cached && Date.now() - cached.fetchedAt < DATASETS_CACHE_TTL) {
27
+ return cached.data ? { ...dataset, ...cached.data } : dataset;
28
+ }
29
+ try {
30
+ const fresh = await fetchJson('/datasets/' + encodeURIComponent(dataset.id));
31
+ const data = {};
32
+ for (const prop of INJECTED_DATASET_PROPS) {
33
+ if (fresh[prop] !== undefined)
34
+ data[prop] = fresh[prop];
35
+ }
36
+ data.userPermissions = fresh.userPermissions ?? [];
37
+ datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
38
+ return { ...dataset, ...data };
39
+ }
40
+ catch (err) {
41
+ // a private dataset without an api key, or a network failure: keep the raw
42
+ // configuration entry so the app still loads, and warn in the dev-server UI
43
+ console.warn('[dev-server] failed to enrich dataset ' + dataset.id + ', keeping raw configuration entry', err);
44
+ datasetsCache.set(dataset.id, { data: null, fetchedAt: Date.now() });
45
+ return dataset;
46
+ }
47
+ };
48
+ // Enrich the datasets from the remote data-fair, then rewrite every remote origin to ours.
49
+ // The rewrite is applied even when there is no dataset to enrich: a configuration can carry
50
+ // remote urls anywhere (logos, links, tileserver styles), not only in datasets[].href.
51
+ export const prepareConfig = async (configuration, deps) => {
52
+ const datasets = configuration?.datasets?.filter((d) => !!d);
53
+ const enriched = datasets?.length
54
+ ? { ...configuration, datasets: await Promise.all(datasets.map(d => enrichDataset(d, deps.fetchJson))) }
55
+ : configuration;
56
+ return deps.localize(enriched, deps.remoteOrigin, deps.localOrigin);
57
+ };
@@ -0,0 +1,3 @@
1
+ export declare const remoteFetchRaw: (path: string) => Promise<Response>;
2
+ export declare const remoteFetch: (path: string) => Promise<any>;
3
+ export declare const remoteFetchBuffer: (path: string) => Promise<Buffer<ArrayBuffer>>;
package/src/remote.js ADDED
@@ -0,0 +1,22 @@
1
+ // Access to the remote data-fair api. Only JSON and buffer reads: every mutation goes
2
+ // through data-fair itself, the dev-server never writes on behalf of the developer.
3
+ import config from './config.js';
4
+ // Fetch a resource from the remote data-fair api
5
+ export const remoteFetchRaw = async (path) => {
6
+ const res = await fetch(config.dataFair.url + '/api/v1' + path, {
7
+ headers: config.dataFair.apiKey ? { 'x-apiKey': config.dataFair.apiKey } : {}
8
+ });
9
+ if (!res.ok)
10
+ throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
11
+ return res;
12
+ };
13
+ export const remoteFetch = async (path) => {
14
+ const res = await remoteFetchRaw(path);
15
+ return res.json();
16
+ };
17
+ // Same, for an attachment: a file, never json, and never decoded as text — see the app proxy
18
+ // below, where decoding a binary body as utf8 destroys it.
19
+ export const remoteFetchBuffer = async (path) => {
20
+ const res = await remoteFetchRaw(path);
21
+ return Buffer.from(await res.arrayBuffer());
22
+ };