@wix/origami-site 0.1.17 → 0.1.19
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 +3 -3
- package/src/build/pageList.js +2 -1
- package/src/server/index.d.ts +4 -3
- package/src/server/index.js +2 -1
- package/src/server/requests/createSite.js +87 -25
- package/src/server/requests/errors.d.ts +2 -1
- package/src/server/requests/errors.js +16 -0
- package/src/server/router/index.js +17 -0
- package/src/server/router/pickRolePage.js +6 -0
- package/src/server/router/protocol.js +44 -0
- package/src/server/site-tree/readSiteTree.js +79 -4
- package/src/server/site-tree/routeJson.js +28 -0
- package/src/server/tokens/builderAuthTokenProvider.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-site",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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.
|
|
51
|
+
"@wix/origami-transforms": "0.1.11",
|
|
52
52
|
"@babel/parser": "^7.25.0",
|
|
53
53
|
"@wix/system-core-components": "^1.3.10"
|
|
54
54
|
},
|
|
@@ -91,5 +91,5 @@
|
|
|
91
91
|
"optional": true
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
|
-
"falconPackageHash": "
|
|
94
|
+
"falconPackageHash": "0c04ba80f9a2e61581387449cc08422f3616a50308d1d5691f31751f"
|
|
95
95
|
}
|
package/src/build/pageList.js
CHANGED
|
@@ -4,7 +4,8 @@ const resolvedId = `\0${publicId}`
|
|
|
4
4
|
/** @param {import('@wix/origami-types').SiteRouteTable} table */
|
|
5
5
|
function moduleSource(table) {
|
|
6
6
|
// itemPath is relative to src/ and already includes the pages/ prefix.
|
|
7
|
-
|
|
7
|
+
// Role pages too: the router decides on the server which one the browser loads.
|
|
8
|
+
const entries = [...table.pages, ...table.routers.flatMap(router => Object.values(router.rolePages)), table.notFound].map(page => {
|
|
8
9
|
const entry = page.itemPath
|
|
9
10
|
? `/src/${page.itemPath}/_generated/index.jsx`
|
|
10
11
|
: '@wix/origami-site/default-error-404'
|
package/src/server/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type SiteOptions = {
|
|
|
9
9
|
mode: 'dev' | 'prod'
|
|
10
10
|
table?: SiteRouteTable // prod: the table from the build. dev: omitted, the core scans.
|
|
11
11
|
wix: { clientId: string; siteId: string } // from VITE_WIX_CLIENT_ID / VITE_WIX_METASITE_ID
|
|
12
|
-
tokens?: TokenProvider // default:
|
|
12
|
+
tokens?: TokenProvider // default: builderAuthTokenProvider(wix)
|
|
13
13
|
access?: AccessSource // default: noAccessRules (4)
|
|
14
14
|
accessHandlers?: AccessHandler[] // default: []
|
|
15
15
|
fetch?: typeof fetch // tests inject a fake
|
|
@@ -70,8 +70,9 @@ export interface AccessHandler {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
export { SiteTreeError } from './requests/errors.js'
|
|
74
|
-
|
|
73
|
+
export { SiteTreeError, RouterServiceError } from './requests/errors.js'
|
|
74
|
+
/** The default TokenProvider: the visitor's builder-auth cookie when fresh, else a one-off anonymous token. Stores nothing. */
|
|
75
|
+
export function builderAuthTokenProvider(options: { clientId: string; siteId: string; wixApiBaseUrl?: string; fetch?: typeof fetch; timeoutMs?: number }): TokenProvider
|
|
75
76
|
export function createSite(options: SiteOptions): Promise<OrigamiSite>
|
|
76
77
|
export function readSiteTree(root: string): Promise<SiteRouteTable>
|
|
77
78
|
export function runInRequestContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T>
|
package/src/server/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { readSiteTree } from './site-tree/readSiteTree.js'
|
|
2
2
|
export { writeBuiltTable, readBuiltTable } from './site-tree/builtTable.js'
|
|
3
|
-
export { SiteTreeError } from './requests/errors.js'
|
|
3
|
+
export { SiteTreeError, RouterServiceError } from './requests/errors.js'
|
|
4
|
+
export { builderAuthTokenProvider } from './tokens/builderAuthTokenProvider.js'
|
|
4
5
|
export { createSite, noAccessRules } from './requests/createSite.js'
|
|
5
6
|
export { runInRequestContext, getRequestContext, getAccessToken } from './requests/context.js'
|
|
6
7
|
export { serializePageData } from './requests/pageData.js'
|
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
import { createSiteTree } from '../site-tree/createSiteTree.js'
|
|
2
2
|
import { readSiteTree } from '../site-tree/readSiteTree.js'
|
|
3
|
-
import {
|
|
3
|
+
import { callRouter } from '../router/index.js'
|
|
4
|
+
import { pickRolePage } from '../router/pickRolePage.js'
|
|
5
|
+
import { builderAuthTokenProvider } from '../tokens/builderAuthTokenProvider.js'
|
|
6
|
+
import { RouterServiceError, SiteTreeError } from './errors.js'
|
|
4
7
|
import { normalizeRequestPath } from './urlPath.js'
|
|
5
8
|
import { canBePagePath } from './canBePagePath.js'
|
|
6
|
-
import { runInRequestContext } from './context.js'
|
|
9
|
+
import { getAccessToken, runInRequestContext } from './context.js'
|
|
7
10
|
import { errorPage } from './errorPage.js'
|
|
8
11
|
|
|
9
12
|
/** @type {import('../index.js').AccessSource} */
|
|
10
13
|
export const noAccessRules = { async load() { return new Map() } }
|
|
11
|
-
const unavailableTokens = { async getAccessToken() { throw new Error('Default access tokens are not available until router support.') } }
|
|
12
14
|
const defaultLogger = {
|
|
13
15
|
warn: (...args) => console.warn('[origami]', ...args),
|
|
14
16
|
error: (...args) => console.error('[origami]', ...args),
|
|
15
17
|
}
|
|
18
|
+
/** @param {import('@wix/origami-types').SiteRouteTable} table */
|
|
19
|
+
const rolePagesOf = table => table.routers.flatMap(router => Object.values(router.rolePages))
|
|
16
20
|
|
|
17
21
|
/** @param {import('../index.js').SiteOptions} options @returns {Promise<import('../index.js').OrigamiSite>} */
|
|
18
22
|
export async function createSite(options) {
|
|
19
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
root, mode, wix, access = noAccessRules, accessHandlers = [], logger = defaultLogger, fetch = globalThis.fetch,
|
|
25
|
+
wixApiBaseUrl = 'https://www.wixapis.com', routerTimeoutMs = 5000,
|
|
26
|
+
} = options
|
|
27
|
+
const tokens = options.tokens ?? builderAuthTokenProvider({ clientId: wix.clientId, siteId: wix.siteId, wixApiBaseUrl, fetch })
|
|
28
|
+
/** Fail closed: a rule with no handler stops the site instead of serving the page open.
|
|
29
|
+
* @param {{ itemPath: string, access: import('@wix/origami-types').AccessRule[] }} route */
|
|
30
|
+
const handlerFor = (route, rule) => {
|
|
31
|
+
const handler = accessHandlers.find(candidate => candidate.type === rule.type)
|
|
32
|
+
if (!handler) throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [route.itemPath], `No access handler for rule type "${rule.type}".`)
|
|
33
|
+
return handler
|
|
34
|
+
}
|
|
20
35
|
/** @param {import('@wix/origami-types').SiteRouteTable} table */
|
|
21
36
|
async function prepare(table) {
|
|
22
37
|
const rules = await access.load(table, root)
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
const withRules = route => ({ ...route, access: rules.get(route.key) ?? route.access })
|
|
39
|
+
const prepared = {
|
|
40
|
+
...table,
|
|
41
|
+
pages: table.pages.map(withRules),
|
|
42
|
+
routers: table.routers.map(router => ({
|
|
43
|
+
...router,
|
|
44
|
+
rolePages: Object.fromEntries(Object.entries(router.rolePages).map(([role, page]) => [role, withRules(page)])),
|
|
45
|
+
})),
|
|
28
46
|
}
|
|
29
|
-
|
|
47
|
+
for (const route of [...prepared.pages, ...rolePagesOf(prepared)]) for (const rule of route.access) handlerFor(route, rule)
|
|
48
|
+
return prepared
|
|
30
49
|
}
|
|
31
50
|
const tree = mode === 'dev' ? createSiteTree(root, prepare) : null
|
|
32
51
|
const productionTable = tree ? null : await prepare(options.table ?? await readSiteTree(root))
|
|
@@ -43,7 +62,59 @@ export async function createSite(options) {
|
|
|
43
62
|
if (path === undefined) return { kind: 'notFound' }
|
|
44
63
|
if (path === '/_origami' || path.startsWith('/_origami/')) return { kind: 'reserved', path }
|
|
45
64
|
const route = current.pages.find(page => page.url === path)
|
|
46
|
-
|
|
65
|
+
if (route) return { kind: 'page', route }
|
|
66
|
+
// The bare prefix and every path below it go to the router: /team, /team/john, /team/a/b/c.
|
|
67
|
+
const [, prefix, ...rest] = path.split('/')
|
|
68
|
+
const router = current.routers.find(candidate => candidate.prefix === prefix)
|
|
69
|
+
return router ? { kind: 'router', route: router, suffix: `/${rest.join('/')}` } : { kind: 'notFound' }
|
|
70
|
+
}
|
|
71
|
+
/** @param {{ itemPath: string, access: import('@wix/origami-types').AccessRule[] }} route @param {Request} request @param {import('../index.js').RequestContext} context */
|
|
72
|
+
async function checkAccess(route, request, context) {
|
|
73
|
+
for (const rule of route.access) {
|
|
74
|
+
const response = await handlerFor(route, rule).check(rule, request, context)
|
|
75
|
+
if (response) return { kind: /** @type {const} */ ('response'), response }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** @param {{ key: string, title: string, itemPath?: string }} route @param {200 | 404} status
|
|
79
|
+
* @param {import('../index.js').PageData['route']} routeData @param {Record<string, unknown>} services @param {HeadersInit} [headers] */
|
|
80
|
+
function pageResolution(route, status, routeData, services = {}, headers = {}) {
|
|
81
|
+
const page = { key: route.key, title: route.title, ...(route.itemPath === undefined ? {} : { itemPath: route.itemPath }) }
|
|
82
|
+
return { kind: /** @type {const} */ ('page'), status, page, data: { page: page.key, route: routeData, services }, headers: new Headers(headers) }
|
|
83
|
+
}
|
|
84
|
+
/** @param {Extract<import('../index.js').RouteMatch, { kind: 'router' }>} match @param {URL} url @param {string} path */
|
|
85
|
+
async function resolveRouter(match, url, path, current, request, context) {
|
|
86
|
+
// Answers differ per visitor, so nothing on a router path may be cached, including its 404 and redirects.
|
|
87
|
+
const noStore = { 'cache-control': 'private, no-store' }
|
|
88
|
+
let token
|
|
89
|
+
try { token = await getAccessToken() } catch (error) { return errorPage(error, 503, mode, logger) }
|
|
90
|
+
let answer
|
|
91
|
+
try {
|
|
92
|
+
answer = await callRouter({ router: match.route, suffix: match.suffix, url, token, wixApiBaseUrl, timeoutMs: routerTimeoutMs, fetch })
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof RouterServiceError) return errorPage(error, 503, mode, logger)
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
if (answer.kind === 'redirect') {
|
|
98
|
+
return { kind: /** @type {const} */ ('response'), response: new Response(null, { status: answer.status, headers: { ...noStore, location: answer.location } }) }
|
|
99
|
+
}
|
|
100
|
+
const rolePage = answer.kind === 'page' ? pickRolePage(match.route, answer.page) : null
|
|
101
|
+
if (answer.kind === 'page' && !rolePage)
|
|
102
|
+
logger.warn(`The router for /${match.route.prefix} answered page "${answer.page}", which no role page in route.json matches.`)
|
|
103
|
+
if (answer.kind !== 'page' || !rolePage) return pageResolution(current.notFound, 404, { kind: 'notFound', url: path }, {}, noStore)
|
|
104
|
+
const denied = await checkAccess(rolePage, request, context)
|
|
105
|
+
if (denied) {
|
|
106
|
+
// A login redirect is per visitor too. Copy into new headers: Response.redirect()'s are immutable.
|
|
107
|
+
const headers = new Headers(denied.response.headers)
|
|
108
|
+
if (!headers.has('cache-control')) headers.set('cache-control', noStore['cache-control'])
|
|
109
|
+
const { status, statusText, body } = denied.response
|
|
110
|
+
return { kind: /** @type {const} */ ('response'), response: new Response(body, { status, statusText, headers }) }
|
|
111
|
+
}
|
|
112
|
+
const externalBaseUrl = url.origin
|
|
113
|
+
return pageResolution(rolePage, 200, { kind: 'router', url: path, role: rolePage.role }, {
|
|
114
|
+
'@wix/viewer-service-dynamic-route': { dynamicRouteData: { pageData: answer.data, publicData: answer.publicData } },
|
|
115
|
+
'@wix/site-service-url': { externalBaseUrl },
|
|
116
|
+
'@wix/viewer-service-url': { externalBaseUrl },
|
|
117
|
+
}, noStore)
|
|
47
118
|
}
|
|
48
119
|
return {
|
|
49
120
|
table,
|
|
@@ -70,22 +141,13 @@ export async function createSite(options) {
|
|
|
70
141
|
current = await table()
|
|
71
142
|
match = matchPath(path, current)
|
|
72
143
|
}
|
|
144
|
+
if (match.kind === 'router') return await resolveRouter(match, url, /** @type {string} */ (path), current, request, context)
|
|
73
145
|
if (match.kind === 'page') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const response = await handler.check(rule, request, context)
|
|
78
|
-
if (response) return { kind: 'response', response }
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const found = match.kind === 'page'
|
|
82
|
-
const route = match.kind === 'page' ? match.route : current.notFound
|
|
83
|
-
const page = { key: route.key, title: route.title, ...(route.itemPath === undefined ? {} : { itemPath: route.itemPath }) }
|
|
84
|
-
return {
|
|
85
|
-
kind: 'page', status: found ? 200 : 404, page,
|
|
86
|
-
data: { page: page.key, route: { kind: found ? 'page' : 'notFound', url: path ?? url.pathname }, services: {} },
|
|
87
|
-
headers: new Headers(),
|
|
146
|
+
const denied = await checkAccess(match.route, request, context)
|
|
147
|
+
if (denied) return denied
|
|
148
|
+
return pageResolution(match.route, 200, { kind: 'page', url: /** @type {string} */ (path) })
|
|
88
149
|
}
|
|
150
|
+
return pageResolution(current.notFound, 404, { kind: 'notFound', url: path ?? url.pathname })
|
|
89
151
|
})
|
|
90
152
|
} catch (error) { return errorPage(error, 500, mode, logger) }
|
|
91
153
|
},
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export class SiteTreeError extends Error {
|
|
2
2
|
code:
|
|
3
|
-
| 'NOT_SUPPORTED_YET' // router pages are not supported in stage 1
|
|
4
3
|
| 'DUPLICATE_URL' // two folders give the same URL
|
|
5
4
|
| 'ROUTE_JSON_INVALID' // not JSON, or a required field missing or of the wrong type
|
|
6
5
|
| 'ROUTE_JSON_MISSING_SERVICE_URL' // no routerServiceUrl: origami refuses to start
|
|
7
6
|
| 'ROUTE_JSON_SIBLINGS_DIFFER' // [a]/route.json and [b]/route.json differ
|
|
8
7
|
| 'ROLE_FOLDER_MISSING' // pages.member = 'team-member' but no [team-member]/ folder
|
|
8
|
+
| 'ROLE_PAGE_HAS_NO_FILES' // [x]/ has a route.json but no page files
|
|
9
9
|
| 'ROLE_FOLDER_NOT_IN_PAGES' // a [x]/ folder that no role in pages names
|
|
10
10
|
| 'BRACKET_OUTSIDE_PREFIX' // [x]/ not directly under a prefix folder
|
|
11
11
|
| 'PREFIX_HAS_FILES' // team/ itself holds page files
|
|
@@ -19,6 +19,7 @@ export class SiteTreeError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export class RouterServiceError extends Error {
|
|
22
|
+
constructor(code: RouterServiceError['code'], routerPrefix: string, message: string, details?: { status?: number; cause?: unknown })
|
|
22
23
|
code: 'TIMEOUT' | 'NETWORK' | 'HTTP_STATUS' | 'BAD_RESPONSE'
|
|
23
24
|
routerPrefix: string
|
|
24
25
|
status?: number
|
|
@@ -11,3 +11,19 @@ export class SiteTreeError extends Error {
|
|
|
11
11
|
this.paths = [...paths]
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
export class RouterServiceError extends Error {
|
|
16
|
+
/**
|
|
17
|
+
* @param {import('./errors.js').RouterServiceError['code']} code
|
|
18
|
+
* @param {string} routerPrefix
|
|
19
|
+
* @param {string} message
|
|
20
|
+
* @param {{ status?: number, cause?: unknown }} [details]
|
|
21
|
+
*/
|
|
22
|
+
constructor(code, routerPrefix, message, { status, cause } = {}) {
|
|
23
|
+
super(`RouterServiceError ${code} for /${routerPrefix}: ${message}`, { cause })
|
|
24
|
+
this.name = 'RouterServiceError'
|
|
25
|
+
this.code = code
|
|
26
|
+
this.routerPrefix = routerPrefix
|
|
27
|
+
if (status !== undefined) this.status = status
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RouterServiceError } from '../requests/errors.js'
|
|
2
|
+
import { readRouterAnswer, routerRequest } from './protocol.js'
|
|
3
|
+
|
|
4
|
+
/** One router call. Never cached and never retried: answers differ per visitor, and a POST isn't safe to repeat.
|
|
5
|
+
* @param {{ router: import('@wix/origami-types').RouterRoute, suffix: string, url: URL, token: string,
|
|
6
|
+
* wixApiBaseUrl: string, timeoutMs: number, fetch: typeof fetch }} options */
|
|
7
|
+
export async function callRouter({ fetch, timeoutMs, ...options }) {
|
|
8
|
+
const { prefix } = options.router
|
|
9
|
+
let response
|
|
10
|
+
try {
|
|
11
|
+
response = await fetch(routerRequest(options), { signal: AbortSignal.timeout(timeoutMs) })
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
const timedOut = cause instanceof DOMException && cause.name === 'TimeoutError'
|
|
14
|
+
throw new RouterServiceError(timedOut ? 'TIMEOUT' : 'NETWORK', prefix, timedOut ? `no answer within ${timeoutMs} ms` : 'the request failed', { cause })
|
|
15
|
+
}
|
|
16
|
+
return readRouterAnswer(response, prefix)
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Today the services answer with a Wix page id (route.json pageIds); later with the role itself. One lookup covers both.
|
|
2
|
+
* @param {import('@wix/origami-types').RouterRoute} router @param {string} resultPage */
|
|
3
|
+
export function pickRolePage(router, resultPage) {
|
|
4
|
+
if (Object.hasOwn(router.rolePages, resultPage)) return router.rolePages[resultPage]
|
|
5
|
+
return Object.values(router.rolePages).find(page => page.pageId === resultPage) ?? null
|
|
6
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The Wix router service protocol, in one file: a change the router teams ask for is a change here.
|
|
2
|
+
// Piler parity (wix-next builder-utils routerUtils.ts), except that roleVariations is really sent.
|
|
3
|
+
import { RouterServiceError } from '../requests/errors.js'
|
|
4
|
+
|
|
5
|
+
/** @param {{ router: import('@wix/origami-types').RouterRoute, suffix: string, url: URL, token: string, wixApiBaseUrl: string }} options */
|
|
6
|
+
export function routerRequest({ router, suffix, url, token, wixApiBaseUrl }) {
|
|
7
|
+
const { config } = router
|
|
8
|
+
const pageRoles = Object.fromEntries(Object.values(router.rolePages).map(page =>
|
|
9
|
+
[page.role, { id: page.pageId ?? page.role, title: page.title }]))
|
|
10
|
+
return new Request(new URL(`${config.routerServiceUrl}/pages`, wixApiBaseUrl), {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
// The raw token, no "Bearer", as piler sent it.
|
|
13
|
+
headers: { 'content-type': 'application/json', authorization: token },
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
routerPrefix: `/${router.prefix}`,
|
|
16
|
+
fullUrl: `${url.protocol}//${url.host}${url.pathname}`,
|
|
17
|
+
routerSuffix: suffix,
|
|
18
|
+
config: config.config,
|
|
19
|
+
pageRoles,
|
|
20
|
+
roleVariations: config.roleVariations,
|
|
21
|
+
requestInfo: { env: 'browser', formFactor: 'desktop' },
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ kind: 'page', page: string, data: unknown, publicData: unknown } | { kind: 'notFound' } | { kind: 'redirect', status: 301 | 302, location: string }} RouterAnswer */
|
|
27
|
+
|
|
28
|
+
/** @param {Response} response @param {string} routerPrefix @returns {Promise<RouterAnswer>} */
|
|
29
|
+
export async function readRouterAnswer(response, routerPrefix) {
|
|
30
|
+
if (!response.ok) throw new RouterServiceError('HTTP_STATUS', routerPrefix, `the router answered HTTP ${response.status}`, { status: response.status })
|
|
31
|
+
let body
|
|
32
|
+
try { body = await response.json() } catch (cause) { throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'the body is not JSON', { cause }) }
|
|
33
|
+
const result = body?.result
|
|
34
|
+
if (typeof result !== 'object' || result === null) throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'the body has no "result"')
|
|
35
|
+
if (result.status === 200 && typeof result.page === 'string') return { kind: 'page', page: result.page, data: result.data ?? {}, publicData: result.publicData ?? {} }
|
|
36
|
+
if ((result.status === 301 || result.status === 302) && typeof result.redirectUrl === 'string') {
|
|
37
|
+
try { new Headers({ location: result.redirectUrl }) } catch (cause) {
|
|
38
|
+
throw new RouterServiceError('BAD_RESPONSE', routerPrefix, 'redirectUrl is not a valid Location header', { cause })
|
|
39
|
+
}
|
|
40
|
+
return { kind: 'redirect', status: result.status, location: result.redirectUrl }
|
|
41
|
+
}
|
|
42
|
+
if (result.status === 404) return { kind: 'notFound' }
|
|
43
|
+
throw new RouterServiceError('BAD_RESPONSE', routerPrefix, `unexpected result status ${JSON.stringify(result.status)}`)
|
|
44
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Ported from origami-site-runtime/routing/pagesToRoutes.js for the layout 2 tree.
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
-
import { basename, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
import { parse } from '@babel/parser'
|
|
5
5
|
import { findItems, pageUrl } from '@wix/origami-types'
|
|
6
6
|
import { SiteTreeError } from '../requests/errors.js'
|
|
7
|
+
import { canonicalJson, readRouteJson } from './routeJson.js'
|
|
7
8
|
|
|
8
9
|
/** @type {Map<string, { mtimeMs: number, metadata: { title?: string, pageId?: string } }>} */
|
|
9
10
|
const metadataCache = new Map()
|
|
@@ -60,6 +61,63 @@ export const siteRoot = root => {
|
|
|
60
61
|
return resolve(root)
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
const isBracket = name => /^\[[^[\]]+\]$/.test(name)
|
|
65
|
+
const isPageFolder = entry => entry.isDirectory() && !entry.name.startsWith('.') && !['_generated', 'node_modules'].includes(entry.name)
|
|
66
|
+
const hasPageFiles = entries => entries.some(entry => entry.isFile() && !entry.name.startsWith('.'))
|
|
67
|
+
|
|
68
|
+
/** A router prefix: `pages/<prefix>/` with only `[slug]` role page folders, each with an identical route.json.
|
|
69
|
+
* @returns {import('@wix/origami-types').RouterRoute} */
|
|
70
|
+
function readRouter(folder, itemPath, entries) {
|
|
71
|
+
const path = `src/${itemPath}`
|
|
72
|
+
const children = entries.filter(isPageFolder)
|
|
73
|
+
const prefix = basename(folder)
|
|
74
|
+
if (itemPath.split('/').length !== 2 || /^\(.*\)$/.test(prefix))
|
|
75
|
+
throw new SiteTreeError('BRACKET_OUTSIDE_PREFIX', children.filter(child => isBracket(child.name)).map(child => `${path}/${child.name}`),
|
|
76
|
+
'A [page] folder must sit directly in a router prefix folder at the top of src/pages, e.g. src/pages/team/[team-member].')
|
|
77
|
+
if (entries.some(entry => entry.name === 'route.json'))
|
|
78
|
+
throw new SiteTreeError('ROUTE_JSON_INVALID', [`${path}/route.json`], `route.json belongs in each [page] folder of /${prefix}, not in the prefix folder.`)
|
|
79
|
+
if (hasPageFiles(entries))
|
|
80
|
+
throw new SiteTreeError('PREFIX_HAS_FILES', [path], `The router prefix folder "${prefix}" holds only [page] folders; the Wix router answers /${prefix} itself.`)
|
|
81
|
+
const others = children.filter(child => !isBracket(child.name))
|
|
82
|
+
if (others.length)
|
|
83
|
+
throw new SiteTreeError('PREFIX_COLLIDES_WITH_PAGE', others.map(child => `${path}/${child.name}`), `Every URL under /${prefix} goes to the Wix router, so it can't hold other pages.`)
|
|
84
|
+
/** @type {{ slug: string, itemPath: string, path: string, config: import('@wix/origami-types').RouteConfig, title: string }[]} */
|
|
85
|
+
const roleFolders = []
|
|
86
|
+
for (const child of children) {
|
|
87
|
+
const childPath = `${path}/${child.name}`
|
|
88
|
+
const childFolder = resolve(folder, child.name)
|
|
89
|
+
const childEntries = readdirSync(childFolder, { withFileTypes: true })
|
|
90
|
+
const nested = childEntries.filter(isPageFolder)
|
|
91
|
+
if (nested.length) throw new SiteTreeError('ROLE_PAGE_HAS_CHILDREN', nested.map(entry => `${childPath}/${entry.name}`), 'A [page] folder cannot hold other pages.')
|
|
92
|
+
if (!childEntries.some(entry => entry.name === 'route.json'))
|
|
93
|
+
throw new SiteTreeError('ROUTE_JSON_INVALID', [`${childPath}/route.json`], 'Every [page] folder of a router needs its route.json.')
|
|
94
|
+
// Without them the browser would import a page entry that doesn't exist, once the router picks this page.
|
|
95
|
+
if (!hasPageFiles(childEntries.filter(entry => entry.name !== 'route.json')))
|
|
96
|
+
throw new SiteTreeError('ROLE_PAGE_HAS_NO_FILES', [childPath], 'This [page] folder has a route.json but no page files.')
|
|
97
|
+
roleFolders.push({
|
|
98
|
+
slug: child.name.slice(1, -1), itemPath: `${itemPath}/${child.name}`, path: childPath,
|
|
99
|
+
config: readRouteJson(resolve(childFolder, 'route.json'), `${childPath}/route.json`),
|
|
100
|
+
title: pageMetadata(childFolder).title,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
const [first] = roleFolders
|
|
104
|
+
const differ = roleFolders.find(folder => canonicalJson(folder.config) !== canonicalJson(first.config))
|
|
105
|
+
if (differ) throw new SiteTreeError('ROUTE_JSON_SIBLINGS_DIFFER', [`${first.path}/route.json`, `${differ.path}/route.json`],
|
|
106
|
+
'Role pages of one router must have identical route.json files.')
|
|
107
|
+
const { config } = first
|
|
108
|
+
const unnamed = roleFolders.filter(folder => !Object.values(config.pages).includes(folder.slug))
|
|
109
|
+
if (unnamed.length) throw new SiteTreeError('ROLE_FOLDER_NOT_IN_PAGES', unnamed.map(folder => folder.path), 'No role in route.json "pages" names this folder.')
|
|
110
|
+
/** @type {Record<string, import('@wix/origami-types').RolePage>} */
|
|
111
|
+
const rolePages = {}
|
|
112
|
+
for (const [role, slug] of Object.entries(config.pages)) {
|
|
113
|
+
const folder = roleFolders.find(candidate => candidate.slug === slug)
|
|
114
|
+
if (!folder) throw new SiteTreeError('ROLE_FOLDER_MISSING', [`${path}/[${slug}]`], `route.json names the "${role}" page "${slug}", but there is no [${slug}] folder.`)
|
|
115
|
+
const pageId = config.pageIds?.[role]
|
|
116
|
+
rolePages[role] = { key: folder.itemPath, role, slug, itemPath: folder.itemPath, title: folder.title, access: [], ...(pageId ? { pageId } : {}) }
|
|
117
|
+
}
|
|
118
|
+
return { key: itemPath, prefix, config, rolePages }
|
|
119
|
+
}
|
|
120
|
+
|
|
63
121
|
/** @param {string} root @returns {Promise<import('@wix/origami-types').SiteRouteTable>} */
|
|
64
122
|
export const readSiteTree = async root => {
|
|
65
123
|
const src = resolve(siteRoot(root), 'src')
|
|
@@ -68,11 +126,23 @@ export const readSiteTree = async root => {
|
|
|
68
126
|
/** @type {import('@wix/origami-types').SiteNotFoundRoute} */
|
|
69
127
|
let notFound = { key: '@wix/origami-site/default-error-404', title: 'Page not found' }
|
|
70
128
|
const pathsByUrl = new Map()
|
|
71
|
-
const { items, routers } = existsSync(resolve(src, 'pages'))
|
|
129
|
+
const { items, routers: routerPaths } = existsSync(resolve(src, 'pages'))
|
|
72
130
|
? findItems(path => readdirSync(resolve(src, path), { withFileTypes: true }), 'pages')
|
|
73
131
|
: { items: [], routers: [] }
|
|
74
|
-
|
|
132
|
+
/** @type {import('@wix/origami-types').RouterRoute[]} */
|
|
133
|
+
const routers = []
|
|
134
|
+
const prefixes = new Set(routerPaths.filter(path => isBracket(basename(path))).map(path => dirname(path)))
|
|
135
|
+
for (const prefix of prefixes) {
|
|
136
|
+
if (prefix === 'pages/error404')
|
|
137
|
+
throw new SiteTreeError('NOT_FOUND_HAS_CHILDREN', [`src/${prefix}`], 'The not-found page cannot have children.')
|
|
138
|
+
routers.push(readRouter(resolve(src, prefix), prefix, readdirSync(resolve(src, prefix), { withFileTypes: true })))
|
|
139
|
+
}
|
|
140
|
+
for (const path of routerPaths.filter(path => basename(path) === 'route.json')) {
|
|
141
|
+
if (!routers.some(router => Object.values(router.rolePages).some(page => path === `${page.itemPath}/route.json`)))
|
|
142
|
+
throw new SiteTreeError('ROUTE_JSON_INVALID', [`src/${path}`], 'route.json belongs in a [page] folder of a router prefix.')
|
|
143
|
+
}
|
|
75
144
|
for (const itemPath of items) {
|
|
145
|
+
if (routers.some(router => Object.values(router.rolePages).some(page => page.itemPath === itemPath))) continue
|
|
76
146
|
const path = `src/${itemPath}`
|
|
77
147
|
const metadata = pageMetadata(resolve(src, itemPath))
|
|
78
148
|
if (itemPath === 'pages/error404') notFound = { key: itemPath, itemPath, title: metadata.title }
|
|
@@ -88,5 +158,10 @@ export const readSiteTree = async root => {
|
|
|
88
158
|
if (notFoundChildren.length)
|
|
89
159
|
throw new SiteTreeError('NOT_FOUND_HAS_CHILDREN', ['src/pages/error404', ...notFoundChildren.map(page => `src/${page.itemPath}`)],
|
|
90
160
|
'The not-found page cannot have children.')
|
|
91
|
-
|
|
161
|
+
for (const router of routers) {
|
|
162
|
+
const page = pages.find(candidate => candidate.url === `/${router.prefix}` || candidate.url.startsWith(`/${router.prefix}/`))
|
|
163
|
+
if (page) throw new SiteTreeError('PREFIX_COLLIDES_WITH_PAGE', [`src/${router.key}`, `src/${page.itemPath}`],
|
|
164
|
+
`Every URL under /${router.prefix} goes to the Wix router, so "${page.url}" can't be a page.`)
|
|
165
|
+
}
|
|
166
|
+
return { version: 2, pages, routers, notFound, popups: [] }
|
|
92
167
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { SiteTreeError } from '../requests/errors.js'
|
|
3
|
+
|
|
4
|
+
const isRecord = value => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
const isStringRecord = value => isRecord(value) && Object.values(value).every(item => typeof item === 'string' && item !== '')
|
|
6
|
+
|
|
7
|
+
/** Reads and checks one route.json. The core reads only routerServiceUrl, pages and pageIds; the rest passes through.
|
|
8
|
+
* @param {string} file absolute path @param {string} path site-relative path for errors
|
|
9
|
+
* @returns {import('@wix/origami-types').RouteConfig} */
|
|
10
|
+
export function readRouteJson(file, path) {
|
|
11
|
+
let config
|
|
12
|
+
try { config = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
|
|
13
|
+
throw new SiteTreeError('ROUTE_JSON_INVALID', [path], `Not valid JSON: ${/** @type {Error} */ (error).message}`)
|
|
14
|
+
}
|
|
15
|
+
const invalid = message => new SiteTreeError('ROUTE_JSON_INVALID', [path], message)
|
|
16
|
+
if (!isRecord(config)) throw invalid('route.json must hold a JSON object.')
|
|
17
|
+
if (typeof config.routerServiceUrl !== 'string' || !config.routerServiceUrl)
|
|
18
|
+
throw new SiteTreeError('ROUTE_JSON_MISSING_SERVICE_URL', [path], 'route.json has no routerServiceUrl, so the Wix router cannot be called. Export the site again.')
|
|
19
|
+
if (!isStringRecord(config.pages) || !Object.keys(config.pages).length) throw invalid('"pages" must map each role to its page folder name, e.g. { "member": "team-member" }.')
|
|
20
|
+
if (config.pageIds !== undefined && !isStringRecord(config.pageIds)) throw invalid('"pageIds" must map each role to a Wix page id.')
|
|
21
|
+
if (!('config' in config)) throw invalid('"config" is missing.')
|
|
22
|
+
if (!isRecord(config.roleVariations)) throw invalid('"roleVariations" must be an object.')
|
|
23
|
+
return config
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Key order and whitespace don't count when siblings are compared. */
|
|
27
|
+
export const canonicalJson = value => JSON.stringify(value, (_key, item) =>
|
|
28
|
+
isRecord(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Cookie names from @wix/builder-auth (constants.ts, tokenStore.ts): scoped by `_${siteId}`, expiry in epoch ms.
|
|
2
|
+
const cookie = (header, name) => {
|
|
3
|
+
for (const part of (header ?? '').split(';')) {
|
|
4
|
+
const index = part.indexOf('=')
|
|
5
|
+
if (index > 0 && part.slice(0, index).trim() === name) {
|
|
6
|
+
try { return decodeURIComponent(part.slice(index + 1).trim()) } catch { return undefined }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The visitor's own builder-auth token when the cookie is there and fresh; otherwise a one-off anonymous token.
|
|
12
|
+
* Stores nothing and sets no cookie: builder-auth in the browser keeps owning the visitor's tokens.
|
|
13
|
+
* @param {{ clientId: string, siteId: string, wixApiBaseUrl?: string, fetch?: typeof fetch, timeoutMs?: number }} options
|
|
14
|
+
* @returns {import('../index.js').TokenProvider} */
|
|
15
|
+
export function builderAuthTokenProvider({ clientId, siteId, wixApiBaseUrl = 'https://www.wixapis.com', fetch = globalThis.fetch, timeoutMs = 5000 }) {
|
|
16
|
+
return {
|
|
17
|
+
async getAccessToken(request) {
|
|
18
|
+
const header = request.headers.get('cookie')
|
|
19
|
+
const token = cookie(header, `wix_access_token_${siteId}`)
|
|
20
|
+
const expiresAt = Number(cookie(header, `wix_token_expires_at_${siteId}`))
|
|
21
|
+
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
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|