@octanejs/app-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Bundler-neutral loading for `octane.config.ts`.
5
+ *
6
+ * Development integrations can inject their own module runner so config
7
+ * modules participate in the integration's native graph and invalidation.
8
+ * Build tools and CLIs use the esbuild evaluator, which bundles local config
9
+ * helpers, compiles imported `.tsrx` files for the server, and reports every
10
+ * file consulted for watch/cache invalidation.
11
+ */
12
+
13
+ /** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/app-core' */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { createHash } from 'node:crypto';
18
+ import { builtinModules, createRequire } from 'node:module';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ import { build } from 'esbuild';
22
+ import { compile } from 'octane/compiler';
23
+
24
+ import { resolveOctaneConfig } from './resolve-config.js';
25
+
26
+ const DEFAULT_CONFIG_FILE = 'octane.config.ts';
27
+ const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
28
+ const BUILTINS = new Set([...builtinModules, ...builtinModules.map((id) => `node:${id}`)]);
29
+
30
+ // Validation + defaults have no compiler/esbuild imports and remain safe to
31
+ // include in production server bundles.
32
+ export { resolveOctaneConfig } from './resolve-config.js';
33
+
34
+ /**
35
+ * @typedef {Object} ConfigModuleRunner
36
+ * @property {(id: string) => Promise<Record<string, unknown>>} loadModule
37
+ * @property {((id: string) => string[] | Promise<string[]>)} [getDependencies]
38
+ * @property {((id: string) => string[] | Promise<string[]>)} [getMissingDependencies]
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} LoadConfigOptions
43
+ * @property {string} [configFile] Relative to `projectRoot`, or absolute
44
+ * @property {boolean} [requireAdapter]
45
+ * @property {ConfigModuleRunner | ((id: string) => Promise<Record<string, unknown>>)} [moduleRunner]
46
+ * @property {string} [cacheDir] Directory for the evaluated ESM module
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} LoadedOctaneConfig
51
+ * @property {ResolvedOctaneConfig} config
52
+ * @property {string} configPath
53
+ * @property {string[]} dependencies Existing files that affect the config
54
+ * @property {string[]} missingDependencies Unresolved files/specifiers consulted while loading
55
+ */
56
+
57
+ /**
58
+ * Return the absolute config path for a project.
59
+ *
60
+ * @param {string} projectRoot
61
+ * @param {string} [configFile]
62
+ * @returns {string}
63
+ */
64
+ export function getOctaneConfigPath(projectRoot, configFile = DEFAULT_CONFIG_FILE) {
65
+ return path.resolve(projectRoot, configFile);
66
+ }
67
+
68
+ /**
69
+ * @param {string} projectRoot
70
+ * @param {string} [configFile]
71
+ * @returns {boolean}
72
+ */
73
+ export function octaneConfigExists(projectRoot, configFile = DEFAULT_CONFIG_FILE) {
74
+ return fs.existsSync(getOctaneConfigPath(projectRoot, configFile));
75
+ }
76
+
77
+ /**
78
+ * Load, validate, and resolve `octane.config.ts`.
79
+ *
80
+ * @param {string} projectRoot
81
+ * @param {LoadConfigOptions} [options]
82
+ * @returns {Promise<ResolvedOctaneConfig>}
83
+ */
84
+ export async function loadOctaneConfig(projectRoot, options = {}) {
85
+ return (await loadOctaneConfigWithMetadata(projectRoot, options)).config;
86
+ }
87
+
88
+ /**
89
+ * Load a config and return the dependency metadata an integration needs to
90
+ * invalidate its cache and register watch dependencies.
91
+ *
92
+ * @param {string} projectRoot
93
+ * @param {LoadConfigOptions} [options]
94
+ * @returns {Promise<LoadedOctaneConfig>}
95
+ */
96
+ export async function loadOctaneConfigWithMetadata(projectRoot, options = {}) {
97
+ const root = path.resolve(projectRoot);
98
+ const configPath = getOctaneConfigPath(root, options.configFile);
99
+ if (!fs.existsSync(configPath)) {
100
+ throw new Error(`[octane] ${path.basename(configPath)} not found in ${root}`);
101
+ }
102
+
103
+ if (options.moduleRunner) {
104
+ const runner = options.moduleRunner;
105
+ const loadModule = typeof runner === 'function' ? runner : runner.loadModule.bind(runner);
106
+ const configModule = await loadModule(configPath);
107
+ const runnerDependencies =
108
+ typeof runner === 'object' && runner.getDependencies
109
+ ? await runner.getDependencies(configPath)
110
+ : [];
111
+ const runnerMissingDependencies =
112
+ typeof runner === 'object' && runner.getMissingDependencies
113
+ ? await runner.getMissingDependencies(configPath)
114
+ : [];
115
+ return {
116
+ config: resolveOctaneConfig(/** @type {OctaneConfigOptions} */ (configModule.default), {
117
+ requireAdapter: options.requireAdapter,
118
+ }),
119
+ configPath,
120
+ dependencies: sortUnique([configPath, ...runnerDependencies]),
121
+ missingDependencies: sortUnique(runnerMissingDependencies),
122
+ };
123
+ }
124
+
125
+ const evaluated = await evaluateConfigModule(root, configPath, options.cacheDir);
126
+ return {
127
+ config: resolveOctaneConfig(/** @type {OctaneConfigOptions} */ (evaluated.module.default), {
128
+ requireAdapter: options.requireAdapter,
129
+ }),
130
+ configPath,
131
+ dependencies: evaluated.dependencies,
132
+ missingDependencies: evaluated.missingDependencies,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * @param {string} root
138
+ * @param {string} configPath
139
+ * @param {string | undefined} configuredCacheDir
140
+ */
141
+ async function evaluateConfigModule(root, configPath, configuredCacheDir) {
142
+ const dependencies = new Set([configPath]);
143
+ const missingDependencies = new Set();
144
+ const packageRequire = createRequire(configPath);
145
+
146
+ /** @type {import('esbuild').Plugin} */
147
+ const dependencyPlugin = {
148
+ name: 'octane-config-dependencies',
149
+ setup(buildApi) {
150
+ buildApi.onResolve({ filter: /.*/ }, (args) => {
151
+ if (BUILTINS.has(args.path)) return null;
152
+ // Preserve lazy config imports instead of traversing an application's
153
+ // renderer graph during config evaluation. Dev integrations execute
154
+ // these through their native module runner; the neutral evaluator only
155
+ // needs the declarative config object now. Absolute file URLs keep the
156
+ // lazy reference stable when the evaluated module lives in a cache dir.
157
+ if (args.kind === 'dynamic-import' && !isBareSpecifier(args.path)) {
158
+ const candidate = path.resolve(args.resolveDir, args.path);
159
+ const resolved = resolveLocalCandidate(candidate);
160
+ if (resolved) {
161
+ dependencies.add(resolved);
162
+ return { path: pathToFileURL(resolved).href, external: true };
163
+ }
164
+ missingDependencies.add(candidate);
165
+ return { path: args.path, external: true };
166
+ }
167
+ if (isBareSpecifier(args.path)) {
168
+ try {
169
+ const resolved = packageRequire.resolve(args.path);
170
+ dependencies.add(resolved);
171
+ const packageJson = findPackageJson(resolved);
172
+ if (packageJson) dependencies.add(packageJson);
173
+ } catch {
174
+ missingDependencies.add(args.path);
175
+ }
176
+ return null;
177
+ }
178
+
179
+ if (args.kind === 'entry-point') return null;
180
+ const candidate = path.resolve(args.resolveDir, args.path);
181
+ if (!resolveLocalCandidate(candidate)) missingDependencies.add(candidate);
182
+ return null;
183
+ });
184
+
185
+ buildApi.onLoad({ filter: OCTANE_EXTENSION_PATTERN }, async (args) => {
186
+ dependencies.add(args.path);
187
+ const source = await fs.promises.readFile(args.path, 'utf8');
188
+ const filename = path.relative(root, args.path).split(path.sep).join('/');
189
+ const result = compile(source, filename, { mode: 'server', hmr: false });
190
+ return {
191
+ contents: typeof result === 'string' ? result : result.code,
192
+ loader: 'js',
193
+ resolveDir: path.dirname(args.path),
194
+ };
195
+ });
196
+ },
197
+ };
198
+
199
+ let result;
200
+ try {
201
+ result = await build({
202
+ absWorkingDir: root,
203
+ entryPoints: [configPath],
204
+ bundle: true,
205
+ format: 'esm',
206
+ platform: 'node',
207
+ target: 'node22',
208
+ packages: 'external',
209
+ metafile: true,
210
+ sourcemap: 'inline',
211
+ write: false,
212
+ plugins: [dependencyPlugin],
213
+ logLevel: 'silent',
214
+ });
215
+ } catch (error) {
216
+ attachDependencyMetadata(error, dependencies, missingDependencies);
217
+ throw error;
218
+ }
219
+
220
+ for (const input of Object.keys(result.metafile.inputs)) {
221
+ const filename = path.isAbsolute(input) ? input : path.resolve(root, input);
222
+ if (fs.existsSync(filename)) dependencies.add(filename);
223
+ }
224
+
225
+ const output = result.outputFiles[0]?.text;
226
+ if (!output) throw new Error('[octane] Config evaluation produced no JavaScript output.');
227
+
228
+ const cacheDir = configuredCacheDir
229
+ ? path.resolve(root, configuredCacheDir)
230
+ : path.join(root, 'node_modules/.cache/octane/config');
231
+ const outputPath = path.join(cacheDir, 'octane.config.mjs');
232
+ fs.mkdirSync(cacheDir, { recursive: true });
233
+ if (!fs.existsSync(outputPath) || fs.readFileSync(outputPath, 'utf8') !== output) {
234
+ fs.writeFileSync(outputPath, output);
235
+ }
236
+ const contentHash = createHash('sha256').update(output).digest('hex').slice(0, 16);
237
+ let configModule;
238
+ try {
239
+ configModule = await import(`${pathToFileURL(outputPath).href}?v=${contentHash}`);
240
+ } catch (error) {
241
+ attachDependencyMetadata(error, dependencies, missingDependencies);
242
+ throw error;
243
+ }
244
+
245
+ return {
246
+ module: configModule,
247
+ dependencies: sortUnique(dependencies),
248
+ missingDependencies: sortUnique(missingDependencies),
249
+ };
250
+ }
251
+
252
+ /** @param {string} specifier */
253
+ function isBareSpecifier(specifier) {
254
+ return (
255
+ !specifier.startsWith('.') && !path.isAbsolute(specifier) && !specifier.startsWith('file:')
256
+ );
257
+ }
258
+
259
+ /** @param {string} candidate */
260
+ function resolveLocalCandidate(candidate) {
261
+ for (const filename of [
262
+ candidate,
263
+ `${candidate}.ts`,
264
+ `${candidate}.tsx`,
265
+ `${candidate}.tsrx`,
266
+ `${candidate}.js`,
267
+ `${candidate}.mjs`,
268
+ `${candidate}.cjs`,
269
+ path.join(candidate, 'index.ts'),
270
+ path.join(candidate, 'index.tsx'),
271
+ path.join(candidate, 'index.tsrx'),
272
+ path.join(candidate, 'index.js'),
273
+ ]) {
274
+ if (fs.existsSync(filename)) return filename;
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** @param {string} filename */
280
+ function findPackageJson(filename) {
281
+ let current = path.dirname(filename);
282
+ for (;;) {
283
+ const candidate = path.join(current, 'package.json');
284
+ if (fs.existsSync(candidate)) return candidate;
285
+ const parent = path.dirname(current);
286
+ if (parent === current) return null;
287
+ current = parent;
288
+ }
289
+ }
290
+
291
+ /** @param {Iterable<string>} values */
292
+ function sortUnique(values) {
293
+ return [...new Set(values)].sort();
294
+ }
295
+
296
+ /**
297
+ * @param {unknown} error
298
+ * @param {Set<string>} dependencies
299
+ * @param {Set<string>} missingDependencies
300
+ */
301
+ function attachDependencyMetadata(error, dependencies, missingDependencies) {
302
+ if (!error || typeof error !== 'object') return;
303
+ Object.assign(error, {
304
+ dependencies: sortUnique(dependencies),
305
+ missingDependencies: sortUnique(missingDependencies),
306
+ });
307
+ }
package/src/config.js ADDED
@@ -0,0 +1,14 @@
1
+ export { DEFAULT_OUTDIR, ENTRY_FILENAME, OCTANE_NONCE_STATE_KEY } from './constants.js';
2
+ export { resolveOctaneConfig } from './resolve-config.js';
3
+ export { RenderRoute, ServerRoute } from './routes.js';
4
+
5
+ /**
6
+ * Type-safe identity helper for `octane.config.ts`.
7
+ *
8
+ * @template {import('@octanejs/app-core').OctaneConfigOptions} T
9
+ * @param {T} options
10
+ * @returns {T}
11
+ */
12
+ export function defineConfig(options) {
13
+ return options;
14
+ }
@@ -0,0 +1,5 @@
1
+ // @ts-check
2
+ export const DEFAULT_OUTDIR = 'dist';
3
+ export const ENTRY_FILENAME = 'entry.js';
4
+ /** Context.state key a middleware sets to apply a per-request CSP nonce. */
5
+ export const OCTANE_NONCE_STATE_KEY = 'octane.nonce';
package/src/html.js ADDED
@@ -0,0 +1,10 @@
1
+ export { composeHtmlStream } from './server/html-stream.js';
2
+ export {
3
+ HYDRATION_NONCE_PLACEHOLDER,
4
+ applyHydrationNonce,
5
+ getContextNonce,
6
+ injectHydrationEntry,
7
+ nonceAttribute,
8
+ splitSsrTemplate,
9
+ validateSsrTemplate,
10
+ } from './server/html-template.js';
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { DEFAULT_OUTDIR, ENTRY_FILENAME, OCTANE_NONCE_STATE_KEY } from './constants.js';
2
+ export { defineConfig, resolveOctaneConfig } from './config.js';
3
+ export {
4
+ RenderRoute,
5
+ ServerRoute,
6
+ createRouter,
7
+ get_component_export,
8
+ get_route_entry_export_name,
9
+ get_route_entry_id,
10
+ get_route_entry_path,
11
+ } from './routes.js';
12
+ export {
13
+ compose,
14
+ createContext,
15
+ handleServerRoute,
16
+ is_rpc_request,
17
+ runMiddlewareChain,
18
+ } from './middleware.js';
@@ -0,0 +1,3 @@
1
+ export { compose, createContext, runMiddlewareChain } from './server/middleware.js';
2
+ export { handleServerRoute } from './server/server-route.js';
3
+ export { is_rpc_request } from '@ripple-ts/adapter/rpc';
@@ -0,0 +1,177 @@
1
+ // @ts-check
2
+ /**
3
+ * Config validation + defaults — `resolveOctaneConfig` and its validators.
4
+ *
5
+ * Kept in a module with NO heavy imports (no bundler or octane/compiler) because
6
+ * it is part of the PRODUCTION server bundle's graph: the generated server
7
+ * entry re-resolves octane.config.ts through it at boot, and the whole
8
+ * `@octanejs/app-core/production` graph is bundled into dist/server/entry.js.
9
+ * The file-loading half (`loadOctaneConfig`) lives in
10
+ * `load-config.js` and re-exports everything here.
11
+ */
12
+
13
+ /** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/app-core' */
14
+
15
+ import { DEFAULT_OUTDIR } from './constants.js';
16
+
17
+ /**
18
+ * @param {unknown} route
19
+ * @returns {void}
20
+ */
21
+ function validate_render_route(route) {
22
+ if (
23
+ !route ||
24
+ typeof route !== 'object' ||
25
+ /** @type {{ type?: unknown }} */ (route).type !== 'render'
26
+ ) {
27
+ return;
28
+ }
29
+
30
+ const render_route = /** @type {{ entry?: unknown, layout?: unknown }} */ (route);
31
+ const has_entry =
32
+ typeof render_route.entry === 'string' ||
33
+ (Array.isArray(render_route.entry) &&
34
+ render_route.entry.length === 2 &&
35
+ typeof render_route.entry[0] === 'string' &&
36
+ typeof render_route.entry[1] === 'string');
37
+
38
+ if (!has_entry) {
39
+ throw new Error('[octane] RenderRoute requires a string/tuple `entry`.');
40
+ }
41
+
42
+ if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
43
+ throw new Error('[octane] RenderRoute `layout` must be a string path.');
44
+ }
45
+
46
+ const status = /** @type {{ status?: unknown }} */ (route).status;
47
+ if (status !== undefined && (typeof status !== 'number' || !Number.isInteger(status))) {
48
+ throw new Error('[octane] RenderRoute `status` must be an integer.');
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {unknown} rootBoundary
54
+ * @returns {void}
55
+ */
56
+ function validate_root_boundary(rootBoundary) {
57
+ if (rootBoundary === undefined) {
58
+ return;
59
+ }
60
+ if (!rootBoundary || typeof rootBoundary !== 'object') {
61
+ throw new Error('[octane] rootBoundary must be an object when provided.');
62
+ }
63
+
64
+ const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
65
+ for (const name of ['pending', 'catch']) {
66
+ const entry = boundary[/** @type {'pending' | 'catch'} */ (name)];
67
+ if (entry === undefined) continue;
68
+ const valid =
69
+ (typeof entry === 'string' && entry.startsWith('/')) ||
70
+ (Array.isArray(entry) &&
71
+ entry.length === 2 &&
72
+ typeof entry[0] === 'string' &&
73
+ typeof entry[1] === 'string' &&
74
+ entry[1].startsWith('/'));
75
+ if (!valid) {
76
+ throw new Error(
77
+ `[octane] rootBoundary.${name} must be a project-root component module ID or [exportName, moduleId] tuple.`,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Validate a raw octane config and apply all defaults.
85
+ *
86
+ * After this function returns every optional field carries its default
87
+ * value so callers never need to use `??` / `||` fallbacks.
88
+ *
89
+ * The function is idempotent — passing an already-resolved config
90
+ * through it again is safe and produces the same result.
91
+ *
92
+ * @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
93
+ * @param {{ requireAdapter?: boolean }} [options]
94
+ * @returns {ResolvedOctaneConfig}
95
+ */
96
+ export function resolveOctaneConfig(raw, options = {}) {
97
+ const { requireAdapter = false } = options;
98
+
99
+ // ------------------------------------------------------------------
100
+ // Validate
101
+ // ------------------------------------------------------------------
102
+ if (!raw) {
103
+ throw new Error('[octane] octane.config.ts must export a default config object.');
104
+ }
105
+
106
+ if (requireAdapter && !raw.adapter) {
107
+ throw new Error(
108
+ '[octane] This build requires an `adapter` in octane.config.ts. ' +
109
+ 'Install an adapter package (e.g. @octanejs/adapter-vercel) and set the `adapter` property.',
110
+ );
111
+ }
112
+
113
+ if (raw.adapter !== undefined) {
114
+ if (typeof raw.adapter !== 'object' || raw.adapter === null) {
115
+ throw new Error('[octane] adapter must be an adapter object (e.g. `adapter: vercel()`).');
116
+ }
117
+ if (raw.adapter.adapt !== undefined && typeof raw.adapter.adapt !== 'function') {
118
+ throw new Error('[octane] adapter.adapt must be a function.');
119
+ }
120
+ if (raw.adapter.serve !== undefined && typeof raw.adapter.serve !== 'function') {
121
+ throw new Error('[octane] adapter.serve must be a function.');
122
+ }
123
+ }
124
+
125
+ if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
126
+ throw new Error('[octane] router.routes must be an array.');
127
+ }
128
+
129
+ if (raw.router?.preHydrate !== undefined) {
130
+ // A project-root module ID: the client hydrate entry dynamic-imports it in
131
+ // the browser, so it must be root-absolute ('/src/…'), not relative or fs.
132
+ if (typeof raw.router.preHydrate !== 'string' || !raw.router.preHydrate.startsWith('/')) {
133
+ throw new Error(
134
+ "[octane] router.preHydrate must be a project-root module ID (e.g. '/src/pre-hydrate.ts').",
135
+ );
136
+ }
137
+ }
138
+
139
+ for (const route of raw.router?.routes ?? []) {
140
+ validate_render_route(route);
141
+ }
142
+
143
+ validate_root_boundary(raw.rootBoundary);
144
+
145
+ if (
146
+ raw.server?.render !== undefined &&
147
+ raw.server.render !== 'streaming' &&
148
+ raw.server.render !== 'buffered'
149
+ ) {
150
+ throw new Error("[octane] server.render must be 'streaming' or 'buffered'.");
151
+ }
152
+
153
+ // ------------------------------------------------------------------
154
+ // Apply defaults
155
+ // ------------------------------------------------------------------
156
+ return {
157
+ build: {
158
+ outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
159
+ minify: raw.build?.minify,
160
+ target: raw.build?.target,
161
+ },
162
+ adapter: raw.adapter,
163
+ router: {
164
+ routes: raw.router?.routes ?? [],
165
+ preHydrate: raw.router?.preHydrate,
166
+ },
167
+ rootBoundary: raw.rootBoundary ?? {},
168
+ middlewares: raw.middlewares ?? [],
169
+ platform: {
170
+ env: raw.platform?.env ?? {},
171
+ },
172
+ server: {
173
+ trustProxy: raw.server?.trustProxy ?? false,
174
+ render: raw.server?.render ?? 'streaming',
175
+ },
176
+ };
177
+ }
package/src/routes.js ADDED
@@ -0,0 +1,135 @@
1
+ // @ts-check
2
+ /**
3
+ * @typedef {import('@octanejs/app-core').Context} Context
4
+ * @typedef {import('@octanejs/app-core').Middleware} Middleware
5
+ * @typedef {import('@octanejs/app-core').RenderRouteOptions} RenderRouteOptions
6
+ * @typedef {import('@octanejs/app-core').ServerRouteOptions} ServerRouteOptions
7
+ */
8
+
9
+ /**
10
+ * @typedef {string | readonly [string, string]} RenderRouteEntry
11
+ */
12
+
13
+ /**
14
+ * @param {RenderRouteEntry | undefined} entry
15
+ * @returns {string | undefined}
16
+ */
17
+ export function get_route_entry_path(entry) {
18
+ return typeof entry === 'string' ? entry : entry?.[1];
19
+ }
20
+
21
+ /**
22
+ * @param {RenderRouteEntry | undefined} entry
23
+ * @returns {string | undefined}
24
+ */
25
+ export function get_route_entry_export_name(entry) {
26
+ return typeof entry === 'string' ? undefined : entry?.[0];
27
+ }
28
+
29
+ /**
30
+ * @param {RenderRouteEntry | undefined} entry
31
+ * @returns {string | undefined}
32
+ */
33
+ export function get_route_entry_id(entry) {
34
+ const path = get_route_entry_path(entry);
35
+ const export_name = get_route_entry_export_name(entry);
36
+ return path && export_name ? `${path}#${export_name}` : path;
37
+ }
38
+
39
+ /**
40
+ * @param {Record<string, unknown>} module
41
+ * @param {string | undefined} export_name
42
+ * @returns {Function | null}
43
+ */
44
+ export function get_component_export(module, export_name) {
45
+ // When an explicit export name is given, require an exact match. Do NOT fall
46
+ // back to default/first-PascalCase — a typo'd route tuple should fail loudly
47
+ // rather than silently render the wrong component.
48
+ if (export_name) {
49
+ return typeof module[export_name] === 'function' ? module[export_name] : null;
50
+ }
51
+ if (typeof module.default === 'function') {
52
+ return module.default;
53
+ }
54
+ for (const [key, value] of Object.entries(module)) {
55
+ if (typeof value === 'function' && /^[A-Z]/.test(key)) {
56
+ return value;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Route for rendering octane components with SSR
64
+ */
65
+ export class RenderRoute {
66
+ /** @type {'render'} */
67
+ type = 'render';
68
+
69
+ /** @type {string} */
70
+ path;
71
+
72
+ // Non-optional: the constructor throws without one (matches types/index.d.ts).
73
+ /** @type {RenderRouteEntry} */
74
+ entry;
75
+
76
+ /** @type {string | undefined} */
77
+ layout;
78
+
79
+ /** @type {Middleware[]} */
80
+ before;
81
+
82
+ /** @type {number | undefined} */
83
+ status;
84
+
85
+ /**
86
+ * @param {RenderRouteOptions} options
87
+ */
88
+ constructor(options) {
89
+ if (!options.entry) {
90
+ throw new Error('RenderRoute requires an `entry`.');
91
+ }
92
+
93
+ this.path = options.path;
94
+ this.entry = options.entry;
95
+ this.layout = options.layout;
96
+ this.before = options.before ?? [];
97
+ this.status = options.status;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Route for API endpoints (returns Response directly)
103
+ */
104
+ export class ServerRoute {
105
+ /** @type {'server'} */
106
+ type = 'server';
107
+
108
+ /** @type {string} */
109
+ path;
110
+
111
+ /** @type {string[]} */
112
+ methods;
113
+
114
+ /** @type {(context: Context) => Response | Promise<Response>} */
115
+ handler;
116
+
117
+ /** @type {Middleware[]} */
118
+ before;
119
+
120
+ /** @type {Middleware[]} */
121
+ after;
122
+
123
+ /**
124
+ * @param {ServerRouteOptions} options
125
+ */
126
+ constructor(options) {
127
+ this.path = options.path;
128
+ this.methods = options.methods ?? ['GET'];
129
+ this.handler = options.handler;
130
+ this.before = options.before ?? [];
131
+ this.after = options.after ?? [];
132
+ }
133
+ }
134
+
135
+ export { createRouter } from './server/router.js';