@navette-js/wire 0.0.0-stage → 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/lib/express.d.ts +15 -0
- package/lib/express.js +92 -0
- package/lib/index.d.ts +149 -0
- package/lib/index.js +413 -0
- package/lib/uploads.d.ts +37 -0
- package/lib/uploads.js +149 -0
- package/package.json +52 -4
- package/vendor/LIVEWIRE-LICENSE.md +9 -0
- package/vendor/livewire.js +17551 -0
- package/vendor/livewire.min.js +162 -0
- package/vendor/manifest.json +2 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// @navette-js/wire — runtime serveur de composants à état, compatible avec le protocole du client livewire.js 4.4.8.
|
|
2
|
+
// Prototype du jalon 0 (ADR 0001). Référence : sources/demo/vendor/livewire/livewire/src/Mechanisms/HandleComponents.
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { UploadStore } from "./uploads.js";
|
|
6
|
+
export { UploadStore } from "./uploads.js";
|
|
7
|
+
export class WireError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
10
|
+
}
|
|
11
|
+
/** Classe de base d'un composant. Toute propriété propre énumérable ne commençant pas par `_` est de l'état public. */
|
|
12
|
+
export class Component {
|
|
13
|
+
_id = '';
|
|
14
|
+
_effects = {};
|
|
15
|
+
_skipRender = false;
|
|
16
|
+
_errors = {};
|
|
17
|
+
/** Contexte de la requête HTTP en cours (requête, réponse, panel…), fourni par l'intégration. */
|
|
18
|
+
_ctx = null;
|
|
19
|
+
/** Runtime et requête d'origine : servent à monter des composants enfants pendant le rendu. */
|
|
20
|
+
_runtime = null;
|
|
21
|
+
_request = {};
|
|
22
|
+
/** Enfants rendus pendant cette requête, et enfants déjà présents dans le navigateur : clé → [balise, identifiant]. */
|
|
23
|
+
_children = {};
|
|
24
|
+
_previousChildren = {};
|
|
25
|
+
/** HTML affiché tant qu'un composant « lazy » n'est pas chargé : une seule racine. */
|
|
26
|
+
placeholder() { return '<div></div>'; }
|
|
27
|
+
/**
|
|
28
|
+
* Monte un composant enfant et rend son HTML, à insérer dans le rendu du parent (équivalent de `@livewire`).
|
|
29
|
+
* Si le navigateur possède déjà cet enfant (même clé), seule une souche est rendue : le client conserve l'enfant tel quel.
|
|
30
|
+
* `lazy` : l'enfant affiche son `placeholder()` et se charge quand il devient visible.
|
|
31
|
+
*/
|
|
32
|
+
async mountChild(name, params = {}, key, options = {}) {
|
|
33
|
+
if (!this._runtime)
|
|
34
|
+
throw new Error('mountChild() : composant hors runtime.');
|
|
35
|
+
const previous = this._previousChildren[key];
|
|
36
|
+
if (previous) {
|
|
37
|
+
this._children[key] = previous;
|
|
38
|
+
// Propriétés réactives : l'enfant, mis à jour dans la même requête, reprendra ces valeurs (équivalent de #[Reactive]).
|
|
39
|
+
this._runtime.passPropsToChild(previous[1], params);
|
|
40
|
+
return `<${previous[0]} wire:id="${previous[1]}" wire:name="${escapeAttr(name)}" wire:key="${escapeAttr(key)}"></${previous[0]}>`;
|
|
41
|
+
}
|
|
42
|
+
const { html, component } = await this._runtime.mountWithInstance(name, params, { ...this._request, ctx: this._ctx, lazy: options.lazy, key });
|
|
43
|
+
this._children[key] = [/^\s*<([a-zA-Z0-9-]+)/.exec(html)[1], component.id];
|
|
44
|
+
return html;
|
|
45
|
+
}
|
|
46
|
+
mount(_params) { }
|
|
47
|
+
boot() { }
|
|
48
|
+
hydrate() { }
|
|
49
|
+
dehydrate() { }
|
|
50
|
+
get id() { return this._id; }
|
|
51
|
+
/** `self` : l'événement n'est reçu que par ce composant (équivalent de `->self()`). */
|
|
52
|
+
dispatch(name, params = {}, options = {}) {
|
|
53
|
+
(this._effects.dispatches ??= []).push({ name, params: params, ...(options.self ? { self: true } : {}) });
|
|
54
|
+
}
|
|
55
|
+
redirect(url, navigate = false) {
|
|
56
|
+
this._effects.redirect = url;
|
|
57
|
+
if (navigate)
|
|
58
|
+
this._effects.redirectUsingNavigate = true;
|
|
59
|
+
}
|
|
60
|
+
skipRender() { this._skipRender = true; }
|
|
61
|
+
/** Rendu partiel à la Filament : seul le fragment `wire:partial="<nom>"` est renvoyé (effects.partials), sans re-rendre tout le composant. */
|
|
62
|
+
renderPartial(name, html) { (this._effects.partials ??= {})[name] = html; this._skipRender = true; }
|
|
63
|
+
addError(path, message) { (this._errors[path] ??= []).push(message); }
|
|
64
|
+
resetErrorBag() { this._errors = {}; }
|
|
65
|
+
}
|
|
66
|
+
const LIFECYCLE = new Set(['constructor', 'render', 'mount', 'boot', 'hydrate', 'dehydrate', 'dispatch', 'redirect', 'skipRender', 'renderPartial', 'addError', 'resetErrorBag', 'mountChild', 'placeholder']);
|
|
67
|
+
// Seuls les hooks de propriété (updatingFoo / updatedFoo) sont réservés par préfixe : `mountAction`, par exemple, est une méthode publique ordinaire.
|
|
68
|
+
const isLifecycleHook = (m) => LIFECYCLE.has(m) || /^(updating|updated)[A-Z]/.test(m) || m.startsWith('_');
|
|
69
|
+
/**
|
|
70
|
+
* Méthodes appelables depuis le navigateur : uniquement celles que la classe (ou une classe parente) nomme dans `static callable`.
|
|
71
|
+
* TypeScript efface `protected` et `private` à l'exécution : sans cette liste, toute méthode interne serait appelable par une
|
|
72
|
+
* requête forgée, et sa valeur de retour renvoyée au navigateur.
|
|
73
|
+
*/
|
|
74
|
+
export function callableMethods(component) {
|
|
75
|
+
const out = new Set();
|
|
76
|
+
for (let cls = component.constructor; cls && cls !== Component; cls = Object.getPrototypeOf(cls)) {
|
|
77
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'callable') || !Array.isArray(cls.callable))
|
|
78
|
+
continue;
|
|
79
|
+
for (const name of cls.callable)
|
|
80
|
+
if (typeof name === 'string' && !isLifecycleHook(name) && typeof component[name] === 'function')
|
|
81
|
+
out.add(name);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function publicProperties(component) {
|
|
86
|
+
return Object.keys(component).filter((k) => !k.startsWith('_'));
|
|
87
|
+
}
|
|
88
|
+
// --- Synthétiseurs : les tableaux et objets voyagent sous la forme [valeur, { s: 'arr' }] (HandleSynths de Livewire).
|
|
89
|
+
export function dehydrateValue(value) {
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
return null;
|
|
92
|
+
if (value === null || typeof value !== 'object')
|
|
93
|
+
return value;
|
|
94
|
+
if (Array.isArray(value))
|
|
95
|
+
return [value.map(dehydrateValue), { s: 'arr' }];
|
|
96
|
+
return [Object.fromEntries(Object.entries(value).map(([k, v]) => [k, dehydrateValue(v)])), { s: 'arr' }];
|
|
97
|
+
}
|
|
98
|
+
export function hydrateValue(value) {
|
|
99
|
+
if (Array.isArray(value) && value.length === 2 && value[1] && typeof value[1] === 'object' && 's' in value[1]) {
|
|
100
|
+
const inner = value[0];
|
|
101
|
+
if (Array.isArray(inner))
|
|
102
|
+
return inner.map(hydrateValue);
|
|
103
|
+
return Object.fromEntries(Object.entries(inner).map(([k, v]) => [k, hydrateValue(v)]));
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''');
|
|
108
|
+
/** Équivalent de Utils::insertAttributesIntoHtmlRoot : pose des attributs sur la balise racine. */
|
|
109
|
+
export function insertAttributesIntoHtmlRoot(html, attributes) {
|
|
110
|
+
const m = /^\s*<([a-zA-Z0-9-]+)/.exec(html);
|
|
111
|
+
if (!m)
|
|
112
|
+
throw new WireError(500, 'Le rendu d\'un composant doit commencer par une balise racine.');
|
|
113
|
+
const attrs = Object.entries(attributes)
|
|
114
|
+
.map(([k, v]) => `${k}="${escapeAttr(typeof v === 'string' ? v : JSON.stringify(v))}"`)
|
|
115
|
+
.join(' ');
|
|
116
|
+
const at = m.index + m[0].length;
|
|
117
|
+
return html.slice(0, at) + ' ' + attrs + html.slice(at);
|
|
118
|
+
}
|
|
119
|
+
function setByPath(target, segments, value) {
|
|
120
|
+
let node = target;
|
|
121
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
122
|
+
const key = segments[i];
|
|
123
|
+
if (node[key] === null || typeof node[key] !== 'object')
|
|
124
|
+
node[key] = {};
|
|
125
|
+
node = node[key];
|
|
126
|
+
}
|
|
127
|
+
const last = segments[segments.length - 1];
|
|
128
|
+
if (value === '__rm__') {
|
|
129
|
+
Array.isArray(node) ? node.splice(Number(last), 1) : delete node[last];
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
node[last] = value;
|
|
133
|
+
}
|
|
134
|
+
/** Identifiant alphanumérique de 20 caractères, comme Str::random(20) de Livewire (il sert dans des id HTML et des sélecteurs). */
|
|
135
|
+
const randomId = () => Array.from(randomBytes(20), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join('');
|
|
136
|
+
const MAGIC_ACTIONS = new Set(['$refresh', '$set', '$sync', '$commit']);
|
|
137
|
+
const studly = (s) => s.replace(/(^|[_.-])(\w)/g, (_, __, c) => c.toUpperCase());
|
|
138
|
+
/** Faux si la valeur contient, à n'importe quelle profondeur, une référence de fichier temporaire qui ne désigne aucun fichier reçu. */
|
|
139
|
+
function uploadReferencesAreValid(value, store, depth = 0) {
|
|
140
|
+
if (typeof value === 'string')
|
|
141
|
+
return value.startsWith('livewire-files:') ? false : value.startsWith('livewire-file:') ? !!store?.resolve(value) : true;
|
|
142
|
+
if (!value || typeof value !== 'object')
|
|
143
|
+
return true;
|
|
144
|
+
if (depth > 8)
|
|
145
|
+
return false;
|
|
146
|
+
return Object.values(value).every((v) => uploadReferencesAreValid(v, store, depth + 1));
|
|
147
|
+
}
|
|
148
|
+
/** Propriétés réactives d'un composant (`static reactive`) : fixées par le parent, jamais par le navigateur. */
|
|
149
|
+
const reactiveProps = (component) => component.constructor.reactive ?? [];
|
|
150
|
+
export class WireRuntime {
|
|
151
|
+
registry = new Map();
|
|
152
|
+
options;
|
|
153
|
+
constructor(options) { this.options = options; }
|
|
154
|
+
get uploads() { return this.options.uploads ?? null; }
|
|
155
|
+
/** Paramètres passés par un parent à ses enfants déjà présents, pendant la requête en cours : identifiant de l'enfant → paramètres. */
|
|
156
|
+
requestScope = new AsyncLocalStorage();
|
|
157
|
+
passPropsToChild(childId, params) { this.requestScope.getStore()?.childProps.set(childId, params); }
|
|
158
|
+
register(name, cls) { this.registry.set(name, cls); cls.componentName = name; return this; }
|
|
159
|
+
make(name) {
|
|
160
|
+
const cls = this.registry.get(name);
|
|
161
|
+
if (!cls)
|
|
162
|
+
throw new WireError(404, `Composant inconnu : ${name}`);
|
|
163
|
+
return new cls();
|
|
164
|
+
}
|
|
165
|
+
checksum(snapshot) {
|
|
166
|
+
const { children: _children, ...memo } = snapshot.memo; // les enfants peuvent être modifiés par le client
|
|
167
|
+
return createHmac('sha256', this.options.key).update(JSON.stringify({ data: snapshot.data, memo })).digest('hex');
|
|
168
|
+
}
|
|
169
|
+
snapshot(component, name, memo) {
|
|
170
|
+
const data = {};
|
|
171
|
+
for (const k of publicProperties(component))
|
|
172
|
+
data[k] = dehydrateValue(component[k]);
|
|
173
|
+
const body = { data, memo: { id: component.id, name, ...memo, errors: component._errors } };
|
|
174
|
+
return { ...body, checksum: this.checksum(body) };
|
|
175
|
+
}
|
|
176
|
+
/** Paramètres de montage d'un composant « lazy », signés : le navigateur les renvoie tels quels à `__lazyLoad` (conteneur `__mountParamsContainer` de Livewire). */
|
|
177
|
+
encodeMountParams(name, params, request) {
|
|
178
|
+
const body = { data: { forMount: dehydrateValue(params), forComponent: name }, memo: { id: randomId(), name: '__mountParamsContainer', path: request.path ?? '/', method: request.method ?? 'GET', release: 'a-a-a' } };
|
|
179
|
+
return Buffer.from(JSON.stringify({ ...body, checksum: this.checksum(body) })).toString('base64');
|
|
180
|
+
}
|
|
181
|
+
decodeMountParams(name, encoded) {
|
|
182
|
+
let container;
|
|
183
|
+
try {
|
|
184
|
+
container = JSON.parse(Buffer.from(String(encoded), 'base64').toString('utf8'));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
throw new WireError(404, 'Paramètres de montage illisibles.');
|
|
188
|
+
}
|
|
189
|
+
if (!container || typeof container.data !== 'object' || typeof container.memo !== 'object' || typeof container.checksum !== 'string')
|
|
190
|
+
throw new WireError(404, 'Paramètres de montage invalides.');
|
|
191
|
+
const expected = Buffer.from(this.checksum(container)), given = Buffer.from(container.checksum);
|
|
192
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
193
|
+
throw new WireError(419, 'Somme de contrôle des paramètres de montage invalide.');
|
|
194
|
+
// Les paramètres signés pour un composant ne valent pas pour un autre.
|
|
195
|
+
if (container.memo.name !== '__mountParamsContainer' || container.data.forComponent !== name)
|
|
196
|
+
throw new WireError(403, 'Paramètres de montage destinés à un autre composant.');
|
|
197
|
+
return (hydrateValue(container.data.forMount) ?? {});
|
|
198
|
+
}
|
|
199
|
+
/** Comme Livewire : un paramètre de montage du même nom qu'une propriété publique y est copié. */
|
|
200
|
+
assignMatchingProperties(component, params) {
|
|
201
|
+
const known = new Set(publicProperties(component));
|
|
202
|
+
for (const [k, v] of Object.entries(params))
|
|
203
|
+
if (known.has(k))
|
|
204
|
+
component[k] = v;
|
|
205
|
+
}
|
|
206
|
+
async renderRoot(component, name) {
|
|
207
|
+
const html = await component.render();
|
|
208
|
+
return insertAttributesIntoHtmlRoot(html, { 'wire:id': component.id, 'wire:name': name });
|
|
209
|
+
}
|
|
210
|
+
/** Premier rendu : HTML avec wire:snapshot et wire:effects sur la racine.
|
|
211
|
+
* Le mémo ne porte `lazyLoaded` / `lazyIsolated` que pour un composant réellement « lazy » : leur seule présence fait ignorer les événements au client. */
|
|
212
|
+
async mount(name, params = {}, request = {}) {
|
|
213
|
+
return (await this.mountWithInstance(name, params, request)).html;
|
|
214
|
+
}
|
|
215
|
+
/** Comme mount(), mais rend aussi l'instance montée (pour lire son titre, par exemple). */
|
|
216
|
+
async mountWithInstance(name, params = {}, request = {}) {
|
|
217
|
+
const component = this.make(name);
|
|
218
|
+
component._ctx = request.ctx ?? null;
|
|
219
|
+
// Identifiant alphanumérique de 20 caractères, comme Str::random(20) de Livewire (il sert dans des id HTML et des sélecteurs).
|
|
220
|
+
component._id = randomId();
|
|
221
|
+
component._runtime = this;
|
|
222
|
+
component._request = { path: request.path, method: request.method };
|
|
223
|
+
await component.boot();
|
|
224
|
+
this.assignMatchingProperties(component, params);
|
|
225
|
+
let html;
|
|
226
|
+
if (request.lazy) {
|
|
227
|
+
// Composant « lazy » : `mount()` et le rendu attendent l'appel `__lazyLoad` du navigateur.
|
|
228
|
+
const placeholder = insertAttributesIntoHtmlRoot(await component.placeholder(), { 'x-intersect': `$wire.__lazyLoad('${this.encodeMountParams(name, params, request)}')` });
|
|
229
|
+
html = insertAttributesIntoHtmlRoot(placeholder, { 'wire:id': component.id, 'wire:name': name });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
await component.mount(params);
|
|
233
|
+
html = await this.renderRoot(component, name);
|
|
234
|
+
}
|
|
235
|
+
await component.dehydrate();
|
|
236
|
+
const snapshot = this.snapshot(component, name, {
|
|
237
|
+
path: request.path ?? '/', method: request.method ?? 'GET', release: 'a-a-a', children: Object.keys(component._children).length ? component._children : [], scripts: [], assets: [],
|
|
238
|
+
...(request.lazy ? { lazyLoaded: false, lazyIsolated: true } : {}), ...(reactiveProps(component).length ? { props: reactiveProps(component) } : {}), locale: this.options.locale ?? 'en', islands: [],
|
|
239
|
+
});
|
|
240
|
+
const { html: _h, ...effects } = component._effects;
|
|
241
|
+
// Écouteurs d'événements (équivalent de #[On]) : le client appellera `__dispatch(événement, paramètres)`.
|
|
242
|
+
const listeners = Object.keys(component.constructor.listeners ?? {});
|
|
243
|
+
if (listeners.length)
|
|
244
|
+
effects.listeners = listeners;
|
|
245
|
+
return { html: insertAttributesIntoHtmlRoot(html, { ...(request.key !== undefined ? { 'wire:key': request.key } : {}), 'wire:snapshot': JSON.stringify(snapshot), 'wire:effects': JSON.stringify(effects) }), component };
|
|
246
|
+
}
|
|
247
|
+
/** Requête de mise à jour d'un composant : hydratation, mises à jour, appels, rendu, déshydratation. */
|
|
248
|
+
async update(snapshot, updates, calls, ctx = null) {
|
|
249
|
+
if (!snapshot || typeof snapshot.data !== 'object' || typeof snapshot.memo !== 'object' || typeof snapshot.checksum !== 'string')
|
|
250
|
+
throw new WireError(404, 'Snapshot invalide.');
|
|
251
|
+
const expected = Buffer.from(this.checksum(snapshot)), given = Buffer.from(snapshot.checksum);
|
|
252
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
253
|
+
throw new WireError(419, 'Somme de contrôle du snapshot invalide.');
|
|
254
|
+
const name = snapshot.memo.name;
|
|
255
|
+
const component = this.make(name);
|
|
256
|
+
const cls = component.constructor;
|
|
257
|
+
component._id = snapshot.memo.id;
|
|
258
|
+
component._ctx = ctx;
|
|
259
|
+
component._errors = { ...(snapshot.memo.errors ?? {}) };
|
|
260
|
+
component._runtime = this;
|
|
261
|
+
component._request = { path: snapshot.memo.path, method: snapshot.memo.method };
|
|
262
|
+
// `memo.children` n'est pas couvert par la somme de contrôle (le client le tient à jour) : seules les entrées bien formées sont retenues.
|
|
263
|
+
if (snapshot.memo.children && typeof snapshot.memo.children === 'object' && !Array.isArray(snapshot.memo.children)) {
|
|
264
|
+
for (const [key, value] of Object.entries(snapshot.memo.children)) {
|
|
265
|
+
if (Array.isArray(value) && /^[a-zA-Z][a-zA-Z0-9-]*$/.test(String(value[0])) && /^[A-Za-z0-9]{1,64}$/.test(String(value[1])))
|
|
266
|
+
component._previousChildren[key] = [String(value[0]), String(value[1])];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const awaitsLazyLoad = snapshot.memo.lazyLoaded === false;
|
|
270
|
+
let lazyLoaded = false;
|
|
271
|
+
const known = new Set(publicProperties(component));
|
|
272
|
+
for (const [k, v] of Object.entries(snapshot.data))
|
|
273
|
+
if (known.has(k))
|
|
274
|
+
component[k] = hydrateValue(v);
|
|
275
|
+
// Propriétés réactives : elles ne viennent que du parent, rendu plus tôt dans la même requête.
|
|
276
|
+
const reactive = new Set(reactiveProps(component));
|
|
277
|
+
const passed = this.requestScope.getStore()?.childProps.get(component.id);
|
|
278
|
+
if (passed)
|
|
279
|
+
for (const prop of reactive)
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(passed, prop) && known.has(prop))
|
|
281
|
+
component[prop] = passed[prop];
|
|
282
|
+
await component.boot();
|
|
283
|
+
await component.hydrate();
|
|
284
|
+
const applyUpdate = async (path, value) => {
|
|
285
|
+
const segments = path.split('.');
|
|
286
|
+
const prop = segments[0];
|
|
287
|
+
if (!known.has(prop))
|
|
288
|
+
throw new WireError(404, `Propriété publique introuvable : ${prop}`);
|
|
289
|
+
if (cls.locked?.includes(prop))
|
|
290
|
+
throw new WireError(403, `Propriété verrouillée : ${prop}`);
|
|
291
|
+
if (reactive.has(prop))
|
|
292
|
+
throw new WireError(403, `Propriété réactive, fixée par le composant parent : ${prop}`);
|
|
293
|
+
// Un segment comme `__proto__` écrirait dans le prototype partagé par tous les objets du serveur.
|
|
294
|
+
if (segments.some((segment) => segment === '__proto__' || segment === 'constructor' || segment === 'prototype'))
|
|
295
|
+
throw new WireError(403, `Chemin d'état refusé : ${path}`);
|
|
296
|
+
const anyC = component;
|
|
297
|
+
await anyC.updating?.(path, value);
|
|
298
|
+
await anyC[`updating${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
299
|
+
setByPath(component, segments, value);
|
|
300
|
+
await anyC.updated?.(path, value);
|
|
301
|
+
await anyC[`updated${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
302
|
+
};
|
|
303
|
+
for (const [path, value] of Object.entries(updates)) {
|
|
304
|
+
// Une référence de fichier temporaire renvoyée par le navigateur doit désigner un fichier réellement reçu.
|
|
305
|
+
if (!uploadReferencesAreValid(value, this.uploads))
|
|
306
|
+
throw new WireError(403, `Référence de fichier refusée : ${path}`);
|
|
307
|
+
await applyUpdate(path, value);
|
|
308
|
+
}
|
|
309
|
+
const allowed = callableMethods(component);
|
|
310
|
+
const returns = [];
|
|
311
|
+
for (const call of calls) {
|
|
312
|
+
if (typeof call?.method !== 'string' || !Array.isArray(call.params))
|
|
313
|
+
throw new WireError(404, 'Appel invalide.');
|
|
314
|
+
// Actions magiques : sans effet côté serveur, les valeurs voyagent dans `updates` (SupportMagicActions de Livewire).
|
|
315
|
+
if (MAGIC_ACTIONS.has(call.method)) {
|
|
316
|
+
returns.push(null);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (call.method === '__lazyLoad') {
|
|
320
|
+
if (!awaitsLazyLoad || lazyLoaded)
|
|
321
|
+
throw new WireError(404, 'Ce composant n\'attend pas de chargement différé.');
|
|
322
|
+
const params = this.decodeMountParams(name, call.params[0]);
|
|
323
|
+
this.assignMatchingProperties(component, params);
|
|
324
|
+
await component.mount(params);
|
|
325
|
+
lazyLoaded = true;
|
|
326
|
+
returns.push(null);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// Tant qu'il n'est pas chargé, un composant « lazy » n'a pas été monté : rien d'autre n'est appelable.
|
|
330
|
+
if (awaitsLazyLoad && !lazyLoaded)
|
|
331
|
+
throw new WireError(404, 'Composant non chargé.');
|
|
332
|
+
// --- téléversement de fichiers (port de WithFileUploads) : la cible est un chemin d'état, soumis aux mêmes gardes qu'une mise à jour
|
|
333
|
+
if (call.method === '_startUpload' || call.method === '_finishUpload' || call.method === '_uploadErrored' || call.method === '_removeUpload') {
|
|
334
|
+
const store = this.uploads;
|
|
335
|
+
const target = call.params[0];
|
|
336
|
+
if (!store || typeof target !== 'string' || target.length > 500)
|
|
337
|
+
throw new WireError(404, 'Téléversement indisponible.');
|
|
338
|
+
if (call.method === '_startUpload') {
|
|
339
|
+
component.dispatch('upload:generatedSignedUrl', { name: target, url: store.signedUrl(this.options.uploadPrefix ?? '/livewire') }, { self: true });
|
|
340
|
+
component._skipRender = true;
|
|
341
|
+
}
|
|
342
|
+
else if (call.method === '_finishUpload') {
|
|
343
|
+
const ids = Array.isArray(call.params[1]) ? call.params[1].map(String) : [];
|
|
344
|
+
if (!ids.length || ids.some((id) => !store.resolve(id)))
|
|
345
|
+
throw new WireError(404, 'Fichier temporaire introuvable.');
|
|
346
|
+
await applyUpdate(target, call.params[2] === true ? ids.map((id) => UploadStore.reference(id)) : UploadStore.reference(ids[0]));
|
|
347
|
+
component.dispatch('upload:finished', { name: target, tmpFilenames: ids }, { self: true });
|
|
348
|
+
}
|
|
349
|
+
else if (call.method === '_uploadErrored') {
|
|
350
|
+
component.dispatch('upload:errored', { name: target }, { self: true });
|
|
351
|
+
component.addError(target, 'The file failed to upload.');
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
component.dispatch('upload:removed', { name: target, tmpFilename: String(call.params[1] ?? '') }, { self: true });
|
|
355
|
+
component._skipRender = true;
|
|
356
|
+
}
|
|
357
|
+
returns.push(null);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (call.method === '__dispatch') {
|
|
361
|
+
const handler = cls.listeners?.[String(call.params[0])];
|
|
362
|
+
if (!handler)
|
|
363
|
+
throw new WireError(404, `Aucun écouteur pour l'événement : ${String(call.params[0])}`);
|
|
364
|
+
const args = call.params[1] && typeof call.params[1] === 'object' ? Object.values(call.params[1]) : [];
|
|
365
|
+
returns.push((await component[handler](...args)) ?? null);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!allowed.has(call.method)) {
|
|
369
|
+
// Aide au développement : la méthode existe mais n'est pas déclarée appelable.
|
|
370
|
+
if (process.env.NODE_ENV !== 'production' && typeof component[call.method] === 'function' && !isLifecycleHook(call.method))
|
|
371
|
+
console.warn(`[wire] ${name} : « ${call.method} » existe mais n'est pas dans static callable — appel refusé.`);
|
|
372
|
+
throw new WireError(404, `Méthode introuvable : ${call.method}`);
|
|
373
|
+
}
|
|
374
|
+
returns.push((await component[call.method](...call.params)) ?? null);
|
|
375
|
+
}
|
|
376
|
+
const effects = component._effects;
|
|
377
|
+
effects.returns = returns;
|
|
378
|
+
if (awaitsLazyLoad && !lazyLoaded)
|
|
379
|
+
component._skipRender = true;
|
|
380
|
+
const rendered = !component._skipRender && !effects.redirect;
|
|
381
|
+
if (rendered) {
|
|
382
|
+
effects.html = await this.renderRoot(component, name);
|
|
383
|
+
effects.partials ??= {};
|
|
384
|
+
}
|
|
385
|
+
await component.dehydrate();
|
|
386
|
+
const { id: _i, name: _n, errors: _e, ...memo } = snapshot.memo;
|
|
387
|
+
// Après un rendu complet, la liste des enfants est celle qui vient d'être rendue ; sinon elle est conservée.
|
|
388
|
+
if (rendered)
|
|
389
|
+
memo.children = Object.keys(component._children).length ? component._children : [];
|
|
390
|
+
if (lazyLoaded) {
|
|
391
|
+
memo.lazyLoaded = true;
|
|
392
|
+
delete memo.lazyIsolated;
|
|
393
|
+
}
|
|
394
|
+
return { snapshot: this.snapshot(component, name, memo), effects };
|
|
395
|
+
}
|
|
396
|
+
/** Point d'entrée HTTP : corps `{ _token, components: [{ snapshot, updates, calls }] }`. */
|
|
397
|
+
async handle(body, ctx = null) {
|
|
398
|
+
const list = body?.components;
|
|
399
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
400
|
+
throw new WireError(404, 'Charge utile invalide.');
|
|
401
|
+
const components = [];
|
|
402
|
+
// Les composants d'une requête sont traités dans l'ordre : un parent passe ses propriétés réactives aux enfants qui le suivent.
|
|
403
|
+
await this.requestScope.run({ childProps: new Map() }, async () => {
|
|
404
|
+
for (const c of list) {
|
|
405
|
+
if (typeof c?.snapshot !== 'string' || typeof c.updates !== 'object' || !Array.isArray(c.calls))
|
|
406
|
+
throw new WireError(404, 'Charge utile invalide.');
|
|
407
|
+
const { snapshot, effects } = await this.update(JSON.parse(c.snapshot), c.updates ?? {}, c.calls, ctx);
|
|
408
|
+
components.push({ snapshot: JSON.stringify(snapshot), effects });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
return { components, assets: [] };
|
|
412
|
+
}
|
|
413
|
+
}
|
package/lib/uploads.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
export type TemporaryFile = {
|
|
3
|
+
id: string;
|
|
4
|
+
path: string;
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
size: number;
|
|
8
|
+
extension: string;
|
|
9
|
+
};
|
|
10
|
+
export type UploadOptions = {
|
|
11
|
+
maxFileSize?: number;
|
|
12
|
+
maxFiles?: number;
|
|
13
|
+
ttl?: number;
|
|
14
|
+
urlTtl?: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class UploadStore {
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
private key;
|
|
19
|
+
private maxFileSize;
|
|
20
|
+
private maxFiles;
|
|
21
|
+
private ttl;
|
|
22
|
+
private urlTtl;
|
|
23
|
+
constructor(dir: string, key: string, options?: UploadOptions);
|
|
24
|
+
private sign;
|
|
25
|
+
/** Adresse d'envoi signée, valable quelques minutes. */
|
|
26
|
+
signedUrl(prefix?: string): string;
|
|
27
|
+
verifySignature(expires: unknown, signature: unknown): boolean;
|
|
28
|
+
/** Reçoit un envoi `multipart/form-data` (champ `files[]`) et rend les identifiants temporaires, dans l'ordre. */
|
|
29
|
+
receive(req: IncomingMessage): Promise<string[]>;
|
|
30
|
+
/** Fichier temporaire désigné par une valeur d'état (`livewire-file:<id>`) ou un identifiant, s'il existe encore. */
|
|
31
|
+
resolve(value: unknown): TemporaryFile | null;
|
|
32
|
+
static isTemporary(value: unknown): boolean;
|
|
33
|
+
static reference(id: string): string;
|
|
34
|
+
remove(id: string): void;
|
|
35
|
+
/** Supprime les fichiers temporaires plus vieux que la durée de conservation. */
|
|
36
|
+
private sweep;
|
|
37
|
+
}
|
package/lib/uploads.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Téléversements temporaires (équivalent de WithFileUploads / TemporaryUploadedFile de Livewire).
|
|
2
|
+
// Le navigateur demande une adresse signée (`_startUpload`), y envoie les fichiers, puis annonce leurs noms temporaires (`_finishUpload`) :
|
|
3
|
+
// la propriété visée reçoit alors `livewire-file:<identifiant>`. Le fichier ne quitte le dossier temporaire que si l'application l'enregistre.
|
|
4
|
+
import busboy from 'busboy';
|
|
5
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { open } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
const PREFIX = 'livewire-file:';
|
|
10
|
+
const ID = /^[a-f0-9]{32}$/;
|
|
11
|
+
/** Types reconnus à leur signature binaire : le type annoncé par le navigateur n'est jamais cru. */
|
|
12
|
+
const SIGNATURES = [
|
|
13
|
+
{ type: 'image/jpeg', extension: 'jpg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
14
|
+
{ type: 'image/png', extension: 'png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
15
|
+
{ type: 'image/gif', extension: 'gif', test: (b) => ['GIF87a', 'GIF89a'].includes(b.subarray(0, 6).toString('latin1')) },
|
|
16
|
+
{ type: 'image/webp', extension: 'webp', test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
|
|
17
|
+
{ type: 'application/pdf', extension: 'pdf', test: (b) => b.subarray(0, 5).toString('latin1') === '%PDF-' },
|
|
18
|
+
{ type: 'application/zip', extension: 'zip', test: (b) => b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04 },
|
|
19
|
+
];
|
|
20
|
+
/** Texte brut : uniquement pour ces extensions, et seulement si le début du fichier ne contient pas d'octet nul. */
|
|
21
|
+
const TEXT = { txt: 'text/plain', csv: 'text/csv', json: 'application/json', md: 'text/markdown' };
|
|
22
|
+
export class UploadStore {
|
|
23
|
+
dir;
|
|
24
|
+
key;
|
|
25
|
+
maxFileSize;
|
|
26
|
+
maxFiles;
|
|
27
|
+
ttl;
|
|
28
|
+
urlTtl;
|
|
29
|
+
constructor(dir, key, options = {}) {
|
|
30
|
+
this.dir = dir;
|
|
31
|
+
this.key = key;
|
|
32
|
+
this.maxFileSize = options.maxFileSize ?? 12 * 1024 * 1024; // 12 Mo, comme la règle par défaut de Livewire
|
|
33
|
+
this.maxFiles = options.maxFiles ?? 20;
|
|
34
|
+
this.ttl = options.ttl ?? 24 * 60 * 60 * 1000;
|
|
35
|
+
this.urlTtl = options.urlTtl ?? 5 * 60 * 1000;
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
sign(expires) { return createHmac('sha256', this.key).update(`upload:${expires}`).digest('hex'); }
|
|
39
|
+
/** Adresse d'envoi signée, valable quelques minutes. */
|
|
40
|
+
signedUrl(prefix = '/livewire') {
|
|
41
|
+
const expires = Date.now() + this.urlTtl;
|
|
42
|
+
return `${prefix}/upload-file?expires=${expires}&signature=${this.sign(expires)}`;
|
|
43
|
+
}
|
|
44
|
+
verifySignature(expires, signature) {
|
|
45
|
+
const at = Number(expires);
|
|
46
|
+
if (!Number.isFinite(at) || at < Date.now())
|
|
47
|
+
return false;
|
|
48
|
+
const expected = Buffer.from(this.sign(at)), given = Buffer.from(String(signature ?? ''));
|
|
49
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
50
|
+
}
|
|
51
|
+
/** Reçoit un envoi `multipart/form-data` (champ `files[]`) et rend les identifiants temporaires, dans l'ordre. */
|
|
52
|
+
receive(req) {
|
|
53
|
+
this.sweep();
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const ids = [];
|
|
56
|
+
const pending = [];
|
|
57
|
+
let failure = null;
|
|
58
|
+
const fail = (message) => { failure ??= Object.assign(new Error(message), { status: 422 }); };
|
|
59
|
+
let parser;
|
|
60
|
+
try {
|
|
61
|
+
parser = busboy({ headers: req.headers, limits: { fileSize: this.maxFileSize, files: this.maxFiles, fields: 5 } });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
reject(Object.assign(new Error('Envoi invalide.'), { status: 422 }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
parser.on('file', (_field, stream, info) => {
|
|
68
|
+
const id = randomBytes(16).toString('hex');
|
|
69
|
+
const target = path.join(this.dir, id);
|
|
70
|
+
ids.push(id);
|
|
71
|
+
const out = createWriteStream(target, { flags: 'wx' });
|
|
72
|
+
stream.on('limit', () => fail('Fichier trop volumineux.'));
|
|
73
|
+
pending.push(new Promise((done) => { out.on('close', done); out.on('error', () => { fail('Écriture impossible.'); done(); }); }).then(async () => {
|
|
74
|
+
if (failure)
|
|
75
|
+
return;
|
|
76
|
+
const detected = await detect(target, info.filename);
|
|
77
|
+
writeFileSync(`${target}.json`, JSON.stringify({ name: path.basename(String(info.filename ?? 'file')).slice(0, 255), type: detected.type, extension: detected.extension }));
|
|
78
|
+
}));
|
|
79
|
+
stream.pipe(out);
|
|
80
|
+
});
|
|
81
|
+
parser.on('filesLimit', () => fail('Trop de fichiers.'));
|
|
82
|
+
parser.on('error', () => fail('Envoi invalide.'));
|
|
83
|
+
parser.on('close', async () => {
|
|
84
|
+
await Promise.all(pending);
|
|
85
|
+
if (failure || !ids.length) {
|
|
86
|
+
for (const id of ids)
|
|
87
|
+
this.remove(id);
|
|
88
|
+
reject(failure ?? Object.assign(new Error('Aucun fichier.'), { status: 422 }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
resolve(ids);
|
|
92
|
+
});
|
|
93
|
+
req.pipe(parser);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Fichier temporaire désigné par une valeur d'état (`livewire-file:<id>`) ou un identifiant, s'il existe encore. */
|
|
97
|
+
resolve(value) {
|
|
98
|
+
const id = typeof value === 'string' ? (value.startsWith(PREFIX) ? value.slice(PREFIX.length) : value) : '';
|
|
99
|
+
if (!ID.test(id))
|
|
100
|
+
return null;
|
|
101
|
+
const file = path.join(this.dir, id);
|
|
102
|
+
if (!existsSync(file) || !existsSync(`${file}.json`))
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const meta = JSON.parse(readFileSync(`${file}.json`, 'utf8'));
|
|
106
|
+
return { id, path: file, name: String(meta.name), type: String(meta.type), extension: String(meta.extension), size: statSync(file).size };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
static isTemporary(value) { return typeof value === 'string' && value.startsWith(PREFIX) && ID.test(value.slice(PREFIX.length)); }
|
|
113
|
+
static reference(id) { return PREFIX + id; }
|
|
114
|
+
remove(id) {
|
|
115
|
+
if (!ID.test(id))
|
|
116
|
+
return;
|
|
117
|
+
for (const suffix of ['', '.json'])
|
|
118
|
+
rmSync(path.join(this.dir, id + suffix), { force: true });
|
|
119
|
+
}
|
|
120
|
+
/** Supprime les fichiers temporaires plus vieux que la durée de conservation. */
|
|
121
|
+
sweep() {
|
|
122
|
+
const limit = Date.now() - this.ttl;
|
|
123
|
+
try {
|
|
124
|
+
for (const name of readdirSync(this.dir)) {
|
|
125
|
+
const file = path.join(this.dir, name);
|
|
126
|
+
if (statSync(file).mtimeMs < limit)
|
|
127
|
+
rmSync(file, { force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { /* dossier inaccessible : rien à nettoyer */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function detect(file, filename) {
|
|
134
|
+
const handle = await open(file, 'r');
|
|
135
|
+
try {
|
|
136
|
+
const { buffer, bytesRead } = await handle.read(Buffer.alloc(512), 0, 512, 0);
|
|
137
|
+
const head = buffer.subarray(0, bytesRead);
|
|
138
|
+
const known = SIGNATURES.find((signature) => signature.test(head));
|
|
139
|
+
if (known)
|
|
140
|
+
return { type: known.type, extension: known.extension };
|
|
141
|
+
const extension = path.extname(String(filename ?? '')).slice(1).toLowerCase();
|
|
142
|
+
if (TEXT[extension] && !head.includes(0))
|
|
143
|
+
return { type: TEXT[extension], extension };
|
|
144
|
+
return { type: 'application/octet-stream', extension: 'bin' };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await handle.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/wire",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Server-side runtime compatible with Livewire 3 for Node.js and Express.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./express": {
|
|
13
|
+
"types": "./lib/express.d.ts",
|
|
14
|
+
"default": "./lib/express.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"types": "./lib/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"vendor",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md",
|
|
23
|
+
"THIRD-PARTY-NOTICES.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/NavetteJS/navette.git",
|
|
31
|
+
"directory": "packages/wire"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/NavetteJS/navette#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/NavetteJS/navette/issues"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"navette",
|
|
39
|
+
"filament",
|
|
40
|
+
"admin",
|
|
41
|
+
"panel",
|
|
42
|
+
"livewire",
|
|
43
|
+
"express",
|
|
44
|
+
"node"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"busboy": "^1.6.0",
|
|
48
|
+
"express": "^5.1.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public",
|
|
52
|
+
"tag": "beta"
|
|
53
|
+
}
|
|
54
|
+
}
|