@wix/origami-site 0.1.21 → 0.1.22
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 +2 -2
- package/src/server/embeds/embedsForPage.js +12 -0
- package/src/server/embeds/index.js +61 -0
- package/src/server/embeds/protocol.js +26 -0
- package/src/server/index.d.ts +5 -1
- package/src/server/requests/createSite.js +10 -3
- package/src/server/site-tree/readSiteTree.js +1 -1
- package/src/server/tokens/builderAuthTokenProvider.js +17 -10
- package/src/types/index.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-site",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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": [
|
|
@@ -93,5 +93,5 @@
|
|
|
93
93
|
"optional": true
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
|
-
"falconPackageHash": "
|
|
96
|
+
"falconPackageHash": "1930df8fc74e8e5420dac341ec1276d9ee8314849dccf88de790697c"
|
|
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
|
+
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/src/types/index.d.ts
CHANGED
|
@@ -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'
|