@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.
- package/cli/build.task.js +219 -0
- package/cli/get-platform-files.task.js +185 -0
- package/cli/index.js +7 -2
- package/cli/manifest-plugin.js +108 -0
- package/cli/start.task.js +19 -0
- package/package.json +18 -11
- package/runtime/api-runtime.js +14 -0
- package/runtime/page-runtime.js +91 -0
- package/runtime/task-runtime.js +13 -0
- package/src/index.js +4 -4
- package/cli/build.js +0 -449
|
@@ -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.
|
|
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.
|
|
31
|
-
"@ossy/design-system": "^1.
|
|
32
|
-
"@ossy/pages": "^1.
|
|
33
|
-
"@ossy/platform": "^1.
|
|
34
|
-
"@ossy/router": "^1.
|
|
35
|
-
"@ossy/router-react": "^1.
|
|
36
|
-
"@ossy/sdk": "^1.
|
|
37
|
-
"@ossy/sdk-react": "^1.
|
|
38
|
-
"@ossy/themes": "^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": "
|
|
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
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
|
-
}
|