@ossy/app 1.11.1 → 1.11.3

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
@@ -23,7 +23,7 @@ export default () => <h1>About</h1>
23
23
 
24
24
  For a single-file setup, use `src/pages.jsx` (legacy).
25
25
 
26
- Split pages are merged into **`build/.ossy/pages.generated.jsx`** (next to other build output) during `dev` / `build`.
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
27
 
28
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.).
29
29
 
@@ -51,7 +51,7 @@ Define HTTP handlers as an array of `{ id, path, handle(req, res) }` objects (sa
51
51
 
52
52
  Add any number of `*.api.js` (or `.api.mjs` / `.api.cjs`) files under `src/` (nested dirs allowed). Each file’s **default export** is either one route object or an array of routes. Files are merged in lexicographic path order.
53
53
 
54
- Build/dev always writes **`build/.ossy/api.generated.js`** (typically gitignored with `build/`). With no API files it exports an empty array.
54
+ Build/dev writes **`build/.ossy/api.generated.json`** (discovered source paths) and **`api.bundle.json`** (Rollup outputs under **`api-modules/`**). The server imports **`api.runtime.mjs`**, which loads those modules. With no API files, both JSON files list empty arrays.
55
55
 
56
56
  Example `src/health.api.js`:
57
57
 
@@ -69,7 +69,7 @@ API routes are matched before the app is rendered. The router supports dynamic s
69
69
 
70
70
  ## Background worker tasks (`*.task.js`)
71
71
 
72
- For long-running job processors (separate from the SSR server), use **`npx @ossy/cli build --worker`** in a package that only needs the worker. It uses the same Rollup + Babel pipeline as `ossy build`, discovers **`*.task.js`** (and `.task.mjs` / `.task.cjs`) under `src/` (or `--pages <dir>`), and writes **`build/.ossy/tasks.generated.js`** when needed—same idea as `*.api.js` and the generated API bundle.
72
+ For long-running job processors (separate from the SSR server), use **`npx @ossy/cli build --worker`** in a package that only needs the worker. It uses the same Rollup + Babel pipeline as `ossy build`, discovers **`*.task.js`** (and `.task.mjs` / `.task.cjs`) under `src/` (or `--pages <dir>`), and writes **`tasks.generated.json`** / **`tasks.bundle.json`** plus **`tasks.runtime.mjs`**—the same metadata + per-file compile pattern as API routes.
73
73
 
74
74
  Optional legacy aggregate: **`src/tasks.js`** default export is merged **first**, then each `*.task.js` in path order.
75
75
 
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+
5
+ const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
6
+
7
+ function normalizeApiExport (mod) {
8
+ const d = mod?.default
9
+ if (d == null) return []
10
+ return Array.isArray(d) ? d : [d]
11
+ }
12
+
13
+ const { modules } = JSON.parse(fs.readFileSync(path.join(__ossyDir, 'api.bundle.json'), 'utf8'))
14
+
15
+ const out = []
16
+ for (const rel of modules) {
17
+ const abs = path.resolve(__ossyDir, rel)
18
+ const mod = await import(pathToFileURL(abs).href)
19
+ out.push(...normalizeApiExport(mod))
20
+ }
21
+
22
+ export default out
package/cli/build.js CHANGED
@@ -53,15 +53,37 @@ export function minifyBrowserStaticChunks () {
53
53
 
54
54
  /** Subfolder under `build/` for generated pages/api/task entry stubs. */
55
55
  export const OSSY_GEN_DIRNAME = '.ossy'
56
- export const OSSY_GEN_PAGES_BASENAME = 'pages.generated.jsx'
57
- export const OSSY_GEN_API_BASENAME = 'api.generated.js'
58
- export const OSSY_GEN_TASKS_BASENAME = 'tasks.generated.js'
59
-
60
- /** Bundled pages + React for build-time prerender only (not loaded by `server.js`). */
61
- export const OSSY_PAGES_PRERENDER_BUNDLE = 'pages.prerender.bundle.js'
62
- export const OSSY_API_SERVER_BUNDLE = 'api.bundle.js'
63
- export const OSSY_TASKS_SERVER_BUNDLE = 'tasks.bundle.js'
56
+ /** JSON-only manifests (sources + route metadata). */
57
+ export const OSSY_GEN_PAGES_BASENAME = 'pages.generated.json'
58
+ export const OSSY_GEN_API_BASENAME = 'api.generated.json'
59
+ export const OSSY_GEN_TASKS_BASENAME = 'tasks.generated.json'
60
+ /** JSON-only build artifact index (compiled module paths, aligned to generated manifests). */
61
+ export const OSSY_PAGES_BUNDLE_BASENAME = 'pages.bundle.json'
62
+ export const OSSY_API_BUNDLE_BASENAME = 'api.bundle.json'
63
+ export const OSSY_TASKS_BUNDLE_BASENAME = 'tasks.bundle.json'
64
+ /** Small Node loaders (not JSON) that `import()` compiled modules from the bundle manifests. */
65
+ export const OSSY_PAGES_RUNTIME_BASENAME = 'pages.runtime.mjs'
66
+ export const OSSY_API_RUNTIME_BASENAME = 'api.runtime.mjs'
67
+ export const OSSY_TASKS_RUNTIME_BASENAME = 'tasks.runtime.mjs'
68
+
69
+ export const OSSY_PAGE_MODULES_DIRNAME = 'page-modules'
70
+ /** Tiny Rollup inputs that re-export `metadata` so per-page server bundles keep i18n paths. */
71
+ export const OSSY_PAGE_SERVER_ENTRIES_DIRNAME = 'page-server-entries'
72
+ export const OSSY_API_MODULES_DIRNAME = 'api-modules'
73
+ export const OSSY_TASK_MODULES_DIRNAME = 'task-modules'
74
+
64
75
  export const OSSY_MIDDLEWARE_RUNTIME_BASENAME = 'middleware.runtime.js'
76
+ export const OSSY_SERVER_CONFIG_RUNTIME_BASENAME = 'server-config.runtime.mjs'
77
+ export const OSSY_RENDER_PAGE_RUNTIME_BASENAME = 'render-page.task.js'
78
+
79
+ /** Keep React external across per-page server chunks so `pages.runtime.mjs` shares one React. */
80
+ export const OSSY_PAGE_SERVER_EXTERNAL = [
81
+ 'react',
82
+ 'react-dom',
83
+ 'react-dom/static',
84
+ 'react-dom/client',
85
+ 'react/jsx-runtime',
86
+ ]
65
87
 
66
88
  /** Per-page client entries: `hydrate-<pageId>.jsx` under `.ossy/` */
67
89
  const HYDRATE_STUB_PREFIX = 'hydrate-'
@@ -210,9 +232,10 @@ export function createOssyClientRollupPlugins ({ nodeEnv, copyPublicFrom, buildP
210
232
  }
211
233
 
212
234
  /** Bundles a single Node ESM file (inline dynamic imports) for SSR / API / tasks. */
213
- export async function bundleOssyNodeEntry ({ inputPath, outputFile, nodeEnv, onWarn }) {
235
+ export async function bundleOssyNodeEntry ({ inputPath, outputFile, nodeEnv, onWarn, external }) {
214
236
  const bundle = await rollup({
215
237
  input: inputPath,
238
+ ...(external && external.length ? { external } : {}),
216
239
  plugins: createOssyAppBundlePlugins({ nodeEnv }),
217
240
  onwarn (warning, defaultHandler) {
218
241
  if (onWarn) {
@@ -231,14 +254,19 @@ export async function bundleOssyNodeEntry ({ inputPath, outputFile, nodeEnv, onW
231
254
  }
232
255
 
233
256
  /**
234
- * Re-exports middleware via `file:` URL so `src/middleware.js` can keep relative imports.
257
+ * Re-exports middleware and app config via `file:` URLs so `src/*.js` can keep relative imports.
235
258
  */
236
- export function writeAppRuntimeShims ({ middlewareSourcePath, ossyDir }) {
259
+ export function writeAppRuntimeShims ({ middlewareSourcePath, configSourcePath, ossyDir }) {
237
260
  const mwHref = url.pathToFileURL(path.resolve(middlewareSourcePath)).href
238
261
  fs.writeFileSync(
239
262
  path.join(ossyDir, OSSY_MIDDLEWARE_RUNTIME_BASENAME),
240
263
  `// Generated by @ossy/app — do not edit\nexport { default } from '${mwHref}'\n`
241
264
  )
265
+ const cfgHref = url.pathToFileURL(path.resolve(configSourcePath)).href
266
+ fs.writeFileSync(
267
+ path.join(ossyDir, OSSY_SERVER_CONFIG_RUNTIME_BASENAME),
268
+ `// Generated by @ossy/app — do not edit\nexport { default } from '${cfgHref}'\n`
269
+ )
242
270
  }
243
271
 
244
272
  /**
@@ -258,6 +286,18 @@ export function copyOssyAppRuntime ({ scriptDir, buildPath }) {
258
286
  }
259
287
  fs.copyFileSync(path.join(scriptDir, 'worker-entry.js'), path.join(buildPath, 'worker.js'))
260
288
  fs.copyFileSync(path.join(scriptDir, 'worker-runtime.js'), path.join(buildPath, 'worker-runtime.js'))
289
+ const ossyOut = ossyGeneratedDir(buildPath)
290
+ for (const name of [
291
+ OSSY_PAGES_RUNTIME_BASENAME,
292
+ OSSY_API_RUNTIME_BASENAME,
293
+ OSSY_TASKS_RUNTIME_BASENAME,
294
+ ]) {
295
+ fs.copyFileSync(path.join(scriptDir, name), path.join(ossyOut, name))
296
+ }
297
+ fs.copyFileSync(
298
+ path.join(scriptDir, OSSY_RENDER_PAGE_RUNTIME_BASENAME),
299
+ path.join(ossyOut, OSSY_RENDER_PAGE_RUNTIME_BASENAME)
300
+ )
261
301
  }
262
302
 
263
303
  /**
@@ -281,107 +321,162 @@ export function discoverFilesByPattern (srcDir, filePattern) {
281
321
  return files.sort()
282
322
  }
283
323
 
284
- /**
285
- * Merges every `*.api.js` (and `.api.mjs` / `.api.cjs`) under `src/` into one default export array
286
- * for the Ossy API router ({ id, path, handle }). With no API files, emits `export default []`.
287
- */
288
- export function generateApiModule ({ generatedPath, apiFiles }) {
289
- if (apiFiles.length === 0) {
290
- return [
291
- '// Generated by @ossy/app — do not edit',
292
- '',
293
- 'export default []',
294
- '',
295
- ].join('\n')
324
+ export function writeOssyJson (filePath, data) {
325
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
326
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
327
+ }
328
+
329
+ /** JSON manifest: discovered API source paths (posix, relative to `cwd`). */
330
+ export function buildApiManifestPayload (apiFiles, cwd = process.cwd()) {
331
+ return {
332
+ version: 1,
333
+ files: apiFiles.map((f) => path.relative(cwd, f).replace(/\\/g, '/')),
334
+ }
335
+ }
336
+
337
+ /** JSON manifest: discovered task source paths (posix, relative to `cwd`). */
338
+ export function buildTasksManifestPayload (taskFiles, cwd = process.cwd()) {
339
+ return {
340
+ version: 1,
341
+ files: taskFiles.map((f) => path.relative(cwd, f).replace(/\\/g, '/')),
296
342
  }
297
- const lines = [
298
- '// Generated by @ossy/app — do not edit',
299
- '',
300
- ]
301
- apiFiles.forEach((f, i) => {
302
- const rel = relToGeneratedImport(generatedPath, f)
303
- lines.push(`import * as _api${i} from './${rel}'`)
304
- })
305
- lines.push(
306
- '',
307
- 'function _normalizeApiExport(mod) {',
308
- ' const d = mod?.default',
309
- ' if (d == null) return []',
310
- ' return Array.isArray(d) ? d : [d]',
311
- '}',
312
- '',
313
- 'export default [',
314
- )
315
- const parts = apiFiles.map((_, i) => ` ..._normalizeApiExport(_api${i}),`)
316
- lines.push(parts.join('\n'))
317
- lines.push(']')
318
- lines.push('')
319
- return lines.join('\n')
320
343
  }
321
344
 
322
345
  /**
323
- * Writes `build/.ossy/api.generated.js` and returns its path for `@ossy/api/source-file`.
324
- * Always uses the generated file so the Rollup replace target stays stable.
346
+ * Writes `build/.ossy/api.generated.json` (sources only).
347
+ * Compiled modules + `api.bundle.json` are produced by {@link compileOssyNodeArtifacts}.
325
348
  */
326
- export function resolveApiSource ({ srcDir, buildPath }) {
349
+ export function resolveApiSource ({ srcDir, buildPath, cwd = process.cwd() }) {
327
350
  ensureOssyGeneratedDir(buildPath)
328
351
  const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_API_BASENAME)
329
352
  const apiFiles = discoverFilesByPattern(srcDir, API_FILE_PATTERN)
330
- fs.writeFileSync(
331
- generatedPath,
332
- generateApiModule({ generatedPath, apiFiles })
333
- )
334
- return { apiSourcePath: generatedPath, apiOverviewFiles: apiFiles }
353
+ writeOssyJson(generatedPath, buildApiManifestPayload(apiFiles, cwd))
354
+ return { apiGeneratedPath: generatedPath, apiOverviewFiles: apiFiles }
335
355
  }
336
356
 
337
357
  /**
338
- * Merges `src/tasks.js` (optional) and every `*.task.js` under `src/` into one default export array
339
- * of job handlers `{ type, handler }` for the Ossy worker.
358
+ * Writes `build/.ossy/tasks.generated.json` (sources only).
340
359
  */
341
- export function generateTaskModule ({ generatedPath, taskFiles }) {
342
- const lines = [
343
- '// Generated by @ossy/app — do not edit',
344
- '',
345
- ]
346
- taskFiles.forEach((f, i) => {
347
- const rel = relToGeneratedImport(generatedPath, f)
348
- lines.push(`import * as _task${i} from './${rel}'`)
349
- })
350
- lines.push(
351
- '',
352
- 'function _normalizeTaskExport(mod) {',
353
- ' const d = mod?.default',
354
- ' if (d == null) return []',
355
- ' return Array.isArray(d) ? d : [d]',
356
- '}',
357
- '',
358
- 'export default [',
359
- )
360
- const parts = []
361
- taskFiles.forEach((_, i) => {
362
- parts.push(` ..._normalizeTaskExport(_task${i}),`)
363
- })
364
- lines.push(parts.join('\n'))
365
- lines.push(']')
366
- lines.push('')
367
- return lines.join('\n')
368
- }
369
-
370
- /**
371
- * Writes `build/.ossy/tasks.generated.js` and returns its path (stable Rollup input; empty when no task files).
372
- */
373
- export function resolveTaskSource ({ srcDir, buildPath }) {
360
+ export function resolveTaskSource ({ srcDir, buildPath, cwd = process.cwd() }) {
374
361
  ensureOssyGeneratedDir(buildPath)
375
362
  const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_TASKS_BASENAME)
376
363
  const taskFiles = discoverFilesByPattern(srcDir, TASK_FILE_PATTERN)
377
- fs.writeFileSync(
378
- generatedPath,
379
- generateTaskModule({
380
- generatedPath,
381
- taskFiles,
364
+ writeOssyJson(generatedPath, buildTasksManifestPayload(taskFiles, cwd))
365
+ return { tasksGeneratedPath: generatedPath, taskOverviewFiles: taskFiles }
366
+ }
367
+
368
+ export async function compilePageServerModules ({
369
+ pageFiles,
370
+ srcDir,
371
+ ossyDir,
372
+ nodeEnv,
373
+ onWarn,
374
+ }) {
375
+ const modsDir = path.join(ossyDir, OSSY_PAGE_MODULES_DIRNAME)
376
+ const entriesDir = path.join(ossyDir, OSSY_PAGE_SERVER_ENTRIES_DIRNAME)
377
+ fs.rmSync(modsDir, { recursive: true, force: true })
378
+ fs.rmSync(entriesDir, { recursive: true, force: true })
379
+ if (pageFiles.length === 0) {
380
+ return []
381
+ }
382
+ fs.mkdirSync(modsDir, { recursive: true })
383
+ fs.mkdirSync(entriesDir, { recursive: true })
384
+ const bundlePages = []
385
+ for (const f of pageFiles) {
386
+ const pageId = clientHydrateIdForPage(f, srcDir)
387
+ const safeId = String(pageId).replace(/[^a-zA-Z0-9_-]+/g, '-') || 'page'
388
+ const outName = `${safeId}.mjs`
389
+ const outFile = path.join(modsDir, outName)
390
+ const stubPath = path.join(entriesDir, `${safeId}.mjs`)
391
+ writePageServerRollupEntry({ pageAbsPath: f, stubPath })
392
+ await bundleOssyNodeEntry({
393
+ inputPath: stubPath,
394
+ outputFile: outFile,
395
+ nodeEnv,
396
+ onWarn,
397
+ external: OSSY_PAGE_SERVER_EXTERNAL,
382
398
  })
383
- )
384
- return { taskSourcePath: generatedPath, taskOverviewFiles: taskFiles }
399
+ bundlePages.push({
400
+ id: pageId,
401
+ module: `${OSSY_PAGE_MODULES_DIRNAME}/${outName}`,
402
+ })
403
+ }
404
+ return bundlePages
405
+ }
406
+
407
+ export async function compileApiServerModules ({ apiFiles, ossyDir, nodeEnv, onWarn }) {
408
+ const modsDir = path.join(ossyDir, OSSY_API_MODULES_DIRNAME)
409
+ fs.rmSync(modsDir, { recursive: true, force: true })
410
+ if (apiFiles.length === 0) {
411
+ return []
412
+ }
413
+ fs.mkdirSync(modsDir, { recursive: true })
414
+ const modules = []
415
+ for (let i = 0; i < apiFiles.length; i++) {
416
+ const outName = `api-${i}.mjs`
417
+ const outFile = path.join(modsDir, outName)
418
+ await bundleOssyNodeEntry({
419
+ inputPath: apiFiles[i],
420
+ outputFile: outFile,
421
+ nodeEnv,
422
+ onWarn,
423
+ })
424
+ modules.push(`${OSSY_API_MODULES_DIRNAME}/${outName}`)
425
+ }
426
+ return modules
427
+ }
428
+
429
+ export async function compileTaskServerModules ({ taskFiles, ossyDir, nodeEnv, onWarn }) {
430
+ const modsDir = path.join(ossyDir, OSSY_TASK_MODULES_DIRNAME)
431
+ fs.rmSync(modsDir, { recursive: true, force: true })
432
+ if (taskFiles.length === 0) {
433
+ return []
434
+ }
435
+ fs.mkdirSync(modsDir, { recursive: true })
436
+ const modules = []
437
+ for (let i = 0; i < taskFiles.length; i++) {
438
+ const outName = `task-${i}.mjs`
439
+ const outFile = path.join(modsDir, outName)
440
+ await bundleOssyNodeEntry({
441
+ inputPath: taskFiles[i],
442
+ outputFile: outFile,
443
+ nodeEnv,
444
+ onWarn,
445
+ })
446
+ modules.push(`${OSSY_TASK_MODULES_DIRNAME}/${outName}`)
447
+ }
448
+ return modules
449
+ }
450
+
451
+ /**
452
+ * Writes `pages.bundle.json`, `api.bundle.json`, `tasks.bundle.json` by Rollup-compiling each source module.
453
+ */
454
+ export async function compileOssyNodeArtifacts ({
455
+ pageFiles,
456
+ srcDir,
457
+ ossyDir,
458
+ apiFiles,
459
+ taskFiles,
460
+ nodeEnv,
461
+ onWarn,
462
+ }) {
463
+ const [pageBundleList, apiModuleList, taskModuleList] = await Promise.all([
464
+ compilePageServerModules({ pageFiles, srcDir, ossyDir, nodeEnv, onWarn }),
465
+ compileApiServerModules({ apiFiles, ossyDir, nodeEnv, onWarn }),
466
+ compileTaskServerModules({ taskFiles, ossyDir, nodeEnv, onWarn }),
467
+ ])
468
+ writeOssyJson(path.join(ossyDir, OSSY_PAGES_BUNDLE_BASENAME), {
469
+ version: 1,
470
+ pages: pageBundleList,
471
+ })
472
+ writeOssyJson(path.join(ossyDir, OSSY_API_BUNDLE_BASENAME), {
473
+ version: 1,
474
+ modules: apiModuleList,
475
+ })
476
+ writeOssyJson(path.join(ossyDir, OSSY_TASKS_BUNDLE_BASENAME), {
477
+ version: 1,
478
+ modules: taskModuleList,
479
+ })
385
480
  }
386
481
 
387
482
  export function filePathToRoute(filePath, srcDir) {
@@ -412,9 +507,40 @@ export function clientHydrateIdForPage (pageAbsPath, srcDir) {
412
507
  return idMatch ? idMatch[1] : derived.id
413
508
  }
414
509
 
510
+ export function pageSourceExportsMetadata (pageAbsPath) {
511
+ try {
512
+ const src = fs.readFileSync(pageAbsPath, 'utf8')
513
+ return /\bexport\s+const\s+metadata\b/.test(src)
514
+ } catch {
515
+ return false
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Rollup tree-shakes `export const metadata` when the page file is the entry and `default`
521
+ * never references it — breaks i18n `path` objects at runtime. Re-export from a shim entry
522
+ * so `metadata` stays in the module graph.
523
+ */
524
+ export function writePageServerRollupEntry ({ pageAbsPath, stubPath }) {
525
+ const rel = relToGeneratedImport(stubPath, pageAbsPath)
526
+ const meta = pageSourceExportsMetadata(pageAbsPath)
527
+ ? ', metadata'
528
+ : ''
529
+ fs.mkdirSync(path.dirname(stubPath), { recursive: true })
530
+ fs.writeFileSync(
531
+ stubPath,
532
+ [
533
+ '// Generated by @ossy/app — do not edit',
534
+ `export { default${meta} } from '${rel}'`,
535
+ '',
536
+ ].join('\n'),
537
+ 'utf8'
538
+ )
539
+ }
540
+
415
541
  /**
416
542
  * One client entry per page: imports only that page module and hydrates the document.
417
- * Keeps the same `toPage` shape as `generatePagesModule` so SSR and client trees match.
543
+ * Keeps the same `toPage` shape as `pages.runtime.mjs` + manifests so SSR and client trees match.
418
544
  */
419
545
  export function generatePageHydrateModule ({ pageAbsPath, stubAbsPath, srcDir }) {
420
546
  const rel = relToGeneratedImport(stubAbsPath, pageAbsPath)
@@ -491,32 +617,39 @@ export function buildClientHydrateInput (pageFiles, srcDir, ossyDir) {
491
617
  return input
492
618
  }
493
619
 
494
- export function generatePagesModule (pageFiles, srcDir, generatedPath) {
495
- const lines = [
496
- "import React from 'react'",
497
- ...pageFiles.map((f, i) => {
498
- const rel = relToGeneratedImport(generatedPath, f)
499
- return `import * as _page${i} from './${rel}'`
500
- }),
501
- '',
502
- 'function toPage(mod, derived) {',
503
- ' const meta = mod?.metadata || {}',
504
- " const def = mod?.default",
505
- " if (typeof def === 'function') {",
506
- " return { ...derived, ...meta, element: React.createElement(def) }",
507
- ' }',
508
- " return { ...derived, ...meta, ...(def || {}) }",
509
- '}',
510
- '',
511
- 'export default [',
512
- ...pageFiles.map((f, i) => {
513
- const { id, path: defaultPath } = filePathToRoute(f, srcDir)
514
- const pathStr = JSON.stringify(defaultPath)
515
- return ` toPage(_page${i}, { id: '${id}', path: ${pathStr} }),`
516
- }),
517
- ']',
518
- ]
519
- return lines.join('\n')
620
+ /** JSON manifest: route ids, default paths, and page source paths (posix, relative to `cwd`). */
621
+ export function buildPagesGeneratedPayload (pageFiles, srcDir, cwd = process.cwd()) {
622
+ const pages = pageFiles.map((f) => {
623
+ const { path: routePath } = filePathToRoute(f, srcDir)
624
+ const pageId = clientHydrateIdForPage(f, srcDir)
625
+ return {
626
+ id: pageId,
627
+ path: routePath,
628
+ sourceFile: path.relative(cwd, f).replace(/\\/g, '/'),
629
+ }
630
+ })
631
+ return { version: 1, pages }
632
+ }
633
+
634
+ export function writePagesManifest ({
635
+ pageFiles,
636
+ srcDir,
637
+ pagesGeneratedPath,
638
+ cwd = process.cwd(),
639
+ }) {
640
+ writeOssyJson(pagesGeneratedPath, buildPagesGeneratedPayload(pageFiles, srcDir, cwd))
641
+ }
642
+
643
+ export function parsePagesFromManifestJson (manifestPath) {
644
+ try {
645
+ const raw = fs.readFileSync(manifestPath, 'utf8')
646
+ const data = JSON.parse(raw)
647
+ const pages = data?.pages
648
+ if (!Array.isArray(pages)) return []
649
+ return pages.map((p) => ({ id: p.id, path: p.path }))
650
+ } catch {
651
+ return []
652
+ }
520
653
  }
521
654
 
522
655
  export function parsePagesFromSource(filePath) {
@@ -557,7 +690,6 @@ export function parsePagesFromSource(filePath) {
557
690
  */
558
691
  export function getBuildOverviewSnapshot ({
559
692
  pagesSourcePath,
560
- apiSourcePath,
561
693
  apiOverviewFiles = [],
562
694
  configPath,
563
695
  pageFiles,
@@ -567,17 +699,14 @@ export function getBuildOverviewSnapshot ({
567
699
  const configRel = fs.existsSync(configPath) ? rel(configPath) : null
568
700
 
569
701
  const pages = pageFiles?.length
570
- ? pageFiles.map((f) => filePathToRoute(f, srcDir))
571
- : parsePagesFromSource(pagesSourcePath)
572
-
573
- const apiFilesToScan =
574
- apiOverviewFiles?.length > 0
575
- ? apiOverviewFiles
576
- : fs.existsSync(apiSourcePath)
577
- ? [apiSourcePath]
578
- : []
702
+ ? pageFiles.map((f) => {
703
+ const { id, path: routePath } = filePathToRoute(f, srcDir)
704
+ return { id: clientHydrateIdForPage(f, srcDir), path: routePath }
705
+ })
706
+ : parsePagesFromManifestJson(pagesSourcePath)
707
+
579
708
  const apiRoutes = []
580
- for (const f of apiFilesToScan) {
709
+ for (const f of apiOverviewFiles) {
581
710
  if (fs.existsSync(f)) apiRoutes.push(...parsePagesFromSource(f))
582
711
  }
583
712
 
@@ -633,24 +762,21 @@ export const build = async (cliArgs) => {
633
762
  log: false,
634
763
  })
635
764
 
636
- const pagesGeneratedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
765
+ const ossyDir = ossyGeneratedDir(buildPath)
766
+ const pagesGeneratedPath = path.join(ossyDir, OSSY_GEN_PAGES_BASENAME)
637
767
 
638
- fs.writeFileSync(
768
+ writePagesManifest({
769
+ pageFiles,
770
+ srcDir,
639
771
  pagesGeneratedPath,
640
- generatePagesModule(pageFiles, srcDir, pagesGeneratedPath)
641
- )
642
- const ossyDir = ossyGeneratedDir(buildPath)
772
+ })
643
773
  writePageHydrateStubs(pageFiles, srcDir, ossyDir)
644
774
  const clientHydrateInput = buildClientHydrateInput(pageFiles, srcDir, ossyDir)
645
775
 
646
- const {
647
- apiSourcePath: resolvedApi,
648
- apiOverviewFiles,
649
- } = resolveApiSource({
776
+ const { apiOverviewFiles } = resolveApiSource({
650
777
  srcDir,
651
778
  buildPath,
652
779
  })
653
- let apiSourcePath = resolvedApi
654
780
  let middlewareSourcePath = path.resolve('src/middleware.js');
655
781
  const publicDir = path.resolve('public')
656
782
 
@@ -662,14 +788,11 @@ export const build = async (cliArgs) => {
662
788
  ? configPath
663
789
  : path.resolve(scriptDir, 'default-config.js')
664
790
 
665
- const pagesBundlePath = path.join(ossyDir, OSSY_PAGES_PRERENDER_BUNDLE)
666
- const apiBundlePath = path.join(ossyDir, OSSY_API_SERVER_BUNDLE)
667
- const tasksBundlePath = path.join(ossyDir, OSSY_TASKS_SERVER_BUNDLE)
791
+ const pagesEntryPath = path.join(ossyDir, OSSY_PAGES_RUNTIME_BASENAME)
668
792
 
669
793
  const useDashboard = Object.keys(clientHydrateInput).length > 0
670
794
  const overviewSnap = getBuildOverviewSnapshot({
671
795
  pagesSourcePath: pagesGeneratedPath,
672
- apiSourcePath,
673
796
  apiOverviewFiles,
674
797
  configPath,
675
798
  pageFiles,
@@ -706,7 +829,6 @@ export const build = async (cliArgs) => {
706
829
  console.log('\n \x1b[1m@ossy/app\x1b[0m \x1b[2mbuild\x1b[0m')
707
830
  printBuildOverview({
708
831
  pagesSourcePath: pagesGeneratedPath,
709
- apiSourcePath,
710
832
  apiOverviewFiles,
711
833
  configPath,
712
834
  pageFiles,
@@ -724,28 +846,20 @@ export const build = async (cliArgs) => {
724
846
  }
725
847
  : undefined
726
848
 
727
- await bundleOssyNodeEntry({
728
- inputPath: pagesGeneratedPath,
729
- outputFile: pagesBundlePath,
730
- nodeEnv: 'production',
731
- onWarn: warnSink,
732
- })
733
- await bundleOssyNodeEntry({
734
- inputPath: apiSourcePath,
735
- outputFile: apiBundlePath,
736
- nodeEnv: 'production',
737
- onWarn: warnSink,
738
- })
739
- const { taskSourcePath } = resolveTaskSource({ srcDir, buildPath })
740
- await bundleOssyNodeEntry({
741
- inputPath: taskSourcePath,
742
- outputFile: tasksBundlePath,
849
+ const { taskOverviewFiles } = resolveTaskSource({ srcDir, buildPath })
850
+ await compileOssyNodeArtifacts({
851
+ pageFiles,
852
+ srcDir,
853
+ ossyDir,
854
+ apiFiles: apiOverviewFiles,
855
+ taskFiles: taskOverviewFiles,
743
856
  nodeEnv: 'production',
744
857
  onWarn: warnSink,
745
858
  })
746
859
 
747
860
  writeAppRuntimeShims({
748
861
  middlewareSourcePath,
862
+ configSourcePath,
749
863
  ossyDir,
750
864
  })
751
865
  copyOssyAppRuntime({ scriptDir, buildPath })
@@ -759,7 +873,7 @@ export const build = async (cliArgs) => {
759
873
  copyPublicFrom: publicDir,
760
874
  buildPath,
761
875
  nodeEnv: 'production',
762
- pagesBundlePath,
876
+ pagesEntryPath,
763
877
  configSourcePath,
764
878
  createClientRollupPlugins: createOssyClientRollupPlugins,
765
879
  minifyBrowserStaticChunks,
package/cli/dev.js CHANGED
@@ -7,11 +7,11 @@ import {
7
7
  filePathToRoute,
8
8
  discoverFilesByPattern,
9
9
  PAGE_FILE_PATTERN,
10
- generatePagesModule,
10
+ writePagesManifest,
11
11
  resolveApiSource,
12
12
  resolveTaskSource,
13
13
  resetOssyBuildDir,
14
- bundleOssyNodeEntry,
14
+ compileOssyNodeArtifacts,
15
15
  copyOssyAppRuntime,
16
16
  writeAppRuntimeShims,
17
17
  createOssyAppBundlePlugins,
@@ -21,11 +21,7 @@ import {
21
21
  clientHydrateIdForPage,
22
22
  ossyGeneratedDir,
23
23
  OSSY_GEN_PAGES_BASENAME,
24
- OSSY_GEN_API_BASENAME,
25
- OSSY_GEN_TASKS_BASENAME,
26
- OSSY_PAGES_PRERENDER_BUNDLE,
27
- OSSY_API_SERVER_BUNDLE,
28
- OSSY_TASKS_SERVER_BUNDLE,
24
+ OSSY_PAGES_RUNTIME_BASENAME,
29
25
  writeResourceTemplatesBarrelIfPresent,
30
26
  resourceTemplatesDir,
31
27
  OSSY_RESOURCE_TEMPLATES_OUT,
@@ -48,7 +44,7 @@ export const dev = async (cliArgs) => {
48
44
  const buildPath = path.resolve('build')
49
45
  const srcDir = path.resolve('src')
50
46
  const configPath = path.resolve(options['--config'] || 'src/config.js');
51
- const pageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
47
+ let currentPageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
52
48
 
53
49
  resetOssyBuildDir(buildPath)
54
50
 
@@ -58,35 +54,33 @@ export const dev = async (cliArgs) => {
58
54
  })
59
55
  let resourceTemplatesDevLogged = false
60
56
 
61
- const pagesGeneratedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
62
- fs.writeFileSync(
63
- pagesGeneratedPath,
64
- generatePagesModule(pageFiles, srcDir, pagesGeneratedPath)
65
- )
66
57
  const ossyDir = ossyGeneratedDir(buildPath)
67
- writePageHydrateStubs(pageFiles, srcDir, ossyDir)
68
- const clientHydrateInput = buildClientHydrateInput(pageFiles, srcDir, ossyDir)
69
-
70
- const {
71
- apiSourcePath: resolvedApi,
72
- apiOverviewFiles,
73
- } = resolveApiSource({
58
+ const pagesGeneratedPath = path.join(ossyDir, OSSY_GEN_PAGES_BASENAME)
59
+ writePagesManifest({
60
+ pageFiles: currentPageFiles,
74
61
  srcDir,
75
- buildPath,
62
+ pagesGeneratedPath,
76
63
  })
77
- let apiSourcePath = resolvedApi
78
- resolveTaskSource({ srcDir, buildPath })
64
+ writePageHydrateStubs(currentPageFiles, srcDir, ossyDir)
65
+ const clientHydrateInput = buildClientHydrateInput(currentPageFiles, srcDir, ossyDir)
66
+
67
+ let apiOverviewFiles = []
68
+ let taskOverviewFiles = []
69
+ const refreshApiTaskManifests = () => {
70
+ apiOverviewFiles = resolveApiSource({ srcDir, buildPath }).apiOverviewFiles
71
+ taskOverviewFiles = resolveTaskSource({ srcDir, buildPath }).taskOverviewFiles
72
+ }
73
+ refreshApiTaskManifests()
79
74
  let middlewareSourcePath = path.resolve(options['--middleware-source'] || 'src/middleware.js');
80
75
  const publicDir = path.resolve('public')
81
76
 
82
- if (pageFiles.length === 0) {
77
+ if (currentPageFiles.length === 0) {
83
78
  console.log('\n \x1b[1m@ossy/app\x1b[0m \x1b[2mdev\x1b[0m')
84
79
  printBuildOverview({
85
80
  pagesSourcePath: pagesGeneratedPath,
86
- apiSourcePath,
87
81
  apiOverviewFiles,
88
82
  configPath,
89
- pageFiles,
83
+ pageFiles: currentPageFiles,
90
84
  })
91
85
  if (resourceTemplatesResult.wrote && resourceTemplatesResult.path) {
92
86
  console.log(
@@ -103,29 +97,21 @@ export const dev = async (cliArgs) => {
103
97
  ? configPath
104
98
  : path.resolve(scriptDir, 'default-config.js')
105
99
 
106
- const pagesBundlePath = path.join(ossyDir, OSSY_PAGES_PRERENDER_BUNDLE)
107
- const apiBundlePath = path.join(ossyDir, OSSY_API_SERVER_BUNDLE)
108
- const tasksBundlePath = path.join(ossyDir, OSSY_TASKS_SERVER_BUNDLE)
109
- const tasksGeneratedPath = path.join(ossyDir, OSSY_GEN_TASKS_BASENAME)
100
+ const pagesEntryPath = path.join(ossyDir, OSSY_PAGES_RUNTIME_BASENAME)
110
101
 
111
102
  const runNodeBundles = async () => {
112
- await bundleOssyNodeEntry({
113
- inputPath: pagesGeneratedPath,
114
- outputFile: pagesBundlePath,
115
- nodeEnv: 'development',
116
- })
117
- await bundleOssyNodeEntry({
118
- inputPath: apiSourcePath,
119
- outputFile: apiBundlePath,
120
- nodeEnv: 'development',
121
- })
122
- await bundleOssyNodeEntry({
123
- inputPath: tasksGeneratedPath,
124
- outputFile: tasksBundlePath,
103
+ refreshApiTaskManifests()
104
+ await compileOssyNodeArtifacts({
105
+ pageFiles: currentPageFiles,
106
+ srcDir,
107
+ ossyDir,
108
+ apiFiles: apiOverviewFiles,
109
+ taskFiles: taskOverviewFiles,
125
110
  nodeEnv: 'development',
126
111
  })
127
112
  writeAppRuntimeShims({
128
113
  middlewareSourcePath,
114
+ configSourcePath,
129
115
  ossyDir,
130
116
  })
131
117
  copyOssyAppRuntime({ scriptDir, buildPath })
@@ -194,38 +180,7 @@ export const dev = async (cliArgs) => {
194
180
  }
195
181
 
196
182
  const nodeWatchOpts = { watch: { clearScreen: false } }
197
- const watchConfigs = [
198
- {
199
- input: pagesGeneratedPath,
200
- output: {
201
- file: pagesBundlePath,
202
- format: 'esm',
203
- inlineDynamicImports: true,
204
- },
205
- plugins: createOssyAppBundlePlugins({ nodeEnv: 'development' }),
206
- ...nodeWatchOpts,
207
- },
208
- {
209
- input: apiSourcePath,
210
- output: {
211
- file: apiBundlePath,
212
- format: 'esm',
213
- inlineDynamicImports: true,
214
- },
215
- plugins: createOssyAppBundlePlugins({ nodeEnv: 'development' }),
216
- ...nodeWatchOpts,
217
- },
218
- {
219
- input: tasksGeneratedPath,
220
- output: {
221
- file: tasksBundlePath,
222
- format: 'esm',
223
- inlineDynamicImports: true,
224
- },
225
- plugins: createOssyAppBundlePlugins({ nodeEnv: 'development' }),
226
- ...nodeWatchOpts,
227
- },
228
- ]
183
+ const watchConfigs = []
229
184
  if (Object.keys(clientHydrateInput).length > 0) {
230
185
  watchConfigs.push({
231
186
  input: clientHydrateInput,
@@ -233,6 +188,30 @@ export const dev = async (cliArgs) => {
233
188
  plugins: clientPlugins,
234
189
  watch: { clearScreen: false },
235
190
  })
191
+ } else {
192
+ watchConfigs.push({
193
+ input: '\0ossy-dev-noop',
194
+ output: {
195
+ file: path.join(ossyDir, '.dev-noop-out.mjs'),
196
+ format: 'esm',
197
+ inlineDynamicImports: true,
198
+ },
199
+ plugins: [
200
+ {
201
+ name: 'ossy-dev-noop',
202
+ resolveId (id) {
203
+ if (id === '\0ossy-dev-noop') return id
204
+ return null
205
+ },
206
+ load (id) {
207
+ if (id === '\0ossy-dev-noop') return 'export default 0\n'
208
+ return null
209
+ },
210
+ },
211
+ ...createOssyAppBundlePlugins({ nodeEnv: 'development' }),
212
+ ],
213
+ ...nodeWatchOpts,
214
+ })
236
215
  }
237
216
  const watcher = watch(watchConfigs)
238
217
 
@@ -251,22 +230,22 @@ export const dev = async (cliArgs) => {
251
230
  if (event.code === 'END') {
252
231
  writeAppRuntimeShims({
253
232
  middlewareSourcePath,
233
+ configSourcePath,
254
234
  ossyDir,
255
235
  })
256
236
  copyOssyAppRuntime({ scriptDir, buildPath })
257
- if (pageFiles.length > 0) {
237
+ if (currentPageFiles.length > 0) {
258
238
  const pageIds = [
259
- ...new Set(pageFiles.map((f) => clientHydrateIdForPage(f, srcDir))),
239
+ ...new Set(currentPageFiles.map((f) => clientHydrateIdForPage(f, srcDir))),
260
240
  ].sort()
261
241
  const overviewSnap = getBuildOverviewSnapshot({
262
242
  pagesSourcePath: pagesGeneratedPath,
263
- apiSourcePath,
264
243
  apiOverviewFiles,
265
244
  configPath,
266
- pageFiles,
245
+ pageFiles: currentPageFiles,
267
246
  })
268
247
  const idToPath = Object.fromEntries(
269
- pageFiles.map((f) => [
248
+ currentPageFiles.map((f) => [
270
249
  clientHydrateIdForPage(f, srcDir),
271
250
  filePathToRoute(f, srcDir).path,
272
251
  ])
@@ -298,7 +277,7 @@ export const dev = async (cliArgs) => {
298
277
  try {
299
278
  await prerenderReactTask.handler({
300
279
  op: 'prerenderPagesParallel',
301
- pagesBundlePath,
280
+ pagesEntryPath,
302
281
  configSourcePath,
303
282
  publicDir: path.join(buildPath, 'public'),
304
283
  reporter,
@@ -315,37 +294,34 @@ export const dev = async (cliArgs) => {
315
294
 
316
295
  const regenApiGenerated = () => {
317
296
  if (options['--api-source']) return
318
- resolveApiSource({
319
- srcDir,
320
- buildPath,
321
- })
322
- const gen = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_API_BASENAME)
323
- if (fs.existsSync(gen) && typeof watcher?.invalidate === 'function') {
324
- watcher.invalidate(gen)
325
- }
297
+ void runNodeBundles().then(() => scheduleRestart())
326
298
  }
327
299
 
328
300
  const regenTasksGenerated = () => {
329
- resolveTaskSource({ srcDir, buildPath })
330
- if (fs.existsSync(tasksGeneratedPath) && typeof watcher?.invalidate === 'function') {
331
- watcher.invalidate(tasksGeneratedPath)
332
- }
301
+ void runNodeBundles().then(() => scheduleRestart())
333
302
  }
334
303
 
335
304
  fs.watch(srcDir, { recursive: true }, (eventType, filename) => {
336
305
  if (!filename) return
337
306
  if (/\.page\.(jsx?|tsx?)$/.test(filename)) {
338
307
  const refreshedPageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
308
+ currentPageFiles = refreshedPageFiles
339
309
  const regenPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
340
- fs.writeFileSync(regenPath, generatePagesModule(refreshedPageFiles, srcDir, regenPath))
310
+ writePagesManifest({
311
+ pageFiles: refreshedPageFiles,
312
+ srcDir,
313
+ pagesGeneratedPath: regenPath,
314
+ })
341
315
  writePageHydrateStubs(refreshedPageFiles, srcDir, ossyDir)
342
- if (typeof watcher?.invalidate === 'function') {
343
- watcher.invalidate(regenPath)
344
- for (const f of refreshedPageFiles) {
345
- const hid = clientHydrateIdForPage(f, srcDir)
346
- watcher.invalidate(path.join(ossyDir, `hydrate-${hid}.jsx`))
316
+ void runNodeBundles().then(() => {
317
+ if (typeof watcher?.invalidate === 'function') {
318
+ for (const f of refreshedPageFiles) {
319
+ const hid = clientHydrateIdForPage(f, srcDir)
320
+ watcher.invalidate(path.join(ossyDir, `hydrate-${hid}.jsx`))
321
+ }
347
322
  }
348
- }
323
+ scheduleRestart()
324
+ })
349
325
  }
350
326
  if (/\.api\.(mjs|cjs|js)$/.test(filename)) {
351
327
  regenApiGenerated()
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import React from 'react'
5
+
6
+ const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ function readJson (name) {
9
+ return JSON.parse(fs.readFileSync(path.join(__ossyDir, name), 'utf8'))
10
+ }
11
+
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
+ }
38
+
39
+ export default out
@@ -2,7 +2,7 @@ import path from 'path'
2
2
  import url from 'url'
3
3
  import fs from 'fs'
4
4
  import { rollup } from 'rollup'
5
- import { BuildPage } from './render-page.task.js'
5
+ import { BuildPage, buildPrerenderAppConfig } from './render-page.task.js'
6
6
  import { pageIdFromHydrateEntryName } from './build-terminal.js'
7
7
 
8
8
  /**
@@ -24,35 +24,6 @@ function pathIsPrerenderable (routePath) {
24
24
  return true
25
25
  }
26
26
 
27
- /** Mirrors server-era app shell config with static defaults (no cookies / session). */
28
- export function buildPrerenderAppConfig ({
29
- buildTimeConfig,
30
- pageList,
31
- activeRouteId,
32
- urlPath,
33
- }) {
34
- const pages = pageList.map((page) => {
35
- const entry = {
36
- id: page?.id,
37
- path: page?.path,
38
- }
39
- if (activeRouteId != null && page?.id === activeRouteId) {
40
- entry.element = page?.element
41
- }
42
- return entry
43
- })
44
- return {
45
- ...buildTimeConfig,
46
- url: urlPath,
47
- theme: buildTimeConfig.theme || 'light',
48
- isAuthenticated: false,
49
- workspaceId: buildTimeConfig.workspaceId,
50
- apiUrl: buildTimeConfig.apiUrl,
51
- pages,
52
- sidebarPrimaryCollapsed: false,
53
- }
54
- }
55
-
56
27
  function copyPublicToBuild ({ copyPublicFrom, buildPath }) {
57
28
  if (!copyPublicFrom || !fs.existsSync(copyPublicFrom)) return
58
29
  const dest = path.join(buildPath, 'public')
@@ -170,13 +141,13 @@ async function prerenderOnePage ({
170
141
  }
171
142
 
172
143
  async function prerenderPagesParallel ({
173
- pagesBundlePath,
144
+ pagesEntryPath,
174
145
  configSourcePath,
175
146
  publicDir,
176
147
  reporter,
177
148
  }) {
178
149
  const cfgHref = url.pathToFileURL(path.resolve(configSourcePath)).href
179
- const pagesHref = url.pathToFileURL(path.resolve(pagesBundlePath)).href
150
+ const pagesHref = url.pathToFileURL(path.resolve(pagesEntryPath)).href
180
151
 
181
152
  const configModule = await import(cfgHref)
182
153
  const pagesModule = await import(pagesHref)
@@ -237,7 +208,7 @@ async function runProduction ({
237
208
  copyPublicFrom,
238
209
  buildPath,
239
210
  nodeEnv,
240
- pagesBundlePath,
211
+ pagesEntryPath,
241
212
  configSourcePath,
242
213
  createClientRollupPlugins,
243
214
  minifyBrowserStaticChunks,
@@ -259,7 +230,7 @@ async function runProduction ({
259
230
  })
260
231
 
261
232
  const { failures: prerenderFailures } = await prerenderPagesParallel({
262
- pagesBundlePath,
233
+ pagesEntryPath,
263
234
  configSourcePath,
264
235
  publicDir: path.join(buildPath, 'public'),
265
236
  reporter,
@@ -284,7 +255,7 @@ export default {
284
255
  }
285
256
  if (op === 'prerenderPagesParallel') {
286
257
  return prerenderPagesParallel({
287
- pagesBundlePath: input.pagesBundlePath,
258
+ pagesEntryPath: input.pagesEntryPath,
288
259
  configSourcePath: input.configSourcePath,
289
260
  publicDir: input.publicDir,
290
261
  reporter: input.reporter,
@@ -1,6 +1,38 @@
1
1
  import React, { cloneElement } from 'react'
2
2
  import { prerenderToNodeStream } from 'react-dom/static'
3
3
 
4
+ /**
5
+ * App shell config for SSR / prerender (mirrors client: theme, pages metadata, active route element).
6
+ */
7
+ export function buildPrerenderAppConfig ({
8
+ buildTimeConfig,
9
+ pageList,
10
+ activeRouteId,
11
+ urlPath,
12
+ isAuthenticated = false,
13
+ }) {
14
+ const pages = pageList.map((page) => {
15
+ const entry = {
16
+ id: page?.id,
17
+ path: page?.path,
18
+ }
19
+ if (activeRouteId != null && page?.id === activeRouteId) {
20
+ entry.element = page?.element
21
+ }
22
+ return entry
23
+ })
24
+ return {
25
+ ...buildTimeConfig,
26
+ url: urlPath,
27
+ theme: buildTimeConfig.theme || 'light',
28
+ isAuthenticated,
29
+ workspaceId: buildTimeConfig.workspaceId,
30
+ apiUrl: buildTimeConfig.apiUrl,
31
+ pages,
32
+ sidebarPrimaryCollapsed: false,
33
+ }
34
+ }
35
+
4
36
  /** Strips non-JSON content (e.g. React elements on `pages`) for the bootstrap script. */
5
37
  export function appConfigForBootstrap (appConfig) {
6
38
  if (!appConfig || typeof appConfig !== 'object') return appConfig
package/cli/server.js CHANGED
@@ -6,12 +6,34 @@ import { Router as OssyRouter } from '@ossy/router'
6
6
  import { ProxyInternal } from './proxy-internal.js'
7
7
  import cookieParser from 'cookie-parser'
8
8
 
9
- import ApiRoutes from './.ossy/api.bundle.js'
9
+ import ApiRoutes from './.ossy/api.runtime.mjs'
10
+ import pageRoutes from './.ossy/pages.runtime.mjs'
11
+ import buildTimeConfig from './.ossy/server-config.runtime.mjs'
12
+ import { BuildPage, buildPrerenderAppConfig } from './.ossy/render-page.task.js'
10
13
  import Middleware from './.ossy/middleware.runtime.js'
11
14
 
12
15
  /** API bundle default may be an empty array. */
13
16
  const apiRouteList = ApiRoutes ?? []
14
17
 
18
+ const sitePageList = Array.isArray(pageRoutes) ? pageRoutes : []
19
+
20
+ /** When `src/config.js` is minimal, infer language list from the first multi-path page. */
21
+ function pageRouterLanguageOptions (config, pages) {
22
+ let supported = config?.supportedLanguages
23
+ let defaultLanguage = config?.defaultLanguage
24
+ if ((!supported || supported.length <= 1) && pages.length > 0) {
25
+ const p0 = pages[0]
26
+ if (p0 && typeof p0.path === 'object' && p0.path != null) {
27
+ supported = Object.keys(p0.path)
28
+ defaultLanguage = defaultLanguage || supported[0]
29
+ }
30
+ }
31
+ return {
32
+ supportedLanguages: Array.isArray(supported) ? supported : [],
33
+ defaultLanguage,
34
+ }
35
+ }
36
+
15
37
  const app = express();
16
38
 
17
39
  const currentDir = path.dirname(url.fileURLToPath(import.meta.url))
@@ -101,22 +123,62 @@ const middleware = [
101
123
 
102
124
  app.use(middleware)
103
125
 
104
- const Router = OssyRouter.of({
126
+ const apiRouter = OssyRouter.of({
105
127
  pages: apiRouteList,
106
128
  })
107
129
 
108
- app.all('*all', (req, res) => {
109
- const pathname = req.originalUrl
130
+ const { supportedLanguages, defaultLanguage } = pageRouterLanguageOptions(
131
+ buildTimeConfig,
132
+ sitePageList
133
+ )
110
134
 
111
- const route = Router.getPageByUrl(pathname)
135
+ const pageRouter = OssyRouter.of({
136
+ pages: sitePageList,
137
+ defaultLanguage,
138
+ supportedLanguages,
139
+ })
112
140
 
113
- if (route && typeof route.handle === 'function') {
114
- console.log(`[@ossy/app][server] Handling API route: ${pathname}`)
115
- route.handle(req, res)
116
- return
117
- }
141
+ app.all('*all', async (req, res) => {
142
+ const requestUrl = req.originalUrl || '/'
118
143
 
119
- res.status(404).send('Not found')
144
+ try {
145
+ const apiRoute = apiRouter.getPageByUrl(requestUrl)
146
+ if (apiRoute && typeof apiRoute.handle === 'function') {
147
+ console.log(`[@ossy/app][server] Handling API route: ${requestUrl}`)
148
+ apiRoute.handle(req, res)
149
+ return
150
+ }
151
+
152
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
153
+ res.status(404).send('Not found')
154
+ return
155
+ }
156
+
157
+ const pageRoute = pageRouter.getPageByUrl(requestUrl)
158
+ if (pageRoute?.element) {
159
+ const appConfig = buildPrerenderAppConfig({
160
+ buildTimeConfig,
161
+ pageList: sitePageList,
162
+ activeRouteId: pageRoute.id,
163
+ urlPath: requestUrl,
164
+ isAuthenticated: !!req.isAuthenticated,
165
+ })
166
+ const html = await BuildPage.handle({
167
+ route: pageRoute,
168
+ appConfig,
169
+ isDevReloadEnabled,
170
+ })
171
+ res.status(200).type('html').send(html)
172
+ return
173
+ }
174
+
175
+ res.status(404).send('Not found')
176
+ } catch (err) {
177
+ console.error('[@ossy/app][server] Request handling failed:', err)
178
+ if (!res.headersSent) {
179
+ res.status(500).type('text').send('Internal Server Error')
180
+ }
181
+ }
120
182
  })
121
183
 
122
184
  app.listen(port, () => {
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+
5
+ const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
6
+
7
+ function normalizeTaskExport (mod) {
8
+ const d = mod?.default
9
+ if (d == null) return []
10
+ return Array.isArray(d) ? d : [d]
11
+ }
12
+
13
+ const { modules } = JSON.parse(fs.readFileSync(path.join(__ossyDir, 'tasks.bundle.json'), 'utf8'))
14
+
15
+ const out = []
16
+ for (const rel of modules) {
17
+ const abs = path.resolve(__ossyDir, rel)
18
+ const mod = await import(pathToFileURL(abs).href)
19
+ out.push(...normalizeTaskExport(mod))
20
+ }
21
+
22
+ export default out
@@ -1,5 +1,5 @@
1
1
  import 'dotenv/config'
2
- import taskHandlers from './.ossy/tasks.bundle.js'
2
+ import taskHandlers from './.ossy/tasks.runtime.mjs'
3
3
  import { runWorkerScheduler } from './worker-runtime.js'
4
4
 
5
5
  runWorkerScheduler(taskHandlers)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
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.1",
31
- "@ossy/design-system": "^1.11.1",
32
- "@ossy/pages": "^1.11.1",
33
- "@ossy/router": "^1.11.1",
34
- "@ossy/router-react": "^1.11.1",
35
- "@ossy/sdk": "^1.11.1",
36
- "@ossy/sdk-react": "^1.11.1",
37
- "@ossy/themes": "^1.11.1",
30
+ "@ossy/connected-components": "^1.11.3",
31
+ "@ossy/design-system": "^1.11.3",
32
+ "@ossy/pages": "^1.11.3",
33
+ "@ossy/router": "^1.11.3",
34
+ "@ossy/router-react": "^1.11.3",
35
+ "@ossy/sdk": "^1.11.3",
36
+ "@ossy/sdk-react": "^1.11.3",
37
+ "@ossy/themes": "^1.11.3",
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": "dfa200a3ea12c726db2054a0ab902844507716a0"
70
+ "gitHead": "c8b8fe48b3462ef9f7994894a1b17d844740dc11"
71
71
  }