@octanejs/vite-plugin 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/package.json +45 -0
- package/src/bin/preview.js +46 -0
- package/src/constants.js +2 -0
- package/src/index.js +486 -0
- package/src/load-config.js +240 -0
- package/src/project-codegen.js +158 -0
- package/src/routes.js +127 -0
- package/src/server/component-wrappers.js +55 -0
- package/src/server/middleware.js +126 -0
- package/src/server/production.js +26 -0
- package/src/server/render-route.js +214 -0
- package/src/server/router.js +122 -0
- package/src/server/server-route.js +46 -0
- package/src/server/virtual-entry.js +15 -0
- package/tsconfig.typecheck.json +18 -0
- package/types/index.d.ts +189 -0
- package/types/production.d.ts +59 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility for loading and resolving octane.config.ts.
|
|
3
|
+
*
|
|
4
|
+
* `resolveOctaneConfig` is the single source of truth for all config
|
|
5
|
+
* validation and default values. Every consumer should receive a
|
|
6
|
+
* `ResolvedOctaneConfig` rather than applying ad-hoc defaults.
|
|
7
|
+
*
|
|
8
|
+
* `loadOctaneConfig` is the single entry point for loading the config
|
|
9
|
+
* file. It accepts an optional Vite dev server — when provided the
|
|
10
|
+
* config is loaded via `ssrLoadModule` (no temp server overhead,
|
|
11
|
+
* HMR-aware). Otherwise a temporary Vite server is spun up, used to
|
|
12
|
+
* transpile the TypeScript config, and immediately shut down.
|
|
13
|
+
*
|
|
14
|
+
* Used by the Vite plugin (during dev + build), the preview CLI script,
|
|
15
|
+
* and the generated production server entry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/vite-plugin' */
|
|
19
|
+
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import { compile } from 'octane/compiler';
|
|
23
|
+
import { DEFAULT_OUTDIR } from './constants.js';
|
|
24
|
+
|
|
25
|
+
const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} route
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
function validate_render_route(route) {
|
|
32
|
+
if (
|
|
33
|
+
!route ||
|
|
34
|
+
typeof route !== 'object' ||
|
|
35
|
+
/** @type {{ type?: unknown }} */ (route).type !== 'render'
|
|
36
|
+
) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const render_route = /** @type {{ entry?: unknown, layout?: unknown }} */ (route);
|
|
41
|
+
const has_entry =
|
|
42
|
+
typeof render_route.entry === 'string' ||
|
|
43
|
+
(Array.isArray(render_route.entry) &&
|
|
44
|
+
render_route.entry.length === 2 &&
|
|
45
|
+
typeof render_route.entry[0] === 'string' &&
|
|
46
|
+
typeof render_route.entry[1] === 'string');
|
|
47
|
+
|
|
48
|
+
if (!has_entry) {
|
|
49
|
+
throw new Error('[@octanejs/vite-plugin] RenderRoute requires a string/tuple `entry`.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
|
|
53
|
+
throw new Error('[@octanejs/vite-plugin] RenderRoute `layout` must be a string path.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {unknown} rootBoundary
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
function validate_root_boundary(rootBoundary) {
|
|
62
|
+
if (rootBoundary === undefined) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!rootBoundary || typeof rootBoundary !== 'object') {
|
|
66
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary must be an object when provided.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
|
|
70
|
+
if (boundary.pending !== undefined && typeof boundary.pending !== 'function') {
|
|
71
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary.pending must be a component function.');
|
|
72
|
+
}
|
|
73
|
+
if (boundary.catch !== undefined && typeof boundary.catch !== 'function') {
|
|
74
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary.catch must be a component function.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate a raw octane config and apply all defaults.
|
|
80
|
+
*
|
|
81
|
+
* After this function returns every optional field carries its default
|
|
82
|
+
* value so callers never need to use `??` / `||` fallbacks.
|
|
83
|
+
*
|
|
84
|
+
* The function is idempotent — passing an already-resolved config
|
|
85
|
+
* through it again is safe and produces the same result.
|
|
86
|
+
*
|
|
87
|
+
* @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
|
|
88
|
+
* @param {{ requireAdapter?: boolean }} [options]
|
|
89
|
+
* @returns {ResolvedOctaneConfig}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveOctaneConfig(raw, options = {}) {
|
|
92
|
+
const { requireAdapter = false } = options;
|
|
93
|
+
|
|
94
|
+
// ------------------------------------------------------------------
|
|
95
|
+
// Validate
|
|
96
|
+
// ------------------------------------------------------------------
|
|
97
|
+
if (!raw) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'[@octanejs/vite-plugin] octane.config.ts must export a default config object.',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (requireAdapter) {
|
|
104
|
+
if (!raw.adapter) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'[@octanejs/vite-plugin] Production builds require an `adapter` in octane.config.ts. ' +
|
|
107
|
+
'Install an adapter package (e.g. @ripple-ts/adapter-node) and set the `adapter` property.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!raw.adapter.runtime) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'[@octanejs/vite-plugin] The adapter in octane.config.ts is missing the `runtime` property. ' +
|
|
114
|
+
'Make sure your adapter exports runtime primitives.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
|
|
120
|
+
throw new Error('[@octanejs/vite-plugin] router.routes must be an array.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const route of raw.router?.routes ?? []) {
|
|
124
|
+
validate_render_route(route);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
validate_root_boundary(raw.rootBoundary);
|
|
128
|
+
|
|
129
|
+
// ------------------------------------------------------------------
|
|
130
|
+
// Apply defaults
|
|
131
|
+
// ------------------------------------------------------------------
|
|
132
|
+
return {
|
|
133
|
+
build: {
|
|
134
|
+
outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
|
|
135
|
+
minify: raw.build?.minify,
|
|
136
|
+
target: raw.build?.target,
|
|
137
|
+
},
|
|
138
|
+
adapter: raw.adapter,
|
|
139
|
+
router: {
|
|
140
|
+
routes: raw.router?.routes ?? [],
|
|
141
|
+
},
|
|
142
|
+
rootBoundary: raw.rootBoundary ?? {},
|
|
143
|
+
middlewares: raw.middlewares ?? [],
|
|
144
|
+
platform: {
|
|
145
|
+
env: raw.platform?.env ?? {},
|
|
146
|
+
},
|
|
147
|
+
server: {
|
|
148
|
+
trustProxy: raw.server?.trustProxy ?? false,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Return the absolute path to octane.config.ts for the given project root.
|
|
155
|
+
*
|
|
156
|
+
* This is the single source of truth for the config file name / location.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
export function getOctaneConfigPath(projectRoot) {
|
|
162
|
+
return path.join(projectRoot, 'octane.config.ts');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check whether a octane.config.ts file exists in the given root.
|
|
167
|
+
*
|
|
168
|
+
* Use this before calling `loadOctaneConfig` when the absence of a
|
|
169
|
+
* config is a valid state (e.g. the Vite plugin running in SPA mode).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function octaneConfigExists(projectRoot) {
|
|
175
|
+
return fs.existsSync(getOctaneConfigPath(projectRoot));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Load octane.config.ts, validate, and apply defaults via `resolveOctaneConfig`.
|
|
180
|
+
*
|
|
181
|
+
* When a Vite dev server is provided via `options.vite`, the config is loaded
|
|
182
|
+
* through its `ssrLoadModule` — avoiding the cost of spinning up a temporary
|
|
183
|
+
* server and enabling HMR-aware reloads.
|
|
184
|
+
*
|
|
185
|
+
* When no dev server is available (build / preview), a temporary Vite server
|
|
186
|
+
* is created in middleware mode, used to transpile the config, then shut down.
|
|
187
|
+
*
|
|
188
|
+
* Throws if the config file does not exist or is invalid.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
191
|
+
* @param {{ vite?: import('vite').ViteDevServer, requireAdapter?: boolean }} [options]
|
|
192
|
+
* @returns {Promise<ResolvedOctaneConfig>}
|
|
193
|
+
*/
|
|
194
|
+
export async function loadOctaneConfig(projectRoot, options = {}) {
|
|
195
|
+
const { vite, requireAdapter } = options;
|
|
196
|
+
const configPath = getOctaneConfigPath(projectRoot);
|
|
197
|
+
|
|
198
|
+
if (!fs.existsSync(configPath)) {
|
|
199
|
+
throw new Error(`[@octanejs/vite-plugin] octane.config.ts not found in ${projectRoot}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// When a running Vite dev server is available, use it directly.
|
|
203
|
+
if (vite) {
|
|
204
|
+
const configModule = await vite.ssrLoadModule(configPath);
|
|
205
|
+
return resolveOctaneConfig(configModule.default, { requireAdapter });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Otherwise spin up a temporary Vite server (build / preview).
|
|
209
|
+
// The temp server only transpiles octane.config.ts (plain TypeScript) —
|
|
210
|
+
// no .tsrx compilation plugin is needed beyond config-referenced helpers.
|
|
211
|
+
const { createServer } = await import('vite');
|
|
212
|
+
|
|
213
|
+
const tempVite = await createServer({
|
|
214
|
+
root: projectRoot,
|
|
215
|
+
configFile: false,
|
|
216
|
+
appType: 'custom',
|
|
217
|
+
server: { middlewareMode: true },
|
|
218
|
+
plugins: [
|
|
219
|
+
{
|
|
220
|
+
name: 'octane-config-tsrx-loader',
|
|
221
|
+
transform(source, id) {
|
|
222
|
+
if (!OCTANE_EXTENSION_PATTERN.test(id)) return null;
|
|
223
|
+
const filename = id.replace(projectRoot, '');
|
|
224
|
+
return compile(source, filename, {
|
|
225
|
+
mode: 'server',
|
|
226
|
+
hmr: false,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
logLevel: 'silent',
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const configModule = await tempVite.ssrLoadModule(configPath);
|
|
236
|
+
return resolveOctaneConfig(configModule.default, { requireAdapter });
|
|
237
|
+
} finally {
|
|
238
|
+
await tempVite.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** @import { ResolvedConfig } from 'vite' */
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const RESOLVED_ADAPTER_BROWSER_STUB_ID = '\0octane:adapter-browser-stub';
|
|
7
|
+
export const SERVER_ONLY_ADAPTER_IDS = new Set([
|
|
8
|
+
'@ripple-ts/adapter-node',
|
|
9
|
+
'@ripple-ts/adapter-bun',
|
|
10
|
+
'@ripple-ts/adapter-vercel',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/** @type {Map<string, string>} */
|
|
14
|
+
const generated_file_cache = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function create_adapter_browser_stub_source() {
|
|
20
|
+
return `export const runtime = undefined;
|
|
21
|
+
export function serve() {
|
|
22
|
+
throw new Error('[octane] Server adapters cannot run in the browser.');
|
|
23
|
+
}
|
|
24
|
+
export function nodeRequestToWebRequest() {
|
|
25
|
+
throw new Error('[octane] Node request helpers cannot run in the browser.');
|
|
26
|
+
}
|
|
27
|
+
export function webResponseToNodeResponse() {
|
|
28
|
+
throw new Error('[octane] Node response helpers cannot run in the browser.');
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {ResolvedConfig} viteConfig
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function get_project_generated_dir(viteConfig) {
|
|
38
|
+
return path.join(viteConfig.cacheDir, 'project');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {ResolvedConfig} viteConfig
|
|
43
|
+
* @param {string} name
|
|
44
|
+
* @param {string} source
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function write_project_generated_file(viteConfig, name, source) {
|
|
48
|
+
const dir = get_project_generated_dir(viteConfig);
|
|
49
|
+
const file = path.join(dir, name);
|
|
50
|
+
|
|
51
|
+
if (generated_file_cache.get(file) === source && fs.existsSync(file)) {
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) {
|
|
57
|
+
fs.writeFileSync(file, source);
|
|
58
|
+
}
|
|
59
|
+
generated_file_cache.set(file, source);
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate the client hydration entry (served at virtual:octane-hydrate).
|
|
65
|
+
*
|
|
66
|
+
* CONFIG-FREE: it does NOT import octane.config.ts. Importing the config into
|
|
67
|
+
* the browser would drag the plugin (and the server adapter) — with their
|
|
68
|
+
* `node:fs` imports — into the client graph and throw at module-eval. Instead
|
|
69
|
+
* the server serializes everything needed into #__octane_data ({ entry,
|
|
70
|
+
* exportName, layout, params }), and this entry dynamic-imports the page/layout
|
|
71
|
+
* from there. (A static, build-analyzable import map is a Phase-2 concern.)
|
|
72
|
+
*
|
|
73
|
+
* octane specifics:
|
|
74
|
+
* - `import { hydrateRoot } from 'octane'` (NO `mount`).
|
|
75
|
+
* - `hydrateRoot(container, body, props)` signature (container FIRST, React-18
|
|
76
|
+
* shape) — no `{ target, props }` wrapper.
|
|
77
|
+
* - The layout `children` is a ComponentBody `(s) => Page(s, { params })`,
|
|
78
|
+
* NOT a 0-arg thunk: octane's `childSlot` invokes a bare function child
|
|
79
|
+
* as a ComponentBody (block first arg, `{}` props), so page data rides the
|
|
80
|
+
* closure — mirroring the server `createLayoutWrapper`.
|
|
81
|
+
* - hydrateRoot() itself locates/consumes the <script data-octane-suspense>
|
|
82
|
+
* seed inside #root, so the entry does nothing special for suspense.
|
|
83
|
+
*
|
|
84
|
+
* `getComponentExport` mirrors routes.js `get_component_export` (route named
|
|
85
|
+
* export > default > first PascalCase) so server and client pick the SAME
|
|
86
|
+
* component.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ configPath?: string, staticEntries?: string[] }} [_options]
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function create_client_entry_source(_options) {
|
|
92
|
+
return `// Auto-generated by @octanejs/vite-plugin.
|
|
93
|
+
// This file is written to Vite's cacheDir/project folder.
|
|
94
|
+
|
|
95
|
+
import { hydrateRoot } from 'octane';
|
|
96
|
+
|
|
97
|
+
function getComponentExport(module, exportName) {
|
|
98
|
+
// Explicit export name requires an exact match; do NOT fall back, so a
|
|
99
|
+
// typo'd route renders nothing rather than the wrong component.
|
|
100
|
+
if (exportName) return typeof module[exportName] === 'function' ? module[exportName] : undefined;
|
|
101
|
+
if (typeof module.default === 'function') return module.default;
|
|
102
|
+
return Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
(async () => {
|
|
106
|
+
try {
|
|
107
|
+
const el = document.getElementById('__octane_data');
|
|
108
|
+
const target = document.getElementById('root');
|
|
109
|
+
if (!el || !target) {
|
|
110
|
+
console.error('[octane] Unable to hydrate: missing #__octane_data or #root.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const data = JSON.parse(el.textContent || '{}'); // { entry, exportName, layout, params }
|
|
114
|
+
if (!data.entry) {
|
|
115
|
+
console.error('[octane] Unable to hydrate: no route entry in #__octane_data.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pageMod = await import(/* @vite-ignore */ data.entry);
|
|
120
|
+
const Component = getComponentExport(pageMod, data.exportName ?? undefined);
|
|
121
|
+
if (!Component) {
|
|
122
|
+
console.error('[octane] Unable to hydrate: no component export for', data.entry);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const params = data.params;
|
|
127
|
+
|
|
128
|
+
if (data.layout) {
|
|
129
|
+
const layoutMod = await import(/* @vite-ignore */ data.layout);
|
|
130
|
+
const Layout = getComponentExport(layoutMod);
|
|
131
|
+
if (Layout) {
|
|
132
|
+
// children is a ComponentBody closing over params; octane's childSlot
|
|
133
|
+
// invokes a function child PROPS-FIRST as \`({}, block, extra)\`, so we
|
|
134
|
+
// ignore the empty props and render the page with its real \`{ params }\`,
|
|
135
|
+
// threading the scope + extra — mirroring the server createLayoutWrapper
|
|
136
|
+
// (which is scope-first on the server ABI) so the markers line up.
|
|
137
|
+
const children = (_props, scope, extra) => Component({ params }, scope, extra);
|
|
138
|
+
hydrateRoot(target, Layout, { params, children });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
hydrateRoot(target, Component, { params });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('[octane] Failed to bootstrap client hydration.', error);
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} filename
|
|
153
|
+
* @param {string} root
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function to_vite_root_import(filename, root) {
|
|
157
|
+
return '/' + path.relative(root, filename).split(path.sep).join('/');
|
|
158
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@octanejs/vite-plugin').Context} Context
|
|
3
|
+
* @typedef {import('@octanejs/vite-plugin').Middleware} Middleware
|
|
4
|
+
* @typedef {import('@octanejs/vite-plugin').RenderRouteOptions} RenderRouteOptions
|
|
5
|
+
* @typedef {import('@octanejs/vite-plugin').ServerRouteOptions} ServerRouteOptions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {string | readonly [string, string]} RenderRouteEntry
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {RenderRouteEntry | undefined} entry
|
|
14
|
+
* @returns {string | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function get_route_entry_path(entry) {
|
|
17
|
+
return typeof entry === 'string' ? entry : entry?.[1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {RenderRouteEntry | undefined} entry
|
|
22
|
+
* @returns {string | undefined}
|
|
23
|
+
*/
|
|
24
|
+
export function get_route_entry_export_name(entry) {
|
|
25
|
+
return typeof entry === 'string' ? undefined : entry?.[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {RenderRouteEntry | undefined} entry
|
|
30
|
+
* @returns {string | undefined}
|
|
31
|
+
*/
|
|
32
|
+
export function get_route_entry_id(entry) {
|
|
33
|
+
const path = get_route_entry_path(entry);
|
|
34
|
+
const export_name = get_route_entry_export_name(entry);
|
|
35
|
+
return path && export_name ? `${path}#${export_name}` : path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Record<string, unknown>} module
|
|
40
|
+
* @param {string | undefined} export_name
|
|
41
|
+
* @returns {Function | null}
|
|
42
|
+
*/
|
|
43
|
+
export function get_component_export(module, export_name) {
|
|
44
|
+
// When an explicit export name is given, require an exact match. Do NOT fall
|
|
45
|
+
// back to default/first-PascalCase — a typo'd route tuple should fail loudly
|
|
46
|
+
// rather than silently render the wrong component.
|
|
47
|
+
if (export_name) {
|
|
48
|
+
return typeof module[export_name] === 'function' ? module[export_name] : null;
|
|
49
|
+
}
|
|
50
|
+
if (typeof module.default === 'function') {
|
|
51
|
+
return module.default;
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(module)) {
|
|
54
|
+
if (typeof value === 'function' && /^[A-Z]/.test(key)) {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Route for rendering octane components with SSR
|
|
63
|
+
*/
|
|
64
|
+
export class RenderRoute {
|
|
65
|
+
/** @type {'render'} */
|
|
66
|
+
type = 'render';
|
|
67
|
+
|
|
68
|
+
/** @type {string} */
|
|
69
|
+
path;
|
|
70
|
+
|
|
71
|
+
/** @type {RenderRouteEntry | undefined} */
|
|
72
|
+
entry;
|
|
73
|
+
|
|
74
|
+
/** @type {string | undefined} */
|
|
75
|
+
layout;
|
|
76
|
+
|
|
77
|
+
/** @type {Middleware[]} */
|
|
78
|
+
before;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {RenderRouteOptions} options
|
|
82
|
+
*/
|
|
83
|
+
constructor(options) {
|
|
84
|
+
if (!options.entry) {
|
|
85
|
+
throw new Error('RenderRoute requires an `entry`.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.path = options.path;
|
|
89
|
+
this.entry = options.entry;
|
|
90
|
+
this.layout = options.layout;
|
|
91
|
+
this.before = options.before ?? [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Route for API endpoints (returns Response directly)
|
|
97
|
+
*/
|
|
98
|
+
export class ServerRoute {
|
|
99
|
+
/** @type {'server'} */
|
|
100
|
+
type = 'server';
|
|
101
|
+
|
|
102
|
+
/** @type {string} */
|
|
103
|
+
path;
|
|
104
|
+
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
methods;
|
|
107
|
+
|
|
108
|
+
/** @type {(context: Context) => Response | Promise<Response>} */
|
|
109
|
+
handler;
|
|
110
|
+
|
|
111
|
+
/** @type {Middleware[]} */
|
|
112
|
+
before;
|
|
113
|
+
|
|
114
|
+
/** @type {Middleware[]} */
|
|
115
|
+
after;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {ServerRouteOptions} options
|
|
119
|
+
*/
|
|
120
|
+
constructor(options) {
|
|
121
|
+
this.path = options.path;
|
|
122
|
+
this.methods = options.methods ?? ['GET'];
|
|
123
|
+
this.handler = options.handler;
|
|
124
|
+
this.before = options.before ?? [];
|
|
125
|
+
this.after = options.after ?? [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server component composition for the octane renderer.
|
|
3
|
+
*
|
|
4
|
+
* octane's server ABI is PROPS-FIRST (matching the client): a component body is
|
|
5
|
+
* `(props, scope, extra) => string`. `render(Component, props)` invokes the ROOT
|
|
6
|
+
* directly as `Component(props, rootScope, undefined)` and does NOT wrap it in
|
|
7
|
+
* block markers — and `hydrateRoot()` adopts the container's FIRST CHILD as the
|
|
8
|
+
* root's own node. So the wrapper must call the top-level component DIRECTLY
|
|
9
|
+
* (wrapping it in `ssrComponent` would add an extra `<!--[-->…<!--]-->` layer that
|
|
10
|
+
* `clone()` then mis-adopts on hydrate).
|
|
11
|
+
*
|
|
12
|
+
* Only the layout's `{children}` is a nested hole: the compiled layout emits
|
|
13
|
+
* `ssrChild(props.children, scope)`, and `ssrChild` invokes a FUNCTION child as
|
|
14
|
+
* `children({}, scope, undefined)` wrapped in one `<!--[-->…<!--]-->` range. So
|
|
15
|
+
* `children` is a ComponentBody that calls the page directly, and any page data
|
|
16
|
+
* (params) rides its CLOSURE — `ssrChild` supplies only `{}`. The client
|
|
17
|
+
* `childSlot` applies the identical rule (bare function = ComponentBody, `{}`
|
|
18
|
+
* props, one marker range), so server markers and client adoption line up.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {(props?: any, scope?: any, extra?: any) => string} ServerComponent
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a page component, baking in its route props.
|
|
25
|
+
*
|
|
26
|
+
* @param {ServerComponent} Page
|
|
27
|
+
* @param {Record<string, unknown>} pageProps
|
|
28
|
+
* @returns {ServerComponent}
|
|
29
|
+
*/
|
|
30
|
+
export function createPropsWrapper(Page, pageProps) {
|
|
31
|
+
return function Root(_props, scope) {
|
|
32
|
+
return Page(pageProps, scope, undefined);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compose a layout with a page: the layout's `{children}` renders the page.
|
|
38
|
+
*
|
|
39
|
+
* @param {ServerComponent} Layout
|
|
40
|
+
* @param {ServerComponent} Page
|
|
41
|
+
* @param {Record<string, unknown>} pageProps
|
|
42
|
+
* @returns {ServerComponent}
|
|
43
|
+
*/
|
|
44
|
+
export function createLayoutWrapper(Layout, Page, pageProps) {
|
|
45
|
+
return function Root(_props, scope) {
|
|
46
|
+
// `children` is a ComponentBody closing over pageProps; the layout's
|
|
47
|
+
// `{children}` hole runs it via ssrChild (which supplies `{}` props and
|
|
48
|
+
// wraps the output in one block range), so the page still gets its real
|
|
49
|
+
// route props through the closure. PROPS-FIRST: childSlot/ssrChild call it
|
|
50
|
+
// as `({}, scope, extra)`, so the page's real props are passed explicitly.
|
|
51
|
+
const children = (/** @type {any} */ _cprops, /** @type {any} */ cscope) =>
|
|
52
|
+
Page(pageProps, cscope, undefined);
|
|
53
|
+
return Layout({ ...pageProps, children }, scope, undefined);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@octanejs/vite-plugin').Context} Context
|
|
3
|
+
* @typedef {import('@octanejs/vite-plugin').Middleware} Middleware
|
|
4
|
+
* @typedef {import('@octanejs/vite-plugin').NextFunction} NextFunction
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compose multiple middlewares into a single middleware
|
|
9
|
+
* Follows Koa-style execution: request flows down, response flows back up
|
|
10
|
+
*
|
|
11
|
+
* @param {Middleware[]} middlewares
|
|
12
|
+
* @returns {(context: Context, finalHandler: () => Promise<Response>) => Promise<Response>}
|
|
13
|
+
*/
|
|
14
|
+
export function compose(middlewares) {
|
|
15
|
+
return function composed(context, finalHandler) {
|
|
16
|
+
let index = -1;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {number} i
|
|
20
|
+
* @returns {Promise<Response>}
|
|
21
|
+
*/
|
|
22
|
+
function dispatch(i) {
|
|
23
|
+
if (i <= index) {
|
|
24
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
25
|
+
}
|
|
26
|
+
index = i;
|
|
27
|
+
|
|
28
|
+
/** @type {Middleware | (() => Promise<Response>) | undefined} */
|
|
29
|
+
let fn;
|
|
30
|
+
|
|
31
|
+
if (i < middlewares.length) {
|
|
32
|
+
fn = middlewares[i];
|
|
33
|
+
} else if (i === middlewares.length) {
|
|
34
|
+
fn = finalHandler;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!fn) {
|
|
38
|
+
return Promise.reject(new Error('No handler provided'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// For the final handler, we don't pass next
|
|
43
|
+
if (i === middlewares.length) {
|
|
44
|
+
return Promise.resolve(/** @type {() => Promise<Response>} */ (fn)());
|
|
45
|
+
}
|
|
46
|
+
// For middlewares, pass context and next
|
|
47
|
+
return Promise.resolve(/** @type {Middleware} */ (fn)(context, () => dispatch(i + 1)));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return Promise.reject(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return dispatch(0);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a context object for the request
|
|
59
|
+
* @param {Request} request
|
|
60
|
+
* @param {Record<string, string>} params
|
|
61
|
+
* @returns {Context}
|
|
62
|
+
*/
|
|
63
|
+
export function createContext(request, params) {
|
|
64
|
+
return {
|
|
65
|
+
request,
|
|
66
|
+
params,
|
|
67
|
+
url: new URL(request.url),
|
|
68
|
+
state: new Map(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run middlewares with a final handler
|
|
74
|
+
* Combines global middlewares, route-level before/after, and the handler
|
|
75
|
+
*
|
|
76
|
+
* @param {Context} context
|
|
77
|
+
* @param {Middleware[]} globalMiddlewares
|
|
78
|
+
* @param {Middleware[]} beforeMiddlewares
|
|
79
|
+
* @param {() => Promise<Response>} handler
|
|
80
|
+
* @param {Middleware[]} afterMiddlewares
|
|
81
|
+
* @returns {Promise<Response>}
|
|
82
|
+
*/
|
|
83
|
+
export async function runMiddlewareChain(
|
|
84
|
+
context,
|
|
85
|
+
globalMiddlewares,
|
|
86
|
+
beforeMiddlewares,
|
|
87
|
+
handler,
|
|
88
|
+
afterMiddlewares = [],
|
|
89
|
+
) {
|
|
90
|
+
// Combine global + before middlewares
|
|
91
|
+
const allMiddlewares = [...globalMiddlewares, ...beforeMiddlewares];
|
|
92
|
+
|
|
93
|
+
// If there are after middlewares, wrap the handler to run them
|
|
94
|
+
const wrappedHandler =
|
|
95
|
+
afterMiddlewares.length > 0
|
|
96
|
+
? async () => {
|
|
97
|
+
const response = await handler();
|
|
98
|
+
// After middlewares can inspect/modify the response
|
|
99
|
+
// but have limited ability to change it in our model
|
|
100
|
+
// We run them for side-effects (logging, etc.)
|
|
101
|
+
return runAfterMiddlewares(context, afterMiddlewares, response);
|
|
102
|
+
}
|
|
103
|
+
: handler;
|
|
104
|
+
|
|
105
|
+
const composed = compose(allMiddlewares);
|
|
106
|
+
return composed(context, wrappedHandler);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run after middlewares with the response
|
|
111
|
+
* After middlewares run in order and can intercept/modify the response
|
|
112
|
+
*
|
|
113
|
+
* @param {Context} context
|
|
114
|
+
* @param {Middleware[]} middlewares
|
|
115
|
+
* @param {Response} response
|
|
116
|
+
* @returns {Promise<Response>}
|
|
117
|
+
*/
|
|
118
|
+
async function runAfterMiddlewares(context, middlewares, response) {
|
|
119
|
+
let currentResponse = response;
|
|
120
|
+
|
|
121
|
+
for (const middleware of middlewares) {
|
|
122
|
+
currentResponse = await middleware(context, async () => currentResponse);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return currentResponse;
|
|
126
|
+
}
|