@egen-civitas/esm-platform 0.0.0-stage → 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 +29 -0
- package/README.md +117 -2
- package/dist/adapters/web/camera.d.ts +10 -0
- package/dist/adapters/web/camera.js +31 -0
- package/dist/adapters/web/file-registry.d.ts +7 -0
- package/dist/adapters/web/file-registry.js +26 -0
- package/dist/adapters/web/files.d.ts +7 -0
- package/dist/adapters/web/files.js +22 -0
- package/dist/adapters/web/index.d.ts +9 -0
- package/dist/adapters/web/index.js +9 -0
- package/dist/adapters/web/lifecycle.d.ts +3 -0
- package/dist/adapters/web/lifecycle.js +20 -0
- package/dist/adapters/web/open-external.d.ts +8 -0
- package/dist/adapters/web/open-external.js +32 -0
- package/dist/adapters/web/permissions.d.ts +12 -0
- package/dist/adapters/web/permissions.js +48 -0
- package/dist/adapters/web/pick-files.d.ts +17 -0
- package/dist/adapters/web/pick-files.js +49 -0
- package/dist/adapters/web/session.d.ts +12 -0
- package/dist/adapters/web/session.js +19 -0
- package/dist/adapters/web/web-adapter.test.js +1233 -0
- 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 +34 -0
- package/dist/capabilities.js +48 -0
- package/dist/detect.d.ts +25 -0
- package/dist/detect.js +38 -0
- package/dist/detect.test.js +23 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +27 -0
- package/dist/errors.test.js +31 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +14 -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 +24 -0
- package/dist/platform.js +56 -0
- package/dist/platform.test.js +696 -0
- package/dist/register-capabilities.test.js +188 -0
- 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 +23 -0
- package/dist/testing/index.js +14 -0
- package/dist/testing/mock-host.d.ts +49 -0
- package/dist/testing/mock-host.js +86 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.js +1 -0
- package/package.json +57 -3
|
@@ -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
|
+
}
|