@ossy/app 0.15.12 → 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
  }
@@ -41,18 +108,138 @@ function relToGeneratedImport (generatedAbs, targetAbs) {
41
108
  return path.relative(path.dirname(generatedAbs), targetAbs).replace(/\\/g, '/')
42
109
  }
43
110
 
44
- /** Clears Rollup output dir but keeps `build/.ossy` (generated sources live there). */
45
- export function ossyCleanBuildDirPlugin (buildPath) {
46
- return {
47
- name: 'ossy-clean-build-dir',
48
- buildStart () {
49
- if (!fs.existsSync(buildPath)) return
50
- for (const name of fs.readdirSync(buildPath)) {
51
- if (name === OSSY_GEN_DIRNAME) continue
52
- fs.rmSync(path.join(buildPath, name), { recursive: true, force: true })
53
- }
54
- },
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 []
55
124
  }
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)))
130
+ }
131
+
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')
152
+ }
153
+
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
+ )
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
56
243
  }
57
244
 
58
245
  export function discoverApiFiles(srcDir) {
@@ -92,19 +279,22 @@ export function discoverTaskFiles(srcDir) {
92
279
  }
93
280
 
94
281
  /**
95
- * Merges `src/api.js` (optional) and every `*.api.js` under the pages tree into one default export array
96
- * 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 []`.
97
284
  */
98
- 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
+ }
99
294
  const lines = [
100
295
  '// Generated by @ossy/app — do not edit',
101
296
  '',
102
297
  ]
103
- const hasLegacy = legacyPath && fs.existsSync(legacyPath)
104
- if (hasLegacy) {
105
- const rel = relToGeneratedImport(generatedPath, legacyPath)
106
- lines.push(`import _legacyApi from './${rel}'`)
107
- }
108
298
  apiFiles.forEach((f, i) => {
109
299
  const rel = relToGeneratedImport(generatedPath, f)
110
300
  lines.push(`import * as _api${i} from './${rel}'`)
@@ -119,13 +309,7 @@ export function generateApiModule ({ generatedPath, apiFiles, legacyPath }) {
119
309
  '',
120
310
  'export default [',
121
311
  )
122
- const parts = []
123
- if (hasLegacy) {
124
- parts.push(' ..._normalizeApiExport({ default: _legacyApi }),')
125
- }
126
- apiFiles.forEach((_, i) => {
127
- parts.push(` ..._normalizeApiExport(_api${i}),`)
128
- })
312
+ const parts = apiFiles.map((_, i) => ` ..._normalizeApiExport(_api${i}),`)
129
313
  lines.push(parts.join('\n'))
130
314
  lines.push(']')
131
315
  lines.push('')
@@ -133,60 +317,29 @@ export function generateApiModule ({ generatedPath, apiFiles, legacyPath }) {
133
317
  }
134
318
 
135
319
  /**
136
- * 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.
137
322
  */
138
- export function resolveApiSource ({ cwd, pagesOpt, scriptDir, explicitApiSource, buildPath }) {
139
- const srcDir = path.resolve(cwd, pagesOpt)
140
- const legacyPath = path.resolve(cwd, 'src/api.js')
141
- const defaultStub = path.resolve(scriptDir, 'api.js')
142
- const bp = path.resolve(buildPath ?? path.join(cwd, 'build'))
143
- ensureOssyGeneratedDir(bp)
144
- const generatedPath = path.join(ossyGeneratedDir(bp), OSSY_GEN_API_BASENAME)
145
-
146
- if (explicitApiSource) {
147
- const p = path.isAbsolute(explicitApiSource)
148
- ? explicitApiSource
149
- : path.resolve(cwd, explicitApiSource)
150
- if (fs.existsSync(p)) {
151
- return { apiSourcePath: p, apiOverviewFiles: [p], usedGenerated: false }
152
- }
153
- return { apiSourcePath: defaultStub, apiOverviewFiles: [], usedGenerated: false }
154
- }
155
-
323
+ export function resolveApiSource ({ srcDir, buildPath }) {
324
+ ensureOssyGeneratedDir(buildPath)
325
+ const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_API_BASENAME)
156
326
  const apiFiles = discoverApiFiles(srcDir)
157
- const hasLegacy = fs.existsSync(legacyPath)
158
- // Always feed Rollup the same generated entry when there is any API surface,
159
- // so adding/removing `*.api.js` does not require changing the replace target mid-dev.
160
- if (apiFiles.length > 0 || hasLegacy) {
161
- fs.writeFileSync(
162
- generatedPath,
163
- generateApiModule({
164
- generatedPath,
165
- apiFiles,
166
- legacyPath: hasLegacy ? legacyPath : null,
167
- })
168
- )
169
- const overview = [...(hasLegacy ? [legacyPath] : []), ...apiFiles]
170
- return { apiSourcePath: generatedPath, apiOverviewFiles: overview, usedGenerated: true }
171
- }
172
-
173
- return { apiSourcePath: defaultStub, apiOverviewFiles: [], usedGenerated: false }
327
+ fs.writeFileSync(
328
+ generatedPath,
329
+ generateApiModule({ generatedPath, apiFiles })
330
+ )
331
+ return { apiSourcePath: generatedPath, apiOverviewFiles: apiFiles }
174
332
  }
175
333
 
176
334
  /**
177
335
  * Merges `src/tasks.js` (optional) and every `*.task.js` under `src/` into one default export array
178
336
  * of job handlers `{ type, handler }` for the Ossy worker.
179
337
  */
180
- export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
338
+ export function generateTaskModule ({ generatedPath, taskFiles }) {
181
339
  const lines = [
182
340
  '// Generated by @ossy/app — do not edit',
183
341
  '',
184
342
  ]
185
- const hasLegacy = legacyPath && fs.existsSync(legacyPath)
186
- if (hasLegacy) {
187
- const rel = relToGeneratedImport(generatedPath, legacyPath)
188
- lines.push(`import _legacyTasks from './${rel}'`)
189
- }
190
343
  taskFiles.forEach((f, i) => {
191
344
  const rel = relToGeneratedImport(generatedPath, f)
192
345
  lines.push(`import * as _task${i} from './${rel}'`)
@@ -202,9 +355,6 @@ export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
202
355
  'export default [',
203
356
  )
204
357
  const parts = []
205
- if (hasLegacy) {
206
- parts.push(' ..._normalizeTaskExport({ default: _legacyTasks }),')
207
- }
208
358
  taskFiles.forEach((_, i) => {
209
359
  parts.push(` ..._normalizeTaskExport(_task${i}),`)
210
360
  })
@@ -217,45 +367,28 @@ export function generateTaskModule ({ generatedPath, taskFiles, legacyPath }) {
217
367
  /**
218
368
  * Resolves the Rollup entry for @ossy/tasks/source-file and which files to list in the worker build overview.
219
369
  */
220
- export function resolveTaskSource ({ cwd, pagesOpt, scriptDir, explicitTaskSource, buildPath }) {
221
- const srcDir = path.resolve(cwd, pagesOpt)
222
- const legacyPath = path.resolve(cwd, 'src/tasks.js')
370
+ export function resolveTaskSource ({ srcDir, scriptDir, buildPath }) {
223
371
  const defaultStub = path.resolve(scriptDir, 'tasks.js')
224
- const bp = path.resolve(buildPath ?? path.join(cwd, 'build'))
225
- ensureOssyGeneratedDir(bp)
226
- const generatedPath = path.join(ossyGeneratedDir(bp), OSSY_GEN_TASKS_BASENAME)
227
-
228
- if (explicitTaskSource) {
229
- const p = path.isAbsolute(explicitTaskSource)
230
- ? explicitTaskSource
231
- : path.resolve(cwd, explicitTaskSource)
232
- if (fs.existsSync(p)) {
233
- return { taskSourcePath: p, taskOverviewFiles: [p], usedGenerated: false }
234
- }
235
- return { taskSourcePath: defaultStub, taskOverviewFiles: [], usedGenerated: false }
236
- }
372
+ ensureOssyGeneratedDir(buildPath)
373
+ const generatedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_TASKS_BASENAME)
237
374
 
238
375
  const taskFiles = discoverTaskFiles(srcDir)
239
- const hasLegacy = fs.existsSync(legacyPath)
240
- if (taskFiles.length > 0 || hasLegacy) {
376
+ if (taskFiles.length > 0) {
241
377
  fs.writeFileSync(
242
378
  generatedPath,
243
379
  generateTaskModule({
244
380
  generatedPath,
245
381
  taskFiles,
246
- legacyPath: hasLegacy ? legacyPath : null,
247
382
  })
248
383
  )
249
- const overview = [...(hasLegacy ? [legacyPath] : []), ...taskFiles]
250
- return { taskSourcePath: generatedPath, taskOverviewFiles: overview, usedGenerated: true }
384
+ return { taskSourcePath: generatedPath, taskOverviewFiles: taskFiles, usedGenerated: true }
251
385
  }
252
386
 
253
387
  return { taskSourcePath: defaultStub, taskOverviewFiles: [], usedGenerated: false }
254
388
  }
255
389
 
256
390
  export function discoverPageFiles(srcDir) {
257
- const dir = path.resolve(srcDir)
258
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
391
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) {
259
392
  return []
260
393
  }
261
394
  const files = []
@@ -267,7 +400,7 @@ export function discoverPageFiles(srcDir) {
267
400
  else if (PAGE_FILE_PATTERN.test(e.name)) files.push(full)
268
401
  }
269
402
  }
270
- walk(dir)
403
+ walk(srcDir)
271
404
  return files.sort()
272
405
  }
273
406
 
@@ -280,8 +413,105 @@ export function filePathToRoute(filePath, srcDir) {
280
413
  return { id, path: routePath }
281
414
  }
282
415
 
283
- export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
284
- const resolvedSrc = path.resolve(cwd, srcDir)
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
+ }
485
+ }
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
+ )
501
+ }
502
+ }
503
+
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
512
+ }
513
+
514
+ export function generatePagesModule (pageFiles, srcDir, generatedPath) {
285
515
  const lines = [
286
516
  "import React from 'react'",
287
517
  ...pageFiles.map((f, i) => {
@@ -300,7 +530,7 @@ export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
300
530
  '',
301
531
  'export default [',
302
532
  ...pageFiles.map((f, i) => {
303
- const { id, path: defaultPath } = filePathToRoute(f, resolvedSrc)
533
+ const { id, path: defaultPath } = filePathToRoute(f, srcDir)
304
534
  const pathStr = JSON.stringify(defaultPath)
305
535
  return ` toPage(_page${i}, { id: '${id}', path: ${pathStr} }),`
306
536
  }),
@@ -309,7 +539,7 @@ export function generatePagesModule (pageFiles, cwd, srcDir, generatedPath) {
309
539
  return lines.join('\n')
310
540
  }
311
541
 
312
- export async function discoverModulePageFiles({ cwd, configPath }) {
542
+ export async function discoverModulePageFiles({ configPath }) {
313
543
  if (!configPath || !fs.existsSync(configPath)) return []
314
544
  try {
315
545
  // Try a cheap static parse first so we don't depend on the config file being
@@ -334,7 +564,7 @@ export async function discoverModulePageFiles({ cwd, configPath }) {
334
564
  }
335
565
 
336
566
  if (modules.length) {
337
- const req = createRequire(path.resolve(cwd, 'package.json'))
567
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'))
338
568
  const files = []
339
569
  for (const name of modules) {
340
570
  const pkgJsonPath = req.resolve(`${name}/package.json`)
@@ -353,7 +583,7 @@ export async function discoverModulePageFiles({ cwd, configPath }) {
353
583
 
354
584
  if (!modules2.length) return []
355
585
 
356
- const req = createRequire(path.resolve(cwd, 'package.json'))
586
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'))
357
587
  const files = []
358
588
  for (const name of modules2) {
359
589
  const pkgJsonPath = req.resolve(`${name}/package.json`)
@@ -406,22 +636,17 @@ export function printBuildOverview({
406
636
  apiSourcePath,
407
637
  apiOverviewFiles = [],
408
638
  configPath,
409
- isPageFiles,
410
639
  pageFiles,
411
640
  }) {
412
- const rel = (p) => path.relative(process.cwd(), p)
413
- const cwd = process.cwd()
414
- 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')
415
643
  console.log('\n \x1b[1mBuild overview\x1b[0m')
416
- console.log(' ' + '─'.repeat(50))
417
- console.log(` \x1b[36mPages:\x1b[0m ${rel(pagesSourcePath)}`)
418
644
  if (fs.existsSync(configPath)) {
419
645
  console.log(` \x1b[36mConfig:\x1b[0m ${rel(configPath)}`)
420
646
  }
421
- console.log(` \x1b[36mAPI:\x1b[0m ${rel(apiSourcePath)}`)
422
647
  console.log(' ' + '─'.repeat(50))
423
648
 
424
- const pages = isPageFiles && pageFiles?.length
649
+ const pages = pageFiles?.length
425
650
  ? pageFiles.map((f) => filePathToRoute(f, srcDir))
426
651
  : parsePagesFromSource(pagesSourcePath)
427
652
  if (pages.length > 0) {
@@ -456,192 +681,58 @@ export function printBuildOverview({
456
681
  console.log(' ' + '─'.repeat(50) + '\n')
457
682
  }
458
683
 
459
- const WORKER_EXTERNAL_IDS = new Set([
460
- ...builtinModules,
461
- ...builtinModules.map((m) => `node:${m}`),
462
- 'dotenv',
463
- 'dotenv/config',
464
- '@ossy/sdk',
465
- 'openai',
466
- 'sharp',
467
- ])
468
-
469
- function isWorkerExternal(id) {
470
- if (id.startsWith('.') || path.isAbsolute(id)) return false
471
- if (WORKER_EXTERNAL_IDS.has(id)) return true
472
- if (id.startsWith('node:')) return true
473
- if (id === '@ossy/sdk' || id.startsWith('@ossy/sdk/')) return true
474
- if (id === 'openai' || id.startsWith('openai/')) return true
475
- if (id === 'sharp' || id.startsWith('sharp/')) return true
476
- if (id === 'dotenv' || id.startsWith('dotenv/')) return true
477
- return false
478
- }
479
-
480
- /**
481
- * Worker-only bundle: discovers `*.task.js` (and optional `src/tasks.js`), writes `build/.ossy/tasks.generated.js`
482
- * when needed, and emits `build/worker.js`. Invoke via `npx @ossy/cli build --worker`.
483
- */
484
- export async function buildWorker(cliArgs) {
485
- console.log('[@ossy/app][build][worker] Starting...')
486
-
487
- const workerArgv = cliArgs.filter((a) => a !== '--worker')
488
- const options = arg(
489
- {
490
- '--pages': String,
491
- '--p': '--pages',
492
- '--destination': String,
493
- '--d': '--destination',
494
- '--task-source': String,
495
- },
496
- { argv: workerArgv }
497
- )
498
-
499
- const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
500
- const cwd = process.cwd()
501
- const pagesOpt = options['--pages'] || 'src'
502
- const buildPath = path.resolve(options['--destination'] || 'build')
503
- const { taskSourcePath, taskOverviewFiles, usedGenerated } = resolveTaskSource({
504
- cwd,
505
- pagesOpt,
506
- scriptDir,
507
- explicitTaskSource: options['--task-source'],
508
- buildPath,
509
- })
510
-
511
- const workerEntryPath = path.resolve(scriptDir, 'worker-entry.js')
512
- if (!fs.existsSync(workerEntryPath)) {
513
- throw new Error(`[@ossy/app][build][worker] Missing worker entry: ${workerEntryPath}`)
514
- }
515
-
516
- console.log('\n \x1b[1mWorker build\x1b[0m')
517
- console.log(' ' + '─'.repeat(50))
518
- console.log(` \x1b[36mTasks module:\x1b[0m ${path.relative(cwd, taskSourcePath)}`)
519
- if (taskOverviewFiles.length > 0) {
520
- console.log(' \x1b[36mTask files:\x1b[0m')
521
- taskOverviewFiles.forEach((f) => console.log(` ${path.relative(cwd, f)}`))
522
- } else if (!usedGenerated) {
523
- console.log(' \x1b[33m(no *.task.js; using empty task list stub)\x1b[0m')
524
- }
525
- console.log(' ' + '─'.repeat(50) + '\n')
526
-
527
- const bundle = await rollup({
528
- input: workerEntryPath,
529
- external: isWorkerExternal,
530
- plugins: [
531
- ossyCleanBuildDirPlugin(buildPath),
532
- replace({
533
- preventAssignment: true,
534
- delimiters: ['%%', '%%'],
535
- '@ossy/tasks/source-file': taskSourcePath,
536
- }),
537
- replace({
538
- preventAssignment: true,
539
- 'process.env.NODE_ENV': JSON.stringify('production'),
540
- }),
541
- json(),
542
- resolveCommonJsDependencies(),
543
- resolveDependencies({ preferBuiltins: true }),
544
- babel({
545
- babelHelpers: 'bundled',
546
- presets: ['@babel/preset-env', '@babel/preset-react'],
547
- }),
548
- ],
549
- })
550
-
551
- await bundle.write({
552
- dir: buildPath,
553
- entryFileNames: 'worker.js',
554
- chunkFileNames: '[name]-[hash].js',
555
- format: 'esm',
556
- })
557
-
558
- console.log('[@ossy/app][build][worker] Finished')
559
- }
560
-
561
684
  export const build = async (cliArgs) => {
562
685
  const options = arg({
563
- '--pages': String,
564
- '--p': '--pages',
565
-
566
- '--destination': String,
567
- '--d': '--destination',
568
-
569
686
  '--config': String,
570
687
  '-c': '--config',
571
-
572
- '--api-source': String,
573
- '--worker': Boolean,
574
- '--task-source': String,
575
688
  }, { argv: cliArgs })
576
689
 
577
- if (options['--worker']) {
578
- return buildWorker(cliArgs)
579
- }
580
-
581
690
  console.log('[@ossy/app][build] Starting...')
582
691
 
583
692
 
584
693
  const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
585
- const cwd = process.cwd()
586
- const pagesOpt = options['--pages'] || 'src'
587
- const buildPath = path.resolve(options['--destination'] || 'build')
588
- ensureOssyGeneratedDir(buildPath)
589
- const srcDir = path.resolve(pagesOpt)
694
+ const buildPath = path.resolve('build')
695
+ const srcDir = path.resolve('src')
590
696
  const configPath = path.resolve(options['--config'] || 'src/config.js');
591
697
  const pageFiles = discoverPageFiles(srcDir)
592
- const modulePageFiles = await discoverModulePageFiles({ cwd, configPath })
593
- const pagesJsxPath = path.resolve('src/pages.jsx')
594
- const hasPagesJsx = fs.existsSync(pagesJsxPath)
595
-
596
- let effectivePagesSource
597
- let isPageFiles = false
598
- if (pageFiles.length > 0 || modulePageFiles.length > 0) {
599
- const pagesGeneratedPath = path.join(ossyGeneratedDir(buildPath), OSSY_GEN_PAGES_BASENAME)
600
- fs.writeFileSync(
601
- pagesGeneratedPath,
602
- generatePagesModule([...pageFiles, ...modulePageFiles], cwd, pagesOpt, pagesGeneratedPath)
603
- )
604
- effectivePagesSource = pagesGeneratedPath
605
- isPageFiles = true
606
- } else if (hasPagesJsx) {
607
- effectivePagesSource = pagesJsxPath
608
- } else {
609
- throw new Error(`[@ossy/app][build] No pages found. Create *.page.jsx files in src/, or src/pages.jsx`);
610
- }
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)
611
714
 
612
715
  const {
613
716
  apiSourcePath: resolvedApi,
614
717
  apiOverviewFiles,
615
718
  } = resolveApiSource({
616
- cwd,
617
- pagesOpt,
618
- scriptDir,
619
- explicitApiSource: options['--api-source'],
719
+ srcDir,
620
720
  buildPath,
621
721
  })
622
722
  let apiSourcePath = resolvedApi
623
- let middlewareSourcePath = path.resolve(options['--middleware-source'] || 'src/middleware.js');
723
+ let middlewareSourcePath = path.resolve('src/middleware.js');
624
724
  const publicDir = path.resolve('public')
625
725
 
626
- const inputClient = path.resolve(scriptDir, 'client.js')
627
726
  const inputServer = path.resolve(scriptDir, 'server.js')
628
727
 
629
- const inputFiles = [inputClient, inputServer]
630
-
631
- const appEntryPath = path.resolve(scriptDir, 'default-app.jsx')
632
728
  printBuildOverview({
633
- pagesSourcePath: effectivePagesSource,
729
+ pagesSourcePath: pagesGeneratedPath,
634
730
  apiSourcePath,
635
731
  apiOverviewFiles,
636
732
  configPath,
637
- isPageFiles,
638
- pageFiles: isPageFiles ? pageFiles : [],
733
+ pageFiles,
639
734
  });
640
735
 
641
- if (!fs.existsSync(apiSourcePath)) {
642
- apiSourcePath = path.resolve(scriptDir, 'api.js')
643
- }
644
-
645
736
  if (!fs.existsSync(middlewareSourcePath)) {
646
737
  middlewareSourcePath = path.resolve(scriptDir, 'middleware.js')
647
738
  }
@@ -650,88 +741,64 @@ export const build = async (cliArgs) => {
650
741
  ? configPath
651
742
  : path.resolve(scriptDir, 'default-config.js')
652
743
 
653
- const inputOptions = {
654
- input: inputFiles,
655
- plugins: [
656
- ossyCleanBuildDirPlugin(buildPath),
657
- // inject({ 'React': 'react' }),
658
- replace({
659
- preventAssignment: true,
660
- delimiters: ['%%', '%%'],
661
- '@ossy/app/source-file': appEntryPath,
662
- }),
663
- replace({
664
- preventAssignment: true,
665
- delimiters: ['%%', '%%'],
666
- '@ossy/pages/source-file': effectivePagesSource,
667
- }),
668
- replace({
669
- preventAssignment: true,
670
- delimiters: ['%%', '%%'],
671
- '@ossy/api/source-file': apiSourcePath,
672
- }),
673
- replace({
674
- preventAssignment: true,
675
- delimiters: ['%%', '%%'],
676
- '@ossy/middleware/source-file': middlewareSourcePath,
677
- }),
678
- replace({
679
- preventAssignment: true,
680
- delimiters: ['%%', '%%'],
681
- '@ossy/config/source-file': configSourcePath,
682
- }),
683
- replace({
684
- preventAssignment: true,
685
- 'process.env.NODE_ENV': JSON.stringify('production')
686
- }),
687
- json(),
688
- // removeOwnPeerDependencies(),
689
- resolveCommonJsDependencies(),
690
- resolveDependencies({ preferBuiltins: true }),
691
- babel({
692
- babelHelpers: 'bundled',
693
- // exclude: ['**/node_modules/**/*'],
694
- presets: ['@babel/preset-env', '@babel/preset-react']
695
- }),
696
- // preserveDirectives(),
697
- minifyJS(),
698
- copy({
699
- targets: [
700
- fs.existsSync(publicDir)
701
- ? { src: `${publicDir}/**/*`, dest: 'build/public' }
702
- : undefined,
703
- ].filter(x => !!x)
704
- })
705
- ],
706
- };
707
-
708
- const outputOptions = [
709
- {
710
- dir: 'build',
711
- // preserveModules: true,
712
- entryFileNames: ({ name }) => {
713
-
714
- const serverFileNames = ['server', 'api', 'middleware']
715
-
716
- if (serverFileNames.includes(name)) {
717
- return '[name].js'
718
- } else if (name === 'client') {
719
- return 'public/static/main.js'
720
- } else if (name === 'config') {
721
- return 'public/static/[name].js'
722
- } else {
723
- return 'public/static/[name].js'
724
- }
725
- },
726
- chunkFileNames: 'public/static/[name]-[hash].js',
727
- format: 'esm',
728
- }
729
- ];
730
-
731
- const bundle = await rollup(inputOptions);
732
-
733
- for (const options of outputOptions) {
734
- 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()
735
802
  }
736
803
 
737
804
  ensureBuildStubs(buildPath)