@wix/origami-site-express 0.0.0-stage → 0.1.0

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 CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @wix/origami-site-express
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runs an exported Wix origami site on Node with Express and Vite, on top of `@wix/origami-site`.
4
+ Design: [docs/site-express-adapter-plan.md](../../../docs/site-express-adapter-plan.md).
5
+
6
+ The site has no server code. Its scripts call the bin:
7
+
8
+ | Script | Command | What it does |
9
+ |---|---|---|
10
+ | `yarn dev` | `origami-site-express dev` | Vite in middleware mode + Express + the core in dev mode: hot reload, live routing |
11
+
12
+ `PORT` sets the port (default 3000). `yarn dev` listens on `localhost` only, because it serves the site's source
13
+ through Vite; set `HOST=0.0.0.0` to reach it from another machine or a container. `VITE_WIX_CLIENT_ID` and `VITE_WIX_SITE_ID` come from `.env`.
14
+
15
+ - The adapter loads `@wix/origami-site` from the **site root**, never its own copy.
16
+ - In dev each request imports the core through Vite's module runner, so a linked core reloads on edit
17
+ without a restart. An installed core loads once.
18
+ - HMR runs on the site's own port (`server.ws.server`).
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import * as commands from '../src/index.js'
3
+
4
+ const [command] = process.argv.slice(2)
5
+ if (!Object.hasOwn(commands, command)) {
6
+ console.error(`Usage: origami-site-express <${Object.keys(commands).join(' | ')}>`)
7
+ process.exit(1)
8
+ }
9
+ try {
10
+ await commands[command]({ root: process.cwd() })
11
+ } catch (error) {
12
+ console.error(error instanceof Error ? error.stack ?? error.message : error)
13
+ process.exit(1)
14
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@wix/origami-site-express",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Run an exported Wix origami site on Node with Express and Vite.",
5
+ "type": "module",
6
+ "files": [
7
+ "src",
8
+ "bin"
9
+ ],
10
+ "bin": {
11
+ "origami-site-express": "./bin/origami-site-express.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.d.ts",
16
+ "default": "./src/index.js"
17
+ },
18
+ "./adapter": {
19
+ "types": "./src/adapter/index.d.ts",
20
+ "default": "./src/adapter/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.12"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc --noEmit",
29
+ "lint": "oxlint .",
30
+ "test": "node --test test/*.spec.js"
31
+ },
32
+ "dependencies": {
33
+ "@vitejs/plugin-react": "^6.1.1",
34
+ "express": "^5.2.1",
35
+ "vite": "^8.1.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@wix/origami-site": "^0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/express": "^5.0.0",
42
+ "@types/node": "^22.0.0",
43
+ "@wix/origami-site": "0.1.0",
44
+ "typescript": "^6.0.3"
45
+ },
46
+ "publishUnscoped": false,
47
+ "publishConfig": {
48
+ "registry": "https://registry.npmjs.org/",
49
+ "access": "public"
50
+ },
51
+ "wix": {
52
+ "artifact": {
53
+ "groupId": "com.wixpress.npm",
54
+ "artifactId": "origami-site-express",
55
+ "targets": {
56
+ "docker": false
57
+ }
58
+ },
59
+ "validations": {
60
+ "source": [
61
+ "lint"
62
+ ],
63
+ "postBuild": [
64
+ "test"
65
+ ]
66
+ }
67
+ },
68
+ "falconPackageHash": "6b12967d51ce9c6f91010418e950985808b8bce67fc93f175fe38836"
6
69
  }
@@ -0,0 +1,60 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ const READY = /origami-site-express ready (\S+)/
5
+
6
+ /** The bin of the adapter the site depends on, run with Node directly: the same command `yarn dev` runs. */
7
+ const binPath = root => join(dirname(createRequire(join(root, 'package.json')).resolve('@wix/origami-site-express/package.json')), 'bin/origami-site-express.js')
8
+
9
+ /** @param {'dev' | 'build' | 'start'} command @param {string} root @param {Record<string, string>} env */
10
+ async function spawnBin(command, root, env) {
11
+ // Loaded here so the CLI can import the descriptor without child_process.
12
+ const { spawn } = await import('node:child_process')
13
+ const child = spawn(process.execPath, [binPath(root), command], {
14
+ cwd: root, stdio: ['ignore', 'pipe', 'pipe'],
15
+ env: { ...process.env, ...env, ...(command === 'start' ? { NODE_ENV: 'production' } : {}) },
16
+ })
17
+ let output = ''
18
+ child.stdout.on('data', chunk => { output += chunk })
19
+ child.stderr.on('data', chunk => { output += chunk })
20
+ /** @type {Promise<number | null>} */
21
+ const exited = new Promise(resolve => child.once('exit', code => resolve(code)))
22
+ return { child, exited, output: () => output }
23
+ }
24
+
25
+ /** @param {'dev' | 'start'} command @param {string} root @param {Record<string, string>} env @param {number} timeoutMs
26
+ * @returns {Promise<import('@wix/origami-site/types').RunningSite>} */
27
+ async function run(command, root, env, timeoutMs) {
28
+ const { child, exited, output } = await spawnBin(command, root, env)
29
+ const url = await new Promise((resolve, reject) => {
30
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`origami-site-express ${command} was not ready after ${timeoutMs} ms\n${output()}`)) }, timeoutMs)
31
+ const onData = () => {
32
+ const ready = READY.exec(output())
33
+ if (ready) { clearTimeout(timer); child.stdout.off('data', onData); resolve(new URL(ready[1])) }
34
+ }
35
+ child.stdout.on('data', onData)
36
+ exited.then(code => { clearTimeout(timer); reject(new Error(`origami-site-express ${command} exited with ${code}\n${output()}`)) })
37
+ })
38
+ return {
39
+ url,
40
+ logs: output,
41
+ restarts: () => output().split('\n').filter(line => line.includes('origami-site-express restarted')).length,
42
+ async stop() {
43
+ if (child.exitCode !== null || child.signalCode !== null) return
44
+ const kill = setTimeout(() => child.kill('SIGKILL'), 5_000)
45
+ child.kill('SIGTERM')
46
+ await exited
47
+ clearTimeout(kill)
48
+ },
49
+ }
50
+ }
51
+
52
+ /** @type {import('@wix/origami-site/types').ConformanceDriver} */
53
+ export const driver = {
54
+ dev: (root, env) => run('dev', root, env, 30_000),
55
+ async build(root, env) {
56
+ const { exited, output } = await spawnBin('build', root, env)
57
+ return (await exited) === 0 ? { ok: true, output: output() } : { ok: false, output: output() }
58
+ },
59
+ start: (root, env) => run('start', root, env, 15_000),
60
+ }
@@ -0,0 +1,4 @@
1
+ import type { OrigamiAdapter } from '@wix/origami-site/types'
2
+ /** The adapter descriptor the CLI and the conformance suite load from `@wix/origami-site-express/adapter`. */
3
+ declare const adapter: Omit<OrigamiAdapter, 'project'>
4
+ export default adapter
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { driver } from './driver.js'
3
+
4
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
5
+
6
+ /** @type {Omit<import('@wix/origami-site/types').OrigamiAdapter, 'project'>} */
7
+ export default {
8
+ name: pkg.name,
9
+ version: pkg.version,
10
+ framework: { name: 'express', range: pkg.dependencies.express },
11
+ conformance: driver,
12
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type CommandOptions = { root: string }
2
+ /** `origami-site-express dev`: Vite in middleware mode + Express + the core, live. */
3
+ export function dev(options: CommandOptions): Promise<void>
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { dev } from './server/dev.js'
@@ -0,0 +1,29 @@
1
+ /** Express terminal middleware: Node req → Web Request → site.handle() → Web Response → Node res.
2
+ * @param {{
3
+ * getSite: () => Promise<import('@wix/origami-site/types').OrigamiSite>,
4
+ * renderPage: (resolution: Extract<import('@wix/origami-site/types').Resolution, { kind: 'page' }>, request: Request) => Promise<string>,
5
+ * errorResponse: typeof import('@wix/origami-site/server').errorResponse,
6
+ * node: typeof import('@wix/origami-site/node'),
7
+ * mode: 'dev' | 'prod',
8
+ * }} options
9
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} */
10
+ export function catchAll({ getSite, renderPage, errorResponse, node, mode }) {
11
+ return async (req, res) => {
12
+ const head = req.method === 'HEAD'
13
+ try {
14
+ const request = node.toWebRequest(req, res)
15
+ const resolution = await (await getSite()).handle(request)
16
+ let response
17
+ if (resolution.kind === 'page') {
18
+ const headers = new Headers(resolution.headers)
19
+ if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8')
20
+ response = new Response(await renderPage(resolution, request), { status: resolution.status, headers })
21
+ } else response = resolution.response
22
+ await node.sendWebResponse(res, response, { head })
23
+ } catch (error) {
24
+ // Part of the response is already out, or a failed body already closed it: a 500 can't follow.
25
+ if (res.headersSent || res.destroyed) res.destroy()
26
+ else await node.sendWebResponse(res, errorResponse(error, { mode }), { head })
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /** The site's own copy of the core, never this package's: under pnpm the two differ.
6
+ * @param {string} root @param {'server' | 'node' | 'build'} entry */
7
+ export function coreEntry(root, entry) {
8
+ return createRequire(join(root, 'package.json')).resolve(`@wix/origami-site/${entry}`)
9
+ }
10
+
11
+ /** @param {string} root @returns {Promise<typeof import('@wix/origami-site/server')>} */
12
+ export const importCoreServer = root => import(pathToFileURL(coreEntry(root, 'server')).href)
13
+ /** @param {string} root @returns {Promise<typeof import('@wix/origami-site/node')>} */
14
+ export const importCoreNode = root => import(pathToFileURL(coreEntry(root, 'node')).href)
15
+ /** @param {string} root @returns {Promise<typeof import('@wix/origami-site/build')>} */
16
+ export const importCoreBuild = root => import(pathToFileURL(coreEntry(root, 'build')).href)
@@ -0,0 +1,49 @@
1
+ /** @typedef {import('@wix/origami-site/types').OrigamiSite} OrigamiSite */
2
+ /** @typedef {Awaited<ReturnType<OrigamiSite['table']>>} SiteRouteTable */
3
+
4
+ /** The dev site, re-created whenever the core module is re-evaluated. `loadCore` imports the core through
5
+ * Vite's module runner: a linked core (outside node_modules) comes back as a new namespace object after an
6
+ * edit; an installed one loads once. A failed createSite is retried by the next request.
7
+ * @param {{ loadCore: () => Promise<any>, create: (core: any) => Promise<OrigamiSite> }} options
8
+ * @returns {{ currentSite: () => Promise<OrigamiSite>, site: () => OrigamiSite | undefined,
9
+ * siteTree: { table: () => ReturnType<OrigamiSite['table']>, onTableChange: OrigamiSite['onTableChange'] } }} */
10
+ export function createCurrentSite({ loadCore, create }) {
11
+ /** @type {{ core?: unknown, site?: OrigamiSite }} */
12
+ let current = {}
13
+ /** @type {{ core: unknown, site: Promise<OrigamiSite> } | undefined} */
14
+ let creating
15
+ /** @type {Set<(table: SiteRouteTable) => void>} */
16
+ const listeners = new Set()
17
+ /** @type {(() => void) | undefined} */
18
+ let unsubscribe
19
+ async function currentSite() {
20
+ const core = await loadCore()
21
+ if (core === current.core && current.site) return current.site
22
+ // A request that already sees a newer core waits for the older creation, then makes its own.
23
+ if (creating && creating.core !== core) { await creating.site.catch(() => {}); return currentSite() }
24
+ if (!creating) {
25
+ const site = (async () => {
26
+ await current.site?.close()
27
+ current = {}
28
+ const created = await create(core)
29
+ current = { core, site: created }
30
+ unsubscribe?.()
31
+ unsubscribe = created.onTableChange(table => { for (const listener of listeners) listener(table) })
32
+ return created
33
+ })().finally(() => { creating = undefined })
34
+ creating = { core, site }
35
+ }
36
+ return creating.site
37
+ }
38
+ return {
39
+ currentSite,
40
+ /** The site already created, if any: for watcher events, which must not create one. */
41
+ site: () => current.site,
42
+ /** What origamiVitePlugins reads. It was given once, at Vite startup, so it follows each new site. */
43
+ siteTree: {
44
+ table: async () => (await currentSite()).table(),
45
+ /** @param {(table: SiteRouteTable) => void} listener */
46
+ onTableChange(listener) { listeners.add(listener); return () => { listeners.delete(listener) } },
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,51 @@
1
+ import { createServer as createHttpServer } from 'node:http'
2
+ import express from 'express'
3
+ import { createServer as createViteServer, isRunnableDevEnvironment, loadEnv } from 'vite'
4
+ import react from '@vitejs/plugin-react'
5
+ import { coreEntry, importCoreBuild, importCoreNode, importCoreServer } from './core.js'
6
+ import { catchAll } from './bridge.js'
7
+ import { pageHtml } from './shell.js'
8
+ import { host, port, siteOptions } from './env.js'
9
+ import { serve } from './lifecycle.js'
10
+ import { createCurrentSite } from './currentSite.js'
11
+
12
+ /** `origami-site-express dev`: Vite in middleware mode, Express, and the core in dev mode, all live.
13
+ * @param {{ root: string }} options */
14
+ export async function dev({ root }) {
15
+ const env = loadEnv('development', root, ['VITE_', 'ORIGAMI_'])
16
+ const { errorResponse, serializePageData } = await importCoreServer(root)
17
+ const { origamiVitePlugins } = await importCoreBuild(root)
18
+ const node = await importCoreNode(root)
19
+ const serverEntry = coreEntry(root, 'server')
20
+ const { currentSite, site, siteTree } = createCurrentSite({
21
+ loadCore: () => isRunnableDevEnvironment(vite.environments.ssr) ? vite.environments.ssr.runner.import(serverEntry) : vite.ssrLoadModule(serverEntry),
22
+ create: core => core.createSite({ root, mode: 'dev', ...siteOptions(env) }),
23
+ })
24
+ const httpServer = createHttpServer()
25
+ const vite = await createViteServer({
26
+ root, configFile: false, appType: 'custom', mode: 'development',
27
+ // HMR on the site's own port: without it Vite opens a second port, and two sites would collide.
28
+ server: { middlewareMode: true, ws: { server: httpServer } },
29
+ // React after ours: origami:jsx-checks is enforce 'pre', so our Babel checks see the source first.
30
+ plugins: [...origamiVitePlugins({ root, siteTree }), react()],
31
+ // No index.html lists the pages, so Vite can't scan them up front. Without this, the first visit to a lazily
32
+ // loaded page finds new dependencies, re-optimizes them and reloads, and the page briefly runs two Reacts.
33
+ optimizeDeps: { entries: ['src/site/_generated/client.jsx', 'src/pages/**/_generated/index.jsx'] },
34
+ // The core's CommonJS dependencies go to Node. Linked (as in this repo), Vite would inline them as ESM,
35
+ // where `exports` is not defined. Add any CommonJS package the core starts importing.
36
+ ssr: { external: ['@wix/origami-types'] },
37
+ })
38
+ vite.watcher.on('all', (_event, path) => site()?.fileChanged(path))
39
+ const app = express()
40
+ app.disable('x-powered-by')
41
+ app.use(vite.middlewares)
42
+ app.use(catchAll({
43
+ mode: 'dev', errorResponse, node, getSite: currentSite,
44
+ renderPage: (resolution, request) => vite.transformIndexHtml(new URL(request.url).pathname, pageHtml(resolution, {
45
+ serializePageData,
46
+ body: '<script type="module" src="/src/site/_generated/client.jsx"></script>',
47
+ })),
48
+ }))
49
+ httpServer.on('request', app)
50
+ await serve(httpServer, { port: port(), host: host('dev'), onClose: async () => { await vite.close(); await site()?.close() } })
51
+ }
@@ -0,0 +1,15 @@
1
+ /** The createSite options that come from the environment.
2
+ * @param {Record<string, string | undefined>} env */
3
+ export function siteOptions(env) {
4
+ return {
5
+ wix: { clientId: env.VITE_WIX_CLIENT_ID ?? '', siteId: env.VITE_WIX_SITE_ID ?? '' },
6
+ ...(env.ORIGAMI_WIX_API_BASE ? { wixApiBaseUrl: env.ORIGAMI_WIX_API_BASE } : {}),
7
+ }
8
+ }
9
+
10
+ export const port = () => Number(process.env.PORT ?? 3000)
11
+
12
+ /** Dev serves the site's source through Vite, so it stays on this machine unless HOST says otherwise.
13
+ * 127.0.0.1, not localhost: on macOS localhost binds only ::1. Start listens on every interface, for Docker.
14
+ * @param {'dev' | 'prod'} mode */
15
+ export const host = mode => process.env.HOST || (mode === 'dev' ? '127.0.0.1' : undefined)
@@ -0,0 +1,24 @@
1
+ /** Listens, prints the one ready line, and shuts down on SIGTERM/SIGINT. No host means every interface.
2
+ * @param {import('node:http').Server} server @param {{ port: number, host?: string | undefined, onClose: () => Promise<unknown> }} options */
3
+ export async function serve(server, { port, host, onClose }) {
4
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => resolve(undefined)) })
5
+ const address = /** @type {import('node:net').AddressInfo} */ (server.address())
6
+ // The URL a client can open: the bound address, or localhost when listening on every interface.
7
+ const shown = !host || address.address === '::' || address.address === '0.0.0.0' ? 'localhost'
8
+ : address.family === 'IPv6' ? `[${address.address}]` : address.address
9
+ console.log(`origami-site-express ready http://${shown}:${address.port}`)
10
+ let stopping = false
11
+ const stop = async () => {
12
+ if (stopping) return
13
+ stopping = true
14
+ const closed = new Promise(resolve => server.close(resolve))
15
+ server.closeIdleConnections()
16
+ const force = setTimeout(() => server.closeAllConnections(), 10_000).unref()
17
+ try { await onClose() } catch (error) { console.error('[origami]', error) }
18
+ await closed
19
+ clearTimeout(force)
20
+ process.exit(0)
21
+ }
22
+ process.once('SIGTERM', stop)
23
+ process.once('SIGINT', stop)
24
+ }
@@ -0,0 +1,23 @@
1
+ const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
2
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
3
+ })[char])
4
+
5
+ /** The HTML shell for a page Resolution, the same in dev and prod; only the asset tags differ.
6
+ * @param {Extract<import('@wix/origami-site/types').Resolution, { kind: 'page' }>} resolution
7
+ * @param {{ serializePageData: (data: import('@wix/origami-site/types').PageData) => string, head?: string, body: string }} parts */
8
+ export function pageHtml({ page, data }, { serializePageData, head = '', body }) {
9
+ return `<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14
+ <title>${escapeHtml(page.title)}</title>
15
+ ${head}
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ <script type="application/json" id="origami-page">${serializePageData(data)}</script>
20
+ ${body}
21
+ </body>
22
+ </html>`
23
+ }