solidstep 0.3.4 → 0.4.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/README.md CHANGED
@@ -1,829 +1,1094 @@
1
- # SolidStep
2
-
3
- Next Solid Step towards a more performant web - A full-stack SolidJS framework for building modern web applications with file-based routing, SSR, and built-in security.
4
-
5
- ## Features
6
-
7
- - 🌟 **Built on SolidJS and Vite** - Leverage the power of SolidJS for reactive and efficient UIs
8
- - 🚀 **File-based Routing** - Automatic routing based on your file structure
9
- - ⚡ **Server-Side Rendering (SSR)** - Fast initial page loads with full SSR support
10
- - 🔄 **Data Loading** - Built-in loaders for efficient data fetching
11
- - 🎨 **Layouts & Groups** - Nested layouts and parallel route groups
12
- - 🛡️ **Security First** - Built-in CSP, CORS, CSRF, and cookie utilities
13
- - 🎯 **Server Actions** - Type-safe server functions with automatic serialization
14
- - ⚙️ **Middleware Support** - Request/response interceptors
15
- - 📦 **Caching** - Built-in page-level caching
16
- - 📝 **TypeScript** - Full TypeScript support out of the box
17
- - 📊 **Built-in Logging** - Configurable Pino logger for logging
18
- - 🌐 **Fetch Utilities** - Type-safe fetch wrappers with timeout and error handling for both client and server
19
-
20
- ## Getting Started
21
-
22
- ### Create a New Project
23
-
24
- ```bash
25
- [npx | yarn dlx | pnpm dlx | bunx] @varlabs/create-solidstep@latest my-app
26
- cd my-app
27
- [npm | yarn | pnpm | bun] install
28
- [npm | yarn | pnpm | bun] run dev
29
- ```
30
-
31
- ### Special Files
32
-
33
- - `page.tsx` - Page component
34
- - `layout.tsx` - Layout wrapper
35
- - `loading.tsx` - Loading state (Streaming - optional)
36
- - `error.tsx` - Error boundary (optional)
37
- - `not-found.tsx` - 404 page (root only - optional)
38
- - `route.ts` - API route handler
39
- - `middleware.ts` - Request middleware
40
-
41
- **A route is defined by either the presence of a `page.tsx` or `route.ts` file in a directory.**
42
-
43
- **Similar to NextJS, routes are not indexed if they have a '_' placed at the beginning of the name**
44
-
45
- ### Configuration
46
-
47
- Configure your app in `app.config.ts`:
48
-
49
- ```tsx
50
- import { defineConfig } from 'solidstep';
51
- import tailwindcss from '@tailwindcss/vite';
52
-
53
- export default defineConfig({
54
- server: {
55
- preset: 'node',
56
- },
57
- plugins: [
58
- {
59
- type: 'client', // or 'server' or 'both' - depends on where you want to use the plugin
60
- plugin: tailwindcss()
61
- }
62
- ],
63
- });
64
- ```
65
-
66
- #### Vite Configuration
67
-
68
- You can customize Vite settings for both client and server builds.
69
-
70
- __When trying to configure absolute path imports__
71
- 1. Add the path alias in tsconfig.json (for TypeScript support):
72
- ```json
73
- {
74
- "compilerOptions": {
75
- "baseUrl": ".",
76
- "paths": {
77
- "@/*": ["./*"]
78
- }
79
- }
80
- }
81
- ```
82
-
83
- 2. Then add the same alias in the Vite config inside `app.config.ts` to ensure it works during build and runtime:
84
- ```tsx
85
- import { defineConfig } from 'solidstep';
86
- import { resolve } from 'node:path';
87
- import { dirname } from 'node:path';
88
- import { fileURLToPath } from 'node:url';
89
-
90
- const __dirname = dirname(fileURLToPath(import.meta.url));
91
-
92
- export default defineConfig({
93
- server: {
94
- preset: 'node',
95
- },
96
- vite: {
97
- resolve: {
98
- alias: {
99
- '@': resolve(__dirname, '.'),
100
- },
101
- },
102
- },
103
- });
104
- ```
105
-
106
- ### Project Structure
107
-
108
- ```
109
- my-app/
110
- ├── app/
111
- ├── page.tsx # Home page (/)
112
- │ ├── layout.tsx # Root layout
113
- │ ├── middleware.ts # Request middleware
114
- │ ├── about/
115
- │ └── page.tsx # About page (/about)
116
- ├── (admin)/
117
- | └── dashboard/
118
- │ | └── page.tsx # Group route (/dashboard)
119
- │ └── blog/
120
- ├── layout.tsx # Blog layout
121
- │ ├── page.tsx # Blog index (/blog)
122
- └── [slug]/
123
- └── page.tsx # Dynamic route (/blog/:slug)
124
- ├── public/
125
- │ └── favicon.ico
126
- ├── app.config.ts
127
- └── package.json
128
- ```
129
-
130
- ## Core Concepts
131
-
132
- ### Layouts
133
-
134
- Wrap multiple pages with shared UI:
135
-
136
- ```tsx
137
- export default function BlogLayout(props: { children: any }) {
138
- return (
139
- <div>
140
- <nav>Blog Navigation</nav>
141
- {props.children()}
142
- </div>
143
- );
144
- }
145
- ```
146
-
147
- ### Pages
148
-
149
- Create a `page.tsx` file in any directory under `app/` to define a route:
150
-
151
- ```tsx
152
- export default function HomePage() {
153
- return <h1>Welcome to SolidStep!</h1>;
154
- }
155
- ```
156
-
157
- **Similar to NextJS, only content returned by a `page` or `route` is sent to the client**
158
-
159
- ### Group Routes
160
- Use parentheses to group routes without affecting the URL:
161
-
162
- ```app/
163
- ├── (admin)/
164
- │ └── dashboard/
165
- └── page.tsx // matches /dashboard
166
- └── (user)/
167
- └── profile/
168
- └── page.tsx // matches /profile
169
- ```
170
-
171
- ### Dynamic Routes
172
-
173
- Use square brackets for dynamic segments:
174
-
175
- ```tsx
176
- // app/blog/[slug]/page.tsx - matches /blog/my-post, /blog/another-post, etc.
177
-
178
- export default function BlogPost(props: { routeParams: { slug: string } }) {
179
- return <h1>Post: {props.routeParams.slug}</h1>;
180
- }
181
- ```
182
-
183
- **Catch-all routes:**
184
- ```tsx
185
- // app/docs/[...path]/page.tsx - matches /docs/a, /docs/a/b, etc.
186
- ```
187
-
188
- **Catch-all routes (Optional):**
189
- ```tsx
190
- // app/docs/[[...path]]/page.tsx - matches /docs, /docs/a, /docs/a/b, etc.
191
- ```
192
-
193
- ### Parallel Routes (Groups)
194
-
195
- Render multiple sections simultaneously:
196
-
197
- ```
198
- app/
199
- ├── layout.tsx
200
- ├── page.tsx
201
- └── @graph1/
202
- └── page.tsx
203
- └── @graph2/
204
- └── page.tsx
205
- ```
206
-
207
- ```tsx
208
- export default function RootLayout(props: {
209
- children: any;
210
- slots: { graph1: any; graph2: any; };
211
- }) {
212
- return (
213
- <main>
214
- {props.children()}
215
- <aside>
216
- <div>{props.slots.graph1()}</div>
217
- <div>{props.slots.graph2()}</div>
218
- </aside>
219
- </main>
220
- );
221
- }
222
- ```
223
-
224
- ### Data Loading
225
-
226
- Use `defineLoader` to fetch data on the server:
227
-
228
- ```tsx
229
- import { defineLoader, type LoaderDataFromFunction } from 'solidstep/utils/loader';
230
-
231
- export const loader = defineLoader(async (request) => {
232
- const posts = await fetchPosts();
233
- return { posts };
234
- });
235
-
236
- type LoaderData = LoaderDataFromFunction<typeof loader>;
237
-
238
- export default function BlogPage(props: { loaderData: LoaderData }) {
239
- return (
240
- <ul>
241
- <For each={props.loaderData.posts}>
242
- {(post) => <li>{post.title}</li>}
243
- </For>
244
- </ul>
245
- );
246
- }
247
- ```
248
-
249
- ### Server Actions
250
-
251
- Create type-safe server functions:
252
-
253
- ```tsx
254
- 'use server';
255
-
256
- export const createPost = async (data: { title: string }) => {
257
- await db.posts.create(data);
258
- return { success: true };
259
- };
260
- ```
261
-
262
- Call from client:
263
-
264
- ```tsx
265
- import { createPost } from './actions';
266
-
267
- function CreatePostForm() {
268
- const handleSubmit = async (e: Event) => {
269
- e.preventDefault();
270
- await createPost({ title: 'My Post' });
271
- };
272
-
273
- return <form onSubmit={handleSubmit}>...</form>;
274
- }
275
- ```
276
-
277
- ### Metadata
278
-
279
- Define metadata for SEO:
280
-
281
- ```tsx
282
- import { meta } from 'solidstep/utils/types';
283
-
284
- // can also be async
285
- export const generateMeta = meta(() => {
286
- return {
287
- title: {
288
- type: 'title',
289
- content: 'My Site',
290
- attributes: {},
291
- },
292
- description: {
293
- type: 'meta',
294
- attributes: {
295
- name: 'description',
296
- content: 'My awesome site',
297
- },
298
- },
299
- // manifest
300
- manifest: {
301
- type: 'link',
302
- attributes: {
303
- rel: 'manifest',
304
- href: '/site.webmanifest',
305
- },
306
- },
307
- // google fonts
308
- 'google-font-link': {
309
- type: 'link',
310
- attributes: {
311
- rel: 'preconnect',
312
- href: 'https://fonts.googleapis.com'
313
- }
314
- },
315
- 'gstatic-font-link': {
316
- type: 'link',
317
- attributes: {
318
- rel: 'preconnect',
319
- href: 'https://fonts.gstatic.com',
320
- crossorigin: ''
321
- }
322
- },
323
- 'inter-font': {
324
- type: 'link',
325
- attributes: {
326
- rel: 'stylesheet',
327
- href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'
328
- }
329
- },
330
- // external js
331
- 'analytics-script': {
332
- type: 'script',
333
- attributes: {
334
- src: 'analytics.js',
335
- defer: true,
336
- }
337
- }
338
- };
339
- });
340
- ```
341
-
342
- ### Middleware
343
-
344
- Intercept and modify requests:
345
-
346
- ```tsx
347
- import { defineMiddleware } from 'vinxi/http';
348
-
349
- export default defineMiddleware({
350
- onRequest: async (request) => {
351
- console.log('Incoming request:', request.url);
352
- // Modify request if needed
353
- return request;
354
- },
355
- });
356
- ```
357
-
358
- ### Page Options
359
-
360
- Configure page-level caching:
361
-
362
- ```tsx
363
- export const options = {
364
- cache: {
365
- ttl: 60000, // Cache for 60 seconds
366
- },
367
- responseHeaders: { // Custom headers for pages
368
- 'X-Custom-Header': 'MyValue',
369
- 'Cache-Control': 'public, max-age=60', // Client-side caching
370
- },
371
- };
372
- ```
373
- - Regarding caching, setting `ttl` to `0` or omitting it will disable caching for that page.
374
- - Setting a positive integer value will cache the page for that duration in milliseconds.
375
- - Invalidation of cached pages can be done using the `invalidateCache` and `revalidatePath` utilities.
376
- - The `responseHeaders` option allows you to set custom HTTP headers for the page response.
377
-
378
- ## API Routes
379
-
380
- Create REST endpoints:
381
- - GET
382
- - POST
383
- - PUT
384
- - DELETE
385
- - PATCH
386
-
387
- ```tsx
388
- export async function GET(request: Request, { params }: any) {
389
- const posts = await fetchPosts();
390
- return new Response(JSON.stringify(posts), {
391
- headers: { 'Content-Type': 'application/json' },
392
- });
393
- }
394
-
395
- export async function POST(request: Request) {
396
- const data = await request.json();
397
- }
398
- ```
399
-
400
- ## Server Assets
401
- Serve static files from the `server-assets/` directory:
402
-
403
- ```my-app/
404
- ├── server-assets/
405
- │ └── secret.txt
406
- ```
407
-
408
- Access via `my-app/server-assets/secret.txt` URL:
409
-
410
- ```ts
411
- const TEMPLATE_PATH = join(process.cwd(), 'server-assets', 'templates', 'template.ejs');
412
- const template = await fs.promises.readFile(TEMPLATE_PATH, 'utf-8');
413
- ```
414
-
415
- ## Utilities
416
-
417
- ### Cache (Server-Side)
418
- - Every page can be cached by setting the `options.cache` property in the page.
419
- - You can also manually invalidate the cache for specific routes.
420
- - Invalidation can be done in two ways:
421
- 1. Using the `invalidateCache` utility to only invalidate paths.
422
- ```tsx
423
- import { invalidateCache } from 'solidstep/utils/cache';
424
-
425
- const action = async () => {
426
- 'use server';
427
-
428
- ...
429
-
430
- // Invalidate cache after data mutation
431
- await invalidateCache('/some-route');
432
-
433
- ...
434
-
435
- return { success: true };
436
- };
437
- ```
438
- 2. Using the `revalidatePath` utility to revalidate specific paths and revalidate the frontend DOM - signaling the server action as a Single Flight Mutation query.
439
- ```tsx
440
- import { revalidatePath } from 'solidstep/utils/cache';
441
-
442
- const action = async () => {
443
- 'use server';
444
-
445
- ...
446
-
447
- // Revalidate path after data mutation
448
- await revalidatePath('/some-route');
449
-
450
- ...
451
-
452
- return { success: true };
453
- };
454
- ```
455
-
456
- ### Cookies
457
- ```tsx
458
- import { getCookie, setCookie } from 'solidstep/utils/cookies';
459
-
460
- export const loader = defineLoader(async () => {
461
- const userData = await getCookie();
462
-
463
- if (!userData) {
464
- return [];
465
- }
466
-
467
- const userId = userData.id;
468
-
469
- const { data, error } = await getDocumentsByUserId(userId);
470
-
471
- if (error || !data) {
472
- return [];
473
- }
474
-
475
- return data as Document[];
476
- });
477
-
478
- const action = async () => {
479
- 'use server';
480
-
481
- await setCookie('session', JSON.stringify({ id: 'user-id' }), { httpOnly: true, secure: true, maxAge: 3600 });
482
-
483
- return { success: true };
484
- };
485
- ```
486
-
487
- ### CORS
488
- ```tsx
489
- import { cors } from 'solidstep/utils/cors';
490
-
491
- const trustedOrigins = ['https://example.com', 'https://another-example.com'];
492
-
493
- const corsMiddleware = cors(trustedOrigins);
494
-
495
- ...
496
-
497
- const corsHeaders = corsMiddleware(origin, event.node.req.method === 'OPTIONS');
498
-
499
- ...
500
- ```
501
-
502
- ### CSP
503
- ```tsx
504
- import { createBasePolicy, serializePolicy, withNonce } from 'solidstep/utils/csp';
505
-
506
- let cspPolicy = createBasePolicy();
507
-
508
- ...
509
-
510
- cspPolicy = withNonce(cspPolicy, nonce);
511
-
512
- ...
513
-
514
- event.response.headers.set('Content-Security-Policy', serializePolicy(cspPolicy));
515
-
516
- ...
517
- ```
518
-
519
- ### CSRF Protection
520
- ```tsx
521
- import { csrf } from 'solidstep/utils/csrf';
522
-
523
- const trustedOrigins = ['https://example.com', 'https://another-example.com'];
524
-
525
- const csrfMiddleware = csrf(trustedOrigins);
526
-
527
- ...
528
-
529
- const csrfResult = csrfMiddleware(
530
- event.node.req.method,
531
- requestUrl,
532
- origin,
533
- event.node.req.headers.referer
534
- );
535
-
536
- if (!csrfResult.success) {
537
- event.node.res.statusCode = 403; // Forbidden
538
- event.node.res.end(csrfResult.message);
539
- return;
540
- }
541
- ```
542
-
543
- ### Redirects
544
- ```tsx
545
- import { redirect } from 'solidstep/utils/redirect';
546
-
547
- export const loader = defineLoader(async () => {
548
- redirect('/login');
549
- });
550
-
551
- // or in client
552
- export function MyComponent() {
553
- const handleClick = () => {
554
- redirect('/dashboard');
555
- };
556
-
557
- return <button onClick={handleClick}>Go to Dashboard</button>;
558
- }
559
- ```
560
-
561
- ### Error Handling
562
- ```tsx
563
- // first define an error collection
564
- import { createErrorFactory } from 'solidstep/utils/error-handler';
565
-
566
- export const createError = createErrorFactory({
567
- 'db-query-error': {
568
- message: 'Something went wrong with the database query, not idea what',
569
- severity: 'high',
570
- action: (error) => {
571
- console.error('Generic DB query error', error);
572
- throw error;
573
- },
574
- },
575
- 'auth-error': {
576
- message: 'User authentication failed',
577
- severity: 'high',
578
- action: (error) => {
579
- console.error('User authentication error', error);
580
- throw error;
581
- },
582
- },
583
- 'service-error': {
584
- message:
585
- 'Some service (external or internal that is interfacing with the app) failed',
586
- severity: 'high',
587
- action: (error) => {
588
- console.error('Service error', error);
589
- throw error;
590
- },
591
- },
592
- });
593
-
594
- // then use it in your loaders, actions or routes
595
- export const loader = defineLoader(async () => {
596
- const data = await tryCatch(fetchDataFromDB());
597
- if (data.error) {
598
- // handle the error using the defined error collection
599
- createError('db-query-error').action();
600
-
601
- // or overwrite the defaults
602
- createError('db-query-error', {
603
- // customize the error
604
- message: data.error.message,
605
- action: (error) => {
606
- // just log it for example
607
- console.error('Custom action for DB error', error);
608
- },
609
- severity: 'critical',
610
- cause: data.error,
611
- metadata: { query: 'SELECT * FROM users' },
612
- }).action();
613
-
614
- // defer the definition and the handling
615
- const error = createError('db-query-error');
616
- // some logic
617
- error.action();
618
-
619
- // or throw the error
620
- const error = createError('db-query-error', {
621
- cause: data.error,
622
- });
623
- throw error;
624
- }
625
- return data.result;
626
- });
627
- ```
628
-
629
- ### Logging
630
-
631
- SolidStep includes a built-in Pino logger that can be configured globally:
632
-
633
- ```tsx
634
- import { defineConfig } from 'solidstep';
635
-
636
- export default defineConfig({
637
- server: {
638
- preset: 'node',
639
- },
640
- logger: {
641
- level: 'info',
642
- transport: {
643
- target: 'pino-pretty', // Use pino-pretty for human-readable logs
644
- options: {
645
- colorize: true
646
- }
647
- }
648
- }
649
- });
650
- ```
651
-
652
- Use the logger in your code:
653
-
654
- ```tsx
655
- import { logger } from 'solidstep/utils/logger';
656
-
657
- export const loader = defineLoader(async () => {
658
- logger.info('Fetching posts');
659
-
660
- try {
661
- const posts = await fetchPosts();
662
- logger.info(`Fetched ${posts.length} posts`);
663
- return { posts };
664
- } catch (error) {
665
- logger.error('Failed to fetch posts', error);
666
- throw error;
667
- }
668
- });
669
- ```
670
-
671
- **Logger Configuration Options:**
672
- - `false` or `undefined` - Disables logging (silent mode)
673
- - `true` - Enables default Pino logger
674
- - `object` - Custom Pino configuration object [Pino Docs](https://getpino.io/#/docs/api?id=options)
675
-
676
- ### Fetch Utilities
677
-
678
- SolidStep provides type-safe fetch wrappers for both client and server with built-in timeout and error handling:
679
-
680
- **Client-side Fetch:**
681
- ```tsx
682
- import fetch from 'solidstep/utils/fetch.client';
683
-
684
- async function fetchPosts() {
685
- const posts = await fetch<Post[]>('/api/posts', {
686
- method: 'GET',
687
- MAX_FETCH_TIME: 5000,
688
- });
689
-
690
- return posts;
691
- }
692
-
693
- ...
694
-
695
- // To get full response including status, headers, etc.
696
- const response = await fetch<Post[], false>(
697
- '/api/posts',
698
- { method: 'GET' },
699
- false
700
- );
701
-
702
- console.log(response.status); // HTTP status code
703
- ```
704
-
705
- **Server-side Fetch:**
706
- ```tsx
707
- import fetch from 'solidstep/utils/fetch.server';
708
-
709
- export const loader = defineLoader(async () => {
710
- const data = await fetch<ApiResponse>('https://api.example.com/data', {
711
- method: 'POST',
712
- body: JSON.stringify({ query: 'test' }),
713
- headers: {
714
- 'Content-Type': 'application/json',
715
- },
716
- MAX_FETCH_TIME: 10000,
717
- });
718
-
719
- return data;
720
- });
721
- ```
722
-
723
- **Features:**
724
- - Automatic timeout handling with AbortController (default: 4000ms)
725
- - Automatic JSON parsing (optional)
726
- - Error handling for HTTP 4xx/5xx responses
727
- - Type-safe responses with TypeScript generics
728
- - Server-side uses undici for better performance
729
-
730
- ### Server-Only Code
731
-
732
- Ensure code only runs on the server and throws an error if accessed on the client:
733
-
734
- ```tsx
735
- import 'solidstep/utils/server-only';
736
-
737
- export const SECRET_KEY = process.env.SECRET_KEY;
738
- export const DATABASE_URL = process.env.DATABASE_URL;
739
-
740
- export async function queryDatabase(query: string) {
741
- }
742
- ```
743
-
744
- **Use case:** Import this at the top of any file that should never be used for the client (e.g., database utilities, API keys, server secrets).
745
-
746
- ```tsx
747
- import 'solidstep/utils/server-only';
748
-
749
- export const db = createDatabaseConnection(process.env.DATABASE_URL);
750
- ```
751
-
752
- If accidentally imported on the client, it will throw:
753
- ```
754
- Error: This module is only available on the server side.
755
- ```
756
-
757
- ## Preloading/prefetching strategies
758
- SolidStep supports various preloading and prefetching strategies to enhance user experience by loading data and resources ahead of time. This can significantly reduce perceived latency and improve navigation speed within your application. Solidstep does not include any preloading/prefetching by default, but you can implement your own strategies using the built-in fetch utilities and SolidJS features.
759
-
760
- Some common strategies include:
761
- - **Link Prefetching**: Use the `<link rel="prefetch">` tag to hint the browser to prefetch resources for links that users are likely to click on next.
762
- - **Using Intersection Observer**: Implement lazy loading and prefetching of data when certain elements come into the viewport.
763
- - **Using [instant.page](https://instant.page/)**: A small library that preloads pages on hover or touchstart events.
764
- ```tsx
765
- export const RootLayout = (props) => {
766
- return (
767
- <body>
768
- ...
769
- <NoHydration>
770
- <script src="//instant.page/5.2.0" type="module" integrity="sha384-jnZyxPjiipYXnSU0ygqeac2q7CVYMbh84q0uHVRRxEtvFPiQYbXWUorga2aqZJ0z"></script>
771
- </NoHydration>
772
- </body>
773
- );
774
- };
775
- ```
776
- - **Using [Foresight.js](https://foresightjs.com/)**: A library that preloads pages based on user behavior and patterns.
777
- ```tsx
778
- import { ForesightManager } from "js.foresight";
779
- import { onMount } from "solid-js";
780
-
781
- export const RootLayout = (props) => {
782
- onMount(() => {
783
- ForesightManager.initialize({
784
- // Configuration options
785
- });
786
- });
787
- return (
788
- <body>
789
- ...
790
- </body>
791
- );
792
- };
793
- ```
794
- - **Custom Preloading Logic**: Write custom logic to preload data for specific routes or components based on user behavior or application state.
795
-
796
- ## Environment Variables
797
- As SolidStep is built using Vite, it follows the same guide as stated in [Vite docs](https://vite.dev/guide/env-and-mode) regarding environment variables.
798
-
799
- ## Future Plans
800
- - Support for dynamic site.webmanifest, robots.txt, sitemap.xml, manifest.json, and llms.txt
801
- - Support loading and error pages for parallel routes
802
- - Support deferring loaders
803
- - Image/font optimizations
804
- - Possible SSG, ISR, and PPR
805
- - Advanced caching strategies
806
- - WebSocket support
807
-
808
- ## Testing
809
-
810
- SolidStep does not include a built-in testing framework. However, we recommend setting up testing using Vitest ecosystem. You can use [Vitest](https://vitest.dev/) for unit and integration tests, and [Playwright](https://playwright.dev/) for end-to-end testing.
811
-
812
- ### Testing Server Actions
813
-
814
- When testing server actions, you can use Vitest to accomplish this. Just test as you would with any other async function.
815
-
816
- When testing pages (e2e tests), you can trigger server actions by simulating user interactions that would call those actions. If needed, you can also intercept network requests to directly test the action endpoints. Use the testing framework's capabilities to intercept the requests and ensure the responses have the expected results. If the server action returns json data, stringify it and add it to the response body as well as setting the content-type header to 'application/json'. If the action has a more complex return type, use seroval to serialize the response before sending it back.
817
-
818
- ## License
819
-
820
- MIT
821
-
822
- ## Links
823
-
824
- - [GitHub](https://github.com/HamzaKV/solidstep)
825
- - [SolidJS Documentation](https://www.solidjs.com/)
826
-
827
- ## Special Mentions
828
- - Inspired by [Remix](https://remix.run/), [Next.js](https://nextjs.org/), and [TanStack](https://tanstack.com/)
829
- - Built with [Vite](https://vitejs.dev/), [SolidJS](https://www.solidjs.com/), [Vinxi](https://github.com/nksaraf/vinxi), [Undici](https://undici.nodejs.org/#/), [Pino](https://getpino.io/#/) and [Seroval](https://github.com/lxsmnsyc/seroval)
1
+ # SolidStep
2
+
3
+ Next Solid Step towards a more performant web - A full-stack SolidJS framework for building modern web applications with file-based routing, SSR, and built-in security.
4
+
5
+ ## Features
6
+
7
+ - 🌟 **Built on SolidJS and Vite** - Leverage the power of SolidJS for reactive and efficient UIs
8
+ - 🚀 **File-based Routing** - Automatic routing based on your file structure
9
+ - ⚡ **Server-Side Rendering (SSR)** - Fast initial page loads with full SSR support
10
+ - 🔄 **Data Loading** - Built-in loaders for efficient data fetching
11
+ - 🎨 **Layouts & Groups** - Nested layouts and parallel route groups
12
+ - 🛡️ **Security First** - Built-in CSP, CORS, CSRF, and cookie utilities
13
+ - 🎯 **Server Actions** - Type-safe server functions with automatic serialization
14
+ - ⚙️ **Middleware Support** - Request/response interceptors
15
+ - 📦 **Caching** - Built-in page-level caching
16
+ - 📝 **TypeScript** - Full TypeScript support out of the box
17
+ - 📊 **Built-in Logging** - Configurable Pino logger for logging
18
+ - 🌐 **Fetch Utilities** - Type-safe fetch wrappers with timeout and error handling for both client and server
19
+
20
+ ## Getting Started
21
+
22
+ ### Create a New Project
23
+
24
+ ```bash
25
+ [npx | yarn dlx | pnpm dlx | bunx] @varlabs/create-solidstep@latest my-app
26
+ cd my-app
27
+ [npm | yarn | pnpm | bun] install
28
+ [npm | yarn | pnpm | bun] run dev
29
+ ```
30
+
31
+ ### Special Files
32
+
33
+ - `page.tsx` - Page component
34
+ - `layout.tsx` - Layout wrapper
35
+ - `loading.tsx` - Loading state (Streaming - optional)
36
+ - `error.tsx` - Error boundary (optional)
37
+ - `not-found.tsx` - 404 page (root only - optional)
38
+ - `route.ts` - API route handler
39
+ - `middleware.ts` - Request middleware
40
+ - `instrumentation.ts` - Server instrumentation hooks (optional)
41
+
42
+ **A route is defined by either the presence of a `page.tsx` or `route.ts` file in a directory.**
43
+
44
+ **Similar to NextJS, routes are not indexed if they have a '_' placed at the beginning of the name**
45
+
46
+ ### Configuration
47
+
48
+ Configure your app in `app.config.ts`:
49
+
50
+ ```tsx
51
+ import { defineConfig } from 'solidstep';
52
+ import tailwindcss from '@tailwindcss/vite';
53
+
54
+ export default defineConfig({
55
+ server: {
56
+ preset: 'node',
57
+ },
58
+ plugins: [
59
+ {
60
+ type: 'client', // or 'server' or 'both' - depends on where you want to use the plugin
61
+ plugin: tailwindcss()
62
+ }
63
+ ],
64
+ });
65
+ ```
66
+
67
+ #### Vite Configuration
68
+
69
+ You can customize Vite settings for both client and server builds.
70
+
71
+ __When trying to configure absolute path imports__
72
+ 1. Add the path alias in tsconfig.json (for TypeScript support):
73
+ ```json
74
+ {
75
+ "compilerOptions": {
76
+ "baseUrl": ".",
77
+ "paths": {
78
+ "@/*": ["./*"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ 2. Then add the same alias in the Vite config inside `app.config.ts` to ensure it works during build and runtime:
85
+ ```tsx
86
+ import { defineConfig } from 'solidstep';
87
+ import { resolve } from 'node:path';
88
+ import { dirname } from 'node:path';
89
+ import { fileURLToPath } from 'node:url';
90
+
91
+ const __dirname = dirname(fileURLToPath(import.meta.url));
92
+
93
+ export default defineConfig({
94
+ server: {
95
+ preset: 'node',
96
+ },
97
+ vite: {
98
+ resolve: {
99
+ alias: {
100
+ '@': resolve(__dirname, '.'),
101
+ },
102
+ },
103
+ },
104
+ });
105
+ ```
106
+
107
+ ### Project Structure
108
+
109
+ ```
110
+ my-app/
111
+ ├── app/
112
+ │ ├── page.tsx # Home page (/)
113
+ │ ├── layout.tsx # Root layout
114
+ │ ├── middleware.ts # Request middleware
115
+ ├── about/
116
+ │ └── page.tsx # About page (/about)
117
+ ├── (admin)/
118
+ │ | └── dashboard/
119
+ | └── page.tsx # Group route (/dashboard)
120
+ └── blog/
121
+ │ ├── layout.tsx # Blog layout
122
+ ├── page.tsx # Blog index (/blog)
123
+ └── [slug]/
124
+ │ └── page.tsx # Dynamic route (/blog/:slug)
125
+ ├── public/
126
+ │ └── favicon.ico
127
+ ├── app.config.ts
128
+ └── package.json
129
+ ```
130
+
131
+ ## Core Concepts
132
+
133
+ ### Layouts
134
+
135
+ Wrap multiple pages with shared UI:
136
+
137
+ ```tsx
138
+ export default function BlogLayout(props: { children: any }) {
139
+ return (
140
+ <div>
141
+ <nav>Blog Navigation</nav>
142
+ {props.children()}
143
+ </div>
144
+ );
145
+ }
146
+ ```
147
+
148
+ ### Pages
149
+
150
+ Create a `page.tsx` file in any directory under `app/` to define a route:
151
+
152
+ ```tsx
153
+ export default function HomePage() {
154
+ return <h1>Welcome to SolidStep!</h1>;
155
+ }
156
+ ```
157
+
158
+ **Similar to NextJS, only content returned by a `page` or `route` is sent to the client**
159
+
160
+ ### Group Routes
161
+ Use parentheses to group routes without affecting the URL:
162
+
163
+ ```app/
164
+ ├── (admin)/
165
+ └── dashboard/
166
+ └── page.tsx // matches /dashboard
167
+ └── (user)/
168
+ └── profile/
169
+ └── page.tsx // matches /profile
170
+ ```
171
+
172
+ ### Dynamic Routes
173
+
174
+ Use square brackets for dynamic segments:
175
+
176
+ ```tsx
177
+ // app/blog/[slug]/page.tsx - matches /blog/my-post, /blog/another-post, etc.
178
+
179
+ export default function BlogPost(props: { routeParams: { slug: string } }) {
180
+ return <h1>Post: {props.routeParams.slug}</h1>;
181
+ }
182
+ ```
183
+
184
+ **Catch-all routes:**
185
+ ```tsx
186
+ // app/docs/[...path]/page.tsx - matches /docs/a, /docs/a/b, etc.
187
+ ```
188
+
189
+ **Catch-all routes (Optional):**
190
+ ```tsx
191
+ // app/docs/[[...path]]/page.tsx - matches /docs, /docs/a, /docs/a/b, etc.
192
+ ```
193
+
194
+ ### Parallel Routes (Groups)
195
+
196
+ Render multiple sections simultaneously:
197
+
198
+ ```
199
+ app/
200
+ ├── layout.tsx
201
+ ├── page.tsx
202
+ └── @graph1/
203
+ └── page.tsx
204
+ └── @graph2/
205
+ └── page.tsx
206
+ ```
207
+
208
+ ```tsx
209
+ export default function RootLayout(props: {
210
+ children: any;
211
+ slots: { graph1: any; graph2: any; };
212
+ }) {
213
+ return (
214
+ <main>
215
+ {props.children()}
216
+ <aside>
217
+ <div>{props.slots.graph1()}</div>
218
+ <div>{props.slots.graph2()}</div>
219
+ </aside>
220
+ </main>
221
+ );
222
+ }
223
+ ```
224
+
225
+ ### Data Loading
226
+
227
+ Use `defineLoader` to fetch data on the server:
228
+
229
+ ```tsx
230
+ import { defineLoader, type LoaderDataFromFunction } from 'solidstep/utils/loader';
231
+
232
+ export const loader = defineLoader(async (request) => {
233
+ const posts = await fetchPosts();
234
+ return { posts };
235
+ });
236
+
237
+ type LoaderData = LoaderDataFromFunction<typeof loader>;
238
+
239
+ export default function BlogPage(props: { loaderData: LoaderData }) {
240
+ return (
241
+ <ul>
242
+ <For each={props.loaderData.posts}>
243
+ {(post) => <li>{post.title}</li>}
244
+ </For>
245
+ </ul>
246
+ );
247
+ }
248
+ ```
249
+
250
+ ### Server Actions
251
+
252
+ Create type-safe server functions:
253
+
254
+ ```tsx
255
+ 'use server';
256
+
257
+ export const createPost = async (data: { title: string }) => {
258
+ await db.posts.create(data);
259
+ return { success: true };
260
+ };
261
+ ```
262
+
263
+ Call from client:
264
+
265
+ ```tsx
266
+ import { createPost } from './actions';
267
+
268
+ function CreatePostForm() {
269
+ const handleSubmit = async (e: Event) => {
270
+ e.preventDefault();
271
+ await createPost({ title: 'My Post' });
272
+ };
273
+
274
+ return <form onSubmit={handleSubmit}>...</form>;
275
+ }
276
+ ```
277
+
278
+ ### Form Actions
279
+
280
+ Use the `<Form>` component to submit forms via server actions — similar to Next.js form handling:
281
+
282
+ ```tsx
283
+ // app/invoices/actions.ts
284
+ 'use server';
285
+
286
+ export async function createInvoice(formData: FormData) {
287
+ const rawFormData = {
288
+ customerId: formData.get('customerId'),
289
+ amount: formData.get('amount'),
290
+ status: formData.get('status'),
291
+ };
292
+ // mutate data, revalidate cache
293
+ }
294
+ ```
295
+
296
+ ```tsx
297
+ // app/invoices/page.tsx
298
+ import { Form } from 'solidstep/form';
299
+ import { createInvoice } from './actions';
300
+
301
+ export default function Page() {
302
+ return (
303
+ <Form action={createInvoice}>
304
+ <input name="customerId" />
305
+ <input name="amount" type="number" />
306
+ <select name="status">
307
+ <option value="pending">Pending</option>
308
+ <option value="paid">Paid</option>
309
+ </select>
310
+ <button type="submit">Create Invoice</button>
311
+ </Form>
312
+ );
313
+ }
314
+ ```
315
+
316
+ **Passing additional arguments with `bind`:**
317
+
318
+ ```tsx
319
+ import { Form } from 'solidstep/form';
320
+ import { updateUser } from './actions';
321
+
322
+ export function UserProfile(props: { userId: string }) {
323
+ const updateUserWithId = updateUser.bind(null, props.userId);
324
+
325
+ return (
326
+ <Form action={updateUserWithId}>
327
+ <input type="text" name="name" />
328
+ <button type="submit">Update User Name</button>
329
+ </Form>
330
+ );
331
+ }
332
+ ```
333
+
334
+ **Form validation with `useActionState`:**
335
+
336
+ ```tsx
337
+ import { useActionState } from 'solidstep/hooks/action-state';
338
+ import { Form } from 'solidstep/form';
339
+ import { signup } from './actions';
340
+
341
+ export function SignupForm() {
342
+ const [state, formAction, pending, error] = useActionState(signup, {
343
+ errors: {} as Record<string, string[]>,
344
+ message: '',
345
+ });
346
+
347
+ return (
348
+ <Form action={formAction}>
349
+ <label for="email">Email</label>
350
+ <input type="email" id="email" name="email" required />
351
+ {state().errors.email && <span>{state().errors.email[0]}</span>}
352
+ <p aria-live="polite">{state().message}</p>
353
+ {error() && <p role="alert" style="color:red">{error()!.message}</p>}
354
+ <button disabled={pending()}>
355
+ {pending() ? 'Signing up...' : 'Sign up'}
356
+ </button>
357
+ </Form>
358
+ );
359
+ }
360
+ ```
361
+
362
+ **Pending state with `useFormStatus`:**
363
+
364
+ ```tsx
365
+ import { useFormStatus } from 'solidstep/hooks/form-status';
366
+
367
+ export function SubmitButton() {
368
+ const { pending } = useFormStatus();
369
+
370
+ return (
371
+ <button disabled={pending()} type="submit">
372
+ {pending() ? 'Submitting...' : 'Submit'}
373
+ </button>
374
+ );
375
+ }
376
+ ```
377
+
378
+ > **Good to know:**
379
+ > - `<Form>` supports progressive enhancement — when JS is disabled, forms submit natively to the server action endpoint.
380
+ > - `useActionState` returns SolidJS accessors: call `state()`, `pending()`, and `error()` to read values. `error()` is `null` until the action throws, and resets to `null` on the next submission.
381
+ > - `useFormStatus` must be used in a component nested inside `<Form>`.
382
+
383
+ ### Metadata
384
+
385
+ Define metadata for SEO:
386
+
387
+ ```tsx
388
+ import { meta } from 'solidstep/utils/types';
389
+
390
+ // can also be async
391
+ export const generateMeta = meta(() => {
392
+ return {
393
+ title: {
394
+ type: 'title',
395
+ content: 'My Site',
396
+ attributes: {},
397
+ },
398
+ description: {
399
+ type: 'meta',
400
+ attributes: {
401
+ name: 'description',
402
+ content: 'My awesome site',
403
+ },
404
+ },
405
+ // manifest
406
+ manifest: {
407
+ type: 'link',
408
+ attributes: {
409
+ rel: 'manifest',
410
+ href: '/site.webmanifest',
411
+ },
412
+ },
413
+ // google fonts
414
+ 'google-font-link': {
415
+ type: 'link',
416
+ attributes: {
417
+ rel: 'preconnect',
418
+ href: 'https://fonts.googleapis.com'
419
+ }
420
+ },
421
+ 'gstatic-font-link': {
422
+ type: 'link',
423
+ attributes: {
424
+ rel: 'preconnect',
425
+ href: 'https://fonts.gstatic.com',
426
+ crossorigin: ''
427
+ }
428
+ },
429
+ 'inter-font': {
430
+ type: 'link',
431
+ attributes: {
432
+ rel: 'stylesheet',
433
+ href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'
434
+ }
435
+ },
436
+ // external js
437
+ 'analytics-script': {
438
+ type: 'script',
439
+ attributes: {
440
+ src: 'analytics.js',
441
+ defer: true,
442
+ }
443
+ }
444
+ };
445
+ });
446
+ ```
447
+
448
+ ### Middleware
449
+
450
+ Intercept and modify requests. SolidStep's `defineMiddleware` composes an ordered array of middleware units into a single handler, so you can keep concerns (auth, CORS, CSRF, logging) in separate, reusable pieces:
451
+
452
+ ```tsx
453
+ // app/middleware.ts
454
+ import { defineMiddleware, type Middleware } from 'solidstep/utils/middleware';
455
+
456
+ const logger: Middleware = {
457
+ onRequest: (event) => {
458
+ console.log('Incoming request:', event.path);
459
+ },
460
+ };
461
+
462
+ const auth: Middleware = {
463
+ onRequest: (event) => {
464
+ if (!event.headers.get('authorization')) {
465
+ // Return a Response to short-circuit — later middleware and the
466
+ // route handler are skipped.
467
+ return new Response('Unauthorized', { status: 401 });
468
+ }
469
+ },
470
+ };
471
+
472
+ export default defineMiddleware([logger, auth]);
473
+ ```
474
+
475
+ - `onRequest` hooks run in array order and stop as soon as one returns a `Response` (or calls `event.respondWith(...)`).
476
+ - `onBeforeResponse` hooks always all run, in array order, and receive the resolved response so it can be inspected or mutated.
477
+
478
+ You can still use Vinxi's own single-object form from `vinxi/http` if you prefer:
479
+
480
+ ```tsx
481
+ import { defineMiddleware } from 'vinxi/http';
482
+
483
+ export default defineMiddleware({
484
+ onRequest: (event) => {
485
+ console.log('Incoming request:', event.path);
486
+ },
487
+ });
488
+ ```
489
+
490
+ ### Instrumentation
491
+
492
+ SolidStep provides a server-side instrumentation API for observability, telemetry, and error tracking. Create an `app/instrumentation.ts` file to hook into the request lifecycle — no configuration required.
493
+
494
+ ```tsx
495
+ // app/instrumentation.ts
496
+ import { defineInstrumentation } from 'solidstep/utils/instrumentation';
497
+
498
+ export default defineInstrumentation({
499
+ async register() {
500
+ // Called once at server startup.
501
+ // Initialize your telemetry SDK here (e.g., OpenTelemetry, Sentry).
502
+ console.log('[instrumentation] Server starting...');
503
+ },
504
+
505
+ async onRequest(request, context) {
506
+ // Called before each request is processed.
507
+ context.metadata.requestId = crypto.randomUUID();
508
+ console.log(`[instrumentation] ${request.method} ${context.pathname} (${context.routeType})`);
509
+ },
510
+
511
+ async onResponseEnd(request, context) {
512
+ // Called after the response is complete.
513
+ console.log(`[instrumentation] ${context.statusCode} ${context.pathname} ${context.duration.toFixed(1)}ms`);
514
+ },
515
+
516
+ async onRequestError(error, request, context) {
517
+ // Called when an unhandled error occurs during request processing.
518
+ console.error(`[instrumentation] Error in ${context.pathname}:`, error.message);
519
+ },
520
+ });
521
+ ```
522
+
523
+ **Available Hooks:**
524
+
525
+ | Hook | When it fires | Arguments |
526
+ |------|--------------|-----------|
527
+ | `register` | Once at server startup | None |
528
+ | `onRequest` | Before each request | `(request: Request, context: RequestContext)` |
529
+ | `onResponseStart` | When response is ready, before streaming | `(request: Request, response: Response, context: ResponseContext)` |
530
+ | `onResponseEnd` | After response stream is complete | `(request: Request, context: ResponseContext)` |
531
+ | `onRequestError` | When an unhandled error occurs | `(error: Error, context: RequestContext)` |
532
+
533
+ **Context Objects:**
534
+
535
+ - `RequestContext` — includes `routePath`, `pathname`, `routeType` (`'page'` | `'api'` | `'server-action'` | `'not-found'`), `params`, `searchParams`, `startTime`, `startTimeEpoch`, and `metadata`
536
+ - `ResponseContext` — extends `RequestContext` with `statusCode` and `duration` (ms)
537
+ - `metadata` — a mutable `Record<string, unknown>` shared across all hooks for the same request. Use it to pass data between hooks (e.g., OpenTelemetry spans, request IDs).
538
+
539
+ **Key Behaviors:**
540
+ - Zero-config: if `app/instrumentation.ts` doesn't exist, the framework silently uses a no-op fallback
541
+ - `register()` completes before the first request is handled
542
+ - Errors in hooks are caught and logged — they never crash user requests
543
+ - Works with page routes, API routes, and server actions
544
+ - Compatible with OpenTelemetry, Sentry, Datadog, and any Node.js telemetry SDK
545
+
546
+ **OpenTelemetry Example:**
547
+
548
+ ```tsx
549
+ // app/instrumentation.ts
550
+ import { defineInstrumentation } from 'solidstep/utils/instrumentation';
551
+ import { NodeSDK } from '@opentelemetry/sdk-node';
552
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
553
+ import { trace } from '@opentelemetry/api';
554
+
555
+ let sdk: NodeSDK;
556
+
557
+ export default defineInstrumentation({
558
+ async register() {
559
+ sdk = new NodeSDK({
560
+ traceExporter: new OTLPTraceExporter(),
561
+ serviceName: 'my-solidstep-app',
562
+ });
563
+ sdk.start();
564
+ },
565
+
566
+ async onRequest(request, context) {
567
+ const tracer = trace.getTracer('solidstep');
568
+ const span = tracer.startSpan(`${request.method} ${context.routePath}`);
569
+ context.metadata.span = span;
570
+ },
571
+
572
+ async onResponseEnd(request, context) {
573
+ const span = context.metadata.span as any;
574
+ span?.setAttribute('http.status_code', context.statusCode);
575
+ span?.end();
576
+ },
577
+
578
+ async onRequestError(error, request, context) {
579
+ const span = context.metadata.span as any;
580
+ span?.recordException(error);
581
+ span?.end();
582
+ },
583
+ });
584
+ ```
585
+
586
+ ### Page Options
587
+
588
+ Configure page-level caching:
589
+
590
+ ```tsx
591
+ export const options = {
592
+ cache: {
593
+ ttl: 60000, // Cache for 60 seconds
594
+ },
595
+ responseHeaders: { // Custom headers for pages
596
+ 'X-Custom-Header': 'MyValue',
597
+ 'Cache-Control': 'public, max-age=60', // Client-side caching
598
+ },
599
+ };
600
+ ```
601
+ - Regarding caching, setting `ttl` to `0` or omitting it will disable caching for that page.
602
+ - Setting a positive integer value will cache the page for that duration in milliseconds.
603
+ - Invalidation of cached pages can be done using the `invalidateCache` and `revalidatePath` utilities.
604
+ - The `responseHeaders` option allows you to set custom HTTP headers for the page response.
605
+
606
+ ## API Routes
607
+
608
+ Create REST endpoints:
609
+ - GET
610
+ - POST
611
+ - PUT
612
+ - DELETE
613
+ - PATCH
614
+
615
+ ```tsx
616
+ export async function GET(request: Request, { params }: any) {
617
+ const posts = await fetchPosts();
618
+ return new Response(JSON.stringify(posts), {
619
+ headers: { 'Content-Type': 'application/json' },
620
+ });
621
+ }
622
+
623
+ export async function POST(request: Request) {
624
+ const data = await request.json();
625
+ }
626
+ ```
627
+
628
+ ## Server Assets
629
+ Serve static files from the `server-assets/` directory:
630
+
631
+ ```my-app/
632
+ ├── server-assets/
633
+ │ └── secret.txt
634
+ ```
635
+
636
+ Access via `my-app/server-assets/secret.txt` URL:
637
+
638
+ ```ts
639
+ const TEMPLATE_PATH = join(process.cwd(), 'server-assets', 'templates', 'template.ejs');
640
+ const template = await fs.promises.readFile(TEMPLATE_PATH, 'utf-8');
641
+ ```
642
+
643
+ ## Utilities
644
+
645
+ ### Cache (Server-Side)
646
+ - Every page can be cached by setting the `options.cache` property in the page.
647
+ - You can also manually invalidate the cache for specific routes.
648
+ - Invalidation can be done in two ways:
649
+ 1. Using the `invalidateCache` utility to only invalidate paths.
650
+ ```tsx
651
+ import { invalidateCache } from 'solidstep/utils/cache';
652
+
653
+ const action = async () => {
654
+ 'use server';
655
+
656
+ ...
657
+
658
+ // Invalidate cache after data mutation
659
+ await invalidateCache('/some-route');
660
+
661
+ ...
662
+
663
+ return { success: true };
664
+ };
665
+ ```
666
+ 2. Using the `revalidatePath` utility to revalidate specific paths and revalidate the frontend DOM - signaling the server action as a Single Flight Mutation query.
667
+ ```tsx
668
+ import { revalidatePath } from 'solidstep/utils/cache';
669
+
670
+ const action = async () => {
671
+ 'use server';
672
+
673
+ ...
674
+
675
+ // Revalidate path after data mutation
676
+ await revalidatePath('/some-route');
677
+
678
+ ...
679
+
680
+ return { success: true };
681
+ };
682
+ ```
683
+
684
+ ### Cookies
685
+ ```tsx
686
+ import { getCookie, setCookie } from 'solidstep/utils/cookies';
687
+
688
+ export const loader = defineLoader(async () => {
689
+ const userData = await getCookie();
690
+
691
+ if (!userData) {
692
+ return [];
693
+ }
694
+
695
+ const userId = userData.id;
696
+
697
+ const { data, error } = await getDocumentsByUserId(userId);
698
+
699
+ if (error || !data) {
700
+ return [];
701
+ }
702
+
703
+ return data as Document[];
704
+ });
705
+
706
+ const action = async () => {
707
+ 'use server';
708
+
709
+ await setCookie('session', JSON.stringify({ id: 'user-id' }), { httpOnly: true, secure: true, maxAge: 3600 });
710
+
711
+ return { success: true };
712
+ };
713
+ ```
714
+
715
+ ### CORS
716
+ ```tsx
717
+ import { cors } from 'solidstep/utils/cors';
718
+
719
+ const trustedOrigins = ['https://example.com', 'https://another-example.com'];
720
+
721
+ const corsMiddleware = cors(trustedOrigins);
722
+
723
+ ...
724
+
725
+ const corsHeaders = corsMiddleware(origin, event.node.req.method === 'OPTIONS');
726
+
727
+ ...
728
+ ```
729
+
730
+ ### CSP
731
+ ```tsx
732
+ import { createBasePolicy, serializePolicy, withNonce } from 'solidstep/utils/csp';
733
+
734
+ let cspPolicy = createBasePolicy();
735
+
736
+ ...
737
+
738
+ cspPolicy = withNonce(cspPolicy, nonce);
739
+
740
+ ...
741
+
742
+ event.response.headers.set('Content-Security-Policy', serializePolicy(cspPolicy));
743
+
744
+ ...
745
+ ```
746
+
747
+ ### CSRF Protection
748
+ ```tsx
749
+ import { csrf } from 'solidstep/utils/csrf';
750
+
751
+ const trustedOrigins = ['https://example.com', 'https://another-example.com'];
752
+
753
+ const csrfMiddleware = csrf(trustedOrigins);
754
+
755
+ ...
756
+
757
+ const csrfResult = csrfMiddleware(
758
+ event.node.req.method,
759
+ requestUrl,
760
+ origin,
761
+ event.node.req.headers.referer
762
+ );
763
+
764
+ if (!csrfResult.success) {
765
+ event.node.res.statusCode = 403; // Forbidden
766
+ event.node.res.end(csrfResult.message);
767
+ return;
768
+ }
769
+ ```
770
+
771
+ ### Redirects
772
+ ```tsx
773
+ import { redirect } from 'solidstep/utils/redirect';
774
+
775
+ export const loader = defineLoader(async () => {
776
+ redirect('/login');
777
+ });
778
+
779
+ // or in client
780
+ export function MyComponent() {
781
+ const handleClick = () => {
782
+ redirect('/dashboard');
783
+ };
784
+
785
+ return <button onClick={handleClick}>Go to Dashboard</button>;
786
+ }
787
+ ```
788
+
789
+ ### Error Handling
790
+ ```tsx
791
+ // first define an error collection
792
+ import { createErrorFactory } from 'solidstep/utils/error-handler';
793
+
794
+ export const createError = createErrorFactory({
795
+ 'db-query-error': {
796
+ message: 'Something went wrong with the database query, not idea what',
797
+ severity: 'high',
798
+ action: (error) => {
799
+ console.error('Generic DB query error', error);
800
+ throw error;
801
+ },
802
+ },
803
+ 'auth-error': {
804
+ message: 'User authentication failed',
805
+ severity: 'high',
806
+ action: (error) => {
807
+ console.error('User authentication error', error);
808
+ throw error;
809
+ },
810
+ },
811
+ 'service-error': {
812
+ message:
813
+ 'Some service (external or internal that is interfacing with the app) failed',
814
+ severity: 'high',
815
+ action: (error) => {
816
+ console.error('Service error', error);
817
+ throw error;
818
+ },
819
+ },
820
+ });
821
+
822
+ // then use it in your loaders, actions or routes
823
+ export const loader = defineLoader(async () => {
824
+ const data = await tryCatch(fetchDataFromDB());
825
+ if (data.error) {
826
+ // handle the error using the defined error collection
827
+ createError('db-query-error').action();
828
+
829
+ // or overwrite the defaults
830
+ createError('db-query-error', {
831
+ // customize the error
832
+ message: data.error.message,
833
+ action: (error) => {
834
+ // just log it for example
835
+ console.error('Custom action for DB error', error);
836
+ },
837
+ severity: 'critical',
838
+ cause: data.error,
839
+ metadata: { query: 'SELECT * FROM users' },
840
+ }).action();
841
+
842
+ // defer the definition and the handling
843
+ const error = createError('db-query-error');
844
+ // some logic
845
+ error.action();
846
+
847
+ // or throw the error
848
+ const error = createError('db-query-error', {
849
+ cause: data.error,
850
+ });
851
+ throw error;
852
+ }
853
+ return data.result;
854
+ });
855
+ ```
856
+
857
+ ### Logging
858
+
859
+ SolidStep includes a built-in Pino logger that can be configured globally:
860
+
861
+ ```tsx
862
+ import { defineConfig } from 'solidstep';
863
+
864
+ export default defineConfig({
865
+ server: {
866
+ preset: 'node',
867
+ },
868
+ logger: {
869
+ level: 'info',
870
+ transport: {
871
+ target: 'pino-pretty', // Use pino-pretty for human-readable logs
872
+ options: {
873
+ colorize: true
874
+ }
875
+ }
876
+ }
877
+ });
878
+ ```
879
+
880
+ Use the logger in your code:
881
+
882
+ ```tsx
883
+ import { logger } from 'solidstep/utils/logger';
884
+
885
+ export const loader = defineLoader(async () => {
886
+ logger.info('Fetching posts');
887
+
888
+ try {
889
+ const posts = await fetchPosts();
890
+ logger.info(`Fetched ${posts.length} posts`);
891
+ return { posts };
892
+ } catch (error) {
893
+ logger.error('Failed to fetch posts', error);
894
+ throw error;
895
+ }
896
+ });
897
+ ```
898
+
899
+ **Logger Configuration Options:**
900
+ - `false` or `undefined` - Disables logging (silent mode)
901
+ - `true` - Enables default Pino logger
902
+ - `object` - Custom Pino configuration object [Pino Docs](https://getpino.io/#/docs/api?id=options)
903
+
904
+ ### Fetch Utilities
905
+
906
+ SolidStep provides type-safe fetch wrappers for both client and server with built-in timeout and error handling:
907
+
908
+ **Client-side Fetch:**
909
+ ```tsx
910
+ import fetch from 'solidstep/utils/fetch.client';
911
+
912
+ async function fetchPosts() {
913
+ const posts = await fetch<Post[]>('/api/posts', {
914
+ method: 'GET',
915
+ MAX_FETCH_TIME: 5000,
916
+ });
917
+
918
+ return posts;
919
+ }
920
+
921
+ ...
922
+
923
+ // To get full response including status, headers, etc.
924
+ const response = await fetch<Post[], false>(
925
+ '/api/posts',
926
+ { method: 'GET' },
927
+ false
928
+ );
929
+
930
+ console.log(response.status); // HTTP status code
931
+ ```
932
+
933
+ **Server-side Fetch:**
934
+ ```tsx
935
+ import fetch from 'solidstep/utils/fetch.server';
936
+
937
+ export const loader = defineLoader(async () => {
938
+ const data = await fetch<ApiResponse>('https://api.example.com/data', {
939
+ method: 'POST',
940
+ body: JSON.stringify({ query: 'test' }),
941
+ headers: {
942
+ 'Content-Type': 'application/json',
943
+ },
944
+ MAX_FETCH_TIME: 10000,
945
+ });
946
+
947
+ return data;
948
+ });
949
+ ```
950
+
951
+ **Features:**
952
+ - Automatic timeout handling with AbortController (default: 4000ms)
953
+ - Automatic JSON parsing (optional)
954
+ - Error handling for HTTP 4xx/5xx responses
955
+ - Type-safe responses with TypeScript generics
956
+ - Server-side uses undici for better performance
957
+
958
+ ### Server-Only Code
959
+
960
+ Ensure code only runs on the server and throws an error if accessed on the client:
961
+
962
+ ```tsx
963
+ import 'solidstep/utils/server-only';
964
+
965
+ export const SECRET_KEY = process.env.SECRET_KEY;
966
+ export const DATABASE_URL = process.env.DATABASE_URL;
967
+
968
+ export async function queryDatabase(query: string) {
969
+ }
970
+ ```
971
+
972
+ **Use case:** Import this at the top of any file that should never be used for the client (e.g., database utilities, API keys, server secrets).
973
+
974
+ ```tsx
975
+ import 'solidstep/utils/server-only';
976
+
977
+ export const db = createDatabaseConnection(process.env.DATABASE_URL);
978
+ ```
979
+
980
+ If accidentally imported on the client, it will throw:
981
+ ```
982
+ Error: This module is only available on the server side.
983
+ ```
984
+
985
+ ## Preloading/prefetching strategies
986
+ SolidStep supports various preloading and prefetching strategies to enhance user experience by loading data and resources ahead of time. This can significantly reduce perceived latency and improve navigation speed within your application. Solidstep does not include any preloading/prefetching by default, but you can implement your own strategies using the built-in fetch utilities and SolidJS features.
987
+
988
+ Some common strategies include:
989
+ - **Link Prefetching**: Use the `<link rel="prefetch">` tag to hint the browser to prefetch resources for links that users are likely to click on next.
990
+ - **Using Intersection Observer**: Implement lazy loading and prefetching of data when certain elements come into the viewport.
991
+ - **Using [instant.page](https://instant.page/)**: A small library that preloads pages on hover or touchstart events.
992
+ ```tsx
993
+ export const RootLayout = (props) => {
994
+ return (
995
+ <body>
996
+ ...
997
+ <NoHydration>
998
+ <script src="//instant.page/5.2.0" type="module" integrity="sha384-jnZyxPjiipYXnSU0ygqeac2q7CVYMbh84q0uHVRRxEtvFPiQYbXWUorga2aqZJ0z"></script>
999
+ </NoHydration>
1000
+ </body>
1001
+ );
1002
+ };
1003
+ ```
1004
+ - **Using [Foresight.js](https://foresightjs.com/)**: A library that preloads pages based on user behavior and patterns.
1005
+ ```tsx
1006
+ import { ForesightManager } from "js.foresight";
1007
+ import { onMount } from "solid-js";
1008
+
1009
+ export const RootLayout = (props) => {
1010
+ onMount(() => {
1011
+ ForesightManager.initialize({
1012
+ // Configuration options
1013
+ });
1014
+ });
1015
+ return (
1016
+ <body>
1017
+ ...
1018
+ </body>
1019
+ );
1020
+ };
1021
+ ```
1022
+ - **Custom Preloading Logic**: Write custom logic to preload data for specific routes or components based on user behavior or application state.
1023
+
1024
+ ## Fonts
1025
+ Install fonts (for example, from [Fontsource](https://github.com/fontsource/fontsource)) and import into `globals.css` example:
1026
+ ```css
1027
+ @import '@fontsource-variable/dm-sans';
1028
+ @import '@fontsource-variable/jetbrains-mono';
1029
+
1030
+ @theme inline {
1031
+ --font-sans: 'DM Sans Variable', sans-serif;
1032
+ --font-mono: 'JetBrains Mono Variable', monospace;
1033
+ /* ... */
1034
+ }
1035
+ ```
1036
+
1037
+ ## Images
1038
+ Use the package called [Unpic](https://unpic.pics/img/solid/) for images. An open source and powerful tool for images on the web.
1039
+ ```bash
1040
+ [npm | yarn | pnpm | bun] install @unpic/solid
1041
+ ```
1042
+
1043
+ ```tsx
1044
+ import type { Component } from "solid-js";
1045
+ import { Image } from "@unpic/solid";
1046
+
1047
+ const MyComponent: Component = () => {
1048
+ return (
1049
+ <Image
1050
+ src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
1051
+ layout="constrained"
1052
+ width={800}
1053
+ height={600}
1054
+ alt="A lovely bath"
1055
+ />
1056
+ );
1057
+ };
1058
+ ```
1059
+
1060
+ ## Environment Variables
1061
+ As SolidStep is built using Vite, it follows the same guide as stated in [Vite docs](https://vite.dev/guide/env-and-mode) regarding environment variables.
1062
+
1063
+ ## Future Plans
1064
+ - Pre-rendering static pages at build time and partial pre-rendering for dynamic pages
1065
+ - Support for dynamic site.webmanifest, robots.txt, sitemap.xml, manifest.json, and llms.txt
1066
+ - Support loading and error pages for parallel routes
1067
+ - Support caching loaders
1068
+ - Support deferring loaders
1069
+ - Possible SSG, ISR, and PPR
1070
+ - Advanced caching strategies
1071
+ - WebSocket support
1072
+
1073
+ ## Testing
1074
+
1075
+ SolidStep does not include a built-in testing framework. However, we recommend setting up testing using Vitest ecosystem. You can use [Vitest](https://vitest.dev/) for unit and integration tests, and [Playwright](https://playwright.dev/) for end-to-end testing.
1076
+
1077
+ ### Testing Server Actions
1078
+
1079
+ When testing server actions, you can use Vitest to accomplish this. Just test as you would with any other async function.
1080
+
1081
+ When testing pages (e2e tests), you can trigger server actions by simulating user interactions that would call those actions. If needed, you can also intercept network requests to directly test the action endpoints. Use the testing framework's capabilities to intercept the requests and ensure the responses have the expected results. If the server action returns json data, stringify it and add it to the response body as well as setting the content-type header to 'application/json'. If the action has a more complex return type, use seroval to serialize the response before sending it back.
1082
+
1083
+ ## License
1084
+
1085
+ MIT
1086
+
1087
+ ## Links
1088
+
1089
+ - [GitHub](https://github.com/HamzaKV/solidstep)
1090
+ - [SolidJS Documentation](https://www.solidjs.com/)
1091
+
1092
+ ## Special Mentions
1093
+ - Inspired by [Remix](https://remix.run/), [Next.js](https://nextjs.org/), and [TanStack](https://tanstack.com/)
1094
+ - Built with [Vite](https://vitejs.dev/), [SolidJS](https://www.solidjs.com/), [Vinxi](https://github.com/nksaraf/vinxi), [Undici](https://undici.nodejs.org/#/), [Pino](https://getpino.io/#/) and [Seroval](https://github.com/lxsmnsyc/seroval)