@weaverse/next 0.1.0-alpha.12 → 0.1.0-alpha.14

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 (106) hide show
  1. package/README.md +125 -23
  2. package/dist/{chunk-MQ6N5ODA.mjs → chunk-NL22OVD7.mjs} +93 -4
  3. package/dist/chunk-NL22OVD7.mjs.map +1 -0
  4. package/dist/index.js +98 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32 -26
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/server.js +158 -2
  9. package/dist/server.js.map +1 -1
  10. package/dist/server.mjs +126 -5
  11. package/dist/server.mjs.map +1 -1
  12. package/dist/types/client.d.ts +16 -0
  13. package/dist/types/client.d.ts.map +1 -0
  14. package/dist/types/hooks.d.ts +20 -0
  15. package/dist/types/hooks.d.ts.map +1 -0
  16. package/dist/types/index.d.ts +34 -0
  17. package/dist/types/index.d.ts.map +1 -0
  18. package/dist/types/item.d.ts +54 -0
  19. package/dist/types/item.d.ts.map +1 -0
  20. package/dist/types/loader.d.ts +25 -0
  21. package/dist/types/loader.d.ts.map +1 -0
  22. package/dist/types/main.d.ts +11 -0
  23. package/dist/types/main.d.ts.map +1 -0
  24. package/dist/types/provider.d.ts +82 -0
  25. package/dist/types/provider.d.ts.map +1 -0
  26. package/dist/types/registry.d.ts +15 -0
  27. package/dist/types/registry.d.ts.map +1 -0
  28. package/dist/types/renderer.d.ts +20 -0
  29. package/dist/types/renderer.d.ts.map +1 -0
  30. package/dist/types/request-info.d.ts +8 -0
  31. package/dist/types/request-info.d.ts.map +1 -0
  32. package/dist/types/revalidate-item.d.ts +81 -0
  33. package/dist/types/revalidate-item.d.ts.map +1 -0
  34. package/dist/types/root-provider.d.ts +61 -0
  35. package/dist/types/root-provider.d.ts.map +1 -0
  36. package/dist/types/runtime.d.ts +113 -0
  37. package/dist/types/runtime.d.ts.map +1 -0
  38. package/dist/types/seo.d.ts +55 -0
  39. package/dist/types/seo.d.ts.map +1 -0
  40. package/dist/types/server/configs.d.ts +27 -0
  41. package/dist/types/server/configs.d.ts.map +1 -0
  42. package/dist/types/server/normalize-page-url.d.ts +19 -0
  43. package/dist/types/server/normalize-page-url.d.ts.map +1 -0
  44. package/dist/types/server/revalidate-handler.d.ts +55 -0
  45. package/dist/types/server/revalidate-handler.d.ts.map +1 -0
  46. package/dist/types/server/server-client.d.ts +21 -0
  47. package/dist/types/server/server-client.d.ts.map +1 -0
  48. package/dist/types/server.d.ts +19 -0
  49. package/dist/types/server.d.ts.map +1 -0
  50. package/dist/types/studio-bridge.d.ts +16 -0
  51. package/dist/types/studio-bridge.d.ts.map +1 -0
  52. package/dist/types/studio-connect.d.ts +27 -0
  53. package/dist/types/studio-connect.d.ts.map +1 -0
  54. package/dist/types/studio-router.d.ts +50 -0
  55. package/dist/types/studio-router.d.ts.map +1 -0
  56. package/dist/types/studio-script-src.d.ts +12 -0
  57. package/dist/types/studio-script-src.d.ts.map +1 -0
  58. package/dist/types/theme-settings-store.d.ts +17 -0
  59. package/dist/types/theme-settings-store.d.ts.map +1 -0
  60. package/dist/types/translation-context.d.ts +156 -0
  61. package/dist/types/translation-context.d.ts.map +1 -0
  62. package/dist/types/translation-store.d.ts +44 -0
  63. package/dist/types/translation-store.d.ts.map +1 -0
  64. package/dist/types/types.d.ts +661 -0
  65. package/dist/types/types.d.ts.map +1 -0
  66. package/dist/types/use-weaverse-next-studio.d.ts +34 -0
  67. package/dist/types/use-weaverse-next-studio.d.ts.map +1 -0
  68. package/dist/types/utils.d.ts +12 -0
  69. package/dist/types/utils.d.ts.map +1 -0
  70. package/package.json +7 -6
  71. package/src/client.ts +101 -0
  72. package/src/hooks.ts +50 -0
  73. package/src/index.ts +153 -0
  74. package/src/item.ts +151 -0
  75. package/src/loader.ts +135 -0
  76. package/src/main.tsx +45 -0
  77. package/src/provider.tsx +225 -0
  78. package/src/registry.ts +63 -0
  79. package/src/renderer.tsx +99 -0
  80. package/src/request-info.ts +95 -0
  81. package/src/revalidate-item.ts +210 -0
  82. package/src/root-provider.tsx +181 -0
  83. package/src/runtime.ts +521 -0
  84. package/src/seo.ts +157 -0
  85. package/src/server/configs.ts +182 -0
  86. package/src/server/normalize-page-url.ts +65 -0
  87. package/src/server/revalidate-handler.ts +357 -0
  88. package/src/server/server-client.ts +727 -0
  89. package/src/server.ts +67 -0
  90. package/src/studio-bridge.tsx +82 -0
  91. package/src/studio-connect.tsx +88 -0
  92. package/src/studio-router.ts +85 -0
  93. package/src/studio-script-src.ts +125 -0
  94. package/src/theme-settings-store.ts +46 -0
  95. package/src/translation-context.tsx +299 -0
  96. package/src/translation-store.ts +69 -0
  97. package/src/types.ts +748 -0
  98. package/src/use-weaverse-next-studio.tsx +106 -0
  99. package/src/utils.ts +37 -0
  100. package/dist/chunk-MQ6N5ODA.mjs.map +0 -1
  101. package/dist/index.d.mts +0 -592
  102. package/dist/index.d.ts +0 -592
  103. package/dist/seo-CrEFl_Up.d.mts +0 -516
  104. package/dist/seo-CrEFl_Up.d.ts +0 -516
  105. package/dist/server.d.mts +0 -100
  106. package/dist/server.d.ts +0 -100
@@ -0,0 +1,182 @@
1
+ import type {
2
+ WeaverseNextBaseConfigs,
3
+ WeaverseNextRequestContext,
4
+ } from '../types'
5
+
6
+ const DEFAULT_WEAVERSE_HOST = 'https://studio.weaverse.io'
7
+ const DEFAULT_WEAVERSE_API_BASE = 'https://api.weaverse.io'
8
+
9
+ /**
10
+ * Weaverse-controlled apex domains the Studio bridge / data API may be served
11
+ * from (matched on the host itself and any subdomain). Kept in sync with
12
+ * Hydrogen's `isTrustedStudioHost`.
13
+ */
14
+ const TRUSTED_STUDIO_DOMAINS = ['weaverse.io', 'weaverse.dev']
15
+
16
+ /**
17
+ * Whether `host` is an origin we trust to resolve page/theme data. The
18
+ * `weaverseHost` query param is attacker-controllable, so a crafted
19
+ * `?weaverseHost=` must be rejected before it can point server-side fetches at
20
+ * an arbitrary origin (SSRF). Only `weaverse.io`/`weaverse.dev` and subdomains
21
+ * over https are trusted.
22
+ */
23
+ function isTrustedStudioHost(host: string): boolean {
24
+ let parsed: URL
25
+ try {
26
+ parsed = new URL(host)
27
+ } catch {
28
+ return false
29
+ }
30
+ if (parsed.protocol !== 'https:') {
31
+ return false
32
+ }
33
+ let { hostname } = parsed
34
+ return TRUSTED_STUDIO_DOMAINS.some(
35
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`)
36
+ )
37
+ }
38
+
39
+ function toUrl(value: string | URL): URL | null {
40
+ try {
41
+ return typeof value === 'string'
42
+ ? new URL(value, 'http://localhost')
43
+ : value
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Extract the request search params from the explicit Next request context.
51
+ * Prefers `searchParams`, falls back to parsing `url`.
52
+ */
53
+ export function getContextSearchParams(
54
+ context?: WeaverseNextRequestContext
55
+ ): URLSearchParams {
56
+ if (context?.searchParams) {
57
+ return new URLSearchParams(context.searchParams)
58
+ }
59
+ if (context?.url) {
60
+ let url = toUrl(context.url)
61
+ if (url) {
62
+ return new URLSearchParams(url.searchParams)
63
+ }
64
+ }
65
+ return new URLSearchParams()
66
+ }
67
+
68
+ /**
69
+ * Last value of a (possibly duplicated) query key. Matches Hydrogen's
70
+ * `getRequestQueries`, which assigns while iterating and so keeps the last
71
+ * occurrence — Studio appends its own control params, so a stale earlier value
72
+ * must not win.
73
+ */
74
+ function lastParam(
75
+ searchParams: URLSearchParams,
76
+ key: string
77
+ ): string | undefined {
78
+ let values = searchParams.getAll(key)
79
+ return values.length ? values.at(-1) : undefined
80
+ }
81
+
82
+ function coerceBool(value: string | undefined): boolean {
83
+ return value === 'true'
84
+ }
85
+
86
+ function readEnv(
87
+ env: Record<string, string | undefined> | undefined,
88
+ key: string
89
+ ): string | undefined {
90
+ let fromConfig = env?.[key]
91
+ if (fromConfig !== undefined) {
92
+ return fromConfig
93
+ }
94
+ if (typeof process !== 'undefined' && process.env) {
95
+ return process.env[key]
96
+ }
97
+ return
98
+ }
99
+
100
+ /** Overrides used while resolving request-scoped Weaverse configuration. */
101
+ export interface ResolveConfigsOptions {
102
+ /** Environment values; missing keys fall back to `process.env`. */
103
+ env?: Record<string, string | undefined>
104
+ /** Public data API base override. */
105
+ weaverseApiBase?: string
106
+ /** Studio origin override. */
107
+ weaverseHost?: string
108
+ /** Studio asset version override. */
109
+ weaverseVersion?: string
110
+ }
111
+
112
+ /**
113
+ * Derive Weaverse configs from the request context and env, close to Hydrogen's
114
+ * `getWeaverseConfigs`. Resolves the trusted host, public API base, design /
115
+ * preview / revision flags, and public env — but never resolves the final
116
+ * `projectId` (that priority chain, which may call an async resolver, lives in
117
+ * the server client). Query/env projectId candidates are returned for the
118
+ * client to combine with function/string inputs.
119
+ */
120
+ export function getWeaverseNextConfigs(
121
+ context?: WeaverseNextRequestContext,
122
+ options: ResolveConfigsOptions = {}
123
+ ): WeaverseNextBaseConfigs {
124
+ let searchParams = getContextSearchParams(context)
125
+ let { env } = options
126
+
127
+ let queryHost = lastParam(searchParams, 'weaverseHost')
128
+ // A trusted query host is canonicalized to its origin before use, so a crafted
129
+ // `?weaverseHost=https://studio.weaverse.io/evil?x=1` can only ever resolve to
130
+ // `https://studio.weaverse.io` — never carrying an attacker path/query into the
131
+ // server-side API base.
132
+ let trustedUrlHost =
133
+ queryHost && isTrustedStudioHost(queryHost) ? new URL(queryHost).origin : ''
134
+
135
+ let configuredHost =
136
+ options.weaverseHost || readEnv(env, 'WEAVERSE_HOST') || ''
137
+ let weaverseHost = trustedUrlHost || configuredHost || DEFAULT_WEAVERSE_HOST
138
+
139
+ // Public data reads default to the edge proxy, but an explicit custom host
140
+ // (staging/self-hosted Weaverse) must stay the API base so those deployments
141
+ // don't fetch page/config data from the production proxy.
142
+ let weaverseApiBase =
143
+ trustedUrlHost ||
144
+ options.weaverseApiBase ||
145
+ readEnv(env, 'WEAVERSE_PUBLIC_API_BASE') ||
146
+ (configuredHost && configuredHost !== DEFAULT_WEAVERSE_HOST
147
+ ? configuredHost
148
+ : DEFAULT_WEAVERSE_API_BASE)
149
+
150
+ let isRevisionPreview =
151
+ context?.isRevisionPreview ?? searchParams.has('__revisionId')
152
+ let isDesignMode =
153
+ context?.isDesignMode ?? coerceBool(lastParam(searchParams, 'isDesignMode'))
154
+ let isPreviewMode =
155
+ context?.isPreviewMode ??
156
+ coerceBool(lastParam(searchParams, 'isPreviewMode'))
157
+
158
+ return {
159
+ queryProjectId: lastParam(searchParams, 'weaverseProjectId') || '',
160
+ envProjectId: readEnv(env, 'WEAVERSE_PROJECT_ID') || '',
161
+ weaverseHost,
162
+ weaverseApiBase,
163
+ weaverseApiKey:
164
+ lastParam(searchParams, 'weaverseApiKey') ||
165
+ readEnv(env, 'WEAVERSE_API_KEY') ||
166
+ '',
167
+ weaverseVersion:
168
+ lastParam(searchParams, 'weaverseVersion') ||
169
+ options.weaverseVersion ||
170
+ '',
171
+ isDesignMode,
172
+ isPreviewMode,
173
+ isRevisionPreview,
174
+ sectionType:
175
+ context?.sectionType || lastParam(searchParams, 'sectionType') || '',
176
+ publicEnv: {
177
+ PUBLIC_STORE_DOMAIN: readEnv(env, 'PUBLIC_STORE_DOMAIN') || '',
178
+ PUBLIC_STOREFRONT_API_TOKEN:
179
+ readEnv(env, 'PUBLIC_STOREFRONT_API_TOKEN') || '',
180
+ },
181
+ }
182
+ }
@@ -0,0 +1,65 @@
1
+ import type { WeaverseNextRequestContext } from '../types'
2
+
3
+ // Query params that influence Weaverse page resolution on the Builder:
4
+ // revision previews, design-mode flags, design-mode draft item overrides,
5
+ // and `weaverse*` control params (host, version, project id). Everything
6
+ // else — utm_*, fbclid, gclid, storefront filters — is irrelevant to which
7
+ // page the Builder returns and would otherwise fragment any fetch cache keyed
8
+ // on the request body. `weaverseDraftItem` is the current draft item param;
9
+ // `__weaverseDraftItem` is kept as a legacy compatibility alias.
10
+ const KEPT_PARAM_RE =
11
+ /^(?:__revisionId|__weaverseDraftItem|isDesignMode|weaverse)/
12
+ const DATA_SUFFIX = '.data'
13
+
14
+ /**
15
+ * Normalize the storefront URL sent in the Builder page API request body.
16
+ *
17
+ * The Builder reads only the pathname (locale/type/handle fallback) and the
18
+ * Weaverse/Studio control params from this URL, so keep exactly those and drop
19
+ * tracking params. Params are sorted for a stable, cache-friendly string.
20
+ *
21
+ * Ported from `@weaverse/hydrogen`'s `normalizePageUrl`, but tolerant of a
22
+ * relative URL (Next route handlers may only know the pathname).
23
+ */
24
+ export function normalizeNextPageUrl(url: string): string {
25
+ let parsed: URL
26
+ try {
27
+ parsed = new URL(url)
28
+ } catch {
29
+ try {
30
+ parsed = new URL(url, 'http://localhost')
31
+ } catch {
32
+ return url
33
+ }
34
+ }
35
+
36
+ let kept = [...parsed.searchParams].filter(([key]) => KEPT_PARAM_RE.test(key))
37
+ kept.sort(([a], [b]) => a.localeCompare(b))
38
+ let search = new URLSearchParams(kept).toString()
39
+
40
+ let { pathname } = parsed
41
+ if (pathname.endsWith(DATA_SUFFIX)) {
42
+ pathname = pathname.slice(0, -DATA_SUFFIX.length)
43
+ }
44
+ return `${parsed.origin}${pathname}${search ? `?${search}` : ''}`
45
+ }
46
+
47
+ /**
48
+ * Build an absolute-ish request URL string from the explicit request context,
49
+ * preferring `url`, then `pathname` + `searchParams`. Used as the input to
50
+ * {@link normalizeNextPageUrl}.
51
+ */
52
+ export function resolveRequestUrl(
53
+ context?: WeaverseNextRequestContext
54
+ ): string {
55
+ if (context?.url) {
56
+ return typeof context.url === 'string'
57
+ ? context.url
58
+ : context.url.toString()
59
+ }
60
+ let pathname = context?.pathname || '/'
61
+ let search = context?.searchParams
62
+ ? new URLSearchParams(context.searchParams).toString()
63
+ : ''
64
+ return `http://localhost${pathname}${search ? `?${search}` : ''}`
65
+ }
@@ -0,0 +1,357 @@
1
+ import type { PageType } from '@weaverse/schema'
2
+ import { PageTypeSchema } from '@weaverse/schema'
3
+ import {
4
+ ROUTE_CONTEXT_I18N_KEYS,
5
+ sanitizeRouteContextSearch,
6
+ type WeaverseNextRevalidateRouteContext,
7
+ } from '../revalidate-item'
8
+ import type {
9
+ WeaverseNextComponentData,
10
+ WeaverseNextI18n,
11
+ WeaverseNextRequestContext,
12
+ WeaverseNextServerClient,
13
+ } from '../types'
14
+ import { generateDataFromSchema } from '../utils'
15
+
16
+ const NO_STORE_HEADERS = {
17
+ 'Content-Type': 'application/json',
18
+ 'Cache-Control': 'no-store',
19
+ } as const
20
+
21
+ const STATUS_OK = 200
22
+ const STATUS_BAD_REQUEST = 400
23
+ const STATUS_NOT_FOUND = 404
24
+ const STATUS_UNPROCESSABLE = 422
25
+ const STATUS_SERVER_ERROR = 500
26
+
27
+ const MAX_PATHNAME_LENGTH = 2048
28
+ const MAX_SEARCH_LENGTH = 8192
29
+ const MAX_HANDLE_LENGTH = 512
30
+ const MAX_I18N_FIELD_LENGTH = 256
31
+ // ASCII control characters (C0 range + DEL) are never valid in a route path.
32
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control chars in untrusted pathnames is the intent.
33
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/
34
+
35
+ /** Configuration for the App Router item-revalidation handler. */
36
+ export interface WeaverseNextRevalidateHandlerConfig {
37
+ /**
38
+ * Reuse the app's server-client factory so the loader runs with the same
39
+ * component registry and commerce (storefront) context as page loads.
40
+ *
41
+ * The optional second argument is the validated, same-origin route context
42
+ * reconstructed from the request body. It is `undefined` for legacy bodies
43
+ * that carry no `routeContext`, so a one-argument `(request) => client`
44
+ * callback remains valid. Consumers must still source project ID, Studio
45
+ * host, API bases/keys, and env from server config — never from this
46
+ * browser-provided context.
47
+ */
48
+ getClient: (
49
+ request: Request,
50
+ requestContext?: WeaverseNextRequestContext
51
+ ) => Promise<WeaverseNextServerClient> | WeaverseNextServerClient
52
+ }
53
+
54
+ /** POST body accepted by the revalidate route. */
55
+ export interface WeaverseNextRevalidateRequestBody {
56
+ /** Unsaved Studio item settings to pass to the registered component loader. */
57
+ draftItem: WeaverseNextComponentData
58
+ /** Public route identity used to reconstruct the loader request context. */
59
+ routeContext?: WeaverseNextRevalidateRouteContext
60
+ }
61
+
62
+ /**
63
+ * Whether a browser-provided pathname is safe to reconstruct a same-origin URL
64
+ * from. Rejects protocol-relative (`//`), absolute-URL, backslash, control
65
+ * character, query-in-path, and fragment-in-path inputs before any URL is
66
+ * built. Literal/encoded dot segments are allowed here and normalized later by
67
+ * the URL pathname setter.
68
+ */
69
+ function isValidPathname(pathname: string): boolean {
70
+ if (!pathname || pathname.length > MAX_PATHNAME_LENGTH) {
71
+ return false
72
+ }
73
+ if (pathname[0] !== '/' || pathname.startsWith('//')) {
74
+ return false
75
+ }
76
+ if (pathname.includes('\\') || pathname.includes('?')) {
77
+ return false
78
+ }
79
+ if (pathname.includes('#') || CONTROL_CHAR_PATTERN.test(pathname)) {
80
+ return false
81
+ }
82
+ return true
83
+ }
84
+
85
+ /**
86
+ * Whether a value is a plain object (literal or `null`-prototype), the only
87
+ * i18n container the wire shape allows. Arrays, class instances, and other
88
+ * exotic objects are rejected so nothing exotic reaches field validation.
89
+ */
90
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
91
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
92
+ return false
93
+ }
94
+ let proto = Object.getPrototypeOf(value)
95
+ return proto === Object.prototype || proto === null
96
+ }
97
+
98
+ /**
99
+ * Validate and pick the allowed i18n fields. Returns `undefined` for a valid
100
+ * empty result and `null` for malformed input so the handler can fail closed.
101
+ *
102
+ * The normative i18n shape carries only the {@link ROUTE_CONTEXT_I18N_KEYS}.
103
+ * Malformed input is rejected rather than silently narrowed: any unknown own
104
+ * key (nested object, extra string, anything), a non-plain container (array,
105
+ * class instance), or a known key whose value is not an optional string of at
106
+ * most {@link MAX_I18N_FIELD_LENGTH} characters fails closed.
107
+ */
108
+ function sanitizeRouteContextI18n(
109
+ input: unknown
110
+ ): WeaverseNextI18n | undefined | null {
111
+ if (input === undefined) {
112
+ return
113
+ }
114
+ if (!isPlainObject(input)) {
115
+ return null
116
+ }
117
+ let source = input
118
+ let allowed = new Set<string>(ROUTE_CONTEXT_I18N_KEYS)
119
+ // Reject unknown own keys instead of dropping them, so a crafted context
120
+ // cannot smuggle extra properties past the trust boundary.
121
+ for (let key of Object.keys(source)) {
122
+ if (!allowed.has(key)) {
123
+ return null
124
+ }
125
+ }
126
+ let result: Record<string, string> = {}
127
+ for (let key of ROUTE_CONTEXT_I18N_KEYS) {
128
+ let value = source[key]
129
+ if (value === undefined) {
130
+ continue
131
+ }
132
+ if (typeof value !== 'string' || value.length > MAX_I18N_FIELD_LENGTH) {
133
+ return null
134
+ }
135
+ result[key] = value
136
+ }
137
+ return Object.keys(result).length ? result : undefined
138
+ }
139
+
140
+ /**
141
+ * Reconstruct a validated, same-origin {@link WeaverseNextRequestContext} from
142
+ * the untrusted route context. Returns `null` for any malformed field so the
143
+ * handler can answer `400 invalid-route-context` before creating a client. The
144
+ * endpoint origin is fixed first, then validated components are assigned, so
145
+ * the browser cannot change protocol, host, port, or credentials.
146
+ */
147
+ function reconstructRouteContext(
148
+ request: Request,
149
+ routeContext: unknown
150
+ ): WeaverseNextRequestContext | null {
151
+ if (
152
+ typeof routeContext !== 'object' ||
153
+ routeContext === null ||
154
+ Array.isArray(routeContext)
155
+ ) {
156
+ return null
157
+ }
158
+ let { pathname, search, handle, pageType, i18n } = routeContext as Record<
159
+ string,
160
+ unknown
161
+ >
162
+
163
+ if (typeof pathname !== 'string' || !isValidPathname(pathname)) {
164
+ return null
165
+ }
166
+
167
+ if (typeof search !== 'string' || search.length > MAX_SEARCH_LENGTH) {
168
+ return null
169
+ }
170
+ let rawSearch = search
171
+ if (rawSearch.length && !rawSearch.startsWith('?')) {
172
+ return null
173
+ }
174
+
175
+ let sanitizedI18n = sanitizeRouteContextI18n(i18n)
176
+ if (sanitizedI18n === null) {
177
+ return null
178
+ }
179
+
180
+ let resolvedPageType: PageType | undefined
181
+ if (pageType !== undefined) {
182
+ let parsed = PageTypeSchema.safeParse(pageType)
183
+ if (!parsed.success) {
184
+ return null
185
+ }
186
+ resolvedPageType = parsed.data
187
+ }
188
+
189
+ let resolvedHandle: string | undefined
190
+ if (handle !== undefined) {
191
+ if (
192
+ typeof handle !== 'string' ||
193
+ !handle ||
194
+ handle.length > MAX_HANDLE_LENGTH
195
+ ) {
196
+ return null
197
+ }
198
+ resolvedHandle = handle
199
+ }
200
+
201
+ // Fix the endpoint origin first, then assign validated components. Reuse the
202
+ // URL's canonical pathname in both context fields so raw input never survives
203
+ // in only one place.
204
+ let endpointUrl = new URL(request.url)
205
+ let routeUrl = new URL(endpointUrl.origin)
206
+ routeUrl.pathname = pathname
207
+ routeUrl.search = sanitizeRouteContextSearch(rawSearch)
208
+ let canonicalPathname = routeUrl.pathname
209
+ let searchParams = routeUrl.searchParams
210
+
211
+ // Last value wins for duplicated Studio controls, matching
212
+ // `getWeaverseNextConfigs()` and its cache-mode resolution.
213
+ let lastParam = (key: string): string | undefined => {
214
+ let values = searchParams.getAll(key)
215
+ return values.length ? values.at(-1) : undefined
216
+ }
217
+
218
+ return {
219
+ pathname: canonicalPathname,
220
+ searchParams,
221
+ url: routeUrl,
222
+ // Headers come from the actual same-origin POST, never its JSON body.
223
+ headers: new Headers(request.headers),
224
+ ...(sanitizedI18n ? { i18n: sanitizedI18n } : {}),
225
+ ...(resolvedPageType ? { pageType: resolvedPageType } : {}),
226
+ ...(resolvedHandle ? { handle: resolvedHandle } : {}),
227
+ isDesignMode: lastParam('isDesignMode') === 'true',
228
+ isPreviewMode: lastParam('isPreviewMode') === 'true',
229
+ isRevisionPreview: searchParams.has('__revisionId'),
230
+ sectionType: lastParam('sectionType') || undefined,
231
+ }
232
+ }
233
+
234
+ function json(body: Record<string, unknown>, status: number) {
235
+ return new Response(JSON.stringify(body), {
236
+ status,
237
+ headers: NO_STORE_HEADERS,
238
+ })
239
+ }
240
+
241
+ function parseDraftItem(payload: unknown): WeaverseNextComponentData | null {
242
+ if (typeof payload !== 'object' || payload === null) {
243
+ return null
244
+ }
245
+ let draftItem = (payload as { draftItem?: unknown }).draftItem
246
+ if (typeof draftItem !== 'object' || draftItem === null) {
247
+ return null
248
+ }
249
+ let { id, type } = draftItem as { id?: unknown; type?: unknown }
250
+ if (typeof id !== 'string' || !id || typeof type !== 'string' || !type) {
251
+ return null
252
+ }
253
+ return draftItem as WeaverseNextComponentData
254
+ }
255
+
256
+ /** App Router handler returned for Studio item revalidation requests. */
257
+ export interface WeaverseNextRevalidateHandler {
258
+ /** Handle a Studio request to rerun one registered component loader. */
259
+ POST: (request: Request) => Promise<Response>
260
+ }
261
+
262
+ /**
263
+ * Create the Studio per-item revalidation route handler.
264
+ *
265
+ * Studio design-mode edits to loader-backed inputs (e.g. resource pickers) need
266
+ * fresh server loader data for a single item. Re-running it through
267
+ * `router.refresh()` re-applies the whole RSC tree — and the draft-item query
268
+ * rewrite changes the App Router page segment key, remounting the page and
269
+ * resetting scroll. This handler runs just the edited component's `loader`
270
+ * with the unsaved draft settings and returns the payload for an in-place
271
+ * client update (see the `2026-07-02--next-item-revalidation` spec).
272
+ *
273
+ * Security: only registered component types are executable, the handler never
274
+ * persists anything, and responses are `no-store`. Exposure is equivalent to
275
+ * the existing `weaverseDraftItem` URL param, which already lets any visitor
276
+ * run design-mode loaders with arbitrary draft settings.
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * // app/api/weaverse/revalidate/route.ts
281
+ * export const { POST } = createWeaverseNextRevalidateHandler({
282
+ * getClient: () => getWeaverseServerClient(Promise.resolve({})),
283
+ * })
284
+ * ```
285
+ */
286
+ export function createWeaverseNextRevalidateHandler(
287
+ config: WeaverseNextRevalidateHandlerConfig
288
+ ): WeaverseNextRevalidateHandler {
289
+ return {
290
+ POST: async (request: Request) => {
291
+ let payload: unknown
292
+ try {
293
+ payload = await request.json()
294
+ } catch {
295
+ return json({ error: 'invalid-payload' }, STATUS_BAD_REQUEST)
296
+ }
297
+
298
+ let draftItem = parseDraftItem(payload)
299
+ if (!draftItem) {
300
+ return json({ error: 'invalid-payload' }, STATUS_BAD_REQUEST)
301
+ }
302
+
303
+ // Missing `routeContext` is valid legacy input; a present but malformed
304
+ // context is rejected at the trust boundary before any client is created.
305
+ let requestContext: WeaverseNextRequestContext | undefined
306
+ let body = payload as { routeContext?: unknown }
307
+ if (Object.hasOwn(body, 'routeContext')) {
308
+ let reconstructed = reconstructRouteContext(request, body.routeContext)
309
+ if (!reconstructed) {
310
+ return json({ error: 'invalid-route-context' }, STATUS_BAD_REQUEST)
311
+ }
312
+ requestContext = reconstructed
313
+ }
314
+
315
+ let client: WeaverseNextServerClient
316
+ try {
317
+ client = await config.getClient(request, requestContext)
318
+ } catch (error) {
319
+ console.error('❌ Revalidate handler getClient failed.', error)
320
+ return json({ error: 'client-init-failed' }, STATUS_SERVER_ERROR)
321
+ }
322
+
323
+ let component = client.components.find(
324
+ (candidate) => candidate?.schema?.type === draftItem.type
325
+ )
326
+ if (!component) {
327
+ return json({ error: 'unknown-component-type' }, STATUS_NOT_FOUND)
328
+ }
329
+ if (typeof component.loader !== 'function') {
330
+ return json({ error: 'missing-loader' }, STATUS_UNPROCESSABLE)
331
+ }
332
+
333
+ try {
334
+ // Same args shape as `runWeaverseComponentLoaders`, so component
335
+ // loaders behave identically between page load and revalidation.
336
+ let loaderData = await component.loader({
337
+ data: {
338
+ ...generateDataFromSchema(component.schema),
339
+ ...draftItem.data,
340
+ },
341
+ weaverse: client,
342
+ context: client.requestContext,
343
+ commerce: client.commerce,
344
+ })
345
+ return json({ loaderData: loaderData ?? null }, STATUS_OK)
346
+ } catch (error) {
347
+ console.error(
348
+ '❌ Item loader revalidation failed.',
349
+ draftItem.type,
350
+ draftItem.id,
351
+ error
352
+ )
353
+ return json({ error: 'loader-failed' }, STATUS_SERVER_ERROR)
354
+ }
355
+ },
356
+ }
357
+ }