@torpor/build 0.4.14 → 1.0.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 +20 -2
- package/dist/Jsonify-Dg6gChd3.d.mts +10 -0
- package/dist/Jsonify-Dg6gChd3.d.mts.map +1 -0
- package/dist/ParseRouteParams-DBmqlmLh.d.mts +42 -0
- package/dist/ParseRouteParams-DBmqlmLh.d.mts.map +1 -0
- package/dist/{RouteType-B6BMOyYw.mjs → Router-C4ADUfhv.mjs} +30 -42
- package/dist/Router-C4ADUfhv.mjs.map +1 -0
- package/dist/{Server-CF5wDJp6.d.mts → Server-C_IKav_e.d.mts} +44 -16
- package/dist/Server-C_IKav_e.d.mts.map +1 -0
- package/dist/{ServerEvent-CxPwA4lH.mjs → ServerEvent-Dljn5i7A.mjs} +37 -11
- package/dist/ServerEvent-Dljn5i7A.mjs.map +1 -0
- package/dist/Site-CMa153FA.d.mts +565 -0
- package/dist/Site-CMa153FA.d.mts.map +1 -0
- package/dist/StandardSchema-D28L8_zZ.d.mts +100 -0
- package/dist/StandardSchema-D28L8_zZ.d.mts.map +1 -0
- package/dist/TypedResponse-6lznqN4U.d.mts +22 -0
- package/dist/TypedResponse-6lznqN4U.d.mts.map +1 -0
- package/dist/{_page-BQs4WvCO.mjs → _page-4cQKs_Dn.mjs} +3 -5
- package/dist/_page-4cQKs_Dn.mjs.map +1 -0
- package/dist/bin/index.d.mts +1 -1
- package/dist/bin/index.js +15 -11
- package/dist/bin/index.js.map +1 -1
- package/dist/flattenHeaders-C_YYLdOq.mjs +114 -0
- package/dist/flattenHeaders-C_YYLdOq.mjs.map +1 -0
- package/dist/form.d.mts +35 -0
- package/dist/form.d.mts.map +1 -0
- package/dist/form.mjs +48 -0
- package/dist/form.mjs.map +1 -0
- package/dist/index.d.mts +72 -156
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +158 -26
- package/dist/index.mjs.map +1 -1
- package/dist/nav.d.mts +58 -5
- package/dist/nav.d.mts.map +1 -1
- package/dist/nav.mjs +116 -38
- package/dist/nav.mjs.map +1 -1
- package/dist/openapi.d.mts +144 -0
- package/dist/openapi.d.mts.map +1 -0
- package/dist/openapi.mjs +2 -0
- package/dist/pathTrie-D4Ax2Mu9.mjs +160 -0
- package/dist/pathTrie-D4Ax2Mu9.mjs.map +1 -0
- package/dist/plugin-D8Www11B.mjs +202 -0
- package/dist/plugin-D8Www11B.mjs.map +1 -0
- package/dist/{response-C5TtAsh1.mjs → response-Bg4w2S1q.mjs} +16 -39
- package/dist/response-Bg4w2S1q.mjs.map +1 -0
- package/dist/response.d.mts +309 -289
- package/dist/response.d.mts.map +1 -1
- package/dist/response.mjs +3 -4
- package/dist/run.d.mts +11 -4
- package/dist/run.d.mts.map +1 -1
- package/dist/run.mjs +2 -8
- package/dist/runOpenApi-BUi1RH1l.mjs +1381 -0
- package/dist/runOpenApi-BUi1RH1l.mjs.map +1 -0
- package/dist/schema.d.mts +32 -0
- package/dist/schema.d.mts.map +1 -0
- package/dist/schema.mjs +2 -0
- package/dist/server.d.mts +10 -8
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +132 -4
- package/dist/server.mjs.map +1 -0
- package/dist/state.d.mts.map +1 -1
- package/dist/state.mjs +2 -3
- package/dist/test.d.mts +8 -10
- package/dist/test.d.mts.map +1 -1
- package/dist/test.mjs +283 -79
- package/dist/test.mjs.map +1 -1
- package/dist/{seeOther-B4Yhu9iq.mjs → unprocessable-DAZbrDeR.mjs} +34 -13
- package/dist/unprocessable-DAZbrDeR.mjs.map +1 -0
- package/dist/validate-Ok4krlkX.mjs +40 -0
- package/dist/validate-Ok4krlkX.mjs.map +1 -0
- package/package.json +28 -17
- package/src/bin/index.ts +13 -2
- package/src/dev.ts +10 -0
- package/src/form/formDataToRecord.ts +23 -0
- package/src/form/readForm.ts +71 -0
- package/src/form.ts +4 -0
- package/src/index.ts +53 -1
- package/src/nav/api.test-d.ts +79 -0
- package/src/nav/api.ts +104 -0
- package/src/nav/formSubmit.ts +18 -4
- package/src/nav/navigate.ts +40 -15
- package/src/nav/route.ts +38 -0
- package/src/nav.ts +3 -1
- package/src/openapi/docsHtml.ts +25 -0
- package/src/openapi/document.ts +154 -0
- package/src/openapi/plugin.ts +78 -0
- package/src/openapi/types.ts +82 -0
- package/src/openapi.ts +14 -0
- package/src/response/TypedResponse.ts +17 -0
- package/src/response/badRequest.ts +14 -2
- package/src/response/created.ts +13 -2
- package/src/response/found.ts +2 -2
- package/src/response/movedPermanently.ts +2 -2
- package/src/response/notModified.ts +2 -2
- package/src/response/ok.ts +13 -4
- package/src/response/response.ts +4 -4
- package/src/response/unprocessable.ts +14 -2
- package/src/run/depCache.ts +175 -0
- package/src/run/devPlugin.ts +128 -0
- package/src/run/prepareTemplate.ts +6 -3
- package/src/run/run.ts +63 -39
- package/src/run/runBuild.ts +63 -12
- package/src/run/runDev.ts +146 -58
- package/src/run/runOpenApi.ts +52 -0
- package/src/run/runPreview.ts +17 -26
- package/src/run/staleTorpCopies.ts +100 -0
- package/src/run.ts +2 -1
- package/src/schema.ts +7 -0
- package/src/server/CookieHelper.ts +17 -7
- package/src/server/Server.ts +54 -30
- package/src/server/ServerEvent.ts +24 -1
- package/src/server/connect/connectMiddleware.ts +44 -40
- package/src/server/connect/flattenHeaders.ts +1 -23
- package/src/server/connect/requestToNodeMessage.ts +6 -3
- package/src/server/types/MiddlewareFunction.ts +18 -4
- package/src/site/Router.ts +52 -41
- package/src/site/Site.ts +184 -10
- package/src/site/checkLayoutSlots.ts +114 -0
- package/src/site/checkRoutes.ts +435 -0
- package/src/site/clientEntry.ts +22 -9
- package/src/site/layoutSlots.ts +53 -0
- package/src/site/manifest.ts +125 -6
- package/src/site/serverEntry.ts +341 -132
- package/src/state/$page.ts +2 -1
- package/src/state/$serverPage.ts +22 -0
- package/src/test/runTest.ts +306 -118
- package/src/types/Adapter.ts +12 -0
- package/src/types/Jsonify.ts +20 -0
- package/src/types/PageData.test-d.ts +136 -0
- package/src/types/PageData.ts +35 -0
- package/src/types/PageEndPoint.ts +25 -9
- package/src/types/PageForm.test-d.ts +78 -0
- package/src/types/PageForm.ts +29 -0
- package/src/types/PageLoadEvent.ts +16 -4
- package/src/types/PageLoadReturn.ts +13 -0
- package/src/types/PageProps.ts +14 -0
- package/src/types/PageServerAction.ts +8 -2
- package/src/types/PageServerEndPoint.test-d.ts +105 -0
- package/src/types/PageServerEndPoint.ts +99 -5
- package/src/types/PageServerLoad.ts +11 -3
- package/src/types/ParseRouteParams.test-d.ts +84 -0
- package/src/types/ParseRouteParams.ts +64 -0
- package/src/types/Route.ts +20 -1
- package/src/types/RouteHandler.ts +8 -1
- package/src/types/ServerEndPoint.test-d.ts +77 -0
- package/src/types/ServerEndPoint.ts +148 -16
- package/src/types/ServerHook.ts +10 -3
- package/src/types/ServerLoadEvent.ts +48 -3
- package/src/types/ServerRequest.ts +10 -2
- package/src/types/SitePlugin.ts +24 -0
- package/src/types/StandardSchema.ts +109 -0
- package/src/utils/pathToRegex.ts +4 -2
- package/src/utils/pathTrie.ts +182 -0
- package/src/utils/searchParamsToRecord.ts +18 -0
- package/src/utils/torporPackages.ts +150 -0
- package/src/utils/tsconfigAliases.ts +90 -0
- package/src/validation/ValidationError.ts +18 -0
- package/src/validation/endpoint.ts +83 -0
- package/src/validation/validate.ts +26 -0
- package/dist/RouteType-B6BMOyYw.mjs.map +0 -1
- package/dist/Server-CF5wDJp6.d.mts.map +0 -1
- package/dist/ServerEvent-CxPwA4lH.mjs.map +0 -1
- package/dist/Site-DgC6WWn1.d.mts +0 -78
- package/dist/Site-DgC6WWn1.d.mts.map +0 -1
- package/dist/_page-BQs4WvCO.mjs.map +0 -1
- package/dist/connectMiddleware-D7nehjj3.mjs +0 -252
- package/dist/connectMiddleware-D7nehjj3.mjs.map +0 -1
- package/dist/pathToRegex-TUAMc3zS.mjs +0 -11
- package/dist/pathToRegex-TUAMc3zS.mjs.map +0 -1
- package/dist/response-C5TtAsh1.mjs.map +0 -1
- package/dist/run-BPFtSYA3.mjs +0 -332
- package/dist/run-BPFtSYA3.mjs.map +0 -1
- package/dist/seeOther-B4Yhu9iq.mjs.map +0 -1
- package/src/server/Routerx.ts +0 -72
- package/src/server/connect/bufferToArrayBuffer.ts +0 -8
- package/src/server/connect/readableToBuffer.ts +0 -16
package/src/nav/navigate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { clearLayoutSlot, fillLayoutSlot, hydrate } from "@torpor/view";
|
|
2
2
|
import { type Component, type SlotRender } from "@torpor/view";
|
|
3
|
-
import { mount } from "@torpor/view";
|
|
3
|
+
import { mount, unmount } from "@torpor/view";
|
|
4
4
|
import $page from "../state/$page";
|
|
5
5
|
import client from "../state/client";
|
|
6
6
|
import type LayoutPath from "../types/LayoutPath";
|
|
@@ -98,6 +98,13 @@ export default async function navigate(url: URL, withHydration = false): Promise
|
|
|
98
98
|
// TODO: There's probably a nicer way to do this with reducers or something
|
|
99
99
|
let component = clientEndPoint.component as Component;
|
|
100
100
|
let slots: Record<string, SlotRender> | undefined = undefined;
|
|
101
|
+
let reused = false;
|
|
102
|
+
// The index of the innermost reused layout. Its slotRegion is the region
|
|
103
|
+
// that must be cleared and refilled (it contains the next layout's or the
|
|
104
|
+
// page's content). This is usually the last entry of the stack, but not
|
|
105
|
+
// when an outer layout is reused while an inner one is new — e.g.
|
|
106
|
+
// navigating between sections that share the root layout.
|
|
107
|
+
let reusedIndex = -1;
|
|
101
108
|
if (handler.layouts) {
|
|
102
109
|
let slotFunctions: SlotRender[] = [];
|
|
103
110
|
// The last slot function will render the client component
|
|
@@ -119,12 +126,12 @@ export default async function navigate(url: URL, withHydration = false): Promise
|
|
|
119
126
|
?.default;
|
|
120
127
|
if (layoutEndPoint?.component) {
|
|
121
128
|
if (layoutStack[i].reuse) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
// Reuse this layout — clear and refill its slot (done in
|
|
130
|
+
// the try block below so a failure doesn't leave the slot
|
|
131
|
+
// half-cleared)
|
|
132
|
+
component = slotFunctions[i + 1] as Component;
|
|
133
|
+
reusedIndex = i;
|
|
134
|
+
reused = true;
|
|
128
135
|
break;
|
|
129
136
|
} else if (i === 0) {
|
|
130
137
|
component = layoutEndPoint.component as Component;
|
|
@@ -145,16 +152,34 @@ export default async function navigate(url: URL, withHydration = false): Promise
|
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
try {
|
|
156
|
+
if (reused) {
|
|
157
|
+
// The layout is being reused — clear the old slot content, then
|
|
158
|
+
// call the slot function directly to fill it with the new page.
|
|
159
|
+
// We must not go through `mount` here: the slot's container still
|
|
160
|
+
// holds the layout's own children (e.g. a header), which `mount`
|
|
161
|
+
// refuses to mount into. Both the clear and the fill are inside
|
|
162
|
+
// the try so that a failure doesn't leave the slot half-cleared.
|
|
163
|
+
const slotRegion = layoutStack[reusedIndex].slotRegion;
|
|
164
|
+
parent = slotRegion.startNode!.parentNode as HTMLElement;
|
|
165
|
+
clearLayoutSlot(slotRegion);
|
|
166
|
+
component(parent, null);
|
|
167
|
+
} else if (withHydration) {
|
|
168
|
+
hydrate(parent, component, $props, slots);
|
|
169
|
+
} else {
|
|
170
|
+
// The layout chain changed (or there was no previous layout to
|
|
171
|
+
// reuse): tear down the previous UI entirely — disposing its
|
|
172
|
+
// region tree and clearing `#app` — so `mount` starts fresh.
|
|
173
|
+
// Without this, `mount` throws because `#app` still holds the
|
|
174
|
+
// previous render's children, and it would reuse a stale root
|
|
175
|
+
// region.
|
|
176
|
+
unmount(parent);
|
|
152
177
|
mount(parent, component, $props, slots);
|
|
153
|
-
} catch (error) {
|
|
154
|
-
// TODO: Show a proper Error component
|
|
155
|
-
parent.innerHTML = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
|
|
156
|
-
console.log(error);
|
|
157
178
|
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// TODO: Show a proper Error component
|
|
181
|
+
parent.innerHTML = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
|
|
182
|
+
console.log(error);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
185
|
// Reset prefetched data on each navigation
|
package/src/nav/route.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ExactRouteParams, ParseRouteParams } from "../types/ParseRouteParams";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a route path in a type-safe manner, filling in dynamic segments, e.g.
|
|
5
|
+
* `route("/posts/[id]", { id: 5 })` returns `/posts/5`. The params object is
|
|
6
|
+
* checked for exact keys: missing or unknown params error at compile time.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: The rest args conditional must stay INLINE — routing it through a type
|
|
9
|
+
* alias defeats `Params` inference, and excess keys stop being checked
|
|
10
|
+
* @param path The route path
|
|
11
|
+
* @param params The route params, required if the path has dynamic segments
|
|
12
|
+
* @returns The path with the params filled in, URI-encoded
|
|
13
|
+
*/
|
|
14
|
+
export default function route<
|
|
15
|
+
Route extends string,
|
|
16
|
+
Params extends ParseRouteParams<Route> = ParseRouteParams<Route>,
|
|
17
|
+
>(
|
|
18
|
+
path: Route,
|
|
19
|
+
...args: string extends Route
|
|
20
|
+
? [params?: Record<string, string>]
|
|
21
|
+
: keyof ParseRouteParams<Route> extends never
|
|
22
|
+
? []
|
|
23
|
+
: [params: Params & ExactRouteParams<Params, ParseRouteParams<Route>>]
|
|
24
|
+
): string {
|
|
25
|
+
const params = ((args as unknown[])[0] ?? {}) as Record<string, string>;
|
|
26
|
+
return path.replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, splat: string | undefined, name: string) => {
|
|
27
|
+
const value = params[name];
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
throw new Error(`Missing param '${name}' for route '${path}'`);
|
|
30
|
+
}
|
|
31
|
+
return splat
|
|
32
|
+
? value
|
|
33
|
+
.split("/")
|
|
34
|
+
.map((segment) => encodeURIComponent(segment))
|
|
35
|
+
.join("/")
|
|
36
|
+
: encodeURIComponent(String(value));
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/nav.ts
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A standalone HTML page loading Swagger UI from a CDN, pointed at the
|
|
3
|
+
* OpenAPI document endpoint.
|
|
4
|
+
*/
|
|
5
|
+
export default function openApiDocsHtml(docPath: string): string {
|
|
6
|
+
return `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<title>API Docs</title>
|
|
12
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="swagger-ui"></div>
|
|
16
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
|
17
|
+
<script>
|
|
18
|
+
window.addEventListener("DOMContentLoaded", () => {
|
|
19
|
+
SwaggerUIBundle({ url: ${JSON.stringify(docPath)}, dom_id: "#swagger-ui" });
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "../types/StandardSchema";
|
|
2
|
+
import type { OpenApiPluginOptions, OpenApiRouteEntry, JsonSchema } from "./types";
|
|
3
|
+
|
|
4
|
+
// The endpoint handler names, in the order they appear in docs
|
|
5
|
+
const METHODS = ["get", "post", "patch", "put", "del", "options", "head"] as const;
|
|
6
|
+
type Method = (typeof METHODS)[number];
|
|
7
|
+
|
|
8
|
+
// Handlers whose schema validates the query string; all others validate the
|
|
9
|
+
// json request body
|
|
10
|
+
const QUERY_METHODS: readonly string[] = ["get", "head"];
|
|
11
|
+
const BODY_METHODS: readonly string[] = ["post", "patch", "put", "del", "options"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds an OpenAPI 3.1 document from a list of +server endpoints.
|
|
15
|
+
*
|
|
16
|
+
* Each endpoint's route path (`/api/posts/[id]`) is converted to OpenAPI
|
|
17
|
+
* syntax (`/api/posts/{id}`); its `params` schema becomes path parameters,
|
|
18
|
+
* its `get`/`head` schema becomes query parameters, and its `post`/`patch`/
|
|
19
|
+
* `put`/`del`/`options` schema becomes a json request body. Responses are
|
|
20
|
+
* stubbed: a `200` for every handler, plus a `422` when the handler declares
|
|
21
|
+
* an input schema (validation errors are returned automatically).
|
|
22
|
+
*
|
|
23
|
+
* Throws if any endpoint declares a schema but the options have no
|
|
24
|
+
* `toJsonSchema` converter.
|
|
25
|
+
*/
|
|
26
|
+
export default function buildOpenApiDocument(
|
|
27
|
+
entries: OpenApiRouteEntry[],
|
|
28
|
+
options: OpenApiPluginOptions,
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
if (!options.toJsonSchema) {
|
|
31
|
+
const withSchema = entries.find(hasSchemas);
|
|
32
|
+
if (withSchema) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`The endpoint at ${withSchema.path} declares schema(s), but the openApi() plugin ` +
|
|
35
|
+
`was not given a "toJsonSchema" converter. Pass one, e.g. ` +
|
|
36
|
+
`openApi({ toJsonSchema: (s) => z.toJSONSchema(s) })`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const pathItem = buildPathItem(entry, options);
|
|
44
|
+
if (pathItem) {
|
|
45
|
+
paths[openApiPath(entry.path)] = pathItem;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
openapi: "3.1.0",
|
|
51
|
+
info: {
|
|
52
|
+
title: options.title ?? "API",
|
|
53
|
+
version: options.version ?? "1.0.0",
|
|
54
|
+
},
|
|
55
|
+
paths,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasSchemas(entry: OpenApiRouteEntry): boolean {
|
|
60
|
+
const schema = entry.endPoint?.schema;
|
|
61
|
+
if (!schema) return false;
|
|
62
|
+
return METHODS.some((method) => schema[method]) || !!schema.params;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildPathItem(
|
|
66
|
+
entry: OpenApiRouteEntry,
|
|
67
|
+
options: OpenApiPluginOptions,
|
|
68
|
+
): Record<string, unknown> | undefined {
|
|
69
|
+
const endPoint = entry.endPoint;
|
|
70
|
+
const pathItem: Record<string, unknown> = {};
|
|
71
|
+
let count = 0;
|
|
72
|
+
|
|
73
|
+
for (const method of METHODS) {
|
|
74
|
+
if (typeof endPoint[method] !== "function") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
count++;
|
|
78
|
+
|
|
79
|
+
const schema = endPoint.schema?.[method];
|
|
80
|
+
const paramsSchema = endPoint.schema?.params;
|
|
81
|
+
|
|
82
|
+
const parameters: Record<string, unknown>[] = [];
|
|
83
|
+
if (paramsSchema) {
|
|
84
|
+
parameters.push(...flattenObject(convert(paramsSchema, options), "path"));
|
|
85
|
+
}
|
|
86
|
+
if (schema && QUERY_METHODS.includes(method)) {
|
|
87
|
+
parameters.push(...flattenObject(convert(schema, options), "query"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const responses: Record<string, unknown> = { "200": { description: "OK" } };
|
|
91
|
+
if (schema) {
|
|
92
|
+
responses["422"] = { description: "Validation failed" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pathItem[method === "del" ? "delete" : method] = {
|
|
96
|
+
operationId: operationId(method, entry.path),
|
|
97
|
+
...(parameters.length ? { parameters } : {}),
|
|
98
|
+
...(schema && BODY_METHODS.includes(method)
|
|
99
|
+
? {
|
|
100
|
+
requestBody: {
|
|
101
|
+
required: true,
|
|
102
|
+
content: { "application/json": { schema: convert(schema, options) } },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
responses,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return count > 0 ? pathItem : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function convert(schema: StandardSchemaV1, options: OpenApiPluginOptions): JsonSchema {
|
|
114
|
+
return options.toJsonSchema!(schema);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flattens a converted object schema into OpenAPI parameters. Path
|
|
119
|
+
* parameters are always required; query parameters take their `required`
|
|
120
|
+
* flag from the schema's `required` array.
|
|
121
|
+
*/
|
|
122
|
+
function flattenObject(
|
|
123
|
+
jsonSchema: JsonSchema,
|
|
124
|
+
location: "path" | "query",
|
|
125
|
+
): Record<string, unknown>[] {
|
|
126
|
+
const properties = jsonSchema.properties;
|
|
127
|
+
if (!properties || typeof properties !== "object") {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const required = Array.isArray(jsonSchema.required) ? jsonSchema.required : [];
|
|
131
|
+
return Object.entries(properties as Record<string, JsonSchema>).map(([name, propSchema]) => ({
|
|
132
|
+
name,
|
|
133
|
+
in: location,
|
|
134
|
+
required: location === "path" ? true : required.includes(name),
|
|
135
|
+
schema: propSchema,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Converts a route path to OpenAPI syntax: `/api/posts/[id]` becomes
|
|
141
|
+
* `/api/posts/{id}`.
|
|
142
|
+
*/
|
|
143
|
+
function openApiPath(routePath: string): string {
|
|
144
|
+
return routePath.replace(/\[\.\.\.([^\]]+)\]/g, "{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function operationId(method: Method, routePath: string): string {
|
|
148
|
+
const segments = openApiPath(routePath)
|
|
149
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
150
|
+
.filter((s) => s.length > 0)
|
|
151
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1));
|
|
152
|
+
const verb = method === "del" ? "delete" : method;
|
|
153
|
+
return verb + (segments.join("") || "Root");
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type SitePlugin from "../types/SitePlugin";
|
|
2
|
+
import openApiDocsHtml from "./docsHtml";
|
|
3
|
+
import type { OpenApiPluginOptions, ResolvedOpenApiOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `site.pluginState` key the openApi plugin stores its resolved options
|
|
7
|
+
* under. A registry symbol (`Symbol.for`), so that the user's config, the
|
|
8
|
+
* manifest plugin and the generated runtime code all resolve to the same
|
|
9
|
+
* key even when they are loaded as separate module instances.
|
|
10
|
+
*/
|
|
11
|
+
export const OPEN_API_STATE_KEY: symbol = Symbol.for("@torpor/build/openapi");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Serves an OpenAPI 3.1 document for the site's `+server` endpoints, plus an
|
|
15
|
+
* optional interactive docs page.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Site } from "@torpor/build";
|
|
19
|
+
* import { openApi } from "@torpor/build/openapi";
|
|
20
|
+
* import { z } from "zod";
|
|
21
|
+
*
|
|
22
|
+
* const site = new Site();
|
|
23
|
+
* site.addRouteFolder("src/routes");
|
|
24
|
+
* site.plugins = [
|
|
25
|
+
* openApi({
|
|
26
|
+
* title: "My API",
|
|
27
|
+
* toJsonSchema: (s) => z.toJSONSchema(s as z.ZodType, { io: "input" }),
|
|
28
|
+
* }),
|
|
29
|
+
* ];
|
|
30
|
+
* export default site;
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* The document is served at `/openapi.json` (configurable with `path`) and
|
|
34
|
+
* the docs page at `/docs` (set `docs: false` to disable). Endpoints are
|
|
35
|
+
* picked up from every `+server.ts` route: its `params` schema becomes path
|
|
36
|
+
* parameters, `get`/`head` schemas become query parameters, and
|
|
37
|
+
* `post`/`patch`/`put`/`del`/`options` schemas become request bodies.
|
|
38
|
+
*
|
|
39
|
+
* The resolved options are stored in `site.pluginState` under
|
|
40
|
+
* `OPEN_API_STATE_KEY`, which the manifest plugin reads at build time to
|
|
41
|
+
* generate the document endpoint.
|
|
42
|
+
*/
|
|
43
|
+
export function openApi(options: OpenApiPluginOptions = {}): SitePlugin {
|
|
44
|
+
return (site) => {
|
|
45
|
+
const docPath = withLeadingSlash(options.path ?? "/openapi.json");
|
|
46
|
+
if (site.routes.some((route) => route.path === docPath)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`There is already a route registered at ${docPath}, so the OpenAPI document can't be ` +
|
|
49
|
+
`served there. Pass a different "path" to the openApi() plugin`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolved: ResolvedOpenApiOptions = {
|
|
54
|
+
path: docPath,
|
|
55
|
+
docs: options.docs === false ? undefined : withLeadingSlash(options.docs ?? "/docs"),
|
|
56
|
+
title: options.title ?? "API",
|
|
57
|
+
version: options.version ?? "1.0.0",
|
|
58
|
+
toJsonSchema: options.toJsonSchema,
|
|
59
|
+
};
|
|
60
|
+
site.pluginState.set(OPEN_API_STATE_KEY, resolved);
|
|
61
|
+
|
|
62
|
+
if (resolved.docs) {
|
|
63
|
+
const html = openApiDocsHtml(docPath);
|
|
64
|
+
site.addRoute(resolved.docs, {
|
|
65
|
+
server: {
|
|
66
|
+
get: () =>
|
|
67
|
+
new Response(html, {
|
|
68
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function withLeadingSlash(pathName: string): string {
|
|
77
|
+
return pathName.startsWith("/") ? pathName : "/" + pathName;
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "../types/StandardSchema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A JSON Schema object, as produced by a schema library's converter (e.g.
|
|
5
|
+
* `z.toJSONSchema`, valibot's `toJsonSchema`, arktype's `toJsonSchema`).
|
|
6
|
+
*/
|
|
7
|
+
export type JsonSchema = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts a Standard Schema (zod, valibot, arktype, ...) into a JSON Schema
|
|
11
|
+
* object, for inclusion in an OpenAPI document. Supplied to the openApi
|
|
12
|
+
* plugin, since each schema library has its own conversion function:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // zod
|
|
16
|
+
* openApi({ toJsonSchema: (s) => z.toJSONSchema(s as z.ZodType, { io: "input" }) })
|
|
17
|
+
* // arktype
|
|
18
|
+
* openApi({ toJsonSchema: (s) => (s as Type).toJsonSchema() })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export type ToJsonSchema = (schema: StandardSchemaV1) => JsonSchema;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for the openApi plugin.
|
|
25
|
+
*/
|
|
26
|
+
export type OpenApiPluginOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* The path to serve the OpenAPI document at. Defaults to `/openapi.json`.
|
|
29
|
+
*/
|
|
30
|
+
path?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The path to serve interactive API docs at, or `false` to disable the
|
|
33
|
+
* docs page. Defaults to `/docs`.
|
|
34
|
+
*/
|
|
35
|
+
docs?: string | false;
|
|
36
|
+
/**
|
|
37
|
+
* The API title for the document's `info` section. Defaults to `"API"`.
|
|
38
|
+
*/
|
|
39
|
+
title?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The API version for the document's `info` section. Defaults to
|
|
42
|
+
* `"1.0.0"`.
|
|
43
|
+
*/
|
|
44
|
+
version?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Converts an endpoint's schemas into JSON Schema. Required for endpoints
|
|
47
|
+
* that declare schemas; endpoints without schemas are documented with
|
|
48
|
+
* their paths and methods only.
|
|
49
|
+
*/
|
|
50
|
+
toJsonSchema?: ToJsonSchema;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* OpenApi options with defaults applied. Created by the openApi plugin and
|
|
55
|
+
* consumed by the document builder.
|
|
56
|
+
*/
|
|
57
|
+
export type ResolvedOpenApiOptions = {
|
|
58
|
+
path: string;
|
|
59
|
+
/** Undefined when the docs page is disabled */
|
|
60
|
+
docs: string | undefined;
|
|
61
|
+
title: string;
|
|
62
|
+
version: string;
|
|
63
|
+
toJsonSchema: ToJsonSchema | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A ServerEndPoint shape, loosely typed so that endpoint modules loaded from
|
|
68
|
+
* anywhere can be passed to the document builder.
|
|
69
|
+
*/
|
|
70
|
+
export type OpenApiEndPoint = {
|
|
71
|
+
schema?: Record<string, StandardSchemaV1 | undefined> | undefined;
|
|
72
|
+
[method: string]: unknown;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A single API endpoint to include in an OpenAPI document: its route path
|
|
77
|
+
* (with `[param]` segments) and its default-exported endpoint object.
|
|
78
|
+
*/
|
|
79
|
+
export type OpenApiRouteEntry = {
|
|
80
|
+
path: string;
|
|
81
|
+
endPoint: OpenApiEndPoint;
|
|
82
|
+
};
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import buildOpenApiDocument from "./openapi/document";
|
|
2
|
+
import openApiDocsHtml from "./openapi/docsHtml";
|
|
3
|
+
import { OPEN_API_STATE_KEY, openApi } from "./openapi/plugin";
|
|
4
|
+
|
|
5
|
+
export { buildOpenApiDocument, openApi, openApiDocsHtml, OPEN_API_STATE_KEY };
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
JsonSchema,
|
|
9
|
+
OpenApiEndPoint,
|
|
10
|
+
OpenApiPluginOptions,
|
|
11
|
+
OpenApiRouteEntry,
|
|
12
|
+
ResolvedOpenApiOptions,
|
|
13
|
+
ToJsonSchema,
|
|
14
|
+
} from "./openapi/types";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Response whose JSON body type is known, as returned by e.g. `ok({ ... })`.
|
|
3
|
+
* Purely a compile-time marker: `__body` is never set at runtime.
|
|
4
|
+
*/
|
|
5
|
+
export default interface TypedResponse<T> extends Response {
|
|
6
|
+
/**
|
|
7
|
+
* Phantom property carrying the body type. Required so that plain
|
|
8
|
+
* `Response` values do not match `TypedResponse<infer T>`.
|
|
9
|
+
*/
|
|
10
|
+
readonly __body: T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A Response that does not carry typed JSON data (e.g. redirects, errors,
|
|
15
|
+
* plain-text bodies). Loads may return these alongside typed data responses.
|
|
16
|
+
*/
|
|
17
|
+
export type UntypedResponse = Response & { readonly __body?: never };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import response from "./response";
|
|
2
|
+
import type { Jsonify } from "../types/Jsonify";
|
|
3
|
+
import type TypedResponse from "./TypedResponse";
|
|
4
|
+
|
|
5
|
+
type BadRequestResponse<T extends object | string | undefined> = T extends object
|
|
6
|
+
? TypedResponse<Jsonify<T>>
|
|
7
|
+
: Response;
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* 400 Bad Request
|
|
@@ -12,8 +18,14 @@ import response from "./response";
|
|
|
12
18
|
* Clients that receive a 400 response should expect that repeating the request
|
|
13
19
|
* without modification will fail with the same error.
|
|
14
20
|
*
|
|
21
|
+
* An object body is typed: it becomes the page's `$props.form` (with its JSON
|
|
22
|
+
* form) after a form submit, so validation errors can be surfaced with their
|
|
23
|
+
* field types via `PageForm`.
|
|
24
|
+
*
|
|
15
25
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/400
|
|
16
26
|
*/
|
|
17
|
-
export default function badRequest
|
|
18
|
-
|
|
27
|
+
export default function badRequest<T extends object | string | undefined>(
|
|
28
|
+
body?: T,
|
|
29
|
+
): BadRequestResponse<T> {
|
|
30
|
+
return response(400, body ?? "Bad request") as BadRequestResponse<T>;
|
|
19
31
|
}
|
package/src/response/created.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import type { Jsonify } from "../types/Jsonify";
|
|
2
|
+
import type TypedResponse from "./TypedResponse";
|
|
1
3
|
import response from "./response";
|
|
2
4
|
|
|
5
|
+
type CreatedResponse<T extends object | string | undefined> = T extends object
|
|
6
|
+
? TypedResponse<Jsonify<T>>
|
|
7
|
+
: Response;
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* 201 Created
|
|
5
11
|
*
|
|
@@ -13,8 +19,13 @@ import response from "./response";
|
|
|
13
19
|
* initiating request or by the URL in the value of the Location header provided
|
|
14
20
|
* with the response.
|
|
15
21
|
*
|
|
22
|
+
* An object body is typed: the client sees its JSON form through
|
|
23
|
+
* `makeApi`-created callers.
|
|
24
|
+
*
|
|
16
25
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/201
|
|
17
26
|
*/
|
|
18
|
-
export default function created
|
|
19
|
-
|
|
27
|
+
export default function created<T extends object | string | undefined>(
|
|
28
|
+
body?: T,
|
|
29
|
+
): CreatedResponse<T> {
|
|
30
|
+
return response(201, body) as CreatedResponse<T>;
|
|
20
31
|
}
|
package/src/response/found.ts
CHANGED
|
@@ -25,6 +25,6 @@ import transfer from "./transfer";
|
|
|
25
25
|
*
|
|
26
26
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
|
|
27
27
|
*/
|
|
28
|
-
export default function
|
|
29
|
-
return transfer(
|
|
28
|
+
export default function found(location: string): Response {
|
|
29
|
+
return transfer(302, location);
|
|
30
30
|
}
|
|
@@ -20,6 +20,6 @@ import transfer from "./transfer";
|
|
|
20
20
|
*
|
|
21
21
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/301
|
|
22
22
|
*/
|
|
23
|
-
export default function
|
|
24
|
-
return transfer(
|
|
23
|
+
export default function movedPermanently(location: string): Response {
|
|
24
|
+
return transfer(301, location);
|
|
25
25
|
}
|
|
@@ -29,6 +29,6 @@ import transfer from "./transfer";
|
|
|
29
29
|
*
|
|
30
30
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304
|
|
31
31
|
*/
|
|
32
|
-
export default function notModified(
|
|
33
|
-
return transfer(304,
|
|
32
|
+
export default function notModified(): Response {
|
|
33
|
+
return transfer(304, "");
|
|
34
34
|
}
|
package/src/response/ok.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import type { Jsonify } from "../types/Jsonify";
|
|
2
|
+
import type TypedResponse from "./TypedResponse";
|
|
1
3
|
import response from "./response";
|
|
2
4
|
|
|
5
|
+
type OkResponse<T extends object | string | undefined> = T extends object
|
|
6
|
+
? TypedResponse<Jsonify<T>>
|
|
7
|
+
: Response;
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* 200 OK
|
|
5
11
|
*
|
|
@@ -17,13 +23,16 @@ import response from "./response";
|
|
|
17
23
|
* - TRACE: The response has a message body containing the request as received
|
|
18
24
|
* by the server.
|
|
19
25
|
*
|
|
20
|
-
* Although possible, successful PUT or DELETE requests often do not result in
|
|
21
|
-
* 200 OK response. It is more common to see 201 Created if the resource is
|
|
26
|
+
* Although possible, successful PUT or DELETE requests often do not result in
|
|
27
|
+
* a 200 OK response. It is more common to see 201 Created if the resource is
|
|
22
28
|
* uploaded or created for the first time, or 204 No Content upon successful
|
|
23
29
|
* deletion of a resource.
|
|
24
30
|
*
|
|
31
|
+
* An object body is typed: the client sees its JSON form through
|
|
32
|
+
* `makeApi`-created callers.
|
|
33
|
+
*
|
|
25
34
|
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/200
|
|
26
35
|
*/
|
|
27
|
-
export default function ok
|
|
28
|
-
return response(200, body)
|
|
36
|
+
export default function ok<T extends object | string | undefined>(body?: T): OkResponse<T> {
|
|
37
|
+
return response(200, body) as OkResponse<T>;
|
|
29
38
|
}
|
package/src/response/response.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a response with the supplied status code and optional body.
|
|
3
3
|
*
|
|
4
|
-
* If the body is an object, it will be converted to JSON and the
|
|
4
|
+
* If the body is an object, it will be converted to JSON and the Content-Type
|
|
5
5
|
* header set to "application/json". Otherwise, if the body is a string, the
|
|
6
|
-
*
|
|
6
|
+
* Content-Type header will be set to "text/plain".
|
|
7
7
|
*/
|
|
8
8
|
export default function response(status: number, body?: object | string): Response {
|
|
9
9
|
let headers: Record<string, string> | undefined = undefined;
|
|
10
10
|
if (body) {
|
|
11
11
|
if (typeof body === "object") {
|
|
12
|
-
headers = { "
|
|
12
|
+
headers = { "Content-Type": "application/json" };
|
|
13
13
|
body = JSON.stringify(body);
|
|
14
14
|
} else {
|
|
15
|
-
headers = { "
|
|
15
|
+
headers = { "Content-Type": "text/plain" };
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
return new Response(body, {
|