@data-fair/dev-server 2.5.0 → 2.5.2
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 +44 -0
- package/package.json +1 -1
- package/src/app.js +86 -14
- package/src/attachments.d.ts +19 -0
- package/src/attachments.js +125 -0
- package/src/base-app-urls.d.ts +1 -0
- package/src/base-app-urls.js +37 -0
- package/ui/dist/assets/{index-BtPwUKGa.js → index-DIjFauc6.js} +1 -1
- package/ui/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,50 @@ 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. Le paramètre `privateAccess` la rend visible,
|
|
55
|
+
mais data-fair répond 401 sans authentification — il n'est donc envoyé que si
|
|
56
|
+
`DATAFAIR_API_KEY` est renseignée.
|
|
57
|
+
|
|
58
|
+
Ne pas la trouver n'arrête pas la recherche pour autant : les applications qui
|
|
59
|
+
tournent dessus sont souvent publiques, et il suffit de connaître son URL pour
|
|
60
|
+
les lister. Les URL des applications de base publiées suivent deux conventions
|
|
61
|
+
(`<data-fair>/apps/<slug>/<mineure>/` et
|
|
62
|
+
`https://cdn.jsdelivr.net/npm/@scope/<paquet>@<mineure>/dist/`), et le filtre
|
|
63
|
+
de `/applications` est une égalité stricte : une URL devinée ne peut que ne
|
|
64
|
+
rien renvoyer, jamais renvoyer autre chose. Les candidates sont donc essayées
|
|
65
|
+
en une requête, ce qui suffit à lister les configurations publiques d'une
|
|
66
|
+
application de base privée, sans aucune clé.
|
|
67
|
+
|
|
68
|
+
Une clé reste nécessaire pour une application, elle, privée. Elle se crée
|
|
69
|
+
depuis le compte ou l'organisation sur le data-fair distant, et se met dans le
|
|
70
|
+
`.env` de l'application :
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
DATAFAIR_API_KEY=xxx
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Ce `.env` est celui que `df-dev-env` génère, et qu'il laisse intact tant qu'on
|
|
77
|
+
ne passe pas `--force` — qui, lui, le réécrit entièrement et emporte la clé.
|
|
78
|
+
|
|
79
|
+
La copie ramène aussi les pièces jointes de l'application distante dans
|
|
80
|
+
`.dev-attachments/` (git-ignoré). Une configuration ne référence une pièce
|
|
81
|
+
jointe que par son nom et reconstruit son URL à l'affichage
|
|
82
|
+
(`application.href + '/attachments/' + name`) : sans les fichiers, une
|
|
83
|
+
configuration de production copiée s'affiche avec toutes ses images cassées.
|
|
84
|
+
Elles sont servies sous `/config/attachments/`, listées dans
|
|
85
|
+
`window.APPLICATION.attachments`, et proposées par le formulaire de
|
|
86
|
+
configuration comme data-fair le fait avec `context.attachments`. Un fichier
|
|
87
|
+
déposé à la main dans `.dev-attachments/` est listé et servi de la même façon.
|
|
88
|
+
|
|
45
89
|
## Development
|
|
46
90
|
|
|
47
91
|
Run development server :
|
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 { ATTACHMENTS_DIR, attachmentPath, copyAttachments, listAttachments } from './attachments.js';
|
|
6
|
+
import { candidateBaseAppUrls } from './base-app-urls.js';
|
|
5
7
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
6
8
|
import { createServer } from 'node:http';
|
|
7
9
|
import express from 'express';
|
|
@@ -15,7 +17,7 @@ import { isElementNode, createTextNode, createElement, appendChild } from '@pars
|
|
|
15
17
|
import escapeStringRegexp from 'escape-string-regexp';
|
|
16
18
|
import eventPromise from '@data-fair/lib-utils/event-promise.js';
|
|
17
19
|
import { createSpaMiddleware } from '@data-fair/lib-express/serve-spa.js';
|
|
18
|
-
import { resolve } from 'node:path';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
19
21
|
const debug = debugModule('df-dev-server');
|
|
20
22
|
const app = express();
|
|
21
23
|
const server = createServer(app);
|
|
@@ -110,6 +112,27 @@ app.post('/config/error', (req, res) => {
|
|
|
110
112
|
}
|
|
111
113
|
res.send();
|
|
112
114
|
});
|
|
115
|
+
// The attachments of the application under development, read by the dev-server UI to feed the
|
|
116
|
+
// `context.attachments` of the configuration form — data-fair feeds it with
|
|
117
|
+
// application.attachments, and an application configuration references an attachment by name.
|
|
118
|
+
app.get('/config/attachments', (req, res) => {
|
|
119
|
+
res.send(listAttachments());
|
|
120
|
+
});
|
|
121
|
+
// window.APPLICATION.href points at /config, so an application building an attachment url the
|
|
122
|
+
// production way — application.href + '/attachments/' + name — lands here. Same path shape as
|
|
123
|
+
// data-fair (applications/:id/attachments/*), same no-cache: a re-copied file shows up at once.
|
|
124
|
+
app.get('/config/attachments/*attachmentPath', (req, res) => {
|
|
125
|
+
const relFilePath = join(...req.params.attachmentPath);
|
|
126
|
+
const filePath = attachmentPath(ATTACHMENTS_DIR, relFilePath);
|
|
127
|
+
if (!filePath || !existsSync(filePath)) {
|
|
128
|
+
res.status(404).send({ error: `attachment "${relFilePath}" not found in ${ATTACHMENTS_DIR}` });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
132
|
+
// served from a root, and not from the absolute path: send() refuses any path holding a
|
|
133
|
+
// hidden segment, and the attachments directory is itself a dotted one
|
|
134
|
+
res.sendFile(relFilePath, { root: resolve(ATTACHMENTS_DIR) });
|
|
135
|
+
});
|
|
113
136
|
// Identification of the app under development: its name is read from the
|
|
114
137
|
// "application-name" meta tag of the local index.html and its version from the
|
|
115
138
|
// package.json of the current directory.
|
|
@@ -128,22 +151,44 @@ const localAppInfo = async () => {
|
|
|
128
151
|
debug('failed to fetch local app index.html', err);
|
|
129
152
|
}
|
|
130
153
|
let version;
|
|
154
|
+
let packageName;
|
|
131
155
|
try {
|
|
132
|
-
|
|
156
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
157
|
+
version = pkg.version;
|
|
158
|
+
packageName = pkg.name;
|
|
133
159
|
}
|
|
134
160
|
catch (err) {
|
|
135
161
|
debug('failed to read local package.json', err);
|
|
136
162
|
}
|
|
137
|
-
return { name, version };
|
|
163
|
+
return { name, version, packageName };
|
|
164
|
+
};
|
|
165
|
+
// The owner the dev-server works on behalf of, in the "type:id[:department]" form the
|
|
166
|
+
// data-fair api expects for its owner and privateAccess filters.
|
|
167
|
+
const ownerFilter = () => {
|
|
168
|
+
const owner = config.dataFair.owner;
|
|
169
|
+
let filter = `${owner.type}:${owner.id}`;
|
|
170
|
+
if (owner.department)
|
|
171
|
+
filter += ':' + owner.department;
|
|
172
|
+
return filter;
|
|
138
173
|
};
|
|
139
174
|
// Fetch a resource from the remote data-fair api
|
|
140
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) => {
|
|
141
186
|
const res = await fetch(config.dataFair.url + '/api/v1' + path, {
|
|
142
187
|
headers: config.dataFair.apiKey ? { 'x-apiKey': config.dataFair.apiKey } : {}
|
|
143
188
|
});
|
|
144
189
|
if (!res.ok)
|
|
145
190
|
throw new Error(`error ${res.status} on remote data-fair: ${await res.text()}`);
|
|
146
|
-
return res
|
|
191
|
+
return res;
|
|
147
192
|
};
|
|
148
193
|
// Short-lived cache for the dataset enrichment below, so that every preview reload does not
|
|
149
194
|
// hammer the remote data-fair API. Failures are cached too: a private dataset without an api
|
|
@@ -327,7 +372,7 @@ const minorVersion = (version) => version.split('.').slice(0, 2).join('.');
|
|
|
327
372
|
// list configurations of the app under development that exist on the remote data-fair
|
|
328
373
|
// with the same minor version, useful to test quickly for regressions
|
|
329
374
|
app.get('/configurations', async (req, res) => {
|
|
330
|
-
const { name, version } = await localAppInfo();
|
|
375
|
+
const { name, version, packageName } = await localAppInfo();
|
|
331
376
|
const response = { appName: name, localVersion: version, remoteUrl: config.dataFair.url };
|
|
332
377
|
if (!name) {
|
|
333
378
|
res.status(400).send({ ...response, error: 'The "application-name" meta tag was not found in the local app index.html' });
|
|
@@ -338,16 +383,31 @@ app.get('/configurations', async (req, res) => {
|
|
|
338
383
|
return;
|
|
339
384
|
}
|
|
340
385
|
response.minorVersion = minorVersion(version);
|
|
386
|
+
// a base application restricted to an organization is invisible to an anonymous request.
|
|
387
|
+
// privateAccess is what makes it visible, and data-fair answers 401 to it without
|
|
388
|
+
// credentials, so it is only sent when an api key is configured.
|
|
389
|
+
response.authenticated = !!config.dataFair.apiKey;
|
|
341
390
|
try {
|
|
342
|
-
|
|
391
|
+
let baseAppsQuery = '/base-applications?applicationName=' + encodeURIComponent(name) + '&size=1000&count=false';
|
|
392
|
+
if (response.authenticated)
|
|
393
|
+
baseAppsQuery += '&privateAccess=' + encodeURIComponent(ownerFilter());
|
|
394
|
+
const baseApps = await remoteFetch(baseAppsQuery);
|
|
343
395
|
const matchingBaseApps = (baseApps.results ?? []).filter((b) => typeof b.version === 'string' && minorVersion(b.version) === response.minorVersion);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
396
|
+
// Not finding the base application is not the end of the search: it may simply be private
|
|
397
|
+
// while the applications running on it are public, and those are reachable as soon as its
|
|
398
|
+
// url is known. Guessing the url costs nothing — the filter below is an exact match.
|
|
399
|
+
const versionByUrl = new Map(matchingBaseApps.map((b) => [b.url, b.version]));
|
|
400
|
+
const baseAppUrls = matchingBaseApps.length
|
|
401
|
+
? [...versionByUrl.keys()]
|
|
402
|
+
: candidateBaseAppUrls(config.dataFair.url, name, packageName, response.minorVersion);
|
|
403
|
+
// one request for the whole set: the data-fair list filters accept comma separated values
|
|
404
|
+
const applications = await remoteFetch('/applications?base-application=' + encodeURIComponent(baseAppUrls.join(',')) + '&size=1000&count=false&select=id,title,owner,url');
|
|
405
|
+
response.results = (applications.results ?? []).map((application) => ({
|
|
406
|
+
id: application.id,
|
|
407
|
+
title: application.title,
|
|
408
|
+
owner: application.owner,
|
|
409
|
+
baseAppVersion: versionByUrl.get(application.url) ?? response.minorVersion
|
|
410
|
+
}));
|
|
351
411
|
response.results.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
352
412
|
res.send(response);
|
|
353
413
|
}
|
|
@@ -358,10 +418,20 @@ app.get('/configurations', async (req, res) => {
|
|
|
358
418
|
// copy a configuration from the remote data-fair, keeping its remote origins so that
|
|
359
419
|
// .dev-config.json stays portable — see localize.ts
|
|
360
420
|
app.get('/configurations/:id', async (req, res) => {
|
|
421
|
+
const id = encodeURIComponent(req.params.id);
|
|
361
422
|
try {
|
|
362
423
|
// Sent as-is, with its remote origins: the UI stores this straight into .dev-config.json,
|
|
363
424
|
// which must stay portable. Origins are rewritten on the read path, in prepareConfig.
|
|
364
|
-
|
|
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');
|
|
430
|
+
const attachments = await copyAttachments(application.attachments ?? [], (name) => remoteFetchBuffer('/applications/' + id + '/attachments/' + encodeURIComponent(name)));
|
|
431
|
+
if (attachments.failed.length) {
|
|
432
|
+
console.warn('[dev-server] failed to copy attachments ' + attachments.failed.join(', ') + ', images referencing them will be broken');
|
|
433
|
+
}
|
|
434
|
+
res.send({ configuration, attachments });
|
|
365
435
|
}
|
|
366
436
|
catch (err) {
|
|
367
437
|
res.status(500).send({ error: err.message });
|
|
@@ -419,6 +489,8 @@ app.use('/app', createProxyMiddleware({
|
|
|
419
489
|
configuration,
|
|
420
490
|
exposedUrl: `http://localhost:${config.port}/app`,
|
|
421
491
|
href: `http://localhost:${config.port}/config`,
|
|
492
|
+
// data-fair serializes the whole application document, attachments included
|
|
493
|
+
attachments: listAttachments(),
|
|
422
494
|
apiUrl: `http://localhost:${config.port}/data-fair/api/v1`,
|
|
423
495
|
wsUrl: `ws://localhost:${config.port}/data-fair`,
|
|
424
496
|
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
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const candidateBaseAppUrls: (dataFairUrl: string, appName: string, packageName: string | undefined, minorVersion: string) => string[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Candidate urls of the base application under development.
|
|
2
|
+
//
|
|
3
|
+
// Discovering it by name is the authoritative path, but it only works for a base application
|
|
4
|
+
// the request is allowed to see: one restricted to an organization is invisible to an anonymous
|
|
5
|
+
// request, while the applications running on it are often public — which is precisely the case
|
|
6
|
+
// of an application developed for a single customer. The /applications listing filters on an
|
|
7
|
+
// exact url, so a wrong candidate returns nothing and a right one returns the applications: a
|
|
8
|
+
// guess can never produce a false match, and they all fit in a single request.
|
|
9
|
+
//
|
|
10
|
+
// The two conventions published base applications follow, as of today:
|
|
11
|
+
// https://koumoul.com/apps/<slug>/<minor>/
|
|
12
|
+
// https://cdn.jsdelivr.net/npm/@scope/<package>@<minor>/dist/
|
|
13
|
+
// The slug is the repository name, which is not always the application name — "Liste et fiches"
|
|
14
|
+
// is served under /apps/list-details/ — so both are tried, along with the package name stripped
|
|
15
|
+
// of the prefixes repositories use.
|
|
16
|
+
const slugs = (appName, packageName) => {
|
|
17
|
+
const candidates = [appName];
|
|
18
|
+
if (packageName) {
|
|
19
|
+
const unscoped = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName;
|
|
20
|
+
candidates.push(unscoped, unscoped.replace(/^(app|data-fair)-/, ''));
|
|
21
|
+
}
|
|
22
|
+
return [...new Set(candidates.filter(Boolean))];
|
|
23
|
+
};
|
|
24
|
+
export const candidateBaseAppUrls = (dataFairUrl, appName, packageName, minorVersion) => {
|
|
25
|
+
const urls = [];
|
|
26
|
+
// the same origin as the data-fair the dev-server talks to: a base application is served
|
|
27
|
+
// beside it, never on some unrelated host
|
|
28
|
+
const origin = new URL(dataFairUrl).origin;
|
|
29
|
+
for (const slug of slugs(appName, packageName)) {
|
|
30
|
+
urls.push(`${origin}/apps/${slug}/${minorVersion}/`);
|
|
31
|
+
}
|
|
32
|
+
if (packageName) {
|
|
33
|
+
const scoped = packageName.startsWith('@') ? packageName : '@data-fair/' + packageName;
|
|
34
|
+
urls.push(`https://cdn.jsdelivr.net/npm/${scoped}@${minorVersion}/dist/`);
|
|
35
|
+
}
|
|
36
|
+
return urls;
|
|
37
|
+
};
|