@data-fair/dev-server 2.3.4 → 2.4.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
package/src/app.js
CHANGED
|
@@ -10,7 +10,7 @@ import debugModule from 'debug';
|
|
|
10
10
|
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
|
|
11
11
|
import * as parse5 from 'parse5';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
|
-
import { isElementNode, createTextNode, createElement } from '@parse5/tools';
|
|
13
|
+
import { isElementNode, createTextNode, createElement, appendChild } from '@parse5/tools';
|
|
14
14
|
import escapeStringRegexp from 'escape-string-regexp';
|
|
15
15
|
import eventPromise from '@data-fair/lib-utils/event-promise.js';
|
|
16
16
|
import { createSpaMiddleware } from '@data-fair/lib-express/serve-spa.js';
|
|
@@ -82,10 +82,21 @@ const appUrl = new URL(config.app.url);
|
|
|
82
82
|
const appPrefix = appUrl.pathname.endsWith('/') ? appUrl.pathname.substring(0, appUrl.pathname.length - 1) : appUrl.pathname;
|
|
83
83
|
// very basic CRUD of config
|
|
84
84
|
app.get('/config', (req, res, next) => {
|
|
85
|
-
const devConfig =
|
|
85
|
+
const devConfig = readDevConfig();
|
|
86
86
|
debug('read dev config', devConfig);
|
|
87
87
|
res.send(devConfig);
|
|
88
88
|
});
|
|
89
|
+
// the config with datasets enriched from the remote data-fair, so the dev-server UI
|
|
90
|
+
// can display schemas/concepts and build concept & dataset filter params with the
|
|
91
|
+
// exact same data as the application receives in window.APPLICATION
|
|
92
|
+
app.get('/config/enriched', async (req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
res.send(await refreshConfigDatasets(readDevConfig()));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
res.status(500).send({ error: err.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
89
100
|
app.put('/config', (req, res, next) => {
|
|
90
101
|
debug('save dev config', req.body);
|
|
91
102
|
writeFileSync('.dev-config.json', JSON.stringify(req.body, null, 2));
|
|
@@ -133,6 +144,183 @@ const remoteFetch = async (path) => {
|
|
|
133
144
|
throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
|
|
134
145
|
return res.json();
|
|
135
146
|
};
|
|
147
|
+
// Short-lived cache for the dataset enrichment below, so that every preview reload does not
|
|
148
|
+
// hammer the remote data-fair API. Failures are cached too: a private dataset without an api
|
|
149
|
+
// key would otherwise be re-fetched, and re-warned about, on every single reload.
|
|
150
|
+
const datasetsCache = new Map();
|
|
151
|
+
const DATASETS_CACHE_TTL = 30_000;
|
|
152
|
+
// The exact set of properties data-fair injects in a configuration dataset entry. Sticking to
|
|
153
|
+
// it matters: forwarding the whole remote dataset would let an application rely, in dev, on a
|
|
154
|
+
// property that production never sends.
|
|
155
|
+
const INJECTED_DATASET_PROPS = ['id', 'href', 'page', 'title', 'slug', 'finalizedAt', 'schema', 'userPermissions'];
|
|
156
|
+
// Rebuild a configuration dataset entry the same way data-fair does in production
|
|
157
|
+
// (refreshConfigDatasetsRefs in api/src/applications/utils.ts): the app stores only
|
|
158
|
+
// minimal references, data-fair injects the full schema (with concepts), finalizedAt,
|
|
159
|
+
// slug and userPermissions at request time. We reproduce that so applications
|
|
160
|
+
// following the skill contract read window.APPLICATION.configuration.datasets
|
|
161
|
+
// identically in dev and in prod.
|
|
162
|
+
const enrichDataset = async (dataset) => {
|
|
163
|
+
if (!dataset?.id)
|
|
164
|
+
return dataset;
|
|
165
|
+
const cached = datasetsCache.get(dataset.id);
|
|
166
|
+
if (cached && Date.now() - cached.fetchedAt < DATASETS_CACHE_TTL) {
|
|
167
|
+
return cached.data ? { ...dataset, ...cached.data } : dataset;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const fresh = await remoteFetch('/datasets/' + encodeURIComponent(dataset.id));
|
|
171
|
+
const localBase = `http://localhost:${config.port}/data-fair`;
|
|
172
|
+
const data = {};
|
|
173
|
+
for (const prop of INJECTED_DATASET_PROPS) {
|
|
174
|
+
if (fresh[prop] !== undefined)
|
|
175
|
+
data[prop] = fresh[prop];
|
|
176
|
+
}
|
|
177
|
+
if (typeof data.href === 'string')
|
|
178
|
+
data.href = data.href.replace(config.dataFair.url, localBase);
|
|
179
|
+
data.userPermissions = fresh.userPermissions ?? [];
|
|
180
|
+
datasetsCache.set(dataset.id, { data, fetchedAt: Date.now() });
|
|
181
|
+
return { ...dataset, ...data };
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// a private dataset without an api key, or a network failure: keep the raw
|
|
185
|
+
// configuration entry so the app still loads, and warn in the dev-server UI
|
|
186
|
+
console.warn('[dev-server] failed to enrich dataset ' + dataset.id + ', keeping raw configuration entry', err);
|
|
187
|
+
datasetsCache.set(dataset.id, { data: null, fetchedAt: Date.now() });
|
|
188
|
+
return dataset;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const refreshConfigDatasets = async (configuration) => {
|
|
192
|
+
const datasets = configuration?.datasets?.filter((d) => !!d);
|
|
193
|
+
if (!datasets?.length)
|
|
194
|
+
return configuration;
|
|
195
|
+
const enriched = await Promise.all(datasets.map(enrichDataset));
|
|
196
|
+
return { ...configuration, datasets: enriched };
|
|
197
|
+
};
|
|
198
|
+
// read the .dev-config.json file of the app under development
|
|
199
|
+
const readDevConfig = () => existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
|
|
200
|
+
// Reproduce the waiting strategy of the capture service (capture/api/utils/page.ts) so that a
|
|
201
|
+
// timing regression shows up in development instead of in production. Defaults of the service:
|
|
202
|
+
// screenshotTimeout = 20s, gif cadenced at 15 fps, maxAnimationFrames = 1800 and a total budget
|
|
203
|
+
// of 2 x screenshotTimeout for the whole animation (capture + decoding + encoding included).
|
|
204
|
+
const SCREENSHOT_TIMEOUT = 20_000;
|
|
205
|
+
const MAX_ANIMATION_FRAMES = 1800;
|
|
206
|
+
const captureSimulationScript = (opts) => {
|
|
207
|
+
// df:capture-delay is expressed in seconds and capped at screenshotTimeout: a value in
|
|
208
|
+
// milliseconds silently means "wait the full timeout", which is worth showing as such.
|
|
209
|
+
const declaredDelay = opts.captureDelay === undefined ? null : Number(opts.captureDelay);
|
|
210
|
+
const delayMs = declaredDelay === null || isNaN(declaredDelay) ? null : Math.min(declaredDelay * 1000, SCREENSHOT_TIMEOUT);
|
|
211
|
+
return `
|
|
212
|
+
;(function () {
|
|
213
|
+
var TYPE = ${JSON.stringify(opts.type)}
|
|
214
|
+
var DELAY_MS = ${delayMs === null ? 'null' : delayMs}
|
|
215
|
+
var DECLARED_DELAY = ${JSON.stringify(opts.captureDelay ?? null)}
|
|
216
|
+
var LEGACY_TRIGGER = ${opts.legacyTrigger}
|
|
217
|
+
var TIMEOUT = ${SCREENSHOT_TIMEOUT}
|
|
218
|
+
var MAX_FRAMES = ${MAX_ANIMATION_FRAMES}
|
|
219
|
+
var ANIMATION_BUDGET = TIMEOUT * 2
|
|
220
|
+
var triggerCalled = false
|
|
221
|
+
|
|
222
|
+
// ?capture is our own switch, production has no equivalent: drop it from the URL before any
|
|
223
|
+
// script of the app runs, so that reactiveSearchParams never sees a param it would not see in
|
|
224
|
+
// production. thumbnail and capture-test-error stay, data-fair really sends those.
|
|
225
|
+
try {
|
|
226
|
+
var currentUrl = new URL(window.location.href)
|
|
227
|
+
if (currentUrl.searchParams.has('capture')) {
|
|
228
|
+
currentUrl.searchParams.delete('capture')
|
|
229
|
+
window.history.replaceState(window.history.state, '', currentUrl.pathname + currentUrl.search + currentUrl.hash)
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.warn('[capture] failed to clean the capture param from the url', err)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log('[capture] contexte de capture simulé : type=' + TYPE + ', ' +
|
|
236
|
+
${opts.thumbnail ? "'vignette par défaut (?thumbnail=true)'" : "'capture manuelle (pas de ?thumbnail)'"} +
|
|
237
|
+
(DELAY_MS === null ? ', aucune attente déclarée' : ', df:capture-delay=' + DECLARED_DELAY))
|
|
238
|
+
if (DELAY_MS !== null && isNaN(Number(DECLARED_DELAY))) {
|
|
239
|
+
console.error('[capture] df:capture-delay="' + DECLARED_DELAY + '" n\\'est pas un nombre')
|
|
240
|
+
} else if (DELAY_MS === TIMEOUT && Number(DECLARED_DELAY) > 60) {
|
|
241
|
+
console.error('[capture] df:capture-delay="' + DECLARED_DELAY + '" s\\'exprime en SECONDES et est plafonnée à ' +
|
|
242
|
+
(TIMEOUT / 1000) + 's : cette valeur vaut "attendre le timeout complet". Valeurs saines : 1 à 5.')
|
|
243
|
+
}
|
|
244
|
+
${opts.testError
|
|
245
|
+
? 'setTimeout(function () { console.error(\'[capture] capture-test-error=true : le service ferait échouer la capture après 1s (chemin de test de santé)\') }, 1000)'
|
|
246
|
+
: ''}
|
|
247
|
+
|
|
248
|
+
function startAnimation () {
|
|
249
|
+
if (typeof window.animateCaptureFrame !== 'function') {
|
|
250
|
+
console.error('[capture] animateCaptureFrame n\\'est pas définie quand le trigger se résout : en production le page.evaluate du service lève et TOUTE la capture échoue. La définir AVANT d\\'appeler triggerCapture(true).')
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
var frames = 0
|
|
254
|
+
var start = Date.now()
|
|
255
|
+
var interval = setInterval(function () {
|
|
256
|
+
var elapsed = Date.now() - start
|
|
257
|
+
if (elapsed > ANIMATION_BUDGET) {
|
|
258
|
+
console.error('[capture] budget d\\'horloge dépassé (' + Math.round(elapsed / 1000) + 's > ' + (ANIMATION_BUDGET / 1000) +
|
|
259
|
+
's = 2 x screenshotTimeout) après ' + frames + ' images : la capture réelle échouerait. Borner le nombre d\\'images indépendamment de la durée configurée.')
|
|
260
|
+
clearInterval(interval)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (frames >= MAX_FRAMES) {
|
|
264
|
+
console.error('[capture] arrêt après le nombre maximum d\\'images (' + MAX_FRAMES + ')')
|
|
265
|
+
clearInterval(interval)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
frames++
|
|
269
|
+
var stopped
|
|
270
|
+
try {
|
|
271
|
+
stopped = window.animateCaptureFrame()
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('[capture] animateCaptureFrame a levé, la capture réelle échouerait', err)
|
|
274
|
+
clearInterval(interval)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (stopped) {
|
|
278
|
+
console.log('[capture] animation terminée après ' + frames + ' images (' + (frames / 15).toFixed(1) +
|
|
279
|
+
's de gif, ' + Math.round(elapsed / 1000) + 's de rendu ici — le service ajoute encore le décodage et l\\'encodage gifsicle dans le même budget)')
|
|
280
|
+
clearInterval(interval)
|
|
281
|
+
}
|
|
282
|
+
}, 1000 / 15)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// page.exposeFunction returns a promise: an app that forgets the await gets a truthy value
|
|
286
|
+
// whatever the real answer, cf. app-minimal. Resolving a promise here surfaces that bug.
|
|
287
|
+
window.triggerCapture = function (animationSupported) {
|
|
288
|
+
if (triggerCalled) console.warn('[capture] triggerCapture appelé plusieurs fois, le service ne retient que le premier appel')
|
|
289
|
+
triggerCalled = true
|
|
290
|
+
var animate = !!animationSupported && TYPE === 'gif'
|
|
291
|
+
console.log('[capture] triggerCapture(' + animationSupported + ') -> ' + animate +
|
|
292
|
+
(animationSupported && TYPE !== 'gif' ? ' (l\\'app supporte l\\'animation mais le service ne demande un gif que sur ?type=gif)' : ''))
|
|
293
|
+
if (animate) startAnimation()
|
|
294
|
+
else console.log('[capture] capture png immédiate de l\\'état courant')
|
|
295
|
+
return Promise.resolve(animate)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// approximation of the networkidle0 of the service: no request for 500ms after load
|
|
299
|
+
window.addEventListener('load', function () {
|
|
300
|
+
setTimeout(function () {
|
|
301
|
+
if (triggerCalled) return
|
|
302
|
+
if (DELAY_MS !== null) waitWithoutTrigger(DELAY_MS, 'df:capture-delay=' + DECLARED_DELAY + 's')
|
|
303
|
+
else if (LEGACY_TRIGGER) waitWithoutTrigger(TIMEOUT, 'x-capture="trigger" (déprécié)')
|
|
304
|
+
else {
|
|
305
|
+
setTimeout(function () {
|
|
306
|
+
if (triggerCalled) return
|
|
307
|
+
console.warn('[capture] network idle et aucune attente déclarée : le service capture 1s plus tard, sans jamais avoir eu triggerCapture. Déclarer df:capture-delay et appeler triggerCapture.')
|
|
308
|
+
}, 1000)
|
|
309
|
+
}
|
|
310
|
+
}, 500)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
function waitWithoutTrigger (ms, why) {
|
|
314
|
+
console.log('[capture] network idle sans triggerCapture, le service attend ' + Math.round(ms / 1000) + 's (' + why + ')')
|
|
315
|
+
setTimeout(function () {
|
|
316
|
+
if (triggerCalled) return
|
|
317
|
+
console.error('[capture] triggerCapture n\\'a pas été appelé après ' + Math.round(ms / 1000) +
|
|
318
|
+
's : chaque capture en production paiera cette attente. L\\'appeler sur TOUS les chemins terminaux — données prêtes, résultat vide, erreur de données, configuration invalide.')
|
|
319
|
+
}, ms)
|
|
320
|
+
}
|
|
321
|
+
})()
|
|
322
|
+
`;
|
|
323
|
+
};
|
|
136
324
|
// extract the major.minor part of a version, versions on remote base apps look like "1.3"
|
|
137
325
|
const minorVersion = (version) => version.split('.').slice(0, 2).join('.');
|
|
138
326
|
// list configurations of the app under development that exist on the remote data-fair
|
|
@@ -200,16 +388,33 @@ app.use('/app', createProxyMiddleware({
|
|
|
200
388
|
proxyReq.setHeader('Accept-Encoding', 'identity'); // disable compression
|
|
201
389
|
},
|
|
202
390
|
proxyRes(proxyRes, req, res) {
|
|
203
|
-
|
|
391
|
+
let configuration = readDevConfig();
|
|
204
392
|
// console.log('inject config', configuration)
|
|
205
393
|
const dataBuffers = [];
|
|
206
394
|
proxyRes.on('data', (data) => { dataBuffers.push(data); });
|
|
207
395
|
proxyRes.on('end', async () => {
|
|
208
396
|
try {
|
|
209
|
-
|
|
397
|
+
const rawBody = Buffer.concat(dataBuffers);
|
|
398
|
+
// Only the HTML document is rewritten. Everything else — fonts, images, wasm, but also
|
|
399
|
+
// any js/css we have no reason to touch — is forwarded byte for byte: decoding it as
|
|
400
|
+
// utf8 replaces every invalid sequence with U+FFFD and destroys binary assets.
|
|
401
|
+
const contentType = proxyRes.headers['content-type'];
|
|
402
|
+
if (!contentType?.includes('text/html')) {
|
|
403
|
+
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
|
|
404
|
+
res.end(rawBody);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
let output = rawBody.toString();
|
|
210
408
|
if (output.includes('%APPLICATION%')) {
|
|
409
|
+
try {
|
|
410
|
+
configuration = await refreshConfigDatasets(configuration);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
console.warn('[dev-server] failed to enrich configuration datasets', err);
|
|
414
|
+
}
|
|
211
415
|
const filledBody = output.replace(/%APPLICATION%/g, JSON.stringify({
|
|
212
416
|
id: 'dev-application',
|
|
417
|
+
slug: 'dev-application',
|
|
213
418
|
title: 'Dev application',
|
|
214
419
|
configuration,
|
|
215
420
|
exposedUrl: `http://localhost:${config.port}/app`,
|
|
@@ -226,45 +431,6 @@ app.use('/app', createProxyMiddleware({
|
|
|
226
431
|
const bodyNode = html.childNodes.filter(isElementNode).find(c => c.tagName === 'body');
|
|
227
432
|
if (!headNode || !bodyNode)
|
|
228
433
|
throw new Error('HTML structure is broken, expect html, head and body elements');
|
|
229
|
-
// add a script to simulate instrumentation by capture service
|
|
230
|
-
// @ts-ignore
|
|
231
|
-
const query = req.query;
|
|
232
|
-
if (query.thumbnail === 'true') {
|
|
233
|
-
const script = createElement('script', { type: 'text/javascript' });
|
|
234
|
-
script.childNodes.push(createTextNode(`
|
|
235
|
-
console.log('[capture] Simulate a screenshot capture context')
|
|
236
|
-
var triggerCalled = false
|
|
237
|
-
window.triggerCapture = function (animationSupported) {
|
|
238
|
-
triggerCalled = true
|
|
239
|
-
console.log('[capture] triggerCapture called')
|
|
240
|
-
if (animationSupported) {
|
|
241
|
-
console.log('[capture] this application supports animated screenshots')
|
|
242
|
-
var i = 0
|
|
243
|
-
const interval = setInterval(function () {
|
|
244
|
-
i++
|
|
245
|
-
if (i === 1800) {
|
|
246
|
-
console.error('[capture] stop after the maximum number of frames was attained')
|
|
247
|
-
clearInterval(interval)
|
|
248
|
-
}
|
|
249
|
-
var stopped = window.animateCaptureFrame()
|
|
250
|
-
if (stopped) {
|
|
251
|
-
console.log('[capture] animation was stopped after ' + i + ' frames')
|
|
252
|
-
clearInterval(interval)
|
|
253
|
-
}
|
|
254
|
-
}, 67)
|
|
255
|
-
return true
|
|
256
|
-
} else {
|
|
257
|
-
console.log('[capture] this application does not support animated screenshots')
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
setTimeout(function() {
|
|
261
|
-
if (!triggerCalled) {
|
|
262
|
-
console.error('[capture] triggerCapture was not called after a 5s wait')
|
|
263
|
-
}
|
|
264
|
-
}, 5000)
|
|
265
|
-
`));
|
|
266
|
-
headNode.childNodes.push(script);
|
|
267
|
-
}
|
|
268
434
|
const meta = {};
|
|
269
435
|
for (const node of headNode.childNodes.filter(isElementNode)) {
|
|
270
436
|
if (node.tagName === 'meta') {
|
|
@@ -274,6 +440,31 @@ app.use('/app', createProxyMiddleware({
|
|
|
274
440
|
meta[name] = content;
|
|
275
441
|
}
|
|
276
442
|
}
|
|
443
|
+
// simulate the instrumentation of the capture service.
|
|
444
|
+
// ?thumbnail=true is the default thumbnail context (data-fair only adds it when the
|
|
445
|
+
// request carries no other param, cf. isDefaultThumbnail) ; ?capture=png|gif is a
|
|
446
|
+
// dev-server only switch so that a manual capture — which has triggerCapture but no
|
|
447
|
+
// thumbnail param in production — can be simulated too.
|
|
448
|
+
// @ts-ignore
|
|
449
|
+
const query = req.query;
|
|
450
|
+
const captureType = query.capture === 'gif' ? 'gif' : 'png';
|
|
451
|
+
if (query.thumbnail === 'true' || query.capture) {
|
|
452
|
+
const script = createElement('script', { type: 'text/javascript' });
|
|
453
|
+
// appendChild, not childNodes.push: the parse5 serializer decides to escape a text
|
|
454
|
+
// node from its parent, and would turn every && of the script into &&
|
|
455
|
+
appendChild(script, createTextNode(captureSimulationScript({
|
|
456
|
+
type: captureType,
|
|
457
|
+
thumbnail: query.thumbnail === 'true',
|
|
458
|
+
captureDelay: meta['df:capture-delay'],
|
|
459
|
+
legacyTrigger: meta['x-capture'] === 'trigger',
|
|
460
|
+
testError: query['capture-test-error'] === 'true'
|
|
461
|
+
})));
|
|
462
|
+
// the real service installs triggerCapture through page.exposeFunction, before
|
|
463
|
+
// page.goto : it exists before any script of the document, including the inline
|
|
464
|
+
// %APPLICATION% one. Inject first so that testing !!window.triggerCapture is as
|
|
465
|
+
// reliable here as in production.
|
|
466
|
+
headNode.childNodes.unshift(script);
|
|
467
|
+
}
|
|
277
468
|
// companion script that lets the embedded app report its height / sync params
|
|
278
469
|
// to the parent <d-frame> ; injected for every mode so the UI mode toggle works
|
|
279
470
|
// without requiring a df:overflow meta (same variant & version as data-fair prod)
|