@ossy/app 1.11.0 → 1.11.2

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
@@ -1,3 +1,5 @@
1
+ import util from 'node:util'
2
+
1
3
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
2
4
 
3
5
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
@@ -15,6 +17,13 @@ function padVisible (s, width) {
15
17
  return n >= width ? s : s + ' '.repeat(width - n)
16
18
  }
17
19
 
20
+ function truncateVisible (s, maxW) {
21
+ const plain = stripAnsi(s)
22
+ if (plain.length <= maxW) return s
23
+ const cut = Math.max(0, maxW - 1)
24
+ return plain.slice(0, cut) + '…'
25
+ }
26
+
18
27
  export function pageIdFromHydrateEntryName (entryName) {
19
28
  const p = String(entryName)
20
29
  return p.startsWith('hydrate__') ? p.slice('hydrate__'.length) : p
@@ -51,24 +60,35 @@ function rowLeadIcon (r, mode) {
51
60
  return green('✔')
52
61
  }
53
62
 
63
+ function useTty (stream) {
64
+ return (
65
+ stream.isTTY === true &&
66
+ process.env.TERM !== 'dumb' &&
67
+ process.env.CI !== 'true' &&
68
+ process.env.OSSY_PLAIN_OUTPUT !== '1'
69
+ )
70
+ }
71
+
54
72
  /**
55
- * Lerna-style live table: one row per page, hydrate + prerender columns.
56
- * TTY: redraws in place with a spinner while work runs. Non-TTY: one line per finished step.
73
+ * Merged overview + per-page progress, split layout: left = status table, right = log tail.
74
+ * TTY: single redraw block + optional console capture into the right column.
57
75
  */
58
- export function createParallelPagesReporter ({
76
+ export function createBuildDashboard ({
59
77
  scope = '@ossy/app',
78
+ mode = 'full',
60
79
  pageIds,
80
+ /** @type {Record<string, string>} route path per page id */
81
+ idToPath = {},
82
+ overview = { title: '@ossy/app', configRel: null, apiRoutes: [] },
61
83
  stream = process.stdout,
62
- /** `'full'`: hydrate + prerender columns. `'prerender-only'`: dev watch (client built separately). */
63
- mode = 'full',
64
84
  } = {}) {
65
85
  const ids = [...pageIds].sort()
66
- const maxId = Math.max(8, ...ids.map((id) => String(id).length))
67
- const tty =
68
- stream.isTTY === true &&
69
- process.env.TERM !== 'dumb' &&
70
- process.env.CI !== 'true' &&
71
- process.env.OSSY_PLAIN_OUTPUT !== '1'
86
+ const maxId = Math.max(6, ...ids.map((id) => String(id).length))
87
+ const tty = useTty(stream)
88
+ const termW = Math.max(60, stream.columns || 100)
89
+ const LEFT_W = Math.min(56, Math.max(38, Math.floor(termW * 0.42)))
90
+ const GAP = 2
91
+ const RIGHT_W = Math.max(16, termW - LEFT_W - GAP)
72
92
 
73
93
  /** @type {Map<string, { bundle: object, prerender: object }>} */
74
94
  const rows = new Map()
@@ -79,9 +99,13 @@ export function createParallelPagesReporter ({
79
99
  })
80
100
  }
81
101
 
102
+ const logBuffer = []
103
+ const MAX_LOG = 120
82
104
  let frame = 0
83
105
  let spinTimer = null
84
106
  let blockLines = 0
107
+ let captureActive = false
108
+ let savedConsole = null
85
109
 
86
110
  const anyRunning = () => {
87
111
  for (const r of rows.values()) {
@@ -90,44 +114,107 @@ export function createParallelPagesReporter ({
90
114
  return false
91
115
  }
92
116
 
93
- function ensureSpin () {
94
- if (!tty || spinTimer) return
95
- spinTimer = setInterval(() => {
96
- frame += 1
97
- redraw()
98
- }, 80)
117
+ function pushLog (line) {
118
+ const s = String(line).trim() ? String(line) : ''
119
+ if (!s) return
120
+ if (!tty) {
121
+ stream.write(`${s}\n`)
122
+ return
123
+ }
124
+ logBuffer.push(dim(s))
125
+ if (logBuffer.length > MAX_LOG) logBuffer.splice(0, logBuffer.length - MAX_LOG)
126
+ redraw()
99
127
  }
100
128
 
101
- function stopSpin () {
102
- if (spinTimer) {
103
- clearInterval(spinTimer)
104
- spinTimer = null
129
+ function leftHeaderLines () {
130
+ const out = []
131
+ out.push(bold(overview.title || `${scope} build`))
132
+ if (overview.configRel) {
133
+ out.push(`${dim('config')} ${truncateVisible(overview.configRel, LEFT_W - 10)}`)
105
134
  }
135
+ const api = overview.apiRoutes || []
136
+ if (api.length === 0) {
137
+ /* noop */
138
+ } else if (api.length <= 2) {
139
+ for (const r of api) {
140
+ const line = `${dim('api')} ${truncateVisible(`${r.id} ${r.path}`, LEFT_W - 6)}`
141
+ out.push(line)
142
+ }
143
+ } else {
144
+ out.push(`${dim('api')} ${api.length} routes`)
145
+ }
146
+ out.push(dim('─'.repeat(Math.min(LEFT_W - 2, 44))))
147
+ return out
106
148
  }
107
149
 
108
- function lineFor (id) {
150
+ function lineForPageRow (id) {
109
151
  const r = rows.get(id)
110
152
  const lead = rowLeadIcon(r, mode)
153
+ const pathStr = idToPath[id] != null ? dim(String(idToPath[id])) : ''
111
154
  const idCol = padVisible(dim(String(id)), maxId)
155
+ const pathPad = padVisible(pathStr, 14)
112
156
  const p = fmtPart(r.prerender, 'html', frame)
113
157
  if (mode === 'prerender-only') {
114
- return ` ${lead} ${dim(scope)} ${idCol} ${p}`
158
+ return ` ${lead} ${dim(scope)} ${idCol} ${pathPad} ${p}`
115
159
  }
116
160
  const b = fmtPart(r.bundle, 'hydrate', frame)
117
- return ` ${lead} ${dim(scope)} ${idCol} ${padVisible(b, 24)} ${p}`
161
+ return ` ${lead} ${dim(scope)} ${idCol} ${pathPad} ${padVisible(b, 20)} ${p}`
162
+ }
163
+
164
+ function buildLeftColumnLines () {
165
+ const head = leftHeaderLines()
166
+ const body = ids.map((id) => lineForPageRow(id))
167
+ return [...head, ...body]
168
+ }
169
+
170
+ function buildHeaderOnlyLines () {
171
+ return leftHeaderLines()
172
+ }
173
+
174
+ /** Right column row i: logs bottom-aligned to the block height */
175
+ function rightColumnLine (i, totalRows) {
176
+ const start = logBuffer.length - totalRows + i
177
+ if (start < 0 || start >= logBuffer.length) return dim('·')
178
+ return truncateVisible(logBuffer[start], RIGHT_W)
179
+ }
180
+
181
+ function paintRow (leftLine, rightLine) {
182
+ const L = padVisible(truncateVisible(leftLine, LEFT_W), LEFT_W)
183
+ const R = truncateVisible(rightLine, RIGHT_W)
184
+ return `${L}${' '.repeat(GAP)}${R}\n`
118
185
  }
119
186
 
120
187
  function redraw () {
121
188
  if (!tty) return
122
- const lines = ids.map((id) => lineFor(id))
189
+ const leftCol = buildLeftColumnLines()
190
+ const n = leftCol.length
191
+ const lines = []
192
+ for (let i = 0; i < n; i++) {
193
+ lines.push(paintRow(leftCol[i], rightColumnLine(i, n)))
194
+ }
123
195
  if (blockLines === 0) {
124
- for (const ln of lines) stream.write(`${ln}\n`)
196
+ for (const ln of lines) stream.write(ln)
125
197
  blockLines = lines.length
126
198
  return
127
199
  }
128
200
  stream.write(`\x1b[${blockLines}A`)
129
201
  for (const ln of lines) {
130
- stream.write(`\x1b[2K\r${ln}\n`)
202
+ stream.write(`\x1b[2K\r${ln}`)
203
+ }
204
+ }
205
+
206
+ function ensureSpin () {
207
+ if (!tty || spinTimer) return
208
+ spinTimer = setInterval(() => {
209
+ frame += 1
210
+ redraw()
211
+ }, 80)
212
+ }
213
+
214
+ function stopSpin () {
215
+ if (spinTimer) {
216
+ clearInterval(spinTimer)
217
+ spinTimer = null
131
218
  }
132
219
  }
133
220
 
@@ -139,7 +226,58 @@ export function createParallelPagesReporter ({
139
226
  if (err && !ok) stream.write(` ${red(String(err.message || err))}\n`)
140
227
  }
141
228
 
229
+ function beginCapture () {
230
+ if (!tty || captureActive) return
231
+ captureActive = true
232
+ savedConsole = {
233
+ log: console.log,
234
+ warn: console.warn,
235
+ info: console.info,
236
+ error: console.error,
237
+ }
238
+ const fmt = (...a) => util.format(...a)
239
+ console.log = (...a) => {
240
+ pushLog(`[log] ${fmt(...a)}`)
241
+ }
242
+ console.warn = (...a) => {
243
+ pushLog(`[warn] ${fmt(...a)}`)
244
+ }
245
+ console.info = (...a) => {
246
+ pushLog(`[info] ${fmt(...a)}`)
247
+ }
248
+ console.error = (...a) => {
249
+ const msg = fmt(...a)
250
+ pushLog(`[error] ${msg}`)
251
+ savedConsole.error(`[@ossy/app] ${msg}`)
252
+ }
253
+ }
254
+
255
+ function endCapture () {
256
+ if (!captureActive || !savedConsole) return
257
+ console.log = savedConsole.log
258
+ console.warn = savedConsole.warn
259
+ console.info = savedConsole.info
260
+ console.error = savedConsole.error
261
+ savedConsole = null
262
+ captureActive = false
263
+ }
264
+
142
265
  return {
266
+ pushLog,
267
+
268
+ /** Start optional console hijack + first paint (TTY). Plain header only when not a TTY. */
269
+ start () {
270
+ if (!tty) {
271
+ for (const ln of buildHeaderOnlyLines()) {
272
+ stream.write(`${ln}\n`)
273
+ }
274
+ stream.write('\n')
275
+ return
276
+ }
277
+ beginCapture()
278
+ redraw()
279
+ },
280
+
143
281
  startBundle (pageId) {
144
282
  if (mode === 'prerender-only') return
145
283
  const r = rows.get(pageId)
@@ -188,23 +326,18 @@ export function createParallelPagesReporter ({
188
326
  redraw()
189
327
  },
190
328
 
191
- /** Stop spinner and leave cursor below the block (TTY). */
192
- dispose () {
193
- stopSpin()
194
- if (tty && blockLines > 0) {
195
- redraw()
196
- stream.write('\n')
197
- blockLines = 0
198
- }
329
+ printSectionTitle () {
330
+ /* merged into overview.title — no-op for API compat */
199
331
  },
200
332
 
201
- printSectionTitle (title) {
333
+ dispose () {
202
334
  stopSpin()
335
+ endCapture()
203
336
  if (tty && blockLines > 0) {
337
+ redraw()
204
338
  stream.write('\n')
205
339
  blockLines = 0
206
340
  }
207
- stream.write(`\n ${bold(scope)} ${dim(title)}\n`)
208
341
  },
209
342
  }
210
343
  }