@orieljs/live 0.1.0-alpha.3
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 +202 -0
- package/README.md +47 -0
- package/dist/index.d.ts +258 -0
- package/dist/index.js +1095 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
// Component.ts
|
|
2
|
+
import { LiveManager, UrlGenerator, ValidationFactory } from "@orieljs/contracts";
|
|
3
|
+
import { inject } from "@orieljs/container";
|
|
4
|
+
|
|
5
|
+
// metadata.ts
|
|
6
|
+
Symbol.metadata ??= /* @__PURE__ */ Symbol.for("Symbol.metadata");
|
|
7
|
+
var LISTENERS = /* @__PURE__ */ Symbol.for("oriel:live.listeners");
|
|
8
|
+
var LOCKED = /* @__PURE__ */ Symbol.for("oriel:live.locked");
|
|
9
|
+
var COMPUTED = /* @__PURE__ */ Symbol.for("oriel:live.computed");
|
|
10
|
+
function own(metadata, key, empty, copy) {
|
|
11
|
+
if (!Object.hasOwn(metadata, key)) metadata[key] = metadata[key] === void 0 ? empty() : copy(metadata[key]);
|
|
12
|
+
return metadata[key];
|
|
13
|
+
}
|
|
14
|
+
function On(event) {
|
|
15
|
+
return function(_method, context) {
|
|
16
|
+
const listeners = own(
|
|
17
|
+
context.metadata,
|
|
18
|
+
LISTENERS,
|
|
19
|
+
() => ({}),
|
|
20
|
+
(inherited) => Object.fromEntries(Object.entries(inherited).map(([name, methods2]) => [name, [...methods2]]))
|
|
21
|
+
);
|
|
22
|
+
const methods = listeners[event] ??= [];
|
|
23
|
+
if (!methods.includes(String(context.name))) methods.push(String(context.name));
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function Locked(_target, context) {
|
|
27
|
+
own(context.metadata, LOCKED, () => [], (inherited) => [...inherited]).push(String(context.name));
|
|
28
|
+
}
|
|
29
|
+
var computedCache = /* @__PURE__ */ new WeakMap();
|
|
30
|
+
function cachedGetter(name, getter) {
|
|
31
|
+
return function() {
|
|
32
|
+
let cache = computedCache.get(this);
|
|
33
|
+
if (cache === void 0) computedCache.set(this, cache = /* @__PURE__ */ new Map());
|
|
34
|
+
if (!cache.has(name)) cache.set(name, getter.call(this));
|
|
35
|
+
return cache.get(name);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function Computed(getter, context) {
|
|
39
|
+
own(context.metadata, COMPUTED, () => [], (inherited) => [...inherited]).push(String(context.name));
|
|
40
|
+
return cachedGetter(String(context.name), getter);
|
|
41
|
+
}
|
|
42
|
+
function forgetComputed(instance) {
|
|
43
|
+
computedCache.delete(instance);
|
|
44
|
+
}
|
|
45
|
+
function metadataOf(cls) {
|
|
46
|
+
return cls[Symbol.metadata] ?? {};
|
|
47
|
+
}
|
|
48
|
+
function chainOf(cls) {
|
|
49
|
+
const chain = [];
|
|
50
|
+
for (let c = cls; c !== null && c !== Function.prototype; c = Object.getPrototypeOf(c)) {
|
|
51
|
+
chain.unshift(c);
|
|
52
|
+
}
|
|
53
|
+
return chain;
|
|
54
|
+
}
|
|
55
|
+
function staticsOf(cls, key) {
|
|
56
|
+
return chainOf(cls).flatMap((c) => Object.hasOwn(c, key) && c[key] !== void 0 ? [c[key]] : []);
|
|
57
|
+
}
|
|
58
|
+
function listenersOf(cls) {
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const [event, methods] of Object.entries(metadataOf(cls)[LISTENERS] ?? {})) {
|
|
61
|
+
result[event] = [...methods];
|
|
62
|
+
}
|
|
63
|
+
for (const listeners of staticsOf(cls, "listeners")) {
|
|
64
|
+
for (const [event, method] of Object.entries(listeners)) {
|
|
65
|
+
const methods = result[event] ??= [];
|
|
66
|
+
if (!methods.includes(method)) methods.push(method);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
function lockedOf(cls) {
|
|
72
|
+
return /* @__PURE__ */ new Set([...metadataOf(cls)[LOCKED] ?? [], ...staticsOf(cls, "locked").flat()]);
|
|
73
|
+
}
|
|
74
|
+
function computedOf(cls) {
|
|
75
|
+
return /* @__PURE__ */ new Set([...metadataOf(cls)[COMPUTED] ?? [], ...staticsOf(cls, "computed").flat()]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Component.ts
|
|
79
|
+
var COMPONENT = /* @__PURE__ */ Symbol.for("oriel:live.component");
|
|
80
|
+
var states = /* @__PURE__ */ new WeakMap();
|
|
81
|
+
function stateOf(component) {
|
|
82
|
+
let state = states.get(component);
|
|
83
|
+
if (state === void 0) states.set(component, state = { id: "", name: "", errors: {}, dispatches: [], skipRender: false });
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
function isComponentClass(value) {
|
|
87
|
+
return typeof value === "function" && value[COMPONENT] === true;
|
|
88
|
+
}
|
|
89
|
+
var ErrorBag = class {
|
|
90
|
+
constructor(messages) {
|
|
91
|
+
this.messages = messages;
|
|
92
|
+
}
|
|
93
|
+
messages;
|
|
94
|
+
has(key) {
|
|
95
|
+
return (this.messages[key]?.length ?? 0) > 0;
|
|
96
|
+
}
|
|
97
|
+
first(key) {
|
|
98
|
+
return key === void 0 ? this.all()[0] : this.messages[key]?.[0];
|
|
99
|
+
}
|
|
100
|
+
get(key) {
|
|
101
|
+
return [...this.messages[key] ?? []];
|
|
102
|
+
}
|
|
103
|
+
all() {
|
|
104
|
+
return Object.values(this.messages).flat();
|
|
105
|
+
}
|
|
106
|
+
any() {
|
|
107
|
+
return !this.isEmpty();
|
|
108
|
+
}
|
|
109
|
+
isEmpty() {
|
|
110
|
+
return this.count() === 0;
|
|
111
|
+
}
|
|
112
|
+
count() {
|
|
113
|
+
return this.all().length;
|
|
114
|
+
}
|
|
115
|
+
toObject() {
|
|
116
|
+
return Object.fromEntries(Object.entries(this.messages).map(([key, messages]) => [key, [...messages]]));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
var PendingEvent = class {
|
|
120
|
+
constructor(event) {
|
|
121
|
+
this.event = event;
|
|
122
|
+
}
|
|
123
|
+
event;
|
|
124
|
+
/** Only this component's own listeners receive the event. */
|
|
125
|
+
self() {
|
|
126
|
+
this.event.self = true;
|
|
127
|
+
delete this.event.to;
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
/** Only components of this class (or registered name) receive the event. */
|
|
131
|
+
to(component) {
|
|
132
|
+
this.event.to = component;
|
|
133
|
+
delete this.event.self;
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var Component = class {
|
|
138
|
+
static [COMPONENT] = true;
|
|
139
|
+
/** The view that wraps a full-page component (default: config `live.layout`). */
|
|
140
|
+
static layout;
|
|
141
|
+
/** The page title passed to the layout as `title`. */
|
|
142
|
+
static title;
|
|
143
|
+
/** Static fallbacks for runtimes without decorators: { event: 'method' }, locked names, computed getter names. */
|
|
144
|
+
static listeners;
|
|
145
|
+
static locked;
|
|
146
|
+
static computed;
|
|
147
|
+
getId() {
|
|
148
|
+
return stateOf(this).id;
|
|
149
|
+
}
|
|
150
|
+
getName() {
|
|
151
|
+
return stateOf(this).name;
|
|
152
|
+
}
|
|
153
|
+
/** Dispatches a browser event; every component listening for it (@On) runs its handler. */
|
|
154
|
+
dispatch(name, params = {}) {
|
|
155
|
+
const event = { name, params };
|
|
156
|
+
stateOf(this).dispatches.push(event);
|
|
157
|
+
return new PendingEvent(event);
|
|
158
|
+
}
|
|
159
|
+
/** Sends the browser to `url` after this request; `navigate` uses o:navigate instead of a full page load. */
|
|
160
|
+
redirect(url, options = {}) {
|
|
161
|
+
const state = stateOf(this);
|
|
162
|
+
state.redirect = url;
|
|
163
|
+
if (options.navigate === true) state.redirectUsingNavigate = true;
|
|
164
|
+
else delete state.redirectUsingNavigate;
|
|
165
|
+
}
|
|
166
|
+
redirectRoute(name, parameters, options = {}) {
|
|
167
|
+
this.redirect(inject(UrlGenerator).route(name, parameters), options);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Validates the public state. On success clears the error bag and returns the validated data; on failure fills the
|
|
171
|
+
* error bag and throws (the rest of the action is skipped and the component re-renders with `errors`).
|
|
172
|
+
*/
|
|
173
|
+
async validate(rules, messages, attributes) {
|
|
174
|
+
const validator = inject(ValidationFactory).make(Object.fromEntries(Object.entries(this)), rules, messages, attributes);
|
|
175
|
+
try {
|
|
176
|
+
const data = await validator.validated();
|
|
177
|
+
stateOf(this).errors = {};
|
|
178
|
+
return data;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const errors = error.errors;
|
|
181
|
+
if (error.kind === "Validation" && errors !== void 0) stateOf(this).errors = { ...errors };
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
addError(key, message) {
|
|
186
|
+
;
|
|
187
|
+
(stateOf(this).errors[key] ??= []).push(message);
|
|
188
|
+
}
|
|
189
|
+
/** Clears every error, or only those of `keys`. */
|
|
190
|
+
resetErrorBag(...keys) {
|
|
191
|
+
const state = stateOf(this);
|
|
192
|
+
if (keys.length === 0) state.errors = {};
|
|
193
|
+
else for (const key of keys) delete state.errors[key];
|
|
194
|
+
}
|
|
195
|
+
get errors() {
|
|
196
|
+
return new ErrorBag(stateOf(this).errors);
|
|
197
|
+
}
|
|
198
|
+
/** Puts public properties (all of them when none are named) back to their initial values. */
|
|
199
|
+
reset(...properties) {
|
|
200
|
+
const fresh = new this.constructor();
|
|
201
|
+
const names = properties.length > 0 ? properties : Object.keys(this);
|
|
202
|
+
for (const name of names) if (Object.hasOwn(fresh, name)) this[name] = fresh[name];
|
|
203
|
+
forgetComputed(this);
|
|
204
|
+
}
|
|
205
|
+
/** Keeps the current HTML: the response carries the new state but no `html` effect. */
|
|
206
|
+
skipRender() {
|
|
207
|
+
stateOf(this).skipRender = true;
|
|
208
|
+
}
|
|
209
|
+
/** A full-page component as a route action: Route.get('/counter', Counter). Route parameters go to mount(). */
|
|
210
|
+
async __invoke() {
|
|
211
|
+
return inject(LiveManager).page(this);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// embed.ts
|
|
216
|
+
import { LiveManager as LiveManager3 } from "@orieljs/contracts";
|
|
217
|
+
import { inject as inject3 } from "@orieljs/container";
|
|
218
|
+
|
|
219
|
+
// Testable.ts
|
|
220
|
+
import { AssertionError } from "assert";
|
|
221
|
+
import { isDeepStrictEqual } from "util";
|
|
222
|
+
import { LiveManager as LiveManager2 } from "@orieljs/contracts";
|
|
223
|
+
import { inject as inject2 } from "@orieljs/container";
|
|
224
|
+
|
|
225
|
+
// errors.ts
|
|
226
|
+
var LiveError = class extends Error {
|
|
227
|
+
name = "LiveError";
|
|
228
|
+
kind = "Server";
|
|
229
|
+
status = 500;
|
|
230
|
+
};
|
|
231
|
+
var CorruptComponentPayloadError = class extends LiveError {
|
|
232
|
+
name = "CorruptComponentPayloadError";
|
|
233
|
+
kind = "Authorization";
|
|
234
|
+
status = 403;
|
|
235
|
+
constructor(component = "unknown") {
|
|
236
|
+
super(`Live encountered corrupt data when trying to hydrate the [${component}] component.`);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var MethodNotCallableError = class extends LiveError {
|
|
240
|
+
name = "MethodNotCallableError";
|
|
241
|
+
kind = "Authorization";
|
|
242
|
+
status = 403;
|
|
243
|
+
constructor(method, component) {
|
|
244
|
+
super(`Unable to call component method. Public method [${method}] not found on component [${component}].`);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var PropertyNotUpdatableError = class extends LiveError {
|
|
248
|
+
name = "PropertyNotUpdatableError";
|
|
249
|
+
kind = "Authorization";
|
|
250
|
+
status = 403;
|
|
251
|
+
constructor(path, component) {
|
|
252
|
+
super(`Cannot update property [${path}] on component [${component}]: it is not a public, unlocked property.`);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
var LiveComponentNotFoundError = class extends LiveError {
|
|
256
|
+
name = "LiveComponentNotFoundError";
|
|
257
|
+
kind = "Http";
|
|
258
|
+
status = 404;
|
|
259
|
+
constructor(component) {
|
|
260
|
+
super(`Unable to find component: [${component}].`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
var InvalidLiveRequestError = class extends LiveError {
|
|
264
|
+
name = "InvalidLiveRequestError";
|
|
265
|
+
kind = "Http";
|
|
266
|
+
status = 400;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// html.ts
|
|
270
|
+
var ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
271
|
+
function escapeAttribute(value) {
|
|
272
|
+
return value.replace(/[&<>"']/g, (char) => ESCAPES[char]);
|
|
273
|
+
}
|
|
274
|
+
var VOID = /* @__PURE__ */ new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
275
|
+
var RAW_TEXT = /* @__PURE__ */ new Set(["script", "style", "textarea", "title"]);
|
|
276
|
+
var TOKEN = /<\/([A-Za-z][\w:-]*)\s*>|<([A-Za-z][\w:-]*)(?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*(\/?)>/g;
|
|
277
|
+
function rootTag(html, component) {
|
|
278
|
+
const fail2 = () => {
|
|
279
|
+
throw new LiveError(`Live only supports one HTML element per component. Multiple root elements were detected for component [${component}].`);
|
|
280
|
+
};
|
|
281
|
+
const source = html.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
282
|
+
let depth = 0;
|
|
283
|
+
let root;
|
|
284
|
+
let rootEnd = -1;
|
|
285
|
+
TOKEN.lastIndex = 0;
|
|
286
|
+
for (let match = TOKEN.exec(source); match !== null; match = TOKEN.exec(source)) {
|
|
287
|
+
const [token, closing, opening, selfClosing] = match;
|
|
288
|
+
if (root === void 0 && match.index !== 0) fail2();
|
|
289
|
+
if (depth === 0 && root !== void 0) fail2();
|
|
290
|
+
if (opening !== void 0) {
|
|
291
|
+
const tag = opening.toLowerCase();
|
|
292
|
+
root ??= tag;
|
|
293
|
+
if (selfClosing === "/" || VOID.has(tag)) {
|
|
294
|
+
if (depth === 0) rootEnd = match.index + token.length;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (RAW_TEXT.has(tag)) {
|
|
298
|
+
const closer = new RegExp(`</${tag}`, "gi");
|
|
299
|
+
closer.lastIndex = TOKEN.lastIndex;
|
|
300
|
+
const end = closer.exec(source);
|
|
301
|
+
if (end !== null) TOKEN.lastIndex = end.index;
|
|
302
|
+
}
|
|
303
|
+
depth++;
|
|
304
|
+
} else if (closing !== void 0) {
|
|
305
|
+
if (--depth < 0) fail2();
|
|
306
|
+
if (depth === 0) rootEnd = match.index + token.length;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (root === void 0 || depth !== 0 || source.slice(rootEnd).trim() !== "") fail2();
|
|
310
|
+
return root;
|
|
311
|
+
}
|
|
312
|
+
function insertAttributesIntoRoot(html, attributes) {
|
|
313
|
+
const source = html.trim();
|
|
314
|
+
const match = /^((?:<!--[\s\S]*?-->\s*)*<[A-Za-z][\w:-]*)/.exec(source);
|
|
315
|
+
if (match === null) throw new LiveError("Live could not find the root element of the component.");
|
|
316
|
+
const rendered = Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join("");
|
|
317
|
+
return match[1] + rendered + source.slice(match[1].length);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Testable.ts
|
|
321
|
+
var fail = (message, actual, expected) => {
|
|
322
|
+
throw new AssertionError({ message, actual, expected, operator: "live" });
|
|
323
|
+
};
|
|
324
|
+
var getPath = (target, path) => path.split(".").reduce((node, segment) => typeof node === "object" && node !== null ? node[segment] : void 0, target);
|
|
325
|
+
var Testable = class _Testable {
|
|
326
|
+
constructor(manager, result) {
|
|
327
|
+
this.manager = manager;
|
|
328
|
+
this.#snapshot = JSON.stringify(result.snapshot);
|
|
329
|
+
this.#html = result.html ?? "";
|
|
330
|
+
this.#effects = result.effects;
|
|
331
|
+
this.#instance = result.instance;
|
|
332
|
+
}
|
|
333
|
+
manager;
|
|
334
|
+
#snapshot;
|
|
335
|
+
#html;
|
|
336
|
+
#effects;
|
|
337
|
+
#instance;
|
|
338
|
+
/** Mounts `component` with `params`, as `<Live component={…} {...params} />` would. */
|
|
339
|
+
static async create(component, params = {}) {
|
|
340
|
+
const manager = inject2(LiveManager2);
|
|
341
|
+
return new _Testable(manager, await manager.mountComponent(component, params));
|
|
342
|
+
}
|
|
343
|
+
/** The component instance after the last request. */
|
|
344
|
+
instance() {
|
|
345
|
+
return this.#instance;
|
|
346
|
+
}
|
|
347
|
+
/** A public property (dotted paths reach into arrays and objects). */
|
|
348
|
+
get(path) {
|
|
349
|
+
return getPath(this.#instance, path);
|
|
350
|
+
}
|
|
351
|
+
/** The last rendered HTML, without the o:id, o:snapshot and o:effects attributes (Livewire strips them too). */
|
|
352
|
+
html() {
|
|
353
|
+
return this.#html.replace(/ o:(id|snapshot|effects)="[^"]*"/g, "");
|
|
354
|
+
}
|
|
355
|
+
/** The effects of the last request. */
|
|
356
|
+
effects() {
|
|
357
|
+
return this.#effects;
|
|
358
|
+
}
|
|
359
|
+
async set(path, value) {
|
|
360
|
+
return this.#update(typeof path === "string" ? { [path]: value } : path, []);
|
|
361
|
+
}
|
|
362
|
+
async call(method, ...params) {
|
|
363
|
+
return this.#update({}, [{ method, params }]);
|
|
364
|
+
}
|
|
365
|
+
async toggle(path) {
|
|
366
|
+
return this.#update({}, [{ method: "$toggle", params: [path] }]);
|
|
367
|
+
}
|
|
368
|
+
/** Dispatches an event to this component, as the browser does for its listeners. */
|
|
369
|
+
async dispatch(event, params = {}) {
|
|
370
|
+
return this.#update({}, [{ method: "__dispatch", params: [event, params] }]);
|
|
371
|
+
}
|
|
372
|
+
async refresh() {
|
|
373
|
+
return this.#update({}, [{ method: "$refresh", params: [] }]);
|
|
374
|
+
}
|
|
375
|
+
async #update(updates, calls) {
|
|
376
|
+
const result = await this.manager.updateComponent(this.#snapshot, updates, calls);
|
|
377
|
+
this.#snapshot = JSON.stringify(result.snapshot);
|
|
378
|
+
if (result.html !== void 0) this.#html = result.html;
|
|
379
|
+
this.#effects = result.effects;
|
|
380
|
+
this.#instance = result.instance;
|
|
381
|
+
return this;
|
|
382
|
+
}
|
|
383
|
+
assertSet(path, value) {
|
|
384
|
+
const actual = this.get(path);
|
|
385
|
+
if (!isDeepStrictEqual(actual, value)) fail(`Failed asserting that property [${path}] is set to the expected value.`, actual, value);
|
|
386
|
+
return this;
|
|
387
|
+
}
|
|
388
|
+
assertNotSet(path, value) {
|
|
389
|
+
if (isDeepStrictEqual(this.get(path), value)) fail(`Failed asserting that property [${path}] is not set to the given value.`, this.get(path), value);
|
|
390
|
+
return this;
|
|
391
|
+
}
|
|
392
|
+
/** Each value, HTML-escaped, appears in the rendered HTML. */
|
|
393
|
+
assertSee(...values) {
|
|
394
|
+
for (const value of values) if (!this.html().includes(escapeAttribute(value))) fail(`Failed asserting that [${value}] is in the rendered HTML.`, this.html(), value);
|
|
395
|
+
return this;
|
|
396
|
+
}
|
|
397
|
+
assertDontSee(...values) {
|
|
398
|
+
for (const value of values) if (this.html().includes(escapeAttribute(value))) fail(`Failed asserting that [${value}] is not in the rendered HTML.`, this.html(), value);
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
/** Each value appears in the rendered HTML as written (not escaped). */
|
|
402
|
+
assertSeeHtml(...values) {
|
|
403
|
+
for (const value of values) if (!this.html().includes(value)) fail(`Failed asserting that [${value}] is in the rendered HTML.`, this.html(), value);
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
assertDispatched(name, params) {
|
|
407
|
+
const dispatched = (this.#effects.dispatches ?? []).filter((event) => event.name === name);
|
|
408
|
+
if (dispatched.length === 0) fail(`Failed asserting that an event [${name}] was dispatched.`, this.#effects.dispatches ?? [], name);
|
|
409
|
+
if (params !== void 0 && !dispatched.some((event) => isDeepStrictEqual(event.params, params))) {
|
|
410
|
+
fail(`Failed asserting that event [${name}] was dispatched with the expected params.`, dispatched.map((event) => event.params), params);
|
|
411
|
+
}
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
assertNotDispatched(name) {
|
|
415
|
+
if ((this.#effects.dispatches ?? []).some((event) => event.name === name)) fail(`Failed asserting that an event [${name}] was not dispatched.`);
|
|
416
|
+
return this;
|
|
417
|
+
}
|
|
418
|
+
/** The error bag has errors for every key given (or any error at all with no keys). */
|
|
419
|
+
assertHasErrors(keys = []) {
|
|
420
|
+
const errors = stateOf(this.#instance).errors;
|
|
421
|
+
const wanted = [keys].flat();
|
|
422
|
+
if (wanted.length === 0 && Object.keys(errors).length === 0) fail("Component has no errors.", errors);
|
|
423
|
+
for (const key of wanted) if ((errors[key]?.length ?? 0) === 0) fail(`Component missing error: ${key}`, errors, key);
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
/** No errors for the keys given (or none at all with no keys). */
|
|
427
|
+
assertHasNoErrors(keys = []) {
|
|
428
|
+
const errors = stateOf(this.#instance).errors;
|
|
429
|
+
const wanted = [keys].flat();
|
|
430
|
+
const found = wanted.length === 0 ? Object.keys(errors).filter((key) => (errors[key]?.length ?? 0) > 0) : wanted.filter((key) => (errors[key]?.length ?? 0) > 0);
|
|
431
|
+
if (found.length > 0) fail(`Component has errors: "${found.join('", "')}"`, errors);
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
assertRedirect(url) {
|
|
435
|
+
const redirect = this.#effects.redirect;
|
|
436
|
+
if (redirect === void 0) fail("Component did not perform a redirect.");
|
|
437
|
+
if (url !== void 0 && redirect !== url) fail("Component redirected to an unexpected URL.", redirect, url);
|
|
438
|
+
return this;
|
|
439
|
+
}
|
|
440
|
+
assertNoRedirect() {
|
|
441
|
+
if (this.#effects.redirect !== void 0) fail("Component performed a redirect.", this.#effects.redirect);
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
/** The last call returned `value`. */
|
|
445
|
+
assertReturned(value) {
|
|
446
|
+
const returned = this.#effects.returns?.at(-1);
|
|
447
|
+
if (!isDeepStrictEqual(returned, value)) fail("Failed asserting that the method returned the expected value.", returned, value);
|
|
448
|
+
return this;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// embed.ts
|
|
453
|
+
async function live(component, params = {}, key) {
|
|
454
|
+
const html = await inject3(LiveManager3).mount(component, params, key === void 0 ? void 0 : String(key));
|
|
455
|
+
return { toHtml: () => html };
|
|
456
|
+
}
|
|
457
|
+
function LiveComponent({ component, "o:key": key, children: _children, ...params }) {
|
|
458
|
+
return live(component, params, key);
|
|
459
|
+
}
|
|
460
|
+
var Live = Object.assign(LiveComponent, {
|
|
461
|
+
/** Registers a component under a name (components in app/Live are registered at boot). */
|
|
462
|
+
component(name, component) {
|
|
463
|
+
inject3(LiveManager3).register(component, name);
|
|
464
|
+
},
|
|
465
|
+
/** Livewire::test(): mounts `component` for assertions. */
|
|
466
|
+
test(component, params = {}) {
|
|
467
|
+
return Testable.create(component, params);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// LiveComponentManager.ts
|
|
472
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
473
|
+
import { randomBytes } from "crypto";
|
|
474
|
+
import {
|
|
475
|
+
ClassRegistry,
|
|
476
|
+
ConfigRepository,
|
|
477
|
+
CurrentRoute,
|
|
478
|
+
Encrypter,
|
|
479
|
+
HttpRequest,
|
|
480
|
+
Redirector,
|
|
481
|
+
ViewFactory
|
|
482
|
+
} from "@orieljs/contracts";
|
|
483
|
+
import { currentContainer } from "@orieljs/container";
|
|
484
|
+
import { Str as Str2 } from "@orieljs/support";
|
|
485
|
+
|
|
486
|
+
// checksum.ts
|
|
487
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
488
|
+
function canonicalJson(value) {
|
|
489
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item === void 0 ? null : item)).join(",")}]`;
|
|
490
|
+
if (typeof value === "object" && value !== null) {
|
|
491
|
+
const entries = Object.keys(value).sort().filter((key) => value[key] !== void 0).map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
|
|
492
|
+
return `{${entries.join(",")}}`;
|
|
493
|
+
}
|
|
494
|
+
return JSON.stringify(value);
|
|
495
|
+
}
|
|
496
|
+
function checksum(key, payload) {
|
|
497
|
+
return createHmac("sha256", key).update(canonicalJson({ data: payload.data, memo: payload.memo })).digest("hex");
|
|
498
|
+
}
|
|
499
|
+
function verifyChecksum(key, payload, given) {
|
|
500
|
+
if (typeof given !== "string") return false;
|
|
501
|
+
const expected = Buffer.from(checksum(key, payload), "utf8");
|
|
502
|
+
const actual = Buffer.from(given, "utf8");
|
|
503
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// naming.ts
|
|
507
|
+
import { existsSync, readdirSync } from "fs";
|
|
508
|
+
import { join, relative, sep } from "path";
|
|
509
|
+
import { pathToFileURL } from "url";
|
|
510
|
+
import { Str } from "@orieljs/support";
|
|
511
|
+
function componentName(file) {
|
|
512
|
+
return file.replace(/\.(tsx|ts|jsx|js|mts|mjs)$/, "").split(/[\\/]/).map((segment) => Str.kebab(segment)).join(".");
|
|
513
|
+
}
|
|
514
|
+
function defaultName(cls) {
|
|
515
|
+
const name = cls.name;
|
|
516
|
+
return typeof name === "string" && name !== "" ? Str.kebab(name) : void 0;
|
|
517
|
+
}
|
|
518
|
+
function componentFiles(directory) {
|
|
519
|
+
if (!existsSync(directory)) return [];
|
|
520
|
+
return readdirSync(directory, { recursive: true, encoding: "utf8" }).filter((file) => /\.(tsx|ts|jsx|js|mts|mjs)$/.test(file) && !/\.(test|spec|d)\.[^.]+$/.test(file)).map((file) => file.split(sep).join("/")).sort();
|
|
521
|
+
}
|
|
522
|
+
async function discover(directory) {
|
|
523
|
+
const found = [];
|
|
524
|
+
for (const file of componentFiles(directory)) {
|
|
525
|
+
const module = await import(
|
|
526
|
+
/* @vite-ignore */
|
|
527
|
+
pathToFileURL(join(directory, file)).href
|
|
528
|
+
);
|
|
529
|
+
found.push([componentName(relative(directory, join(directory, file))), module.default]);
|
|
530
|
+
}
|
|
531
|
+
return found;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// security.ts
|
|
535
|
+
import { Collection as Collection2 } from "@orieljs/collections";
|
|
536
|
+
|
|
537
|
+
// synthesizers.ts
|
|
538
|
+
import { Collection } from "@orieljs/collections";
|
|
539
|
+
function isModel(value) {
|
|
540
|
+
if (typeof value !== "object" || value === null) return false;
|
|
541
|
+
const candidate = value;
|
|
542
|
+
return typeof candidate.getKey === "function" && typeof candidate.getKeyName === "function" && typeof candidate.getTable === "function" && typeof candidate.exists === "boolean";
|
|
543
|
+
}
|
|
544
|
+
var isPlainObject = (value) => {
|
|
545
|
+
const proto = Object.getPrototypeOf(value);
|
|
546
|
+
return proto === Object.prototype || proto === null;
|
|
547
|
+
};
|
|
548
|
+
var isTuple = (value) => Array.isArray(value) && value.length === 2 && typeof value[1] === "object" && value[1] !== null && !Array.isArray(value[1]) && typeof value[1].s === "string";
|
|
549
|
+
function classNameOf(registry, cls) {
|
|
550
|
+
return registry.nameOf(cls) ?? registry.register(cls);
|
|
551
|
+
}
|
|
552
|
+
function dehydrate(value, registry, path, seen = /* @__PURE__ */ new Set()) {
|
|
553
|
+
switch (typeof value) {
|
|
554
|
+
case "undefined":
|
|
555
|
+
return [null, { s: "u" }];
|
|
556
|
+
case "string":
|
|
557
|
+
case "boolean":
|
|
558
|
+
return value;
|
|
559
|
+
case "number":
|
|
560
|
+
if (!Number.isFinite(value)) throw new LiveError(`Property [${path}] is ${value}: Live state must be JSON (finite numbers only).`);
|
|
561
|
+
return value;
|
|
562
|
+
case "bigint":
|
|
563
|
+
case "symbol":
|
|
564
|
+
case "function":
|
|
565
|
+
throw new LiveError(`Property [${path}] holds a ${typeof value}: Live state may only hold primitives, arrays, plain objects, Dates, models and collections.`);
|
|
566
|
+
}
|
|
567
|
+
if (value === null) return null;
|
|
568
|
+
const object = value;
|
|
569
|
+
if (seen.has(object)) throw new LiveError(`Property [${path}] contains a circular reference.`);
|
|
570
|
+
seen.add(object);
|
|
571
|
+
try {
|
|
572
|
+
if (object instanceof Date) return [object.toISOString(), { s: "dt" }];
|
|
573
|
+
if (Array.isArray(object)) return [object.map((item, i) => dehydrate(item, registry, `${path}.${i}`, seen)), { s: "arr" }];
|
|
574
|
+
if (isModel(object)) {
|
|
575
|
+
const name = classNameOf(registry, object.constructor);
|
|
576
|
+
if (!object.exists) throw new LiveError(`Property [${path}] holds an unsaved [${name}] model: only saved models can be kept between requests.`);
|
|
577
|
+
return [null, { s: "mdl", class: name, key: object.getKey() }];
|
|
578
|
+
}
|
|
579
|
+
if (object instanceof Collection) {
|
|
580
|
+
const items = object.all();
|
|
581
|
+
const first = items[0];
|
|
582
|
+
if (items.length > 0 && isModel(first) && items.every((item) => isModel(item) && item.constructor === first.constructor)) {
|
|
583
|
+
for (const [i, item] of items.entries()) {
|
|
584
|
+
if (!item.exists) throw new LiveError(`Property [${path}.${i}] holds an unsaved model.`);
|
|
585
|
+
}
|
|
586
|
+
const name = classNameOf(registry, first.constructor);
|
|
587
|
+
return [null, { s: "elcln", class: name, keys: items.map((item) => item.getKey()) }];
|
|
588
|
+
}
|
|
589
|
+
return [items.map((item, i) => dehydrate(item, registry, `${path}.${i}`, seen)), { s: "clctn" }];
|
|
590
|
+
}
|
|
591
|
+
if (isPlainObject(object)) {
|
|
592
|
+
const out = {};
|
|
593
|
+
for (const [key, item] of Object.entries(object)) out[key] = dehydrate(item, registry, `${path}.${key}`, seen);
|
|
594
|
+
return [out, { s: "obj" }];
|
|
595
|
+
}
|
|
596
|
+
const type = object.constructor?.name || "object";
|
|
597
|
+
throw new LiveError(
|
|
598
|
+
`Property [${path}] holds a [${type}] instance: Live state may only hold primitives, arrays, plain objects, Dates, models and collections.`
|
|
599
|
+
);
|
|
600
|
+
} finally {
|
|
601
|
+
seen.delete(object);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function modelClass(registry, name) {
|
|
605
|
+
if (typeof name !== "string" || !registry.has(name)) throw new LiveError(`Unable to hydrate the [${String(name)}] model: it is not registered.`);
|
|
606
|
+
return registry.resolve(name);
|
|
607
|
+
}
|
|
608
|
+
async function hydrate(value, registry) {
|
|
609
|
+
if (!isTuple(value)) return value;
|
|
610
|
+
const [inner, meta] = value;
|
|
611
|
+
switch (meta.s) {
|
|
612
|
+
case "u":
|
|
613
|
+
return void 0;
|
|
614
|
+
case "dt":
|
|
615
|
+
return new Date(String(inner));
|
|
616
|
+
case "arr":
|
|
617
|
+
return Promise.all(inner.map((item) => hydrate(item, registry)));
|
|
618
|
+
case "clctn":
|
|
619
|
+
return new Collection(await Promise.all(inner.map((item) => hydrate(item, registry))));
|
|
620
|
+
case "obj": {
|
|
621
|
+
const out = {};
|
|
622
|
+
for (const [key, item] of Object.entries(inner)) {
|
|
623
|
+
Object.defineProperty(out, key, { value: await hydrate(item, registry), enumerable: true, writable: true, configurable: true });
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
case "mdl":
|
|
628
|
+
return modelClass(registry, meta.class).findOrFail(meta.key);
|
|
629
|
+
case "elcln": {
|
|
630
|
+
const cls = modelClass(registry, meta.class);
|
|
631
|
+
const keys = meta.keys ?? [];
|
|
632
|
+
const keyName = new cls().getKeyName();
|
|
633
|
+
const found = [...await cls.query().whereIn(keyName, keys).get()];
|
|
634
|
+
const byKey = new Map(found.map((model) => [String(model.getKey()), model]));
|
|
635
|
+
return new Collection(keys.map((key) => byKey.get(String(key))).filter((model) => model !== void 0));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
throw new LiveError(`Unknown snapshot value type [${String(meta.s)}].`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// security.ts
|
|
642
|
+
var HOOKS = /* @__PURE__ */ new Set(["constructor", "render", "mount", "boot", "booted", "hydrate", "dehydrate", "updating", "updated", "rendering", "rendered", "exception"]);
|
|
643
|
+
var HOOK_PREFIX = /^(updating|updated|hydrate|dehydrate)[A-Z_]/;
|
|
644
|
+
var UNSAFE_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
645
|
+
var BASE_API = new Set(Object.getOwnPropertyNames(Component.prototype));
|
|
646
|
+
var INDEX = /^(?:0|[1-9]\d*)$/;
|
|
647
|
+
function isArraySlot(array, segment) {
|
|
648
|
+
return INDEX.test(segment) && Number(segment) <= array.length;
|
|
649
|
+
}
|
|
650
|
+
function isCallable(instance, method) {
|
|
651
|
+
if (method === "" || method.startsWith("_") || method.startsWith("$") || method.startsWith("#")) return false;
|
|
652
|
+
if (HOOKS.has(method) || HOOK_PREFIX.test(method) || BASE_API.has(method)) return false;
|
|
653
|
+
if (lockedOf(instance.constructor).has(method)) return false;
|
|
654
|
+
const isBase = (proto) => proto === Component.prototype || proto === Object.prototype;
|
|
655
|
+
for (let proto = Object.getPrototypeOf(instance); proto !== null && !isBase(proto); proto = Object.getPrototypeOf(proto)) {
|
|
656
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, method);
|
|
657
|
+
if (descriptor !== void 0) return typeof descriptor.value === "function";
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
function isUpdatable(instance, path) {
|
|
662
|
+
const segments = path.split(".");
|
|
663
|
+
const root = segments[0];
|
|
664
|
+
if (root === "" || root.startsWith("_") || segments.some((segment) => segment === "" || UNSAFE_SEGMENTS.has(segment))) return false;
|
|
665
|
+
if (!Object.hasOwn(instance, root)) return false;
|
|
666
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, root);
|
|
667
|
+
if (!descriptor.enumerable || descriptor.get !== void 0) return false;
|
|
668
|
+
const cls = instance.constructor;
|
|
669
|
+
if (lockedOf(cls).has(root) || computedOf(cls).has(root)) return false;
|
|
670
|
+
let node = descriptor.value;
|
|
671
|
+
for (const segment of segments.slice(1)) {
|
|
672
|
+
if (isModel(node) || node instanceof Collection2) return false;
|
|
673
|
+
if (Array.isArray(node) && !isArraySlot(node, segment)) return false;
|
|
674
|
+
if (typeof node !== "object" || node === null || !Object.hasOwn(node, segment)) return true;
|
|
675
|
+
node = node[segment];
|
|
676
|
+
}
|
|
677
|
+
return !isModel(node) && !(node instanceof Collection2);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// LiveComponentManager.ts
|
|
681
|
+
var renderContext = new AsyncLocalStorage();
|
|
682
|
+
function optional(container, token) {
|
|
683
|
+
if (!container.bound(token)) return void 0;
|
|
684
|
+
try {
|
|
685
|
+
return container.make(token);
|
|
686
|
+
} catch {
|
|
687
|
+
return void 0;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
691
|
+
function setOwn(object, key, value) {
|
|
692
|
+
Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true });
|
|
693
|
+
}
|
|
694
|
+
async function hook(instance, name, ...args) {
|
|
695
|
+
const method = instance[name];
|
|
696
|
+
if (typeof method === "function") await method.apply(instance, args);
|
|
697
|
+
}
|
|
698
|
+
var LiveComponentManager = class {
|
|
699
|
+
constructor(app) {
|
|
700
|
+
this.app = app;
|
|
701
|
+
}
|
|
702
|
+
app;
|
|
703
|
+
/** The request scope when there is one (sessions, the current route), else the application. */
|
|
704
|
+
container() {
|
|
705
|
+
return currentContainer() ?? this.app;
|
|
706
|
+
}
|
|
707
|
+
registry() {
|
|
708
|
+
return this.container().make(ClassRegistry);
|
|
709
|
+
}
|
|
710
|
+
/** Live.component('counter', Counter): registers a component under a name the snapshots use. */
|
|
711
|
+
register(component, name) {
|
|
712
|
+
if (!isComponentClass(component)) throw new LiveError(`[${component.name}] is not a Live component: it must extend Component.`);
|
|
713
|
+
const resolved = name ?? defaultName(component);
|
|
714
|
+
if (resolved === void 0) throw new LiveError("Anonymous component classes need a name: Live.component(name, Class).");
|
|
715
|
+
this.registry().register(component, resolved);
|
|
716
|
+
}
|
|
717
|
+
/** The registered name of a component class, registering it under its kebab-case class name the first time. */
|
|
718
|
+
nameOf(component) {
|
|
719
|
+
const registry = this.registry();
|
|
720
|
+
const existing = registry.nameOf(component);
|
|
721
|
+
if (existing !== void 0) return existing;
|
|
722
|
+
this.register(component);
|
|
723
|
+
return registry.nameOf(component);
|
|
724
|
+
}
|
|
725
|
+
resolve(name) {
|
|
726
|
+
const registry = this.registry();
|
|
727
|
+
if (!registry.has(name)) throw new LiveComponentNotFoundError(name);
|
|
728
|
+
const cls = registry.resolve(name);
|
|
729
|
+
if (!isComponentClass(cls)) throw new LiveComponentNotFoundError(name);
|
|
730
|
+
return cls;
|
|
731
|
+
}
|
|
732
|
+
isLiveRequest(request) {
|
|
733
|
+
return request.header("X-Oriel-Live") === "true";
|
|
734
|
+
}
|
|
735
|
+
/** The first render: HTML with o:id, o:snapshot and o:effects on the root element. */
|
|
736
|
+
async mount(component, params = {}, key) {
|
|
737
|
+
const context = renderContext.getStore();
|
|
738
|
+
if (context === void 0) return (await this.mountComponent(component, params)).html;
|
|
739
|
+
const childKey = key ?? `lw-${context.index++}`;
|
|
740
|
+
const existing = Object.hasOwn(context.previous, childKey) ? context.previous[childKey] : void 0;
|
|
741
|
+
if (existing !== void 0) {
|
|
742
|
+
setOwn(context.children, childKey, existing);
|
|
743
|
+
return `<${existing[0]} o:id="${escapeAttribute(existing[1])}"></${existing[0]}>`;
|
|
744
|
+
}
|
|
745
|
+
const result = await this.mountComponent(component, params);
|
|
746
|
+
setOwn(context.children, childKey, [result.tag, result.snapshot.memo.id]);
|
|
747
|
+
return result.html;
|
|
748
|
+
}
|
|
749
|
+
async mountComponent(component, params = {}, existing) {
|
|
750
|
+
const cls = typeof component === "string" ? this.resolve(component) : component;
|
|
751
|
+
if (!isComponentClass(cls)) throw new LiveError(`[${cls.name}] is not a Live component: it must extend Component.`);
|
|
752
|
+
const name = this.nameOf(cls);
|
|
753
|
+
const instance = existing ?? this.container().make(cls);
|
|
754
|
+
const state = stateOf(instance);
|
|
755
|
+
state.id = randomBytes(15).toString("base64url");
|
|
756
|
+
state.name = name;
|
|
757
|
+
await hook(instance, "boot");
|
|
758
|
+
for (const [property, value] of Object.entries(params)) {
|
|
759
|
+
if (Object.hasOwn(instance, property)) instance[property] = value;
|
|
760
|
+
}
|
|
761
|
+
this.prepareComputed(instance);
|
|
762
|
+
await hook(instance, "mount", params);
|
|
763
|
+
const request = optional(this.container(), HttpRequest);
|
|
764
|
+
const memo = { id: state.id, name, path: request?.path() ?? "", method: request?.method() ?? "GET", children: {}, errors: {} };
|
|
765
|
+
const { html, tag } = await this.render(instance, memo, {});
|
|
766
|
+
const effects = this.effects(instance);
|
|
767
|
+
const listeners = Object.keys(listenersOf(cls));
|
|
768
|
+
if (listeners.length > 0) effects.listeners = listeners;
|
|
769
|
+
const snapshot = await this.snapshot(instance, memo);
|
|
770
|
+
const attributes = { "o:snapshot": JSON.stringify(snapshot), "o:effects": JSON.stringify(effects), "o:id": state.id };
|
|
771
|
+
return { instance, snapshot, effects, html: insertAttributesIntoRoot(html, attributes), tag };
|
|
772
|
+
}
|
|
773
|
+
/** Applies each component's updates and calls, re-renders it and returns its new snapshot and effects. */
|
|
774
|
+
async update(request) {
|
|
775
|
+
const components = [];
|
|
776
|
+
for (const component of request.components) {
|
|
777
|
+
const result = await this.updateComponent(component.snapshot, component.updates, component.calls);
|
|
778
|
+
components.push({ snapshot: JSON.stringify(result.snapshot), effects: result.effects });
|
|
779
|
+
}
|
|
780
|
+
return { components };
|
|
781
|
+
}
|
|
782
|
+
async updateComponent(snapshotJson, updates, calls) {
|
|
783
|
+
const snapshot = this.verify(snapshotJson);
|
|
784
|
+
const cls = this.resolve(snapshot.memo.name);
|
|
785
|
+
const instance = this.container().make(cls);
|
|
786
|
+
const state = stateOf(instance);
|
|
787
|
+
state.id = snapshot.memo.id;
|
|
788
|
+
state.name = snapshot.memo.name;
|
|
789
|
+
state.errors = { ...snapshot.memo.errors };
|
|
790
|
+
await hook(instance, "boot");
|
|
791
|
+
const registry = this.registry();
|
|
792
|
+
for (const [property, value] of Object.entries(snapshot.data)) {
|
|
793
|
+
Object.defineProperty(instance, property, { value: await hydrate(value, registry), enumerable: true, writable: true, configurable: true });
|
|
794
|
+
}
|
|
795
|
+
this.prepareComputed(instance);
|
|
796
|
+
await hook(instance, "hydrate");
|
|
797
|
+
const returns = [];
|
|
798
|
+
try {
|
|
799
|
+
for (const [path, value] of Object.entries(updates)) await this.applyUpdate(instance, path, value);
|
|
800
|
+
for (const call of calls) returns.push(await this.call(instance, call.method, call.params));
|
|
801
|
+
} catch (error) {
|
|
802
|
+
const errors = error.errors;
|
|
803
|
+
if (error.kind !== "Validation" || !isRecord(errors)) throw error;
|
|
804
|
+
state.errors = { ...errors };
|
|
805
|
+
}
|
|
806
|
+
const memo = { ...snapshot.memo, children: { ...snapshot.memo.children } };
|
|
807
|
+
const effects = this.effects(instance);
|
|
808
|
+
let html;
|
|
809
|
+
let tag;
|
|
810
|
+
if (!state.skipRender) {
|
|
811
|
+
;
|
|
812
|
+
({ html, tag } = await this.render(instance, memo, snapshot.memo.children));
|
|
813
|
+
html = insertAttributesIntoRoot(html, { "o:id": state.id });
|
|
814
|
+
effects.html = html;
|
|
815
|
+
}
|
|
816
|
+
if (calls.length > 0) effects.returns = returns;
|
|
817
|
+
return { instance, snapshot: await this.snapshot(instance, memo), effects, html, tag };
|
|
818
|
+
}
|
|
819
|
+
/** The route action of POST /oriel/live/update. */
|
|
820
|
+
async handle(request) {
|
|
821
|
+
if (!this.isLiveRequest(request)) throw new InvalidLiveRequestError("Live update requests need the X-Oriel-Live header.");
|
|
822
|
+
return this.update(this.payload(request));
|
|
823
|
+
}
|
|
824
|
+
/** A full-page component (Route.get('/counter', Counter)): mounted with the route parameters, inside the layout. */
|
|
825
|
+
async page(component) {
|
|
826
|
+
const container = this.container();
|
|
827
|
+
const params = { ...optional(container, CurrentRoute)?.parameters ?? {} };
|
|
828
|
+
const cls = component.constructor;
|
|
829
|
+
const result = await this.mountComponent(cls, params, component);
|
|
830
|
+
const redirect = result.effects.redirect;
|
|
831
|
+
if (redirect !== void 0) {
|
|
832
|
+
return optional(container, Redirector)?.to(redirect) ?? new Response(null, { status: 302, headers: { Location: redirect } });
|
|
833
|
+
}
|
|
834
|
+
const layout = cls.layout ?? optional(container, ConfigRepository)?.get("live.layout") ?? "components.layouts.app";
|
|
835
|
+
const html = result.html;
|
|
836
|
+
const data = { children: { toHtml: () => html } };
|
|
837
|
+
if (cls.title !== void 0) data.title = cls.title;
|
|
838
|
+
return container.make(ViewFactory).make(layout, data);
|
|
839
|
+
}
|
|
840
|
+
/** Parses and checks a snapshot: a bad checksum throws before anything is hydrated. */
|
|
841
|
+
verify(snapshotJson) {
|
|
842
|
+
let snapshot;
|
|
843
|
+
try {
|
|
844
|
+
snapshot = typeof snapshotJson === "string" ? JSON.parse(snapshotJson) : void 0;
|
|
845
|
+
} catch {
|
|
846
|
+
throw new CorruptComponentPayloadError();
|
|
847
|
+
}
|
|
848
|
+
if (!isRecord(snapshot) || !isRecord(snapshot.data) || !isRecord(snapshot.memo)) throw new CorruptComponentPayloadError();
|
|
849
|
+
const memo = snapshot.memo;
|
|
850
|
+
const name = typeof memo.name === "string" ? memo.name : "unknown";
|
|
851
|
+
const key = this.key();
|
|
852
|
+
let valid;
|
|
853
|
+
try {
|
|
854
|
+
valid = verifyChecksum(key, { data: snapshot.data, memo: snapshot.memo }, snapshot.checksum);
|
|
855
|
+
} catch {
|
|
856
|
+
valid = false;
|
|
857
|
+
}
|
|
858
|
+
if (!valid) throw new CorruptComponentPayloadError(name);
|
|
859
|
+
return snapshot;
|
|
860
|
+
}
|
|
861
|
+
key() {
|
|
862
|
+
return this.container().make(Encrypter).getKey();
|
|
863
|
+
}
|
|
864
|
+
/** The request body, read from the raw JSON when the request has it, so TrimStrings can't alter typed values. */
|
|
865
|
+
payload(request) {
|
|
866
|
+
let body;
|
|
867
|
+
try {
|
|
868
|
+
const content = request.content;
|
|
869
|
+
body = typeof content === "function" ? JSON.parse(content.call(request)) : request.all();
|
|
870
|
+
} catch {
|
|
871
|
+
throw new InvalidLiveRequestError("The Live update request body is not valid JSON.");
|
|
872
|
+
}
|
|
873
|
+
const components = isRecord(body) ? body.components : void 0;
|
|
874
|
+
const valid = Array.isArray(components) && components.every(
|
|
875
|
+
(component) => isRecord(component) && typeof component.snapshot === "string" && isRecord(component.updates) && Array.isArray(component.calls) && component.calls.every((call) => isRecord(call) && typeof call.method === "string" && Array.isArray(call.params))
|
|
876
|
+
);
|
|
877
|
+
if (!valid) throw new InvalidLiveRequestError("The Live update request body is malformed.");
|
|
878
|
+
return body;
|
|
879
|
+
}
|
|
880
|
+
/** Makes every computed getter (static `computed` too) cache its value for the rest of the request. */
|
|
881
|
+
prepareComputed(instance) {
|
|
882
|
+
for (const name of computedOf(instance.constructor)) {
|
|
883
|
+
for (let proto = Object.getPrototypeOf(instance); proto !== null; proto = Object.getPrototypeOf(proto)) {
|
|
884
|
+
const getter = Object.getOwnPropertyDescriptor(proto, name)?.get;
|
|
885
|
+
if (getter === void 0) continue;
|
|
886
|
+
Object.defineProperty(instance, name, { get: cachedGetter(name, getter), enumerable: false, configurable: true });
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async applyUpdate(instance, path, value) {
|
|
892
|
+
if (!isUpdatable(instance, path)) throw new PropertyNotUpdatableError(path, stateOf(instance).name);
|
|
893
|
+
const [root, ...rest] = path.split(".");
|
|
894
|
+
const key = rest.length > 0 ? rest.join(".") : void 0;
|
|
895
|
+
const studly = Str2.studly(root);
|
|
896
|
+
await hook(instance, "updating", path, value);
|
|
897
|
+
await hook(instance, `updating${studly}`, value, key);
|
|
898
|
+
const target = instance;
|
|
899
|
+
if (rest.length === 0) target[root] = value;
|
|
900
|
+
else {
|
|
901
|
+
const assertSlot = (node2, segment) => {
|
|
902
|
+
if (Array.isArray(node2) && !isArraySlot(node2, segment)) throw new PropertyNotUpdatableError(path, stateOf(instance).name);
|
|
903
|
+
};
|
|
904
|
+
let node = target[root];
|
|
905
|
+
if (!isRecord(node) && !Array.isArray(node)) target[root] = node = {};
|
|
906
|
+
for (const segment of rest.slice(0, -1)) {
|
|
907
|
+
assertSlot(node, segment);
|
|
908
|
+
const next = node[segment];
|
|
909
|
+
node = isRecord(next) || Array.isArray(next) ? next : node[segment] = {};
|
|
910
|
+
}
|
|
911
|
+
const last = rest.at(-1);
|
|
912
|
+
assertSlot(node, last);
|
|
913
|
+
Object.defineProperty(node, last, { value, enumerable: true, writable: true, configurable: true });
|
|
914
|
+
}
|
|
915
|
+
await hook(instance, "updated", path, value);
|
|
916
|
+
await hook(instance, `updated${studly}`, value, key);
|
|
917
|
+
}
|
|
918
|
+
/** Runs one call from the browser: a magic action, an event, or a public method. Returns its return value. */
|
|
919
|
+
async call(instance, method, params) {
|
|
920
|
+
const name = stateOf(instance).name;
|
|
921
|
+
switch (method) {
|
|
922
|
+
case "$refresh":
|
|
923
|
+
return void 0;
|
|
924
|
+
case "$set":
|
|
925
|
+
return this.applyUpdate(instance, String(params[0]), params[1]);
|
|
926
|
+
case "$toggle": {
|
|
927
|
+
const path = String(params[0]);
|
|
928
|
+
const current = path.split(".").reduce((node, segment) => isRecord(node) || Array.isArray(node) ? node[segment] : void 0, instance);
|
|
929
|
+
return this.applyUpdate(instance, path, !current);
|
|
930
|
+
}
|
|
931
|
+
case "__dispatch": {
|
|
932
|
+
const [event, detail] = params;
|
|
933
|
+
const listeners = listenersOf(instance.constructor);
|
|
934
|
+
for (const listener of Object.hasOwn(listeners, String(event)) ? listeners[String(event)] : []) {
|
|
935
|
+
if (!isCallable(instance, listener)) throw new MethodNotCallableError(listener, name);
|
|
936
|
+
await instance[listener](isRecord(detail) ? detail : {});
|
|
937
|
+
}
|
|
938
|
+
return void 0;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (!isCallable(instance, method)) throw new MethodNotCallableError(method, name);
|
|
942
|
+
return instance[method](...params);
|
|
943
|
+
}
|
|
944
|
+
/** Renders the component (rendering/rendered hooks, nested children tracked) and checks for a single root. */
|
|
945
|
+
async render(instance, memo, previous) {
|
|
946
|
+
await hook(instance, "rendering");
|
|
947
|
+
const context = { previous, children: {}, index: 0 };
|
|
948
|
+
const html = await renderContext.run(context, async () => {
|
|
949
|
+
const output = await instance.render();
|
|
950
|
+
return typeof output === "string" ? output : this.container().make(ViewFactory).renderToString(output);
|
|
951
|
+
});
|
|
952
|
+
await hook(instance, "rendered", html);
|
|
953
|
+
memo.children = context.children;
|
|
954
|
+
return { html: html.trim(), tag: rootTag(html, memo.name) };
|
|
955
|
+
}
|
|
956
|
+
/** Dispatches, redirects: the effects every response carries. */
|
|
957
|
+
effects(instance) {
|
|
958
|
+
const state = stateOf(instance);
|
|
959
|
+
const effects = {};
|
|
960
|
+
if (state.dispatches.length > 0) {
|
|
961
|
+
effects.dispatches = state.dispatches.map(({ name, params, self, to }) => ({
|
|
962
|
+
name,
|
|
963
|
+
params,
|
|
964
|
+
...self ? { self } : {},
|
|
965
|
+
...to === void 0 ? {} : { to: typeof to === "string" ? to : this.nameOf(to) }
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
if (state.redirect !== void 0) effects.redirect = state.redirect;
|
|
969
|
+
if (state.redirectUsingNavigate) effects.redirectUsingNavigate = true;
|
|
970
|
+
return effects;
|
|
971
|
+
}
|
|
972
|
+
/** dehydrate() hook, then the state as snapshot data, the error bag, and the checksum over both. */
|
|
973
|
+
async snapshot(instance, memo) {
|
|
974
|
+
await hook(instance, "dehydrate");
|
|
975
|
+
const registry = this.registry();
|
|
976
|
+
const data = {};
|
|
977
|
+
for (const [property, value] of Object.entries(instance)) data[property] = dehydrate(value, registry, `${memo.name}.${property}`);
|
|
978
|
+
const signed = { data, memo: { ...memo, errors: { ...stateOf(instance).errors } } };
|
|
979
|
+
return { ...signed, checksum: checksum(this.key(), signed) };
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// LiveServiceProvider.ts
|
|
984
|
+
import { ConfigRepository as ConfigRepository3, ConsoleKernel, HttpRequest as HttpRequest2, LiveManager as LiveManager4, Router } from "@orieljs/contracts";
|
|
985
|
+
import { inject as inject4 } from "@orieljs/container";
|
|
986
|
+
import { ServiceProvider } from "@orieljs/support";
|
|
987
|
+
|
|
988
|
+
// commands.ts
|
|
989
|
+
import { existsSync as existsSync2 } from "fs";
|
|
990
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
991
|
+
import { dirname, join as join2, relative as relative2, sep as sep2 } from "path";
|
|
992
|
+
import { ConfigRepository as ConfigRepository2 } from "@orieljs/contracts";
|
|
993
|
+
import { Str as Str3 } from "@orieljs/support";
|
|
994
|
+
var COMPONENT_STUB = `import { Component } from '@orieljs/framework'
|
|
995
|
+
|
|
996
|
+
export default class {{ class }} extends Component {
|
|
997
|
+
render() {
|
|
998
|
+
return <div></div>
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
`;
|
|
1002
|
+
var TEST_STUB = `import { Live } from '@orieljs/framework'
|
|
1003
|
+
import { expect, test } from 'vitest'
|
|
1004
|
+
import {{ class }} from '{{ component }}'
|
|
1005
|
+
import { TestCase } from '{{ testCase }}'
|
|
1006
|
+
|
|
1007
|
+
TestCase.boot()
|
|
1008
|
+
|
|
1009
|
+
test('{{ name }} renders', async () => {
|
|
1010
|
+
const component = await Live.test({{ class }})
|
|
1011
|
+
expect(component.html()).toContain('<div')
|
|
1012
|
+
})
|
|
1013
|
+
`;
|
|
1014
|
+
var CLASS_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1015
|
+
function importPath(from, to) {
|
|
1016
|
+
const path = relative2(dirname(from), to).split(sep2).join("/").replace(/\.tsx?$/, "");
|
|
1017
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
1018
|
+
}
|
|
1019
|
+
function registerLiveCommands(kernel, app) {
|
|
1020
|
+
kernel.command("make:live {name : The component name, e.g. Posts/CreatePost} {--test : Also create a test} {--force : Overwrite existing files}", async function() {
|
|
1021
|
+
const segments = String(this.argument("name") ?? "").split(/[/\\.]+/).filter((segment) => segment !== "").map((segment) => Str3.studly(segment));
|
|
1022
|
+
if (segments.length === 0 || !segments.every((segment) => CLASS_NAME.test(segment))) {
|
|
1023
|
+
this.error(`Component name [${String(this.argument("name"))}] is not valid: use names like Posts/CreatePost.`);
|
|
1024
|
+
return 1;
|
|
1025
|
+
}
|
|
1026
|
+
const className = segments.at(-1);
|
|
1027
|
+
const config = app.bound(ConfigRepository2) ? app.make(ConfigRepository2) : void 0;
|
|
1028
|
+
const directory = config?.get("live.class_path") ?? app.appPath("Live");
|
|
1029
|
+
const file = join2(directory, ...segments.slice(0, -1), `${className}.tsx`);
|
|
1030
|
+
const force = this.option("force") === true;
|
|
1031
|
+
if (existsSync2(file) && !force) {
|
|
1032
|
+
this.error(`Component [${relative2(app.basePath(), file)}] already exists.`);
|
|
1033
|
+
return 1;
|
|
1034
|
+
}
|
|
1035
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1036
|
+
await writeFile(file, COMPONENT_STUB.replace("{{ class }}", () => className));
|
|
1037
|
+
this.info(`Component [${relative2(app.basePath(), file)}] created successfully.`);
|
|
1038
|
+
if (this.option("test") === true) {
|
|
1039
|
+
const testFile = app.basePath(join2("tests", "Feature", "Live", ...segments.slice(0, -1), `${className}.test.ts`));
|
|
1040
|
+
if (existsSync2(testFile) && !force) {
|
|
1041
|
+
this.error(`Test [${relative2(app.basePath(), testFile)}] already exists.`);
|
|
1042
|
+
return 1;
|
|
1043
|
+
}
|
|
1044
|
+
const name = segments.map((segment) => Str3.kebab(segment)).join(".");
|
|
1045
|
+
const test = TEST_STUB.replaceAll("{{ class }}", () => className).replace("{{ component }}", () => importPath(testFile, file)).replace("{{ testCase }}", () => importPath(testFile, app.basePath(join2("tests", "TestCase.ts")))).replace("{{ name }}", () => name);
|
|
1046
|
+
await mkdir(dirname(testFile), { recursive: true });
|
|
1047
|
+
await writeFile(testFile, test);
|
|
1048
|
+
this.info(`Test [${relative2(app.basePath(), testFile)}] created successfully.`);
|
|
1049
|
+
}
|
|
1050
|
+
return 0;
|
|
1051
|
+
}).purpose("Create a new Live component");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// LiveServiceProvider.ts
|
|
1055
|
+
var UPDATE_URI = "/oriel/live/update";
|
|
1056
|
+
var LiveServiceProvider = class extends ServiceProvider {
|
|
1057
|
+
register() {
|
|
1058
|
+
this.mergeConfigFrom({ class_path: this.app.appPath("Live"), layout: "components.layouts.app" }, "live");
|
|
1059
|
+
this.app.singleton(LiveComponentManager, () => new LiveComponentManager(this.app));
|
|
1060
|
+
this.app.singleton(LiveManager4, (container) => container.make(LiveComponentManager));
|
|
1061
|
+
}
|
|
1062
|
+
async boot() {
|
|
1063
|
+
const manager = this.app.make(LiveComponentManager);
|
|
1064
|
+
if (this.app.bound(Router)) {
|
|
1065
|
+
this.app.make(Router).post(UPDATE_URI, () => manager.handle(inject4(HttpRequest2))).middleware("web").name("live.update");
|
|
1066
|
+
}
|
|
1067
|
+
if (this.app.bound(ConsoleKernel)) registerLiveCommands(this.app.make(ConsoleKernel), this.app);
|
|
1068
|
+
const directory = this.app.make(ConfigRepository3).get("live.class_path", this.app.appPath("Live"));
|
|
1069
|
+
for (const [name, exported] of await this.app.run(() => discover(directory))) {
|
|
1070
|
+
if (isComponentClass(exported)) manager.register(exported, name);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
export {
|
|
1075
|
+
Component,
|
|
1076
|
+
Computed,
|
|
1077
|
+
CorruptComponentPayloadError,
|
|
1078
|
+
ErrorBag,
|
|
1079
|
+
InvalidLiveRequestError,
|
|
1080
|
+
Live,
|
|
1081
|
+
LiveComponentManager,
|
|
1082
|
+
LiveComponentNotFoundError,
|
|
1083
|
+
LiveError,
|
|
1084
|
+
LiveServiceProvider,
|
|
1085
|
+
Locked,
|
|
1086
|
+
MethodNotCallableError,
|
|
1087
|
+
On,
|
|
1088
|
+
PendingEvent,
|
|
1089
|
+
PropertyNotUpdatableError,
|
|
1090
|
+
Testable,
|
|
1091
|
+
UPDATE_URI,
|
|
1092
|
+
componentName,
|
|
1093
|
+
live
|
|
1094
|
+
};
|
|
1095
|
+
//# sourceMappingURL=index.js.map
|