@ossy/app 1.15.2 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/build.task.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import url from 'node:url'
4
3
  import { pathToFileURL } from 'node:url'
5
- import { createRequire } from 'node:module'
6
4
  import arg from 'arg'
7
5
  import { rollup } from 'rollup'
8
6
  import babel from '@rollup/plugin-babel'
@@ -10,65 +8,214 @@ import { nodeResolve as resolveDependencies } from '@rollup/plugin-node-resolve'
10
8
  import resolveCommonJsDependencies from '@rollup/plugin-commonjs'
11
9
  import json from '@rollup/plugin-json'
12
10
  import replace from '@rollup/plugin-replace'
13
- import nodeExternals from 'rollup-plugin-node-externals'
14
- import getPlatformFiles from './getPlatformFiles.task.js'
15
11
 
16
- function resetBuildDir (buildPath) {
17
- fs.rmSync(buildPath, { recursive: true, force: true })
18
- fs.mkdirSync(buildPath, { recursive: true })
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 })
19
43
  }
20
44
 
21
- export async function build (cliArgs = []) {
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
+ }
22
91
 
23
- const options = arg({
24
- '--config': String,
25
- '-c': '--config',
26
- }, { argv: cliArgs })
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
+ )
27
126
 
28
127
  const cwd = process.cwd()
29
128
  const buildPath = path.resolve(cwd, 'build')
129
+ const publicOutDir = path.join(buildPath, 'public')
130
+ const staticOutDir = path.join(publicOutDir, 'static')
30
131
  const srcDir = path.resolve(cwd, 'src')
31
- const publicDir = path.resolve(cwd, 'public')
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)
32
137
 
33
- resetBuildDir(buildPath)
34
- const entries = await getPlatformFiles(srcDir)
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)
35
142
 
36
- if (publicDir && fs.existsSync(publicDir)) {
37
- fs.cpSync(publicDir, path.join(buildPath, 'public'), { recursive: true, force: true })
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 } = 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 })
38
161
  }
39
162
 
163
+ const configPath = path.resolve(srcDir, 'config.js')
164
+ const configValue = await loadConfig(configPath)
165
+
166
+ ensureDir(staticOutDir)
167
+
40
168
  const bundle = await rollup({
41
- input: entries,
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),
42
177
  plugins: [
43
178
  replace({
44
179
  preventAssignment: true,
45
180
  'process.env.NODE_ENV': '"production"',
46
181
  }),
47
182
  json(),
48
- nodeExternals({
49
- deps: false,
50
- devDeps: true,
51
- peerDeps: false,
52
- packagePath: path.join(process.cwd(), 'package.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'],
53
191
  }),
54
192
  resolveCommonJsDependencies(),
55
- resolveDependencies({ preferBuiltins: true }),
56
193
  babel({
57
194
  babelHelpers: 'bundled',
58
- extensions: ['.jsx', '.tsx'],
195
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
59
196
  presets: [['@babel/preset-react', { runtime: 'automatic' }]],
60
197
  }),
198
+ manifestPlugin({
199
+ entriesByStub,
200
+ srcDir,
201
+ staticOutDir,
202
+ configValue,
203
+ manifestPath: path.join(buildPath, 'manifest.json'),
204
+ }),
61
205
  ],
62
206
  })
63
207
 
64
208
  await bundle.write({
65
- dir: buildPath,
209
+ dir: staticOutDir,
66
210
  format: 'esm',
67
- // entryFileNames: (chunk) => (chunk.name === 'server' ? 'ssr/app.mjs' : 'public/static/app.js'),
68
- chunkFileNames: 'public/static/chunks/[name]-[hash].js',
69
- plugins: [],
211
+ entryFileNames: '[name]-[hash].js',
212
+ chunkFileNames: 'chunks/[name]-[hash].js',
213
+ sourcemap: false,
70
214
  })
71
215
 
72
216
  await bundle.close()
73
217
 
218
+ if (fs.existsSync(publicSrc)) {
219
+ fs.cpSync(publicSrc, publicOutDir, { recursive: true, force: true })
220
+ }
74
221
  }
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
5
+ export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
6
+ export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
7
+
8
+ /**
9
+ * @typedef {'page' | 'api' | 'task'} EntryKind
10
+ *
11
+ * @typedef {object} PlatformEntry
12
+ * @property {EntryKind} kind Which bucket this entry lives in.
13
+ * @property {string} sourcePath Absolute path to the user-authored source file.
14
+ *
15
+ * @typedef {object} PlatformFiles
16
+ * @property {PlatformEntry[]} pages Discovered `*.page.{jsx,tsx,...}` entries.
17
+ * @property {PlatformEntry[]} apis Discovered `*.api.{js,mjs,cjs}` entries.
18
+ * @property {PlatformEntry[]} tasks Discovered `*.task.{js,mjs,cjs}` entries.
19
+ */
20
+
21
+ function discoverFilesByPattern (srcDir, filePattern) {
22
+ const dir = path.resolve(srcDir)
23
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
24
+ const files = []
25
+ const walk = (d) => {
26
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
27
+ const full = path.join(d, e.name)
28
+ if (e.isDirectory()) walk(full)
29
+ else if (filePattern.test(e.name)) files.push(full)
30
+ }
31
+ }
32
+ walk(dir)
33
+ return files
34
+ }
35
+
36
+ function classifyFile (absPath) {
37
+ const base = path.basename(absPath)
38
+ if (PAGE_FILE_PATTERN.test(base)) return 'page'
39
+ if (API_FILE_PATTERN.test(base)) return 'api'
40
+ if (TASK_FILE_PATTERN.test(base)) return 'task'
41
+ return null
42
+ }
43
+
44
+ /**
45
+ * Default `id` derived from a source file's location relative to `src/`.
46
+ * Used when a source module doesn't export `metadata.id`.
47
+ * `src/home.page.jsx` -> `home`
48
+ * `src/index.page.jsx` -> `home`
49
+ * `src/blog/post.page.jsx` -> `blog-post`
50
+ */
51
+ export function metadataIdFromFile (absPath, srcDir) {
52
+ const rel = path.relative(srcDir, absPath).replace(/\\/g, '/')
53
+ const noExt = rel
54
+ .replace(PAGE_FILE_PATTERN, '')
55
+ .replace(API_FILE_PATTERN, '')
56
+ .replace(TASK_FILE_PATTERN, '')
57
+ if (noExt === 'home' || noExt === 'index') return 'home'
58
+ return noExt.replace(/\//g, '-')
59
+ }
60
+
61
+ /**
62
+ * Default URL path for a page when `metadata.path` isn't set.
63
+ * `home` is a special case — it lives at `/`.
64
+ */
65
+ export function defaultPageRoute (id) {
66
+ return id === 'home' ? '/' : '/' + id
67
+ }
68
+
69
+ /**
70
+ * Walks `srcDir` and returns the user-authored entries the platform cares
71
+ * about, bucketed by kind. Empty (zero-byte) source files are skipped — we
72
+ * treat them as placeholders the user is mid-creating.
73
+ *
74
+ * Metadata isn't read here on purpose: the build pipeline reads it from the
75
+ * bundled output instead, which lets users compute `metadata.path` (and
76
+ * anything else) from imports, config, env vars, etc.
77
+ *
78
+ * @param {string} srcDir Absolute path to the project's `src/` directory.
79
+ * @returns {Promise<PlatformFiles>}
80
+ */
81
+ export default async function getPlatformFiles (srcDir) {
82
+ const out = { pages: [], apis: [], tasks: [] }
83
+ const all = [
84
+ ...discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN),
85
+ ...discoverFilesByPattern(srcDir, API_FILE_PATTERN),
86
+ ...discoverFilesByPattern(srcDir, TASK_FILE_PATTERN),
87
+ ]
88
+ for (const sourcePath of all) {
89
+ const stat = fs.statSync(sourcePath)
90
+ if (!stat.isFile() || stat.size === 0) continue
91
+ const kind = classifyFile(sourcePath)
92
+ if (!kind) continue
93
+ out[kind === 'page' ? 'pages' : kind === 'api' ? 'apis' : 'tasks'].push({
94
+ kind,
95
+ sourcePath,
96
+ })
97
+ }
98
+ return out
99
+ }
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.js'
6
+
7
+ /**
8
+ * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
9
+ *
10
+ * @typedef {object} ManifestPluginOptions
11
+ * @property {Map<string, PlatformEntry>} entriesByStub
12
+ * Maps each generated stub's absolute path (Rollup's `facadeModuleId`) to
13
+ * the `PlatformEntry` it was created for. Used to recover the entry's
14
+ * `kind` and source location after Rollup has bundled and hashed each
15
+ * chunk.
16
+ * @property {string} srcDir
17
+ * Absolute path to the project's `src/` — used to derive fallback `id`s
18
+ * from source file paths when `metadata.id` is missing.
19
+ * @property {string} staticOutDir
20
+ * Absolute path to Rollup's `output.dir` (typically
21
+ * `build/public/static/`). Bundled chunks live here, and we dynamically
22
+ * `import()` them to read their exported metadata.
23
+ * @property {object} configValue
24
+ * The serializable config object (typically `src/config.js`'s default
25
+ * export). Inlined under `manifest.config` so the platform server doesn't
26
+ * need a runtime `import('./config.js')`.
27
+ * @property {string} manifestPath
28
+ * Absolute path to write the manifest to (typically `build/manifest.json`).
29
+ *
30
+ * @typedef {'page' | 'api' | 'task'} ManifestEntryType
31
+ *
32
+ * @typedef {object} ManifestEntry
33
+ * @property {ManifestEntryType} type Discriminator — one of `page | api | task`.
34
+ * @property {string} id Unique within `type`.
35
+ * @property {string | Record<string, string>} [path]
36
+ * Required for pages and APIs; absent on tasks.
37
+ * @property {string} [title] Optional; only meaningful for pages today.
38
+ * @property {string} entry URL the platform serves the bundle from
39
+ * (e.g. `/static/home.page-7f2a.js`).
40
+ *
41
+ * @typedef {object} Manifest
42
+ * @property {ManifestEntry[]} entries Flat, ordered list of every routable thing.
43
+ * @property {object} config Inlined `src/config.js` default export.
44
+ */
45
+
46
+ /**
47
+ * Rollup plugin that emits `build/manifest.json` — the single contract the
48
+ * platform server, worker, and any future CMS use to discover pages, APIs,
49
+ * and tasks.
50
+ *
51
+ * Output shape is intentionally flat: every routable thing lives in one
52
+ * `entries` array, discriminated by `type`. Consumers filter as needed
53
+ * (`entries.filter(e => e.type === 'page')` etc.). `config` keeps its own
54
+ * top-level key because it isn't an entry — it's the runtime context the
55
+ * platform threads into every page render.
56
+ *
57
+ * Metadata is read from the **bundled** output, not from the source file.
58
+ * Each entry stub re-exports `metadata` from the user's module, so once
59
+ * Rollup writes the chunk we can `await import(chunkUrl)` and read
60
+ * `mod.metadata` directly. This lets users compute metadata however they
61
+ * like (function calls, `Object.fromEntries`, values derived from imported
62
+ * config, etc.) — anything that runs at module load in Node will work.
63
+ *
64
+ * The plugin runs in the `writeBundle` hook (rather than `generateBundle` +
65
+ * `emitFile`) for two reasons:
66
+ *
67
+ * 1. The manifest lives at `build/manifest.json`, one level above
68
+ * `output.dir` (`build/public/static/`). Rollup forbids relative paths
69
+ * in `emitFile({ type: 'asset', fileName })`, so we write the file
70
+ * directly with `fs` once Rollup has finished writing the chunks.
71
+ * 2. We need the chunks on disk to dynamically `import()` them and read
72
+ * their exported `metadata`.
73
+ *
74
+ * Duplicate ids within a `type` abort the build — routers key on `id` and
75
+ * would otherwise silently dispatch to the wrong entry.
76
+ *
77
+ * @param {ManifestPluginOptions} options
78
+ * @returns {import('rollup').Plugin}
79
+ */
80
+ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, manifestPath }) {
81
+ return {
82
+ name: 'ossy-manifest',
83
+ async writeBundle (_, bundle) {
84
+ /** @type {ManifestEntry[]} */
85
+ const entries = []
86
+ const seenIds = { page: new Set(), api: new Set(), task: new Set() }
87
+
88
+ for (const fileName of Object.keys(bundle)) {
89
+ const chunk = bundle[fileName]
90
+ if (chunk.type !== 'chunk' || !chunk.isEntry) continue
91
+ const facade = chunk.facadeModuleId
92
+ if (!facade) continue
93
+ const entryInfo = entriesByStub.get(facade)
94
+ if (!entryInfo) continue
95
+
96
+ const url = '/static/' + fileName
97
+ const localPath = path.join(staticOutDir, fileName)
98
+
99
+ // Cache-bust the import so consecutive builds in a watch loop don't
100
+ // pick up a stale module from Node's loader cache.
101
+ const importHref = pathToFileURL(localPath).href + `?ts=${Date.now()}`
102
+ let mod
103
+ try {
104
+ mod = await import(importHref)
105
+ } catch (err) {
106
+ this.error(
107
+ `[@ossy/app][build] Failed to import bundled ${entryInfo.kind} entry ` +
108
+ `${entryInfo.sourcePath}: ${err && err.message ? err.message : err}`,
109
+ )
110
+ }
111
+
112
+ const rawMeta = (mod && mod.metadata) || {}
113
+ const id = rawMeta.id || metadataIdFromFile(entryInfo.sourcePath, srcDir)
114
+ if (!id) {
115
+ this.error(`[@ossy/app][build] ${entryInfo.kind} entry missing metadata.id: ${entryInfo.sourcePath}`)
116
+ }
117
+ if (seenIds[entryInfo.kind].has(id)) {
118
+ this.error(`[@ossy/app][build] Duplicate ${entryInfo.kind} id "${id}"`)
119
+ }
120
+ seenIds[entryInfo.kind].add(id)
121
+
122
+ if (entryInfo.kind === 'page') {
123
+ const pagePath = rawMeta.path !== undefined ? rawMeta.path : defaultPageRoute(id)
124
+ entries.push({ type: 'page', id, path: pagePath, title: rawMeta.title, entry: url })
125
+ } else if (entryInfo.kind === 'api') {
126
+ entries.push({ type: 'api', id, path: rawMeta.path, entry: url })
127
+ } else {
128
+ entries.push({ type: 'task', id, entry: url })
129
+ }
130
+ }
131
+
132
+ /** @type {Manifest} */
133
+ const manifest = { entries, config: configValue || {} }
134
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
135
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
136
+ },
137
+ }
138
+ }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.15.2",
3
+ "version": "1.16.1",
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.2",
31
- "@ossy/design-system": "^1.15.2",
32
- "@ossy/pages": "^1.15.2",
33
- "@ossy/platform": "^1.14.2",
34
- "@ossy/router": "^1.15.2",
35
- "@ossy/router-react": "^1.15.2",
36
- "@ossy/sdk": "^1.15.2",
37
- "@ossy/sdk-react": "^1.15.2",
38
- "@ossy/themes": "^1.15.2",
36
+ "@ossy/connected-components": "^1.16.1",
37
+ "@ossy/design-system": "^1.16.1",
38
+ "@ossy/pages": "^1.16.1",
39
+ "@ossy/platform": "^1.15.1",
40
+ "@ossy/router": "^1.16.1",
41
+ "@ossy/router-react": "^1.16.1",
42
+ "@ossy/sdk": "^1.16.1",
43
+ "@ossy/sdk-react": "^1.16.1",
44
+ "@ossy/themes": "^1.16.1",
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": "6a15f3ac4cafdb190beed11e4a93adc0ba28c245"
78
+ "gitHead": "031e2329e267a3e0cebc2ec0d484fa11b53082d9"
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,
3
+ PAGE_FILE_PATTERN,
4
+ API_FILE_PATTERN,
5
+ TASK_FILE_PATTERN,
6
6
  } from '../cli/build.task.js'
@@ -1,234 +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
-
15
- export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
16
- export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
17
- export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
18
- const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.(mjs|cjs|js)$/
19
-
20
- export const OSSY_GEN_DIRNAME = '.ossy'
21
- export const OSSY_GEN_ENTRIES_BASENAME = 'entries.generated.json'
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
- function resetBuildDir (buildPath) {
28
- fs.rmSync(buildPath, { recursive: true, force: true })
29
- const dir = path.join(buildPath, OSSY_GEN_DIRNAME)
30
- fs.mkdirSync(dir, { recursive: true })
31
- return dir
32
- }
33
-
34
- function relToGeneratedImport (generatedAbs, targetAbs) {
35
- return path.relative(path.dirname(generatedAbs), targetAbs).replace(/\\/g, '/')
36
- }
37
-
38
- export function discoverFilesByPattern (srcDir, filePattern) {
39
- const dir = path.resolve(srcDir)
40
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
41
- return []
42
- }
43
- const files = []
44
- const walk = (d) => {
45
- const entries = fs.readdirSync(d, { withFileTypes: true })
46
- for (const e of entries) {
47
- const full = path.join(d, e.name)
48
- if (e.isDirectory()) walk(full)
49
- else if (filePattern.test(e.name)) files.push(full)
50
- }
51
- }
52
- walk(dir)
53
- return files
54
- }
55
-
56
-
57
- function writeJson (filePath, data) {
58
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
59
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
60
- }
61
-
62
- function filePathToRoute (filePath, srcDir) {
63
- const rel = path.relative(srcDir, filePath).replace(/\\/g, '/')
64
- let pathPart = rel.replace(PAGE_FILE_PATTERN, '').replace(/\/index$/, '').replace(/\/home$/, '') || 'home'
65
- if (pathPart === 'index' || pathPart === 'home') pathPart = 'home'
66
- const id = pathPart === 'home' ? 'home' : pathPart.replace(/\//g, '-')
67
- const routePath = pathPart === 'home' ? '/' : '/' + pathPart
68
- return { id, path: routePath }
69
- }
70
-
71
- function pageRouteFromSource (pageAbsPath, srcDir) {
72
- const derived = filePathToRoute(pageAbsPath, srcDir)
73
- try {
74
- const src = fs.readFileSync(pageAbsPath, 'utf8')
75
- const metaIdx = src.indexOf('export const metadata')
76
- if (metaIdx === -1) return derived
77
- const after = src.slice(metaIdx)
78
- const id = after.match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? derived.id
79
- const strPath = after.match(/\bpath\s*:\s*['"]([^'"]+)['"]/)?.[1]
80
- if (strPath) return { id, path: strPath }
81
- const pathObjBody = after.match(/\bpath\s*:\s*\{([\s\S]*?)\}/)?.[1]
82
- if (pathObjBody) {
83
- const languagePathEntries = [...pathObjBody.matchAll(/([A-Za-z0-9_]+)\s*:\s*['"]([^'"]+)['"]/g)]
84
- if (languagePathEntries.length > 0) {
85
- return {
86
- id,
87
- path: Object.fromEntries(languagePathEntries.map(([, language, routePath]) => [language, routePath])),
88
- }
89
- }
90
- }
91
- return { id, path: derived.path }
92
- } catch {
93
- return derived
94
- }
95
- }
96
-
97
- function generateHydrateEntry ({ pageFiles, srcDir, stubAbsPath }) {
98
- const seenIds = new Set()
99
- const pageLines = pageFiles.map((f) => {
100
- const { id } = pageRouteFromSource(f, srcDir)
101
- if (seenIds.has(id)) {
102
- throw new Error(`[@ossy/app] Duplicate page id "${id}" (${f}). Pages need unique ids.`)
103
- }
104
- seenIds.add(id)
105
- const rel = relToGeneratedImport(stubAbsPath, f)
106
- return ` ${JSON.stringify(id)}: () => import('./${rel}'),`
107
- })
108
- return [
109
- '// Generated by @ossy/app — do not edit',
110
- "import { createElement } from 'react'",
111
- "import { hydrateRoot } from 'react-dom/client'",
112
- "import { App } from '@ossy/connected-components'",
113
- 'const config = window.__INITIAL_APP_CONFIG__ || {}',
114
- 'const pages = {',
115
- ...pageLines,
116
- '}',
117
- 'const load = pages[config.pageId]',
118
- 'if (load) {',
119
- ' load().then((mod) => {',
120
- ' const Page = mod.default',
121
- ' const metadata = mod.metadata || {}',
122
- ' function PageShell (props) {',
123
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
124
- " createElement('head', null,",
125
- " createElement('meta', { charSet: 'utf-8' }),",
126
- " createElement('title', null, metadata.title || ''),",
127
- ' ),',
128
- ' createElement(App, props,',
129
- ' createElement(Page, props)',
130
- ' )',
131
- ' )',
132
- ' }',
133
- ' hydrateRoot(document, createElement(PageShell, config))',
134
- ' })',
135
- '}',
136
- '',
137
- ].join('\n')
138
- }
139
-
140
- function generateSsrEntry ({ pageFiles, srcDir, stubAbsPath }) {
141
- const seenIds = new Set()
142
- const pagesLiteral = []
143
- const pageModuleLines = []
144
- for (const f of pageFiles) {
145
- const { id, path: routePath } = pageRouteFromSource(f, srcDir)
146
- if (seenIds.has(id)) {
147
- throw new Error(`[@ossy/app] Duplicate page id "${id}" (${f}). Pages need unique ids.`)
148
- }
149
- seenIds.add(id)
150
- const rel = relToGeneratedImport(stubAbsPath, f)
151
- pagesLiteral.push(` { id: ${JSON.stringify(id)}, path: ${JSON.stringify(routePath)} },`)
152
- pageModuleLines.push(` ${JSON.stringify(id)}: () => import('./${rel}'),`)
153
- }
154
- return [
155
- '// Generated by @ossy/app — do not edit',
156
- "import { createElement } from 'react'",
157
- "import { renderToPipeableStream } from 'react-dom/server'",
158
- "import { Writable } from 'node:stream'",
159
- "import { App } from '@ossy/connected-components'",
160
- 'export const pages = [',
161
- ...pagesLiteral,
162
- ']',
163
- 'const pageModules = {',
164
- ...pageModuleLines,
165
- '}',
166
- 'function PageShell (props) {',
167
- ' const meta = props._pageMeta || {}',
168
- " return createElement('html', { lang: props.defaultLanguage || 'en' },",
169
- " createElement('head', null,",
170
- " createElement('meta', { charSet: 'utf-8' }),",
171
- " createElement('title', null, meta.title || ''),",
172
- ' ),',
173
- ' createElement(App, props,',
174
- ' createElement(props._pageComponent, props)',
175
- ' )',
176
- ' )',
177
- '}',
178
- 'export async function renderPage (pageId, props) {',
179
- ' const load = pageModules[pageId]',
180
- ' if (!load) throw new Error(`[@ossy/app] Unknown page id: ${pageId}`)',
181
- ' const mod = await load()',
182
- ' const merged = { ...props, _pageComponent: mod.default, _pageMeta: mod.metadata || {} }',
183
- ' return new Promise((resolve, reject) => {',
184
- " let html = ''",
185
- ' const writable = new Writable({',
186
- ' write (chunk, _enc, cb) { html += chunk.toString(); cb() },',
187
- ' })',
188
- ' const { pipe } = renderToPipeableStream(createElement(PageShell, merged), {',
189
- ' onAllReady () { pipe(writable) },',
190
- ' onError (err) { reject(err) },',
191
- ' })',
192
- " writable.on('finish', () => resolve(html))",
193
- ' })',
194
- '}',
195
- '',
196
- ].join('\n')
197
- }
198
-
199
- export async function build (cliArgs = []) {
200
-
201
- const options = arg({
202
- '--config': String,
203
- '-c': '--config',
204
- }, { argv: cliArgs })
205
-
206
- const cwd = process.cwd()
207
- const buildPath = path.resolve(cwd, 'build')
208
- const ossyDir = path.join(buildPath, OSSY_GEN_DIRNAME)
209
- const srcDir = path.resolve(cwd, 'src')
210
- const publicDir = path.resolve(cwd, 'public')
211
- const configPath = path.resolve(cwd, options['--config'] || 'src/config.js')
212
- const middlewarePath = fs.existsSync(path.resolve(cwd, 'src/middleware.js')) ? path.resolve(cwd, 'src/middleware.js') : null
213
-
214
- resetBuildDir(buildPath)
215
-
216
- const pageFiles = discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN)
217
- const apiFiles = discoverFilesByPattern(srcDir, API_FILE_PATTERN)
218
- const taskFiles = discoverFilesByPattern(srcDir, TASK_FILE_PATTERN)
219
- const resourceTemplatesFiles = discoverFilesByPattern(srcDir, TASK_FILE_PATTERN)
220
-
221
- const entries = [
222
- ...(middlewarePath ? [middlewarePath] : []),
223
- ...(configPath ? [configPath] : []),
224
- ...pageFiles,
225
- ...apiFiles,
226
- ...taskFiles,
227
- ...resourceTemplatesFiles,
228
- ]
229
-
230
- fs.mkdirSync(path.dirname(path.join(ossyDir, OSSY_GEN_ENTRIES_BASENAME)), { recursive: true })
231
- fs.writeFileSync(path.join(ossyDir, OSSY_GEN_ENTRIES_BASENAME), `${JSON.stringify(entries, null, 2)}\n`, 'utf8')
232
-
233
-
234
- }
@@ -1,51 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import url from 'node:url'
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
- export const RESOURCE_TEMPLATE_FILE_PATTERN = /\.resource\.(mjs|cjs|js)$/
9
-
10
- function discoverFilesByPattern (srcDir, filePattern) {
11
- const dir = path.resolve(srcDir)
12
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
13
- return []
14
- }
15
- const files = []
16
- const walk = (d) => {
17
- const entries = fs.readdirSync(d, { withFileTypes: true })
18
- for (const e of entries) {
19
- const full = path.join(d, e.name)
20
- if (e.isDirectory()) walk(full)
21
- else if (filePattern.test(e.name)) files.push(full)
22
- }
23
- }
24
- walk(dir)
25
- return files
26
- }
27
-
28
-
29
- export default async function getPlatformFiles(_folder) {
30
- const cwd = process.cwd()
31
- const folder = path.resolve(cwd, _folder)
32
-
33
- const configPath = fs.existsSync(path.resolve(folder, 'config.js')) ? path.resolve(folder, 'config.js') : null
34
- const middlewarePath = fs.existsSync(path.resolve(folder, 'middleware.js')) ? path.resolve(folder, 'middleware.js') : null
35
-
36
- const pageFiles = discoverFilesByPattern(folder, PAGE_FILE_PATTERN)
37
- const apiFiles = discoverFilesByPattern(folder, API_FILE_PATTERN)
38
- const taskFiles = discoverFilesByPattern(folder, TASK_FILE_PATTERN)
39
- const resourceTemplatesFiles = discoverFilesByPattern(folder, TASK_FILE_PATTERN)
40
-
41
- const entries = [
42
- ...(middlewarePath ? [middlewarePath] : []),
43
- ...(configPath ? [configPath] : []),
44
- ...pageFiles,
45
- ...apiFiles,
46
- ...taskFiles,
47
- ...resourceTemplatesFiles,
48
- ]
49
-
50
- return entries
51
- }