@ossy/app 1.15.1 → 1.16.0

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.
@@ -0,0 +1,219 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import arg from 'arg'
5
+ import { rollup } from 'rollup'
6
+ import babel from '@rollup/plugin-babel'
7
+ import { nodeResolve as resolveDependencies } from '@rollup/plugin-node-resolve'
8
+ import resolveCommonJsDependencies from '@rollup/plugin-commonjs'
9
+ import json from '@rollup/plugin-json'
10
+ import replace from '@rollup/plugin-replace'
11
+
12
+ import getPlatformFiles, {
13
+ PAGE_FILE_PATTERN,
14
+ API_FILE_PATTERN,
15
+ TASK_FILE_PATTERN,
16
+ } from './get-platform-files.task.js'
17
+ import { manifestPlugin } from './manifest-plugin.js'
18
+
19
+ export { PAGE_FILE_PATTERN, API_FILE_PATTERN, TASK_FILE_PATTERN }
20
+
21
+ // Generated entry stubs live under `build/.ossy/entries/`. Putting them
22
+ // inside `build/` means they're cleaned automatically with every build,
23
+ // gitignored alongside other build output, and never pollute the project
24
+ // root. The source-relative `import` paths in each stub are computed off
25
+ // of this absolute location, so they keep working wherever it lives.
26
+ const STUB_DIR_REL = path.join('build', '.ossy', 'entries')
27
+
28
+ // Node built-ins that may appear without the `node:` prefix when CommonJS deps
29
+ // are wrapped by `@rollup/plugin-commonjs`. We keep them external so the
30
+ // runtime resolves the actual built-in instead of bundling a polyfill.
31
+ const NODE_BUILTINS = new Set([
32
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
33
+ 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
34
+ 'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
35
+ 'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline',
36
+ 'repl', 'stream', 'string_decoder', 'sys', 'timers', 'tls', 'trace_events',
37
+ 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib',
38
+ ])
39
+
40
+ function resetDir (dir) {
41
+ fs.rmSync(dir, { recursive: true, force: true })
42
+ fs.mkdirSync(dir, { recursive: true })
43
+ }
44
+
45
+ function ensureDir (dir) {
46
+ fs.mkdirSync(dir, { recursive: true })
47
+ }
48
+
49
+ function stubFileNameFor (kind, sourcePath, srcDir) {
50
+ const rel = path.relative(srcDir, sourcePath).replace(/\\/g, '/')
51
+ const flat = rel.replace(/\//g, '__')
52
+ const ext = kind === 'page' ? '.entry.jsx' : '.entry.js'
53
+ return flat.replace(/\.(jsx?|tsx?|mjs|cjs)$/, '') + ext
54
+ }
55
+
56
+ function relImport (fromAbs, toAbs) {
57
+ let rel = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/')
58
+ if (!rel.startsWith('.')) rel = './' + rel
59
+ return rel
60
+ }
61
+
62
+ function generatePageStub ({ stubAbs, sourceAbs }) {
63
+ const importPath = relImport(stubAbs, sourceAbs)
64
+ return [
65
+ "// Generated by @ossy/app — do not edit",
66
+ "import { createPageEntry } from '@ossy/app/runtime/page-runtime'",
67
+ `import * as page from '${importPath}'`,
68
+ '',
69
+ 'const entry = createPageEntry(page, { entryUrl: import.meta.url })',
70
+ 'export const metadata = entry.metadata',
71
+ 'export const render = entry.render',
72
+ '',
73
+ "if (typeof window !== 'undefined') entry.hydrate()",
74
+ '',
75
+ ].join('\n')
76
+ }
77
+
78
+ function generateApiStub ({ stubAbs, sourceAbs }) {
79
+ const importPath = relImport(stubAbs, sourceAbs)
80
+ return [
81
+ "// Generated by @ossy/app — do not edit",
82
+ "import { createApiEntry } from '@ossy/app/runtime/api-runtime'",
83
+ `import * as api from '${importPath}'`,
84
+ '',
85
+ 'const entry = createApiEntry(api)',
86
+ 'export const metadata = entry.metadata',
87
+ 'export const handle = entry.handle',
88
+ '',
89
+ ].join('\n')
90
+ }
91
+
92
+ function generateTaskStub ({ stubAbs, sourceAbs }) {
93
+ const importPath = relImport(stubAbs, sourceAbs)
94
+ return [
95
+ "// Generated by @ossy/app — do not edit",
96
+ "import { createTaskEntry } from '@ossy/app/runtime/task-runtime'",
97
+ `import * as task from '${importPath}'`,
98
+ '',
99
+ 'const entry = createTaskEntry(task)',
100
+ 'export const metadata = entry.metadata',
101
+ 'export const run = entry.run',
102
+ '',
103
+ ].join('\n')
104
+ }
105
+
106
+ function stubFor (kind, args) {
107
+ if (kind === 'page') return generatePageStub(args)
108
+ if (kind === 'api') return generateApiStub(args)
109
+ return generateTaskStub(args)
110
+ }
111
+
112
+ async function loadConfig (configPath) {
113
+ if (!fs.existsSync(configPath)) return {}
114
+ const mod = await import(pathToFileURL(configPath).href + `?t=${Date.now()}`)
115
+ return mod.default || {}
116
+ }
117
+
118
+ export async function build (cliArgs = []) {
119
+ arg(
120
+ {
121
+ '--config': String,
122
+ '-c': '--config',
123
+ },
124
+ { argv: cliArgs },
125
+ )
126
+
127
+ const cwd = process.cwd()
128
+ const buildPath = path.resolve(cwd, 'build')
129
+ const publicOutDir = path.join(buildPath, 'public')
130
+ const staticOutDir = path.join(publicOutDir, 'static')
131
+ const srcDir = path.resolve(cwd, 'src')
132
+ // `public/` is a sibling of `src/`, not nested inside it. Keeping them
133
+ // separate means the build copies the public tree verbatim without
134
+ // accidentally treating `*.page.*` / `*.api.*` files inside it as entries.
135
+ const publicSrc = path.resolve(cwd, 'public')
136
+ const stubDir = path.resolve(cwd, STUB_DIR_REL)
137
+
138
+ // Wipe the whole build dir up front; this also takes the previous
139
+ // `build/.ossy/entries/` stubs with it, so stub generation is idempotent.
140
+ resetDir(buildPath)
141
+ ensureDir(stubDir)
142
+
143
+ const platformFiles = await getPlatformFiles(srcDir)
144
+ const allEntries = [...platformFiles.pages, ...platformFiles.apis, ...platformFiles.tasks]
145
+
146
+ const entriesByStub = new Map()
147
+ const stubInputMap = {}
148
+
149
+ for (const entry of allEntries) {
150
+ const { kind, sourcePath, metadata } = entry
151
+ const stubFileName = stubFileNameFor(kind, sourcePath, srcDir)
152
+ const stubAbs = path.join(stubDir, stubFileName)
153
+ fs.writeFileSync(stubAbs, stubFor(kind, { stubAbs, sourceAbs: sourcePath }), 'utf8')
154
+
155
+ const inputName = path.basename(sourcePath).replace(/\.(jsx?|tsx?|mjs|cjs)$/, '')
156
+ if (stubInputMap[inputName]) {
157
+ throw new Error(`[@ossy/app][build] Duplicate entry name "${inputName}" between ${stubInputMap[inputName]} and ${stubAbs}.`)
158
+ }
159
+ stubInputMap[inputName] = stubAbs
160
+ entriesByStub.set(stubAbs, { kind, sourcePath, metadata })
161
+ }
162
+
163
+ const configPath = path.resolve(srcDir, 'config.js')
164
+ const configValue = await loadConfig(configPath)
165
+
166
+ ensureDir(staticOutDir)
167
+
168
+ const bundle = await rollup({
169
+ input: stubInputMap,
170
+ // Pages execute in BOTH runtimes (Node SSR + browser hydration). We only
171
+ // externalize Node built-ins; everything else (React, ReactDOM, App
172
+ // wrapper, page deps) is bundled into per-entry chunks with shared chunks
173
+ // hoisted by Rollup. The platform server never imports React directly, so
174
+ // the dual-package hazard cannot happen — there is exactly one React in
175
+ // the page bundle, regardless of runtime.
176
+ external: (id) => id.startsWith('node:') || NODE_BUILTINS.has(id),
177
+ plugins: [
178
+ replace({
179
+ preventAssignment: true,
180
+ 'process.env.NODE_ENV': '"production"',
181
+ }),
182
+ json(),
183
+ // Pages execute in BOTH Node (SSR) and browser (hydration). The bundle
184
+ // dynamically imports `react-dom/server` only on the server; we must
185
+ // therefore resolve packages with the Node export conditions so React
186
+ // gives us `renderToPipeableStream` rather than its browser-only stub.
187
+ resolveDependencies({
188
+ preferBuiltins: false,
189
+ exportConditions: ['node', 'module', 'import', 'default'],
190
+ extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx'],
191
+ }),
192
+ resolveCommonJsDependencies(),
193
+ babel({
194
+ babelHelpers: 'bundled',
195
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
196
+ presets: [['@babel/preset-react', { runtime: 'automatic' }]],
197
+ }),
198
+ manifestPlugin({
199
+ entriesByStub,
200
+ configValue,
201
+ manifestPath: path.join(buildPath, 'manifest.json'),
202
+ }),
203
+ ],
204
+ })
205
+
206
+ await bundle.write({
207
+ dir: staticOutDir,
208
+ format: 'esm',
209
+ entryFileNames: '[name]-[hash].js',
210
+ chunkFileNames: 'chunks/[name]-[hash].js',
211
+ sourcemap: false,
212
+ })
213
+
214
+ await bundle.close()
215
+
216
+ if (fs.existsSync(publicSrc)) {
217
+ fs.cpSync(publicSrc, publicOutDir, { recursive: true, force: true })
218
+ }
219
+ }
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { parse as babelParse } from '@babel/parser'
4
+
5
+ export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
6
+ export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
7
+ export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
8
+
9
+ /**
10
+ * @typedef {'page' | 'api' | 'task'} EntryKind
11
+ *
12
+ * @typedef {object} PlatformEntry
13
+ * @property {EntryKind} kind Which bucket this entry lives in.
14
+ * @property {string} sourcePath Absolute path to the user-authored source file.
15
+ * @property {object} metadata Normalized metadata (always has `id`; pages always have `path`).
16
+ *
17
+ * @typedef {object} PlatformFiles
18
+ * @property {PlatformEntry[]} pages Discovered `*.page.{jsx,tsx,...}` entries.
19
+ * @property {PlatformEntry[]} apis Discovered `*.api.{js,mjs,cjs}` entries.
20
+ * @property {PlatformEntry[]} tasks Discovered `*.task.{js,mjs,cjs}` entries.
21
+ */
22
+
23
+ function discoverFilesByPattern (srcDir, filePattern) {
24
+ const dir = path.resolve(srcDir)
25
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
26
+ const files = []
27
+ const walk = (d) => {
28
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
29
+ const full = path.join(d, e.name)
30
+ if (e.isDirectory()) walk(full)
31
+ else if (filePattern.test(e.name)) files.push(full)
32
+ }
33
+ }
34
+ walk(dir)
35
+ return files
36
+ }
37
+
38
+ function classifyFile (absPath) {
39
+ const base = path.basename(absPath)
40
+ if (PAGE_FILE_PATTERN.test(base)) return 'page'
41
+ if (API_FILE_PATTERN.test(base)) return 'api'
42
+ if (TASK_FILE_PATTERN.test(base)) return 'task'
43
+ return null
44
+ }
45
+
46
+ function metadataIdFromFile (absPath, srcDir) {
47
+ const rel = path.relative(srcDir, absPath).replace(/\\/g, '/')
48
+ const noExt = rel
49
+ .replace(PAGE_FILE_PATTERN, '')
50
+ .replace(API_FILE_PATTERN, '')
51
+ .replace(TASK_FILE_PATTERN, '')
52
+ if (noExt === 'home' || noExt === 'index') return 'home'
53
+ return noExt.replace(/\//g, '-')
54
+ }
55
+
56
+ function defaultPageRoute (id) {
57
+ return id === 'home' ? '/' : '/' + id
58
+ }
59
+
60
+ // Parses the static `metadata` export from a source file without executing it.
61
+ // Returns a plain JS-able shape: strings, numbers, booleans, arrays, and
62
+ // nested objects with string keys. Arbitrary expressions (function calls,
63
+ // references) are not supported here — pages must declare their metadata as
64
+ // a literal object expression so the build can read it without bundling.
65
+ function parseMetadataFromSource (absPath) {
66
+ const source = fs.readFileSync(absPath, 'utf8')
67
+ const ext = path.extname(absPath)
68
+ const plugins = ['importMeta']
69
+ if (ext === '.tsx' || ext === '.ts') plugins.push('typescript')
70
+ if (ext === '.jsx' || ext === '.tsx') plugins.push('jsx')
71
+
72
+ let ast
73
+ try {
74
+ ast = babelParse(source, {
75
+ sourceType: 'module',
76
+ allowImportExportEverywhere: true,
77
+ plugins,
78
+ })
79
+ } catch (err) {
80
+ throw new Error(`[@ossy/app][build] Failed to parse ${absPath}: ${err.message}`)
81
+ }
82
+
83
+ for (const node of ast.program.body) {
84
+ if (node.type !== 'ExportNamedDeclaration') continue
85
+ const decl = node.declaration
86
+ if (!decl || decl.type !== 'VariableDeclaration') continue
87
+ for (const declarator of decl.declarations) {
88
+ if (declarator.id?.type !== 'Identifier') continue
89
+ if (declarator.id.name !== 'metadata') continue
90
+ const literal = literalFromNode(declarator.init)
91
+ if (literal === undefined) {
92
+ throw new Error(`[@ossy/app][build] metadata in ${absPath} must be a literal object expression.`)
93
+ }
94
+ return literal
95
+ }
96
+ }
97
+ return null
98
+ }
99
+
100
+ function literalFromNode (node) {
101
+ if (!node) return undefined
102
+ switch (node.type) {
103
+ case 'StringLiteral':
104
+ case 'NumericLiteral':
105
+ case 'BooleanLiteral':
106
+ return node.value
107
+ case 'NullLiteral':
108
+ return null
109
+ case 'TemplateLiteral':
110
+ if (node.expressions.length === 0) return node.quasis.map((q) => q.value.cooked).join('')
111
+ return undefined
112
+ case 'ArrayExpression': {
113
+ const out = []
114
+ for (const el of node.elements) {
115
+ if (el === null) { out.push(null); continue }
116
+ const value = literalFromNode(el)
117
+ if (value === undefined) return undefined
118
+ out.push(value)
119
+ }
120
+ return out
121
+ }
122
+ case 'ObjectExpression': {
123
+ const out = {}
124
+ for (const prop of node.properties) {
125
+ if (prop.type !== 'ObjectProperty') return undefined
126
+ let key
127
+ if (prop.key.type === 'Identifier') key = prop.key.name
128
+ else if (prop.key.type === 'StringLiteral') key = prop.key.value
129
+ else return undefined
130
+ const value = literalFromNode(prop.value)
131
+ if (value === undefined) return undefined
132
+ out[key] = value
133
+ }
134
+ return out
135
+ }
136
+ default:
137
+ return undefined
138
+ }
139
+ }
140
+
141
+ function ensureMetadataForKind ({ kind, sourcePath, parsed, srcDir }) {
142
+ const fallbackId = metadataIdFromFile(sourcePath, srcDir)
143
+ const meta = { ...(parsed || {}) }
144
+ if (!meta.id) meta.id = fallbackId
145
+ if (kind === 'page' && meta.path === undefined) meta.path = defaultPageRoute(meta.id)
146
+ if (kind === 'task' && !meta.id && meta.type) meta.id = meta.type
147
+ return meta
148
+ }
149
+
150
+ /**
151
+ * Walks `srcDir` and returns the user-authored entries that the platform
152
+ * cares about, bucketed by kind and with their static metadata already
153
+ * parsed. Empty (zero-byte) source files are skipped — they're treated as
154
+ * placeholders the user is mid-creating.
155
+ *
156
+ * Build is the only thing that calls this today, but it's deliberately a
157
+ * separate task so the gather phase stays inspectable on its own (handy
158
+ * for tooling, dev servers, and tests that want to know "what's in src/?"
159
+ * without triggering Rollup).
160
+ *
161
+ * @param {string} srcDir Absolute path to the project's `src/` directory.
162
+ * @returns {Promise<PlatformFiles>}
163
+ */
164
+ export default async function getPlatformFiles (srcDir) {
165
+ const out = { pages: [], apis: [], tasks: [] }
166
+ const all = [
167
+ ...discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN),
168
+ ...discoverFilesByPattern(srcDir, API_FILE_PATTERN),
169
+ ...discoverFilesByPattern(srcDir, TASK_FILE_PATTERN),
170
+ ]
171
+ for (const sourcePath of all) {
172
+ const stat = fs.statSync(sourcePath)
173
+ if (!stat.isFile() || stat.size === 0) continue
174
+ const kind = classifyFile(sourcePath)
175
+ if (!kind) continue
176
+ const parsed = parseMetadataFromSource(sourcePath)
177
+ const metadata = ensureMetadataForKind({ kind, sourcePath, parsed, srcDir })
178
+ out[kind === 'page' ? 'pages' : kind === 'api' ? 'apis' : 'tasks'].push({
179
+ kind,
180
+ sourcePath,
181
+ metadata,
182
+ })
183
+ }
184
+ return out
185
+ }
package/cli/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { build } from './build.js'
2
+ import { build } from './build.task.js'
3
+ import { start } from './start.task.js'
3
4
 
4
5
  const [,, command, ...restArgs] = process.argv
5
6
 
6
7
  if (!command) {
7
8
  console.error(
8
- '[@ossy/app] No command provided. Usage: app build'
9
+ '[@ossy/app] No command provided. Usage: app <build|start>'
9
10
  )
10
11
  process.exit(1)
11
12
  }
@@ -15,6 +16,10 @@ const run = async () => {
15
16
  await build(restArgs)
16
17
  return
17
18
  }
19
+ if (command === 'start') {
20
+ await start(restArgs)
21
+ return
22
+ }
18
23
  console.error(`[@ossy/app] Unknown command: ${command}`)
19
24
  process.exit(1)
20
25
  }
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
6
+ *
7
+ * @typedef {object} ManifestPluginOptions
8
+ * @property {Map<string, PlatformEntry>} entriesByStub
9
+ * Maps each generated stub's absolute path (Rollup's `facadeModuleId`) to
10
+ * the `PlatformEntry` it was created for. The plugin uses this to recover
11
+ * the entry's `kind` and parsed metadata after Rollup has bundled and
12
+ * hashed each chunk.
13
+ * @property {object} configValue
14
+ * The serializable config object (typically `src/config.js`'s default
15
+ * export). Inlined under `manifest.config` so the platform server doesn't
16
+ * need a runtime `import('./config.js')`.
17
+ * @property {string} manifestPath
18
+ * Absolute path to write the manifest to (typically `build/manifest.json`).
19
+ *
20
+ * @typedef {'page' | 'api' | 'task'} ManifestEntryType
21
+ *
22
+ * @typedef {object} ManifestEntry
23
+ * @property {ManifestEntryType} type Discriminator — one of `page | api | task`.
24
+ * @property {string} id Unique within `type`.
25
+ * @property {string | Record<string, string>} [path]
26
+ * Required for pages and APIs; absent on tasks.
27
+ * @property {string} [title] Optional; only meaningful for pages today.
28
+ * @property {string} entry URL the platform serves the bundle from
29
+ * (e.g. `/static/home.page-7f2a.js`).
30
+ *
31
+ * @typedef {object} Manifest
32
+ * @property {ManifestEntry[]} entries Flat, ordered list of every routable thing.
33
+ * @property {object} config Inlined `src/config.js` default export.
34
+ */
35
+
36
+ /**
37
+ * Rollup plugin that emits `build/manifest.json` — the single contract the
38
+ * platform server, worker, and any future CMS use to discover pages, APIs,
39
+ * and tasks.
40
+ *
41
+ * Output shape is intentionally flat: every routable thing lives in one
42
+ * `entries` array, discriminated by `type`. Consumers filter as needed
43
+ * (`entries.filter(e => e.type === 'page')` etc.). `config` keeps its own
44
+ * top-level key because it isn't an entry — it's the runtime context the
45
+ * platform threads into every page render.
46
+ *
47
+ * The plugin runs in the `writeBundle` hook (rather than `generateBundle` +
48
+ * `emitFile`) for two reasons:
49
+ *
50
+ * 1. The manifest lives at `build/manifest.json`, one level above
51
+ * `output.dir` (`build/public/static/`). Rollup forbids relative paths
52
+ * in `emitFile({ type: 'asset', fileName })`, so we write the file
53
+ * directly with `fs` once Rollup has finished writing the chunks.
54
+ * 2. Hashed chunk filenames are only stable in the post-write bundle
55
+ * object, which is exactly what `writeBundle` is given.
56
+ *
57
+ * For each entry chunk in the bundle whose `facadeModuleId` matches a
58
+ * known stub (see `entriesByStub`), the plugin appends a record to
59
+ * `manifest.entries` and stamps `entry: '/static/<chunk-name>'`.
60
+ * Duplicate ids within a `type` abort the build — routers key on `id`
61
+ * and would otherwise silently dispatch to the wrong entry.
62
+ *
63
+ * @param {ManifestPluginOptions} options
64
+ * @returns {import('rollup').Plugin}
65
+ */
66
+ export function manifestPlugin ({ entriesByStub, configValue, manifestPath }) {
67
+ return {
68
+ name: 'ossy-manifest',
69
+ writeBundle (_, bundle) {
70
+ /** @type {ManifestEntry[]} */
71
+ const entries = []
72
+ const seenIds = { page: new Set(), api: new Set(), task: new Set() }
73
+
74
+ for (const fileName of Object.keys(bundle)) {
75
+ const chunk = bundle[fileName]
76
+ if (chunk.type !== 'chunk' || !chunk.isEntry) continue
77
+ const facade = chunk.facadeModuleId
78
+ if (!facade) continue
79
+ const entryInfo = entriesByStub.get(facade)
80
+ if (!entryInfo) continue
81
+
82
+ const url = '/static/' + fileName
83
+ const meta = entryInfo.metadata
84
+ const id = meta.id
85
+ if (!id) {
86
+ this.error(`[@ossy/app][build] ${entryInfo.kind} entry missing metadata.id: ${entryInfo.sourcePath}`)
87
+ }
88
+ if (seenIds[entryInfo.kind].has(id)) {
89
+ this.error(`[@ossy/app][build] Duplicate ${entryInfo.kind} id "${id}"`)
90
+ }
91
+ seenIds[entryInfo.kind].add(id)
92
+
93
+ if (entryInfo.kind === 'page') {
94
+ entries.push({ type: 'page', id, path: meta.path, title: meta.title, entry: url })
95
+ } else if (entryInfo.kind === 'api') {
96
+ entries.push({ type: 'api', id, path: meta.path, entry: url })
97
+ } else {
98
+ entries.push({ type: 'task', id, entry: url })
99
+ }
100
+ }
101
+
102
+ /** @type {Manifest} */
103
+ const manifest = { entries, config: configValue || {} }
104
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
105
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
106
+ },
107
+ }
108
+ }
@@ -0,0 +1,19 @@
1
+ import path from 'node:path'
2
+ import arg from 'arg'
3
+ import { startServer } from '@ossy/platform/server'
4
+
5
+ export async function start (cliArgs = []) {
6
+ const options = arg({
7
+ '--port': Number,
8
+ '-p': '--port',
9
+ '--build-dir': String,
10
+ '--cwd': String,
11
+ }, { argv: cliArgs })
12
+
13
+ const cwd = options['--cwd'] ? path.resolve(options['--cwd']) : process.cwd()
14
+ const buildDir = options['--build-dir'] || 'build'
15
+ const port = options['--port']
16
+
17
+ const { lifetime } = await startServer({ cwd, buildDir, port })
18
+ await lifetime
19
+ }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
7
7
  "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./runtime/page-runtime": "./runtime/page-runtime.js",
11
+ "./runtime/api-runtime": "./runtime/api-runtime.js",
12
+ "./runtime/task-runtime": "./runtime/task-runtime.js"
13
+ },
8
14
  "bin": {
9
15
  "app": "./cli/index.js"
10
16
  },
@@ -27,15 +33,15 @@
27
33
  "@babel/eslint-parser": "^7.15.8",
28
34
  "@babel/preset-react": "^7.26.3",
29
35
  "@babel/register": "^7.25.9",
30
- "@ossy/connected-components": "^1.15.1",
31
- "@ossy/design-system": "^1.15.1",
32
- "@ossy/pages": "^1.15.1",
33
- "@ossy/platform": "^1.14.1",
34
- "@ossy/router": "^1.15.1",
35
- "@ossy/router-react": "^1.15.1",
36
- "@ossy/sdk": "^1.15.1",
37
- "@ossy/sdk-react": "^1.15.1",
38
- "@ossy/themes": "^1.15.1",
36
+ "@ossy/connected-components": "^1.16.0",
37
+ "@ossy/design-system": "^1.16.0",
38
+ "@ossy/pages": "^1.16.0",
39
+ "@ossy/platform": "^1.15.0",
40
+ "@ossy/router": "^1.16.0",
41
+ "@ossy/router-react": "^1.16.0",
42
+ "@ossy/sdk": "^1.16.0",
43
+ "@ossy/sdk-react": "^1.16.0",
44
+ "@ossy/themes": "^1.16.0",
39
45
  "@rollup/plugin-alias": "^6.0.0",
40
46
  "@rollup/plugin-babel": "6.1.0",
41
47
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -63,10 +69,11 @@
63
69
  },
64
70
  "files": [
65
71
  "/cli",
72
+ "/runtime",
66
73
  "/scripts",
67
74
  "/src",
68
75
  "README.md",
69
76
  "tsconfig.json"
70
77
  ],
71
- "gitHead": "6476ba57e0bdf65f55dfd6671a59c7a7ad773327"
78
+ "gitHead": "db2ab43d6b3266c59eb53c8046811e0acb3f87f1"
72
79
  }
@@ -0,0 +1,14 @@
1
+ export function createApiEntry (apiModule) {
2
+ const metadata = apiModule.metadata || {}
3
+ const handler = apiModule.default
4
+ return {
5
+ metadata,
6
+ async handle (req, res) {
7
+ if (typeof handler !== 'function') {
8
+ res.status(503).type('text').send('API handler unavailable')
9
+ return
10
+ }
11
+ return handler(req, res)
12
+ },
13
+ }
14
+ }
@@ -0,0 +1,91 @@
1
+ import { createElement } from 'react'
2
+ import { App } from '@ossy/connected-components'
3
+
4
+ function buildTree ({ Component, metadata, props }) {
5
+ const lang = props.htmlLang || props.defaultLanguage || 'en'
6
+ return createElement(
7
+ 'html',
8
+ { lang },
9
+ createElement(
10
+ 'head',
11
+ null,
12
+ createElement('meta', { charSet: 'utf-8' }),
13
+ createElement('title', null, metadata.title || props.documentTitle || ''),
14
+ ),
15
+ createElement(App, props, createElement(Component, props)),
16
+ )
17
+ }
18
+
19
+ function escapeBootstrapJson (json) {
20
+ return json
21
+ .replace(/</g, '\\u003c')
22
+ .replace(/>/g, '\\u003e')
23
+ .replace(/\u2028/g, '\\u2028')
24
+ .replace(/\u2029/g, '\\u2029')
25
+ }
26
+
27
+ // `entryUrl` is `import.meta.url` of the bundled stub. On the server this is a
28
+ // `file://...` URL pointing inside `build/public/static/`; we slice off the
29
+ // public-relative segment so the browser <script type="module" src="..."> tag
30
+ // resolves through the static asset middleware.
31
+ function toBootstrapUrl (entryUrl) {
32
+ if (!entryUrl) return null
33
+ if (entryUrl.startsWith('file://')) {
34
+ const u = new URL(entryUrl)
35
+ const idx = u.pathname.lastIndexOf('/static/')
36
+ if (idx !== -1) return u.pathname.slice(idx)
37
+ return null
38
+ }
39
+ return entryUrl
40
+ }
41
+
42
+ export function createPageEntry (pageModule, options = {}) {
43
+ const Component = pageModule.default
44
+ const metadata = pageModule.metadata || {}
45
+ const entryUrl = options.entryUrl || null
46
+
47
+ async function render (props = {}) {
48
+ const [{ renderToPipeableStream }, { Writable }] = await Promise.all([
49
+ import('react-dom/server'),
50
+ import('node:stream'),
51
+ ])
52
+
53
+ const tree = buildTree({ Component, metadata, props })
54
+ const bootstrapUrl = toBootstrapUrl(entryUrl)
55
+ const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
56
+ const bootstrapScriptContent =
57
+ 'window.__OSSY__=' + escapeBootstrapJson(JSON.stringify(props ?? {}))
58
+
59
+ return new Promise((resolve, reject) => {
60
+ let html = ''
61
+ const writable = new Writable({
62
+ write (chunk, _enc, cb) { html += chunk.toString(); cb() },
63
+ })
64
+ // React 19's `renderToPipeableStream` emits a leading `<!DOCTYPE html>`
65
+ // whenever the root element is `<html>`. We keep that single source of
66
+ // truth instead of concatenating our own — duplicating it would emit
67
+ // `<!doctype html><!DOCTYPE html>...` which still parses but is ugly.
68
+ writable.on('finish', () => resolve(html))
69
+ writable.on('error', reject)
70
+ const stream = renderToPipeableStream(tree, {
71
+ bootstrapScriptContent,
72
+ bootstrapModules,
73
+ onAllReady () { stream.pipe(writable) },
74
+ onError (err) { reject(err) },
75
+ })
76
+ })
77
+ }
78
+
79
+ let hydrated = false
80
+ function hydrate () {
81
+ if (hydrated) return
82
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
83
+ hydrated = true
84
+ const props = window.__OSSY__ || {}
85
+ import('react-dom/client').then(({ hydrateRoot }) => {
86
+ hydrateRoot(document, buildTree({ Component, metadata, props }))
87
+ })
88
+ }
89
+
90
+ return { metadata, render, hydrate }
91
+ }
@@ -0,0 +1,13 @@
1
+ export function createTaskEntry (taskModule) {
2
+ const metadata = taskModule.metadata || {}
3
+ const handler = taskModule.default
4
+ return {
5
+ metadata,
6
+ async run (context) {
7
+ if (typeof handler !== 'function') {
8
+ throw new Error('[@ossy/app][task] No default export to run')
9
+ }
10
+ return handler(context)
11
+ },
12
+ }
13
+ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export {
2
2
  build,
3
- writeResourceTemplatesBarrelIfPresent,
4
- resourceTemplatesDir,
5
- OSSY_RESOURCE_TEMPLATES_OUT,
6
- } from '../cli/build.js'
3
+ PAGE_FILE_PATTERN,
4
+ API_FILE_PATTERN,
5
+ TASK_FILE_PATTERN,
6
+ } from '../cli/build.task.js'
package/cli/build.js DELETED
@@ -1,449 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import url from 'node:url'
4
- import { pathToFileURL } from 'node:url'
5
- import { createRequire } from 'node:module'
6
- import arg from 'arg'
7
- import { rollup } from 'rollup'
8
- import babel from '@rollup/plugin-babel'
9
- import { nodeResolve as resolveDependencies } from '@rollup/plugin-node-resolve'
10
- import resolveCommonJsDependencies from '@rollup/plugin-commonjs'
11
- import json from '@rollup/plugin-json'
12
- import replace from '@rollup/plugin-replace'
13
- import nodeExternals from 'rollup-plugin-node-externals'
14
- import { minify as minifyWithTerser } from 'terser'
15
-
16
- export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
17
- export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
18
- export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
19
- const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.js$/
20
-
21
- export const OSSY_GEN_DIRNAME = '.ossy'
22
- export const OSSY_GEN_PAGES_BASENAME = 'pages.generated.json'
23
- export const OSSY_GEN_API_BASENAME = 'api.generated.json'
24
- export const OSSY_GEN_TASKS_BASENAME = 'tasks.generated.json'
25
- export const OSSY_RESOURCE_TEMPLATES_OUT = 'resource-templates.generated.json'
26
-
27
- const HYDRATE_ENTRY_FILENAME = 'hydrate-entry.jsx'
28
- const SSR_ENTRY_FILENAME = 'ssr-entry.mjs'
29
-
30
- function ossyGeneratedDir (buildPath) {
31
- return path.join(buildPath, OSSY_GEN_DIRNAME)
32
- }
33
-
34
- function ensureOssyGeneratedDir (buildPath) {
35
- const dir = ossyGeneratedDir(buildPath)
36
- fs.mkdirSync(dir, { recursive: true })
37
- return dir
38
- }
39
-
40
- function resetBuildDir (buildPath) {
41
- fs.rmSync(buildPath, { recursive: true, force: true })
42
- ensureOssyGeneratedDir(buildPath)
43
- }
44
-
45
- function relToGeneratedImport (generatedAbs, targetAbs) {
46
- return path.relative(path.dirname(generatedAbs), targetAbs).replace(/\\/g, '/')
47
- }
48
-
49
- export function discoverFilesByPattern (srcDir, filePattern) {
50
- const dir = path.resolve(srcDir)
51
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
52
- return []
53
- }
54
- const files = []
55
- const walk = (d) => {
56
- const entries = fs.readdirSync(d, { withFileTypes: true })
57
- for (const e of entries) {
58
- const full = path.join(d, e.name)
59
- if (e.isDirectory()) walk(full)
60
- else if (filePattern.test(e.name)) files.push(full)
61
- }
62
- }
63
- walk(dir)
64
- return files.sort()
65
- }
66
-
67
- export function buildTasksManifestPayload (taskFiles, cwd = process.cwd()) {
68
- return taskFiles.map((f) => path.relative(cwd, f).replace(/\\/g, '/'))
69
- }
70
-
71
- export function parseIdPathPairsFromFile (filePath) {
72
- try {
73
- const content = fs.readFileSync(filePath, 'utf8')
74
- const items = []
75
- const idPathPattern = /\{\s*id\s*:\s*['"]([^'"]*)['"]\s*,\s*path\s*:\s*['"]([^'"]*)['"]/g
76
- let m
77
- while ((m = idPathPattern.exec(content)) !== null) {
78
- items.push({ id: m[1], path: m[2] })
79
- }
80
- return items
81
- } catch {
82
- return []
83
- }
84
- }
85
-
86
- function writeJson (filePath, data) {
87
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
88
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
89
- }
90
-
91
- function filePathToRoute (filePath, srcDir) {
92
- const rel = path.relative(srcDir, filePath).replace(/\\/g, '/')
93
- let pathPart = rel.replace(PAGE_FILE_PATTERN, '').replace(/\/index$/, '').replace(/\/home$/, '') || 'home'
94
- if (pathPart === 'index' || pathPart === 'home') pathPart = 'home'
95
- const id = pathPart === 'home' ? 'home' : pathPart.replace(/\//g, '-')
96
- const routePath = pathPart === 'home' ? '/' : '/' + pathPart
97
- return { id, path: routePath }
98
- }
99
-
100
- function pageRouteFromSource (pageAbsPath, srcDir) {
101
- const derived = filePathToRoute(pageAbsPath, srcDir)
102
- try {
103
- const src = fs.readFileSync(pageAbsPath, 'utf8')
104
- const metaIdx = src.indexOf('export const metadata')
105
- if (metaIdx === -1) return derived
106
- const after = src.slice(metaIdx)
107
- const id = after.match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? derived.id
108
- const strPath = after.match(/\bpath\s*:\s*['"]([^'"]+)['"]/)?.[1]
109
- if (strPath) return { id, path: strPath }
110
- const pathObjBody = after.match(/\bpath\s*:\s*\{([\s\S]*?)\}/)?.[1]
111
- if (pathObjBody) {
112
- const languagePathEntries = [...pathObjBody.matchAll(/([A-Za-z0-9_]+)\s*:\s*['"]([^'"]+)['"]/g)]
113
- if (languagePathEntries.length > 0) {
114
- return {
115
- id,
116
- path: Object.fromEntries(languagePathEntries.map(([, language, routePath]) => [language, routePath])),
117
- }
118
- }
119
- }
120
- return { id, path: derived.path }
121
- } catch {
122
- return derived
123
- }
124
- }
125
-
126
- function createNodePlugins (nodeEnv) {
127
- return [
128
- replace({
129
- preventAssignment: true,
130
- 'process.env.NODE_ENV': JSON.stringify(nodeEnv),
131
- }),
132
- json(),
133
- nodeExternals({
134
- deps: false,
135
- devDeps: true,
136
- peerDeps: false,
137
- packagePath: path.join(process.cwd(), 'package.json'),
138
- }),
139
- resolveCommonJsDependencies(),
140
- resolveDependencies({ preferBuiltins: true }),
141
- babel({
142
- babelHelpers: 'bundled',
143
- extensions: ['.jsx', '.tsx'],
144
- presets: [['@babel/preset-react', { runtime: 'automatic' }]],
145
- }),
146
- ]
147
- }
148
-
149
- async function bundleNodeEntry ({ inputPath, outputFile, nodeEnv }) {
150
- const bundle = await rollup({
151
- input: inputPath,
152
- plugins: createNodePlugins(nodeEnv),
153
- })
154
- await bundle.write({
155
- file: outputFile,
156
- format: 'esm',
157
- inlineDynamicImports: true,
158
- })
159
- await bundle.close()
160
- }
161
-
162
- function generateHydrateEntry ({ pageFiles, srcDir, stubAbsPath }) {
163
- const seenIds = new Set()
164
- const pageLines = pageFiles.map((f) => {
165
- const { id } = pageRouteFromSource(f, srcDir)
166
- if (seenIds.has(id)) {
167
- throw new Error(`[@ossy/app] Duplicate page id "${id}" (${f}). Pages need unique ids.`)
168
- }
169
- seenIds.add(id)
170
- const rel = relToGeneratedImport(stubAbsPath, f)
171
- return ` ${JSON.stringify(id)}: () => import('./${rel}'),`
172
- })
173
- return [
174
- '// Generated by @ossy/app — do not edit',
175
- "import { createElement } from 'react'",
176
- "import { hydrateRoot } from 'react-dom/client'",
177
- "import { App } from '@ossy/connected-components'",
178
- 'const config = window.__INITIAL_APP_CONFIG__ || {}',
179
- 'const pages = {',
180
- ...pageLines,
181
- '}',
182
- 'const load = pages[config.pageId]',
183
- 'if (load) {',
184
- ' load().then((mod) => {',
185
- ' const Page = mod.default',
186
- ' const metadata = mod.metadata || {}',
187
- ' function PageShell (props) {',
188
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
189
- " createElement('head', null,",
190
- " createElement('meta', { charSet: 'utf-8' }),",
191
- " createElement('title', null, metadata.title || ''),",
192
- ' ),',
193
- ' createElement(App, props,',
194
- ' createElement(Page, props)',
195
- ' )',
196
- ' )',
197
- ' }',
198
- ' hydrateRoot(document, createElement(PageShell, config))',
199
- ' })',
200
- '}',
201
- '',
202
- ].join('\n')
203
- }
204
-
205
- function generateSsrEntry ({ pageFiles, srcDir, stubAbsPath }) {
206
- const seenIds = new Set()
207
- const pagesLiteral = []
208
- const pageModuleLines = []
209
- for (const f of pageFiles) {
210
- const { id, path: routePath } = pageRouteFromSource(f, srcDir)
211
- if (seenIds.has(id)) {
212
- throw new Error(`[@ossy/app] Duplicate page id "${id}" (${f}). Pages need unique ids.`)
213
- }
214
- seenIds.add(id)
215
- const rel = relToGeneratedImport(stubAbsPath, f)
216
- pagesLiteral.push(` { id: ${JSON.stringify(id)}, path: ${JSON.stringify(routePath)} },`)
217
- pageModuleLines.push(` ${JSON.stringify(id)}: () => import('./${rel}'),`)
218
- }
219
- return [
220
- '// Generated by @ossy/app — do not edit',
221
- "import { createElement } from 'react'",
222
- "import { renderToPipeableStream } from 'react-dom/server'",
223
- "import { Writable } from 'node:stream'",
224
- "import { App } from '@ossy/connected-components'",
225
- 'export const pages = [',
226
- ...pagesLiteral,
227
- ']',
228
- 'const pageModules = {',
229
- ...pageModuleLines,
230
- '}',
231
- 'function PageShell (props) {',
232
- ' const meta = props._pageMeta || {}',
233
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
234
- " createElement('head', null,",
235
- " createElement('meta', { charSet: 'utf-8' }),",
236
- " createElement('title', null, meta.title || ''),",
237
- ' ),',
238
- ' createElement(App, props,',
239
- ' createElement(props._pageComponent, props)',
240
- ' )',
241
- ' )',
242
- '}',
243
- 'export async function renderPage (pageId, props) {',
244
- ' const load = pageModules[pageId]',
245
- ' if (!load) throw new Error(`[@ossy/app] Unknown page id: ${pageId}`)',
246
- ' const mod = await load()',
247
- ' const merged = { ...props, _pageComponent: mod.default, _pageMeta: mod.metadata || {} }',
248
- ' return new Promise((resolve, reject) => {',
249
- " let html = ''",
250
- ' const writable = new Writable({',
251
- ' write (chunk, _enc, cb) { html += chunk.toString(); cb() },',
252
- ' })',
253
- ' const { pipe } = renderToPipeableStream(createElement(PageShell, merged), {',
254
- ' onAllReady () { pipe(writable) },',
255
- ' onError (err) { reject(err) },',
256
- ' })',
257
- " writable.on('finish', () => resolve(html))",
258
- ' })',
259
- '}',
260
- '',
261
- ].join('\n')
262
- }
263
-
264
- function writeAppRuntimeShims ({ middlewareSourcePath, configSourcePath, ossyDir }) {
265
- writeJson(path.join(ossyDir, 'middleware.runtime.json'), {
266
- modulePath: path.resolve(middlewareSourcePath),
267
- })
268
- writeJson(path.join(ossyDir, 'server-config.runtime.json'), {
269
- modulePath: path.resolve(configSourcePath),
270
- })
271
- }
272
-
273
- function minifyBrowserStaticChunks () {
274
- return {
275
- name: 'minify-browser-static-chunks',
276
- async renderChunk (code, chunk, outputOptions) {
277
- const fileName = chunk.fileName
278
- if (!fileName || !fileName.startsWith('public/static/')) return null
279
- const result = await minifyWithTerser(code, {
280
- sourceMap: outputOptions.sourcemap === true || typeof outputOptions.sourcemap === 'string',
281
- module: outputOptions.format === 'es' || outputOptions.format === 'esm',
282
- })
283
- return result.code ?? code
284
- },
285
- }
286
- }
287
-
288
- async function compileCombinedBundle ({ ssrEntryPath, clientEntryPath, buildPath, nodeEnv, copyPublicFrom }) {
289
- if (copyPublicFrom && fs.existsSync(copyPublicFrom)) {
290
- fs.cpSync(copyPublicFrom, path.join(buildPath, 'public'), { recursive: true, force: true })
291
- }
292
- const bundle = await rollup({
293
- input: { server: ssrEntryPath, app: clientEntryPath },
294
- plugins: createNodePlugins(nodeEnv),
295
- })
296
- await bundle.write({
297
- dir: buildPath,
298
- format: 'esm',
299
- entryFileNames: (chunk) => (chunk.name === 'server' ? 'ssr/app.mjs' : 'public/static/app.js'),
300
- chunkFileNames: 'public/static/chunks/[name]-[hash].js',
301
- plugins: [minifyBrowserStaticChunks()],
302
- })
303
- await bundle.close()
304
- }
305
-
306
- async function compileApiServerModules ({ apiFiles, ossyDir, nodeEnv }) {
307
- const modsDir = path.join(ossyDir, 'api-modules')
308
- fs.rmSync(modsDir, { recursive: true, force: true })
309
- fs.mkdirSync(modsDir, { recursive: true })
310
- const routes = []
311
- for (let i = 0; i < apiFiles.length; i++) {
312
- const outName = `api-${i}.mjs`
313
- const outFile = path.join(modsDir, outName)
314
- await bundleNodeEntry({ inputPath: apiFiles[i], outputFile: outFile, nodeEnv })
315
- let meta = {}
316
- try {
317
- const mod = await import(pathToFileURL(outFile).href)
318
- meta = mod?.metadata && typeof mod.metadata === 'object' ? mod.metadata : {}
319
- } catch {}
320
- routes.push({ ...meta, module: `api-modules/${outName}` })
321
- }
322
- return routes
323
- }
324
-
325
- async function compileTaskServerModules ({ taskFiles, ossyDir, nodeEnv }) {
326
- const modsDir = path.join(ossyDir, 'task-modules')
327
- fs.rmSync(modsDir, { recursive: true, force: true })
328
- fs.mkdirSync(modsDir, { recursive: true })
329
- const tasks = []
330
- for (let i = 0; i < taskFiles.length; i++) {
331
- const outName = `task-${i}.mjs`
332
- const outFile = path.join(modsDir, outName)
333
- await bundleNodeEntry({ inputPath: taskFiles[i], outputFile: outFile, nodeEnv })
334
- let meta = {}
335
- try {
336
- const mod = await import(pathToFileURL(outFile).href)
337
- meta = mod?.metadata && typeof mod.metadata === 'object' ? mod.metadata : {}
338
- } catch {}
339
- tasks.push({ ...meta, module: `task-modules/${outName}` })
340
- }
341
- return tasks
342
- }
343
-
344
- function writeGeneratedEntries ({ pageFiles, srcDir, ossyDir }) {
345
- const ssrEntryPath = path.join(ossyDir, SSR_ENTRY_FILENAME)
346
- const hydrateEntryPath = path.join(ossyDir, HYDRATE_ENTRY_FILENAME)
347
- fs.writeFileSync(ssrEntryPath, generateSsrEntry({ pageFiles, srcDir, stubAbsPath: ssrEntryPath }))
348
- fs.writeFileSync(hydrateEntryPath, generateHydrateEntry({ pageFiles, srcDir, stubAbsPath: hydrateEntryPath }))
349
- return { ssrEntryPath, hydrateEntryPath }
350
- }
351
-
352
- function copyPlatformRuntime ({ buildPath }) {
353
- const require = createRequire(import.meta.url)
354
- const platformServerPath = require.resolve('@ossy/platform/server')
355
- const platformWorkerPath = require.resolve('@ossy/platform/worker')
356
- const platformServerDir = path.dirname(platformServerPath)
357
- const platformWorkerDir = path.dirname(platformWorkerPath)
358
-
359
- for (const name of ['server.js', 'proxy-internal.js']) {
360
- fs.copyFileSync(path.join(platformServerDir, name), path.join(buildPath, name))
361
- }
362
- fs.copyFileSync(path.join(platformWorkerDir, 'worker-entry.js'), path.join(buildPath, 'worker.js'))
363
- fs.copyFileSync(path.join(platformWorkerDir, 'worker-runtime.js'), path.join(buildPath, 'worker-runtime.js'))
364
- }
365
-
366
- export function resourceTemplatesDir (cwd = process.cwd()) {
367
- return path.join(cwd, 'src', 'resource-templates')
368
- }
369
-
370
- export function discoverResourceTemplateFiles (templatesDir) {
371
- if (!fs.existsSync(templatesDir) || !fs.statSync(templatesDir).isDirectory()) return []
372
- return fs
373
- .readdirSync(templatesDir)
374
- .filter((n) => RESOURCE_TEMPLATE_FILE_PATTERN.test(n))
375
- .map((n) => path.join(templatesDir, n))
376
- .sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
377
- }
378
-
379
- export function generateResourceTemplatesBarrelSource ({ outputAbs, templateFilesAbs }) {
380
- const payload = templateFilesAbs.map((f) => relToGeneratedImport(outputAbs, f))
381
- return `${JSON.stringify(payload, null, 2)}\n`
382
- }
383
-
384
- export function writeResourceTemplatesBarrelIfPresent ({ cwd = process.cwd(), log = true } = {}) {
385
- const dir = resourceTemplatesDir(cwd)
386
- if (!fs.existsSync(dir)) {
387
- return { wrote: false, count: 0, path: null }
388
- }
389
- const files = discoverResourceTemplateFiles(dir)
390
- const outAbs = path.join(dir, OSSY_RESOURCE_TEMPLATES_OUT)
391
- fs.writeFileSync(outAbs, generateResourceTemplatesBarrelSource({ outputAbs: outAbs, templateFilesAbs: files }), 'utf8')
392
- if (log) {
393
- console.log(`[@ossy/app][resource-templates] merged ${files.length} template(s) → ${path.relative(cwd, outAbs)}`)
394
- }
395
- return { wrote: true, count: files.length, path: outAbs }
396
- }
397
-
398
- export async function build (cliArgs = []) {
399
- const options = arg({
400
- '--config': String,
401
- '-c': '--config',
402
- }, { argv: cliArgs })
403
-
404
- const scriptDir = path.dirname(url.fileURLToPath(import.meta.url))
405
- const cwd = process.cwd()
406
- const buildPath = path.resolve(cwd, 'build')
407
- const srcDir = path.resolve(cwd, 'src')
408
- const publicDir = path.resolve(cwd, 'public')
409
- const configPath = path.resolve(cwd, options['--config'] || 'src/config.js')
410
-
411
- resetBuildDir(buildPath)
412
- writeResourceTemplatesBarrelIfPresent({ cwd, log: false })
413
- const ossyDir = ossyGeneratedDir(buildPath)
414
-
415
- const pageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
416
- const apiFiles = discoverFilesByPattern(srcDir, API_FILE_PATTERN)
417
- const taskFiles = discoverFilesByPattern(srcDir, TASK_FILE_PATTERN)
418
-
419
- writeJson(
420
- path.join(ossyDir, OSSY_GEN_PAGES_BASENAME),
421
- pageFiles.map((f) => {
422
- const { id, path: routePath } = pageRouteFromSource(f, srcDir)
423
- return { id, path: routePath, sourceFile: path.relative(cwd, f).replace(/\\/g, '/') }
424
- })
425
- )
426
-
427
- const apiRouteList = await compileApiServerModules({ apiFiles, ossyDir, nodeEnv: 'production' })
428
- const taskRouteList = await compileTaskServerModules({ taskFiles, ossyDir, nodeEnv: 'production' })
429
- writeJson(path.join(ossyDir, OSSY_GEN_API_BASENAME), apiRouteList)
430
- writeJson(path.join(ossyDir, OSSY_GEN_TASKS_BASENAME), taskRouteList)
431
-
432
- const middlewareSourcePath = fs.existsSync(path.resolve(cwd, 'src/middleware.js'))
433
- ? path.resolve(cwd, 'src/middleware.js')
434
- : path.resolve(scriptDir, 'Middleware.js')
435
- const configSourcePath = fs.existsSync(configPath)
436
- ? configPath
437
- : path.resolve(scriptDir, 'default-config.js')
438
- writeAppRuntimeShims({ middlewareSourcePath, configSourcePath, ossyDir })
439
-
440
- const { ssrEntryPath, hydrateEntryPath } = writeGeneratedEntries({ pageFiles, srcDir, ossyDir })
441
- copyPlatformRuntime({ buildPath })
442
- await compileCombinedBundle({
443
- ssrEntryPath,
444
- clientEntryPath: hydrateEntryPath,
445
- buildPath,
446
- nodeEnv: 'production',
447
- copyPublicFrom: publicDir,
448
- })
449
- }