@jk2908/solas 0.3.8 → 0.4.1

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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +66 -6
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +16 -2
  5. package/dist/internal/browser-router/link.d.ts +1 -1
  6. package/dist/internal/browser-router/link.js +1 -1
  7. package/dist/internal/browser-router/router.d.ts +2 -165
  8. package/dist/internal/browser-router/router.js +3 -99
  9. package/dist/internal/browser-router/shared.d.ts +169 -0
  10. package/dist/internal/browser-router/shared.js +71 -0
  11. package/dist/internal/browser-router/use-router.d.ts +1 -1
  12. package/dist/internal/codegen/environments.js +5 -4
  13. package/dist/internal/env/rsc.d.ts +2 -2
  14. package/dist/internal/env/rsc.js +159 -62
  15. package/dist/internal/http-router/create-http-router.d.ts +1 -1
  16. package/dist/internal/http-router/create-http-router.js +4 -2
  17. package/dist/internal/http-router/router.d.ts +4 -14
  18. package/dist/internal/http-router/router.js +32 -59
  19. package/dist/internal/navigation/http-exception.d.ts +4 -4
  20. package/dist/internal/navigation/http-exception.js +4 -5
  21. package/dist/internal/navigation/redirect-boundary.js +2 -11
  22. package/dist/internal/navigation/redirect.d.ts +3 -0
  23. package/dist/internal/navigation/redirect.js +51 -0
  24. package/dist/internal/postbuild.d.ts +1 -0
  25. package/dist/{cli/build.js → internal/postbuild.js} +13 -48
  26. package/dist/internal/prerender.d.ts +4 -19
  27. package/dist/internal/prerender.js +8 -98
  28. package/dist/internal/public-files.d.ts +18 -0
  29. package/dist/internal/public-files.js +63 -0
  30. package/dist/internal/resolver.d.ts +23 -23
  31. package/dist/internal/server/actions.d.ts +2 -5
  32. package/dist/internal/server/actions.js +4 -35
  33. package/dist/internal/server/csrf.d.ts +14 -0
  34. package/dist/internal/server/csrf.js +98 -0
  35. package/dist/router.d.ts +1 -0
  36. package/dist/router.js +1 -0
  37. package/dist/solas.d.ts +12 -1
  38. package/dist/solas.js +116 -1
  39. package/dist/types.d.ts +8 -3
  40. package/dist/utils/base-path.d.ts +14 -0
  41. package/dist/utils/base-path.js +85 -0
  42. package/dist/utils/export-reader.js +10 -4
  43. package/package.json +4 -7
  44. package/dist/cli/build.d.ts +0 -7
  45. package/dist/cli/dev.d.ts +0 -4
  46. package/dist/cli/dev.js +0 -13
  47. package/dist/cli/preview.d.ts +0 -1
  48. package/dist/cli/preview.js +0 -47
  49. package/dist/cli.d.ts +0 -2
  50. package/dist/cli.js +0 -28
package/dist/solas.js CHANGED
@@ -12,12 +12,14 @@ var Solas;
12
12
  Config.ENTRY_RSC = 'entry.rsc.tsx';
13
13
  Config.ENTRY_SSR = 'entry.ssr.tsx';
14
14
  Config.ENTRY_BROWSER = 'entry.browser.tsx';
15
- Config.ASSETS_DIR = 'assets';
15
+ Config.ASSETS_DIR = `_${Config.SLUG}`;
16
+ Config.PUBLIC_DIR = 'public';
16
17
  Config.$ = Symbol(Config.SLUG);
17
18
  Config.REQUEST_META_KEY = `__${Config.SLUG.toUpperCase()}__`;
18
19
  Config.LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'fatal'];
19
20
  Config.PRERENDER_MODES = ['full', 'ppr', false];
20
21
  Config.TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
22
+ Config.RUNTIME_MANIFEST = 'runtime-manifest.json';
21
23
  const CONFIG_KEYS = new Set([
22
24
  'port',
23
25
  'logger',
@@ -25,6 +27,7 @@ var Solas;
25
27
  'precompress',
26
28
  'prerender',
27
29
  'sitemap',
30
+ 'trustedOrigins',
28
31
  'trailingSlash',
29
32
  'url',
30
33
  ]);
@@ -67,6 +70,33 @@ var Solas;
67
70
  errors.push('config.precompress must be a boolean');
68
71
  }
69
72
  }
73
+ if ('trustedOrigins' in input && input.trustedOrigins !== undefined) {
74
+ if (!Array.isArray(input.trustedOrigins)) {
75
+ errors.push('config.trustedOrigins must be an array of origins');
76
+ }
77
+ else {
78
+ for (const [index, value] of input.trustedOrigins.entries()) {
79
+ if (typeof value !== 'string') {
80
+ errors.push(`config.trustedOrigins[${index}] must be a string`);
81
+ continue;
82
+ }
83
+ try {
84
+ const url = new URL(value);
85
+ const canonical = value.replace(/\/$/, '');
86
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
87
+ errors.push(`config.trustedOrigins[${index}] must use http:// or https://`);
88
+ continue;
89
+ }
90
+ if (canonical !== url.origin) {
91
+ errors.push(`config.trustedOrigins[${index}] must be an origin without a path, query, or hash`);
92
+ }
93
+ }
94
+ catch {
95
+ errors.push(`config.trustedOrigins[${index}] must be a valid URL origin`);
96
+ }
97
+ }
98
+ }
99
+ }
70
100
  if ('sitemap' in input && input.sitemap !== undefined && input.sitemap !== false) {
71
101
  if (typeof input.sitemap !== 'boolean' && typeof input.sitemap !== 'object') {
72
102
  errors.push('config.sitemap must be a boolean or an object with a routes function');
@@ -129,6 +159,91 @@ var Solas;
129
159
  return value;
130
160
  }
131
161
  Solas.getVersion = getVersion;
162
+ let Runtime;
163
+ (function (Runtime) {
164
+ const manifestCache = new Map();
165
+ function getManifestPath(outDir) {
166
+ return [outDir, Config.GENERATED_DIR, Config.RUNTIME_MANIFEST]
167
+ .map((part, index) => {
168
+ const normalised = part.replace(/\\/g, '/').replace(/\/+/g, '/');
169
+ if (index === 0)
170
+ return normalised.replace(/\/+$/, '');
171
+ return normalised.replace(/^\/+/, '').replace(/\/+$/, '');
172
+ })
173
+ .join('/');
174
+ }
175
+ Runtime.getManifestPath = getManifestPath;
176
+ async function loadManifest(outDir) {
177
+ if (manifestCache.has(outDir)) {
178
+ return manifestCache.get(outDir) ?? null;
179
+ }
180
+ const file = Bun.file(getManifestPath(outDir));
181
+ if (!(await file.exists())) {
182
+ manifestCache.set(outDir, null);
183
+ return null;
184
+ }
185
+ try {
186
+ const value = JSON.parse(await file.text());
187
+ if (!isRecord(value)) {
188
+ manifestCache.set(outDir, null);
189
+ return null;
190
+ }
191
+ const artifacts = value.artifacts ?? value.routes;
192
+ const publicFiles = value.publicFiles;
193
+ if (!isRecord(artifacts)) {
194
+ manifestCache.set(outDir, null);
195
+ return null;
196
+ }
197
+ if (publicFiles !== undefined && !Array.isArray(publicFiles)) {
198
+ manifestCache.set(outDir, null);
199
+ return null;
200
+ }
201
+ for (const entry of Object.values(artifacts)) {
202
+ if (!isRecord(entry)) {
203
+ manifestCache.set(outDir, null);
204
+ return null;
205
+ }
206
+ const { mode, files } = entry;
207
+ if (mode !== 'full' && mode !== 'ppr') {
208
+ manifestCache.set(outDir, null);
209
+ return null;
210
+ }
211
+ if (files !== undefined) {
212
+ if (!Array.isArray(files)) {
213
+ manifestCache.set(outDir, null);
214
+ return null;
215
+ }
216
+ for (const file of files) {
217
+ if (file !== 'html' &&
218
+ file !== 'prelude' &&
219
+ file !== 'postponed' &&
220
+ file !== 'metadata') {
221
+ manifestCache.set(outDir, null);
222
+ return null;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ for (const entry of publicFiles ?? []) {
228
+ if (typeof entry !== 'string' || !entry.startsWith('/')) {
229
+ manifestCache.set(outDir, null);
230
+ return null;
231
+ }
232
+ }
233
+ const manifest = {
234
+ artifacts: artifacts,
235
+ publicFiles: new Set(publicFiles ?? []),
236
+ };
237
+ manifestCache.set(outDir, manifest);
238
+ return manifest;
239
+ }
240
+ catch {
241
+ manifestCache.set(outDir, null);
242
+ return null;
243
+ }
244
+ }
245
+ Runtime.loadManifest = loadManifest;
246
+ })(Runtime = Solas.Runtime || (Solas.Runtime = {}));
132
247
  let Events;
133
248
  (function (Events) {
134
249
  Events.names = {
package/dist/types.d.ts CHANGED
@@ -2,35 +2,38 @@ type BunRequest = Request & {
2
2
  params?: Record<string, string | string[]>;
3
3
  };
4
4
  import { ExportReader } from './utils/export-reader.js';
5
+ import type { BrowserRouter } from './internal/browser-router/shared.js';
5
6
  import type { Build } from './internal/build.js';
6
7
  import type { HttpRouter } from './internal/http-router/router.js';
7
8
  import type { Metadata } from './internal/metadata.js';
8
9
  import type { HttpException } from './internal/navigation/http-exception.js';
9
- import { BrowserRouter } from './internal/browser-router/router.js';
10
10
  import { Solas } from './solas.js';
11
11
  export type LogLevel = (typeof Solas.Config.LOG_LEVELS)[number];
12
+ type Origin = `http://${string}` | `https://${string}`;
12
13
  type PluginConfigBase = {
13
14
  port?: number;
14
15
  precompress?: boolean;
15
16
  prerender?: Route.Prerender;
16
17
  metadata?: Metadata.Item;
17
18
  trailingSlash?: (typeof Solas.Config.TRAILING_SLASH_MODES)[number];
19
+ trustedOrigins?: readonly Origin[];
18
20
  readonly logger?: {
19
21
  level?: LogLevel;
20
22
  };
21
23
  };
22
24
  export type PluginConfig = PluginConfigBase & ({
23
- url: `http://${string}` | `https://${string}`;
25
+ url: Origin;
24
26
  sitemap: true | {
25
27
  routes: (existing: string[]) => string[] | Promise<string[]>;
26
28
  };
27
29
  } | {
28
- url?: `http://${string}` | `https://${string}`;
30
+ url?: Origin;
29
31
  sitemap?: false;
30
32
  });
31
33
  export type RuntimeConfig = PluginConfig & {
32
34
  precompress: NonNullable<PluginConfig['precompress']>;
33
35
  trailingSlash: NonNullable<PluginConfig['trailingSlash']>;
36
+ trustedOrigins: NonNullable<PluginConfig['trustedOrigins']>;
34
37
  };
35
38
  export type BuildContext = {
36
39
  prerenderRoutes: Set<string>;
@@ -99,6 +102,8 @@ export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' |
99
102
  export type Primitive = string | number | boolean | bigint | symbol | null | undefined;
100
103
  export type LooseNumber<T extends number> = T | (number & {});
101
104
  export type BuildManifest = {
105
+ base: string;
106
+ publicFiles: string[];
102
107
  prerenderRoutes: string[];
103
108
  sitemapRoutes: string[];
104
109
  precompress: boolean;
@@ -0,0 +1,14 @@
1
+ export declare namespace BasePath {
2
+ /**
3
+ * Normalise a base path so every check uses the same shape
4
+ */
5
+ function normalise(value: string | null | undefined): string;
6
+ /**
7
+ * Strip the base path from a request path
8
+ */
9
+ function strip(pathname: string, base: string | null | undefined): string | null;
10
+ /**
11
+ * Add the base path to a path when needed
12
+ */
13
+ function apply(pathname: string, base: string | null | undefined): string;
14
+ }
@@ -0,0 +1,85 @@
1
+ export { BasePath };
2
+ var BasePath;
3
+ (function (BasePath) {
4
+ /**
5
+ * Normalise a base path so every check uses the same shape
6
+ */
7
+ function normalise(value) {
8
+ // no base means the app lives at the site root
9
+ if (!value)
10
+ return '/';
11
+ if (value === '/' || value === '.' || value === './')
12
+ return '/';
13
+ let pathname = value.trim();
14
+ if (!pathname)
15
+ return '/';
16
+ // plain path bases are the common case, so keep them cheap
17
+ if (!pathname.startsWith('http://') && !pathname.startsWith('https://')) {
18
+ const hashIndex = pathname.indexOf('#');
19
+ const searchIndex = pathname.indexOf('?');
20
+ const end = hashIndex === -1
21
+ ? searchIndex
22
+ : searchIndex === -1
23
+ ? hashIndex
24
+ : Math.min(hashIndex, searchIndex);
25
+ if (end >= 0)
26
+ pathname = pathname.slice(0, end);
27
+ }
28
+ else {
29
+ try {
30
+ // full urls can still show up here, but we only need the path part
31
+ pathname = new URL(pathname).pathname;
32
+ }
33
+ catch {
34
+ // if parsing fails, fall back to the raw value below
35
+ }
36
+ }
37
+ if (!pathname || pathname === '.' || pathname === './')
38
+ return '/';
39
+ // keep one stable shape: leading slash, trailing slash
40
+ if (!pathname.startsWith('/'))
41
+ pathname = `/${pathname}`;
42
+ return pathname.endsWith('/') ? pathname : `${pathname}/`;
43
+ }
44
+ BasePath.normalise = normalise;
45
+ /**
46
+ * Strip the base path from a request path
47
+ */
48
+ function strip(pathname, base) {
49
+ const normalisedBase = normalise(base);
50
+ // root base means there is nothing to strip
51
+ if (normalisedBase === '/')
52
+ return pathname || '/';
53
+ const basePath = normalisedBase.slice(0, -1);
54
+ // treat both '/docs' and '/docs/' as the app root
55
+ if (pathname === basePath || pathname === normalisedBase)
56
+ return '/';
57
+ // paths outside the base do not belong to this app
58
+ if (!pathname.startsWith(`${basePath}/`))
59
+ return null;
60
+ // return the path as the app should see it
61
+ return pathname.slice(basePath.length) || '/';
62
+ }
63
+ BasePath.strip = strip;
64
+ /**
65
+ * Add the base path to a path when needed
66
+ */
67
+ function apply(pathname, base) {
68
+ const normalisedBase = normalise(base);
69
+ // always work with a path-like value
70
+ const target = pathname.startsWith('/') ? pathname : `/${pathname}`;
71
+ // root base means the path can pass through unchanged
72
+ if (normalisedBase === '/')
73
+ return target;
74
+ const basePath = normalisedBase.slice(0, -1);
75
+ // leave it alone if the base is already there
76
+ if (target === basePath || target.startsWith(`${basePath}/`))
77
+ return target;
78
+ // the app root maps to the base path itself
79
+ if (target === '/')
80
+ return normalisedBase;
81
+ // everything else sits underneath the base path
82
+ return `${basePath}${target}`;
83
+ }
84
+ BasePath.apply = apply;
85
+ })(BasePath || (BasePath = {}));
@@ -84,17 +84,23 @@ export class ExportReader {
84
84
  * The export must be in the form of `export const|let|var name = <literal>`
85
85
  */
86
86
  async literal(filePath, name, validate) {
87
- const code = await this.raw(filePath);
87
+ if (!(await this.has(filePath, name)))
88
+ return;
89
+ // transpile first so comments and type-only syntax do not confuse the
90
+ // literal matcher with exports that do not actually exist at runtime
91
+ const code = this.#getTranspiler(filePath).transformSync(await this.raw(filePath));
88
92
  // build the matcher from escaped plain-text pieces so arbitrary export names
89
93
  // cannot change the regex shape
90
94
  const source =
91
- // match: `export const|let|var `
92
- '\\bexport\\s+(?:const|let|var)\\s+' +
95
+ // match: `export const|let|var ` at statement boundaries
96
+ '(?:^|[;\\n])\\s*export\\s+(?:const|let|var)\\s+' +
93
97
  // treat export name as plain text in regex
94
98
  name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
95
99
  // capture one supported literal value (string, number, boolean, null)
96
100
  '\\s*=\\s*(?<value>(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|\\x60(?:[^\\x60\\\\]|\\\\.)*\\x60|true|false|null|-?\\d+(?:\\.\\d+)?))(?=\\s|;|$)';
97
- const text = code.match(new RegExp(source))?.groups?.value;
101
+ // multiline mode lets ^ match the start of each transpiled line, so the
102
+ // export regex stays anchored to a real statement boundary instead of the file start
103
+ const text = code.match(new RegExp(source, 'm'))?.groups?.value;
98
104
  if (!text)
99
105
  return;
100
106
  // only support cheap literal parsing here. Anything richer should go through
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.3.8",
4
- "description": "A React Server Components meta-framework powered by Vite",
3
+ "version": "0.4.1",
4
+ "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",
7
7
  "ppr",
@@ -17,10 +17,7 @@
17
17
  "license": "MIT",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/jk2908/solas.git"
21
- },
22
- "bin": {
23
- "solas": "./dist/cli.js"
20
+ "url": "git+https://github.com/jk2908/solas.git"
24
21
  },
25
22
  "files": [
26
23
  "dist",
@@ -74,7 +71,7 @@
74
71
  }
75
72
  },
76
73
  "scripts": {
77
- "build": "rm -rf dist && tsgo && chmod +x dist/cli.js",
74
+ "build": "rm -rf dist && tsgo",
78
75
  "lint": "bunx oxlint .",
79
76
  "lint:fix": "bunx oxlint --fix .",
80
77
  "format": "bunx oxfmt --write .",
@@ -1,7 +0,0 @@
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>;
package/dist/cli/dev.d.ts DELETED
@@ -1,4 +0,0 @@
1
- /**
2
- * Start the vite development server
3
- */
4
- export declare function dev(): Promise<void>;
package/dist/cli/dev.js DELETED
@@ -1,13 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export declare function preview(): Promise<void>;
@@ -1,47 +0,0 @@
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.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- export {};
package/dist/cli.js DELETED
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { build } from './cli/build.js';
3
- import { dev } from './cli/dev.js';
4
- import { preview } from './cli/preview.js';
5
- import { Solas } from './solas.js';
6
- // read the subcommand once and dispatch below
7
- const [, , command] = process.argv;
8
- switch (command) {
9
- case 'build':
10
- await build();
11
- break;
12
- case 'dev':
13
- await dev();
14
- break;
15
- case 'preview':
16
- await preview();
17
- break;
18
- default:
19
- console.log(`
20
- ${Solas.Config.NAME} - cli
21
-
22
- Commands:
23
- build Build for production (vite build + prerender + compress)
24
- dev Start development server
25
- preview Preview production build (serves prerendered HTML with SSR fallback)
26
- `);
27
- process.exit(command ? 1 : 0);
28
- }