create-pracht 0.7.1 → 0.7.2
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/fallback-versions.json +20 -0
- package/package.json +3 -2
- package/skills/add-auth/SKILL.md +25 -7
- package/skills/add-db/SKILL.md +5 -3
- package/skills/add-images/SKILL.md +17 -3
- package/skills/add-observability/SKILL.md +13 -4
- package/skills/audit-auth/SKILL.md +29 -1
- package/skills/audit-bundles/SKILL.md +15 -0
- package/skills/audit-headers/SKILL.md +4 -0
- package/skills/audit-islands/SKILL.md +41 -5
- package/skills/audit-loaders/SKILL.md +20 -3
- package/skills/audit-redirects/SKILL.md +22 -8
- package/skills/audit-secrets/SKILL.md +15 -4
- package/skills/audit-shells/SKILL.md +17 -1
- package/skills/migrate-nextjs/SKILL.md +5 -5
- package/skills/pracht-debug/SKILL.md +3 -1
- package/skills/pracht-deploy/SKILL.md +13 -2
- package/skills/pracht-scaffold/SKILL.md +6 -4
- package/skills/pre-deploy/SKILL.md +8 -0
- package/skills/tune-render-mode/SKILL.md +20 -2
- package/skills/typed-routes/SKILL.md +50 -3
- package/skills/upgrade-pracht/SKILL.md +7 -2
- package/src/index.js +69 -26
- package/src/versions.js +43 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@pracht/adapter-cloudflare": "^0.8.0",
|
|
3
|
+
"@pracht/adapter-netlify": "^0.3.0",
|
|
4
|
+
"@pracht/adapter-node": "^0.5.0",
|
|
5
|
+
"@pracht/adapter-static": "^0.2.0",
|
|
6
|
+
"@pracht/adapter-vercel": "^0.4.0",
|
|
7
|
+
"@pracht/capabilities": "^0.6.0",
|
|
8
|
+
"@pracht/cli": "^1.15.0",
|
|
9
|
+
"@pracht/content": "^0.1.4",
|
|
10
|
+
"@pracht/core": "^0.19.0",
|
|
11
|
+
"@pracht/i18n": "^0.1.5",
|
|
12
|
+
"@pracht/image": "^0.5.0",
|
|
13
|
+
"@pracht/markdown": "^0.1.4",
|
|
14
|
+
"@pracht/openapi": "^0.2.6",
|
|
15
|
+
"@pracht/preact-ssr-precompile": "^0.1.4",
|
|
16
|
+
"@pracht/query": "^0.1.0",
|
|
17
|
+
"@pracht/session": "^0.1.2",
|
|
18
|
+
"@pracht/test": "^0.3.0",
|
|
19
|
+
"@pracht/vite-plugin": "^0.14.0"
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pracht",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pracht",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"bin",
|
|
31
31
|
"src",
|
|
32
32
|
"skills",
|
|
33
|
+
"fallback-versions.json",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
35
36
|
"type": "module",
|
|
@@ -40,6 +41,6 @@
|
|
|
40
41
|
"provenance": true
|
|
41
42
|
},
|
|
42
43
|
"scripts": {
|
|
43
|
-
"build": "node ./scripts/sync-skills.js"
|
|
44
|
+
"build": "node ./scripts/sync-skills.js && node ./scripts/sync-versions.js"
|
|
44
45
|
}
|
|
45
46
|
}
|
package/skills/add-auth/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-auth
|
|
3
|
-
version: 2.1.
|
|
3
|
+
version: 2.1.1
|
|
4
4
|
description: |
|
|
5
5
|
Wire session-based auth into a pracht app with `@pracht/session`: encrypted
|
|
6
6
|
cookie sessions, gate middleware, login/logout/signup API routes, `<Form>`
|
|
@@ -170,23 +170,40 @@ Workers, measure a login against the plan's CPU limit.
|
|
|
170
170
|
|
|
171
171
|
## Step 5: Auth API routes
|
|
172
172
|
|
|
173
|
+
`src/server/redirects.ts` — the one gate for every request-derived redirect
|
|
174
|
+
target. Never use a `startsWith("/") && !startsWith("//")` prefix check: URL
|
|
175
|
+
parsing turns `\` into `/`, so `/\evil.com` passes it and lands off-origin.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
|
|
179
|
+
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
|
|
180
|
+
try {
|
|
181
|
+
const target = new URL(value, base);
|
|
182
|
+
return target.origin === base.origin
|
|
183
|
+
? `${target.pathname}${target.search}${target.hash}`
|
|
184
|
+
: fallback;
|
|
185
|
+
} catch {
|
|
186
|
+
return fallback;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
173
191
|
`src/api/auth/login.ts`:
|
|
174
192
|
|
|
175
193
|
```ts
|
|
176
194
|
import { redirect, type ApiRouteArgs } from "@pracht/core";
|
|
177
195
|
|
|
196
|
+
import { safeRedirectPath } from "../../server/redirects.ts";
|
|
178
197
|
import { sessions } from "../../server/session.ts";
|
|
179
198
|
import { verifyCredentials } from "../../server/users.ts";
|
|
180
199
|
|
|
181
|
-
export async function POST({ request }: ApiRouteArgs) {
|
|
200
|
+
export async function POST({ request, url }: ApiRouteArgs) {
|
|
182
201
|
const form = await request.formData();
|
|
183
202
|
const email = String(form.get("email") ?? "");
|
|
184
203
|
const password = String(form.get("password") ?? "");
|
|
185
204
|
|
|
186
205
|
// The redirect field is user input — gate it, or this is an open redirect.
|
|
187
|
-
const
|
|
188
|
-
const target =
|
|
189
|
-
requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard";
|
|
206
|
+
const target = safeRedirectPath(form.get("redirect"), url, "/dashboard");
|
|
190
207
|
|
|
191
208
|
const user = await verifyCredentials(email, password);
|
|
192
209
|
if (!user) {
|
|
@@ -226,11 +243,12 @@ export async function POST({ request }: ApiRouteArgs) {
|
|
|
226
243
|
```tsx
|
|
227
244
|
import { Form, type LoaderArgs, type RouteComponentProps } from "@pracht/core";
|
|
228
245
|
|
|
246
|
+
import { safeRedirectPath } from "../server/redirects.ts";
|
|
247
|
+
|
|
229
248
|
export async function loader({ url }: LoaderArgs) {
|
|
230
|
-
const requested = url.searchParams.get("redirect") ?? "/dashboard";
|
|
231
249
|
return {
|
|
232
250
|
error: url.searchParams.get("error") === "1",
|
|
233
|
-
redirect:
|
|
251
|
+
redirect: safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"),
|
|
234
252
|
};
|
|
235
253
|
}
|
|
236
254
|
|
package/skills/add-db/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-db
|
|
3
|
-
version: 1.2.
|
|
3
|
+
version: 1.2.1
|
|
4
4
|
description: |
|
|
5
5
|
Wire Drizzle ORM into a pracht app: pick the target (D1, PlanetScale, Neon,
|
|
6
6
|
Supabase, Turso, Postgres, MySQL, SQLite), then generate driver setup, schema,
|
|
@@ -93,11 +93,13 @@ export const db = drizzle(pool, { schema });
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
For Cloudflare D1, register the Cloudflare context type once via the `Register`
|
|
96
|
-
augmentation
|
|
97
|
-
`
|
|
96
|
+
augmentation. Keep the `import`: without it the file is a global script and the
|
|
97
|
+
declaration replaces `@pracht/core`'s types instead of extending them.
|
|
98
98
|
|
|
99
99
|
```ts
|
|
100
100
|
// src/env.d.ts
|
|
101
|
+
import "@pracht/core";
|
|
102
|
+
|
|
101
103
|
declare module "@pracht/core" {
|
|
102
104
|
interface Register {
|
|
103
105
|
context: {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-images
|
|
3
|
-
version: 1.0
|
|
3
|
+
version: 1.1.0
|
|
4
4
|
description: |
|
|
5
5
|
Wire `@pracht/image`: the CLS-safe zero-runtime `<Image>`, the loader for your
|
|
6
|
-
target (sharp endpoint, Cloudflare, Vercel, passthrough), `?pracht` build-time
|
|
6
|
+
target (sharp endpoint, Cloudflare, Vercel, Netlify, passthrough), `?pracht` build-time
|
|
7
7
|
imports with blur placeholders, prebuilt WebP variants, and the optimization
|
|
8
8
|
endpoint's security settings.
|
|
9
9
|
Use for "add images", "optimize images", "responsive images", "next/image
|
|
@@ -39,6 +39,7 @@ pracht inspect build --json # adapterTarget (requires a prior `pracht build`)
|
|
|
39
39
|
| Node | `defaultLoader` + the built-in endpoint | needs `sharp`; put a CDN in front |
|
|
40
40
|
| Cloudflare | `cloudflareLoader` | Image Resizing must be enabled on the zone; sharp does not run on Workers |
|
|
41
41
|
| Vercel | `vercelLoader` | needs an `images` section in the project config; Vercel only serves widths listed in `images.sizes` |
|
|
42
|
+
| Netlify | `netlifyLoader` | Image CDN; remote sources need `images.remote_images` in `netlify.toml`; no sharp endpoint |
|
|
42
43
|
| Static host | `passthroughLoader` | no image service — srcset is omitted |
|
|
43
44
|
| Any target, no runtime service | `?pracht&pracht-static` imports | prebuilt WebP variants, no loader involved |
|
|
44
45
|
|
|
@@ -76,9 +77,22 @@ configureImage({
|
|
|
76
77
|
|
|
77
78
|
`createDefaultLoader("/my/endpoint")` builds a default-style loader for a custom
|
|
78
79
|
endpoint path. Root-absolute endpoints pick up Vite's deploy `base`
|
|
79
|
-
automatically; provider loaders (`cloudflareLoader`, `vercelLoader`)
|
|
80
|
+
automatically; provider loaders (`cloudflareLoader`, `vercelLoader`, `netlifyLoader`)
|
|
80
81
|
deliberately stay at the origin root.
|
|
81
82
|
|
|
83
|
+
For Netlify, use `configureImage({ loader: import.meta.env.DEV ?
|
|
84
|
+
passthroughLoader : netlifyLoader })` so `pracht dev` renders original images.
|
|
85
|
+
Import both loaders from `@pracht/image`. For remote sources, merge the
|
|
86
|
+
allowed URL patterns into the existing `netlify.toml`:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
[images]
|
|
90
|
+
remote_images = ['https://images\.example\.com/.*']
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Local source files need no image-service configuration; Netlify negotiates
|
|
94
|
+
the output format and caches resized images. Skip the Node endpoint step.
|
|
95
|
+
|
|
82
96
|
## Step 4: Render images
|
|
83
97
|
|
|
84
98
|
```tsx
|
|
@@ -48,6 +48,12 @@ but trivial.
|
|
|
48
48
|
|
|
49
49
|
## Step 2: Server error tracking
|
|
50
50
|
|
|
51
|
+
Pracht already prints one line per 5xx to the server console — phase, route id,
|
|
52
|
+
source file, request path, message, and the stack — so the app is not silent
|
|
53
|
+
before any of this is wired. What that default does not give you is a request
|
|
54
|
+
id, a duration, the successful requests, or delivery to a sink off the box.
|
|
55
|
+
Say so when the user only wants "errors in my logs": they may already have it.
|
|
56
|
+
|
|
51
57
|
### Sentry path (Node adapter)
|
|
52
58
|
|
|
53
59
|
The pattern below uses `@sentry/node` and works on the **Node adapter only**
|
|
@@ -274,10 +280,13 @@ Key properties to state when scaffolding this:
|
|
|
274
280
|
- Delivery snapshots the registered sinks before callbacks run. Adding or
|
|
275
281
|
replacing a sink from inside a callback takes effect on the next dispatch,
|
|
276
282
|
so the current event is never delivered twice to one name.
|
|
277
|
-
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
283
|
+
- A sink's returned promise is handed to the request's `waitUntil()`, so an
|
|
284
|
+
`async` sink finishes after the response on every adapter (Cloudflare
|
|
285
|
+
`ctx.waitUntil`, Netlify/Vercel `context.waitUntil`, Node's shutdown drain)
|
|
286
|
+
and its rejection is reported. A batching exporter that flushes on its own
|
|
287
|
+
schedule should be flushed from middleware with
|
|
288
|
+
`args.waitUntil(exporter.flush())` — never reach for
|
|
289
|
+
`context.executionContext.waitUntil`.
|
|
281
290
|
- Audit events cover *dispatch* only. A cross-origin 403, an unknown-capability
|
|
282
291
|
404, and an unknown MCP tool name all return before dispatch and emit
|
|
283
292
|
nothing, so do not build a reconnaissance alert on these events — use the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-auth
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.5.0
|
|
4
4
|
description: |
|
|
5
5
|
Find pracht routes that look protected but aren't: missing auth middleware,
|
|
6
6
|
middleware that augments context but never gates, client-only checks, and
|
|
@@ -87,6 +87,13 @@ auth is enforced because `request.headers.get('x-user-id')` returns a value
|
|
|
87
87
|
when present, but unauthenticated requests just get `null` and the loader has
|
|
88
88
|
to handle it. Flag every loader downstream of an Augmenter that doesn't.
|
|
89
89
|
|
|
90
|
+
A shell `loader` that redirects anonymous users is **not** a gate either. A
|
|
91
|
+
client that already holds the shell's data sends `x-pracht-shell-data: <shell>`
|
|
92
|
+
on route-state requests and the server skips the shell loader, so the route's
|
|
93
|
+
data comes back without it running. Flag any shell whose loader is the only
|
|
94
|
+
thing between an anonymous request and the routes under it; the gate belongs
|
|
95
|
+
in middleware.
|
|
96
|
+
|
|
90
97
|
## Step 2: Identify protected routes
|
|
91
98
|
|
|
92
99
|
A route is "expected protected" if any of:
|
|
@@ -165,6 +172,27 @@ target. From `pracht inspect api --json`:
|
|
|
165
172
|
- Common bug: dashboard route is protected by middleware, but
|
|
166
173
|
`POST /api/items` is not — attacker bypasses the UI entirely.
|
|
167
174
|
|
|
175
|
+
### Server islands
|
|
176
|
+
|
|
177
|
+
Every module in `src/server-islands/` (or `pracht({ serverIslandsDir })`) is
|
|
178
|
+
reachable at `GET /__pracht/server-island` with the visitor's cookies, for any
|
|
179
|
+
page whose route or shell lists it in `export const serverIslands = [...]` —
|
|
180
|
+
with that route's middleware
|
|
181
|
+
(see <https://pracht.resynapse.dev/docs/server-islands#security>). Map each
|
|
182
|
+
server island to the routes that list it, directly or through their shell.
|
|
183
|
+
|
|
184
|
+
- Flag a server island listed by both gated and ungated routes whose loader
|
|
185
|
+
returns user-specific data without checking `context` itself — `error`: the
|
|
186
|
+
ungated route serves it to anyone.
|
|
187
|
+
- Flag pages that gate a server island only at render time
|
|
188
|
+
(`{isAdmin && <AdminStats />}` behind a loader check, not middleware) —
|
|
189
|
+
`error`: the endpoint runs it for anyone who passes that route's middleware.
|
|
190
|
+
- Flag server island loaders that pick *whose* data to load from `props`
|
|
191
|
+
(`userId`, `orderId`, `accountId`) — the caller controls the query string
|
|
192
|
+
(`error`, IDOR). Identity must come from `context`.
|
|
193
|
+
- Flag server island loaders with side effects (writes, sends) — the endpoint is
|
|
194
|
+
a `GET` (`warn`).
|
|
195
|
+
|
|
168
196
|
## Step 5: Client/server enforcement parity
|
|
169
197
|
|
|
170
198
|
Grep client components for patterns like `if (!user) return <Login />`. For
|
|
@@ -56,6 +56,14 @@ reference with `dist/client/.vite/manifest.json` for chunk metadata
|
|
|
56
56
|
(file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in
|
|
57
57
|
analysis (Step 4) and CSS sizing, which the analyze report does not cover.
|
|
58
58
|
|
|
59
|
+
Size CSS from the `cssManifest` this command reports, never from
|
|
60
|
+
`dist/client/.vite/manifest.json`. A `hydration: "none"` or `"islands"` route is
|
|
61
|
+
absent from the client manifest by construction, so reading it directly reports
|
|
62
|
+
no CSS for a page whose document links a stylesheet — under-reporting exactly
|
|
63
|
+
the routes pracht recommends. `inspect` merges `dist/server/css-manifest.json`,
|
|
64
|
+
the build's own record, over the client manifest; an older build without that
|
|
65
|
+
file is client-only, and the CSS column should say so rather than read as zero.
|
|
66
|
+
|
|
59
67
|
## Step 3: Interpret per-route payload
|
|
60
68
|
|
|
61
69
|
From the Step 1 JSON, report:
|
|
@@ -191,6 +199,13 @@ rel="stylesheet">` tags on static pages, and the build warns.
|
|
|
191
199
|
`build.cssCodeSplit: false` is rejected outright — there is no `index.html` to
|
|
192
200
|
link the single stylesheet it produces, so every page would ship without one.
|
|
193
201
|
|
|
202
|
+
When a route's CSS is small and the site is entered cold — a static export, a
|
|
203
|
+
content site arriving from search — the stylesheet link is often the only
|
|
204
|
+
render-blocking request left on a page that ships no JavaScript. Report
|
|
205
|
+
`pracht({ inlineCss: true })` as a suggestion there, with the cost: the same
|
|
206
|
+
bytes in every document and no shared stylesheet cache. An app whose visitors
|
|
207
|
+
move between pages should keep the link.
|
|
208
|
+
|
|
194
209
|
## Step 7: Report
|
|
195
210
|
|
|
196
211
|
Three sections:
|
|
@@ -121,6 +121,10 @@ into `speculation` (they emit an inline
|
|
|
121
121
|
`<script id="pracht-state" type="application/json">` — it does not need
|
|
122
122
|
`'unsafe-inline'`. Do not add `'unsafe-inline'` unless the app truly emits
|
|
123
123
|
executable inline scripts and the tradeoff is documented.
|
|
124
|
+
5. Routes with `streaming: true` emit executable inline scripts for deferred
|
|
125
|
+
data and boundary swaps. Under a nonce policy, return the request nonce as
|
|
126
|
+
`scriptNonce` from the shell `head()` and allow it in `script-src`; it also
|
|
127
|
+
covers the speculation rules script.
|
|
124
128
|
|
|
125
129
|
Output a draft CSP referencing `docs/CSP.md` and explain what each origin is
|
|
126
130
|
for. Do not hand the user a CSP that breaks their site — present as draft.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-islands
|
|
3
|
-
version: 1.0
|
|
3
|
+
version: 1.1.0
|
|
4
4
|
description: |
|
|
5
5
|
Audit pracht hydration: over-hydrated routes that should be `hydration:
|
|
6
6
|
"islands"` or `"none"`, dead interactivity outside the islands directory,
|
|
@@ -89,9 +89,22 @@ there is no wrapper to mark a component elsewhere as an island.
|
|
|
89
89
|
Island props are serialized to JSON in the HTML. At each island call site on
|
|
90
90
|
an islands route, flag props that are functions, symbols, bigints, class
|
|
91
91
|
instances (`Date`, `Map`, ...), JSX elements, or circular — rendering throws a
|
|
92
|
-
descriptive error naming the offending prop path.
|
|
93
|
-
island
|
|
94
|
-
|
|
92
|
+
descriptive error naming the offending prop path. Children passed into an
|
|
93
|
+
island are fine: markup renders once on the server as static HTML, and plain
|
|
94
|
+
text arrives as a string value. Flag:
|
|
95
|
+
|
|
96
|
+
- a render function passed as children (it throws);
|
|
97
|
+
- children the island expects to re-render or pass props into, and islands
|
|
98
|
+
among the children that read context the outer island provides (the browser
|
|
99
|
+
gives them the default) — those belong inside the island or need a prop;
|
|
100
|
+
- an island that splits markup children with `toChildArray`, `Children.map`, or
|
|
101
|
+
`Children.count` (tabs, carousels): it receives one opaque node, so labels
|
|
102
|
+
belong in props and panels are toggled through a ref (`hidden`);
|
|
103
|
+
- an island that renders `children` inside `<p>`, `<a>`, or `<button>` while
|
|
104
|
+
call sites pass block elements, links, or buttons (the dev server throws
|
|
105
|
+
when it sees this), or raw HTML in children that may be malformed: the
|
|
106
|
+
browser moves those nodes out of the slot and the island is left unhydrated
|
|
107
|
+
with a console error.
|
|
95
108
|
|
|
96
109
|
### 3d. Hydration strategy tuning (`info`)
|
|
97
110
|
|
|
@@ -115,7 +128,28 @@ navigation to, from, and between them is full-document (MPA-style), and
|
|
|
115
128
|
route-state prefetching is skipped for them. Flag apps that rely on
|
|
116
129
|
client-side state surviving navigation across such routes (in-memory stores,
|
|
117
130
|
module-level caches shared between pages) — every navigation is a fresh
|
|
118
|
-
document.
|
|
131
|
+
document, unless `pracht({ client: { islandsNavigation: true } })` is set. With
|
|
132
|
+
it, links between islands pages (and from them to `none` pages) swap the page
|
|
133
|
+
into the same document and an island rendered with identical props on both
|
|
134
|
+
pages keeps its state; module state survives too. Still flag state that must
|
|
135
|
+
survive a link to a full-hydration route, a reload, or a browser without the
|
|
136
|
+
Navigation API, since those remain full loads. Page-change animation does not need the client router: with
|
|
137
|
+
`defineApp({ viewTransitions: true })` these documents carry a
|
|
138
|
+
`@view-transition` rule and animate as cross-document view transitions, so
|
|
139
|
+
flag only apps that load extra JavaScript to animate such navigations.
|
|
140
|
+
|
|
141
|
+
### 3g. Personal data in islands on cached pages (`warn`)
|
|
142
|
+
|
|
143
|
+
Island props on an `ssg`/`isg` route are fixed at build or regeneration time
|
|
144
|
+
and shared by every visitor. Flag islands that exist only to fetch per-visitor
|
|
145
|
+
content after load (a hand-rolled `fetch` of the session, cart, or "signed in
|
|
146
|
+
as" data in `useEffect`) and islands whose props look personal. Suggest a
|
|
147
|
+
server island instead (`src/server-islands/`, see
|
|
148
|
+
<https://pracht.resynapse.dev/docs/server-islands>): its
|
|
149
|
+
loader runs per request with the route's middleware context, and it may
|
|
150
|
+
contain islands, which hydrate once the server island is swapped in. On
|
|
151
|
+
full-hydration pages islands inside a server island stay static HTML — flag those
|
|
152
|
+
(`warn`) if they are expected to be interactive.
|
|
119
153
|
|
|
120
154
|
## Step 4: Report
|
|
121
155
|
|
|
@@ -139,5 +173,7 @@ finding.
|
|
|
139
173
|
routes.
|
|
140
174
|
4. Verify hydration in a running app via `html[data-pracht-islands-hydrated="true"]`
|
|
141
175
|
(set after all `load` islands hydrate) and per-island `data-hydrated="true"`.
|
|
176
|
+
Server islands report `html[data-pracht-server-islands-ready="true"]` once every server
|
|
177
|
+
island fetch the page started has settled, on every hydration mode.
|
|
142
178
|
|
|
143
179
|
$ARGUMENTS
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-loaders
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Audit pracht route loaders for serializability, leaked secrets, unsafe
|
|
6
6
|
`loaderCache`, browser-only API use, and missing `AbortSignal` plumbing.
|
|
@@ -38,13 +38,22 @@ manifest (`RouteConfig.loader`); the inspect JSON surfaces that as
|
|
|
38
38
|
`loaderFile` (null when the loader lives in the route module itself). Reading
|
|
39
39
|
only `file` misses every externalized loader.
|
|
40
40
|
|
|
41
|
+
Shells can export a `loader` too. Read each distinct `shellFile` from the same
|
|
42
|
+
JSON and audit its `loader` like a route's: its return value reaches the
|
|
43
|
+
browser as `shellData` on every page under that shell.
|
|
44
|
+
|
|
41
45
|
## Step 2: Run the five checks
|
|
42
46
|
|
|
43
47
|
For each `loader` (and `getStaticPaths` when present):
|
|
44
48
|
|
|
45
49
|
### 2a. Serializability
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
Skip this check for `hydration: "islands"` and `"none"` routes: they ship no
|
|
52
|
+
loader data. Otherwise, check `vite.config.*` for
|
|
53
|
+
`pracht({ client: { richData: true } })` first.
|
|
54
|
+
|
|
55
|
+
Without `richData` (the default), loader data is `JSON.stringify`'d while the
|
|
56
|
+
component's types still promise the original. Flag returns that contain any of:
|
|
48
57
|
|
|
49
58
|
| Construct | Why it breaks |
|
|
50
59
|
| ---------------------------- | ----------------------------------- |
|
|
@@ -57,6 +66,13 @@ Flag returns that contain any of:
|
|
|
57
66
|
| `bigint` | `JSON.stringify` throws |
|
|
58
67
|
| `undefined` in arrays/object | Drops keys; arrays become `null` |
|
|
59
68
|
|
|
69
|
+
With `richData`, `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, `undefined`,
|
|
70
|
+
and shared or circular references arrive intact: do not flag them. Flag
|
|
71
|
+
functions, symbols, typed arrays, and class instances without `toJSON()`
|
|
72
|
+
(`Error` included) instead: each fails the request with a 500 naming the path,
|
|
73
|
+
in production too. A class instance with `toJSON()` arrives as its output, so
|
|
74
|
+
its type lies.
|
|
75
|
+
|
|
60
76
|
A bare promise in loader data is always a bug — it serializes to `{}`. The fix
|
|
61
77
|
is `defer(promise)`, which marks the field as deferred and is read in the
|
|
62
78
|
component with `use()` inside a `<Suspense>` boundary. Flag a bare promise as an
|
|
@@ -73,7 +89,8 @@ Two `defer()` rules worth checking while you are in the loader:
|
|
|
73
89
|
behind a getter. An unresolved marker throws during serialization.
|
|
74
90
|
|
|
75
91
|
Recommend converting to `string` (ISO for dates), plain arrays, or plain objects
|
|
76
|
-
before return.
|
|
92
|
+
before return, or turning on `client.richData` when an app returns many dates
|
|
93
|
+
or maps.
|
|
77
94
|
|
|
78
95
|
### 2b. Secret leaks
|
|
79
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-redirects
|
|
3
|
-
version: 1.1.
|
|
3
|
+
version: 1.1.2
|
|
4
4
|
description: |
|
|
5
5
|
Find open redirects in pracht loaders, middleware, and navigation. The framework
|
|
6
6
|
rejects unsafe schemes and CRLF at both ends, but a hand-rolled 3xx Response
|
|
@@ -86,16 +86,18 @@ For each request-derived target, look for one of:
|
|
|
86
86
|
| Gate | Safe? |
|
|
87
87
|
| -------------------------------------- | ----- |
|
|
88
88
|
| Hardcoded allowlist of paths/origins | Yes |
|
|
89
|
-
| `target.startsWith('/')` AND `!target.startsWith('//')` | Yes — same-origin path only |
|
|
90
89
|
| `new URL(target, base).origin === url.origin` | Yes — origin comparison |
|
|
90
|
+
| `target.startsWith('/')` AND `!target.startsWith('//')` | **Bypassable** (`/\evil.example`) |
|
|
91
91
|
| `new URL(target).hostname === expected` | Yes if `expected` is trusted |
|
|
92
92
|
| No check | **Open redirect** |
|
|
93
93
|
| `target.includes(domain)` (substring) | **Bypassable** (`evil.com#yourdomain.com`) |
|
|
94
94
|
| Regex without anchors | **Likely bypassable** |
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
Prefix checks are **not** sufficient. `//evil.example/path` is
|
|
97
|
+
protocol-relative, and URL parsing turns `\` into `/` and strips tabs and
|
|
98
|
+
newlines, so `/\evil.example` and `/\t/evil.example` pass
|
|
99
|
+
`startsWith('/') && !startsWith('//')` yet land on another origin. Only an
|
|
100
|
+
origin comparison after `new URL(target, url)` is a real gate.
|
|
99
101
|
|
|
100
102
|
Remember: `redirect()`'s built-in validation covers scheme and CRLF only — it
|
|
101
103
|
happily redirects to any well-formed `http(s)` origin, so Class A sites still
|
|
@@ -118,9 +120,21 @@ Severity is the primary scale; the verdict is a secondary domain label:
|
|
|
118
120
|
For each `open`/`risky` finding, propose a fix snippet, e.g.:
|
|
119
121
|
|
|
120
122
|
```ts
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
|
|
124
|
+
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
|
|
125
|
+
try {
|
|
126
|
+
const target = new URL(value, base);
|
|
127
|
+
return target.origin === base.origin
|
|
128
|
+
? `${target.pathname}${target.search}${target.hash}`
|
|
129
|
+
: fallback;
|
|
130
|
+
} catch {
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return redirect(safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"), {
|
|
136
|
+
request,
|
|
137
|
+
});
|
|
124
138
|
```
|
|
125
139
|
|
|
126
140
|
## Step 5: Cross-check with the framework guards
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-secrets
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Detect environment variables and secrets reaching the client bundle via loader
|
|
6
6
|
return values, hydration state, or server-only modules imported from client code
|
|
@@ -74,8 +74,9 @@ risk in the report.
|
|
|
74
74
|
|
|
75
75
|
## Step 3: Module import boundaries
|
|
76
76
|
|
|
77
|
-
Grep client-rendered files (route components, shells,
|
|
78
|
-
them) for
|
|
77
|
+
Grep client-rendered files (route components, shells, the app root registered
|
|
78
|
+
as `defineApp({ root })` or `pages/_root.tsx`, anything imported from them) for
|
|
79
|
+
imports of:
|
|
79
80
|
|
|
80
81
|
- `node:*` builtins
|
|
81
82
|
- `@pracht/adapter-*`
|
|
@@ -87,12 +88,22 @@ The Vite plugin strips the server-only exports `loader`, `head`, `headers`,
|
|
|
87
88
|
query (`middleware` is not a route-file export — middleware lives in the
|
|
88
89
|
manifest); see the client module transform notes in `docs/ARCHITECTURE.md`
|
|
89
90
|
and `docs/ENV.md`. But a component that imports `../server/db` will still
|
|
90
|
-
pull `db` into the client bundle. Flag those imports.
|
|
91
|
+
pull `db` into the client bundle. Flag those imports. The app root's browser
|
|
92
|
+
copy loses only `dehydrate` and the imports nothing else uses; `setup()`,
|
|
93
|
+
`Root`, `hydrate()`, and everything they import ship. A `dehydrate` bound in
|
|
94
|
+
the same destructuring as those (`export const { setup, Root, dehydrate,
|
|
95
|
+
hydrate } = createRoot(…)`) or reached through `export *` ships with them, so
|
|
96
|
+
flag a server-only value passed into that call. A root whose other code calls
|
|
97
|
+
or re-exports `dehydrate` ships whole; flag its server-only imports too.
|
|
91
98
|
|
|
92
99
|
## Step 4: Hidden surfaces
|
|
93
100
|
|
|
94
101
|
Check for accidental exposure outside loaders:
|
|
95
102
|
|
|
103
|
+
- The app root's `dehydrate()` snapshot: it lands in `pracht-state` as `root`
|
|
104
|
+
and in every route-state response, like loader data. For `@pracht/query`
|
|
105
|
+
that is every successful query cached during the request, so flag a
|
|
106
|
+
`queryFn` or `setQueryData` in a loader that caches a secret-bearing value.
|
|
96
107
|
- `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
|
|
97
108
|
- `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
|
|
98
109
|
document headers enter `dist/server/headers-manifest.json`; every serverful
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-shells
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.4.0
|
|
4
4
|
description: |
|
|
5
5
|
Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/
|
|
6
6
|
`<body>` rendered inside a shell, shells that never render `children`, unused
|
|
@@ -92,6 +92,22 @@ route `headers()`). Optional, but if present:
|
|
|
92
92
|
- Flag shells whose `headers()` returns `undefined` unconditionally — delete
|
|
93
93
|
the export.
|
|
94
94
|
|
|
95
|
+
### 2g. `loader` export
|
|
96
|
+
|
|
97
|
+
Shells may export `loader(args)` for layout-level data, read with
|
|
98
|
+
`useShellData()` in the shell and its routes. If present:
|
|
99
|
+
- Run the audit-loaders checks on it: its return value is serialized into
|
|
100
|
+
every page under the shell (`shellData` in the hydration state and the
|
|
101
|
+
route-state JSON).
|
|
102
|
+
- `useShellData()` returns `undefined` while the shell renders without its
|
|
103
|
+
data (SPA loading state, an error boundary after the shell loader failed).
|
|
104
|
+
Flag a shell that dereferences it unguarded.
|
|
105
|
+
- Flag a shell loader used as an auth gate (redirecting anonymous users) with
|
|
106
|
+
no gating middleware on its routes: navigations inside the shell ask the
|
|
107
|
+
server to skip it. Gate in middleware.
|
|
108
|
+
- Flag several routes under one shell whose loaders each fetch the same
|
|
109
|
+
layout-level value (the current user): that belongs in the shell loader.
|
|
110
|
+
|
|
95
111
|
## Step 3: Coverage and waste
|
|
96
112
|
|
|
97
113
|
Shell registration is mode-aware — check the `mode` field from the Step 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: migrate-nextjs
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.8.1
|
|
4
4
|
description: |
|
|
5
5
|
Migrate a Next.js app to pracht: App or Pages Router pages, layouts, middleware,
|
|
6
6
|
API routes, data fetching, and metadata — plus React→Preact, `className`→`class`,
|
|
@@ -74,7 +74,7 @@ automatic**:
|
|
|
74
74
|
| ------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
75
75
|
| `pages/` directory | `pagesDir` plugin option | Auto-discovers routes from the file system |
|
|
76
76
|
| `app/page.tsx` | `src/routes/*.tsx` + `route()` in manifest | File is a module; wiring is explicit |
|
|
77
|
-
| `app/layout.tsx` | `src/shells/*.tsx` + `shells` in `defineApp` (pages: `_app.tsx` per directory) | Named shells; a directory `_app` replaces its parent
|
|
77
|
+
| `app/layout.tsx` | `src/shells/*.tsx` + `shells` in `defineApp` (pages: `_app.tsx` per directory) | Named shells; a directory `_app` replaces its parent. Layout data: shell `loader` + `useShellData()` |
|
|
78
78
|
| `app/loading.tsx` | `Loading` export on the shell | SSR placeholder for SPA routes until the client router takes over |
|
|
79
79
|
| `app/error.tsx` | `ErrorBoundary` export in route module | Same concept, different wiring |
|
|
80
80
|
| `app/not-found.tsx` | `notFound:` in `defineApp` (or `pages/404.tsx` in pagesDir mode) | Not a route — never matches a URL, so it cannot shadow static assets |
|
|
@@ -87,7 +87,7 @@ automatic**:
|
|
|
87
87
|
| `"use client"` (few, mostly-server app) | `hydration: "islands"` + `src/islands/` | Only islands ship JS; see Phase 4 |
|
|
88
88
|
| `revalidatePath` / `res.revalidate()` | `webhookRevalidate()` + `POST /__pracht/revalidate` | On-demand ISG; combinable with `timeRevalidate(seconds)` |
|
|
89
89
|
| `useRouter()` (next/navigation) | `useNavigate()` | Takes a path, or `{ route: "id" }` after `pracht typegen` |
|
|
90
|
-
| `useSearchParams()` | `useSearchParams()` | Reactive read-only params; SSG gets the browser query after hydration,
|
|
90
|
+
| `useSearchParams()` | `useSearchParams()` | Reactive read-only params; SSG gets the browser query after hydration. For validated, typed values export a `search` schema and read `args.search` / `useSearch()` |
|
|
91
91
|
| `useParams()` | `useParams()` | Direct equivalent; also `params` in loader args |
|
|
92
92
|
| `next/link` `<Link>` | `<Link route="..." params={{…}}>` or plain `<a>` | Prefer typed `<Link>` after `pracht typegen`; the router intercepts same-origin anchors |
|
|
93
93
|
| `next/link` `prefetch={false}` | `<Link prefetch="none">` | Default `"intent"` (hover/focus); also `"viewport"`, `"render"` |
|
|
@@ -337,8 +337,8 @@ and fix errors iteratively.
|
|
|
337
337
|
| `next` | `@pracht/core` + `@pracht/cli` + `@pracht/vite-plugin` + a target adapter |
|
|
338
338
|
| `next/image` | `@pracht/image` |
|
|
339
339
|
| `react`, `react-dom` | `preact` |
|
|
340
|
-
| `next/font/local` | `defineFont()`
|
|
341
|
-
| `next/font/google` |
|
|
340
|
+
| `next/font/local` | Import WOFF2 with `?url&no-inline` into [`defineFont()`](https://pracht.resynapse.dev/docs/fonts); register `head().fonts`, apply `font.className`/`font.style` |
|
|
341
|
+
| `next/font/google` | Self-host WOFF2 in `src/fonts/`, then follow `next/font/local`; pracht never fetches fonts at build time |
|
|
342
342
|
| `@next/mdx` | `@mdx-js/rollup` (Vite plugin) |
|
|
343
343
|
| `next-auth` | Direct integration in middleware/loaders |
|
|
344
344
|
| `next/og` | `@vercel/og` or a custom solution |
|
|
@@ -92,7 +92,9 @@ Work in order; stop at the root cause.
|
|
|
92
92
|
### 3. Loader / API errors
|
|
93
93
|
|
|
94
94
|
- On slow pages, read `Server-Timing` before reading code.
|
|
95
|
-
- Loaders must return serializable data — no functions, no circular refs.
|
|
95
|
+
- Loaders must return serializable data — no functions, no circular refs. A
|
|
96
|
+
`Date` arrives as a string unless `pracht({ client: { richData: true } })` is
|
|
97
|
+
set; with it, an unsupported value fails with an error naming its path.
|
|
96
98
|
- API handlers must return `Response` objects, and a default export must branch
|
|
97
99
|
on `request.method`.
|
|
98
100
|
- Look for unhandled rejections or thrown errors.
|
|
@@ -75,6 +75,14 @@ step (`--port <n>`, `--skip-build` to reuse a build).
|
|
|
75
75
|
`Accept-Encoding`, streaming for dynamic bodies, an in-memory LRU for static
|
|
76
76
|
assets). Behind a proxy or CDN that already compresses, set
|
|
77
77
|
`nodeAdapter({ compression: false })` to avoid doing it twice.
|
|
78
|
+
- The generated server drains on `SIGTERM`/`SIGINT`: it stops accepting
|
|
79
|
+
connections and waits for in-flight requests and `args.waitUntil()` work up
|
|
80
|
+
to `nodeAdapter({ shutdownTimeoutMs })` (default 10s). Keep it below the
|
|
81
|
+
process manager's kill grace period. A custom `createNodeRequestHandler()`
|
|
82
|
+
server calls `await handler.drain(ms)` after `server.close()` instead.
|
|
83
|
+
- Background work after the response is `args.waitUntil(promise)` on every
|
|
84
|
+
adapter — do not wire `executionContext.waitUntil` (Cloudflare) or the
|
|
85
|
+
platform context yourself.
|
|
78
86
|
|
|
79
87
|
Docker:
|
|
80
88
|
|
|
@@ -280,8 +288,11 @@ SSG loaders run only at build time and must produce HTML plus valid JSON route
|
|
|
280
288
|
state; dynamic SSG routes must export `getStaticPaths()`. Only
|
|
281
289
|
manifest-registered capabilities participate, and every registered capability
|
|
282
290
|
module must load successfully so exposure validation fails closed. The
|
|
283
|
-
`notFound` page
|
|
284
|
-
`404.html`
|
|
291
|
+
`notFound` page may set `hydration: "islands"` or `"none"` to keep the client
|
|
292
|
+
router out of `404.html` — the router is there only to replace the build URL
|
|
293
|
+
with the visitor's, so a 404 that does not name the requested URL pays nothing
|
|
294
|
+
for it. `staticAdapter({ fallback })` still requires full hydration there,
|
|
295
|
+
because that document is built from the not-found page's route state.
|
|
285
296
|
|
|
286
297
|
**Deploy base.** Sub-path deploys (GitHub Pages *project* sites, S3 key
|
|
287
298
|
prefixes) set Vite `base` to that path. CDN and document-relative bases (`""`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pracht-scaffold
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.5.0
|
|
4
4
|
description: |
|
|
5
5
|
Scaffold pracht code with the native generators (`pracht generate
|
|
6
6
|
route|shell|middleware|api`), falling back to manual edits only when the CLI
|
|
@@ -49,7 +49,9 @@ If the CLI can express the request, do not reimplement the scaffold by hand.
|
|
|
49
49
|
| `--revalidate` | ISG window in seconds (`isg` only, default 3600) |
|
|
50
50
|
| `--json` | Machine-readable output |
|
|
51
51
|
|
|
52
|
-
`generate shell` and `generate middleware` take `--name`; `generate
|
|
52
|
+
`generate shell` and `generate middleware` take `--name`; `generate shell
|
|
53
|
+
--loader` adds a shell `loader` export read in the shell with `useShellData()`
|
|
54
|
+
(layout-level data such as the signed-in user). `generate api` takes
|
|
53
55
|
`--path` and `--methods`. All subcommands accept `--json` — use it when another
|
|
54
56
|
agent or tool consumes the output. When the pracht MCP server is registered
|
|
55
57
|
(docs/MCP.md), call the `generate_route`/`generate_shell`/`generate_middleware`/
|
|
@@ -82,8 +84,8 @@ agent or tool consumes the output. When the pracht MCP server is registered
|
|
|
82
84
|
|
|
83
85
|
| Kind | Directory | Key exports |
|
|
84
86
|
| ---------- | ----------------- | -------------------------------------------------------------------------------- |
|
|
85
|
-
| Route | `src/routes/` | `loader`, `head`, `Component`, `ErrorBoundary`, `getStaticPaths`
|
|
86
|
-
| Shell | `src/shells/` | `Shell`, `head`
|
|
87
|
+
| Route | `src/routes/` | `loader`, `head`, `Component`, `ErrorBoundary`, `getStaticPaths`, `search` |
|
|
88
|
+
| Shell | `src/shells/` | `Shell`, `loader` (read with `useShellData()`), `head`, `headers`, `Loading`, `ErrorBoundary` |
|
|
87
89
|
| Middleware | `src/middleware/` | `middleware` |
|
|
88
90
|
| API route | `src/api/` | Named method handlers (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) or one default dispatcher |
|
|
89
91
|
|
|
@@ -93,6 +93,14 @@ For a PR deploy, `pracht report --base origin/main` produces a markdown summary
|
|
|
93
93
|
true })` is set; application code should still observe the public base in
|
|
94
94
|
`request.url`, and the proxy must own the bare-base redirect (`/app` →
|
|
95
95
|
`/app/`).
|
|
96
|
+
- If the app registers background work with `args.waitUntil()`, the process
|
|
97
|
+
manager sends `SIGTERM` (not `SIGKILL`) on stop and its grace period exceeds
|
|
98
|
+
`nodeAdapter({ shutdownTimeoutMs })` (default 10s); otherwise that work is
|
|
99
|
+
cut off on every deploy. A custom server entry must call `handler.drain()`
|
|
100
|
+
itself.
|
|
101
|
+
- Background work still reaching for `context.executionContext.waitUntil` or a
|
|
102
|
+
platform context should move to the portable `args.waitUntil()` — the
|
|
103
|
+
platform-specific call is undefined on every other adapter.
|
|
96
104
|
|
|
97
105
|
### Cloudflare Workers (`@pracht/adapter-cloudflare`)
|
|
98
106
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tune-render-mode
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Recommend the right render mode (ssg, isg, ssr, spa) per route from what each
|
|
6
6
|
loader actually does, then apply the change after confirmation.
|
|
@@ -53,6 +53,18 @@ For each route:
|
|
|
53
53
|
anything personalized** → **`ssr`**
|
|
54
54
|
- Auth dashboards, anything user-specific, anything that varies by user
|
|
55
55
|
identity at request time.
|
|
56
|
+
- **Unless only a small part of the page is personal** (a cart count,
|
|
57
|
+
"signed in as …", a recommendation strip) and the rest is shared: then
|
|
58
|
+
recommend **`ssg`/`isg`** for the route plus a **server island** for
|
|
59
|
+
the personal part (see branch 5b).
|
|
60
|
+
|
|
61
|
+
5b. **Personal data is confined to a small, self-contained part of the page**
|
|
62
|
+
→ keep the route **`ssg`/`isg`** and move that part into `src/server-islands/`:
|
|
63
|
+
a default-export component plus a `loader` that reads `context` instead of
|
|
64
|
+
the page loader, used as JSX with a `fallback`. See
|
|
65
|
+
<https://pracht.resynapse.dev/docs/server-islands>. Not available on the static
|
|
66
|
+
adapter. Server island props are untrusted input — the server island loader must
|
|
67
|
+
authorize from `context` itself.
|
|
56
68
|
|
|
57
69
|
6. **Heavy client interactivity, no SEO need, auth-gated** → **`spa`**
|
|
58
70
|
- Internal admin tools, post-login dashboards where the first paint can be a
|
|
@@ -108,6 +120,9 @@ Examples of recommendations:
|
|
|
108
120
|
request — cannot be prerendered." (`error`)
|
|
109
121
|
- `spa` → `ssr` when route has SEO-relevant `head()` and unauthenticated
|
|
110
122
|
visitors should see content. (`warn`)
|
|
123
|
+
- `ssr` → `isg(600)` + server island when the loader reads the session only for a
|
|
124
|
+
header greeting or cart badge: "move the personal part into
|
|
125
|
+
`src/server-islands/`; the page itself is shared." (`info`)
|
|
111
126
|
|
|
112
127
|
## Step 3b: Consider the hydration mode too
|
|
113
128
|
|
|
@@ -124,7 +139,8 @@ field (absent from the JSON), grep the manifest for `hydration:` (pages apps:
|
|
|
124
139
|
box, newsletter form) → `hydration: "islands"` with the widgets moved to
|
|
125
140
|
`src/islands/`.
|
|
126
141
|
- Caveats: islands routes use MPA-style full-document navigation (no client
|
|
127
|
-
router)
|
|
142
|
+
router) unless `client.islandsNavigation` swaps islands pages in place,
|
|
143
|
+
island props must be JSON-serializable, and `render: "spa"` cannot
|
|
128
144
|
combine with `"islands"`/`"none"`.
|
|
129
145
|
|
|
130
146
|
## Step 3c: Consider streaming for slow SSR routes
|
|
@@ -190,6 +206,8 @@ Apply the edits only after the user confirms.
|
|
|
190
206
|
1. Never silently change render modes. Always present the recommendation and
|
|
191
207
|
the exact diff first; apply only after explicit user approval.
|
|
192
208
|
2. If a route uses `auth` middleware, default to `ssr` — auth implies cookies.
|
|
209
|
+
The exception is middleware that only *reads* the session for a small
|
|
210
|
+
personal part of an otherwise public page: that is the server island case (5b).
|
|
193
211
|
3. All three adapters support ISG — the mechanisms differ. Confirm which
|
|
194
212
|
adapter is in play, then use this capability table:
|
|
195
213
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typed-routes
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.3.0
|
|
4
4
|
description: |
|
|
5
5
|
Add or maintain pracht typed routes: run typegen, adopt route-id based links and
|
|
6
6
|
navigation, and replace hard-coded hrefs with generated helpers.
|
|
@@ -79,6 +79,10 @@ when `src/pracht.d.ts` does not exist. In CI, prefer:
|
|
|
79
79
|
pracht typegen --check
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
The check compares declarations, not bytes, so generated files reformatted by
|
|
83
|
+
the project's formatter still read as up to date and regenerating leaves them
|
|
84
|
+
formatted. Do not add them to a formatter ignore list on that account.
|
|
85
|
+
|
|
82
86
|
## Step 3: Replace string navigation where it matters
|
|
83
87
|
|
|
84
88
|
### Components
|
|
@@ -149,6 +153,46 @@ loader type their data as `undefined`. The id must be the active route: a
|
|
|
149
153
|
mismatch throws (with a descriptive message in dev), so a shell or island that
|
|
150
154
|
needs another route's data must receive it as props instead.
|
|
151
155
|
|
|
156
|
+
Shell loaders register the same way: `useShellData("app")` types the result
|
|
157
|
+
from the `app` shell's `loader` for every shell a route renders under. It is
|
|
158
|
+
typed `| undefined` because the shell can render without its data (the SPA
|
|
159
|
+
loading state), and naming a shell the active route does not use throws.
|
|
160
|
+
|
|
161
|
+
### Search params
|
|
162
|
+
|
|
163
|
+
A route that reads its query string should export a `search` schema (any
|
|
164
|
+
Standard Schema validator) instead of parsing `url.searchParams` by hand.
|
|
165
|
+
Typegen then types `search` on `<Link>`, `navigate()`, and `href()` from the
|
|
166
|
+
schema's input, and `useSearch(routeId)` returns its output:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
import { Link, useSearch, type LoaderArgs, type SearchArgs } from "@pracht/core";
|
|
170
|
+
import * as z from "zod";
|
|
171
|
+
|
|
172
|
+
export const search = z.object({
|
|
173
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
174
|
+
q: z.string().optional(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export async function loader(args: LoaderArgs & SearchArgs<typeof search>) {
|
|
178
|
+
return listProducts(args.search); // { page: number; q?: string }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Component() {
|
|
182
|
+
const { page, q } = useSearch("products");
|
|
183
|
+
return <Link route="products" search={{ page: page + 1, q }}>Next</Link>;
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- Values arrive as strings (arrays for repeated keys): coerce with
|
|
188
|
+
`z.coerce.*`, and give optional params defaults so the bare URL is valid. A
|
|
189
|
+
key that may repeat is a plain string when it appears once — accept both.
|
|
190
|
+
- A rejected query answers 400 and renders the route's `ErrorBoundary` with
|
|
191
|
+
`error.issues`; client navigation does the same.
|
|
192
|
+
- SSG/ISG routes are prerendered with an empty query, so the schema must
|
|
193
|
+
accept one; loaders there only ever see the defaults.
|
|
194
|
+
- Keep `useSearchParams()` for raw access; it is unchanged.
|
|
195
|
+
|
|
152
196
|
### API routes
|
|
153
197
|
|
|
154
198
|
After typegen, `apiFetch()` type-checks API calls end to end — paths,
|
|
@@ -182,8 +226,11 @@ Generated param types accept `RouteParamInput = string | number | boolean`
|
|
|
182
226
|
- `:path*` requires `params: { path: RouteParamInput }`.
|
|
183
227
|
- Routes with no dynamic segments should omit `params`.
|
|
184
228
|
- Missing and extra params should fail at typecheck time.
|
|
185
|
-
- `search`
|
|
186
|
-
|
|
229
|
+
- `search` is typed by the route module's `search` schema input when it
|
|
230
|
+
exports one: unknown keys and wrong value types fail, keys whose input
|
|
231
|
+
cannot come from a string (`z.number()`) fail, and a required key makes
|
|
232
|
+
`search` itself required. Routes without a schema accept `string`,
|
|
233
|
+
`URLSearchParams`, or an object of primitive values/arrays.
|
|
187
234
|
|
|
188
235
|
## Step 5: Verify
|
|
189
236
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: upgrade-pracht
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Upgrade the `@pracht/*` packages safely: inventory installed versions, read the
|
|
6
6
|
changelogs between installed and target, map breaking changes to real usage,
|
|
@@ -130,12 +130,17 @@ pracht doctor --json # wiring still valid
|
|
|
130
130
|
pracht typegen --check # generated route types up to date?
|
|
131
131
|
pracht typegen # regenerate if --check failed or routes changed
|
|
132
132
|
pracht verify --json # framework-aware checks
|
|
133
|
+
pnpm typecheck # both tsconfig programs, if the app has the script
|
|
133
134
|
pracht build # full production build (budgets included)
|
|
134
135
|
pnpm test # the app's own suite
|
|
135
136
|
```
|
|
136
137
|
|
|
137
138
|
`pracht doctor`, `verify`, and `typegen --check` exit non-zero on failure, so
|
|
138
|
-
they gate CI cleanly.
|
|
139
|
+
they gate CI cleanly. Act on doctor *warnings* too: `pnpm up` never touches
|
|
140
|
+
files `create-pracht` wrote, so a warning is how an older scaffold learns it
|
|
141
|
+
needs a config change — for example adding `"src/**/*.d.ts"` to the
|
|
142
|
+
`include` of `tsconfig.client.json`, without which typed route hooks are
|
|
143
|
+
`unknown` in client code and `<Link route>` accepts any string.
|
|
139
144
|
|
|
140
145
|
## Step 7: Rollback note
|
|
141
146
|
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { basename, dirname, resolve } from "node:path";
|
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
+
import { loadFallbackVersionRanges } from "./versions.js";
|
|
9
|
+
|
|
8
10
|
export class ValidationError extends Error {
|
|
9
11
|
constructor(message) {
|
|
10
12
|
super(message);
|
|
@@ -12,22 +14,6 @@ export class ValidationError extends Error {
|
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
const FALLBACK_VERSION_RANGES = {
|
|
16
|
-
"@pracht/adapter-cloudflare": "^0.5.8",
|
|
17
|
-
"@pracht/adapter-netlify": "^0.1.0",
|
|
18
|
-
"@pracht/adapter-node": "^0.3.8",
|
|
19
|
-
"@pracht/adapter-static": "^0.1.0",
|
|
20
|
-
"@pracht/adapter-vercel": "^0.2.8",
|
|
21
|
-
"@pracht/cli": "^1.11.0",
|
|
22
|
-
"@pracht/core": "^0.14.0",
|
|
23
|
-
"@pracht/vite-plugin": "^0.9.0",
|
|
24
|
-
"@tailwindcss/vite": "^4.1.0",
|
|
25
|
-
"netlify-cli": "^21.6.0",
|
|
26
|
-
tailwindcss: "^4.1.0",
|
|
27
|
-
typescript: "^6.0.0",
|
|
28
|
-
vercel: "^56.5.0",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
17
|
/**
|
|
32
18
|
* Cloudflare `compatibility_date` for scaffolded apps.
|
|
33
19
|
*
|
|
@@ -105,6 +91,24 @@ function readFileSyncSafe(path) {
|
|
|
105
91
|
|
|
106
92
|
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
107
93
|
|
|
94
|
+
// Used only when the registry lookup fails. The `@pracht/*` ranges are the
|
|
95
|
+
// versions this create-pracht was built alongside (see src/versions.js).
|
|
96
|
+
const FALLBACK_VERSION_RANGES = {
|
|
97
|
+
"@tailwindcss/vite": "^4.1.0",
|
|
98
|
+
"netlify-cli": "^21.6.0",
|
|
99
|
+
tailwindcss: "^4.1.0",
|
|
100
|
+
typescript: "^6.0.0",
|
|
101
|
+
vercel: "^56.5.0",
|
|
102
|
+
...loadFallbackVersionRanges(PACKAGE_ROOT),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// The Node floor every pracht package declares, read from this package's own
|
|
106
|
+
// engines field so a bump lands in scaffolded apps without a second edit.
|
|
107
|
+
const NODE_MINIMUM = (
|
|
108
|
+
JSON.parse(readFileSyncSafe(resolve(PACKAGE_ROOT, "package.json"))).engines?.node ?? ">=22.18"
|
|
109
|
+
).replace(/^\D*/, "");
|
|
110
|
+
const NODE_MAJOR = NODE_MINIMUM.split(".")[0];
|
|
111
|
+
|
|
108
112
|
// The published package bundles a copy of the repo skills (see
|
|
109
113
|
// scripts/sync-skills.js); inside the monorepo we fall back to the source.
|
|
110
114
|
const SKILL_DIRS = [resolve(PACKAGE_ROOT, "skills"), resolve(PACKAGE_ROOT, "../../skills")];
|
|
@@ -225,7 +229,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
225
229
|
return;
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
const { pnpmWorkspaceNotice } = await scaffoldProject({
|
|
232
|
+
const { pnpmWorkspaceNotice, versionFallbacks } = await scaffoldProject({
|
|
229
233
|
adapter: ADAPTERS[resolvedAdapter],
|
|
230
234
|
agentSkills,
|
|
231
235
|
agentTools: resolvedAgentTools,
|
|
@@ -289,6 +293,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
289
293
|
pnpmWorkspaceNotice,
|
|
290
294
|
router: resolvedRouter,
|
|
291
295
|
tailwind: resolvedTailwind,
|
|
296
|
+
versionFallbacks,
|
|
292
297
|
}),
|
|
293
298
|
);
|
|
294
299
|
} else {
|
|
@@ -303,6 +308,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
303
308
|
router: resolvedRouter,
|
|
304
309
|
skipInstall: options.skipInstall,
|
|
305
310
|
tailwind: resolvedTailwind,
|
|
311
|
+
versionFallbacks,
|
|
306
312
|
});
|
|
307
313
|
}
|
|
308
314
|
}
|
|
@@ -319,7 +325,7 @@ export async function scaffoldProject({
|
|
|
319
325
|
targetDir,
|
|
320
326
|
}) {
|
|
321
327
|
const packageName = toPackageName(basename(targetDir));
|
|
322
|
-
const { files, pnpmWorkspaceNotice } = await buildProjectFiles({
|
|
328
|
+
const { files, pnpmWorkspaceNotice, versionFallbacks } = await buildProjectFiles({
|
|
323
329
|
adapter,
|
|
324
330
|
agentSkills,
|
|
325
331
|
agentTools,
|
|
@@ -347,7 +353,7 @@ export async function scaffoldProject({
|
|
|
347
353
|
// AGENTS.md (and the CLAUDE.md alias pointing at it) are agent tooling too —
|
|
348
354
|
// `--no-agent-tools` means a project with none of it, not "all of it except
|
|
349
355
|
// the instruction files". README.md carries the same commands for humans.
|
|
350
|
-
if (!agentTools) return { pnpmWorkspaceNotice };
|
|
356
|
+
if (!agentTools) return { pnpmWorkspaceNotice, versionFallbacks };
|
|
351
357
|
|
|
352
358
|
try {
|
|
353
359
|
await symlink("AGENTS.md", resolve(targetDir, "CLAUDE.md"));
|
|
@@ -359,7 +365,7 @@ export async function scaffoldProject({
|
|
|
359
365
|
}
|
|
360
366
|
}
|
|
361
367
|
|
|
362
|
-
return { pnpmWorkspaceNotice };
|
|
368
|
+
return { pnpmWorkspaceNotice, versionFallbacks };
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
export function getPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
@@ -676,7 +682,13 @@ function normalizeAdapter(value) {
|
|
|
676
682
|
return null;
|
|
677
683
|
}
|
|
678
684
|
|
|
685
|
+
/**
|
|
686
|
+
* Latest published ranges for `packageNames`. `fallbacks` lists the packages
|
|
687
|
+
* whose registry lookup failed and got the bundled range instead; a scaffold
|
|
688
|
+
* that asked for no lookup (`remote: false`) reports none.
|
|
689
|
+
*/
|
|
679
690
|
async function resolveVersions(packageNames, { remote = true } = {}) {
|
|
691
|
+
const fallbacks = [];
|
|
680
692
|
const entries = await Promise.all(
|
|
681
693
|
packageNames.map(async (name) => {
|
|
682
694
|
const fallback = FALLBACK_VERSION_RANGES[name] ?? "latest";
|
|
@@ -684,11 +696,13 @@ async function resolveVersions(packageNames, { remote = true } = {}) {
|
|
|
684
696
|
try {
|
|
685
697
|
return [name, `^${await fetchLatestVersion(name)}`];
|
|
686
698
|
} catch {
|
|
699
|
+
fallbacks.push(name);
|
|
687
700
|
return [name, fallback];
|
|
688
701
|
}
|
|
689
702
|
}),
|
|
690
703
|
);
|
|
691
|
-
|
|
704
|
+
const versions = Object.fromEntries(entries);
|
|
705
|
+
return { fallbacks: fallbacks.sort().map((name) => `${name}@${versions[name]}`), versions };
|
|
692
706
|
}
|
|
693
707
|
|
|
694
708
|
async function buildProjectFiles({
|
|
@@ -720,7 +734,9 @@ async function buildProjectFiles({
|
|
|
720
734
|
packagesToResolve.push("tailwindcss", "@tailwindcss/vite");
|
|
721
735
|
}
|
|
722
736
|
|
|
723
|
-
const versions = await resolveVersions(packagesToResolve, {
|
|
737
|
+
const { fallbacks: versionFallbacks, versions } = await resolveVersions(packagesToResolve, {
|
|
738
|
+
remote: resolveRemoteVersions,
|
|
739
|
+
});
|
|
724
740
|
const policyMajor = pnpmMajor ?? 11;
|
|
725
741
|
const ancestorWorkspace =
|
|
726
742
|
targetDir && packageManager === "pnpm" ? findAncestorPnpmWorkspace(targetDir) : null;
|
|
@@ -733,6 +749,11 @@ async function buildProjectFiles({
|
|
|
733
749
|
: null;
|
|
734
750
|
|
|
735
751
|
const files = {
|
|
752
|
+
// Build images (Cloudflare Pages, Netlify, most CI) pick a Node version
|
|
753
|
+
// from this file; without it they choose their own default, which is
|
|
754
|
+
// routinely older than pracht's `engines.node` and fails the build on a
|
|
755
|
+
// missing modern built-in rather than on a version mismatch.
|
|
756
|
+
".nvmrc": `${NODE_MAJOR}\n`,
|
|
736
757
|
".gitignore":
|
|
737
758
|
"dist\nnode_modules\n.netlify\n.wrangler\n.vercel\n.env*\n!.env.example\n.dev.vars\n# Keep .pracht/app-graph.json committed — it is the `pracht plan` snapshot.\n",
|
|
738
759
|
"README.md": createReadme({
|
|
@@ -821,7 +842,7 @@ async function buildProjectFiles({
|
|
|
821
842
|
files["pnpm-workspace.yaml"] = createPnpmWorkspaceConfig(adapter, tailwind, policyMajor);
|
|
822
843
|
}
|
|
823
844
|
|
|
824
|
-
return { files, pnpmWorkspaceNotice };
|
|
845
|
+
return { files, pnpmWorkspaceNotice, versionFallbacks };
|
|
825
846
|
}
|
|
826
847
|
|
|
827
848
|
function createMcpConfig() {
|
|
@@ -888,7 +909,8 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
888
909
|
"@pracht/cli": versions["@pracht/cli"],
|
|
889
910
|
"@pracht/vite-plugin": versions["@pracht/vite-plugin"],
|
|
890
911
|
preact: "^10.26.9",
|
|
891
|
-
|
|
912
|
+
// At least @pracht/core's peer floor, or the first install warns.
|
|
913
|
+
"preact-render-to-string": "^6.7.0",
|
|
892
914
|
typescript: versions["typescript"],
|
|
893
915
|
vite: "^8.0.0",
|
|
894
916
|
};
|
|
@@ -923,6 +945,10 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
923
945
|
version: "0.0.0",
|
|
924
946
|
private: true,
|
|
925
947
|
type: "module",
|
|
948
|
+
// Matches every pracht package. Vercel reads this to choose a Node
|
|
949
|
+
// version; the `.nvmrc` next to it covers the hosts that read that
|
|
950
|
+
// instead.
|
|
951
|
+
engines: { node: `>=${NODE_MINIMUM}` },
|
|
926
952
|
scripts,
|
|
927
953
|
dependencies: {
|
|
928
954
|
[adapter.packageName]: versions[adapter.packageName],
|
|
@@ -1021,6 +1047,7 @@ function createShellFile(projectName, tailwind = false) {
|
|
|
1021
1047
|
"",
|
|
1022
1048
|
"export function head() {",
|
|
1023
1049
|
" return {",
|
|
1050
|
+
' lang: "en",',
|
|
1024
1051
|
' meta: [{ content: "width=device-width, initial-scale=1", name: "viewport" }],',
|
|
1025
1052
|
` title: ${JSON.stringify(projectName)},`,
|
|
1026
1053
|
" };",
|
|
@@ -1171,10 +1198,13 @@ function createClientTSConfig(router) {
|
|
|
1171
1198
|
compilerOptions: {
|
|
1172
1199
|
customConditions: ["browser"],
|
|
1173
1200
|
},
|
|
1201
|
+
// Declaration files carry the generated route/capability types and the
|
|
1202
|
+
// app's `Register` augmentations; without them the client program sees
|
|
1203
|
+
// `useRouteData()` as `unknown` and accepts any `<Link route>`.
|
|
1174
1204
|
include:
|
|
1175
1205
|
router === "pages"
|
|
1176
|
-
? ["src/pages/**/*", "src/islands/**/*"]
|
|
1177
|
-
: ["src/routes/**/*", "src/shells/**/*", "src/islands/**/*"],
|
|
1206
|
+
? ["src/pages/**/*", "src/islands/**/*", "src/**/*.d.ts"]
|
|
1207
|
+
: ["src/routes/**/*", "src/shells/**/*", "src/islands/**/*", "src/**/*.d.ts"],
|
|
1178
1208
|
};
|
|
1179
1209
|
|
|
1180
1210
|
// Pages route modules are shared with the browser, but these two convention
|
|
@@ -1984,6 +2014,7 @@ function printNextSteps({
|
|
|
1984
2014
|
router,
|
|
1985
2015
|
skipInstall,
|
|
1986
2016
|
tailwind,
|
|
2017
|
+
versionFallbacks = [],
|
|
1987
2018
|
}) {
|
|
1988
2019
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1989
2020
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
@@ -2027,6 +2058,18 @@ function printNextSteps({
|
|
|
2027
2058
|
console.log("Dependency installation did not complete. The project files were still created.");
|
|
2028
2059
|
}
|
|
2029
2060
|
|
|
2061
|
+
if (versionFallbacks.length > 0) {
|
|
2062
|
+
console.log("");
|
|
2063
|
+
console.log(
|
|
2064
|
+
"Could not look up the latest versions on the npm registry, so package.json uses\n" +
|
|
2065
|
+
"the ranges this create-pracht was released with. Check for newer versions once\n" +
|
|
2066
|
+
"you are online:",
|
|
2067
|
+
);
|
|
2068
|
+
for (const entry of versionFallbacks) {
|
|
2069
|
+
console.log(` ${entry}`);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2030
2073
|
if (pnpmWorkspaceNotice) {
|
|
2031
2074
|
console.log("");
|
|
2032
2075
|
console.log(
|
package/src/versions.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Written next to `package.json` by `scripts/sync-versions.js` at build time. */
|
|
5
|
+
export const FALLBACK_VERSIONS_FILE = "fallback-versions.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `^<version>` for every published `@pracht/*` package in a packages
|
|
9
|
+
* directory, keyed by package name.
|
|
10
|
+
*/
|
|
11
|
+
export function collectWorkspaceVersionRanges(packagesDir) {
|
|
12
|
+
const ranges = {};
|
|
13
|
+
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
14
|
+
if (!entry.isDirectory()) continue;
|
|
15
|
+
const manifestPath = resolve(packagesDir, entry.name, "package.json");
|
|
16
|
+
if (!existsSync(manifestPath)) continue;
|
|
17
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
18
|
+
if (manifest.private || !manifest.name?.startsWith("@pracht/") || !manifest.version) continue;
|
|
19
|
+
ranges[manifest.name] = `^${manifest.version}`;
|
|
20
|
+
}
|
|
21
|
+
return Object.fromEntries(Object.entries(ranges).sort(([a], [b]) => a.localeCompare(b)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `@pracht/*` ranges a scaffold falls back to when the registry lookup
|
|
26
|
+
* fails. Inside the monorepo they come straight from the sibling packages, so
|
|
27
|
+
* a checkout never goes stale; a published copy reads the file its build
|
|
28
|
+
* wrote from those same siblings.
|
|
29
|
+
*/
|
|
30
|
+
export function loadFallbackVersionRanges(packageRoot) {
|
|
31
|
+
const packagesDir = resolve(packageRoot, "..");
|
|
32
|
+
try {
|
|
33
|
+
const core = JSON.parse(readFileSync(resolve(packagesDir, "framework/package.json"), "utf-8"));
|
|
34
|
+
if (core.name === "@pracht/core") return collectWorkspaceVersionRanges(packagesDir);
|
|
35
|
+
} catch {
|
|
36
|
+
// Not a checkout of the monorepo.
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(resolve(packageRoot, FALLBACK_VERSIONS_FILE), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|