@jk2908/solas 0.4.3 → 0.5.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/CHANGELOG.md +20 -0
- package/README.md +200 -8
- package/dist/adapters/bun.d.ts +12 -0
- package/dist/adapters/bun.js +39 -0
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +34 -0
- package/dist/index.js +15 -8
- package/dist/internal/browser-router/link.js +2 -0
- package/dist/internal/{prefetcher.d.ts → browser-router/response-cache.d.ts} +12 -5
- package/dist/internal/{prefetcher.js → browser-router/response-cache.js} +11 -4
- package/dist/internal/browser-router/router.d.ts +1 -0
- package/dist/internal/browser-router/router.js +45 -13
- package/dist/internal/browser-router/shared.d.ts +1 -0
- package/dist/internal/browser-router/shared.js +1 -0
- package/dist/internal/browser-router/use-router.d.ts +1 -0
- package/dist/internal/build.js +14 -13
- package/dist/internal/codegen/config.js +3 -2
- package/dist/internal/codegen/environments.d.ts +2 -1
- package/dist/internal/codegen/environments.js +6 -4
- package/dist/internal/codegen/maps.js +7 -5
- package/dist/internal/env/rsc.d.ts +1 -0
- package/dist/internal/env/rsc.js +1 -0
- package/dist/internal/http-router/router.js +8 -7
- package/dist/internal/postbuild.js +9 -8
- package/dist/internal/prerender.js +14 -13
- package/dist/internal/runtimes/bun.d.ts +9 -0
- package/dist/internal/runtimes/bun.js +33 -0
- package/dist/internal/runtimes/node.d.ts +9 -0
- package/dist/internal/runtimes/node.js +31 -0
- package/dist/internal/runtimes/runtime.d.ts +29 -0
- package/dist/internal/runtimes/runtime.js +32 -0
- package/dist/runtimes/bun.d.ts +13 -0
- package/dist/runtimes/bun.js +39 -0
- package/dist/solas.d.ts +4 -1
- package/dist/solas.js +27 -6
- package/dist/types.d.ts +6 -1
- package/dist/utils/compress.js +2 -2
- package/dist/utils/export-reader.js +69 -58
- package/package.json +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-06-06
|
|
4
|
+
|
|
5
|
+
- Added runtime selection via `runtime: 'auto' | 'node' | 'bun'`, with `auto` choosing Bun when available and falling back to Node otherwise.
|
|
6
|
+
- Removed the default Bun runtime requirement from the documented workflow. Standard `vite dev`, `vite build`, and `vite preview` commands now work on the default Node path, while Bun-backed Vite commands remain available when you want to run Solas in Bun.
|
|
7
|
+
- Added a dedicated `@jk2908/solas/$` runtime-safe export for generated/runtime code so preview and production server bundles no longer need to pull through the package root plugin entry.
|
|
8
|
+
- Fixed prerender static param resolution to stop depending on host support for `Promise.try`, so build-time route processing now works correctly under Node-based Vite runs.
|
|
9
|
+
|
|
10
|
+
## 0.4.5 - 2026-05-29
|
|
11
|
+
|
|
12
|
+
- Documented concrete `href` and `router.go(...)` usage in the README, including how explicit `query` values merge with an existing query string and take precedence for duplicate keys.
|
|
13
|
+
- Clarified in the README that `router.go(...)` and `router.refresh()` are awaitable, and that `router.refresh()` always refreshes the current browser location at call time.
|
|
14
|
+
- Fixed browser-router typing so `refresh` is exposed as a promise-returning method, matching the runtime implementation.
|
|
15
|
+
|
|
16
|
+
## 0.4.4 - 2026-05-29
|
|
17
|
+
|
|
18
|
+
- Added `router.refresh()` to the browser router, and made it clear that it clears the current route cache before fetching a fresh RSC payload.
|
|
19
|
+
- Reworked browser-router response caching so prefetched RSC responses can be reused by later navigations without a second fetch.
|
|
20
|
+
- Documented client routing and generated route typing in the README, including `useRouter()`, `router.go()`, `router.prefetch()`, `router.refresh()`, `Link` prefetch behaviour, and typed `Route.Metadata`/`Route.StaticParams` usage.
|
|
21
|
+
- Added a refresh demo route to the basic example app for manual regression testing.
|
|
22
|
+
|
|
3
23
|
## 0.4.3 - 2026-05-27
|
|
4
24
|
|
|
5
25
|
- Updated README docs to show that `dynamic()` must be awaited in request-time deferred `ppr` usage examples.
|
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@ Solas is a minimal React meta-framework powered by Vite, created for experimenti
|
|
|
4
4
|
|
|
5
5
|
Solas is experimental and currently has no automated test suite, so expect rough edges.
|
|
6
6
|
|
|
7
|
-
Solas currently requires Bun 1.2+ on your `PATH`. You can still manage dependencies with `npm`, `pnpm`, or `yarn`, but the Solas CLI and Vite plugin runtime use Bun APIs directly.
|
|
8
|
-
|
|
9
7
|
## Install
|
|
10
8
|
|
|
11
9
|
```sh
|
|
@@ -75,10 +73,202 @@ If a route has both `+page.tsx` and `+endpoint.ts`, Solas selects the GET handle
|
|
|
75
73
|
|
|
76
74
|
Non-GET methods (`POST`, `PUT`, `PATCH`, `DELETE`) always run `+endpoint.ts`.
|
|
77
75
|
|
|
76
|
+
## Client Routing
|
|
77
|
+
|
|
78
|
+
Import client navigation helpers from `@jk2908/solas/router`.
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { Link, useRouter } from '@jk2908/solas/router'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Solas generates route types for your app. `Link` and `router.go(...)` use those generated route types for autocomplete and type checking:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
<Link href="/posts" />
|
|
88
|
+
<Link href="/p/:id" params={{ id: 'post-1' }} />
|
|
89
|
+
|
|
90
|
+
await router.go('/posts')
|
|
91
|
+
await router.go('/p/:id', { params: { id: 'post-1' } })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That gives you:
|
|
95
|
+
|
|
96
|
+
- autocomplete for known route paths
|
|
97
|
+
- required params for dynamic routes like `/p/:id`
|
|
98
|
+
- rejected params for static routes that do not accept them
|
|
99
|
+
- typed query and navigation options on `router.go(...)`
|
|
100
|
+
|
|
101
|
+
If you already have a concrete path, you can pass that directly instead of using route params:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
;<Link href={`/p/${post.id}`} />
|
|
105
|
+
|
|
106
|
+
await router.go(`/p/${post.id}`)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
In that form the path is already resolved, so `params` are not used. Typed `Link` and `router.go(...)` only allow `params` when you pass a route pattern like `/p/:id`.
|
|
110
|
+
|
|
111
|
+
If the target already contains a query string and you also pass `query`, Solas merges them. Existing query entries are kept unless you override the same key in `query`, and explicit `query` values win:
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
<Link href={`/p/${post.id}?tab=summary`} query={{ draft: true, tab: 'full' }} />
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
That resolves to `/p/${post.id}?tab=full&draft=true`.
|
|
118
|
+
|
|
119
|
+
Use `Link` for same-origin app navigation. Prefetching is opt-in:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<Link href="/posts">Posts</Link>
|
|
123
|
+
<Link href="/posts" prefetch="intent">Prefetch on focus or touch</Link>
|
|
124
|
+
<Link href="/posts" prefetch="hover">Prefetch on hover</Link>
|
|
125
|
+
<Link href="/p/:id" params={{ id: 'post-1' }}>Typed params</Link>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`prefetch="none"` is the default. Solas does not automatically prefetch routes unless you opt in with `Link` or call `router.prefetch(...)` yourself.
|
|
129
|
+
|
|
130
|
+
Use `useRouter()` inside client components for programmatic navigation, prefetching, and refreshing the current route:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
'use client'
|
|
134
|
+
|
|
135
|
+
import { useRouter } from '@jk2908/solas/router'
|
|
136
|
+
|
|
137
|
+
export function Controls() {
|
|
138
|
+
const router = useRouter()
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
<button type="button" onClick={() => router.go('/posts')}>
|
|
143
|
+
Go to posts
|
|
144
|
+
</button>
|
|
145
|
+
|
|
146
|
+
<button type="button" onMouseEnter={() => router.prefetch('/posts')}>
|
|
147
|
+
Prefetch posts
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
<button type="button" onClick={() => router.refresh()}>
|
|
151
|
+
Refresh current route
|
|
152
|
+
</button>
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`router.go(...)` and `router.refresh()` both return promises, so you can `await` either of them when you need to sequence work after navigation completes:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
const finalPath = await router.go('/posts')
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
await router.refresh()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`router.refresh()` always refreshes the current browser location at the moment you call it. If you call it after `await router.go('/posts')`, it refreshes `/posts` (or the final redirected path).
|
|
169
|
+
|
|
170
|
+
`router.go(...)` accepts route params and query values using the same typed route rules as `Link`:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
await router.go('/p/:id', {
|
|
174
|
+
params: { id: 'post-2' },
|
|
175
|
+
query: { draft: true },
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Those same generated route types can also be reused outside navigation helpers when you want route params to stay typed in page exports:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
import type { Route, Solas } from '@jk2908/solas'
|
|
183
|
+
|
|
184
|
+
export const metadata: Route.Metadata<Solas.Routes['/writing/:slug']> = ({ params }) => {
|
|
185
|
+
const post = allPosts?.find(p => p.__mdsrc.slug === params?.slug)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
title: post?.title ?? 'Post not found',
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const params: Route.StaticParams<Solas.Routes['/writing/:slug']> = () =>
|
|
193
|
+
allPosts?.map(p => ({ slug: p.__mdsrc.slug })) ?? []
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
That keeps your route params aligned across links, imperative navigation, metadata, and static params.
|
|
197
|
+
|
|
198
|
+
`router.refresh()` clears the cached RSC response for the current path and fetches a fresh payload, so it is most useful for routes that render request-time data. `router.isNavigating` exposes pending client-side navigation state.
|
|
199
|
+
|
|
78
200
|
## Config
|
|
79
201
|
|
|
80
202
|
All Solas options are passed to `solas()` inside `defineConfig`.
|
|
81
203
|
|
|
204
|
+
## Runtime
|
|
205
|
+
|
|
206
|
+
Solas uses a runtime for filesystem access, mime lookup, and hashing.
|
|
207
|
+
|
|
208
|
+
This is not a deployment or packaging adapter. Platform adapters are not available yet.
|
|
209
|
+
|
|
210
|
+
Use the `runtime` config key to select the runtime used by Solas server code.
|
|
211
|
+
|
|
212
|
+
Supported values are `'auto'`, `'node'`, and `'bun'`.
|
|
213
|
+
|
|
214
|
+
`solas()` defaults to `runtime: 'auto'`. In a Bun process, that selects Bun. Otherwise it falls back to Node.
|
|
215
|
+
|
|
216
|
+
If you already run Vite through Bun, `runtime: 'auto'` is usually enough and Solas will detect Bun automatically.
|
|
217
|
+
|
|
218
|
+
### Node runtime
|
|
219
|
+
|
|
220
|
+
If you want to pin Node explicitly, set `runtime: 'node'`:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { defineConfig } from 'vite'
|
|
224
|
+
|
|
225
|
+
import solas from '@jk2908/solas'
|
|
226
|
+
import react from '@vitejs/plugin-react'
|
|
227
|
+
|
|
228
|
+
export default defineConfig({
|
|
229
|
+
plugins: [solas({ runtime: 'node' }), react()],
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Use the normal Vite commands with the Node runtime:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"scripts": {
|
|
238
|
+
"dev": "vite dev",
|
|
239
|
+
"build": "vite build",
|
|
240
|
+
"preview": "vite preview"
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Bun runtime
|
|
246
|
+
|
|
247
|
+
If you want Solas runtime code to execute in Bun, set `runtime: 'bun'`:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { defineConfig } from 'vite'
|
|
251
|
+
|
|
252
|
+
import solas from '@jk2908/solas'
|
|
253
|
+
import react from '@vitejs/plugin-react'
|
|
254
|
+
|
|
255
|
+
export default defineConfig({
|
|
256
|
+
plugins: [solas({ runtime: 'bun' }), react()],
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
When you use the Bun runtime, run Vite through Bun so the server/runtime code executes in a Bun process:
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"scripts": {
|
|
265
|
+
"dev": "bunx --bun vite dev",
|
|
266
|
+
"build": "bunx --bun vite build",
|
|
267
|
+
"preview": "bunx --bun vite preview"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
82
272
|
### `url`
|
|
83
273
|
|
|
84
274
|
`url` is optional. If you set it, Solas treats it as the public origin for your app.
|
|
@@ -398,18 +588,20 @@ Add scripts to your app:
|
|
|
398
588
|
```json
|
|
399
589
|
{
|
|
400
590
|
"scripts": {
|
|
401
|
-
"dev": "
|
|
402
|
-
"build": "
|
|
403
|
-
"preview": "
|
|
591
|
+
"dev": "vite dev",
|
|
592
|
+
"build": "vite build",
|
|
593
|
+
"preview": "vite preview"
|
|
404
594
|
}
|
|
405
595
|
}
|
|
406
596
|
```
|
|
407
597
|
|
|
598
|
+
These defaults assume either `runtime: 'node'` or `runtime: 'auto'` while running Vite under Node. If you set `runtime: 'bun'`, or let `runtime: 'auto'` resolve to Bun by running Vite through Bun, use the Bun-backed commands shown in the runtime section above.
|
|
599
|
+
|
|
408
600
|
## Commands
|
|
409
601
|
|
|
410
|
-
- `
|
|
411
|
-
- `
|
|
412
|
-
- `
|
|
602
|
+
- `vite dev` starts the development server.
|
|
603
|
+
- `vite build` creates a production build. Solas finalizes that build by prerendering configured routes, writing the runtime manifest, generating `sitemap.xml` when enabled, and precompressing output when enabled.
|
|
604
|
+
- `vite preview` serves the built app for local verification.
|
|
413
605
|
|
|
414
606
|
## Security
|
|
415
607
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { RuntimeBase } from '../internal/runtimes/runtime.js';
|
|
2
|
+
export declare class RuntimeBun extends RuntimeBase {
|
|
3
|
+
readonly name: "bun";
|
|
4
|
+
readonly module: string;
|
|
5
|
+
exists(filePath: string): Promise<boolean>;
|
|
6
|
+
readText(filePath: string): Promise<string>;
|
|
7
|
+
readBuffer(filePath: string): Promise<ArrayBuffer>;
|
|
8
|
+
mimeType(filePath: string): string;
|
|
9
|
+
write(filePath: string, content: string | NodeJS.ArrayBufferView): Promise<void>;
|
|
10
|
+
hash(value: string): string;
|
|
11
|
+
}
|
|
12
|
+
export default function bunAdapter(): RuntimeBun;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { RuntimeBase } from '../internal/runtimes/runtime.js';
|
|
2
|
+
import { Solas } from '../solas.js';
|
|
3
|
+
export class RuntimeBun extends RuntimeBase {
|
|
4
|
+
name = 'bun';
|
|
5
|
+
module = `${Solas.Config.PKG_NAME}/adapters/bun`;
|
|
6
|
+
async exists(filePath) {
|
|
7
|
+
return Bun.file(filePath).exists();
|
|
8
|
+
}
|
|
9
|
+
readText(filePath) {
|
|
10
|
+
return Bun.file(filePath).text();
|
|
11
|
+
}
|
|
12
|
+
readBuffer(filePath) {
|
|
13
|
+
return Bun.file(filePath).arrayBuffer();
|
|
14
|
+
}
|
|
15
|
+
mimeType(filePath) {
|
|
16
|
+
return Bun.file(filePath).type || 'application/octet-stream';
|
|
17
|
+
}
|
|
18
|
+
async write(filePath, content) {
|
|
19
|
+
// normalise wider arraybuffer views into a shape Bun.write accepts directly
|
|
20
|
+
await Bun.write(filePath, typeof content === 'string'
|
|
21
|
+
? content
|
|
22
|
+
: content instanceof Uint8Array
|
|
23
|
+
? content
|
|
24
|
+
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength));
|
|
25
|
+
}
|
|
26
|
+
hash(value) {
|
|
27
|
+
const hash = Bun.hash(value);
|
|
28
|
+
// Bun.hash returns an integer-like value, so keep it in BigInt space and avoid
|
|
29
|
+
// precision loss. Clamp it to an unsigned 64-bit value before hex
|
|
30
|
+
// formatting. Pad to 16 chars to match the Node adapter
|
|
31
|
+
// output shape
|
|
32
|
+
return BigInt.asUintN(64, typeof hash === 'bigint' ? hash : BigInt(hash))
|
|
33
|
+
.toString(16)
|
|
34
|
+
.padStart(16, '0');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export default function bunAdapter() {
|
|
38
|
+
return new RuntimeBun();
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RuntimeBase } from '../internal/runtimes/runtime.js';
|
|
2
|
+
export declare class RuntimeNode extends RuntimeBase {
|
|
3
|
+
readonly name: "node";
|
|
4
|
+
readonly module: string;
|
|
5
|
+
exists(filePath: string): Promise<boolean>;
|
|
6
|
+
readText(filePath: string): Promise<string>;
|
|
7
|
+
readBuffer(filePath: string): Promise<ArrayBuffer>;
|
|
8
|
+
mimeType(filePath: string): string;
|
|
9
|
+
write(filePath: string, content: string | NodeJS.ArrayBufferView): Promise<void>;
|
|
10
|
+
hash(value: string): string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { lookup } from 'mime-types';
|
|
4
|
+
import { RuntimeBase } from '../internal/runtimes/runtime.js';
|
|
5
|
+
import { Solas } from '../solas.js';
|
|
6
|
+
export class RuntimeNode extends RuntimeBase {
|
|
7
|
+
name = 'node';
|
|
8
|
+
module = `${Solas.Config.PKG_NAME}/adapters/node`;
|
|
9
|
+
async exists(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
readText(filePath) {
|
|
19
|
+
return fs.readFile(filePath, 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
async readBuffer(filePath) {
|
|
22
|
+
const buffer = await fs.readFile(filePath);
|
|
23
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
24
|
+
}
|
|
25
|
+
mimeType(filePath) {
|
|
26
|
+
return lookup(filePath) || 'application/octet-stream';
|
|
27
|
+
}
|
|
28
|
+
async write(filePath, content) {
|
|
29
|
+
await fs.writeFile(filePath, content);
|
|
30
|
+
}
|
|
31
|
+
hash(value) {
|
|
32
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,19 +14,26 @@ import { writeMaps } from './internal/codegen/maps.js';
|
|
|
14
14
|
import { writeTypes } from './internal/codegen/types.js';
|
|
15
15
|
import { postbuild } from './internal/postbuild.js';
|
|
16
16
|
import { collect as collectPublicFiles } from './internal/public-files.js';
|
|
17
|
+
import { Runtime } from './internal/runtimes/runtime.js';
|
|
17
18
|
import { Solas } from './solas.js';
|
|
18
19
|
const DEFAULT_CONFIG = {
|
|
20
|
+
runtime: 'auto',
|
|
19
21
|
precompress: false,
|
|
20
22
|
prerender: false,
|
|
21
23
|
trustedOrigins: [],
|
|
22
24
|
trailingSlash: 'never',
|
|
23
25
|
};
|
|
24
26
|
function solas(c) {
|
|
25
|
-
const
|
|
26
|
-
...DEFAULT_CONFIG,
|
|
27
|
+
const validatedConfig = Solas.Config.validate({
|
|
27
28
|
...c,
|
|
28
29
|
url: c?.url ?? process.env.VITE_APP_URL?.toString(),
|
|
29
30
|
});
|
|
31
|
+
const config = {
|
|
32
|
+
...DEFAULT_CONFIG,
|
|
33
|
+
...validatedConfig,
|
|
34
|
+
runtime: validatedConfig.runtime ?? DEFAULT_CONFIG.runtime,
|
|
35
|
+
};
|
|
36
|
+
Runtime.runtime = Solas.Runtime.create(config.runtime);
|
|
30
37
|
if (config.logger?.level)
|
|
31
38
|
Logger.defaultLevel = config.logger.level;
|
|
32
39
|
const logger = new Logger();
|
|
@@ -43,11 +50,11 @@ function solas(c) {
|
|
|
43
50
|
const cached = fileCache.get(filePath);
|
|
44
51
|
if (cached === content) {
|
|
45
52
|
// if content is unchanged and file exists, skip write
|
|
46
|
-
if (await
|
|
53
|
+
if (await Runtime.exists(filePath))
|
|
47
54
|
return null;
|
|
48
55
|
// else, file is missing but cached content is the same as
|
|
49
56
|
// last time we saw it, write it
|
|
50
|
-
await
|
|
57
|
+
await Runtime.write(filePath, content);
|
|
51
58
|
fileCache.set(filePath, content);
|
|
52
59
|
return path.relative(process.cwd(), filePath);
|
|
53
60
|
}
|
|
@@ -57,7 +64,7 @@ function solas(c) {
|
|
|
57
64
|
if (curr === content)
|
|
58
65
|
return null;
|
|
59
66
|
try {
|
|
60
|
-
await
|
|
67
|
+
await Runtime.write(filePath, content);
|
|
61
68
|
fileCache.set(filePath, content);
|
|
62
69
|
return path.relative(process.cwd(), filePath);
|
|
63
70
|
}
|
|
@@ -70,7 +77,7 @@ function solas(c) {
|
|
|
70
77
|
// file doesn't exist, write it
|
|
71
78
|
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
72
79
|
try {
|
|
73
|
-
await
|
|
80
|
+
await Runtime.write(filePath, content);
|
|
74
81
|
fileCache.set(filePath, content);
|
|
75
82
|
return path.relative(process.cwd(), filePath);
|
|
76
83
|
}
|
|
@@ -100,7 +107,7 @@ function solas(c) {
|
|
|
100
107
|
['manifest.ts', writeManifest(manifest)],
|
|
101
108
|
['maps.ts', writeMaps(imports, modules)],
|
|
102
109
|
[`${Solas.Config.SLUG}.d.ts`, writeTypes(manifest)],
|
|
103
|
-
[Solas.Config.ENTRY_RSC, writeRSCEntry()],
|
|
110
|
+
[Solas.Config.ENTRY_RSC, writeRSCEntry(config)],
|
|
104
111
|
[Solas.Config.ENTRY_SSR, writeSSREntry()],
|
|
105
112
|
[Solas.Config.ENTRY_BROWSER, writeBrowserEntry()],
|
|
106
113
|
];
|
|
@@ -299,7 +306,7 @@ function solas(c) {
|
|
|
299
306
|
}
|
|
300
307
|
// write build manifest
|
|
301
308
|
const generatedDir = path.join(process.cwd(), Solas.Config.GENERATED_DIR);
|
|
302
|
-
await
|
|
309
|
+
await Runtime.write(path.join(generatedDir, 'build.json'), JSON.stringify({
|
|
303
310
|
base: resolvedViteConfig?.base ?? '/',
|
|
304
311
|
publicFiles: await collectPublicFiles(resolvedViteConfig?.publicDir),
|
|
305
312
|
prerenderRoutes: Array.from(buildContext.prerenderRoutes),
|
|
@@ -16,6 +16,8 @@ function guard(path, prefetcher) {
|
|
|
16
16
|
export function Link({ children, href, params, prefetch = 'none', query, ...rest }) {
|
|
17
17
|
const { go, prefetch: prefetcher } = useRouter();
|
|
18
18
|
const timer = useRef(null);
|
|
19
|
+
// track whether the link is meant to be handled by the router, to avoid
|
|
20
|
+
// unnecessary prefetching and event handling for external links
|
|
19
21
|
const handled = useRef(false);
|
|
20
22
|
const target = BrowserRouter.toTarget(href, params, query);
|
|
21
23
|
// clear any pending hover-prefetch timer on unmount
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
export declare namespace
|
|
1
|
+
export declare namespace ResponseCache {
|
|
2
2
|
type Entry = {
|
|
3
3
|
promise: Promise<Response>;
|
|
4
4
|
timeoutId: ReturnType<typeof setTimeout>;
|
|
5
5
|
};
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A simple in-memory cache for RSC response promises used by the BrowserRouter.
|
|
9
|
+
* It lets a later navigation reuse a prefetched response for the same path,
|
|
10
|
+
* and helps avoid issuing a second fetch when navigation follows shortly
|
|
11
|
+
* after prefetch. Entries are stored by normalised path with TTL and
|
|
12
|
+
* max size eviction
|
|
13
|
+
*/
|
|
14
|
+
export declare class ResponseCache {
|
|
8
15
|
#private;
|
|
9
16
|
ttl: number;
|
|
10
17
|
maxSize: number;
|
|
@@ -16,7 +23,7 @@ export declare class Prefetcher {
|
|
|
16
23
|
* Converts a url path to a cache key by normalising it
|
|
17
24
|
* against a base url
|
|
18
25
|
*/
|
|
19
|
-
static
|
|
26
|
+
static toCacheKey(path: string, base: string): string | null;
|
|
20
27
|
/**
|
|
21
28
|
* Evicts the oldest entry from the cache
|
|
22
29
|
*/
|
|
@@ -26,8 +33,8 @@ export declare class Prefetcher {
|
|
|
26
33
|
*/
|
|
27
34
|
has(path: string): boolean;
|
|
28
35
|
/**
|
|
29
|
-
* Retrieves a fresh response promise for the given path if it exists
|
|
30
|
-
*
|
|
36
|
+
* Retrieves a fresh response promise for the given path if it exists by
|
|
37
|
+
* cloning the cached response so each consumer gets an unread stream
|
|
31
38
|
*/
|
|
32
39
|
get(path: string): Promise<Response> | undefined;
|
|
33
40
|
/**
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A simple in-memory cache for RSC response promises used by the BrowserRouter.
|
|
3
|
+
* It lets a later navigation reuse a prefetched response for the same path,
|
|
4
|
+
* and helps avoid issuing a second fetch when navigation follows shortly
|
|
5
|
+
* after prefetch. Entries are stored by normalised path with TTL and
|
|
6
|
+
* max size eviction
|
|
7
|
+
*/
|
|
8
|
+
export class ResponseCache {
|
|
2
9
|
#cache = new Map();
|
|
3
10
|
ttl = 60_000;
|
|
4
11
|
maxSize = 32;
|
|
@@ -10,7 +17,7 @@ export class Prefetcher {
|
|
|
10
17
|
* Converts a url path to a cache key by normalising it
|
|
11
18
|
* against a base url
|
|
12
19
|
*/
|
|
13
|
-
static
|
|
20
|
+
static toCacheKey(path, base) {
|
|
14
21
|
try {
|
|
15
22
|
const url = new URL(path, base);
|
|
16
23
|
// hash is client-only and never sent to the server, so exclude it
|
|
@@ -40,8 +47,8 @@ export class Prefetcher {
|
|
|
40
47
|
return this.#cache.has(path);
|
|
41
48
|
}
|
|
42
49
|
/**
|
|
43
|
-
* Retrieves a fresh response promise for the given path if it exists
|
|
44
|
-
*
|
|
50
|
+
* Retrieves a fresh response promise for the given path if it exists by
|
|
51
|
+
* cloning the cached response so each consumer gets an unread stream
|
|
45
52
|
*/
|
|
46
53
|
get(path) {
|
|
47
54
|
const promise = this.#cache.get(path)?.promise;
|
|
@@ -4,6 +4,7 @@ export { BrowserRouter } from './shared.js';
|
|
|
4
4
|
export declare const BrowserRouterContext: import("react").Context<{
|
|
5
5
|
go: BrowserRouter.Go;
|
|
6
6
|
prefetch: (path: string) => void;
|
|
7
|
+
refresh: BrowserRouter.Refresh;
|
|
7
8
|
isNavigating: boolean;
|
|
8
9
|
url: {
|
|
9
10
|
pathname?: string | undefined;
|
|
@@ -4,12 +4,13 @@ import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
|
4
4
|
import { createFromFetch } from '@vitejs/plugin-rsc/browser';
|
|
5
5
|
import { Logger } from '../../utils/logger.js';
|
|
6
6
|
import { Solas } from '../../solas.js';
|
|
7
|
-
import {
|
|
7
|
+
import { ResponseCache } from './response-cache.js';
|
|
8
8
|
import { BrowserRouter } from './shared.js';
|
|
9
9
|
export { BrowserRouter } from './shared.js';
|
|
10
10
|
export const BrowserRouterContext = createContext({
|
|
11
11
|
go: async () => '',
|
|
12
12
|
prefetch: () => { },
|
|
13
|
+
refresh: async () => '',
|
|
13
14
|
isNavigating: false,
|
|
14
15
|
url: {},
|
|
15
16
|
});
|
|
@@ -17,10 +18,18 @@ const DEFAULT_GO_CONFIG = {
|
|
|
17
18
|
replace: false,
|
|
18
19
|
};
|
|
19
20
|
const logger = new Logger();
|
|
20
|
-
const
|
|
21
|
+
const responseCache = new ResponseCache();
|
|
21
22
|
export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
|
|
22
23
|
const id = useRef(0);
|
|
23
24
|
const controller = useRef(null);
|
|
25
|
+
/**
|
|
26
|
+
* Navigates to a given path
|
|
27
|
+
*
|
|
28
|
+
* @param to - the target path to navigate to, which can be a route pattern with params or an external URL
|
|
29
|
+
* @param opts - options for navigation, including whether to replace the current history entry and pass query
|
|
30
|
+
* and route params
|
|
31
|
+
* @returns the final path navigated to after any redirects, or the original path if navigation failed
|
|
32
|
+
*/
|
|
24
33
|
const go = useCallback(async (to, opts = {}) => {
|
|
25
34
|
id.current += 1;
|
|
26
35
|
const navigationId = id.current;
|
|
@@ -36,7 +45,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
36
45
|
throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
|
|
37
46
|
}
|
|
38
47
|
const url = new URL(target, window.location.origin);
|
|
39
|
-
const key =
|
|
48
|
+
const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
|
|
40
49
|
if (!key)
|
|
41
50
|
throw new Error('Invalid navigation url');
|
|
42
51
|
path = key;
|
|
@@ -48,7 +57,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
48
57
|
window.history.pushState(null, '', path);
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
|
-
let promise =
|
|
60
|
+
let promise = responseCache.get(path);
|
|
52
61
|
existing = promise !== undefined;
|
|
53
62
|
if (!promise) {
|
|
54
63
|
const ctrl = new AbortController();
|
|
@@ -57,7 +66,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
57
66
|
headers: { accept: 'text/x-component' },
|
|
58
67
|
signal: ctrl.signal,
|
|
59
68
|
});
|
|
60
|
-
|
|
69
|
+
responseCache.set(path, promise);
|
|
61
70
|
}
|
|
62
71
|
if (navigationId !== id.current)
|
|
63
72
|
return path;
|
|
@@ -65,7 +74,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
65
74
|
promise,
|
|
66
75
|
createFromFetch(promise),
|
|
67
76
|
]);
|
|
68
|
-
const resolvedPath =
|
|
77
|
+
const resolvedPath = ResponseCache.toCacheKey(res.url, window.location.origin) ?? path;
|
|
69
78
|
if (navigationId !== id.current)
|
|
70
79
|
return resolvedPath;
|
|
71
80
|
if (resolvedPath !== path) {
|
|
@@ -92,20 +101,42 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
92
101
|
finally {
|
|
93
102
|
if (navigationId === id.current)
|
|
94
103
|
controller.current = null;
|
|
95
|
-
if (!existing)
|
|
96
|
-
|
|
97
|
-
}
|
|
104
|
+
if (!existing)
|
|
105
|
+
responseCache.remove(path);
|
|
98
106
|
}
|
|
99
107
|
return path;
|
|
100
108
|
}, [setPayload]);
|
|
109
|
+
/**
|
|
110
|
+
* Prefetches the RSC response for a given path and caches it for later navigation.
|
|
111
|
+
* Does nothing if a cached response already exists for the path
|
|
112
|
+
*
|
|
113
|
+
* @param path - the target path to prefetch
|
|
114
|
+
* @returns void
|
|
115
|
+
*/
|
|
101
116
|
const prefetch = useCallback((path) => {
|
|
102
|
-
const key =
|
|
117
|
+
const key = ResponseCache.toCacheKey(path, window.location.origin);
|
|
103
118
|
if (!key)
|
|
104
119
|
return;
|
|
105
|
-
if (
|
|
120
|
+
if (responseCache.has(key))
|
|
106
121
|
return;
|
|
107
|
-
|
|
122
|
+
responseCache.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
|
|
108
123
|
}, []);
|
|
124
|
+
/**
|
|
125
|
+
* Refreshes the current page by re-fetching the RSC response for the current path and updating the
|
|
126
|
+
* payload. It also clears any cached response for the current path to ensure that the latest
|
|
127
|
+
* version is fetched
|
|
128
|
+
*/
|
|
129
|
+
const refresh = useCallback(() => {
|
|
130
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
131
|
+
const key = ResponseCache.toCacheKey(currentPath, window.location.origin);
|
|
132
|
+
if (!key)
|
|
133
|
+
return Promise.resolve(currentPath);
|
|
134
|
+
if (responseCache.has(key))
|
|
135
|
+
responseCache.remove(key);
|
|
136
|
+
return go(currentPath, {
|
|
137
|
+
replace: true,
|
|
138
|
+
});
|
|
139
|
+
}, [go]);
|
|
109
140
|
useEffect(() => {
|
|
110
141
|
const handler = () => go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
|
|
111
142
|
replace: true,
|
|
@@ -120,11 +151,12 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
120
151
|
const value = useMemo(() => ({
|
|
121
152
|
go,
|
|
122
153
|
prefetch,
|
|
154
|
+
refresh,
|
|
123
155
|
isNavigating,
|
|
124
156
|
url: {
|
|
125
157
|
pathname: url?.pathname,
|
|
126
158
|
search: url?.search,
|
|
127
159
|
},
|
|
128
|
-
}), [go, prefetch, isNavigating, url]);
|
|
160
|
+
}), [go, prefetch, refresh, isNavigating, url]);
|
|
129
161
|
return _jsx(BrowserRouterContext, { value: value, children: children });
|
|
130
162
|
}
|
|
@@ -156,6 +156,7 @@ export declare namespace BrowserRouter {
|
|
|
156
156
|
<TTo extends Target>(to: TTo, opts?: TargetConfig & Replace): Promise<string>;
|
|
157
157
|
<TTo extends string>(to: string extends TTo ? TTo : never, opts?: GoOptions): Promise<string>;
|
|
158
158
|
};
|
|
159
|
+
export type Refresh = () => Promise<string>;
|
|
159
160
|
/**
|
|
160
161
|
* Convert a route pattern and params into a real path string. This is used internally
|
|
161
162
|
* to implement <Link /> and router.go
|