@jk2908/solas 0.3.0 → 0.3.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.
- package/CHANGELOG.md +12 -0
- package/dist/cli/build.d.ts +7 -0
- package/dist/cli/build.js +183 -0
- package/dist/cli/dev.d.ts +4 -0
- package/dist/cli/dev.js +13 -0
- package/dist/cli/preview.d.ts +1 -0
- package/dist/cli/preview.js +47 -0
- package/dist/cli.js +4 -238
- package/dist/index.js +2 -0
- package/dist/internal/browser-router/link.d.ts +17 -0
- package/dist/internal/{navigation → browser-router}/link.js +22 -16
- package/dist/internal/browser-router/router.d.ts +184 -0
- package/dist/internal/{router/router-provider.js → browser-router/router.js} +81 -12
- package/dist/internal/{router → browser-router}/use-router.d.ts +1 -1
- package/dist/internal/browser-router/use-router.js +5 -0
- package/dist/internal/{navigation → browser-router}/use-search-params.js +1 -1
- package/dist/internal/build.js +2 -2
- package/dist/internal/codegen/config.js +17 -8
- package/dist/internal/codegen/environments.js +7 -7
- package/dist/internal/codegen/manifest.js +3 -3
- package/dist/internal/codegen/maps.js +11 -15
- package/dist/internal/codegen/types.d.ts +5 -0
- package/dist/internal/codegen/types.js +48 -0
- package/dist/internal/codegen/utils.d.ts +10 -0
- package/dist/internal/codegen/utils.js +27 -2
- package/dist/internal/env/browser.js +6 -6
- package/dist/internal/env/flight.d.ts +29 -0
- package/dist/internal/env/flight.js +187 -0
- package/dist/internal/env/request-context.d.ts +1 -1
- package/dist/internal/env/rsc.d.ts +1 -1
- package/dist/internal/env/rsc.js +23 -28
- package/dist/internal/env/ssr.d.ts +2 -2
- package/dist/internal/env/ssr.js +27 -13
- package/dist/internal/env/utils.js +13 -1
- package/dist/internal/http-router/create-http-router.d.ts +6 -0
- package/dist/internal/{router/create-router.js → http-router/create-http-router.js} +5 -5
- package/dist/internal/{router → http-router}/router.d.ts +9 -9
- package/dist/internal/{router → http-router}/router.js +20 -19
- package/dist/internal/{router → http-router}/utils.d.ts +11 -3
- package/dist/internal/{router → http-router}/utils.js +9 -1
- package/dist/internal/metadata.js +10 -10
- package/dist/internal/prerender.d.ts +4 -9
- package/dist/internal/prerender.js +6 -23
- package/dist/internal/render/head.js +1 -1
- package/dist/internal/render/tree.d.ts +1 -1
- package/dist/internal/render/tree.js +17 -13
- package/dist/internal/{router/resolver.d.ts → resolver.d.ts} +41 -41
- package/dist/internal/{router/resolver.js → resolver.js} +7 -7
- package/dist/internal/server/actions.js +1 -1
- package/dist/internal/server/cookies.d.ts +3 -2
- package/dist/internal/server/cookies.js +4 -3
- package/dist/internal/server/dynamic.d.ts +1 -3
- package/dist/internal/server/dynamic.js +3 -11
- package/dist/internal/server/headers.d.ts +2 -2
- package/dist/internal/server/headers.js +3 -3
- package/dist/internal/server/url.d.ts +2 -2
- package/dist/internal/server/url.js +3 -3
- package/dist/navigation.d.ts +2 -4
- package/dist/navigation.js +2 -4
- package/dist/router.d.ts +3 -4
- package/dist/router.js +3 -4
- package/dist/solas.d.ts +3 -1
- package/dist/solas.js +1 -1
- package/dist/types.d.ts +15 -7
- package/dist/utils/logger.js +1 -1
- package/package.json +2 -7
- package/dist/internal/navigation/link.d.ts +0 -13
- package/dist/internal/router/create-router.d.ts +0 -6
- package/dist/internal/router/router-context.d.ts +0 -15
- package/dist/internal/router/router-context.js +0 -8
- package/dist/internal/router/router-provider.d.ts +0 -10
- package/dist/internal/router/use-router.js +0 -5
- /package/dist/internal/{navigation → browser-router}/use-search-params.d.ts +0 -0
- /package/dist/internal/{router/prefetcher.d.ts → prefetcher.d.ts} +0 -0
- /package/dist/internal/{router/prefetcher.js → prefetcher.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 - 2026-04-21
|
|
4
|
+
|
|
5
|
+
- Fixed PPR flight transport and closed-connection handling by replacing `rsc-html-stream` with the local runtime transport.
|
|
6
|
+
- Fixed prerender artifact manifest handling for dynamic params by writing the final built artifact manifest and using it for runtime artifact lookups.
|
|
7
|
+
|
|
8
|
+
## 0.3.1 - 2026-04-07
|
|
9
|
+
|
|
10
|
+
- Fixed `useSearchParams()` client builds.
|
|
11
|
+
- Reworked the code generators to keep the source templates readable while still emitting tidy generated files.
|
|
12
|
+
- Added a shared template dedent helper for generated source and tightened nested object and route map indentation.
|
|
13
|
+
- Made generated config output emit logger code only when a logger level is configured.
|
|
14
|
+
|
|
3
15
|
## 0.3.0 - 2026-04-07
|
|
4
16
|
|
|
5
17
|
- Fixed `useSearchParams()` hydration so query-driven ui uses the initial request url on first render.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build command does more than just run vite build - it also handles prerendering and
|
|
3
|
+
* precompressing assets. This is because prerendering needs to run against the built
|
|
4
|
+
* server entry to ensure the same code paths as preview, and precompressing needs
|
|
5
|
+
* to include the prerendered html and json files
|
|
6
|
+
*/
|
|
7
|
+
export declare function build(): Promise<void>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Compress } from '../utils/compress.js';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import { Prerender } from '../internal/prerender.js';
|
|
6
|
+
import { Solas } from '../solas.js';
|
|
7
|
+
const logger = new Logger();
|
|
8
|
+
/**
|
|
9
|
+
* The build command does more than just run vite build - it also handles prerendering and
|
|
10
|
+
* precompressing assets. This is because prerendering needs to run against the built
|
|
11
|
+
* server entry to ensure the same code paths as preview, and precompressing needs
|
|
12
|
+
* to include the prerendered html and json files
|
|
13
|
+
*/
|
|
14
|
+
export async function build() {
|
|
15
|
+
// build and prerender should both run in production mode
|
|
16
|
+
process.env.NODE_ENV = 'production';
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const manifestPath = path.join(cwd, Solas.Config.GENERATED_DIR, 'build.json');
|
|
19
|
+
// run vite build
|
|
20
|
+
logger.info('[build]', 'running vite build...');
|
|
21
|
+
const vite = Bun.spawnSync(['bunx', '--bun', 'vite', 'build', '--mode', 'production'], {
|
|
22
|
+
cwd,
|
|
23
|
+
stdout: 'inherit',
|
|
24
|
+
stderr: 'inherit',
|
|
25
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
26
|
+
});
|
|
27
|
+
if (vite.exitCode !== 0) {
|
|
28
|
+
logger.error('[build] vite build failed');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// read build manifest
|
|
32
|
+
let manifest;
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
35
|
+
manifest = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
logger.error('[build] failed to read build manifest', err);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
|
|
42
|
+
const rscDir = path.join(outDir, 'rsc');
|
|
43
|
+
const artifactRoot = Prerender.Artifact.getRootPath(outDir);
|
|
44
|
+
// clear old prerender artifacts so routes that have switched modes
|
|
45
|
+
// do not keep stale metadata from a previous build
|
|
46
|
+
await fs.rm(artifactRoot, { recursive: true, force: true });
|
|
47
|
+
// prerender routes
|
|
48
|
+
if (manifest.prerenderRoutes.length > 0) {
|
|
49
|
+
const timeout = Prerender.Build.getTimeout();
|
|
50
|
+
const concurrency = Prerender.Build.getConcurrency();
|
|
51
|
+
// track the extra prerender files we write for preview
|
|
52
|
+
const artifactManifest = {};
|
|
53
|
+
// keep in-flight artifact writes bounded so result handling does not block on one route at a time
|
|
54
|
+
const pendingWrites = new Set();
|
|
55
|
+
logger.info('[prerender]', `prerendering ${manifest.prerenderRoutes.length} routes (timeout: ${timeout}ms, concurrency: ${concurrency})...`);
|
|
56
|
+
// load the built server entry and render each prerendered route through it
|
|
57
|
+
const rscEntry = path.join(rscDir, 'index.js');
|
|
58
|
+
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
59
|
+
async function enqueueWrite(task) {
|
|
60
|
+
const write = task().finally(() => {
|
|
61
|
+
pendingWrites.delete(write);
|
|
62
|
+
});
|
|
63
|
+
pendingWrites.add(write);
|
|
64
|
+
if (pendingWrites.size >= concurrency) {
|
|
65
|
+
await Promise.race(pendingWrites);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// run prerender through the built app so build output uses the same path as preview
|
|
69
|
+
for await (const result of Prerender.Build.run(app, manifest.prerenderRoutes, {
|
|
70
|
+
timeout,
|
|
71
|
+
concurrency,
|
|
72
|
+
origin: manifest.url,
|
|
73
|
+
})) {
|
|
74
|
+
const route = result.route;
|
|
75
|
+
if ('error' in result) {
|
|
76
|
+
logger.error(`[prerender]: Failed ${route}: ${result.error}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode)`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if ('status' in result) {
|
|
80
|
+
logger.warn(`[prerender]: Skipped ${route}: ${result.status}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const artifact = result.artifact;
|
|
84
|
+
const artifactDir = Prerender.Artifact.getPath(outDir, route);
|
|
85
|
+
await enqueueWrite(async () => {
|
|
86
|
+
try {
|
|
87
|
+
if (artifact.mode === 'ppr') {
|
|
88
|
+
// for ppr save the shell now and keep the postponed state for later
|
|
89
|
+
await fs.mkdir(artifactDir, { recursive: true });
|
|
90
|
+
const writes = [
|
|
91
|
+
Bun.write(path.join(artifactDir, 'prelude.html'), artifact.html),
|
|
92
|
+
Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
|
|
93
|
+
schema: artifact.schema,
|
|
94
|
+
route: artifact.route,
|
|
95
|
+
createdAt: artifact.createdAt,
|
|
96
|
+
mode: artifact.mode,
|
|
97
|
+
})),
|
|
98
|
+
];
|
|
99
|
+
if (artifact.postponed !== undefined) {
|
|
100
|
+
writes.push(Bun.write(path.join(artifactDir, 'postponed.json'), JSON.stringify(artifact.postponed)));
|
|
101
|
+
}
|
|
102
|
+
await Promise.all(writes);
|
|
103
|
+
artifactManifest[route] = {
|
|
104
|
+
mode: artifact.mode,
|
|
105
|
+
files: artifact.postponed !== undefined
|
|
106
|
+
? ['metadata', 'prelude', 'postponed']
|
|
107
|
+
: ['metadata', 'prelude'],
|
|
108
|
+
};
|
|
109
|
+
logger.info('[prerender:artifacts]', JSON.stringify({
|
|
110
|
+
route,
|
|
111
|
+
prelude: artifact.html,
|
|
112
|
+
postponed: artifact.postponed ?? null,
|
|
113
|
+
metadata: {
|
|
114
|
+
schema: artifact.schema,
|
|
115
|
+
route: artifact.route,
|
|
116
|
+
createdAt: artifact.createdAt,
|
|
117
|
+
mode: artifact.mode,
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
logger.info('[prerender]', `${route} (ppr)`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// full prerender still keeps metadata so preview knows to serve saved html
|
|
124
|
+
await fs.mkdir(artifactDir, { recursive: true });
|
|
125
|
+
const fullPrerenderFilename = Prerender.Artifact.FULL_PRERENDER_FILENAME;
|
|
126
|
+
await Promise.all([
|
|
127
|
+
Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
|
|
128
|
+
schema: artifact.schema,
|
|
129
|
+
route: artifact.route,
|
|
130
|
+
createdAt: artifact.createdAt,
|
|
131
|
+
mode: artifact.mode,
|
|
132
|
+
})),
|
|
133
|
+
Bun.write(Prerender.Artifact.getFilePath(outDir, route, fullPrerenderFilename), artifact.html),
|
|
134
|
+
]);
|
|
135
|
+
artifactManifest[route] = {
|
|
136
|
+
mode: artifact.mode,
|
|
137
|
+
files: ['metadata', 'html'],
|
|
138
|
+
};
|
|
139
|
+
logger.info(`[prerender]: ${route} (full)`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
logger.error(`[prerender]: Failed ${route}: ${err}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode).`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
await Promise.all(pendingWrites);
|
|
147
|
+
// write one manifest for the saved prerender files after all routes finish
|
|
148
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
149
|
+
await Bun.write(Prerender.Artifact.getManifestPath(outDir), JSON.stringify({
|
|
150
|
+
routes: artifactManifest,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
// sitemap
|
|
154
|
+
if (manifest.sitemapRoutes.length > 0 && manifest.url) {
|
|
155
|
+
const origin = manifest.url.replace(/\/$/, '');
|
|
156
|
+
const urls = manifest.sitemapRoutes
|
|
157
|
+
.map(route => ` <url><loc>${origin}${route}</loc></url>`)
|
|
158
|
+
.join('\n');
|
|
159
|
+
const sitemap = [
|
|
160
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
161
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
162
|
+
urls,
|
|
163
|
+
'</urlset>',
|
|
164
|
+
].join('\n');
|
|
165
|
+
await Bun.write(path.join(outDir, 'sitemap.xml'), sitemap);
|
|
166
|
+
logger.info('[sitemap]', `generated ${manifest.sitemapRoutes.length} urls`);
|
|
167
|
+
}
|
|
168
|
+
// precompress
|
|
169
|
+
if (manifest.precompress) {
|
|
170
|
+
logger.info('[precompress]', 'compressing assets...');
|
|
171
|
+
// compress after prerender so generated html and json are included too
|
|
172
|
+
for await (const { input, compressed } of Compress.run(outDir, {
|
|
173
|
+
filter: f => /\.(js|css|html|svg|json|txt)$/.test(f),
|
|
174
|
+
})) {
|
|
175
|
+
await Bun.write(`${input}.br`, compressed);
|
|
176
|
+
logger.info('[precompress]', `${path.basename(input)}.br`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// cleanup
|
|
180
|
+
// this file is only needed while the build command is running
|
|
181
|
+
await fs.unlink(manifestPath).catch(() => { });
|
|
182
|
+
logger.info('[build]', 'done');
|
|
183
|
+
}
|
package/dist/cli/dev.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start the vite development server
|
|
3
|
+
*/
|
|
4
|
+
export async function dev() {
|
|
5
|
+
const proc = Bun.spawn(['bunx', '--bun', 'vite', 'dev'], {
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
stdout: 'inherit',
|
|
8
|
+
stderr: 'inherit',
|
|
9
|
+
stdin: 'inherit',
|
|
10
|
+
env: { ...process.env, NODE_ENV: 'development' },
|
|
11
|
+
});
|
|
12
|
+
await proc.exited;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function preview(): Promise<void>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { Solas } from '../solas.js';
|
|
5
|
+
const logger = new Logger();
|
|
6
|
+
const DEFAULT_PREVIEW_PORT = 4173;
|
|
7
|
+
const [, , , ...args] = process.argv;
|
|
8
|
+
export async function preview() {
|
|
9
|
+
// preview should behave like production, not like vite dev
|
|
10
|
+
process.env.NODE_ENV = 'production';
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
|
|
13
|
+
const rscDir = path.join(outDir, 'rsc');
|
|
14
|
+
const rscEntry = path.join(rscDir, 'index.js');
|
|
15
|
+
const portFlagIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
|
|
16
|
+
const parsedPort = portFlagIndex >= 0 && args[portFlagIndex + 1]
|
|
17
|
+
? Number(args[portFlagIndex + 1])
|
|
18
|
+
: DEFAULT_PREVIEW_PORT;
|
|
19
|
+
// fail fast if the port is invalid
|
|
20
|
+
if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
|
|
21
|
+
logger.error(`[preview] invalid port: ${args[portFlagIndex + 1] ?? 'undefined'}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
// the built server entry handles routing, prerendered html, and ssr here
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(rscEntry);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
logger.error(`[preview] missing ${path.relative(cwd, rscEntry)} - run \`${Solas.Config.SLUG} build\` from this project directory first`, err);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
33
|
+
try {
|
|
34
|
+
// keep the preview server thin and let the app handle requests
|
|
35
|
+
Bun.serve({
|
|
36
|
+
port: parsedPort,
|
|
37
|
+
fetch: app.fetch,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
logger.error(`[preview] failed to start on port ${parsedPort}: ${err}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
logger.info('[preview]', `server running at http://localhost:${parsedPort}`);
|
|
45
|
+
// keep the process running after the server starts
|
|
46
|
+
await new Promise(() => { });
|
|
47
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,244 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import { build } from './cli/build.js';
|
|
3
|
+
import { dev } from './cli/dev.js';
|
|
4
|
+
import { preview } from './cli/preview.js';
|
|
4
5
|
import { Solas } from './solas.js';
|
|
5
|
-
import { Compress } from './utils/compress.js';
|
|
6
|
-
import { Logger } from './utils/logger.js';
|
|
7
|
-
import { Prerender } from './internal/prerender.js';
|
|
8
|
-
const logger = new Logger();
|
|
9
|
-
const DEFAULT_PREVIEW_PORT = 4173;
|
|
10
|
-
/**
|
|
11
|
-
* The build command does more than just run vite build - it also handles prerendering and
|
|
12
|
-
* precompressing assets. This is because prerendering needs to run against the built
|
|
13
|
-
* server entry to ensure the same code paths as preview, and precompressing needs
|
|
14
|
-
* to include the prerendered html and json files
|
|
15
|
-
*/
|
|
16
|
-
async function build() {
|
|
17
|
-
// build and prerender should both run in production mode
|
|
18
|
-
process.env.NODE_ENV = 'production';
|
|
19
|
-
const cwd = process.cwd();
|
|
20
|
-
const manifestPath = path.join(cwd, Solas.Config.GENERATED_DIR, 'build.json');
|
|
21
|
-
// run vite build
|
|
22
|
-
logger.info('[build]', 'running vite build...');
|
|
23
|
-
const vite = Bun.spawnSync(['bunx', '--bun', 'vite', 'build', '--mode', 'production'], {
|
|
24
|
-
cwd,
|
|
25
|
-
stdout: 'inherit',
|
|
26
|
-
stderr: 'inherit',
|
|
27
|
-
env: { ...process.env, NODE_ENV: 'production' },
|
|
28
|
-
});
|
|
29
|
-
if (vite.exitCode !== 0) {
|
|
30
|
-
logger.error('[build] vite build failed');
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
// read build manifest
|
|
34
|
-
let manifest;
|
|
35
|
-
try {
|
|
36
|
-
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
37
|
-
manifest = JSON.parse(raw);
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
40
|
-
logger.error('[build] failed to read build manifest', err);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
|
|
44
|
-
const rscDir = path.join(outDir, 'rsc');
|
|
45
|
-
const artifactRoot = Prerender.Artifact.getRootPath(outDir);
|
|
46
|
-
// clear old prerender artifacts so routes that have switched modes
|
|
47
|
-
// do not keep stale metadata from a previous build
|
|
48
|
-
await fs.rm(artifactRoot, { recursive: true, force: true });
|
|
49
|
-
// prerender routes
|
|
50
|
-
if (manifest.prerenderRoutes.length > 0) {
|
|
51
|
-
const timeout = Prerender.Build.getTimeout();
|
|
52
|
-
const concurrency = Prerender.Build.getConcurrency();
|
|
53
|
-
// track the extra prerender files we write for preview
|
|
54
|
-
const artifactManifestRoutes = {};
|
|
55
|
-
// keep in-flight artifact writes bounded so result handling does not block on one route at a time
|
|
56
|
-
const pendingWrites = new Set();
|
|
57
|
-
logger.info('[prerender]', `prerendering ${manifest.prerenderRoutes.length} routes (timeout: ${timeout}ms, concurrency: ${concurrency})...`);
|
|
58
|
-
// load the built server entry and render each prerendered route through it
|
|
59
|
-
const rscEntry = path.join(rscDir, 'index.js');
|
|
60
|
-
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
61
|
-
async function enqueueWrite(task) {
|
|
62
|
-
const write = task().finally(() => {
|
|
63
|
-
pendingWrites.delete(write);
|
|
64
|
-
});
|
|
65
|
-
pendingWrites.add(write);
|
|
66
|
-
if (pendingWrites.size >= concurrency) {
|
|
67
|
-
await Promise.race(pendingWrites);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
// run prerender through the built app so build output uses the same path as preview
|
|
71
|
-
for await (const result of Prerender.Build.run(app, manifest.prerenderRoutes, {
|
|
72
|
-
timeout,
|
|
73
|
-
concurrency,
|
|
74
|
-
origin: manifest.url,
|
|
75
|
-
})) {
|
|
76
|
-
const route = result.route;
|
|
77
|
-
if ('error' in result) {
|
|
78
|
-
logger.error(`[prerender]: Failed ${route}: ${result.error}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode)`);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if ('status' in result) {
|
|
82
|
-
logger.warn(`[prerender]: Skipped ${route}: ${result.status}`);
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const artifact = result.artifact;
|
|
86
|
-
const artifactDir = Prerender.Artifact.getPath(outDir, route);
|
|
87
|
-
await enqueueWrite(async () => {
|
|
88
|
-
try {
|
|
89
|
-
if (artifact.mode === 'ppr') {
|
|
90
|
-
// for ppr save the shell now and keep the postponed state for later
|
|
91
|
-
await fs.mkdir(artifactDir, { recursive: true });
|
|
92
|
-
const writes = [
|
|
93
|
-
Bun.write(path.join(artifactDir, 'prelude.html'), artifact.html),
|
|
94
|
-
Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
|
|
95
|
-
schema: artifact.schema,
|
|
96
|
-
route: artifact.route,
|
|
97
|
-
createdAt: artifact.createdAt,
|
|
98
|
-
mode: artifact.mode,
|
|
99
|
-
})),
|
|
100
|
-
];
|
|
101
|
-
if (artifact.postponed !== undefined) {
|
|
102
|
-
writes.push(Bun.write(path.join(artifactDir, 'postponed.json'), JSON.stringify(artifact.postponed)));
|
|
103
|
-
}
|
|
104
|
-
await Promise.all(writes);
|
|
105
|
-
artifactManifestRoutes[route] = {
|
|
106
|
-
mode: artifact.mode,
|
|
107
|
-
createdAt: artifact.createdAt,
|
|
108
|
-
files: artifact.postponed !== undefined
|
|
109
|
-
? ['metadata', 'prelude', 'postponed']
|
|
110
|
-
: ['metadata', 'prelude'],
|
|
111
|
-
};
|
|
112
|
-
logger.info('[prerender:artifacts]', JSON.stringify({
|
|
113
|
-
route,
|
|
114
|
-
prelude: artifact.html,
|
|
115
|
-
postponed: artifact.postponed ?? null,
|
|
116
|
-
metadata: {
|
|
117
|
-
schema: artifact.schema,
|
|
118
|
-
route: artifact.route,
|
|
119
|
-
createdAt: artifact.createdAt,
|
|
120
|
-
mode: artifact.mode,
|
|
121
|
-
},
|
|
122
|
-
}));
|
|
123
|
-
logger.info('[prerender]', `${route} (ppr)`);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
// full prerender still keeps metadata so preview knows to serve saved html
|
|
127
|
-
await fs.mkdir(artifactDir, { recursive: true });
|
|
128
|
-
const fullPrerenderFilename = Prerender.Artifact.getFullHtmlFileName(artifact.html);
|
|
129
|
-
await Promise.all([
|
|
130
|
-
Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
|
|
131
|
-
schema: artifact.schema,
|
|
132
|
-
route: artifact.route,
|
|
133
|
-
createdAt: artifact.createdAt,
|
|
134
|
-
mode: artifact.mode,
|
|
135
|
-
})),
|
|
136
|
-
Bun.write(Prerender.Artifact.getFilePath(outDir, route, fullPrerenderFilename), artifact.html),
|
|
137
|
-
]);
|
|
138
|
-
artifactManifestRoutes[route] = {
|
|
139
|
-
mode: artifact.mode,
|
|
140
|
-
createdAt: artifact.createdAt,
|
|
141
|
-
files: ['metadata', 'html'],
|
|
142
|
-
fullPrerenderFilename,
|
|
143
|
-
};
|
|
144
|
-
logger.info(`[prerender]: ${route} (full)`);
|
|
145
|
-
}
|
|
146
|
-
catch (err) {
|
|
147
|
-
logger.error(`[prerender]: Failed ${route}: ${err}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode).`);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
await Promise.all(pendingWrites);
|
|
152
|
-
// write one manifest for the saved prerender files after all routes finish
|
|
153
|
-
await fs.mkdir(artifactRoot, { recursive: true });
|
|
154
|
-
await Bun.write(Prerender.Artifact.getManifestPath(outDir), JSON.stringify({
|
|
155
|
-
generatedAt: Date.now(),
|
|
156
|
-
routes: artifactManifestRoutes,
|
|
157
|
-
}));
|
|
158
|
-
}
|
|
159
|
-
// sitemap
|
|
160
|
-
if (manifest.sitemapRoutes.length > 0 && manifest.url) {
|
|
161
|
-
const origin = manifest.url.replace(/\/$/, '');
|
|
162
|
-
const urls = manifest.sitemapRoutes
|
|
163
|
-
.map(route => ` <url><loc>${origin}${route}</loc></url>`)
|
|
164
|
-
.join('\n');
|
|
165
|
-
const sitemap = [
|
|
166
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
167
|
-
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
168
|
-
urls,
|
|
169
|
-
'</urlset>',
|
|
170
|
-
].join('\n');
|
|
171
|
-
await Bun.write(path.join(outDir, 'sitemap.xml'), sitemap);
|
|
172
|
-
logger.info('[sitemap]', `generated ${manifest.sitemapRoutes.length} urls`);
|
|
173
|
-
}
|
|
174
|
-
// precompress
|
|
175
|
-
if (manifest.precompress) {
|
|
176
|
-
logger.info('[precompress]', 'compressing assets...');
|
|
177
|
-
// compress after prerender so generated html and json are included too
|
|
178
|
-
for await (const { input, compressed } of Compress.run(outDir, {
|
|
179
|
-
filter: f => /\.(js|css|html|svg|json|txt)$/.test(f),
|
|
180
|
-
})) {
|
|
181
|
-
await Bun.write(`${input}.br`, compressed);
|
|
182
|
-
logger.info('[precompress]', `${path.basename(input)}.br`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// cleanup
|
|
186
|
-
// this file is only needed while the build command is running
|
|
187
|
-
await fs.unlink(manifestPath).catch(() => { });
|
|
188
|
-
logger.info('[build]', 'done');
|
|
189
|
-
}
|
|
190
|
-
async function dev() {
|
|
191
|
-
const proc = Bun.spawn(['bunx', '--bun', 'vite', 'dev'], {
|
|
192
|
-
cwd: process.cwd(),
|
|
193
|
-
stdout: 'inherit',
|
|
194
|
-
stderr: 'inherit',
|
|
195
|
-
stdin: 'inherit',
|
|
196
|
-
env: { ...process.env, NODE_ENV: 'development' },
|
|
197
|
-
});
|
|
198
|
-
await proc.exited;
|
|
199
|
-
}
|
|
200
|
-
async function preview() {
|
|
201
|
-
// preview should behave like production, not like vite dev
|
|
202
|
-
process.env.NODE_ENV = 'production';
|
|
203
|
-
const cwd = process.cwd();
|
|
204
|
-
const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
|
|
205
|
-
const rscDir = path.join(outDir, 'rsc');
|
|
206
|
-
const rscEntry = path.join(rscDir, 'index.js');
|
|
207
|
-
const portFlagIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
|
|
208
|
-
const parsedPort = portFlagIndex >= 0 && args[portFlagIndex + 1]
|
|
209
|
-
? Number(args[portFlagIndex + 1])
|
|
210
|
-
: DEFAULT_PREVIEW_PORT;
|
|
211
|
-
// fail fast if the port is invalid
|
|
212
|
-
if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
|
|
213
|
-
logger.error(`[preview] invalid port: ${args[portFlagIndex + 1] ?? 'undefined'}`);
|
|
214
|
-
process.exit(1);
|
|
215
|
-
}
|
|
216
|
-
// the built server entry handles routing, prerendered html, and ssr here
|
|
217
|
-
try {
|
|
218
|
-
await fs.access(rscEntry);
|
|
219
|
-
}
|
|
220
|
-
catch (err) {
|
|
221
|
-
logger.error(`[preview] missing ${path.relative(cwd, rscEntry)} - run \`${Solas.Config.SLUG} build\` from this project directory first`, err);
|
|
222
|
-
process.exit(1);
|
|
223
|
-
}
|
|
224
|
-
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
225
|
-
try {
|
|
226
|
-
// keep the preview server thin and let the app handle requests
|
|
227
|
-
Bun.serve({
|
|
228
|
-
port: parsedPort,
|
|
229
|
-
fetch: app.fetch,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
logger.error(`[preview] failed to start on port ${parsedPort}: ${err}`);
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
logger.info('[preview]', `server running at http://localhost:${parsedPort}`);
|
|
237
|
-
// keep the process running after the server starts
|
|
238
|
-
await new Promise(() => { });
|
|
239
|
-
}
|
|
240
6
|
// read the subcommand once and dispatch below
|
|
241
|
-
const [, , command
|
|
7
|
+
const [, , command] = process.argv;
|
|
242
8
|
switch (command) {
|
|
243
9
|
case 'build':
|
|
244
10
|
await build();
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { writeConfig } from './internal/codegen/config.js';
|
|
|
10
10
|
import { writeBrowserEntry, writeRSCEntry, writeSSREntry, } from './internal/codegen/environments.js';
|
|
11
11
|
import { writeManifest } from './internal/codegen/manifest.js';
|
|
12
12
|
import { writeMaps } from './internal/codegen/maps.js';
|
|
13
|
+
import { writeTypes } from './internal/codegen/types.js';
|
|
13
14
|
import { Solas } from './solas.js';
|
|
14
15
|
const DEFAULT_CONFIG = {
|
|
15
16
|
precompress: true,
|
|
@@ -94,6 +95,7 @@ function solas(c) {
|
|
|
94
95
|
['config.ts', writeConfig(config)],
|
|
95
96
|
['manifest.ts', writeManifest(manifest)],
|
|
96
97
|
['maps.ts', writeMaps(imports, modules)],
|
|
98
|
+
[`${Solas.Config.SLUG}.d.ts`, writeTypes(manifest)],
|
|
97
99
|
[Solas.Config.ENTRY_RSC, writeRSCEntry()],
|
|
98
100
|
[Solas.Config.ENTRY_SSR, writeSSREntry()],
|
|
99
101
|
[Solas.Config.ENTRY_BROWSER, writeBrowserEntry()],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BrowserRouter } from './router.js';
|
|
2
|
+
type AnchorProps = React.ComponentPropsWithRef<'a'> & {
|
|
3
|
+
href: string;
|
|
4
|
+
};
|
|
5
|
+
type BaseProps = {
|
|
6
|
+
prefetch?: 'intent' | 'hover' | 'none';
|
|
7
|
+
} & AnchorProps;
|
|
8
|
+
type Props = BaseProps & BrowserRouter.LinkProps;
|
|
9
|
+
/**
|
|
10
|
+
* A link component that navigates to a given target
|
|
11
|
+
* @param href - the route target to navigate to
|
|
12
|
+
* @param prefetch - when to prefetch the linked page, defaults to 'none'
|
|
13
|
+
* @param rest - other props to pass to the underlying anchor element
|
|
14
|
+
* @returns a link element that navigates to the given target
|
|
15
|
+
*/
|
|
16
|
+
export declare function Link(props: Props): React.JSX.Element;
|
|
17
|
+
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { BrowserRouter } from './router.js';
|
|
5
|
+
import { useRouter } from './use-router.js';
|
|
5
6
|
function guard(path, prefetcher) {
|
|
6
7
|
const connection = window.navigator.connection;
|
|
7
8
|
if (document.visibilityState === 'hidden')
|
|
@@ -12,22 +13,22 @@ function guard(path, prefetcher) {
|
|
|
12
13
|
return;
|
|
13
14
|
prefetcher(path);
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
-
* A link component that navigates to a given href
|
|
17
|
-
* @param href - the href to navigate to
|
|
18
|
-
* @param prefetch - when to prefetch the linked page, defaults to 'none'
|
|
19
|
-
* @param rest - other props to pass to the underlying anchor element
|
|
20
|
-
* @returns a link element that navigates to the given href
|
|
21
|
-
*/
|
|
22
|
-
export function Link({ children, href, prefetch = 'none', ...rest }) {
|
|
16
|
+
export function Link({ children, href, params, prefetch = 'none', query, ...rest }) {
|
|
23
17
|
const { go, prefetch: prefetcher } = useRouter();
|
|
24
18
|
const timer = useRef(null);
|
|
19
|
+
const handled = useRef(false);
|
|
20
|
+
const target = BrowserRouter.toTarget(href, params, query);
|
|
25
21
|
// clear any pending hover-prefetch timer on unmount
|
|
26
22
|
useEffect(() => () => {
|
|
27
23
|
if (timer.current)
|
|
28
24
|
clearTimeout(timer.current);
|
|
29
25
|
}, []);
|
|
30
|
-
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
handled.current =
|
|
28
|
+
BrowserRouter.isHashOnlyTarget(target) ||
|
|
29
|
+
BrowserRouter.isExternalTarget(target, window.location.origin);
|
|
30
|
+
}, [target]);
|
|
31
|
+
return (_jsx("a", { ...rest, href: target, onClick: e => {
|
|
31
32
|
rest.onClick?.(e);
|
|
32
33
|
if (e.defaultPrevented)
|
|
33
34
|
return;
|
|
@@ -40,33 +41,38 @@ export function Link({ children, href, prefetch = 'none', ...rest }) {
|
|
|
40
41
|
return;
|
|
41
42
|
if (rest.download)
|
|
42
43
|
return;
|
|
43
|
-
|
|
44
|
-
if (to.origin !== window.location.origin)
|
|
44
|
+
if (handled.current)
|
|
45
45
|
return;
|
|
46
46
|
e.preventDefault();
|
|
47
|
-
go(
|
|
47
|
+
go(href, { params, query });
|
|
48
48
|
}, onFocus: e => {
|
|
49
49
|
rest.onFocus?.(e);
|
|
50
50
|
if (e.defaultPrevented)
|
|
51
51
|
return;
|
|
52
52
|
if (prefetch !== 'intent')
|
|
53
53
|
return;
|
|
54
|
-
|
|
54
|
+
if (handled.current)
|
|
55
|
+
return;
|
|
56
|
+
guard(target, prefetcher);
|
|
55
57
|
}, onTouchStart: e => {
|
|
56
58
|
rest.onTouchStart?.(e);
|
|
57
59
|
if (e.defaultPrevented)
|
|
58
60
|
return;
|
|
59
61
|
if (prefetch !== 'intent')
|
|
60
62
|
return;
|
|
61
|
-
|
|
63
|
+
if (handled.current)
|
|
64
|
+
return;
|
|
65
|
+
guard(target, prefetcher);
|
|
62
66
|
}, onMouseEnter: e => {
|
|
63
67
|
rest.onMouseEnter?.(e);
|
|
64
68
|
if (e.defaultPrevented)
|
|
65
69
|
return;
|
|
66
70
|
if (prefetch !== 'hover')
|
|
67
71
|
return;
|
|
72
|
+
if (handled.current)
|
|
73
|
+
return;
|
|
68
74
|
timer.current = setTimeout(() => {
|
|
69
|
-
guard(
|
|
75
|
+
guard(target, prefetcher);
|
|
70
76
|
}, 100);
|
|
71
77
|
}, onMouseLeave: e => {
|
|
72
78
|
rest.onMouseLeave?.(e);
|