@k2b/ssr 0.12.0-rc.0

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,190 @@
1
+ /**
2
+ * Shared utilities for SSR adapters - path helpers, asset responses,
3
+ * SSE live reload, and security utilities.
4
+ */
5
+ import { dirname, join, resolve } from "path";
6
+ import type { SsrConfig } from "../index";
7
+
8
+ /**
9
+ * Normalize a public app base path.
10
+ * - undefined, "", "/" => ""
11
+ * - "/docs/" => "/docs"
12
+ * - values without leading slash are rejected
13
+ */
14
+ export const normalizeBasePath = (input?: string): string => {
15
+ const value = input?.trim() ?? "";
16
+ if (!value || value === "/") return "";
17
+ if (!value.startsWith("/")) {
18
+ throw new Error(`[ssr] basePath must start with "/" or be empty. Received: ${JSON.stringify(input)}`);
19
+ }
20
+ return value.replace(/\/+$/, "");
21
+ };
22
+
23
+ /**
24
+ * Public HTTP path prefix used for SSR assets/endpoints.
25
+ */
26
+ export const toSsrPath = (basePath: string): string =>
27
+ basePath ? `${basePath}/_ssr` : "/_ssr";
28
+
29
+ /**
30
+ * Get the _ssr directory path based on dev/prod mode.
31
+ * Dev: uses config.rootDir (fallback process.cwd())
32
+ * Prod: uses dirname(Bun.main) (next to compiled binary)
33
+ */
34
+ export const getSsrDir = (config: SsrConfig): string =>
35
+ join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
36
+
37
+ const HASHED_CHUNK = /^chunk-[a-z0-9]+\.js$/i;
38
+ const ASSET_FILE = /^[a-z0-9._-]+\.js(?:\.map)?$/i;
39
+
40
+ /**
41
+ * Stable entry names can change during development. Content-hashed chunks
42
+ * cannot, so the browser may retain them across page navigations.
43
+ */
44
+ export const getCacheHeaders = (dev: boolean, filename = "") =>
45
+ !dev || HASHED_CHUNK.test(filename) ? "public, max-age=31536000, immutable" : "no-cache";
46
+
47
+ const matchesEtag = (header: string, etag: string): boolean => {
48
+ const normalized = etag.replace(/^W\//, "");
49
+ return header.split(",").some((candidate) => {
50
+ const value = candidate.trim();
51
+ return value === "*" || value.replace(/^W\//, "") === normalized;
52
+ });
53
+ };
54
+
55
+ const isNotModified = (request: Request, etag: string, lastModified: number): boolean => {
56
+ const ifNoneMatch = request.headers.get("If-None-Match");
57
+ if (ifNoneMatch) return matchesEtag(ifNoneMatch, etag);
58
+
59
+ const ifModifiedSince = request.headers.get("If-Modified-Since");
60
+ if (!ifModifiedSince) return false;
61
+ const since = Date.parse(ifModifiedSince);
62
+ return Number.isFinite(since) && Math.floor(lastModified / 1000) <= Math.floor(since / 1000);
63
+ };
64
+
65
+ /**
66
+ * Serves generated island assets without eagerly buffering their contents.
67
+ * Dev validators make stable entries cheap to revalidate while preserving
68
+ * immediate rebuild visibility.
69
+ */
70
+ export const createAssetResponse = async (
71
+ request: Request,
72
+ directory: string,
73
+ filename: string,
74
+ dev: boolean,
75
+ ): Promise<Response> => {
76
+ if (!ASSET_FILE.test(filename)) return notFound();
77
+ const path = safePath(directory, filename);
78
+ if (!path) return notFound();
79
+
80
+ const contentType = filename.endsWith(".map")
81
+ ? "application/json; charset=utf-8"
82
+ : "application/javascript";
83
+ const file = Bun.file(path, { type: contentType });
84
+ if (!(await file.exists())) return notFound();
85
+
86
+ const cacheControl = getCacheHeaders(dev, filename);
87
+ if (!dev) {
88
+ return new Response(file, {
89
+ headers: { "Content-Type": contentType, "Cache-Control": cacheControl },
90
+ });
91
+ }
92
+
93
+ const lastModified = file.lastModified;
94
+ const etag = `W/"${file.size.toString(16)}-${Math.trunc(lastModified).toString(16)}"`;
95
+ const validatorHeaders = {
96
+ "Cache-Control": cacheControl,
97
+ ETag: etag,
98
+ "Last-Modified": new Date(lastModified).toUTCString(),
99
+ };
100
+
101
+ if (isNotModified(request, etag, lastModified)) {
102
+ return new Response(null, { status: 304, headers: validatorHeaders });
103
+ }
104
+
105
+ return new Response(file, {
106
+ headers: { "Content-Type": contentType, ...validatorHeaders },
107
+ });
108
+ };
109
+
110
+ /**
111
+ * SSE headers for live reload
112
+ */
113
+ export const SSE_HEADERS = {
114
+ "Content-Type": "text/event-stream",
115
+ "Cache-Control": "no-cache",
116
+ Connection: "keep-alive",
117
+ } as const;
118
+
119
+ const reloadId = crypto.randomUUID();
120
+ const reloadConnected = new TextEncoder().encode(`: connected\ndata: ${reloadId}\n\n`);
121
+ const reloadPing = new TextEncoder().encode(": ping\n\n");
122
+
123
+ export const getReloadId = (): string => reloadId;
124
+
125
+ export const createPingResponse = (): Response =>
126
+ new Response("ok", { headers: { "X-SSR-Reload-ID": reloadId } });
127
+
128
+ /**
129
+ * Creates a Server-Sent Events stream for live reload
130
+ */
131
+ export const createReloadStream = (signal?: AbortSignal): ReadableStream<Uint8Array> => {
132
+ let interval: ReturnType<typeof setInterval> | undefined;
133
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
134
+ const handleAbort = () => {
135
+ stopHeartbeat();
136
+ try {
137
+ streamController?.close();
138
+ } catch {
139
+ // The consumer may already have canceled the stream.
140
+ }
141
+ };
142
+ const stopHeartbeat = () => {
143
+ if (interval === undefined) return;
144
+ clearInterval(interval);
145
+ interval = undefined;
146
+ signal?.removeEventListener("abort", handleAbort);
147
+ };
148
+
149
+ return new ReadableStream<Uint8Array>({
150
+ start(controller) {
151
+ streamController = controller;
152
+ if (signal?.aborted) {
153
+ controller.close();
154
+ return;
155
+ }
156
+
157
+ signal?.addEventListener("abort", handleAbort, { once: true });
158
+ controller.enqueue(reloadConnected);
159
+ interval = setInterval(() => {
160
+ try {
161
+ controller.enqueue(reloadPing);
162
+ } catch {
163
+ stopHeartbeat();
164
+ }
165
+ }, 5000);
166
+ },
167
+ cancel: stopHeartbeat,
168
+ });
169
+ };
170
+
171
+ /**
172
+ * Creates a Response for the SSE reload endpoint
173
+ */
174
+ export const createReloadResponse = (signal?: AbortSignal): Response =>
175
+ new Response(createReloadStream(signal), { headers: SSE_HEADERS });
176
+
177
+ /**
178
+ * 404 Response
179
+ */
180
+ export const notFound = (): Response =>
181
+ new Response("Not found", { status: 404 });
182
+
183
+ /**
184
+ * Safely join paths, preventing path traversal attacks.
185
+ * Returns null if the resulting path escapes the base directory.
186
+ */
187
+ export const safePath = (base: string, filename: string): string | null => {
188
+ const resolved = resolve(base, filename);
189
+ return resolved.startsWith(resolve(base) + "/") ? resolved : null;
190
+ };
package/src/build.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Island bundler - discovers *.island.tsx and *.client.tsx files,
3
+ * transforms them for the browser, and outputs chunks to _ssr directory.
4
+ */
5
+ import { relative, resolve } from "path";
6
+ import { Glob } from "bun";
7
+ import { unlink } from "fs/promises";
8
+ import { transform } from "./transform";
9
+ import { ISLAND_ID_LENGTH, islandIdFromFile, toStableKey } from "./island-id";
10
+
11
+ type ComponentType = "island" | "client";
12
+
13
+ export type DevSourcemap = "none" | "linked" | "inline";
14
+
15
+ const getComponentType = (path: string): ComponentType => (path.includes(".client.") ? "client" : "island");
16
+
17
+ const getSelector = (type: ComponentType, id: string) =>
18
+ type === "island" ? `solid-island[data-id="${id}"]` : `solid-client[data-id="${id}"]`;
19
+
20
+ const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
21
+ const GENERATED_ASSET = /^(?:[a-f0-9]{12}|chunk-[a-z0-9]+)\.js(?:\.map)?$/i;
22
+
23
+ const removeStaleBuildAssets = async (outdir: string, outputs: readonly Bun.BuildArtifact[]): Promise<number> => {
24
+ const current = new Set(outputs.map((output) => resolve(output.path)));
25
+ let removed = 0;
26
+
27
+ for await (const file of new Glob("*.js*").scan({ cwd: outdir, absolute: true })) {
28
+ const filename = file.slice(file.lastIndexOf("/") + 1);
29
+ if (!GENERATED_ASSET.test(filename) || current.has(resolve(file))) continue;
30
+ await unlink(file);
31
+ removed += 1;
32
+ }
33
+
34
+ return removed;
35
+ };
36
+
37
+ /**
38
+ * Workaround for Bun bundler bug: duplicate export statements in shared chunks
39
+ * when splitting=true. We only run this in production because rewriting chunk
40
+ * source in dev can affect evaluation order in cyclic-sensitive modules.
41
+ */
42
+ export const dedupeSharedChunkExports = async (outdir: string, verbose: boolean): Promise<number> => {
43
+ let fixedChunks = 0;
44
+ const chunkGlob = new Glob("chunk-*.js");
45
+ for await (const chunkFile of chunkGlob.scan({ cwd: outdir, absolute: true })) {
46
+ const src = await Bun.file(chunkFile).text();
47
+ // Collect all export blocks and deduplicate.
48
+ const exportRegex = /^export\s*\{[^}]*\}\s*;?\s*$/gm;
49
+ const exportBlocks = [...src.matchAll(exportRegex)].map((m) => m[0]);
50
+ if (exportBlocks.length > 1) {
51
+ // Parse all exported names from all blocks.
52
+ const seenNames = new Set<string>();
53
+ const uniqueBlocks: string[] = [];
54
+ for (const block of exportBlocks) {
55
+ const names =
56
+ block
57
+ .match(/\{([^}]*)\}/)?.[1]
58
+ ?.split(",")
59
+ .map((n) => n.trim())
60
+ .filter(Boolean) ?? [];
61
+ const newNames = names.filter((n) => !seenNames.has(n));
62
+ if (newNames.length > 0) {
63
+ uniqueBlocks.push(`export { ${newNames.join(", ")} };`);
64
+ newNames.forEach((n) => seenNames.add(n));
65
+ }
66
+ }
67
+ // Replace all export blocks with deduplicated ones.
68
+ let fixed = src;
69
+ for (const block of exportBlocks) {
70
+ fixed = fixed.replace(block, "");
71
+ }
72
+ // Append single combined export before any debugId comment.
73
+ const debugIdIdx = fixed.lastIndexOf("//# debugId=");
74
+ const combined = uniqueBlocks.join("\n") + "\n";
75
+ if (debugIdIdx !== -1) {
76
+ fixed = fixed.slice(0, debugIdIdx) + combined + fixed.slice(debugIdIdx);
77
+ } else {
78
+ fixed = fixed.trimEnd() + "\n" + combined;
79
+ }
80
+ await Bun.write(chunkFile, fixed);
81
+ fixedChunks += 1;
82
+ if (verbose) console.log(` Fixed duplicate exports in ${chunkFile.split("/").pop()}`);
83
+ }
84
+ }
85
+ return fixedChunks;
86
+ };
87
+
88
+ export const buildIslands = async (options: {
89
+ pattern: string;
90
+ outdir: string;
91
+ cwd: string;
92
+ verbose: boolean;
93
+ dev?: boolean;
94
+ devSourcemap?: DevSourcemap;
95
+ external?: string[];
96
+ }): Promise<void> => {
97
+ const { pattern, outdir, cwd, verbose, dev = false, devSourcemap = "linked", external } = options;
98
+ const resolvedCwd = resolve(cwd);
99
+
100
+ const totalStart = performance.now();
101
+
102
+ const files: string[] = [];
103
+
104
+ const scanStart = performance.now();
105
+ for await (const file of new Glob(pattern).scan({
106
+ cwd: resolvedCwd,
107
+ absolute: true,
108
+ })) {
109
+ files.push(file);
110
+ }
111
+ if (verbose) console.log(`Scan: found ${files.length} file(s) in ${fmt(performance.now() - scanStart)}`);
112
+
113
+ if (!files.length) {
114
+ if (verbose) console.log("No island/client files found.");
115
+ return;
116
+ }
117
+
118
+ // Build component metadata
119
+ const components = files.map((componentPath) => {
120
+ const id = islandIdFromFile(componentPath, resolvedCwd);
121
+ const key = toStableKey(componentPath, resolvedCwd);
122
+ const type = getComponentType(componentPath);
123
+ const selector = getSelector(type, id);
124
+ return { path: componentPath, id, key, type, selector };
125
+ });
126
+
127
+ // Detect hash collisions and fail fast with actionable diagnostics
128
+ const idMap = new Map<string, typeof components>();
129
+ for (const c of components) {
130
+ if (!idMap.has(c.id)) {
131
+ idMap.set(c.id, []);
132
+ }
133
+ idMap.get(c.id)!.push(c);
134
+ }
135
+
136
+ const collisions = [...idMap.entries()].filter(([, items]) => items.length > 1);
137
+ if (collisions.length > 0) {
138
+ const details = collisions
139
+ .map(([id, items]) => {
140
+ const list = items.map((item) => ` - ${item.key} (${relative(resolvedCwd, item.path)})`).join("\n");
141
+ return ` ID ${id}:\n${list}`;
142
+ })
143
+ .join("\n");
144
+ throw new Error(`[ssr] Island ID collision detected. Rename files or adjust roots.\n${details}`);
145
+ }
146
+
147
+ // Build all islands together with code splitting
148
+ // This ensures Solid is only bundled once as a shared chunk
149
+ const transformTimings = verbose ? new Map<string, number>() : undefined;
150
+ const bundleStart = performance.now();
151
+ const result = await Bun.build({
152
+ entrypoints: components.map((c) => c.id),
153
+ outdir,
154
+ naming: { entry: "[name].js", chunk: "chunk-[hash].js" },
155
+ target: "browser",
156
+ external,
157
+ minify: !dev,
158
+ splitting: true,
159
+ sourcemap: dev ? devSourcemap : "none",
160
+ plugins: [
161
+ {
162
+ name: "solid-islands",
163
+ setup(build) {
164
+ // Resolve component IDs as virtual entrypoints
165
+ build.onResolve({ filter: new RegExp(`^[a-f0-9]{${ISLAND_ID_LENGTH}}$`) }, (args) => ({
166
+ path: args.path,
167
+ namespace: "island",
168
+ }));
169
+
170
+ // Generate hydration code for each component
171
+ build.onLoad({ filter: /.*/, namespace: "island" }, (args) => {
172
+ const component = components.find((c) => c.id === args.path);
173
+ if (!component) {
174
+ return { contents: "", loader: "js" };
175
+ }
176
+
177
+ return {
178
+ contents: `import{render,createComponent}from"solid-js/web";import{deserialize}from"seroval";import C from"${component.path}";document.querySelectorAll('${component.selector}').forEach(e=>{e.innerHTML="";render(()=>createComponent(C,deserialize(e.dataset.props||"{}")),e)})`,
179
+ loader: "js",
180
+ };
181
+ });
182
+
183
+ // Transform TSX/JSX with Solid DOM mode
184
+ build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
185
+ const t0 = performance.now();
186
+ // Import with ? suffix to register file with bun --watch
187
+ // Issue: https://github.com/oven-sh/bun/issues/4689
188
+ const contents = await import(`${path}?`, {
189
+ with: { type: "text" },
190
+ });
191
+ const result = {
192
+ contents: await transform(contents.default, path, "dom"),
193
+ loader: "js" as const,
194
+ };
195
+ transformTimings?.set(path, performance.now() - t0);
196
+ return result;
197
+ });
198
+ },
199
+ },
200
+ ],
201
+ });
202
+
203
+ if (result.success) {
204
+ const removed = await removeStaleBuildAssets(outdir, result.outputs);
205
+ if (verbose && removed > 0) console.log(`Removed ${removed} stale build asset(s).`);
206
+
207
+ if (!dev) {
208
+ await dedupeSharedChunkExports(outdir, verbose);
209
+ } else if (verbose) {
210
+ console.log("Skipped shared-chunk export rewrite in dev mode.");
211
+ }
212
+ }
213
+
214
+ if (verbose) {
215
+ console.log(`Bundle: ${fmt(performance.now() - bundleStart)}`);
216
+ for (const c of components) {
217
+ const rel = relative(resolvedCwd, c.path);
218
+ const t = transformTimings?.get(c.path);
219
+ console.log(` ${rel} -> ${outdir}/${c.id}.js${t != null ? ` (transform: ${fmt(t)})` : ""}`);
220
+ }
221
+ }
222
+ console.log(
223
+ `Built ${files.length} component(s) to ${outdir}/ in ${fmt(performance.now() - totalStart)}${verbose ? " (total)" : ""}`,
224
+ );
225
+
226
+ if (!result.success) {
227
+ console.error("Build failed:");
228
+ result.logs.forEach((m) => console.error(` ${m}`));
229
+ }
230
+ };
package/src/index.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Core SSR module - exports createConfig() which provides:
3
+ * - config: SSR configuration for adapters
4
+ * - plugin: Bun plugin for build/dev that transforms islands
5
+ * - html: Renders JSX to Response with hydration scripts
6
+ */
7
+ import { renderToString } from "solid-js/web";
8
+ import type { JSX } from "solid-js";
9
+ import type { BunPlugin } from "bun";
10
+ import { statSync } from "fs";
11
+ import { transform } from "./transform";
12
+ import { buildIslands, type DevSourcemap } from "./build";
13
+ import { join, dirname, resolve } from "path";
14
+ import { resolveIslandImport } from "./island-resolve";
15
+ import { getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
16
+ // @ts-ignore - Bun text import
17
+ import devClientCode from "./adapter/client.js" with { type: "text" };
18
+
19
+ // ============================================================================
20
+ // Constants
21
+ // ============================================================================
22
+
23
+ /** Glob pattern for island/client component files */
24
+ const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
25
+
26
+ /**
27
+ * Build version used for cache busting island script imports in production.
28
+ * Uses server entrypoint mtime as a stable per-build version value.
29
+ */
30
+ const getBuildVersion = (dev: boolean): string => {
31
+ if (dev) return "";
32
+ try {
33
+ return String(Math.floor(statSync(Bun.main).mtimeMs));
34
+ } catch {
35
+ return String(Date.now());
36
+ }
37
+ };
38
+
39
+ // ============================================================================
40
+ // Types
41
+ // ============================================================================
42
+
43
+ export type SsrOptions<T extends object = object> = {
44
+ /** Enable dev mode (default: false) */
45
+ dev?: boolean;
46
+ /** Enable verbose logging (default: true in prod, false in dev) */
47
+ verbose?: boolean;
48
+ /** Project root for island discovery and dev _ssr assets (default: process.cwd()) */
49
+ rootDir?: string;
50
+ /** Public app mount path for SSR assets and dev endpoints (default: "") */
51
+ basePath?: string;
52
+ /** Modules to exclude from the island bundle (passed to Bun.build) */
53
+ external?: string[];
54
+ /** Development island sourcemaps (default: "linked") */
55
+ devSourcemap?: DevSourcemap;
56
+ /** HTML template function (optional, has default) */
57
+ template?: (
58
+ ctx: {
59
+ body: string;
60
+ scripts: string;
61
+ } & T,
62
+ ) => string | Promise<string>;
63
+ };
64
+
65
+ export type SsrConfig = {
66
+ dev: boolean;
67
+ verbose?: boolean;
68
+ rootDir?: string;
69
+ basePath: string;
70
+ ssrPath: string;
71
+ };
72
+
73
+ export type RenderFn = () => JSX.Element;
74
+
75
+ export type HtmlFn<T extends object> = (render: RenderFn, options?: T) => Promise<Response>;
76
+
77
+ type PluginFn = () => BunPlugin;
78
+
79
+ export type SsrResult<T extends object> = {
80
+ config: SsrConfig;
81
+ plugin: PluginFn;
82
+ html: HtmlFn<T>;
83
+ };
84
+
85
+ // ============================================================================
86
+ // createConfig() - Create SSR configuration
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Creates SSR configuration, html renderer, and build plugin.
91
+ *
92
+ * Components follow naming conventions:
93
+ * - `*.island.tsx` - SSR rendered + hydrated on client (interactive)
94
+ * - `*.client.tsx` - Client-only rendered (not SSR)
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * // config.ts
99
+ * import { createConfig } from "@k2b/ssr";
100
+ *
101
+ * type PageOptions = { title?: string };
102
+ *
103
+ * export const { config, plugin, html } = createConfig<PageOptions>({
104
+ * dev: process.env.NODE_ENV === "development",
105
+ * template: ({ body, scripts, title }) => `
106
+ * <!DOCTYPE html>
107
+ * <html>
108
+ * <head><title>${title ?? "App"}</title></head>
109
+ * <body>${body}</body>
110
+ * ${scripts}
111
+ * </html>
112
+ * `,
113
+ * });
114
+ * ```
115
+ */
116
+ export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
117
+ const {
118
+ dev = false,
119
+ verbose,
120
+ external,
121
+ devSourcemap = "linked",
122
+ template,
123
+ rootDir: rootDirOption,
124
+ basePath: basePathOption,
125
+ } = options;
126
+ const rootDir = resolve(rootDirOption ?? process.cwd());
127
+ const basePath = normalizeBasePath(basePathOption);
128
+ const ssrPath = toSsrPath(basePath);
129
+
130
+ // Default template if none provided
131
+ const htmlTemplate =
132
+ template ??
133
+ (({ body, scripts }) => `
134
+ <!DOCTYPE html>
135
+ <html>
136
+ <head>
137
+ <meta charset="utf-8">
138
+ <meta name="viewport" content="width=device-width, initial-scale=1">
139
+ </head>
140
+ <body>
141
+ ${body}
142
+ ${scripts}
143
+ </body>
144
+ </html>
145
+ `);
146
+
147
+ // Config object for routes adapters
148
+ const config: SsrConfig = {
149
+ dev,
150
+ verbose,
151
+ rootDir,
152
+ basePath,
153
+ ssrPath,
154
+ };
155
+
156
+ const buildVersion = getBuildVersion(dev);
157
+
158
+ const islandDisplayStyle =
159
+ "<style>solid-client,solid-island{display:contents}</style>";
160
+
161
+ // Hydration script - dynamically loads island/client bundles based on DOM
162
+ const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath)};const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</script>`;
163
+ const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
164
+
165
+ // HTML renderer
166
+ const html: HtmlFn<T> = async (render, opts = {} as T) => {
167
+ if (render.constructor.name === "AsyncFunction") {
168
+ throw new Error("[ssr] html() expects a synchronous render function: html(() => <Page />)");
169
+ }
170
+
171
+ const body = renderToString(render);
172
+
173
+ // Framework-injected assets
174
+ let scripts = `${islandDisplayStyle}\n${hydrationScript}`;
175
+
176
+ // Add dev tools script in dev mode (inlined)
177
+ if (dev) {
178
+ scripts += `\n${devConfigScript}\n<script type="module">${devClientCode}</script>`;
179
+ }
180
+
181
+ const content = await htmlTemplate({
182
+ body,
183
+ scripts,
184
+ ...opts,
185
+ });
186
+
187
+ return new Response(content, {
188
+ headers: { "Content-Type": "text/html; charset=utf-8" },
189
+ });
190
+ };
191
+
192
+ // Build islands once per run
193
+ let islandsBuilt = false;
194
+
195
+ // Bun plugin for build/dev
196
+ const plugin: PluginFn = () => {
197
+ return {
198
+ name: "solid-ssr",
199
+ setup(build) {
200
+ // Determine output directory
201
+ const prodOutdir = build.config?.outdir;
202
+ const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : join(rootDir, "_ssr");
203
+
204
+ const ensureIslands = async () => {
205
+ if (islandsBuilt) return;
206
+ islandsBuilt = true;
207
+ await buildIslands({
208
+ pattern: COMPONENT_PATTERN,
209
+ outdir: islandsOutdir,
210
+ cwd: rootDir,
211
+ verbose: verbose ?? !dev,
212
+ dev,
213
+ devSourcemap,
214
+ external,
215
+ });
216
+ };
217
+
218
+ // Build islands on start (works for Bun.build)
219
+ build.onStart?.(ensureIslands);
220
+
221
+ // Handle .island and .client imports (without .tsx extension)
222
+ build.onResolve({ filter: /\.(island|client)$/ }, (args) => {
223
+ const resolveDir = args.resolveDir || (args.importer ? dirname(args.importer) : rootDir);
224
+ return {
225
+ path: resolveIslandImport(args.path, resolveDir, args.importer || undefined),
226
+ };
227
+ });
228
+
229
+ // Transform TSX/JSX files with Solid SSR
230
+ build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
231
+ // Fallback for Bun.plugin (has no onStart)
232
+ await ensureIslands();
233
+ // Import with ? suffix to register file with bun --watch
234
+ // Issue: https://github.com/oven-sh/bun/issues/4689
235
+ const contents = await import(`${path}?`, { with: { type: "text" } });
236
+ return {
237
+ contents: await transform(contents.default, path, "ssr", dev, rootDir),
238
+ loader: "js",
239
+ };
240
+ });
241
+ },
242
+ };
243
+ };
244
+
245
+ return { config, plugin, html };
246
+ };