release: WRNexusJS 0.6.0
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
export interface ClientModuleScope {
|
||||
output: Record<string, (payload?: unknown) => void>;
|
||||
server: Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
props: Readonly<Record<string, unknown>>;
|
||||
refs: Record<string, Element>;
|
||||
}
|
||||
const moduleCache = new Map<string, Promise<any>>();
|
||||
export async function loadClientFunctions(
|
||||
url: string,
|
||||
scope: ClientModuleScope,
|
||||
): Promise<Record<string, (...args: unknown[]) => unknown>> {
|
||||
const module = await (moduleCache.get(url) ??
|
||||
(() => {
|
||||
const promise = import(/* @vite-ignore */ url);
|
||||
moduleCache.set(url, promise);
|
||||
return promise;
|
||||
})());
|
||||
if (typeof module.bindClientScope === "function") return module.bindClientScope(scope);
|
||||
return module.__wrnexusClientFunctions ?? {};
|
||||
}
|
||||
export function invalidateClientModule(url: string): void {
|
||||
moduleCache.delete(url);
|
||||
}
|
||||
@@ -29,3 +29,9 @@ export function getNavRuntime(): string {
|
||||
export function getRealtimeRuntime(): string {
|
||||
return REALTIME_RUNTIME;
|
||||
}
|
||||
|
||||
export * from "./outputs.ts";
|
||||
export * from "./server-client.ts";
|
||||
export * from "./refs.ts";
|
||||
export * from "./client-functions.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
@@ -243,6 +243,16 @@ export const NAV_RUNTIME = String.raw`
|
||||
*/
|
||||
function dispose(root) {
|
||||
unmountPackageRuntimes(root);
|
||||
try {
|
||||
var stores = window.__wrnexusStoreContainer;
|
||||
if (stores && typeof stores.disposePageStores === "function") {
|
||||
Promise.resolve(stores.disposePageStores()).catch(function (error) {
|
||||
console.error("[wrnexus] failed to dispose page stores", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] failed to access page stores", error);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusDisposeBehaviors ===
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type OutputHandler<T = unknown> = (payload: T) => void | Promise<void>;
|
||||
export interface OutputHost extends HTMLElement {
|
||||
__wrnexusOutputHandlers?: Map<string, Set<OutputHandler>>;
|
||||
}
|
||||
|
||||
export function registerOutputHandler<T>(
|
||||
host: OutputHost,
|
||||
name: string,
|
||||
handler: OutputHandler<T>,
|
||||
): () => void {
|
||||
const registry = (host.__wrnexusOutputHandlers ??= new Map());
|
||||
const handlers = registry.get(name) ?? new Set();
|
||||
handlers.add(handler as OutputHandler);
|
||||
registry.set(name, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler as OutputHandler);
|
||||
if (!handlers.size) registry.delete(name);
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeOutput<T>(host: OutputHost, name: string, payload?: T): Promise<void> {
|
||||
const handlers = host.__wrnexusOutputHandlers?.get(name);
|
||||
if (handlers?.size) {
|
||||
for (const handler of handlers) await handler(payload);
|
||||
return;
|
||||
}
|
||||
// Compatibility path for legacy listeners outside a hydrated WRN parent.
|
||||
host.dispatchEvent(new CustomEvent(name, { detail: payload }));
|
||||
host.dispatchEvent(new CustomEvent(`wrnexus:${name}`, { detail: payload }));
|
||||
}
|
||||
|
||||
export function createOutputProxy<T extends Record<string, (...args: any[]) => void>>(
|
||||
host: OutputHost,
|
||||
): T {
|
||||
return new Proxy({} as T, {
|
||||
get: (_target, property) => (payload?: unknown) =>
|
||||
invokeOutput(host, String(property), payload),
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var pendingUpdateHooks = new Map();
|
||||
var updateHooksScheduled = false;
|
||||
var behaviorObserver;
|
||||
var clientModuleCache = new Map();
|
||||
|
||||
function reportDiagnostic(code, message, element, detail) {
|
||||
var payload = {
|
||||
@@ -478,6 +479,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
}
|
||||
|
||||
function loadClientModule(element) {
|
||||
var url = element.getAttribute("data-wrn-client-module");
|
||||
if (!url) return Promise.resolve(null);
|
||||
var promise = clientModuleCache.get(url);
|
||||
if (!promise) {
|
||||
promise = import(url).catch(function (error) {
|
||||
clientModuleCache.delete(url);
|
||||
reportDiagnostic("WRN-CLIENT-MODULE", "Failed to load browser function module '" + url + "'.", element, error);
|
||||
return null;
|
||||
});
|
||||
clientModuleCache.set(url, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
function setupScope(el) {
|
||||
if (el.__wrnexusScope) return;
|
||||
|
||||
@@ -677,13 +693,39 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
var behaviorFunctions = {};
|
||||
var componentEventTarget = el.querySelector("[data-wrn-events]") || el;
|
||||
var moduleBindings = {};
|
||||
var componentEventTarget = el.hasAttribute("data-wrn-events")
|
||||
? el
|
||||
: el.querySelector("[data-wrn-events]") || el;
|
||||
var declaredEvents = new Set(
|
||||
String(componentEventTarget.getAttribute("data-wrn-events") || "")
|
||||
.split(",")
|
||||
.map(function (name) { return name.trim(); })
|
||||
.filter(Boolean),
|
||||
);
|
||||
var outputHandlers = componentEventTarget.__wrnexusOutputHandlers || (componentEventTarget.__wrnexusOutputHandlers = {});
|
||||
var outputProxy = new Proxy({}, {
|
||||
get: function (_target, property) {
|
||||
return function (payload) {
|
||||
return invokeComponentOutput(componentEventTarget, String(property), payload);
|
||||
};
|
||||
},
|
||||
});
|
||||
var componentRpcName = componentEventTarget.getAttribute("data-wrn-component") || componentEventTarget.getAttribute("data-wrn-hydration") || "component";
|
||||
var serverProxy = new Proxy({}, {
|
||||
get: function (_target, property) {
|
||||
return function () {
|
||||
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
|
||||
};
|
||||
},
|
||||
});
|
||||
var propsProxy = new Proxy({}, {
|
||||
get: function (_target, property) { return peekScope(String(property)); },
|
||||
set: function () { throw new TypeError("WRN-PROP-READONLY: props are readonly"); },
|
||||
});
|
||||
var refsProxy = new Proxy({}, {
|
||||
get: function (_target, property) { return el.querySelector('[data-ref="' + String(property).replace(/"/g, '\"') + '"]'); },
|
||||
});
|
||||
var stateWatchers = {};
|
||||
var anyStateListeners = new Set();
|
||||
var cleanupCallbacks = [];
|
||||
@@ -695,6 +737,11 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
return dispatchComponentEvent(componentEventTarget, name, detail);
|
||||
};
|
||||
}
|
||||
if (name === "output") return outputProxy;
|
||||
if (name === "server") return serverProxy;
|
||||
if (name === "props") return propsProxy;
|
||||
if (name === "refs") return refsProxy;
|
||||
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
|
||||
if (name === "$emit") {
|
||||
return function (eventName, detail) {
|
||||
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
||||
@@ -919,6 +966,42 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
});
|
||||
}
|
||||
|
||||
function installClientModule(module) {
|
||||
if (!module) return;
|
||||
var stateProxy = new Proxy({}, {
|
||||
get: function (_target, property) { return readScope(String(property)); },
|
||||
set: function (_target, property, value) { writeScope(String(property), value); return true; },
|
||||
ownKeys: function () { return Object.keys(signals); },
|
||||
getOwnPropertyDescriptor: function () { return { enumerable: true, configurable: true }; },
|
||||
});
|
||||
var context = {
|
||||
state: stateProxy,
|
||||
output: outputProxy,
|
||||
server: serverProxy,
|
||||
props: propsProxy,
|
||||
refs: refsProxy,
|
||||
};
|
||||
var importedBindings = module.__wrnexusImportedBindings;
|
||||
if (importedBindings && typeof importedBindings === "object") {
|
||||
Object.keys(importedBindings).forEach(function (name) {
|
||||
var binding = importedBindings[name];
|
||||
moduleBindings[name] = binding;
|
||||
if (binding && typeof binding.subscribe === "function") {
|
||||
cleanupCallbacks.push(binding.subscribe(function () { renderAll(); }));
|
||||
}
|
||||
});
|
||||
}
|
||||
var functions = typeof module.bindClientScope === "function"
|
||||
? module.bindClientScope(context)
|
||||
: module.__wrnexusClientFunctions;
|
||||
if (!functions || typeof functions !== "object") return;
|
||||
Object.keys(functions).forEach(function (name) {
|
||||
if (typeof functions[name] === "function") behaviorFunctions[name] = functions[name];
|
||||
});
|
||||
}
|
||||
|
||||
installClientModule(el.__wrnexusClientModule);
|
||||
|
||||
// A binding belongs to THIS scope only when el is the node's nearest
|
||||
// [data-scope] ancestor. Otherwise a nested scope owns it and we skip it,
|
||||
// so an outer scope never clobbers an inner one's values.
|
||||
@@ -1233,6 +1316,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
eventLocals.event = event;
|
||||
eventLocals.$event = event;
|
||||
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
||||
|
||||
try {
|
||||
runStmt(
|
||||
@@ -1773,6 +1857,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
locals.event = event;
|
||||
locals.$event = event;
|
||||
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
||||
|
||||
try {
|
||||
runStmt(
|
||||
@@ -1794,6 +1879,19 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
? { passive: true }
|
||||
: undefined;
|
||||
|
||||
if (target === componentEventTarget && declaredEvents.has(evt)) {
|
||||
var directHandler = function (payload) {
|
||||
var locals = decodeLoopLocals(node);
|
||||
locals.payload = payload;
|
||||
locals.event = undefined;
|
||||
locals.$event = undefined;
|
||||
return runStmt(stmt, locals);
|
||||
};
|
||||
(outputHandlers[evt] || (outputHandlers[evt] = new Set())).add(directHandler);
|
||||
cleanupCallbacks.push(function () {
|
||||
if (outputHandlers[evt]) outputHandlers[evt].delete(directHandler);
|
||||
});
|
||||
}
|
||||
target.addEventListener(evt, listener, options);
|
||||
cleanupCallbacks.push(function () {
|
||||
target.removeEventListener(evt, listener, options);
|
||||
@@ -1932,7 +2030,16 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
function hydrate() {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
setupScope(element);
|
||||
var moduleUrl = element.getAttribute("data-wrn-client-module");
|
||||
if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__") {
|
||||
setupScope(element);
|
||||
return;
|
||||
}
|
||||
loadClientModule(element).then(function (module) {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
element.__wrnexusClientModule = module;
|
||||
setupScope(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (strategy === "load") {
|
||||
@@ -2616,6 +2723,43 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
}
|
||||
|
||||
function invokeComponentOutput(root, name, payload) {
|
||||
if (!root || !name) return undefined;
|
||||
var registry = root.__wrnexusOutputHandlers;
|
||||
var handlers = registry && registry[name];
|
||||
if (handlers && handlers.size) {
|
||||
var values = [];
|
||||
handlers.forEach(function (handler) { values.push(handler(payload)); });
|
||||
return values.some(function (value) { return value && typeof value.then === "function"; })
|
||||
? Promise.all(values)
|
||||
: values[values.length - 1];
|
||||
}
|
||||
return dispatchComponentEvent(root, name, payload);
|
||||
}
|
||||
|
||||
function callServerFunction(component, functionName, args) {
|
||||
var csrf = document.querySelector('meta[name="wrnexus-csrf"]');
|
||||
return fetch("/__wrnexus/rpc", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-wrnexus-csrf": csrf ? csrf.getAttribute("content") || "" : "",
|
||||
},
|
||||
body: JSON.stringify({ component: component, function: functionName, args: args || [] }),
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () { return null; }).then(function (payload) {
|
||||
if (!response.ok || !payload || !payload.ok) {
|
||||
var error = new Error(payload && payload.error && payload.error.message || "Server function call failed");
|
||||
error.code = payload && payload.error && payload.error.code || "WRN-RPC-FAILED";
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchComponentEvent(root, name, detail) {
|
||||
if (!root || !name) return null;
|
||||
var EventConstructor =
|
||||
@@ -3789,6 +3933,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
window.__wrnexusHydrateScopes = hydrateScopes;
|
||||
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
|
||||
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
||||
window.__wrnexusDisposeBehaviors = disposeBehaviors;
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function collectRefs(root: ParentNode): Record<string, Element> {
|
||||
const refs: Record<string, Element> = {};
|
||||
if (root instanceof Element && root.hasAttribute("data-ref"))
|
||||
refs[root.getAttribute("data-ref")!] = root;
|
||||
root.querySelectorAll("[data-ref]").forEach((element) => {
|
||||
const name = element.getAttribute("data-ref");
|
||||
if (name) refs[name] = element;
|
||||
});
|
||||
return refs;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface ServerCallOptions {
|
||||
endpoint?: string;
|
||||
signal?: AbortSignal;
|
||||
headers?: HeadersInit;
|
||||
csrfToken?: string;
|
||||
}
|
||||
export class WrnServerCallError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
readonly status: number,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function csrfFromCookie(): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const raw = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie)?.[1];
|
||||
return raw ? decodeURIComponent(raw) : undefined;
|
||||
}
|
||||
|
||||
export async function callServerFunction<TInput extends unknown[], TOutput>(
|
||||
component: string,
|
||||
functionName: string,
|
||||
args: TInput,
|
||||
options: ServerCallOptions = {},
|
||||
): Promise<TOutput> {
|
||||
const csrfToken = options.csrfToken ?? csrfFromCookie();
|
||||
const response = await fetch(options.endpoint ?? "/__wrnexus/rpc", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(csrfToken ? { "x-wrnexus-csrf": csrfToken } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify({ component, function: functionName, args }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as any;
|
||||
if (!response.ok || !payload?.ok)
|
||||
throw new WrnServerCallError(
|
||||
payload?.error?.message ?? `Server call failed (${response.status})`,
|
||||
payload?.error?.code ?? "WRN-RPC-FAILED",
|
||||
response.status,
|
||||
payload?.error?.details,
|
||||
);
|
||||
return payload.value as TOutput;
|
||||
}
|
||||
|
||||
export function createServerProxy<T extends Record<string, (...args: any[]) => Promise<any>>>(
|
||||
component: string,
|
||||
options: ServerCallOptions = {},
|
||||
): T {
|
||||
return new Proxy({} as T, {
|
||||
get:
|
||||
(_target, property) =>
|
||||
(...args: unknown[]) =>
|
||||
callServerFunction(component, String(property), args, options),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface HydrationScopeApi {
|
||||
get(name: string): unknown;
|
||||
set(name: string, value: unknown): void;
|
||||
call(name: string, ...args: unknown[]): unknown;
|
||||
snapshot(): Readonly<Record<string, unknown>>;
|
||||
dispose(): void;
|
||||
}
|
||||
export interface WrnexusBrowserGlobals {
|
||||
__wrnexusHydrateScopes?(root?: ParentNode): void;
|
||||
__wrnexusDisposeBehaviors?(root?: ParentNode): void;
|
||||
}
|
||||
Reference in New Issue
Block a user