@egen-civitas/esm-platform 1.1.0 → 1.2.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/CHANGELOG.md +15 -0
- package/README.md +64 -2
- package/dist/adapters/web/index.d.ts +7 -5
- package/dist/adapters/web/index.js +7 -22
- package/dist/bridge/client.d.ts +48 -0
- package/dist/bridge/client.js +212 -0
- package/dist/bridge/client.test.js +1264 -0
- package/dist/bridge/index.d.ts +4 -0
- package/dist/bridge/index.js +2 -0
- package/dist/bridge/protocol.d.ts +72 -0
- package/dist/bridge/protocol.js +66 -0
- package/dist/bridge/protocol.test.js +215 -0
- package/dist/capabilities.d.ts +15 -7
- package/dist/capabilities.js +15 -39
- package/dist/detect.d.ts +9 -2
- package/dist/detect.js +15 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/native-platform.test.js +329 -0
- package/dist/native.d.ts +24 -0
- package/dist/native.js +46 -0
- package/dist/platform.d.ts +6 -3
- package/dist/platform.js +27 -18
- package/dist/platform.test.js +3 -6
- package/dist/registry.d.ts +52 -0
- package/dist/registry.js +101 -0
- package/dist/registry.test.js +681 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mock-host.d.ts +49 -0
- package/dist/testing/mock-host.js +86 -0
- package/dist/types.d.ts +34 -12
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @egen-civitas/esm-platform
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c5d96c9: Client du protocole Host v1 (C5) : `createBridgeClient` (handshake mémorisé, délais, concurrence, messages invalides ignorés et comptés), `getHostInfo()`, `getHostBridge()`, `createMockHost()` (testing). En natif, `getPlatform().ready` attend désormais le handshake et rejette `INCOMPATIBLE` si le Host est de version incompatible ou annonce une autre plateforme. Aucun changement en Web.
|
|
8
|
+
- 340f5fe: Registre de capacités (C6).
|
|
9
|
+
|
|
10
|
+
- `esm-platform` : `src/registry.ts` devient l'unique description des capacités (version, méthodes, adaptateurs Web et natif) ; le gabarit « indisponible », les adaptateurs, le handshake et la disponibilité en sont dérivés. Une capacité n'est disponible que si le Host l'annonce dans une version supérieure ou égale à celle du registre. `Platform` est dérivé de `CapabilityInterfaces`, extensible par fusion de déclaration. Procédure « ajouter une capacité » dans le README ; comportement des 9 capacités v1 inchangé.
|
|
11
|
+
- `esm-globals` : `EgenCapabilityExtensions` permet d'étendre `CapabilityName` sans modifier ce paquet. Les 9 noms v1 restent une union fermée.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [340f5fe]
|
|
16
|
+
- @egen-civitas/esm-globals@2.2.0
|
|
17
|
+
|
|
3
18
|
## 1.1.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -42,6 +42,56 @@ getPlatform.mockReturnValue(createMockPlatform({ capabilities: { openExternal: {
|
|
|
42
42
|
le reste reste `UNAVAILABLE`. Le mock et l'implémentation passent les **mêmes** tests de contrat (`src/platform.test.ts`).
|
|
43
43
|
`createMockHost()` (simulation de `window.egenHostBridge`) viendra avec le client de pont (C5/C9).
|
|
44
44
|
|
|
45
|
+
## Capacités v1
|
|
46
|
+
|
|
47
|
+
Tout ce qui énumère les capacités (gabarit « indisponible », handshake avec le Host, adaptateurs Web et natif, mocks) lit **un seul registre** :
|
|
48
|
+
`src/registry.ts`. Il associe à chaque nom une `version`, la liste exhaustive de ses `methods` (`async` ou `subscribe`) et ses adaptateurs `web` / `native`.
|
|
49
|
+
|
|
50
|
+
| Capacité | Version | Web | Natif | Priorité | Remarque |
|
|
51
|
+
|---|---|---|---|---|---|
|
|
52
|
+
| `session` | 1 | fournisseur vide (cookie) | à faire (C7) | P0 | chemin critique du chantier D |
|
|
53
|
+
| `secureStorage` | 1 | **jamais** (pas de faux coffre) | à faire (C7) | P0 | valeurs `string` uniquement |
|
|
54
|
+
| `openExternal` | 1 | `https:`, `mailto:`, `tel:` | à faire (C7) | P0 | allowlist de schémas |
|
|
55
|
+
| `lifecycle` | 1 | `visibilitychange` | à faire (C7) | P0 | abonnements avec désabonnement |
|
|
56
|
+
| `permissions` | 1 | API Permissions si présente | à faire (C7) | P1 | noms normalisés |
|
|
57
|
+
| `files` | 1 | `select` seul | à faire (C7) | P1 | *handle*, jamais de binaire inline |
|
|
58
|
+
| `camera` | 1 | `<input capture>` | à faire (C7) | P1 | *handle* de fichier |
|
|
59
|
+
| `push` | 1 | **jamais** | à faire (C7) | P2 | |
|
|
60
|
+
| `biometric` | 1 | **jamais** | à faire (C7) | P2 | déverrouillage local uniquement |
|
|
61
|
+
|
|
62
|
+
Une capacité n'est disponible (`has()` vrai) que si un adaptateur la fournit **et**, en natif, que le Host l'annonce dans une version **supérieure ou égale** à celle du registre.
|
|
63
|
+
|
|
64
|
+
## Ajouter une capacité
|
|
65
|
+
|
|
66
|
+
Règle (N9) : une capacité P1/P2 n'est implémentée que lorsqu'**une app l'utilise**. La procédure ne modifie **aucun autre fichier du framework**
|
|
67
|
+
que ceux listés ci-dessous : le critère est vérifié par `src/registry.test.ts`, qui ajoute une capacité fictive entièrement depuis un fichier de test.
|
|
68
|
+
|
|
69
|
+
1. **Interface TypeScript** dans son propre fichier (`src/extensions/<nom>.ts`), avec les **deux fusions de déclaration** qui l'enregistrent côté types
|
|
70
|
+
(ni `types.ts` ni `esm-globals` ne sont modifiés) :
|
|
71
|
+
```ts
|
|
72
|
+
export interface MaCapacite {
|
|
73
|
+
faire(entree: string): Promise<string>;
|
|
74
|
+
onEvenement(callback: () => void): Unsubscribe;
|
|
75
|
+
}
|
|
76
|
+
declare module '@egen-civitas/esm-globals' {
|
|
77
|
+
interface EgenCapabilityExtensions {
|
|
78
|
+
maCapacite: true; // la rend valide comme `CapabilityName` (donc dans `requiredCapabilities`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
declare module '../types.js' {
|
|
82
|
+
interface CapabilityInterfaces {
|
|
83
|
+
maCapacite: MaCapacite; // la rend accessible : `platform.maCapacite`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
2. **Entrée du registre** (`registerCapability('maCapacite', { version, methods, web, native })`). `methods` est vérifié **à la compilation** : un oubli ne compile pas.
|
|
88
|
+
3. **Adaptateur Web** (`src/adapters/web/<nom>.ts`) : une fabrique qui retourne `undefined` si le navigateur ne sait pas faire. Une méthode non fournie reste `UNAVAILABLE`. **Jamais de simulation de sécurité.**
|
|
89
|
+
4. **Adaptateur natif** : un simple *appelant* du pont (`client.call('maCapacite', 'faire', args)`, `client.onEvent(...)`), sans logique métier.
|
|
90
|
+
5. **Mock** : `createMockPlatform({ capabilities: { maCapacite: { … } } })` fonctionne sans rien ajouter ; ajouter ses valeurs par défaut dans `src/testing/` seulement si utile.
|
|
91
|
+
6. **Tests** : ajouter les lignes de la capacité aux tables de `src/platform.test.ts` (elles servent d'oracle indépendant du registre), un test par méthode de l'adaptateur Web, et un test natif avec `createMockHost`.
|
|
92
|
+
7. **Changeset** *minor* (`esm-platform`, et `esm-globals` si une extension de `CapabilityName` est publiée).
|
|
93
|
+
8. **Ligne ajoutée au tableau des capacités** ci-dessus.
|
|
94
|
+
|
|
45
95
|
## État d'avancement
|
|
46
96
|
| Élément | Tâche | État |
|
|
47
97
|
|---|---|---|
|
|
@@ -52,5 +102,17 @@ le reste reste `UNAVAILABLE`. Le mock et l'implémentation passent les **mêmes*
|
|
|
52
102
|
| `getPlatform()` sûr par défaut (aucune capacité simulée) | C1 | fait |
|
|
53
103
|
| `createMockPlatform` | C9 | partiel (`createMockHost` à faire avec C5) |
|
|
54
104
|
| Adaptateur Web | C4 | fait |
|
|
55
|
-
| Client de pont / protocole v1 | C5 |
|
|
56
|
-
| Registre et procédure d'ajout d'une capacité | C6 |
|
|
105
|
+
| Client de pont / protocole v1 (`createBridgeClient`, handshake, `getHostInfo`) | C5 | fait |
|
|
106
|
+
| Registre et procédure d'ajout d'une capacité | C6 | fait |
|
|
107
|
+
|
|
108
|
+
## Tester sans Host
|
|
109
|
+
`createMockHost()` (`@egen-civitas/esm-platform/testing`) simule `window.egenHostBridge` : handshake automatique, méthodes
|
|
110
|
+
programmables (`handlers: { 'secureStorage.get': () => 'valeur' }`), latence, silence (tests de timeout), version de
|
|
111
|
+
protocole incompatible, messages bruts arbitraires. Il rejoue le **contrat** du protocole v1, pas du code natif.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const host = createMockHost({ capabilities: { secureStorage: { v: 1 } }, handlers: { 'secureStorage.get': () => 'v' } });
|
|
115
|
+
host.install(); // window.egenHostBridge
|
|
116
|
+
window.egenTarget = 'android';
|
|
117
|
+
await getPlatform().ready; // handshake
|
|
118
|
+
```
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { CapabilityOverrides } from '../../capabilities.js';
|
|
2
1
|
/**
|
|
3
|
-
* Capacités fournies par le navigateur. PRINCIPE (doc C4) : ne jamais simuler de la sécurité.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Capacités fournies par le navigateur. PRINCIPE (doc C4) : ne jamais simuler de la sécurité.
|
|
3
|
+
*
|
|
4
|
+
* La liste de ce qui est fourni ne vit plus ici : chaque capacité déclare son adaptateur Web dans `registry.ts`
|
|
5
|
+
* (tâche C6), et `createWebCapabilities()` ne fait que lire le registre. Sont ABSENTES (`has()` faux, méthodes
|
|
6
|
+
* `UNAVAILABLE`) : `secureStorage` (pas de faux coffre dans `localStorage`), `push` et `biometric` (v1), ainsi que
|
|
7
|
+
* `permissions` quand l'API Permissions n'existe pas.
|
|
6
8
|
*/
|
|
7
|
-
export
|
|
9
|
+
export { createWebCapabilities } from '../../registry.js';
|
|
@@ -1,24 +1,9 @@
|
|
|
1
|
-
import { createWebCamera } from './camera.js';
|
|
2
|
-
import { createWebFiles } from './files.js';
|
|
3
|
-
import { createWebLifecycle } from './lifecycle.js';
|
|
4
|
-
import { createWebOpenExternal } from './open-external.js';
|
|
5
|
-
import { createWebPermissions, isWebPermissionsAvailable } from './permissions.js';
|
|
6
|
-
import { createWebSession } from './session.js';
|
|
7
1
|
/**
|
|
8
|
-
* Capacités fournies par le navigateur. PRINCIPE (doc C4) : ne jamais simuler de la sécurité.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
2
|
+
* Capacités fournies par le navigateur. PRINCIPE (doc C4) : ne jamais simuler de la sécurité.
|
|
3
|
+
*
|
|
4
|
+
* La liste de ce qui est fourni ne vit plus ici : chaque capacité déclare son adaptateur Web dans `registry.ts`
|
|
5
|
+
* (tâche C6), et `createWebCapabilities()` ne fait que lire le registre. Sont ABSENTES (`has()` faux, méthodes
|
|
6
|
+
* `UNAVAILABLE`) : `secureStorage` (pas de faux coffre dans `localStorage`), `push` et `biometric` (v1), ainsi que
|
|
7
|
+
* `permissions` quand l'API Permissions n'existe pas.
|
|
11
8
|
*/
|
|
12
|
-
export
|
|
13
|
-
const capabilities = {
|
|
14
|
-
session: createWebSession(),
|
|
15
|
-
openExternal: createWebOpenExternal(),
|
|
16
|
-
lifecycle: createWebLifecycle(),
|
|
17
|
-
files: createWebFiles(),
|
|
18
|
-
camera: createWebCamera(),
|
|
19
|
-
};
|
|
20
|
-
if (isWebPermissionsAvailable()) {
|
|
21
|
-
capabilities.permissions = createWebPermissions();
|
|
22
|
-
}
|
|
23
|
-
return capabilities;
|
|
24
|
-
}
|
|
9
|
+
export { createWebCapabilities } from '../../registry.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CapabilityName } from '@egen-civitas/esm-globals';
|
|
2
|
+
import type { PlatformEnvironment, Unsubscribe } from '../types.js';
|
|
3
|
+
import { type HostBridge } from './protocol.js';
|
|
4
|
+
/** Ce que le Host annonce lors du handshake (contenu validé par le client). */
|
|
5
|
+
export interface HostInfo {
|
|
6
|
+
/** Version du protocole retenue par le Host. */
|
|
7
|
+
protocol: number;
|
|
8
|
+
/** Version de l'application hôte. */
|
|
9
|
+
host: string;
|
|
10
|
+
platform: PlatformEnvironment;
|
|
11
|
+
/** Capacités que le Host accepte de servir (le Host contrôle ce qui est accessible). */
|
|
12
|
+
capabilities: Partial<Record<CapabilityName, {
|
|
13
|
+
v: number;
|
|
14
|
+
}>>;
|
|
15
|
+
}
|
|
16
|
+
export interface BridgeClientOptions {
|
|
17
|
+
bridge: HostBridge;
|
|
18
|
+
/** Cible de ce build : le Host doit annoncer la même plateforme. */
|
|
19
|
+
environment: PlatformEnvironment;
|
|
20
|
+
/** Version du framework, transmise au Host dans le `hello`. */
|
|
21
|
+
framework?: string;
|
|
22
|
+
/** Délai par défaut avant `TIMEOUT`. @default 30000 */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface CallOptions {
|
|
26
|
+
/** Remplace le délai par défaut pour cet appel. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface BridgeClient {
|
|
30
|
+
/** Handshake obligatoire (règle 1 du protocole). Mémorisé : les appels suivants retournent le même résultat. */
|
|
31
|
+
hello(): Promise<HostInfo>;
|
|
32
|
+
/** Appelle une méthode d'une capacité. Rejette avec `PlatformError` ; exige un handshake réussi. */
|
|
33
|
+
call<T = unknown>(namespace: string, method: string, args?: unknown, options?: CallOptions): Promise<T>;
|
|
34
|
+
/** S'abonne à un événement du Host (ex. `lifecycle.resume`). */
|
|
35
|
+
onEvent(name: string, callback: (data: unknown) => void): Unsubscribe;
|
|
36
|
+
/** `null` tant que le handshake n'a pas réussi. */
|
|
37
|
+
readonly hostInfo: HostInfo | null;
|
|
38
|
+
/** Compteurs de diagnostic : messages ignorés (invalides, orphelins, version inconnue) et appels en vol. */
|
|
39
|
+
stats(): {
|
|
40
|
+
ignoredMessages: number;
|
|
41
|
+
inFlight: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Client du protocole Host v1 (côté Shell). Sécurité : aucune évaluation dynamique, seuls les champs attendus sont
|
|
46
|
+
* lus, tout message invalide / orphelin / de version inconnue est ignoré et compté, jamais exécuté.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createBridgeClient(options: BridgeClientOptions): BridgeClient;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { PlatformError } from '../errors.js';
|
|
2
|
+
import { isKnownCapability } from '../registry.js';
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS, HOST_PROTOCOL_NAME, KNOWN_ERROR_CODES, MAX_MESSAGE_LENGTH, SUPPORTED_PROTOCOL, isSupportedProtocolVersion, parseHostMessage, } from './protocol.js';
|
|
4
|
+
const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
5
|
+
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
6
|
+
/**
|
|
7
|
+
* Client du protocole Host v1 (côté Shell). Sécurité : aucune évaluation dynamique, seuls les champs attendus sont
|
|
8
|
+
* lus, tout message invalide / orphelin / de version inconnue est ignoré et compté, jamais exécuté.
|
|
9
|
+
*/
|
|
10
|
+
export function createBridgeClient(options) {
|
|
11
|
+
const { bridge, environment, framework, timeoutMs: defaultTimeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
12
|
+
const pending = new Map();
|
|
13
|
+
const listeners = new Map();
|
|
14
|
+
let info = null;
|
|
15
|
+
let helloPromise = null;
|
|
16
|
+
let ignored = 0;
|
|
17
|
+
let counter = 0;
|
|
18
|
+
const nextId = () => {
|
|
19
|
+
for (;;) {
|
|
20
|
+
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
21
|
+
? crypto.randomUUID()
|
|
22
|
+
: `${Date.now().toString(36)}-${(counter++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
23
|
+
if (!pending.has(id)) {
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function toPlatformError(failure, namespace, method) {
|
|
29
|
+
const { code, message, retryable } = failure.e;
|
|
30
|
+
const known = typeof code === 'string' && KNOWN_ERROR_CODES.has(code);
|
|
31
|
+
const text = typeof message === 'string' && message.length > 0
|
|
32
|
+
? message.slice(0, MAX_ERROR_MESSAGE_LENGTH)
|
|
33
|
+
: `Le Host a refusé l'appel ${namespace}.${method}.`;
|
|
34
|
+
return new PlatformError((known ? code : 'HOST_ERROR'), text, {
|
|
35
|
+
retryable: retryable === true,
|
|
36
|
+
capability: namespace,
|
|
37
|
+
method,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function handleIncoming(raw) {
|
|
41
|
+
const message = parseHostMessage(raw);
|
|
42
|
+
if (!message) {
|
|
43
|
+
ignored++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if ('ev' in message) {
|
|
47
|
+
if (!isSupportedProtocolVersion(message.v)) {
|
|
48
|
+
ignored++;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
listeners.get(message.ev)?.forEach((callback) => {
|
|
52
|
+
try {
|
|
53
|
+
callback(message.d);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error(`[egen-platform] Un abonné de l'événement "${message.ev}" a levé une erreur :`, error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const entry = pending.get(message.id);
|
|
62
|
+
if (!entry) {
|
|
63
|
+
ignored++; // réponse orpheline : id inconnu, déjà traitée ou expirée
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
pending.delete(message.id);
|
|
67
|
+
clearTimeout(entry.timer);
|
|
68
|
+
if (!isSupportedProtocolVersion(message.v)) {
|
|
69
|
+
entry.reject(new PlatformError('INCOMPATIBLE', `Le Host répond avec la version de protocole ${String(message.v)}, non supportée.`, {
|
|
70
|
+
capability: entry.namespace,
|
|
71
|
+
method: entry.method,
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (message.ok) {
|
|
76
|
+
entry.resolve(message.r);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
entry.reject(toPlatformError(message, entry.namespace, entry.method));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
bridge.subscribe(handleIncoming);
|
|
83
|
+
function request(namespace, method, args, version, timeoutMs) {
|
|
84
|
+
const reject = (code, message) => Promise.reject(new PlatformError(code, message, { capability: namespace, method }));
|
|
85
|
+
if (!NAME_PATTERN.test(namespace) || !NAME_PATTERN.test(method)) {
|
|
86
|
+
return reject('INVALID_ARGS', `Nom d'appel invalide : "${namespace}.${method}".`);
|
|
87
|
+
}
|
|
88
|
+
const id = nextId();
|
|
89
|
+
const envelope = { p: HOST_PROTOCOL_NAME, v: version, id, ns: namespace, m: method };
|
|
90
|
+
if (args !== undefined) {
|
|
91
|
+
envelope.a = args;
|
|
92
|
+
}
|
|
93
|
+
let json;
|
|
94
|
+
try {
|
|
95
|
+
json = JSON.stringify(envelope);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return reject('INVALID_ARGS', `Les arguments de ${namespace}.${method} ne sont pas sérialisables en JSON.`);
|
|
99
|
+
}
|
|
100
|
+
if (json.length > MAX_MESSAGE_LENGTH) {
|
|
101
|
+
return reject('INVALID_ARGS', `Message trop volumineux pour ${namespace}.${method} (${json.length} caractères, maximum ${MAX_MESSAGE_LENGTH}) : utiliser une référence de fichier.`);
|
|
102
|
+
}
|
|
103
|
+
return new Promise((resolve, rejectPromise) => {
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
pending.delete(id);
|
|
106
|
+
rejectPromise(new PlatformError('TIMEOUT', `Le Host n'a pas répondu à ${namespace}.${method} en ${timeoutMs} ms.`, {
|
|
107
|
+
retryable: true,
|
|
108
|
+
capability: namespace,
|
|
109
|
+
method,
|
|
110
|
+
}));
|
|
111
|
+
}, timeoutMs);
|
|
112
|
+
pending.set(id, { namespace, method, resolve, reject: rejectPromise, timer });
|
|
113
|
+
try {
|
|
114
|
+
bridge.send(json);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
pending.delete(id);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
rejectPromise(new PlatformError('HOST_ERROR', `Échec de l'envoi au Host : ${error instanceof Error ? error.message : String(error)}`, {
|
|
120
|
+
capability: namespace,
|
|
121
|
+
method,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function parseHelloResult(result) {
|
|
127
|
+
const incompatible = (reason) => new PlatformError('INCOMPATIBLE', `Handshake refusé : ${reason}`, { capability: 'host', method: 'hello' });
|
|
128
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
|
|
129
|
+
throw incompatible('réponse invalide.');
|
|
130
|
+
}
|
|
131
|
+
const value = result;
|
|
132
|
+
if (!isSupportedProtocolVersion(value.protocol)) {
|
|
133
|
+
throw incompatible(`le Host parle le protocole ${String(value.protocol)}, ce client supporte ${SUPPORTED_PROTOCOL.min}–${SUPPORTED_PROTOCOL.max}.`);
|
|
134
|
+
}
|
|
135
|
+
if (value.platform !== environment) {
|
|
136
|
+
throw incompatible(`le Host annonce la plateforme "${String(value.platform)}", ce build cible "${environment}".`);
|
|
137
|
+
}
|
|
138
|
+
if (typeof value.host !== 'string' || value.host.length === 0 || value.host.length > 64) {
|
|
139
|
+
throw incompatible('version du Host absente ou invalide.');
|
|
140
|
+
}
|
|
141
|
+
const capabilities = {};
|
|
142
|
+
if (typeof value.capabilities === 'object' && value.capabilities !== null && !Array.isArray(value.capabilities)) {
|
|
143
|
+
for (const [name, descriptor] of Object.entries(value.capabilities)) {
|
|
144
|
+
const version = descriptor?.v;
|
|
145
|
+
// Capacité inconnue de ce client = ignorée (compatibilité ascendante) ; descripteur invalide = ignoré.
|
|
146
|
+
if (isKnownCapability(name) && Number.isInteger(version) && version >= 1) {
|
|
147
|
+
capabilities[name] = { v: version };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { protocol: value.protocol, host: value.host, platform: environment, capabilities };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
hello() {
|
|
155
|
+
if (!helloPromise) {
|
|
156
|
+
const attempt = request('host', 'hello', { protocol: { ...SUPPORTED_PROTOCOL }, ...(framework ? { framework } : {}) }, SUPPORTED_PROTOCOL.max, defaultTimeoutMs).then((result) => {
|
|
157
|
+
info = parseHelloResult(result);
|
|
158
|
+
return info;
|
|
159
|
+
});
|
|
160
|
+
helloPromise = attempt;
|
|
161
|
+
attempt.catch((error) => {
|
|
162
|
+
// Un échec passager (timeout, envoi) peut être retenté ; une incompatibilité est définitive.
|
|
163
|
+
if (error instanceof PlatformError && error.retryable && helloPromise === attempt) {
|
|
164
|
+
helloPromise = null;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return helloPromise;
|
|
169
|
+
},
|
|
170
|
+
call(namespace, method, args, callOptions = {}) {
|
|
171
|
+
if (namespace === 'host') {
|
|
172
|
+
return Promise.reject(new PlatformError('INVALID_ARGS', 'Le namespace "host" est réservé au handshake.', { method }));
|
|
173
|
+
}
|
|
174
|
+
if (!NAME_PATTERN.test(namespace) || !NAME_PATTERN.test(method)) {
|
|
175
|
+
return Promise.reject(new PlatformError('INVALID_ARGS', `Nom d'appel invalide : "${namespace}.${method}".`, {
|
|
176
|
+
capability: namespace,
|
|
177
|
+
method,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
if (!info) {
|
|
181
|
+
return Promise.reject(new PlatformError('INCOMPATIBLE', `Appel ${namespace}.${method} refusé : le handshake avec le Host n'est pas terminé.`, {
|
|
182
|
+
capability: namespace,
|
|
183
|
+
method,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
if (!info.capabilities[namespace]) {
|
|
187
|
+
return Promise.reject(new PlatformError('UNAVAILABLE', `Le Host n'annonce pas la capacité "${namespace}".`, {
|
|
188
|
+
capability: namespace,
|
|
189
|
+
method,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
return request(namespace, method, args, info.protocol, callOptions.timeoutMs ?? defaultTimeoutMs);
|
|
193
|
+
},
|
|
194
|
+
onEvent(name, callback) {
|
|
195
|
+
let set = listeners.get(name);
|
|
196
|
+
if (!set) {
|
|
197
|
+
set = new Set();
|
|
198
|
+
listeners.set(name, set);
|
|
199
|
+
}
|
|
200
|
+
set.add(callback);
|
|
201
|
+
return () => {
|
|
202
|
+
set.delete(callback);
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
get hostInfo() {
|
|
206
|
+
return info;
|
|
207
|
+
},
|
|
208
|
+
stats() {
|
|
209
|
+
return { ignoredMessages: ignored, inFlight: pending.size };
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|