@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.
- package/LICENSE +21 -0
- package/README.md +402 -0
- package/package.json +69 -0
- package/src/adapter/bun.ts +51 -0
- package/src/adapter/client.js +421 -0
- package/src/adapter/elysia.ts +39 -0
- package/src/adapter/hono.ts +190 -0
- package/src/adapter/utils.ts +190 -0
- package/src/build.ts +230 -0
- package/src/index.ts +246 -0
- package/src/island-id.ts +78 -0
- package/src/island-resolve.ts +39 -0
- package/src/nav.ts +308 -0
- package/src/transform.ts +157 -0
|
@@ -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
|
+
};
|