@wix/origami-site 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-site",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Runs an exported Wix origami site: file-based routing, Wix dynamic pages, popups and build checks. Use with @wix/origami-site-astro or @wix/origami-site-express.",
5
5
  "type": "module",
6
6
  "files": [
@@ -48,7 +48,7 @@
48
48
  "@wix/origami-types": "1.4.0",
49
49
  "@wix/builder-error-pages": "^1.1.6",
50
50
  "@wix/builder-piler-components": "^1.6.7",
51
- "@wix/origami-transforms": "0.1.12",
51
+ "@wix/origami-transforms": "0.1.13",
52
52
  "@babel/parser": "^7.25.0",
53
53
  "@wix/system-core-components": "^1.3.10",
54
54
  "@wix/origami-services": "1.0.0",
@@ -93,5 +93,5 @@
93
93
  "optional": true
94
94
  }
95
95
  },
96
- "falconPackageHash": "e514bdb7045f0acc5bb6538bca6aa81fdd740d9a7d70429ebf1309b5"
96
+ "falconPackageHash": "72150b4816fa665210177bd7d837200f6c48db066002819f4ade0c27"
97
97
  }
@@ -0,0 +1,12 @@
1
+ /** The three HTML strings for one page. An embed with a page filter is left out of a page with no id.
2
+ * The custom embeds API sends no pageFilter today (only app site embeds have one), so every embed shows on every
3
+ * page; the filter is kept for parity with wix-next's site-express-export scriptsEmbeds.js.
4
+ * @param {import('./protocol.js').CustomEmbed[]} embeds @param {string | undefined} pageId @returns {import('../index.js').PageEmbeds} */
5
+ export function embedsForPage(embeds, pageId) {
6
+ const shown = embeds.filter(embed => {
7
+ const pageIds = embed.pageFilter?.pageIds
8
+ return !pageIds || (pageId !== undefined && pageIds.includes(pageId))
9
+ })
10
+ const join = position => shown.filter(embed => embed.position === position).map(embed => embed.embedData?.html ?? '').join('\n')
11
+ return { head: join('HEAD'), bodyStart: join('BODY_START'), bodyEnd: join('BODY_END') }
12
+ }
@@ -0,0 +1,61 @@
1
+ import { fetchAnonymousToken } from '../tokens/builderAuthTokenProvider.js'
2
+ import { EmbedsHttpError, fetchEssentialEmbeds } from './protocol.js'
3
+ import { embedsForPage } from './embedsForPage.js'
4
+
5
+ export const noEmbeds = Object.freeze({ head: '', bodyStart: '', bodyEnd: '' })
6
+
7
+ /** Site embeds, kept in memory. Embeds are site data, not visitor data, so one site token and one list serve
8
+ * every visitor. A stale list is served while a background refresh runs; a failure never blocks or breaks a
9
+ * page: it is logged and the last list (or none) is served until the next attempt.
10
+ * @param {{ clientId: string, wixApiBaseUrl: string, fetch: typeof fetch, logger: import('../index.js').Logger, ttlMs?: number, timeoutMs?: number }} options */
11
+ export function createEmbeds({ clientId, wixApiBaseUrl, fetch, logger, ttlMs = 60_000, timeoutMs = 2_000 }) {
12
+ /** @type {import('./protocol.js').CustomEmbed[]} */
13
+ let embeds = []
14
+ let checkedAt = -Infinity
15
+ /** @type {Promise<void> | undefined} */
16
+ let refreshing
17
+ /** @type {{ accessToken: string, expiresAt: number } | undefined} */
18
+ let token
19
+ let warned = false
20
+ /** @param {AbortSignal} signal */
21
+ async function siteToken(signal) {
22
+ if (token && token.expiresAt > Date.now()) return token.accessToken
23
+ const fresh = await fetchAnonymousToken({ clientId, wixApiBaseUrl, fetch, signal })
24
+ // A minute early, so a token never expires between this check and the call.
25
+ token = { accessToken: fresh.accessToken, expiresAt: Date.now() + fresh.expiresInMs - 60_000 }
26
+ return token.accessToken
27
+ }
28
+ function refresh() {
29
+ refreshing ??= (async () => {
30
+ // One budget for the token and the list together: the first page waits at most timeoutMs.
31
+ const signal = AbortSignal.timeout(timeoutMs)
32
+ try {
33
+ embeds = await fetchEssentialEmbeds({ token: await siteToken(signal), wixApiBaseUrl, fetch, signal })
34
+ } catch (error) {
35
+ // Only a rejected token is dropped; an outage of the embeds service keeps it until it expires.
36
+ if (error instanceof EmbedsHttpError && (error.status === 401 || error.status === 403)) token = undefined
37
+ logger.error(`Wix custom embeds are unavailable; serving ${embeds.length ? 'the last list' : 'pages without them'}. ${error instanceof Error ? error.message : error}`)
38
+ } finally {
39
+ checkedAt = Date.now()
40
+ refreshing = undefined
41
+ }
42
+ })()
43
+ return refreshing
44
+ }
45
+ return {
46
+ /** @param {string | undefined} pageId @returns {Promise<import('../index.js').PageEmbeds>} */
47
+ async forPage(pageId) {
48
+ if (!clientId) {
49
+ if (!warned) logger.warn('VITE_WIX_CLIENT_ID is not set, so pages are served without Wix custom embeds.')
50
+ warned = true
51
+ return noEmbeds
52
+ }
53
+ if (Date.now() - checkedAt >= ttlMs) {
54
+ const pending = refresh()
55
+ // Only the very first list is waited for; later ones refresh in the background.
56
+ if (checkedAt === -Infinity) await pending
57
+ }
58
+ return embedsForPage(embeds, pageId)
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,26 @@
1
+ // The Wix custom embeds API, in one file. Piler parity (wix-next builder-site-embeds api.ts): only enabled
2
+ // ESSENTIAL embeds go into the server HTML. The others need the visitor's consent first.
3
+
4
+ /** @typedef {{ id?: string, enabled?: boolean, position?: 'HEAD' | 'BODY_START' | 'BODY_END', pageFilter?: { pageIds?: string[] }, embedData?: { category?: string, html?: string } }} CustomEmbed */
5
+
6
+ const isStringList = value => Array.isArray(value) && value.every(item => typeof item === 'string')
7
+ /** One malformed embed is dropped, so it can never break the pages the others are planted in. */
8
+ const isWellFormed = embed => typeof embed?.embedData?.html === 'string' &&
9
+ (embed.pageFilter?.pageIds === undefined || isStringList(embed.pageFilter.pageIds))
10
+
11
+ export class EmbedsHttpError extends Error {
12
+ /** @param {number} status */
13
+ constructor(status) { super(`The custom embeds request answered HTTP ${status}.`); this.status = status }
14
+ }
15
+
16
+ /** @param {{ token: string, wixApiBaseUrl: string, fetch: typeof fetch, signal: AbortSignal }} options @returns {Promise<CustomEmbed[]>} */
17
+ export async function fetchEssentialEmbeds({ token, wixApiBaseUrl, fetch, signal }) {
18
+ const response = await fetch(new URL('/embeds/v1/custom-embeds', wixApiBaseUrl), { headers: { authorization: `Bearer ${token}` }, signal })
19
+ if (!response.ok) throw new EmbedsHttpError(response.status)
20
+ const body = await response.json()
21
+ // A bad body throws, so the last good list is kept; an empty list may come back as {}, with no customEmbeds.
22
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new Error('The custom embeds response is not an object.')
23
+ const { customEmbeds } = body
24
+ if (customEmbeds !== undefined && !Array.isArray(customEmbeds)) throw new Error('The custom embeds response has no customEmbeds list.')
25
+ return (customEmbeds ?? []).filter(embed => embed?.enabled !== false && embed?.embedData?.category === 'ESSENTIAL' && isWellFormed(embed))
26
+ }
@@ -15,6 +15,7 @@ export type SiteOptions = {
15
15
  fetch?: typeof fetch // tests inject a fake
16
16
  wixApiBaseUrl?: string // default 'https://www.wixapis.com'; the conformance suite points it at the fake
17
17
  routerTimeoutMs?: number // default 5000
18
+ embeds?: false | { ttlMs?: number; timeoutMs?: number } // Wix custom embeds; default { ttlMs: 60000, timeoutMs: 2000 }; false: none
18
19
  logger?: Logger // default: console with an [origami] prefix
19
20
  }
20
21
 
@@ -39,9 +40,12 @@ export type RouteMatch =
39
40
  | { kind: 'notFound' }
40
41
 
41
42
  export type Resolution =
42
- | { kind: 'page'; status: 200 | 404; page: PageRef; data: PageData; headers: Headers }
43
+ | { kind: 'page'; status: 200 | 404; page: PageRef; data: PageData; headers: Headers; embeds: PageEmbeds }
43
44
  | { kind: 'response'; response: Response } // redirects, reserved endpoints, 405, 503
44
45
 
46
+ /** The page's essential Wix custom embeds, as HTML for the end of <head>, the start of <body> and the end of <body>. */
47
+ export type PageEmbeds = { head: string; bodyStart: string; bodyEnd: string }
48
+
45
49
  export type PageRef = { key: string; itemPath?: string; title: string }
46
50
 
47
51
  export type PageData = {
@@ -3,6 +3,7 @@ import { readSiteTree } from '../site-tree/readSiteTree.js'
3
3
  import { callRouter } from '../router/index.js'
4
4
  import { pickRolePage } from '../router/pickRolePage.js'
5
5
  import { builderAuthTokenProvider } from '../tokens/builderAuthTokenProvider.js'
6
+ import { createEmbeds, noEmbeds } from '../embeds/index.js'
6
7
  import { RouterServiceError, SiteTreeError } from './errors.js'
7
8
  import { normalizeRequestPath } from './urlPath.js'
8
9
  import { canBePagePath } from './canBePagePath.js'
@@ -25,6 +26,7 @@ export async function createSite(options) {
25
26
  wixApiBaseUrl = 'https://www.wixapis.com', routerTimeoutMs = 5000,
26
27
  } = options
27
28
  const tokens = options.tokens ?? builderAuthTokenProvider({ clientId: wix.clientId, siteId: wix.siteId, wixApiBaseUrl, fetch })
29
+ const embeds = options.embeds === false ? null : createEmbeds({ clientId: wix.clientId, wixApiBaseUrl, fetch, logger, ...options.embeds })
28
30
  /** Fail closed: a rule with no handler stops the site instead of serving the page open.
29
31
  * @param {{ itemPath: string, access: import('@wix/origami-types').AccessRule[] }} route */
30
32
  const handlerFor = (route, rule) => {
@@ -75,13 +77,16 @@ export async function createSite(options) {
75
77
  if (response) return { kind: /** @type {const} */ ('response'), response }
76
78
  }
77
79
  }
78
- /** @param {{ key: string, title: string, itemPath?: string }} route @param {200 | 404} status
80
+ /** @param {{ key: string, title: string, itemPath?: string, pageId?: string }} route @param {200 | 404} status
79
81
  * @param {import('../index.js').PageData['route']} routeData @param {Record<string, unknown>} services @param {HeadersInit} [headers] */
80
- function pageResolution(route, status, routeData, services = {}, headers = {}) {
82
+ async function pageResolution(route, status, routeData, services = {}, headers = {}) {
81
83
  const page = { key: route.key, title: route.title, ...(route.itemPath === undefined ? {} : { itemPath: route.itemPath }) }
82
84
  // Identity and dynamic-route configs use different services; merge per service if server layers ever overlap.
83
85
  const identity = wix.siteId ? { '@wix/viewer-service-environment': { metaSiteId: wix.siteId } } : {}
84
- return { kind: /** @type {const} */ ('page'), status, page, data: { page: page.key, route: routeData, services: { ...identity, ...services } }, headers: new Headers(headers) }
86
+ return {
87
+ kind: /** @type {const} */ ('page'), status, page, data: { page: page.key, route: routeData, services: { ...identity, ...services } }, headers: new Headers(headers),
88
+ embeds: embeds ? await embeds.forPage(route.pageId) : noEmbeds,
89
+ }
85
90
  }
86
91
  /** @param {Extract<import('../index.js').RouteMatch, { kind: 'router' }>} match @param {URL} url @param {string} path */
87
92
  async function resolveRouter(match, url, path, current, request, context) {
@@ -133,6 +138,8 @@ export async function createSite(options) {
133
138
  if (path === '/_origami' || path?.startsWith('/_origami/')) {
134
139
  return { kind: 'response', response: new Response(null, { status: 404 }) }
135
140
  }
141
+ // Starts the first embeds fetch now, so it runs alongside the router call.
142
+ embeds?.forPage(undefined)
136
143
  let current = await table()
137
144
  let match = matchPath(path, current)
138
145
  if (match.kind === 'notFound' && path !== undefined && tree && canBePagePath(path)) {
@@ -145,7 +145,7 @@ export const readSiteTree = async root => {
145
145
  if (routers.some(router => Object.values(router.rolePages).some(page => page.itemPath === itemPath))) continue
146
146
  const path = `src/${itemPath}`
147
147
  const metadata = pageMetadata(resolve(src, itemPath))
148
- if (itemPath === 'pages/error404') notFound = { key: itemPath, itemPath, title: metadata.title }
148
+ if (itemPath === 'pages/error404') notFound = { key: itemPath, itemPath, ...metadata }
149
149
  else {
150
150
  const url = pageUrl(itemPath)
151
151
  const previous = pathsByUrl.get(url)
@@ -8,6 +8,22 @@ const cookie = (header, name) => {
8
8
  }
9
9
  }
10
10
 
11
+ /** A new anonymous visitor token for the site's OAuth app.
12
+ * @param {{ clientId: string, wixApiBaseUrl: string, fetch: typeof fetch, signal: AbortSignal }} options
13
+ * @returns {Promise<{ accessToken: string, expiresInMs: number }>} */
14
+ export async function fetchAnonymousToken({ clientId, wixApiBaseUrl, fetch, signal }) {
15
+ if (!clientId) throw new Error('VITE_WIX_CLIENT_ID is not set, so there is no Wix visitor token.')
16
+ const response = await fetch(new URL('/oauth2/token', wixApiBaseUrl), {
17
+ method: 'POST', headers: { 'content-type': 'application/json' },
18
+ body: JSON.stringify({ client_id: clientId, grant_type: 'anonymous' }),
19
+ signal,
20
+ })
21
+ if (!response.ok) throw new Error(`The anonymous token request answered HTTP ${response.status}.`)
22
+ const { access_token: accessToken, expires_in: expiresIn } = await response.json()
23
+ if (typeof accessToken !== 'string' || !accessToken) throw new Error('The anonymous token response has no access_token.')
24
+ return { accessToken, expiresInMs: (Number(expiresIn) || 0) * 1000 }
25
+ }
26
+
11
27
  /** The visitor's own builder-auth token when the cookie is there and fresh; otherwise a one-off anonymous token.
12
28
  * Stores nothing and sets no cookie: builder-auth in the browser keeps owning the visitor's tokens.
13
29
  * @param {{ clientId: string, siteId: string, wixApiBaseUrl?: string, fetch?: typeof fetch, timeoutMs?: number }} options
@@ -19,16 +35,7 @@ export function builderAuthTokenProvider({ clientId, siteId, wixApiBaseUrl = 'ht
19
35
  const token = cookie(header, `wix_access_token_${siteId}`)
20
36
  const expiresAt = Number(cookie(header, `wix_token_expires_at_${siteId}`))
21
37
  if (token && expiresAt > Date.now()) return token
22
- if (!clientId) throw new Error('VITE_WIX_CLIENT_ID is not set, so there is no visitor token for the Wix router.')
23
- const response = await fetch(new URL('/oauth2/token', wixApiBaseUrl), {
24
- method: 'POST', headers: { 'content-type': 'application/json' },
25
- body: JSON.stringify({ client_id: clientId, grant_type: 'anonymous' }),
26
- signal: AbortSignal.timeout(timeoutMs),
27
- })
28
- if (!response.ok) throw new Error(`The anonymous token request answered HTTP ${response.status}.`)
29
- const { access_token: accessToken } = await response.json()
30
- if (typeof accessToken !== 'string' || !accessToken) throw new Error('The anonymous token response has no access_token.')
31
- return accessToken
38
+ return (await fetchAnonymousToken({ clientId, wixApiBaseUrl, fetch, signal: AbortSignal.timeout(timeoutMs) })).accessToken
32
39
  },
33
40
  }
34
41
  }
@@ -42,7 +42,7 @@ export type BuildResult = { ok: true; output: string } | { ok: false; output: st
42
42
 
43
43
  /** Core contracts only; runtime APIs belong to @wix/origami-site/server. */
44
44
  export type {
45
- SiteOptions, OrigamiSite, RouteMatch, Resolution, PageRef, PageData,
45
+ SiteOptions, OrigamiSite, RouteMatch, Resolution, PageRef, PageData, PageEmbeds,
46
46
  RequestContext, TokenProvider, AccessRule, AccessSource, AccessHandler,
47
47
  Logger, SiteTreeError, RouterServiceError,
48
48
  } from '../server/index.js'