@ossy/app 1.11.5 → 1.11.7

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
@@ -21,11 +21,9 @@ export const metadata = { path: { en: '/about', sv: '/om' } }
21
21
  export default () => <h1>About</h1>
22
22
  ```
23
23
 
24
- For a single-file setup, use `src/pages.jsx` (legacy).
24
+ During `dev` / `build`, the tooling writes **JSON manifests** under **`build/.ossy/`**: **`pages.generated.json`** (after compile: route ids, paths, `sourceFile`, merged `metadata`, and **`module`** — a `page-modules/<id>.mjs` path the **Node** server `import()`s per request for SSR), **`pages.bundle.json`** (compiled module index), plus the same pattern for API and tasks. **`pages.runtime.mjs`** exports the route table from **`pages.generated.json`** only.
25
25
 
26
- During `dev` / `build`, the tooling writes **JSON manifests** under **`build/.ossy/`**: **`pages.generated.json`** (route ids, default paths, and `sourceFile` paths), **`pages.bundle.json`** (compiled `page-modules/<id>.mjs` paths), plus the same pattern for API and tasks (`*.generated.json` / `*.bundle.json`). Small **`*.runtime.mjs`** loaders (copied from `@ossy/app`) dynamically `import()` those compiled modules at runtime.
27
-
28
- **Client JS (per-page):** For each `*.page.jsx`, the build emits **`build/.ossy/hydrate-<pageId>.jsx`** → **`public/static/hydrate-<pageId>.js`**. The HTML for a request only loads the hydrate script for the **current** route (full document navigation), so other pages’ components are not part of that entry. React and shared dependencies still go into hashed shared chunks. The inline config (`window.__INITIAL_APP_CONFIG__`) no longer includes the full `pages` list—only request-time fields (theme, `apiUrl`, etc.).
26
+ **Client JS (per-page):** For each `*.page.jsx`, the build emits **`build/.ossy/hydrate-<pageId>.jsx`** **`public/static/<pageId>.js`**. Rollup bundles that entry with the page source so **`react` resolves in the browser** (unlike the Node `page-modules/*.mjs` chunks, which keep `react` external). The HTML only loads the hydrate script for the **current** route. The inline config (`window.__INITIAL_APP_CONFIG__`) keeps request-time fields (theme, `apiUrl`, etc.); `pages` in config include `id`, `path`, and `module` for consistency with the manifest.
29
27
 
30
28
  Add `src/config.js` for workspace and theme:
31
29
 
package/cli/build.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
  import url from 'url';
3
3
  import fs from 'fs';
4
+ import { pathToFileURL } from 'node:url'
4
5
  import { rollup } from 'rollup';
5
6
  import babel from '@rollup/plugin-babel';
6
7
  import { nodeResolve as resolveDependencies } from '@rollup/plugin-node-resolve'
@@ -67,6 +68,7 @@ export const OSSY_API_RUNTIME_BASENAME = 'api.runtime.mjs'
67
68
  export const OSSY_TASKS_RUNTIME_BASENAME = 'tasks.runtime.mjs'
68
69
 
69
70
  export const OSSY_PAGE_MODULES_DIRNAME = 'page-modules'
71
+
70
72
  /** Tiny Rollup inputs that re-export `metadata` so per-page server bundles keep i18n paths. */
71
73
  export const OSSY_PAGE_SERVER_ENTRIES_DIRNAME = 'page-server-entries'
72
74
  export const OSSY_API_MODULES_DIRNAME = 'api-modules'
@@ -385,7 +387,8 @@ export async function compilePageServerModules ({
385
387
  for (const f of pageFiles) {
386
388
  const pageId = clientHydrateIdForPage(f, srcDir)
387
389
  const safeId = String(pageId).replace(/[^a-zA-Z0-9_-]+/g, '-') || 'page'
388
- const outName = `${safeId}.mjs`
390
+ const relPath = pageServerModuleRelPath(f, srcDir)
391
+ const outName = path.posix.basename(relPath)
389
392
  const outFile = path.join(modsDir, outName)
390
393
  const stubPath = path.join(entriesDir, `${safeId}.mjs`)
391
394
  writePageServerRollupEntry({ pageAbsPath: f, stubPath })
@@ -398,7 +401,7 @@ export async function compilePageServerModules ({
398
401
  })
399
402
  bundlePages.push({
400
403
  id: pageId,
401
- module: `${OSSY_PAGE_MODULES_DIRNAME}/${outName}`,
404
+ module: relPath,
402
405
  })
403
406
  }
404
407
  return bundlePages
@@ -448,6 +451,50 @@ export async function compileTaskServerModules ({ taskFiles, ossyDir, nodeEnv, o
448
451
  return modules
449
452
  }
450
453
 
454
+ /**
455
+ * Merges compiled `metadata` + `module` into `pages.generated.json` (same order as `pageBundleList`).
456
+ * Writes `module` on each route for Node SSR (`import()` of `page-modules/*.mjs`); hydrate uses a separate Rollup client entry.
457
+ */
458
+ export async function enrichPagesGeneratedManifest ({
459
+ ossyDir,
460
+ pagesGeneratedPath,
461
+ pageBundleList,
462
+ }) {
463
+ if (!pageBundleList?.length || !fs.existsSync(pagesGeneratedPath)) return
464
+ const raw = JSON.parse(fs.readFileSync(pagesGeneratedPath, 'utf8'))
465
+ const basePages = raw?.pages
466
+ if (!Array.isArray(basePages) || basePages.length !== pageBundleList.length) {
467
+ throw new Error(
468
+ '[@ossy/app] pages.generated.json page count must match compiled page modules (re-run build).'
469
+ )
470
+ }
471
+ const pages = []
472
+ for (let i = 0; i < basePages.length; i++) {
473
+ const abs = path.join(ossyDir, pageBundleList[i].module)
474
+ const mod = await import(pathToFileURL(abs).href)
475
+ const meta = mod?.metadata && typeof mod.metadata === 'object' ? mod.metadata : {}
476
+ const derived = { id: basePages[i].id, path: basePages[i].path }
477
+ const merged = {
478
+ ...derived,
479
+ ...meta,
480
+ sourceFile: basePages[i].sourceFile,
481
+ module: pageBundleList[i].module,
482
+ }
483
+ try {
484
+ JSON.stringify(merged)
485
+ } catch {
486
+ pages.push({
487
+ ...derived,
488
+ sourceFile: basePages[i].sourceFile,
489
+ module: pageBundleList[i].module,
490
+ })
491
+ continue
492
+ }
493
+ pages.push(merged)
494
+ }
495
+ writeOssyJson(pagesGeneratedPath, { version: raw.version ?? 1, pages })
496
+ }
497
+
451
498
  /**
452
499
  * Writes `pages.bundle.json`, `api.bundle.json`, `tasks.bundle.json` by Rollup-compiling each source module.
453
500
  */
@@ -477,6 +524,11 @@ export async function compileOssyNodeArtifacts ({
477
524
  version: 1,
478
525
  modules: taskModuleList,
479
526
  })
527
+ await enrichPagesGeneratedManifest({
528
+ ossyDir,
529
+ pagesGeneratedPath: path.join(ossyDir, OSSY_GEN_PAGES_BASENAME),
530
+ pageBundleList,
531
+ })
480
532
  }
481
533
 
482
534
  export function filePathToRoute(filePath, srcDir) {
@@ -489,7 +541,7 @@ export function filePathToRoute(filePath, srcDir) {
489
541
  }
490
542
 
491
543
  /**
492
- * Basename for `/static/hydrate-<id>.js` must match `route.id` after `metadata` is merged in `toPage`
544
+ * Basename for `/static/<id>.js` (per-page client bundle) must match `route.id` after `metadata` is merged in `toPage`
493
545
  * (`{ ...derived, ...metadata }`). Uses a light `metadata` scan when possible.
494
546
  */
495
547
  export function clientHydrateIdForPage (pageAbsPath, srcDir) {
@@ -507,6 +559,13 @@ export function clientHydrateIdForPage (pageAbsPath, srcDir) {
507
559
  return idMatch ? idMatch[1] : derived.id
508
560
  }
509
561
 
562
+ /** Posix path relative to `build/.ossy/` for the compiled **Node** page module (SSR). */
563
+ export function pageServerModuleRelPath (pageAbsPath, srcDir) {
564
+ const pageId = clientHydrateIdForPage(pageAbsPath, srcDir)
565
+ const safeId = String(pageId).replace(/[^a-zA-Z0-9_-]+/g, '-') || 'page'
566
+ return `${OSSY_PAGE_MODULES_DIRNAME}/${safeId}.mjs`
567
+ }
568
+
510
569
  export function pageSourceExportsMetadata (pageAbsPath) {
511
570
  try {
512
571
  const src = fs.readFileSync(pageAbsPath, 'utf8')
@@ -539,36 +598,25 @@ export function writePageServerRollupEntry ({ pageAbsPath, stubPath }) {
539
598
  }
540
599
 
541
600
  /**
542
- * One client entry per page: imports only that page module and hydrates the document.
543
- * Keeps the same `toPage` shape as `pages.runtime.mjs` + manifests so SSR and client trees match.
601
+ * One client entry per page: Rollup bundles this stub with the page source so `react` resolves in the browser.
602
+ * (Do not `import()` the Node `page-modules/*.mjs` build from the client those files use bare `react` specifiers.)
544
603
  */
545
604
  export function generatePageHydrateModule ({ pageAbsPath, stubAbsPath, srcDir }) {
546
605
  const rel = relToGeneratedImport(stubAbsPath, pageAbsPath)
547
- const { id, path: routePath } = filePathToRoute(pageAbsPath, srcDir)
548
- const pathLiteral = JSON.stringify(routePath)
549
- const idLiteral = JSON.stringify(id)
550
606
  return [
551
607
  '// Generated by @ossy/app — do not edit',
552
608
  '',
553
- "import React, { cloneElement } from 'react'",
609
+ "import React, { createElement } from 'react'",
554
610
  "import 'react-dom'",
555
611
  "import { hydrateRoot } from 'react-dom/client'",
556
612
  `import * as _page from './${rel}'`,
557
613
  '',
558
- 'function toPage(mod, derived) {',
559
- ' const meta = mod?.metadata || {}',
560
- ' const def = mod?.default',
561
- ' if (typeof def === \'function\') {',
562
- ' return { ...derived, ...meta, element: React.createElement(def) }',
563
- ' }',
564
- ' return { ...derived, ...meta, ...(def || {}) }',
565
- '}',
566
- '',
567
- `const _route = toPage(_page, { id: ${idLiteral}, path: ${pathLiteral} })`,
568
614
  'const initialConfig = window.__INITIAL_APP_CONFIG__ || {}',
569
- 'const rootTree = _route?.element',
570
- ' ? cloneElement(_route.element, initialConfig)',
571
- " : React.createElement('p', null, 'Not found')",
615
+ 'const Page = _page?.default',
616
+ 'if (typeof Page !== \'function\') {',
617
+ ' throw new Error(`[@ossy/app] Page must export default as a function component`)',
618
+ '}',
619
+ 'const rootTree = createElement(Page, initialConfig)',
572
620
  'hydrateRoot(document, rootTree)',
573
621
  '',
574
622
  ].join('\n')
@@ -646,33 +694,30 @@ export function parsePagesFromManifestJson (manifestPath) {
646
694
  const data = JSON.parse(raw)
647
695
  const pages = data?.pages
648
696
  if (!Array.isArray(pages)) return []
649
- return pages.map((p) => ({ id: p.id, path: p.path }))
697
+ return pages.map((p) => ({
698
+ id: p.id,
699
+ path: p.path,
700
+ ...(typeof p.module === 'string' ? { module: p.module } : {}),
701
+ }))
650
702
  } catch {
651
703
  return []
652
704
  }
653
705
  }
654
706
 
655
- export function parsePagesFromSource(filePath) {
707
+ /**
708
+ * Best-effort scan of a source file for `{ id, path }` literals (e.g. `*.api.js` default export).
709
+ * Used only for the build dashboard API list — **not** for page discovery (`*.page.jsx` only).
710
+ */
711
+ export function parseIdPathPairsFromFile (filePath) {
656
712
  try {
657
713
  const content = fs.readFileSync(filePath, 'utf8')
658
714
  const items = []
659
- // Match { id: 'x', path: '/y' } or { id: "x", path: "/y" }
660
715
  const idPathPattern = /\{\s*id\s*:\s*['"]([^'"]*)['"]\s*,\s*path\s*:\s*['"]([^'"]*)['"]/g
661
- // Match { path: '/y', element: ... } (path-first)
662
- const pathElementPattern = /\{\s*path\s*:\s*['"]([^'"]*)['"]\s*,\s*element\s*:/g
663
- // Match { path: { en: '/x', sv: '/y' }, ... }
664
716
  const pathObjPattern = /\{\s*path\s*:\s*\{\s*([^}]+)\}/g
665
717
  let m
666
718
  while ((m = idPathPattern.exec(content)) !== null) {
667
719
  items.push({ id: m[1], path: m[2] })
668
720
  }
669
- if (items.length === 0) {
670
- while ((m = pathElementPattern.exec(content)) !== null) {
671
- const p = m[1]
672
- const id = p === '/' ? 'home' : p.replace(/^\//, '').replace(/\/$/, '').replace(/\//g, '-') || 'page'
673
- items.push({ id, path: p })
674
- }
675
- }
676
721
  if (items.length === 0) {
677
722
  while ((m = pathObjPattern.exec(content)) !== null) {
678
723
  const pathStr = m[1].replace(/\s/g, '').replace(/:/g, ': ')
@@ -707,7 +752,7 @@ export function getBuildOverviewSnapshot ({
707
752
 
708
753
  const apiRoutes = []
709
754
  for (const f of apiOverviewFiles) {
710
- if (fs.existsSync(f)) apiRoutes.push(...parsePagesFromSource(f))
755
+ if (fs.existsSync(f)) apiRoutes.push(...parseIdPathPairsFromFile(f))
711
756
  }
712
757
 
713
758
  return { configRel, pages, apiRoutes }
package/cli/dev.js CHANGED
@@ -132,7 +132,7 @@ export const dev = async (cliArgs) => {
132
132
  entryFileNames ({ name }) {
133
133
  if (name.startsWith('hydrate__')) {
134
134
  const pageId = name.slice('hydrate__'.length)
135
- return `static/hydrate-${pageId}.js`
135
+ return `static/${pageId}.js`
136
136
  }
137
137
  return 'static/[name].js'
138
138
  },
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { fileURLToPath, pathToFileURL } from 'node:url'
4
- import React from 'react'
3
+ import { fileURLToPath } from 'node:url'
5
4
 
6
5
  const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
7
6
 
@@ -9,31 +8,7 @@ function readJson (name) {
9
8
  return JSON.parse(fs.readFileSync(path.join(__ossyDir, name), 'utf8'))
10
9
  }
11
10
 
12
- function toPage (mod, derived) {
13
- const meta = mod?.metadata || {}
14
- const def = mod?.default
15
- if (typeof def === 'function') {
16
- return { ...derived, ...meta, element: React.createElement(def) }
17
- }
18
- return { ...derived, ...meta, ...(def || {}) }
19
- }
20
-
21
- const { pages: metaPages } = readJson('pages.generated.json')
22
- const { pages: bundlePages } = readJson('pages.bundle.json')
23
-
24
- if (metaPages.length !== bundlePages.length) {
25
- throw new Error(
26
- '[@ossy/app][pages.runtime] pages.generated.json and pages.bundle.json must list the same number of pages'
27
- )
28
- }
29
-
30
- const out = []
31
- for (let i = 0; i < metaPages.length; i++) {
32
- const derived = { id: metaPages[i].id, path: metaPages[i].path }
33
- const rel = bundlePages[i].module
34
- const abs = path.resolve(__ossyDir, rel)
35
- const mod = await import(pathToFileURL(abs).href)
36
- out.push(toPage(mod, derived))
37
- }
11
+ /** Route list from `pages.generated.json` (includes `module` for lazy `import()` after build). */
12
+ const { pages } = readJson('pages.generated.json')
38
13
 
39
- export default out
14
+ export default Array.isArray(pages) ? pages : []
@@ -55,7 +55,7 @@ async function bundleOneHydratePage ({
55
55
  const n = chunkInfo.name
56
56
  if (n.startsWith('hydrate__')) {
57
57
  const pageId = n.slice('hydrate__'.length)
58
- return `static/hydrate-${pageId}.js`
58
+ return `static/${pageId}.js`
59
59
  }
60
60
  return 'static/[name].js'
61
61
  },
@@ -157,7 +157,7 @@ async function prerenderPagesParallel ({
157
157
 
158
158
  const routesToRender = []
159
159
  for (const route of pageList) {
160
- if (!route?.element) continue
160
+ if (typeof route?.module !== 'string' || !route.module) continue
161
161
  if (!pathIsPrerenderable(route.path)) {
162
162
  reporter?.skipPrerender?.(
163
163
  route.id,
@@ -1,8 +1,29 @@
1
- import React, { cloneElement } from 'react'
1
+ import path from 'node:path'
2
+ import { fileURLToPath, pathToFileURL } from 'node:url'
3
+ import React, { createElement } from 'react'
2
4
  import { prerenderToNodeStream } from 'react-dom/static'
3
5
 
6
+ const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ async function loadPageDefaultExport (route) {
9
+ if (typeof route?.module !== 'string' || !route.module) {
10
+ throw new Error(
11
+ `[@ossy/app][BuildPage] Route "${route?.id ?? '?'}" has no compiled module path (re-run build so pages.generated.json includes "module").`
12
+ )
13
+ }
14
+ const abs = path.join(__ossyDir, route.module)
15
+ const mod = await import(pathToFileURL(abs).href)
16
+ const def = mod?.default
17
+ if (typeof def !== 'function') {
18
+ throw new Error(
19
+ `[@ossy/app][BuildPage] Page "${route?.id}" must export default as a function component (got ${typeof def}).`
20
+ )
21
+ }
22
+ return def
23
+ }
24
+
4
25
  /**
5
- * App shell config for SSR / prerender (mirrors client: theme, pages metadata, active route element).
26
+ * App shell config for SSR / prerender (mirrors client: theme, pages metadata, props for the active page).
6
27
  */
7
28
  export function buildPrerenderAppConfig ({
8
29
  buildTimeConfig,
@@ -11,10 +32,11 @@ export function buildPrerenderAppConfig ({
11
32
  urlPath,
12
33
  isAuthenticated = false,
13
34
  }) {
14
- /** Never attach `element` here it cannot round-trip through `JSON.stringify` in the hydrate bootstrap. */
35
+ /** `module` is the compiled page path under `.ossy/` (Node `import()` only; not loaded as raw ESM in the browser). */
15
36
  const pages = pageList.map((page) => ({
16
37
  id: page?.id,
17
38
  path: page?.path,
39
+ ...(typeof page?.module === 'string' ? { module: page.module } : {}),
18
40
  }))
19
41
  return {
20
42
  ...buildTimeConfig,
@@ -28,11 +50,15 @@ export function buildPrerenderAppConfig ({
28
50
  }
29
51
  }
30
52
 
31
- /** Strips non-JSON content (e.g. React elements on `pages`) for the bootstrap script. */
53
+ /** Strips non-JSON content for the bootstrap script; keeps serializable route fields including `module`. */
32
54
  export function appConfigForBootstrap (appConfig) {
33
55
  if (!appConfig || typeof appConfig !== 'object') return appConfig
34
56
  const pages = Array.isArray(appConfig.pages)
35
- ? appConfig.pages.map(({ id, path }) => ({ id, path }))
57
+ ? appConfig.pages.map(({ id, path, module }) => ({
58
+ id,
59
+ path,
60
+ ...(typeof module === 'string' ? { module } : {}),
61
+ }))
36
62
  : appConfig.pages
37
63
  return { ...appConfig, pages }
38
64
  }
@@ -65,12 +91,13 @@ export function buildHydrationAppConfig (appConfig) {
65
91
  export const BuildPage = {
66
92
  async handle ({ route, appConfig, isDevReloadEnabled }) {
67
93
  const hydrationConfig = buildHydrationAppConfig(appConfig)
68
- const rootElement = cloneElement(route.element, hydrationConfig)
94
+ const Page = await loadPageDefaultExport(route)
95
+ const rootElement = createElement(Page, hydrationConfig)
69
96
  const devReloadScript = isDevReloadEnabled
70
97
  ? `(function(){try{var es=new EventSource('/__ossy_reload');es.addEventListener('reload',function(){location.reload();});}catch(e){}})();`
71
98
  : ``
72
99
 
73
- const hydrateUrl = `/static/hydrate-${route.id}.js`
100
+ const hydrateUrl = `/static/${route.id}.js`
74
101
  const { prelude } = await prerenderToNodeStream(rootElement, {
75
102
  bootstrapScriptContent: `window.__INITIAL_APP_CONFIG__ = ${JSON.stringify(hydrationConfig)};${devReloadScript}`,
76
103
  bootstrapModules: [hydrateUrl],
package/cli/server.js CHANGED
@@ -155,7 +155,7 @@ app.all('*all', async (req, res) => {
155
155
  }
156
156
 
157
157
  const pageRoute = pageRouter.getPageByUrl(requestUrl)
158
- if (pageRoute?.element) {
158
+ if (pageRoute?.module) {
159
159
  const appConfig = buildPrerenderAppConfig({
160
160
  buildTimeConfig,
161
161
  pageList: sitePageList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.11.5",
3
+ "version": "1.11.7",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -27,14 +27,14 @@
27
27
  "@babel/eslint-parser": "^7.15.8",
28
28
  "@babel/preset-react": "^7.26.3",
29
29
  "@babel/register": "^7.25.9",
30
- "@ossy/connected-components": "^1.11.5",
31
- "@ossy/design-system": "^1.11.5",
32
- "@ossy/pages": "^1.11.5",
33
- "@ossy/router": "^1.11.5",
34
- "@ossy/router-react": "^1.11.5",
35
- "@ossy/sdk": "^1.11.5",
36
- "@ossy/sdk-react": "^1.11.5",
37
- "@ossy/themes": "^1.11.5",
30
+ "@ossy/connected-components": "^1.11.7",
31
+ "@ossy/design-system": "^1.11.7",
32
+ "@ossy/pages": "^1.11.7",
33
+ "@ossy/router": "^1.11.7",
34
+ "@ossy/router-react": "^1.11.7",
35
+ "@ossy/sdk": "^1.11.7",
36
+ "@ossy/sdk-react": "^1.11.7",
37
+ "@ossy/themes": "^1.11.7",
38
38
  "@rollup/plugin-alias": "^6.0.0",
39
39
  "@rollup/plugin-babel": "6.1.0",
40
40
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -67,5 +67,5 @@
67
67
  "README.md",
68
68
  "tsconfig.json"
69
69
  ],
70
- "gitHead": "9dcb9e0e0a09c23690f1a8e009fa79e2961fe6d6"
70
+ "gitHead": "43d1eca8557efc9586398a893ce9832b2ebf0444"
71
71
  }