@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 +1 -1
- package/src/app.js +20 -77
- package/src/attachments.js +34 -18
- package/src/enrich.d.ts +9 -0
- package/src/enrich.js +57 -0
- package/src/remote.d.ts +3 -0
- package/src/remote.js +22 -0
- package/ui/dist/assets/{index-DIjFauc6.js → index-PYtSD7Kz.js} +1 -1
- package/ui/dist/index.html +1 -1
package/package.json
CHANGED
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
//
|
|
424
|
-
// which must stay portable. Origins are rewritten on the read path, in
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
const application = await
|
|
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');
|
package/src/attachments.js
CHANGED
|
@@ -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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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(
|
|
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;
|
package/src/enrich.d.ts
ADDED
|
@@ -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
|
+
};
|
package/src/remote.d.ts
ADDED
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
|
+
};
|