@sigil-dev/grimoire 0.3.0 → 0.5.0
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 +174 -1
- package/index.ts +29 -22
- package/package.json +1 -1
- package/public/__grimoire__/client.js +5 -36
- package/public/__grimoire__/hydrate.js +7 -45
- package/src/build.ts +88 -0
- package/src/client-router.ts +1 -1
- package/src/cookie-utils.ts +66 -66
- package/src/enhance.ts +1 -1
- package/src/hydrate.ts +1 -1
- package/src/plugins.ts +19 -5
- package/src/renderer.ts +38 -6
- package/src/router.ts +2 -2
- package/src/scanner.ts +20 -0
- package/src/server.ts +83 -83
- package/src/ssrPlugin.ts +7 -2
- package/src/transform-routes.ts +6 -1
- package/src/typegen.ts +77 -3
- package/src/types.ts +45 -1
- package/test/exports.test.ts +38 -0
- package/test/fail.test.ts +46 -46
- package/test/headers.test.ts +96 -96
- package/test/middleware.test.ts +217 -217
- package/test/redirect-error.test.ts +112 -112
- package/test/rendering.test.ts +310 -172
- package/test/scanning.test.ts +87 -2
- package/test/server.test.ts +178 -8
- package/test/streaming.test.ts +132 -132
- package/test/typegen.test.ts +6 -6
- package/.grimoire/_routes.dom.js +0 -4
- package/.grimoire/_routes.hydrate.js +0 -4
- package/.grimoire/tsconfig.generated.json +0 -11
- package/.grimoire/types/ambient.d.ts +0 -6
- package/.grimoire/types/api/hello/$types.d.ts +0 -29
package/README.md
CHANGED
|
@@ -1 +1,174 @@
|
|
|
1
|
-
|
|
1
|
+
# @sigil-dev/grimoire
|
|
2
|
+
|
|
3
|
+
Full-stack server framework for [Sigil](https://git.cane1712.dev/sigil/sigil).
|
|
4
|
+
Heavily inspired by SvelteKit. Most of [its documentation](https://svelte.dev/docs/kit/introduction) applies here
|
|
5
|
+
|
|
6
|
+
## Quick start
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun i -g @sigil-dev/cli
|
|
10
|
+
sigil create
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Route files
|
|
14
|
+
|
|
15
|
+
Place files in /src/routes.
|
|
16
|
+
|
|
17
|
+
| File | Purpose |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `+page.tsx | index.tsx | routeName.tsx` | Page |
|
|
20
|
+
| `+page.server.ts` | Server data passed to page / form actions |
|
|
21
|
+
| `+layout.tsx` | Page layout |
|
|
22
|
+
| `+layout.server.ts` | Layout-level `load` |
|
|
23
|
+
| `+server.ts` | API route (raw `Request → Response`) |
|
|
24
|
+
| `+error.tsx` | Error boundary page |
|
|
25
|
+
| `hooks.server.ts` | Global request middleware (project root) |
|
|
26
|
+
|
|
27
|
+
`/routes/about/index.tsx`, `/routes/about/+page.tsx` and `/routes/about.tsx` all work and all do the same thing.
|
|
28
|
+
For path parameters use square brackets. `src/routes/posts/[slug]/+page.tsx` → `/posts/:slug`.
|
|
29
|
+
|
|
30
|
+
## Loading data
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// src/routes/posts/[slug]/+page.server.ts
|
|
34
|
+
import { error } from "@sigil-dev/grimoire";
|
|
35
|
+
import type { TypedLoadContext } from "@sigil-dev/grimoire";
|
|
36
|
+
|
|
37
|
+
export async function load({ params }: TypedLoadContext<{ slug: string }>) {
|
|
38
|
+
const post = await db.posts.findBySlug(params.slug);
|
|
39
|
+
if (!post) throw error(404, "Post not found");
|
|
40
|
+
return { post }; // → page receives { data: { post }, params }
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The return value is passed as the `data` prop to the matching `+page.tsx` component.
|
|
45
|
+
|
|
46
|
+
## Form actions
|
|
47
|
+
|
|
48
|
+
Export HTTP method handlers alongside `load`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// src/routes/login/+page.server.ts
|
|
52
|
+
import { fail, redirect } from "@sigil-dev/grimoire";
|
|
53
|
+
|
|
54
|
+
export async function POST({ request }) {
|
|
55
|
+
const data = await request.formData();
|
|
56
|
+
const user = await auth.login(data.get("email"), data.get("password"));
|
|
57
|
+
if (!user) return fail(400, { error: "Invalid credentials" });
|
|
58
|
+
throw redirect(303, "/dashboard");
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use `enhance` with the `use` prop on the client for fetch-based submission without a full page reload:
|
|
63
|
+
> This is jank and will likely be reworked.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// src/routes/login/+page.tsx
|
|
67
|
+
import { enhance } from "@sigil-dev/grimoire/client";
|
|
68
|
+
|
|
69
|
+
export default function LoginPage() {
|
|
70
|
+
const errors = $state({});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form use={[enhance, { onFail: (data) => errors.set(data) }]}>
|
|
74
|
+
<input name="email" />
|
|
75
|
+
<input name="password" type="password" />
|
|
76
|
+
<button>Log in</button>
|
|
77
|
+
</form>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Error handling
|
|
83
|
+
|
|
84
|
+
`error(status, message)` can be thrown from any `load` function or action. Grimoire renders the closest `+error.tsx` if one exists, otherwise falls back to a plain-text response.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// src/routes/+error.tsx
|
|
88
|
+
export default function ErrorPage({ status, message }: { status: number; message: string }) {
|
|
89
|
+
return (
|
|
90
|
+
<html>
|
|
91
|
+
<body>
|
|
92
|
+
<h1>{status}</h1>
|
|
93
|
+
<p>{message}</p>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## API routes
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// src/routes/api/items/+server.ts
|
|
104
|
+
export async function GET({ url }) {
|
|
105
|
+
const items = await db.items.list();
|
|
106
|
+
return Response.json(items);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function POST({ request }) {
|
|
110
|
+
const body = await request.json();
|
|
111
|
+
const item = await db.items.create(body);
|
|
112
|
+
return Response.json(item, { status: 201 });
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Layouts
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
// src/routes/+layout.tsx
|
|
120
|
+
import { Head } from "@sigil-dev/grimoire";
|
|
121
|
+
|
|
122
|
+
export default function Layout({ children }) {
|
|
123
|
+
return (
|
|
124
|
+
<html>
|
|
125
|
+
<Head><title>My App</title></Head>
|
|
126
|
+
<body>{children}</body>
|
|
127
|
+
</html>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Hooks (middleware)
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// hooks.server.ts (project root)
|
|
136
|
+
import type { Handle } from "@sigil-dev/grimoire/hooks";
|
|
137
|
+
|
|
138
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
139
|
+
event.locals.user = await getUser(event.request);
|
|
140
|
+
return resolve(event);
|
|
141
|
+
};
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { defineConfig } from "@sigil-dev/grimoire";
|
|
148
|
+
|
|
149
|
+
export default defineConfig({
|
|
150
|
+
port: 3000,
|
|
151
|
+
host: "localhost",
|
|
152
|
+
routes: "src/routes", // default
|
|
153
|
+
plugins: [],
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Helpers
|
|
158
|
+
|
|
159
|
+
| Import | Description |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `error(status, message)` | Throw an HTTP error from `load` or an action |
|
|
162
|
+
| `fail(status, data)` | Return validation errors from an action |
|
|
163
|
+
| `redirect(status, location)` | Throw a redirect from `load` or an action |
|
|
164
|
+
| `sequence(...handles)` | Compose multiple `Handle` middleware functions |
|
|
165
|
+
|
|
166
|
+
## Security headers
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { securityHeaders } from "@sigil-dev/grimoire/headers";
|
|
170
|
+
|
|
171
|
+
// use as a Handle in hooks.server.ts, or compose with sequence()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`securityHeaders()` returns a `Handle` that sets CSP, HSTS, X-Frame-Options, and related headers on every response.
|
package/index.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
export type { RouteFile, RouteTree } from "./src/scanner";
|
|
2
|
-
export { scanRoutes } from "./src/scanner";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export
|
|
6
|
-
export {
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
9
|
-
export
|
|
10
|
-
export type {
|
|
11
|
-
export {
|
|
12
|
-
export
|
|
13
|
-
export {
|
|
14
|
-
export type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
export type { RouteFile, RouteTree } from "./src/scanner";
|
|
2
|
+
export { scanRoutes } from "./src/scanner";
|
|
3
|
+
export { buildProject } from "./src/build";
|
|
4
|
+
export { createServer } from "./src/server";
|
|
5
|
+
export { fail, isFailResult } from "./src/fail";
|
|
6
|
+
export type { FailResult } from "./src/fail";
|
|
7
|
+
export { redirect, isRedirectResult } from "./src/redirect";
|
|
8
|
+
export type { RedirectResult } from "./src/redirect";
|
|
9
|
+
export { error, isErrorResult } from "./src/error";
|
|
10
|
+
export type { ErrorResult } from "./src/error";
|
|
11
|
+
export type { Handle, RequestEvent, Cookies, CookieOptions, ResolveFunction } from "./src/hooks";
|
|
12
|
+
export { parseCookies } from "./src/cookie-utils";
|
|
13
|
+
export { sequence, createHooks } from "./src/hooks";
|
|
14
|
+
export type { TypegenConfig } from "./src/typegen";
|
|
15
|
+
export { generateTypes } from "./src/typegen";
|
|
16
|
+
export type {
|
|
17
|
+
GrimoireConfig,
|
|
18
|
+
GrimoirePlugin,
|
|
19
|
+
LoadContext,
|
|
20
|
+
RenderContext,
|
|
21
|
+
RouteInfo,
|
|
22
|
+
TypedLoadContext,
|
|
23
|
+
PageServerLoad,
|
|
24
|
+
LayoutServerLoad,
|
|
25
|
+
RequestHandler,
|
|
26
|
+
WsRouteHandler,
|
|
27
|
+
} from "./src/types";
|
|
28
|
+
export { defineConfig } from "./src/types";
|
|
29
|
+
export { Head } from "./src/head";
|
package/package.json
CHANGED
|
@@ -1,62 +1,31 @@
|
|
|
1
|
-
// .grimoire/_routes.
|
|
1
|
+
// .grimoire/_routes.ts
|
|
2
2
|
var routes = {};
|
|
3
3
|
|
|
4
|
-
// src/scope.ts
|
|
5
|
-
function withEffectScope(fn) {
|
|
6
|
-
const prev = globalThis.__sigilEffectScope;
|
|
7
|
-
const disposers = [];
|
|
8
|
-
globalThis.__sigilEffectScope = disposers;
|
|
9
|
-
try {
|
|
10
|
-
fn();
|
|
11
|
-
} finally {
|
|
12
|
-
globalThis.__sigilEffectScope = prev;
|
|
13
|
-
}
|
|
14
|
-
return () => {
|
|
15
|
-
for (const d of disposers)
|
|
16
|
-
d();
|
|
17
|
-
disposers.length = 0;
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
4
|
// src/client-router.ts
|
|
22
5
|
var routeMap = {};
|
|
23
|
-
var disposeCurrentPage = null;
|
|
24
6
|
async function navigate(path) {
|
|
25
7
|
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
26
8
|
const json = await res.json();
|
|
9
|
+
console.log("navigate response:", json);
|
|
27
10
|
const { data, params, pattern, head } = json;
|
|
28
11
|
const Page = routeMap[pattern];
|
|
29
12
|
if (!Page) {
|
|
30
13
|
window.location.href = path;
|
|
31
14
|
return;
|
|
32
15
|
}
|
|
33
|
-
|
|
34
|
-
disposeCurrentPage = null;
|
|
35
|
-
globalThis.__nodes = [];
|
|
36
|
-
let node;
|
|
37
|
-
disposeCurrentPage = withEffectScope(() => {
|
|
38
|
-
node = Page({ ...data, params });
|
|
39
|
-
});
|
|
16
|
+
const node = Page({ ...data, params });
|
|
40
17
|
const slot = document.getElementById("grimoire-page");
|
|
41
18
|
if (!slot)
|
|
42
19
|
return;
|
|
43
20
|
slot.replaceChildren(node);
|
|
44
21
|
updateHead(head);
|
|
45
22
|
history.pushState({}, "", path);
|
|
46
|
-
currentPath = path;
|
|
47
23
|
window.scrollTo(0, 0);
|
|
48
24
|
}
|
|
49
|
-
|
|
50
|
-
function initRouter(routes2, initialDispose) {
|
|
25
|
+
function initRouter(routes2) {
|
|
51
26
|
routeMap = routes2;
|
|
52
|
-
disposeCurrentPage = initialDispose ?? null;
|
|
53
27
|
document.addEventListener("click", handleClick);
|
|
54
|
-
window.addEventListener("popstate", () =>
|
|
55
|
-
if (location.pathname !== currentPath) {
|
|
56
|
-
currentPath = location.pathname;
|
|
57
|
-
navigate(location.pathname);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
28
|
+
window.addEventListener("popstate", () => navigate(location.pathname));
|
|
60
29
|
}
|
|
61
30
|
function updateHead(headHtml) {
|
|
62
31
|
document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
|
|
@@ -1,62 +1,31 @@
|
|
|
1
|
-
// .grimoire/_routes.
|
|
1
|
+
// .grimoire/_routes.ts
|
|
2
2
|
var routes = {};
|
|
3
3
|
|
|
4
|
-
// src/scope.ts
|
|
5
|
-
function withEffectScope(fn) {
|
|
6
|
-
const prev = globalThis.__sigilEffectScope;
|
|
7
|
-
const disposers = [];
|
|
8
|
-
globalThis.__sigilEffectScope = disposers;
|
|
9
|
-
try {
|
|
10
|
-
fn();
|
|
11
|
-
} finally {
|
|
12
|
-
globalThis.__sigilEffectScope = prev;
|
|
13
|
-
}
|
|
14
|
-
return () => {
|
|
15
|
-
for (const d of disposers)
|
|
16
|
-
d();
|
|
17
|
-
disposers.length = 0;
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
4
|
// src/client-router.ts
|
|
22
5
|
var routeMap = {};
|
|
23
|
-
var disposeCurrentPage = null;
|
|
24
6
|
async function navigate(path) {
|
|
25
7
|
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
26
8
|
const json = await res.json();
|
|
9
|
+
console.log("navigate response:", json);
|
|
27
10
|
const { data, params, pattern, head } = json;
|
|
28
11
|
const Page = routeMap[pattern];
|
|
29
12
|
if (!Page) {
|
|
30
13
|
window.location.href = path;
|
|
31
14
|
return;
|
|
32
15
|
}
|
|
33
|
-
|
|
34
|
-
disposeCurrentPage = null;
|
|
35
|
-
globalThis.__nodes = [];
|
|
36
|
-
let node;
|
|
37
|
-
disposeCurrentPage = withEffectScope(() => {
|
|
38
|
-
node = Page({ ...data, params });
|
|
39
|
-
});
|
|
16
|
+
const node = Page({ ...data, params });
|
|
40
17
|
const slot = document.getElementById("grimoire-page");
|
|
41
18
|
if (!slot)
|
|
42
19
|
return;
|
|
43
20
|
slot.replaceChildren(node);
|
|
44
21
|
updateHead(head);
|
|
45
22
|
history.pushState({}, "", path);
|
|
46
|
-
currentPath = path;
|
|
47
23
|
window.scrollTo(0, 0);
|
|
48
24
|
}
|
|
49
|
-
|
|
50
|
-
function initRouter(routes2, initialDispose) {
|
|
25
|
+
function initRouter(routes2) {
|
|
51
26
|
routeMap = routes2;
|
|
52
|
-
disposeCurrentPage = initialDispose ?? null;
|
|
53
27
|
document.addEventListener("click", handleClick);
|
|
54
|
-
window.addEventListener("popstate", () =>
|
|
55
|
-
if (location.pathname !== currentPath) {
|
|
56
|
-
currentPath = location.pathname;
|
|
57
|
-
navigate(location.pathname);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
28
|
+
window.addEventListener("popstate", () => navigate(location.pathname));
|
|
60
29
|
}
|
|
61
30
|
function updateHead(headHtml) {
|
|
62
31
|
document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
|
|
@@ -84,18 +53,11 @@ function handleClick(e) {
|
|
|
84
53
|
|
|
85
54
|
// src/hydrate.ts
|
|
86
55
|
var stateEl = document.getElementById("__grimoire_state__");
|
|
87
|
-
var initialDispose;
|
|
88
56
|
if (stateEl) {
|
|
89
57
|
const state = JSON.parse(stateEl.textContent);
|
|
90
58
|
const Page = routes[state.pattern];
|
|
91
59
|
if (Page) {
|
|
92
|
-
|
|
93
|
-
if (slot) {
|
|
94
|
-
globalThis.__nodes = Array.from(slot.childNodes);
|
|
95
|
-
initialDispose = withEffectScope(() => {
|
|
96
|
-
Page({ ...state.data, params: state.params });
|
|
97
|
-
});
|
|
98
|
-
}
|
|
60
|
+
Page({ ...state.data, params: state.params });
|
|
99
61
|
}
|
|
100
62
|
}
|
|
101
|
-
initRouter(routes
|
|
63
|
+
initRouter(routes);
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir } from "fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "path";
|
|
3
|
+
import { sigil } from "@sigil-dev/compiler/bun";
|
|
4
|
+
import { generateManifest } from "./manifest-gen";
|
|
5
|
+
import { runHook } from "./plugins";
|
|
6
|
+
import { scanRoutes } from "./scanner";
|
|
7
|
+
import type { RouteTree } from "./scanner";
|
|
8
|
+
import { transformRoutes } from "./transform-routes";
|
|
9
|
+
import { generateTypes } from "./typegen";
|
|
10
|
+
import type { BuildResult, GrimoireConfig, GrimoirePlugin } from "./types";
|
|
11
|
+
|
|
12
|
+
export async function buildProject(
|
|
13
|
+
config: GrimoireConfig,
|
|
14
|
+
plugins: GrimoirePlugin[] = [],
|
|
15
|
+
): Promise<{ result: BuildResult; tree: RouteTree }> {
|
|
16
|
+
await runHook(plugins, "onBuildStart");
|
|
17
|
+
|
|
18
|
+
const { routes = "src/routes" } = config;
|
|
19
|
+
|
|
20
|
+
const routesDir = isAbsolute(routes)
|
|
21
|
+
? routes.replace(/\0/g, "")
|
|
22
|
+
: join(process.cwd(), routes).replace(/\0/g, "");
|
|
23
|
+
const tree = await scanRoutes(routesDir, process.cwd());
|
|
24
|
+
|
|
25
|
+
await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
|
|
26
|
+
|
|
27
|
+
await generateTypes(tree, {
|
|
28
|
+
projectRoot: process.cwd(),
|
|
29
|
+
routesDir,
|
|
30
|
+
outDir: join(process.cwd(), ".grimoire/types"),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const compiledDir = join(process.cwd(), ".grimoire/compiled");
|
|
34
|
+
await mkdir(compiledDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const pageRoutes = tree.routes.filter(
|
|
37
|
+
(r) => r.type === "page" || r.type === "simple",
|
|
38
|
+
);
|
|
39
|
+
const [hydrateFiles, domFiles] = await Promise.all([
|
|
40
|
+
transformRoutes(pageRoutes, compiledDir, "hydrate", plugins),
|
|
41
|
+
transformRoutes(pageRoutes, compiledDir, "dom", plugins),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const hydrateManifest = join(process.cwd(), ".grimoire/_routes.hydrate.js");
|
|
45
|
+
const domManifest = join(process.cwd(), ".grimoire/_routes.dom.js");
|
|
46
|
+
await Promise.all([
|
|
47
|
+
Bun.write(hydrateManifest, generateManifest(pageRoutes, hydrateFiles)),
|
|
48
|
+
Bun.write(domManifest, generateManifest(pageRoutes, domFiles)),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const makeRoutesPlugin = (manifestPath: string) => ({
|
|
52
|
+
name: "grimoire-routes",
|
|
53
|
+
setup(build: any) {
|
|
54
|
+
build.onResolve({ filter: /^#grimoire-routes$/ }, () => ({
|
|
55
|
+
path: manifestPath,
|
|
56
|
+
}));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const [hydrateResult, domResult] = await Promise.all([
|
|
61
|
+
Bun.build({
|
|
62
|
+
entrypoints: [join(import.meta.dir, "./hydrate.ts")],
|
|
63
|
+
outdir: join(process.cwd(), "public/__grimoire__"),
|
|
64
|
+
plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
|
|
65
|
+
}),
|
|
66
|
+
Bun.build({
|
|
67
|
+
entrypoints: [join(import.meta.dir, "./client.ts")],
|
|
68
|
+
outdir: join(process.cwd(), "public/__grimoire__"),
|
|
69
|
+
plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
if (!hydrateResult.success) {
|
|
74
|
+
for (const log of hydrateResult.logs) console.error(log);
|
|
75
|
+
}
|
|
76
|
+
if (!domResult.success) {
|
|
77
|
+
for (const log of domResult.logs) console.error(log);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result: BuildResult = {
|
|
81
|
+
success: hydrateResult.success && domResult.success,
|
|
82
|
+
outputs: [...hydrateResult.outputs, ...domResult.outputs].map((o) => o.path),
|
|
83
|
+
errors: [...hydrateResult.logs, ...domResult.logs].map(String),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await runHook(plugins, "onBuildEnd", result);
|
|
87
|
+
return { result, tree };
|
|
88
|
+
}
|
package/src/client-router.ts
CHANGED
package/src/cookie-utils.ts
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse cookies from a Cookie header string.
|
|
3
|
-
*/
|
|
4
|
-
export function parseCookies(cookieHeader: string): Map<string, string> {
|
|
5
|
-
const map = new Map<string, string>();
|
|
6
|
-
if (!cookieHeader) return map;
|
|
7
|
-
for (const pair of cookieHeader.split(";")) {
|
|
8
|
-
const [name, ...rest] = pair.split("=");
|
|
9
|
-
if (name) map.set(name.trim(), rest.join("=").trim());
|
|
10
|
-
}
|
|
11
|
-
return map;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Build a Set-Cookie header from name, value, and options.
|
|
16
|
-
*/
|
|
17
|
-
export function serializeCookie(
|
|
18
|
-
name: string,
|
|
19
|
-
value: string,
|
|
20
|
-
options?: {
|
|
21
|
-
path?: string;
|
|
22
|
-
domain?: string;
|
|
23
|
-
maxAge?: number;
|
|
24
|
-
expires?: Date;
|
|
25
|
-
httpOnly?: boolean;
|
|
26
|
-
secure?: boolean;
|
|
27
|
-
sameSite?: "strict" | "lax" | "none";
|
|
28
|
-
},
|
|
29
|
-
): string {
|
|
30
|
-
let cookie = `${name}=${encodeURIComponent(value)}`;
|
|
31
|
-
if (options?.path) cookie += `; Path=${options.path}`;
|
|
32
|
-
if (options?.domain) cookie += `; Domain=${options.domain}`;
|
|
33
|
-
if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
|
|
34
|
-
if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
|
|
35
|
-
if (options?.httpOnly) cookie += "; HttpOnly";
|
|
36
|
-
if (options?.secure) cookie += "; Secure";
|
|
37
|
-
if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
38
|
-
return cookie;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Create a Cookies helper from a Cookie header string.
|
|
43
|
-
*/
|
|
44
|
-
export function createCookies(
|
|
45
|
-
cookieHeader: string,
|
|
46
|
-
): import("./hooks").Cookies & { toHeaders(): string[] } {
|
|
47
|
-
const store = parseCookies(cookieHeader);
|
|
48
|
-
const pending: string[] = [];
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
get(name: string) {
|
|
52
|
-
return store.get(name);
|
|
53
|
-
},
|
|
54
|
-
set(name: string, value: string, options?: any) {
|
|
55
|
-
store.set(name, value);
|
|
56
|
-
pending.push(serializeCookie(name, value, options));
|
|
57
|
-
},
|
|
58
|
-
delete(name: string) {
|
|
59
|
-
store.delete(name);
|
|
60
|
-
pending.push(serializeCookie(name, "", { maxAge: 0 }));
|
|
61
|
-
},
|
|
62
|
-
toHeaders() {
|
|
63
|
-
return pending;
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Parse cookies from a Cookie header string.
|
|
3
|
+
*/
|
|
4
|
+
export function parseCookies(cookieHeader: string): Map<string, string> {
|
|
5
|
+
const map = new Map<string, string>();
|
|
6
|
+
if (!cookieHeader) return map;
|
|
7
|
+
for (const pair of cookieHeader.split(";")) {
|
|
8
|
+
const [name, ...rest] = pair.split("=");
|
|
9
|
+
if (name) map.set(name.trim(), rest.join("=").trim());
|
|
10
|
+
}
|
|
11
|
+
return map;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a Set-Cookie header from name, value, and options.
|
|
16
|
+
*/
|
|
17
|
+
export function serializeCookie(
|
|
18
|
+
name: string,
|
|
19
|
+
value: string,
|
|
20
|
+
options?: {
|
|
21
|
+
path?: string;
|
|
22
|
+
domain?: string;
|
|
23
|
+
maxAge?: number;
|
|
24
|
+
expires?: Date;
|
|
25
|
+
httpOnly?: boolean;
|
|
26
|
+
secure?: boolean;
|
|
27
|
+
sameSite?: "strict" | "lax" | "none";
|
|
28
|
+
},
|
|
29
|
+
): string {
|
|
30
|
+
let cookie = `${name}=${encodeURIComponent(value)}`;
|
|
31
|
+
if (options?.path) cookie += `; Path=${options.path}`;
|
|
32
|
+
if (options?.domain) cookie += `; Domain=${options.domain}`;
|
|
33
|
+
if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
|
|
34
|
+
if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
|
|
35
|
+
if (options?.httpOnly) cookie += "; HttpOnly";
|
|
36
|
+
if (options?.secure) cookie += "; Secure";
|
|
37
|
+
if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
38
|
+
return cookie;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a Cookies helper from a Cookie header string.
|
|
43
|
+
*/
|
|
44
|
+
export function createCookies(
|
|
45
|
+
cookieHeader: string,
|
|
46
|
+
): import("./hooks").Cookies & { toHeaders(): string[] } {
|
|
47
|
+
const store = parseCookies(cookieHeader);
|
|
48
|
+
const pending: string[] = [];
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get(name: string) {
|
|
52
|
+
return store.get(name);
|
|
53
|
+
},
|
|
54
|
+
set(name: string, value: string, options?: any) {
|
|
55
|
+
store.set(name, value);
|
|
56
|
+
pending.push(serializeCookie(name, value, options));
|
|
57
|
+
},
|
|
58
|
+
delete(name: string) {
|
|
59
|
+
store.delete(name);
|
|
60
|
+
pending.push(serializeCookie(name, "", { maxAge: 0 }));
|
|
61
|
+
},
|
|
62
|
+
toHeaders() {
|
|
63
|
+
return pending;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/enhance.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const [errors, setErrors] = createSignal({});
|
|
11
11
|
*
|
|
12
|
-
* <form use
|
|
12
|
+
* <form use={[enhance, { action: "/api/login", onSuccess: () => { ... }, onFail: (data) => setErrors(data) }]}>
|
|
13
13
|
* ...
|
|
14
14
|
* </form>
|
|
15
15
|
*/
|
package/src/hydrate.ts
CHANGED
package/src/plugins.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
import type { GrimoirePlugin } from "./types";
|
|
1
|
+
import type { BuildResult, GrimoirePlugin, LoadContext, Route, Server } from "./types";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Fire-and-forget hooks routed through runHook.
|
|
4
|
+
// onRequest and onRouteRender are intentionally excluded: they have distinct
|
|
5
|
+
// calling conventions (middleware chain / inline transform loop) handled in server.ts.
|
|
6
|
+
// Keep HookArgs in sync with GrimoirePlugin in types.ts.
|
|
7
|
+
type HookArgs = {
|
|
8
|
+
onStart: [server: Server];
|
|
9
|
+
onStop: [reason: "shutdown" | "restart"];
|
|
10
|
+
onBuildStart: [];
|
|
11
|
+
onBuildEnd: [result: BuildResult];
|
|
12
|
+
onRouteLoad: [route: Route, context: LoadContext];
|
|
13
|
+
};
|
|
14
|
+
type FireAndForgetHook = keyof HookArgs;
|
|
15
|
+
|
|
16
|
+
export async function runHook<K extends FireAndForgetHook>(
|
|
4
17
|
plugins: GrimoirePlugin[],
|
|
5
|
-
hook:
|
|
6
|
-
...args:
|
|
18
|
+
hook: K,
|
|
19
|
+
...args: HookArgs[K]
|
|
7
20
|
): Promise<void> {
|
|
8
21
|
for (const plugin of plugins) {
|
|
9
|
-
|
|
22
|
+
const fn = plugin[hook] as ((...a: any[]) => any) | undefined;
|
|
23
|
+
await fn?.(...args);
|
|
10
24
|
}
|
|
11
25
|
}
|
|
12
26
|
|