@weaverse/next 0.1.0-alpha.13 → 0.1.0-alpha.15
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/README.md +125 -23
- package/dist/{chunk-MQ6N5ODA.mjs → chunk-NL22OVD7.mjs} +93 -4
- package/dist/chunk-NL22OVD7.mjs.map +1 -0
- package/dist/index.js +86 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20 -27
- package/dist/index.mjs.map +1 -1
- package/dist/server.js +158 -2
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +126 -5
- package/dist/server.mjs.map +1 -1
- package/dist/types/client.d.ts +16 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/hooks.d.ts +20 -0
- package/dist/types/hooks.d.ts.map +1 -0
- package/dist/types/index.d.ts +34 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/item.d.ts +54 -0
- package/dist/types/item.d.ts.map +1 -0
- package/dist/types/loader.d.ts +25 -0
- package/dist/types/loader.d.ts.map +1 -0
- package/dist/types/main.d.ts +11 -0
- package/dist/types/main.d.ts.map +1 -0
- package/dist/types/provider.d.ts +82 -0
- package/dist/types/provider.d.ts.map +1 -0
- package/dist/types/registry.d.ts +15 -0
- package/dist/types/registry.d.ts.map +1 -0
- package/dist/types/renderer.d.ts +20 -0
- package/dist/types/renderer.d.ts.map +1 -0
- package/dist/types/request-info.d.ts +8 -0
- package/dist/types/request-info.d.ts.map +1 -0
- package/dist/types/revalidate-item.d.ts +81 -0
- package/dist/types/revalidate-item.d.ts.map +1 -0
- package/dist/types/root-provider.d.ts +61 -0
- package/dist/types/root-provider.d.ts.map +1 -0
- package/dist/types/runtime.d.ts +113 -0
- package/dist/types/runtime.d.ts.map +1 -0
- package/dist/types/seo.d.ts +55 -0
- package/dist/types/seo.d.ts.map +1 -0
- package/dist/types/server/configs.d.ts +27 -0
- package/dist/types/server/configs.d.ts.map +1 -0
- package/dist/types/server/normalize-page-url.d.ts +19 -0
- package/dist/types/server/normalize-page-url.d.ts.map +1 -0
- package/dist/types/server/revalidate-handler.d.ts +55 -0
- package/dist/types/server/revalidate-handler.d.ts.map +1 -0
- package/dist/types/server/server-client.d.ts +21 -0
- package/dist/types/server/server-client.d.ts.map +1 -0
- package/dist/types/server.d.ts +19 -0
- package/dist/types/server.d.ts.map +1 -0
- package/dist/types/studio-bridge.d.ts +16 -0
- package/dist/types/studio-bridge.d.ts.map +1 -0
- package/dist/types/studio-connect.d.ts +27 -0
- package/dist/types/studio-connect.d.ts.map +1 -0
- package/dist/types/studio-router.d.ts +50 -0
- package/dist/types/studio-router.d.ts.map +1 -0
- package/dist/types/studio-script-src.d.ts +12 -0
- package/dist/types/studio-script-src.d.ts.map +1 -0
- package/dist/types/theme-settings-store.d.ts +17 -0
- package/dist/types/theme-settings-store.d.ts.map +1 -0
- package/dist/types/translation-context.d.ts +156 -0
- package/dist/types/translation-context.d.ts.map +1 -0
- package/dist/types/translation-store.d.ts +44 -0
- package/dist/types/translation-store.d.ts.map +1 -0
- package/dist/types/types.d.ts +661 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/use-weaverse-next-studio.d.ts +34 -0
- package/dist/types/use-weaverse-next-studio.d.ts.map +1 -0
- package/dist/types/utils.d.ts +12 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +7 -6
- package/src/client.ts +101 -0
- package/src/hooks.ts +50 -0
- package/src/index.ts +153 -0
- package/src/item.ts +151 -0
- package/src/loader.ts +135 -0
- package/src/main.tsx +45 -0
- package/src/provider.tsx +225 -0
- package/src/registry.ts +63 -0
- package/src/renderer.tsx +99 -0
- package/src/request-info.ts +95 -0
- package/src/revalidate-item.ts +210 -0
- package/src/root-provider.tsx +181 -0
- package/src/runtime.ts +521 -0
- package/src/seo.ts +157 -0
- package/src/server/configs.ts +182 -0
- package/src/server/normalize-page-url.ts +65 -0
- package/src/server/revalidate-handler.ts +357 -0
- package/src/server/server-client.ts +727 -0
- package/src/server.ts +67 -0
- package/src/studio-bridge.tsx +82 -0
- package/src/studio-connect.tsx +88 -0
- package/src/studio-router.ts +85 -0
- package/src/studio-script-src.ts +128 -0
- package/src/theme-settings-store.ts +46 -0
- package/src/translation-context.tsx +299 -0
- package/src/translation-store.ts +69 -0
- package/src/types.ts +748 -0
- package/src/use-weaverse-next-studio.tsx +106 -0
- package/src/utils.ts +37 -0
- package/dist/chunk-MQ6N5ODA.mjs.map +0 -1
- package/dist/index.d.mts +0 -592
- package/dist/index.d.ts +0 -592
- package/dist/seo-CrEFl_Up.d.mts +0 -516
- package/dist/seo-CrEFl_Up.d.ts +0 -516
- package/dist/server.d.mts +0 -100
- 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
|
+
}
|