@navette-js/wire 0.0.0-stage → 1.0.0-beta.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/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 +154 -0
- package/lib/index.js +417 -0
- package/lib/uploads.d.ts +37 -0
- package/lib/uploads.js +148 -0
- package/package.json +51 -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,417 @@
|
|
|
1
|
+
// @navette-js/wire — server runtime for stateful components, speaking the Livewire client protocol (livewire.js 4.4.8).
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { UploadStore } from "./uploads.js";
|
|
5
|
+
export { UploadStore } from "./uploads.js";
|
|
6
|
+
export class WireError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
9
|
+
}
|
|
10
|
+
/** Base class of a component. Every own enumerable property not starting with `_` is public state. */
|
|
11
|
+
export class Component {
|
|
12
|
+
_id = '';
|
|
13
|
+
_effects = {};
|
|
14
|
+
_skipRender = false;
|
|
15
|
+
_errors = {};
|
|
16
|
+
/** Context of the current HTTP request (request, response, panel…), supplied by the integration. */
|
|
17
|
+
_ctx = null;
|
|
18
|
+
/** Runtime and originating request, used to mount child components while rendering. */
|
|
19
|
+
_runtime = null;
|
|
20
|
+
_request = {};
|
|
21
|
+
/** Children rendered during this request, and children already in the browser: key → [tag, id]. */
|
|
22
|
+
_children = {};
|
|
23
|
+
_previousChildren = {};
|
|
24
|
+
/** HTML shown until a lazy component is loaded; must have a single root. */
|
|
25
|
+
placeholder() { return '<div></div>'; }
|
|
26
|
+
/**
|
|
27
|
+
* Mounts a child component and returns its HTML, to insert into the parent's render.
|
|
28
|
+
* If the browser already has this child (same key), only a stub is rendered and the client keeps the child as is.
|
|
29
|
+
* `lazy`: the child shows its `placeholder()` and loads once it becomes visible.
|
|
30
|
+
*/
|
|
31
|
+
async mountChild(name, params = {}, key, options = {}) {
|
|
32
|
+
if (!this._runtime)
|
|
33
|
+
throw new Error('mountChild(): component is not attached to a runtime.');
|
|
34
|
+
const previous = this._previousChildren[key];
|
|
35
|
+
if (previous) {
|
|
36
|
+
this._children[key] = previous;
|
|
37
|
+
// Reactive props: the child, updated later in the same request, picks these values up.
|
|
38
|
+
this._runtime.passPropsToChild(previous[1], params);
|
|
39
|
+
return `<${previous[0]} wire:id="${previous[1]}" wire:name="${escapeAttr(name)}" wire:key="${escapeAttr(key)}"></${previous[0]}>`;
|
|
40
|
+
}
|
|
41
|
+
const { html, component } = await this._runtime.mountWithInstance(name, params, { ...this._request, ctx: this._ctx, lazy: options.lazy, key });
|
|
42
|
+
this._children[key] = [/^\s*<([a-zA-Z0-9-]+)/.exec(html)[1], component.id];
|
|
43
|
+
return html;
|
|
44
|
+
}
|
|
45
|
+
mount(_params) { }
|
|
46
|
+
boot() { }
|
|
47
|
+
hydrate() { }
|
|
48
|
+
dehydrate() { }
|
|
49
|
+
get id() { return this._id; }
|
|
50
|
+
/** Dispatches a browser event; with `self`, only this component receives it. */
|
|
51
|
+
dispatch(name, params = {}, options = {}) {
|
|
52
|
+
(this._effects.dispatches ??= []).push({ name, params: params, ...(options.self ? { self: true } : {}) });
|
|
53
|
+
}
|
|
54
|
+
redirect(url, navigate = false) {
|
|
55
|
+
this._effects.redirect = url;
|
|
56
|
+
if (navigate)
|
|
57
|
+
this._effects.redirectUsingNavigate = true;
|
|
58
|
+
}
|
|
59
|
+
skipRender() { this._skipRender = true; }
|
|
60
|
+
/** Partial render: only the `wire:partial="<name>"` fragment is sent back (effects.partials), the component is not re-rendered. */
|
|
61
|
+
renderPartial(name, html) { (this._effects.partials ??= {})[name] = html; this._skipRender = true; }
|
|
62
|
+
addError(path, message) { (this._errors[path] ??= []).push(message); }
|
|
63
|
+
resetErrorBag() { this._errors = {}; }
|
|
64
|
+
}
|
|
65
|
+
const LIFECYCLE = new Set(['constructor', 'render', 'mount', 'boot', 'hydrate', 'dehydrate', 'dispatch', 'redirect', 'skipRender', 'renderPartial', 'addError', 'resetErrorBag', 'mountChild', 'placeholder']);
|
|
66
|
+
// Only property hooks (updatingFoo / updatedFoo) are reserved by prefix: `mountAction`, for example, is an ordinary public method.
|
|
67
|
+
const isLifecycleHook = (m) => LIFECYCLE.has(m) || /^(updating|updated)[A-Z]/.test(m) || m.startsWith('_');
|
|
68
|
+
/**
|
|
69
|
+
* Methods callable from the browser: only those the class (or a parent class) lists in `static callable`.
|
|
70
|
+
* TypeScript erases `protected` and `private` at runtime: without this allowlist, any internal method could be called by a
|
|
71
|
+
* forged request and its return value sent back to the browser.
|
|
72
|
+
*/
|
|
73
|
+
export function callableMethods(component) {
|
|
74
|
+
const out = new Set();
|
|
75
|
+
for (let cls = component.constructor; cls && cls !== Component; cls = Object.getPrototypeOf(cls)) {
|
|
76
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'callable') || !Array.isArray(cls.callable))
|
|
77
|
+
continue;
|
|
78
|
+
for (const name of cls.callable)
|
|
79
|
+
if (typeof name === 'string' && !isLifecycleHook(name) && typeof component[name] === 'function')
|
|
80
|
+
out.add(name);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
export function publicProperties(component) {
|
|
85
|
+
return Object.keys(component).filter((k) => !k.startsWith('_'));
|
|
86
|
+
}
|
|
87
|
+
// Wire format: arrays and objects travel as [value, { s: 'arr' }] tuples.
|
|
88
|
+
export function dehydrateValue(value) {
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
if (value === null || typeof value !== 'object')
|
|
92
|
+
return value;
|
|
93
|
+
if (Array.isArray(value))
|
|
94
|
+
return [value.map(dehydrateValue), { s: 'arr' }];
|
|
95
|
+
return [Object.fromEntries(Object.entries(value).map(([k, v]) => [k, dehydrateValue(v)])), { s: 'arr' }];
|
|
96
|
+
}
|
|
97
|
+
export function hydrateValue(value) {
|
|
98
|
+
if (Array.isArray(value) && value.length === 2 && value[1] && typeof value[1] === 'object' && 's' in value[1]) {
|
|
99
|
+
const inner = value[0];
|
|
100
|
+
if (Array.isArray(inner))
|
|
101
|
+
return inner.map(hydrateValue);
|
|
102
|
+
return Object.fromEntries(Object.entries(inner).map(([k, v]) => [k, hydrateValue(v)]));
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''');
|
|
107
|
+
/** Adds attributes to the root element of an HTML fragment. */
|
|
108
|
+
export function insertAttributesIntoHtmlRoot(html, attributes) {
|
|
109
|
+
const m = /^\s*<([a-zA-Z0-9-]+)/.exec(html);
|
|
110
|
+
if (!m)
|
|
111
|
+
throw new WireError(500, 'A component render must start with a root element.');
|
|
112
|
+
const attrs = Object.entries(attributes)
|
|
113
|
+
.map(([k, v]) => `${k}="${escapeAttr(typeof v === 'string' ? v : JSON.stringify(v))}"`)
|
|
114
|
+
.join(' ');
|
|
115
|
+
const at = m.index + m[0].length;
|
|
116
|
+
return html.slice(0, at) + ' ' + attrs + html.slice(at);
|
|
117
|
+
}
|
|
118
|
+
function setByPath(target, segments, value) {
|
|
119
|
+
let node = target;
|
|
120
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
121
|
+
const key = segments[i];
|
|
122
|
+
if (node[key] === null || typeof node[key] !== 'object')
|
|
123
|
+
node[key] = {};
|
|
124
|
+
node = node[key];
|
|
125
|
+
}
|
|
126
|
+
const last = segments[segments.length - 1];
|
|
127
|
+
if (value === '__rm__') {
|
|
128
|
+
Array.isArray(node) ? node.splice(Number(last), 1) : delete node[last];
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
node[last] = value;
|
|
132
|
+
}
|
|
133
|
+
/** 20-character alphanumeric id (used in HTML ids and CSS selectors, so no symbols). */
|
|
134
|
+
const randomId = () => Array.from(randomBytes(20), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join('');
|
|
135
|
+
const MAGIC_ACTIONS = new Set(['$refresh', '$set', '$sync', '$commit']);
|
|
136
|
+
const studly = (s) => s.replace(/(^|[_.-])(\w)/g, (_, __, c) => c.toUpperCase());
|
|
137
|
+
/** False if the value contains, at any depth, a temporary file reference that does not point to a received file. */
|
|
138
|
+
function uploadReferencesAreValid(value, store, depth = 0) {
|
|
139
|
+
if (typeof value === 'string')
|
|
140
|
+
return value.startsWith('livewire-files:') ? false : value.startsWith('livewire-file:') ? !!store?.resolve(value) : true;
|
|
141
|
+
if (!value || typeof value !== 'object')
|
|
142
|
+
return true;
|
|
143
|
+
if (depth > 8)
|
|
144
|
+
return false;
|
|
145
|
+
return Object.values(value).every((v) => uploadReferencesAreValid(v, store, depth + 1));
|
|
146
|
+
}
|
|
147
|
+
/** Reactive props of a component (`static reactive`): set by the parent, never by the browser. */
|
|
148
|
+
const reactiveProps = (component) => component.constructor.reactive ?? [];
|
|
149
|
+
export class WireRuntime {
|
|
150
|
+
registry = new Map();
|
|
151
|
+
options;
|
|
152
|
+
constructor(options) { this.options = options; }
|
|
153
|
+
get uploads() { return this.options.uploads ?? null; }
|
|
154
|
+
/** Params passed by a parent to its existing children during the current request: child id → params. */
|
|
155
|
+
requestScope = new AsyncLocalStorage();
|
|
156
|
+
passPropsToChild(childId, params) { this.requestScope.getStore()?.childProps.set(childId, params); }
|
|
157
|
+
names = new Map();
|
|
158
|
+
register(name, cls) { this.registry.set(name, cls); if (!this.names.has(cls))
|
|
159
|
+
this.names.set(cls, name); cls.componentName = name; return this; }
|
|
160
|
+
/** Name under which this runtime registered a class. A class shared by several runtimes (one per panel) keeps one name per runtime. */
|
|
161
|
+
nameOf(cls) { return this.names.get(cls) ?? cls.componentName ?? ''; }
|
|
162
|
+
make(name) {
|
|
163
|
+
const cls = this.registry.get(name);
|
|
164
|
+
if (!cls)
|
|
165
|
+
throw new WireError(404, `Unknown component: ${name}`);
|
|
166
|
+
return new cls();
|
|
167
|
+
}
|
|
168
|
+
checksum(snapshot) {
|
|
169
|
+
const { children: _children, ...memo } = snapshot.memo; // the client may rewrite `children`, so it is not signed
|
|
170
|
+
return createHmac('sha256', this.options.key).update(JSON.stringify({ data: snapshot.data, memo })).digest('hex');
|
|
171
|
+
}
|
|
172
|
+
snapshot(component, name, memo) {
|
|
173
|
+
const data = {};
|
|
174
|
+
for (const k of publicProperties(component))
|
|
175
|
+
data[k] = dehydrateValue(component[k]);
|
|
176
|
+
const body = { data, memo: { id: component.id, name, ...memo, errors: component._errors } };
|
|
177
|
+
return { ...body, checksum: this.checksum(body) };
|
|
178
|
+
}
|
|
179
|
+
/** Signed mount params of a lazy component, in a `__mountParamsContainer`: the browser sends them back unchanged to `__lazyLoad`. */
|
|
180
|
+
encodeMountParams(name, params, request) {
|
|
181
|
+
const body = { data: { forMount: dehydrateValue(params), forComponent: name }, memo: { id: randomId(), name: '__mountParamsContainer', path: request.path ?? '/', method: request.method ?? 'GET', release: 'a-a-a' } };
|
|
182
|
+
return Buffer.from(JSON.stringify({ ...body, checksum: this.checksum(body) })).toString('base64');
|
|
183
|
+
}
|
|
184
|
+
decodeMountParams(name, encoded) {
|
|
185
|
+
let container;
|
|
186
|
+
try {
|
|
187
|
+
container = JSON.parse(Buffer.from(String(encoded), 'base64').toString('utf8'));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new WireError(404, 'Unreadable mount params.');
|
|
191
|
+
}
|
|
192
|
+
if (!container || typeof container.data !== 'object' || typeof container.memo !== 'object' || typeof container.checksum !== 'string')
|
|
193
|
+
throw new WireError(404, 'Invalid mount params.');
|
|
194
|
+
const expected = Buffer.from(this.checksum(container)), given = Buffer.from(container.checksum);
|
|
195
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
196
|
+
throw new WireError(419, 'Invalid mount params checksum.');
|
|
197
|
+
// Params signed for one component are not valid for another.
|
|
198
|
+
if (container.memo.name !== '__mountParamsContainer' || container.data.forComponent !== name)
|
|
199
|
+
throw new WireError(403, 'Mount params were issued for another component.');
|
|
200
|
+
return (hydrateValue(container.data.forMount) ?? {});
|
|
201
|
+
}
|
|
202
|
+
/** A mount param named like a public property is copied into it. */
|
|
203
|
+
assignMatchingProperties(component, params) {
|
|
204
|
+
const known = new Set(publicProperties(component));
|
|
205
|
+
for (const [k, v] of Object.entries(params))
|
|
206
|
+
if (known.has(k))
|
|
207
|
+
component[k] = v;
|
|
208
|
+
}
|
|
209
|
+
async renderRoot(component, name) {
|
|
210
|
+
const html = await component.render();
|
|
211
|
+
return insertAttributesIntoHtmlRoot(html, { 'wire:id': component.id, 'wire:name': name });
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Initial render: HTML with wire:snapshot and wire:effects on the root.
|
|
215
|
+
* The memo only carries `lazyLoaded` / `lazyIsolated` for lazy components: their mere presence makes the client ignore events.
|
|
216
|
+
*/
|
|
217
|
+
async mount(name, params = {}, request = {}) {
|
|
218
|
+
return (await this.mountWithInstance(name, params, request)).html;
|
|
219
|
+
}
|
|
220
|
+
/** Like mount(), but also returns the mounted instance (to read its title, for example). */
|
|
221
|
+
async mountWithInstance(name, params = {}, request = {}) {
|
|
222
|
+
const component = this.make(name);
|
|
223
|
+
component._ctx = request.ctx ?? null;
|
|
224
|
+
component._id = randomId();
|
|
225
|
+
component._runtime = this;
|
|
226
|
+
component._request = { path: request.path, method: request.method };
|
|
227
|
+
await component.boot();
|
|
228
|
+
this.assignMatchingProperties(component, params);
|
|
229
|
+
let html;
|
|
230
|
+
if (request.lazy) {
|
|
231
|
+
// Lazy component: `mount()` and rendering wait for the browser's `__lazyLoad` call.
|
|
232
|
+
const placeholder = insertAttributesIntoHtmlRoot(await component.placeholder(), { 'x-intersect': `$wire.__lazyLoad('${this.encodeMountParams(name, params, request)}')` });
|
|
233
|
+
html = insertAttributesIntoHtmlRoot(placeholder, { 'wire:id': component.id, 'wire:name': name });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
await component.mount(params);
|
|
237
|
+
html = await this.renderRoot(component, name);
|
|
238
|
+
}
|
|
239
|
+
await component.dehydrate();
|
|
240
|
+
const snapshot = this.snapshot(component, name, {
|
|
241
|
+
path: request.path ?? '/', method: request.method ?? 'GET', release: 'a-a-a', children: Object.keys(component._children).length ? component._children : [], scripts: [], assets: [],
|
|
242
|
+
...(request.lazy ? { lazyLoaded: false, lazyIsolated: true } : {}), ...(reactiveProps(component).length ? { props: reactiveProps(component) } : {}), locale: this.options.locale ?? 'en', islands: [],
|
|
243
|
+
});
|
|
244
|
+
const { html: _h, ...effects } = component._effects;
|
|
245
|
+
// Event listeners: the client calls `__dispatch(event, params)` when one fires.
|
|
246
|
+
const listeners = Object.keys(component.constructor.listeners ?? {});
|
|
247
|
+
if (listeners.length)
|
|
248
|
+
effects.listeners = listeners;
|
|
249
|
+
return { html: insertAttributesIntoHtmlRoot(html, { ...(request.key !== undefined ? { 'wire:key': request.key } : {}), 'wire:snapshot': JSON.stringify(snapshot), 'wire:effects': JSON.stringify(effects) }), component };
|
|
250
|
+
}
|
|
251
|
+
/** Component update request: hydrate, apply updates, run calls, render, dehydrate. */
|
|
252
|
+
async update(snapshot, updates, calls, ctx = null) {
|
|
253
|
+
if (!snapshot || typeof snapshot.data !== 'object' || typeof snapshot.memo !== 'object' || typeof snapshot.checksum !== 'string')
|
|
254
|
+
throw new WireError(404, 'Invalid snapshot.');
|
|
255
|
+
const expected = Buffer.from(this.checksum(snapshot)), given = Buffer.from(snapshot.checksum);
|
|
256
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
257
|
+
throw new WireError(419, 'Invalid snapshot checksum.');
|
|
258
|
+
const name = snapshot.memo.name;
|
|
259
|
+
const component = this.make(name);
|
|
260
|
+
const cls = component.constructor;
|
|
261
|
+
component._id = snapshot.memo.id;
|
|
262
|
+
component._ctx = ctx;
|
|
263
|
+
component._errors = { ...(snapshot.memo.errors ?? {}) };
|
|
264
|
+
component._runtime = this;
|
|
265
|
+
component._request = { path: snapshot.memo.path, method: snapshot.memo.method };
|
|
266
|
+
// `memo.children` is not covered by the checksum (the client maintains it): only well-formed entries are kept.
|
|
267
|
+
if (snapshot.memo.children && typeof snapshot.memo.children === 'object' && !Array.isArray(snapshot.memo.children)) {
|
|
268
|
+
for (const [key, value] of Object.entries(snapshot.memo.children)) {
|
|
269
|
+
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])))
|
|
270
|
+
component._previousChildren[key] = [String(value[0]), String(value[1])];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const awaitsLazyLoad = snapshot.memo.lazyLoaded === false;
|
|
274
|
+
let lazyLoaded = false;
|
|
275
|
+
const known = new Set(publicProperties(component));
|
|
276
|
+
for (const [k, v] of Object.entries(snapshot.data))
|
|
277
|
+
if (known.has(k))
|
|
278
|
+
component[k] = hydrateValue(v);
|
|
279
|
+
// Reactive props only come from the parent, rendered earlier in the same request.
|
|
280
|
+
const reactive = new Set(reactiveProps(component));
|
|
281
|
+
const passed = this.requestScope.getStore()?.childProps.get(component.id);
|
|
282
|
+
if (passed)
|
|
283
|
+
for (const prop of reactive)
|
|
284
|
+
if (Object.prototype.hasOwnProperty.call(passed, prop) && known.has(prop))
|
|
285
|
+
component[prop] = passed[prop];
|
|
286
|
+
await component.boot();
|
|
287
|
+
await component.hydrate();
|
|
288
|
+
const applyUpdate = async (path, value) => {
|
|
289
|
+
const segments = path.split('.');
|
|
290
|
+
const prop = segments[0];
|
|
291
|
+
if (!known.has(prop))
|
|
292
|
+
throw new WireError(404, `Public property not found: ${prop}`);
|
|
293
|
+
if (cls.locked?.includes(prop))
|
|
294
|
+
throw new WireError(403, `Locked property: ${prop}`);
|
|
295
|
+
if (reactive.has(prop))
|
|
296
|
+
throw new WireError(403, `Reactive property, set by the parent component: ${prop}`);
|
|
297
|
+
// A segment such as `__proto__` would write into the prototype shared by every object on the server.
|
|
298
|
+
if (segments.some((segment) => segment === '__proto__' || segment === 'constructor' || segment === 'prototype'))
|
|
299
|
+
throw new WireError(403, `State path rejected: ${path}`);
|
|
300
|
+
const anyC = component;
|
|
301
|
+
await anyC.updating?.(path, value);
|
|
302
|
+
await anyC[`updating${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
303
|
+
setByPath(component, segments, value);
|
|
304
|
+
await anyC.updated?.(path, value);
|
|
305
|
+
await anyC[`updated${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
306
|
+
};
|
|
307
|
+
for (const [path, value] of Object.entries(updates)) {
|
|
308
|
+
// A temporary file reference sent by the browser must point to a file that was actually received.
|
|
309
|
+
if (!uploadReferencesAreValid(value, this.uploads))
|
|
310
|
+
throw new WireError(403, `File reference rejected: ${path}`);
|
|
311
|
+
await applyUpdate(path, value);
|
|
312
|
+
}
|
|
313
|
+
const allowed = callableMethods(component);
|
|
314
|
+
const returns = [];
|
|
315
|
+
for (const call of calls) {
|
|
316
|
+
if (typeof call?.method !== 'string' || !Array.isArray(call.params))
|
|
317
|
+
throw new WireError(404, 'Invalid call.');
|
|
318
|
+
// Magic actions are no-ops on the server: their values travel in `updates`.
|
|
319
|
+
if (MAGIC_ACTIONS.has(call.method)) {
|
|
320
|
+
returns.push(null);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (call.method === '__lazyLoad') {
|
|
324
|
+
if (!awaitsLazyLoad || lazyLoaded)
|
|
325
|
+
throw new WireError(404, 'This component is not waiting for a lazy load.');
|
|
326
|
+
const params = this.decodeMountParams(name, call.params[0]);
|
|
327
|
+
this.assignMatchingProperties(component, params);
|
|
328
|
+
await component.mount(params);
|
|
329
|
+
lazyLoaded = true;
|
|
330
|
+
returns.push(null);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Until it is loaded, a lazy component has not been mounted: nothing else is callable.
|
|
334
|
+
if (awaitsLazyLoad && !lazyLoaded)
|
|
335
|
+
throw new WireError(404, 'Component not loaded yet.');
|
|
336
|
+
// File uploads: the target is a state path, subject to the same guards as an update.
|
|
337
|
+
if (call.method === '_startUpload' || call.method === '_finishUpload' || call.method === '_uploadErrored' || call.method === '_removeUpload') {
|
|
338
|
+
const store = this.uploads;
|
|
339
|
+
const target = call.params[0];
|
|
340
|
+
if (!store || typeof target !== 'string' || target.length > 500)
|
|
341
|
+
throw new WireError(404, 'Uploads are not available.');
|
|
342
|
+
if (call.method === '_startUpload') {
|
|
343
|
+
component.dispatch('upload:generatedSignedUrl', { name: target, url: store.signedUrl(this.options.uploadPrefix ?? '/livewire') }, { self: true });
|
|
344
|
+
component._skipRender = true;
|
|
345
|
+
}
|
|
346
|
+
else if (call.method === '_finishUpload') {
|
|
347
|
+
const ids = Array.isArray(call.params[1]) ? call.params[1].map(String) : [];
|
|
348
|
+
if (!ids.length || ids.some((id) => !store.resolve(id)))
|
|
349
|
+
throw new WireError(404, 'Temporary file not found.');
|
|
350
|
+
await applyUpdate(target, call.params[2] === true ? ids.map((id) => UploadStore.reference(id)) : UploadStore.reference(ids[0]));
|
|
351
|
+
component.dispatch('upload:finished', { name: target, tmpFilenames: ids }, { self: true });
|
|
352
|
+
}
|
|
353
|
+
else if (call.method === '_uploadErrored') {
|
|
354
|
+
component.dispatch('upload:errored', { name: target }, { self: true });
|
|
355
|
+
component.addError(target, 'The file failed to upload.');
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
component.dispatch('upload:removed', { name: target, tmpFilename: String(call.params[1] ?? '') }, { self: true });
|
|
359
|
+
component._skipRender = true;
|
|
360
|
+
}
|
|
361
|
+
returns.push(null);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (call.method === '__dispatch') {
|
|
365
|
+
const handler = cls.listeners?.[String(call.params[0])];
|
|
366
|
+
if (!handler)
|
|
367
|
+
throw new WireError(404, `No listener for event: ${String(call.params[0])}`);
|
|
368
|
+
const args = call.params[1] && typeof call.params[1] === 'object' ? Object.values(call.params[1]) : [];
|
|
369
|
+
returns.push((await component[handler](...args)) ?? null);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (!allowed.has(call.method)) {
|
|
373
|
+
// Development hint: the method exists but is not declared callable.
|
|
374
|
+
if (process.env.NODE_ENV !== 'production' && typeof component[call.method] === 'function' && !isLifecycleHook(call.method))
|
|
375
|
+
console.warn(`[wire] ${name}: "${call.method}" exists but is not listed in static callable; call rejected.`);
|
|
376
|
+
throw new WireError(404, `Method not found: ${call.method}`);
|
|
377
|
+
}
|
|
378
|
+
returns.push((await component[call.method](...call.params)) ?? null);
|
|
379
|
+
}
|
|
380
|
+
const effects = component._effects;
|
|
381
|
+
effects.returns = returns;
|
|
382
|
+
if (awaitsLazyLoad && !lazyLoaded)
|
|
383
|
+
component._skipRender = true;
|
|
384
|
+
const rendered = !component._skipRender && !effects.redirect;
|
|
385
|
+
if (rendered) {
|
|
386
|
+
effects.html = await this.renderRoot(component, name);
|
|
387
|
+
effects.partials ??= {};
|
|
388
|
+
}
|
|
389
|
+
await component.dehydrate();
|
|
390
|
+
const { id: _i, name: _n, errors: _e, ...memo } = snapshot.memo;
|
|
391
|
+
// After a full render, the children list is the one just rendered; otherwise it is kept.
|
|
392
|
+
if (rendered)
|
|
393
|
+
memo.children = Object.keys(component._children).length ? component._children : [];
|
|
394
|
+
if (lazyLoaded) {
|
|
395
|
+
memo.lazyLoaded = true;
|
|
396
|
+
delete memo.lazyIsolated;
|
|
397
|
+
}
|
|
398
|
+
return { snapshot: this.snapshot(component, name, memo), effects };
|
|
399
|
+
}
|
|
400
|
+
/** HTTP entry point; body: `{ _token, components: [{ snapshot, updates, calls }] }`. */
|
|
401
|
+
async handle(body, ctx = null) {
|
|
402
|
+
const list = body?.components;
|
|
403
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
404
|
+
throw new WireError(404, 'Invalid payload.');
|
|
405
|
+
const components = [];
|
|
406
|
+
// Components are processed in order so a parent can pass its reactive props to the children that follow it.
|
|
407
|
+
await this.requestScope.run({ childProps: new Map() }, async () => {
|
|
408
|
+
for (const c of list) {
|
|
409
|
+
if (typeof c?.snapshot !== 'string' || typeof c.updates !== 'object' || !Array.isArray(c.calls))
|
|
410
|
+
throw new WireError(404, 'Invalid payload.');
|
|
411
|
+
const { snapshot, effects } = await this.update(JSON.parse(c.snapshot), c.updates ?? {}, c.calls, ctx);
|
|
412
|
+
components.push({ snapshot: JSON.stringify(snapshot), effects });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
return { components, assets: [] };
|
|
416
|
+
}
|
|
417
|
+
}
|
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
|
+
/** Signed upload URL, valid for a few minutes (`urlTtl`). */
|
|
26
|
+
signedUrl(prefix?: string): string;
|
|
27
|
+
verifySignature(expires: unknown, signature: unknown): boolean;
|
|
28
|
+
/** Receives a `multipart/form-data` upload (`files[]` field) and returns the temporary ids, in order. */
|
|
29
|
+
receive(req: IncomingMessage): Promise<string[]>;
|
|
30
|
+
/** Temporary file referenced by a state value (`livewire-file:<id>`) or a bare id, if it still exists. */
|
|
31
|
+
resolve(value: unknown): TemporaryFile | null;
|
|
32
|
+
static isTemporary(value: unknown): boolean;
|
|
33
|
+
static reference(id: string): string;
|
|
34
|
+
remove(id: string): void;
|
|
35
|
+
/** Deletes temporary files older than the retention time (`ttl`). */
|
|
36
|
+
private sweep;
|
|
37
|
+
}
|
package/lib/uploads.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Temporary uploads. The browser asks for a signed URL (`_startUpload`), posts the files to it, then reports their temporary ids
|
|
2
|
+
// (`_finishUpload`): the target property then holds `livewire-file:<id>`. A file only leaves the temporary directory if the app stores it.
|
|
3
|
+
import busboy from 'busboy';
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { open } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const PREFIX = 'livewire-file:';
|
|
9
|
+
const ID = /^[a-f0-9]{32}$/;
|
|
10
|
+
/** Types detected from their magic bytes: the type announced by the browser is never trusted. */
|
|
11
|
+
const SIGNATURES = [
|
|
12
|
+
{ type: 'image/jpeg', extension: 'jpg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
13
|
+
{ type: 'image/png', extension: 'png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
14
|
+
{ type: 'image/gif', extension: 'gif', test: (b) => ['GIF87a', 'GIF89a'].includes(b.subarray(0, 6).toString('latin1')) },
|
|
15
|
+
{ type: 'image/webp', extension: 'webp', test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
|
|
16
|
+
{ type: 'application/pdf', extension: 'pdf', test: (b) => b.subarray(0, 5).toString('latin1') === '%PDF-' },
|
|
17
|
+
{ type: 'application/zip', extension: 'zip', test: (b) => b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04 },
|
|
18
|
+
];
|
|
19
|
+
/** Plain text: only for these extensions, and only if the start of the file contains no null byte. */
|
|
20
|
+
const TEXT = { txt: 'text/plain', csv: 'text/csv', json: 'application/json', md: 'text/markdown' };
|
|
21
|
+
export class UploadStore {
|
|
22
|
+
dir;
|
|
23
|
+
key;
|
|
24
|
+
maxFileSize;
|
|
25
|
+
maxFiles;
|
|
26
|
+
ttl;
|
|
27
|
+
urlTtl;
|
|
28
|
+
constructor(dir, key, options = {}) {
|
|
29
|
+
this.dir = dir;
|
|
30
|
+
this.key = key;
|
|
31
|
+
this.maxFileSize = options.maxFileSize ?? 12 * 1024 * 1024; // 12 MB
|
|
32
|
+
this.maxFiles = options.maxFiles ?? 20;
|
|
33
|
+
this.ttl = options.ttl ?? 24 * 60 * 60 * 1000;
|
|
34
|
+
this.urlTtl = options.urlTtl ?? 5 * 60 * 1000;
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
sign(expires) { return createHmac('sha256', this.key).update(`upload:${expires}`).digest('hex'); }
|
|
38
|
+
/** Signed upload URL, valid for a few minutes (`urlTtl`). */
|
|
39
|
+
signedUrl(prefix = '/livewire') {
|
|
40
|
+
const expires = Date.now() + this.urlTtl;
|
|
41
|
+
return `${prefix}/upload-file?expires=${expires}&signature=${this.sign(expires)}`;
|
|
42
|
+
}
|
|
43
|
+
verifySignature(expires, signature) {
|
|
44
|
+
const at = Number(expires);
|
|
45
|
+
if (!Number.isFinite(at) || at < Date.now())
|
|
46
|
+
return false;
|
|
47
|
+
const expected = Buffer.from(this.sign(at)), given = Buffer.from(String(signature ?? ''));
|
|
48
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
49
|
+
}
|
|
50
|
+
/** Receives a `multipart/form-data` upload (`files[]` field) and returns the temporary ids, in order. */
|
|
51
|
+
receive(req) {
|
|
52
|
+
this.sweep();
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const ids = [];
|
|
55
|
+
const pending = [];
|
|
56
|
+
let failure = null;
|
|
57
|
+
const fail = (message) => { failure ??= Object.assign(new Error(message), { status: 422 }); };
|
|
58
|
+
let parser;
|
|
59
|
+
try {
|
|
60
|
+
parser = busboy({ headers: req.headers, limits: { fileSize: this.maxFileSize, files: this.maxFiles, fields: 5 } });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
reject(Object.assign(new Error('Invalid upload.'), { status: 422 }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
parser.on('file', (_field, stream, info) => {
|
|
67
|
+
const id = randomBytes(16).toString('hex');
|
|
68
|
+
const target = path.join(this.dir, id);
|
|
69
|
+
ids.push(id);
|
|
70
|
+
const out = createWriteStream(target, { flags: 'wx' });
|
|
71
|
+
stream.on('limit', () => fail('File too large.'));
|
|
72
|
+
pending.push(new Promise((done) => { out.on('close', done); out.on('error', () => { fail('Could not write the file.'); done(); }); }).then(async () => {
|
|
73
|
+
if (failure)
|
|
74
|
+
return;
|
|
75
|
+
const detected = await detect(target, info.filename);
|
|
76
|
+
writeFileSync(`${target}.json`, JSON.stringify({ name: path.basename(String(info.filename ?? 'file')).slice(0, 255), type: detected.type, extension: detected.extension }));
|
|
77
|
+
}));
|
|
78
|
+
stream.pipe(out);
|
|
79
|
+
});
|
|
80
|
+
parser.on('filesLimit', () => fail('Too many files.'));
|
|
81
|
+
parser.on('error', () => fail('Invalid upload.'));
|
|
82
|
+
parser.on('close', async () => {
|
|
83
|
+
await Promise.all(pending);
|
|
84
|
+
if (failure || !ids.length) {
|
|
85
|
+
for (const id of ids)
|
|
86
|
+
this.remove(id);
|
|
87
|
+
reject(failure ?? Object.assign(new Error('No file.'), { status: 422 }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
resolve(ids);
|
|
91
|
+
});
|
|
92
|
+
req.pipe(parser);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** Temporary file referenced by a state value (`livewire-file:<id>`) or a bare id, if it still exists. */
|
|
96
|
+
resolve(value) {
|
|
97
|
+
const id = typeof value === 'string' ? (value.startsWith(PREFIX) ? value.slice(PREFIX.length) : value) : '';
|
|
98
|
+
if (!ID.test(id))
|
|
99
|
+
return null;
|
|
100
|
+
const file = path.join(this.dir, id);
|
|
101
|
+
if (!existsSync(file) || !existsSync(`${file}.json`))
|
|
102
|
+
return null;
|
|
103
|
+
try {
|
|
104
|
+
const meta = JSON.parse(readFileSync(`${file}.json`, 'utf8'));
|
|
105
|
+
return { id, path: file, name: String(meta.name), type: String(meta.type), extension: String(meta.extension), size: statSync(file).size };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
static isTemporary(value) { return typeof value === 'string' && value.startsWith(PREFIX) && ID.test(value.slice(PREFIX.length)); }
|
|
112
|
+
static reference(id) { return PREFIX + id; }
|
|
113
|
+
remove(id) {
|
|
114
|
+
if (!ID.test(id))
|
|
115
|
+
return;
|
|
116
|
+
for (const suffix of ['', '.json'])
|
|
117
|
+
rmSync(path.join(this.dir, id + suffix), { force: true });
|
|
118
|
+
}
|
|
119
|
+
/** Deletes temporary files older than the retention time (`ttl`). */
|
|
120
|
+
sweep() {
|
|
121
|
+
const limit = Date.now() - this.ttl;
|
|
122
|
+
try {
|
|
123
|
+
for (const name of readdirSync(this.dir)) {
|
|
124
|
+
const file = path.join(this.dir, name);
|
|
125
|
+
if (statSync(file).mtimeMs < limit)
|
|
126
|
+
rmSync(file, { force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch { /* directory unreadable: nothing to sweep */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function detect(file, filename) {
|
|
133
|
+
const handle = await open(file, 'r');
|
|
134
|
+
try {
|
|
135
|
+
const { buffer, bytesRead } = await handle.read(Buffer.alloc(512), 0, 512, 0);
|
|
136
|
+
const head = buffer.subarray(0, bytesRead);
|
|
137
|
+
const known = SIGNATURES.find((signature) => signature.test(head));
|
|
138
|
+
if (known)
|
|
139
|
+
return { type: known.type, extension: known.extension };
|
|
140
|
+
const extension = path.extname(String(filename ?? '')).slice(1).toLowerCase();
|
|
141
|
+
if (TEXT[extension] && !head.includes(0))
|
|
142
|
+
return { type: TEXT[extension], extension };
|
|
143
|
+
return { type: 'application/octet-stream', extension: 'bin' };
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await handle.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/wire",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
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
|
+
"admin",
|
|
40
|
+
"panel",
|
|
41
|
+
"livewire",
|
|
42
|
+
"express",
|
|
43
|
+
"node"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"busboy": "^1.6.0",
|
|
47
|
+
"express": "^5.1.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"tag": "beta"
|
|
52
|
+
}
|
|
53
|
+
}
|