@ossy/app 0.15.13 → 1.0.1

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
@@ -5,28 +5,95 @@ import { rollup } from 'rollup';
5
5
  import babel from '@rollup/plugin-babel';
6
6
  import { nodeResolve as resolveDependencies } from '@rollup/plugin-node-resolve'
7
7
  import resolveCommonJsDependencies from '@rollup/plugin-commonjs'
8
- import removeOwnPeerDependencies from 'rollup-plugin-peer-deps-external'
9
- import minifyJS from '@rollup/plugin-terser'
8
+ import { minify as minifyWithTerser } from 'terser'
10
9
  // import typescript from '@rollup/plugin-typescript'
11
- import preserveDirectives from "rollup-plugin-preserve-directives"
12
10
  import json from "@rollup/plugin-json"
11
+ import nodeExternals from 'rollup-plugin-node-externals'
13
12
  import copy from 'rollup-plugin-copy';
14
13
  import replace from '@rollup/plugin-replace';
15
14
  import arg from 'arg'
16
15
  import { ensureBuildStubs } from '../scripts/ensure-build-stubs.mjs'
17
- import { builtinModules, createRequire } from 'node:module'
16
+ import { createRequire } from 'node:module'
18
17
  // import inject from '@rollup/plugin-inject'
19
18
 
20
19
  const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
21
20
  const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
22
21
  const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
22
+ const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.js$/
23
23
 
24
- /** Subfolder under `--destination` / `build` for generated pages/api/task entry stubs. */
24
+ const NODE_MODULES_SEG = `${path.sep}node_modules${path.sep}`
25
+
26
+ /**
27
+ * Keep third-party code out of the Rollup graph for the server build.
28
+ * `preserveModules` + `@rollup/plugin-commonjs` otherwise emits `_virtual/*` chunks that import
29
+ * `{ __require }` from real `node_modules` paths, which Node ESM cannot execute.
30
+ *
31
+ * Rules:
32
+ * - Resolved absolute path under `node_modules` → external (any npm dep, including transitive).
33
+ * - Unresolved bare specifier → external so Node loads it (covers `react`, `lodash`, customers’
34
+ * `@acme/*`, etc.). Exception: `@ossy/*` is bundled (framework packages; monorepo paths or
35
+ * published tarballs — avoids relying on Node resolving those bare ids from `build/server.js`).
36
+ *
37
+ * React’s main entry is still CJS; there is no separate official ESM prod file for `import 'react'`.
38
+ * Leaving it external is correct: Node applies default export interop when Rollup does not rewrite it.
39
+ */
40
+ export function ossyServerExternal (id, _importer, _isResolved) {
41
+ if (!id || id[0] === '\0') return false
42
+ if (path.isAbsolute(id)) {
43
+ return id.includes(NODE_MODULES_SEG)
44
+ }
45
+ if (id.startsWith('.')) return false
46
+ if (id.startsWith('@ossy/')) return false
47
+ return true
48
+ }
49
+
50
+ /** Written next to `*.resource.js` under `src/resource-templates/` when that dir exists. */
51
+ export const OSSY_RESOURCE_TEMPLATES_OUT = '.ossy-system-templates.generated.js'
52
+
53
+ /** Rollup output paths for JS served to the browser (see `entryFileNames` / `chunkFileNames`). */
54
+ const BROWSER_STATIC_PREFIX = 'public/static/'
55
+
56
+ function minifyBrowserStaticChunks () {
57
+ return {
58
+ name: 'minify-browser-static-chunks',
59
+ async renderChunk (code, chunk, outputOptions) {
60
+ const fileName = chunk.fileName
61
+ if (!fileName || !fileName.startsWith(BROWSER_STATIC_PREFIX)) {
62
+ return null
63
+ }
64
+ const useSourceMap =
65
+ outputOptions.sourcemap === true || typeof outputOptions.sourcemap === 'string'
66
+ const fmt = outputOptions.format
67
+ const result = await minifyWithTerser(code, {
68
+ sourceMap: useSourceMap,
69
+ module: fmt === 'es' || fmt === 'esm',
70
+ })
71
+ const out = result.code ?? code
72
+ if (useSourceMap && result.map) {
73
+ const map =
74
+ typeof result.map === 'string' ? JSON.parse(result.map) : result.map
75
+ return { code: out, map }
76
+ }
77
+ return out
78
+ },
79
+ }
80
+ }
81
+
82
+ /** Subfolder under `build/` for generated pages/api/task entry stubs. */
25
83
  export const OSSY_GEN_DIRNAME = '.ossy'
26
84
  export const OSSY_GEN_PAGES_BASENAME = 'pages.generated.jsx'
27
85
  export const OSSY_GEN_API_BASENAME = 'api.generated.js'
28
86
  export const OSSY_GEN_TASKS_BASENAME = 'tasks.generated.js'
29
87
 
88
+ /** Per-page client entries: `hydrate-<pageId>.jsx` under `.ossy/` */
89
+ const HYDRATE_STUB_PREFIX = 'hydrate-'
90
+ const HYDRATE_STUB_SUFFIX = '.jsx'
91
+
92
+ /** Rollup input chunk name for a page id (safe identifier; id may contain `-`). */
93
+ export function hydrateEntryName (pageId) {
94
+ return `hydrate__${pageId}`
95
+ }
96
+
30
97
  export function ossyGeneratedDir (buildPath) {
31
98
  return path.join(buildPath, OSSY_GEN_DIRNAME)
32
99
  }
@@ -37,33 +104,142 @@ export function ensureOssyGeneratedDir (buildPath) {
37
104
  return dir
38
105
  }
39
106
 
40
- export function resolvePageShellSource (cwd, scriptDir) {
41
- const candidates = [
42
- path.resolve(cwd, 'src/page-shell.jsx'),
43
- path.resolve(cwd, 'src/page-shell.js'),
44
- ]
45
- for (const p of candidates) {
46
- if (fs.existsSync(p)) return p
107
+ function relToGeneratedImport (generatedAbs, targetAbs) {
108
+ return path.relative(path.dirname(generatedAbs), targetAbs).replace(/\\/g, '/')
109
+ }
110
+
111
+ /** Deletes the entire build output dir, then recreates `build/.ossy` (generated stubs are written next). */
112
+ export function resetOssyBuildDir (buildPath) {
113
+ fs.rmSync(buildPath, { recursive: true, force: true })
114
+ ensureOssyGeneratedDir(buildPath)
115
+ }
116
+
117
+ export function resourceTemplatesDir (cwd = process.cwd()) {
118
+ return path.join(cwd, 'src', 'resource-templates')
119
+ }
120
+
121
+ export function discoverResourceTemplateFiles (templatesDir) {
122
+ if (!fs.existsSync(templatesDir) || !fs.statSync(templatesDir).isDirectory()) {
123
+ return []
47
124
  }
48
- return path.resolve(scriptDir, 'page-shell-default.jsx')
125
+ return fs
126
+ .readdirSync(templatesDir)
127
+ .filter((n) => RESOURCE_TEMPLATE_FILE_PATTERN.test(n))
128
+ .map((n) => path.join(templatesDir, n))
129
+ .sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
49
130
  }
50
131
 
51
- function relToGeneratedImport (generatedAbs, targetAbs) {
52
- return path.relative(path.dirname(generatedAbs), targetAbs).replace(/\\/g, '/')
132
+ export function generateResourceTemplatesBarrelSource ({ outputAbs, templateFilesAbs }) {
133
+ const lines = [
134
+ '// Generated by @ossy/app — do not edit',
135
+ '',
136
+ ]
137
+ templateFilesAbs.forEach((f, i) => {
138
+ const rel = relToGeneratedImport(outputAbs, f)
139
+ lines.push(`import _resource${i} from './${rel}'`)
140
+ })
141
+ lines.push('')
142
+ lines.push(
143
+ '/** Built-in resource templates merged into every workspace (with imported templates) in the API. */'
144
+ )
145
+ lines.push('export const SystemTemplates = [')
146
+ templateFilesAbs.forEach((_, i) => {
147
+ lines.push(` _resource${i},`)
148
+ })
149
+ lines.push(']')
150
+ lines.push('')
151
+ return lines.join('\n')
53
152
  }
54
153
 
55
- /** Clears Rollup output dir but keeps `build/.ossy` (generated sources live there). */
56
- export function ossyCleanBuildDirPlugin (buildPath) {
57
- return {
58
- name: 'ossy-clean-build-dir',
59
- buildStart () {
60
- if (!fs.existsSync(buildPath)) return
61
- for (const name of fs.readdirSync(buildPath)) {
62
- if (name === OSSY_GEN_DIRNAME) continue
63
- fs.rmSync(path.join(buildPath, name), { recursive: true, force: true })
64
- }
65
- },
154
+ /**
155
+ * If `src/resource-templates/` exists, writes `.ossy-system-templates.generated.js` there.
156
+ * No-op when the directory is missing (e.g. website packages). Runs during `build` / `dev`.
157
+ */
158
+ export function writeResourceTemplatesBarrelIfPresent ({ cwd = process.cwd(), log = true } = {}) {
159
+ const dir = resourceTemplatesDir(cwd)
160
+ if (!fs.existsSync(dir)) {
161
+ return { wrote: false, count: 0, path: null }
162
+ }
163
+ const files = discoverResourceTemplateFiles(dir)
164
+ const outAbs = path.join(dir, OSSY_RESOURCE_TEMPLATES_OUT)
165
+ fs.writeFileSync(outAbs, generateResourceTemplatesBarrelSource({ outputAbs: outAbs, templateFilesAbs: files }), 'utf8')
166
+ if (log) {
167
+ console.log(
168
+ `[@ossy/app][resource-templates] merged ${files.length} template(s) → ${path.relative(cwd, outAbs)}`
169
+ )
66
170
  }
171
+ return { wrote: true, count: files.length, path: outAbs }
172
+ }
173
+
174
+ /**
175
+ * Shared Rollup plugins (virtual path replaces, resolve, JSX).
176
+ * Server builds pass `nodeExternals: true` so `dependencies` stay importable from Node at runtime.
177
+ */
178
+ export function createOssyRollupPlugins ({
179
+ pagesGeneratedPath,
180
+ apiSourcePath,
181
+ middlewareSourcePath,
182
+ configSourcePath,
183
+ nodeEnv,
184
+ nodeExternals: useNodeExternals = false,
185
+ preferBuiltins = true,
186
+ copyPublicFrom,
187
+ buildPath,
188
+ }) {
189
+ const plugins = [
190
+ replace({
191
+ preventAssignment: true,
192
+ delimiters: ['%%', '%%'],
193
+ '@ossy/pages/source-file': pagesGeneratedPath,
194
+ }),
195
+ replace({
196
+ preventAssignment: true,
197
+ delimiters: ['%%', '%%'],
198
+ '@ossy/api/source-file': apiSourcePath,
199
+ }),
200
+ replace({
201
+ preventAssignment: true,
202
+ delimiters: ['%%', '%%'],
203
+ '@ossy/middleware/source-file': middlewareSourcePath,
204
+ }),
205
+ replace({
206
+ preventAssignment: true,
207
+ delimiters: ['%%', '%%'],
208
+ '@ossy/config/source-file': configSourcePath,
209
+ }),
210
+ replace({
211
+ preventAssignment: true,
212
+ 'process.env.NODE_ENV': JSON.stringify(nodeEnv),
213
+ }),
214
+ json(),
215
+ ]
216
+ if (useNodeExternals) {
217
+ plugins.push(
218
+ nodeExternals({
219
+ deps: true,
220
+ devDeps: false,
221
+ peerDeps: true,
222
+ packagePath: path.join(process.cwd(), 'package.json'),
223
+ })
224
+ )
225
+ }
226
+ plugins.push(
227
+ resolveCommonJsDependencies(),
228
+ resolveDependencies({ preferBuiltins }),
229
+ babel({
230
+ babelHelpers: 'bundled',
231
+ extensions: ['.jsx', '.tsx'],
232
+ presets: ['@babel/preset-react'],
233
+ })
234
+ )
235
+ if (copyPublicFrom && fs.existsSync(copyPublicFrom)) {
236
+ plugins.push(
237
+ copy({
238
+ targets: [{ src: `${copyPublicFrom}/**/*`, dest: path.join(buildPath, 'public') }],
239
+ })
240
+ )
241
+ }
242
+ return plugins
67
243
  }
68
244
 
69
245
  export function discoverApiFiles(srcDir) {
@@ -103,19 +279,22 @@ export function discoverTaskFiles(srcDir) {
103
279
  }
104
280
 
105
281
  /**
106
- * Merges `src/api.js` (optional) and every `*.api.js` under the pages tree into one default export array
107
- * for the Ossy API router ({ id, path, handle }).
282
+ * Merges every `*.api.js` (and `.api.mjs` / `.api.cjs`) under `src/` into one default export array
283
+ * for the Ossy API router ({ id, path, handle }). With no API files, emits `export default []`.
108
284
  */
109
- export function generateApiModule ({ generatedPath, apiFiles, legacyPath }) {
285
+ export function generateApiModule ({ generatedPath, apiFiles }) {
286
+ if (apiFiles.length === 0) {
287
+ return [
288
+ '// Generated by @ossy/app — do not edit',
289
+ '',
290
+ 'export default []',
291
+ '',
292
+ ].join('\n')
293
+ }
110
294
  const lines = [
111
295
  '// Generated by @ossy/app — do not edit',
112
296
  '',
113
297
  ]
114
- const hasLegacy = legacyPath && fs.existsSync(legacyPath)
115
- if (hasLegacy) {
116
- const rel = relToGeneratedImport(generatedPath, legacyPath)
117
- lines.push(`import _legacyApi from './${rel}'`)
118
- }
119
298
  apiFiles.forEach((f, i) => {
120
299
  const rel = relToGeneratedImport(generatedPath, f)
121
300
  lines.push(`import * as _api${i} from './${rel}'`)
@@ -130,13 +309,7 @@ export function generateApiModule ({ generatedPath, apiFiles, legacyPath }) {
130
309
  '',
131
310
  'export default [',
132
311
  )
133
- const parts = []
134
- if (hasLegacy) {
135
- parts.push(' ..._normalizeApiExport({ default: _legacyApi }),')
136
- }
137
- apiFiles.forEach((_, i) => {
138
- parts.push(` ..._normalizeApiExport(_api${i}),`)
139
- })
312
+ const parts = apiFiles.map((_, i) => ` ..._normalizeApiExport(_api${i}),`)
140
313
  lines.push(parts.join('\n'))
141
314
  lines.push(']')
142
315
  lines.push('')
@@ -144,60 +317,29 @@ export function generateApiModule ({ generatedPath, apiFiles, legacyPath }) {
144
317
  }
145
318
 
146
319
  /**
147
- * Resolves the Rollup entry for @ossy/api/source-file and which files to scan for the build overview.
320
+ * Writes `build/.ossy/api.generated.js` and returns its path for `@ossy/api/source-file`.
321
+ * Always uses the generated file so the Rollup replace target stays stable.
148
322
  */
149
- export function resolveApiSource ({ cwd, pagesOpt, scriptDir, explicitApiSource, buildPath }) {
150
- const srcDir = path.resolve(cwd, pagesOpt)
151
- const legacyPath = path.resolve(cwd, 'src/api.js')
152
- const defaultStub = path.resolve(scriptDir, 'api.js')
153
- const bp = path.resolve(buildPath ?? path.join(cwd, 'build'))
154
- ensureOssyGeneratedDir(bp)
155
- const generatedPath = path.join(ossyGeneratedDir(bp), OSSY_GEN_API_BASENAME)
156
-
157
- if (explicitApiSource) {
158
- const p = path.isAbsolute(explicitApiSource)
159
- ? explicitApiSource
160
- : path.resolve(cwd, explicitApiSource)
161
- if (fs.existsSync(p)) {
162
- return { apiSourcePath: p, apiOverviewFiles: [p], usedGenerated: false }
163
- }
164
- return { apiSourcePath: defaultStub, apiOverviewFiles: [], usedGenerated: false }
165
- }
166
-
323
+ export function resolveApiSource ({ srcDir, buildPath }) {
324
+ ensureOssyGeneratedDir(buildPath)
325
+ const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_API_BASENAME)
167
326
  const apiFiles = discoverApiFiles(srcDir)
168
- const hasLegacy = fs.existsSync(legacyPath)
169
- // Always feed Rollup the same generated entry when there is any API surface,
170
- // so adding/removing `*.api.js` does not require changing the replace target mid-dev.
171
- if (apiFiles.length > 0 || hasLegacy) {
172
- fs.writeFileSync(
173
- generatedPath,
174
- generateApiModule({
175
- generatedPath,
176
- apiFiles,
177
- legacyPath: hasLegacy ? legacyPath : null,
178
- })
179
- )
180
- const overview = [...(hasLegacy ? [legacyPath] : []), ...apiFiles]
181
- return { apiSourcePath: generatedPath, apiOverviewFiles: overview, usedGenerated: true }
182
- }
183
-
184
- return { apiSourcePath: defaultStub, apiOverviewFiles: [], usedGenerated: false }
327
+ fs.writeFileSync(
328
+ generatedPath,
329
+ generateApiModule({ generatedPath, apiFiles })
330
+ )
331
+ return { apiSourcePath: generatedPath, apiOverviewFiles: apiFiles }
185
332
  }
186
333
 
187
334
  /**
188
335
  * Merges `src/tasks.js` (optional) and every `*.task.js` under `src/` into one default export array
189
336
  * of job handlers `{ type, handler }` for the Ossy worker.
190
337
  */
191
- export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
338
+ export function generateTaskModule ({ generatedPath, taskFiles }) {
192
339
  const lines = [
193
340
  '// Generated by @ossy/app — do not edit',
194
341
  '',
195
342
  ]
196
- const hasLegacy = legacyPath && fs.existsSync(legacyPath)
197
- if (hasLegacy) {
198
- const rel = relToGeneratedImport(generatedPath, legacyPath)
199
- lines.push(`import _legacyTasks from './${rel}'`)
200
- }
201
343
  taskFiles.forEach((f, i) => {
202
344
  const rel = relToGeneratedImport(generatedPath, f)
203
345
  lines.push(`import * as _task${i} from './${rel}'`)
@@ -213,9 +355,6 @@ export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
213
355
  'export default [',
214
356
  )
215
357
  const parts = []
216
- if (hasLegacy) {
217
- parts.push(' ..._normalizeTaskExport({ default: _legacyTasks }),')
218
- }
219
358
  taskFiles.forEach((_, i) => {
220
359
  parts.push(` ..._normalizeTaskExport(_task${i}),`)
221
360
  })
@@ -228,45 +367,28 @@ export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
228
367
  /**
229
368
  * Resolves the Rollup entry for @ossy/tasks/source-file and which files to list in the worker build overview.
230
369
  */
231
- export function resolveTaskSource ({ cwd, pagesOpt, scriptDir, explicitTaskSource, buildPath }) {
232
- const srcDir = path.resolve(cwd, pagesOpt)
233
- const legacyPath = path.resolve(cwd, 'src/tasks.js')
370
+ export function resolveTaskSource ({ srcDir, scriptDir, buildPath }) {
234
371
  const defaultStub = path.resolve(scriptDir, 'tasks.js')
235
- const bp = path.resolve(buildPath ?? path.join(cwd, 'build'))
236
- ensureOssyGeneratedDir(bp)
237
- const generatedPath = path.join(ossyGeneratedDir(bp), OSSY_GEN_TASKS_BASENAME)
238
-
239
- if (explicitTaskSource) {
240
- const p = path.isAbsolute(explicitTaskSource)
241
- ? explicitTaskSource
242
- : path.resolve(cwd, explicitTaskSource)
243
- if (fs.existsSync(p)) {
244
- return { taskSourcePath: p, taskOverviewFiles: [p], usedGenerated: false }
245
- }
246
- return { taskSourcePath: defaultStub, taskOverviewFiles: [], usedGenerated: false }
247
- }
372
+ ensureOssyGeneratedDir(buildPath)
373
+ const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_TASKS_BASENAME)
248
374
 
249
375
  const taskFiles = discoverTaskFiles(srcDir)
250
- const hasLegacy = fs.existsSync(legacyPath)
251
- if (taskFiles.length > 0 || hasLegacy) {
376
+ if (taskFiles.length > 0) {
252
377
  fs.writeFileSync(
253
378
  generatedPath,
254
379
  generateTaskModule({
255
380
  generatedPath,
256
381
  taskFiles,
257
- legacyPath: hasLegacy ? legacyPath : null,
258
382
  })
259
383
  )
260
- const overview = [...(hasLegacy ? [legacyPath] : []), ...taskFiles]
261
- return { taskSourcePath: generatedPath, taskOverviewFiles: overview, usedGenerated: true }
384
+ return { taskSourcePath: generatedPath, taskOverviewFiles: taskFiles, usedGenerated: true }
262
385
  }
263
386
 
264
387
  return { taskSourcePath: defaultStub, taskOverviewFiles: [], usedGenerated: false }
265
388
  }
266
389
 
267
390
  export function discoverPageFiles(srcDir) {
268
- const dir = path.resolve(srcDir)
269
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
391
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) {
270
392
  return []
271
393
  }
272
394
  const files = []
@@ -278,7 +400,7 @@ export function discoverPageFiles(srcDir) {
278
400
  else if (PAGE_FILE_PATTERN.test(e.name)) files.push(full)
279
401
  }
280
402
  }
281
- walk(dir)
403
+ walk(srcDir)
282
404
  return files.sort()
283
405
  }
284
406
 
@@ -291,29 +413,105 @@ export function filePathToRoute(filePath, srcDir) {
291
413
  return { id, path: routePath }
292
414
  }
293
415
 
294
- /** Route base for `filePathToRoute`: site `--pages` dir, or a package’s `src` dir (first `src` segment in the path). */
295
- export function pageFilesRouteBaseDir (filePath, sitePagesDir) {
296
- const fp = path.resolve(filePath)
297
- const siteRoot = path.resolve(sitePagesDir)
298
- const sitePrefix = siteRoot + path.sep
299
- if (fp === siteRoot || fp.startsWith(sitePrefix)) {
300
- return siteRoot
416
+ /**
417
+ * Basename for `/static/hydrate-<id>.js` must match `route.id` after `metadata` is merged in `toPage`
418
+ * (`{ ...derived, ...metadata }`). Uses a light `metadata` scan when possible.
419
+ */
420
+ export function clientHydrateIdForPage (pageAbsPath, srcDir) {
421
+ const derived = filePathToRoute(pageAbsPath, srcDir)
422
+ let src = ''
423
+ try {
424
+ src = fs.readFileSync(pageAbsPath, 'utf8')
425
+ } catch {
426
+ return derived.id
427
+ }
428
+ const metaIdx = src.indexOf('export const metadata')
429
+ if (metaIdx === -1) return derived.id
430
+ const after = src.slice(metaIdx)
431
+ const idMatch = after.match(/\bid\s*:\s*['"]([^'"]+)['"]/)
432
+ return idMatch ? idMatch[1] : derived.id
433
+ }
434
+
435
+ /**
436
+ * One client entry per page: imports only that page module and hydrates the document.
437
+ * Keeps the same `toPage` shape as `generatePagesModule` so SSR and client trees match.
438
+ */
439
+ export function generatePageHydrateModule ({ pageAbsPath, stubAbsPath, srcDir }) {
440
+ const rel = relToGeneratedImport(stubAbsPath, pageAbsPath)
441
+ const { id, path: routePath } = filePathToRoute(pageAbsPath, srcDir)
442
+ const pathLiteral = JSON.stringify(routePath)
443
+ const idLiteral = JSON.stringify(id)
444
+ return [
445
+ '// Generated by @ossy/app — do not edit',
446
+ '',
447
+ "import React, { cloneElement } from 'react'",
448
+ "import 'react-dom'",
449
+ "import { hydrateRoot } from 'react-dom/client'",
450
+ `import * as _page from './${rel}'`,
451
+ '',
452
+ 'function toPage(mod, derived) {',
453
+ ' const meta = mod?.metadata || {}',
454
+ ' const def = mod?.default',
455
+ ' if (typeof def === \'function\') {',
456
+ ' return { ...derived, ...meta, element: React.createElement(def) }',
457
+ ' }',
458
+ ' return { ...derived, ...meta, ...(def || {}) }',
459
+ '}',
460
+ '',
461
+ `const _route = toPage(_page, { id: ${idLiteral}, path: ${pathLiteral} })`,
462
+ 'const initialConfig = window.__INITIAL_APP_CONFIG__ || {}',
463
+ 'const rootTree = _route?.element',
464
+ ' ? cloneElement(_route.element, initialConfig)',
465
+ " : React.createElement('p', null, 'Not found')",
466
+ 'hydrateRoot(document, rootTree)',
467
+ '',
468
+ ].join('\n')
469
+ }
470
+
471
+ /** Writes `hydrate-<id>.jsx` for each page; removes stale `hydrate-*` outputs in `ossyDir` first. */
472
+ export function writePageHydrateStubs (pageFiles, srcDir, ossyDir) {
473
+ if (!fs.existsSync(ossyDir)) fs.mkdirSync(ossyDir, { recursive: true })
474
+ for (const ent of fs.readdirSync(ossyDir, { withFileTypes: true })) {
475
+ const full = path.join(ossyDir, ent.name)
476
+ if (ent.isDirectory() && ent.name.startsWith(HYDRATE_STUB_PREFIX)) {
477
+ fs.rmSync(full, { recursive: true, force: true })
478
+ } else if (
479
+ ent.isFile() &&
480
+ ent.name.startsWith(HYDRATE_STUB_PREFIX) &&
481
+ ent.name.endsWith(HYDRATE_STUB_SUFFIX)
482
+ ) {
483
+ fs.rmSync(full, { force: true })
484
+ }
301
485
  }
302
- const parts = fp.split(path.sep)
303
- const srcIdx = parts.indexOf('src')
304
- if (srcIdx !== -1) {
305
- return parts.slice(0, srcIdx + 1).join(path.sep)
486
+ const seenIds = new Set()
487
+ for (const f of pageFiles) {
488
+ const hydrateId = clientHydrateIdForPage(f, srcDir)
489
+ if (seenIds.has(hydrateId)) {
490
+ throw new Error(
491
+ `[@ossy/app] Duplicate client hydrate id "${hydrateId}" (${f}). Per-page hydrate bundles need unique ids.`
492
+ )
493
+ }
494
+ seenIds.add(hydrateId)
495
+ const stubPath = path.join(ossyDir, `${HYDRATE_STUB_PREFIX}${hydrateId}${HYDRATE_STUB_SUFFIX}`)
496
+ fs.mkdirSync(path.dirname(stubPath), { recursive: true })
497
+ fs.writeFileSync(
498
+ stubPath,
499
+ generatePageHydrateModule({ pageAbsPath: f, stubAbsPath: stubPath, srcDir })
500
+ )
306
501
  }
307
- return siteRoot
308
502
  }
309
503
 
310
- /** Derive `{ id, path }` for build overview when mixing site pages and installable `modules` package paths. */
311
- export function routeForPageFileOverview (filePath, sitePagesDir) {
312
- return filePathToRoute(filePath, pageFilesRouteBaseDir(filePath, sitePagesDir))
504
+ export function buildClientHydrateInput (pageFiles, srcDir, ossyDir) {
505
+ const input = {}
506
+ for (const f of pageFiles) {
507
+ const hydrateId = clientHydrateIdForPage(f, srcDir)
508
+ const stubPath = path.join(ossyDir, `${HYDRATE_STUB_PREFIX}${hydrateId}${HYDRATE_STUB_SUFFIX}`)
509
+ input[hydrateEntryName(hydrateId)] = stubPath
510
+ }
511
+ return input
313
512
  }
314
513
 
315
- export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
316
- const resolvedSrc = path.resolve(cwd, srcDir)
514
+ export function generatePagesModule (pageFiles, srcDir, generatedPath) {
317
515
  const lines = [
318
516
  "import React from 'react'",
319
517
  ...pageFiles.map((f, i) => {
@@ -332,8 +530,7 @@ export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
332
530
  '',
333
531
  'export default [',
334
532
  ...pageFiles.map((f, i) => {
335
- const base = pageFilesRouteBaseDir(f, resolvedSrc)
336
- const { id, path: defaultPath } = filePathToRoute(f, base)
533
+ const { id, path: defaultPath } = filePathToRoute(f, srcDir)
337
534
  const pathStr = JSON.stringify(defaultPath)
338
535
  return ` toPage(_page${i}, { id: '${id}', path: ${pathStr} }),`
339
536
  }),
@@ -342,81 +539,61 @@ export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
342
539
  return lines.join('\n')
343
540
  }
344
541
 
345
- function installableModulePackagesFromCfgObject (cfg, warn) {
346
- if (Array.isArray(cfg.modules) && cfg.modules.length) {
347
- return cfg.modules
348
- }
349
- if (Array.isArray(cfg.pagesModules) && cfg.pagesModules.length) {
350
- warn?.('[@ossy/app][build] `pagesModules` is deprecated; use `modules` in src/config.js')
351
- return cfg.pagesModules
352
- }
353
- if (typeof cfg.pagesModule === 'string') {
354
- warn?.('[@ossy/app][build] `pagesModule` is deprecated; use `modules: [ ... ]` in src/config.js')
355
- return [cfg.pagesModule]
356
- }
357
- return []
358
- }
359
-
360
- export async function discoverModulePageFiles({ cwd, configPath }) {
542
+ export async function discoverModulePageFiles({ configPath }) {
361
543
  if (!configPath || !fs.existsSync(configPath)) return []
362
544
  try {
363
545
  // Try a cheap static parse first so we don't depend on the config file being
364
546
  // importable (configs often import theme/template modules that may not be
365
547
  // resolvable in the build-time node context).
366
548
  const cfgSource = fs.readFileSync(configPath, 'utf8')
367
- const moduleNames = []
368
- let warnedLegacy = false
369
- const warnOnce = (msg) => {
370
- if (!warnedLegacy) {
371
- console.warn(msg)
372
- warnedLegacy = true
373
- }
374
- }
549
+ const modules = []
375
550
 
376
- const modulesArr = cfgSource.match(/\bmodules\s*:\s*\[([^\]]*)\]/m)
377
- const pagesModulesArr = cfgSource.match(/\bpagesModules\s*:\s*\[([^\]]*)\]/m)
378
- const listBody = modulesArr?.[1] ?? pagesModulesArr?.[1]
379
- if (listBody !== undefined && listBody !== null) {
380
- if (pagesModulesArr && !modulesArr) {
381
- warnOnce('[@ossy/app][build] `pagesModules` is deprecated; use `modules` in src/config.js')
382
- }
551
+ // pagesModules: ['a', "b"]
552
+ const arrMatch = cfgSource.match(/pagesModules\s*:\s*\[([^\]]*)\]/m)
553
+ if (arrMatch?.[1]) {
554
+ const body = arrMatch[1]
383
555
  const re = /['"]([^'"]+)['"]/g
384
556
  let m
385
- while ((m = re.exec(listBody)) !== null) moduleNames.push(m[1])
557
+ while ((m = re.exec(body)) !== null) modules.push(m[1])
386
558
  }
387
559
 
388
- if (moduleNames.length === 0) {
389
- const singlePagesModule = cfgSource.match(/\bpagesModule\s*:\s*['"]([^'"]+)['"]/m)
390
- if (singlePagesModule?.[1]) {
391
- warnOnce('[@ossy/app][build] `pagesModule` is deprecated; use `modules: [ ... ]` in src/config.js')
392
- moduleNames.push(singlePagesModule[1])
393
- }
560
+ // pagesModule: 'a'
561
+ if (modules.length === 0) {
562
+ const singleMatch = cfgSource.match(/pagesModule\s*:\s*['"]([^'"]+)['"]/m)
563
+ if (singleMatch?.[1]) modules.push(singleMatch[1])
394
564
  }
395
565
 
396
- const loadFromNames = (names) => {
397
- const req = createRequire(path.resolve(cwd, 'package.json'))
566
+ if (modules.length) {
567
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'))
398
568
  const files = []
399
- for (const name of names) {
569
+ for (const name of modules) {
400
570
  const pkgJsonPath = req.resolve(`${name}/package.json`)
401
571
  const pkgDir = path.dirname(pkgJsonPath)
402
- const moduleSrcDir = path.join(pkgDir, 'src')
403
- files.push(...discoverPageFiles(moduleSrcDir))
572
+ const modulePagesDir = path.join(pkgDir, 'src', 'pages')
573
+ files.push(...discoverPageFiles(modulePagesDir))
404
574
  }
405
575
  return files
406
576
  }
407
577
 
408
- if (moduleNames.length) {
409
- return loadFromNames(moduleNames)
410
- }
411
-
412
578
  const mod = await import(url.pathToFileURL(configPath))
413
579
  const cfg = mod?.default ?? mod ?? {}
414
- const modules2 = installableModulePackagesFromCfgObject(cfg, (msg) => console.warn(msg))
580
+ const modules2 = Array.isArray(cfg.pagesModules)
581
+ ? cfg.pagesModules
582
+ : (typeof cfg.pagesModule === 'string' ? [cfg.pagesModule] : [])
415
583
 
416
584
  if (!modules2.length) return []
417
- return loadFromNames(modules2)
585
+
586
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'))
587
+ const files = []
588
+ for (const name of modules2) {
589
+ const pkgJsonPath = req.resolve(`${name}/package.json`)
590
+ const pkgDir = path.dirname(pkgJsonPath)
591
+ const modulePagesDir = path.join(pkgDir, 'src', 'pages')
592
+ files.push(...discoverPageFiles(modulePagesDir))
593
+ }
594
+ return files
418
595
  } catch (e) {
419
- console.warn('[@ossy/app][build] `modules` config could not be loaded; continuing without installable module pages')
596
+ console.warn('[@ossy/app][build] pagesModules config could not be loaded; continuing without modules')
420
597
  return []
421
598
  }
422
599
  }
@@ -459,25 +636,19 @@ export function printBuildOverview({
459
636
  apiSourcePath,
460
637
  apiOverviewFiles = [],
461
638
  configPath,
462
- isPageFiles,
463
639
  pageFiles,
464
640
  }) {
465
- const rel = (p) => path.relative(process.cwd(), p)
466
- const cwd = process.cwd()
467
- const srcDir = path.resolve(cwd, 'src')
641
+ const rel = (p) => p ? path.relative(process.cwd(), p) : undefined
642
+ const srcDir = path.resolve(process.cwd(), 'src')
468
643
  console.log('\n \x1b[1mBuild overview\x1b[0m')
469
- console.log(' ' + '─'.repeat(50))
470
- console.log(` \x1b[36mPages:\x1b[0m ${rel(pagesSourcePath)}`)
471
644
  if (fs.existsSync(configPath)) {
472
645
  console.log(` \x1b[36mConfig:\x1b[0m ${rel(configPath)}`)
473
646
  }
474
- console.log(` \x1b[36mAPI:\x1b[0m ${rel(apiSourcePath)}`)
475
647
  console.log(' ' + '─'.repeat(50))
476
648
 
477
- const pages =
478
- isPageFiles && pageFiles?.length
479
- ? pageFiles.map((f) => routeForPageFileOverview(f, srcDir))
480
- : parsePagesFromSource(pagesSourcePath)
649
+ const pages = pageFiles?.length
650
+ ? pageFiles.map((f) => filePathToRoute(f, srcDir))
651
+ : parsePagesFromSource(pagesSourcePath)
481
652
  if (pages.length > 0) {
482
653
  console.log(' \x1b[36mRoutes:\x1b[0m')
483
654
  const maxId = Math.max(6, ...pages.map((p) => String(p.id).length))
@@ -510,192 +681,58 @@ export function printBuildOverview({
510
681
  console.log(' ' + '─'.repeat(50) + '\n')
511
682
  }
512
683
 
513
- const WORKER_EXTERNAL_IDS = new Set([
514
- ...builtinModules,
515
- ...builtinModules.map((m) => `node:${m}`),
516
- 'dotenv',
517
- 'dotenv/config',
518
- '@ossy/sdk',
519
- 'openai',
520
- 'sharp',
521
- ])
522
-
523
- function isWorkerExternal(id) {
524
- if (id.startsWith('.') || path.isAbsolute(id)) return false
525
- if (WORKER_EXTERNAL_IDS.has(id)) return true
526
- if (id.startsWith('node:')) return true
527
- if (id === '@ossy/sdk' || id.startsWith('@ossy/sdk/')) return true
528
- if (id === 'openai' || id.startsWith('openai/')) return true
529
- if (id === 'sharp' || id.startsWith('sharp/')) return true
530
- if (id === 'dotenv' || id.startsWith('dotenv/')) return true
531
- return false
532
- }
533
-
534
- /**
535
- * Worker-only bundle: discovers `*.task.js` (and optional `src/tasks.js`), writes `build/.ossy/tasks.generated.js`
536
- * when needed, and emits `build/worker.js`. Invoke via `npx @ossy/cli build --worker`.
537
- */
538
- export async function buildWorker(cliArgs) {
539
- console.log('[@ossy/app][build][worker] Starting...')
540
-
541
- const workerArgv = cliArgs.filter((a) => a !== '--worker')
542
- const options = arg(
543
- {
544
- '--pages': String,
545
- '--p': '--pages',
546
- '--destination': String,
547
- '--d': '--destination',
548
- '--task-source': String,
549
- },
550
- { argv: workerArgv }
551
- )
552
-
553
- const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
554
- const cwd = process.cwd()
555
- const pagesOpt = options['--pages'] || 'src'
556
- const buildPath = path.resolve(options['--destination'] || 'build')
557
- const { taskSourcePath, taskOverviewFiles, usedGenerated } = resolveTaskSource({
558
- cwd,
559
- pagesOpt,
560
- scriptDir,
561
- explicitTaskSource: options['--task-source'],
562
- buildPath,
563
- })
564
-
565
- const workerEntryPath = path.resolve(scriptDir, 'worker-entry.js')
566
- if (!fs.existsSync(workerEntryPath)) {
567
- throw new Error(`[@ossy/app][build][worker] Missing worker entry: ${workerEntryPath}`)
568
- }
569
-
570
- console.log('\n \x1b[1mWorker build\x1b[0m')
571
- console.log(' ' + '─'.repeat(50))
572
- console.log(` \x1b[36mTasks module:\x1b[0m ${path.relative(cwd, taskSourcePath)}`)
573
- if (taskOverviewFiles.length > 0) {
574
- console.log(' \x1b[36mTask files:\x1b[0m')
575
- taskOverviewFiles.forEach((f) => console.log(` ${path.relative(cwd, f)}`))
576
- } else if (!usedGenerated) {
577
- console.log(' \x1b[33m(no *.task.js; using empty task list stub)\x1b[0m')
578
- }
579
- console.log(' ' + '─'.repeat(50) + '\n')
580
-
581
- const bundle = await rollup({
582
- input: workerEntryPath,
583
- external: isWorkerExternal,
584
- plugins: [
585
- ossyCleanBuildDirPlugin(buildPath),
586
- replace({
587
- preventAssignment: true,
588
- delimiters: ['%%', '%%'],
589
- '@ossy/tasks/source-file': taskSourcePath,
590
- }),
591
- replace({
592
- preventAssignment: true,
593
- 'process.env.NODE_ENV': JSON.stringify('production'),
594
- }),
595
- json(),
596
- resolveCommonJsDependencies(),
597
- resolveDependencies({ preferBuiltins: true }),
598
- babel({
599
- babelHelpers: 'bundled',
600
- presets: ['@babel/preset-env', '@babel/preset-react'],
601
- }),
602
- ],
603
- })
604
-
605
- await bundle.write({
606
- dir: buildPath,
607
- entryFileNames: 'worker.js',
608
- chunkFileNames: '[name]-[hash].js',
609
- format: 'esm',
610
- })
611
-
612
- console.log('[@ossy/app][build][worker] Finished')
613
- }
614
-
615
684
  export const build = async (cliArgs) => {
616
685
  const options = arg({
617
- '--pages': String,
618
- '--p': '--pages',
619
-
620
- '--destination': String,
621
- '--d': '--destination',
622
-
623
686
  '--config': String,
624
687
  '-c': '--config',
625
-
626
- '--api-source': String,
627
- '--worker': Boolean,
628
- '--task-source': String,
629
688
  }, { argv: cliArgs })
630
689
 
631
- if (options['--worker']) {
632
- return buildWorker(cliArgs)
633
- }
634
-
635
690
  console.log('[@ossy/app][build] Starting...')
636
691
 
637
692
 
638
693
  const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
639
- const cwd = process.cwd()
640
- const pagesOpt = options['--pages'] || 'src'
641
- const buildPath = path.resolve(options['--destination'] || 'build')
642
- ensureOssyGeneratedDir(buildPath)
643
- const srcDir = path.resolve(pagesOpt)
694
+ const buildPath = path.resolve('build')
695
+ const srcDir = path.resolve('src')
644
696
  const configPath = path.resolve(options['--config'] || 'src/config.js');
645
697
  const pageFiles = discoverPageFiles(srcDir)
646
- const modulePageFiles = await discoverModulePageFiles({ cwd, configPath })
647
- const pagesJsxPath = path.resolve('src/pages.jsx')
648
- const hasPagesJsx = fs.existsSync(pagesJsxPath)
649
-
650
- let effectivePagesSource
651
- let isPageFiles = false
652
- if (pageFiles.length > 0 || modulePageFiles.length > 0) {
653
- const pagesGeneratedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
654
- fs.writeFileSync(
655
- pagesGeneratedPath,
656
- generatePagesModule([...pageFiles, ...modulePageFiles], cwd, pagesOpt, pagesGeneratedPath)
657
- )
658
- effectivePagesSource = pagesGeneratedPath
659
- isPageFiles = true
660
- } else if (hasPagesJsx) {
661
- effectivePagesSource = pagesJsxPath
662
- } else {
663
- throw new Error(`[@ossy/app][build] No pages found. Create *.page.jsx files in src/, or src/pages.jsx`);
664
- }
698
+ const modulePageFiles = await discoverModulePageFiles({ configPath })
699
+
700
+ resetOssyBuildDir(buildPath)
701
+
702
+ writeResourceTemplatesBarrelIfPresent({ cwd: process.cwd(), log: true })
703
+
704
+ const pagesGeneratedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
705
+
706
+ const allPageFiles = [...pageFiles, ...modulePageFiles]
707
+ fs.writeFileSync(
708
+ pagesGeneratedPath,
709
+ generatePagesModule(allPageFiles, srcDir, pagesGeneratedPath)
710
+ )
711
+ const ossyDir = ossyGeneratedDir(buildPath)
712
+ writePageHydrateStubs(allPageFiles, srcDir, ossyDir)
713
+ const clientHydrateInput = buildClientHydrateInput(allPageFiles, srcDir, ossyDir)
665
714
 
666
715
  const {
667
716
  apiSourcePath: resolvedApi,
668
717
  apiOverviewFiles,
669
718
  } = resolveApiSource({
670
- cwd,
671
- pagesOpt,
672
- scriptDir,
673
- explicitApiSource: options['--api-source'],
719
+ srcDir,
674
720
  buildPath,
675
721
  })
676
722
  let apiSourcePath = resolvedApi
677
- let middlewareSourcePath = path.resolve(options['--middleware-source'] || 'src/middleware.js');
723
+ let middlewareSourcePath = path.resolve('src/middleware.js');
678
724
  const publicDir = path.resolve('public')
679
725
 
680
- const inputClient = path.resolve(scriptDir, 'client.js')
681
726
  const inputServer = path.resolve(scriptDir, 'server.js')
682
727
 
683
- const inputFiles = [inputClient, inputServer]
684
-
685
- const appEntryPath = path.resolve(scriptDir, 'default-app.jsx')
686
728
  printBuildOverview({
687
- pagesSourcePath: effectivePagesSource,
729
+ pagesSourcePath: pagesGeneratedPath,
688
730
  apiSourcePath,
689
731
  apiOverviewFiles,
690
732
  configPath,
691
- isPageFiles,
692
- pageFiles: isPageFiles ? [...pageFiles, ...modulePageFiles] : [],
733
+ pageFiles,
693
734
  });
694
735
 
695
- if (!fs.existsSync(apiSourcePath)) {
696
- apiSourcePath = path.resolve(scriptDir, 'api.js')
697
- }
698
-
699
736
  if (!fs.existsSync(middlewareSourcePath)) {
700
737
  middlewareSourcePath = path.resolve(scriptDir, 'middleware.js')
701
738
  }
@@ -704,95 +741,64 @@ export const build = async (cliArgs) => {
704
741
  ? configPath
705
742
  : path.resolve(scriptDir, 'default-config.js')
706
743
 
707
- const pageShellSourcePath = resolvePageShellSource(cwd, scriptDir)
708
-
709
- const inputOptions = {
710
- input: inputFiles,
711
- plugins: [
712
- ossyCleanBuildDirPlugin(buildPath),
713
- // inject({ 'React': 'react' }),
714
- replace({
715
- preventAssignment: true,
716
- delimiters: ['%%', '%%'],
717
- '@ossy/app/source-file': appEntryPath,
718
- }),
719
- replace({
720
- preventAssignment: true,
721
- delimiters: ['%%', '%%'],
722
- '@ossy/pages/source-file': effectivePagesSource,
723
- }),
724
- replace({
725
- preventAssignment: true,
726
- delimiters: ['%%', '%%'],
727
- '@ossy/api/source-file': apiSourcePath,
728
- }),
729
- replace({
730
- preventAssignment: true,
731
- delimiters: ['%%', '%%'],
732
- '@ossy/middleware/source-file': middlewareSourcePath,
733
- }),
734
- replace({
735
- preventAssignment: true,
736
- delimiters: ['%%', '%%'],
737
- '@ossy/config/source-file': configSourcePath,
738
- }),
739
- replace({
740
- preventAssignment: true,
741
- delimiters: ['%%', '%%'],
742
- '@ossy/page-shell/source-file': pageShellSourcePath,
743
- }),
744
- replace({
745
- preventAssignment: true,
746
- 'process.env.NODE_ENV': JSON.stringify('production')
747
- }),
748
- json(),
749
- // removeOwnPeerDependencies(),
750
- resolveCommonJsDependencies(),
751
- resolveDependencies({ preferBuiltins: true }),
752
- babel({
753
- babelHelpers: 'bundled',
754
- // exclude: ['**/node_modules/**/*'],
755
- presets: ['@babel/preset-env', '@babel/preset-react']
756
- }),
757
- // preserveDirectives(),
758
- minifyJS(),
759
- copy({
760
- targets: [
761
- fs.existsSync(publicDir)
762
- ? { src: `${publicDir}/**/*`, dest: 'build/public' }
763
- : undefined,
764
- ].filter(x => !!x)
765
- })
766
- ],
767
- };
768
-
769
- const outputOptions = [
770
- {
771
- dir: 'build',
772
- // preserveModules: true,
773
- entryFileNames: ({ name }) => {
774
-
775
- const serverFileNames = ['server', 'api', 'middleware']
776
-
777
- if (serverFileNames.includes(name)) {
778
- return '[name].js'
779
- } else if (name === 'client') {
780
- return 'public/static/main.js'
781
- } else if (name === 'config') {
782
- return 'public/static/[name].js'
783
- } else {
784
- return 'public/static/[name].js'
785
- }
786
- },
787
- chunkFileNames: 'public/static/[name]-[hash].js',
788
- format: 'esm',
789
- }
790
- ];
791
-
792
- const bundle = await rollup(inputOptions);
793
-
794
- for (const options of outputOptions) {
795
- await bundle.write(options);
744
+ const sharedPluginOpts = {
745
+ pagesGeneratedPath,
746
+ apiSourcePath,
747
+ middlewareSourcePath,
748
+ configSourcePath,
749
+ nodeEnv: 'production',
750
+ buildPath,
751
+ }
752
+
753
+ const serverPlugins = createOssyRollupPlugins({
754
+ ...sharedPluginOpts,
755
+ nodeExternals: true,
756
+ preferBuiltins: true,
757
+ copyPublicFrom: publicDir,
758
+ })
759
+
760
+ const serverBundle = await rollup({
761
+ input: { server: inputServer },
762
+ plugins: serverPlugins,
763
+ external: ossyServerExternal,
764
+ })
765
+ await serverBundle.write({
766
+ dir: buildPath,
767
+ format: 'esm',
768
+ preserveModules: true,
769
+ preserveModulesRoot: path.dirname(inputServer),
770
+ entryFileNames ({ name }) {
771
+ return name === 'server' ? 'server.js' : '[name].js'
772
+ },
773
+ assetFileNames: '[name][extname]',
774
+ })
775
+ await serverBundle.close()
776
+
777
+ const clientPlugins = createOssyRollupPlugins({
778
+ ...sharedPluginOpts,
779
+ nodeExternals: false,
780
+ preferBuiltins: false,
781
+ })
782
+
783
+ if (Object.keys(clientHydrateInput).length > 0) {
784
+ const clientBundle = await rollup({
785
+ input: clientHydrateInput,
786
+ plugins: clientPlugins,
787
+ })
788
+ await clientBundle.write({
789
+ dir: buildPath,
790
+ format: 'esm',
791
+ entryFileNames ({ name }) {
792
+ if (name.startsWith('hydrate__')) {
793
+ const pageId = name.slice('hydrate__'.length)
794
+ return `public/static/hydrate-${pageId}.js`
795
+ }
796
+ return 'public/static/[name].js'
797
+ },
798
+ chunkFileNames: 'public/static/[name]-[hash].js',
799
+ plugins: [minifyBrowserStaticChunks()],
800
+ })
801
+ await clientBundle.close()
796
802
  }
797
803
 
798
804
  ensureBuildStubs(buildPath)