@businessdash/sdk 0.9.60 → 0.9.80

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 (239) hide show
  1. package/README.md +991 -295
  2. package/assets/biab-sw.js +92 -0
  3. package/dist/account-core/index.d.ts +126 -0
  4. package/dist/account-core/index.d.ts.map +1 -0
  5. package/dist/account-core/index.js +191 -0
  6. package/dist/account-core/index.js.map +1 -0
  7. package/dist/account.d.ts +11 -0
  8. package/dist/account.d.ts.map +1 -0
  9. package/dist/account.js +11 -0
  10. package/dist/account.js.map +1 -0
  11. package/dist/auth-handler.d.ts +1 -1
  12. package/dist/auth-links.d.ts +33 -0
  13. package/dist/auth-links.d.ts.map +1 -0
  14. package/dist/auth-links.js +64 -0
  15. package/dist/auth-links.js.map +1 -0
  16. package/dist/blog-core/index.d.ts +112 -0
  17. package/dist/blog-core/index.d.ts.map +1 -0
  18. package/dist/blog-core/index.js +330 -0
  19. package/dist/blog-core/index.js.map +1 -0
  20. package/dist/blog.d.ts +11 -0
  21. package/dist/blog.d.ts.map +1 -0
  22. package/dist/blog.js +11 -0
  23. package/dist/blog.js.map +1 -0
  24. package/dist/chat-core/index.d.ts +97 -0
  25. package/dist/chat-core/index.d.ts.map +1 -0
  26. package/dist/chat-core/index.js +237 -0
  27. package/dist/chat-core/index.js.map +1 -0
  28. package/dist/chat.d.ts +11 -0
  29. package/dist/chat.d.ts.map +1 -0
  30. package/dist/chat.js +11 -0
  31. package/dist/chat.js.map +1 -0
  32. package/dist/checkout-core/index.d.ts +65 -0
  33. package/dist/checkout-core/index.d.ts.map +1 -0
  34. package/dist/checkout-core/index.js +159 -0
  35. package/dist/checkout-core/index.js.map +1 -0
  36. package/dist/checkout.d.ts +10 -0
  37. package/dist/checkout.d.ts.map +1 -0
  38. package/dist/checkout.js +10 -0
  39. package/dist/checkout.js.map +1 -0
  40. package/dist/cli.js +133 -25
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +622 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +888 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/consent-core/index.cjs +107 -0
  47. package/dist/consent-core/index.d.ts +103 -0
  48. package/dist/consent-core/index.d.ts.map +1 -0
  49. package/dist/consent-core/index.js +157 -0
  50. package/dist/consent-core/index.js.map +1 -0
  51. package/dist/contracts.d.ts +3526 -767
  52. package/dist/contracts.d.ts.map +1 -1
  53. package/dist/contracts.js +571 -4
  54. package/dist/contracts.js.map +1 -1
  55. package/dist/data-model-schema.d.ts +1 -1
  56. package/dist/data-model-schema.d.ts.map +1 -1
  57. package/dist/element.d.ts +30 -0
  58. package/dist/element.d.ts.map +1 -1
  59. package/dist/element.js +56 -0
  60. package/dist/element.js.map +1 -1
  61. package/dist/embed-protocol.d.ts +86 -0
  62. package/dist/embed-protocol.d.ts.map +1 -0
  63. package/dist/embed-protocol.js +60 -0
  64. package/dist/embed-protocol.js.map +1 -0
  65. package/dist/framework-support.d.ts +122 -0
  66. package/dist/framework-support.d.ts.map +1 -0
  67. package/dist/framework-support.js +323 -0
  68. package/dist/framework-support.js.map +1 -0
  69. package/dist/index.cjs +1355 -24
  70. package/dist/legal-core/index.cjs +115 -0
  71. package/dist/legal-core/index.d.ts +160 -0
  72. package/dist/legal-core/index.d.ts.map +1 -0
  73. package/dist/legal-core/index.js +220 -0
  74. package/dist/legal-core/index.js.map +1 -0
  75. package/dist/marketing-core/index.d.ts +66 -0
  76. package/dist/marketing-core/index.d.ts.map +1 -0
  77. package/dist/marketing-core/index.js +154 -0
  78. package/dist/marketing-core/index.js.map +1 -0
  79. package/dist/marketing.d.ts +7 -0
  80. package/dist/marketing.d.ts.map +1 -0
  81. package/dist/marketing.js +7 -0
  82. package/dist/marketing.js.map +1 -0
  83. package/dist/mcp-adapters/index.d.ts +135 -0
  84. package/dist/mcp-adapters/index.d.ts.map +1 -0
  85. package/dist/mcp-adapters/index.js +187 -0
  86. package/dist/mcp-adapters/index.js.map +1 -0
  87. package/dist/mcp-adapters.d.ts +7 -0
  88. package/dist/mcp-adapters.d.ts.map +1 -0
  89. package/dist/mcp-adapters.js +7 -0
  90. package/dist/mcp-adapters.js.map +1 -0
  91. package/dist/model3d-core/index.cjs +95 -0
  92. package/dist/model3d-core/index.d.ts +167 -0
  93. package/dist/model3d-core/index.d.ts.map +1 -0
  94. package/dist/model3d-core/index.js +158 -0
  95. package/dist/model3d-core/index.js.map +1 -0
  96. package/dist/notifications-core/index.cjs +315 -0
  97. package/dist/notifications-core/index.d.ts +229 -0
  98. package/dist/notifications-core/index.d.ts.map +1 -0
  99. package/dist/notifications-core/index.js +445 -0
  100. package/dist/notifications-core/index.js.map +1 -0
  101. package/dist/portal-core/index.d.ts +451 -0
  102. package/dist/portal-core/index.d.ts.map +1 -0
  103. package/dist/portal-core/index.js +819 -0
  104. package/dist/portal-core/index.js.map +1 -0
  105. package/dist/portal.d.ts +7 -0
  106. package/dist/portal.d.ts.map +1 -0
  107. package/dist/portal.js +7 -0
  108. package/dist/portal.js.map +1 -0
  109. package/dist/push-core/index.cjs +171 -0
  110. package/dist/push-core/index.d.ts +149 -0
  111. package/dist/push-core/index.d.ts.map +1 -0
  112. package/dist/push-core/index.js +231 -0
  113. package/dist/push-core/index.js.map +1 -0
  114. package/dist/react-marketing.d.ts +1 -17
  115. package/dist/react-marketing.d.ts.map +1 -1
  116. package/dist/react-marketing.js +4 -0
  117. package/dist/react-marketing.js.map +1 -1
  118. package/dist/react.d.ts +294 -101
  119. package/dist/react.d.ts.map +1 -1
  120. package/dist/react.js +302 -246
  121. package/dist/react.js.map +1 -1
  122. package/dist/seed-core/index.d.ts +354 -0
  123. package/dist/seed-core/index.d.ts.map +1 -0
  124. package/dist/seed-core/index.js +420 -0
  125. package/dist/seed-core/index.js.map +1 -0
  126. package/dist/seed.d.ts +7 -0
  127. package/dist/seed.d.ts.map +1 -0
  128. package/dist/seed.js +7 -0
  129. package/dist/seed.js.map +1 -0
  130. package/dist/seo-core/blog.d.ts +94 -0
  131. package/dist/seo-core/blog.d.ts.map +1 -0
  132. package/dist/seo-core/blog.js +149 -0
  133. package/dist/seo-core/blog.js.map +1 -0
  134. package/dist/seo-core/commerce.d.ts +64 -0
  135. package/dist/seo-core/commerce.d.ts.map +1 -0
  136. package/dist/seo-core/commerce.js +87 -0
  137. package/dist/seo-core/commerce.js.map +1 -0
  138. package/dist/seo-core/index.cjs +751 -0
  139. package/dist/seo-core/index.d.ts +226 -0
  140. package/dist/seo-core/index.d.ts.map +1 -0
  141. package/dist/seo-core/index.js +455 -0
  142. package/dist/seo-core/index.js.map +1 -0
  143. package/dist/seo-core/local.d.ts +181 -0
  144. package/dist/seo-core/local.d.ts.map +1 -0
  145. package/dist/seo-core/local.js +193 -0
  146. package/dist/seo-core/local.js.map +1 -0
  147. package/dist/sitemap-core/index.cjs +167 -0
  148. package/dist/sitemap-core/index.d.ts +155 -0
  149. package/dist/sitemap-core/index.d.ts.map +1 -0
  150. package/dist/sitemap-core/index.js +195 -0
  151. package/dist/sitemap-core/index.js.map +1 -0
  152. package/dist/social-core/index.d.ts +74 -0
  153. package/dist/social-core/index.d.ts.map +1 -0
  154. package/dist/social-core/index.js +72 -0
  155. package/dist/social-core/index.js.map +1 -0
  156. package/dist/social.d.ts +7 -0
  157. package/dist/social.d.ts.map +1 -0
  158. package/dist/social.js +7 -0
  159. package/dist/social.js.map +1 -0
  160. package/dist/solid-compat.d.ts +204 -0
  161. package/dist/solid-compat.d.ts.map +1 -0
  162. package/dist/solid-compat.js +221 -0
  163. package/dist/solid-compat.js.map +1 -0
  164. package/dist/store-core/index.d.ts +141 -0
  165. package/dist/store-core/index.d.ts.map +1 -0
  166. package/dist/store-core/index.js +277 -0
  167. package/dist/store-core/index.js.map +1 -0
  168. package/dist/store.d.ts +12 -0
  169. package/dist/store.d.ts.map +1 -0
  170. package/dist/store.js +12 -0
  171. package/dist/store.js.map +1 -0
  172. package/dist/vanilla.d.ts +111 -0
  173. package/dist/vanilla.d.ts.map +1 -1
  174. package/dist/vanilla.js +206 -0
  175. package/dist/vanilla.js.map +1 -1
  176. package/dist/verify-core/index.d.ts +98 -0
  177. package/dist/verify-core/index.d.ts.map +1 -0
  178. package/dist/verify-core/index.js +186 -0
  179. package/dist/verify-core/index.js.map +1 -0
  180. package/dist/verify.d.ts +8 -0
  181. package/dist/verify.d.ts.map +1 -0
  182. package/dist/verify.js +8 -0
  183. package/dist/verify.js.map +1 -0
  184. package/openapi.json +4516 -1311
  185. package/package.json +128 -11
  186. package/src/account-core/index.ts +309 -0
  187. package/src/account.ts +11 -0
  188. package/src/angular/headless.ts +519 -0
  189. package/src/angular/index.ts +3 -0
  190. package/src/auth-links.ts +76 -0
  191. package/src/blog-core/index.ts +423 -0
  192. package/src/blog.ts +11 -0
  193. package/src/chat-core/index.ts +335 -0
  194. package/src/chat.ts +11 -0
  195. package/src/checkout-core/index.ts +225 -0
  196. package/src/checkout.ts +10 -0
  197. package/src/cli.ts +184 -28
  198. package/src/client.ts +1097 -0
  199. package/src/consent-core/index.ts +213 -0
  200. package/src/contracts.ts +768 -11
  201. package/src/data-model-schema.ts +1 -1
  202. package/src/element.ts +102 -0
  203. package/src/embed-protocol.ts +100 -0
  204. package/src/framework-support.ts +440 -0
  205. package/src/legal-core/index.ts +276 -0
  206. package/src/marketing-core/index.ts +209 -0
  207. package/src/marketing.ts +7 -0
  208. package/src/mcp-adapters/index.ts +264 -0
  209. package/src/mcp-adapters.ts +7 -0
  210. package/src/model3d-core/index.ts +248 -0
  211. package/src/notifications-core/index.ts +650 -0
  212. package/src/portal-core/index.ts +1564 -0
  213. package/src/portal.ts +7 -0
  214. package/src/push-core/index.ts +335 -0
  215. package/src/qwik.tsx +602 -2
  216. package/src/react-marketing.tsx +5 -0
  217. package/src/react.tsx +434 -319
  218. package/src/seed-core/index.ts +735 -0
  219. package/src/seed.ts +7 -0
  220. package/src/seo-core/blog.ts +216 -0
  221. package/src/seo-core/commerce.ts +138 -0
  222. package/src/seo-core/index.ts +605 -0
  223. package/src/seo-core/local.ts +333 -0
  224. package/src/sitemap-core/index.ts +296 -0
  225. package/src/social-core/index.ts +103 -0
  226. package/src/social.ts +7 -0
  227. package/src/solid-compat.tsx +347 -0
  228. package/src/solid-surfaces.tsx +1311 -0
  229. package/src/solid.tsx +82 -60
  230. package/src/store-core/index.ts +426 -0
  231. package/src/store.ts +12 -0
  232. package/src/svelte/SocialLinks.svelte +84 -0
  233. package/src/svelte/headless.ts +435 -0
  234. package/src/svelte/index.ts +9 -0
  235. package/src/vanilla.ts +367 -0
  236. package/src/verify-core/index.ts +288 -0
  237. package/src/verify.ts +8 -0
  238. package/src/vue/headless.ts +527 -0
  239. package/src/vue/index.ts +3 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `@businessdash/sdk/mcp-adapters` — mount the MCP connector on any framework.
3
+ *
4
+ * `mcpHandler` returns Web-standard `(Request) => Promise<Response>`, which is
5
+ * exactly right for Next's App Router, Astro, Remix and TanStack Start, and
6
+ * exactly wrong for everything else: SvelteKit hands you `{ request }`,
7
+ * Nuxt/Nitro hands you an H3 `event`, Qwik City hands you a `RequestEvent` with
8
+ * `send()`, and Express hands you Node's `req`/`res`.
9
+ *
10
+ * So a consumer on four of the eight frameworks had to work out the bridging
11
+ * themselves, for a surface whose whole selling point is that an org hands
12
+ * Claude or ChatGPT a URL on their OWN domain. This module is that bridging,
13
+ * written once.
14
+ *
15
+ * Every adapter is a thin shape-change over the same handler — no adapter
16
+ * reimplements the proxy, the error mapping or the manifest caching, so a fix
17
+ * to any of those lands everywhere at once.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import {
23
+ type McpProxyOptions,
24
+ mcpHandler,
25
+ mcpManifestHandler,
26
+ } from "../mcp.js";
27
+
28
+ /**
29
+ * SvelteKit (and Astro, which has the same shape).
30
+ *
31
+ * ```ts
32
+ * // src/routes/api/mcp/+server.ts
33
+ * import { sveltekitMcpHandler } from '@businessdash/sdk/mcp-adapters'
34
+ * export const { POST, GET } = sveltekitMcpHandler({ siteId, baseUrl })
35
+ * ```
36
+ */
37
+ export function sveltekitMcpHandler(options: McpProxyOptions): {
38
+ POST: (event: { request: Request }) => Promise<Response>;
39
+ GET: () => Promise<Response>;
40
+ } {
41
+ const handler = mcpHandler(options);
42
+ return {
43
+ POST: (event) => handler.POST(event.request),
44
+ GET: () => handler.GET(),
45
+ };
46
+ }
47
+
48
+ /** SvelteKit / Astro manifest route for `.well-known/mcp.json`. */
49
+ export function sveltekitMcpManifestHandler(
50
+ options: McpProxyOptions,
51
+ ): (event: { request: Request }) => Promise<Response> {
52
+ const handler = mcpManifestHandler(options);
53
+ return (event) => handler(event.request);
54
+ }
55
+
56
+ /** Astro is the same shape as SvelteKit; aliased so the import reads right. */
57
+ export const astroMcpHandler = sveltekitMcpHandler;
58
+ export const astroMcpManifestHandler = sveltekitMcpManifestHandler;
59
+
60
+ /**
61
+ * The minimum of H3's event we touch. Declared structurally rather than
62
+ * importing `h3`, so this module adds no dependency for consumers who are not
63
+ * on Nuxt.
64
+ */
65
+ type H3EventLike = {
66
+ node: { req: { method?: string | undefined } };
67
+ web?: { request?: Request } | undefined;
68
+ };
69
+
70
+ /**
71
+ * Nuxt / Nitro.
72
+ *
73
+ * ```ts
74
+ * // server/routes/api/mcp.post.ts
75
+ * import { nitroMcpHandler } from '@businessdash/sdk/mcp-adapters'
76
+ * export default defineEventHandler(nitroMcpHandler({ siteId, baseUrl }))
77
+ * ```
78
+ *
79
+ * Nitro exposes the original Web `Request` on `event.web.request` in recent
80
+ * versions. `toWebRequest` is passed in rather than imported so this stays
81
+ * dependency-free — on older Nitro, hand it `toWebRequest` from `h3`.
82
+ */
83
+ export function nitroMcpHandler(
84
+ options: McpProxyOptions,
85
+ deps: { toWebRequest?: (event: unknown) => Request } = {},
86
+ ): (event: H3EventLike) => Promise<Response> {
87
+ const handler = mcpHandler(options);
88
+ return async (event) => {
89
+ const request = event.web?.request ?? deps.toWebRequest?.(event);
90
+ if (!request) {
91
+ // Failing loudly beats proxying an empty body and returning a
92
+ // confusing JSON-RPC error the consumer cannot trace.
93
+ throw new Error(
94
+ "nitroMcpHandler could not read a Web Request from the H3 event. On Nitro versions without `event.web.request`, pass `{ toWebRequest }` from `h3`.",
95
+ );
96
+ }
97
+ return (event.node.req.method ?? "GET").toUpperCase() === "POST"
98
+ ? handler.POST(request)
99
+ : handler.GET();
100
+ };
101
+ }
102
+
103
+ /** Nuxt / Nitro manifest route. */
104
+ export function nitroMcpManifestHandler(
105
+ options: McpProxyOptions,
106
+ deps: { toWebRequest?: (event: unknown) => Request } = {},
107
+ ): (event: H3EventLike) => Promise<Response> {
108
+ const handler = mcpManifestHandler(options);
109
+ return async (event) => {
110
+ const request = event.web?.request ?? deps.toWebRequest?.(event);
111
+ if (!request) {
112
+ throw new Error(
113
+ "nitroMcpManifestHandler could not read a Web Request from the H3 event. Pass `{ toWebRequest }` from `h3` on older Nitro.",
114
+ );
115
+ }
116
+ return handler(request);
117
+ };
118
+ }
119
+
120
+ /** The slice of Qwik City's `RequestEvent` these adapters use. */
121
+ type QwikRequestEventLike = {
122
+ request: Request;
123
+ send: (response: Response) => void;
124
+ };
125
+
126
+ /**
127
+ * Qwik City.
128
+ *
129
+ * ```ts
130
+ * // src/routes/api/mcp/index.ts
131
+ * import { qwikMcpHandler } from '@businessdash/sdk/mcp-adapters'
132
+ * const mcp = qwikMcpHandler({ siteId, baseUrl })
133
+ * export const onPost = mcp.onPost
134
+ * export const onGet = mcp.onGet
135
+ * ```
136
+ *
137
+ * Qwik City returns a response by CALLING `send()` rather than returning it,
138
+ * which is the detail most people get wrong when wiring this by hand.
139
+ */
140
+ export function qwikMcpHandler(options: McpProxyOptions): {
141
+ onPost: (event: QwikRequestEventLike) => Promise<void>;
142
+ onGet: (event: QwikRequestEventLike) => Promise<void>;
143
+ } {
144
+ const handler = mcpHandler(options);
145
+ return {
146
+ onPost: async (event) => {
147
+ event.send(await handler.POST(event.request));
148
+ },
149
+ onGet: async (event) => {
150
+ event.send(await handler.GET());
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Qwik City manifest route. */
156
+ export function qwikMcpManifestHandler(
157
+ options: McpProxyOptions,
158
+ ): (event: QwikRequestEventLike) => Promise<void> {
159
+ const handler = mcpManifestHandler(options);
160
+ return async (event) => {
161
+ event.send(await handler(event.request));
162
+ };
163
+ }
164
+
165
+ /** The Node req/res surface these adapters touch. */
166
+ type NodeRequestLike = {
167
+ method?: string | undefined;
168
+ url?: string | undefined;
169
+ headers: Record<string, string | string[] | undefined>;
170
+ on(event: string, listener: (chunk?: unknown) => void): unknown;
171
+ };
172
+
173
+ type NodeResponseLike = {
174
+ statusCode: number;
175
+ setHeader(name: string, value: string): void;
176
+ end(body?: string): void;
177
+ };
178
+
179
+ /**
180
+ * Express, and Angular Universal / Analog, which both run on Node's req/res.
181
+ *
182
+ * ```ts
183
+ * const mcp = expressMcpHandler({ siteId, baseUrl })
184
+ * app.post('/api/mcp', mcp)
185
+ * app.get('/api/mcp', mcp)
186
+ * app.get('/.well-known/mcp.json', expressMcpManifestHandler({ siteId, baseUrl }))
187
+ * ```
188
+ *
189
+ * Node gives a stream rather than a `Request`, so the body is collected first.
190
+ * `origin` matters for the manifest — it is what tells the platform which
191
+ * domain to advertise — so it is reconstructed from the Host header rather
192
+ * than guessed.
193
+ */
194
+ export function expressMcpHandler(
195
+ options: McpProxyOptions,
196
+ ): (req: NodeRequestLike, res: NodeResponseLike) => Promise<void> {
197
+ const handler = mcpHandler(options);
198
+ return async (req, res) => {
199
+ const method = (req.method ?? "GET").toUpperCase();
200
+ const response =
201
+ method === "POST"
202
+ ? await handler.POST(
203
+ new Request(reconstructUrl(req), {
204
+ method: "POST",
205
+ headers: {
206
+ "content-type": "application/json",
207
+ accept: headerValue(req.headers.accept) ?? "application/json",
208
+ },
209
+ body: await readBody(req),
210
+ }),
211
+ )
212
+ : await handler.GET();
213
+ await writeNodeResponse(res, response);
214
+ };
215
+ }
216
+
217
+ /** Express / Node manifest route for `.well-known/mcp.json`. */
218
+ export function expressMcpManifestHandler(
219
+ options: McpProxyOptions,
220
+ ): (req: NodeRequestLike, res: NodeResponseLike) => Promise<void> {
221
+ const handler = mcpManifestHandler(options);
222
+ return async (req, res) => {
223
+ const response = await handler(new Request(reconstructUrl(req)));
224
+ await writeNodeResponse(res, response);
225
+ };
226
+ }
227
+
228
+ function headerValue(value: string | string[] | undefined): string | undefined {
229
+ return Array.isArray(value) ? value[0] : value;
230
+ }
231
+
232
+ /**
233
+ * Rebuild the absolute URL Node does not give us.
234
+ *
235
+ * The manifest handler reads `new URL(request.url).origin` to tell the platform
236
+ * which domain to advertise, so a placeholder host here would publish the wrong
237
+ * connector URL — the one failure mode of this whole surface.
238
+ */
239
+ function reconstructUrl(req: NodeRequestLike): string {
240
+ const host = headerValue(req.headers.host) ?? "localhost";
241
+ const proto = headerValue(req.headers["x-forwarded-proto"]) ?? "https";
242
+ return `${proto}://${host}${req.url ?? "/"}`;
243
+ }
244
+
245
+ function readBody(req: NodeRequestLike): Promise<string> {
246
+ return new Promise((resolve, reject) => {
247
+ const chunks: string[] = [];
248
+ req.on("data", (chunk) => chunks.push(String(chunk)));
249
+ req.on("end", () => resolve(chunks.join("")));
250
+ req.on("error", (error) => reject(error));
251
+ });
252
+ }
253
+
254
+ async function writeNodeResponse(
255
+ res: NodeResponseLike,
256
+ response: Response,
257
+ ): Promise<void> {
258
+ res.statusCode = response.status;
259
+ response.headers.forEach((value, name) => {
260
+ res.setHeader(name, value);
261
+ });
262
+ const text = await response.text();
263
+ res.end(text.length > 0 ? text : undefined);
264
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@businessdash/sdk/mcp-adapters` — mount the MCP connector on any framework.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export * from "./mcp-adapters/index.js";
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `@businessdash/sdk/model3d` — 3D product models on the org's own site.
3
+ *
4
+ * ## This ships no renderer, and that is the point
5
+ *
6
+ * A three.js build is ~600KB before your model. `<model-viewer>` is ~300KB. An
7
+ * SDK that pulled either down on every product page would be an SDK people rip
8
+ * out, and it would make the choice of engine ours rather than yours.
9
+ *
10
+ * So this decides WHAT to show and hands you the facts. You mount whichever
11
+ * viewer you already use, or none.
12
+ *
13
+ * ```ts
14
+ * const plan = planProductModel(product);
15
+ * if (plan.ar) showArLink(plan.ar.url); // zero JS, native iOS
16
+ * if (plan.viewer) mountYourViewer(plan.viewer.url, plan.viewer.format);
17
+ * if (plan.download) showLink(plan.download.url, plan.download.label);
18
+ * ```
19
+ *
20
+ * ## The three formats do genuinely different jobs
21
+ *
22
+ * **AR Quick Look (`.usdz`)** — the one worth having. On iOS, iPadOS and
23
+ * visionOS Safari an `<a rel="ar">` opens the model in the room, in the camera,
24
+ * with no JavaScript at all. No library, no canvas, no WebGL. Where it applies
25
+ * it beats an embedded viewer on every axis, so it is checked first.
26
+ *
27
+ * **Web viewer (`.glb`/`.gltf`/`.obj`/`.stl`)** — the rotatable canvas. `.glb`
28
+ * carries its own materials and is what you want; `.obj` and `.stl` are
29
+ * geometry only and render in a neutral grey, which is fine for a machined part
30
+ * and disappointing for a sofa.
31
+ *
32
+ * **CAD download (`.step`/`.stp`/`.iges`)** — NOT renderable, deliberately.
33
+ * STEP is a boundary-representation format needing a geometry kernel; the
34
+ * browser WASM builds of OpenCascade that can tessellate it are 10–30MB, which
35
+ * is not something to pull down for a product photo. It is also the wrong shape
36
+ * for who asks: an engineer evaluating a bracket wants the file in their own CAD
37
+ * package, not a spinning preview.
38
+ *
39
+ * ## Absent is the normal case
40
+ *
41
+ * These fields are stripped unless the org holds the `ecommerce.product_3d`
42
+ * add-on, and most businesses never attach a model. Every function here treats
43
+ * missing as ordinary and returns nothing to render.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ /** The product fields this reads. Anything with these keys works. */
49
+ export type ModelBearingProduct = {
50
+ id?: string;
51
+ model3dUrl?: string | null;
52
+ model3dUsdzUrl?: string | null;
53
+ /** The API reports availability, never the URL. See `download` below. */
54
+ model3dCadAvailable?: boolean;
55
+ model3dCadName?: string | null;
56
+ };
57
+
58
+ /** What a web viewer can load. */
59
+ export type WebModelFormat = "glb" | "gltf" | "obj" | "stl";
60
+
61
+ /** What a CAD download might be. */
62
+ export type CadFormat = "step" | "iges" | "native";
63
+
64
+ export type ModelPlan = {
65
+ /**
66
+ * Present when the model can be placed in the room on this device.
67
+ *
68
+ * Only on Apple platforms, and only with a `.usdz`. Render as
69
+ * `<a rel="ar" href={url}><img …/></a>` — the `<img>` child is required by
70
+ * Quick Look, and the link needs no JavaScript whatsoever.
71
+ */
72
+ ar: { url: string } | null;
73
+ /**
74
+ * Present when there is a model a WebGL viewer can load.
75
+ *
76
+ * `neutralMaterial` is true for `.obj` and `.stl`, which carry geometry but
77
+ * no materials — worth knowing, because a viewer that lights them like a
78
+ * `.glb` renders a black silhouette.
79
+ */
80
+ viewer: {
81
+ url: string;
82
+ format: WebModelFormat;
83
+ neutralMaterial: boolean;
84
+ } | null;
85
+ /**
86
+ * Present when the org attached a CAD file.
87
+ *
88
+ * `url` points at the platform's gated endpoint, never at the file — that
89
+ * route requires cookies, rate-limits the caller and re-checks the
90
+ * entitlement before handing anything over. Render it as an ordinary link;
91
+ * the visitor's browser sends its cookie automatically.
92
+ *
93
+ * `format` is inferred from the filename when the org supplied one, so a
94
+ * page can say "STEP" rather than "CAD" without a round trip.
95
+ */
96
+ download: { url: string; label: string; format: CadFormat } | null;
97
+ /** True when there is nothing 3D at all — the common case. */
98
+ empty: boolean;
99
+ };
100
+
101
+ /** Lowercased extension, ignoring query strings and fragments. */
102
+ function extensionOf(url: string): string {
103
+ const path = url.split(/[?#]/)[0] ?? "";
104
+ const last = path.slice(path.lastIndexOf("/") + 1);
105
+ const dot = last.lastIndexOf(".");
106
+ return dot === -1 ? "" : last.slice(dot + 1).toLowerCase();
107
+ }
108
+
109
+ /** Which web viewer format a URL is, if any. */
110
+ export function webModelFormat(
111
+ url: string | null | undefined,
112
+ ): WebModelFormat | null {
113
+ if (!url) return null;
114
+ const ext = extensionOf(url);
115
+ return ext === "glb" || ext === "gltf" || ext === "obj" || ext === "stl"
116
+ ? ext
117
+ : null;
118
+ }
119
+
120
+ /** Which CAD family a URL belongs to. Unknown extensions are `native`. */
121
+ export function cadFormat(url: string | null | undefined): CadFormat | null {
122
+ if (!url) return null;
123
+ const ext = extensionOf(url);
124
+ if (ext === "step" || ext === "stp") return "step";
125
+ if (ext === "iges" || ext === "igs") return "iges";
126
+ return ext ? "native" : null;
127
+ }
128
+
129
+ /**
130
+ * Does THIS browser support AR Quick Look?
131
+ *
132
+ * Feature-detected rather than sniffed: Safari advertises support by
133
+ * recognising `rel="ar"` on an anchor. That is Apple's own documented check and
134
+ * it survives the next user-agent change, which a regex on "iPhone" does not.
135
+ *
136
+ * False on the server, so a plan built during SSR omits the AR branch. Re-plan
137
+ * on the client if you want the link to appear without a navigation — the check
138
+ * is a few microseconds.
139
+ */
140
+ export function supportsArQuickLook(): boolean {
141
+ if (typeof document === "undefined") return false;
142
+ const anchor = document.createElement("a");
143
+ return Boolean(anchor.relList?.supports?.("ar"));
144
+ }
145
+
146
+ export type PlanProductModelOptions = {
147
+ /**
148
+ * Override the AR capability check.
149
+ *
150
+ * Pass `true` during SSR when you know the request came from an Apple
151
+ * device, or `false` to suppress AR entirely.
152
+ */
153
+ arSupported?: boolean;
154
+ /**
155
+ * Where your app proxies `GET storefront/products/{id}/cad`.
156
+ *
157
+ * Required for `download` to appear, and required to be YOUR route rather
158
+ * than the platform's: the API key is a server secret, so the browser
159
+ * cannot call the platform endpoint directly. Proxy it and pass your own
160
+ * path — `cadDownloadUrl: `/api/products/${product.id}/cad``.
161
+ *
162
+ * Without it the plan reports no download even when a file exists, which is
163
+ * the safe direction: a dead link is worse than an absent one.
164
+ */
165
+ cadDownloadUrl?: string;
166
+ };
167
+
168
+ /**
169
+ * Decide what to render for one product.
170
+ *
171
+ * AR is not exclusive with the viewer — a product can offer both, and on an
172
+ * iPhone the usual arrangement is a canvas with a "View in your room" button
173
+ * over it. The plan reports each independently rather than picking for you.
174
+ */
175
+ export function planProductModel(
176
+ product: ModelBearingProduct,
177
+ options: PlanProductModelOptions = {},
178
+ ): ModelPlan {
179
+ const arSupported = options.arSupported ?? supportsArQuickLook();
180
+ const usdz = product.model3dUsdzUrl?.trim() || null;
181
+ const ar = arSupported && usdz ? { url: usdz } : null;
182
+
183
+ const viewerUrl = product.model3dUrl?.trim() || null;
184
+ const format = webModelFormat(viewerUrl);
185
+ const viewer =
186
+ viewerUrl && format
187
+ ? {
188
+ url: viewerUrl,
189
+ format,
190
+ // .obj and .stl are geometry only. A viewer lighting them like
191
+ // a .glb renders a black silhouette.
192
+ neutralMaterial: format === "obj" || format === "stl",
193
+ }
194
+ : null;
195
+
196
+ const cadName = product.model3dCadName?.trim() || null;
197
+ // Inferred from the filename, since the URL is never in the payload. Falls
198
+ // back to `native`, which only changes the default label.
199
+ const cad = product.model3dCadAvailable
200
+ ? (cadFormat(cadName) ?? "native")
201
+ : null;
202
+ const download =
203
+ cad && options.cadDownloadUrl
204
+ ? {
205
+ url: options.cadDownloadUrl,
206
+ label: cadName || defaultCadLabel(cad),
207
+ format: cad,
208
+ }
209
+ : null;
210
+
211
+ return { ar, viewer, download, empty: !ar && !viewer && !download && !usdz };
212
+ }
213
+
214
+ function defaultCadLabel(format: CadFormat): string {
215
+ if (format === "step") return "Download STEP file";
216
+ if (format === "iges") return "Download IGES file";
217
+ return "Download CAD file";
218
+ }
219
+
220
+ /**
221
+ * The attributes an AR Quick Look anchor needs.
222
+ *
223
+ * Spread onto your own `<a>`. Quick Look requires an `<img>` or equivalent
224
+ * child — an empty anchor silently does nothing, which is the single most
225
+ * common way this gets implemented wrong.
226
+ */
227
+ export function arLinkAttributes(url: string): {
228
+ href: string;
229
+ rel: string;
230
+ } {
231
+ return { href: url, rel: "ar" };
232
+ }
233
+
234
+ /**
235
+ * True when the org has the 3D add-on, judged from a product payload.
236
+ *
237
+ * The API omits these keys entirely without `ecommerce.product_3d`, so their
238
+ * absence distinguishes "not paid for" from "paid for, none attached" — where
239
+ * the key is present and null. Useful for deciding whether to show an upsell
240
+ * rather than nothing.
241
+ */
242
+ export function hasModel3dEntitlement(product: object): boolean {
243
+ return (
244
+ "model3dUrl" in product ||
245
+ "model3dUsdzUrl" in product ||
246
+ "model3dCadAvailable" in product
247
+ );
248
+ }