@octanejs/vite-plugin 0.1.3 → 0.1.4

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,157 @@
1
+ // Production SSR build — end-to-end over the fixture app (tests/_fixtures/app):
2
+ // `vite build` must produce BOTH bundles (dist/client assets + the
3
+ // self-contained dist/server/entry.js), and the server bundle's handler must
4
+ // render a route with hydratable output whose body region and #__octane_data
5
+ // payload BYTE-MATCH dev SSR for the same request — that is the contract that
6
+ // lets hydrateRoot adopt production responses exactly like dev ones.
7
+ //
8
+ // The fixture has no installed node_modules (it is not a workspace package);
9
+ // the setup symlinks the workspace's octane / @octanejs/vite-plugin / vite in,
10
+ // which is exactly what a pnpm install would produce.
11
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { pathToFileURL, fileURLToPath } from 'node:url';
15
+ import { build, createServer, type ViteDevServer } from 'vite';
16
+
17
+ const fixtureRoot = fileURLToPath(new URL('./_fixtures/app', import.meta.url));
18
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
19
+ const repoRoot = path.resolve(packageRoot, '../..');
20
+ const distDir = path.join(fixtureRoot, 'dist');
21
+
22
+ function linkPackage(name: string, target: string) {
23
+ const dest = path.join(fixtureRoot, 'node_modules', name);
24
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
25
+ fs.rmSync(dest, { recursive: true, force: true });
26
+ fs.symlinkSync(target, dest, 'dir');
27
+ }
28
+
29
+ /** The rendered body region: everything streamed into `<div id="root">`. */
30
+ function bodyRegionOf(html: string): string {
31
+ const open = '<div id="root">';
32
+ const start = html.indexOf(open);
33
+ const end = html.lastIndexOf('</div>');
34
+ expect(start).toBeGreaterThan(-1);
35
+ expect(end).toBeGreaterThan(start);
36
+ return html.slice(start + open.length, end);
37
+ }
38
+
39
+ function dataScriptOf(html: string): string {
40
+ const match = html.match(/<script id="__octane_data" type="application\/json">(.*?)<\/script>/s);
41
+ expect(match).not.toBeNull();
42
+ return match![1];
43
+ }
44
+
45
+ let devServer: ViteDevServer | null = null;
46
+ let devOrigin = '';
47
+
48
+ beforeAll(async () => {
49
+ linkPackage('octane', path.join(repoRoot, 'packages/octane'));
50
+ linkPackage('@octanejs/vite-plugin', packageRoot);
51
+ linkPackage('vite', path.join(packageRoot, 'node_modules/vite'));
52
+
53
+ fs.rmSync(distDir, { recursive: true, force: true });
54
+
55
+ // The production build: client bundle, then (closeBundle) the server bundle.
56
+ await build({ root: fixtureRoot, logLevel: 'silent' });
57
+
58
+ // A dev server on a random port — the byte-compat oracle.
59
+ devServer = await createServer({
60
+ root: fixtureRoot,
61
+ logLevel: 'silent',
62
+ server: { port: 0 },
63
+ });
64
+ await devServer.listen();
65
+ const address = devServer.httpServer?.address();
66
+ if (!address || typeof address !== 'object') throw new Error('dev server has no address');
67
+ devOrigin = `http://localhost:${address.port}`;
68
+ }, 180_000);
69
+
70
+ afterAll(async () => {
71
+ await devServer?.close();
72
+ fs.rmSync(distDir, { recursive: true, force: true });
73
+ fs.rmSync(path.join(fixtureRoot, 'node_modules'), { recursive: true, force: true });
74
+ });
75
+
76
+ describe('production SSR build', () => {
77
+ it('emits both bundles, moves the template to dist/server, and strips build metadata', () => {
78
+ expect(fs.existsSync(path.join(distDir, 'server/entry.js'))).toBe(true);
79
+ expect(fs.existsSync(path.join(distDir, 'server/index.html'))).toBe(true);
80
+ // The template must NOT stay in the static dir (it would shadow SSR at '/'
81
+ // on filesystem-first hosts) and the manifest must not ship.
82
+ expect(fs.existsSync(path.join(distDir, 'client/index.html'))).toBe(false);
83
+ expect(fs.existsSync(path.join(distDir, 'client/.vite'))).toBe(false);
84
+ // The client build produced hashed assets, including the hydrate entry
85
+ // referenced by the moved template.
86
+ const template = fs.readFileSync(path.join(distDir, 'server/index.html'), 'utf-8');
87
+ const scriptSrc = template.match(/<script type="module"[^>]*src="(\/assets\/[^"]+)"/)?.[1];
88
+ expect(scriptSrc).toBeTruthy();
89
+ expect(fs.existsSync(path.join(distDir, 'client', scriptSrc!))).toBe(true);
90
+ // The SSR placeholders survived the client build untouched.
91
+ expect(template).toContain('<!--ssr-head-->');
92
+ expect(template).toContain('<!--ssr-body-->');
93
+ });
94
+
95
+ it('the server bundle is self-contained (imports only node builtins)', () => {
96
+ const entry = fs.readFileSync(path.join(distDir, 'server/entry.js'), 'utf-8');
97
+ const specifiers = [...entry.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
98
+ expect(specifiers.length).toBeGreaterThan(0);
99
+ for (const spec of specifiers) {
100
+ expect(spec.startsWith('node:'), `unexpected external import: ${spec}`).toBe(true);
101
+ }
102
+ });
103
+
104
+ it('renders a route through the built handler, byte-matching dev SSR', async () => {
105
+ const { handler } = await import(pathToFileURL(path.join(distDir, 'server/entry.js')).href);
106
+
107
+ for (const url of ['/', '/pages/hello']) {
108
+ const prodResponse = await handler(new Request(`http://localhost${url}`));
109
+ expect(prodResponse.status).toBe(200);
110
+ expect(prodResponse.headers.get('content-type')).toBe('text/html; charset=utf-8');
111
+ const prodHtml = await prodResponse.text();
112
+
113
+ const devResponse = await fetch(`${devOrigin}${url}`);
114
+ expect(devResponse.status).toBe(200);
115
+ const devHtml = await devResponse.text();
116
+
117
+ // The hydratable body region and the hydration payload are the
118
+ // byte-compat contract between dev and production.
119
+ expect(bodyRegionOf(prodHtml)).toBe(bodyRegionOf(devHtml));
120
+ expect(dataScriptOf(prodHtml)).toBe(dataScriptOf(devHtml));
121
+
122
+ // Sanity: it actually rendered the page.
123
+ expect(prodHtml).toContain('fixture-nav');
124
+ expect(prodHtml).toContain(url === '/' ? 'Fixture page home' : 'Fixture page hello');
125
+ expect(prodHtml).toContain(`<p class="url">${url}</p>`);
126
+ }
127
+ });
128
+
129
+ it('returns 404 for unmatched routes (no catch-all in the fixture)', async () => {
130
+ const { handler } = await import(pathToFileURL(path.join(distDir, 'server/entry.js')).href);
131
+ const response = await handler(new Request('http://localhost/nope/nothing'));
132
+ expect(response.status).toBe(404);
133
+ });
134
+
135
+ it('nodeHandler bridges the same handler for Node-style serverless wrappers', async () => {
136
+ const { nodeHandler } = await import(pathToFileURL(path.join(distDir, 'server/entry.js')).href);
137
+ const chunks: Buffer[] = [];
138
+ const headers: Record<string, unknown> = {};
139
+ const res = {
140
+ statusCode: 0,
141
+ headersSent: false,
142
+ setHeader(key: string, value: unknown) {
143
+ headers[key.toLowerCase()] = value;
144
+ },
145
+ write(chunk: Uint8Array) {
146
+ chunks.push(Buffer.from(chunk));
147
+ },
148
+ end(chunk?: Uint8Array) {
149
+ if (chunk) chunks.push(Buffer.from(chunk));
150
+ },
151
+ };
152
+ await nodeHandler({ method: 'GET', url: '/pages/node', headers: { host: 'localhost' } }, res);
153
+ expect(res.statusCode).toBe(200);
154
+ expect(headers['content-type']).toBe('text/html; charset=utf-8');
155
+ expect(Buffer.concat(chunks).toString('utf-8')).toContain('Fixture page node');
156
+ });
157
+ });
@@ -13,6 +13,6 @@
13
13
  "allowJs": true,
14
14
  "checkJs": false
15
15
  },
16
- "include": ["src/**/*", "types/**/*"],
17
- "exclude": ["node_modules", "dist", "tests"]
16
+ "include": ["src/**/*", "types/**/*", "tests/**/*.ts"],
17
+ "exclude": ["node_modules", "dist", "tests/_fixtures"]
18
18
  }
package/types/index.d.ts CHANGED
@@ -8,6 +8,15 @@ import type { RuntimePrimitives } from '@ripple-ts/adapter';
8
8
  export interface OctanePluginOptions {
9
9
  /** Override the client HMR default (on in serve mode, off for SSR). */
10
10
  hmr?: boolean;
11
+ /**
12
+ * Path fragments the compiler's plain `.ts`/`.js` hook-slotting pass must
13
+ * skip — hand-slot-forwarding library sources that would otherwise be
14
+ * double-slotted. Published bindings live in node_modules and are skipped
15
+ * automatically; set this for monorepo / aliased-to-source setups where
16
+ * pnpm symlinks resolve `@octanejs/*` imports to `packages/*\/src` paths
17
+ * (e.g. `['/packages/tanstack-router/src/']`).
18
+ */
19
+ exclude?: string[];
11
20
  }
12
21
 
13
22
  /**
@@ -16,6 +25,18 @@ export interface OctanePluginOptions {
16
25
  * config / routing / dev SSR / hydrate.
17
26
  */
18
27
  export function octane(options?: OctanePluginOptions): Plugin[];
28
+
29
+ /**
30
+ * Is this a request the Vite dev server owns (module / asset / internal
31
+ * namespace / transform query), as opposed to a page navigation? The dev SSR
32
+ * middleware uses it so a catch-all RenderRoute never swallows Vite requests.
33
+ *
34
+ * `fileRoots` (the Vite root + publicDir) gate the file-extension heuristic:
35
+ * an extension-bearing path is only Vite's when it names a real file under
36
+ * one of them, so page URLs like `/docs/v2.0` still SSR. Without `fileRoots`
37
+ * any extension counts (conservative).
38
+ */
39
+ export function isViteOwnedUrl(url: URL, fileRoots?: string[]): boolean;
19
40
  export function defineConfig(options: OctaneConfigOptions): OctaneConfigOptions;
20
41
  export function resolveOctaneConfig(
21
42
  raw: OctaneConfigOptions,
@@ -38,6 +59,7 @@ export class RenderRoute {
38
59
  entry: RenderRouteEntry;
39
60
  layout?: string;
40
61
  before: Middleware[];
62
+ status?: number;
41
63
  constructor(options: RenderRouteOptions);
42
64
  }
43
65
 
@@ -66,6 +88,11 @@ export interface RenderRouteOptions {
66
88
  layout?: string;
67
89
  /** Middleware to run before rendering */
68
90
  before?: Middleware[];
91
+ /**
92
+ * HTTP status for the rendered response (default 200). Set 404 on a
93
+ * catch-all route so the SSR'd not-found page reports its real status.
94
+ */
95
+ status?: number;
69
96
  }
70
97
 
71
98
  export interface ServerRouteOptions {
@@ -112,6 +139,27 @@ export type Component<T = Record<string, any>> = (
112
139
 
113
140
  export type RenderRouteEntry = string | readonly [exportName: string, path: string];
114
141
 
142
+ /**
143
+ * Props every RenderRoute component (and layout) receives: the route params
144
+ * and the request `url` (pathname + search, origin-free — the client hydrate
145
+ * entry re-renders with the identical string).
146
+ */
147
+ export interface RenderRouteProps {
148
+ params: Record<string, string>;
149
+ url: string;
150
+ }
151
+
152
+ /**
153
+ * The app hook run by the client hydrate entry BEFORE `hydrateRoot` (config
154
+ * `router.preHydrate`): commit client-side state the server already resolved —
155
+ * typically a client router loading its match tree — so the first hydration
156
+ * pass adopts the same tree the server rendered.
157
+ */
158
+ export type PreHydrateHook = (info: {
159
+ url: string;
160
+ params: Record<string, string>;
161
+ }) => void | Promise<void>;
162
+
115
163
  export interface RootBoundaryOptions {
116
164
  pending?: Component<Record<string, never>>;
117
165
  catch?: Component<{ error: unknown; reset: () => void }>;
@@ -124,17 +172,15 @@ export interface OctaneConfigOptions {
124
172
  minify?: boolean;
125
173
  target?: BuildEnvironmentOptions['target'];
126
174
  };
127
- adapter?: {
128
- serve: AdapterServeFunction;
129
- /**
130
- * Platform-specific runtime primitives provided by the adapter.
131
- * Required for production builds; in development the plugin falls back
132
- * to Node.js defaults if not provided.
133
- */
134
- runtime: RuntimePrimitives;
135
- };
175
+ adapter?: OctaneAdapter;
136
176
  router?: {
137
177
  routes: Route[];
178
+ /**
179
+ * Vite-root path (e.g. '/src/pre-hydrate.ts') of a module whose default
180
+ * export is a {@link PreHydrateHook}. The client hydrate entry imports it
181
+ * and awaits the hook before calling `hydrateRoot`.
182
+ */
183
+ preHydrate?: string;
138
184
  };
139
185
  /** Global root pending/catch UI used by client and SSR render roots */
140
186
  rootBoundary?: RootBoundaryOptions;
@@ -150,6 +196,14 @@ export interface OctaneConfigOptions {
150
196
  * @default false
151
197
  */
152
198
  trustProxy?: boolean;
199
+ /**
200
+ * Production SSR mode: 'streaming' (default) flushes the shell at
201
+ * first await and streams suspense segments out-of-order (same engine
202
+ * as dev SSR); 'buffered' awaits everything (`prerender`) and sends
203
+ * one document — for hosts that break streamed responses.
204
+ * @default 'streaming'
205
+ */
206
+ render?: 'streaming' | 'buffered';
153
207
  };
154
208
  }
155
209
 
@@ -163,12 +217,10 @@ export interface ResolvedOctaneConfig {
163
217
  minify?: boolean;
164
218
  target?: BuildEnvironmentOptions['target'];
165
219
  };
166
- adapter?: {
167
- serve: AdapterServeFunction;
168
- runtime: RuntimePrimitives;
169
- };
220
+ adapter?: OctaneAdapter;
170
221
  router: {
171
222
  routes: Route[];
223
+ preHydrate?: string;
172
224
  };
173
225
  rootBoundary: RootBoundaryOptions;
174
226
  /** @default [] */
@@ -180,9 +232,46 @@ export interface ResolvedOctaneConfig {
180
232
  server: {
181
233
  /** @default false */
182
234
  trustProxy: boolean;
235
+ /** @default 'streaming' */
236
+ render: 'streaming' | 'buffered';
183
237
  };
184
238
  }
185
239
 
240
+ /**
241
+ * The build context @octanejs/vite-plugin passes to an adapter's `adapt()`
242
+ * after `vite build` produced both bundles.
243
+ */
244
+ export interface AdaptContext {
245
+ /** Absolute project root (the Vite root). */
246
+ root: string;
247
+ /** The config `build.outDir` (relative to root, e.g. 'dist'). */
248
+ outDir: string;
249
+ /** Absolute path of the static client bundle ({outDir}/client). */
250
+ clientDir: string;
251
+ /** Absolute path of the server bundle ({outDir}/server, contains entry.js). */
252
+ serverDir: string;
253
+ /** Prefixed build logger. */
254
+ log: (message: string) => void;
255
+ }
256
+
257
+ /**
258
+ * The octane.config.ts `adapter` contract. All parts are optional and
259
+ * independent:
260
+ *
261
+ * - `adapt(ctx)` — post-build hook: restructure dist/client + dist/server for
262
+ * a deployment target (e.g. @octanejs/adapter-vercel emits `.vercel/output`).
263
+ * - `serve(handler, opts)` — replaces the generated server entry's built-in
264
+ * Node boot when running `node dist/server/entry.js` / `octane-preview`.
265
+ * - `runtime` — platform primitives (hashing, async context) replacing the
266
+ * entry's Node defaults; needed on non-Node runtimes.
267
+ */
268
+ export interface OctaneAdapter {
269
+ name?: string;
270
+ adapt?: (ctx: AdaptContext) => void | Promise<void>;
271
+ serve?: AdapterServeFunction;
272
+ runtime?: RuntimePrimitives;
273
+ }
274
+
186
275
  export type AdapterServeFunction = (
187
276
  handler: (request: Request, platform?: unknown) => Response | Promise<Response>,
188
277
  options?: Record<string, unknown>,
@@ -0,0 +1,31 @@
1
+ import type { IncomingMessage, ServerResponse, Server } from 'node:http';
2
+
3
+ /** Convert a Node.js IncomingMessage to a Web Request. */
4
+ export function nodeRequestToWebRequest(nodeRequest: IncomingMessage): Request;
5
+
6
+ /**
7
+ * Pipe a Web Response to a Node.js ServerResponse, streaming chunk-by-chunk
8
+ * (a streaming SSR body flushes as it renders).
9
+ */
10
+ export function sendWebResponse(nodeResponse: ServerResponse, webResponse: Response): Promise<void>;
11
+
12
+ /**
13
+ * Serve a static file from `staticDir` when the request path maps to one.
14
+ * `/assets/*` (hash-named build output) gets immutable caching; other files
15
+ * revalidate. Returns true when the request was handled.
16
+ */
17
+ export function serveStaticFile(
18
+ req: IncomingMessage,
19
+ res: ServerResponse,
20
+ staticDir: string,
21
+ ): boolean;
22
+
23
+ /**
24
+ * Minimal production HTTP server: static files from `staticDir` first (the
25
+ * built client assets), then the fetch-style SSR handler. The default boot for
26
+ * `node dist/server/entry.js` when octane.config.ts has no adapter.
27
+ */
28
+ export function createNodeServer(
29
+ handler: (request: Request) => Response | Promise<Response>,
30
+ options?: { staticDir?: string },
31
+ ): { listen: (port?: number) => Server; close: () => void };
@@ -6,6 +6,7 @@ import type {
6
6
  OctaneConfigOptions,
7
7
  RootBoundaryOptions,
8
8
  } from '@octanejs/vite-plugin';
9
+ import type { RenderResult, StreamOptions, RenderOptions } from 'octane/server';
9
10
 
10
11
  export function resolveOctaneConfig(
11
12
  raw: OctaneConfigOptions,
@@ -13,45 +14,57 @@ export function resolveOctaneConfig(
13
14
  ): ResolvedOctaneConfig;
14
15
 
15
16
  export interface ClientAssetEntry {
16
- /** Path to the built JS file (relative to client output dir) */
17
+ /** Path to the built JS file (relative to the client output dir) */
17
18
  js: string;
18
- /** Paths to the built CSS files (relative to client output dir) */
19
+ /** Paths to the built CSS files (relative to the client output dir) */
19
20
  css: string[];
20
21
  }
21
22
 
22
23
  export interface ServerManifest {
23
24
  routes: Route[];
24
- components: Record<string, Function>;
25
- layouts: Record<string, Function>;
25
+ /** RenderRoute entry module path → module namespace (export picked per-route) */
26
+ components: Record<string, Record<string, unknown>>;
27
+ /** Layout module path → module namespace */
28
+ layouts: Record<string, Record<string, unknown>>;
26
29
  middlewares: Middleware[];
27
- /** Map of entry path → _$_server_$_ object for RPC support */
28
- rpcModules?: Record<string, Record<string, Function>>;
29
30
  /** Trust X-Forwarded-* headers when deriving origin for RPC fetch */
30
31
  trustProxy?: boolean;
32
+ /** 'streaming' (default) renders via renderToReadableStream; 'buffered' awaits everything via prerender */
33
+ render?: 'streaming' | 'buffered';
31
34
  rootBoundary?: RootBoundaryOptions;
32
- /** Platform-specific runtime primitives from the adapter */
33
- runtime: RuntimePrimitives;
34
- /** Map of route entry paths to built client asset paths (preload tags). */
35
+ /** config `router.preHydrate`, serialized into #__octane_data for the client entry */
36
+ preHydrate?: string | null;
37
+ /** Map of entry path `module server` namespace for RPC support */
38
+ rpcModules?: Record<string, Record<string, Function>>;
39
+ /** Platform primitives (adapter's, or the generated entry's Node defaults) */
40
+ runtime?: RuntimePrimitives;
41
+ /** Route entry module path → built client asset paths (preload tags) */
35
42
  clientAssets?: Record<string, ClientAssetEntry>;
36
43
  }
37
44
 
38
- /**
39
- * octane RenderResult — re-exported from 'octane/server' (the single source of
40
- * truth) rather than re-declared, so the shape can't silently drift. Note
41
- * `render()` is async and `css` is ALREADY a ready, deduped
42
- * `<style data-octane="hash">…</style>` string (NOT a Set<string> needing a
43
- * `getCss` lookup like Ripple).
44
- */
45
- export type { RenderResult } from 'octane/server';
46
- import type { RenderResult } from 'octane/server';
47
-
48
45
  export interface HandlerOptions {
49
- render: (component: Function, props?: unknown) => Promise<RenderResult>;
46
+ /** `renderToReadableStream` from 'octane/server' (the streaming engine dev SSR uses) */
47
+ renderToReadableStream: (
48
+ component: Function,
49
+ props?: unknown,
50
+ options?: StreamOptions,
51
+ ) => Promise<ReadableStream<Uint8Array>>;
52
+ /** `prerender` from 'octane/static' (the buffered await-everything fallback) */
53
+ prerender: (
54
+ component: Function,
55
+ props?: unknown,
56
+ options?: RenderOptions,
57
+ ) => Promise<RenderResult>;
58
+ /** The BUILT dist client index.html (moved to dist/server by the build) */
50
59
  htmlTemplate: string;
60
+ /** RPC executor from 'octane/server' */
51
61
  executeServerFunction: (fn: Function, body: string) => Promise<string>;
52
62
  }
53
63
 
54
- /** Production fetch-handler factory. PHASE 2. */
64
+ /**
65
+ * Production fetch-handler factory. Mirrors the dev middleware's render path
66
+ * byte-for-byte in everything hydration can see (see server/production.js).
67
+ */
55
68
  export function createHandler(
56
69
  manifest: ServerManifest,
57
70
  options: HandlerOptions,