@chr33s/solarflare 0.0.2

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.
Files changed (47) hide show
  1. package/package.json +52 -0
  2. package/readme.md +183 -0
  3. package/src/ast.ts +316 -0
  4. package/src/build.bundle-client.ts +404 -0
  5. package/src/build.bundle-server.ts +131 -0
  6. package/src/build.bundle.ts +48 -0
  7. package/src/build.emit-manifests.ts +25 -0
  8. package/src/build.hmr-entry.ts +88 -0
  9. package/src/build.scan.ts +182 -0
  10. package/src/build.ts +227 -0
  11. package/src/build.validate.ts +63 -0
  12. package/src/client.hmr.ts +78 -0
  13. package/src/client.styles.ts +68 -0
  14. package/src/client.ts +190 -0
  15. package/src/codemod.ts +688 -0
  16. package/src/console-forward.ts +254 -0
  17. package/src/critical-css.ts +103 -0
  18. package/src/devtools-json.ts +52 -0
  19. package/src/diff-dom-streaming.ts +406 -0
  20. package/src/early-flush.ts +125 -0
  21. package/src/early-hints.ts +83 -0
  22. package/src/fetch.ts +44 -0
  23. package/src/fs.ts +11 -0
  24. package/src/head.ts +876 -0
  25. package/src/hmr.ts +647 -0
  26. package/src/hydration.ts +238 -0
  27. package/src/manifest.runtime.ts +25 -0
  28. package/src/manifest.ts +23 -0
  29. package/src/paths.ts +96 -0
  30. package/src/render-priority.ts +69 -0
  31. package/src/route-cache.ts +163 -0
  32. package/src/router-deferred.ts +85 -0
  33. package/src/router-stream.ts +65 -0
  34. package/src/router.ts +535 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/serialize.ts +38 -0
  37. package/src/server.hmr.ts +67 -0
  38. package/src/server.styles.ts +42 -0
  39. package/src/server.ts +480 -0
  40. package/src/solarflare.d.ts +101 -0
  41. package/src/speculation-rules.ts +171 -0
  42. package/src/store.ts +78 -0
  43. package/src/stream-assets.ts +135 -0
  44. package/src/stylesheets.ts +222 -0
  45. package/src/worker.config.ts +243 -0
  46. package/src/worker.ts +542 -0
  47. package/tsconfig.json +21 -0
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@chr33s/solarflare",
3
+ "version": "0.0.2",
4
+ "license": "MIT",
5
+ "bin": "./src/build.ts",
6
+ "files": [
7
+ "src",
8
+ "tsconfig.json",
9
+ "!src/*.test.ts"
10
+ ],
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/solarflare.d.ts",
15
+ "import": "./src/worker.ts"
16
+ },
17
+ "./client": "./src/client.ts",
18
+ "./server": "./src/server.ts",
19
+ "./tsconfig.json": "./tsconfig.json"
20
+ },
21
+ "scripts": {
22
+ "check": "oxfmt --check && oxlint --type-aware --type-check",
23
+ "fix": "oxlint --type-aware --type-check --fix && oxfmt --write",
24
+ "test": "WRANGLER_LOG=error; node --test src/*.test.ts"
25
+ },
26
+ "dependencies": {
27
+ "@preact/signals": "2.8.0",
28
+ "lightningcss": "1.31.1",
29
+ "picomatch": "4.0.3",
30
+ "preact": "11.0.0-beta.1",
31
+ "preact-custom-element": "4.6.0",
32
+ "preact-render-to-string": "6.6.5",
33
+ "rolldown": "1.0.0-rc.4",
34
+ "turbo-stream": "3.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@preact/signals-debug": "1.4.1",
38
+ "@types/node": "25.2.3",
39
+ "oxfmt": "0.32.0",
40
+ "oxlint": "1.47.0",
41
+ "oxlint-tsgolint": "0.13.0",
42
+ "playwright": "1.58.2",
43
+ "typescript": "5.9.3",
44
+ "wrangler": "4.65.0"
45
+ },
46
+ "optionalDependencies": {
47
+ "ts-morph": "27.0.2"
48
+ },
49
+ "engines": {
50
+ "node": ">=24.12.0"
51
+ }
52
+ }
package/readme.md ADDED
@@ -0,0 +1,183 @@
1
+ > [!WARNING]
2
+ > Experimental: API is unstable and not production-ready.
3
+
4
+ # Solarflare
5
+
6
+ Cloudflare-optimized streaming SSR/CSR meta-framework built on web platform APIs, whilst retaining the DX of JSX / React|Preact.
7
+
8
+ ## Features
9
+
10
+ - **Streaming SSR** — File-based routing with deferred promise streaming
11
+ - **Web Components** — Hydration via `preact-custom-element`
12
+ - **SPA Navigation** — Navigation API + View Transitions
13
+ - **HMR** — Hot module replacement with scroll restoration
14
+ - **Styles** — Constructable Stylesheets, critical CSS extraction
15
+ - **Performance** — Early hints, route caching, preconnect hints, speculation rules
16
+ - **Cloudflare** — Workers-optimized with edge caching
17
+ - **TypeScript** — Full type safety
18
+
19
+ ## Requirements
20
+
21
+ - [Node.js](https://nodejs.org) ≥v24.12.0
22
+ - Modern browser (Chrome 102+, Edge 102+, Safari 15.4+)
23
+
24
+ ## CLI
25
+
26
+ ```sh
27
+ solarflare [options]
28
+ ```
29
+
30
+ | Option | Description |
31
+ | -------------------- | ------------------------- |
32
+ | `--clean`, `-c` | Clean output before build |
33
+ | `--debug`, `-d` | Enable debugging |
34
+ | `--production`, `-p` | Optimize for production |
35
+ | `--serve`, `-s` | Start dev server with HMR |
36
+ | `--sourcemap` | Generate source maps |
37
+ | `--watch`, `-w` | Watch and rebuild |
38
+
39
+ ## Conventions
40
+
41
+ | Directory | Purpose |
42
+ | ---------- | ------------------------------------------------------- |
43
+ | `./src` | Original (source) human readable code |
44
+ | `./dist` | Compiled (distribution) [client, server] output code |
45
+ | `./public` | Static assets, copied verbatim to dist/client directory |
46
+
47
+ | File | Purpose |
48
+ | -------------- | -------------------------------- |
49
+ | `*.client.tsx` | Client component (web component) |
50
+ | `*.server.tsx` | Server handler (Workers runtime) |
51
+ | `_layout.tsx` | Layout wrapper |
52
+ | `_*` | Private (not routed) |
53
+ | `$param` | Dynamic segment → `:param` |
54
+ | `index.*` | Directory root |
55
+
56
+ | Path | Purpose |
57
+ | ----- | ---------------------------------------- |
58
+ | `/_*` | reserved internal use (e.g. `/_console`) |
59
+
60
+ ## API
61
+
62
+ ### Server Handler
63
+
64
+ ```tsx
65
+ export default async function server(request: Request, params: Record<string, string>) {
66
+ return {
67
+ _status: 200,
68
+ _headers: { "Cache-Control": "max-age=3600" },
69
+ title: "Hello",
70
+ };
71
+ }
72
+ ```
73
+
74
+ Promise-valued props are streamed independently (deferred):
75
+
76
+ ```tsx
77
+ export default async function server() {
78
+ const user = await fetchUser(); // blocking
79
+ const analytics = fetchAnalytics(); // deferred
80
+ const recommendations = fetchRecommendations(); // deferred
81
+ return { user, analytics, recommendations };
82
+ }
83
+ ```
84
+
85
+ ### Client Component
86
+
87
+ ```tsx
88
+ export default function Client({ title }: { title: string }) {
89
+ return <h1>{title}</h1>;
90
+ }
91
+ ```
92
+
93
+ ### Layout
94
+
95
+ ```tsx
96
+ import type { VNode } from "preact";
97
+ import { Body, Head } from "@chr33s/solarflare/server";
98
+
99
+ export default function Layout({ children }: { children: VNode }) {
100
+ return (
101
+ <html>
102
+ <head>
103
+ <Head />
104
+ </html>
105
+ <body>
106
+ {children}
107
+ <Body />
108
+ </body>
109
+ </html>
110
+ );
111
+ }
112
+ ```
113
+
114
+ ### Deferred (Suspense like deferred renderer)
115
+
116
+ ```tsx
117
+ import { Deferred } from "@chr33s/solarflare/client";
118
+
119
+ <Deferred priority="high" fallback={<div>Loading additional content...</div>}>
120
+ ...
121
+ </Deferred>;
122
+ ```
123
+
124
+ ### Configuration using meta tags
125
+
126
+ ```html
127
+ <!-- router -->
128
+ <meta name="sf:base" content="/" />
129
+ <meta name="sf:scroll-behavior" content="auto" />
130
+ <meta name="sf:view-transitions" content="false" />
131
+
132
+ <!-- performance -->
133
+ <meta name="sf:preconnect" content="https://cdn.example.com" />
134
+ <meta name="sf:early-flush" content="true" />
135
+ <meta name="sf:critical-css" content="true" />
136
+ <meta name="sf:cache-max-age" content="300" />
137
+ <meta name="sf:cache-swr" content="3600" />
138
+ <meta name="sf:prefetch" content="/about, /faq, /blog/*" />
139
+ <meta name="sf:prerender" content="/, /landing" />
140
+ <meta name="sf:prefetch-selector" content="a.nav-link" />
141
+ <meta name="sf:speculation-eagerness" content="moderate" />
142
+ ```
143
+
144
+ ### Custom Web Component
145
+
146
+ ```tsx
147
+ import { define } from "@chr33s/solarflare/client";
148
+ export default define(MyComponent, { shadow: true });
149
+ ```
150
+
151
+ ## Environment
152
+
153
+ | File | Purpose |
154
+ | ----------------------- | ------------------------------------------------------------ |
155
+ | `WRANGLER_LOG` | Set logging verbosity for both wrangler & console forwarding |
156
+ | `WRANGLER_SEND_METRICS` | Disable sending anonymous usage data to Cloudflare |
157
+
158
+ ## Examples
159
+
160
+ - [Basic](examples/basic/readme.md) — Layouts, dynamic routes, API, components
161
+ - [Bun](examples/bun/readme.md) — Bun runtime example
162
+ - [Deno](examples/deno/readme.md) — Deno runtime example
163
+ - [Minimal](examples/minimal/readme.md) — Single route
164
+ - [Node](examples/node/readme.md) — Using `srvx` instead of Workers
165
+ - [Shopify App](examples/shopify-app/readme.md) — Shopify app starter
166
+
167
+ ## Development
168
+
169
+ ```sh
170
+ npm install
171
+ npm run dev
172
+ ```
173
+
174
+ ## Codemod
175
+
176
+ ```sh
177
+ npm install --save-optional
178
+ npx solarflare --codemod ./app
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT
package/src/ast.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import ts from "typescript";
4
+ import { parsePath, type ParsedPath, type ModuleKind } from "./paths.ts";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function readCompilerOptions(
9
+ configPath = join(__dirname, "..", "tsconfig.json"),
10
+ sys: ts.ParseConfigHost = ts.sys,
11
+ ) {
12
+ const configFile = ts.readConfigFile(configPath, (path) => sys.readFile(path));
13
+
14
+ if (configFile.error) {
15
+ console.warn("Failed to read tsconfig.json, using defaults");
16
+ return {};
17
+ }
18
+
19
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, sys, dirname(configPath));
20
+
21
+ if (parsed.errors.length > 0) {
22
+ console.warn("Errors parsing tsconfig.json, using defaults");
23
+ return {};
24
+ }
25
+
26
+ return parsed.options;
27
+ }
28
+
29
+ const COMPILER_OPTIONS: ts.CompilerOptions = readCompilerOptions();
30
+
31
+ export function createProgram(files: string[]) {
32
+ return ts.createProgram(files, COMPILER_OPTIONS);
33
+ }
34
+
35
+ export interface ExportInfo {
36
+ type: ts.Type;
37
+ signatures: readonly ts.Signature[];
38
+ typeString: string;
39
+ isFunction: boolean;
40
+ parameters: ParameterInfo[];
41
+ returnType: string | null;
42
+ }
43
+
44
+ export interface ParameterInfo {
45
+ name: string;
46
+ type: string;
47
+ optional: boolean;
48
+ properties: string[];
49
+ }
50
+
51
+ function getFirstCallSignature(type: ts.Type) {
52
+ const signatures = type.getCallSignatures();
53
+ return signatures.length > 0 ? signatures[0] : null;
54
+ }
55
+
56
+ function getSignatureParameterInfo(
57
+ checker: ts.TypeChecker,
58
+ signature: ts.Signature,
59
+ sourceFile: ts.SourceFile,
60
+ ) {
61
+ const parameters: ParameterInfo[] = [];
62
+
63
+ for (const param of signature.getParameters()) {
64
+ const paramType = checker.getTypeOfSymbolAtLocation(param, sourceFile);
65
+ const properties = paramType.getProperties().map((p) => p.getName());
66
+
67
+ parameters.push({
68
+ name: param.getName(),
69
+ type: checker.typeToString(paramType),
70
+ optional: !!(param.flags & ts.SymbolFlags.Optional),
71
+ properties,
72
+ });
73
+ }
74
+
75
+ return {
76
+ parameters,
77
+ returnType: checker.typeToString(signature.getReturnType()),
78
+ };
79
+ }
80
+
81
+ /** Gets detailed information about a module's default export. */
82
+ export function getDefaultExportInfo(checker: ts.TypeChecker, sourceFile: ts.SourceFile) {
83
+ const symbol = checker.getSymbolAtLocation(sourceFile);
84
+ if (!symbol) return null;
85
+
86
+ const exports = checker.getExportsOfModule(symbol);
87
+ const defaultExport = exports.find((e) => e.escapedName === "default");
88
+ if (!defaultExport) return null;
89
+
90
+ const type = checker.getTypeOfSymbolAtLocation(defaultExport, sourceFile);
91
+ const signatures = type.getCallSignatures();
92
+ const typeString = checker.typeToString(type);
93
+ const signature = getFirstCallSignature(type);
94
+ const isFunction = !!signature;
95
+
96
+ const parameters: ParameterInfo[] = [];
97
+ let returnType: string | null = null;
98
+
99
+ if (isFunction && signature) {
100
+ const info = getSignatureParameterInfo(checker, signature, sourceFile);
101
+ parameters.push(...info.parameters);
102
+ returnType = info.returnType;
103
+ }
104
+
105
+ return {
106
+ type,
107
+ signatures,
108
+ typeString,
109
+ isFunction,
110
+ parameters,
111
+ returnType,
112
+ };
113
+ }
114
+
115
+ export interface ValidationResult {
116
+ file: string;
117
+ kind: ModuleKind;
118
+ valid: boolean;
119
+ errors: string[];
120
+ warnings: string[];
121
+ exportInfo: ExportInfo | null;
122
+ }
123
+
124
+ export function validateModule(program: ts.Program, filePath: string, baseDir: string = "./src") {
125
+ const fullPath = join(baseDir, filePath);
126
+ const sourceFile = program.getSourceFile(fullPath);
127
+ const checker = program.getTypeChecker();
128
+ const parsed = parsePath(filePath);
129
+
130
+ const result: ValidationResult = {
131
+ file: filePath,
132
+ kind: parsed.kind,
133
+ valid: true,
134
+ errors: [],
135
+ warnings: [],
136
+ exportInfo: null,
137
+ };
138
+
139
+ if (!sourceFile) {
140
+ result.valid = false;
141
+ result.errors.push(`Source file not found: ${fullPath}`);
142
+ return result;
143
+ }
144
+
145
+ const exportInfo = getDefaultExportInfo(checker, sourceFile);
146
+ result.exportInfo = exportInfo;
147
+
148
+ if (!exportInfo) {
149
+ result.valid = false;
150
+ result.errors.push("Missing default export");
151
+ return result;
152
+ }
153
+
154
+ // Validate based on module kind
155
+ switch (parsed.kind) {
156
+ case "server":
157
+ validateServerModule(result, exportInfo);
158
+ break;
159
+ case "client":
160
+ validateClientModule(result, exportInfo);
161
+ break;
162
+ case "layout":
163
+ validateLayoutModule(result, exportInfo);
164
+ break;
165
+ }
166
+
167
+ return result;
168
+ }
169
+
170
+ function validateServerModule(result: ValidationResult, exportInfo: ExportInfo) {
171
+ if (!exportInfo.isFunction) {
172
+ result.valid = false;
173
+ result.errors.push("Default export must be a function");
174
+ return;
175
+ }
176
+
177
+ if (exportInfo.parameters.length < 1) {
178
+ result.warnings.push("Server loader should accept (request, params?, env?) parameters");
179
+ }
180
+
181
+ // Check first param is Request-like
182
+ const firstParam = exportInfo.parameters[0];
183
+ if (firstParam && !firstParam.type.includes("Request") && firstParam.type !== "any") {
184
+ result.warnings.push(`First parameter should be Request, got ${firstParam.type}`);
185
+ }
186
+ }
187
+
188
+ function validateClientModule(result: ValidationResult, exportInfo: ExportInfo) {
189
+ if (!exportInfo.isFunction) {
190
+ result.valid = false;
191
+ result.errors.push("Default export must be a function component");
192
+ return;
193
+ }
194
+
195
+ // Check return type is JSX-like
196
+ if (
197
+ exportInfo.returnType &&
198
+ !exportInfo.returnType.includes("VNode") &&
199
+ !exportInfo.returnType.includes("Element") &&
200
+ !exportInfo.returnType.includes("JSX") &&
201
+ exportInfo.returnType !== "null" &&
202
+ exportInfo.returnType !== "any"
203
+ ) {
204
+ result.warnings.push(`Component should return JSX, got ${exportInfo.returnType}`);
205
+ }
206
+ }
207
+
208
+ function validateLayoutModule(result: ValidationResult, exportInfo: ExportInfo) {
209
+ if (!exportInfo.isFunction) {
210
+ result.valid = false;
211
+ result.errors.push("Default export must be a function component");
212
+ return;
213
+ }
214
+
215
+ if (exportInfo.parameters.length === 0) {
216
+ result.warnings.push("Layout should accept { children } prop");
217
+ return;
218
+ }
219
+
220
+ // Check first param has 'children' property
221
+ const firstParam = exportInfo.parameters[0];
222
+ if (!firstParam.properties.includes("children")) {
223
+ result.warnings.push('Layout props should include "children"');
224
+ }
225
+ }
226
+
227
+ export interface ModuleEntry {
228
+ path: string;
229
+ parsed: ParsedPath;
230
+ validation: ValidationResult | null;
231
+ }
232
+
233
+ export function getTypeDeclaration(kind: ModuleKind) {
234
+ switch (kind) {
235
+ case "server":
236
+ return "(request: Request, params: Record<string, string>, env: Env) => Response | Promise<Response> | Record<string, unknown> | Promise<Record<string, unknown>>";
237
+ case "client":
238
+ return '(props: any) => import("preact").VNode';
239
+ case "layout":
240
+ return '(props: { children: import("preact").VNode }) => import("preact").VNode';
241
+ case "error":
242
+ return '(props: { error: Error; url?: URL; statusCode?: number; reset?: () => void }) => import("preact").VNode';
243
+ default:
244
+ return "unknown";
245
+ }
246
+ }
247
+
248
+ export function generateTypedModulesFile(entries: ModuleEntry[]) {
249
+ const errors: string[] = [];
250
+
251
+ // Group by kind
252
+ const serverModules = entries.filter((e) => e.parsed.kind === "server");
253
+ const clientModules = entries.filter((e) => e.parsed.kind === "client");
254
+ const layoutModules = entries.filter((e) => e.parsed.kind === "layout");
255
+ const errorModule = entries.find((e) => e.parsed.kind === "error");
256
+
257
+ // Check for validation errors
258
+ for (const entry of entries) {
259
+ if (entry.validation && !entry.validation.valid) {
260
+ for (const error of entry.validation.errors) {
261
+ errors.push(`${entry.path}: ${error}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ // Import paths are relative from dist/ to src/
267
+ const generateEntries = (modules: ModuleEntry[]) =>
268
+ modules
269
+ .map((m) => ` './${m.parsed.normalized}': () => import('../src/${m.parsed.normalized}')`)
270
+ .join(",\n");
271
+
272
+ const errorEntry = errorModule
273
+ ? `() => import('../src/${errorModule.parsed.normalized}')`
274
+ : "undefined";
275
+
276
+ const content = `/**
277
+ * Auto-generated route modules
278
+ * Pre-resolved imports for Cloudflare Workers compatibility
279
+ *
280
+ * Module types validated via AST analysis:
281
+ * - Server modules: ${serverModules.length}
282
+ * - Client modules: ${clientModules.length}
283
+ * - Layout modules: ${layoutModules.length}
284
+ * - Error module: ${errorModule ? "yes" : "no"}
285
+ */
286
+
287
+ type ServerLoader = ${getTypeDeclaration("server")}
288
+ type ClientComponent = ${getTypeDeclaration("client")}
289
+ type LayoutComponent = ${getTypeDeclaration("layout")}
290
+ type ErrorComponent = ${getTypeDeclaration("error")}
291
+
292
+ interface ModuleMap {
293
+ server: Record<string, () => Promise<{ default: ServerLoader }>>
294
+ client: Record<string, () => Promise<{ default: ClientComponent }>>
295
+ layout: Record<string, () => Promise<{ default: LayoutComponent }>>
296
+ error?: () => Promise<{ default: ErrorComponent }>
297
+ }
298
+
299
+ const modules: ModuleMap = {
300
+ server: {
301
+ ${generateEntries(serverModules)}
302
+ },
303
+ client: {
304
+ ${generateEntries(clientModules)}
305
+ },
306
+ layout: {
307
+ ${generateEntries(layoutModules)}
308
+ },
309
+ error: ${errorEntry},
310
+ }
311
+
312
+ export default modules
313
+ `;
314
+
315
+ return { content, errors };
316
+ }