@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @vobs/runtime
2
+
3
+ The renderer-agnostic runtime of the vobs framework: DOM operations, compiled bindings, dynamic insertion, list reconciliation, and lifecycle boundaries.
4
+
5
+ This package implements the operations the vobs compiler emits (`createElement`, `bindText`, `insertList`, ...) against a pluggable `VobsRenderer`, so the same component code runs under different rendering backends.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @vobs/runtime @vobs/reactivity
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ### Boundaries (user-facing)
16
+
17
+ ```tsx
18
+ import { ErrorBoundary, AsyncBoundary } from '@vobs/runtime'
19
+
20
+ <ErrorBoundary fallback={(error, retry) => <button onClick={retry}>Retry</button>}>
21
+ <RiskyView />
22
+ </ErrorBoundary>
23
+
24
+ <AsyncBoundary fallback={<Spinner />}>
25
+ <SlowView />
26
+ </AsyncBoundary>
27
+ ```
28
+
29
+ ### Dynamic children and lists
30
+
31
+ ```ts
32
+ import { effect, state } from '@vobs/reactivity'
33
+ import { insertDynamicValue, insertList, createFragment } from '@vobs/runtime'
34
+
35
+ const view = state(<span>hello</span>)
36
+ insertDynamicValue(parent, anchor, () => view.value) // swaps nodes reactively
37
+
38
+ const items = state(['a', 'b'])
39
+ insertList(parent, anchor, () => items.value, {
40
+ key: item => item // keyed reconciliation; indexed when omitted
41
+ })
42
+ ```
43
+
44
+ Reactive text binding:
45
+
46
+ ```ts
47
+ import { bindText } from '@vobs/runtime'
48
+
49
+ bindText(node, () => count.value)
50
+ ```
51
+
52
+ ### Custom renderers
53
+
54
+ Implement `VobsRenderer` (node creation, insertion, removal, attribute/property/event application, clearing) and register it with `setRenderer`. All compiler-emitted operations route through it.
55
+
56
+ ## API
57
+
58
+ | Signature | Description |
59
+ | --- | --- |
60
+ | `setRenderer(renderer)` / `getRenderer()` | Registers the active renderer backend. |
61
+ | `createElement(tag)` / `createText(text)` / `createComment(text)` | Node factories (hydration-aware). |
62
+ | `insertBefore(parent, node, anchor)` / `removeChild(parent, node)` | Tree mutation, fragment-aware. |
63
+ | `setAttribute(node, name, value)` / `setProperty(node, name, value)` | Attribute vs property writes; `isPropertyKey` gates the property whitelist. |
64
+ | `addEventListener(node, event, handler)` | Registers under the current owner — handlers get owner-scoped error isolation and are removed on owner dispose. |
65
+ | `spreadProps(node, props)` / `setStaticProps(node, props)` | Apply prop objects; property keys accept `false`, attribute keys skip it. |
66
+ | `bindText` / `bindAttribute` / `bindProperty` | Reactive one-way bindings to a node target. |
67
+ | `ref(target)` / `setRef(node, target)` | Ref plumbing for element access. |
68
+ | `insertDynamic(parent, anchor, factory)` / `insertDynamicValue(...)` | Reactive node swapping with full disposal of replaced subtrees. |
69
+ | `insertList(parent, anchor, factory, options?)` | Keyed/indexed list reconciliation with per-row owners. |
70
+ | `createFragment(render)` | Multi-root node container. |
71
+ | `ErrorBoundary` / `insertErrorBoundary` | Catches child render/effect errors with retry. |
72
+ | `insertBoundary` | Low-level boundary primitive (loading/error/empty switching). |
73
+ | `AsyncBoundary` / `insertAsyncBoundary` | Async view swapping with fallback. |
74
+ | `Profiler` / `insertProfiler` | Render timing instrumentation. |
75
+ | HMR exports (`createHmrStateStore`, `registerHmrInstance`, `updateHmrModule`, ...) | Hot-reload state preservation. |
76
+
77
+ ## Types
78
+
79
+ `VobsRenderer`, `VobsNode`, `VobsFragment`, `DynamicChild`, `NodeFactory`, `Ref`, plus option types for each boundary.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "license": "MIT",
3
+ "files": [
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
7
+ ],
8
+ "name": "@vobs/runtime",
9
+ "version": "1.0.0",
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./error": "./src/error.ts"
16
+ },
17
+ "dependencies": {
18
+ "@vobs/reactivity": "1.0.0"
19
+ }
20
+ }
@@ -0,0 +1,97 @@
1
+ import { createOwner, state } from '@vobs/reactivity'
2
+ import { insertDynamic, type NodeFactory } from './dynamic'
3
+ import { createFragment, type VobsNode } from './fragment'
4
+
5
+ export type AsyncBoundaryView = ReturnType<NodeFactory> | NodeFactory
6
+ export type AsyncBoundaryFallback = (error: Error, retry: () => void) => ReturnType<NodeFactory>
7
+ export interface AsyncBoundaryOptions<T> {
8
+ promise: PromiseLike<T> | (() => PromiseLike<T>)
9
+ children: (value: T) => ReturnType<NodeFactory>
10
+ loading?: AsyncBoundaryView
11
+ fallback?: AsyncBoundaryFallback
12
+ resetKey?: () => unknown
13
+ }
14
+ export interface AsyncBoundaryProps<T> extends AsyncBoundaryOptions<T> {}
15
+
16
+ export function insertAsyncBoundary<T>(parent: Node, anchor: Node | null, options: AsyncBoundaryOptions<T>): void {
17
+ const boundary = createOwner()
18
+ boundary.run(() => {
19
+ const loading = state(true)
20
+ const data = state<T | undefined>(undefined)
21
+ const resolved = state(false)
22
+ const error = state<Error | null>(null)
23
+ let token = 0
24
+ let previousKey: unknown
25
+ let initialized = false
26
+ let fallbackActive = false
27
+ boundary.onError(reason => {
28
+ if (fallbackActive) throw reason
29
+ error.value = normalizeError(reason)
30
+ loading.value = false
31
+ })
32
+
33
+ const start = (): void => {
34
+ const current = ++token
35
+ loading.value = true
36
+ error.value = null
37
+ data.value = undefined
38
+ resolved.value = false
39
+ let promise: PromiseLike<T>
40
+ try {
41
+ promise = typeof options.promise === 'function' ? options.promise() : options.promise
42
+ } catch (reason) {
43
+ if (current === token) {
44
+ loading.value = false
45
+ error.value = normalizeError(reason)
46
+ }
47
+ return
48
+ }
49
+ Promise.resolve(promise).then(value => {
50
+ if (current !== token || boundary.disposed) return
51
+ data.value = value
52
+ resolved.value = true
53
+ loading.value = false
54
+ }, reason => {
55
+ if (current !== token || boundary.disposed) return
56
+ error.value = normalizeError(reason)
57
+ loading.value = false
58
+ })
59
+ }
60
+
61
+ const retry = (): void => start()
62
+ start()
63
+ insertDynamic(parent, anchor, () => {
64
+ const key = options.resetKey?.()
65
+ if (!initialized) {
66
+ initialized = true
67
+ previousKey = key
68
+ } else if (!Object.is(previousKey, key)) {
69
+ previousKey = key
70
+ start()
71
+ }
72
+ if (loading.value) {
73
+ fallbackActive = false
74
+ return resolveView(options.loading)
75
+ }
76
+ if (error.value) {
77
+ fallbackActive = true
78
+ return options.fallback?.(error.value, retry) ?? null
79
+ }
80
+ fallbackActive = false
81
+ return resolved.value ? options.children(data.value as T) : resolveView(options.loading)
82
+ })
83
+ })
84
+ }
85
+
86
+ export function AsyncBoundary<T>(props: AsyncBoundaryProps<T>): VobsNode {
87
+ return createFragment((parent, anchor) => insertAsyncBoundary(parent, anchor, props))
88
+ }
89
+
90
+ function resolveView(view: AsyncBoundaryView | undefined): ReturnType<NodeFactory> {
91
+ if (!view) return null
92
+ return typeof view === 'function' ? view() : view
93
+ }
94
+
95
+ function normalizeError(reason: unknown): Error {
96
+ return reason instanceof Error ? reason : new Error(String(reason))
97
+ }
package/src/bind.ts ADDED
@@ -0,0 +1,47 @@
1
+ // 动态绑定:将 Signal 绑定到 DOM 节点
2
+
3
+ import { effect } from '@vobs/reactivity'
4
+ import type { Signal } from '@vobs/reactivity'
5
+ import { setAttribute, setProperty, setTextContent } from './ops'
6
+
7
+ export type ValueSource<T> = Signal<T> | (() => T)
8
+
9
+ function readSource<T>(source: ValueSource<T>): T {
10
+ return typeof source === 'function' ? source() : source.value
11
+ }
12
+
13
+ export function bindText(
14
+ node: Text,
15
+ source: ValueSource<unknown>
16
+ ): void {
17
+ effect(() => {
18
+ const value = readSource(source)
19
+ setTextContent(node, value === null || value === undefined || typeof value === 'boolean' ? '' : String(value))
20
+ })
21
+ }
22
+
23
+ export function bindAttribute(
24
+ node: Element,
25
+ key: string,
26
+ source: ValueSource<unknown>
27
+ ): void {
28
+ effect(() => {
29
+ const value = readSource(source)
30
+ setAttribute(node, key, key === 'style' && value && typeof value === 'object' && !Array.isArray(value)
31
+ ? Object.entries(value as Record<string, unknown>)
32
+ .filter(([, entry]) => entry !== null && entry !== undefined && entry !== false)
33
+ .map(([name, entry]) => `${name.replace(/[A-Z]/gu, match => `-${match.toLowerCase()}`)}:${String(entry)}`)
34
+ .join(';')
35
+ : String(value))
36
+ })
37
+ }
38
+
39
+ export function bindProperty(
40
+ node: Element,
41
+ key: string,
42
+ source: ValueSource<unknown>
43
+ ): void {
44
+ effect(() => {
45
+ setProperty(node, key, readSource(source))
46
+ })
47
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createDOMRenderer, createText, createVobs, Profiler, AsyncBoundary, state } from '@vobs/vobs'
3
+
4
+ describe('AsyncBoundary and Profiler', () => {
5
+ it('renders loading then resolved content', async () => {
6
+ let resolve!: (value: string) => void
7
+ const promise = new Promise<string>(done => { resolve = done })
8
+ const host = document.createElement('div')
9
+ const app = createVobs({ renderer: createDOMRenderer(), render: () => AsyncBoundary({
10
+ promise,
11
+ loading: createText('loading'),
12
+ children: value => createText(value)
13
+ }) })
14
+ app.mount(host)
15
+ expect(host.textContent).toBe('loading')
16
+ resolve('ready')
17
+ await promise
18
+ await Promise.resolve()
19
+ expect(host.textContent).toBe('ready')
20
+ app.destroy()
21
+ })
22
+
23
+ it('ignores stale Promise results after a resetKey change', async () => {
24
+ let resolveFirst!: (value: string) => void
25
+ let resolveSecond!: (value: string) => void
26
+ const first = new Promise<string>(done => { resolveFirst = done })
27
+ const second = new Promise<string>(done => { resolveSecond = done })
28
+ const key = state(0)
29
+ const host = document.createElement('div')
30
+ const app = createVobs({ renderer: createDOMRenderer(), render: () => AsyncBoundary({
31
+ promise: () => key.value === 0 ? first : second,
32
+ resetKey: () => key.value,
33
+ children: value => createText(value)
34
+ }) })
35
+ app.mount(host)
36
+ key.value = 1
37
+ app.update()
38
+ resolveFirst('stale')
39
+ resolveSecond('fresh')
40
+ await Promise.resolve()
41
+ await Promise.resolve()
42
+ expect(host.textContent).toBe('fresh')
43
+ app.destroy()
44
+ })
45
+
46
+ it('reports mount and update phases', () => {
47
+ const events: string[] = []
48
+ const host = document.createElement('div')
49
+ const app = createVobs({ renderer: createDOMRenderer(), render: () => Profiler({
50
+ id: 'demo',
51
+ onRender: info => events.push(info.phase),
52
+ children: () => createText('content')
53
+ }) })
54
+ app.mount(host)
55
+ expect(events).toEqual(['mount'])
56
+ app.destroy()
57
+ vi.restoreAllMocks()
58
+ })
59
+ })
@@ -0,0 +1,93 @@
1
+ import { createOwner, state } from '@vobs/reactivity'
2
+ import { invokeRuntimeDebug } from './debug'
3
+ import { insertDynamic, type NodeFactory } from './dynamic'
4
+ import { normalizeVobsError } from './error'
5
+
6
+ export type BoundaryRetry = () => void | Promise<unknown>
7
+ export type BoundaryFallback = (error: Error, retry: BoundaryRetry) => ReturnType<NodeFactory>
8
+
9
+ export interface BoundaryOptions {
10
+ children: NodeFactory
11
+ fallback: BoundaryFallback
12
+ onRetry?: () => void | Promise<unknown>
13
+ resetKey?: () => unknown
14
+ }
15
+
16
+ /**
17
+ * Shared no-wrapper boundary protocol. The boundary owns its reactive error
18
+ * state and child branch, so replacing the branch also disposes its Owner.
19
+ */
20
+ export function insertBoundary(
21
+ parent: Node,
22
+ anchor: Node | null,
23
+ options: BoundaryOptions
24
+ ): void {
25
+ const boundary = createOwner()
26
+ boundary.run(() => {
27
+ const error = state<Error | null>(null)
28
+ let fallbackActive = false
29
+ let lastError: Error | null = null
30
+ let initialized = false
31
+ let previousKey: unknown
32
+
33
+ boundary.onError(reason => {
34
+ if (fallbackActive) throw reason
35
+ const normalized = normalizeVobsError(reason, {
36
+ code: 'VOBS_R001',
37
+ layer: 'runtime',
38
+ fix: '检查组件渲染逻辑,或在边界 fallback 中提供恢复操作。'
39
+ })
40
+ lastError = normalized
41
+ invokeRuntimeDebug('error', {
42
+ error: normalized,
43
+ owner: boundary,
44
+ phase: 'boundary',
45
+ handled: true,
46
+ recovery: 'fallback'
47
+ })
48
+ error.value = normalized
49
+ })
50
+
51
+ const retry = (): void | Promise<unknown> => {
52
+ if (error.value) {
53
+ invokeRuntimeDebug('error', {
54
+ error: error.value,
55
+ owner: boundary,
56
+ phase: 'boundary',
57
+ handled: true,
58
+ recovery: 'retrying'
59
+ })
60
+ }
61
+ error.value = null
62
+ return options.onRetry?.()
63
+ }
64
+
65
+ insertDynamic(parent, anchor, () => {
66
+ const nextKey = options.resetKey?.()
67
+ if (!initialized || !Object.is(previousKey, nextKey)) {
68
+ initialized = true
69
+ previousKey = nextKey
70
+ if (error.value) error.value = null
71
+ }
72
+
73
+ const currentError = error.value
74
+ if (!currentError) {
75
+ if (fallbackActive && lastError) {
76
+ invokeRuntimeDebug('error', {
77
+ error: lastError,
78
+ owner: boundary,
79
+ phase: 'boundary',
80
+ handled: true,
81
+ recovery: 'recovered'
82
+ })
83
+ lastError = null
84
+ }
85
+ fallbackActive = false
86
+ return options.children()
87
+ }
88
+
89
+ fallbackActive = true
90
+ return options.fallback(currentError, retry)
91
+ })
92
+ })
93
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { Owner } from '@vobs/reactivity'
2
+
3
+ export type RuntimeDebugEnvironment = 'client' | 'server'
4
+
5
+ /**
6
+ * Lightweight context copied onto debug events created synchronously inside a
7
+ * Router loader, Effect or SSR render. It is intentionally optional so the
8
+ * runtime remains useful without DevTools.
9
+ */
10
+ export interface RuntimeDebugContext {
11
+ readonly environment?: RuntimeDebugEnvironment
12
+ readonly sessionId?: string
13
+ readonly route?: string
14
+ readonly navigationId?: number
15
+ readonly dataRequestId?: number
16
+ readonly updateId?: string
17
+ readonly effectId?: string
18
+ readonly source?: string
19
+ }
20
+
21
+ export interface RuntimeHydrationMismatch {
22
+ readonly kind: 'missing-node' | 'extra-node' | 'position' | 'content'
23
+ readonly expected: string
24
+ readonly actual: string
25
+ readonly path: string
26
+ readonly message: string
27
+ }
28
+
29
+ export type RuntimeDomMutationOperation = 'text' | 'property' | 'attribute' | 'insert' | 'remove'
30
+
31
+ export interface RuntimeDomMutation {
32
+ readonly operation: RuntimeDomMutationOperation
33
+ readonly target: string
34
+ readonly parent?: string
35
+ readonly key?: string
36
+ readonly previousValue?: unknown
37
+ readonly nextValue?: unknown
38
+ }
39
+
40
+ export interface RuntimeErrorEvent {
41
+ readonly error: unknown
42
+ readonly owner: Owner
43
+ readonly phase: 'event' | 'boundary'
44
+ readonly handled: boolean
45
+ readonly recovery: 'propagated' | 'handled' | 'fallback' | 'retrying' | 'recovered'
46
+ }
47
+
48
+ export interface RuntimeDebugHooks {
49
+ domMutation?(mutation: RuntimeDomMutation): void
50
+ error?(event: RuntimeErrorEvent): void
51
+ hydrationMismatch?(event: RuntimeHydrationMismatch): void
52
+ }
53
+
54
+ let activeRuntimeDebugHooks: RuntimeDebugHooks | null = null
55
+ let activeRuntimeDebugContext: RuntimeDebugContext | null = null
56
+
57
+ export function setRuntimeDebugHooks(hooks: RuntimeDebugHooks | null): RuntimeDebugHooks | null {
58
+ const previous = activeRuntimeDebugHooks
59
+ activeRuntimeDebugHooks = hooks
60
+ return previous
61
+ }
62
+
63
+ export function getRuntimeDebugHooks(): RuntimeDebugHooks | null {
64
+ return activeRuntimeDebugHooks
65
+ }
66
+
67
+ export function getRuntimeDebugContext(): RuntimeDebugContext | null {
68
+ return activeRuntimeDebugContext
69
+ }
70
+
71
+ /** Run a synchronous operation with trace context, preserving async renders. */
72
+ export function runWithRuntimeDebugContext<T>(context: RuntimeDebugContext, task: () => T): T {
73
+ const previous = activeRuntimeDebugContext
74
+ const next = { ...previous, ...context }
75
+ activeRuntimeDebugContext = next
76
+ let result: T
77
+ try {
78
+ result = task()
79
+ } catch (error) {
80
+ activeRuntimeDebugContext = previous
81
+ throw error
82
+ }
83
+ if (isPromiseLike(result)) {
84
+ return Promise.resolve(result).finally(() => {
85
+ if (activeRuntimeDebugContext === next) activeRuntimeDebugContext = previous
86
+ }) as T
87
+ }
88
+ activeRuntimeDebugContext = previous
89
+ return result
90
+ }
91
+
92
+ /** Keep a context active across callback boundaries such as Effect execution. */
93
+ export function pushRuntimeDebugContext(context: RuntimeDebugContext): () => void {
94
+ const previous = activeRuntimeDebugContext
95
+ activeRuntimeDebugContext = { ...previous, ...context }
96
+ let restored = false
97
+ return () => {
98
+ if (restored) return
99
+ restored = true
100
+ activeRuntimeDebugContext = previous
101
+ }
102
+ }
103
+
104
+ export function invokeRuntimeDebug<K extends keyof RuntimeDebugHooks>(
105
+ name: K,
106
+ ...args: Parameters<NonNullable<RuntimeDebugHooks[K]>>
107
+ ): void {
108
+ const callback = activeRuntimeDebugHooks?.[name] as ((...values: unknown[]) => void) | undefined
109
+ if (!callback) return
110
+ try {
111
+ callback(...args)
112
+ } catch {
113
+ // Debug tooling must never change runtime behavior.
114
+ }
115
+ }
116
+
117
+ export function readDebugValue(read: () => unknown): unknown {
118
+ try {
119
+ return read()
120
+ } catch {
121
+ return '[Uninspectable]'
122
+ }
123
+ }
124
+
125
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
126
+ return Boolean(value) && (typeof value === 'object' || typeof value === 'function')
127
+ && typeof (value as { then?: unknown }).then === 'function'
128
+ }
129
+
130
+ export function describeDebugNode(node: unknown): string {
131
+ if (!node || typeof node !== 'object') return 'node'
132
+ const value = node as {
133
+ readonly nodeName?: unknown
134
+ readonly tagName?: unknown
135
+ readonly id?: unknown
136
+ readonly className?: unknown
137
+ }
138
+ const name = typeof value.tagName === 'string'
139
+ ? value.tagName.toLowerCase()
140
+ : typeof value.nodeName === 'string' ? value.nodeName.toLowerCase() : 'node'
141
+ const id = typeof value.id === 'string' && value.id ? `#${value.id}` : ''
142
+ const className = typeof value.className === 'string' && value.className
143
+ ? `.${value.className.trim().split(/\s+/).filter(Boolean).join('.')}`
144
+ : ''
145
+ return `${name}${id}${className}`
146
+ }