@crombolo/common 0.0.0-stage → 0.37.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/middleware.d.ts +92 -0
- package/dist/middleware.js +44 -0
- package/dist/result.d.ts +136 -0
- package/dist/result.js +200 -0
- package/dist/routes.d.ts +71 -0
- package/dist/routes.js +21 -0
- package/package.json +37 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware chain's contract (story 210): what a site's own code, an npm
|
|
3
|
+
* package or a server bundle implements to be inserted into a site's chain.
|
|
4
|
+
* Plain web `Request` and `Response`, with crombolo's extras in `context`.
|
|
5
|
+
* docs/rendering.md, "Middleware".
|
|
6
|
+
*/
|
|
7
|
+
/** The request's cookies, read-only. Reading them marks the answer personal. */
|
|
8
|
+
export interface ReadonlyRequestCookies extends Iterable<[string, RequestCookie]> {
|
|
9
|
+
get(name: string): RequestCookie | undefined;
|
|
10
|
+
getAll(name?: string): RequestCookie[];
|
|
11
|
+
has(name: string): boolean;
|
|
12
|
+
readonly size: number;
|
|
13
|
+
}
|
|
14
|
+
export interface RequestCookie {
|
|
15
|
+
name: string;
|
|
16
|
+
value: string;
|
|
17
|
+
}
|
|
18
|
+
/** Where in a site's handling a middleware runs. */
|
|
19
|
+
export type MiddlewareStage = 'request' | 'route' | 'page';
|
|
20
|
+
/** What crombolo gives a middleware besides the request. */
|
|
21
|
+
export interface MiddlewareContext<Site = unknown, Page = unknown, Member = unknown> {
|
|
22
|
+
/** The request's address, parsed. */
|
|
23
|
+
url: URL;
|
|
24
|
+
/** The request's cookies; reading them makes the answer `private, no-store`. */
|
|
25
|
+
cookies: ReadonlyRequestCookies;
|
|
26
|
+
/** The site's services acting as the visitor (story 206). */
|
|
27
|
+
site(): Site;
|
|
28
|
+
/** The page found, in the `route` and `page` stages. */
|
|
29
|
+
page?: Page;
|
|
30
|
+
/** The signed-in member, in the `page` stage. */
|
|
31
|
+
member?: Member;
|
|
32
|
+
/** An editor is previewing a draft. */
|
|
33
|
+
preview: boolean;
|
|
34
|
+
/** Aborts when the chain's time limit runs out, or the visitor leaves. */
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
/** The stage this middleware is running in. */
|
|
37
|
+
stage: MiddlewareStage;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One link in the chain. Answer outright by not calling `next`; change the
|
|
41
|
+
* request with `next(changed)`; change the answer by returning `next()`'s
|
|
42
|
+
* `Response` altered.
|
|
43
|
+
*/
|
|
44
|
+
export type MiddlewareHandler<Site = unknown, Page = unknown, Member = unknown> = (request: Request, next: (request?: Request) => Promise<Response>, context: MiddlewareContext<Site, Page, Member>) => Response | Promise<Response>;
|
|
45
|
+
/** A condition on a header, cookie or query value, as Next.js's matcher takes. */
|
|
46
|
+
export interface MatcherCondition {
|
|
47
|
+
type: 'header' | 'cookie' | 'query';
|
|
48
|
+
key: string;
|
|
49
|
+
/** A regular expression the value must match; any value when left out. */
|
|
50
|
+
value?: string;
|
|
51
|
+
}
|
|
52
|
+
/** A path pattern in Next.js's matcher syntax, with optional conditions. */
|
|
53
|
+
export type Matcher = string | {
|
|
54
|
+
source: string;
|
|
55
|
+
has?: MatcherCondition[];
|
|
56
|
+
missing?: MatcherCondition[];
|
|
57
|
+
};
|
|
58
|
+
/** A middleware: a handler, or one with a name for logs and the paths it runs for. */
|
|
59
|
+
export type Middleware<Site = unknown, Page = unknown, Member = unknown> = MiddlewareHandler<Site, Page, Member> | {
|
|
60
|
+
/** For logs and errors: `security-headers`, `region-redirect`. */
|
|
61
|
+
name: string;
|
|
62
|
+
/** The paths it runs for; every site path when left out. */
|
|
63
|
+
matcher?: Matcher | Matcher[];
|
|
64
|
+
handle: MiddlewareHandler<Site, Page, Member>;
|
|
65
|
+
};
|
|
66
|
+
/** A site's chain, by stage, each run in the order listed. */
|
|
67
|
+
export interface MiddlewareChain<Site = unknown, Page = unknown, Member = unknown> {
|
|
68
|
+
/** First, before the redirect tables and content finding. */
|
|
69
|
+
request?: Middleware<Site, Page, Member>[];
|
|
70
|
+
/** After content finding, with `context.page`; a rewrite is resolved again. */
|
|
71
|
+
route?: Middleware<Site, Page, Member>[];
|
|
72
|
+
/** After public access, with `context.page` and `context.member`; no rewrites. */
|
|
73
|
+
page?: Middleware<Site, Page, Member>[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The request for another path on this site, rendered at the address the
|
|
77
|
+
* visitor asked for. `path` may carry its own query; without one the
|
|
78
|
+
* request's query carries over.
|
|
79
|
+
*/
|
|
80
|
+
export declare function rewrite(request: Request, path: string): Request;
|
|
81
|
+
/** A `Set-Cookie`'s attributes. */
|
|
82
|
+
export interface CookieOptions {
|
|
83
|
+
path?: string;
|
|
84
|
+
maxAge?: number;
|
|
85
|
+
expires?: Date;
|
|
86
|
+
domain?: string;
|
|
87
|
+
secure?: boolean;
|
|
88
|
+
httpOnly?: boolean;
|
|
89
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
90
|
+
}
|
|
91
|
+
/** The response with a cookie set on it; `Max-Age=0` and an empty value delete one. */
|
|
92
|
+
export declare function withCookie(response: Response, name: string, value: string, options?: CookieOptions): Response;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware chain's contract (story 210): what a site's own code, an npm
|
|
3
|
+
* package or a server bundle implements to be inserted into a site's chain.
|
|
4
|
+
* Plain web `Request` and `Response`, with crombolo's extras in `context`.
|
|
5
|
+
* docs/rendering.md, "Middleware".
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The request for another path on this site, rendered at the address the
|
|
9
|
+
* visitor asked for. `path` may carry its own query; without one the
|
|
10
|
+
* request's query carries over.
|
|
11
|
+
*/
|
|
12
|
+
export function rewrite(request, path) {
|
|
13
|
+
if (!path.startsWith('/') || path.startsWith('//'))
|
|
14
|
+
throw new Error(`A rewrite goes to a path on this site, such as /about, not '${path}'.`);
|
|
15
|
+
const from = new URL(request.url);
|
|
16
|
+
const to = new URL(path, from);
|
|
17
|
+
if (!path.includes('?'))
|
|
18
|
+
to.search = from.search;
|
|
19
|
+
return new Request(to, request);
|
|
20
|
+
}
|
|
21
|
+
/** The response with a cookie set on it; `Max-Age=0` and an empty value delete one. */
|
|
22
|
+
export function withCookie(response, name, value, options = {}) {
|
|
23
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
|
|
24
|
+
throw new Error(`'${name}' is not a cookie name.`);
|
|
25
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path ?? '/'}`];
|
|
26
|
+
if (options.maxAge !== undefined)
|
|
27
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
28
|
+
if (options.expires)
|
|
29
|
+
parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
30
|
+
if (options.domain)
|
|
31
|
+
parts.push(`Domain=${options.domain}`);
|
|
32
|
+
if (options.secure ?? true)
|
|
33
|
+
parts.push('Secure');
|
|
34
|
+
if (options.httpOnly ?? true)
|
|
35
|
+
parts.push('HttpOnly');
|
|
36
|
+
parts.push(`SameSite=${options.sameSite ?? 'Lax'}`);
|
|
37
|
+
const headers = new Headers(response.headers);
|
|
38
|
+
headers.append('set-cookie', parts.join('; '));
|
|
39
|
+
return new Response(response.body, {
|
|
40
|
+
status: response.status,
|
|
41
|
+
statusText: response.statusText,
|
|
42
|
+
headers,
|
|
43
|
+
});
|
|
44
|
+
}
|
package/dist/result.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value, or the HTTP outcome a page answers instead (story 206; in
|
|
3
|
+
* `@crombolo/common` since story 210, so apps and packages share it).
|
|
4
|
+
*
|
|
5
|
+
* The site's own services return a `Result` so data code composes and its
|
|
6
|
+
* failures are typed. A component ends the chain with `unwrap()`, which returns
|
|
7
|
+
* the value or throws the outcome for the renderer to answer.
|
|
8
|
+
* docs/rendering.md, "Server code".
|
|
9
|
+
*/
|
|
10
|
+
/** A redirect a page may answer with. */
|
|
11
|
+
export type RedirectStatus = 301 | 302 | 303 | 307 | 308;
|
|
12
|
+
/** A refusal a page may answer with, rendered by the site's status pages. */
|
|
13
|
+
export type RefusalStatus = 400 | 401 | 403 | 404 | 410 | 422;
|
|
14
|
+
export type OutcomeStatus = RedirectStatus | RefusalStatus;
|
|
15
|
+
/** One input that failed validation. */
|
|
16
|
+
export interface FieldError {
|
|
17
|
+
field: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
/** The name each status goes by, as the helper that answers it. */
|
|
21
|
+
export declare const OUTCOME_NAMES: Readonly<Record<OutcomeStatus, string>>;
|
|
22
|
+
declare const BRAND: unique symbol;
|
|
23
|
+
/**
|
|
24
|
+
* Throws unless a redirect target is safe to put in a `Location` header: a path,
|
|
25
|
+
* or an `http(s)` address. A protocol-relative address, another scheme such as
|
|
26
|
+
* `javascript:`, or anything that would break the header is a template error.
|
|
27
|
+
*/
|
|
28
|
+
export declare function assertSafeLocation(url: string): string;
|
|
29
|
+
/** What every result can do, whichever side it is. */
|
|
30
|
+
interface ResultMethods<T, S extends OutcomeStatus> {
|
|
31
|
+
isOk(): this is Ok<T>;
|
|
32
|
+
isOutcome(): this is Outcome<S>;
|
|
33
|
+
map<U>(f: (value: T) => U): Result<U, S>;
|
|
34
|
+
mapAsync<U>(f: (value: T) => Promise<U>): Promise<Result<U, S>>;
|
|
35
|
+
andThen<U, S2 extends OutcomeStatus = never>(f: (value: T) => Result<U, S2>): Result<U, S | S2>;
|
|
36
|
+
andThenAsync<U, S2 extends OutcomeStatus = never>(f: (value: T) => Promise<Result<U, S2>>): Promise<Result<U, S | S2>>;
|
|
37
|
+
match<R>(handlers: MatchHandlers<T, S, R>): R;
|
|
38
|
+
/** The value, or throws the outcome for the page to answer. */
|
|
39
|
+
unwrap(): T;
|
|
40
|
+
unwrapOr<U>(fallback: U): T | U;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `match`'s handlers: `ok`, and either one per status the result can hold or
|
|
44
|
+
* `outcome` for the rest.
|
|
45
|
+
*/
|
|
46
|
+
export type MatchHandlers<T, S extends OutcomeStatus, R> = {
|
|
47
|
+
ok: (value: T) => R;
|
|
48
|
+
} & (({
|
|
49
|
+
[K in S]: (outcome: Outcome<K>) => R;
|
|
50
|
+
} & {
|
|
51
|
+
outcome?: never;
|
|
52
|
+
}) | ({
|
|
53
|
+
[K in S]?: (outcome: Outcome<K>) => R;
|
|
54
|
+
} & {
|
|
55
|
+
outcome: (outcome: Outcome<S>) => R;
|
|
56
|
+
}));
|
|
57
|
+
/** A value. */
|
|
58
|
+
export declare class Ok<T> implements ResultMethods<T, never> {
|
|
59
|
+
readonly value: T;
|
|
60
|
+
readonly ok: true;
|
|
61
|
+
constructor(value: T);
|
|
62
|
+
isOk(): this is Ok<T>;
|
|
63
|
+
isOutcome(): this is Outcome<never>;
|
|
64
|
+
map<U>(f: (value: T) => U): Ok<U>;
|
|
65
|
+
mapAsync<U>(f: (value: T) => Promise<U>): Promise<Ok<U>>;
|
|
66
|
+
andThen<U, S2 extends OutcomeStatus = never>(f: (value: T) => Result<U, S2>): Result<U, S2>;
|
|
67
|
+
andThenAsync<U, S2 extends OutcomeStatus = never>(f: (value: T) => Promise<Result<U, S2>>): Promise<Result<U, S2>>;
|
|
68
|
+
match<R>(handlers: MatchHandlers<T, never, R>): R;
|
|
69
|
+
unwrap(): T;
|
|
70
|
+
unwrapOr<U>(_fallback: U): T;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* An HTTP outcome in place of a value: a redirect, or a refusal the site's
|
|
74
|
+
* status pages render. Built with `Outcome.notFound()` and its siblings; thrown
|
|
75
|
+
* by `unwrap()` and by `@crombolo/crombolo/navigation`'s functions.
|
|
76
|
+
*/
|
|
77
|
+
export declare class Outcome<S extends OutcomeStatus = OutcomeStatus> implements ResultMethods<never, S> {
|
|
78
|
+
readonly status: S;
|
|
79
|
+
readonly ok: false;
|
|
80
|
+
readonly [BRAND] = true;
|
|
81
|
+
/** The visitor-facing words for this case, shown by the status page. */
|
|
82
|
+
readonly message: string | undefined;
|
|
83
|
+
/** Inputs that failed, for 400 and 422. */
|
|
84
|
+
readonly errors: readonly FieldError[];
|
|
85
|
+
/** Where a redirect sends the visitor. */
|
|
86
|
+
readonly location: string | undefined;
|
|
87
|
+
private constructor();
|
|
88
|
+
/** The helper's name for this status: `notFound`, `foundAt`. */
|
|
89
|
+
get name(): string;
|
|
90
|
+
get isRedirect(): boolean;
|
|
91
|
+
isOk(): this is Ok<never>;
|
|
92
|
+
isOutcome(): this is Outcome<S>;
|
|
93
|
+
map<U>(_f: (value: never) => U): Outcome<S>;
|
|
94
|
+
mapAsync<U>(_f: (value: never) => Promise<U>): Promise<Outcome<S>>;
|
|
95
|
+
andThen<U, S2 extends OutcomeStatus = never>(_f: (value: never) => Result<U, S2>): Outcome<S>;
|
|
96
|
+
andThenAsync<U, S2 extends OutcomeStatus = never>(_f: (value: never) => Promise<Result<U, S2>>): Promise<Outcome<S>>;
|
|
97
|
+
match<R>(handlers: MatchHandlers<never, S, R>): R;
|
|
98
|
+
unwrap(): never;
|
|
99
|
+
unwrapOr<U>(fallback: U): U;
|
|
100
|
+
/** 307: the visitor is sent on, keeping the request's method. */
|
|
101
|
+
static redirect(url: string): Outcome<307>;
|
|
102
|
+
/** 308: the page has moved for good, keeping the request's method. */
|
|
103
|
+
static permanentRedirect(url: string): Outcome<308>;
|
|
104
|
+
/** 302: the everyday redirect, as `umbracoRedirect` answers. */
|
|
105
|
+
static foundAt(url: string): Outcome<302>;
|
|
106
|
+
/** 301: the page has moved for good. */
|
|
107
|
+
static movedPermanentlyTo(url: string): Outcome<301>;
|
|
108
|
+
/** 303: after a POST, look at this address with a GET. */
|
|
109
|
+
static seeOther(url: string): Outcome<303>;
|
|
110
|
+
static badRequest(message?: string, errors?: readonly FieldError[]): Outcome<400>;
|
|
111
|
+
static unauthorized(message?: string): Outcome<401>;
|
|
112
|
+
static forbidden(message?: string): Outcome<403>;
|
|
113
|
+
static notFound(message?: string): Outcome<404>;
|
|
114
|
+
static gone(message?: string): Outcome<410>;
|
|
115
|
+
/** 422: well-formed input that failed validation. */
|
|
116
|
+
static unprocessableContent(errors: readonly FieldError[], message?: string): Outcome<422>;
|
|
117
|
+
}
|
|
118
|
+
export type Result<T, S extends OutcomeStatus = OutcomeStatus> = Ok<T> | Outcome<S>;
|
|
119
|
+
/** A value as a result. */
|
|
120
|
+
export declare function ok<T>(value: T): Ok<T>;
|
|
121
|
+
export declare function isOk<T, S extends OutcomeStatus>(result: Result<T, S>): result is Ok<T>;
|
|
122
|
+
/** Whether a value, or something thrown, is an outcome; true across copies of this module. */
|
|
123
|
+
export declare function isOutcome(value: unknown): value is Outcome;
|
|
124
|
+
type ValuesOf<R extends readonly Result<unknown, OutcomeStatus>[]> = {
|
|
125
|
+
-readonly [K in keyof R]: R[K] extends Result<infer T, OutcomeStatus> ? T : never;
|
|
126
|
+
};
|
|
127
|
+
type StatusesOf<R extends readonly Result<unknown, OutcomeStatus>[]> = R[number] extends Result<unknown, infer S> ? S : never;
|
|
128
|
+
/** Every value, or the first outcome among them. */
|
|
129
|
+
declare function all<const R extends readonly Result<unknown, OutcomeStatus>[]>(results: R): Result<ValuesOf<R>, StatusesOf<R>>;
|
|
130
|
+
export declare const Result: {
|
|
131
|
+
ok: typeof ok;
|
|
132
|
+
all: typeof all;
|
|
133
|
+
isOk: typeof isOk;
|
|
134
|
+
isOutcome: typeof isOutcome;
|
|
135
|
+
};
|
|
136
|
+
export {};
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value, or the HTTP outcome a page answers instead (story 206; in
|
|
3
|
+
* `@crombolo/common` since story 210, so apps and packages share it).
|
|
4
|
+
*
|
|
5
|
+
* The site's own services return a `Result` so data code composes and its
|
|
6
|
+
* failures are typed. A component ends the chain with `unwrap()`, which returns
|
|
7
|
+
* the value or throws the outcome for the renderer to answer.
|
|
8
|
+
* docs/rendering.md, "Server code".
|
|
9
|
+
*/
|
|
10
|
+
/** The name each status goes by, as the helper that answers it. */
|
|
11
|
+
export const OUTCOME_NAMES = {
|
|
12
|
+
301: 'movedPermanentlyTo',
|
|
13
|
+
302: 'foundAt',
|
|
14
|
+
303: 'seeOther',
|
|
15
|
+
307: 'redirect',
|
|
16
|
+
308: 'permanentRedirect',
|
|
17
|
+
400: 'badRequest',
|
|
18
|
+
401: 'unauthorized',
|
|
19
|
+
403: 'forbidden',
|
|
20
|
+
404: 'notFound',
|
|
21
|
+
410: 'gone',
|
|
22
|
+
422: 'unprocessableContent',
|
|
23
|
+
};
|
|
24
|
+
const BRAND = Symbol.for('crombolo.outcome');
|
|
25
|
+
/**
|
|
26
|
+
* Throws unless a redirect target is safe to put in a `Location` header: a path,
|
|
27
|
+
* or an `http(s)` address. A protocol-relative address, another scheme such as
|
|
28
|
+
* `javascript:`, or anything that would break the header is a template error.
|
|
29
|
+
*/
|
|
30
|
+
export function assertSafeLocation(url) {
|
|
31
|
+
if (typeof url !== 'string' || url.trim() === '')
|
|
32
|
+
throw new Error('A redirect needs an address to send the visitor to.');
|
|
33
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it refuses.
|
|
34
|
+
if (/[\u0000-\u001f\u007f]/.test(url))
|
|
35
|
+
throw new Error(`Refused to redirect to ${JSON.stringify(url)}: it would break the header.`);
|
|
36
|
+
const trimmed = url.trim();
|
|
37
|
+
if (/^[/\\]{2}/.test(trimmed) || /^\/\\/.test(trimmed))
|
|
38
|
+
throw new Error(`Refused to redirect to '${url}': an address starting '//' could lead to any site. Write the scheme, or a path.`);
|
|
39
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed)?.[1]?.toLowerCase();
|
|
40
|
+
if (scheme && scheme !== 'http' && scheme !== 'https')
|
|
41
|
+
throw new Error(`Refused to redirect to '${url}': only paths and http(s) addresses are allowed.`);
|
|
42
|
+
return trimmed;
|
|
43
|
+
}
|
|
44
|
+
/** A value. */
|
|
45
|
+
export class Ok {
|
|
46
|
+
value;
|
|
47
|
+
ok = true;
|
|
48
|
+
constructor(value) {
|
|
49
|
+
this.value = value;
|
|
50
|
+
}
|
|
51
|
+
isOk() {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
isOutcome() {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
map(f) {
|
|
58
|
+
return new Ok(f(this.value));
|
|
59
|
+
}
|
|
60
|
+
async mapAsync(f) {
|
|
61
|
+
return new Ok(await f(this.value));
|
|
62
|
+
}
|
|
63
|
+
andThen(f) {
|
|
64
|
+
return f(this.value);
|
|
65
|
+
}
|
|
66
|
+
andThenAsync(f) {
|
|
67
|
+
return f(this.value);
|
|
68
|
+
}
|
|
69
|
+
match(handlers) {
|
|
70
|
+
return handlers.ok(this.value);
|
|
71
|
+
}
|
|
72
|
+
unwrap() {
|
|
73
|
+
return this.value;
|
|
74
|
+
}
|
|
75
|
+
unwrapOr(_fallback) {
|
|
76
|
+
return this.value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* An HTTP outcome in place of a value: a redirect, or a refusal the site's
|
|
81
|
+
* status pages render. Built with `Outcome.notFound()` and its siblings; thrown
|
|
82
|
+
* by `unwrap()` and by `@crombolo/crombolo/navigation`'s functions.
|
|
83
|
+
*/
|
|
84
|
+
export class Outcome {
|
|
85
|
+
status;
|
|
86
|
+
ok = false;
|
|
87
|
+
[BRAND] = true;
|
|
88
|
+
/** The visitor-facing words for this case, shown by the status page. */
|
|
89
|
+
message;
|
|
90
|
+
/** Inputs that failed, for 400 and 422. */
|
|
91
|
+
errors;
|
|
92
|
+
/** Where a redirect sends the visitor. */
|
|
93
|
+
location;
|
|
94
|
+
constructor(status, init = {}) {
|
|
95
|
+
this.status = status;
|
|
96
|
+
this.message = init.message;
|
|
97
|
+
this.errors = init.errors ?? [];
|
|
98
|
+
this.location = init.location;
|
|
99
|
+
}
|
|
100
|
+
/** The helper's name for this status: `notFound`, `foundAt`. */
|
|
101
|
+
get name() {
|
|
102
|
+
return OUTCOME_NAMES[this.status];
|
|
103
|
+
}
|
|
104
|
+
get isRedirect() {
|
|
105
|
+
return this.status < 400;
|
|
106
|
+
}
|
|
107
|
+
isOk() {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
isOutcome() {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
map(_f) {
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
async mapAsync(_f) {
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
andThen(_f) {
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
async andThenAsync(_f) {
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
match(handlers) {
|
|
126
|
+
const own = handlers[this.status];
|
|
127
|
+
if (own)
|
|
128
|
+
return own(this);
|
|
129
|
+
return handlers.outcome(this);
|
|
130
|
+
}
|
|
131
|
+
unwrap() {
|
|
132
|
+
throw this;
|
|
133
|
+
}
|
|
134
|
+
unwrapOr(fallback) {
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
/** 307: the visitor is sent on, keeping the request's method. */
|
|
138
|
+
static redirect(url) {
|
|
139
|
+
return new Outcome(307, { location: assertSafeLocation(url) });
|
|
140
|
+
}
|
|
141
|
+
/** 308: the page has moved for good, keeping the request's method. */
|
|
142
|
+
static permanentRedirect(url) {
|
|
143
|
+
return new Outcome(308, { location: assertSafeLocation(url) });
|
|
144
|
+
}
|
|
145
|
+
/** 302: the everyday redirect, as `umbracoRedirect` answers. */
|
|
146
|
+
static foundAt(url) {
|
|
147
|
+
return new Outcome(302, { location: assertSafeLocation(url) });
|
|
148
|
+
}
|
|
149
|
+
/** 301: the page has moved for good. */
|
|
150
|
+
static movedPermanentlyTo(url) {
|
|
151
|
+
return new Outcome(301, { location: assertSafeLocation(url) });
|
|
152
|
+
}
|
|
153
|
+
/** 303: after a POST, look at this address with a GET. */
|
|
154
|
+
static seeOther(url) {
|
|
155
|
+
return new Outcome(303, { location: assertSafeLocation(url) });
|
|
156
|
+
}
|
|
157
|
+
static badRequest(message, errors) {
|
|
158
|
+
return new Outcome(400, { message, errors });
|
|
159
|
+
}
|
|
160
|
+
static unauthorized(message) {
|
|
161
|
+
return new Outcome(401, { message });
|
|
162
|
+
}
|
|
163
|
+
static forbidden(message) {
|
|
164
|
+
return new Outcome(403, { message });
|
|
165
|
+
}
|
|
166
|
+
static notFound(message) {
|
|
167
|
+
return new Outcome(404, { message });
|
|
168
|
+
}
|
|
169
|
+
static gone(message) {
|
|
170
|
+
return new Outcome(410, { message });
|
|
171
|
+
}
|
|
172
|
+
/** 422: well-formed input that failed validation. */
|
|
173
|
+
static unprocessableContent(errors, message) {
|
|
174
|
+
return new Outcome(422, { message, errors });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** A value as a result. */
|
|
178
|
+
export function ok(value) {
|
|
179
|
+
return new Ok(value);
|
|
180
|
+
}
|
|
181
|
+
export function isOk(result) {
|
|
182
|
+
return result instanceof Ok;
|
|
183
|
+
}
|
|
184
|
+
/** Whether a value, or something thrown, is an outcome; true across copies of this module. */
|
|
185
|
+
export function isOutcome(value) {
|
|
186
|
+
return (typeof value === 'object' &&
|
|
187
|
+
value !== null &&
|
|
188
|
+
value[BRAND] === true);
|
|
189
|
+
}
|
|
190
|
+
/** Every value, or the first outcome among them. */
|
|
191
|
+
function all(results) {
|
|
192
|
+
const values = [];
|
|
193
|
+
for (const result of results) {
|
|
194
|
+
if (!result.ok)
|
|
195
|
+
return result;
|
|
196
|
+
values.push(result.value);
|
|
197
|
+
}
|
|
198
|
+
return new Ok(values);
|
|
199
|
+
}
|
|
200
|
+
export const Result = { ok, all, isOk, isOutcome };
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route handler contract (story 211): a site's own endpoints, and a server
|
|
3
|
+
* bundle's, at the paths they declare. Plain web `Request` and `Response`;
|
|
4
|
+
* a handler may also return a `Result`, whose value is sent as JSON and whose
|
|
5
|
+
* outcome answers its status. docs/rendering.md, "Route handlers".
|
|
6
|
+
*/
|
|
7
|
+
import type { CookieOptions, ReadonlyRequestCookies } from './middleware.ts';
|
|
8
|
+
import type { Result } from './result.ts';
|
|
9
|
+
export type RouteMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
10
|
+
/** The methods a route may declare a handler for; `HEAD` comes from `GET` when not given. */
|
|
11
|
+
export declare const ROUTE_METHODS: readonly RouteMethod[];
|
|
12
|
+
/** What a handler is given besides the request. */
|
|
13
|
+
export interface RouteContext<Site = unknown, Member = unknown> {
|
|
14
|
+
/** The path's `:name` segments. */
|
|
15
|
+
params: Record<string, string>;
|
|
16
|
+
url: URL;
|
|
17
|
+
/** The request's cookies; reading them is fine, the answer is private by default. */
|
|
18
|
+
cookies: ReadonlyRequestCookies;
|
|
19
|
+
/** Sets a cookie on the answer. */
|
|
20
|
+
setCookie(name: string, value: string, options?: CookieOptions): void;
|
|
21
|
+
/** The site's services acting as the caller (story 206). */
|
|
22
|
+
site(): Site;
|
|
23
|
+
/** The signed-in member, by session cookie or bearer token; looked up when asked. */
|
|
24
|
+
member(): Promise<Member | undefined>;
|
|
25
|
+
/** Aborts when the route's time limit runs out, or the caller leaves. */
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
/** One method's handler: a `Response`, or a `Result` (a value as JSON, an outcome as its status). */
|
|
29
|
+
export type RouteHandler<Site = unknown, Member = unknown, Extra = unknown> = (request: Request, context: RouteContext<Site, Member> & Extra) => Response | Result<unknown> | Promise<Response | Result<unknown>>;
|
|
30
|
+
/** A route's limits and checks. */
|
|
31
|
+
export interface RouteOptions {
|
|
32
|
+
/**
|
|
33
|
+
* A cookie-authenticated `POST`, `PUT`, `PATCH` or `DELETE` must come from a
|
|
34
|
+
* page of this site, or carry the member's anti-forgery token. On by default;
|
|
35
|
+
* `false` for a webhook receiver. Bearer-token calls are never checked.
|
|
36
|
+
*/
|
|
37
|
+
csrf?: boolean;
|
|
38
|
+
/** Its own limit, in place of the site's default; `false` for none. */
|
|
39
|
+
rateLimit?: {
|
|
40
|
+
perMinute: number;
|
|
41
|
+
by?: 'member' | 'caller';
|
|
42
|
+
} | false;
|
|
43
|
+
/**
|
|
44
|
+
* Answers pages on the Delivery API's allowed origins
|
|
45
|
+
* (`deliveryApi.cors.allowedOrigins`) with CORS headers, pre-flights included.
|
|
46
|
+
* Off by default: only this site's own pages may read the answer.
|
|
47
|
+
*/
|
|
48
|
+
cors?: boolean;
|
|
49
|
+
/** Only for a signed-in member, or a member of one of these groups: 401 or 403 otherwise. */
|
|
50
|
+
members?: true | {
|
|
51
|
+
groups: readonly string[];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Where it answers. `site`, the default, at its path on the site; `backoffice`
|
|
55
|
+
* only for a bundle, below its back-office namespace, for users holding the
|
|
56
|
+
* bundle's section.
|
|
57
|
+
*/
|
|
58
|
+
audience?: 'site' | 'backoffice';
|
|
59
|
+
}
|
|
60
|
+
/** A route as declared: a path, a handler per method, and its options. */
|
|
61
|
+
export interface SiteRoute<Site = unknown, Member = unknown, Extra = unknown> {
|
|
62
|
+
path: string;
|
|
63
|
+
handlers: Partial<Record<RouteMethod, RouteHandler<Site, Member, Extra>>>;
|
|
64
|
+
options: RouteOptions;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A route: `route('/api/records/:species', { GET: list, POST: add })`. A site
|
|
68
|
+
* route's path starts with `/`; a bundle's back-office route's is relative to
|
|
69
|
+
* its namespace.
|
|
70
|
+
*/
|
|
71
|
+
export declare function route<Site = unknown, Member = unknown, Extra = unknown>(path: string, handlers: Partial<Record<RouteMethod, RouteHandler<Site, Member, Extra>>>, options?: RouteOptions): SiteRoute<Site, Member, Extra>;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The methods a route may declare a handler for; `HEAD` comes from `GET` when not given. */
|
|
2
|
+
export const ROUTE_METHODS = [
|
|
3
|
+
'GET',
|
|
4
|
+
'HEAD',
|
|
5
|
+
'POST',
|
|
6
|
+
'PUT',
|
|
7
|
+
'PATCH',
|
|
8
|
+
'DELETE',
|
|
9
|
+
'OPTIONS',
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* A route: `route('/api/records/:species', { GET: list, POST: add })`. A site
|
|
13
|
+
* route's path starts with `/`; a bundle's back-office route's is relative to
|
|
14
|
+
* its namespace.
|
|
15
|
+
*/
|
|
16
|
+
export function route(path, handlers, options = {}) {
|
|
17
|
+
const unknown = Object.keys(handlers).filter((method) => !ROUTE_METHODS.includes(method));
|
|
18
|
+
if (unknown.length > 0)
|
|
19
|
+
throw new Error(`The route ${path} names ${unknown.join(', ')}, which is not an HTTP method it can answer.`);
|
|
20
|
+
return { path, handlers, options };
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/common",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"description": "The contracts code outside crombolo is written against: the Result a site's services return, and the middleware chain's contract. No dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/common"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"crombolo",
|
|
18
|
+
"result",
|
|
19
|
+
"middleware"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.0"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"NOTICE"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/README.md
DELETED