@wix/origami-site 0.1.2 → 0.1.4
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 +6 -4
- package/src/browser/index.d.ts +7 -3
- package/src/browser/index.js +57 -1
- package/src/server/index.d.ts +1 -2
- package/src/server/index.js +3 -0
- package/src/server/requests/canBePagePath.js +3 -0
- package/src/server/requests/context.js +22 -0
- package/src/server/requests/createSite.js +93 -0
- package/src/server/requests/errorPage.js +15 -0
- package/src/server/requests/pageData.js +4 -0
- package/src/server/requests/urlPath.js +63 -0
- package/src/server/site-tree/createSiteTree.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-site",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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": [
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc --noEmit",
|
|
11
11
|
"lint": "oxlint .",
|
|
12
|
-
"test": "node --test test/*.spec.js && tsc --noEmit -p tsconfig.test.json"
|
|
12
|
+
"test": "node --test test/*.spec.js && tsc --noEmit -p tsconfig.test.json && vitest run --config vitest.config.js"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/node": "^22.0.0",
|
|
16
16
|
"typescript": "^6.0.3",
|
|
17
17
|
"vite": "^8",
|
|
18
18
|
"@vitejs/plugin-react": "^5.2.0",
|
|
19
|
-
"postcss": "^8.5.3"
|
|
19
|
+
"postcss": "^8.5.3",
|
|
20
|
+
"jsdom": "^23.0.1",
|
|
21
|
+
"vitest": "^4.1.11"
|
|
20
22
|
},
|
|
21
23
|
"publishUnscoped": false,
|
|
22
24
|
"publishConfig": {
|
|
@@ -77,5 +79,5 @@
|
|
|
77
79
|
"peerDependencies": {
|
|
78
80
|
"vite": "^8"
|
|
79
81
|
},
|
|
80
|
-
"falconPackageHash": "
|
|
82
|
+
"falconPackageHash": "69ad67bddf0c45dc3c8fd3dcebfb36083391808674c0ac438126d4c7"
|
|
81
83
|
}
|
package/src/browser/index.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { PageData } from '../server/index.js'
|
|
2
|
-
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
|
|
4
|
+
export type PageModule = { default: ComponentType }
|
|
3
5
|
export type MountPageOptions = {
|
|
4
|
-
pages: Record<string, () => Promise<
|
|
6
|
+
pages: Record<string, () => Promise<PageModule>>
|
|
5
7
|
popups: Record<string, () => Promise<unknown>>
|
|
6
8
|
seoUriByPageId: Record<string, string>
|
|
7
9
|
beforeMount: () => Promise<unknown>
|
|
8
10
|
}
|
|
9
11
|
export function mountPage(element: Element | null, options: MountPageOptions): Promise<() => void>
|
|
10
|
-
|
|
12
|
+
/** Cached per page load. Warns once and returns undefined if the block is missing.
|
|
13
|
+
* Throws an Error with the parse failure as its cause for invalid JSON. */
|
|
14
|
+
export function readPageData(): PageData | undefined
|
package/src/browser/index.js
CHANGED
|
@@ -1 +1,57 @@
|
|
|
1
|
-
|
|
1
|
+
import { createElement, StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import { PilerPage } from '@wix/builder-piler-components'
|
|
4
|
+
|
|
5
|
+
let hasReadPageData = false
|
|
6
|
+
/** @type {import('../server/index.js').PageData | undefined} */
|
|
7
|
+
let pageData
|
|
8
|
+
/** @type {Error | undefined} */
|
|
9
|
+
let pageDataError
|
|
10
|
+
|
|
11
|
+
/** Read the adapter's JSON block without evaluating it as JavaScript.
|
|
12
|
+
* @returns {import('../server/index.js').PageData | undefined}
|
|
13
|
+
*/
|
|
14
|
+
export function readPageData() {
|
|
15
|
+
if (!hasReadPageData) {
|
|
16
|
+
hasReadPageData = true
|
|
17
|
+
const script = document.getElementById('origami-page')
|
|
18
|
+
if (!script) {
|
|
19
|
+
console.warn('Missing #origami-page data block')
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
pageData = JSON.parse(script.textContent ?? '')
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
pageDataError = new Error('Invalid JSON in #origami-page', { cause })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (pageDataError) throw pageDataError
|
|
29
|
+
return pageData
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {Element | null} element
|
|
33
|
+
* @param {import('./index.js').MountPageOptions} options
|
|
34
|
+
* @returns {Promise<() => void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function mountPage(element, { pages, beforeMount }) {
|
|
37
|
+
if (!element) throw new Error('Cannot mount page: root element is missing')
|
|
38
|
+
const data = readPageData()
|
|
39
|
+
const pageKey = data?.page
|
|
40
|
+
const loadPage = (typeof pageKey === 'string' && Object.hasOwn(pages, pageKey) && pages[pageKey])
|
|
41
|
+
|| (Object.hasOwn(pages, 'pages/error404') && pages['pages/error404'])
|
|
42
|
+
|| (Object.hasOwn(pages, '@wix/origami-site/default-error-404') && pages['@wix/origami-site/default-error-404'])
|
|
43
|
+
if (typeof loadPage !== 'function') {
|
|
44
|
+
throw new Error('Cannot mount page: requested page and default 404 are missing')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await beforeMount()
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(error)
|
|
51
|
+
}
|
|
52
|
+
// Generated page providers read PageData.services during this import.
|
|
53
|
+
const { default: Page } = await loadPage()
|
|
54
|
+
const root = createRoot(element)
|
|
55
|
+
root.render(createElement(StrictMode, null, createElement(PilerPage, { Page })))
|
|
56
|
+
return () => root.unmount()
|
|
57
|
+
}
|
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_SITE_ID
|
|
12
|
-
tokens?: TokenProvider // default:
|
|
12
|
+
tokens?: TokenProvider // default: rejects until router support (stage 2)
|
|
13
13
|
access?: AccessSource // default: noAccessRules (4)
|
|
14
14
|
accessHandlers?: AccessHandler[] // default: []
|
|
15
15
|
fetch?: typeof fetch // tests inject a fake
|
|
@@ -78,4 +78,3 @@ export function runInRequestContext<T>(ctx: RequestContext, fn: () => Promise<T>
|
|
|
78
78
|
export function getRequestContext(): RequestContext
|
|
79
79
|
export function getAccessToken(): Promise<string>
|
|
80
80
|
export function serializePageData(data: PageData): string
|
|
81
|
-
export function builderAuthTokenProvider(options: { clientId: string; siteId: string }): TokenProvider
|
package/src/server/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export { readSiteTree } from './site-tree/readSiteTree.js'
|
|
2
2
|
export { SiteTreeError } from './requests/errors.js'
|
|
3
|
+
export { createSite, noAccessRules } from './requests/createSite.js'
|
|
4
|
+
export { runInRequestContext, getRequestContext, getAccessToken } from './requests/context.js'
|
|
5
|
+
export { serializePageData } from './requests/pageData.js'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
/** @type {AsyncLocalStorage<{ context: import('../index.js').RequestContext, token?: Promise<string> }>} */
|
|
4
|
+
const storage = new AsyncLocalStorage()
|
|
5
|
+
|
|
6
|
+
/** @template T @param {import('../index.js').RequestContext} ctx @param {() => Promise<T>} fn */
|
|
7
|
+
export function runInRequestContext(ctx, fn) {
|
|
8
|
+
return storage.run({ context: ctx }, fn)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getRequestContext() {
|
|
12
|
+
const state = storage.getStore()
|
|
13
|
+
if (!state) throw new Error('No Origami request context is active.')
|
|
14
|
+
return state.context
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getAccessToken() {
|
|
18
|
+
const context = getRequestContext()
|
|
19
|
+
const state = /** @type {NonNullable<ReturnType<typeof storage.getStore>>} */ (storage.getStore())
|
|
20
|
+
state.token ??= Promise.resolve().then(() => context.tokens.getAccessToken(context.request))
|
|
21
|
+
return state.token
|
|
22
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createSiteTree } from '../site-tree/createSiteTree.js'
|
|
2
|
+
import { readSiteTree } from '../site-tree/readSiteTree.js'
|
|
3
|
+
import { SiteTreeError } from './errors.js'
|
|
4
|
+
import { normalizeRequestPath } from './urlPath.js'
|
|
5
|
+
import { canBePagePath } from './canBePagePath.js'
|
|
6
|
+
import { runInRequestContext } from './context.js'
|
|
7
|
+
import { errorPage } from './errorPage.js'
|
|
8
|
+
|
|
9
|
+
/** @type {import('../index.js').AccessSource} */
|
|
10
|
+
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
|
+
const defaultLogger = {
|
|
13
|
+
warn: (...args) => console.warn('[origami]', ...args),
|
|
14
|
+
error: (...args) => console.error('[origami]', ...args),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {import('../index.js').SiteOptions} options @returns {Promise<import('../index.js').OrigamiSite>} */
|
|
18
|
+
export async function createSite(options) {
|
|
19
|
+
const { root, mode, access = noAccessRules, accessHandlers = [], tokens = unavailableTokens, logger = defaultLogger } = options
|
|
20
|
+
/** @param {import('@wix/origami-types').SiteRouteTable} table */
|
|
21
|
+
async function prepare(table) {
|
|
22
|
+
const rules = await access.load(table, root)
|
|
23
|
+
const pages = table.pages.map(page => ({ ...page, access: rules.get(page.key) ?? page.access }))
|
|
24
|
+
for (const page of pages) for (const rule of page.access) {
|
|
25
|
+
if (!accessHandlers.some(handler => handler.type === rule.type)) {
|
|
26
|
+
throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [page.itemPath], `No access handler for rule type "${rule.type}".`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ...table, pages }
|
|
30
|
+
}
|
|
31
|
+
const tree = mode === 'dev' ? createSiteTree(root, prepare) : null
|
|
32
|
+
const productionTable = tree ? null : await prepare(options.table ?? await readSiteTree(root))
|
|
33
|
+
let closed = false
|
|
34
|
+
async function table() {
|
|
35
|
+
if (closed) throw new Error('Site is closed.')
|
|
36
|
+
return tree ? tree.table() : /** @type {import('@wix/origami-types').SiteRouteTable} */ (productionTable)
|
|
37
|
+
}
|
|
38
|
+
try { await table() } catch (error) {
|
|
39
|
+
if (mode !== 'dev' || !(error instanceof SiteTreeError)) { await tree?.close(); throw error }
|
|
40
|
+
}
|
|
41
|
+
/** @param {string | undefined} path @param {import('@wix/origami-types').SiteRouteTable} current @returns {import('../index.js').RouteMatch} */
|
|
42
|
+
function matchPath(path, current) {
|
|
43
|
+
if (path === undefined) return { kind: 'notFound' }
|
|
44
|
+
if (path === '/_origami' || path.startsWith('/_origami/')) return { kind: 'reserved', path }
|
|
45
|
+
const route = current.pages.find(page => page.url === path)
|
|
46
|
+
return route ? { kind: 'page', route } : { kind: 'notFound' }
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
table,
|
|
50
|
+
async match(url) { return matchPath(normalizeRequestPath(url.pathname), await table()) },
|
|
51
|
+
fileChanged(path) { tree?.fileChanged(path) },
|
|
52
|
+
onTableChange(listener) { return tree?.onTableChange(listener) ?? (() => {}) },
|
|
53
|
+
async close() { closed = true; await tree?.close() },
|
|
54
|
+
async handle(request) {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(request.url)
|
|
57
|
+
const context = { request, url, tokens, locals: new Map() }
|
|
58
|
+
return await runInRequestContext(context, async () => {
|
|
59
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
60
|
+
return { kind: 'response', response: new Response(null, { status: 405, headers: { Allow: 'GET, HEAD' } }) }
|
|
61
|
+
}
|
|
62
|
+
const path = normalizeRequestPath(url.pathname)
|
|
63
|
+
if (path === '/_origami' || path?.startsWith('/_origami/')) {
|
|
64
|
+
return { kind: 'response', response: new Response(null, { status: 404 }) }
|
|
65
|
+
}
|
|
66
|
+
let current = await table()
|
|
67
|
+
let match = matchPath(path, current)
|
|
68
|
+
if (match.kind === 'notFound' && path !== undefined && tree && canBePagePath(path)) {
|
|
69
|
+
tree.fileChanged('src/pages')
|
|
70
|
+
current = await table()
|
|
71
|
+
match = matchPath(path, current)
|
|
72
|
+
}
|
|
73
|
+
if (match.kind === 'page') {
|
|
74
|
+
for (const rule of match.route.access) {
|
|
75
|
+
const handler = accessHandlers.find(candidate => candidate.type === rule.type)
|
|
76
|
+
if (!handler) throw new SiteTreeError('UNSUPPORTED_ACCESS_RULE', [match.route.itemPath], `No access handler for rule type "${rule.type}".`)
|
|
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(),
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
} catch (error) { return errorPage(error, 500, mode, logger) }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
|
|
2
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
3
|
+
})[char])
|
|
4
|
+
|
|
5
|
+
/** Plain HTML: builder-error-pages exports React components, not server HTML. */
|
|
6
|
+
export function errorPage(error, status, mode, logger) {
|
|
7
|
+
// Logging must not turn a handled failure into a rejected request.
|
|
8
|
+
try { logger.error(error instanceof Error ? error.stack ?? error.message : String(error)) } catch {}
|
|
9
|
+
const title = status === 503 ? 'Service unavailable' : 'Internal server error'
|
|
10
|
+
const detail = mode === 'dev' ? `<pre>${escapeHtml(error instanceof Error ? error.message : error)}</pre>` : ''
|
|
11
|
+
return { kind: /** @type {const} */ ('response'), response: new Response(
|
|
12
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body><h1>${title}</h1>${detail}</body></html>`,
|
|
13
|
+
{ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } },
|
|
14
|
+
) }
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// URL path normalization shared by route declarations and incoming requests. A normalized path is
|
|
2
|
+
// absolute, percent-decoded, and free of anything that could escape the route table: traversal
|
|
3
|
+
// segments, embedded separators, control characters.
|
|
4
|
+
|
|
5
|
+
const RESERVED_IN_RAW_PATH = /[\\?#]/
|
|
6
|
+
const SEGMENT_SEPARATORS = '\\/?#'
|
|
7
|
+
const HIGHEST_CONTROL_CODE = 0x1f
|
|
8
|
+
const DELETE_CODE = 0x7f
|
|
9
|
+
|
|
10
|
+
export class UrlPathError extends Error {
|
|
11
|
+
constructor(reason) {
|
|
12
|
+
super(reason)
|
|
13
|
+
this.name = 'UrlPathError'
|
|
14
|
+
this.reason = reason
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hasUnsafeCharacter = value => {
|
|
19
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
20
|
+
const code = value.charCodeAt(index)
|
|
21
|
+
if (code <= HIGHEST_CONTROL_CODE || code === DELETE_CODE) return true
|
|
22
|
+
if (SEGMENT_SEPARATORS.includes(value[index])) return true
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const decodeSegment = segment => {
|
|
28
|
+
let decoded
|
|
29
|
+
try {
|
|
30
|
+
decoded = decodeURIComponent(segment)
|
|
31
|
+
} catch {
|
|
32
|
+
throw new UrlPathError('unsafe-segment')
|
|
33
|
+
}
|
|
34
|
+
if (decoded === '.' || decoded === '..' || hasUnsafeCharacter(decoded)) throw new UrlPathError('unsafe-segment')
|
|
35
|
+
return decoded
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const normalizeUrlPath = value => {
|
|
39
|
+
if (
|
|
40
|
+
typeof value !== 'string' ||
|
|
41
|
+
!value.startsWith('/') ||
|
|
42
|
+
RESERVED_IN_RAW_PATH.test(value) ||
|
|
43
|
+
value.includes('//')
|
|
44
|
+
) {
|
|
45
|
+
throw new UrlPathError('not-absolute')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const trimmed = value === '/' ? value : value.replace(/\/+$/, '')
|
|
49
|
+
if (trimmed === '/') return trimmed
|
|
50
|
+
|
|
51
|
+
return `/${trimmed.slice(1).split('/').map(decodeSegment).join('/')}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The Web Request URL parser has already normalized url.pathname. Reject encoded
|
|
55
|
+
// separators, control characters and leftover traversal segments; bad input becomes a 404.
|
|
56
|
+
export const normalizeRequestPath = requestUrl => {
|
|
57
|
+
if (typeof requestUrl !== 'string' || !requestUrl.startsWith('/')) return undefined
|
|
58
|
+
try {
|
|
59
|
+
return normalizeUrlPath(requestUrl.split(/[?#]/, 1)[0])
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -2,7 +2,7 @@ import { relative, resolve, sep } from 'node:path'
|
|
|
2
2
|
import { readSiteTree, siteRoot } from './readSiteTree.js'
|
|
3
3
|
|
|
4
4
|
/** Internal live table; adapters supply file events, never a second watcher. */
|
|
5
|
-
export const createSiteTree = root => {
|
|
5
|
+
export const createSiteTree = (root, prepare = async table => table) => {
|
|
6
6
|
const rootPath = siteRoot(root)
|
|
7
7
|
let dirty = true
|
|
8
8
|
let closed = false
|
|
@@ -20,7 +20,7 @@ export const createSiteTree = root => {
|
|
|
20
20
|
dirty = false
|
|
21
21
|
// Serialize rescans, including recovery after a rejected scan.
|
|
22
22
|
pending = (pending ?? Promise.resolve()).catch(() => {}).then(async () => {
|
|
23
|
-
const next = await readSiteTree(rootPath)
|
|
23
|
+
const next = await prepare(await readSiteTree(rootPath))
|
|
24
24
|
const nextSignature = JSON.stringify(next)
|
|
25
25
|
if (signature !== nextSignature) {
|
|
26
26
|
const hadTable = current !== undefined
|