refactor(site): [SITE-CONSOLE-01] restore typed console and lifecycle ownership

This commit is contained in:
Harvey Zhao committed 2026-09-15 06:15:32 +08:00
1 parent 6cd2d0ffbe
commit 3dba82a453
29 files changed
+1884 -36

No files matched your search

@@ -0,0 +1,10 @@
import type { DomRuntime, ReactRuntime } from './types.ts'
export function install(React: ReactRuntime, ReactDOM: DomRuntime, View: ReactRuntime['Component'], host: Record<string, unknown>) {
const consoleLog = (element: Element) => ReactDOM.render(React.createElement(View), element)
consoleLog.unmount = (element: Element) => ReactDOM.unmountComponentAtNode(element)
host.React = React
host.ReactDOM = ReactDOM
host.consoleLog = consoleLog
return consoleLog
}
@@ -0,0 +1,17 @@
export function errorArgument(value: unknown): unknown {
try {
if (Object.prototype.toString.call(value) !== '[object Error]')
return value
const error = value as Error
const stack = error.stack
const message = error.message
if (typeof stack !== 'string' || !message)
return value
const header = error.name ? `${error.name}: ${message}` : message
return stack.startsWith(header) ? value : `${header}\n${stack}`
}
catch {
// Keep the legacy parser's behavior for unusual throwing Error getters.
return value
}
}
@@ -0,0 +1,36 @@
export const css = `
position: relative;
height: 100%;
display: flex;
flex-direction: column;
background-color: #242424;
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #eee;
padding: 7px 10px;
font-size: 12px;
background-color: #111;
border-bottom: 1px solid rgb(255 255 255 / 10%);
user-select: none;
.console-header-left {
display: flex;
align-items: center;
.console-header-number {
margin-left: 5px;
color: rgba(255, 255, 255, 0.4);
}
}
.console-header-right {
cursor: pointer;
}
}
.console-component {
scroll-behavior: smooth;
position: relative;
overflow: auto;
height: 200px;
flex: 1;
}
`
@@ -0,0 +1,107 @@
import type { Clock, ConsoleTarget, Log, NativeMethod, Parser } from './types.ts'
import { errorArgument } from './errors.ts'
interface Listener { callback?: (log: Log) => void }
interface SavedMethod { name: string, descriptor?: PropertyDescriptor, wrapper: NativeMethod }
interface Owner {
listeners: Set<Listener>
pending: Set<number>
saved: SavedMethod[]
feed: { pointers: Record<string, unknown>, src: { npm: string, github: string } }
previousFeed?: PropertyDescriptor
}
export function createSubscriptions(target: ConsoleTarget, methods: string[], parse: Parser, clock: Clock) {
let owner: Owner | undefined
function restore(current: Owner) {
for (const timer of current.pending)
clock.clearTimeout(timer)
current.pending.clear()
for (const saved of current.saved) {
if (target[saved.name] !== saved.wrapper)
continue
if (saved.descriptor)
Object.defineProperty(target, saved.name, saved.descriptor)
else
delete target[saved.name]
}
if (target.feed === current.feed) {
if (current.previousFeed)
Object.defineProperty(target, 'feed', current.previousFeed)
else
delete target.feed
}
}
return (callback: (log: Log) => void) => {
if (!owner) {
const current: Owner = {
listeners: new Set(),
pending: new Set(),
saved: [],
previousFeed: Object.getOwnPropertyDescriptor(target, 'feed'),
feed: { pointers: {}, src: { npm: 'https://npmjs.com/package/console-feed', github: 'https://github.com/samdenty99/console-feed' } },
}
owner = current
try {
for (const name of methods) {
const original = target[name] as NativeMethod
const wrapper: NativeMethod = function (...args) {
Reflect.apply(original, this, args)
const listeners = [...current.listeners]
if (!listeners.length)
return
const timer = clock.setTimeout(() => {
current.pending.delete(timer)
const log = parse(name, name === 'error' ? args.map(errorArgument) : args)
if (log) {
let failed = false
let failure: unknown
for (const listener of listeners) {
try {
listener.callback?.(log)
}
catch (error) {
if (!failed) {
failed = true
failure = error
}
else {
clock.setTimeout(() => {
throw error
}, 0)
}
}
}
if (failed)
throw failure
}
}, 0)
current.pending.add(timer)
}
current.feed.pointers[name] = original
current.saved.push({ name, descriptor: Object.getOwnPropertyDescriptor(target, name), wrapper })
target[name] = wrapper
}
target.feed = current.feed
}
catch (error) {
restore(current)
owner = undefined
throw error
}
}
const current = owner
const listener: Listener = { callback }
current.listeners.add(listener)
return () => {
listener.callback = undefined
current.listeners.delete(listener)
if (!current.listeners.size && owner === current) {
owner = undefined
restore(current)
}
}
}
}
@@ -0,0 +1,17 @@
import type * as React from 'react'
export interface Log { method: string, id?: string, data?: unknown[] }
export type Parser = (method: string, args: unknown[], id?: string) => Log | false
export type NativeMethod = (this: unknown, ...args: unknown[]) => unknown
export interface ConsoleTarget { [method: string]: unknown }
export interface Clock {
setTimeout: (callback: () => void, delay: number) => number
clearTimeout: (id: number) => void
}
export interface Feed { Console: React.ComponentType<{ logs: Log[], variant: string }> }
export interface Styled { div: (strings: TemplateStringsArray) => React.ComponentType<React.PropsWithChildren> }
export type ReactRuntime = typeof React
export interface DomRuntime {
render: (element: React.ReactElement, container: Element) => unknown
unmountComponentAtNode: (container: Element) => boolean
}
@@ -0,0 +1,68 @@
import type { Feed, Log, Parser, ReactRuntime, Styled } from './types.ts'
import { css } from './style.ts'
import { createSubscriptions } from './subscriptions.ts'
export function createView(React: ReactRuntime, feed: Feed, styled: Styled, parse: Parser, methods: string[]) {
const template = Object.freeze(Object.defineProperty([css], 'raw', { value: Object.freeze([css]) })) as unknown as TemplateStringsArray
const Container = styled.div(template)
const subscribe = createSubscriptions(window.console as unknown as Record<string, unknown>, methods, parse, window)
interface State { logs: Log[], hover: boolean }
return class ConsoleView extends React.Component<object, State> {
scrollTimer: number | null = null
consoleRef = React.createRef<HTMLDivElement>()
private unsubscribe?: () => void
private mounted = false
constructor(props: object) {
super(props)
this.onClear = this.onClear.bind(this)
this.onMouseEnter = this.onMouseEnter.bind(this)
this.onMouseLeave = this.onMouseLeave.bind(this)
this.state = { logs: [], hover: false }
}
override componentDidMount() {
this.mounted = true
this.unsubscribe = subscribe(log => this.add(log))
}
add(log: Log) {
if (!this.mounted)
return
this.setState(state => ({ logs: [...state.logs, log] }))
const element = this.consoleRef.current
if (!this.state.hover && element) {
if (this.scrollTimer !== null)
window.clearTimeout(this.scrollTimer)
this.scrollTimer = window.setTimeout(() => {
this.scrollTimer = null
if (this.mounted && this.consoleRef.current === element)
element.scrollTop = element.scrollHeight
}, 200)
}
}
onClear() { this.setState({ logs: [] }) }
onMouseEnter() { this.setState({ hover: true }) }
onMouseLeave() { this.setState({ hover: false }) }
override componentWillUnmount() {
this.mounted = false
this.unsubscribe?.()
this.unsubscribe = undefined
if (this.scrollTimer !== null)
window.clearTimeout(this.scrollTimer)
this.scrollTimer = null
}
override render() {
const count = React.createElement('span', { className: 'console-header-number' }, this.state.logs.length)
const title = React.createElement('div', { className: 'console-header-left' }, 'Console', ' ', count)
const clear = React.createElement('div', { className: 'console-header-right', onClick: this.onClear }, 'Clear')
const header = React.createElement('div', { className: 'console-header' }, title, clear)
const logs = React.createElement(feed.Console, { logs: this.state.logs, variant: 'dark' })
const content = React.createElement('div', { ref: this.consoleRef, className: 'console-component', onMouseEnter: this.onMouseEnter, onMouseLeave: this.onMouseLeave }, logs)
return React.createElement(Container, null, header, content)
}
}
}