@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/build.d.ts +7 -0
  3. package/dist/cli/build.js +183 -0
  4. package/dist/cli/dev.d.ts +4 -0
  5. package/dist/cli/dev.js +13 -0
  6. package/dist/cli/preview.d.ts +1 -0
  7. package/dist/cli/preview.js +47 -0
  8. package/dist/cli.js +4 -238
  9. package/dist/index.js +2 -0
  10. package/dist/internal/browser-router/link.d.ts +17 -0
  11. package/dist/internal/{navigation → browser-router}/link.js +22 -16
  12. package/dist/internal/browser-router/router.d.ts +184 -0
  13. package/dist/internal/{router/router-provider.js → browser-router/router.js} +81 -12
  14. package/dist/internal/{router → browser-router}/use-router.d.ts +1 -1
  15. package/dist/internal/browser-router/use-router.js +5 -0
  16. package/dist/internal/{navigation → browser-router}/use-search-params.js +1 -1
  17. package/dist/internal/build.js +2 -2
  18. package/dist/internal/codegen/config.js +17 -8
  19. package/dist/internal/codegen/environments.js +7 -7
  20. package/dist/internal/codegen/manifest.js +3 -3
  21. package/dist/internal/codegen/maps.js +11 -15
  22. package/dist/internal/codegen/types.d.ts +5 -0
  23. package/dist/internal/codegen/types.js +48 -0
  24. package/dist/internal/codegen/utils.d.ts +10 -0
  25. package/dist/internal/codegen/utils.js +27 -2
  26. package/dist/internal/env/browser.js +6 -6
  27. package/dist/internal/env/flight.d.ts +29 -0
  28. package/dist/internal/env/flight.js +187 -0
  29. package/dist/internal/env/request-context.d.ts +1 -1
  30. package/dist/internal/env/rsc.d.ts +1 -1
  31. package/dist/internal/env/rsc.js +23 -28
  32. package/dist/internal/env/ssr.d.ts +2 -2
  33. package/dist/internal/env/ssr.js +27 -13
  34. package/dist/internal/env/utils.js +13 -1
  35. package/dist/internal/http-router/create-http-router.d.ts +6 -0
  36. package/dist/internal/{router/create-router.js → http-router/create-http-router.js} +5 -5
  37. package/dist/internal/{router → http-router}/router.d.ts +9 -9
  38. package/dist/internal/{router → http-router}/router.js +20 -19
  39. package/dist/internal/{router → http-router}/utils.d.ts +11 -3
  40. package/dist/internal/{router → http-router}/utils.js +9 -1
  41. package/dist/internal/metadata.js +10 -10
  42. package/dist/internal/prerender.d.ts +4 -9
  43. package/dist/internal/prerender.js +6 -23
  44. package/dist/internal/render/head.js +1 -1
  45. package/dist/internal/render/tree.d.ts +1 -1
  46. package/dist/internal/render/tree.js +17 -13
  47. package/dist/internal/{router/resolver.d.ts → resolver.d.ts} +41 -41
  48. package/dist/internal/{router/resolver.js → resolver.js} +7 -7
  49. package/dist/internal/server/actions.js +1 -1
  50. package/dist/internal/server/cookies.d.ts +3 -2
  51. package/dist/internal/server/cookies.js +4 -3
  52. package/dist/internal/server/dynamic.d.ts +1 -3
  53. package/dist/internal/server/dynamic.js +3 -11
  54. package/dist/internal/server/headers.d.ts +2 -2
  55. package/dist/internal/server/headers.js +3 -3
  56. package/dist/internal/server/url.d.ts +2 -2
  57. package/dist/internal/server/url.js +3 -3
  58. package/dist/navigation.d.ts +2 -4
  59. package/dist/navigation.js +2 -4
  60. package/dist/router.d.ts +3 -4
  61. package/dist/router.js +3 -4
  62. package/dist/solas.d.ts +3 -1
  63. package/dist/solas.js +1 -1
  64. package/dist/types.d.ts +15 -7
  65. package/dist/utils/logger.js +1 -1
  66. package/package.json +2 -7
  67. package/dist/internal/navigation/link.d.ts +0 -13
  68. package/dist/internal/router/create-router.d.ts +0 -6
  69. package/dist/internal/router/router-context.d.ts +0 -15
  70. package/dist/internal/router/router-context.js +0 -8
  71. package/dist/internal/router/router-provider.d.ts +0 -10
  72. package/dist/internal/router/use-router.js +0 -5
  73. /package/dist/internal/{navigation → browser-router}/use-search-params.d.ts +0 -0
  74. /package/dist/internal/{router/prefetcher.d.ts → prefetcher.d.ts} +0 -0
  75. /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
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Start the vite development server
3
+ */
4
+ export declare function dev(): Promise<void>;
@@ -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 fs from 'node:fs/promises';
3
- import path from 'node:path';
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, ...args] = process.argv;
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 { useRouter } from '../router/use-router.js';
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
- return (_jsx("a", { ...rest, href: href, onClick: e => {
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
- const to = new URL(href, window.location.origin);
44
- if (to.origin !== window.location.origin)
44
+ if (handled.current)
45
45
  return;
46
46
  e.preventDefault();
47
- go(to.pathname + to.search + to.hash);
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
- guard(href, prefetcher);
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
- guard(href, prefetcher);
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(href, prefetcher);
75
+ guard(target, prefetcher);
70
76
  }, 100);
71
77
  }, onMouseLeave: e => {
72
78
  rest.onMouseLeave?.(e);