@shamar/wire 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -0
- package/client/wire.js +722 -0
- package/dist/component.d.ts +21 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/component.js +32 -0
- package/dist/component.js.map +1 -0
- package/dist/html.d.ts +3 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +11 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +43 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +129 -0
- package/dist/kernel.js.map +1 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +33 -0
- package/dist/snapshot.js.map +1 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# @shamar/wire
|
|
2
|
+
|
|
3
|
+
Server-driven reactive components for Shamar admin panels. This is the runtime those panels are built on: the same role [Livewire](https://livewire.laravel.com/) plays for Filament, and Conduit plays for Almasix Orbit.
|
|
4
|
+
|
|
5
|
+
`@shamar/wire` is the kernel and the browser script (`client/wire.js`, `wireClientPath()`). On Adonis, `@shamar/adonis` discovers `app/wire`, serves `GET /wire.js` and `POST /wire`, and renders `@wire('counter')`. `node ace make:wire counter` creates the class and the Edge view. A Shamar admin panel is one host. Any other Node server calls `mount` and `update` itself. The docs section **Wire** walks through both.
|
|
6
|
+
|
|
7
|
+
Existing Alpine UI stays. A piece of the panel moves onto Wire when its markup opts in with `wire:*`. `live()` form fields still use `POST {panel}/{slug}/form-state`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
`@shamar/wire` is not on npm yet. The packages published as 0.3.0 (`@shamar/adonis` and the rest) do not depend on it. The next release publishes this package first, then `@shamar/adonis`, because the host depends on it. Until then, use it from this repository.
|
|
12
|
+
|
|
13
|
+
After that release, the panel package pulls Wire in:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @shamar/adonis
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`@shamar/adonis` depends on `@shamar/wire` and re-exports `WireKernel`, `WireComponent`, and `WireRequest`. Install the kernel on its own only when you are not using the Adonis host:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @shamar/wire
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No peer dependencies. Node 20+.
|
|
26
|
+
|
|
27
|
+
## What a request does
|
|
28
|
+
|
|
29
|
+
1. **Mount.** The server creates the component, copies `data` into a signed snapshot, and returns one HTML island.
|
|
30
|
+
2. **Commit.** The browser posts that snapshot plus property updates and method calls to the island’s `wire:endpoint`.
|
|
31
|
+
3. **Verify.** The server rejects the body unless the snapshot checksum matches. Public state is whatever is on `data` after `create()`; keys the client invents are ignored.
|
|
32
|
+
4. **Run.** Property hooks run, then method calls, in that order.
|
|
33
|
+
5. **Render.** The server signs a new snapshot with the same island id and returns HTML. The browser morphs the current island so focus, cursor, and nested Alpine trees survive.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"snapshot": { "id": "…", "name": "counter", "data": { "count": 0 }, "checksum": "…" },
|
|
38
|
+
"updates": { "count": 1 },
|
|
39
|
+
"calls": [{ "method": "increment", "params": [] }]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The response is the same shape the kernel returns from `mount` and `update`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"snapshot": { "id": "…", "name": "counter", "data": { "count": 2 }, "checksum": "…" },
|
|
48
|
+
"html": "<div wire:id=\"…\" wire:snapshot=\"…\" wire:endpoint=\"/admin/wire\">…</div>",
|
|
49
|
+
"effects": { "redirect": "/admin/orders/1" }
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`effects` is present only when the component set one. A redirect is not stored in the snapshot.
|
|
54
|
+
|
|
55
|
+
## Write a component
|
|
56
|
+
|
|
57
|
+
A component is a small object with public `data` and optional methods. Register it by name on a `WireKernel`.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { WireKernel, type WireComponent } from '@shamar/wire'
|
|
61
|
+
|
|
62
|
+
class Counter implements WireComponent {
|
|
63
|
+
data = { count: 0 }
|
|
64
|
+
|
|
65
|
+
increment(): void {
|
|
66
|
+
this.data.count = Number(this.data.count) + 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
updatedCount(): void {
|
|
70
|
+
if (Number(this.data.count) < 0) this.data.count = 0
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const kernel = new WireKernel(process.env.APP_KEY!, {
|
|
75
|
+
counter: {
|
|
76
|
+
create: () => new Counter(),
|
|
77
|
+
render: (component) => `<button wire:click="increment">${component.data.count}</button>`,
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const island = kernel.mount('counter', '/admin/wire')
|
|
82
|
+
// island.html is the first render
|
|
83
|
+
// island.snapshot is what the browser must post back
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`create()` runs on every update. The kernel then copies snapshot `data` onto the new instance, applying only keys that already exist. Do not keep request-only state on the class and expect it to survive the next POST. Put it on `data`, or recompute it inside `updated` / the action method.
|
|
87
|
+
|
|
88
|
+
### Property hooks
|
|
89
|
+
|
|
90
|
+
After a public property changes, the kernel calls, in order:
|
|
91
|
+
|
|
92
|
+
| Hook | When |
|
|
93
|
+
|------|------|
|
|
94
|
+
| `updating(key, value)` | Before `data[key]` is assigned |
|
|
95
|
+
| `updated(key)` | After the assignment |
|
|
96
|
+
| `updatedTitle()` | After `title` changes. The name is `updated` + the key with its first letter uppercased |
|
|
97
|
+
|
|
98
|
+
Hooks may be async. A throw fails the request; the browser leaves the island as it was.
|
|
99
|
+
|
|
100
|
+
### Methods
|
|
101
|
+
|
|
102
|
+
`wire:click`, `wire:submit`, and `wire:keydown` become `calls`. The kernel invokes the matching function on the component with the parsed arguments.
|
|
103
|
+
|
|
104
|
+
These names are rejected: `data`, `updated`, `call`, `constructor`, `hydrate`, `dehydrate`, `render`, and any name that starts with `_`. An unknown name throws `Unknown wire method`.
|
|
105
|
+
|
|
106
|
+
### Effects
|
|
107
|
+
|
|
108
|
+
Set `this.effects` during a method. The browser navigates on `redirect` and does not morph.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
save() {
|
|
112
|
+
this.effects = { redirect: '/admin/orders/4' }
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## HTML the kernel emits
|
|
117
|
+
|
|
118
|
+
`mount` and `update` wrap your `render()` result:
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<div wire:id="a1b2…" wire:snapshot="{…signed json…}" wire:endpoint="/admin/wire">
|
|
122
|
+
<!-- render() -->
|
|
123
|
+
</div>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`wire:snapshot` is the full snapshot, including `checksum`, escaped for an attribute. Treat it as opaque. Rendering user content inside the island is your job: use `escapeHtml` / `escapeAttr` from this package.
|
|
127
|
+
|
|
128
|
+
## Browser runtime
|
|
129
|
+
|
|
130
|
+
`@shamar/adonis` serves `assets/wire.js` at `{panel}/assets/wire.js` and loads it from the admin shell. The script listens on `document`, so every island on the page shares one runtime.
|
|
131
|
+
|
|
132
|
+
It coexists with Alpine. On `alpine:init` it registers the `$wire` magic. After a morph it calls `Alpine.initTree` on nodes that were inserted.
|
|
133
|
+
|
|
134
|
+
### Bindings
|
|
135
|
+
|
|
136
|
+
| Attribute | Behavior |
|
|
137
|
+
|-----------|----------|
|
|
138
|
+
| `wire:model="title"` | Queue the value. It is sent with the next action (`wire:click`, `wire:submit`, …). |
|
|
139
|
+
| `wire:model.live="title"` | POST on `input`. |
|
|
140
|
+
| `wire:model.blur="title"` | POST on `blur`. |
|
|
141
|
+
| `wire:model.debounce.250ms="query"` | POST after 250ms of quiet. |
|
|
142
|
+
| `wire:click="save"` | POST a call to `save`. |
|
|
143
|
+
| `wire:click="remove('draft')"` | Call with parsed arguments. Quoted strings, numbers, `true`, `false`, and `null` are typed; anything else is a string. |
|
|
144
|
+
| `wire:submit="save"` | On the `<form>`. Prevents the browser submit and calls `save`. |
|
|
145
|
+
| `wire:keydown.enter="save"` | Call when Enter is pressed inside the element. |
|
|
146
|
+
| `wire:keydown="onKey"` | Call for any key on that element. |
|
|
147
|
+
| `wire:loading` | Hidden until a request for this island is in flight. |
|
|
148
|
+
| `wire:loading.remove` | Visible until a request is in flight, then hidden. |
|
|
149
|
+
| `wire:ignore` | Morph leaves this element and its descendants alone. |
|
|
150
|
+
| `wire:key="{{ id }}"` | Stable identity for a child so morph reuses the DOM node. |
|
|
151
|
+
|
|
152
|
+
A later request for the same island supersedes an in-flight one. The stale response is discarded.
|
|
153
|
+
|
|
154
|
+
### `$wire` in Alpine
|
|
155
|
+
|
|
156
|
+
Inside an island, after Alpine starts:
|
|
157
|
+
|
|
158
|
+
```html
|
|
159
|
+
<button type="button" x-on:click="$wire.increment()">Add</button>
|
|
160
|
+
<button type="button" x-on:click="$wire.$set('title', 'Draft')">Rename</button>
|
|
161
|
+
<span x-text="$wire.count"></span>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
| Access | Effect |
|
|
165
|
+
|--------|--------|
|
|
166
|
+
| `$wire.count` | Read the property from the current snapshot. |
|
|
167
|
+
| `$wire.count = 3` | POST `{ updates: { count: 3 } }`. |
|
|
168
|
+
| `$wire.increment(1)` | POST a call. |
|
|
169
|
+
| `$wire.$set('count', 3)` | Same as a property write. |
|
|
170
|
+
| `$wire.$call('increment', 1)` | Same as a method call. |
|
|
171
|
+
|
|
172
|
+
## Signing
|
|
173
|
+
|
|
174
|
+
Snapshots are HMAC-SHA256 over `id`, `name`, and a canonical JSON form of `data` (object keys sorted). `signSnapshot` and `verifySnapshot` use `timingSafeEqual`. `newComponentId()` is 8 random bytes, hex-encoded.
|
|
175
|
+
|
|
176
|
+
Pass a stable secret into `WireKernel`. The checksum covers public `data` only, so a client cannot add properties, rename the component, or change the island id without failing verification.
|
|
177
|
+
|
|
178
|
+
The panel’s global search uses a process-lifetime secret (`randomBytes` at module load). A restart invalidates islands that were already on the page; the next full navigation mounts a fresh one.
|
|
179
|
+
|
|
180
|
+
## How `@shamar/adonis` hosts it
|
|
181
|
+
|
|
182
|
+
| Piece | Where |
|
|
183
|
+
|-------|--------|
|
|
184
|
+
| `POST {panel}/wire` | `AdminController.wire`. Requires an authenticated panel session and returns JSON. |
|
|
185
|
+
| `GET {panel}/assets/wire.js` | Browser runtime. |
|
|
186
|
+
| Global search | First island. Rendered in the top bar from `mountGlobalSearch`. Updates go through `updateGlobalSearch`. |
|
|
187
|
+
| Re-exports | `WireKernel`, `WireComponent`, `WireRequest` from `@shamar/adonis`. |
|
|
188
|
+
|
|
189
|
+
Global search walks resources that are visible in navigation and have searchable fields, skips any the current user cannot view, and returns up to eight record links. One resource throwing does not clear the other hits.
|
|
190
|
+
|
|
191
|
+
To add another island, register a component the same way `global-search` is registered and render `kernel.mount(name, endpoint).html` into the Edge view. Point `endpoint` at the panel’s `/wire` route (or your own route that calls `kernel.update`).
|
|
192
|
+
|
|
193
|
+
## Helpers
|
|
194
|
+
|
|
195
|
+
| Export | Use |
|
|
196
|
+
|--------|-----|
|
|
197
|
+
| `WireKernel` | `mount(name, endpoint)` and `update(request, endpoint)`. |
|
|
198
|
+
| `escapeHtml` / `escapeAttr` | Text and attribute escaping inside `render`. |
|
|
199
|
+
| `signSnapshot` / `verifySnapshot` | Checksums, if you store or forward snapshots yourself. |
|
|
200
|
+
| `invokeMethod` | Call a public method with the same blocked-name rules as the kernel. |
|
|
201
|
+
| `applyUpdates` | Copy a patch onto `data` for keys that already exist. Does not run hooks; `WireKernel.update` runs hooks itself. |
|
|
202
|
+
| `newComponentId` | New island id. |
|
|
203
|
+
|
|
204
|
+
## Tests
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pnpm --filter @shamar/wire test
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The suite covers a signed mount, a method call, a rejected tampered snapshot, property `updated` hooks, and a redirect effect that stays out of the snapshot.
|