@octanejs/vite-plugin 0.1.5 → 0.1.6

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.
@@ -1,218 +1,10 @@
1
- // @ts-check
2
- /** @import { ResolvedConfig } from 'vite' */
3
-
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
-
7
- export const RESOLVED_ADAPTER_BROWSER_STUB_ID = '\0octane:adapter-browser-stub';
8
- // Server-only deploy/adapter packages: client-side imports of these specifiers
9
- // resolve to the browser stub below instead of the real module (whose graph
10
- // pulls node builtins). Every published adapter package MUST be listed here,
11
- // and its public exports added to the stub.
12
- export const SERVER_ONLY_ADAPTER_IDS = new Set([
13
- '@ripple-ts/adapter-node',
14
- '@ripple-ts/adapter-bun',
15
- '@ripple-ts/adapter-vercel',
16
- '@octanejs/adapter-vercel',
17
- ]);
18
-
19
- /** @type {Map<string, string>} */
20
- const generated_file_cache = new Map();
21
-
22
- /**
23
- * The browser stand-in shared by every SERVER_ONLY_ADAPTER_IDS package — it
24
- * must export the UNION of their public names, each failing loudly on use
25
- * (never at import, so merely reaching the module keeps the app alive).
26
- * @returns {string}
27
- */
28
- export function create_adapter_browser_stub_source() {
29
- return `export const runtime = undefined;
30
- export function serve() {
31
- throw new Error('[octane] Server adapters cannot run in the browser.');
32
- }
33
- export function nodeRequestToWebRequest() {
34
- throw new Error('[octane] Node request helpers cannot run in the browser.');
35
- }
36
- export function webResponseToNodeResponse() {
37
- throw new Error('[octane] Node response helpers cannot run in the browser.');
38
- }
39
- export function vercel() {
40
- throw new Error('[octane] Deploy adapters cannot run in the browser.');
41
- }
42
- export function adapt() {
43
- throw new Error('[octane] Deploy adapters cannot run in the browser.');
44
- }
45
- `;
46
- }
47
-
48
- /**
49
- * @param {ResolvedConfig} viteConfig
50
- * @returns {string}
51
- */
52
- export function get_project_generated_dir(viteConfig) {
53
- return path.join(viteConfig.cacheDir, 'project');
54
- }
55
-
56
- /**
57
- * @param {ResolvedConfig} viteConfig
58
- * @param {string} name
59
- * @param {string} source
60
- * @returns {string}
61
- */
62
- export function write_project_generated_file(viteConfig, name, source) {
63
- const dir = get_project_generated_dir(viteConfig);
64
- const file = path.join(dir, name);
65
-
66
- if (generated_file_cache.get(file) === source && fs.existsSync(file)) {
67
- return file;
68
- }
69
-
70
- fs.mkdirSync(dir, { recursive: true });
71
- if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) {
72
- fs.writeFileSync(file, source);
73
- }
74
- generated_file_cache.set(file, source);
75
- return file;
76
- }
77
-
78
- /**
79
- * Generate the client hydration entry (served at virtual:octane-hydrate).
80
- *
81
- * CONFIG-FREE: it does NOT import octane.config.ts. Importing the config into
82
- * the browser would drag the plugin (and the server adapter) — with their
83
- * `node:fs` imports — into the client graph and throw at module-eval. Instead
84
- * the server serializes everything needed into #__octane_data ({ entry,
85
- * exportName, layout, params, url, preHydrate }), and this entry
86
- * dynamic-imports the page/layout from there.
87
- *
88
- * `staticEntries` (production builds) lists every module path the server can
89
- * name in #__octane_data — page entries, layouts, and the preHydrate hook.
90
- * Each becomes a STATIC `() => import('/src/…')` in a lookup map, so Rollup
91
- * sees, chunks, and hashes them; the runtime falls back to the hidden dynamic
92
- * import only for paths outside the map (the dev case, where the map is empty
93
- * and Vite serves any module by URL).
94
- *
95
- * octane specifics:
96
- * - `import { hydrateRoot } from 'octane'` (NO `mount`).
97
- * - `hydrateRoot(container, body, props)` signature (container FIRST, React-18
98
- * shape) — no `{ target, props }` wrapper.
99
- * - The layout `children` is a ComponentBody `(s) => Page(s, { params })`,
100
- * NOT a 0-arg thunk: octane's `childSlot` invokes a bare function child
101
- * as a ComponentBody (block first arg, `{}` props), so page data rides the
102
- * closure — mirroring the server `createLayoutWrapper`.
103
- * - hydrateRoot() itself locates/consumes the <script data-octane-suspense>
104
- * seed inside #root, so the entry does nothing special for suspense.
105
- * - `preHydrate` (config `router.preHydrate`, a Vite-root module path) is
106
- * imported and its default export awaited BEFORE hydrateRoot — the hook an
107
- * app-level client router uses to commit its match tree so the first
108
- * hydration pass adopts the same resolved tree the server rendered.
109
- *
110
- * `getComponentExport` mirrors routes.js `get_component_export` (route named
111
- * export > default > first PascalCase) so server and client pick the SAME
112
- * component.
113
- *
114
- * @param {{ configPath?: string, staticEntries?: string[] }} [options]
115
- * @returns {string}
116
- */
117
- export function create_client_entry_source(options = {}) {
118
- const staticEntries = [...new Set(options.staticEntries ?? [])];
119
- const static_map_lines = staticEntries
120
- .map((entry) => ` ${JSON.stringify(entry)}: () => import(${JSON.stringify(entry)}),`)
121
- .join('\n');
122
-
123
- return `// Auto-generated by @octanejs/vite-plugin.
124
- // This file is written to Vite's cacheDir/project folder.
125
-
126
- import { hydrateRoot } from 'octane';
127
-
128
- // Static import map (production): every module the server may name in
129
- // #__octane_data, as bundle-analyzable dynamic imports. Empty in dev.
130
- const routeModules = {
131
- ${static_map_lines}
132
- };
133
-
134
- // Dynamic import Vite's import-analysis can NOT see: its rewrite appends
135
- // '?import' to variable dynamic imports, and a queried URL evaluates as a
136
- // SECOND browser module instance — so the page (or the preHydrate hook) would
137
- // no longer share module singletons with statically-imported copies of the
138
- // same files (e.g. a client router the app also imports directly).
139
- const dynamicImport = new Function('specifier', 'return import(specifier)');
140
-
141
- function importModule(path) {
142
- const loader = routeModules[path];
143
- return loader ? loader() : dynamicImport(path);
144
- }
145
-
146
- function getComponentExport(module, exportName) {
147
- // Explicit export name requires an exact match; do NOT fall back, so a
148
- // typo'd route renders nothing rather than the wrong component.
149
- if (exportName) return typeof module[exportName] === 'function' ? module[exportName] : undefined;
150
- if (typeof module.default === 'function') return module.default;
151
- return Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
152
- }
153
-
154
- (async () => {
155
- try {
156
- const el = document.getElementById('__octane_data');
157
- const target = document.getElementById('root');
158
- if (!el || !target) {
159
- console.error('[octane] Unable to hydrate: missing #__octane_data or #root.');
160
- return;
161
- }
162
- const data = JSON.parse(el.textContent || '{}'); // { entry, exportName, layout, params, url, preHydrate }
163
- if (!data.entry) {
164
- console.error('[octane] Unable to hydrate: no route entry in #__octane_data.');
165
- return;
166
- }
167
-
168
- const pageMod = await importModule(data.entry);
169
- const Component = getComponentExport(pageMod, data.exportName ?? undefined);
170
- if (!Component) {
171
- console.error('[octane] Unable to hydrate: no component export for', data.entry);
172
- return;
173
- }
174
-
175
- const params = data.params;
176
- const url = data.url;
177
-
178
- // Run the app's pre-hydrate hook (config \`router.preHydrate\`) before the
179
- // first hydration render — e.g. a client router committing its match tree
180
- // so hydration adopts the same resolved tree the server rendered.
181
- if (data.preHydrate) {
182
- const preMod = await importModule(data.preHydrate);
183
- const hook = preMod.default;
184
- if (typeof hook === 'function') await hook({ url, params });
185
- }
186
-
187
- // Props mirror the server render exactly: { params, url }.
188
- if (data.layout) {
189
- const layoutMod = await importModule(data.layout);
190
- const Layout = getComponentExport(layoutMod);
191
- if (Layout) {
192
- // children is a ComponentBody closing over the page props; octane's
193
- // childSlot invokes a function child PROPS-FIRST as \`({}, block, extra)\`,
194
- // so we ignore the empty props and render the page with its real
195
- // \`{ params, url }\`, threading the scope + extra — mirroring the server
196
- // createLayoutWrapper so the markers line up.
197
- const children = (_props, scope, extra) => Component({ params, url }, scope, extra);
198
- hydrateRoot(target, Layout, { params, url, children });
199
- return;
200
- }
201
- }
202
-
203
- hydrateRoot(target, Component, { params, url });
204
- } catch (error) {
205
- console.error('[octane] Failed to bootstrap client hydration.', error);
206
- }
207
- })();
208
- `;
209
- }
210
-
211
- /**
212
- * @param {string} filename
213
- * @param {string} root
214
- * @returns {string}
215
- */
216
- export function to_vite_root_import(filename, root) {
217
- return '/' + path.relative(root, filename).split(path.sep).join('/');
218
- }
1
+ export {
2
+ RESOLVED_ADAPTER_BROWSER_STUB_ID,
3
+ SERVER_ONLY_ADAPTER_IDS,
4
+ create_adapter_browser_stub_source,
5
+ create_client_entry_source,
6
+ get_project_generated_dir,
7
+ normalize_module_reference,
8
+ to_vite_root_import,
9
+ write_project_generated_file,
10
+ } from '@octanejs/app-core/codegen';
@@ -1,171 +1 @@
1
- // @ts-check
2
- /**
3
- * Config validation + defaults — `resolveOctaneConfig` and its validators.
4
- *
5
- * Kept in a module with NO heavy imports (no vite, no 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/vite-plugin/production` graph is bundled into dist/server/entry.js.
9
- * The file-loading half (`loadOctaneConfig`, which spins up Vite) lives in
10
- * `load-config.js` and re-exports everything here.
11
- */
12
-
13
- /** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/vite-plugin' */
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('[@octanejs/vite-plugin] RenderRoute requires a string/tuple `entry`.');
40
- }
41
-
42
- if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
43
- throw new Error('[@octanejs/vite-plugin] 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('[@octanejs/vite-plugin] 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('[@octanejs/vite-plugin] rootBoundary must be an object when provided.');
62
- }
63
-
64
- const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
65
- if (boundary.pending !== undefined && typeof boundary.pending !== 'function') {
66
- throw new Error('[@octanejs/vite-plugin] rootBoundary.pending must be a component function.');
67
- }
68
- if (boundary.catch !== undefined && typeof boundary.catch !== 'function') {
69
- throw new Error('[@octanejs/vite-plugin] rootBoundary.catch must be a component function.');
70
- }
71
- }
72
-
73
- /**
74
- * Validate a raw octane config and apply all defaults.
75
- *
76
- * After this function returns every optional field carries its default
77
- * value so callers never need to use `??` / `||` fallbacks.
78
- *
79
- * The function is idempotent — passing an already-resolved config
80
- * through it again is safe and produces the same result.
81
- *
82
- * @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
83
- * @param {{ requireAdapter?: boolean }} [options]
84
- * @returns {ResolvedOctaneConfig}
85
- */
86
- export function resolveOctaneConfig(raw, options = {}) {
87
- const { requireAdapter = false } = options;
88
-
89
- // ------------------------------------------------------------------
90
- // Validate
91
- // ------------------------------------------------------------------
92
- if (!raw) {
93
- throw new Error(
94
- '[@octanejs/vite-plugin] octane.config.ts must export a default config object.',
95
- );
96
- }
97
-
98
- if (requireAdapter && !raw.adapter) {
99
- throw new Error(
100
- '[@octanejs/vite-plugin] This build requires an `adapter` in octane.config.ts. ' +
101
- 'Install an adapter package (e.g. @octanejs/adapter-vercel) and set the `adapter` property.',
102
- );
103
- }
104
-
105
- if (raw.adapter !== undefined) {
106
- if (typeof raw.adapter !== 'object' || raw.adapter === null) {
107
- throw new Error(
108
- '[@octanejs/vite-plugin] adapter must be an adapter object (e.g. `adapter: vercel()`).',
109
- );
110
- }
111
- if (raw.adapter.adapt !== undefined && typeof raw.adapter.adapt !== 'function') {
112
- throw new Error('[@octanejs/vite-plugin] adapter.adapt must be a function.');
113
- }
114
- if (raw.adapter.serve !== undefined && typeof raw.adapter.serve !== 'function') {
115
- throw new Error('[@octanejs/vite-plugin] adapter.serve must be a function.');
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
- if (raw.router?.preHydrate !== undefined) {
124
- // A Vite-root module path: the client hydrate entry dynamic-imports it in
125
- // the browser, so it must be root-absolute ('/src/…'), not relative or fs.
126
- if (typeof raw.router.preHydrate !== 'string' || !raw.router.preHydrate.startsWith('/')) {
127
- throw new Error(
128
- "[@octanejs/vite-plugin] router.preHydrate must be a Vite-root module path (e.g. '/src/pre-hydrate.ts').",
129
- );
130
- }
131
- }
132
-
133
- for (const route of raw.router?.routes ?? []) {
134
- validate_render_route(route);
135
- }
136
-
137
- validate_root_boundary(raw.rootBoundary);
138
-
139
- if (
140
- raw.server?.render !== undefined &&
141
- raw.server.render !== 'streaming' &&
142
- raw.server.render !== 'buffered'
143
- ) {
144
- throw new Error("[@octanejs/vite-plugin] server.render must be 'streaming' or 'buffered'.");
145
- }
146
-
147
- // ------------------------------------------------------------------
148
- // Apply defaults
149
- // ------------------------------------------------------------------
150
- return {
151
- build: {
152
- outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
153
- minify: raw.build?.minify,
154
- target: raw.build?.target,
155
- },
156
- adapter: raw.adapter,
157
- router: {
158
- routes: raw.router?.routes ?? [],
159
- preHydrate: raw.router?.preHydrate,
160
- },
161
- rootBoundary: raw.rootBoundary ?? {},
162
- middlewares: raw.middlewares ?? [],
163
- platform: {
164
- env: raw.platform?.env ?? {},
165
- },
166
- server: {
167
- trustProxy: raw.server?.trustProxy ?? false,
168
- render: raw.server?.render ?? 'streaming',
169
- },
170
- };
171
- }
1
+ export { resolveOctaneConfig } from '@octanejs/app-core/config';
package/src/routes.js CHANGED
@@ -1,133 +1 @@
1
- // @ts-check
2
- /**
3
- * @typedef {import('@octanejs/vite-plugin').Context} Context
4
- * @typedef {import('@octanejs/vite-plugin').Middleware} Middleware
5
- * @typedef {import('@octanejs/vite-plugin').RenderRouteOptions} RenderRouteOptions
6
- * @typedef {import('@octanejs/vite-plugin').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
- }
1
+ export * from '@octanejs/app-core/routes';
@@ -1,56 +1,5 @@
1
- // @ts-check
2
- /**
3
- * Server component composition for the octane renderer.
4
- *
5
- * octane's server ABI is PROPS-FIRST (matching the client): a component body is
6
- * `(props, scope, extra) => string`. `render(Component, props)` invokes the ROOT
7
- * directly as `Component(props, rootScope, undefined)` and does NOT wrap it in
8
- * block markers — and `hydrateRoot()` adopts the container's FIRST CHILD as the
9
- * root's own node. So the wrapper must call the top-level component DIRECTLY
10
- * (wrapping it in `ssrComponent` would add an extra `<!--[-->…<!--]-->` layer that
11
- * `clone()` then mis-adopts on hydrate).
12
- *
13
- * Only the layout's `{children}` is a nested hole: the compiled layout emits
14
- * `ssrChild(props.children, scope)`, and `ssrChild` invokes a FUNCTION child as
15
- * `children({}, scope, undefined)` wrapped in one `<!--[-->…<!--]-->` range. So
16
- * `children` is a ComponentBody that calls the page directly, and any page data
17
- * (params) rides its CLOSURE — `ssrChild` supplies only `{}`. The client
18
- * `childSlot` applies the identical rule (bare function = ComponentBody, `{}`
19
- * props, one marker range), so server markers and client adoption line up.
20
- *
21
- * @typedef {(props?: any, scope?: any, extra?: any) => string} ServerComponent
22
- */
23
-
24
- /**
25
- * Wrap a page component, baking in its route props.
26
- *
27
- * @param {ServerComponent} Page
28
- * @param {Record<string, unknown>} pageProps
29
- * @returns {ServerComponent}
30
- */
31
- export function createPropsWrapper(Page, pageProps) {
32
- return function Root(_props, scope) {
33
- return Page(pageProps, scope, undefined);
34
- };
35
- }
36
-
37
- /**
38
- * Compose a layout with a page: the layout's `{children}` renders the page.
39
- *
40
- * @param {ServerComponent} Layout
41
- * @param {ServerComponent} Page
42
- * @param {Record<string, unknown>} pageProps
43
- * @returns {ServerComponent}
44
- */
45
- export function createLayoutWrapper(Layout, Page, pageProps) {
46
- return function Root(_props, scope) {
47
- // `children` is a ComponentBody closing over pageProps; the layout's
48
- // `{children}` hole runs it via ssrChild (which supplies `{}` props and
49
- // wraps the output in one block range), so the page still gets its real
50
- // route props through the closure. PROPS-FIRST: childSlot/ssrChild call it
51
- // as `({}, scope, extra)`, so the page's real props are passed explicitly.
52
- const children = (/** @type {any} */ _cprops, /** @type {any} */ cscope) =>
53
- Page(pageProps, cscope, undefined);
54
- return Layout({ ...pageProps, children }, scope, undefined);
55
- };
56
- }
1
+ export {
2
+ createLayoutWrapper,
3
+ createPropsWrapper,
4
+ createRootBoundaryWrapper,
5
+ } from '@octanejs/app-core/production';
@@ -0,0 +1 @@
1
+ export { composeHtmlStream } from '@octanejs/app-core/html';
@@ -0,0 +1,9 @@
1
+ export {
2
+ HYDRATION_NONCE_PLACEHOLDER,
3
+ applyHydrationNonce,
4
+ getContextNonce,
5
+ injectHydrationEntry,
6
+ nonceAttribute,
7
+ splitSsrTemplate,
8
+ validateSsrTemplate,
9
+ } from '@octanejs/app-core/html';