@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.3.4",
3
+ "version": "2.4.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 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 = existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
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
- const configuration = existsSync('.dev-config.json') ? JSON.parse(readFileSync('.dev-config.json', 'utf8')) : {};
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
- let output = Buffer.concat(dataBuffers).toString();
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 &amp;&amp;
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)