toiljs 0.0.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 (86) hide show
  1. package/.babelrc +13 -0
  2. package/.gitattributes +2 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  5. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
  8. package/.github/changelog-config.json +45 -0
  9. package/.github/dependabot.yml +27 -0
  10. package/.github/workflows/ci.yml +191 -0
  11. package/.idea/codeStyles/Project.xml +54 -0
  12. package/.idea/codeStyles/codeStyleConfig.xml +5 -0
  13. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  14. package/.idea/modules.xml +8 -0
  15. package/.idea/prettier.xml +6 -0
  16. package/.idea/toiljs.iml +8 -0
  17. package/.idea/vcs.xml +6 -0
  18. package/.prettierrc.json +12 -0
  19. package/.vscode/settings.json +10 -0
  20. package/CHANGELOG.md +5 -0
  21. package/LICENSE +188 -0
  22. package/README.md +1 -0
  23. package/as-pect.asconfig.json +34 -0
  24. package/as-pect.config.js +65 -0
  25. package/eslint.config.js +48 -0
  26. package/examples/basic/.prettierrc +1 -0
  27. package/examples/basic/client/404.tsx +14 -0
  28. package/examples/basic/client/layout.tsx +14 -0
  29. package/examples/basic/client/routes/about.tsx +13 -0
  30. package/examples/basic/client/routes/blog/[id].tsx +14 -0
  31. package/examples/basic/client/routes/docs/[...slug].tsx +15 -0
  32. package/examples/basic/client/routes/index.tsx +13 -0
  33. package/examples/basic/client/routes/io.tsx +28 -0
  34. package/examples/basic/eslint.config.js +3 -0
  35. package/examples/basic/package.json +24 -0
  36. package/examples/basic/toil.config.ts +7 -0
  37. package/examples/basic/tsconfig.json +4 -0
  38. package/package.json +141 -0
  39. package/presets/eslint.js +77 -0
  40. package/presets/no-uint8array-tostring.js +201 -0
  41. package/presets/prettier.json +11 -0
  42. package/presets/tsconfig.json +37 -0
  43. package/src/backend/index.ts +167 -0
  44. package/src/cli/create.ts +272 -0
  45. package/src/cli/index.ts +161 -0
  46. package/src/cli/ui.ts +79 -0
  47. package/src/client/channel.ts +146 -0
  48. package/src/client/index.ts +12 -0
  49. package/src/client/match.ts +39 -0
  50. package/src/client/runtime.tsx +190 -0
  51. package/src/compiler/config.ts +115 -0
  52. package/src/compiler/generate.ts +91 -0
  53. package/src/compiler/index.ts +49 -0
  54. package/src/compiler/plugin.ts +26 -0
  55. package/src/compiler/routes.ts +70 -0
  56. package/src/compiler/vite.ts +90 -0
  57. package/src/io/BinaryReader.ts +344 -0
  58. package/src/io/BinaryWriter.ts +385 -0
  59. package/src/io/FastMap.ts +127 -0
  60. package/src/io/FastSet.ts +96 -0
  61. package/src/io/index.ts +11 -0
  62. package/src/io/lengths.ts +14 -0
  63. package/src/io/types.ts +18 -0
  64. package/src/logger/index.ts +22 -0
  65. package/src/server/index.ts +11 -0
  66. package/src/server/main.ts +13 -0
  67. package/src/shared/index.ts +10 -0
  68. package/std/client/index.d.ts +15 -0
  69. package/std/client/package.json +3 -0
  70. package/test/channel.test.ts +21 -0
  71. package/test/io.test.ts +85 -0
  72. package/test/placeholder.test.ts +9 -0
  73. package/test/routes.test.ts +42 -0
  74. package/tests/server/example.spec.ts +7 -0
  75. package/toilconfig.json +30 -0
  76. package/tsconfig.backend.json +13 -0
  77. package/tsconfig.base.json +35 -0
  78. package/tsconfig.cli.json +13 -0
  79. package/tsconfig.client.json +14 -0
  80. package/tsconfig.compiler.json +13 -0
  81. package/tsconfig.io.json +12 -0
  82. package/tsconfig.json +22 -0
  83. package/tsconfig.logger.json +12 -0
  84. package/tsconfig.server.json +10 -0
  85. package/tsconfig.shared.json +12 -0
  86. package/vitest.config.ts +22 -0
@@ -0,0 +1,39 @@
1
+ /** Extracted dynamic route parameters, e.g. `{ id: "42" }` for `/blog/:id` matching `/blog/42`. */
2
+ export type RouteParams = Record<string, string>;
3
+
4
+ /**
5
+ * Matches a route pattern against a pathname, returning extracted params or `null` if no match.
6
+ * Pure and runtime-agnostic (used by the router and unit-tested directly).
7
+ * matchRoute('/', '/') -> {}
8
+ * matchRoute('/blog/:id', '/blog/42') -> { id: '42' }
9
+ * matchRoute('/docs/*slug', '/docs/a/b') -> { slug: 'a/b' } (catch-all)
10
+ * matchRoute('/about', '/x') -> null
11
+ */
12
+ export function matchRoute(pattern: string, pathname: string): RouteParams | null {
13
+ const patternSegs = pattern.split('/').filter(Boolean);
14
+ const pathSegs = pathname.split('/').filter(Boolean);
15
+
16
+ const params: RouteParams = {};
17
+ for (let i = 0; i < patternSegs.length; i++) {
18
+ const p = patternSegs[i];
19
+
20
+ // Catch-all (`*slug`): captures the rest of the path (one or more segments).
21
+ if (p.startsWith('*')) {
22
+ const rest = pathSegs.slice(i);
23
+ if (rest.length === 0) return null;
24
+ params[p.slice(1)] = rest.map((s) => decodeURIComponent(s)).join('/');
25
+ return params;
26
+ }
27
+
28
+ if (i >= pathSegs.length) return null;
29
+ const value = pathSegs[i];
30
+ if (p.startsWith(':')) {
31
+ params[p.slice(1)] = decodeURIComponent(value);
32
+ } else if (p !== value) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // No catch-all consumed the tail: lengths must match exactly.
38
+ return patternSegs.length === pathSegs.length ? params : null;
39
+ }
@@ -0,0 +1,190 @@
1
+ import {
2
+ createContext,
3
+ lazy,
4
+ Suspense,
5
+ useContext,
6
+ useEffect,
7
+ useState,
8
+ type ComponentType,
9
+ type MouseEvent,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import { createRoot } from 'react-dom/client';
13
+
14
+ import { matchRoute, type RouteParams } from './match.js';
15
+
16
+ /** A route entry produced by the compiler: a URL pattern and a lazy loader for its page component. */
17
+ export interface RouteDef {
18
+ readonly pattern: string;
19
+ readonly load: () => Promise<{ default: ComponentType }>;
20
+ }
21
+
22
+ /** Optional root layout loader (wraps every page). */
23
+ export type LayoutLoader =
24
+ | (() => Promise<{ default: ComponentType<{ children?: ReactNode }> }>)
25
+ | null;
26
+
27
+ /** Optional custom not-found (404) page loader, rendered when no route matches. */
28
+ export type NotFoundLoader = (() => Promise<{ default: ComponentType }>) | null;
29
+
30
+ const listeners = new Set<() => void>();
31
+
32
+ /** Navigates to `href` without a full page reload (history pushState + re-render). */
33
+ export function navigate(href: string): void {
34
+ window.history.pushState({}, '', href);
35
+ for (const listener of listeners) listener();
36
+ }
37
+
38
+ const ParamsContext = createContext<RouteParams>({});
39
+
40
+ /** Current dynamic route params, e.g. `{ id }` inside `/blog/:id`. */
41
+ export function useParams(): RouteParams {
42
+ return useContext(ParamsContext);
43
+ }
44
+
45
+ /** Returns the imperative `navigate(href)` function. */
46
+ export function useNavigate(): (href: string) => void {
47
+ return navigate;
48
+ }
49
+
50
+ /** Subscribes to and returns the current `location.pathname`. */
51
+ export function useLocation(): string {
52
+ const [pathname, setPathname] = useState<string>(() => window.location.pathname);
53
+ useEffect(() => {
54
+ const update = (): void => {
55
+ setPathname(window.location.pathname);
56
+ };
57
+ listeners.add(update);
58
+ window.addEventListener('popstate', update);
59
+ return () => {
60
+ listeners.delete(update);
61
+ window.removeEventListener('popstate', update);
62
+ };
63
+ }, []);
64
+ return pathname;
65
+ }
66
+
67
+ /** Client-side navigation link. Falls back to default browser behavior for modified clicks. */
68
+ export function Link(props: { href: string; className?: string; children?: ReactNode }): ReactNode {
69
+ const { href, className, children } = props;
70
+ const onClick = (e: MouseEvent): void => {
71
+ if (
72
+ e.defaultPrevented ||
73
+ e.button !== 0 ||
74
+ e.metaKey ||
75
+ e.ctrlKey ||
76
+ e.shiftKey ||
77
+ e.altKey
78
+ )
79
+ return;
80
+ e.preventDefault();
81
+ navigate(href);
82
+ };
83
+ return (
84
+ <a
85
+ href={href}
86
+ className={className}
87
+ onClick={onClick}>
88
+ {children}
89
+ </a>
90
+ );
91
+ }
92
+
93
+ const pageCache = new Map<RouteDef, ComponentType>();
94
+ function pageComponent(route: RouteDef): ComponentType {
95
+ let component = pageCache.get(route);
96
+ if (!component) {
97
+ component = lazy(route.load);
98
+ pageCache.set(route, component);
99
+ }
100
+ return component;
101
+ }
102
+
103
+ let layoutComponent: ComponentType<{ children?: ReactNode }> | null = null;
104
+ let layoutLoader: LayoutLoader = null;
105
+ function resolveLayout(loader: NonNullable<LayoutLoader>): ComponentType<{ children?: ReactNode }> {
106
+ if (layoutLoader !== loader || !layoutComponent) {
107
+ layoutComponent = lazy(loader);
108
+ layoutLoader = loader;
109
+ }
110
+ return layoutComponent;
111
+ }
112
+
113
+ let notFoundComponent: ComponentType | null = null;
114
+ let notFoundLoader: NotFoundLoader = null;
115
+ function resolveNotFound(loader: NonNullable<NotFoundLoader>): ComponentType {
116
+ if (notFoundLoader !== loader || !notFoundComponent) {
117
+ notFoundComponent = lazy(loader);
118
+ notFoundLoader = loader;
119
+ }
120
+ return notFoundComponent;
121
+ }
122
+
123
+ /** Matches the current location to a route and renders it, optionally wrapped in the root layout. */
124
+ export function Router(props: {
125
+ routes: RouteDef[];
126
+ layout?: LayoutLoader;
127
+ notFound?: NotFoundLoader;
128
+ }): ReactNode {
129
+ const { routes, layout = null, notFound = null } = props;
130
+ const pathname = useLocation();
131
+
132
+ let matched: RouteDef | undefined;
133
+ let params: RouteParams = {};
134
+ for (const route of routes) {
135
+ const result = matchRoute(route.pattern, pathname);
136
+ if (result) {
137
+ matched = route;
138
+ params = result;
139
+ break;
140
+ }
141
+ }
142
+
143
+ let page: ReactNode;
144
+ if (matched) {
145
+ const Page = pageComponent(matched);
146
+ page = (
147
+ <Suspense fallback={null}>
148
+ <Page />
149
+ </Suspense>
150
+ );
151
+ } else if (notFound) {
152
+ const NotFound = resolveNotFound(notFound);
153
+ page = (
154
+ <Suspense fallback={null}>
155
+ <NotFound />
156
+ </Suspense>
157
+ );
158
+ } else {
159
+ page = <div style={{ padding: 24, fontFamily: 'system-ui' }}>404 — Not found</div>;
160
+ }
161
+
162
+ const withParams = <ParamsContext.Provider value={params}>{page}</ParamsContext.Provider>;
163
+
164
+ if (layout) {
165
+ const Layout = resolveLayout(layout);
166
+ return (
167
+ <Suspense fallback={null}>
168
+ <Layout>{withParams}</Layout>
169
+ </Suspense>
170
+ );
171
+ }
172
+ return withParams;
173
+ }
174
+
175
+ /** Mounts the toil client app into `#root`. Called by the generated `.toil/entry.tsx`. */
176
+ export function mount(
177
+ routes: RouteDef[],
178
+ layout: LayoutLoader = null,
179
+ notFound: NotFoundLoader = null,
180
+ ): void {
181
+ const el = document.getElementById('root');
182
+ if (!el) throw new Error('toil: #root element not found');
183
+ createRoot(el).render(
184
+ <Router
185
+ routes={routes}
186
+ layout={layout}
187
+ notFound={notFound}
188
+ />,
189
+ );
190
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { runnerImport, type InlineConfig } from 'vite';
6
+
7
+ /**
8
+ * Client-side (TSX/React/Vite) configuration. All fields optional; sensible defaults applied.
9
+ */
10
+ export interface ClientConfig {
11
+ /** Client source directory, relative to root. Default `client`. */
12
+ readonly srcDir?: string;
13
+ /** Routes directory, relative to `srcDir`. Default `routes`. */
14
+ readonly routesDir?: string;
15
+ /** Production output directory, relative to root. Default `dist`. */
16
+ readonly outDir?: string;
17
+ /** Public base path. Default `/`. */
18
+ readonly base?: string;
19
+ /** Dev server port. Default `3000`. */
20
+ readonly port?: number;
21
+ /**
22
+ * Raw Vite escape hatch, deep-merged over the framework's opinionated config.
23
+ * This is NOT the client config itself — toil owns the Vite setup; use this only
24
+ * to override specific Vite options.
25
+ */
26
+ readonly vite?: InlineConfig;
27
+ }
28
+
29
+ /**
30
+ * Server-side (AssemblyScript → WASM) configuration. Reserved: the compiler does not yet
31
+ * build the server target via `toil build`; today it is compiled by `toilscript` directly.
32
+ */
33
+ export interface ServerConfig {
34
+ /** Server source directory, relative to root. Default `server`. */
35
+ readonly srcDir?: string;
36
+ /** Server build output directory, relative to root. Default `build/server`. */
37
+ readonly outDir?: string;
38
+ }
39
+
40
+ /**
41
+ * The `toil.config` schema (Next.js-style). All fields optional; sensible defaults applied.
42
+ * Client and server are configured in separate sections.
43
+ */
44
+ export interface ToilConfig {
45
+ /** Project root. Defaults to the current working directory. */
46
+ readonly root?: string;
47
+ /** Client (TSX/React/Vite) configuration. */
48
+ readonly client?: ClientConfig;
49
+ /** Server (AssemblyScript/WASM) configuration. */
50
+ readonly server?: ServerConfig;
51
+ }
52
+
53
+ /** Fully-resolved config with absolute paths, used internally by the compiler. */
54
+ export interface ResolvedToilConfig {
55
+ readonly root: string;
56
+ readonly srcDir: string;
57
+ readonly clientAbsDir: string;
58
+ readonly routesAbsDir: string;
59
+ readonly toilDir: string;
60
+ readonly outDir: string;
61
+ readonly base: string;
62
+ readonly port: number;
63
+ /** Absolute path to the framework client runtime (`toiljs/client`). */
64
+ readonly runtimePath: string;
65
+ readonly vite: InlineConfig;
66
+ }
67
+
68
+ /** Identity helper for typed config files: `export default defineConfig({ ... })`. */
69
+ export function defineConfig(config: ToilConfig): ToilConfig {
70
+ return config;
71
+ }
72
+
73
+ const CONFIG_NAMES = ['toil.config.ts', 'toil.config.mts', 'toil.config.js', 'toil.config.mjs'];
74
+
75
+ /** Path to the built client runtime (`build/client/index.js`), sibling to `build/compiler`. */
76
+ function resolveRuntimePath(): string {
77
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../client/index.js');
78
+ }
79
+
80
+ /** Finds and loads `toil.config.*` from `root` (via Vite's bundling loader), then resolves defaults. */
81
+ export async function loadConfig(
82
+ opts: { root?: string; port?: number } = {},
83
+ ): Promise<ResolvedToilConfig> {
84
+ const root = path.resolve(opts.root ?? process.cwd());
85
+
86
+ let user: ToilConfig = {};
87
+ for (const name of CONFIG_NAMES) {
88
+ const candidate = path.join(root, name);
89
+ if (fs.existsSync(candidate)) {
90
+ // Vite's module runner bundles/transforms the (possibly .ts) config and returns it
91
+ // typed as our `ToilConfig` — no cast needed.
92
+ const { module } = await runnerImport<{ default?: ToilConfig }>(candidate);
93
+ if (module.default) user = module.default;
94
+ break;
95
+ }
96
+ }
97
+
98
+ const client = user.client ?? {};
99
+ const srcDir = client.srcDir ?? 'client';
100
+ const routesDir = client.routesDir ?? 'routes';
101
+ const clientAbsDir = path.join(root, srcDir);
102
+
103
+ return {
104
+ root,
105
+ srcDir,
106
+ clientAbsDir,
107
+ routesAbsDir: path.join(clientAbsDir, routesDir),
108
+ toilDir: path.join(root, '.toil'),
109
+ outDir: client.outDir ?? 'dist',
110
+ base: client.base ?? '/',
111
+ port: opts.port ?? client.port ?? 3000,
112
+ runtimePath: resolveRuntimePath(),
113
+ vite: client.vite ?? {},
114
+ };
115
+ }
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { type ResolvedToilConfig } from './config.js';
5
+ import { scanRoutes, type ScannedRoute } from './routes.js';
6
+
7
+ /** Returns a `./`-prefixed POSIX path from the `.toil` dir to `abs`, for use in generated imports. */
8
+ function relFromToil(cfg: ResolvedToilConfig, abs: string): string {
9
+ let rel = path.relative(cfg.toilDir, abs).replace(/\\/g, '/');
10
+ if (!rel.startsWith('.')) rel = './' + rel;
11
+ return rel;
12
+ }
13
+
14
+ function findLayout(cfg: ResolvedToilConfig): string | undefined {
15
+ return ['layout.tsx', 'layout.jsx']
16
+ .map((name) => path.join(cfg.clientAbsDir, name))
17
+ .find((p) => fs.existsSync(p));
18
+ }
19
+
20
+ /** Finds an optional custom not-found page at `client/404.{tsx,jsx}`. */
21
+ function findNotFound(cfg: ResolvedToilConfig): string | undefined {
22
+ return ['404.tsx', '404.jsx']
23
+ .map((name) => path.join(cfg.clientAbsDir, name))
24
+ .find((p) => fs.existsSync(p));
25
+ }
26
+
27
+ /**
28
+ * Generates the `.toil/` working dir (routes table, mount entry, HTML) and returns the scanned
29
+ * routes. Called before every dev/build and on route add/remove during dev.
30
+ */
31
+ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
32
+ const routes = scanRoutes(cfg.routesAbsDir);
33
+ fs.mkdirSync(cfg.toilDir, { recursive: true });
34
+
35
+ const layoutFile = findLayout(cfg);
36
+ const notFoundFile = findNotFound(cfg);
37
+ const routesSrc =
38
+ `// AUTO-GENERATED by toil — do not edit.\n` +
39
+ `import type { RouteDef, LayoutLoader, NotFoundLoader } from 'toiljs/client';\n\n` +
40
+ `export const routes: RouteDef[] = [\n` +
41
+ routes
42
+ .map(
43
+ (r) =>
44
+ ` { pattern: ${JSON.stringify(r.pattern)}, load: () => import(${JSON.stringify(relFromToil(cfg, r.file))}) },`,
45
+ )
46
+ .join('\n') +
47
+ `\n];\n\n` +
48
+ `export const layout: LayoutLoader = ${layoutFile ? `() => import(${JSON.stringify(relFromToil(cfg, layoutFile))})` : 'null'};\n` +
49
+ `export const notFound: NotFoundLoader = ${notFoundFile ? `() => import(${JSON.stringify(relFromToil(cfg, notFoundFile))})` : 'null'};\n`;
50
+ fs.writeFileSync(path.join(cfg.toilDir, 'routes.ts'), routesSrc);
51
+
52
+ const entrySrc =
53
+ `// AUTO-GENERATED by toil — do not edit.\n` +
54
+ `import { mount } from 'toiljs/client';\n` +
55
+ `import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n` +
56
+ `import { routes, layout, notFound } from './routes';\n\n` +
57
+ `// Expose toiljs IO as native globals (typed via ./toil-env.d.ts).\n` +
58
+ `Object.assign(globalThis, { BinaryWriter, BinaryReader, FastMap, FastSet });\n\n` +
59
+ `mount(routes, layout, notFound);\n`;
60
+ fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
61
+
62
+ // Ambient global types so `new BinaryWriter()` etc. resolve in the IDE without an import.
63
+ // Written at the project root (not inside `.toil`) because TypeScript's `include` globs skip
64
+ // dot-directories — same reason Next.js keeps `next-env.d.ts` at the root. The consumer's
65
+ // tsconfig lists `toil-env.d.ts` in `include`.
66
+ const envSrc =
67
+ `// AUTO-GENERATED by toil — do not edit.\n` +
68
+ `import type {\n` +
69
+ ` BinaryWriter as ToilBinaryWriter,\n` +
70
+ ` BinaryReader as ToilBinaryReader,\n` +
71
+ ` FastMap as ToilFastMap,\n` +
72
+ ` FastSet as ToilFastSet,\n` +
73
+ `} from 'toiljs/io';\n\n` +
74
+ `declare global {\n` +
75
+ ` const BinaryWriter: typeof ToilBinaryWriter;\n` +
76
+ ` const BinaryReader: typeof ToilBinaryReader;\n` +
77
+ ` const FastMap: typeof ToilFastMap;\n` +
78
+ ` const FastSet: typeof ToilFastSet;\n` +
79
+ `}\n\n` +
80
+ `export {};\n`;
81
+ fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), envSrc);
82
+
83
+ const htmlSrc =
84
+ `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n` +
85
+ ` <meta name="viewport" content="width=device-width, initial-scale=1" />\n` +
86
+ ` <title>Toil App</title>\n </head>\n <body>\n <div id="root"></div>\n` +
87
+ ` <script type="module" src="./entry.tsx"></script>\n </body>\n</html>\n`;
88
+ fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), htmlSrc);
89
+
90
+ return routes;
91
+ }
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { build as viteBuild, createServer, type ViteDevServer } from 'vite';
5
+ import { startBackend, type RunningBackend } from 'toiljs/backend';
6
+
7
+ import { loadConfig } from './config.js';
8
+ import { generate } from './generate.js';
9
+ import { createViteConfig } from './vite.js';
10
+
11
+ export interface ToilCommandOptions {
12
+ readonly root?: string;
13
+ readonly port?: number;
14
+ }
15
+
16
+ /** Starts the Vite dev server (HMR + transforms) for the client app. Returns the running server. */
17
+ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
18
+ const cfg = await loadConfig(opts);
19
+ generate(cfg);
20
+ const server = await createServer(createViteConfig(cfg));
21
+ await server.listen();
22
+ server.printUrls();
23
+ return server;
24
+ }
25
+
26
+ /** Produces an optimized production SPA bundle in the configured `outDir`. */
27
+ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
28
+ const cfg = await loadConfig(opts);
29
+ generate(cfg);
30
+ await viteBuild(createViteConfig(cfg));
31
+ }
32
+
33
+ /**
34
+ * Self-hosts the built client over the high-performance hyper-express backend (uWebSockets.js),
35
+ * serving the configured `outDir` with an SPA fallback plus a WebSocket channel. Requires a prior
36
+ * `build`. Returns the running backend.
37
+ */
38
+ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBackend> {
39
+ const cfg = await loadConfig(opts);
40
+ const outDir = path.resolve(cfg.root, cfg.outDir);
41
+ if (!fs.existsSync(path.join(outDir, 'index.html'))) {
42
+ throw new Error(`No build found in ${outDir}. Run \`toiljs build\` first.`);
43
+ }
44
+ return startBackend({ root: outDir, port: cfg.port });
45
+ }
46
+
47
+ export { defineConfig } from './config.js';
48
+ export type { ToilConfig } from './config.js';
49
+ export type { RunningBackend, BackendOptions } from 'toiljs/backend';
@@ -0,0 +1,26 @@
1
+ import { type Plugin } from 'vite';
2
+
3
+ import { type ResolvedToilConfig } from './config.js';
4
+ import { generate } from './generate.js';
5
+
6
+ /**
7
+ * Vite plugin that keeps the generated route table in sync during dev: when a route file is
8
+ * added or removed, it regenerates `.toil/routes.ts` and triggers a full reload. Editing a
9
+ * route file's contents hot-reloads through `@vitejs/plugin-react` as usual.
10
+ */
11
+ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
12
+ return {
13
+ name: 'toil',
14
+ configureServer(server) {
15
+ const onChange = (file: string): void => {
16
+ if (file.replace(/\\/g, '/').startsWith(cfg.routesAbsDir.replace(/\\/g, '/'))) {
17
+ generate(cfg);
18
+ server.ws.send({ type: 'full-reload' });
19
+ }
20
+ };
21
+ server.watcher.add(cfg.routesAbsDir);
22
+ server.watcher.on('add', onChange);
23
+ server.watcher.on('unlink', onChange);
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** A discovered route: the source file and the URL pattern it serves. */
5
+ export interface ScannedRoute {
6
+ readonly file: string;
7
+ readonly pattern: string;
8
+ }
9
+
10
+ const ROUTE_EXT = /\.(tsx|jsx)$/;
11
+
12
+ /**
13
+ * Derives a route pattern from a route file path (relative to the routes dir).
14
+ * index.tsx -> /
15
+ * about.tsx -> /about
16
+ * blog/index.tsx -> /blog
17
+ * blog/[id].tsx -> /blog/:id
18
+ * docs/[...slug].tsx -> /docs/*slug (catch-all)
19
+ */
20
+ export function filePathToRoute(relPath: string): string {
21
+ const withoutExt = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '');
22
+ const segments = withoutExt.split('/').filter(Boolean);
23
+ const out: string[] = [];
24
+ for (let i = 0; i < segments.length; i++) {
25
+ const segment = segments[i];
26
+ if (segment === 'index' && i === segments.length - 1) continue;
27
+ out.push(
28
+ segment
29
+ .replace(/^\[\.\.\.(.+)\]$/, '*$1') // catch-all [...slug] -> *slug
30
+ .replace(/^\[(.+)\]$/, ':$1'), // dynamic [id] -> :id
31
+ );
32
+ }
33
+ return '/' + out.join('/');
34
+ }
35
+
36
+ /**
37
+ * Ranks a pattern so more specific routes match first: static segments beat dynamic (`:x`),
38
+ * which beat catch-all (`*x`); deeper routes beat shallower ones.
39
+ */
40
+ function specificity(pattern: string): number {
41
+ const segments = pattern.split('/').filter(Boolean);
42
+ let score = segments.length * 10;
43
+ for (const segment of segments) {
44
+ if (segment.startsWith('*')) score -= 5; // catch-all: lowest
45
+ else if (!segment.startsWith(':')) score += 5; // static: highest
46
+ }
47
+ return score;
48
+ }
49
+
50
+ /** Recursively scans `routesDir` for `.tsx`/`.jsx` files, returning routes sorted by specificity. */
51
+ export function scanRoutes(routesDir: string): ScannedRoute[] {
52
+ if (!fs.existsSync(routesDir)) return [];
53
+ const found: ScannedRoute[] = [];
54
+ const walk = (dir: string): void => {
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ const full = path.join(dir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ walk(full);
59
+ } else if (ROUTE_EXT.test(entry.name)) {
60
+ found.push({
61
+ file: full,
62
+ pattern: filePathToRoute(path.relative(routesDir, full)),
63
+ });
64
+ }
65
+ }
66
+ };
67
+ walk(routesDir);
68
+ found.sort((a, b) => specificity(b.pattern) - specificity(a.pattern));
69
+ return found;
70
+ }
@@ -0,0 +1,90 @@
1
+ import path from 'node:path';
2
+
3
+ import react from '@vitejs/plugin-react';
4
+ import { nodePolyfills } from 'vite-plugin-node-polyfills';
5
+ import { mergeConfig, type InlineConfig } from 'vite';
6
+
7
+ import { type ResolvedToilConfig } from './config.js';
8
+ import { toilPlugin } from './plugin.js';
9
+
10
+ /** Image extensions routed to `images/` in the build output. */
11
+ const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
12
+ /** Font extensions routed to `fonts/`. */
13
+ const FONT_EXT = /^(woff|woff2|eot|ttf|otf)$/i;
14
+
15
+ /** Routes a built asset to a typed sub-folder (`images/`, `fonts/`, `css/`, else `assets/`). */
16
+ function assetFileName(name: string): string {
17
+ const ext = name.split('.').pop() ?? '';
18
+ if (IMAGE_EXT.test(ext)) return 'images/[name][extname]';
19
+ if (FONT_EXT.test(ext)) return 'fonts/[name][extname]';
20
+ if (/^css$/i.test(ext)) return 'css/[name][extname]';
21
+ return 'assets/[name][extname]';
22
+ }
23
+
24
+ /** Splits React's runtime into its own long-lived chunk for better caching. */
25
+ function manualChunks(id: string): string | undefined {
26
+ if (!id.includes('node_modules')) return undefined;
27
+ if (
28
+ id.includes('node_modules/react-dom') ||
29
+ id.includes('node_modules/react/') ||
30
+ id.includes('node_modules/scheduler')
31
+ ) {
32
+ return 'react';
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Builds the framework-owned Vite config. Vite's `root` is the generated `.toil` dir so its
39
+ * `index.html` emits at the output root (assets resolve correctly); `fs.allow` opens the
40
+ * project (for `client/`) and the framework runtime. The opinionated default — Node polyfills
41
+ * (Buffer/global/process), React plugin, toil route plugin, typed asset folders, React chunk
42
+ * splitting and tuned build options — is applied here; `toiljs/client` is aliased to the
43
+ * runtime, and the user's `client.vite` overrides deep-merge on top.
44
+ */
45
+ export function createViteConfig(cfg: ResolvedToilConfig): InlineConfig {
46
+ // .../build/client/index.js -> framework package root (covers build/ + node_modules in dev)
47
+ const frameworkRoot = path.resolve(path.dirname(cfg.runtimePath), '..', '..');
48
+
49
+ const base: InlineConfig = {
50
+ root: cfg.toilDir,
51
+ base: cfg.base,
52
+ configFile: false,
53
+ plugins: [
54
+ nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
55
+ react(),
56
+ toilPlugin(cfg),
57
+ ],
58
+ resolve: {
59
+ alias: {
60
+ 'toiljs/client': cfg.runtimePath,
61
+ },
62
+ dedupe: ['react', 'react-dom'],
63
+ },
64
+ server: {
65
+ port: cfg.port,
66
+ fs: { allow: [cfg.root, frameworkRoot] },
67
+ },
68
+ build: {
69
+ outDir: path.resolve(cfg.root, cfg.outDir),
70
+ emptyOutDir: true,
71
+ target: 'es2020',
72
+ modulePreload: false,
73
+ cssCodeSplit: false,
74
+ assetsInlineLimit: 10000,
75
+ chunkSizeWarningLimit: 3000,
76
+ commonjsOptions: {
77
+ strictRequires: true,
78
+ transformMixedEsModules: true,
79
+ },
80
+ rollupOptions: {
81
+ output: {
82
+ chunkFileNames: 'assets/[name]-[hash].js',
83
+ assetFileNames: (assetInfo) => assetFileName(assetInfo.names[0] ?? ''),
84
+ manualChunks,
85
+ },
86
+ },
87
+ },
88
+ };
89
+ return mergeConfig(base, cfg.vite);
90
+ }