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

+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { generateConsole } from './site-vendor/console/build.ts'
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use yarn build:console [--check]')
const candidate = await generateConsole(process.cwd())
const output = 'docs/assets/js/console.js'
if (process.argv.includes('--check'))
assert.equal(fs.readFileSync(output, 'utf8'), candidate, 'Generated console drift')
else fs.writeFileSync(output, candidate)
console.log('Console TS entry and view generated/checked; 100 frozen vendor modules and Parcel runtime retained.')
+6 -2
View File
@@ -55,6 +55,10 @@ members as Buffers. Compare `package/dist/codicon.ttf` directly with `fontPath`,
and each non-null notice member with its frozen `source`. Do not pipe binary font
output through PowerShell text redirection. No dependency installation is needed.
Follow-up: finish Monaco's broader bundled-component notice audit, then recover the
console.js build and resolve remaining fonts/media. Do not upgrade these assets
The desktop console's owned TS entry/view and lifecycle now build through
`build:console` / `check:console`; see [console maintenance](console/README.md).
Its other 100 Parcel modules remain frozen pending full provenance and notices.
Follow-up: finish Monaco's broader bundled-component notice audit, the console
vendor provenance and remaining fonts/media. Do not upgrade these assets
without verifying globals, AMD/worker paths, CSS, consoleLog and user interaction.
+69
View File
@@ -0,0 +1,69 @@
# Desktop console maintenance
`yarn build:console` generates `docs/assets/js/console.js`; `yarn check:console`
compares it without writing. Never edit that output. This is the desktop editor
console, separate from the mobile vConsole integration.
## Modules and ownership
| File | Responsibility |
| -------------------------- | ------------------------------------------------------------------------------------ |
| `build.ts` | Verify frozen input, compile two owned modules, replace their Parcel function bodies |
| `runtime/entry.ts` | Install React, ReactDOM and consoleLog globals; return the React render result |
| `runtime/view.ts` | Component state, Clear, hover, scroll scheduling and unmount |
| `runtime/subscriptions.ts` | One hook owner, subscriptions, queued delivery and descriptor restoration |
| `runtime/errors.ts` | Preserve native error message and stack without mutating the input |
| `runtime/style.ts` | Original stylesheet, decoded exactly from the frozen view |
| `runtime/types.ts` | Structural interfaces for injected runtimes and parser |
The fixed upstream is `refactor/baselines/site-vendor/console-original.js`, checked
by SHA-256. Only W5CS (view) and Focm (entry) bodies change. The other 100 modules,
their dependency maps and Parcel runtime remain byte-for-byte intact. The dead
`/index.js.map` trailer is removed; a complete upstream source map is unavailable.
The entry injects existing React/ReactDOM 17.0.2, console-feed and
styled-components 5.3.3. RxPG supplies the historical parser and V2JG the method
list. Root `@types/react` 19.1.10 is used for type checking only; no React 19 runtime
is included. These module IDs are a frozen build boundary, not a general Parcel
patching API. Changing the vendor requires a separate provenance review.
## Data and cleanup
Each native call forwards once with its receiver, then queues parsing. Wrappers
retain the old undefined return behavior. Count/timing/assert parsing runs once
per call, and active viewers receive the same parsed record; raw object references
remain available to the old inspector. Error-only logs add a missing error header
before the historical stack conversion, preserving the original Error object.
The first subscriber captures method/feed descriptors and installs the hook.
Additional viewers share it. Delivery snapshots subscribers when the call occurs;
removed viewers do not receive it and later viewers do not receive old logs.
The final subscriber cancels pending delivery and restores only wrappers and
metadata still owned by this instance. Later external wrappers remain intact.
An external wrapper retaining an old hook can forward native calls without
retaining viewer callbacks. Failed installation rolls back. A throwing listener
does not starve other viewers; its error remains observable asynchronously.
No general interception of third-party console mutations is attempted.
The component owns its 200ms scroll timer and cancels it on unmount; the callback
also verifies the live element. `consoleLog(element)` still returns the React
component, and repeat mounting into the same container reuses it. The additive
`consoleLog.unmount(element)` forwards ReactDOM's boolean result. The editor uses
it on non-persisted pagehide. Persisted pagehide retains state; controlled lifecycle
tests do not certify physical-device BFCache behavior.
```sh
yarn build:console
yarn check:console
yarn typecheck:docs-tools
yarn test:site-console
yarn build:site-assets
yarn test:browser test/browser/site-console.spec.js test/browser/site-editor.spec.js --workers=1
```
Historical browser cases intentionally reproduce frozen defects; candidate cases
prove their repairs independently. Unit tests check shared ownership, failed
installation, callback errors, object identity, exact CSS and vendor preservation.
No new dependency is required. Existing esbuild 0.27.7 and TypeScript 5.9.3 provide
generation and structural parsing. Full console dependency provenance and notices
remain SITE-07 / VENDOR-08; this build does not close them. See
`refactor/console-modernization.md` for contracts and evidence.
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { build } from 'esbuild'
import ts from 'typescript'
export const upstreamSha256 = 'e00bbf82bf08c452825690372ded39a9b60f4baf438f0e7ce8300e9bc4629c03'
export const obsoleteMap = '//# sourceMappingURL=/index.js.map'
export function moduleRanges(code: string) {
const source = ts.createSourceFile('console.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const ranges = new Map<string, { start: number, end: number }>()
function visit(node: ts.Node) {
if (ts.isPropertyAssignment(node) && ts.isStringLiteral(node.name) && ['W5CS', 'Focm'].includes(node.name.text)) {
assert(ts.isArrayLiteralExpression(node.initializer))
const fn = node.initializer.elements[0]
assert(fn && ts.isFunctionExpression(fn))
assert(!ranges.has(node.name.text), 'Duplicate owned console module')
ranges.set(node.name.text, { start: fn.body.getStart(source) + 1, end: fn.body.end - 1 })
}
ts.forEachChild(node, visit)
}
visit(source)
assert.equal(ranges.size, 2, 'Missing owned console modules')
return ranges
}
export async function generateConsole(root: string) {
const upstream = fs.readFileSync(path.join(root, 'refactor/baselines/site-vendor/console-original.js'), 'utf8')
assert.equal(createHash('sha256').update(upstream).digest('hex'), upstreamSha256, 'Frozen console source changed')
const ranges = moduleRanges(upstream)
const bodies = new Map<string, string>()
for (const [id, entry] of [['W5CS', 'view'], ['Focm', 'entry']] as const) {
const result = await build({ entryPoints: [path.join(root, `scripts/site-vendor/console/runtime/${entry}.ts`)], bundle: true, write: false, platform: 'browser', format: 'iife', globalName: '__consoleOwned', target: 'es2015', minify: true, legalComments: 'none', logLevel: 'silent' })
const compiled = result.outputFiles[0]?.text
assert(compiled)
const expression = id === 'W5CS'
? '__consoleOwned.createView(require("react"),require("console-feed"),require("styled-components").default,require("RxPG").default,require("V2JG").default)'
: '__consoleOwned.install(require("react"),require("react-dom"),require("./Console").default,window)'
bodies.set(id, `\n"use strict";${compiled}Object.defineProperty(exports,"__esModule",{value:true});exports.default=${expression};\n`)
}
let output = upstream
for (const [id, range] of [...ranges].sort((a, b) => b[1].start - a[1].start))
output = output.slice(0, range.start) + bodies.get(id) + output.slice(range.end)
assert(output.endsWith(obsoleteMap), 'Unexpected original console source map trailer')
return output.slice(0, -obsoleteMap.length)
}
@@ -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)
}
}
}
+2 -1
View File
@@ -31,6 +31,7 @@
"../packages/artplayer-vitepress/build/**/*.ts",
"build-vconsole.mjs",
"browser-validation/**/*.ts",
"../test/browser/chapter-hover.ts"
"../test/browser/chapter-hover.ts",
"build-console.mjs"
]
}