@ossy/app 1.13.2 → 1.13.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/cli/build.js CHANGED
@@ -12,8 +12,6 @@ import nodeExternals from 'rollup-plugin-node-externals'
12
12
  import copy from 'rollup-plugin-copy';
13
13
  import replace from '@rollup/plugin-replace';
14
14
  import arg from 'arg'
15
- import prerenderReactTask from './prerender-react.task.js'
16
- import { createBuildDashboard } from './build-terminal.js'
17
15
 
18
16
  export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
19
17
  export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
@@ -23,15 +21,12 @@ const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.js$/
23
21
  /** Written next to `*.resource.js` under `src/resource-templates/` when that dir exists. */
24
22
  export const OSSY_RESOURCE_TEMPLATES_OUT = '.ossy-system-templates.generated.js'
25
23
 
26
- /** Rollup output paths when `output.dir` is `<build>/public` (see `entryFileNames` / `chunkFileNames`). */
27
- const BROWSER_STATIC_PREFIX = 'static/'
28
-
29
24
  export function minifyBrowserStaticChunks () {
30
25
  return {
31
26
  name: 'minify-browser-static-chunks',
32
27
  async renderChunk (code, chunk, outputOptions) {
33
28
  const fileName = chunk.fileName
34
- if (!fileName || !fileName.startsWith(BROWSER_STATIC_PREFIX)) {
29
+ if (!fileName || !fileName.startsWith('public/static/')) {
35
30
  return null
36
31
  }
37
32
  const useSourceMap =
@@ -87,19 +82,13 @@ export const OSSY_PAGE_SERVER_EXTERNAL = [
87
82
  'react/jsx-runtime',
88
83
  ]
89
84
 
90
- /** Output directory (relative to buildPath) for per-page SSR bundles. */
85
+ /** Output directory (relative to buildPath) for SSR bundle. */
91
86
  export const OSSY_SSR_DIRNAME = 'ssr'
92
- /** Temp stub entries for SSR bundles (inside .ossy/). */
93
- const OSSY_SSR_ENTRIES_DIRNAME = 'ssr-entries'
94
-
95
- /** Per-page client entries: `hydrate-<pageId>.jsx` under `.ossy/` */
96
- const HYDRATE_STUB_PREFIX = 'hydrate-'
97
- const HYDRATE_STUB_SUFFIX = '.jsx'
98
87
 
99
- /** Rollup input chunk name for a page id (safe identifier; id may contain `-`). */
100
- export function hydrateEntryName (pageId) {
101
- return `hydrate__${pageId}`
102
- }
88
+ /** Shared client hydrate entry filename under `.ossy/` */
89
+ const HYDRATE_ENTRY_FILENAME = 'hydrate-entry.jsx'
90
+ /** Shared SSR entry filename under `.ossy/` */
91
+ const SSR_ENTRY_FILENAME = 'ssr-entry.mjs'
103
92
 
104
93
  export function ossyGeneratedDir (buildPath) {
105
94
  return path.join(buildPath, OSSY_GEN_DIRNAME)
@@ -205,45 +194,12 @@ export function createOssyAppBundlePlugins ({ nodeEnv }) {
205
194
  }
206
195
 
207
196
  /**
208
- * Rollup plugins for browser hydrate bundles. React and all deps are bundled in so the output
209
- * is self-contained and requires no import maps or CDN.
210
- */
211
- export function createOssyClientRollupPlugins ({ nodeEnv, copyPublicFrom, buildPath }) {
212
- const plugins = [
213
- replace({
214
- preventAssignment: true,
215
- 'process.env.NODE_ENV': JSON.stringify(nodeEnv),
216
- }),
217
- json(),
218
- nodeExternals({
219
- deps: false,
220
- devDeps: true,
221
- peerDeps: false,
222
- packagePath: path.join(process.cwd(), 'package.json'),
223
- }),
224
- resolveCommonJsDependencies(),
225
- resolveDependencies({ preferBuiltins: false }),
226
- babel({
227
- babelHelpers: 'bundled',
228
- extensions: ['.jsx', '.tsx'],
229
- presets: [['@babel/preset-react', { runtime: 'automatic' }]],
230
- }),
231
- ]
232
- if (copyPublicFrom && fs.existsSync(copyPublicFrom)) {
233
- plugins.push(
234
- copy({
235
- targets: [{ src: `${copyPublicFrom}/**/*`, dest: path.join(buildPath, 'public') }],
236
- })
237
- )
238
- }
239
- return plugins
240
- }
241
-
242
- /**
243
- * Rollup plugins for per-page SSR bundles: server-side resolution, React and all deps bundled in
244
- * so the output is self-contained (no node_modules needed at runtime).
197
+ * Rollup plugins for the combined SSR + client bundle.
198
+ * `preferBuiltins: true` is correct for the SSR entry (uses `node:stream`); page component code
199
+ * does not import Node built-ins so this is safe for the browser entry as well.
200
+ * `copyPublicFrom` is handled separately before the Rollup call.
245
201
  */
246
- export function createOssySsrBundlePlugins ({ nodeEnv }) {
202
+ export function createCombinedBundlePlugins ({ nodeEnv }) {
247
203
  return [
248
204
  replace({
249
205
  preventAssignment: true,
@@ -266,93 +222,6 @@ export function createOssySsrBundlePlugins ({ nodeEnv }) {
266
222
  ]
267
223
  }
268
224
 
269
- /** Generates the SSR entry stub for a page: exports renderPage(props, options) and metadata. */
270
- export function generatePageSsrModule ({ pageAbsPath, stubAbsPath }) {
271
- const rel = relToGeneratedImport(stubAbsPath, pageAbsPath)
272
- return [
273
- '// Generated by @ossy/app — do not edit',
274
- '',
275
- "import { createElement } from 'react'",
276
- "import { renderToPipeableStream } from 'react-dom/server'",
277
- "import { Writable } from 'node:stream'",
278
- "import { App } from '@ossy/connected-components'",
279
- `import * as _page from './${rel}'`,
280
- '',
281
- 'export const metadata = _page.metadata',
282
- '',
283
- 'function PageShell (props) {',
284
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
285
- " createElement('head', null,",
286
- " createElement('meta', { charSet: 'utf-8' }),",
287
- " createElement('title', null, (_page.metadata && _page.metadata.title) || ''),",
288
- ' ),',
289
- ' createElement(App, props,',
290
- ' createElement(_page.default, props)',
291
- ' )',
292
- ' )',
293
- '}',
294
- '',
295
- 'export function renderPage (props, options = {}) {',
296
- ' return new Promise((resolve, reject) => {',
297
- " let html = ''",
298
- ' const writable = new Writable({',
299
- ' write (chunk, _enc, cb) { html += chunk.toString(); cb() },',
300
- ' })',
301
- ' const { pipe } = renderToPipeableStream(createElement(PageShell, props), {',
302
- ' ...options,',
303
- ' onAllReady () { pipe(writable) },',
304
- ' onError (err) { reject(err) },',
305
- ' })',
306
- " writable.on('finish', () => resolve(html))",
307
- ' })',
308
- '}',
309
- '',
310
- ].join('\n')
311
- }
312
-
313
- /** Writes `ssr-entries/<id>.mjs` stubs for each page into ossyDir. */
314
- export function writePageSsrStubs (pageFiles, srcDir, ossyDir) {
315
- const entriesDir = path.join(ossyDir, OSSY_SSR_ENTRIES_DIRNAME)
316
- fs.rmSync(entriesDir, { recursive: true, force: true })
317
- if (pageFiles.length === 0) return
318
- fs.mkdirSync(entriesDir, { recursive: true })
319
- for (const f of pageFiles) {
320
- const pageId = clientHydrateIdForPage(f, srcDir)
321
- const stubPath = path.join(entriesDir, `${pageId}.mjs`)
322
- fs.mkdirSync(path.dirname(stubPath), { recursive: true })
323
- fs.writeFileSync(stubPath, generatePageSsrModule({ pageAbsPath: f, stubAbsPath: stubPath }))
324
- }
325
- }
326
-
327
- /** Compiles per-page SSR bundles to `build/ssr/<id>.mjs`. Each is self-contained with React bundled in. */
328
- export async function compilePageSsrModules ({ pageFiles, srcDir, ossyDir, buildPath, nodeEnv, onWarn }) {
329
- const ssrDir = path.join(buildPath, OSSY_SSR_DIRNAME)
330
- const entriesDir = path.join(ossyDir, OSSY_SSR_ENTRIES_DIRNAME)
331
- fs.rmSync(ssrDir, { recursive: true, force: true })
332
- if (pageFiles.length === 0) return []
333
- fs.mkdirSync(ssrDir, { recursive: true })
334
- const plugins = createOssySsrBundlePlugins({ nodeEnv })
335
- const results = await Promise.all(
336
- pageFiles.map(async (f) => {
337
- const pageId = clientHydrateIdForPage(f, srcDir)
338
- const stubPath = path.join(entriesDir, `${pageId}.mjs`)
339
- const outFile = path.join(ssrDir, `${pageId}.mjs`)
340
- const bundle = await rollup({
341
- input: stubPath,
342
- plugins,
343
- onwarn (warning, defaultHandler) {
344
- if (onWarn) { onWarn(warning); return }
345
- defaultHandler(warning)
346
- },
347
- })
348
- await bundle.write({ file: outFile, format: 'esm', inlineDynamicImports: true })
349
- await bundle.close()
350
- return { id: pageId }
351
- })
352
- )
353
- return results
354
- }
355
-
356
225
  /** Bundles a single Node ESM file (inline dynamic imports) for SSR / API / tasks. */
357
226
  export async function bundleOssyNodeEntry ({ inputPath, outputFile, nodeEnv, onWarn, external }) {
358
227
  const bundle = await rollup({
@@ -573,73 +442,22 @@ export async function compileTaskServerModules ({ taskFiles, ossyDir, nodeEnv, o
573
442
  }
574
443
 
575
444
  /**
576
- * Merges compiled `metadata` + `module` into `pages.generated.json` (same order as `pageBundleList`).
577
- * Writes `module` on each route for Node SSR (`import()` of `page-modules/*.mjs`); hydrate uses a separate Rollup client entry.
578
- */
579
- /**
580
- * Enriches `pages.generated.json` with `module` paths pointing to SSR bundles and merges
581
- * any `metadata` exported by each page. Runs after SSR bundles are compiled.
582
- */
583
- export async function enrichPagesGeneratedManifest ({ ossyDir, pagesGeneratedPath }) {
584
- if (!fs.existsSync(pagesGeneratedPath)) return
585
- const basePages = JSON.parse(fs.readFileSync(pagesGeneratedPath, 'utf8'))
586
- if (!Array.isArray(basePages) || basePages.length === 0) return
587
-
588
- const pages = []
589
- for (const basePage of basePages) {
590
- const moduleRelPath = `../ssr/${basePage.id}.mjs`
591
- const bundleAbs = path.join(ossyDir, moduleRelPath)
592
- let meta = {}
593
- if (fs.existsSync(bundleAbs)) {
594
- try {
595
- const mod = await import(pathToFileURL(bundleAbs).href)
596
- meta = mod?.metadata && typeof mod.metadata === 'object' ? mod.metadata : {}
597
- } catch {
598
- // metadata unreadable — continue with defaults
599
- }
600
- }
601
- const merged = {
602
- id: basePage.id,
603
- path: basePage.path,
604
- ...meta,
605
- sourceFile: basePage.sourceFile,
606
- module: moduleRelPath,
607
- }
608
- try {
609
- JSON.stringify(merged)
610
- } catch {
611
- pages.push({ id: basePage.id, path: basePage.path, sourceFile: basePage.sourceFile, module: moduleRelPath })
612
- continue
613
- }
614
- pages.push(merged)
615
- }
616
- writeOssyJson(pagesGeneratedPath, pages)
617
- }
618
-
619
- /**
620
- * Compiles server-side artifacts: per-page SSR bundles, API modules, and task modules.
445
+ * Compiles server-side artifacts: API modules and task modules.
446
+ * SSR page bundles are now produced by the combined Rollup pass in compileCombinedBundle.
621
447
  */
622
448
  export async function compileOssyNodeArtifacts ({
623
- pageFiles,
624
- srcDir,
625
- ossyDir,
626
- buildPath,
627
449
  apiFiles,
628
450
  taskFiles,
451
+ ossyDir,
629
452
  nodeEnv,
630
453
  onWarn,
631
454
  }) {
632
- const [, apiRouteList, taskList] = await Promise.all([
633
- compilePageSsrModules({ pageFiles, srcDir, ossyDir, buildPath, nodeEnv, onWarn }),
455
+ const [apiRouteList, taskList] = await Promise.all([
634
456
  compileApiServerModules({ apiFiles, ossyDir, nodeEnv, onWarn }),
635
457
  compileTaskServerModules({ taskFiles, ossyDir, nodeEnv, onWarn }),
636
458
  ])
637
459
  writeOssyJson(path.join(ossyDir, OSSY_GEN_API_BASENAME), apiRouteList)
638
460
  writeOssyJson(path.join(ossyDir, OSSY_GEN_TASKS_BASENAME), taskList)
639
- await enrichPagesGeneratedManifest({
640
- ossyDir,
641
- pagesGeneratedPath: path.join(ossyDir, OSSY_GEN_PAGES_BASENAME),
642
- })
643
461
  }
644
462
 
645
463
  export function filePathToRoute(filePath, srcDir) {
@@ -670,6 +488,25 @@ export function clientHydrateIdForPage (pageAbsPath, srcDir) {
670
488
  return idMatch ? idMatch[1] : derived.id
671
489
  }
672
490
 
491
+ /**
492
+ * Like `clientHydrateIdForPage` but also extracts `path` from `metadata`.
493
+ * Used to embed the static page route map directly into the SSR entry.
494
+ */
495
+ export function pageRouteFromSource (pageAbsPath, srcDir) {
496
+ const derived = filePathToRoute(pageAbsPath, srcDir)
497
+ try {
498
+ const src = fs.readFileSync(pageAbsPath, 'utf8')
499
+ const metaIdx = src.indexOf('export const metadata')
500
+ if (metaIdx === -1) return derived
501
+ const after = src.slice(metaIdx)
502
+ const id = after.match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? derived.id
503
+ const strPath = after.match(/\bpath\s*:\s*['"]([^'"]+)['"]/)?.[1]
504
+ return { id, path: strPath ?? derived.path }
505
+ } catch {
506
+ return derived
507
+ }
508
+ }
509
+
673
510
  /** Posix path relative to `build/.ossy/` for the compiled **Node** page module (SSR). */
674
511
  export function pageServerModuleRelPath (pageAbsPath, srcDir) {
675
512
  const pageId = clientHydrateIdForPage(pageAbsPath, srcDir)
@@ -709,92 +546,213 @@ export function writePageServerRollupEntry ({ pageAbsPath, stubPath }) {
709
546
  }
710
547
 
711
548
  /**
712
- * One bundle per page: exports the component (for server SSR import) and auto-hydrates in the browser.
713
- * React is kept as a bare external import resolved from node_modules on the server and via
714
- * import map on the client.
549
+ * Generates a single shared hydrate entry that dynamically imports the active page at runtime.
550
+ * Rollup processes this once, emitting React and shared deps as reusable chunks instead of
551
+ * bundling them into every page separately.
715
552
  */
716
- export function generatePageHydrateModule ({ pageAbsPath, stubAbsPath, srcDir }) {
717
- const rel = relToGeneratedImport(stubAbsPath, pageAbsPath)
553
+ export function generateHydrateEntry ({ pageFiles, srcDir, stubAbsPath }) {
554
+ const seenIds = new Set()
555
+ const pageLines = []
556
+ for (const f of pageFiles) {
557
+ const hydrateId = clientHydrateIdForPage(f, srcDir)
558
+ if (seenIds.has(hydrateId)) {
559
+ throw new Error(
560
+ `[@ossy/app] Duplicate client hydrate id "${hydrateId}" (${f}). Pages need unique ids.`
561
+ )
562
+ }
563
+ seenIds.add(hydrateId)
564
+ const rel = relToGeneratedImport(stubAbsPath, f)
565
+ pageLines.push(` '${hydrateId}': () => import('./${rel}'),`)
566
+ }
567
+
718
568
  return [
719
569
  '// Generated by @ossy/app — do not edit',
720
570
  '',
721
571
  "import { createElement } from 'react'",
722
572
  "import { hydrateRoot } from 'react-dom/client'",
723
573
  "import { App } from '@ossy/connected-components'",
724
- `import * as _page from './${rel}'`,
725
574
  '',
726
- 'export default _page.default',
727
- 'export const metadata = _page.metadata',
575
+ 'const config = window.__INITIAL_APP_CONFIG__ || {}',
576
+ '',
577
+ 'const pages = {',
578
+ ...pageLines,
579
+ '}',
728
580
  '',
729
- "if (typeof window !== 'undefined') {",
730
- " const config = window.__INITIAL_APP_CONFIG__ || {}",
731
- ' function PageShell (props) {',
732
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
733
- " createElement('head', null,",
734
- " createElement('meta', { charSet: 'utf-8' }),",
735
- " createElement('title', null, (_page.metadata && _page.metadata.title) || ''),",
736
- ' ),',
737
- ' createElement(App, props,',
738
- ' createElement(_page.default, props)',
581
+ 'const load = pages[config.pageId]',
582
+ 'if (load) {',
583
+ ' load().then((mod) => {',
584
+ ' const Page = mod.default',
585
+ " const metadata = mod.metadata || {}",
586
+ ' function PageShell (props) {',
587
+ " return createElement('html', { lang: props.defaultLanguage || 'en' },",
588
+ " createElement('head', null,",
589
+ " createElement('meta', { charSet: 'utf-8' }),",
590
+ " createElement('title', null, metadata.title || ''),",
591
+ ' ),',
592
+ ' createElement(App, props,',
593
+ ' createElement(Page, props)',
594
+ ' )',
739
595
  ' )',
740
- ' )',
741
- ' }',
742
- ' hydrateRoot(document, createElement(PageShell, config))',
596
+ ' }',
597
+ ' hydrateRoot(document, createElement(PageShell, config))',
598
+ ' })',
743
599
  '}',
744
600
  '',
745
601
  ].join('\n')
746
602
  }
747
603
 
748
- /** Writes `hydrate-<id>.jsx` for each page; removes stale `hydrate-*` outputs in `ossyDir` first. */
749
- export function writePageHydrateStubs (pageFiles, srcDir, ossyDir) {
604
+ /** Writes a single `hydrate-entry.jsx`; removes any stale per-page `hydrate-*.jsx` stubs first. */
605
+ export function writeHydrateEntry (pageFiles, srcDir, ossyDir) {
750
606
  if (!fs.existsSync(ossyDir)) fs.mkdirSync(ossyDir, { recursive: true })
751
607
  for (const ent of fs.readdirSync(ossyDir, { withFileTypes: true })) {
752
- const full = path.join(ossyDir, ent.name)
753
- if (ent.isDirectory() && ent.name.startsWith(HYDRATE_STUB_PREFIX)) {
754
- fs.rmSync(full, { recursive: true, force: true })
755
- } else if (
608
+ if (
756
609
  ent.isFile() &&
757
- ent.name.startsWith(HYDRATE_STUB_PREFIX) &&
758
- ent.name.endsWith(HYDRATE_STUB_SUFFIX)
610
+ ent.name.startsWith('hydrate-') &&
611
+ ent.name.endsWith('.jsx') &&
612
+ ent.name !== HYDRATE_ENTRY_FILENAME
759
613
  ) {
760
- fs.rmSync(full, { force: true })
614
+ fs.rmSync(path.join(ossyDir, ent.name), { force: true })
761
615
  }
762
616
  }
617
+ const stubPath = path.join(ossyDir, HYDRATE_ENTRY_FILENAME)
618
+ fs.writeFileSync(stubPath, generateHydrateEntry({ pageFiles, srcDir, stubAbsPath: stubPath }))
619
+ }
620
+
621
+ /**
622
+ * Generates the single shared SSR entry that exports a static `pages` array and a
623
+ * `renderPage(pageId, props, options)` function. Each page is dynamically imported so
624
+ * Rollup can split out lazy chunks per page when code-splitting is enabled.
625
+ */
626
+ export function generateSsrEntry ({ pageFiles, srcDir, stubAbsPath }) {
763
627
  const seenIds = new Set()
628
+ const pagesLiteral = []
629
+ const pageModuleLines = []
630
+
764
631
  for (const f of pageFiles) {
765
- const hydrateId = clientHydrateIdForPage(f, srcDir)
766
- if (seenIds.has(hydrateId)) {
632
+ const { id, path: routePath } = pageRouteFromSource(f, srcDir)
633
+ if (seenIds.has(id)) {
767
634
  throw new Error(
768
- `[@ossy/app] Duplicate client hydrate id "${hydrateId}" (${f}). Per-page hydrate bundles need unique ids.`
635
+ `[@ossy/app] Duplicate page id "${id}" (${f}). Pages need unique ids.`
769
636
  )
770
637
  }
771
- seenIds.add(hydrateId)
772
- const stubPath = path.join(ossyDir, `${HYDRATE_STUB_PREFIX}${hydrateId}${HYDRATE_STUB_SUFFIX}`)
773
- fs.mkdirSync(path.dirname(stubPath), { recursive: true })
774
- fs.writeFileSync(
775
- stubPath,
776
- generatePageHydrateModule({ pageAbsPath: f, stubAbsPath: stubPath, srcDir })
777
- )
638
+ seenIds.add(id)
639
+ const rel = relToGeneratedImport(stubAbsPath, f)
640
+ pagesLiteral.push(` { id: '${id}', path: '${routePath}' },`)
641
+ pageModuleLines.push(` '${id}': () => import('./${rel}'),`)
778
642
  }
643
+
644
+ return [
645
+ '// Generated by @ossy/app — do not edit',
646
+ '',
647
+ "import { createElement } from 'react'",
648
+ "import { renderToPipeableStream } from 'react-dom/server'",
649
+ "import { Writable } from 'node:stream'",
650
+ "import { App } from '@ossy/connected-components'",
651
+ '',
652
+ 'export const pages = [',
653
+ ...pagesLiteral,
654
+ ']',
655
+ '',
656
+ 'const pageModules = {',
657
+ ...pageModuleLines,
658
+ '}',
659
+ '',
660
+ 'function PageShell (props) {',
661
+ " const meta = props._pageMeta || {}",
662
+ " return createElement('html', { lang: props.defaultLanguage || 'en' },",
663
+ " createElement('head', null,",
664
+ " createElement('meta', { charSet: 'utf-8' }),",
665
+ " createElement('title', null, meta.title || ''),",
666
+ ' ),',
667
+ ' createElement(App, props,',
668
+ ' createElement(props._pageComponent, props)',
669
+ ' )',
670
+ ' )',
671
+ '}',
672
+ '',
673
+ 'export async function renderPage (pageId, props, options = {}) {',
674
+ ' const load = pageModules[pageId]',
675
+ " if (!load) throw new Error(`[@ossy/app] Unknown page id: ${pageId}`)",
676
+ ' const mod = await load()',
677
+ ' const merged = { ...props, _pageComponent: mod.default, _pageMeta: mod.metadata || {} }',
678
+ ' return new Promise((resolve, reject) => {',
679
+ " let html = ''",
680
+ ' const writable = new Writable({',
681
+ ' write (chunk, _enc, cb) { html += chunk.toString(); cb() },',
682
+ ' })',
683
+ ' const { pipe } = renderToPipeableStream(createElement(PageShell, merged), {',
684
+ ' ...options,',
685
+ ' onAllReady () { pipe(writable) },',
686
+ ' onError (err) { reject(err) },',
687
+ ' })',
688
+ " writable.on('finish', () => resolve(html))",
689
+ ' })',
690
+ '}',
691
+ '',
692
+ ].join('\n')
779
693
  }
780
694
 
781
- export function buildClientHydrateInput (pageFiles, srcDir, ossyDir) {
782
- const input = {}
783
- for (const f of pageFiles) {
784
- const hydrateId = clientHydrateIdForPage(f, srcDir)
785
- const stubPath = path.join(ossyDir, `${HYDRATE_STUB_PREFIX}${hydrateId}${HYDRATE_STUB_SUFFIX}`)
786
- input[hydrateEntryName(hydrateId)] = stubPath
695
+ /** Writes `ssr-entry.mjs` into ossyDir; removes any stale per-page SSR stubs first. */
696
+ export function writeSsrEntry (pageFiles, srcDir, ossyDir) {
697
+ if (!fs.existsSync(ossyDir)) fs.mkdirSync(ossyDir, { recursive: true })
698
+ const stubPath = path.join(ossyDir, SSR_ENTRY_FILENAME)
699
+ fs.writeFileSync(stubPath, generateSsrEntry({ pageFiles, srcDir, stubAbsPath: stubPath }))
700
+ return stubPath
701
+ }
702
+
703
+ /**
704
+ * Runs a single Rollup pass with both the SSR entry and the client hydrate entry as inputs.
705
+ * Produces:
706
+ * build/ssr/app.mjs — Node SSR bundle
707
+ * build/public/static/app.js — browser hydrate bundle
708
+ * build/public/static/chunks/[name]-[hash].js — shared chunks
709
+ *
710
+ * The SSR bundle imports shared chunks via relative paths (`../public/static/chunks/…`);
711
+ * the browser loads the same physical files from `/static/chunks/`.
712
+ */
713
+ export async function compileCombinedBundle ({ ssrEntryPath, clientEntryPath, buildPath, nodeEnv, copyPublicFrom, onWarn }) {
714
+ if (copyPublicFrom && fs.existsSync(copyPublicFrom)) {
715
+ const destPublic = path.join(buildPath, 'public')
716
+ fs.mkdirSync(destPublic, { recursive: true })
717
+ const copyDir = (src, dest) => {
718
+ fs.mkdirSync(dest, { recursive: true })
719
+ for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
720
+ const srcPath = path.join(src, ent.name)
721
+ const destPath = path.join(dest, ent.name)
722
+ if (ent.isDirectory()) copyDir(srcPath, destPath)
723
+ else fs.copyFileSync(srcPath, destPath)
724
+ }
725
+ }
726
+ copyDir(copyPublicFrom, destPublic)
787
727
  }
788
- return input
728
+
729
+ const bundle = await rollup({
730
+ input: { server: ssrEntryPath, app: clientEntryPath },
731
+ plugins: createCombinedBundlePlugins({ nodeEnv }),
732
+ onwarn (warning, defaultHandler) {
733
+ if (onWarn) { onWarn(warning); return }
734
+ defaultHandler(warning)
735
+ },
736
+ })
737
+ await bundle.write({
738
+ dir: buildPath,
739
+ format: 'esm',
740
+ entryFileNames: (chunk) =>
741
+ chunk.name === 'server'
742
+ ? 'ssr/app.mjs'
743
+ : 'public/static/app.js',
744
+ chunkFileNames: 'public/static/chunks/[name]-[hash].js',
745
+ plugins: [minifyBrowserStaticChunks()],
746
+ })
747
+ await bundle.close()
789
748
  }
790
749
 
791
750
  /** JSON manifest: route ids, default paths, and page source paths (posix, relative to `cwd`). */
792
751
  export function buildPagesGeneratedPayload (pageFiles, srcDir, cwd = process.cwd()) {
793
752
  const pages = pageFiles.map((f) => {
794
- const { path: routePath } = filePathToRoute(f, srcDir)
795
- const pageId = clientHydrateIdForPage(f, srcDir)
753
+ const { id, path: routePath } = pageRouteFromSource(f, srcDir)
796
754
  return {
797
- id: pageId,
755
+ id,
798
756
  path: routePath,
799
757
  sourceFile: path.relative(cwd, f).replace(/\\/g, '/'),
800
758
  }
@@ -918,8 +876,9 @@ export const build = async (cliArgs) => {
918
876
  const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
919
877
  const buildPath = path.resolve('build')
920
878
  const srcDir = path.resolve('src')
921
- const configPath = path.resolve(options['--config'] || 'src/config.js');
879
+ const configPath = path.resolve(options['--config'] || 'src/config.js')
922
880
  const pageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
881
+ const publicDir = path.resolve('public')
923
882
 
924
883
  resetOssyBuildDir(buildPath)
925
884
 
@@ -936,16 +895,14 @@ export const build = async (cliArgs) => {
936
895
  srcDir,
937
896
  pagesGeneratedPath,
938
897
  })
939
- writePageHydrateStubs(pageFiles, srcDir, ossyDir)
940
- writePageSsrStubs(pageFiles, srcDir, ossyDir)
941
- const clientHydrateInput = buildClientHydrateInput(pageFiles, srcDir, ossyDir)
942
898
 
943
- const { apiOverviewFiles } = resolveApiSource({
944
- srcDir,
945
- buildPath,
946
- })
947
- let middlewareSourcePath = path.resolve('src/middleware.js');
948
- const publicDir = path.resolve('public')
899
+ // Write generated entries (both SSR and client hydrate)
900
+ const ssrEntryPath = writeSsrEntry(pageFiles, srcDir, ossyDir)
901
+ writeHydrateEntry(pageFiles, srcDir, ossyDir)
902
+ const clientEntryPath = path.join(ossyDir, HYDRATE_ENTRY_FILENAME)
903
+
904
+ const { apiOverviewFiles } = resolveApiSource({ srcDir, buildPath })
905
+ let middlewareSourcePath = path.resolve('src/middleware.js')
949
906
 
950
907
  if (!fs.existsSync(middlewareSourcePath)) {
951
908
  middlewareSourcePath = path.resolve(scriptDir, 'middleware.js')
@@ -955,45 +912,13 @@ export const build = async (cliArgs) => {
955
912
  ? configPath
956
913
  : path.resolve(scriptDir, 'default-config.js')
957
914
 
958
- const useDashboard = Object.keys(clientHydrateInput).length > 0
959
- const overviewSnap = getBuildOverviewSnapshot({
915
+ console.log('\n \x1b[1m@ossy/app\x1b[0m \x1b[2mbuild\x1b[0m')
916
+ printBuildOverview({
960
917
  pagesSourcePath: pagesGeneratedPath,
961
918
  apiOverviewFiles,
962
919
  configPath,
963
920
  pageFiles,
964
921
  })
965
- const idToPath = Object.fromEntries(
966
- pageFiles.map((f) => [
967
- clientHydrateIdForPage(f, srcDir),
968
- filePathToRoute(f, srcDir).path,
969
- ])
970
- )
971
- const pageIds = useDashboard
972
- ? [...new Set(pageFiles.map((f) => clientHydrateIdForPage(f, srcDir)))].sort()
973
- : []
974
-
975
- let dashboard = null
976
- if (useDashboard && pageIds.length > 0) {
977
- dashboard = createBuildDashboard({
978
- mode: 'full',
979
- pageIds,
980
- idToPath,
981
- overview: {
982
- title: '@ossy/app build',
983
- configRel: overviewSnap.configRel,
984
- apiRoutes: overviewSnap.apiRoutes,
985
- },
986
- })
987
- dashboard.start()
988
- } else {
989
- console.log('\n \x1b[1m@ossy/app\x1b[0m \x1b[2mbuild\x1b[0m')
990
- printBuildOverview({
991
- pagesSourcePath: pagesGeneratedPath,
992
- apiOverviewFiles,
993
- configPath,
994
- pageFiles,
995
- })
996
- }
997
922
 
998
923
  if (resourceTemplatesResult.wrote && resourceTemplatesResult.path) {
999
924
  console.log(
@@ -1003,12 +928,9 @@ export const build = async (cliArgs) => {
1003
928
 
1004
929
  const { taskOverviewFiles } = resolveTaskSource({ srcDir, buildPath })
1005
930
  await compileOssyNodeArtifacts({
1006
- pageFiles,
1007
- srcDir,
1008
- ossyDir,
1009
- buildPath,
1010
931
  apiFiles: apiOverviewFiles,
1011
932
  taskFiles: taskOverviewFiles,
933
+ ossyDir,
1012
934
  nodeEnv: 'production',
1013
935
  })
1014
936
 
@@ -1019,23 +941,13 @@ export const build = async (cliArgs) => {
1019
941
  })
1020
942
  copyOssyAppRuntime({ scriptDir, buildPath })
1021
943
 
1022
- if (useDashboard && dashboard) {
1023
- try {
1024
- await prerenderReactTask.handler({
1025
- op: 'runProduction',
1026
- clientHydrateInput,
1027
- pageFilesLength: pageFiles.length,
1028
- copyPublicFrom: publicDir,
1029
- buildPath,
1030
- nodeEnv: 'production',
1031
- createClientRollupPlugins: createOssyClientRollupPlugins,
1032
- minifyBrowserStaticChunks,
1033
- reporter: dashboard,
1034
- })
1035
- } finally {
1036
- dashboard.dispose()
1037
- }
1038
- }
944
+ await compileCombinedBundle({
945
+ ssrEntryPath,
946
+ clientEntryPath,
947
+ buildPath,
948
+ nodeEnv: 'production',
949
+ copyPublicFrom: publicDir,
950
+ })
1039
951
 
1040
952
  console.log(' \x1b[32m✔\x1b[0m \x1b[1m@ossy/app\x1b[0m \x1b[2mbuild finished\x1b[0m\n')
1041
953
  };
@@ -1,144 +1,8 @@
1
1
  import path from 'path'
2
- import fs from 'fs'
3
- import { rollup } from 'rollup'
4
- import { pageIdFromHydrateEntryName } from './build-terminal.js'
5
2
 
6
- function copyPublicToBuild ({ copyPublicFrom, buildPath }) {
7
- if (!copyPublicFrom || !fs.existsSync(copyPublicFrom)) return
8
- const dest = path.join(buildPath, 'public')
9
- fs.mkdirSync(dest, { recursive: true })
10
- fs.cpSync(copyPublicFrom, dest, { recursive: true })
3
+ export function staticHtmlPathForRoute (routePath, publicDir) {
4
+ const segments = routePath === '/' ? [] : routePath.replace(/^\//, '').split('/')
5
+ return path.join(publicDir, ...segments, 'index.html')
11
6
  }
12
7
 
13
- async function bundleOneHydratePage ({
14
- entryName,
15
- stubPath,
16
- buildPath,
17
- plugins,
18
- minifyPlugin,
19
- }) {
20
- const bundle = await rollup({
21
- input: { [entryName]: stubPath },
22
- plugins,
23
- })
24
- try {
25
- await bundle.write({
26
- dir: path.join(buildPath, 'public'),
27
- format: 'esm',
28
- inlineDynamicImports: true,
29
- entryFileNames (chunkInfo) {
30
- const n = chunkInfo.name
31
- if (n.startsWith('hydrate__')) {
32
- const pageId = n.slice('hydrate__'.length)
33
- return `static/${pageId}.js`
34
- }
35
- return 'static/[name].js'
36
- },
37
- chunkFileNames: 'static/[name]-[hash].js',
38
- plugins: minifyPlugin ? [minifyPlugin] : [],
39
- })
40
- } finally {
41
- await bundle.close()
42
- }
43
- }
44
-
45
- async function bundleWithConcurrency (entries, concurrency, processFn) {
46
- const results = new Array(entries.length)
47
- let nextIdx = 0
48
- async function worker () {
49
- while (nextIdx < entries.length) {
50
- const idx = nextIdx++
51
- results[idx] = await processFn(entries[idx]).then(
52
- (value) => ({ status: 'fulfilled', value }),
53
- (reason) => ({ status: 'rejected', reason }),
54
- )
55
- }
56
- }
57
- await Promise.all(Array.from({ length: concurrency }, worker))
58
- return results
59
- }
60
-
61
- async function bundleHydratePagesParallel ({
62
- clientHydrateInput,
63
- buildPath,
64
- copyPublicFrom,
65
- nodeEnv,
66
- buildPathForPlugins,
67
- createClientRollupPlugins,
68
- minifyBrowserStaticChunks,
69
- reporter,
70
- }) {
71
- copyPublicToBuild({ copyPublicFrom, buildPath })
72
- fs.mkdirSync(path.join(buildPath, 'public', 'static'), { recursive: true })
73
-
74
- const entries = Object.entries(clientHydrateInput)
75
-
76
- const BUNDLE_CONCURRENCY = 4
77
-
78
- const results = await bundleWithConcurrency(
79
- entries,
80
- BUNDLE_CONCURRENCY,
81
- async ([entryName, stubPath]) => {
82
- const pageId = pageIdFromHydrateEntryName(entryName)
83
- const t0 = Date.now()
84
- reporter?.startBundle?.(pageId)
85
- try {
86
- const plugins = createClientRollupPlugins({
87
- nodeEnv,
88
- copyPublicFrom: undefined,
89
- buildPath: buildPathForPlugins,
90
- })
91
- await bundleOneHydratePage({
92
- entryName,
93
- stubPath,
94
- buildPath,
95
- plugins,
96
- minifyPlugin: minifyBrowserStaticChunks(),
97
- })
98
- reporter?.completeBundle?.(pageId, { ok: true, ms: Date.now() - t0 })
99
- } catch (error) {
100
- reporter?.completeBundle?.(pageId, {
101
- ok: false,
102
- ms: Date.now() - t0,
103
- error,
104
- })
105
- console.error(`[@ossy/app][client-bundle] ${entryName} failed:`, error)
106
- throw error
107
- }
108
- },
109
- )
110
-
111
- const failures = results.filter((r) => r.status === 'rejected').length
112
- return { results, failures }
113
- }
114
-
115
- export default {
116
- type: '@ossy/app/prerender-react',
117
- async handler (input) {
118
- const op = input?.op
119
- if (op === 'runProduction') {
120
- const { clientHydrateInput, pageFilesLength, copyPublicFrom, buildPath, nodeEnv,
121
- buildPathForPlugins, createClientRollupPlugins, minifyBrowserStaticChunks, reporter } = input
122
- if (pageFilesLength === 0 || Object.keys(clientHydrateInput).length === 0) {
123
- return { bundleFailures: 0 }
124
- }
125
- const { failures: bundleFailures } = await bundleHydratePagesParallel({
126
- clientHydrateInput,
127
- buildPath,
128
- copyPublicFrom,
129
- nodeEnv,
130
- buildPathForPlugins: buildPathForPlugins ?? buildPath,
131
- createClientRollupPlugins,
132
- minifyBrowserStaticChunks,
133
- reporter,
134
- })
135
- if (bundleFailures > 0) {
136
- console.warn(`[@ossy/app][build] Finished with ${bundleFailures} client bundle error(s)`)
137
- }
138
- return { bundleFailures }
139
- }
140
- throw new Error(
141
- `[@ossy/app][prerender-react] Unknown op: ${String(op)} (expected runProduction)`
142
- )
143
- },
144
- }
8
+ export default { type: '@ossy/app/prerender-react' }
@@ -1,21 +1,4 @@
1
- import path from 'node:path'
2
- import { fileURLToPath, pathToFileURL } from 'node:url'
3
-
4
- const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
5
-
6
- async function loadSsrBundle (route) {
7
- if (typeof route?.id !== 'string' || !route.id) {
8
- throw new Error(`[@ossy/app][BuildPage] Route has no id`)
9
- }
10
- const bundlePath = path.join(__ossyDir, '..', 'ssr', `${route.id}.mjs`)
11
- const mod = await import(pathToFileURL(bundlePath).href)
12
- if (typeof mod?.renderPage !== 'function') {
13
- throw new Error(
14
- `[@ossy/app][BuildPage] SSR bundle for "${route.id}" must export renderPage (got ${typeof mod?.renderPage}).`
15
- )
16
- }
17
- return mod
18
- }
1
+ import { renderPage as ssrRender } from '../ssr/app.mjs'
19
2
 
20
3
  export function buildPrerenderAppConfig ({
21
4
  buildTimeConfig,
@@ -36,6 +19,7 @@ export function buildPrerenderAppConfig ({
36
19
  workspaceId: buildTimeConfig.workspaceId,
37
20
  apiUrl: buildTimeConfig.apiUrl,
38
21
  pages,
22
+ pageId: activeRouteId,
39
23
  sidebarPrimaryCollapsed: false,
40
24
  }
41
25
  }
@@ -61,12 +45,7 @@ export function buildHydrationAppConfig (appConfig) {
61
45
 
62
46
  export const BuildPage = {
63
47
  async handle ({ route, appConfig }) {
64
- const hydrationConfig = buildHydrationAppConfig(appConfig)
65
- const { renderPage } = await loadSsrBundle(route)
66
-
67
- return renderPage(hydrationConfig, {
68
- bootstrapScriptContent: `window.__INITIAL_APP_CONFIG__=${JSON.stringify(hydrationConfig)}`,
69
- bootstrapModules: [`/static/${route.id}.js`],
70
- })
48
+ const config = buildHydrationAppConfig(appConfig)
49
+ return ssrRender(route.id, config, {})
71
50
  },
72
51
  }
package/cli/server.js CHANGED
@@ -130,7 +130,7 @@ app.all('*all', async (req, res) => {
130
130
  }
131
131
 
132
132
  const pageRoute = pageRouter.getPageByUrl(requestUrl)
133
- if (pageRoute?.module) {
133
+ if (pageRoute) {
134
134
  const appConfig = buildPrerenderAppConfig({
135
135
  buildTimeConfig,
136
136
  pageList: sitePageList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.13.2",
3
+ "version": "1.13.4",
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.13.2",
31
- "@ossy/design-system": "^1.13.2",
32
- "@ossy/pages": "^1.13.2",
33
- "@ossy/router": "^1.13.2",
34
- "@ossy/router-react": "^1.13.2",
35
- "@ossy/sdk": "^1.13.2",
36
- "@ossy/sdk-react": "^1.13.2",
37
- "@ossy/themes": "^1.13.2",
30
+ "@ossy/connected-components": "^1.13.4",
31
+ "@ossy/design-system": "^1.13.4",
32
+ "@ossy/pages": "^1.13.4",
33
+ "@ossy/router": "^1.13.4",
34
+ "@ossy/router-react": "^1.13.4",
35
+ "@ossy/sdk": "^1.13.4",
36
+ "@ossy/sdk-react": "^1.13.4",
37
+ "@ossy/themes": "^1.13.4",
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": "261f99aae788324c696edaeaa13a67380780e3fc"
70
+ "gitHead": "c2640eba775b192c34a888bae8add3c3461305ee"
71
71
  }