view-anchor 0.1.2 → 0.2.1

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 (46) hide show
  1. package/README.md +111 -39
  2. package/README.zh-CN.md +119 -47
  3. package/dist/index.d.ts +6 -18
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -15
  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 +57 -17
  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 +207 -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 +128 -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 +29 -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 +230 -181
  30. package/docs/bidirectional-design.md +78 -106
  31. package/docs/index.html +772 -0
  32. package/docs/mechanism.md +116 -0
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +108 -0
  35. package/package.json +37 -14
  36. package/src/index.ts +8 -24
  37. package/src/measure-loop.ts +56 -42
  38. package/src/protocol-publisher.ts +254 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +181 -0
  41. package/src/react.ts +175 -139
  42. package/src/size-advertiser.ts +33 -31
  43. package/src/types.ts +35 -82
  44. package/src/view-anchor.ts +259 -236
  45. package/docs/anchor-3d.html +0 -615
  46. package/docs/mechanism.mdx +0 -119
@@ -0,0 +1,254 @@
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
+ // Terminal-state stand-in for `send` so a retained, disposed batcher does not
83
+ // keep the caller's transport closure (and whatever it captured) alive.
84
+ const NOOP_SEND: GeometryBatchSend = () => false
85
+
86
+ /**
87
+ * Coalesces same-task messages without adding a rendering-frame delay. It owns
88
+ * no authorization policy: callers must associate addresses with trusted IPC
89
+ * senders before accepting a delivered batch.
90
+ */
91
+ export function createGeometryBatcher(
92
+ send: GeometryBatchSend,
93
+ options: GeometryBatcherOptions = {},
94
+ ): GeometryBatcher {
95
+ /** State is indexed by anchor so upgrades and clear(anchor) are O(1). */
96
+ interface AnchorState {
97
+ /** Generation, pending placement/size, and their accepted sequence marks. */
98
+ g: number
99
+ p?: PlacementMessage
100
+ s?: SizeMessage
101
+ pSeq?: number
102
+ sSeq?: number
103
+ }
104
+
105
+ const anchors = new Map<string, AnchorState>()
106
+ const pendingAnchors = new Set<AnchorState>()
107
+ let disposed = false
108
+ let scheduled = false
109
+ let flushing = false
110
+
111
+ // `activeOptions` lets a caller keep reporting to the options object that
112
+ // was live when its flush() call started, even if a reentrant dispose()
113
+ // during send() has since cleared the instance-level `options` reference.
114
+ function report(activeOptions: GeometryBatcherOptions, error: unknown): void {
115
+ try {
116
+ activeOptions.onError?.(error)
117
+ } catch {
118
+ // Error reporting must not turn scheduled delivery into an unhandled error.
119
+ }
120
+ }
121
+
122
+ function flush(): boolean {
123
+ if (disposed || flushing) return false
124
+ const messages: GeometryMessage[] = []
125
+ const snapshotStates: AnchorState[] = []
126
+ for (const state of pendingAnchors) {
127
+ if (state.p !== undefined) {
128
+ messages.push(state.p)
129
+ snapshotStates.push(state)
130
+ }
131
+ if (state.s !== undefined) {
132
+ messages.push(state.s)
133
+ snapshotStates.push(state)
134
+ }
135
+ }
136
+ if (messages.length === 0) return false
137
+
138
+ const batch: GeometryBatch = {
139
+ v: GEOMETRY_PROTOCOL_VERSION,
140
+ kind: 'batch',
141
+ messages,
142
+ }
143
+
144
+ // Captured before send() runs so a reentrant dispose() (which clears the
145
+ // instance-level `options`) cannot blind this call's own error report.
146
+ const activeOptions = options
147
+
148
+ flushing = true
149
+ try {
150
+ let accepted: boolean
151
+ try {
152
+ accepted = send(batch) !== false
153
+ } catch (error) {
154
+ report(activeOptions, error)
155
+ return false
156
+ }
157
+ if (!accepted) return false
158
+
159
+ for (let index = 0; index < messages.length; index++) {
160
+ const message = messages[index]!
161
+ const state = anchors.get(message.anchorId)
162
+ // A reentrant clear or generation upgrade replaces the state object.
163
+ if (state === undefined || state !== snapshotStates[index]) continue
164
+ if (message.kind === 'placement') {
165
+ if (state.pSeq === undefined || message.seq > state.pSeq) {
166
+ state.pSeq = message.seq
167
+ }
168
+ // Reentrant publishing may have replaced this message while send ran.
169
+ if (state.p === message) state.p = undefined
170
+ } else {
171
+ if (state.sSeq === undefined || message.seq > state.sSeq) {
172
+ state.sSeq = message.seq
173
+ }
174
+ if (state.s === message) state.s = undefined
175
+ }
176
+ if (state.p === undefined && state.s === undefined) {
177
+ pendingAnchors.delete(state)
178
+ }
179
+ }
180
+ return true
181
+ } finally {
182
+ flushing = false
183
+ }
184
+ }
185
+
186
+ function schedule(): void {
187
+ if (scheduled || disposed) return
188
+ scheduled = true
189
+ queueMicrotask(() => {
190
+ scheduled = false
191
+ if (!disposed) flush()
192
+ })
193
+ }
194
+
195
+ return {
196
+ publish(message) {
197
+ if (disposed) return false
198
+ let state = anchors.get(message.anchorId)
199
+ if (state !== undefined && message.generation < state.g) return true
200
+ if (state === undefined || message.generation > state.g) {
201
+ if (state !== undefined) pendingAnchors.delete(state)
202
+ state = {
203
+ g: message.generation,
204
+ p: undefined,
205
+ s: undefined,
206
+ pSeq: undefined,
207
+ sSeq: undefined,
208
+ }
209
+ anchors.set(message.anchorId, state)
210
+ }
211
+ if (message.kind === 'placement') {
212
+ if (
213
+ (state.p === undefined || message.seq > state.p.seq) &&
214
+ (state.pSeq === undefined || message.seq > state.pSeq)
215
+ ) {
216
+ state.p = message
217
+ pendingAnchors.add(state)
218
+ schedule()
219
+ }
220
+ } else if (
221
+ (state.s === undefined || message.seq > state.s.seq) &&
222
+ (state.sSeq === undefined || message.seq > state.sSeq)
223
+ ) {
224
+ state.s = message
225
+ pendingAnchors.add(state)
226
+ schedule()
227
+ }
228
+ return true
229
+ },
230
+ flush,
231
+ clear(anchorId) {
232
+ if (anchorId === undefined) {
233
+ anchors.clear()
234
+ pendingAnchors.clear()
235
+ return
236
+ }
237
+ const state = anchors.get(anchorId)
238
+ if (state !== undefined) pendingAnchors.delete(state)
239
+ anchors.delete(anchorId)
240
+ },
241
+ dispose() {
242
+ disposed = true
243
+ anchors.clear()
244
+ pendingAnchors.clear()
245
+ // Callers may still mutate the original options object after
246
+ // construction (e.g. reassigning onError); flush() captures its own
247
+ // reference before send() runs, so an in-flight error report keeps
248
+ // reading that object even though dispose() drops the instance's
249
+ // long-lived reference here.
250
+ options = {}
251
+ send = NOOP_SEND
252
+ },
253
+ }
254
+ }
@@ -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,181 @@
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 (!isNonNegativeSafeInteger(value.generation) || !isNonNegativeSafeInteger(value.seq)) {
71
+ return undefined
72
+ }
73
+ const address = {
74
+ v: GEOMETRY_PROTOCOL_VERSION,
75
+ anchorId: value.anchorId,
76
+ generation: value.generation,
77
+ seq: value.seq,
78
+ }
79
+ if (value.kind === 'placement') {
80
+ const placement = decodePlacement(value.placement)
81
+ return placement === undefined ? undefined : { ...address, kind: 'placement', placement }
82
+ }
83
+ if (value.kind === 'size') {
84
+ const size = decodeSize(value.size)
85
+ return size === undefined ? undefined : { ...address, kind: 'size', size }
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ /**
91
+ * Decodes untrusted transport data without throwing. `maxMessages` is required
92
+ * so each receiver, rather than this library, chooses its own batch limit.
93
+ */
94
+ export function decodeGeometryWireValue(
95
+ value: unknown,
96
+ options: GeometryDecodeOptions,
97
+ ): GeometryDecodeResult {
98
+ try {
99
+ if (!isNonNegativeSafeInteger(options?.maxMessages) || options.maxMessages === 0) {
100
+ return error('maxMessages must be a positive safe integer')
101
+ }
102
+ const message = decodeMessage(value)
103
+ if (message !== undefined) return { ok: true, value: message }
104
+ if (!isRecord(value) || value.v !== GEOMETRY_PROTOCOL_VERSION || value.kind !== 'batch') {
105
+ return error('invalid geometry message')
106
+ }
107
+ if (!Array.isArray(value.messages) || value.messages.length > options.maxMessages) {
108
+ return error('invalid geometry batch')
109
+ }
110
+ const messages: GeometryMessage[] = []
111
+ for (const item of value.messages) {
112
+ const decoded = decodeMessage(item)
113
+ if (decoded === undefined) return error('invalid geometry batch member')
114
+ messages.push(decoded)
115
+ }
116
+ return {
117
+ ok: true,
118
+ value: { v: GEOMETRY_PROTOCOL_VERSION, kind: 'batch', messages },
119
+ }
120
+ } catch {
121
+ return error('invalid geometry message')
122
+ }
123
+ }
124
+
125
+ export interface GeometrySequenceGuard {
126
+ /** Returns whether this message is newer than the last accepted equivalent. */
127
+ accept(message: GeometryMessage): boolean
128
+ /** Forgets state for one anchor, or every anchor when omitted. */
129
+ clear(anchorId?: string): void
130
+ }
131
+
132
+ // -1 means that this generation has not seen the corresponding message kind.
133
+ type GeometrySequenceState = [generation: number, placementSeq: number, sizeSeq: number]
134
+
135
+ /**
136
+ * Keeps one generation floor per anchor and a sequence high-water mark per
137
+ * message kind within that generation. Ordering metadata is not authority:
138
+ * receivers must still authorize anchor IDs from their trusted transport
139
+ * context before passing a message here.
140
+ */
141
+ export function createGeometrySequenceGuard(): GeometrySequenceGuard {
142
+ const latest = new Map<string, GeometrySequenceState>()
143
+
144
+ return {
145
+ accept(message) {
146
+ const current = latest.get(message.anchorId)
147
+ if (current === undefined || message.generation > current[0]) {
148
+ latest.set(message.anchorId, [
149
+ message.generation,
150
+ message.kind === 'placement' ? message.seq : -1,
151
+ message.kind === 'size' ? message.seq : -1,
152
+ ])
153
+ return true
154
+ }
155
+ if (message.generation < current[0]) return false
156
+ const sequenceIndex = message.kind === 'placement' ? 1 : 2
157
+ if (message.seq <= current[sequenceIndex]) return false
158
+ current[sequenceIndex] = message.seq
159
+ return true
160
+ },
161
+ clear(anchorId) {
162
+ if (anchorId === undefined) {
163
+ latest.clear()
164
+ return
165
+ }
166
+ latest.delete(anchorId)
167
+ },
168
+ }
169
+ }
170
+
171
+ export {
172
+ createGeometryBatcher,
173
+ createPlacementMessagePublisher,
174
+ createSizeMessagePublisher,
175
+ } from './protocol-publisher.js'
176
+ export type {
177
+ GeometryBatcher,
178
+ GeometryBatcherOptions,
179
+ GeometryBatchSend,
180
+ GeometrySend,
181
+ } from './protocol-publisher.js'