@navette-js/wire 1.0.0-beta.1 → 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/README.md +1 -1
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/lib/express.d.ts +6 -6
- package/lib/express.js +16 -16
- package/lib/index.d.ts +29 -24
- package/lib/index.js +74 -70
- package/lib/uploads.d.ts +4 -4
- package/lib/uploads.js +16 -17
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Server-side runtime compatible with Livewire 3 for Node.js and Express.
|
|
4
4
|
|
|
5
|
-
Part of [Navette](https://github.com/NavetteJS/navette),
|
|
5
|
+
Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`, or install the framework with `npm install @navette-js/panels`.
|
|
6
6
|
|
|
7
7
|
**Beta:** the API may still change between beta releases.
|
|
8
8
|
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Third-party notices
|
|
2
2
|
|
|
3
|
-
Navette is an independent
|
|
3
|
+
Navette is an independent project, not affiliated with or endorsed by the Filament, Livewire or Heroicons projects. It reuses the following works, each under its own license; the full license texts ship next to the files they cover.
|
|
4
4
|
|
|
5
5
|
| Work | Used for | License | License file |
|
|
6
6
|
|---|---|---|---|
|
|
7
|
-
| [Filament](https://github.com/filamentphp/filament) © Filament | HTML markup
|
|
7
|
+
| [Filament](https://github.com/filamentphp/filament) © Filament | HTML markup derived from its Blade views, compiled CSS and JavaScript (`@navette-js/panels/dist/filament`), English translations (`@navette-js/support/data`) | MIT | `packages/panels/dist/FILAMENT-LICENSE.md` |
|
|
8
8
|
| [Livewire](https://github.com/livewire/livewire) © Caleb Porzio | Client script `livewire.js` (`@navette-js/wire/vendor`); the server runtime follows its protocol | MIT | `packages/wire/vendor/LIVEWIRE-LICENSE.md` |
|
|
9
9
|
| [Heroicons](https://github.com/tailwindlabs/heroicons) | SVG icons (`@navette-js/support/icons`) | MIT | `packages/support/icons/HEROICONS-LICENSE.md` |
|
|
10
10
|
| [Inter](https://github.com/rsms/inter) © The Inter Project Authors | Default font files (`@navette-js/panels/dist/filament/fonts`) | SIL Open Font License 1.1 | `packages/panels/dist/filament/fonts/INTER-LICENSE.txt` |
|
package/lib/express.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { Request, Response, Router } from 'express';
|
|
2
2
|
import { type WireRuntime } from './index.ts';
|
|
3
|
-
/**
|
|
3
|
+
/** Double-submit CSRF token: an HttpOnly cookie, plus the token livewire.js sends back in the body (`_token`). */
|
|
4
4
|
export declare function csrfToken(req: Request, res: Response): string;
|
|
5
|
-
/**
|
|
5
|
+
/** Checks the submitted token (body or form field) against the cookie, in constant time. */
|
|
6
6
|
export declare function verifyCsrf(req: Request, token: unknown): boolean;
|
|
7
7
|
export declare function wireRouter(runtime: WireRuntime, prefix?: string, makeContext?: (req: Request, res: Response) => unknown | Promise<unknown>,
|
|
8
|
-
/**
|
|
8
|
+
/** Wraps the handling in a request context and can enrich the response (notifications…). */
|
|
9
9
|
around?: (ctx: any, req: Request, res: Response, handle: () => Promise<any>) => Promise<any>,
|
|
10
|
-
/**
|
|
10
|
+
/** Authorizes a file upload (e.g. signed-in user). Without it, anyone holding a signed URL and the CSRF token can upload. */
|
|
11
11
|
canUpload?: (ctx: any) => boolean | Promise<boolean>): Router;
|
|
12
|
-
/**
|
|
12
|
+
/** `<script>` tag that loads livewire.js with its CSRF token and endpoint URLs. */
|
|
13
13
|
export declare function wireScripts(req: Request, res: Response, prefix?: string): string;
|
|
14
|
-
/** Styles
|
|
14
|
+
/** Styles livewire.js expects (loading states, cloak, progress bar); place them in `<head>`. */
|
|
15
15
|
export declare function wireStyles(progressBarColor?: string): string;
|
package/lib/express.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Express integration: serves the bundled livewire.js and exposes the CSRF-protected update and upload endpoints.
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -8,7 +8,7 @@ const vendor = (f) => fileURLToPath(new URL(`../vendor/${f}`, import.meta.url));
|
|
|
8
8
|
const versionHash = JSON.parse(readFileSync(vendor('manifest.json'), 'utf8'))['/livewire.js'];
|
|
9
9
|
const COOKIE = 'navette_csrf';
|
|
10
10
|
const readCookie = (req, name) => (req.headers.cookie ?? '').split(';').map((c) => c.trim().split('=')).find(([k]) => k === name)?.[1];
|
|
11
|
-
/**
|
|
11
|
+
/** Double-submit CSRF token: an HttpOnly cookie, plus the token livewire.js sends back in the body (`_token`). */
|
|
12
12
|
export function csrfToken(req, res) {
|
|
13
13
|
let token = readCookie(req, COOKIE) ?? res.locals.navetteCsrf;
|
|
14
14
|
if (!token) {
|
|
@@ -18,15 +18,15 @@ export function csrfToken(req, res) {
|
|
|
18
18
|
}
|
|
19
19
|
return token;
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/** Checks the submitted token (body or form field) against the cookie, in constant time. */
|
|
22
22
|
export function verifyCsrf(req, token) {
|
|
23
23
|
const cookie = Buffer.from(readCookie(req, COOKIE) ?? ''), sent = Buffer.from(String(token ?? ''));
|
|
24
24
|
return cookie.length > 0 && cookie.length === sent.length && timingSafeEqual(cookie, sent);
|
|
25
25
|
}
|
|
26
26
|
export function wireRouter(runtime, prefix = '/livewire', makeContext = (req, res) => ({ req, res }),
|
|
27
|
-
/**
|
|
27
|
+
/** Wraps the handling in a request context and can enrich the response (notifications…). */
|
|
28
28
|
around = (_ctx, _req, _res, handle) => handle(),
|
|
29
|
-
/**
|
|
29
|
+
/** Authorizes a file upload (e.g. signed-in user). Without it, anyone holding a signed URL and the CSRF token can upload. */
|
|
30
30
|
canUpload = () => true) {
|
|
31
31
|
const router = express.Router();
|
|
32
32
|
router.get(`${prefix}/livewire.js`, (_req, res) => {
|
|
@@ -35,7 +35,7 @@ canUpload = () => true) {
|
|
|
35
35
|
router.post(`${prefix}/update`, express.json({ limit: '2mb' }), async (req, res) => {
|
|
36
36
|
try {
|
|
37
37
|
if (!verifyCsrf(req, req.body?._token))
|
|
38
|
-
throw new WireError(419, '
|
|
38
|
+
throw new WireError(419, 'Invalid CSRF token.');
|
|
39
39
|
const ctx = await makeContext(req, res);
|
|
40
40
|
res.json(await around(ctx, req, res, () => runtime.handle(req.body, ctx)));
|
|
41
41
|
}
|
|
@@ -45,39 +45,39 @@ canUpload = () => true) {
|
|
|
45
45
|
console.error(e);
|
|
46
46
|
else if (process.env.NODE_ENV !== 'production')
|
|
47
47
|
console.warn(`[wire] ${status} ${e.message} — calls=${JSON.stringify(req.body?.components?.map((c) => c.calls))} updates=${JSON.stringify(req.body?.components?.map((c) => c.updates))}`);
|
|
48
|
-
res.status(status).send(e instanceof Error ? e.message : '
|
|
48
|
+
res.status(status).send(e instanceof Error ? e.message : 'Error');
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
|
-
//
|
|
51
|
+
// File uploads: URL signed by `_startUpload`, CSRF token in a header, files stored in the temporary directory.
|
|
52
52
|
router.post(`${prefix}/upload-file`, async (req, res) => {
|
|
53
53
|
try {
|
|
54
54
|
const store = runtime.uploads;
|
|
55
55
|
if (!store)
|
|
56
|
-
throw new WireError(404, '
|
|
56
|
+
throw new WireError(404, 'Uploads are not available.');
|
|
57
57
|
if (!store.verifySignature(req.query.expires, req.query.signature))
|
|
58
|
-
throw new WireError(401, '
|
|
58
|
+
throw new WireError(401, 'Invalid or expired upload URL.');
|
|
59
59
|
if (!verifyCsrf(req, req.get('x-csrf-token')))
|
|
60
|
-
throw new WireError(419, '
|
|
60
|
+
throw new WireError(419, 'Invalid CSRF token.');
|
|
61
61
|
if (!(await canUpload(await makeContext(req, res))))
|
|
62
|
-
throw new WireError(401, '
|
|
62
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
63
63
|
res.json({ paths: await store.receive(req) });
|
|
64
64
|
}
|
|
65
65
|
catch (e) {
|
|
66
66
|
const status = e instanceof WireError ? e.status : Number(e?.status) || 500;
|
|
67
67
|
if (status === 500)
|
|
68
68
|
console.error(e);
|
|
69
|
-
res.status(status).json({ message: e instanceof Error ? e.message : '
|
|
69
|
+
res.status(status).json({ message: e instanceof Error ? e.message : 'Error' });
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
72
|
return router;
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
74
|
+
/** `<script>` tag that loads livewire.js with its CSRF token and endpoint URLs. */
|
|
75
75
|
export function wireScripts(req, res, prefix = '/livewire') {
|
|
76
76
|
return `<script src="${prefix}/livewire.js?id=${versionHash}" data-csrf="${csrfToken(req, res)}" data-module-url="${prefix}" data-update-uri="${prefix}/update" data-navigate-once="true"></script>`;
|
|
77
77
|
}
|
|
78
|
-
/** Styles
|
|
78
|
+
/** Styles livewire.js expects (loading states, cloak, progress bar); place them in `<head>`. */
|
|
79
79
|
export function wireStyles(progressBarColor = '#2299dd') {
|
|
80
|
-
const BS = String.fromCharCode(92); //
|
|
80
|
+
const BS = String.fromCharCode(92); // backslash: escapes `:` and `.` in CSS selectors
|
|
81
81
|
const sel = (n) => `[wire${BS}:${n.replaceAll('.', BS + '.')}]`;
|
|
82
82
|
const doubled = (names) => names.map((n) => sel(n) + sel(n)).join(', ');
|
|
83
83
|
return '<!-- Livewire Styles --><style>'
|
package/lib/index.d.ts
CHANGED
|
@@ -35,65 +35,65 @@ export declare class WireError extends Error {
|
|
|
35
35
|
status: number;
|
|
36
36
|
constructor(status: number, message: string);
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/** Base class of a component. Every own enumerable property not starting with `_` is public state. */
|
|
39
39
|
export declare abstract class Component {
|
|
40
40
|
_id: string;
|
|
41
41
|
_effects: Effects;
|
|
42
42
|
_skipRender: boolean;
|
|
43
43
|
_errors: Record<string, string[]>;
|
|
44
|
-
/**
|
|
44
|
+
/** Context of the current HTTP request (request, response, panel…), supplied by the integration. */
|
|
45
45
|
_ctx: any;
|
|
46
|
-
/** Runtime
|
|
46
|
+
/** Runtime and originating request, used to mount child components while rendering. */
|
|
47
47
|
_runtime: WireRuntime | null;
|
|
48
48
|
_request: {
|
|
49
49
|
path?: string;
|
|
50
50
|
method?: string;
|
|
51
51
|
};
|
|
52
|
-
/**
|
|
52
|
+
/** Children rendered during this request, and children already in the browser: key → [tag, id]. */
|
|
53
53
|
_children: Record<string, [string, string]>;
|
|
54
54
|
_previousChildren: Record<string, [string, string]>;
|
|
55
|
-
/** HTML
|
|
55
|
+
/** HTML shown until a lazy component is loaded; must have a single root. */
|
|
56
56
|
placeholder(): string | Promise<string>;
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `lazy
|
|
58
|
+
* Mounts a child component and returns its HTML, to insert into the parent's render.
|
|
59
|
+
* If the browser already has this child (same key), only a stub is rendered and the client keeps the child as is.
|
|
60
|
+
* `lazy`: the child shows its `placeholder()` and loads once it becomes visible.
|
|
61
61
|
*/
|
|
62
62
|
mountChild(name: string, params: Record<string, unknown>, key: string, options?: {
|
|
63
63
|
lazy?: boolean;
|
|
64
64
|
}): Promise<string>;
|
|
65
|
-
/**
|
|
65
|
+
/** Renders the component's HTML; must have a single root element. */
|
|
66
66
|
abstract render(): string | Promise<string>;
|
|
67
67
|
mount(_params: Record<string, unknown>): void | Promise<void>;
|
|
68
68
|
boot(): void | Promise<void>;
|
|
69
69
|
hydrate(): void | Promise<void>;
|
|
70
70
|
dehydrate(): void | Promise<void>;
|
|
71
71
|
get id(): string;
|
|
72
|
-
/**
|
|
72
|
+
/** Dispatches a browser event; with `self`, only this component receives it. */
|
|
73
73
|
dispatch(name: string, params?: Record<string, unknown> | unknown[], options?: {
|
|
74
74
|
self?: boolean;
|
|
75
75
|
}): void;
|
|
76
76
|
redirect(url: string, navigate?: boolean): void;
|
|
77
77
|
skipRender(): void;
|
|
78
|
-
/**
|
|
78
|
+
/** Partial render: only the `wire:partial="<name>"` fragment is sent back (effects.partials), the component is not re-rendered. */
|
|
79
79
|
renderPartial(name: string, html: string): void;
|
|
80
80
|
addError(path: string, message: string): void;
|
|
81
81
|
resetErrorBag(): void;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
* TypeScript
|
|
86
|
-
*
|
|
84
|
+
* Methods callable from the browser: only those the class (or a parent class) lists in `static callable`.
|
|
85
|
+
* TypeScript erases `protected` and `private` at runtime: without this allowlist, any internal method could be called by a
|
|
86
|
+
* forged request and its return value sent back to the browser.
|
|
87
87
|
*/
|
|
88
88
|
export declare function callableMethods(component: Component): Set<string>;
|
|
89
89
|
export declare function publicProperties(component: Component): string[];
|
|
90
90
|
export declare function dehydrateValue(value: unknown): unknown;
|
|
91
91
|
export declare function hydrateValue(value: unknown): unknown;
|
|
92
|
-
/**
|
|
92
|
+
/** Adds attributes to the root element of an HTML fragment. */
|
|
93
93
|
export declare function insertAttributesIntoHtmlRoot(html: string, attributes: Record<string, unknown>): string;
|
|
94
94
|
export type RuntimeOptions = {
|
|
95
95
|
key: string;
|
|
96
|
-
locale?: string; /**
|
|
96
|
+
locale?: string; /** Temporary upload store; without it, file uploads are refused. */
|
|
97
97
|
uploads?: UploadStore;
|
|
98
98
|
uploadPrefix?: string;
|
|
99
99
|
};
|
|
@@ -102,27 +102,32 @@ export declare class WireRuntime {
|
|
|
102
102
|
private options;
|
|
103
103
|
constructor(options: RuntimeOptions);
|
|
104
104
|
get uploads(): UploadStore | null;
|
|
105
|
-
/**
|
|
105
|
+
/** Params passed by a parent to its existing children during the current request: child id → params. */
|
|
106
106
|
private requestScope;
|
|
107
107
|
passPropsToChild(childId: string, params: Record<string, unknown>): void;
|
|
108
|
+
private names;
|
|
108
109
|
register(name: string, cls: ComponentClass): this;
|
|
110
|
+
/** Name under which this runtime registered a class. A class shared by several runtimes (one per panel) keeps one name per runtime. */
|
|
111
|
+
nameOf(cls: ComponentClass): string;
|
|
109
112
|
private make;
|
|
110
113
|
checksum(snapshot: Omit<Snapshot, 'checksum'>): string;
|
|
111
114
|
private snapshot;
|
|
112
|
-
/**
|
|
115
|
+
/** Signed mount params of a lazy component, in a `__mountParamsContainer`: the browser sends them back unchanged to `__lazyLoad`. */
|
|
113
116
|
private encodeMountParams;
|
|
114
117
|
private decodeMountParams;
|
|
115
|
-
/**
|
|
118
|
+
/** A mount param named like a public property is copied into it. */
|
|
116
119
|
private assignMatchingProperties;
|
|
117
120
|
private renderRoot;
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
121
|
+
/**
|
|
122
|
+
* Initial render: HTML with wire:snapshot and wire:effects on the root.
|
|
123
|
+
* The memo only carries `lazyLoaded` / `lazyIsolated` for lazy components: their mere presence makes the client ignore events.
|
|
124
|
+
*/
|
|
120
125
|
mount(name: string, params?: Record<string, unknown>, request?: {
|
|
121
126
|
path?: string;
|
|
122
127
|
method?: string;
|
|
123
128
|
ctx?: unknown;
|
|
124
129
|
}): Promise<string>;
|
|
125
|
-
/**
|
|
130
|
+
/** Like mount(), but also returns the mounted instance (to read its title, for example). */
|
|
126
131
|
mountWithInstance(name: string, params?: Record<string, unknown>, request?: {
|
|
127
132
|
path?: string;
|
|
128
133
|
method?: string;
|
|
@@ -133,12 +138,12 @@ export declare class WireRuntime {
|
|
|
133
138
|
html: string;
|
|
134
139
|
component: Component;
|
|
135
140
|
}>;
|
|
136
|
-
/**
|
|
141
|
+
/** Component update request: hydrate, apply updates, run calls, render, dehydrate. */
|
|
137
142
|
update(snapshot: Snapshot, updates: Record<string, unknown>, calls: Call[], ctx?: unknown): Promise<{
|
|
138
143
|
snapshot: Snapshot;
|
|
139
144
|
effects: Effects;
|
|
140
145
|
}>;
|
|
141
|
-
/**
|
|
146
|
+
/** HTTP entry point; body: `{ _token, components: [{ snapshot, updates, calls }] }`. */
|
|
142
147
|
handle(body: any, ctx?: unknown): Promise<{
|
|
143
148
|
components: {
|
|
144
149
|
snapshot: string;
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// @navette-js/wire — runtime
|
|
2
|
-
// Prototype du jalon 0 (ADR 0001). Référence : sources/demo/vendor/livewire/livewire/src/Mechanisms/HandleComponents.
|
|
1
|
+
// @navette-js/wire — server runtime for stateful components, speaking the Livewire client protocol (livewire.js 4.4.8).
|
|
3
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
3
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
4
|
import { UploadStore } from "./uploads.js";
|
|
@@ -8,34 +7,34 @@ export class WireError extends Error {
|
|
|
8
7
|
status;
|
|
9
8
|
constructor(status, message) { super(message); this.status = status; }
|
|
10
9
|
}
|
|
11
|
-
/**
|
|
10
|
+
/** Base class of a component. Every own enumerable property not starting with `_` is public state. */
|
|
12
11
|
export class Component {
|
|
13
12
|
_id = '';
|
|
14
13
|
_effects = {};
|
|
15
14
|
_skipRender = false;
|
|
16
15
|
_errors = {};
|
|
17
|
-
/**
|
|
16
|
+
/** Context of the current HTTP request (request, response, panel…), supplied by the integration. */
|
|
18
17
|
_ctx = null;
|
|
19
|
-
/** Runtime
|
|
18
|
+
/** Runtime and originating request, used to mount child components while rendering. */
|
|
20
19
|
_runtime = null;
|
|
21
20
|
_request = {};
|
|
22
|
-
/**
|
|
21
|
+
/** Children rendered during this request, and children already in the browser: key → [tag, id]. */
|
|
23
22
|
_children = {};
|
|
24
23
|
_previousChildren = {};
|
|
25
|
-
/** HTML
|
|
24
|
+
/** HTML shown until a lazy component is loaded; must have a single root. */
|
|
26
25
|
placeholder() { return '<div></div>'; }
|
|
27
26
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `lazy
|
|
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.
|
|
31
30
|
*/
|
|
32
31
|
async mountChild(name, params = {}, key, options = {}) {
|
|
33
32
|
if (!this._runtime)
|
|
34
|
-
throw new Error('mountChild()
|
|
33
|
+
throw new Error('mountChild(): component is not attached to a runtime.');
|
|
35
34
|
const previous = this._previousChildren[key];
|
|
36
35
|
if (previous) {
|
|
37
36
|
this._children[key] = previous;
|
|
38
|
-
//
|
|
37
|
+
// Reactive props: the child, updated later in the same request, picks these values up.
|
|
39
38
|
this._runtime.passPropsToChild(previous[1], params);
|
|
40
39
|
return `<${previous[0]} wire:id="${previous[1]}" wire:name="${escapeAttr(name)}" wire:key="${escapeAttr(key)}"></${previous[0]}>`;
|
|
41
40
|
}
|
|
@@ -48,7 +47,7 @@ export class Component {
|
|
|
48
47
|
hydrate() { }
|
|
49
48
|
dehydrate() { }
|
|
50
49
|
get id() { return this._id; }
|
|
51
|
-
/**
|
|
50
|
+
/** Dispatches a browser event; with `self`, only this component receives it. */
|
|
52
51
|
dispatch(name, params = {}, options = {}) {
|
|
53
52
|
(this._effects.dispatches ??= []).push({ name, params: params, ...(options.self ? { self: true } : {}) });
|
|
54
53
|
}
|
|
@@ -58,18 +57,18 @@ export class Component {
|
|
|
58
57
|
this._effects.redirectUsingNavigate = true;
|
|
59
58
|
}
|
|
60
59
|
skipRender() { this._skipRender = true; }
|
|
61
|
-
/**
|
|
60
|
+
/** Partial render: only the `wire:partial="<name>"` fragment is sent back (effects.partials), the component is not re-rendered. */
|
|
62
61
|
renderPartial(name, html) { (this._effects.partials ??= {})[name] = html; this._skipRender = true; }
|
|
63
62
|
addError(path, message) { (this._errors[path] ??= []).push(message); }
|
|
64
63
|
resetErrorBag() { this._errors = {}; }
|
|
65
64
|
}
|
|
66
65
|
const LIFECYCLE = new Set(['constructor', 'render', 'mount', 'boot', 'hydrate', 'dehydrate', 'dispatch', 'redirect', 'skipRender', 'renderPartial', 'addError', 'resetErrorBag', 'mountChild', 'placeholder']);
|
|
67
|
-
//
|
|
66
|
+
// Only property hooks (updatingFoo / updatedFoo) are reserved by prefix: `mountAction`, for example, is an ordinary public method.
|
|
68
67
|
const isLifecycleHook = (m) => LIFECYCLE.has(m) || /^(updating|updated)[A-Z]/.test(m) || m.startsWith('_');
|
|
69
68
|
/**
|
|
70
|
-
*
|
|
71
|
-
* TypeScript
|
|
72
|
-
*
|
|
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.
|
|
73
72
|
*/
|
|
74
73
|
export function callableMethods(component) {
|
|
75
74
|
const out = new Set();
|
|
@@ -85,7 +84,7 @@ export function callableMethods(component) {
|
|
|
85
84
|
export function publicProperties(component) {
|
|
86
85
|
return Object.keys(component).filter((k) => !k.startsWith('_'));
|
|
87
86
|
}
|
|
88
|
-
//
|
|
87
|
+
// Wire format: arrays and objects travel as [value, { s: 'arr' }] tuples.
|
|
89
88
|
export function dehydrateValue(value) {
|
|
90
89
|
if (value === undefined)
|
|
91
90
|
return null;
|
|
@@ -105,11 +104,11 @@ export function hydrateValue(value) {
|
|
|
105
104
|
return value;
|
|
106
105
|
}
|
|
107
106
|
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''');
|
|
108
|
-
/**
|
|
107
|
+
/** Adds attributes to the root element of an HTML fragment. */
|
|
109
108
|
export function insertAttributesIntoHtmlRoot(html, attributes) {
|
|
110
109
|
const m = /^\s*<([a-zA-Z0-9-]+)/.exec(html);
|
|
111
110
|
if (!m)
|
|
112
|
-
throw new WireError(500, '
|
|
111
|
+
throw new WireError(500, 'A component render must start with a root element.');
|
|
113
112
|
const attrs = Object.entries(attributes)
|
|
114
113
|
.map(([k, v]) => `${k}="${escapeAttr(typeof v === 'string' ? v : JSON.stringify(v))}"`)
|
|
115
114
|
.join(' ');
|
|
@@ -131,11 +130,11 @@ function setByPath(target, segments, value) {
|
|
|
131
130
|
else
|
|
132
131
|
node[last] = value;
|
|
133
132
|
}
|
|
134
|
-
/**
|
|
133
|
+
/** 20-character alphanumeric id (used in HTML ids and CSS selectors, so no symbols). */
|
|
135
134
|
const randomId = () => Array.from(randomBytes(20), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join('');
|
|
136
135
|
const MAGIC_ACTIONS = new Set(['$refresh', '$set', '$sync', '$commit']);
|
|
137
136
|
const studly = (s) => s.replace(/(^|[_.-])(\w)/g, (_, __, c) => c.toUpperCase());
|
|
138
|
-
/**
|
|
137
|
+
/** False if the value contains, at any depth, a temporary file reference that does not point to a received file. */
|
|
139
138
|
function uploadReferencesAreValid(value, store, depth = 0) {
|
|
140
139
|
if (typeof value === 'string')
|
|
141
140
|
return value.startsWith('livewire-files:') ? false : value.startsWith('livewire-file:') ? !!store?.resolve(value) : true;
|
|
@@ -145,25 +144,29 @@ function uploadReferencesAreValid(value, store, depth = 0) {
|
|
|
145
144
|
return false;
|
|
146
145
|
return Object.values(value).every((v) => uploadReferencesAreValid(v, store, depth + 1));
|
|
147
146
|
}
|
|
148
|
-
/**
|
|
147
|
+
/** Reactive props of a component (`static reactive`): set by the parent, never by the browser. */
|
|
149
148
|
const reactiveProps = (component) => component.constructor.reactive ?? [];
|
|
150
149
|
export class WireRuntime {
|
|
151
150
|
registry = new Map();
|
|
152
151
|
options;
|
|
153
152
|
constructor(options) { this.options = options; }
|
|
154
153
|
get uploads() { return this.options.uploads ?? null; }
|
|
155
|
-
/**
|
|
154
|
+
/** Params passed by a parent to its existing children during the current request: child id → params. */
|
|
156
155
|
requestScope = new AsyncLocalStorage();
|
|
157
156
|
passPropsToChild(childId, params) { this.requestScope.getStore()?.childProps.set(childId, params); }
|
|
158
|
-
|
|
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 ?? ''; }
|
|
159
162
|
make(name) {
|
|
160
163
|
const cls = this.registry.get(name);
|
|
161
164
|
if (!cls)
|
|
162
|
-
throw new WireError(404, `
|
|
165
|
+
throw new WireError(404, `Unknown component: ${name}`);
|
|
163
166
|
return new cls();
|
|
164
167
|
}
|
|
165
168
|
checksum(snapshot) {
|
|
166
|
-
const { children: _children, ...memo } = snapshot.memo; //
|
|
169
|
+
const { children: _children, ...memo } = snapshot.memo; // the client may rewrite `children`, so it is not signed
|
|
167
170
|
return createHmac('sha256', this.options.key).update(JSON.stringify({ data: snapshot.data, memo })).digest('hex');
|
|
168
171
|
}
|
|
169
172
|
snapshot(component, name, memo) {
|
|
@@ -173,7 +176,7 @@ export class WireRuntime {
|
|
|
173
176
|
const body = { data, memo: { id: component.id, name, ...memo, errors: component._errors } };
|
|
174
177
|
return { ...body, checksum: this.checksum(body) };
|
|
175
178
|
}
|
|
176
|
-
/**
|
|
179
|
+
/** Signed mount params of a lazy component, in a `__mountParamsContainer`: the browser sends them back unchanged to `__lazyLoad`. */
|
|
177
180
|
encodeMountParams(name, params, request) {
|
|
178
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' } };
|
|
179
182
|
return Buffer.from(JSON.stringify({ ...body, checksum: this.checksum(body) })).toString('base64');
|
|
@@ -184,19 +187,19 @@ export class WireRuntime {
|
|
|
184
187
|
container = JSON.parse(Buffer.from(String(encoded), 'base64').toString('utf8'));
|
|
185
188
|
}
|
|
186
189
|
catch {
|
|
187
|
-
throw new WireError(404, '
|
|
190
|
+
throw new WireError(404, 'Unreadable mount params.');
|
|
188
191
|
}
|
|
189
192
|
if (!container || typeof container.data !== 'object' || typeof container.memo !== 'object' || typeof container.checksum !== 'string')
|
|
190
|
-
throw new WireError(404, '
|
|
193
|
+
throw new WireError(404, 'Invalid mount params.');
|
|
191
194
|
const expected = Buffer.from(this.checksum(container)), given = Buffer.from(container.checksum);
|
|
192
195
|
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
193
|
-
throw new WireError(419, '
|
|
194
|
-
//
|
|
196
|
+
throw new WireError(419, 'Invalid mount params checksum.');
|
|
197
|
+
// Params signed for one component are not valid for another.
|
|
195
198
|
if (container.memo.name !== '__mountParamsContainer' || container.data.forComponent !== name)
|
|
196
|
-
throw new WireError(403, '
|
|
199
|
+
throw new WireError(403, 'Mount params were issued for another component.');
|
|
197
200
|
return (hydrateValue(container.data.forMount) ?? {});
|
|
198
201
|
}
|
|
199
|
-
/**
|
|
202
|
+
/** A mount param named like a public property is copied into it. */
|
|
200
203
|
assignMatchingProperties(component, params) {
|
|
201
204
|
const known = new Set(publicProperties(component));
|
|
202
205
|
for (const [k, v] of Object.entries(params))
|
|
@@ -207,16 +210,17 @@ export class WireRuntime {
|
|
|
207
210
|
const html = await component.render();
|
|
208
211
|
return insertAttributesIntoHtmlRoot(html, { 'wire:id': component.id, 'wire:name': name });
|
|
209
212
|
}
|
|
210
|
-
/**
|
|
211
|
-
*
|
|
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
|
+
*/
|
|
212
217
|
async mount(name, params = {}, request = {}) {
|
|
213
218
|
return (await this.mountWithInstance(name, params, request)).html;
|
|
214
219
|
}
|
|
215
|
-
/**
|
|
220
|
+
/** Like mount(), but also returns the mounted instance (to read its title, for example). */
|
|
216
221
|
async mountWithInstance(name, params = {}, request = {}) {
|
|
217
222
|
const component = this.make(name);
|
|
218
223
|
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
224
|
component._id = randomId();
|
|
221
225
|
component._runtime = this;
|
|
222
226
|
component._request = { path: request.path, method: request.method };
|
|
@@ -224,7 +228,7 @@ export class WireRuntime {
|
|
|
224
228
|
this.assignMatchingProperties(component, params);
|
|
225
229
|
let html;
|
|
226
230
|
if (request.lazy) {
|
|
227
|
-
//
|
|
231
|
+
// Lazy component: `mount()` and rendering wait for the browser's `__lazyLoad` call.
|
|
228
232
|
const placeholder = insertAttributesIntoHtmlRoot(await component.placeholder(), { 'x-intersect': `$wire.__lazyLoad('${this.encodeMountParams(name, params, request)}')` });
|
|
229
233
|
html = insertAttributesIntoHtmlRoot(placeholder, { 'wire:id': component.id, 'wire:name': name });
|
|
230
234
|
}
|
|
@@ -238,19 +242,19 @@ export class WireRuntime {
|
|
|
238
242
|
...(request.lazy ? { lazyLoaded: false, lazyIsolated: true } : {}), ...(reactiveProps(component).length ? { props: reactiveProps(component) } : {}), locale: this.options.locale ?? 'en', islands: [],
|
|
239
243
|
});
|
|
240
244
|
const { html: _h, ...effects } = component._effects;
|
|
241
|
-
//
|
|
245
|
+
// Event listeners: the client calls `__dispatch(event, params)` when one fires.
|
|
242
246
|
const listeners = Object.keys(component.constructor.listeners ?? {});
|
|
243
247
|
if (listeners.length)
|
|
244
248
|
effects.listeners = listeners;
|
|
245
249
|
return { html: insertAttributesIntoHtmlRoot(html, { ...(request.key !== undefined ? { 'wire:key': request.key } : {}), 'wire:snapshot': JSON.stringify(snapshot), 'wire:effects': JSON.stringify(effects) }), component };
|
|
246
250
|
}
|
|
247
|
-
/**
|
|
251
|
+
/** Component update request: hydrate, apply updates, run calls, render, dehydrate. */
|
|
248
252
|
async update(snapshot, updates, calls, ctx = null) {
|
|
249
253
|
if (!snapshot || typeof snapshot.data !== 'object' || typeof snapshot.memo !== 'object' || typeof snapshot.checksum !== 'string')
|
|
250
|
-
throw new WireError(404, '
|
|
254
|
+
throw new WireError(404, 'Invalid snapshot.');
|
|
251
255
|
const expected = Buffer.from(this.checksum(snapshot)), given = Buffer.from(snapshot.checksum);
|
|
252
256
|
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
253
|
-
throw new WireError(419, '
|
|
257
|
+
throw new WireError(419, 'Invalid snapshot checksum.');
|
|
254
258
|
const name = snapshot.memo.name;
|
|
255
259
|
const component = this.make(name);
|
|
256
260
|
const cls = component.constructor;
|
|
@@ -259,7 +263,7 @@ export class WireRuntime {
|
|
|
259
263
|
component._errors = { ...(snapshot.memo.errors ?? {}) };
|
|
260
264
|
component._runtime = this;
|
|
261
265
|
component._request = { path: snapshot.memo.path, method: snapshot.memo.method };
|
|
262
|
-
// `memo.children`
|
|
266
|
+
// `memo.children` is not covered by the checksum (the client maintains it): only well-formed entries are kept.
|
|
263
267
|
if (snapshot.memo.children && typeof snapshot.memo.children === 'object' && !Array.isArray(snapshot.memo.children)) {
|
|
264
268
|
for (const [key, value] of Object.entries(snapshot.memo.children)) {
|
|
265
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])))
|
|
@@ -272,7 +276,7 @@ export class WireRuntime {
|
|
|
272
276
|
for (const [k, v] of Object.entries(snapshot.data))
|
|
273
277
|
if (known.has(k))
|
|
274
278
|
component[k] = hydrateValue(v);
|
|
275
|
-
//
|
|
279
|
+
// Reactive props only come from the parent, rendered earlier in the same request.
|
|
276
280
|
const reactive = new Set(reactiveProps(component));
|
|
277
281
|
const passed = this.requestScope.getStore()?.childProps.get(component.id);
|
|
278
282
|
if (passed)
|
|
@@ -285,14 +289,14 @@ export class WireRuntime {
|
|
|
285
289
|
const segments = path.split('.');
|
|
286
290
|
const prop = segments[0];
|
|
287
291
|
if (!known.has(prop))
|
|
288
|
-
throw new WireError(404, `
|
|
292
|
+
throw new WireError(404, `Public property not found: ${prop}`);
|
|
289
293
|
if (cls.locked?.includes(prop))
|
|
290
|
-
throw new WireError(403, `
|
|
294
|
+
throw new WireError(403, `Locked property: ${prop}`);
|
|
291
295
|
if (reactive.has(prop))
|
|
292
|
-
throw new WireError(403, `
|
|
293
|
-
//
|
|
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.
|
|
294
298
|
if (segments.some((segment) => segment === '__proto__' || segment === 'constructor' || segment === 'prototype'))
|
|
295
|
-
throw new WireError(403, `
|
|
299
|
+
throw new WireError(403, `State path rejected: ${path}`);
|
|
296
300
|
const anyC = component;
|
|
297
301
|
await anyC.updating?.(path, value);
|
|
298
302
|
await anyC[`updating${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
@@ -301,24 +305,24 @@ export class WireRuntime {
|
|
|
301
305
|
await anyC[`updated${studly(prop)}`]?.(value, segments.slice(1).join('.') || null);
|
|
302
306
|
};
|
|
303
307
|
for (const [path, value] of Object.entries(updates)) {
|
|
304
|
-
//
|
|
308
|
+
// A temporary file reference sent by the browser must point to a file that was actually received.
|
|
305
309
|
if (!uploadReferencesAreValid(value, this.uploads))
|
|
306
|
-
throw new WireError(403, `
|
|
310
|
+
throw new WireError(403, `File reference rejected: ${path}`);
|
|
307
311
|
await applyUpdate(path, value);
|
|
308
312
|
}
|
|
309
313
|
const allowed = callableMethods(component);
|
|
310
314
|
const returns = [];
|
|
311
315
|
for (const call of calls) {
|
|
312
316
|
if (typeof call?.method !== 'string' || !Array.isArray(call.params))
|
|
313
|
-
throw new WireError(404, '
|
|
314
|
-
//
|
|
317
|
+
throw new WireError(404, 'Invalid call.');
|
|
318
|
+
// Magic actions are no-ops on the server: their values travel in `updates`.
|
|
315
319
|
if (MAGIC_ACTIONS.has(call.method)) {
|
|
316
320
|
returns.push(null);
|
|
317
321
|
continue;
|
|
318
322
|
}
|
|
319
323
|
if (call.method === '__lazyLoad') {
|
|
320
324
|
if (!awaitsLazyLoad || lazyLoaded)
|
|
321
|
-
throw new WireError(404, '
|
|
325
|
+
throw new WireError(404, 'This component is not waiting for a lazy load.');
|
|
322
326
|
const params = this.decodeMountParams(name, call.params[0]);
|
|
323
327
|
this.assignMatchingProperties(component, params);
|
|
324
328
|
await component.mount(params);
|
|
@@ -326,15 +330,15 @@ export class WireRuntime {
|
|
|
326
330
|
returns.push(null);
|
|
327
331
|
continue;
|
|
328
332
|
}
|
|
329
|
-
//
|
|
333
|
+
// Until it is loaded, a lazy component has not been mounted: nothing else is callable.
|
|
330
334
|
if (awaitsLazyLoad && !lazyLoaded)
|
|
331
|
-
throw new WireError(404, '
|
|
332
|
-
//
|
|
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.
|
|
333
337
|
if (call.method === '_startUpload' || call.method === '_finishUpload' || call.method === '_uploadErrored' || call.method === '_removeUpload') {
|
|
334
338
|
const store = this.uploads;
|
|
335
339
|
const target = call.params[0];
|
|
336
340
|
if (!store || typeof target !== 'string' || target.length > 500)
|
|
337
|
-
throw new WireError(404, '
|
|
341
|
+
throw new WireError(404, 'Uploads are not available.');
|
|
338
342
|
if (call.method === '_startUpload') {
|
|
339
343
|
component.dispatch('upload:generatedSignedUrl', { name: target, url: store.signedUrl(this.options.uploadPrefix ?? '/livewire') }, { self: true });
|
|
340
344
|
component._skipRender = true;
|
|
@@ -342,7 +346,7 @@ export class WireRuntime {
|
|
|
342
346
|
else if (call.method === '_finishUpload') {
|
|
343
347
|
const ids = Array.isArray(call.params[1]) ? call.params[1].map(String) : [];
|
|
344
348
|
if (!ids.length || ids.some((id) => !store.resolve(id)))
|
|
345
|
-
throw new WireError(404, '
|
|
349
|
+
throw new WireError(404, 'Temporary file not found.');
|
|
346
350
|
await applyUpdate(target, call.params[2] === true ? ids.map((id) => UploadStore.reference(id)) : UploadStore.reference(ids[0]));
|
|
347
351
|
component.dispatch('upload:finished', { name: target, tmpFilenames: ids }, { self: true });
|
|
348
352
|
}
|
|
@@ -360,16 +364,16 @@ export class WireRuntime {
|
|
|
360
364
|
if (call.method === '__dispatch') {
|
|
361
365
|
const handler = cls.listeners?.[String(call.params[0])];
|
|
362
366
|
if (!handler)
|
|
363
|
-
throw new WireError(404, `
|
|
367
|
+
throw new WireError(404, `No listener for event: ${String(call.params[0])}`);
|
|
364
368
|
const args = call.params[1] && typeof call.params[1] === 'object' ? Object.values(call.params[1]) : [];
|
|
365
369
|
returns.push((await component[handler](...args)) ?? null);
|
|
366
370
|
continue;
|
|
367
371
|
}
|
|
368
372
|
if (!allowed.has(call.method)) {
|
|
369
|
-
//
|
|
373
|
+
// Development hint: the method exists but is not declared callable.
|
|
370
374
|
if (process.env.NODE_ENV !== 'production' && typeof component[call.method] === 'function' && !isLifecycleHook(call.method))
|
|
371
|
-
console.warn(`[wire] ${name}
|
|
372
|
-
throw new WireError(404, `
|
|
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}`);
|
|
373
377
|
}
|
|
374
378
|
returns.push((await component[call.method](...call.params)) ?? null);
|
|
375
379
|
}
|
|
@@ -384,7 +388,7 @@ export class WireRuntime {
|
|
|
384
388
|
}
|
|
385
389
|
await component.dehydrate();
|
|
386
390
|
const { id: _i, name: _n, errors: _e, ...memo } = snapshot.memo;
|
|
387
|
-
//
|
|
391
|
+
// After a full render, the children list is the one just rendered; otherwise it is kept.
|
|
388
392
|
if (rendered)
|
|
389
393
|
memo.children = Object.keys(component._children).length ? component._children : [];
|
|
390
394
|
if (lazyLoaded) {
|
|
@@ -393,17 +397,17 @@ export class WireRuntime {
|
|
|
393
397
|
}
|
|
394
398
|
return { snapshot: this.snapshot(component, name, memo), effects };
|
|
395
399
|
}
|
|
396
|
-
/**
|
|
400
|
+
/** HTTP entry point; body: `{ _token, components: [{ snapshot, updates, calls }] }`. */
|
|
397
401
|
async handle(body, ctx = null) {
|
|
398
402
|
const list = body?.components;
|
|
399
403
|
if (!Array.isArray(list) || list.length === 0)
|
|
400
|
-
throw new WireError(404, '
|
|
404
|
+
throw new WireError(404, 'Invalid payload.');
|
|
401
405
|
const components = [];
|
|
402
|
-
//
|
|
406
|
+
// Components are processed in order so a parent can pass its reactive props to the children that follow it.
|
|
403
407
|
await this.requestScope.run({ childProps: new Map() }, async () => {
|
|
404
408
|
for (const c of list) {
|
|
405
409
|
if (typeof c?.snapshot !== 'string' || typeof c.updates !== 'object' || !Array.isArray(c.calls))
|
|
406
|
-
throw new WireError(404, '
|
|
410
|
+
throw new WireError(404, 'Invalid payload.');
|
|
407
411
|
const { snapshot, effects } = await this.update(JSON.parse(c.snapshot), c.updates ?? {}, c.calls, ctx);
|
|
408
412
|
components.push({ snapshot: JSON.stringify(snapshot), effects });
|
|
409
413
|
}
|
package/lib/uploads.d.ts
CHANGED
|
@@ -22,16 +22,16 @@ export declare class UploadStore {
|
|
|
22
22
|
private urlTtl;
|
|
23
23
|
constructor(dir: string, key: string, options?: UploadOptions);
|
|
24
24
|
private sign;
|
|
25
|
-
/**
|
|
25
|
+
/** Signed upload URL, valid for a few minutes (`urlTtl`). */
|
|
26
26
|
signedUrl(prefix?: string): string;
|
|
27
27
|
verifySignature(expires: unknown, signature: unknown): boolean;
|
|
28
|
-
/**
|
|
28
|
+
/** Receives a `multipart/form-data` upload (`files[]` field) and returns the temporary ids, in order. */
|
|
29
29
|
receive(req: IncomingMessage): Promise<string[]>;
|
|
30
|
-
/**
|
|
30
|
+
/** Temporary file referenced by a state value (`livewire-file:<id>`) or a bare id, if it still exists. */
|
|
31
31
|
resolve(value: unknown): TemporaryFile | null;
|
|
32
32
|
static isTemporary(value: unknown): boolean;
|
|
33
33
|
static reference(id: string): string;
|
|
34
34
|
remove(id: string): void;
|
|
35
|
-
/**
|
|
35
|
+
/** Deletes temporary files older than the retention time (`ttl`). */
|
|
36
36
|
private sweep;
|
|
37
37
|
}
|
package/lib/uploads.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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.
|
|
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.
|
|
4
3
|
import busboy from 'busboy';
|
|
5
4
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
5
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
@@ -8,7 +7,7 @@ import { open } from 'node:fs/promises';
|
|
|
8
7
|
import path from 'node:path';
|
|
9
8
|
const PREFIX = 'livewire-file:';
|
|
10
9
|
const ID = /^[a-f0-9]{32}$/;
|
|
11
|
-
/** Types
|
|
10
|
+
/** Types detected from their magic bytes: the type announced by the browser is never trusted. */
|
|
12
11
|
const SIGNATURES = [
|
|
13
12
|
{ type: 'image/jpeg', extension: 'jpg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
14
13
|
{ type: 'image/png', extension: 'png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
@@ -17,7 +16,7 @@ const SIGNATURES = [
|
|
|
17
16
|
{ type: 'application/pdf', extension: 'pdf', test: (b) => b.subarray(0, 5).toString('latin1') === '%PDF-' },
|
|
18
17
|
{ type: 'application/zip', extension: 'zip', test: (b) => b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04 },
|
|
19
18
|
];
|
|
20
|
-
/**
|
|
19
|
+
/** Plain text: only for these extensions, and only if the start of the file contains no null byte. */
|
|
21
20
|
const TEXT = { txt: 'text/plain', csv: 'text/csv', json: 'application/json', md: 'text/markdown' };
|
|
22
21
|
export class UploadStore {
|
|
23
22
|
dir;
|
|
@@ -29,14 +28,14 @@ export class UploadStore {
|
|
|
29
28
|
constructor(dir, key, options = {}) {
|
|
30
29
|
this.dir = dir;
|
|
31
30
|
this.key = key;
|
|
32
|
-
this.maxFileSize = options.maxFileSize ?? 12 * 1024 * 1024; // 12
|
|
31
|
+
this.maxFileSize = options.maxFileSize ?? 12 * 1024 * 1024; // 12 MB
|
|
33
32
|
this.maxFiles = options.maxFiles ?? 20;
|
|
34
33
|
this.ttl = options.ttl ?? 24 * 60 * 60 * 1000;
|
|
35
34
|
this.urlTtl = options.urlTtl ?? 5 * 60 * 1000;
|
|
36
35
|
mkdirSync(dir, { recursive: true });
|
|
37
36
|
}
|
|
38
37
|
sign(expires) { return createHmac('sha256', this.key).update(`upload:${expires}`).digest('hex'); }
|
|
39
|
-
/**
|
|
38
|
+
/** Signed upload URL, valid for a few minutes (`urlTtl`). */
|
|
40
39
|
signedUrl(prefix = '/livewire') {
|
|
41
40
|
const expires = Date.now() + this.urlTtl;
|
|
42
41
|
return `${prefix}/upload-file?expires=${expires}&signature=${this.sign(expires)}`;
|
|
@@ -48,7 +47,7 @@ export class UploadStore {
|
|
|
48
47
|
const expected = Buffer.from(this.sign(at)), given = Buffer.from(String(signature ?? ''));
|
|
49
48
|
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
50
49
|
}
|
|
51
|
-
/**
|
|
50
|
+
/** Receives a `multipart/form-data` upload (`files[]` field) and returns the temporary ids, in order. */
|
|
52
51
|
receive(req) {
|
|
53
52
|
this.sweep();
|
|
54
53
|
return new Promise((resolve, reject) => {
|
|
@@ -61,7 +60,7 @@ export class UploadStore {
|
|
|
61
60
|
parser = busboy({ headers: req.headers, limits: { fileSize: this.maxFileSize, files: this.maxFiles, fields: 5 } });
|
|
62
61
|
}
|
|
63
62
|
catch {
|
|
64
|
-
reject(Object.assign(new Error('
|
|
63
|
+
reject(Object.assign(new Error('Invalid upload.'), { status: 422 }));
|
|
65
64
|
return;
|
|
66
65
|
}
|
|
67
66
|
parser.on('file', (_field, stream, info) => {
|
|
@@ -69,8 +68,8 @@ export class UploadStore {
|
|
|
69
68
|
const target = path.join(this.dir, id);
|
|
70
69
|
ids.push(id);
|
|
71
70
|
const out = createWriteStream(target, { flags: 'wx' });
|
|
72
|
-
stream.on('limit', () => fail('
|
|
73
|
-
pending.push(new Promise((done) => { out.on('close', done); out.on('error', () => { fail('
|
|
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 () => {
|
|
74
73
|
if (failure)
|
|
75
74
|
return;
|
|
76
75
|
const detected = await detect(target, info.filename);
|
|
@@ -78,14 +77,14 @@ export class UploadStore {
|
|
|
78
77
|
}));
|
|
79
78
|
stream.pipe(out);
|
|
80
79
|
});
|
|
81
|
-
parser.on('filesLimit', () => fail('
|
|
82
|
-
parser.on('error', () => fail('
|
|
80
|
+
parser.on('filesLimit', () => fail('Too many files.'));
|
|
81
|
+
parser.on('error', () => fail('Invalid upload.'));
|
|
83
82
|
parser.on('close', async () => {
|
|
84
83
|
await Promise.all(pending);
|
|
85
84
|
if (failure || !ids.length) {
|
|
86
85
|
for (const id of ids)
|
|
87
86
|
this.remove(id);
|
|
88
|
-
reject(failure ?? Object.assign(new Error('
|
|
87
|
+
reject(failure ?? Object.assign(new Error('No file.'), { status: 422 }));
|
|
89
88
|
return;
|
|
90
89
|
}
|
|
91
90
|
resolve(ids);
|
|
@@ -93,7 +92,7 @@ export class UploadStore {
|
|
|
93
92
|
req.pipe(parser);
|
|
94
93
|
});
|
|
95
94
|
}
|
|
96
|
-
/**
|
|
95
|
+
/** Temporary file referenced by a state value (`livewire-file:<id>`) or a bare id, if it still exists. */
|
|
97
96
|
resolve(value) {
|
|
98
97
|
const id = typeof value === 'string' ? (value.startsWith(PREFIX) ? value.slice(PREFIX.length) : value) : '';
|
|
99
98
|
if (!ID.test(id))
|
|
@@ -117,7 +116,7 @@ export class UploadStore {
|
|
|
117
116
|
for (const suffix of ['', '.json'])
|
|
118
117
|
rmSync(path.join(this.dir, id + suffix), { force: true });
|
|
119
118
|
}
|
|
120
|
-
/**
|
|
119
|
+
/** Deletes temporary files older than the retention time (`ttl`). */
|
|
121
120
|
sweep() {
|
|
122
121
|
const limit = Date.now() - this.ttl;
|
|
123
122
|
try {
|
|
@@ -127,7 +126,7 @@ export class UploadStore {
|
|
|
127
126
|
rmSync(file, { force: true });
|
|
128
127
|
}
|
|
129
128
|
}
|
|
130
|
-
catch { /*
|
|
129
|
+
catch { /* directory unreadable: nothing to sweep */ }
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
132
|
async function detect(file, filename) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/wire",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"description": "Server-side runtime compatible with Livewire 3 for Node.js and Express.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"navette",
|
|
39
|
-
"filament",
|
|
40
39
|
"admin",
|
|
41
40
|
"panel",
|
|
42
41
|
"livewire",
|