@vobs/runtime 1.0.0

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/src/error.ts ADDED
@@ -0,0 +1,175 @@
1
+ /** Shared error protocol used by the runtime, compiler and DevTools. */
2
+ export type VobsErrorSeverity = 'error' | 'warning' | 'info'
3
+ export type VobsErrorLayer =
4
+ | 'compiler'
5
+ | 'runtime'
6
+ | 'constraint'
7
+ | 'permission'
8
+ | 'ssr'
9
+ | 'http'
10
+ | 'resource'
11
+
12
+ export interface VobsErrorLocation {
13
+ readonly file: string
14
+ readonly line: number
15
+ readonly column: number
16
+ }
17
+
18
+ export interface VobsErrorOptions {
19
+ readonly code: string
20
+ readonly message: string
21
+ readonly severity?: VobsErrorSeverity
22
+ readonly layer?: VobsErrorLayer
23
+ readonly cause?: unknown
24
+ readonly fix?: string
25
+ readonly location?: VobsErrorLocation
26
+ readonly trace?: readonly string[]
27
+ readonly example?: string
28
+ readonly docs?: string
29
+ readonly codeFrame?: string
30
+ }
31
+
32
+ export interface VobsErrorDefaults {
33
+ readonly code?: string
34
+ readonly severity?: VobsErrorSeverity
35
+ readonly layer?: VobsErrorLayer
36
+ readonly fix?: string
37
+ }
38
+
39
+ /** A framework error with stable machine-readable metadata. */
40
+ export class VobsError extends Error {
41
+ readonly code: string
42
+ readonly severity: VobsErrorSeverity
43
+ readonly layer: VobsErrorLayer
44
+ readonly cause?: unknown
45
+ readonly fix?: string
46
+ readonly location?: VobsErrorLocation
47
+ readonly trace?: readonly string[]
48
+ readonly example?: string
49
+ readonly docs?: string
50
+ readonly codeFrame?: string
51
+
52
+ constructor(options: VobsErrorOptions) {
53
+ super(options.message)
54
+ this.name = 'VobsError'
55
+ this.code = options.code
56
+ this.severity = options.severity ?? 'error'
57
+ this.layer = options.layer ?? 'runtime'
58
+ this.cause = options.cause
59
+ this.fix = options.fix
60
+ this.location = options.location
61
+ this.trace = options.trace
62
+ this.example = options.example
63
+ this.docs = options.docs
64
+ this.codeFrame = options.codeFrame
65
+ }
66
+ }
67
+
68
+ export function createVobsError(options: VobsErrorOptions): VobsError {
69
+ return new VobsError(options)
70
+ }
71
+
72
+ export function isVobsError(value: unknown): value is VobsError {
73
+ return value instanceof VobsError
74
+ || Boolean(value && typeof value === 'object'
75
+ && typeof (value as { code?: unknown }).code === 'string'
76
+ && typeof (value as { message?: unknown }).message === 'string'
77
+ && typeof (value as { layer?: unknown }).layer === 'string')
78
+ }
79
+
80
+ /** Convert thrown strings and third-party errors without losing their message. */
81
+ export function normalizeVobsError(value: unknown, defaults: VobsErrorDefaults = {}): VobsError {
82
+ if (value instanceof VobsError) return value
83
+ if (value instanceof Error) {
84
+ // Preserve the original Error identity so boundaries and DevTools can
85
+ // correlate event/effect phases without creating duplicate diagnostics.
86
+ const metadata = value as Error & {
87
+ readonly vobsCode?: unknown
88
+ readonly vobsHint?: unknown
89
+ readonly vobsSource?: unknown
90
+ }
91
+ const code = defaults.code ?? (typeof metadata.vobsCode === 'string' ? metadata.vobsCode : undefined)
92
+ if (code) defineErrorMetadata(value, 'code', code)
93
+ defineErrorMetadata(value, 'severity', defaults.severity ?? 'error')
94
+ defineErrorMetadata(value, 'layer', defaults.layer ?? 'runtime')
95
+ const fix = defaults.fix ?? (typeof metadata.vobsHint === 'string' ? metadata.vobsHint : undefined)
96
+ if (fix) defineErrorMetadata(value, 'fix', fix)
97
+ const source = metadata.vobsSource
98
+ if (source && typeof source === 'object'
99
+ && typeof (source as { file?: unknown }).file === 'string'
100
+ && typeof (source as { line?: unknown }).line === 'number'
101
+ && typeof (source as { column?: unknown }).column === 'number') {
102
+ defineErrorMetadata(value, 'location', source)
103
+ }
104
+ return value as VobsError
105
+ }
106
+ if (isVobsError(value)) {
107
+ const candidate = value as VobsErrorOptions & { severity?: VobsErrorSeverity; layer?: VobsErrorLayer }
108
+ return new VobsError({
109
+ code: candidate.code,
110
+ message: candidate.message,
111
+ severity: candidate.severity ?? defaults.severity,
112
+ layer: candidate.layer ?? defaults.layer,
113
+ cause: candidate.cause,
114
+ fix: candidate.fix ?? defaults.fix,
115
+ location: candidate.location,
116
+ trace: candidate.trace,
117
+ example: candidate.example,
118
+ docs: candidate.docs,
119
+ codeFrame: candidate.codeFrame
120
+ })
121
+ }
122
+ const message = String(value)
123
+ return new VobsError({
124
+ code: defaults.code ?? 'VOBS_UNKNOWN',
125
+ message,
126
+ severity: defaults.severity ?? 'error',
127
+ layer: defaults.layer ?? 'runtime',
128
+ cause: undefined,
129
+ fix: defaults.fix
130
+ })
131
+ }
132
+
133
+ function defineErrorMetadata(target: Error, key: string, value: unknown): void {
134
+ if (key in target) return
135
+ try {
136
+ Object.defineProperty(target, key, { configurable: true, enumerable: false, value, writable: true })
137
+ } catch {
138
+ // Frozen third-party errors still retain their original message and stack.
139
+ }
140
+ }
141
+
142
+ export interface FormatVobsErrorOptions {
143
+ readonly environment?: 'development' | 'production'
144
+ readonly includeStack?: boolean
145
+ }
146
+
147
+ /** Format a concise, actionable message for terminals and dev overlays. */
148
+ export function formatVobsError(
149
+ value: unknown,
150
+ options: FormatVobsErrorOptions = {}
151
+ ): string {
152
+ const error = normalizeVobsError(value)
153
+ const code = error.code || 'VOBS_UNKNOWN'
154
+ const severity = error.severity || 'error'
155
+ if (options.environment === 'production') return `[Vobs ${code}] ${error.message}`
156
+
157
+ const lines = [`[Vobs ${capitalize(severity)}] ${error.message}`, `Code: ${code}`]
158
+ if (error.location) lines.push(`Location: ${error.location.file}:${error.location.line}:${error.location.column}`)
159
+ if (error.codeFrame) lines.push('', error.codeFrame)
160
+ if (error.cause !== undefined) lines.push(`Cause: ${formatCause(error.cause)}`)
161
+ if (error.trace?.length) lines.push('', `Trace: ${error.trace.join(' → ')}`)
162
+ if (error.fix) lines.push('', `Fix: ${error.fix}`)
163
+ if (error.example) lines.push('', `Example:\n${error.example}`)
164
+ if (error.docs) lines.push(`Docs: ${error.docs}`)
165
+ if (options.includeStack && error.stack) lines.push('', error.stack)
166
+ return lines.join('\n')
167
+ }
168
+
169
+ function formatCause(value: unknown): string {
170
+ return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
171
+ }
172
+
173
+ function capitalize(value: string): string {
174
+ return value.slice(0, 1).toUpperCase() + value.slice(1)
175
+ }
@@ -0,0 +1,21 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createDOMRenderer, setRenderer } from '@vobs/vobs'
4
+ import { addEventListener, createElement, removeEventListener } from './index'
5
+
6
+ describe('event binding dedupe', () => {
7
+ it('replaces a previous binding for the same node and event', () => {
8
+ setRenderer(createDOMRenderer())
9
+ const node = createElement('button')
10
+ let first = 0
11
+ let second = 0
12
+ addEventListener(node, 'click', () => { first++ })
13
+ addEventListener(node, 'click', () => { second++ })
14
+ node.dispatchEvent(new MouseEvent('click'))
15
+ expect(first).toBe(0)
16
+ expect(second).toBe(1)
17
+ removeEventListener(node, 'click', (() => undefined) as EventListener)
18
+ node.dispatchEvent(new MouseEvent('click'))
19
+ expect(second).toBe(1)
20
+ })
21
+ })
@@ -0,0 +1,81 @@
1
+ import { getCurrentOwner } from '@vobs/reactivity'
2
+ import { createComment, getRenderer } from './ops'
3
+
4
+ export interface VobsFragment {
5
+ readonly kind: 'vobs-fragment'
6
+ readonly start: Node
7
+ readonly end: Node
8
+ readonly mount: (parent: Node, anchor: Node | null) => void
9
+ readonly unmount: (parent: Node) => void
10
+ }
11
+
12
+ export type VobsNode = Node | VobsFragment
13
+ export type FragmentFactory = (parent: Node, anchor: Node) => void
14
+
15
+ export function createFragment(factory: FragmentFactory): VobsFragment {
16
+ const start = createComment('vobs:fragment:start')
17
+ const end = createComment('vobs:fragment:end')
18
+ let parent: Node | null = null
19
+ let initialized = false
20
+ const owner = getCurrentOwner()
21
+
22
+ const fragment: VobsFragment = {
23
+ kind: 'vobs-fragment',
24
+ start,
25
+ end,
26
+ mount(nextParent, anchor): void {
27
+ if (parent && parent !== nextParent) {
28
+ throw new Error('Vobs Fragment: 不能跨父节点移动 Fragment')
29
+ }
30
+
31
+ if (initialized) {
32
+ moveRange(nextParent, start, end, anchor)
33
+ return
34
+ }
35
+
36
+ const renderer = getRenderer()
37
+ renderer.insertBefore(nextParent, start, anchor)
38
+ renderer.insertBefore(nextParent, end, anchor)
39
+ parent = nextParent
40
+ initialized = true
41
+ if (owner) owner.run(() => factory(nextParent, end))
42
+ else factory(nextParent, end)
43
+ },
44
+ unmount(nextParent): void {
45
+ if (!initialized || parent !== nextParent) {
46
+ throw new Error('Vobs Fragment: Fragment 不属于指定父节点')
47
+ }
48
+ const renderer = getRenderer()
49
+ let current = renderer.nextSibling(start)
50
+ while (current && current !== end) {
51
+ const next = renderer.nextSibling(current)
52
+ renderer.removeChild(nextParent, current)
53
+ current = next
54
+ }
55
+ renderer.removeChild(nextParent, start)
56
+ renderer.removeChild(nextParent, end)
57
+ parent = null
58
+ initialized = false
59
+ }
60
+ }
61
+ return fragment
62
+ }
63
+
64
+ export function isVobsFragment(value: unknown): value is VobsFragment {
65
+ return Boolean(value) && typeof value === 'object' && (value as VobsFragment).kind === 'vobs-fragment'
66
+ }
67
+
68
+ function moveRange(parent: Node, start: Node, end: Node, anchor: Node | null): void {
69
+ const renderer = getRenderer()
70
+ const nodes: Node[] = [start]
71
+ let current = renderer.nextSibling(start)
72
+ while (current) {
73
+ nodes.push(current)
74
+ if (current === end) break
75
+ current = renderer.nextSibling(current)
76
+ }
77
+ if (nodes[nodes.length - 1] !== end) {
78
+ throw new Error('Vobs Fragment: 找不到结束锚点')
79
+ }
80
+ for (const node of nodes) renderer.insertBefore(parent, node, anchor)
81
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ createHmrStateStore,
4
+ disposeHmrModule,
5
+ resolveComponent,
6
+ updateHmrModule
7
+ } from './hmr'
8
+ import { createComponent, insertBefore, setRenderer } from './ops'
9
+
10
+ describe('runtime HMR', () => {
11
+ it('刷新已挂载的组件节点时复用原 Owner', () => {
12
+ setRenderer<Node, Text, Element, Comment>({
13
+ createText: content => document.createTextNode(content),
14
+ createElement: tag => document.createElement(tag),
15
+ createComment: content => document.createComment(content),
16
+ insertBefore: (parent, child, anchor) => { parent.insertBefore(child, anchor) },
17
+ removeChild: (parent, child) => { parent.removeChild(child) },
18
+ setTextContent: (node, content) => { node.textContent = content },
19
+ setProperty: (node, key, value) => { (node as unknown as Record<string, unknown>)[key] = value },
20
+ setAttribute: (node, key, value) => { node.setAttribute(key, value) },
21
+ addEventListener: (node, event, handler) => { node.addEventListener(event, handler) },
22
+ removeEventListener: (node, event, handler) => { node.removeEventListener(event, handler) },
23
+ nextSibling: node => node.nextSibling,
24
+ clear: container => {
25
+ while (container.childNodes.length > 0) {
26
+ const child = container.childNodes[0]
27
+ if (child) container.removeChild(child)
28
+ }
29
+ }
30
+ })
31
+ const moduleId = `hmr-mounted-${Date.now()}-${Math.random()}`
32
+ const first = resolveComponent(() => document.createTextNode('first'), moduleId, 'Panel')
33
+ const node = createComponent(first, {})
34
+ const parent = document.createElement('div')
35
+ insertBefore(parent, node, null)
36
+
37
+ updateHmrModule(moduleId, { Panel: () => document.createTextNode('second') })
38
+
39
+ expect(parent.textContent).toBe('second')
40
+ })
41
+
42
+ it('更新组件代理时保留已有引用,并保留模块状态', () => {
43
+ const moduleId = `hmr-test-${Date.now()}-${Math.random()}`
44
+ const first = resolveComponent(() => ({ textContent: 'first' } as unknown as Node), moduleId, 'Panel')
45
+ const store = createHmrStateStore(moduleId)
46
+ store.set('count', 3)
47
+
48
+ updateHmrModule(moduleId, { Panel: () => ({ textContent: 'second' } as unknown as Node) })
49
+
50
+ expect(first({})).toHaveProperty('textContent', 'second')
51
+ expect(store.get('count', 0)).toBe(3)
52
+ disposeHmrModule(moduleId)
53
+ expect(createHmrStateStore(moduleId).get('count', 0)).toBe(3)
54
+ })
55
+ })
package/src/hmr.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { VobsNode } from './fragment'
2
+
3
+ export type HmrComponent<Props extends object = Record<string, unknown>> =
4
+ (props: Props) => VobsNode
5
+
6
+ export interface HmrStateStore {
7
+ get<T>(key: string, initial: T | (() => T)): T
8
+ set<T>(key: string, value: T): void
9
+ has(key: string): boolean
10
+ delete(key: string): void
11
+ clear(): void
12
+ }
13
+
14
+ export interface HmrInstance {
15
+ node: VobsNode
16
+ parent: Node | null
17
+ refresh(): void
18
+ }
19
+
20
+ interface HmrModuleState {
21
+ readonly components: Map<string, HmrComponent>
22
+ readonly state: Map<string, unknown>
23
+ readonly instances: Set<HmrInstance>
24
+ }
25
+
26
+ interface HmrGlobal {
27
+ modules: Map<string, HmrModuleState>
28
+ }
29
+
30
+ const globalTarget = globalThis as typeof globalThis & { __VOBS_HMR__?: HmrGlobal }
31
+ const hmrGlobal = globalTarget.__VOBS_HMR__ ?? { modules: new Map<string, HmrModuleState>() }
32
+ globalTarget.__VOBS_HMR__ = hmrGlobal
33
+
34
+ export function resolveComponent<Props extends object>(
35
+ component: HmrComponent<Props>,
36
+ moduleId: string,
37
+ exportName: string
38
+ ): HmrComponent<Props> {
39
+ const module = getModule(moduleId)
40
+ const existing = module.components.get(exportName)
41
+ if (existing) return existing as HmrComponent<Props>
42
+
43
+ const proxy = ((props: Props) => {
44
+ const current = (proxy as HmrComponent<Props> & { current: HmrComponent<Props> }).current
45
+ return current(props)
46
+ }) as HmrComponent<Props> & { current: HmrComponent<Props> }
47
+ proxy.current = component
48
+ Object.defineProperties(proxy, {
49
+ displayName: { configurable: true, value: component.name || exportName },
50
+ hmrKey: { configurable: false, value: `${moduleId}:${exportName}` }
51
+ })
52
+ module.components.set(exportName, proxy as HmrComponent)
53
+ return proxy
54
+ }
55
+
56
+ export function updateHmrModule(_moduleId: string, nextModule: Record<string, unknown>): void {
57
+ const modules = [...hmrGlobal.modules.values()]
58
+ for (const module of modules) {
59
+ let changed = false
60
+ for (const [name, proxy] of module.components) {
61
+ const next = nextModule[name]
62
+ if (typeof next !== 'function') continue
63
+ const hmrProxy = proxy as HmrComponent & { current: HmrComponent; displayName?: string }
64
+ hmrProxy.current = next as HmrComponent
65
+ Object.defineProperty(hmrProxy, 'displayName', { configurable: true, value: next.name || name })
66
+ changed = true
67
+ }
68
+ if (!changed) continue
69
+ for (const instance of module.instances) {
70
+ try {
71
+ instance.refresh()
72
+ } catch {
73
+ // HMR failures remain application errors on the next normal render.
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ export function disposeHmrModule(_moduleId: string): void {
80
+ // State and component proxies intentionally survive module disposal.
81
+ }
82
+
83
+ export function createHmrStateStore(moduleId: string): HmrStateStore {
84
+ const state = getModule(moduleId).state
85
+ return {
86
+ get<T>(key: string, initial: T | (() => T)): T {
87
+ if (!state.has(key)) state.set(key, typeof initial === 'function' ? (initial as () => T)() : initial)
88
+ return state.get(key) as T
89
+ },
90
+ set<T>(key: string, value: T): void {
91
+ state.set(key, value)
92
+ },
93
+ has: key => state.has(key),
94
+ delete: key => { state.delete(key) },
95
+ clear: () => { state.clear() }
96
+ }
97
+ }
98
+
99
+ export function registerHmrInstance(moduleId: string, instance: HmrInstance): () => void {
100
+ const instances = getModule(moduleId).instances
101
+ instances.add(instance)
102
+ return () => instances.delete(instance)
103
+ }
104
+
105
+ export function markHmrInstanceMounted(node: VobsNode, parent: Node): void {
106
+ const instance = hmrInstances.get(node as object)
107
+ if (instance) instance.parent = parent
108
+ }
109
+
110
+ function getModule(moduleId: string): HmrModuleState {
111
+ let module = hmrGlobal.modules.get(moduleId)
112
+ if (!module) {
113
+ module = { components: new Map(), state: new Map(), instances: new Set() }
114
+ hmrGlobal.modules.set(moduleId, module)
115
+ }
116
+ return module
117
+ }
118
+
119
+ const hmrInstances = new WeakMap<object, HmrInstance>()
120
+
121
+ export function associateHmrInstance(node: VobsNode, instance: HmrInstance): void {
122
+ hmrInstances.set(node as object, instance)
123
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type { VobsRenderer } from './renderer'
2
+ export {
3
+ getRuntimeDebugContext,
4
+ getRuntimeDebugHooks,
5
+ invokeRuntimeDebug,
6
+ pushRuntimeDebugContext,
7
+ runWithRuntimeDebugContext,
8
+ setRuntimeDebugHooks,
9
+ describeDebugNode
10
+ } from './debug'
11
+ export type {
12
+ RuntimeDebugContext,
13
+ RuntimeDebugEnvironment,
14
+ RuntimeDebugHooks,
15
+ RuntimeDomMutation,
16
+ RuntimeDomMutationOperation,
17
+ RuntimeErrorEvent,
18
+ RuntimeHydrationMismatch
19
+ } from './debug'
20
+ export { createFragment, isVobsFragment } from './fragment'
21
+ export type { FragmentFactory, VobsFragment, VobsNode } from './fragment'
22
+ export {
23
+ setRenderer,
24
+ getRenderer,
25
+ createText,
26
+ createElement,
27
+ createComment,
28
+ insertBefore,
29
+ removeChild,
30
+ setTextContent,
31
+ setProperty,
32
+ setAttribute,
33
+ spreadProps,
34
+ setStaticProps,
35
+ addEventListener,
36
+ removeEventListener,
37
+ clear,
38
+ createComponent,
39
+ createBlock,
40
+ disposeNodeOwner
41
+ } from './ops'
42
+ export { bindText, bindAttribute, bindProperty } from './bind'
43
+ export type { ValueSource } from './bind'
44
+ export { ref, setRef } from './ref'
45
+ export type { Ref, RefTarget } from './ref'
46
+ export { insertDynamic, insertDynamicValue, insertList, normalizeDynamicChild } from './dynamic'
47
+ export type { DynamicChild, NodeFactory } from './dynamic'
48
+ export type { VobsLocatedError, VobsSourceLocation } from './ops'
49
+ export {
50
+ VobsError,
51
+ createVobsError,
52
+ formatVobsError,
53
+ isVobsError,
54
+ normalizeVobsError
55
+ } from './error'
56
+ export type {
57
+ FormatVobsErrorOptions,
58
+ VobsErrorDefaults,
59
+ VobsErrorLayer,
60
+ VobsErrorLocation,
61
+ VobsErrorOptions,
62
+ VobsErrorSeverity
63
+ } from './error'
64
+ export {
65
+ createHmrStateStore,
66
+ disposeHmrModule,
67
+ markHmrInstanceMounted,
68
+ registerHmrInstance,
69
+ resolveComponent,
70
+ updateHmrModule
71
+ } from './hmr'
72
+ export type { HmrComponent, HmrInstance, HmrStateStore } from './hmr'
73
+ export { insertErrorBoundary } from './error-boundary'
74
+ export { ErrorBoundary } from './error-boundary'
75
+ export type { ErrorBoundaryFallback, ErrorBoundaryOptions, ErrorBoundaryProps } from './error-boundary'
76
+ export { insertBoundary } from './boundary'
77
+ export type { BoundaryFallback, BoundaryOptions, BoundaryRetry } from './boundary'
78
+ export { insertAsyncBoundary, AsyncBoundary } from './async-boundary'
79
+ export type { AsyncBoundaryFallback, AsyncBoundaryOptions, AsyncBoundaryProps, AsyncBoundaryView } from './async-boundary'
80
+ export { insertProfiler, Profiler } from './profiler'
81
+ export type { ProfilerOptions, ProfilerProps, ProfilerRenderInfo } from './profiler'