view-anchor 0.1.2 → 0.2.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.
Files changed (44) hide show
  1. package/README.md +118 -34
  2. package/README.zh-CN.md +128 -44
  3. package/dist/index.d.ts +4 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -14
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +37 -16
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +191 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +131 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +20 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +206 -174
  30. package/docs/bidirectional-design.md +64 -96
  31. package/docs/{anchor-3d.html → index.html} +215 -73
  32. package/docs/mechanism.mdx +55 -49
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +79 -0
  35. package/package.json +30 -4
  36. package/src/index.ts +6 -15
  37. package/src/measure-loop.ts +36 -41
  38. package/src/protocol-publisher.ts +236 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +193 -0
  41. package/src/react.ts +186 -141
  42. package/src/size-advertiser.ts +24 -31
  43. package/src/types.ts +34 -79
  44. package/src/view-anchor.ts +228 -212
@@ -0,0 +1,236 @@
1
+ import type { AdvertisedSize, Placement, Publisher } from './types.js'
2
+ import {
3
+ GEOMETRY_PROTOCOL_VERSION,
4
+ type GeometryAddress,
5
+ type GeometryBatch,
6
+ type GeometryMessage,
7
+ type PlacementMessage,
8
+ type SizeMessage,
9
+ } from './protocol-types.js'
10
+
11
+ export type GeometrySend = Publisher<GeometryMessage>
12
+ export type GeometryBatchSend = Publisher<GeometryBatch>
13
+
14
+ export interface GeometryBatcherOptions {
15
+ /** Observes every batch-delivery error, including explicit flushes; it must not throw. */
16
+ onError?: (error: unknown) => void
17
+ }
18
+
19
+ export interface GeometryBatcher {
20
+ /** Queues one message. Returns false after disposal. */
21
+ publish(message: GeometryMessage): boolean
22
+ /** Attempts delivery of the current latest-value snapshot; delivery errors return false. */
23
+ flush(): boolean
24
+ /** Forgets one anchor's state, or every anchor when omitted. */
25
+ clear(anchorId?: string): void
26
+ /** Clears pending state; already scheduled microtasks become inert. */
27
+ dispose(): void
28
+ }
29
+
30
+ /**
31
+ * Wraps placement updates in a versioned protocol message. Sequence numbers
32
+ * start at 1 and increment with each attempted delivery.
33
+ *
34
+ * Keep one publisher per `{ anchorId, generation }` (e.g. via `useMemo` or `useRef`).
35
+ * Batchers and sequence guards drop messages with older sequence numbers, so
36
+ * recreating a publisher for the same address causes its messages to be dropped.
37
+ * Increment `generation` when intentionally resetting the publisher.
38
+ */
39
+ export function createPlacementMessagePublisher(
40
+ address: GeometryAddress,
41
+ send: GeometrySend,
42
+ ): (placement: Placement) => boolean {
43
+ let seq = 0
44
+
45
+ return (placement) => {
46
+ const message: PlacementMessage = {
47
+ v: GEOMETRY_PROTOCOL_VERSION,
48
+ kind: 'placement',
49
+ anchorId: address.anchorId,
50
+ generation: address.generation,
51
+ seq: ++seq,
52
+ placement,
53
+ }
54
+ return send(message) !== false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Wraps size updates in a versioned protocol message.
60
+ * Follows the same stability rule: keep one publisher per `{ anchorId, generation }`,
61
+ * and increment `generation` when resetting.
62
+ */
63
+ export function createSizeMessagePublisher(
64
+ address: GeometryAddress,
65
+ send: GeometrySend,
66
+ ): (size: AdvertisedSize) => boolean {
67
+ let seq = 0
68
+
69
+ return (size) => {
70
+ const message: SizeMessage = {
71
+ v: GEOMETRY_PROTOCOL_VERSION,
72
+ kind: 'size',
73
+ anchorId: address.anchorId,
74
+ generation: address.generation,
75
+ seq: ++seq,
76
+ size,
77
+ }
78
+ return send(message) !== false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Coalesces same-task messages without adding a rendering-frame delay. It owns
84
+ * no authorization policy: callers must associate addresses with trusted IPC
85
+ * senders before accepting a delivered batch.
86
+ */
87
+ export function createGeometryBatcher(
88
+ send: GeometryBatchSend,
89
+ options: GeometryBatcherOptions = {},
90
+ ): GeometryBatcher {
91
+ /** State is indexed by anchor so upgrades and clear(anchor) are O(1). */
92
+ interface AnchorState {
93
+ /** Generation, pending placement/size, and their accepted sequence marks. */
94
+ g: number
95
+ p?: PlacementMessage
96
+ s?: SizeMessage
97
+ pSeq?: number
98
+ sSeq?: number
99
+ }
100
+
101
+ const anchors = new Map<string, AnchorState>()
102
+ const pendingAnchors = new Set<AnchorState>()
103
+ let disposed = false
104
+ let scheduled = false
105
+ let flushing = false
106
+
107
+ function report(error: unknown): void {
108
+ try {
109
+ options.onError?.(error)
110
+ } catch {
111
+ // Error reporting must not turn scheduled delivery into an unhandled error.
112
+ }
113
+ }
114
+
115
+ function flush(): boolean {
116
+ if (disposed || flushing) return false
117
+ const messages: GeometryMessage[] = []
118
+ const snapshotStates: AnchorState[] = []
119
+ for (const state of pendingAnchors) {
120
+ if (state.p !== undefined) {
121
+ messages.push(state.p)
122
+ snapshotStates.push(state)
123
+ }
124
+ if (state.s !== undefined) {
125
+ messages.push(state.s)
126
+ snapshotStates.push(state)
127
+ }
128
+ }
129
+ if (messages.length === 0) return false
130
+
131
+ const batch: GeometryBatch = {
132
+ v: GEOMETRY_PROTOCOL_VERSION,
133
+ kind: 'batch',
134
+ messages,
135
+ }
136
+
137
+ flushing = true
138
+ try {
139
+ let accepted: boolean
140
+ try {
141
+ accepted = send(batch) !== false
142
+ } catch (error) {
143
+ report(error)
144
+ return false
145
+ }
146
+ if (!accepted) return false
147
+
148
+ for (let index = 0; index < messages.length; index++) {
149
+ const message = messages[index]!
150
+ const state = anchors.get(message.anchorId)
151
+ // A reentrant clear or generation upgrade replaces the state object.
152
+ if (state === undefined || state !== snapshotStates[index]) continue
153
+ if (message.kind === 'placement') {
154
+ if (state.pSeq === undefined || message.seq > state.pSeq) {
155
+ state.pSeq = message.seq
156
+ }
157
+ // Reentrant publishing may have replaced this message while send ran.
158
+ if (state.p === message) state.p = undefined
159
+ } else {
160
+ if (state.sSeq === undefined || message.seq > state.sSeq) {
161
+ state.sSeq = message.seq
162
+ }
163
+ if (state.s === message) state.s = undefined
164
+ }
165
+ if (state.p === undefined && state.s === undefined) {
166
+ pendingAnchors.delete(state)
167
+ }
168
+ }
169
+ return true
170
+ } finally {
171
+ flushing = false
172
+ }
173
+ }
174
+
175
+ function schedule(): void {
176
+ if (scheduled || disposed) return
177
+ scheduled = true
178
+ queueMicrotask(() => {
179
+ scheduled = false
180
+ if (!disposed) flush()
181
+ })
182
+ }
183
+
184
+ return {
185
+ publish(message) {
186
+ if (disposed) return false
187
+ let state = anchors.get(message.anchorId)
188
+ if (state !== undefined && message.generation < state.g) return true
189
+ if (state === undefined || message.generation > state.g) {
190
+ if (state !== undefined) pendingAnchors.delete(state)
191
+ state = {
192
+ g: message.generation,
193
+ p: undefined,
194
+ s: undefined,
195
+ pSeq: undefined,
196
+ sSeq: undefined,
197
+ }
198
+ anchors.set(message.anchorId, state)
199
+ }
200
+ if (message.kind === 'placement') {
201
+ if (
202
+ (state.p === undefined || message.seq > state.p.seq) &&
203
+ (state.pSeq === undefined || message.seq > state.pSeq)
204
+ ) {
205
+ state.p = message
206
+ pendingAnchors.add(state)
207
+ schedule()
208
+ }
209
+ } else if (
210
+ (state.s === undefined || message.seq > state.s.seq) &&
211
+ (state.sSeq === undefined || message.seq > state.sSeq)
212
+ ) {
213
+ state.s = message
214
+ pendingAnchors.add(state)
215
+ schedule()
216
+ }
217
+ return true
218
+ },
219
+ flush,
220
+ clear(anchorId) {
221
+ if (anchorId === undefined) {
222
+ anchors.clear()
223
+ pendingAnchors.clear()
224
+ return
225
+ }
226
+ const state = anchors.get(anchorId)
227
+ if (state !== undefined) pendingAnchors.delete(state)
228
+ anchors.delete(anchorId)
229
+ },
230
+ dispose() {
231
+ disposed = true
232
+ anchors.clear()
233
+ pendingAnchors.clear()
234
+ },
235
+ }
236
+ }
@@ -0,0 +1,43 @@
1
+ import type { AdvertisedSize, Placement } from './types.js'
2
+
3
+ /**
4
+ * Leaf module for the wire-format shapes shared by `protocol.ts` (decode/guard)
5
+ * and `protocol-publisher.ts` (encode/batch). Keeping them here — rather than
6
+ * in either of those two — avoids a value import cycle: `protocol.ts`
7
+ * re-exports `protocol-publisher.ts`'s functions, and those functions need
8
+ * `GEOMETRY_PROTOCOL_VERSION`, so neither of those two files can be the
9
+ * source of it without the other importing back from it.
10
+ */
11
+
12
+ /** Current wire format version for geometry messages. */
13
+ export const GEOMETRY_PROTOCOL_VERSION = 1 as const
14
+
15
+ /** Identifies one logical anchor instance within a transport session. */
16
+ export interface GeometryAddress {
17
+ anchorId: string
18
+ generation: number
19
+ }
20
+
21
+ export interface PlacementMessage extends GeometryAddress {
22
+ v: typeof GEOMETRY_PROTOCOL_VERSION
23
+ kind: 'placement'
24
+ seq: number
25
+ placement: Placement
26
+ }
27
+
28
+ export interface SizeMessage extends GeometryAddress {
29
+ v: typeof GEOMETRY_PROTOCOL_VERSION
30
+ kind: 'size'
31
+ seq: number
32
+ size: AdvertisedSize
33
+ }
34
+
35
+ export type GeometryMessage = PlacementMessage | SizeMessage
36
+
37
+ export interface GeometryBatch {
38
+ v: typeof GEOMETRY_PROTOCOL_VERSION
39
+ kind: 'batch'
40
+ messages: readonly GeometryMessage[]
41
+ }
42
+
43
+ export type GeometryWireValue = GeometryMessage | GeometryBatch
@@ -0,0 +1,193 @@
1
+ import type { AdvertisedSize, Placement } from './types.js'
2
+ import {
3
+ GEOMETRY_PROTOCOL_VERSION,
4
+ type GeometryAddress,
5
+ type GeometryBatch,
6
+ type GeometryMessage,
7
+ type GeometryWireValue,
8
+ type PlacementMessage,
9
+ type SizeMessage,
10
+ } from './protocol-types.js'
11
+
12
+ export { GEOMETRY_PROTOCOL_VERSION }
13
+ export type {
14
+ GeometryAddress,
15
+ GeometryBatch,
16
+ GeometryMessage,
17
+ GeometryWireValue,
18
+ PlacementMessage,
19
+ SizeMessage,
20
+ }
21
+
22
+ export type GeometryDecodeResult =
23
+ | { ok: true; value: GeometryWireValue }
24
+ | { ok: false; error: string }
25
+
26
+ export interface GeometryDecodeOptions {
27
+ /** Required caller-owned resource limit for a received batch. */
28
+ maxMessages: number
29
+ }
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === 'object' && value !== null && !Array.isArray(value)
33
+
34
+ const isSafeInteger = (value: unknown): value is number =>
35
+ typeof value === 'number' && Number.isSafeInteger(value)
36
+
37
+ const isNonNegativeSafeInteger = (value: unknown): value is number =>
38
+ isSafeInteger(value) && value >= 0
39
+
40
+ function error(message: string): GeometryDecodeResult {
41
+ return { ok: false, error: message }
42
+ }
43
+
44
+ function decodePlacement(value: unknown): Placement | undefined {
45
+ if (!isRecord(value) || typeof value.visible !== 'boolean') return undefined
46
+ if (!value.visible) return { visible: false }
47
+ if (!isRecord(value.bounds)) return undefined
48
+ const { x, y, width, height } = value.bounds
49
+ if (
50
+ !isSafeInteger(x) ||
51
+ !isSafeInteger(y) ||
52
+ !isNonNegativeSafeInteger(width) ||
53
+ !isNonNegativeSafeInteger(height)
54
+ ) {
55
+ return undefined
56
+ }
57
+ return { visible: true, bounds: { x, y, width, height } }
58
+ }
59
+
60
+ function decodeSize(value: unknown): AdvertisedSize | undefined {
61
+ if (!isRecord(value)) return undefined
62
+ if (value.axis !== 'block' && value.axis !== 'inline') return undefined
63
+ if (!isNonNegativeSafeInteger(value.extent)) return undefined
64
+ return { axis: value.axis, extent: value.extent }
65
+ }
66
+
67
+ function decodeMessage(value: unknown): GeometryMessage | undefined {
68
+ if (!isRecord(value) || value.v !== GEOMETRY_PROTOCOL_VERSION) return undefined
69
+ if (typeof value.anchorId !== 'string' || value.anchorId.length === 0) return undefined
70
+ if (
71
+ !isNonNegativeSafeInteger(value.generation) ||
72
+ !isNonNegativeSafeInteger(value.seq)
73
+ ) {
74
+ return undefined
75
+ }
76
+ const address = {
77
+ v: GEOMETRY_PROTOCOL_VERSION,
78
+ anchorId: value.anchorId,
79
+ generation: value.generation,
80
+ seq: value.seq,
81
+ }
82
+ if (value.kind === 'placement') {
83
+ const placement = decodePlacement(value.placement)
84
+ return placement === undefined
85
+ ? undefined
86
+ : { ...address, kind: 'placement', placement }
87
+ }
88
+ if (value.kind === 'size') {
89
+ const size = decodeSize(value.size)
90
+ return size === undefined ? undefined : { ...address, kind: 'size', size }
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * Decodes untrusted transport data without throwing. `maxMessages` is required
97
+ * so each receiver, rather than this library, chooses its own batch limit.
98
+ */
99
+ export function decodeGeometryWireValue(
100
+ value: unknown,
101
+ options: GeometryDecodeOptions,
102
+ ): GeometryDecodeResult {
103
+ try {
104
+ if (!isNonNegativeSafeInteger(options?.maxMessages) || options.maxMessages === 0) {
105
+ return error('maxMessages must be a positive safe integer')
106
+ }
107
+ const message = decodeMessage(value)
108
+ if (message !== undefined) return { ok: true, value: message }
109
+ if (!isRecord(value) || value.v !== GEOMETRY_PROTOCOL_VERSION || value.kind !== 'batch') {
110
+ return error('invalid geometry message')
111
+ }
112
+ if (!Array.isArray(value.messages) || value.messages.length > options.maxMessages) {
113
+ return error('invalid geometry batch')
114
+ }
115
+ const messages: GeometryMessage[] = []
116
+ for (const item of value.messages) {
117
+ const decoded = decodeMessage(item)
118
+ if (decoded === undefined) return error('invalid geometry batch member')
119
+ messages.push(decoded)
120
+ }
121
+ return {
122
+ ok: true,
123
+ value: { v: GEOMETRY_PROTOCOL_VERSION, kind: 'batch', messages },
124
+ }
125
+ } catch {
126
+ return error('invalid geometry message')
127
+ }
128
+ }
129
+
130
+ export interface GeometrySequenceGuard {
131
+ /** Returns whether this message is newer than the last accepted equivalent. */
132
+ accept(message: GeometryMessage): boolean
133
+ /** Forgets state for one anchor, or every anchor when omitted. */
134
+ clear(anchorId?: string): void
135
+ }
136
+
137
+ // -1 means that this generation has not seen the corresponding message kind.
138
+ type GeometrySequenceState = [
139
+ generation: number,
140
+ placementSeq: number,
141
+ sizeSeq: number,
142
+ ]
143
+
144
+ /**
145
+ * Keeps one generation floor per anchor and a sequence high-water mark per
146
+ * message kind within that generation. Ordering metadata is not authority:
147
+ * receivers must still authorize anchor IDs from their trusted transport
148
+ * context before passing a message here.
149
+ */
150
+ export function createGeometrySequenceGuard(): GeometrySequenceGuard {
151
+ const latest = new Map<string, GeometrySequenceState>()
152
+
153
+ return {
154
+ accept(message) {
155
+ const current = latest.get(message.anchorId)
156
+ if (current === undefined || message.generation > current[0]) {
157
+ latest.set(
158
+ message.anchorId,
159
+ [
160
+ message.generation,
161
+ message.kind === 'placement' ? message.seq : -1,
162
+ message.kind === 'size' ? message.seq : -1,
163
+ ],
164
+ )
165
+ return true
166
+ }
167
+ if (message.generation < current[0]) return false
168
+ const sequenceIndex = message.kind === 'placement' ? 1 : 2
169
+ if (message.seq <= current[sequenceIndex]) return false
170
+ current[sequenceIndex] = message.seq
171
+ return true
172
+ },
173
+ clear(anchorId) {
174
+ if (anchorId === undefined) {
175
+ latest.clear()
176
+ return
177
+ }
178
+ latest.delete(anchorId)
179
+ },
180
+ }
181
+ }
182
+
183
+ export {
184
+ createGeometryBatcher,
185
+ createPlacementMessagePublisher,
186
+ createSizeMessagePublisher,
187
+ } from './protocol-publisher.js'
188
+ export type {
189
+ GeometryBatcher,
190
+ GeometryBatcherOptions,
191
+ GeometryBatchSend,
192
+ GeometrySend,
193
+ } from './protocol-publisher.js'