@webjsdev/cli 0.10.49 → 0.10.51
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/README.md +5 -3
- package/bin/webjs.js +133 -30
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +67 -18
- package/lib/doctor.js +479 -7
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +38 -6
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +17 -2
- package/templates/.agents/skills/webjs/references/components.md +16 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +42 -8
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +98 -1
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +35 -16
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +3 -1
- package/templates/.agents/skills/webjs/references/styling.md +30 -1
- package/templates/.agents/skills/webjs/references/testing.md +61 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +5 -17
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +33 -9
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/server-actions/page.ts +43 -0
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/auth/queries/current-user.server.ts +7 -5
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +2 -2
- package/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +20 -0
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/server-actions/components/clock-reader.ts +98 -0
- package/templates/gallery/modules/server-actions/queries/read-clock.server.ts +38 -0
- package/templates/gallery/modules/server-actions/utils/clock.server.ts +27 -0
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +4 -2
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/public/sw.js +11 -2
- package/templates/scripts/clear-gallery.mjs +11 -6
- package/templates/test/hello/e2e/hello.test.ts +18 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A mutation paired with the cached GET in ../queries/read-clock.server.ts. With
|
|
3
|
+
// no `method` export it defaults to POST (CSRF-protected, rich request body).
|
|
4
|
+
//
|
|
5
|
+
// `invalidates` lists the cache tags to evict when the action completes. The
|
|
6
|
+
// server drops those tags from its own cache() entries and reports them on the
|
|
7
|
+
// response, so the client coordinator marks them stale and the NEXT readClock()
|
|
8
|
+
// bypasses its browser-cached copy instead of serving a value the mutation just
|
|
9
|
+
// made wrong. Without this export the read would keep answering from cache until
|
|
10
|
+
// its max-age elapsed.
|
|
11
|
+
import type { ActionResult } from '@webjsdev/server';
|
|
12
|
+
import { bumpReading } from '../utils/clock.server.ts';
|
|
13
|
+
|
|
14
|
+
export const invalidates = () => ['clock'];
|
|
15
|
+
|
|
16
|
+
export async function bumpClock(): Promise<ActionResult<{ reading: number }>> {
|
|
17
|
+
// Mutations return the ActionResult envelope, so a caller narrows one shape
|
|
18
|
+
// whether the write succeeded or failed.
|
|
19
|
+
return { success: true, data: { reading: bumpReading() } };
|
|
20
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { test } from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
15
|
import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
|
|
16
|
-
import {
|
|
16
|
+
import { submitForm, loginAndGetCookies } from '@webjsdev/server/testing';
|
|
17
17
|
|
|
18
18
|
const appDir = process.cwd();
|
|
19
19
|
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
@@ -54,10 +54,11 @@ test('an authenticated greet reads the caller off the session via actionContext(
|
|
|
54
54
|
// migrated (run db:generate + db:migrate) rather than fail misleadingly.
|
|
55
55
|
const email = `greet+${Date.now()}@example.com`;
|
|
56
56
|
const password = 'password123';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// `submitForm` renders the signup page and reuses the identity the server put
|
|
58
|
+
// in the form's hidden `__webjs_action` field, which is what tells the
|
|
59
|
+
// dispatcher which action to run; a POST without it is answered 405.
|
|
60
|
+
const signupRes = await submitForm(app.handle, '/features/auth/signup', {
|
|
61
|
+
name: 'Ada', email, password,
|
|
61
62
|
});
|
|
62
63
|
if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
|
|
63
64
|
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Drives the GET-versus-mutation pair. Both are imported normally (the client
|
|
2
|
+
// import is rewritten to a typed RPC stub); the verbs are declared on the action
|
|
3
|
+
// files, so nothing here changes between a GET and a POST call site.
|
|
4
|
+
//
|
|
5
|
+
// The reads are click-driven on purpose. A read issued during SSR would resolve
|
|
6
|
+
// from the action seed on its first client call, so "watch it hit the network"
|
|
7
|
+
// would be wrong for the first paint.
|
|
8
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
9
|
+
import { cardClass } from '#components/ui/card.ts';
|
|
10
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
11
|
+
import { readClock } from '../queries/read-clock.server.ts';
|
|
12
|
+
import { bumpClock } from '../actions/bump-clock.server.ts';
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
reading: number;
|
|
16
|
+
serving: number;
|
|
17
|
+
at: string;
|
|
18
|
+
clickedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ClockReader extends WebComponent {
|
|
22
|
+
private rows = signal<Row[]>([]);
|
|
23
|
+
private busy = signal(false);
|
|
24
|
+
private error = signal('');
|
|
25
|
+
|
|
26
|
+
// Both timestamps are formatted here, in the visitor's timezone, so the server
|
|
27
|
+
// instant and the click time are directly comparable wherever this is deployed.
|
|
28
|
+
private clock(value: Date | string): string {
|
|
29
|
+
return new Date(value).toLocaleTimeString('en-US', { hour12: false });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async read() {
|
|
33
|
+
// `?disabled` only lands on the next render commit, so a second click in the
|
|
34
|
+
// same task would fire a second request. The signal is the real guard.
|
|
35
|
+
if (this.busy.get()) return;
|
|
36
|
+
this.busy.set(true);
|
|
37
|
+
this.error.set('');
|
|
38
|
+
const clickedAt = this.clock(new Date());
|
|
39
|
+
try {
|
|
40
|
+
// A GET action returns its value directly. The stub THROWS on a transport
|
|
41
|
+
// failure, so the call is guarded the same way the envelope is narrowed.
|
|
42
|
+
const r = await readClock();
|
|
43
|
+
this.rows.set([{ ...r, clickedAt }, ...this.rows.get()].slice(0, 6));
|
|
44
|
+
} catch {
|
|
45
|
+
this.error.set('The read failed. Is the server still running?');
|
|
46
|
+
} finally {
|
|
47
|
+
this.busy.set(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async bump() {
|
|
52
|
+
if (this.busy.get()) return;
|
|
53
|
+
this.busy.set(true);
|
|
54
|
+
this.error.set('');
|
|
55
|
+
try {
|
|
56
|
+
// A mutation returns the ActionResult envelope, so narrow on success.
|
|
57
|
+
const r = await bumpClock();
|
|
58
|
+
if (!r.success) this.error.set(r.error ?? 'The bump failed.');
|
|
59
|
+
} catch {
|
|
60
|
+
this.error.set('The bump failed. Is the server still running?');
|
|
61
|
+
} finally {
|
|
62
|
+
this.busy.set(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render() {
|
|
67
|
+
const rows = this.rows.get();
|
|
68
|
+
return html`
|
|
69
|
+
<div class="${cardClass()} grid gap-4 p-5 max-w-[520px]">
|
|
70
|
+
<div class="flex flex-wrap gap-2">
|
|
71
|
+
<button type="button" @click=${() => this.read()} ?disabled=${this.busy.get()}
|
|
72
|
+
aria-busy=${this.busy.get() ? 'true' : 'false'}
|
|
73
|
+
class=${buttonClass()}>Read</button>
|
|
74
|
+
<button type="button" @click=${() => this.bump()} ?disabled=${this.busy.get()}
|
|
75
|
+
aria-busy=${this.busy.get() ? 'true' : 'false'}
|
|
76
|
+
class=${buttonClass({ variant: 'secondary' })}>Bump the counter</button>
|
|
77
|
+
</div>
|
|
78
|
+
<!-- The results are swapped in after a click, so they are announced. -->
|
|
79
|
+
<div role="status" aria-live="polite">
|
|
80
|
+
${rows.length
|
|
81
|
+
? html`
|
|
82
|
+
<ul class="m-0 grid gap-1 list-none p-0 font-mono text-sm">
|
|
83
|
+
${rows.map((r) => html`
|
|
84
|
+
<li class="flex justify-between gap-4">
|
|
85
|
+
<span class="text-foreground">reading #${r.reading}, served ${r.serving} at ${this.clock(r.at)}</span>
|
|
86
|
+
<span class="text-muted-foreground">clicked ${r.clickedAt}</span>
|
|
87
|
+
</li>
|
|
88
|
+
`)}
|
|
89
|
+
</ul>
|
|
90
|
+
`
|
|
91
|
+
: html`<p class="m-0 text-sm text-muted-foreground">Press Read twice in a row, then bump and read again.</p>`}
|
|
92
|
+
</div>
|
|
93
|
+
${this.error.get() ? html`<p role="alert" class="m-0 text-sm text-destructive">${this.error.get()}</p>` : ''}
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
ClockReader.register('clock-reader');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A GET server action. An action declares its HTTP semantics through reserved
|
|
3
|
+
// sibling exports the framework reads statically, the same way a page declares
|
|
4
|
+
// `export const revalidate`.
|
|
5
|
+
//
|
|
6
|
+
// method 'GET' rides the args in the URL, is CSRF-exempt, and carries a weak
|
|
7
|
+
// ETag (a revalidation answers 304). It does NOT cache on its own: a
|
|
8
|
+
// GET with no `cache` export is `no-store`. With
|
|
9
|
+
// no `method` export an action is a POST mutation. (SSR seeding is
|
|
10
|
+
// NOT a GET feature: an action invoked during a fully buffered SSR
|
|
11
|
+
// render is seeded into the page whatever its verb. A streamed page
|
|
12
|
+
// emits no seed block at all.)
|
|
13
|
+
// cache the max-age in seconds, and what makes the response cacheable at
|
|
14
|
+
// all. The number is shorthand for the object form, so
|
|
15
|
+
// { maxAge: 10, swr: 30 } adds a stale-while-revalidate grace
|
|
16
|
+
// window. PRIVATE by default. Only pass
|
|
17
|
+
// { public: true } for data identical for EVERY visitor, since a
|
|
18
|
+
// shared cache keys the entry on the URL and args alone. Same
|
|
19
|
+
// safety rule as a page's `export const revalidate`.
|
|
20
|
+
// tags labels this cached entry so a mutation can evict it by name.
|
|
21
|
+
//
|
|
22
|
+
// One function per file is required once a file carries these config exports.
|
|
23
|
+
import { serveReading } from '../utils/clock.server.ts';
|
|
24
|
+
|
|
25
|
+
export const method = 'GET';
|
|
26
|
+
export const cache = 10;
|
|
27
|
+
export const tags = () => ['clock'];
|
|
28
|
+
|
|
29
|
+
export async function readClock(): Promise<{ reading: number; serving: number; at: string }> {
|
|
30
|
+
// `at` goes over the wire as an ISO instant, not a formatted local time: the
|
|
31
|
+
// card sits it next to a browser-side timestamp, and a server in another
|
|
32
|
+
// timezone would otherwise put the two columns hours apart.
|
|
33
|
+
// `serving` counts the times this body actually ran, so a repeat call answered
|
|
34
|
+
// from the browser cache is visible: the number does not move. It is also why
|
|
35
|
+
// this particular read never answers a 304, since a per-execution counter gives
|
|
36
|
+
// every response a different ETag. A read whose result is stable does.
|
|
37
|
+
return { ...serveReading(), at: new Date().toISOString() };
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A server-only utility (no 'use server'), like format.server.ts next to it: the
|
|
2
|
+
// tiny bit of state the cached GET read and the mutation that invalidates it
|
|
3
|
+
// share. Not RPC-callable, so a browser import would throw at load. A real app
|
|
4
|
+
// keeps this in the database.
|
|
5
|
+
//
|
|
6
|
+
// Two counters, because they show different things. `reading` is the domain
|
|
7
|
+
// value the mutation changes. `servings` counts how many times the read actually
|
|
8
|
+
// EXECUTED on the server, which is what makes a browser-cache hit visible: a
|
|
9
|
+
// response served from cache does not run this function, so the number does not
|
|
10
|
+
// move.
|
|
11
|
+
//
|
|
12
|
+
// Both are per-PROCESS and shared by every visitor, which is fine for a demo but
|
|
13
|
+
// is exactly why a real app puts this in the database. On a deployed gallery
|
|
14
|
+
// someone else's bump moves your reading, and your first read opens at whatever
|
|
15
|
+
// serving number the process is on.
|
|
16
|
+
let reading = 1;
|
|
17
|
+
let servings = 0;
|
|
18
|
+
|
|
19
|
+
export function serveReading(): { reading: number; serving: number } {
|
|
20
|
+
servings += 1;
|
|
21
|
+
return { reading, serving: servings };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bumpReading(): number {
|
|
25
|
+
reading += 1;
|
|
26
|
+
return reading;
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { createTodo } from './create-todo.server.ts';
|
|
4
|
+
import { toggleTodo } from './toggle-todo.server.ts';
|
|
5
|
+
import { deleteTodo } from './delete-todo.server.ts';
|
|
6
|
+
|
|
7
|
+
// The no-JS write path for the todo forms. Every <form> in <todo-app> binds
|
|
8
|
+
// THIS action, and the submit button's own `name="intent"` says which mutation
|
|
9
|
+
// to run, which is how one form serves several buttons.
|
|
10
|
+
//
|
|
11
|
+
// Why an intent dispatcher here rather than binding each button to its own
|
|
12
|
+
// action: this form carries the todo's `id` on a hidden input and needs the
|
|
13
|
+
// SAME id for whichever mutation runs, so one action reading both fields is the
|
|
14
|
+
// simpler shape. When the buttons need no shared payload, bind each one
|
|
15
|
+
// directly instead, with `formaction=${action}` on a <button> inside the bound
|
|
16
|
+
// form. The identity then rides that button's own name/value pair, so it works
|
|
17
|
+
// with JS off too. Two things to know: it must be a <button> (on an
|
|
18
|
+
// <input type="submit"> the identity would occupy `value`, which is also that
|
|
19
|
+
// control's visible label), and a bound submitter cannot carry its own
|
|
20
|
+
// `name`/`value`, which is exactly the channel `name="intent"` uses below.
|
|
21
|
+
//
|
|
22
|
+
// With JS the component intercepts the submit and calls the underlying action
|
|
23
|
+
// directly for the optimistic path, so this runs only with JS off.
|
|
24
|
+
export async function submitTodo(formData: FormData) {
|
|
25
|
+
const intent = String(formData.get('intent') ?? '');
|
|
26
|
+
const id = String(formData.get('id') ?? '');
|
|
27
|
+
if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
|
|
28
|
+
if (intent === 'toggle') return toggleTodo({ id });
|
|
29
|
+
if (intent === 'delete') return deleteTodo({ id });
|
|
30
|
+
return { success: false as const, error: 'Unknown action.', status: 400 };
|
|
31
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The interactive surface. Demonstrates: the WebComponent factory + reactive
|
|
2
2
|
// prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
|
|
3
|
-
// progressive enhancement (each mutation is a <form>
|
|
3
|
+
// progressive enhancement (each mutation is a <form action=${submitTodo}> bound to a server action,
|
|
4
4
|
// intercepted by JS for the optimistic path). All interactivity lives in a
|
|
5
5
|
// component; a page/layout cannot be interactive in its own markup.
|
|
6
6
|
//
|
|
@@ -17,6 +17,7 @@ import { cn } from '#lib/utils/cn.ts';
|
|
|
17
17
|
import { createTodo } from '../actions/create-todo.server.ts';
|
|
18
18
|
import { toggleTodo } from '../actions/toggle-todo.server.ts';
|
|
19
19
|
import { deleteTodo } from '../actions/delete-todo.server.ts';
|
|
20
|
+
import { submitTodo } from '../actions/submit-todo.server.ts';
|
|
20
21
|
import type { Todo } from '../types.ts';
|
|
21
22
|
|
|
22
23
|
type Op =
|
|
@@ -95,9 +96,9 @@ export class TodoApp extends WebComponent({
|
|
|
95
96
|
</div>
|
|
96
97
|
</header>
|
|
97
98
|
|
|
98
|
-
<!-- Add: a real <form> so it works with JS
|
|
99
|
-
with JS, @submit intercepts and runs the optimistic path. -->
|
|
100
|
-
<form
|
|
99
|
+
<!-- Add: a real <form> bound to the server action, so it works with JS
|
|
100
|
+
off; with JS, @submit intercepts and runs the optimistic path. -->
|
|
101
|
+
<form action=${submitTodo} @submit=${(e: SubmitEvent) => this.add(e)}
|
|
101
102
|
class="${cardClass()} flex items-center gap-2 p-2 pl-4 shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
|
|
102
103
|
<input type="hidden" name="intent" value="create" />
|
|
103
104
|
<input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
|
|
@@ -109,7 +110,9 @@ export class TodoApp extends WebComponent({
|
|
|
109
110
|
<ul class="list-none m-0 p-0 grid gap-2">
|
|
110
111
|
${list.length ? list.map((todo) => html`
|
|
111
112
|
<li>
|
|
112
|
-
|
|
113
|
+
<!-- One form, two submit buttons: each carries its own
|
|
114
|
+
name="intent", which the bound action dispatches on. -->
|
|
115
|
+
<form action=${submitTodo}
|
|
113
116
|
class="group flex items-center gap-3 px-3 py-2.5 rounded-xl bg-card border border-border transition-colors hover:border-border-strong ${todo.pending ? 'opacity-55' : ''}">
|
|
114
117
|
<input type="hidden" name="id" value=${todo.id} />
|
|
115
118
|
<!-- Toggle is a submit button (degrades to a form POST no-JS); with JS
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
// A READ is a `'use server'` action so the client (and SSR) can call it via the
|
|
3
3
|
// normal import (rewritten to a typed RPC stub). `method = 'GET'` rides args in
|
|
4
|
-
// the URL
|
|
5
|
-
//
|
|
4
|
+
// the URL and is CSRF-exempt. It declares no `cache`, so the response is
|
|
5
|
+
// `no-store`: the verb marks the read as safe, the `cache` export is what makes
|
|
6
|
+
// it cacheable. The todo page awaits this server-side and hands the rows down as
|
|
7
|
+
// a `.todos=${...}` property, so nothing re-fetches it on the client.
|
|
6
8
|
import { db } from '#db/connection.server.ts';
|
|
7
9
|
import type { Todo } from '../types.ts';
|
|
8
10
|
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
// The row shape is DERIVED from the schema, never re-declared: rename a column
|
|
2
|
+
// in db/schema.server.ts and every consumer of this type is a compile error
|
|
3
|
+
// instead of a silent `undefined` at runtime.
|
|
4
|
+
//
|
|
5
|
+
// `import type` is what makes that safe here. This type is imported by the
|
|
6
|
+
// browser-shipped <todo-app> component, and a VALUE import from a
|
|
7
|
+
// `db/*.server.ts` file would pin the component to a server module and crash it
|
|
8
|
+
// at load (webjs check's no-server-import-in-browser-module flags it). A
|
|
9
|
+
// type-only import is erased by the TypeScript stripper before it can reach the
|
|
10
|
+
// browser, so it is exempt from that rule and costs the client nothing.
|
|
11
|
+
import type { todos } from '#db/schema.server.ts';
|
|
12
|
+
|
|
13
|
+
type TodoRow = typeof todos.$inferSelect;
|
|
14
|
+
|
|
15
|
+
export interface Todo extends TodoRow {
|
|
11
16
|
pending?: boolean; // client-only: true while an optimistic create is in flight
|
|
12
17
|
}
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { createRequestHandler } from '@webjsdev/server';
|
|
7
|
-
import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
|
|
7
|
+
import { testRequest, submitForm, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
|
|
8
8
|
|
|
9
9
|
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
10
|
|
|
@@ -14,7 +14,11 @@ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
14
14
|
// we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
|
|
15
15
|
// clear message rather than report a misleading failure. After the db is set up
|
|
16
16
|
// every assertion runs for real.
|
|
17
|
-
|
|
17
|
+
// The SAME path `.env.example` and `drizzle.config.ts` use, so `db:migrate`
|
|
18
|
+
// prepares the database this test connects to. Pointing somewhere else made the
|
|
19
|
+
// skip below permanent: it told you to run `db:migrate`, and running it
|
|
20
|
+
// migrated a different file.
|
|
21
|
+
process.env.DATABASE_URL ||= 'file:./db/dev.db';
|
|
18
22
|
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
19
23
|
|
|
20
24
|
function makeHandler() {
|
|
@@ -47,24 +51,35 @@ test('signup -> login -> dashboard renders for the authenticated user', async (t
|
|
|
47
51
|
const email = `harness+${Date.now()}@example.com`;
|
|
48
52
|
const password = 'password123';
|
|
49
53
|
|
|
50
|
-
// Real signup through the
|
|
51
|
-
|
|
54
|
+
// Real signup through the bound server action (the no-JS form write-path).
|
|
55
|
+
// `submitForm` renders the page and reuses the identity the server put in the
|
|
56
|
+
// form's hidden field, exactly as a browser with JS off submits it; a POST
|
|
57
|
+
// without that field is not a form submission and is answered 405.
|
|
58
|
+
// Only the REQUEST is guarded: an unmigrated table makes the action throw, and
|
|
59
|
+
// that is the one condition worth skipping for. The assertions below stay
|
|
60
|
+
// outside the try on purpose, so a genuine regression fails loudly instead of
|
|
61
|
+
// being caught and reported as a database that was never set up.
|
|
62
|
+
let signupRes: Response | null = null;
|
|
52
63
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
56
|
-
body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
|
|
64
|
+
signupRes = await submitForm(app.handle, '/features/auth/signup', {
|
|
65
|
+
name: 'Harness', email, password,
|
|
57
66
|
});
|
|
58
|
-
// Success auto-logs-in and 302s to the dashboard (carrying the session
|
|
59
|
-
// cookie); a 422 means validation failed. Either way the action ran.
|
|
60
|
-
assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
|
|
61
|
-
if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
|
|
62
|
-
if (signupRes.status !== 302) canSignup = false;
|
|
63
67
|
} catch {
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
signupRes = null;
|
|
69
|
+
}
|
|
70
|
+
if (!signupRes || signupRes.status >= 500) {
|
|
71
|
+
t.skip('no migrated DB; run db:migrate to enable the full flow');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Success auto-logs-in and 302s to the dashboard (carrying the session
|
|
75
|
+
// cookie); a 422 means validation failed. Either way the action ran.
|
|
76
|
+
assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
|
|
77
|
+
if (signupRes.status === 302) {
|
|
78
|
+
assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
|
|
79
|
+
} else {
|
|
80
|
+
t.skip('signup was rejected by validation; run db:migrate to enable the full flow');
|
|
81
|
+
return;
|
|
66
82
|
}
|
|
67
|
-
if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
|
|
68
83
|
|
|
69
84
|
// Real login captures the genuine signed session cookie.
|
|
70
85
|
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
@@ -47,6 +47,10 @@ cross-origin access use the `cors()` middleware from `@webjsdev/server`; with
|
|
|
47
47
|
Run each of these and fix what it reports, in order:
|
|
48
48
|
|
|
49
49
|
- `npm run check` (correctness: no browser-import or boundary violation).
|
|
50
|
+
- `npm run doctor` (project health; CI runs it too). It fails on whatever
|
|
51
|
+
`package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
|
|
52
|
+
checks that are fatal with no gate entry, `NODE_VERSION` and
|
|
53
|
+
`TSCONFIG_ERASABLE`.
|
|
50
54
|
- `npm run typecheck` (zero type errors).
|
|
51
55
|
- `npm test` (unit tests for the endpoints and modules you built).
|
|
52
56
|
|
|
@@ -63,5 +67,6 @@ npm run start # production server
|
|
|
63
67
|
npm test # unit + browser tests
|
|
64
68
|
npm run typecheck
|
|
65
69
|
npm run check # correctness checks
|
|
70
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
66
71
|
npm run db:generate && npm run db:migrate
|
|
67
72
|
```
|
|
@@ -98,6 +98,10 @@ accessor). Use the shorthand for primitives
|
|
|
98
98
|
Run each of these and fix what it reports, in order:
|
|
99
99
|
|
|
100
100
|
- `npm run check` (correctness: no browser-import or boundary violation).
|
|
101
|
+
- `npm run doctor` (project health; CI runs it too). It fails on whatever
|
|
102
|
+
`package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain
|
|
103
|
+
checks that are fatal with no gate entry, `NODE_VERSION` and
|
|
104
|
+
`TSCONFIG_ERASABLE`.
|
|
101
105
|
- `npm run typecheck` (zero type errors).
|
|
102
106
|
- `npm test` (unit and browser tests for the features you built).
|
|
103
107
|
- `npm run css:build` (compile Tailwind).
|
|
@@ -118,6 +122,7 @@ npm test # unit + browser tests
|
|
|
118
122
|
npm run typecheck
|
|
119
123
|
npm run css:build # compile Tailwind
|
|
120
124
|
npm run check # correctness checks
|
|
125
|
+
npm run doctor # project health (severity per check: webjs.doctor.gate)
|
|
121
126
|
npx webjsdev ui add <name> # copy a ui primitive into components/ui/
|
|
122
127
|
npx webjsdev ui view <name> # inspect a primitive's exact signature
|
|
123
128
|
npm run db:generate && npm run db:migrate
|
package/templates/public/sw.js
CHANGED
|
@@ -15,8 +15,17 @@
|
|
|
15
15
|
* - Same-origin static assets (the per-file ESM modules, the framework
|
|
16
16
|
* runtime under /__webjs/core/, vendor bundles, public assets) are
|
|
17
17
|
* stale-while-revalidate, so a repeat visit works offline. In production
|
|
18
|
-
*
|
|
19
|
-
* a
|
|
18
|
+
* the FRAMEWORK-emitted URLs (modules, the core runtime, vendor bundles)
|
|
19
|
+
* carry a ?v=<hash> content fingerprint automatically, so a changed file
|
|
20
|
+
* gets a new URL and the cache cannot serve stale bytes for those.
|
|
21
|
+
*
|
|
22
|
+
* A public/ asset is the exception: its URL is fingerprinted only when you
|
|
23
|
+
* mark it with asset() (href=${asset('/public/app.css')}). An un-marked
|
|
24
|
+
* public/ URL is stable across deploys, so this worker serves the CACHED
|
|
25
|
+
* copy and revalidates behind it, and the first load after a deploy shows
|
|
26
|
+
* the OLD bytes. The cache version below will not necessarily rescue you,
|
|
27
|
+
* because the build id it derives from is a deploy fingerprint rather than
|
|
28
|
+
* a per-file content hash. Mark any public/ asset whose bytes change.
|
|
20
29
|
*
|
|
21
30
|
* Versioning ties to the deploy. The page registers this worker as
|
|
22
31
|
* `/sw.js?v=<data-webjs-build>` (the importmap build id), so a new deploy
|
|
@@ -79,9 +79,9 @@ const galleryPaths = [
|
|
|
79
79
|
// query), pruned with the rest of the card.
|
|
80
80
|
const galleryModules = [
|
|
81
81
|
'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
|
|
82
|
-
'directives', 'file-storage', 'frames', 'gallery', 'optimistic-ui',
|
|
83
|
-
'route-handler', 'server-actions', 'sessions', 'stream',
|
|
84
|
-
'todo', 'websockets',
|
|
82
|
+
'directives', 'file-storage', 'forms', 'frames', 'gallery', 'optimistic-ui',
|
|
83
|
+
'rate-limit', 'route-handler', 'server-actions', 'sessions', 'stream',
|
|
84
|
+
'streaming', 'suspense', 'todo', 'websockets',
|
|
85
85
|
].map((m) => `modules/${m}`);
|
|
86
86
|
|
|
87
87
|
let removed = 0;
|
|
@@ -173,7 +173,8 @@ export default function Home() {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
function MINIMAL_LAYOUT() {
|
|
176
|
-
return `import { html } from '@webjsdev/core';
|
|
176
|
+
return `import { html, asset } from '@webjsdev/core';
|
|
177
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
180
|
* Root layout: the ONLY file that writes the document shell. It links the
|
|
@@ -191,10 +192,14 @@ function MINIMAL_LAYOUT() {
|
|
|
191
192
|
// hand-written <link> in the template body is ignored by browsers).
|
|
192
193
|
export const metadata = { icons: '/public/favicon.svg' };
|
|
193
194
|
|
|
194
|
-
export default function RootLayout({ children }:
|
|
195
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
195
196
|
return html\`
|
|
196
197
|
<meta name="color-scheme" content="light dark">
|
|
197
|
-
|
|
198
|
+
<!-- asset() content-hashes the url in production, so a deploy that changes
|
|
199
|
+
the CSS changes the url and the framework serves it immutable for a
|
|
200
|
+
year. Without it this stable url can serve the PREVIOUS stylesheet
|
|
201
|
+
from a CDN or a service-worker cache after a deploy. -->
|
|
202
|
+
<link rel="stylesheet" href=\${asset('/public/tailwind.css')}>
|
|
198
203
|
<style>
|
|
199
204
|
html, body { margin: 0; }
|
|
200
205
|
body {
|
|
@@ -12,9 +12,26 @@
|
|
|
12
12
|
import { test, describe, before, after } from 'node:test';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
+
import type { ChildProcess } from 'node:child_process';
|
|
15
16
|
import { createServer } from 'node:net';
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// puppeteer-core is an optional dev dependency, so `import type { Browser,
|
|
19
|
+
// Page } from 'puppeteer-core'` does not resolve until you install it. These
|
|
20
|
+
// minimal structural types keep the file typed in the meantime; swap them for
|
|
21
|
+
// the real imports once puppeteer-core is in package.json. Reaching for `any`
|
|
22
|
+
// here would silently un-type every call below.
|
|
23
|
+
type Page = {
|
|
24
|
+
// goto resolves an HTTPResponse this file never reads, and modelling that
|
|
25
|
+
// type would mean re-declaring puppeteer's. Returning void is the honest
|
|
26
|
+
// narrow shape for the surface actually used.
|
|
27
|
+
goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise<void>;
|
|
28
|
+
title(): Promise<string>;
|
|
29
|
+
on(event: string, handler: (e: Error) => void): void;
|
|
30
|
+
removeAllListeners(event: string): void;
|
|
31
|
+
};
|
|
32
|
+
type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
|
|
33
|
+
|
|
34
|
+
let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
|
|
18
35
|
|
|
19
36
|
function freePort(): Promise<number> {
|
|
20
37
|
return new Promise((resolve, reject) => {
|