@webjsdev/cli 0.10.44 → 0.10.45
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 +2 -3
- package/bin/webjs.js +18 -18
- package/lib/api-gallery.js +1 -1
- package/lib/create.js +51 -108
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +10 -2
- package/templates/.agents/skills/webjs/references/components.md +19 -4
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
- package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
- package/templates/gallery/app/examples/layout.ts +11 -0
- package/templates/gallery/app/features/auth/dashboard/layout.ts +20 -0
- package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
- package/templates/gallery/app/features/auth/dashboard/page.ts +18 -0
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +21 -0
- package/templates/gallery/app/features/auth/login/page.ts +40 -0
- package/templates/gallery/app/features/auth/page.ts +33 -0
- package/templates/gallery/app/features/auth/signup/page.ts +58 -0
- package/templates/gallery/app/features/frames/page.ts +10 -2
- package/templates/gallery/app/features/layout.ts +12 -0
- package/templates/gallery/app/features/server-actions/page.ts +4 -2
- package/templates/gallery/app/features/stream/page.ts +45 -0
- package/templates/gallery/app/features/streaming/page.ts +31 -0
- package/templates/gallery/app/features/suspense/page.ts +34 -0
- package/templates/gallery/app/features/view-transitions/page.ts +41 -0
- package/templates/gallery/app/features/view-transitions/second/page.ts +28 -0
- package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
- package/templates/gallery/modules/auth/auth.server.ts +53 -0
- package/templates/gallery/modules/auth/password.server.ts +20 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
- package/templates/gallery/modules/auth/types.ts +9 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
- package/templates/gallery/modules/server-actions/components/greeter.ts +5 -8
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
- package/templates/gallery/modules/stream/components/stream-demo.ts +76 -0
- package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
- package/templates/gallery/modules/streaming/components/token-stream.ts +46 -0
- package/templates/gallery/modules/suspense/components/slow-fact.ts +19 -0
- package/templates/gallery/test/auth/auth.test.ts +81 -0
- package/templates/scripts/clear-api-gallery.mjs +55 -0
- package/templates/scripts/clear-gallery.mjs +23 -11
- package/lib/lean-copy.js +0 -43
- package/lib/saas-template.js +0 -568
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
// Example node test for the documented test helpers (from @webjsdev/server).
|
|
2
2
|
// Run it with node --test (or webjs test after moving it under test/). The
|
|
3
3
|
// handle() harness drives the FULL request pipeline: createRequestHandler({
|
|
4
|
-
// appDir }) builds it, and rawActionRequest()
|
|
5
|
-
// through it (CSRF + the rich serializer included)
|
|
4
|
+
// appDir }) builds it, and rawActionRequest() / invokeActionForTest() fire a
|
|
5
|
+
// 'use server' action through it (CSRF + the rich serializer included).
|
|
6
6
|
// buildRouteTable(appDir) parses the file router; matchPage / matchApi resolve a
|
|
7
7
|
// URL against it, params included. See the testing docs.
|
|
8
|
+
//
|
|
9
|
+
// greet is gated by the requireAuth middleware, which reads the REAL signed
|
|
10
|
+
// session off the request (the auth gallery card). So an unauthenticated call is
|
|
11
|
+
// genuinely denied (401), and the success path needs a real session cookie
|
|
12
|
+
// (obtained via a signup + loginAndGetCookies, skipped until the db is migrated).
|
|
8
13
|
import { test } from 'node:test';
|
|
9
14
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { createRequestHandler, buildRouteTable, matchPage, matchApi,
|
|
15
|
+
import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
|
|
16
|
+
import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing';
|
|
11
17
|
|
|
12
18
|
const appDir = process.cwd();
|
|
19
|
+
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
20
|
+
|
|
21
|
+
const GREET = 'modules/server-actions/actions/greet.server.ts';
|
|
13
22
|
|
|
14
23
|
test('buildRouteTable + matchPage resolve a dynamic route with its params', async () => {
|
|
15
24
|
const table = await buildRouteTable(appDir);
|
|
@@ -24,47 +33,41 @@ test('matchApi resolves the route-handler endpoint', async () => {
|
|
|
24
33
|
assert.ok(m, 'the route.ts endpoint matches');
|
|
25
34
|
});
|
|
26
35
|
|
|
27
|
-
test('
|
|
36
|
+
test('an unauthenticated greet is denied by requireAuth before greet runs', async () => {
|
|
28
37
|
const app = await createRequestHandler({ appDir, dev: true });
|
|
29
38
|
if (app.warmup) await app.warmup();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'greet',
|
|
34
|
-
|
|
35
|
-
);
|
|
36
|
-
assert.equal(
|
|
39
|
+
// No session cookie -> requireAuth short-circuits with a 401 failure envelope
|
|
40
|
+
// (it reads only the cookie, so this is real without a migrated db).
|
|
41
|
+
const r = (await invokeActionForTest(
|
|
42
|
+
app, GREET, 'greet', [{ name: 'Bob' }], { throwOnError: false },
|
|
43
|
+
)) as { success: boolean; error?: string; status?: number };
|
|
44
|
+
assert.equal(r.success, false);
|
|
45
|
+
assert.equal(r.status, 401);
|
|
37
46
|
});
|
|
38
47
|
|
|
39
|
-
test('
|
|
48
|
+
test('an authenticated greet reads the caller off the session via actionContext()', async (t) => {
|
|
40
49
|
const app = await createRequestHandler({ appDir, dev: true });
|
|
41
50
|
if (app.warmup) await app.warmup();
|
|
42
|
-
|
|
43
|
-
//
|
|
51
|
+
|
|
52
|
+
// Real signup through the auth card's page action, then a real login to capture
|
|
53
|
+
// the signed session cookie. Both hit the users table, so skip until the db is
|
|
54
|
+
// migrated (run db:generate + db:migrate) rather than fail misleadingly.
|
|
55
|
+
const email = `greet+${Date.now()}@example.com`;
|
|
56
|
+
const password = 'password123';
|
|
57
|
+
const signupRes = await testRequest(app.handle, '/features/auth/signup', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
60
|
+
body: new URLSearchParams({ name: 'Ada', email, password }).toString(),
|
|
61
|
+
});
|
|
62
|
+
if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
|
|
63
|
+
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
64
|
+
|
|
65
|
+
// With the session cookie the middleware sets the caller, and greet reads it.
|
|
44
66
|
const r = (await invokeActionForTest(
|
|
45
|
-
app,
|
|
46
|
-
|
|
47
|
-
'greet',
|
|
48
|
-
[{ name: 'Bob' }],
|
|
49
|
-
)) as { success: boolean; data?: { message: string }; error?: string; status?: number };
|
|
67
|
+
app, GREET, 'greet', [{ name: 'Bob' }], { extraCookies: cookies },
|
|
68
|
+
)) as { success: boolean; data?: { message: string } };
|
|
50
69
|
assert.equal(r.success, true);
|
|
51
|
-
// The message carries BOTH the input (Bob) and the
|
|
70
|
+
// The message carries BOTH the input (Bob) and the session caller (Ada).
|
|
52
71
|
assert.match(r.data?.message ?? '', /BOB/);
|
|
53
72
|
assert.match(r.data?.message ?? '', /Ada/);
|
|
54
73
|
});
|
|
55
|
-
|
|
56
|
-
test('the auth middleware short-circuits a signed-out request before greet runs', async () => {
|
|
57
|
-
const app = await createRequestHandler({ appDir, dev: true });
|
|
58
|
-
if (app.warmup) await app.warmup();
|
|
59
|
-
// A middleware short-circuit rides as a normal failure envelope (200 with the
|
|
60
|
-
// status inside), so read the result rather than expecting a thrown non-2xx.
|
|
61
|
-
const r = (await invokeActionForTest(
|
|
62
|
-
app,
|
|
63
|
-
'modules/server-actions/actions/greet.server.ts',
|
|
64
|
-
'greet',
|
|
65
|
-
[{ name: 'Bob', signedOut: true }],
|
|
66
|
-
{ throwOnError: false },
|
|
67
|
-
)) as { success: boolean; data?: { message: string }; error?: string; status?: number };
|
|
68
|
-
assert.equal(r.success, false);
|
|
69
|
-
assert.equal(r.status, 401);
|
|
70
|
-
});
|
|
@@ -5,14 +5,14 @@ import { greet } from '../actions/greet.server.ts';
|
|
|
5
5
|
|
|
6
6
|
export class Greeter extends WebComponent {
|
|
7
7
|
private msg = signal('');
|
|
8
|
-
// Drives the requireAuth middleware on the action: when true the request is
|
|
9
|
-
// treated as signed-out, so the middleware 401s BEFORE greet() runs.
|
|
10
|
-
private signedOut = signal(false);
|
|
11
8
|
|
|
12
9
|
async run(e: SubmitEvent) {
|
|
13
10
|
e.preventDefault();
|
|
14
11
|
const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? '');
|
|
15
|
-
|
|
12
|
+
// The action's requireAuth middleware reads the real session off the request.
|
|
13
|
+
// Signed out, this comes back as a 401 failure envelope ("Sign in to
|
|
14
|
+
// continue."); sign in at /features/auth/login and the greeting succeeds.
|
|
15
|
+
const r = await greet({ name });
|
|
16
16
|
// Narrow on r.success so TS knows `data` (success) vs `error` (failure). A
|
|
17
17
|
// middleware short-circuit arrives here as a normal failure envelope.
|
|
18
18
|
this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error'));
|
|
@@ -27,10 +27,7 @@ export class Greeter extends WebComponent {
|
|
|
27
27
|
<button type="submit"
|
|
28
28
|
class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
|
|
29
29
|
</form>
|
|
30
|
-
<
|
|
31
|
-
<input type="checkbox" @change=${(e: Event) => this.signedOut.set((e.target as HTMLInputElement).checked)} />
|
|
32
|
-
Simulate a signed-out visitor (the middleware 401s before the action runs)
|
|
33
|
-
</label>
|
|
30
|
+
<p class="m-0 text-sm text-muted-foreground">The action is gated by requireAuth. <a class="text-primary" href="/features/auth/login">Sign in</a> to greet; signed out returns a real 401.</p>
|
|
34
31
|
${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
|
|
35
32
|
</div>
|
|
36
33
|
`;
|
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
// .server.ts with NO 'use server'): the action imports it server-side; it never
|
|
8
8
|
// ships to the browser.
|
|
9
9
|
import type { ActionResult } from '@webjsdev/server';
|
|
10
|
+
// This demo depends on the auth gallery card (modules/auth): getCurrentUser
|
|
11
|
+
// reads the real signed session. If you prune the auth card, prune this
|
|
12
|
+
// server-actions card too (or swap this for your own session read); gallery:clear
|
|
13
|
+
// removes both together.
|
|
14
|
+
import { getCurrentUser } from '#modules/auth/auth.server.ts';
|
|
10
15
|
|
|
11
16
|
export interface AuthUser {
|
|
12
17
|
id: string;
|
|
13
|
-
name
|
|
18
|
+
name?: string | null;
|
|
19
|
+
email?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
// The context object the framework passes each middleware. `context` is the
|
|
@@ -24,17 +30,16 @@ interface ActionMiddlewareCtx {
|
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export async function requireAuth(ctx: ActionMiddlewareCtx, next: () => Promise<unknown>): Promise<unknown> {
|
|
27
|
-
// A REAL guard reads the signed session
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
const [input] = ctx.args as [{ signedOut?: boolean } | undefined];
|
|
33
|
-
const user: AuthUser | null = input?.signedOut ? null : { id: 'u_1', name: 'Ada' };
|
|
33
|
+
// A REAL guard reads the signed session off the request, because auth belongs
|
|
34
|
+
// to the request, not the payload. The RPC POST is same-origin, so the auth
|
|
35
|
+
// cookie rides along and getCurrentUser(ctx.request) reads it (this uses the
|
|
36
|
+
// auth gallery card's createAuth config; sign in at /features/auth/login).
|
|
37
|
+
const user = (await getCurrentUser(ctx.request)) as AuthUser | null;
|
|
34
38
|
|
|
35
39
|
// Short-circuit: return an ActionResult WITHOUT calling next(), so the action
|
|
36
40
|
// never runs. On the RPC boundary the short-circuit rides as the result with its
|
|
37
|
-
// status inside the envelope, and a denied call is served no-store (never
|
|
41
|
+
// status inside the envelope, and a denied call is served no-store (never
|
|
42
|
+
// cached). An anonymous caller is genuinely denied here.
|
|
38
43
|
if (!user) return { success: false, error: 'Sign in to continue.', status: 401 } satisfies ActionResult<never>;
|
|
39
44
|
|
|
40
45
|
ctx.context.user = user; // what actionContext().user reads inside the action
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// <webjs-stream> is the element-level DOM-update grammar (#248): a self-applying
|
|
2
|
+
// element that clones its <template> and runs one native DOM method against a
|
|
3
|
+
// target (by id), then removes itself. `renderStream(htmlString)` from
|
|
4
|
+
// @webjsdev/core applies such a payload on the client. It is WebJs's take on
|
|
5
|
+
// Turbo Streams: surgical append / prepend / replace / update / remove, without
|
|
6
|
+
// redrawing a whole region.
|
|
7
|
+
//
|
|
8
|
+
// This demo drives it from the client with buttons. The list below is seeded
|
|
9
|
+
// ONCE in render(); every button mutates the LIVE DOM via renderStream() and the
|
|
10
|
+
// component never re-renders (the row counter is a plain field, deliberately not
|
|
11
|
+
// a signal, so reading it never re-runs render() and wipes the surgical updates).
|
|
12
|
+
// That is the whole point: out-of-band updates that a signal re-render or a frame
|
|
13
|
+
// region-swap would clobber.
|
|
14
|
+
import { WebComponent, html, renderStream } from '@webjsdev/core';
|
|
15
|
+
|
|
16
|
+
// Build a <webjs-stream> payload string. It is a plain string (NOT an html``
|
|
17
|
+
// template), so interpolating the row markup here is fine. `remove` needs no
|
|
18
|
+
// <template>; every other action wraps its content in one.
|
|
19
|
+
function streamPayload(action: string, target: string, inner = '') {
|
|
20
|
+
const body = action === 'remove' ? '' : `<template>${inner}</template>`;
|
|
21
|
+
return `<webjs-stream action="${action}" target="${target}">${body}</webjs-stream>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rowCls = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground';
|
|
25
|
+
const row = (id: string, label: string) => `<li id="${id}" class="${rowCls}">${label}</li>`;
|
|
26
|
+
|
|
27
|
+
export class StreamDemo extends WebComponent {
|
|
28
|
+
// A plain instance field, NOT a signal: incremented to mint unique row ids.
|
|
29
|
+
// It is never read inside render(), so appending a row does not re-render the
|
|
30
|
+
// component and blow away the streamed-in rows.
|
|
31
|
+
#n = 2;
|
|
32
|
+
|
|
33
|
+
append() {
|
|
34
|
+
this.#n++;
|
|
35
|
+
renderStream(streamPayload('append', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (appended)`)));
|
|
36
|
+
}
|
|
37
|
+
prepend() {
|
|
38
|
+
this.#n++;
|
|
39
|
+
renderStream(streamPayload('prepend', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (prepended)`)));
|
|
40
|
+
}
|
|
41
|
+
replaceFirst() {
|
|
42
|
+
// `replace` swaps the target element itself. The replacement keeps id row-1,
|
|
43
|
+
// so the button stays repeatable.
|
|
44
|
+
renderStream(streamPayload('replace', 'row-1', row('row-1', 'Row 1 (replaced)')));
|
|
45
|
+
}
|
|
46
|
+
removeSecond() {
|
|
47
|
+
// `remove` deletes the target and needs no <template>.
|
|
48
|
+
renderStream(streamPayload('remove', 'row-2'));
|
|
49
|
+
}
|
|
50
|
+
reset() {
|
|
51
|
+
// `update` replaces the target's children, restoring the seed list.
|
|
52
|
+
renderStream(streamPayload('update', 'stream-list', row('row-1', 'Row 1') + row('row-2', 'Row 2')));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render() {
|
|
56
|
+
const btn = 'px-3 py-1.5 rounded-lg bg-card border border-border text-sm font-medium text-foreground cursor-pointer transition-colors hover:border-border-strong';
|
|
57
|
+
return html`
|
|
58
|
+
<div class="grid gap-4 max-w-[460px]">
|
|
59
|
+
<div class="flex flex-wrap gap-2">
|
|
60
|
+
<button class=${btn} @click=${() => this.append()}>Append</button>
|
|
61
|
+
<button class=${btn} @click=${() => this.prepend()}>Prepend</button>
|
|
62
|
+
<button class=${btn} @click=${() => this.replaceFirst()}>Replace Row 1</button>
|
|
63
|
+
<button class=${btn} @click=${() => this.removeSecond()}>Remove Row 2</button>
|
|
64
|
+
<button class=${btn} @click=${() => this.reset()}>Reset</button>
|
|
65
|
+
</div>
|
|
66
|
+
<!-- The target list. renderStream() mutates it by id; this markup renders
|
|
67
|
+
once and is never re-rendered by the component. -->
|
|
68
|
+
<ul id="stream-list" class="grid gap-2 m-0 p-0 list-none">
|
|
69
|
+
<li id="row-1" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 1</li>
|
|
70
|
+
<li id="row-2" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 2</li>
|
|
71
|
+
</ul>
|
|
72
|
+
</div>
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
StreamDemo.register('stream-demo');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// A STREAMING server action (#489). Detection is purely on the RETURN VALUE, no
|
|
3
|
+
// config export: an action that returns a ReadableStream / async iterable /
|
|
4
|
+
// async generator streams its chunks over the single RPC response instead of
|
|
5
|
+
// buffering. Each `yield` is rich-serialized and flushed as it is produced, so
|
|
6
|
+
// the call site sees tokens arrive live. Back-pressure is respected and the
|
|
7
|
+
// generator is cancelled if the client disconnects. A streamed result is never
|
|
8
|
+
// cached / ETagged / seeded. One function per file, like every action.
|
|
9
|
+
export async function* streamTokens(prompt: string): AsyncGenerator<string> {
|
|
10
|
+
const words = `Streaming ${prompt} one token at a time, straight from the server.`.split(' ');
|
|
11
|
+
for (const word of words) {
|
|
12
|
+
// A deliberate per-token delay so the streaming is visible; a real action
|
|
13
|
+
// would yield as its upstream (an LLM, a DB cursor, a log tail) produces.
|
|
14
|
+
await new Promise((r) => setTimeout(r, 140));
|
|
15
|
+
yield word + ' ';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Consumes a streaming action at the call site with `for await`. The import of
|
|
2
|
+
// a `'use server'` action is rewritten to an RPC stub, so `await streamTokens()`
|
|
3
|
+
// resolves to an async iterable whose chunks arrive as the server yields them.
|
|
4
|
+
// We append each chunk to an instance signal, so the text builds up live as it
|
|
5
|
+
// streams (the built-in SignalWatcher re-renders on each `.set`). With JS off
|
|
6
|
+
// the button is inert and the empty output renders (streaming is inherently a
|
|
7
|
+
// JS behaviour), so nothing here breaks the no-JS first paint.
|
|
8
|
+
import { WebComponent, signal, html } from '@webjsdev/core';
|
|
9
|
+
import { streamTokens } from '../actions/stream-tokens.server.ts';
|
|
10
|
+
|
|
11
|
+
export class TokenStream extends WebComponent {
|
|
12
|
+
// Plain instance signals (not reactive props), so a class field is fine here.
|
|
13
|
+
private output = signal('');
|
|
14
|
+
private busy = signal(false);
|
|
15
|
+
|
|
16
|
+
private async run() {
|
|
17
|
+
this.output.set('');
|
|
18
|
+
this.busy.set(true);
|
|
19
|
+
try {
|
|
20
|
+
// `await streamTokens(...)` gives the async iterable; `for await` pulls
|
|
21
|
+
// each token as it arrives instead of waiting for the whole response.
|
|
22
|
+
for await (const chunk of await streamTokens('webjs')) {
|
|
23
|
+
this.output.set(this.output.get() + chunk);
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
this.busy.set(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render() {
|
|
31
|
+
const busy = this.busy.get();
|
|
32
|
+
return html`
|
|
33
|
+
<div class="rounded-2xl border border-border bg-card p-5">
|
|
34
|
+
<button
|
|
35
|
+
@click=${() => this.run()}
|
|
36
|
+
?disabled=${busy}
|
|
37
|
+
class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm transition-all hover:bg-primary/90 active:scale-[0.97] disabled:opacity-60"
|
|
38
|
+
>
|
|
39
|
+
${busy ? 'streaming…' : 'Stream tokens'}
|
|
40
|
+
</button>
|
|
41
|
+
<pre class="mt-4 whitespace-pre-wrap font-mono text-sm text-foreground min-h-[3rem]">${this.output.get()}</pre>
|
|
42
|
+
</div>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
TokenStream.register('token-stream');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A bare async-render component whose server data is deliberately SLOW. On its
|
|
2
|
+
// own, async render() BLOCKS the SSR first byte (the resolved value lands in the
|
|
3
|
+
// first paint with no fallback, which is what the async-render demo shows). That
|
|
4
|
+
// is the right default for fast data. Here the data is slow, so the page wraps
|
|
5
|
+
// it in a <webjs-suspense> boundary that flushes its fallback on the first byte
|
|
6
|
+
// and STREAMS this content in when the await settles (progressively on soft
|
|
7
|
+
// navigation too). Same component either way; the boundary is what changes the
|
|
8
|
+
// SSR behaviour from block to stream.
|
|
9
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
10
|
+
|
|
11
|
+
export class SlowFact extends WebComponent {
|
|
12
|
+
async render() {
|
|
13
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
14
|
+
return html`<p class="rounded-2xl border border-border bg-card p-5 text-foreground">
|
|
15
|
+
The answer, after a slow lookup, is <strong>42</strong>.
|
|
16
|
+
</p>`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
SlowFact.register('slow-fact');
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { createRequestHandler } from '@webjsdev/server';
|
|
7
|
+
import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
|
|
8
|
+
|
|
9
|
+
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
|
+
|
|
11
|
+
// The auth pages + dashboard middleware query the users table via Drizzle. Until
|
|
12
|
+
// `db:generate` has authored the migration (then `db:migrate` applies it, or
|
|
13
|
+
// `dev` applies it via webjs.dev.before), a request hitting those modules 500s;
|
|
14
|
+
// we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
|
|
15
|
+
// clear message rather than report a misleading failure. After the db is set up
|
|
16
|
+
// every assertion runs for real.
|
|
17
|
+
process.env.DATABASE_URL ||= 'file:./dev.db';
|
|
18
|
+
process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
|
|
19
|
+
|
|
20
|
+
function makeHandler() {
|
|
21
|
+
// createRequestHandler builds lazily, so it succeeds even before the DB is
|
|
22
|
+
// migrated; the missing table only surfaces when a request reaches a module
|
|
23
|
+
// that queries it. That is why readiness is probed per-response.
|
|
24
|
+
return createRequestHandler({ appDir, dev: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('protected route redirects to login when unauthenticated', async (t) => {
|
|
28
|
+
const app = await makeHandler();
|
|
29
|
+
const res = await testRequest(app.handle, '/features/auth/dashboard');
|
|
30
|
+
if (res.status >= 500) {
|
|
31
|
+
t.skip('app deps not ready (run db:generate + db:migrate)');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// The dashboard middleware calls auth(req); with no session cookie it 302s to
|
|
35
|
+
// login. This needs no DB row, only a cookie read, so it is always real once
|
|
36
|
+
// the modules import.
|
|
37
|
+
assert.equal(res.status, 302, 'unauthenticated dashboard is gated');
|
|
38
|
+
assert.equal(res.headers.get('location'), '/features/auth/login');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('signup -> login -> dashboard renders for the authenticated user', async (t) => {
|
|
42
|
+
const app = await makeHandler();
|
|
43
|
+
// Probe readiness: a 5xx on the dashboard means deps/DB are not set up.
|
|
44
|
+
const probe = await testRequest(app.handle, '/features/auth/dashboard');
|
|
45
|
+
if (probe.status >= 500) { t.skip('app deps not ready; run db:generate + db:migrate'); return; }
|
|
46
|
+
|
|
47
|
+
const email = `harness+${Date.now()}@example.com`;
|
|
48
|
+
const password = 'password123';
|
|
49
|
+
|
|
50
|
+
// Real signup through the page server action (the no-JS form write-path).
|
|
51
|
+
let canSignup = true;
|
|
52
|
+
try {
|
|
53
|
+
const signupRes = await testRequest(app.handle, '/features/auth/signup', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
56
|
+
body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
|
|
57
|
+
});
|
|
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
|
+
} catch {
|
|
64
|
+
// No migrated DB table -> the action throws. Skip the DB-backed assertions.
|
|
65
|
+
canSignup = false;
|
|
66
|
+
}
|
|
67
|
+
if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
|
|
68
|
+
|
|
69
|
+
// Real login captures the genuine signed session cookie.
|
|
70
|
+
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
|
|
71
|
+
|
|
72
|
+
// With the session cookie the protected route now renders (200).
|
|
73
|
+
const dash = await testRequest(app.handle, '/features/auth/dashboard', withSessionCookie({}, cookies));
|
|
74
|
+
assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');
|
|
75
|
+
const body = await dash.text();
|
|
76
|
+
assert.match(body, /Dashboard/, 'the dashboard content rendered');
|
|
77
|
+
// The greeting interpolates the real user, so the name renders and the literal
|
|
78
|
+
// template source never leaks (a counterfactual for the escaping bug).
|
|
79
|
+
assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');
|
|
80
|
+
assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');
|
|
81
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Clear the api scaffold's backend-features showcase to a clean, buildable base.
|
|
3
|
+
//
|
|
4
|
+
// The api template ships a browsable showcase (JSON/HTTP endpoints under
|
|
5
|
+
// app/api/features/, their modules, an env.ts example, and a starter test) so an
|
|
6
|
+
// agent can learn the backend idioms from real, running code. It is the api
|
|
7
|
+
// counterpart of the UI gallery's `gallery:clear`. Learn from the demos relevant
|
|
8
|
+
// to your task first (the skill at .agents/skills/webjs/ teaches the same
|
|
9
|
+
// patterns and SURVIVES this reset), then run this once to shed the showcase, then
|
|
10
|
+
// grow the api in place.
|
|
11
|
+
//
|
|
12
|
+
// It removes the showcase routes + modules + the env.ts example + the widgets
|
|
13
|
+
// test, and resets the root app/route.ts to list only the kept baseline endpoints
|
|
14
|
+
// (/api/health, /api/users). It KEEPS the agent skill, middleware.ts (CORS), the
|
|
15
|
+
// health + users endpoints, modules/users, the database wiring, and the users
|
|
16
|
+
// example test. It is a one-time reset: if the showcase is already gone (no
|
|
17
|
+
// app/api/features/) it does nothing, so a rerun never clobbers an app you built.
|
|
18
|
+
import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
const rm = (p) => { if (existsSync(join(root, p))) { rmSync(join(root, p), { recursive: true, force: true }); return true; } return false; };
|
|
23
|
+
|
|
24
|
+
// Guard: the showcase is identified by app/api/features/. If it is absent, the
|
|
25
|
+
// showcase was already cleared (or this is not an api scaffold), so exit before
|
|
26
|
+
// any destructive write. This keeps a rerun safe and never clobbers a built app.
|
|
27
|
+
if (!existsSync(join(root, 'app/api/features'))) {
|
|
28
|
+
console.log('No backend-features showcase found (app/api/features/ is absent); nothing to clear.');
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 1) The showcase route tree + its module + the env.ts example + the widgets test.
|
|
33
|
+
const showcasePaths = [
|
|
34
|
+
'app/api/features',
|
|
35
|
+
'modules/widgets',
|
|
36
|
+
'env.ts',
|
|
37
|
+
'test/unit/widgets.test.ts',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let removed = 0;
|
|
41
|
+
for (const p of showcasePaths) if (rm(p)) removed++;
|
|
42
|
+
|
|
43
|
+
// 2) Reset the root app/route.ts to list only the kept baseline endpoints. The
|
|
44
|
+
// generated index also lists a `features:` block linking the showcase; strip it
|
|
45
|
+
// (and its comment) so the index does not point at routes that no longer exist.
|
|
46
|
+
// The regex preserves everything else (the app name, the health + users links).
|
|
47
|
+
const routePath = join(root, 'app/route.ts');
|
|
48
|
+
if (existsSync(routePath)) {
|
|
49
|
+
let s = readFileSync(routePath, 'utf8');
|
|
50
|
+
s = s.replace(/\n[ \t]*\/\/ The backend-features showcase[^\n]*\n[ \t]*features: \{[\s\S]*?\n[ \t]*\},\n/, '\n');
|
|
51
|
+
writeFileSync(routePath, s);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(`Backend-features showcase cleared (${removed} paths removed). The agent skill, your database wiring, and the health + users endpoints are kept.`);
|
|
55
|
+
console.log('Next: build your api in app/api/ and modules/, keeping server-only code behind .server.ts.');
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
// once to shed the gallery, then grow the app in place.
|
|
11
11
|
//
|
|
12
12
|
// It removes the gallery routes + modules + demo metadata routes, resets
|
|
13
|
-
// app/page.ts to a minimal home, and drops the demo `todos` table
|
|
14
|
-
// schema. It KEEPS the agent skill
|
|
15
|
-
// database wiring, the theme toggle, and
|
|
16
|
-
//
|
|
17
|
-
// app/features/) it does nothing, so a rerun never clobbers an app you
|
|
13
|
+
// app/page.ts to a minimal home, and drops the demo `todos` table plus the auth
|
|
14
|
+
// card's `passwordHash` column from the schema. It KEEPS the agent skill
|
|
15
|
+
// (.agents/skills/webjs/), the layout, the database wiring, the theme toggle, and
|
|
16
|
+
// the example `users` table. It is a one-time reset: if the gallery is already
|
|
17
|
+
// gone (no app/features/) it does nothing, so a rerun never clobbers an app you
|
|
18
|
+
// built.
|
|
18
19
|
import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { join } from 'node:path';
|
|
20
21
|
|
|
@@ -30,18 +31,24 @@ if (!existsSync(join(root, 'app/features'))) {
|
|
|
30
31
|
process.exit(0);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
// 1) Gallery route trees + example metadata routes.
|
|
34
|
+
// 1) Gallery route trees + example metadata routes. `app/api/auth` is the auth
|
|
35
|
+
// card's createAuth handler (it lives at the app root, not under app/features/,
|
|
36
|
+
// because createAuth hardcodes /api/auth/*), and `test/auth` is the auth card's
|
|
37
|
+
// request-pipeline test, so both are removed here alongside the card.
|
|
34
38
|
const galleryPaths = [
|
|
35
|
-
'app/features', 'app/examples', 'app/sitemaps',
|
|
39
|
+
'app/features', 'app/examples', 'app/sitemaps', 'app/api/auth', 'test/auth',
|
|
36
40
|
'app/icon.ts', 'app/apple-icon.ts', 'app/manifest.ts', 'app/opengraph-image.ts',
|
|
37
41
|
'app/twitter-image.ts', 'app/robots.ts', 'app/sitemap.ts',
|
|
38
42
|
'app/global-error.ts', 'app/global-not-found.ts',
|
|
39
43
|
];
|
|
40
|
-
// 2) The gallery's feature modules (by name
|
|
44
|
+
// 2) The gallery's feature modules (by name). `auth` is the auth card's server
|
|
45
|
+
// modules (createAuth config, password hashing, signup action, current-user
|
|
46
|
+
// query), pruned with the rest of the card.
|
|
41
47
|
const galleryModules = [
|
|
42
|
-
'async-render', 'broadcast', 'caching', 'client-router', 'components',
|
|
48
|
+
'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
|
|
43
49
|
'directives', 'file-storage', 'frames', 'optimistic-ui', 'rate-limit',
|
|
44
|
-
'route-handler', 'server-actions', 'sessions', '
|
|
50
|
+
'route-handler', 'server-actions', 'sessions', 'stream', 'streaming', 'suspense',
|
|
51
|
+
'todo', 'websockets',
|
|
45
52
|
].map((m) => `modules/${m}`);
|
|
46
53
|
|
|
47
54
|
let removed = 0;
|
|
@@ -50,12 +57,17 @@ for (const p of [...galleryPaths, ...galleryModules]) if (rm(p)) removed++;
|
|
|
50
57
|
// 3) Reset app/page.ts to a minimal home (no gallery grid, no dead links).
|
|
51
58
|
writeFileSync(join(root, 'app/page.ts'), MINIMAL_PAGE());
|
|
52
59
|
|
|
53
|
-
// 4) Drop the demo `todos` table
|
|
60
|
+
// 4) Drop the demo `todos` table and the auth card's `passwordHash` column from
|
|
61
|
+
// the schema (keep the example `users` table and everything else), reverting the
|
|
62
|
+
// schema to the minimal base.
|
|
54
63
|
const schemaPath = join(root, 'db/schema.server.ts');
|
|
55
64
|
if (existsSync(schemaPath)) {
|
|
56
65
|
let s = readFileSync(schemaPath, 'utf8');
|
|
57
66
|
s = s.replace(/\n(?:\/\/[^\n]*\n)*export const todos = table\('todos',[\s\S]*?\n\}\);\n/, '\n');
|
|
58
67
|
s = s.replace(/defineRelations\(\{ users, todos \}/, 'defineRelations({ users }');
|
|
68
|
+
// Strip the auth `passwordHash` column (and its leading comment lines) from the
|
|
69
|
+
// users table; the rest of the table is the minimal example base.
|
|
70
|
+
s = s.replace(/\n(?:[ \t]*\/\/[^\n]*\n)*[ \t]*passwordHash: text\(\),\n/, '\n');
|
|
59
71
|
writeFileSync(schemaPath, s);
|
|
60
72
|
}
|
|
61
73
|
|
package/lib/lean-copy.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The scaffold's lean-copy of a ui component (#983).
|
|
3
|
-
*
|
|
4
|
-
* `webjs create` copies a few `@webjsdev/ui` registry components into a
|
|
5
|
-
* generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked
|
|
6
|
-
* `@example` is stripped (the example is served on demand by `webjs ui view` /
|
|
7
|
-
* the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold
|
|
8
|
-
* copiers (`create.js` and `saas-template.js`) go through THIS one helper so
|
|
9
|
-
* they cannot drift.
|
|
10
|
-
*
|
|
11
|
-
* The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath
|
|
12
|
-
* cannot be resolved, this degrades to a no-op (keep the example) so the strip
|
|
13
|
-
* is never a reason `webjs create` fails.
|
|
14
|
-
*
|
|
15
|
-
* @module lean-copy
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
let _mod = null;
|
|
19
|
-
|
|
20
|
-
async function loadPrimitives() {
|
|
21
|
-
if (_mod) return _mod;
|
|
22
|
-
try {
|
|
23
|
-
const m = await import('@webjsdev/ui/registry/extract');
|
|
24
|
-
_mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource };
|
|
25
|
-
} catch {
|
|
26
|
-
_mod = { stripExample: (s) => s, isCustomElementSource: () => true };
|
|
27
|
-
}
|
|
28
|
-
return _mod;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Return the component source as `webjs ui add` would write it: a Tier-1 helper
|
|
33
|
-
* has its worked `@example` stripped and a pointer left; a Tier-2 element is
|
|
34
|
-
* returned unchanged.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} source the component source (imports already rewritten)
|
|
37
|
-
* @param {string} name the component name (for the pointer)
|
|
38
|
-
* @returns {Promise<string>}
|
|
39
|
-
*/
|
|
40
|
-
export async function leanComponentSource(source, name) {
|
|
41
|
-
const { stripExample, isCustomElementSource } = await loadPrimitives();
|
|
42
|
-
return isCustomElementSource(source) ? source : stripExample(source, name);
|
|
43
|
-
}
|