@data-fair/dev-server 2.5.0 → 2.5.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/README.md CHANGED
@@ -42,6 +42,39 @@ partagent rien d'autre que le fichier `.env`, écrit une seule fois par
42
42
  à la racine) il faut régénérer `.env` avant de lancer le dev-server :
43
43
  `node src/dev-env.js --force --app-path=`.
44
44
 
45
+ ## Configurations et pièces jointes distantes
46
+
47
+ Le bouton « Copier une configuration distante » liste les applications qui, sur
48
+ le data-fair distant, tournent sur l'application de base en cours de
49
+ développement, dans la même version mineure. La recherche part du nom lu dans la
50
+ balise `<meta name="application-name">` de l'`index.html` local et de la version
51
+ du `package.json`.
52
+
53
+ Une application de base réservée à une organisation (`privateAccess`) est
54
+ invisible à une requête anonyme : sans elle la liste est vide, quand bien même
55
+ les applications qui l'utilisent seraient publiques. Le paramètre
56
+ `privateAccess` la rend visible, mais data-fair répond 401 sans
57
+ authentification — il n'est donc envoyé que si `DATAFAIR_API_KEY` est
58
+ renseignée. Une clé d'API se crée depuis le compte ou l'organisation sur le
59
+ data-fair distant, et se met dans le `.env` de l'application :
60
+
61
+ ```
62
+ DATAFAIR_API_KEY=xxx
63
+ ```
64
+
65
+ Ce `.env` est celui que `df-dev-env` génère, et qu'il laisse intact tant qu'on
66
+ ne passe pas `--force` — qui, lui, le réécrit entièrement et emporte la clé.
67
+
68
+ La copie ramène aussi les pièces jointes de l'application distante dans
69
+ `.dev-attachments/` (git-ignoré). Une configuration ne référence une pièce
70
+ jointe que par son nom et reconstruit son URL à l'affichage
71
+ (`application.href + '/attachments/' + name`) : sans les fichiers, une
72
+ configuration de production copiée s'affiche avec toutes ses images cassées.
73
+ Elles sont servies sous `/config/attachments/`, listées dans
74
+ `window.APPLICATION.attachments`, et proposées par le formulaire de
75
+ configuration comme data-fair le fait avec `context.attachments`. Un fichier
76
+ déposé à la main dans `.dev-attachments/` est listé et servi de la même façon.
77
+
45
78
  ## Development
46
79
 
47
80
  Run development server :
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/dev-server",
3
- "version": "2.5.0",
3
+ "version": "2.5.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,7 @@
2
2
  import config from './config.js';
3
3
  import uiConfig from './ui-config.js';
4
4
  import { localizeConfig } from './localize.js';
5
+ import { ATTACHMENTS_DIR, attachmentPath, copyAttachments, listAttachments } from './attachments.js';
5
6
  import { WebSocket, WebSocketServer } from 'ws';
6
7
  import { createServer } from 'node:http';
7
8
  import express from 'express';
@@ -15,7 +16,7 @@ import { isElementNode, createTextNode, createElement, appendChild } from '@pars
15
16
  import escapeStringRegexp from 'escape-string-regexp';
16
17
  import eventPromise from '@data-fair/lib-utils/event-promise.js';
17
18
  import { createSpaMiddleware } from '@data-fair/lib-express/serve-spa.js';
18
- import { resolve } from 'node:path';
19
+ import { join, resolve } from 'node:path';
19
20
  const debug = debugModule('df-dev-server');
20
21
  const app = express();
21
22
  const server = createServer(app);
@@ -110,6 +111,27 @@ app.post('/config/error', (req, res) => {
110
111
  }
111
112
  res.send();
112
113
  });
114
+ // The attachments of the application under development, read by the dev-server UI to feed the
115
+ // `context.attachments` of the configuration form — data-fair feeds it with
116
+ // application.attachments, and an application configuration references an attachment by name.
117
+ app.get('/config/attachments', (req, res) => {
118
+ res.send(listAttachments());
119
+ });
120
+ // window.APPLICATION.href points at /config, so an application building an attachment url the
121
+ // production way — application.href + '/attachments/' + name — lands here. Same path shape as
122
+ // data-fair (applications/:id/attachments/*), same no-cache: a re-copied file shows up at once.
123
+ app.get('/config/attachments/*attachmentPath', (req, res) => {
124
+ const relFilePath = join(...req.params.attachmentPath);
125
+ const filePath = attachmentPath(ATTACHMENTS_DIR, relFilePath);
126
+ if (!filePath || !existsSync(filePath)) {
127
+ res.status(404).send({ error: `attachment "${relFilePath}" not found in ${ATTACHMENTS_DIR}` });
128
+ return;
129
+ }
130
+ res.setHeader('Cache-Control', 'no-cache');
131
+ // served from a root, and not from the absolute path: send() refuses any path holding a
132
+ // hidden segment, and the attachments directory is itself a dotted one
133
+ res.sendFile(relFilePath, { root: resolve(ATTACHMENTS_DIR) });
134
+ });
113
135
  // Identification of the app under development: its name is read from the
114
136
  // "application-name" meta tag of the local index.html and its version from the
115
137
  // package.json of the current directory.
@@ -136,14 +158,33 @@ const localAppInfo = async () => {
136
158
  }
137
159
  return { name, version };
138
160
  };
161
+ // The owner the dev-server works on behalf of, in the "type:id[:department]" form the
162
+ // data-fair api expects for its owner and privateAccess filters.
163
+ const ownerFilter = () => {
164
+ const owner = config.dataFair.owner;
165
+ let filter = `${owner.type}:${owner.id}`;
166
+ if (owner.department)
167
+ filter += ':' + owner.department;
168
+ return filter;
169
+ };
139
170
  // Fetch a resource from the remote data-fair api
140
171
  const remoteFetch = async (path) => {
172
+ const res = await remoteFetchRaw(path);
173
+ return res.json();
174
+ };
175
+ // Same, for an attachment: a file, never json, and never decoded as text — see the app proxy
176
+ // below, where decoding a binary body as utf8 destroys it.
177
+ const remoteFetchBuffer = async (path) => {
178
+ const res = await remoteFetchRaw(path);
179
+ return Buffer.from(await res.arrayBuffer());
180
+ };
181
+ const remoteFetchRaw = async (path) => {
141
182
  const res = await fetch(config.dataFair.url + '/api/v1' + path, {
142
183
  headers: config.dataFair.apiKey ? { 'x-apiKey': config.dataFair.apiKey } : {}
143
184
  });
144
185
  if (!res.ok)
145
186
  throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
146
- return res.json();
187
+ return res;
147
188
  };
148
189
  // Short-lived cache for the dataset enrichment below, so that every preview reload does not
149
190
  // hammer the remote data-fair API. Failures are cached too: a private dataset without an api
@@ -338,9 +379,20 @@ app.get('/configurations', async (req, res) => {
338
379
  return;
339
380
  }
340
381
  response.minorVersion = minorVersion(version);
382
+ // a base application restricted to an organization is invisible to an anonymous request, and
383
+ // without it the listing below finds nothing at all — the applications are then never reached,
384
+ // however public they are. privateAccess is what makes it visible, and data-fair answers 401
385
+ // to it without credentials, so it is only sent when an api key is configured.
386
+ response.authenticated = !!config.dataFair.apiKey;
341
387
  try {
342
- const baseApps = await remoteFetch('/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false');
388
+ let baseAppsQuery = '/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false';
389
+ if (response.authenticated)
390
+ baseAppsQuery += '&privateAccess=' + encodeURIComponent(ownerFilter());
391
+ const baseApps = await remoteFetch(baseAppsQuery);
343
392
  const matchingBaseApps = (baseApps.results ?? []).filter((b) => typeof b.version === 'string' && minorVersion(b.version) === response.minorVersion);
393
+ // told apart from "the base application exists but carries no application": the UI has no
394
+ // other way to explain an empty list, and a missing api key is by far the likeliest cause
395
+ response.baseAppFound = matchingBaseApps.length > 0;
344
396
  response.results = [];
345
397
  for (const baseApp of matchingBaseApps) {
346
398
  const applications = await remoteFetch('/applications?base-application=' + encodeURIComponent(baseApp.url) + '&size=1000&count=false&select=id,title,owner');
@@ -358,10 +410,20 @@ app.get('/configurations', async (req, res) => {
358
410
  // copy a configuration from the remote data-fair, keeping its remote origins so that
359
411
  // .dev-config.json stays portable — see localize.ts
360
412
  app.get('/configurations/:id', async (req, res) => {
413
+ const id = encodeURIComponent(req.params.id);
361
414
  try {
362
415
  // Sent as-is, with its remote origins: the UI stores this straight into .dev-config.json,
363
416
  // which must stay portable. Origins are rewritten on the read path, in prepareConfig.
364
- res.send(await remoteFetch('/applications/' + encodeURIComponent(req.params.id) + '/configuration'));
417
+ const configuration = await remoteFetch('/applications/' + id + '/configuration');
418
+ // The attachments come along: a configuration references them by name only, so without the
419
+ // files a copied production configuration renders with every image broken. Fetched from the
420
+ // application itself rather than from the configuration, which never lists them.
421
+ const application = await remoteFetch('/applications/' + id + '?select=attachments');
422
+ const attachments = await copyAttachments(application.attachments ?? [], (name) => remoteFetchBuffer('/applications/' + id + '/attachments/' + encodeURIComponent(name)));
423
+ if (attachments.failed.length) {
424
+ console.warn('[dev-server] failed to copy attachments ' + attachments.failed.join(', ') + ', images referencing them will be broken');
425
+ }
426
+ res.send({ configuration, attachments });
365
427
  }
366
428
  catch (err) {
367
429
  res.status(500).send({ error: err.message });
@@ -419,6 +481,8 @@ app.use('/app', createProxyMiddleware({
419
481
  configuration,
420
482
  exposedUrl: `http://localhost:${config.port}/app`,
421
483
  href: `http://localhost:${config.port}/config`,
484
+ // data-fair serializes the whole application document, attachments included
485
+ attachments: listAttachments(),
422
486
  apiUrl: `http://localhost:${config.port}/data-fair/api/v1`,
423
487
  wsUrl: `ws://localhost:${config.port}/data-fair`,
424
488
  owner: config.dataFair && config.dataFair.owner
@@ -0,0 +1,19 @@
1
+ export declare const ATTACHMENTS_DIR = ".dev-attachments";
2
+ export type Attachment = {
3
+ name: string;
4
+ title: string;
5
+ size: number;
6
+ mimetype: string;
7
+ updatedAt: string;
8
+ };
9
+ export declare const listAttachments: (dir?: string) => Attachment[];
10
+ export declare const attachmentPath: (dir: string, name: string) => string | undefined;
11
+ export type CopyAttachmentsResult = {
12
+ copied: string[];
13
+ failed: string[];
14
+ };
15
+ export declare const copyAttachments: (attachments: {
16
+ name?: string;
17
+ title?: string;
18
+ mimetype?: string;
19
+ }[], fetchAttachment: (name: string) => Promise<Buffer>, dir?: string) => Promise<CopyAttachmentsResult>;
@@ -0,0 +1,125 @@
1
+ // Attachments of the application under development.
2
+ //
3
+ // data-fair lets an application carry attached files: they are listed in the application
4
+ // object (window.APPLICATION.attachments), offered by the configuration form through the
5
+ // `context.attachments` of vjsf, and served under <application.href>/attachments/<name>.
6
+ // An application configuration therefore stores an attachment by name only — see the
7
+ // attachmentImage pattern used by app-eco-watt and friends — and rebuilds its url at
8
+ // render time. We mirror that whole contract locally, with the files sitting in a
9
+ // git-ignored directory, so an image referenced by a copied production configuration
10
+ // displays in dev exactly as it does in production.
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { extname, join, resolve, sep } from 'node:path';
13
+ export const ATTACHMENTS_DIR = '.dev-attachments';
14
+ // Titles are metadata of the remote application, nothing on disk holds them. They are kept
15
+ // beside the files, in a dotfile: hidden entries are excluded from the listing below, so the
16
+ // sidecar can never be mistaken for an attachment.
17
+ const METADATA_FILE = '.metadata.json';
18
+ // Enough to cover what an application attaches (images, and the occasional document). An
19
+ // unknown extension falls back to the mimetype the remote data-fair reported, then to the
20
+ // generic binary type — never to a guess that a browser would act upon.
21
+ const MIME_TYPES = {
22
+ '.avif': 'image/avif',
23
+ '.csv': 'text/csv',
24
+ '.gif': 'image/gif',
25
+ '.ico': 'image/vnd.microsoft.icon',
26
+ '.jpeg': 'image/jpeg',
27
+ '.jpg': 'image/jpeg',
28
+ '.json': 'application/json',
29
+ '.pdf': 'application/pdf',
30
+ '.png': 'image/png',
31
+ '.svg': 'image/svg+xml',
32
+ '.txt': 'text/plain',
33
+ '.webp': 'image/webp'
34
+ };
35
+ const readMetadata = (dir) => {
36
+ const path = join(dir, METADATA_FILE);
37
+ if (!existsSync(path))
38
+ return {};
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
+ if (!Array.isArray(parsed))
42
+ return {};
43
+ return Object.fromEntries(parsed.filter(a => a?.name).map(a => [a.name, a]));
44
+ }
45
+ catch (err) {
46
+ // a hand-edited or truncated sidecar must not take the whole listing down with it:
47
+ // the files themselves are the source of truth, the metadata only decorates them
48
+ return {};
49
+ }
50
+ };
51
+ // The directory is the source of truth, not the sidecar: an image a developer drops in by
52
+ // hand is listed just like one copied from a remote application, and a file deleted by hand
53
+ // disappears. Size and date come from the file, since the local file is what is served.
54
+ export const listAttachments = (dir = ATTACHMENTS_DIR) => {
55
+ if (!existsSync(dir))
56
+ return [];
57
+ const metadata = readMetadata(dir);
58
+ return readdirSync(dir, { withFileTypes: true })
59
+ .filter(entry => entry.isFile() && !entry.name.startsWith('.'))
60
+ .map(entry => {
61
+ const stats = statSync(join(dir, entry.name));
62
+ const known = metadata[entry.name];
63
+ return {
64
+ name: entry.name,
65
+ title: known?.title ?? entry.name,
66
+ size: stats.size,
67
+ mimetype: MIME_TYPES[extname(entry.name).toLowerCase()] ?? known?.mimetype ?? 'application/octet-stream',
68
+ updatedAt: stats.mtime.toISOString()
69
+ };
70
+ })
71
+ .sort((a, b) => a.name.localeCompare(b.name));
72
+ };
73
+ // The requested name reaches us from an application configuration copied from a remote
74
+ // data-fair, so it is never to be trusted: resolve it and refuse anything that lands outside
75
+ // the attachments directory. undefined means "no such attachment", never "here is /etc/passwd".
76
+ export const attachmentPath = (dir, name) => {
77
+ if (!name)
78
+ return undefined;
79
+ const dirPath = resolve(dir);
80
+ const filePath = resolve(dirPath, name);
81
+ if (!filePath.startsWith(dirPath + sep))
82
+ return undefined;
83
+ return filePath;
84
+ };
85
+ // Replace the whole local attachments directory with the attachments of a remote application.
86
+ // Replace, and not merge: copying a configuration replaces the current one entirely, so leaving
87
+ // behind the images of the previously copied application would only produce a directory whose
88
+ // content matches no configuration at all.
89
+ //
90
+ // Everything is downloaded before anything is written, then the directory is swapped in one
91
+ // rename: an interrupted or partly failing copy — a private file with no api key, a network
92
+ // hiccup — never leaves a half-written directory behind, and what did download is kept. A copy
93
+ // where nothing downloads does empty the directory, which is the point: the configuration it
94
+ // came with is being applied all the same, and its images are genuinely missing.
95
+ 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;
105
+ }
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
+ }
113
+ // written aside then swapped in, so an interrupted copy leaves the previous directory intact
114
+ const tmpDir = dir + '.tmp';
115
+ rmSync(tmpDir, { recursive: true, force: true });
116
+ mkdirSync(tmpDir, { recursive: true });
117
+ for (const { attachment, body } of downloaded) {
118
+ writeFileSync(join(tmpDir, attachment.name), body);
119
+ result.copied.push(attachment.name);
120
+ }
121
+ writeFileSync(join(tmpDir, METADATA_FILE), JSON.stringify(downloaded.map(d => d.attachment), null, 2));
122
+ rmSync(dir, { recursive: true, force: true });
123
+ renameSync(tmpDir, dir);
124
+ return result;
125
+ };