@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.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +88 -0
- package/src/codegen.js +297 -0
- package/src/config-loader.js +307 -0
- package/src/config.js +14 -0
- package/src/constants.js +5 -0
- package/src/html.js +10 -0
- package/src/index.js +18 -0
- package/src/middleware.js +3 -0
- package/src/resolve-config.js +177 -0
- package/src/routes.js +135 -0
- package/src/server/component-wrappers.js +99 -0
- package/src/server/html-stream.js +74 -0
- package/src/server/html-template.js +136 -0
- package/src/server/middleware.js +127 -0
- package/src/server/node-http.js +262 -0
- package/src/server/production.js +335 -0
- package/src/server/router.js +123 -0
- package/src/server/server-entry.js +436 -0
- package/src/server/server-route.js +47 -0
- package/types/codegen.d.ts +67 -0
- package/types/config-loader.d.ts +26 -0
- package/types/config.d.ts +26 -0
- package/types/html.d.ts +14 -0
- package/types/index.d.ts +319 -0
- package/types/middleware.d.ts +3 -0
- package/types/node.d.ts +31 -0
- package/types/production.d.ts +98 -0
- package/types/routes.d.ts +17 -0
|
@@ -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
|
+
}
|
package/src/constants.js
ADDED
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,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';
|