diff --git a/client/src/events.ts b/client/src/events.ts index 9290c3b..07a7f08 100644 --- a/client/src/events.ts +++ b/client/src/events.ts @@ -1,54 +1,77 @@ import * as debounce from 'debounce' import { encodedParam } from './action' +import { HyperView, isHyperView } from './hyperview' export type UrlFragment = string -export function listenKeydown(cb: (target: HTMLElement, action: string) => void): void { +export function listenKeydown(cb: (target: HyperView, action: string) => void): void { listenKeyEvent("keydown", cb) } -export function listenKeyup(cb: (target: HTMLElement, action: string) => void): void { +export function listenKeyup(cb: (target: HyperView, action: string) => void): void { listenKeyEvent("keyup", cb) } -export function listenKeyEvent(event: string, cb: (target: HTMLElement, action: string) => void): void { - document.addEventListener(event.toLowerCase(), function(e: KeyboardEvent) { - let source = e.target as HTMLInputElement +export function listenKeyEvent(event: "keyup" | "keydown", cb: (target: HyperView, action: string) => void): void { + + document.addEventListener(event, function(e: KeyboardEvent) { + if (!(e.target instanceof HTMLElement)) { + console.error("listenKeyEvent event target is not HTMLElement: ", e.target) + return + } + let source = e.target let datasetKey = "on" + event + e.key let action = source.dataset[datasetKey] if (!action) return e.preventDefault() - cb(nearestTarget(source), action) + const target = nearestTarget(source) + if (!target) { + console.error("Missing target: ", source) + return + } + cb(target, action) }) } -export function listenBubblingEvent(event: string, cb: (target: HTMLElement, action: string) => void): void { +export function listenBubblingEvent(event: string, cb: (_target: HyperView, action: string) => void): void { document.addEventListener(event, function(e) { - let el = e.target as HTMLInputElement + if (!(e.target instanceof HTMLElement)) { + return + } + let el = e.target // clicks can fire on internal elements. Find the parent with a click handler - let source = el.closest("[data-on" + event + "]") as HTMLElement + let source = el.closest("[data-on" + event + "]") if (!source) return e.preventDefault() let target = nearestTarget(source) - cb(target, source.dataset["on" + event]) + if (!target) { + console.error("Missing target: ", source) + return + } + const action = source.dataset["on" + event] + if (action === undefined) { + console.error("Missing action: ", source, event) + return + } + cb(target, action) }) } -export function listenClick(cb: (target: HTMLElement, action: string) => void): void { +export function listenClick(cb: (target: HyperView, action: string) => void): void { listenBubblingEvent("click", cb) } -export function listenDblClick(cb: (target: HTMLElement, action: string) => void): void { +export function listenDblClick(cb: (target: HyperView, action: string) => void): void { listenBubblingEvent("dblclick", cb) } -export function listenTopLevel(cb: (target: HTMLElement, action: string) => void): void { +export function listenTopLevel(cb: (target: HyperView, action: string) => void): void { document.addEventListener("hyp-load", function(e: CustomEvent) { let action = e.detail.onLoad let target = e.detail.target @@ -72,8 +95,8 @@ export function listenTopLevel(cb: (target: HTMLElement, action: string) => void export function listenLoad(node: HTMLElement): void { // it doesn't really matter WHO runs this except that it should have target - node.querySelectorAll("[data-onload]").forEach((load: HTMLElement) => { - let delay = parseInt(load.dataset.delay) || 0 + node.querySelectorAll("[data-onload]").forEach((load) => { + let delay = parseInt(load.dataset.delay || "") || 0 let onLoad = load.dataset.onload // console.log("load start", load.dataset.onLoad) @@ -95,10 +118,10 @@ export function listenLoad(node: HTMLElement): void { } export function listenMouseEnter(node: HTMLElement): void { - node.querySelectorAll("[data-onmouseenter]").forEach((node: HTMLElement) => { + node.querySelectorAll("[data-onmouseenter]").forEach((node) => { let onMouseEnter = node.dataset.onmouseenter - let target = nearestTarget(node) + let target = nearestNonHyperViewTarget(node) node.onmouseenter = () => { const event = new CustomEvent("hyp-mouseenter", { bubbles: true, detail: { target, onMouseEnter } }) @@ -108,10 +131,10 @@ export function listenMouseEnter(node: HTMLElement): void { } export function listenMouseLeave(node: HTMLElement): void { - node.querySelectorAll("[data-onmouseleave]").forEach((node: HTMLElement) => { + node.querySelectorAll("[data-onmouseleave]").forEach((node) => { let onMouseLeave = node.dataset.onmouseleave - let target = nearestTarget(node) + let target = nearestNonHyperViewTarget(node) node.onmouseleave = () => { const event = new CustomEvent("hyp-mouseleave", { bubbles: true, detail: { target, onMouseLeave } }) @@ -121,21 +144,33 @@ export function listenMouseLeave(node: HTMLElement): void { } -export function listenChange(cb: (target: HTMLElement, action: string) => void): void { +export function listenChange(cb: (target: HyperView, action: string) => void): void { document.addEventListener("change", function(e) { - let el = e.target as HTMLElement + if (!(e.target instanceof HTMLElement)) { + console.error("listenChange event target is not HTMLElement: ", e.target) + return + } + let el = e.target - let source = el.closest("[data-onchange]") as HTMLInputElement + let source = el.closest("[data-onchange]") if (!source) return e.preventDefault() - if (source.value == null) { + if (source.value === null) { console.error("Missing input value:", source) return } let target = nearestTarget(source) + if (!target) { + console.error("Missing target: listenChange") + return + } + if (source.dataset.onchange === undefined) { + console.error("source.dataset.onchange is undefined") + return + } let action = encodedParam(source.dataset.onchange, source.value) cb(target, action) }) @@ -145,33 +180,40 @@ interface LiveInputElement extends HTMLInputElement { debouncedCallback?: Function; } -export function listenInput(startedTyping: (target: HTMLElement) => void, cb: (target: HTMLElement, action: string) => void): void { +export function listenInput(startedTyping: (target: HyperView) => void, cb: (target: HyperView, action: string) => void): void { document.addEventListener("input", function(e) { - let el = e.target as HTMLElement - let source = el.closest("[data-oninput]") as LiveInputElement + if (!(e.target instanceof HTMLElement)) { + console.error("listenInput event target is not HTMLElement: ", e.target) + return + } + let el = e.target + const source = el.closest("[data-oninput]") if (!source) return - let delay = parseInt(source.dataset.delay) || 250 + let delay = parseInt(source.dataset.delay || "") || 250 if (delay < 250) { console.warn("Input delay < 250 can result in poor performance.") } - if (!source?.dataset.oninput) { - console.error("Missing onInput: ", source) - return - } - e.preventDefault() - let target = nearestTarget(source) + const target = nearestTarget(source) + if (!target) { + console.error("Missing target: ", source) + return + } // I want to CANCEL the active request as soon as we start typing startedTyping(target) if (!source.debouncedCallback) { source.debouncedCallback = debounce(() => { - let action = encodedParam(source.dataset.oninput, source.value) + if (!source.dataset.oninput) { + console.error("Missing onInput: ", source) + return + } + const action = encodedParam(source.dataset.oninput, source.value) cb(target, action) }, delay) } @@ -182,11 +224,16 @@ export function listenInput(startedTyping: (target: HTMLElement) => void, cb: (t -export function listenFormSubmit(cb: (target: HTMLElement, action: string, form: FormData) => void): void { +export function listenFormSubmit(cb: (target: HyperView, action: string, form: FormData) => void): void { document.addEventListener("submit", function(e) { - let form = e.target as HTMLFormElement + if (!(e.target instanceof HTMLFormElement)) { + console.error("listenFormSubmit event target is not HTMLFormElement: ", e.target) + return + } + let form = e.target + - if (!form?.dataset.onsubmit) { + if (!form.dataset.onsubmit) { console.error("Missing onSubmit: ", form) return } @@ -195,18 +242,33 @@ export function listenFormSubmit(cb: (target: HTMLElement, action: string, form: let target = nearestTarget(form) const formData = new FormData(form) + if (!target) { + console.error("Missing target: ", form) + return + } cb(target, form.dataset.onsubmit, formData) }) } function nearestTargetId(node: HTMLElement): string | undefined { - let targetData = node.closest("[data-target]") as HTMLElement | undefined + let targetData = node.closest("[data-target]") return targetData?.dataset.target || node.closest("[id]")?.id } -function nearestTarget(node: HTMLElement): HTMLElement { +function nearestTarget(node: HTMLElement): HyperView | undefined { + const target = nearestNonHyperViewTarget(node) + + if (!isHyperView(target)) { + console.error("Non HyperView target: ", target) + return + } + + return target +} + +function nearestNonHyperViewTarget(node: HTMLElement): HTMLElement | undefined { let targetId = nearestTargetId(node) - let target = document.getElementById(targetId) + let target = targetId && document.getElementById(targetId) if (!target) { console.error("Cannot find target: ", targetId, node) diff --git a/client/src/hyperview.ts b/client/src/hyperview.ts new file mode 100644 index 0000000..9b2fe9a --- /dev/null +++ b/client/src/hyperview.ts @@ -0,0 +1,15 @@ +import { type Request } from "./action"; + +export interface HyperView extends HTMLElement { + runAction(action: string): Promise; + activeRequest?: Request; + cancelActiveRequest(): void; + concurrency: ConcurrencyMode; + _timeout?: number; +} + +export const isHyperView = (ele: any): ele is HyperView => { + return ele?.runAction !== undefined; +}; + +export type ConcurrencyMode = string; diff --git a/client/src/index.ts b/client/src/index.ts index d69c683..e94b469 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -5,6 +5,7 @@ import { actionMessage, ActionMessage, Request, newRequest } from './action' import { ViewId, Metadata, parseMetadata, ViewState } from './message' import { setQuery } from "./browser" import { parseResponse, Response, LiveUpdate } from './response' +import { ConcurrencyMode, HyperView, isHyperView } from "./hyperview" let PACKAGE = require('../package.json'); @@ -22,15 +23,6 @@ let addedRulesIndex = new Set(); // Run an action in a given HyperView async function runAction(target: HyperView, action: string, form?: FormData) { - if (target === undefined) { - console.error("Undefined HyperView!", action) - return - } - - if (action === undefined) { - console.error("Undefined Action!", target.id) - return - } if (target.activeRequest && !target.activeRequest?.isCancelled) { // Active Request! @@ -40,7 +32,7 @@ async function runAction(target: HyperView, action: string, form?: FormData) { } } - target._timeout = setTimeout(() => { + target._timeout = window.setTimeout(() => { // add loading after 100ms, not right away // if it runs shorter than that we probably don't want to show the user any loading feedback target.classList.add("hyp-loading") @@ -63,7 +55,7 @@ function handleRedirect(red: Redirect) { console.log("REDIRECT", red) // the other metdata doesn't apply, they are all specific to the page - applyCookies(red.meta.cookies) + applyCookies(red.meta.cookies ?? []) window.location.href = red.url } @@ -72,6 +64,7 @@ function handleRedirect(red: Redirect) { function handleResponse(res: Update) { // console.log("Handle Response", res) let target = handleUpdate(res) + if (!target) return // clean up the request delete target.activeRequest @@ -79,19 +72,18 @@ function handleResponse(res: Update) { target.classList.remove("hyp-loading") } -function handleUpdate(res: Update): HyperView { +function handleUpdate(res: Update): HyperView | undefined { // console.log("|UPDATE|", res) let targetViewId = res.targetViewId || res.viewId - let target = document.getElementById(targetViewId) as HyperView - + let target = document.getElementById(targetViewId) - if (!target) { - console.error("Missing Update Target: ", targetViewId, res) - return target + if (!isHyperView(target)) { + console.error("Missing Update HyperView Target: ", targetViewId, res) + return } - if (res.requestId < target.activeRequest?.requestId) { + if (target.activeRequest?.requestId && res.requestId < target.activeRequest.requestId) { // this should only happen on Replace, since other requests should be dropped // but it's safe to assume we never want to apply an old requestId console.warn("Ignore Stale Action (" + res.requestId + ") vs (" + target.activeRequest.requestId + "): " + res.action) @@ -117,14 +109,14 @@ function handleUpdate(res: Update): HyperView { // Patch the node const old: VNode = create(target) let next: VNode = create(update.content) - let atts = next.attributes as any + let atts = next.attributes - if (atts["id"] != target.id) { + if (atts["id"] !== target.id) { console.error("Mismatched ViewId in update - ", atts["id"], " target:", target.id) return } - let state = (next.attributes as any)["data-state"] + let state = atts["data-state"] next.attributes = old.attributes @@ -133,22 +125,23 @@ function handleUpdate(res: Update): HyperView { // Emit relevant events let newTarget = document.getElementById(target.id) - dispatchContent(newTarget) if (!newTarget) { console.warn("Target Missing: ", target.id) return target } + dispatchContent(newTarget) + // re-add state attribute - if (state == undefined || state == "()") + if (state === undefined || state == "()") delete newTarget.dataset.state else newTarget.dataset.state = state // execute the metadata, anything that doesn't interrupt the dom update runMetadata(res.meta, newTarget) - applyCookies(res.meta.cookies) + applyCookies(res.meta.cookies ?? []) // now way for these to bubble) listenLoad(newTarget) @@ -183,7 +176,7 @@ function runMetadata(meta: Metadata, target?: HTMLElement) { document.title = meta.pageTitle } - meta.events.forEach((remoteEvent) => { + meta.events?.forEach((remoteEvent) => { setTimeout(() => { let event = new CustomEvent(remoteEvent.name, { bubbles: true, detail: remoteEvent.detail }) let eventTarget = target || document @@ -191,9 +184,9 @@ function runMetadata(meta: Metadata, target?: HTMLElement) { }, 10) }) - meta.actions.forEach(([viewId, action]) => { + meta.actions?.forEach(([viewId, action]) => { setTimeout(() => { - let view = window.Hyperbole.hyperView(viewId) + let view = window.Hyperbole?.hyperView(viewId) if (view) { runAction(view, action) } @@ -203,19 +196,19 @@ function runMetadata(meta: Metadata, target?: HTMLElement) { function fixInputs(target: HTMLElement) { - let focused = target.querySelector("[autofocus]") as HTMLInputElement + let focused = target.querySelector("[autofocus]") if (focused?.focus) { focused.focus() } - target.querySelectorAll("input[value]").forEach((input: HTMLInputElement) => { + target.querySelectorAll("input[value]").forEach((input) => { let val = input.getAttribute("value") - if (val !== undefined) { + if (val !== null) { input.value = val } }) - target.querySelectorAll("input[type=checkbox]").forEach((checkbox: HTMLInputElement) => { + target.querySelectorAll("input[type=checkbox]").forEach((checkbox) => { let checked = checkbox.dataset.checked == "True" checkbox.checked = checked }) @@ -223,9 +216,11 @@ function fixInputs(target: HTMLElement) { function addCSS(src: HTMLStyleElement | null) { if (!src) return; - const rules: any = src.sheet.cssRules - for (const rule of rules) { - if (addedRulesIndex.has(rule.cssText) == false) { + const rules = src.sheet?.cssRules + if (!rules) return; + for (let i = 0; i < rules.length; i++) { + const rule = rules.item(i) + if (rule && addedRulesIndex.has(rule.cssText) == false && rootStyles.sheet) { rootStyles.sheet.insertRule(rule.cssText); addedRulesIndex.add(rule.cssText); } @@ -237,13 +232,15 @@ function addCSS(src: HTMLStyleElement | null) { function init() { // metadata attached to initial page loads need to be executed - let meta = parseMetadata(document.getElementById("hyp.metadata").innerText) + let meta = parseMetadata(document.getElementById("hyp.metadata")?.innerText ?? "") // runMetadataImmediate(meta) runMetadata(meta) - rootStyles = document.body.querySelector('style') + const style = document.body.querySelector('style') - if (!rootStyles) { + if (style !== null) { + rootStyles = style + } else { console.warn("rootStyles missing from page, creating...") rootStyles = document.createElement("style") rootStyles.type = "text/css" @@ -304,10 +301,10 @@ function init() { function enrichHyperViews(node: HTMLElement): void { // enrich all the hyperviews - node.querySelectorAll("[id]").forEach((element: HyperView) => { + node.querySelectorAll("[id]").forEach((element) => { element.runAction = function(action: string) { - runAction(this, action) - }.bind(element) + return runAction(element, action) + } element.concurrency = element.dataset.concurrency || "Drop" @@ -331,10 +328,8 @@ document.addEventListener("DOMContentLoaded", init) -// Should we connect to the socket or not? const sock = new SocketConnection() -sock.connect() -sock.addEventListener("update", (ev: CustomEvent) => handleUpdate(ev.detail)) +sock.addEventListener("update", (ev: CustomEvent) => { handleUpdate(ev.detail) }) sock.addEventListener("response", (ev: CustomEvent) => handleResponse(ev.detail)) sock.addEventListener("redirect", (ev: CustomEvent) => handleRedirect(ev.detail)) @@ -351,7 +346,7 @@ type VNode = { // An object whose key/value pairs are the attribute // name and value, respectively - attributes: [string: string] + attributes: { [key: string]:string | undefined } // Is set to `true` if a node is an `svg`, which tells // Omdomdom to treat it, and its children, as such @@ -375,6 +370,11 @@ declare global { interface Window { Hyperbole?: HyperboleAPI; } + interface DocumentEventMap { + "hyp-load": CustomEvent; + "hyp-mouseenter": CustomEvent; + "hyp-mouseleave": CustomEvent; + } } export interface HyperboleAPI { @@ -385,33 +385,18 @@ export interface HyperboleAPI { socket: SocketConnection } - - - -export interface HyperView extends HTMLElement { - runAction(target: HTMLElement, action: string, form?: FormData): Promise - activeRequest?: Request - cancelActiveRequest(): void - concurrency: ConcurrencyMode - _timeout?: any -} - -type ConcurrencyMode = string - - window.Hyperbole = { runAction: runAction, parseMetadata: parseMetadata, action: function(con, ...params: any[]) { - let ps = params.reduce((str, param) => str + " " + JSON.stringify(param), "") - return con + ps + return params.reduce((str, param) => str + " " + JSON.stringify(param), con); }, hyperView: function(viewId) { - let element = document.getElementById(viewId) as any - if (!element?.runAction) { + let element = document.getElementById(viewId) + if (!isHyperView(element)) { console.error("Element id=" + viewId + " was not a HyperView") - return undefined + return } return element }, diff --git a/client/src/message.ts b/client/src/message.ts index e1a2633..f49e30b 100644 --- a/client/src/message.ts +++ b/client/src/message.ts @@ -9,7 +9,7 @@ export type RequestId = number export type EncodedAction = string export type ViewState = string -type RemoteEvent = { name: string, detail: any } +type RemoteEvent = { name: string, detail: unknown } export function renderMetas(meta: Meta[]): string { @@ -99,7 +99,7 @@ export function parseAction(input: string): [ViewId, string] { return [viewId, action] } -function breakNextSegment(input: string): [string, string] | undefined { +function breakNextSegment(input: string): [string, string] { let ix = input.indexOf('|') if (ix === -1) { let err = new Error("Bad Encoding, Expected Segment") diff --git a/client/src/response.ts b/client/src/response.ts index d07909a..44e441e 100644 --- a/client/src/response.ts +++ b/client/src/response.ts @@ -13,8 +13,8 @@ export type ResponseBody = string export function parseResponse(res: ResponseBody): LiveUpdate { const parser = new DOMParser() const doc = parser.parseFromString(res, 'text/html') - const css = doc.querySelector("style") as HTMLStyleElement - const content = doc.querySelector("div") as HTMLElement + const css = doc.querySelector("style") + const content = doc.querySelector("div") return { content: content, @@ -23,7 +23,7 @@ export function parseResponse(res: ResponseBody): LiveUpdate { } export type LiveUpdate = { - content: HTMLElement + content: HTMLElement | null css: HTMLStyleElement | null } diff --git a/client/src/sockets.ts b/client/src/sockets.ts index 1b93324..df84a37 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -6,7 +6,11 @@ import { ViewId, RequestId, EncodedAction, metaValue, Metadata } from "./message const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const defaultAddress = `${protocol}//${window.location.host}${window.location.pathname}` - +interface SocketConnectionEventMap { + "update": CustomEvent; + "response": CustomEvent; + "redirect": CustomEvent; +} export class SocketConnection { socket: WebSocket @@ -17,12 +21,16 @@ export class SocketConnection { queue: ActionMessage[] = [] events: EventTarget - constructor() { + constructor(addr = defaultAddress) { this.events = new EventTarget() + const sock = new WebSocket(addr) + this.socket = sock + // Should we connect to the socket or not? + this.connect(addr, false) } - connect(addr = defaultAddress) { - const sock = new WebSocket(addr) + connect(addr = defaultAddress, createSocket = true) { + const sock = createSocket ? new WebSocket(addr) : this.socket this.socket = sock function onConnectError(ev: Event) { @@ -88,7 +96,7 @@ export class SocketConnection { private runQueue() { // send all messages queued while disconnected - let next: ActionMessage | null = this.queue.pop() + let next: ActionMessage | undefined = this.queue.pop() if (next) { console.log("runQueue: ", next) this.sendAction(next) @@ -197,11 +205,14 @@ export class SocketConnection { // }) // } - addEventListener(e: string, cb: EventListenerOrEventListenerObject) { - this.events.addEventListener(e, cb) + addEventListener(e: K, cb: (ev: SocketConnectionEventMap[K]) => void) { + this.events.addEventListener(e, + // @ts-ignore: HACK + cb + ) } - dispatchEvent(e: Event) { + dispatchEvent(e: SocketConnectionEventMap[K]) { this.events.dispatchEvent(e) } diff --git a/client/tsconfig.json b/client/tsconfig.json index d1bd7be..8ce28f4 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -8,7 +8,8 @@ "lib": ["ES2020","DOM"], "allowJs": true, "moduleResolution": "node", - "declaration": true + "declaration": true, + "strict": true // "skipLibCheck": true /*"declarationMap": true*/ },