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.
- package/.babelrc +13 -0
- package/.gitattributes +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
- package/.github/changelog-config.json +45 -0
- package/.github/dependabot.yml +27 -0
- package/.github/workflows/ci.yml +191 -0
- package/.idea/codeStyles/Project.xml +54 -0
- package/.idea/codeStyles/codeStyleConfig.xml +5 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +6 -0
- package/.idea/toiljs.iml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.prettierrc.json +12 -0
- package/.vscode/settings.json +10 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +188 -0
- package/README.md +1 -0
- package/as-pect.asconfig.json +34 -0
- package/as-pect.config.js +65 -0
- package/eslint.config.js +48 -0
- package/examples/basic/.prettierrc +1 -0
- package/examples/basic/client/404.tsx +14 -0
- package/examples/basic/client/layout.tsx +14 -0
- package/examples/basic/client/routes/about.tsx +13 -0
- package/examples/basic/client/routes/blog/[id].tsx +14 -0
- package/examples/basic/client/routes/docs/[...slug].tsx +15 -0
- package/examples/basic/client/routes/index.tsx +13 -0
- package/examples/basic/client/routes/io.tsx +28 -0
- package/examples/basic/eslint.config.js +3 -0
- package/examples/basic/package.json +24 -0
- package/examples/basic/toil.config.ts +7 -0
- package/examples/basic/tsconfig.json +4 -0
- package/package.json +141 -0
- package/presets/eslint.js +77 -0
- package/presets/no-uint8array-tostring.js +201 -0
- package/presets/prettier.json +11 -0
- package/presets/tsconfig.json +37 -0
- package/src/backend/index.ts +167 -0
- package/src/cli/create.ts +272 -0
- package/src/cli/index.ts +161 -0
- package/src/cli/ui.ts +79 -0
- package/src/client/channel.ts +146 -0
- package/src/client/index.ts +12 -0
- package/src/client/match.ts +39 -0
- package/src/client/runtime.tsx +190 -0
- package/src/compiler/config.ts +115 -0
- package/src/compiler/generate.ts +91 -0
- package/src/compiler/index.ts +49 -0
- package/src/compiler/plugin.ts +26 -0
- package/src/compiler/routes.ts +70 -0
- package/src/compiler/vite.ts +90 -0
- package/src/io/BinaryReader.ts +344 -0
- package/src/io/BinaryWriter.ts +385 -0
- package/src/io/FastMap.ts +127 -0
- package/src/io/FastSet.ts +96 -0
- package/src/io/index.ts +11 -0
- package/src/io/lengths.ts +14 -0
- package/src/io/types.ts +18 -0
- package/src/logger/index.ts +22 -0
- package/src/server/index.ts +11 -0
- package/src/server/main.ts +13 -0
- package/src/shared/index.ts +10 -0
- package/std/client/index.d.ts +15 -0
- package/std/client/package.json +3 -0
- package/test/channel.test.ts +21 -0
- package/test/io.test.ts +85 -0
- package/test/placeholder.test.ts +9 -0
- package/test/routes.test.ts +42 -0
- package/tests/server/example.spec.ts +7 -0
- package/toilconfig.json +30 -0
- package/tsconfig.backend.json +13 -0
- package/tsconfig.base.json +35 -0
- package/tsconfig.cli.json +13 -0
- package/tsconfig.client.json +14 -0
- package/tsconfig.compiler.json +13 -0
- package/tsconfig.io.json +12 -0
- package/tsconfig.json +22 -0
- package/tsconfig.logger.json +12 -0
- package/tsconfig.server.json +10 -0
- package/tsconfig.shared.json +12 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `toiljs create` — an interactive project scaffolder (Clack-powered) that wires a new
|
|
3
|
+
* app to the enforced toiljs presets (tsconfig / eslint / prettier) and file-based routing.
|
|
4
|
+
* Supports a non-interactive path via flags (`--yes`, `--template`, …) for scripting/CI.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { intro, outro, text, select, confirm, isCancel, cancel, spinner, note } from '@clack/prompts';
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
|
|
13
|
+
import { accent, dim, version } from './ui.js';
|
|
14
|
+
|
|
15
|
+
export type Template = 'app' | 'minimal';
|
|
16
|
+
|
|
17
|
+
/** A selectable template in the `create` wizard. */
|
|
18
|
+
interface TemplateOption {
|
|
19
|
+
readonly value: Template;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly hint: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreateOptions {
|
|
25
|
+
readonly name?: string;
|
|
26
|
+
readonly template?: Template;
|
|
27
|
+
readonly install?: boolean;
|
|
28
|
+
readonly git?: boolean;
|
|
29
|
+
readonly pm?: string;
|
|
30
|
+
readonly yes?: boolean;
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Aborts the wizard cleanly on Ctrl-C / cancel, narrowing the prompt result to its value type. */
|
|
35
|
+
function bail<T>(value: T | symbol): asserts value is T {
|
|
36
|
+
if (isCancel(value)) {
|
|
37
|
+
cancel('Scaffolding cancelled.');
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isValidName(name: string): true | string {
|
|
43
|
+
if (!name.trim()) return 'Please enter a project name.';
|
|
44
|
+
if (!/^[a-z0-9._@/-]+$/i.test(name)) return 'Use letters, numbers, dashes, dots or slashes.';
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function isEmptyDir(dir: string): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
const entries = await fs.readdir(dir);
|
|
51
|
+
return entries.length === 0;
|
|
52
|
+
} catch {
|
|
53
|
+
return true; // doesn't exist yet
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Builds the full file map (relative path → contents) for a scaffolded project. */
|
|
58
|
+
function scaffold(name: string, template: Template): Record<string, string> {
|
|
59
|
+
const toilVersion = version();
|
|
60
|
+
const pkg = {
|
|
61
|
+
name: path.basename(name),
|
|
62
|
+
private: true,
|
|
63
|
+
type: 'module',
|
|
64
|
+
scripts: {
|
|
65
|
+
dev: 'toiljs dev',
|
|
66
|
+
build: 'toiljs build',
|
|
67
|
+
lint: 'eslint client',
|
|
68
|
+
typecheck: 'tsc --noEmit',
|
|
69
|
+
format: 'prettier --write "client/**/*.{ts,tsx}"',
|
|
70
|
+
},
|
|
71
|
+
dependencies: {
|
|
72
|
+
toiljs: `^${toilVersion}`,
|
|
73
|
+
react: '^19.2.6',
|
|
74
|
+
'react-dom': '^19.2.6',
|
|
75
|
+
},
|
|
76
|
+
devDependencies: {
|
|
77
|
+
'@types/react': '^19.2.15',
|
|
78
|
+
'@types/react-dom': '^19.2.3',
|
|
79
|
+
eslint: '^10.2.0',
|
|
80
|
+
prettier: '^3.8.1',
|
|
81
|
+
typescript: '^6.0.3',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const files: Record<string, string> = {
|
|
86
|
+
'package.json': JSON.stringify(pkg, null, 4) + '\n',
|
|
87
|
+
'toil.config.ts':
|
|
88
|
+
"import { defineConfig } from 'toiljs/compiler';\n\n" +
|
|
89
|
+
'export default defineConfig({\n client: {\n outDir: \'dist\',\n },\n});\n',
|
|
90
|
+
'tsconfig.json':
|
|
91
|
+
'{\n "extends": "toiljs/tsconfig",\n "include": ["client", "toil-env.d.ts"]\n}\n',
|
|
92
|
+
'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
|
|
93
|
+
'.prettierrc': '"toiljs/prettier"\n',
|
|
94
|
+
'.gitignore': 'node_modules\ndist\n.toil\ntoil-env.d.ts\n',
|
|
95
|
+
'README.md': ['# ' + path.basename(name), '', 'A [toiljs](https://toil.org) app.', '', '## Develop', '', ' npm install', ' npm run dev', '', '## Build', '', ' npm run build', ''].join('\n'),
|
|
96
|
+
'client/layout.tsx':
|
|
97
|
+
"import { type ReactNode } from 'react';\n\n" +
|
|
98
|
+
"import { Link } from 'toiljs/client';\n\n" +
|
|
99
|
+
'export default function Layout({ children }: { children?: ReactNode }) {\n' +
|
|
100
|
+
' return (\n' +
|
|
101
|
+
" <div style={{ fontFamily: 'system-ui', maxWidth: 640, margin: '2rem auto' }}>\n" +
|
|
102
|
+
" <header style={{ borderBottom: '1px solid #ddd', paddingBottom: 8, marginBottom: 16 }}>\n" +
|
|
103
|
+
' <strong>' + path.basename(name) + '</strong> — <Link href="/">home</Link>' +
|
|
104
|
+
(template === 'app' ? ' · <Link href="/about">about</Link>' : '') +
|
|
105
|
+
'\n </header>\n {children}\n </div>\n );\n}\n',
|
|
106
|
+
'client/routes/index.tsx':
|
|
107
|
+
"import { Link } from 'toiljs/client';\n\n" +
|
|
108
|
+
'export default function Home() {\n' +
|
|
109
|
+
' return (\n <main>\n' +
|
|
110
|
+
' <h1>Welcome to toiljs</h1>\n' +
|
|
111
|
+
' <p>File-based routing, bundled by Vite, zero config.</p>\n' +
|
|
112
|
+
(template === 'app'
|
|
113
|
+
? ' <p>\n <Link href="/about">About</Link> · <Link href="/blog/42">Blog post 42</Link>\n </p>\n'
|
|
114
|
+
: '') +
|
|
115
|
+
' </main>\n );\n}\n',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (template === 'app') {
|
|
119
|
+
files['client/routes/about.tsx'] =
|
|
120
|
+
"import { Link } from 'toiljs/client';\n\n" +
|
|
121
|
+
'export default function About() {\n' +
|
|
122
|
+
' return (\n <main>\n <h1>About</h1>\n' +
|
|
123
|
+
' <p>\n This page is served by <code>client/routes/about.tsx</code>.\n </p>\n' +
|
|
124
|
+
' <Link href="/">Back home</Link>\n </main>\n );\n}\n';
|
|
125
|
+
files['client/routes/blog/[id].tsx'] =
|
|
126
|
+
"import { Link, useParams } from 'toiljs/client';\n\n" +
|
|
127
|
+
'export default function BlogPost() {\n' +
|
|
128
|
+
' const { id } = useParams();\n' +
|
|
129
|
+
' return (\n <main>\n <h1>Blog post {id}</h1>\n' +
|
|
130
|
+
' <p>\n Dynamic route from <code>client/routes/blog/[id].tsx</code>.\n </p>\n' +
|
|
131
|
+
' <Link href="/">Back home</Link>\n </main>\n );\n}\n';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function writeFiles(dir: string, files: Record<string, string>): Promise<void> {
|
|
138
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
139
|
+
const full = path.join(dir, rel);
|
|
140
|
+
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
141
|
+
await fs.writeFile(full, contents, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(cmd: string, args: string[], cwd: string): Promise<void> {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const child = spawn(cmd, args, { cwd, stdio: 'ignore', shell: process.platform === 'win32' });
|
|
148
|
+
child.on('error', reject);
|
|
149
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${String(code)}`))));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Runs the create flow (interactive unless `--yes`). */
|
|
154
|
+
export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
155
|
+
intro(accent(' toiljs create '));
|
|
156
|
+
|
|
157
|
+
// 1. Project name
|
|
158
|
+
let name = opts.name;
|
|
159
|
+
if (!name) {
|
|
160
|
+
if (opts.yes) {
|
|
161
|
+
name = 'my-toil-app';
|
|
162
|
+
} else {
|
|
163
|
+
const answer = await text({
|
|
164
|
+
message: 'Project name',
|
|
165
|
+
placeholder: 'my-toil-app',
|
|
166
|
+
defaultValue: 'my-toil-app',
|
|
167
|
+
validate: (v) => {
|
|
168
|
+
const result = isValidName(v || 'my-toil-app');
|
|
169
|
+
return result === true ? undefined : result;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
bail(answer);
|
|
173
|
+
name = answer.trim() || 'my-toil-app';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const valid = isValidName(name);
|
|
177
|
+
if (valid !== true) {
|
|
178
|
+
cancel(valid);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const targetDir = path.resolve(opts.cwd, name);
|
|
183
|
+
const rel = path.relative(opts.cwd, targetDir) || '.';
|
|
184
|
+
|
|
185
|
+
// 2. Guard against clobbering a non-empty dir
|
|
186
|
+
if (!(await isEmptyDir(targetDir))) {
|
|
187
|
+
if (opts.yes) {
|
|
188
|
+
cancel(`Directory ${pc.cyan(rel)} is not empty.`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
const proceed = await confirm({
|
|
192
|
+
message: `Directory ${pc.cyan(rel)} is not empty. Scaffold into it anyway?`,
|
|
193
|
+
initialValue: false,
|
|
194
|
+
});
|
|
195
|
+
bail(proceed);
|
|
196
|
+
if (!proceed) {
|
|
197
|
+
cancel('Scaffolding cancelled.');
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 3. Template
|
|
203
|
+
let template: Template = opts.template ?? 'app';
|
|
204
|
+
if (!opts.template && !opts.yes) {
|
|
205
|
+
const templateOptions: TemplateOption[] = [
|
|
206
|
+
{ value: 'app', label: 'App', hint: 'layout + home/about + a dynamic /blog/[id] route' },
|
|
207
|
+
{ value: 'minimal', label: 'Minimal', hint: 'just a layout and a home route' },
|
|
208
|
+
];
|
|
209
|
+
const choice = await select({ message: 'Which template?', options: templateOptions, initialValue: 'app' });
|
|
210
|
+
bail(choice);
|
|
211
|
+
template = choice === 'minimal' ? 'minimal' : 'app';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Options: git + install
|
|
215
|
+
let initGit = opts.git ?? false;
|
|
216
|
+
let install = opts.install ?? false;
|
|
217
|
+
const pm = opts.pm ?? 'npm';
|
|
218
|
+
if (!opts.yes) {
|
|
219
|
+
if (opts.git === undefined) {
|
|
220
|
+
const g = await confirm({ message: 'Initialize a git repository?', initialValue: true });
|
|
221
|
+
bail(g);
|
|
222
|
+
initGit = g;
|
|
223
|
+
}
|
|
224
|
+
if (opts.install === undefined) {
|
|
225
|
+
const i = await confirm({ message: 'Install dependencies now?', initialValue: false });
|
|
226
|
+
bail(i);
|
|
227
|
+
install = i;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 5. Scaffold
|
|
232
|
+
const s = spinner();
|
|
233
|
+
s.start('Scaffolding project');
|
|
234
|
+
await writeFiles(targetDir, scaffold(name, template));
|
|
235
|
+
s.stop(`Scaffolded ${pc.cyan(rel)}`);
|
|
236
|
+
|
|
237
|
+
// 6. git init (best-effort)
|
|
238
|
+
if (initGit) {
|
|
239
|
+
const g = spinner();
|
|
240
|
+
g.start('Initializing git repository');
|
|
241
|
+
try {
|
|
242
|
+
await run('git', ['init', '-q'], targetDir);
|
|
243
|
+
await run('git', ['add', '-A'], targetDir);
|
|
244
|
+
g.stop('Initialized git repository');
|
|
245
|
+
} catch {
|
|
246
|
+
g.stop(pc.yellow('Skipped git init (git not available)'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 7. Install (best-effort)
|
|
251
|
+
if (install) {
|
|
252
|
+
const i = spinner();
|
|
253
|
+
i.start(`Installing dependencies with ${pm}`);
|
|
254
|
+
try {
|
|
255
|
+
await run(pm, ['install'], targetDir);
|
|
256
|
+
i.stop('Installed dependencies');
|
|
257
|
+
} catch {
|
|
258
|
+
i.stop(pc.yellow(`Could not install with ${pm} — run it yourself later`));
|
|
259
|
+
install = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 8. Next steps
|
|
264
|
+
const steps: string[] = [];
|
|
265
|
+
if (rel !== '.') steps.push(`cd ${rel}`);
|
|
266
|
+
if (!install) steps.push('npm install');
|
|
267
|
+
steps.push(`${accent('npm run dev')} ${dim('start the dev server')}`);
|
|
268
|
+
steps.push(`${accent('npm run build')} ${dim('build for production')}`);
|
|
269
|
+
note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
|
|
270
|
+
|
|
271
|
+
outro(`Created ${accent(path.basename(name))} — happy building! ${dim('· v' + version())}`);
|
|
272
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* toiljs CLI. Routes `create` / `dev` / `build` and wraps them in the toiljs brand banner.
|
|
4
|
+
* The compiler stays presentation-free (imported via the package's own `toiljs/compiler`
|
|
5
|
+
* export); the epic bits — banner, the Clack scaffolding wizard — live here.
|
|
6
|
+
*/
|
|
7
|
+
import { build, dev, start } from 'toiljs/compiler';
|
|
8
|
+
|
|
9
|
+
import { runCreate, type Template } from './create.js';
|
|
10
|
+
import { accent, banner, bold, dim, version } from './ui.js';
|
|
11
|
+
|
|
12
|
+
interface Flags {
|
|
13
|
+
root?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
name?: string;
|
|
16
|
+
template?: Template;
|
|
17
|
+
install?: boolean;
|
|
18
|
+
git?: boolean;
|
|
19
|
+
pm?: string;
|
|
20
|
+
yes?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv: string[]): Flags {
|
|
24
|
+
const flags: Flags = {};
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const arg = argv[i];
|
|
27
|
+
switch (arg) {
|
|
28
|
+
case '--root':
|
|
29
|
+
flags.root = argv[++i];
|
|
30
|
+
break;
|
|
31
|
+
case '--port':
|
|
32
|
+
flags.port = Number(argv[++i]);
|
|
33
|
+
break;
|
|
34
|
+
case '--template':
|
|
35
|
+
case '-t': {
|
|
36
|
+
const t = argv[++i];
|
|
37
|
+
if (t === 'app' || t === 'minimal') flags.template = t;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case '--pm':
|
|
41
|
+
flags.pm = argv[++i];
|
|
42
|
+
break;
|
|
43
|
+
case '--install':
|
|
44
|
+
flags.install = true;
|
|
45
|
+
break;
|
|
46
|
+
case '--no-install':
|
|
47
|
+
flags.install = false;
|
|
48
|
+
break;
|
|
49
|
+
case '--git':
|
|
50
|
+
flags.git = true;
|
|
51
|
+
break;
|
|
52
|
+
case '--no-git':
|
|
53
|
+
flags.git = false;
|
|
54
|
+
break;
|
|
55
|
+
case '-y':
|
|
56
|
+
case '--yes':
|
|
57
|
+
flags.yes = true;
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
// First bare (non-flag) token is the positional project name.
|
|
61
|
+
if (!arg.startsWith('-') && flags.name === undefined) flags.name = arg;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return flags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printHelp(): void {
|
|
68
|
+
const cmd = (name: string, desc: string): string => ` ${accent(name.padEnd(15))}${dim(desc)}`;
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
[
|
|
71
|
+
`${bold('Usage')} ${dim('toiljs')} <command> [options]`,
|
|
72
|
+
'',
|
|
73
|
+
bold('Commands'),
|
|
74
|
+
cmd('create [name]', 'scaffold a new toiljs app'),
|
|
75
|
+
cmd('dev', 'start the dev server with HMR'),
|
|
76
|
+
cmd('build', 'build the optimized production bundle'),
|
|
77
|
+
cmd('start', 'self-host the built app (hyper-express / uWS)'),
|
|
78
|
+
'',
|
|
79
|
+
bold('Options'),
|
|
80
|
+
cmd('--root <dir>', 'project root (default: current directory)'),
|
|
81
|
+
cmd('--port <n>', 'dev server port'),
|
|
82
|
+
cmd('-t, --template', 'create: app | minimal'),
|
|
83
|
+
cmd('-y, --yes', 'create: accept defaults (non-interactive)'),
|
|
84
|
+
cmd('--no-install', "create: don't install dependencies"),
|
|
85
|
+
cmd('-v, --version', 'print the toiljs version'),
|
|
86
|
+
'',
|
|
87
|
+
].join('\n') + '\n',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main(): Promise<void> {
|
|
92
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
93
|
+
|
|
94
|
+
if (command === '--version' || command === '-v') {
|
|
95
|
+
process.stdout.write(version() + '\n');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const flags = parseArgs(rest);
|
|
100
|
+
|
|
101
|
+
switch (command) {
|
|
102
|
+
case 'create':
|
|
103
|
+
banner();
|
|
104
|
+
await runCreate({
|
|
105
|
+
name: flags.name,
|
|
106
|
+
template: flags.template,
|
|
107
|
+
install: flags.install,
|
|
108
|
+
git: flags.git,
|
|
109
|
+
pm: flags.pm,
|
|
110
|
+
yes: flags.yes,
|
|
111
|
+
cwd: process.cwd(),
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'dev':
|
|
116
|
+
banner();
|
|
117
|
+
process.stdout.write(dim(' starting dev server…') + '\n\n');
|
|
118
|
+
await dev({ root: flags.root, port: flags.port });
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case 'build':
|
|
122
|
+
banner();
|
|
123
|
+
process.stdout.write(dim(' building for production…') + '\n\n');
|
|
124
|
+
await build({ root: flags.root });
|
|
125
|
+
process.stdout.write('\n' + accent(' ✓ ') + bold('build complete') + '\n\n');
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case 'start': {
|
|
129
|
+
banner();
|
|
130
|
+
process.stdout.write(dim(' self-hosting the built app…') + '\n\n');
|
|
131
|
+
const server = await start({ root: flags.root, port: flags.port });
|
|
132
|
+
process.stdout.write(
|
|
133
|
+
accent(' ➜ ') +
|
|
134
|
+
bold(`http://localhost:${String(server.port)}`) +
|
|
135
|
+
dim(` ws channel: ${server.wsPath}`) +
|
|
136
|
+
'\n\n',
|
|
137
|
+
);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'help':
|
|
142
|
+
case '--help':
|
|
143
|
+
case '-h':
|
|
144
|
+
case undefined:
|
|
145
|
+
banner();
|
|
146
|
+
printHelp();
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
banner();
|
|
151
|
+
process.stdout.write(dim(` unknown command: ${command}`) + '\n\n');
|
|
152
|
+
printHelp();
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
main().catch((err: unknown) => {
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
process.stderr.write('\n' + accent(' ✗ ') + message + '\n');
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
});
|
package/src/cli/ui.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI presentation: the toiljs brand banner, gradient text, and small helpers.
|
|
3
|
+
* Kept dependency-light (only picocolors); the gradient is hand-rolled truecolor ANSI so
|
|
4
|
+
* the logo pops without pulling in a gradient library.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import pc from 'picocolors';
|
|
11
|
+
|
|
12
|
+
type RGB = readonly [number, number, number];
|
|
13
|
+
|
|
14
|
+
/** toiljs brand amber → deep gold. */
|
|
15
|
+
const FROM: RGB = [250, 204, 80];
|
|
16
|
+
const TO: RGB = [198, 112, 20];
|
|
17
|
+
|
|
18
|
+
/** ANSI-shadow "TOIL" wordmark. */
|
|
19
|
+
const ART: readonly string[] = [
|
|
20
|
+
'████████╗ ██████╗ ██╗ ██╗ ',
|
|
21
|
+
'╚══██╔══╝ ██╔═══██╗ ██║ ██║ ',
|
|
22
|
+
' ██║ ██║ ██║ ██║ ██║ ',
|
|
23
|
+
' ██║ ██║ ██║ ██║ ██║ ',
|
|
24
|
+
' ██║ ╚██████╔╝ ██║ ███████╗',
|
|
25
|
+
' ╚═╝ ╚═════╝ ╚═╝ ╚══════╝',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const dim = pc.dim;
|
|
29
|
+
export const bold = pc.bold;
|
|
30
|
+
|
|
31
|
+
/** True when we should emit ANSI color (a TTY, and not disabled via NO_COLOR). */
|
|
32
|
+
function colorEnabled(): boolean {
|
|
33
|
+
return process.stdout.isTTY && !process.env.NO_COLOR;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The amber brand accent (truecolor). No-ops to plain text when color is disabled. */
|
|
37
|
+
export function brand(s: string): string {
|
|
38
|
+
return colorEnabled() ? `\x1b[38;2;203;152;32m${s}\x1b[39m` : s;
|
|
39
|
+
}
|
|
40
|
+
export const accent = brand;
|
|
41
|
+
|
|
42
|
+
function lerp(a: number, b: number, t: number): number {
|
|
43
|
+
return Math.round(a + (b - a) * t);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Colors each character of `line` along a left→right truecolor gradient. */
|
|
47
|
+
function gradientLine(line: string): string {
|
|
48
|
+
const n = line.length;
|
|
49
|
+
let out = '';
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
const t = n > 1 ? i / (n - 1) : 0;
|
|
52
|
+
const r = lerp(FROM[0], TO[0], t);
|
|
53
|
+
const g = lerp(FROM[1], TO[1], t);
|
|
54
|
+
const b = lerp(FROM[2], TO[2], t);
|
|
55
|
+
out += `\x1b[38;2;${r};${g};${b}m${line[i]}`;
|
|
56
|
+
}
|
|
57
|
+
return out + '\x1b[39m';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reads the toiljs package version (CLI lives at build/cli/, package root is two up). */
|
|
61
|
+
export function version(): string {
|
|
62
|
+
try {
|
|
63
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
64
|
+
const raw = fs.readFileSync(pkgPath, 'utf8');
|
|
65
|
+
const match = /"version"\s*:\s*"([^"]+)"/.exec(raw);
|
|
66
|
+
if (match && match[1]) return match[1];
|
|
67
|
+
} catch {
|
|
68
|
+
/* fall through to default */
|
|
69
|
+
}
|
|
70
|
+
return '0.0.0';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Prints the brand banner: gradient logo + tagline + version. */
|
|
74
|
+
export function banner(): void {
|
|
75
|
+
const lines = colorEnabled() ? ART.map(gradientLine) : ART.slice();
|
|
76
|
+
const tagline = ` the full-stack ${brand('WebAssembly')} framework`;
|
|
77
|
+
const ver = `${dim(' v')}${brand(version())}`;
|
|
78
|
+
process.stdout.write('\n' + lines.join('\n') + '\n\n' + tagline + ' ' + ver + '\n\n');
|
|
79
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the toil backend's WebSocket channel (served by the hyper-express/uWS backend at
|
|
3
|
+
* `/_toil`). Supports text and binary (`ArrayBuffer`) frames, auto-reconnect, and a React hook.
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
/** A frame received from / sent to the channel. */
|
|
8
|
+
export type ChannelData = string | ArrayBuffer;
|
|
9
|
+
|
|
10
|
+
/** Whatever `WebSocket.send` accepts (string / BufferSource / Blob), per the DOM lib. */
|
|
11
|
+
export type SendData = Parameters<WebSocket['send']>[0];
|
|
12
|
+
|
|
13
|
+
export interface ChannelOptions {
|
|
14
|
+
/** Channel path on the toil backend. Default `/_toil`. */
|
|
15
|
+
readonly path?: string;
|
|
16
|
+
/** Full `ws(s)://` URL override (takes precedence over `path`). */
|
|
17
|
+
readonly url?: string;
|
|
18
|
+
/** Auto-reconnect after an unexpected close. Default `true`. */
|
|
19
|
+
readonly reconnect?: boolean;
|
|
20
|
+
/** Reconnect delay in ms. Default `1000`. */
|
|
21
|
+
readonly reconnectDelay?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Channel {
|
|
25
|
+
/** Sends a text or binary frame (no-op until the socket is open). */
|
|
26
|
+
send(data: SendData): void;
|
|
27
|
+
/** Closes the channel and stops reconnecting. */
|
|
28
|
+
close(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Derives the channel's `ws(s)://` URL from the current page location. */
|
|
32
|
+
export function resolveChannelUrl(
|
|
33
|
+
path: string = '/_toil',
|
|
34
|
+
location: { protocol: string; host: string } = window.location,
|
|
35
|
+
): string {
|
|
36
|
+
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
37
|
+
const normalized = path.startsWith('/') ? path : `/${path}`;
|
|
38
|
+
return `${scheme}//${location.host}${normalized}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Opens a channel to the backend, invoking `onMessage` for each frame. Reconnects on unexpected
|
|
43
|
+
* close unless disabled. Returns a handle to `send()` and `close()`.
|
|
44
|
+
*/
|
|
45
|
+
export function connectChannel(
|
|
46
|
+
onMessage: (data: ChannelData) => void,
|
|
47
|
+
options: ChannelOptions = {},
|
|
48
|
+
): Channel {
|
|
49
|
+
const url = options.url ?? resolveChannelUrl(options.path);
|
|
50
|
+
const reconnect = options.reconnect ?? true;
|
|
51
|
+
const delay = options.reconnectDelay ?? 1000;
|
|
52
|
+
|
|
53
|
+
let socket: WebSocket | null = null;
|
|
54
|
+
let stopped = false;
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
|
|
57
|
+
const open = (): void => {
|
|
58
|
+
const ws = new WebSocket(url);
|
|
59
|
+
ws.binaryType = 'arraybuffer';
|
|
60
|
+
socket = ws;
|
|
61
|
+
ws.addEventListener('message', (event: MessageEvent) => {
|
|
62
|
+
if (typeof event.data === 'string') onMessage(event.data);
|
|
63
|
+
else if (event.data instanceof ArrayBuffer) onMessage(event.data);
|
|
64
|
+
});
|
|
65
|
+
ws.addEventListener('close', () => {
|
|
66
|
+
if (!stopped && reconnect) timer = setTimeout(open, delay);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
open();
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
send: (data: SendData): void => {
|
|
73
|
+
if (socket && socket.readyState === WebSocket.OPEN) socket.send(data);
|
|
74
|
+
},
|
|
75
|
+
close: (): void => {
|
|
76
|
+
stopped = true;
|
|
77
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
78
|
+
socket?.close();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ChannelHook {
|
|
84
|
+
/** Whether the socket is currently open. */
|
|
85
|
+
readonly connected: boolean;
|
|
86
|
+
/** Frames received so far, in order. */
|
|
87
|
+
readonly messages: ChannelData[];
|
|
88
|
+
/** Sends a text or binary frame. */
|
|
89
|
+
send: (data: SendData) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* React hook wrapping {@link connectChannel}: connects on mount, tracks `connected` state and the
|
|
94
|
+
* received `messages`, and cleans up on unmount.
|
|
95
|
+
*/
|
|
96
|
+
export function useChannel(options: ChannelOptions = {}): ChannelHook {
|
|
97
|
+
const { path, url, reconnect, reconnectDelay } = options;
|
|
98
|
+
const [connected, setConnected] = useState<boolean>(false);
|
|
99
|
+
const [messages, setMessages] = useState<ChannelData[]>([]);
|
|
100
|
+
const socketRef = useRef<WebSocket | null>(null);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const target = url ?? resolveChannelUrl(path);
|
|
104
|
+
const shouldReconnect = reconnect ?? true;
|
|
105
|
+
const delay = reconnectDelay ?? 1000;
|
|
106
|
+
let stopped = false;
|
|
107
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
|
|
109
|
+
const open = (): void => {
|
|
110
|
+
const ws = new WebSocket(target);
|
|
111
|
+
ws.binaryType = 'arraybuffer';
|
|
112
|
+
socketRef.current = ws;
|
|
113
|
+
ws.addEventListener('open', () => {
|
|
114
|
+
if (!stopped) setConnected(true);
|
|
115
|
+
});
|
|
116
|
+
ws.addEventListener('message', (event: MessageEvent) => {
|
|
117
|
+
if (typeof event.data === 'string') {
|
|
118
|
+
const data = event.data;
|
|
119
|
+
setMessages((prev) => [...prev, data]);
|
|
120
|
+
} else if (event.data instanceof ArrayBuffer) {
|
|
121
|
+
const data = event.data;
|
|
122
|
+
setMessages((prev) => [...prev, data]);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
ws.addEventListener('close', () => {
|
|
126
|
+
if (stopped) return;
|
|
127
|
+
setConnected(false);
|
|
128
|
+
if (shouldReconnect) timer = setTimeout(open, delay);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
open();
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
stopped = true;
|
|
135
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
136
|
+
socketRef.current?.close();
|
|
137
|
+
};
|
|
138
|
+
}, [path, url, reconnect, reconnectDelay]);
|
|
139
|
+
|
|
140
|
+
const send = useCallback((data: SendData): void => {
|
|
141
|
+
const socket = socketRef.current;
|
|
142
|
+
if (socket && socket.readyState === WebSocket.OPEN) socket.send(data);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return { connected, messages, send };
|
|
146
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toiljs client runtime, published as `toiljs/client`. Provides the router (mount/Router/Link),
|
|
3
|
+
* navigation hooks, and route types consumed by the compiler-generated entry. Zero imports
|
|
4
|
+
* needed in user route files beyond this package.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { mount, Router, Link, navigate, useParams, useNavigate, useLocation } from './runtime.js';
|
|
8
|
+
export type { RouteDef, LayoutLoader, NotFoundLoader } from './runtime.js';
|
|
9
|
+
export { matchRoute } from './match.js';
|
|
10
|
+
export type { RouteParams } from './match.js';
|
|
11
|
+
export { connectChannel, useChannel, resolveChannelUrl } from './channel.js';
|
|
12
|
+
export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel.js';
|