@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NavetteJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @navette-js/wire
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-side runtime compatible with Livewire 3 for Node.js and Express.
4
+
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
+
7
+ **Beta:** the API may still change between beta releases.
8
+
9
+ License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
@@ -0,0 +1,12 @@
1
+ # Third-party notices
2
+
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
+
5
+ | Work | Used for | License | License file |
6
+ |---|---|---|---|
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
+ | [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
+ | [Heroicons](https://github.com/tailwindlabs/heroicons) | SVG icons (`@navette-js/support/icons`) | MIT | `packages/support/icons/HEROICONS-LICENSE.md` |
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` |
11
+
12
+ Runtime dependencies installed from npm (Express, Knex, Objection, better-sqlite3, Tiptap, EJS, Busboy…) keep their own licenses.
@@ -0,0 +1,15 @@
1
+ import type { Request, Response, Router } from 'express';
2
+ import { type WireRuntime } from './index.ts';
3
+ /** Double-submit CSRF token: an HttpOnly cookie, plus the token livewire.js sends back in the body (`_token`). */
4
+ export declare function csrfToken(req: Request, res: Response): string;
5
+ /** Checks the submitted token (body or form field) against the cookie, in constant time. */
6
+ export declare function verifyCsrf(req: Request, token: unknown): boolean;
7
+ export declare function wireRouter(runtime: WireRuntime, prefix?: string, makeContext?: (req: Request, res: Response) => unknown | Promise<unknown>,
8
+ /** Wraps the handling in a request context and can enrich the response (notifications…). */
9
+ around?: (ctx: any, req: Request, res: Response, handle: () => Promise<any>) => Promise<any>,
10
+ /** Authorizes a file upload (e.g. signed-in user). Without it, anyone holding a signed URL and the CSRF token can upload. */
11
+ canUpload?: (ctx: any) => boolean | Promise<boolean>): Router;
12
+ /** `<script>` tag that loads livewire.js with its CSRF token and endpoint URLs. */
13
+ export declare function wireScripts(req: Request, res: Response, prefix?: string): string;
14
+ /** Styles livewire.js expects (loading states, cloak, progress bar); place them in `<head>`. */
15
+ export declare function wireStyles(progressBarColor?: string): string;
package/lib/express.js ADDED
@@ -0,0 +1,92 @@
1
+ // Express integration: serves the bundled livewire.js and exposes the CSRF-protected update and upload endpoints.
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import express from 'express';
6
+ import { WireError } from "./index.js";
7
+ const vendor = (f) => fileURLToPath(new URL(`../vendor/${f}`, import.meta.url));
8
+ const versionHash = JSON.parse(readFileSync(vendor('manifest.json'), 'utf8'))['/livewire.js'];
9
+ const COOKIE = 'navette_csrf';
10
+ const readCookie = (req, name) => (req.headers.cookie ?? '').split(';').map((c) => c.trim().split('=')).find(([k]) => k === name)?.[1];
11
+ /** Double-submit CSRF token: an HttpOnly cookie, plus the token livewire.js sends back in the body (`_token`). */
12
+ export function csrfToken(req, res) {
13
+ let token = readCookie(req, COOKIE) ?? res.locals.navetteCsrf;
14
+ if (!token) {
15
+ token = randomBytes(24).toString('base64url');
16
+ res.cookie(COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: req.secure === true });
17
+ res.locals.navetteCsrf = token;
18
+ }
19
+ return token;
20
+ }
21
+ /** Checks the submitted token (body or form field) against the cookie, in constant time. */
22
+ export function verifyCsrf(req, token) {
23
+ const cookie = Buffer.from(readCookie(req, COOKIE) ?? ''), sent = Buffer.from(String(token ?? ''));
24
+ return cookie.length > 0 && cookie.length === sent.length && timingSafeEqual(cookie, sent);
25
+ }
26
+ export function wireRouter(runtime, prefix = '/livewire', makeContext = (req, res) => ({ req, res }),
27
+ /** Wraps the handling in a request context and can enrich the response (notifications…). */
28
+ around = (_ctx, _req, _res, handle) => handle(),
29
+ /** Authorizes a file upload (e.g. signed-in user). Without it, anyone holding a signed URL and the CSRF token can upload. */
30
+ canUpload = () => true) {
31
+ const router = express.Router();
32
+ router.get(`${prefix}/livewire.js`, (_req, res) => {
33
+ res.type('application/javascript').set('Cache-Control', 'public, max-age=31536000, immutable').sendFile(vendor('livewire.js'));
34
+ });
35
+ router.post(`${prefix}/update`, express.json({ limit: '2mb' }), async (req, res) => {
36
+ try {
37
+ if (!verifyCsrf(req, req.body?._token))
38
+ throw new WireError(419, 'Invalid CSRF token.');
39
+ const ctx = await makeContext(req, res);
40
+ res.json(await around(ctx, req, res, () => runtime.handle(req.body, ctx)));
41
+ }
42
+ catch (e) {
43
+ const status = e instanceof WireError ? e.status : 500;
44
+ if (status === 500)
45
+ console.error(e);
46
+ else if (process.env.NODE_ENV !== 'production')
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 : 'Error');
49
+ }
50
+ });
51
+ // File uploads: URL signed by `_startUpload`, CSRF token in a header, files stored in the temporary directory.
52
+ router.post(`${prefix}/upload-file`, async (req, res) => {
53
+ try {
54
+ const store = runtime.uploads;
55
+ if (!store)
56
+ throw new WireError(404, 'Uploads are not available.');
57
+ if (!store.verifySignature(req.query.expires, req.query.signature))
58
+ throw new WireError(401, 'Invalid or expired upload URL.');
59
+ if (!verifyCsrf(req, req.get('x-csrf-token')))
60
+ throw new WireError(419, 'Invalid CSRF token.');
61
+ if (!(await canUpload(await makeContext(req, res))))
62
+ throw new WireError(401, 'Unauthenticated.');
63
+ res.json({ paths: await store.receive(req) });
64
+ }
65
+ catch (e) {
66
+ const status = e instanceof WireError ? e.status : Number(e?.status) || 500;
67
+ if (status === 500)
68
+ console.error(e);
69
+ res.status(status).json({ message: e instanceof Error ? e.message : 'Error' });
70
+ }
71
+ });
72
+ return router;
73
+ }
74
+ /** `<script>` tag that loads livewire.js with its CSRF token and endpoint URLs. */
75
+ export function wireScripts(req, res, prefix = '/livewire') {
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
+ }
78
+ /** Styles livewire.js expects (loading states, cloak, progress bar); place them in `<head>`. */
79
+ export function wireStyles(progressBarColor = '#2299dd') {
80
+ const BS = String.fromCharCode(92); // backslash: escapes `:` and `.` in CSS selectors
81
+ const sel = (n) => `[wire${BS}:${n.replaceAll('.', BS + '.')}]`;
82
+ const doubled = (names) => names.map((n) => sel(n) + sel(n)).join(', ');
83
+ return '<!-- Livewire Styles --><style>'
84
+ + doubled(['loading', 'loading.delay', 'loading.list-item', 'loading.inline-block', 'loading.inline', 'loading.block', 'loading.flex', 'loading.table', 'loading.grid', 'loading.inline-flex']) + ' {display: none;}'
85
+ + doubled(['loading.delay.none', 'loading.delay.shortest', 'loading.delay.shorter', 'loading.delay.short', 'loading.delay.default', 'loading.delay.long', 'loading.delay.longer', 'loading.delay.longest']) + ' {display: none;}'
86
+ + doubled(['offline']) + ' {display: none;}'
87
+ + sel('dirty') + ':not(textarea):not(input):not(select) {display: none;}'
88
+ + `:root {--livewire-progress-bar-color: ${progressBarColor};}`
89
+ + '[x-cloak="x-cloak"],[x-cloak=""] {display: none !important;}'
90
+ + sel('cloak').replace(']', '="wire:cloak"]') + ',' + sel('cloak').replace(']', '=""]') + ' {display: none !important;}'
91
+ + 'dialog#livewire-error::backdrop {background-color: rgba(0, 0, 0, .6);}</style>';
92
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { UploadStore } from './uploads.ts';
2
+ export { UploadStore, type TemporaryFile } from './uploads.ts';
3
+ export type Effects = {
4
+ html?: string;
5
+ returns?: unknown[];
6
+ dispatches?: {
7
+ name: string;
8
+ params: Record<string, unknown>;
9
+ self?: boolean;
10
+ to?: string;
11
+ }[];
12
+ redirect?: string;
13
+ redirectUsingNavigate?: boolean;
14
+ partials?: Record<string, string>;
15
+ [key: string]: unknown;
16
+ };
17
+ export type Snapshot = {
18
+ data: Record<string, unknown>;
19
+ memo: Record<string, any>;
20
+ checksum: string;
21
+ };
22
+ export type Call = {
23
+ method: string;
24
+ params: unknown[];
25
+ metadata?: Record<string, unknown>;
26
+ };
27
+ type ComponentClass = (new () => Component) & {
28
+ componentName?: string;
29
+ callable?: string[];
30
+ locked?: string[];
31
+ reactive?: string[];
32
+ listeners?: Record<string, string>;
33
+ };
34
+ export declare class WireError extends Error {
35
+ status: number;
36
+ constructor(status: number, message: string);
37
+ }
38
+ /** Base class of a component. Every own enumerable property not starting with `_` is public state. */
39
+ export declare abstract class Component {
40
+ _id: string;
41
+ _effects: Effects;
42
+ _skipRender: boolean;
43
+ _errors: Record<string, string[]>;
44
+ /** Context of the current HTTP request (request, response, panel…), supplied by the integration. */
45
+ _ctx: any;
46
+ /** Runtime and originating request, used to mount child components while rendering. */
47
+ _runtime: WireRuntime | null;
48
+ _request: {
49
+ path?: string;
50
+ method?: string;
51
+ };
52
+ /** Children rendered during this request, and children already in the browser: key → [tag, id]. */
53
+ _children: Record<string, [string, string]>;
54
+ _previousChildren: Record<string, [string, string]>;
55
+ /** HTML shown until a lazy component is loaded; must have a single root. */
56
+ placeholder(): string | Promise<string>;
57
+ /**
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
+ */
62
+ mountChild(name: string, params: Record<string, unknown>, key: string, options?: {
63
+ lazy?: boolean;
64
+ }): Promise<string>;
65
+ /** Renders the component's HTML; must have a single root element. */
66
+ abstract render(): string | Promise<string>;
67
+ mount(_params: Record<string, unknown>): void | Promise<void>;
68
+ boot(): void | Promise<void>;
69
+ hydrate(): void | Promise<void>;
70
+ dehydrate(): void | Promise<void>;
71
+ get id(): string;
72
+ /** Dispatches a browser event; with `self`, only this component receives it. */
73
+ dispatch(name: string, params?: Record<string, unknown> | unknown[], options?: {
74
+ self?: boolean;
75
+ }): void;
76
+ redirect(url: string, navigate?: boolean): void;
77
+ skipRender(): void;
78
+ /** Partial render: only the `wire:partial="<name>"` fragment is sent back (effects.partials), the component is not re-rendered. */
79
+ renderPartial(name: string, html: string): void;
80
+ addError(path: string, message: string): void;
81
+ resetErrorBag(): void;
82
+ }
83
+ /**
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
+ */
88
+ export declare function callableMethods(component: Component): Set<string>;
89
+ export declare function publicProperties(component: Component): string[];
90
+ export declare function dehydrateValue(value: unknown): unknown;
91
+ export declare function hydrateValue(value: unknown): unknown;
92
+ /** Adds attributes to the root element of an HTML fragment. */
93
+ export declare function insertAttributesIntoHtmlRoot(html: string, attributes: Record<string, unknown>): string;
94
+ export type RuntimeOptions = {
95
+ key: string;
96
+ locale?: string; /** Temporary upload store; without it, file uploads are refused. */
97
+ uploads?: UploadStore;
98
+ uploadPrefix?: string;
99
+ };
100
+ export declare class WireRuntime {
101
+ private registry;
102
+ private options;
103
+ constructor(options: RuntimeOptions);
104
+ get uploads(): UploadStore | null;
105
+ /** Params passed by a parent to its existing children during the current request: child id → params. */
106
+ private requestScope;
107
+ passPropsToChild(childId: string, params: Record<string, unknown>): void;
108
+ private names;
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;
112
+ private make;
113
+ checksum(snapshot: Omit<Snapshot, 'checksum'>): string;
114
+ private snapshot;
115
+ /** Signed mount params of a lazy component, in a `__mountParamsContainer`: the browser sends them back unchanged to `__lazyLoad`. */
116
+ private encodeMountParams;
117
+ private decodeMountParams;
118
+ /** A mount param named like a public property is copied into it. */
119
+ private assignMatchingProperties;
120
+ private renderRoot;
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
+ */
125
+ mount(name: string, params?: Record<string, unknown>, request?: {
126
+ path?: string;
127
+ method?: string;
128
+ ctx?: unknown;
129
+ }): Promise<string>;
130
+ /** Like mount(), but also returns the mounted instance (to read its title, for example). */
131
+ mountWithInstance(name: string, params?: Record<string, unknown>, request?: {
132
+ path?: string;
133
+ method?: string;
134
+ ctx?: unknown;
135
+ lazy?: boolean;
136
+ key?: string;
137
+ }): Promise<{
138
+ html: string;
139
+ component: Component;
140
+ }>;
141
+ /** Component update request: hydrate, apply updates, run calls, render, dehydrate. */
142
+ update(snapshot: Snapshot, updates: Record<string, unknown>, calls: Call[], ctx?: unknown): Promise<{
143
+ snapshot: Snapshot;
144
+ effects: Effects;
145
+ }>;
146
+ /** HTTP entry point; body: `{ _token, components: [{ snapshot, updates, calls }] }`. */
147
+ handle(body: any, ctx?: unknown): Promise<{
148
+ components: {
149
+ snapshot: string;
150
+ effects: Effects;
151
+ }[];
152
+ assets: unknown[];
153
+ }>;
154
+ }