@webjsdev/cli 0.10.29 → 0.10.31
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/bin/webjs.js +1 -1
- package/lib/api-gallery.js +229 -0
- package/lib/create.js +452 -158
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +70 -2
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.cursorrules +41 -2
- package/templates/.github/copilot-instructions.md +41 -2
- package/templates/AGENTS.md +160 -10
- package/templates/CONVENTIONS.md +150 -11
- package/templates/gallery/app/examples/todo/page.ts +34 -0
- package/templates/gallery/app/features/async-render/page.ts +14 -0
- package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
- package/templates/gallery/app/features/broadcast/page.ts +24 -0
- package/templates/gallery/app/features/caching/page.ts +39 -0
- package/templates/gallery/app/features/client-router/page.ts +34 -0
- package/templates/gallery/app/features/client-router/second/page.ts +20 -0
- package/templates/gallery/app/features/components/page.ts +14 -0
- package/templates/gallery/app/features/directives/page.ts +14 -0
- package/templates/gallery/app/features/env/page.ts +36 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
- package/templates/gallery/app/features/file-storage/page.ts +62 -0
- package/templates/gallery/app/features/forms/page.ts +72 -0
- package/templates/gallery/app/features/metadata/page.ts +55 -0
- package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
- package/templates/gallery/app/features/rate-limit/page.ts +29 -0
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
- package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/app/features/route-handler/page.ts +13 -0
- package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
- package/templates/gallery/app/features/routing/page.ts +47 -0
- package/templates/gallery/app/features/server-actions/page.ts +14 -0
- package/templates/gallery/app/features/service-worker/page.ts +35 -0
- package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
- package/templates/gallery/app/features/websockets/page.ts +25 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
- package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
- package/templates/gallery/modules/components/components/counter-card.ts +35 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
- package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
- package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
- package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
- package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
- package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
- package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +19 -0
- package/templates/gallery/modules/todo/types.ts +12 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
- package/templates/lib/utils/ui.ts +4 -4
- package/templates/test/hello/browser/hello.test.js +12 -6
- package/templates/test/hello/e2e/hello.test.ts +11 -9
- package/templates/test/hello/hello.test.ts +4 -6
- package/templates/web-test-runner.config.js +84 -9
package/bin/webjs.js
CHANGED
|
@@ -524,7 +524,7 @@ components/schema with the actual app the user requested. Use Drizzle +
|
|
|
524
524
|
SQLite for persistence (already wired up). Never store app data in JSON
|
|
525
525
|
files.
|
|
526
526
|
|
|
527
|
-
Full docs: https://docs.webjs.
|
|
527
|
+
Full docs: https://docs.webjs.dev`);
|
|
528
528
|
process.exit(1);
|
|
529
529
|
}
|
|
530
530
|
const noInstall = rest.includes('--no-install');
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-features showcase for `webjs create --template api`.
|
|
3
|
+
* A set of JSON/HTTP endpoints under `app/api/features/` that demonstrate the
|
|
4
|
+
* backend capabilities an API app uses (the api counterpart of the UI gallery).
|
|
5
|
+
* Extracted here (like saas-template.js) to keep create.js readable and dodge
|
|
6
|
+
* nested-template-literal escaping: files are built from arrays of
|
|
7
|
+
* double-quoted strings, so `${...}` and backticks are emitted literally.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write the api backend-features gallery into `<appDir>/app/api/features/**`
|
|
15
|
+
* plus a boot-time env-validation example at `app/env.ts`. Each demo carries a
|
|
16
|
+
* `webjs-scaffold-placeholder` marker so `webjs check` fails until it is
|
|
17
|
+
* pruned or adapted.
|
|
18
|
+
* @param {string} appDir
|
|
19
|
+
*/
|
|
20
|
+
export async function writeApiGallery(appDir) {
|
|
21
|
+
const feat = (...p) => join(appDir, 'app', 'api', 'features', ...p);
|
|
22
|
+
|
|
23
|
+
// 1) route() adapter + input validation.
|
|
24
|
+
await mkdir(join(appDir, 'modules', 'widgets', 'actions'), { recursive: true });
|
|
25
|
+
await writeFile(join(appDir, 'modules', 'widgets', 'actions', 'create-widget.server.ts'), [
|
|
26
|
+
"'use server';",
|
|
27
|
+
"",
|
|
28
|
+
"// A plain 'use server' mutation. Exposed over REST by the route() adapter in",
|
|
29
|
+
"// app/api/features/validate/route.ts, which merges the query + params + JSON",
|
|
30
|
+
"// body into one input, runs the boundary validator, and JSON-responds.",
|
|
31
|
+
"export async function createWidget(input: { name: string }) {",
|
|
32
|
+
" return { id: crypto.randomUUID(), name: input.name, createdAt: new Date().toISOString() };",
|
|
33
|
+
"}",
|
|
34
|
+
"",
|
|
35
|
+
].join('\n'));
|
|
36
|
+
await mkdir(feat('validate'), { recursive: true });
|
|
37
|
+
await writeFile(feat('validate', 'route.ts'), [
|
|
38
|
+
"// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/validate route AND modules/widgets), then delete this marker line. webjs check fails while the marker remains.",
|
|
39
|
+
"// route() turns a 'use server' action into a REST endpoint: it merges the URL",
|
|
40
|
+
"// query, route params, and JSON body into one input, runs `validate` at the",
|
|
41
|
+
"// boundary (a { success:false, fieldErrors } return is a 422, no action call),",
|
|
42
|
+
"// then JSON-responds the result. POST { \"name\": \"Gadget\" } to try it.",
|
|
43
|
+
"import { route } from '@webjsdev/server';",
|
|
44
|
+
"import { createWidget } from '#modules/widgets/actions/create-widget.server.ts';",
|
|
45
|
+
"",
|
|
46
|
+
"const validate = (input: { name?: unknown }) => {",
|
|
47
|
+
" const name = typeof input?.name === 'string' ? input.name.trim() : '';",
|
|
48
|
+
" if (!name) return { success: false as const, fieldErrors: { name: 'name is required' } };",
|
|
49
|
+
" return { success: true as const, data: { name } };",
|
|
50
|
+
"};",
|
|
51
|
+
"",
|
|
52
|
+
"// A GET returns usage so the endpoint is explorable in a browser; the real",
|
|
53
|
+
"// validated action is the POST below.",
|
|
54
|
+
"export async function GET(req: Request) {",
|
|
55
|
+
" const base = new URL(req.url).origin;",
|
|
56
|
+
" return Response.json({",
|
|
57
|
+
" method: 'POST',",
|
|
58
|
+
" usage: 'POST JSON { name } to validate the input and create a widget; an empty name returns a 422 with fieldErrors.',",
|
|
59
|
+
" example: 'curl -X POST -H content-type:application/json -d {\"name\":\"Gadget\"} ' + base + '/api/features/validate',",
|
|
60
|
+
" });",
|
|
61
|
+
"}",
|
|
62
|
+
"",
|
|
63
|
+
"export const POST = route(createWidget, { validate });",
|
|
64
|
+
"",
|
|
65
|
+
].join('\n'));
|
|
66
|
+
|
|
67
|
+
// 2) Rate limiting (middleware scoped to this endpoint).
|
|
68
|
+
await mkdir(feat('rate-limit'), { recursive: true });
|
|
69
|
+
await writeFile(feat('rate-limit', 'middleware.ts'), [
|
|
70
|
+
"// Per-segment middleware: it sits beside this route, so it rate-limits ONLY",
|
|
71
|
+
"// /api/features/rate-limit. rateLimit() is backed by the pluggable cache store",
|
|
72
|
+
"// (in-memory by default; point it at Redis to share the window across nodes).",
|
|
73
|
+
"import { rateLimit } from '@webjsdev/server';",
|
|
74
|
+
"",
|
|
75
|
+
"export default rateLimit({ window: '10s', max: 5, message: 'Slow down: five requests per ten seconds.' });",
|
|
76
|
+
"",
|
|
77
|
+
].join('\n'));
|
|
78
|
+
await writeFile(feat('rate-limit', 'route.ts'), [
|
|
79
|
+
"// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/rate-limit route), then delete this marker line. webjs check fails while the marker remains.",
|
|
80
|
+
"// The middleware.ts beside this file stamps X-RateLimit-* headers and returns",
|
|
81
|
+
"// a 429 with Retry-After once the window is exhausted, so this handler never",
|
|
82
|
+
"// runs on a limited request. Call it six times in ten seconds to see the 429.",
|
|
83
|
+
"export async function GET() {",
|
|
84
|
+
" return Response.json({ ok: true, at: new Date().toISOString() });",
|
|
85
|
+
"}",
|
|
86
|
+
"",
|
|
87
|
+
].join('\n'));
|
|
88
|
+
|
|
89
|
+
// 3) Streaming response (chunks flushed as produced, no buffering).
|
|
90
|
+
await mkdir(feat('stream'), { recursive: true });
|
|
91
|
+
await writeFile(feat('stream', 'route.ts'), [
|
|
92
|
+
"// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/stream route), then delete this marker line. webjs check fails while the marker remains.",
|
|
93
|
+
"// Streams JSON-per-line chunks as they are produced, so a slow or large",
|
|
94
|
+
"// result is delivered incrementally instead of buffered whole. A hand-written",
|
|
95
|
+
"// route.ts returns a ReadableStream for full control. Served as text/plain so",
|
|
96
|
+
"// it renders INLINE and incrementally in a browser (application/x-ndjson would",
|
|
97
|
+
"// make the browser download a file instead); `curl -N` shows the same lines",
|
|
98
|
+
"// arrive one at a time.",
|
|
99
|
+
"export async function GET() {",
|
|
100
|
+
" const encoder = new TextEncoder();",
|
|
101
|
+
" const stream = new ReadableStream({",
|
|
102
|
+
" async start(controller) {",
|
|
103
|
+
" for (let i = 1; i <= 5; i++) {",
|
|
104
|
+
" controller.enqueue(encoder.encode(JSON.stringify({ n: i, at: new Date().toISOString() }) + '\\n'));",
|
|
105
|
+
" await new Promise((r) => setTimeout(r, 200));",
|
|
106
|
+
" }",
|
|
107
|
+
" controller.close();",
|
|
108
|
+
" },",
|
|
109
|
+
" });",
|
|
110
|
+
" return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8', 'x-content-type-options': 'nosniff' } });",
|
|
111
|
+
"}",
|
|
112
|
+
"",
|
|
113
|
+
].join('\n'));
|
|
114
|
+
|
|
115
|
+
// 4) File storage (upload + serve), both hand-written route.ts.
|
|
116
|
+
await mkdir(feat('files', '[key]'), { recursive: true });
|
|
117
|
+
await writeFile(feat('files', 'route.ts'), [
|
|
118
|
+
"// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/files route), then delete this marker line. webjs check fails while the marker remains.",
|
|
119
|
+
"// POST a multipart file: the bytes stream into the FileStore (a local",
|
|
120
|
+
"// .webjs/uploads dir by default, gitignored; swap for S3/R2 with one",
|
|
121
|
+
"// setFileStore() call). Returns the key + a URL served by [key]/route.ts.",
|
|
122
|
+
"// Try: curl -F file=@README.md http://localhost:8080/api/features/files",
|
|
123
|
+
"import { getFileStore, generateKey } from '@webjsdev/server';",
|
|
124
|
+
"",
|
|
125
|
+
"// A GET on the upload endpoint returns usage, so it is explorable in a browser",
|
|
126
|
+
"// (the real upload is the POST below).",
|
|
127
|
+
"export async function GET(req: Request) {",
|
|
128
|
+
" const base = new URL(req.url).origin;",
|
|
129
|
+
" return Response.json({",
|
|
130
|
+
" method: 'POST',",
|
|
131
|
+
" usage: 'POST a multipart form with a `file` field to upload.',",
|
|
132
|
+
" example: 'curl -F file=@README.md ' + base + '/api/features/files',",
|
|
133
|
+
" serve: base + '/api/features/files/<key>',",
|
|
134
|
+
" });",
|
|
135
|
+
"}",
|
|
136
|
+
"",
|
|
137
|
+
"export async function POST(req: Request) {",
|
|
138
|
+
" const form = await req.formData();",
|
|
139
|
+
" const file = form.get('file');",
|
|
140
|
+
" if (!(file instanceof File) || file.size === 0) {",
|
|
141
|
+
" return Response.json({ error: 'no file provided' }, { status: 400 });",
|
|
142
|
+
" }",
|
|
143
|
+
" const key = generateKey(file.name);",
|
|
144
|
+
" const { size, contentType } = await getFileStore().put(key, file, { contentType: file.type });",
|
|
145
|
+
" const base = new URL(req.url).origin;",
|
|
146
|
+
" return Response.json({ key, size, contentType, url: `${base}/api/features/files/${key}` });",
|
|
147
|
+
"}",
|
|
148
|
+
"",
|
|
149
|
+
].join('\n'));
|
|
150
|
+
await writeFile(feat('files', '[key]', 'route.ts'), [
|
|
151
|
+
"// Serves a stored file back by key. getFileStore().get(key) returns the bytes",
|
|
152
|
+
"// as a web ReadableStream (streamed, never buffered whole) plus the recorded",
|
|
153
|
+
"// content type. The key is validated inside the store (traversal-safe).",
|
|
154
|
+
"import { getFileStore } from '@webjsdev/server';",
|
|
155
|
+
"",
|
|
156
|
+
"export async function GET(_req: Request, { params }: { params: { key: string } }) {",
|
|
157
|
+
" const file = await getFileStore().get(params.key);",
|
|
158
|
+
" if (!file) return new Response('Not found', { status: 404 });",
|
|
159
|
+
" return new Response(file.body as ReadableStream<Uint8Array>, {",
|
|
160
|
+
" headers: { 'content-type': file.contentType, 'content-length': String(file.size) },",
|
|
161
|
+
" });",
|
|
162
|
+
"}",
|
|
163
|
+
"",
|
|
164
|
+
].join('\n'));
|
|
165
|
+
|
|
166
|
+
// 5) WebSocket endpoint with broadcast fan-out.
|
|
167
|
+
await mkdir(feat('ws'), { recursive: true });
|
|
168
|
+
await writeFile(feat('ws', 'route.ts'), [
|
|
169
|
+
"// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/ws route), then delete this marker line. webjs check fails while the marker remains.",
|
|
170
|
+
"// A WebSocket endpoint: exporting WS(ws, req) upgrades this route to a socket.",
|
|
171
|
+
"// The framework auto-registers each connection to its path, so broadcast()",
|
|
172
|
+
"// fans a message out to every connected client. Connect two clients to",
|
|
173
|
+
"// ws://localhost:8080/api/features/ws and watch messages arrive in both.",
|
|
174
|
+
"import { broadcast } from '@webjsdev/server';",
|
|
175
|
+
"",
|
|
176
|
+
"// A plain GET (a browser opening the URL) returns usage; the live socket is",
|
|
177
|
+
"// the WS handler below, reached over ws:// with a WebSocket client.",
|
|
178
|
+
"export async function GET(req: Request) {",
|
|
179
|
+
" const base = new URL(req.url).origin.replace(/^http/, 'ws');",
|
|
180
|
+
" return Response.json({",
|
|
181
|
+
" protocol: 'WebSocket',",
|
|
182
|
+
" usage: 'Connect over ws:// and send a message; it is broadcast to every connected client.',",
|
|
183
|
+
" url: base + '/api/features/ws',",
|
|
184
|
+
" });",
|
|
185
|
+
"}",
|
|
186
|
+
"",
|
|
187
|
+
"type WSLike = {",
|
|
188
|
+
" on(event: 'message' | 'close', cb: (data: Buffer) => void): void;",
|
|
189
|
+
" send(msg: string): void;",
|
|
190
|
+
"};",
|
|
191
|
+
"",
|
|
192
|
+
"export function WS(ws: WSLike) {",
|
|
193
|
+
" ws.on('message', (data) => {",
|
|
194
|
+
" broadcast('/api/features/ws', data.toString());",
|
|
195
|
+
" });",
|
|
196
|
+
"}",
|
|
197
|
+
"",
|
|
198
|
+
].join('\n'));
|
|
199
|
+
|
|
200
|
+
// 6) Boot-time env validation (opt-in). env.ts lives at the app ROOT (sibling
|
|
201
|
+
// to middleware.ts), not inside app/. All-optional so it never blocks boot.
|
|
202
|
+
await writeFile(join(appDir, 'env.ts'), [
|
|
203
|
+
"// Boot-time env validation (opt-in, app-root env.ts). A schema of var name to",
|
|
204
|
+
"// type/options; webjs coerces values, applies defaults, and fails fast naming",
|
|
205
|
+
"// EVERY bad var. Kept all-optional here so it never blocks a fresh boot; make",
|
|
206
|
+
"// a var required by dropping `optional`.",
|
|
207
|
+
"export default {",
|
|
208
|
+
" DATABASE_URL: { type: 'string', optional: true },",
|
|
209
|
+
" PORT: { type: 'number', optional: true, default: 8080 },",
|
|
210
|
+
" NODE_ENV: { type: 'enum', values: ['development', 'production', 'test'], optional: true },",
|
|
211
|
+
"};",
|
|
212
|
+
"",
|
|
213
|
+
].join('\n'));
|
|
214
|
+
|
|
215
|
+
// A starter test for the validated action.
|
|
216
|
+
await writeFile(join(appDir, 'test', 'unit', 'widgets.test.ts'), [
|
|
217
|
+
"import { test } from 'node:test';",
|
|
218
|
+
"import assert from 'node:assert/strict';",
|
|
219
|
+
"",
|
|
220
|
+
"import { createWidget } from '#modules/widgets/actions/create-widget.server.ts';",
|
|
221
|
+
"",
|
|
222
|
+
"test('createWidget echoes the name and mints an id', async () => {",
|
|
223
|
+
" const w = await createWidget({ name: 'Gadget' });",
|
|
224
|
+
" assert.equal(w.name, 'Gadget');",
|
|
225
|
+
" assert.ok(w.id);",
|
|
226
|
+
"});",
|
|
227
|
+
"",
|
|
228
|
+
].join('\n'));
|
|
229
|
+
}
|