@skinhub/viewer 0.1.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.
- package/EMBED.md +442 -0
- package/README.md +153 -0
- package/dist/SkinViewer.d.ts +18 -0
- package/dist/SkinViewer.d.ts.map +1 -0
- package/dist/SkinViewer.js +404 -0
- package/dist/SkinViewer.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/item.d.ts +118 -0
- package/dist/item.d.ts.map +1 -0
- package/dist/item.js +319 -0
- package/dist/item.js.map +1 -0
- package/dist/link.d.ts +30 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +18 -0
- package/dist/link.js.map +1 -0
- package/dist/protocol.d.ts +231 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +128 -0
- package/dist/protocol.js.map +1 -0
- package/dist/state.d.ts +107 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +351 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +573 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +106 -0
- package/dist/types.js.map +1 -0
- package/dist/useSkinViewer.d.ts +3 -0
- package/dist/useSkinViewer.d.ts.map +1 -0
- package/dist/useSkinViewer.js +66 -0
- package/dist/useSkinViewer.js.map +1 -0
- package/dist/weapons.d.ts +109 -0
- package/dist/weapons.d.ts.map +1 -0
- package/dist/weapons.js +260 -0
- package/dist/weapons.js.map +1 -0
- package/package.json +65 -0
- package/src/SkinViewer.tsx +465 -0
- package/src/index.ts +88 -0
- package/src/item.ts +373 -0
- package/src/link.ts +33 -0
- package/src/protocol.ts +241 -0
- package/src/state.ts +389 -0
- package/src/types.ts +672 -0
- package/src/useSkinViewer.ts +80 -0
- package/src/weapons.ts +284 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* *** `<SkinViewer />` - THE EMBED AS A REACT COMPONENT. ***
|
|
5
|
+
*
|
|
6
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* *** THE ONE PROPERTY THIS FILE EXISTS TO PROTECT: A CHEAP PROP MUST NOT RELOAD THE FRAME. ***
|
|
8
|
+
*
|
|
9
|
+
* Owner's requirement: `float`, `seed`, `statTrak`, `nameTag`, the stickers and the charm update in
|
|
10
|
+
* place *"to make it feel just like in our website"*; only the weapon, the paint kit and the view go
|
|
11
|
+
* behind a loading card. The renderer guarantees it for a prop change, and `/frame`'s
|
|
12
|
+
* identity-preserving merge guarantees it across the wire - measured there at 140 float messages, 137
|
|
13
|
+
* animation frames, 769 timer samples and ZERO covered frames.
|
|
14
|
+
*
|
|
15
|
+
* *** THIS COMPONENT CAN STILL THROW IT ALL AWAY IN THREE LINES, AND HERE THEY ARE, GUARDED: ***
|
|
16
|
+
*
|
|
17
|
+
* 1. THE `src` IS BUILT ONCE AND NEVER REWRITTEN. Assigning `src` reloads the document, which drops
|
|
18
|
+
* the GL context, re-downloads the model and re-runs every shader compile. Every prop change
|
|
19
|
+
* after mount is a `postMessage`, without exception. `boot` is state that only `reload()` writes.
|
|
20
|
+
*
|
|
21
|
+
* 2. THE `<iframe>` HAS A `key`, AND IT IS DELIBERATELY NOT DERIVED FROM ANY PROP. It is a counter
|
|
22
|
+
* that only `reload()` increments. *** A `key={item.weapon}` HERE - OR ON THIS COMPONENT, IN A
|
|
23
|
+
* CONSUMER'S TREE - WOULD REMOUNT THE FRAME ON EVERY WEAPON CHANGE *** and turn a two-second
|
|
24
|
+
* cross-fade into a full document load. The frame already covers itself on an identity change;
|
|
25
|
+
* it does not need help and cannot be helped this way. The same warning is written into
|
|
26
|
+
* `/frame`'s own file, because the mistake is available at both ends.
|
|
27
|
+
*
|
|
28
|
+
* 3. THE MESSAGE IS A DIFF. See `state.ts`: sending the whole state every tick would be correct on
|
|
29
|
+
* the wire and would still re-seed the renderer's sticker draft sixty times a second.
|
|
30
|
+
*
|
|
31
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
* *** AND THE SECOND: A STALE PACKAGE FAILS LOUDLY, NEVER PARTIALLY. ***
|
|
33
|
+
*
|
|
34
|
+
* No back-compat, no version window - decided policy, and the README's *Versioning* section is its public statement. A protocol mismatch in
|
|
35
|
+
* either direction is terminal: the frame renders nothing, this component stops sending, `onError`
|
|
36
|
+
* carries the sentence naming which side is out of date, and `fallback` gets it too. Nothing is
|
|
37
|
+
* half-applied, because a subtly wrong picture is worse than a blank one with an explanation.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
41
|
+
|
|
42
|
+
import { hostMessage, readFrameEvent, type FrameEvent } from './protocol.js'
|
|
43
|
+
import { toPublicItem } from './item.js'
|
|
44
|
+
import { coversCanvas, diffState, frameUrl, resolveState, type DesiredState } from './state.js'
|
|
45
|
+
import { LINK, type ViewerLink } from './link.js'
|
|
46
|
+
import type { SkinViewerError, SkinViewerProps, ViewerStatus } from './types.js'
|
|
47
|
+
|
|
48
|
+
/** Where the embed is served from. Overridable per component - see {@link SkinViewerProps.origin}. */
|
|
49
|
+
export const DEFAULT_ORIGIN = 'https://skinhub.gg'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* *** THE TWO DEVELOPMENT-ONLY WARNINGS BELOW ARE GUARDED BY THE LITERAL `process.env.NODE_ENV`
|
|
53
|
+
* EXPRESSION, WRITTEN OUT IN FULL AT EACH SITE, AND IT HAS TO STAY THAT WAY. ***
|
|
54
|
+
*
|
|
55
|
+
* Every bundler - webpack, Vite, Next, esbuild, Rollup - substitutes that exact member expression and
|
|
56
|
+
* nothing else. *** ANY INDIRECTION AT ALL DEFEATS IT: *** a helper function, a hoisted `const`, or -
|
|
57
|
+
* measured, and the reason this note exists - optional chaining. `process?.env?.NODE_ENV` is a
|
|
58
|
+
* different AST node, no bundler substitutes it, so it stays a live runtime lookup and both warnings
|
|
59
|
+
* plus their message strings ship inside every customer's production bundle. Written plainly it folds
|
|
60
|
+
* to a constant at build time and the minifier deletes the blocks outright.
|
|
61
|
+
*
|
|
62
|
+
* `test/bundle.test.ts` asserts the literal survives into a real bundle of `dist/`, which is exactly
|
|
63
|
+
* what caught the optional-chained version of this.
|
|
64
|
+
*
|
|
65
|
+
* *** WHICH IS ALSO WHY THERE IS NO `typeof process` GUARD. *** It would let an unbundled browser
|
|
66
|
+
* import this without a `ReferenceError`, and it would cost the substitution to do it. There is no
|
|
67
|
+
* such consumer: this is a React component, React's own development build reads the same bare global,
|
|
68
|
+
* and nobody reaches either without a bundler.
|
|
69
|
+
*
|
|
70
|
+
* `process` IS DECLARED HERE, MODULE-SCOPED, rather than pulled in from `@types/node`.
|
|
71
|
+
* `tsconfig.build.json` compiles with `"types": []` precisely so the published `.d.ts` cannot oblige a
|
|
72
|
+
* browser consumer to install Node's globals in order to typecheck, and a bare `process.env` is a
|
|
73
|
+
* compile error under it. A `declare const` INSIDE a module shadows the ambient one rather than
|
|
74
|
+
* colliding with it, so `bun run typecheck` - which does load bun's globals - and the build agree.
|
|
75
|
+
*/
|
|
76
|
+
declare const process: { env: { NODE_ENV?: string } }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* *** THE TWO KINDS OF FAILURE, AND THEY ARE HELD DIFFERENTLY ON PURPOSE. ***
|
|
80
|
+
*
|
|
81
|
+
* A FRAME FAILURE (`protocol-mismatch`, `render-failed`) arrives as an event and is STICKY state: the
|
|
82
|
+
* scene is gone and nothing in the props can bring it back, so it stays until a reload or - for a lost
|
|
83
|
+
* render - until the next identity change gives the frame something new to try.
|
|
84
|
+
*
|
|
85
|
+
* A SUBJECT FAILURE (`no-item`, `bad-inspect-link`, `unknown-weapon`) is DERIVED FROM THE PROPS and is
|
|
86
|
+
* never sticky. It is a fact about this render's arguments, so the moment the arguments are good it is
|
|
87
|
+
* over. That distinction is the difference between a viewer that recovers when a query resolves and
|
|
88
|
+
* one an integrator has to remount.
|
|
89
|
+
*/
|
|
90
|
+
const isFatal = (code: SkinViewerError['code']) =>
|
|
91
|
+
code === 'protocol-mismatch' || code === 'render-failed' || code === 'unreachable'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* *** HOW LONG THE EMBED HAS TO ANNOUNCE ITSELF BEFORE WE CALL IT UNREACHABLE. ***
|
|
95
|
+
*
|
|
96
|
+
* The frame posts `hello` as soon as its script runs, so this is a document fetch plus a parse - a few
|
|
97
|
+
* hundred milliseconds on a warm connection. Fifteen seconds is therefore not a performance budget, it
|
|
98
|
+
* is the point past which "still loading" stops being a credible explanation for an empty box.
|
|
99
|
+
*
|
|
100
|
+
* *** DELIBERATELY GENEROUS, BECAUSE A FALSE POSITIVE HERE IS SELF-HEALING AND A FALSE NEGATIVE IS
|
|
101
|
+
* NOT. *** The iframe stays mounted under the `fallback`, so a slow connection that lands at sixteen
|
|
102
|
+
* seconds clears the error and renders. A timer too short would flash an error at people on bad
|
|
103
|
+
* networks; no timer at all leaves them with a blank rectangle and nothing to search for.
|
|
104
|
+
*/
|
|
105
|
+
export const CONNECT_TIMEOUT_MS = 15_000
|
|
106
|
+
|
|
107
|
+
export const SkinViewer = (props: SkinViewerProps) => {
|
|
108
|
+
const { className, style, title = 'SkinHub viewer', loading, fallback, handle } = props
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* RESOLVED ON EVERY RENDER, NOT MEMOISED, AND THAT IS THE CHEAPER OPTION HERE.
|
|
112
|
+
*
|
|
113
|
+
* A memo would need a dependency array over props a consumer writes INLINE - `item={{…}}`,
|
|
114
|
+
* `settings={{…}}` - so its identity changes every render anyway and the memo would only add a
|
|
115
|
+
* comparison to the work it fails to skip. Everything downstream compares by VALUE for the same
|
|
116
|
+
* reason (see `diffState`), so a fresh object here costs nothing: it produces no patch, no message
|
|
117
|
+
* and no render in the frame.
|
|
118
|
+
*/
|
|
119
|
+
const desired = resolveState(props)
|
|
120
|
+
const desiredRef = useRef(desired)
|
|
121
|
+
desiredRef.current = desired
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* THE ORIGIN IS READ ONCE. See the prop's own doc: it is the single value that could only be applied
|
|
125
|
+
* by reloading the frame, and a prop that quietly throws away the GL context is the thing this
|
|
126
|
+
* component is built not to have.
|
|
127
|
+
*/
|
|
128
|
+
const originRef = useRef(props.origin ?? DEFAULT_ORIGIN)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* *** THE `src`, AND THE STATE THAT `src` EXPRESSED. ***
|
|
132
|
+
*
|
|
133
|
+
* Built in a `useState` INITIALISER rather than an effect, so the first frame anyone sees is the
|
|
134
|
+
* integrator's item and not ours corrected a tick later - and so a server render emits the same
|
|
135
|
+
* attribute the browser will, with no hydration mismatch to paper over.
|
|
136
|
+
*
|
|
137
|
+
* `nonce` is the `<iframe>`'s key and only `reload()` moves it. See point 2 in the header.
|
|
138
|
+
*/
|
|
139
|
+
const [boot, setBoot] = useState(() => {
|
|
140
|
+
const { src, expressed } = frameUrl(originRef.current, desired)
|
|
141
|
+
return { src, expressed, nonce: 0 }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
/** What the frame has been told so far. The baseline every later diff is measured against. */
|
|
145
|
+
const sent = useRef<DesiredState>(boot.expressed)
|
|
146
|
+
/** No `set` may be sent before the frame has announced itself; until then it has no listener. */
|
|
147
|
+
const connected = useRef(false)
|
|
148
|
+
const frame = useRef<HTMLIFrameElement>(null)
|
|
149
|
+
/** Cancelled the moment the frame speaks; see {@link CONNECT_TIMEOUT_MS}. */
|
|
150
|
+
const connectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
151
|
+
|
|
152
|
+
const [status, setStatus] = useState<ViewerStatus>('connecting')
|
|
153
|
+
const [error, setError] = useState<SkinViewerError | null>(null)
|
|
154
|
+
const [problems, setProblems] = useState<readonly string[]>([])
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* THE CALLBACKS, THROUGH A REF.
|
|
158
|
+
*
|
|
159
|
+
* `onChange` fires on every pointer move of a sticker drag. Putting the handler itself in a
|
|
160
|
+
* dependency array would re-attach the window listener on every render of a consumer who writes
|
|
161
|
+
* `onChange={item => setItem(item)}` inline - which is all of them. One ref, updated during render,
|
|
162
|
+
* and the listener effect below has no dependencies at all.
|
|
163
|
+
*/
|
|
164
|
+
const handlers = useRef(props)
|
|
165
|
+
handlers.current = props
|
|
166
|
+
|
|
167
|
+
const post = useCallback((message: unknown) => {
|
|
168
|
+
const target = frame.current?.contentWindow
|
|
169
|
+
if (!target) return
|
|
170
|
+
// TARGETED AT THE FRAME'S ORIGIN AND NEVER `'*'`. We know it - we built the URL - so there is no
|
|
171
|
+
// reason to broadcast a customer's item state to whatever document happens to be there.
|
|
172
|
+
target.postMessage(message, originRef.current)
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const report = useCallback((next: SkinViewerError) => {
|
|
176
|
+
handlers.current.onError?.(next)
|
|
177
|
+
if (!isFatal(next.code)) return
|
|
178
|
+
setError(next)
|
|
179
|
+
setStatus('error')
|
|
180
|
+
// A MISMATCH IS ONE-WAY. There is no path back: a frame that has answered one message this
|
|
181
|
+
// package cannot read is a frame whose next answer it also cannot trust.
|
|
182
|
+
if (next.code === 'protocol-mismatch') connected.current = false
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* *** THE FLUSH. Every prop change in this package leaves through this function. ***
|
|
187
|
+
*
|
|
188
|
+
* Called after every render and again the moment the frame connects, because a prop can change
|
|
189
|
+
* before the iframe's document exists and that update must not be lost - it has to wait, not
|
|
190
|
+
* evaporate.
|
|
191
|
+
*/
|
|
192
|
+
const flush = useCallback(() => {
|
|
193
|
+
if (!connected.current) return
|
|
194
|
+
const next = desiredRef.current
|
|
195
|
+
const patch = diffState(sent.current, next)
|
|
196
|
+
if (!patch) return
|
|
197
|
+
|
|
198
|
+
/*
|
|
199
|
+
* THE BASELINE KEEPS THE LAST ITEM WE ACTUALLY SENT. A render whose `item` was null - a host
|
|
200
|
+
* whose query briefly returned `undefined` - must not record "no item" as the thing the frame is
|
|
201
|
+
* showing, or the next real item would be diffed against a hole and sent in full for no reason.
|
|
202
|
+
*/
|
|
203
|
+
sent.current = { ...next, item: next.item ?? sent.current.item }
|
|
204
|
+
if (coversCanvas(patch, next.view)) {
|
|
205
|
+
setStatus('loading')
|
|
206
|
+
// A NEW ITEM IS A NEW CHANCE. A lost GL context or a 404 on one model says nothing about the
|
|
207
|
+
// next one, so the fallback comes down and the frame is allowed to try. A protocol mismatch is
|
|
208
|
+
// not cleared here: that one is about the conversation, not the item.
|
|
209
|
+
setError(current => (current?.code === 'render-failed' ? null : current))
|
|
210
|
+
}
|
|
211
|
+
post(hostMessage(patch))
|
|
212
|
+
}, [post])
|
|
213
|
+
|
|
214
|
+
useEffect(flush)
|
|
215
|
+
|
|
216
|
+
/* ── THE CONNECTION DEADLINE ──────────────────────────────────────────────────────────────── */
|
|
217
|
+
/**
|
|
218
|
+
* *** THE ONLY THING STANDING BETWEEN AN UNREACHABLE ORIGIN AND A SILENT EMPTY BOX. ***
|
|
219
|
+
*
|
|
220
|
+
* Keyed on `boot.nonce`, so `reload()` genuinely retries rather than inheriting a spent deadline.
|
|
221
|
+
* See {@link CONNECT_TIMEOUT_MS} and the `unreachable` code for why the browser gives us nothing
|
|
222
|
+
* else to go on.
|
|
223
|
+
*/
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
connectTimer.current = setTimeout(() => {
|
|
226
|
+
connectTimer.current = null
|
|
227
|
+
if (connected.current) return
|
|
228
|
+
report({
|
|
229
|
+
code: 'unreachable',
|
|
230
|
+
message: `The SkinHub viewer embed at ${boot.src} did not respond within ${Math.round(
|
|
231
|
+
CONNECT_TIMEOUT_MS / 1000,
|
|
232
|
+
)}s. Nothing has rendered. Check that the origin is reachable from this browser, that it serves /frame, and that your page's Content-Security-Policy allows framing it (frame-src).`,
|
|
233
|
+
})
|
|
234
|
+
}, CONNECT_TIMEOUT_MS)
|
|
235
|
+
|
|
236
|
+
return () => {
|
|
237
|
+
if (connectTimer.current !== null) clearTimeout(connectTimer.current)
|
|
238
|
+
connectTimer.current = null
|
|
239
|
+
}
|
|
240
|
+
}, [boot.nonce, boot.src, report])
|
|
241
|
+
|
|
242
|
+
/* ── THE CHANNEL ──────────────────────────────────────────────────────────────────────────── */
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const onMessage = (event: MessageEvent) => {
|
|
245
|
+
/*
|
|
246
|
+
* BOTH CHECKS, AND THEY ARE NOT THE SAME CHECK. `event.source` is the sending WINDOW and
|
|
247
|
+
* cannot be spoofed, so it is what stops a sibling frame or an ad tag on the host page from
|
|
248
|
+
* driving this component. `event.origin` is what stops a frame that has been navigated
|
|
249
|
+
* somewhere else from continuing to talk to us. `EMBED.md` §10 tells hand-rolled hosts to do
|
|
250
|
+
* exactly this, and a package that told them to and did not would be worth nothing.
|
|
251
|
+
*/
|
|
252
|
+
if (event.source !== frame.current?.contentWindow) return
|
|
253
|
+
if (event.origin !== originRef.current) return
|
|
254
|
+
|
|
255
|
+
const reading = readFrameEvent(event.data)
|
|
256
|
+
if (reading.kind === 'ignore') return
|
|
257
|
+
if (reading.kind === 'mismatch') {
|
|
258
|
+
report(reading.error)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
receive(reading.event)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* Shadowing the `handle` PROP here would be a trap, so the reader is named for what it does. */
|
|
265
|
+
const receive = (event: FrameEvent) => {
|
|
266
|
+
switch (event.type) {
|
|
267
|
+
case 'hello': {
|
|
268
|
+
connected.current = true
|
|
269
|
+
if (connectTimer.current !== null) {
|
|
270
|
+
clearTimeout(connectTimer.current)
|
|
271
|
+
connectTimer.current = null
|
|
272
|
+
}
|
|
273
|
+
/*
|
|
274
|
+
* *** A LATE ARRIVAL UNDOES `unreachable`, AND ONLY THAT ONE. *** The iframe is never
|
|
275
|
+
* unmounted - the `fallback` is drawn OVER it - so a connection that lands after the timer
|
|
276
|
+
* is a working viewer sitting under an error card, which is worse than the blank box the
|
|
277
|
+
* timer was added to prevent. Cleared here rather than in the timer because this is the
|
|
278
|
+
* only place we learn the embed is really there.
|
|
279
|
+
*/
|
|
280
|
+
setError(current => (current?.code === 'unreachable' ? null : current))
|
|
281
|
+
setStatus(current => (current === 'connecting' || current === 'error' ? 'loading' : current))
|
|
282
|
+
setProblems(event.problems)
|
|
283
|
+
/*
|
|
284
|
+
* A PROBLEM IN `hello` IS OUR BUG AND NOT THE INTEGRATOR'S. They passed props; this
|
|
285
|
+
* package turned them into a query string; the frame is naming the part of that string it
|
|
286
|
+
* could not read. It is surfaced on the hook and warned about in development rather than
|
|
287
|
+
* routed to `onError`, which is for failures they can act on.
|
|
288
|
+
*/
|
|
289
|
+
if (event.problems.length > 0 && process.env.NODE_ENV !== 'production')
|
|
290
|
+
console.warn('[@skinhub/viewer] the embed rejected part of the URL this package built:', event.problems)
|
|
291
|
+
// Anything that changed while the document was still loading goes now.
|
|
292
|
+
flush()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
case 'ready':
|
|
296
|
+
// A LEVEL, NOT AN EDGE - it fires again after every identity change. And it fires after a
|
|
297
|
+
// FAILED load too, which is why a fatal error is not cleared here.
|
|
298
|
+
setStatus(current => (current === 'error' ? current : 'ready'))
|
|
299
|
+
handlers.current.onReady?.()
|
|
300
|
+
return
|
|
301
|
+
case 'error':
|
|
302
|
+
report(event.error)
|
|
303
|
+
return
|
|
304
|
+
case 'change': {
|
|
305
|
+
/*
|
|
306
|
+
* *** THE USER'S EDIT, AND IT IS ALSO WRITTEN INTO OUR BASELINE. ***
|
|
307
|
+
*
|
|
308
|
+
* The second half is the one that is easy to miss: without it, the next patch we send
|
|
309
|
+
* would be diffed against an item that never learned about the drag, so the very next
|
|
310
|
+
* float tick would restate the OLD sticker offsets and yank the sticker back under the
|
|
311
|
+
* user's cursor. The frame is the authority on what the user did; we follow it.
|
|
312
|
+
*/
|
|
313
|
+
sent.current = { ...sent.current, item: event.item }
|
|
314
|
+
handlers.current.onChange?.(toPublicItem(event.item))
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
case 'editing-slot':
|
|
318
|
+
handlers.current.onEditingSlotChange?.(event.slot)
|
|
319
|
+
return
|
|
320
|
+
case 'resize':
|
|
321
|
+
handlers.current.onResize?.({ width: event.width, height: event.height, dpr: event.dpr })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
window.addEventListener('message', onMessage)
|
|
327
|
+
return () => window.removeEventListener('message', onMessage)
|
|
328
|
+
}, [flush, report])
|
|
329
|
+
|
|
330
|
+
/* ── THE SUBJECT'S OWN FAILURES ───────────────────────────────────────────────────────────── */
|
|
331
|
+
/*
|
|
332
|
+
* `no-item`, `bad-inspect-link` and `unknown-weapon` happen HERE rather than in the frame - they are
|
|
333
|
+
* decided while resolving props, before a message is sent. Reported once per distinct message, not
|
|
334
|
+
* once per render, because a host re-rendering at 60 Hz with a bad link would otherwise fill their
|
|
335
|
+
* console and their error reporter.
|
|
336
|
+
*/
|
|
337
|
+
const reported = useRef<string | null>(null)
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
const subjectError = desiredRef.current.subjectError
|
|
340
|
+
if (!subjectError) {
|
|
341
|
+
reported.current = null
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
if (reported.current === subjectError.message) return
|
|
345
|
+
reported.current = subjectError.message
|
|
346
|
+
handlers.current.onError?.(subjectError)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
/* ── THE HANDLE ───────────────────────────────────────────────────────────────────────────── */
|
|
350
|
+
const link: ViewerLink | undefined = handle?.[LINK]
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!link) return
|
|
353
|
+
link.reload = () => {
|
|
354
|
+
/*
|
|
355
|
+
* REBUILT FROM WHAT IS ON SCREEN NOW, not from the props this component mounted with - a reload
|
|
356
|
+
* that reverted the item to its first value would be a surprise, not a refresh.
|
|
357
|
+
*
|
|
358
|
+
* AND IT FALLS BACK TO THE LAST ITEM WE SENT, for the case where this render happens to have no
|
|
359
|
+
* item: a `reload()` fired from a button while the host's query is momentarily `undefined` must
|
|
360
|
+
* bring the item back, not the instruction card.
|
|
361
|
+
*/
|
|
362
|
+
const current = desiredRef.current
|
|
363
|
+
const next: DesiredState = current.item
|
|
364
|
+
? current
|
|
365
|
+
: { ...current, item: sent.current.item, help: sent.current.item ? null : current.help }
|
|
366
|
+
const { src, expressed } = frameUrl(originRef.current, next)
|
|
367
|
+
sent.current = expressed
|
|
368
|
+
connected.current = false
|
|
369
|
+
setStatus('connecting')
|
|
370
|
+
setError(null)
|
|
371
|
+
setProblems([])
|
|
372
|
+
setBoot(previous => ({ src, expressed, nonce: previous.nonce + 1 }))
|
|
373
|
+
}
|
|
374
|
+
return () => {
|
|
375
|
+
link.reload = () => {}
|
|
376
|
+
}
|
|
377
|
+
}, [link])
|
|
378
|
+
|
|
379
|
+
/*
|
|
380
|
+
* WHAT THE HOOK IS TOLD IS THE RESOLVED VIEW OF BOTH FAILURE KINDS, not the raw event state - so a
|
|
381
|
+
* consumer rendering `viewer.status` sees `'error'` for a link that did not decode, without this
|
|
382
|
+
* component having to make a prop-derived fact sticky to get it there.
|
|
383
|
+
*/
|
|
384
|
+
const publicError = error ?? desired.subjectError
|
|
385
|
+
const publicStatus = publicError ? 'error' : status
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
link?.publish({ status: publicStatus, error: publicError, problems })
|
|
388
|
+
}, [link, publicStatus, publicError, problems])
|
|
389
|
+
|
|
390
|
+
/* ── THE BOX ──────────────────────────────────────────────────────────────────────────────── */
|
|
391
|
+
const box = useRef<HTMLDivElement>(null)
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (process.env.NODE_ENV === 'production') return
|
|
394
|
+
const element = box.current
|
|
395
|
+
if (!element || (element.offsetWidth > 0 && element.offsetHeight > 0)) return
|
|
396
|
+
console.warn(
|
|
397
|
+
'[@skinhub/viewer] <SkinViewer> measured 0 px in one dimension, so nothing will be visible. ' +
|
|
398
|
+
'The viewer fills its container and has no intrinsic size - give it one with `style` or `className` ' +
|
|
399
|
+
'(e.g. style={{ width: 640, height: 420 }}), or size the element you put it in.',
|
|
400
|
+
)
|
|
401
|
+
}, [])
|
|
402
|
+
|
|
403
|
+
/*
|
|
404
|
+
* ── WHAT IS DRAWN OVER THE FRAME, IN ORDER ────────────────────────────────────────────────
|
|
405
|
+
*
|
|
406
|
+
* 1. A FATAL FAILURE takes the `fallback` slot. The scene is gone underneath it.
|
|
407
|
+
*
|
|
408
|
+
* 2. A SUBJECT FAILURE takes it too WHEN THE CALLER PROVIDED ONE - they said what they want shown
|
|
409
|
+
* for a bad item and that beats anything we would draw. *** WITH NO `fallback` IT DRAWS NOTHING,
|
|
410
|
+
* AND THAT IS THE POINT: *** the frame is showing the instruction card, and covering it with a
|
|
411
|
+
* skeleton would replace the one screen in this product whose whole job is to teach.
|
|
412
|
+
*
|
|
413
|
+
* 3. OTHERWISE `loading`, until the frame says `ready`.
|
|
414
|
+
*/
|
|
415
|
+
const overlay = error
|
|
416
|
+
? renderFallback(fallback, error)
|
|
417
|
+
: desired.subjectError
|
|
418
|
+
? (renderFallback(fallback, desired.subjectError) ?? null)
|
|
419
|
+
: status === 'ready'
|
|
420
|
+
? null
|
|
421
|
+
: loading
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<div ref={box} className={className} style={{ position: 'relative', ...style }}>
|
|
425
|
+
<iframe
|
|
426
|
+
/* Point 2 in the header. Nothing but `reload()` may move this. */
|
|
427
|
+
key={boot.nonce}
|
|
428
|
+
ref={frame}
|
|
429
|
+
src={boot.src}
|
|
430
|
+
title={title}
|
|
431
|
+
/*
|
|
432
|
+
* *** NO `referrerPolicy`, AND THAT IS A DECISION. *** The embed reads the framing origin
|
|
433
|
+
* from this request's `Referer` header. Setting `no-referrer` here - which looks like good
|
|
434
|
+
* hygiene - would make every embed anonymous to us, which is not a privacy win for the host
|
|
435
|
+
* (we already know the URL they asked for) and does break the one thing that header decides.
|
|
436
|
+
*/
|
|
437
|
+
style={{
|
|
438
|
+
position: 'absolute',
|
|
439
|
+
inset: 0,
|
|
440
|
+
width: '100%',
|
|
441
|
+
height: '100%',
|
|
442
|
+
border: 0,
|
|
443
|
+
// The frame paints nothing of its own, so the canvas composites over the host's page.
|
|
444
|
+
// Without this a browser's default white iframe background would sit in between.
|
|
445
|
+
background: 'transparent',
|
|
446
|
+
display: 'block',
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
{overlay === null || overlay === undefined ? null : (
|
|
450
|
+
/*
|
|
451
|
+
* DRAWN OVER THE FRAME, NEVER INSIDE IT - a React element cannot be structured-cloned across
|
|
452
|
+
* a `postMessage` boundary, so this is the only place a consumer's node can live.
|
|
453
|
+
*
|
|
454
|
+
* `pointerEvents: 'none'` so a spinner does not eat the orbit drag underneath it. A consumer
|
|
455
|
+
* whose overlay is interactive turns it back on in their own node, which is the right way
|
|
456
|
+
* round: the common case costs them nothing and the rare one is one line.
|
|
457
|
+
*/
|
|
458
|
+
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{overlay}</div>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const renderFallback = (fallback: SkinViewerProps['fallback'], error: SkinViewerError) =>
|
|
465
|
+
typeof fallback === 'function' ? fallback(error) : fallback
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* *** `@skinhub/viewer` - THE SKINHUB CS2 SKIN VIEWER, AS A REACT COMPONENT. ***
|
|
3
|
+
*
|
|
4
|
+
* import { SkinViewer } from '@skinhub/viewer'
|
|
5
|
+
*
|
|
6
|
+
* <SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 1449, float: 0.27 }} style={{ height: 420 }} />
|
|
7
|
+
* <SkinViewer inspectLink={tradeOffer.inspectLink} style={{ height: 420 }} />
|
|
8
|
+
*
|
|
9
|
+
* The 3D is not in here. It is a page on our origin that this component embeds and drives over
|
|
10
|
+
* `postMessage`, which is why installing this pulls in no `three`, no `@react-three/fiber` and no
|
|
11
|
+
* asset bundle - the only peer dependency is React. `EMBED.md` documents the same contract for stacks
|
|
12
|
+
* that are not React; this package is the React-shaped door onto it, not a second implementation.
|
|
13
|
+
*
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* WHAT IS EXPORTED, AND WHY THE LIST IS SHORT.
|
|
16
|
+
*
|
|
17
|
+
* `SkinViewer`, `useSkinViewer` - the component and its verbs.
|
|
18
|
+
* the types - so a consumer can name a prop's shape in their own code.
|
|
19
|
+
* `MAP_NAMES`, `WEAPON_IDS` - the two closed vocabularies, as values, for building a picker.
|
|
20
|
+
* the defindex table - because an integrator with a Steam inventory has numbers.
|
|
21
|
+
*
|
|
22
|
+
* Nothing else FROM HERE. The URL builder and the diff are implementation: if they were public, a
|
|
23
|
+
* customer could build a state this package would then have to keep working, and the whole point of
|
|
24
|
+
* the version integer is that there is exactly one shape to keep working.
|
|
25
|
+
*
|
|
26
|
+
* *** THE WIRE TYPES ARE THE ONE EXCEPTION AND THEY ARE A SEPARATE DOOR - `@skinhub/viewer/protocol`.
|
|
27
|
+
* *** Deliberately not the barrel, so they never appear in an autocomplete next to `SkinViewer` and
|
|
28
|
+
* nobody reaches for them by accident. Exported at all because `EMBED.md` §6 already publishes that
|
|
29
|
+
* exact shape in prose, for hosts that are not React, and because the app repo's conformance test has
|
|
30
|
+
* to import both halves of the wire to prove they still agree. See `protocol.ts` for both.
|
|
31
|
+
*
|
|
32
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
* *** DO NOT ADD `"sideEffects": false` TO `package.json`. Measured, Bun 1.3.13: *** with that flag
|
|
34
|
+
* set, a `bun build` bundle of this barrel tree-shakes the entire package away and emits a 248-byte
|
|
35
|
+
* file that re-exports fourteen names and defines none of them. It builds, it publishes, and it fails
|
|
36
|
+
* at import time in the consumer. The flag is the correct thing to want and it is not worth this.
|
|
37
|
+
*
|
|
38
|
+
* (The package's OWN build is `tsc`, not a bundler - one emitted file per source file, matching
|
|
39
|
+
* `@skinhub/cdn` - so the flag would only ever bite a consumer's bundler. That is worse, not better:
|
|
40
|
+
* it moves the failure to somebody else's build.)
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export { SkinViewer, DEFAULT_ORIGIN } from './SkinViewer.js'
|
|
44
|
+
export { useSkinViewer } from './useSkinViewer.js'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* *** ITEM OUT, LINK BACK. *** A picker is not finished when it can show the item - it is finished
|
|
48
|
+
* when it can hand you the link. See `item.ts` for the three easy-to-get-wrong facts these encode.
|
|
49
|
+
*/
|
|
50
|
+
export { fromInspectLink, toInspectLink, toPlacement } from './item.js'
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
MapName,
|
|
54
|
+
SkinViewerCharm,
|
|
55
|
+
SkinViewerError,
|
|
56
|
+
SkinViewerErrorCode,
|
|
57
|
+
SkinViewerHandle,
|
|
58
|
+
SkinViewerItem,
|
|
59
|
+
SkinViewerProps,
|
|
60
|
+
SkinViewerSticker,
|
|
61
|
+
TimeOfDay,
|
|
62
|
+
ViewerAgent,
|
|
63
|
+
ViewerBackground,
|
|
64
|
+
ViewerCameraSettings,
|
|
65
|
+
ViewerEnvironmentSettings,
|
|
66
|
+
ViewerGloves,
|
|
67
|
+
ViewerInteractions,
|
|
68
|
+
ViewerOverlaySettings,
|
|
69
|
+
ViewerQualitySettings,
|
|
70
|
+
ViewerResize,
|
|
71
|
+
ViewerSettings,
|
|
72
|
+
ViewerStatus,
|
|
73
|
+
ViewerSubject,
|
|
74
|
+
ViewerView,
|
|
75
|
+
} from './types.js'
|
|
76
|
+
export { CHEAP_FIELDS, MAP_NAMES } from './types.js'
|
|
77
|
+
|
|
78
|
+
export type { KnownWeaponId, WeaponId } from './weapons.js'
|
|
79
|
+
export {
|
|
80
|
+
defindexForWeaponId,
|
|
81
|
+
isGloveId,
|
|
82
|
+
isKnownWeaponId,
|
|
83
|
+
normalizeWeaponId,
|
|
84
|
+
WEAPON_ID_ALIASES,
|
|
85
|
+
WEAPON_ID_BY_DEFINDEX,
|
|
86
|
+
WEAPON_IDS,
|
|
87
|
+
weaponIdForDefindex,
|
|
88
|
+
} from './weapons.js'
|