dh-remixer-sdk 0.0.28 → 0.0.29-12e235f

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/package.json +1 -1
  2. package/scripts/sdk-update/cmd-utils.mjs +22 -4
  3. package/scripts/sdk-update/sdk-utils.mjs +13 -0
  4. package/scripts/ssg-helmet/build-sitemap-xml.mjs +7 -1
  5. package/scripts/ssg-helmet/prerenderer.mjs +9 -0
  6. package/templates/base/cleanup.json +1 -0
  7. package/templates/base/components/seo/SEOHead.tsx +43 -0
  8. package/templates/base/filemap.json +14 -1
  9. package/templates/base/index.tsx +7 -4
  10. package/templates/base/package.json +2 -2
  11. package/templates/base/remixer/actions.ts +5 -0
  12. package/templates/base/remixer/anonToken.ts +85 -0
  13. package/templates/base/remixer/auth.ts +87 -0
  14. package/templates/base/remixer/context.ts +49 -0
  15. package/templates/base/remixer/core.ts +85 -0
  16. package/templates/base/remixer/data.ts +223 -0
  17. package/templates/base/remixer/dpop.ts +128 -0
  18. package/templates/base/remixer/ecommerce.ts +179 -0
  19. package/templates/base/remixer/forms.ts +12 -0
  20. package/templates/base/remixer/runtime.ts +126 -0
  21. package/templates/base/remixer/storage.ts +11 -0
  22. package/templates/base/remixer/supabase.ts +49 -0
  23. package/templates/base/remixer.ts +51 -0
  24. package/templates/base/vite.config.ts +6 -72
  25. package/templates/ecommerce/filemap.json +0 -1
  26. package/templates/ecommerce/shipping.ts +11 -59
  27. package/templates/ecommerce/stripe-checkout.ts +16 -53
  28. package/templates/immersive/Anchor3D.tsx +62 -0
  29. package/templates/immersive/Atmosphere.tsx +134 -0
  30. package/templates/immersive/ImmersiveStory.tsx +130 -0
  31. package/templates/immersive/ScrollStory.tsx +136 -0
  32. package/templates/immersive/Stage3D.tsx +194 -0
  33. package/templates/immersive/StageContext.ts +37 -0
  34. package/templates/immersive/anchors.tsx +77 -0
  35. package/templates/immersive/cleanup.json +22 -0
  36. package/templates/immersive/filemap.json +13 -0
  37. package/templates/immersive/package.json +13 -0
  38. package/templates/immersive/phases.ts +154 -0
  39. package/templates/immersive/safeCanvas.tsx +82 -0
  40. package/templates/immersive/stage.ts +5 -0
  41. package/templates/immersive/useAnchor.ts +13 -0
  42. package/templates/landing-page/package.json +1 -1
  43. package/templates/base/supabase.ts +0 -62
  44. package/templates/ecommerce/supabase.ts +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.28",
3
+ "version": "0.0.29-12e235f",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -1,13 +1,31 @@
1
1
  import { spawn } from "child_process";
2
2
  import { TARGET_ENVIRONMENT } from "./vars.mjs";
3
3
 
4
+ const TAG_BY_ENV = {
5
+ production: "latest",
6
+ staging: "staging",
7
+ develop: "develop",
8
+ };
9
+
4
10
  export async function getSdkVersion() {
11
+ const tag = TAG_BY_ENV[TARGET_ENVIRONMENT];
12
+
13
+ if (!tag) {
14
+ throw new Error(
15
+ `[sdk-update] Unknown NEXT_PUBLIC_ENVIRONMENT "${TARGET_ENVIRONMENT}". ` +
16
+ `Expected one of: ${Object.keys(TAG_BY_ENV).join(", ")}`,
17
+ );
18
+ }
19
+
5
20
  const res = await fetch("https://registry.npmjs.org/dh-remixer-sdk");
6
21
  const data = await res.json();
7
- const sdkVersion =
8
- TARGET_ENVIRONMENT === "production"
9
- ? data["dist-tags"].latest
10
- : data["dist-tags"].dev;
22
+ const sdkVersion = data["dist-tags"]?.[tag];
23
+
24
+ if (!sdkVersion) {
25
+ throw new Error(
26
+ `[sdk-update] No "${tag}" dist-tag published for dh-remixer-sdk on npm`,
27
+ );
28
+ }
11
29
 
12
30
  return sdkVersion;
13
31
  }
@@ -5,7 +5,19 @@ import { SDK_ROOT } from "./vars.mjs";
5
5
  import { jsonMerge } from "./type-utils.mjs";
6
6
 
7
7
 
8
+ function assertTemplateExists(templateType) {
9
+ if (templateType === "base") return;
10
+ const templateRoot = path.join(SDK_ROOT, "templates", templateType);
11
+ if (!fssync.existsSync(templateRoot)) {
12
+ throw new Error(
13
+ `[sdk-update] Template "${templateType}" not found in installed dh-remixer-sdk (${templateRoot}). ` +
14
+ `Run: npm install dh-remixer-sdk@latest`,
15
+ );
16
+ }
17
+ }
18
+
8
19
  export async function getDeletionList(templateType) {
20
+ assertTemplateExists(templateType);
9
21
  const baseRoot = path.join(SDK_ROOT, "templates", "base");
10
22
  const templateRoot = path.join(SDK_ROOT, "templates", templateType);
11
23
  const templateCleanupPath = path.join(templateRoot, "cleanup.json");
@@ -23,6 +35,7 @@ export async function getDeletionList(templateType) {
23
35
  }
24
36
 
25
37
  export async function getExtractionList(templateType) {
38
+ assertTemplateExists(templateType);
26
39
  const baseRoot = path.join(SDK_ROOT, "templates", "base");
27
40
  const templateRoot = path.join(SDK_ROOT, "templates", templateType);
28
41
  const templateFilemapPath = path.join(templateRoot, "filemap.json");
@@ -6,6 +6,12 @@ const CHANGEFREQ_HOME = "daily";
6
6
  const CHANGEFREQ_NORMAL = "weekly";
7
7
  const CHANGEFREQ_TEMPLATED = "weekly";
8
8
 
9
+ const ensureTrailingSlash = (p) => {
10
+ const [base, suffix] = p.split(/([?#].*)/); // keep query/hash intact
11
+ const normalized = base.endsWith("/") ? base : base + "/";
12
+ return suffix ? normalized + suffix : normalized;
13
+ };
14
+
9
15
  export function buildSitemapXml(origin, routes) {
10
16
  if (!/^https?:\/\//i.test(origin)) origin = "https://" + origin;
11
17
  const cleanOrigin = origin.replace(/\/+$/, "");
@@ -60,7 +66,7 @@ export function buildSitemapXml(origin, routes) {
60
66
  : CHANGEFREQ_NORMAL;
61
67
 
62
68
  return ` <url>
63
- <loc>${esc(abs(path))}</loc>
69
+ <loc>${esc(abs(ensureTrailingSlash(path)))}</loc>
64
70
  <lastmod>${today}</lastmod>
65
71
  <changefreq>${changefreq}</changefreq>
66
72
  <priority>${priority.toFixed(1)}</priority>
@@ -202,6 +202,15 @@ export class Prerenderer {
202
202
  this.#client = await CDP({ port: this.#cdpPort, target: pageTarget });
203
203
  await this.#client.Page.enable();
204
204
  await this.#client.Runtime.enable();
205
+
206
+ // Signal to the app that we are in SSG prerender mode, BEFORE any page
207
+ // scripts run. Components (e.g. ScrollScene, useScrollProgress) can read
208
+ // window.__PRERENDER__ to skip WebGL/Lenis and render a static fallback,
209
+ // which keeps headless Chrome from hanging on canvas rendering and
210
+ // guarantees meaningful HTML for SEO.
211
+ await this.#client.Page.addScriptToEvaluateOnNewDocument({
212
+ source: "window.__PRERENDER__ = true;",
213
+ });
205
214
  }
206
215
 
207
216
  async #launchDirect() {
@@ -5,6 +5,7 @@
5
5
  "public/.htaccess",
6
6
  "public/robots.txt",
7
7
  ".gitignore",
8
+ "lib/supabase.ts",
8
9
  "tsconfig.json",
9
10
  "vite.config.ts",
10
11
  "new.package.json"
@@ -0,0 +1,43 @@
1
+ import { Helmet } from "react-helmet-async";
2
+ import { useLanguage } from "@/context/LanguageContext";
3
+ import images from "@/assets/images.json";
4
+
5
+ interface SEOHeadProps {
6
+ titleKey?: string;
7
+ descriptionKey?: string;
8
+ ogImage?: string;
9
+ ogImageKey?: string;
10
+ }
11
+
12
+ // Canonical <head> for every page. Render exactly one <SEOHead /> per page
13
+ // inside pages/*.tsx; no raw <Helmet> anywhere else. Pulls og:image from
14
+ // assets/images.json by default so social sharing works without extra wiring.
15
+ export function SEOHead({
16
+ titleKey = "home.title",
17
+ descriptionKey = "home.description",
18
+ ogImage,
19
+ ogImageKey = "og_image",
20
+ }: SEOHeadProps) {
21
+ const { t } = useLanguage();
22
+ const siteTitle = import.meta.env.VITE_METADATA_TITLE as string | undefined;
23
+ const translated = t(titleKey);
24
+ const title = siteTitle ? `${translated} | ${siteTitle}` : translated;
25
+ const description = t(descriptionKey);
26
+ const resolvedOg =
27
+ ogImage ?? (images as Record<string, string>)[ogImageKey];
28
+
29
+ return (
30
+ <Helmet>
31
+ <title>{title}</title>
32
+ <meta name="description" content={description} />
33
+ <meta property="og:title" content={title} />
34
+ <meta property="og:description" content={description} />
35
+ <meta property="og:type" content="website" />
36
+ {resolvedOg ? <meta property="og:image" content={resolvedOg} /> : null}
37
+ <meta name="twitter:card" content="summary_large_image" />
38
+ {resolvedOg ? <meta name="twitter:image" content={resolvedOg} /> : null}
39
+ </Helmet>
40
+ );
41
+ }
42
+
43
+ export default SEOHead;
@@ -8,5 +8,18 @@
8
8
  "vite.config.ts": "vite.config.ts",
9
9
  "robots.txt": "public/robots.txt",
10
10
  ".htaccess": "public/.htaccess",
11
- "supabase.ts": "lib/supabase.ts"
11
+ "components/seo/SEOHead.tsx": "components/seo/SEOHead.tsx",
12
+ "remixer.ts": "lib/remixer.ts",
13
+ "remixer/actions.ts": "lib/remixer/actions.ts",
14
+ "remixer/anonToken.ts": "lib/remixer/anonToken.ts",
15
+ "remixer/auth.ts": "lib/remixer/auth.ts",
16
+ "remixer/context.ts": "lib/remixer/context.ts",
17
+ "remixer/core.ts": "lib/remixer/core.ts",
18
+ "remixer/data.ts": "lib/remixer/data.ts",
19
+ "remixer/dpop.ts": "lib/remixer/dpop.ts",
20
+ "remixer/ecommerce.ts": "lib/remixer/ecommerce.ts",
21
+ "remixer/forms.ts": "lib/remixer/forms.ts",
22
+ "remixer/runtime.ts": "lib/remixer/runtime.ts",
23
+ "remixer/storage.ts": "lib/remixer/storage.ts",
24
+ "remixer/supabase.ts": "lib/remixer/supabase.ts"
12
25
  }
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import ReactDOM from "react-dom/client";
3
+ import { HelmetProvider } from "react-helmet-async";
3
4
  import tailwindConfig from "@/tailwind.config";
4
5
  import App from "@/App";
5
6
  import { GOOGLE_FONTS_URL } from "@/fonts";
@@ -27,8 +28,10 @@ if (!rootElement) {
27
28
  const root = ReactDOM.createRoot(rootElement);
28
29
  root.render(
29
30
  <React.StrictMode>
30
- <LanguageProvider>
31
- <App />
32
- </LanguageProvider>
31
+ <HelmetProvider>
32
+ <LanguageProvider>
33
+ <App />
34
+ </LanguageProvider>
35
+ </HelmetProvider>
33
36
  </React.StrictMode>
34
- );
37
+ );
@@ -8,7 +8,7 @@
8
8
  "start": "vite preview --host 0.0.0.0 --mode production --port ${PORT:-4173}",
9
9
  "dev": "vite --host 0.0.0.0 --port ${PORT:-4173} --mode development",
10
10
  "lint": "echo 'Linting not implemented!'",
11
- "remixer-sdk:update": "npm cache clean --force && npm install dh-remixer-sdk@latest && sdk-update"
11
+ "remixer-sdk:update": "bun install dh-remixer-sdk@${REMIXER_SDK_TAG:-latest} && sdk-update"
12
12
  },
13
13
  "dependencies": {
14
14
  "@openobserve/browser-logs": "0.3.1",
@@ -21,7 +21,7 @@
21
21
  "react": "19.2.0",
22
22
  "react-aria-components": "1.11.0",
23
23
  "react-dom": "19.2.0",
24
- "react-helmet": "6.1.0",
24
+ "react-helmet-async": "3.0.0",
25
25
  "react-router-dom": "7.9.6"
26
26
  },
27
27
  "devDependencies": {
@@ -0,0 +1,5 @@
1
+ import { runRemixerAction } from './runtime';
2
+
3
+ export const actions = {
4
+ run: runRemixerAction,
5
+ };
@@ -0,0 +1,85 @@
1
+ import { getAuthBrokerBaseUrl, getProjectId } from './context';
2
+ import { getPublicJwk } from './dpop';
3
+
4
+ type CachedToken = {
5
+ token: string;
6
+ expiresAt: number;
7
+ };
8
+
9
+ let memoryToken: CachedToken | null = null;
10
+ let pendingToken: Promise<string> | null = null;
11
+
12
+ function storageKey() {
13
+ return `remixer:${getProjectId()}:anonymous-token`;
14
+ }
15
+
16
+ function isUsableToken(token: CachedToken | null) {
17
+ return Boolean(token?.token && token.expiresAt - Math.floor(Date.now() / 1000) > 5 * 60);
18
+ }
19
+
20
+ function readSessionToken() {
21
+ try {
22
+ const raw = sessionStorage.getItem(storageKey());
23
+ return raw ? JSON.parse(raw) as CachedToken : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function writeSessionToken(token: CachedToken) {
30
+ try {
31
+ sessionStorage.setItem(storageKey(), JSON.stringify(token));
32
+ } catch {
33
+ // Session storage can be disabled. In-memory cache still covers this page lifetime.
34
+ }
35
+ }
36
+
37
+ export function clearAnonymousToken() {
38
+ memoryToken = null;
39
+ pendingToken = null;
40
+ try {
41
+ sessionStorage.removeItem(storageKey());
42
+ } catch {
43
+ // Session storage can be disabled.
44
+ }
45
+ }
46
+
47
+ async function mintAnonymousToken() {
48
+ const publicJwk = await getPublicJwk();
49
+ const response = await fetch(`${getAuthBrokerBaseUrl()}/anonymous/bootstrap`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ publicJwk }),
53
+ });
54
+
55
+ const body = await response.json().catch(() => ({}));
56
+ if (!response.ok) {
57
+ throw new Error(body.message || body.error || `Anonymous bootstrap failed (${response.status})`);
58
+ }
59
+
60
+ const cached = {
61
+ token: body.token,
62
+ expiresAt: body.expiresAt,
63
+ };
64
+ memoryToken = cached;
65
+ writeSessionToken(cached);
66
+ return cached.token;
67
+ }
68
+
69
+ export async function getAnonymousToken() {
70
+ if (isUsableToken(memoryToken)) return memoryToken!.token;
71
+
72
+ const sessionToken = readSessionToken();
73
+ if (isUsableToken(sessionToken)) {
74
+ memoryToken = sessionToken;
75
+ return sessionToken!.token;
76
+ }
77
+
78
+ if (!pendingToken) {
79
+ pendingToken = mintAnonymousToken().finally(() => {
80
+ pendingToken = null;
81
+ });
82
+ }
83
+
84
+ return pendingToken;
85
+ }
@@ -0,0 +1,87 @@
1
+ import { getProjectSession, isValidProjectSession, supabase } from './supabase';
2
+ import { getAuthBrokerBaseUrl } from './context';
3
+
4
+ export type SignInWithGoogleOptions = {
5
+ returnTo?: string;
6
+ };
7
+
8
+ export type SignupMetadata = Record<string, unknown>;
9
+
10
+ async function brokerEmailAuth(path: 'signin' | 'signup', email: string, password: string, metadata?: SignupMetadata) {
11
+ const res = await fetch(`${getAuthBrokerBaseUrl()}/auth/email/${path}`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({
15
+ email,
16
+ password,
17
+ metadata,
18
+ return_to: window.location.origin,
19
+ }),
20
+ });
21
+
22
+ if (!res.ok) {
23
+ const body = await res.json().catch(() => ({}));
24
+ throw new Error(body.error || `Email auth failed (${res.status})`);
25
+ }
26
+
27
+ const payload = await res.json();
28
+ if (payload.access_token && payload.refresh_token) {
29
+ const { error } = await supabase.auth.setSession({
30
+ access_token: payload.access_token,
31
+ refresh_token: payload.refresh_token,
32
+ });
33
+ if (error) throw error;
34
+ }
35
+
36
+ return payload;
37
+ }
38
+
39
+ export const auth = {
40
+ getSession: getProjectSession,
41
+ async getUser() {
42
+ return (await getProjectSession())?.user ?? null;
43
+ },
44
+ isValidProjectSession,
45
+ onAuthStateChange(callback: Parameters<typeof supabase.auth.onAuthStateChange>[0]) {
46
+ return supabase.auth.onAuthStateChange(callback);
47
+ },
48
+ setSession(accessToken: string, refreshToken: string) {
49
+ return supabase.auth.setSession({
50
+ access_token: accessToken,
51
+ refresh_token: refreshToken,
52
+ });
53
+ },
54
+ signInWithGoogle(options: SignInWithGoogleOptions = {}) {
55
+ const startUrl = new URL(`${getAuthBrokerBaseUrl()}/auth/start`);
56
+ startUrl.searchParams.set('return_to', options.returnTo || `${window.location.origin}/account`);
57
+ startUrl.searchParams.set('nonce', crypto.randomUUID());
58
+ window.location.href = startUrl.toString();
59
+ },
60
+ signInWithEmail(email: string, password: string) {
61
+ return brokerEmailAuth('signin', email, password);
62
+ },
63
+ signUpWithEmail(email: string, password: string, metadata?: SignupMetadata) {
64
+ return brokerEmailAuth('signup', email, password, metadata);
65
+ },
66
+ async completeAuthExchange(exchangeCode: string) {
67
+ const res = await fetch(`${getAuthBrokerBaseUrl()}/auth/exchange`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ exchange_code: exchangeCode }),
71
+ });
72
+
73
+ if (!res.ok) {
74
+ const body = await res.json().catch(() => ({}));
75
+ throw new Error(body.error || `Exchange failed (${res.status})`);
76
+ }
77
+
78
+ const payload = await res.json();
79
+ return supabase.auth.setSession({
80
+ access_token: payload.access_token,
81
+ refresh_token: payload.refresh_token,
82
+ });
83
+ },
84
+ signOut() {
85
+ return supabase.auth.signOut();
86
+ },
87
+ };
@@ -0,0 +1,49 @@
1
+ export type RemixerContext = {
2
+ supabaseUrl: string;
3
+ supabaseAnonKey: string;
4
+ projectId: string;
5
+ runtimeUrl?: string;
6
+ authBrokerBaseUrl: string;
7
+ };
8
+
9
+ declare global {
10
+ interface Window {
11
+ __REMIXER_CTX__?: RemixerContext;
12
+ }
13
+ }
14
+
15
+ function requireContextValue<K extends keyof RemixerContext>(ctx: RemixerContext, key: K): RemixerContext[K] {
16
+ const value = ctx[key];
17
+ if (!value) {
18
+ throw new Error(`Remixer context is missing ${String(key)}`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function getRemixerContext() {
24
+ if (typeof window === 'undefined' || !window.__REMIXER_CTX__) {
25
+ throw new Error('Remixer context is not available');
26
+ }
27
+ return window.__REMIXER_CTX__;
28
+ }
29
+
30
+ export function getSupabaseUrl() {
31
+ return requireContextValue(getRemixerContext(), 'supabaseUrl');
32
+ }
33
+
34
+ export function getSupabaseAnonKey() {
35
+ return requireContextValue(getRemixerContext(), 'supabaseAnonKey');
36
+ }
37
+
38
+ export function getProjectId() {
39
+ return requireContextValue(getRemixerContext(), 'projectId');
40
+ }
41
+
42
+ export function getAuthBrokerBaseUrl() {
43
+ return requireContextValue(getRemixerContext(), 'authBrokerBaseUrl').replace(/\/+$/, '');
44
+ }
45
+
46
+ export function getRuntimeUrl() {
47
+ const ctx = getRemixerContext();
48
+ return (ctx.runtimeUrl || `${getSupabaseUrl().replace(/\/+$/, '')}/functions/v1`).replace(/\/+$/, '');
49
+ }
@@ -0,0 +1,85 @@
1
+ import { getProjectSession } from './supabase';
2
+ import { clearAnonymousToken, getAnonymousToken } from './anonToken';
3
+ import { getRuntimeUrl } from './context';
4
+ import { createDpopProof } from './dpop';
5
+
6
+ export type RemixerRecordData = Record<string, unknown>;
7
+
8
+ type RuntimeAuthHeaders = {
9
+ headers: Record<string, string>;
10
+ tokenSource: 'anonymous' | 'session';
11
+ };
12
+
13
+ async function runtimeAuthHeaders(url: string, method: string): Promise<RuntimeAuthHeaders> {
14
+ const session = await getProjectSession();
15
+ const tokenSource = session?.access_token ? 'session' : 'anonymous';
16
+ const token = session?.access_token ?? await getAnonymousToken();
17
+
18
+ return {
19
+ headers: {
20
+ Authorization: `Bearer ${token}`,
21
+ DPoP: await createDpopProof(url, method),
22
+ },
23
+ tokenSource,
24
+ };
25
+ }
26
+
27
+ export async function runtimeHeaders(url: string, method: string) {
28
+ return (await runtimeAuthHeaders(url, method)).headers;
29
+ }
30
+
31
+ export async function runtimeJsonFetch(
32
+ url: string,
33
+ method: 'GET' | 'POST',
34
+ body?: RemixerRecordData,
35
+ retryOnUnauthorized = true,
36
+ ) {
37
+ const { headers, tokenSource } = await runtimeAuthHeaders(url, method);
38
+ const response = await fetch(url, {
39
+ method,
40
+ headers: {
41
+ ...headers,
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ body: body ? JSON.stringify(body) : undefined,
45
+ });
46
+
47
+ if (response.status === 401 && retryOnUnauthorized && tokenSource === 'anonymous') {
48
+ clearAnonymousToken();
49
+ return runtimeJsonFetch(url, method, body, false);
50
+ }
51
+
52
+ return response;
53
+ }
54
+
55
+ export async function runtimeDataFetch<T>(path: string, body: RemixerRecordData): Promise<T> {
56
+ const url = `${getRuntimeUrl()}/${path}`;
57
+ const method = 'POST';
58
+ const response = await runtimeJsonFetch(url, method, body);
59
+
60
+ if (!response.ok) {
61
+ const error = await response.json().catch(() => ({}));
62
+ throw new Error(error.message || error.error || `Remixer data request failed (${response.status})`);
63
+ }
64
+
65
+ return response.json();
66
+ }
67
+
68
+ export async function remixerFunctionFetch<T>(
69
+ path: string,
70
+ options: { method?: 'GET' | 'POST'; body?: RemixerRecordData; query?: Record<string, string | number | boolean | undefined> } = {},
71
+ ): Promise<T> {
72
+ const url = new URL(`${getRuntimeUrl()}/${path}`);
73
+ Object.entries(options.query || {}).forEach(([key, value]) => {
74
+ if (value !== undefined) url.searchParams.set(key, String(value));
75
+ });
76
+ const method = options.method || 'POST';
77
+ const response = await runtimeJsonFetch(url.toString(), method, options.body);
78
+
79
+ if (!response.ok) {
80
+ const error = await response.json().catch(() => ({}));
81
+ throw new Error(error.message || error.error || `Remixer request failed (${response.status})`);
82
+ }
83
+
84
+ return response.json();
85
+ }