@ketvietlab/ketjs 0.1.2 → 0.1.5
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 +1 -1
- package/README.md +8 -2
- package/dist/agent/capabilities.d.ts +1 -3
- package/dist/agent/capabilities.d.ts.map +1 -1
- package/dist/agent/capabilities.js +10 -1
- package/dist/agent/capabilities.js.map +1 -1
- package/dist/agent/permissions.d.ts +43 -1
- package/dist/agent/permissions.d.ts.map +1 -1
- package/dist/agent/permissions.js +50 -2
- package/dist/agent/permissions.js.map +1 -1
- package/dist/cli.js +246 -97
- package/dist/cli.js.map +1 -1
- package/dist/codegen/dts.d.ts.map +1 -1
- package/dist/codegen/dts.js +1 -0
- package/dist/codegen/dts.js.map +1 -1
- package/dist/data/changeset.d.ts +12 -0
- package/dist/data/changeset.d.ts.map +1 -1
- package/dist/data/changeset.js +73 -17
- package/dist/data/changeset.js.map +1 -1
- package/dist/data/expr.d.ts +25 -1
- package/dist/data/expr.d.ts.map +1 -1
- package/dist/data/expr.js +47 -6
- package/dist/data/expr.js.map +1 -1
- package/dist/data/fleet.d.ts +41 -1
- package/dist/data/fleet.d.ts.map +1 -1
- package/dist/data/fleet.js +100 -11
- package/dist/data/fleet.js.map +1 -1
- package/dist/data/migrate.d.ts +20 -0
- package/dist/data/migrate.d.ts.map +1 -1
- package/dist/data/migrate.js +86 -3
- package/dist/data/migrate.js.map +1 -1
- package/dist/data/physical.d.ts +5 -0
- package/dist/data/physical.d.ts.map +1 -0
- package/dist/data/physical.js +196 -0
- package/dist/data/physical.js.map +1 -0
- package/dist/data/pool.d.ts +2 -1
- package/dist/data/pool.d.ts.map +1 -1
- package/dist/data/pool.js +159 -31
- package/dist/data/pool.js.map +1 -1
- package/dist/data/query.d.ts +10 -1
- package/dist/data/query.d.ts.map +1 -1
- package/dist/data/query.js +136 -27
- package/dist/data/query.js.map +1 -1
- package/dist/data/sqlite.d.ts.map +1 -1
- package/dist/data/sqlite.js +350 -52
- package/dist/data/sqlite.js.map +1 -1
- package/dist/data/time.d.ts +13 -0
- package/dist/data/time.d.ts.map +1 -1
- package/dist/data/time.js +61 -17
- package/dist/data/time.js.map +1 -1
- package/dist/index.d.ts +45 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -18
- package/dist/index.js.map +1 -1
- package/dist/kernel/audit.d.ts +27 -0
- package/dist/kernel/audit.d.ts.map +1 -0
- package/dist/kernel/audit.js +67 -0
- package/dist/kernel/audit.js.map +1 -0
- package/dist/kernel/classification.d.ts +26 -0
- package/dist/kernel/classification.d.ts.map +1 -0
- package/dist/kernel/classification.js +73 -0
- package/dist/kernel/classification.js.map +1 -0
- package/dist/kernel/compose.d.ts +6 -2
- package/dist/kernel/compose.d.ts.map +1 -1
- package/dist/kernel/compose.js +269 -15
- package/dist/kernel/compose.js.map +1 -1
- package/dist/kernel/define.d.ts +2 -3
- package/dist/kernel/define.d.ts.map +1 -1
- package/dist/kernel/define.js +22 -12
- package/dist/kernel/define.js.map +1 -1
- package/dist/kernel/diff.d.ts.map +1 -1
- package/dist/kernel/diff.js +28 -4
- package/dist/kernel/diff.js.map +1 -1
- package/dist/kernel/graph.js +1 -1
- package/dist/kernel/graph.js.map +1 -1
- package/dist/kernel/i18n.d.ts +10 -1
- package/dist/kernel/i18n.d.ts.map +1 -1
- package/dist/kernel/i18n.js +34 -1
- package/dist/kernel/i18n.js.map +1 -1
- package/dist/kernel/layout.d.ts +97 -0
- package/dist/kernel/layout.d.ts.map +1 -1
- package/dist/kernel/layout.js +294 -12
- package/dist/kernel/layout.js.map +1 -1
- package/dist/kernel/menu.d.ts +33 -2
- package/dist/kernel/menu.d.ts.map +1 -1
- package/dist/kernel/menu.js +99 -7
- package/dist/kernel/menu.js.map +1 -1
- package/dist/kernel/modules.d.ts +3 -3
- package/dist/kernel/modules.d.ts.map +1 -1
- package/dist/kernel/modules.js +21 -21
- package/dist/kernel/modules.js.map +1 -1
- package/dist/kernel/permissions.d.ts +10 -0
- package/dist/kernel/permissions.d.ts.map +1 -0
- package/dist/kernel/permissions.js +365 -0
- package/dist/kernel/permissions.js.map +1 -0
- package/dist/kernel/schedule.d.ts +23 -0
- package/dist/kernel/schedule.d.ts.map +1 -0
- package/dist/kernel/schedule.js +83 -0
- package/dist/kernel/schedule.js.map +1 -0
- package/dist/kernel/types.d.ts.map +1 -1
- package/dist/kernel/types.js +5 -4
- package/dist/kernel/types.js.map +1 -1
- package/dist/kernel/workspace.d.ts +92 -14
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +128 -49
- package/dist/kernel/workspace.js.map +1 -1
- package/dist/pdf/assets/Inter-Bold.ttf +0 -0
- package/dist/pdf/assets/Inter-Regular.ttf +0 -0
- package/dist/pdf/assets/Inter-SemiBold.ttf +0 -0
- package/dist/pdf/assets/LICENSE.txt +92 -0
- package/dist/pdf/font.d.ts +10 -0
- package/dist/pdf/font.d.ts.map +1 -0
- package/dist/pdf/font.js +83 -0
- package/dist/pdf/font.js.map +1 -0
- package/dist/pdf/image.d.ts +8 -0
- package/dist/pdf/image.d.ts.map +1 -0
- package/dist/pdf/image.js +94 -0
- package/dist/pdf/image.js.map +1 -0
- package/dist/pdf/index.d.ts +15 -0
- package/dist/pdf/index.d.ts.map +1 -0
- package/dist/pdf/index.js +17 -0
- package/dist/pdf/index.js.map +1 -0
- package/dist/pdf/markup.d.ts +19 -0
- package/dist/pdf/markup.d.ts.map +1 -0
- package/dist/pdf/markup.js +146 -0
- package/dist/pdf/markup.js.map +1 -0
- package/dist/pdf/render.d.ts +14 -0
- package/dist/pdf/render.d.ts.map +1 -0
- package/dist/pdf/render.js +387 -0
- package/dist/pdf/render.js.map +1 -0
- package/dist/scaffold/index.js +6 -6
- package/dist/scaffold/index.js.map +1 -1
- package/dist/scaffold/templates/{app.test.ts.tmpl → deployment.test.ts.tmpl} +3 -3
- package/dist/scaffold/templates/ket.workspace.ts.tmpl +4 -6
- package/dist/scaffold/templates/module.ts.tmpl +1 -4
- package/dist/server/assets.d.ts +23 -0
- package/dist/server/assets.d.ts.map +1 -0
- package/dist/server/assets.js +97 -0
- package/dist/server/assets.js.map +1 -0
- package/dist/server/boot.d.ts +174 -38
- package/dist/server/boot.d.ts.map +1 -1
- package/dist/server/boot.js +399 -140
- package/dist/server/boot.js.map +1 -1
- package/dist/server/config.d.ts +30 -10
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +73 -17
- package/dist/server/config.js.map +1 -1
- package/dist/server/ctx.d.ts +10 -0
- package/dist/server/ctx.d.ts.map +1 -1
- package/dist/server/ctx.js +284 -48
- package/dist/server/ctx.js.map +1 -1
- package/dist/server/development.d.ts +10 -0
- package/dist/server/development.d.ts.map +1 -0
- package/dist/server/development.js +15 -0
- package/dist/server/development.js.map +1 -0
- package/dist/server/fn.d.ts +22 -3
- package/dist/server/fn.d.ts.map +1 -1
- package/dist/server/fn.js +116 -32
- package/dist/server/fn.js.map +1 -1
- package/dist/server/form.d.ts +33 -0
- package/dist/server/form.d.ts.map +1 -0
- package/dist/server/form.js +47 -0
- package/dist/server/form.js.map +1 -0
- package/dist/server/http.d.ts +82 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +373 -37
- package/dist/server/http.js.map +1 -1
- package/dist/server/idem.d.ts +3 -2
- package/dist/server/idem.d.ts.map +1 -1
- package/dist/server/idem.js +12 -4
- package/dist/server/idem.js.map +1 -1
- package/dist/server/jobs.js +1 -1
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/log/combinators.d.ts +42 -0
- package/dist/server/log/combinators.d.ts.map +1 -0
- package/dist/server/log/combinators.js +270 -0
- package/dist/server/log/combinators.js.map +1 -0
- package/dist/server/log/console.d.ts +12 -0
- package/dist/server/log/console.d.ts.map +1 -0
- package/dist/server/log/console.js +96 -0
- package/dist/server/log/console.js.map +1 -0
- package/dist/server/log/file.d.ts +11 -0
- package/dist/server/log/file.d.ts.map +1 -0
- package/dist/server/log/file.js +61 -0
- package/dist/server/log/file.js.map +1 -0
- package/dist/server/log/index.d.ts +23 -0
- package/dist/server/log/index.d.ts.map +1 -0
- package/dist/server/log/index.js +45 -0
- package/dist/server/log/index.js.map +1 -0
- package/dist/server/log/logger.d.ts +53 -0
- package/dist/server/log/logger.d.ts.map +1 -0
- package/dist/server/log/logger.js +94 -0
- package/dist/server/log/logger.js.map +1 -0
- package/dist/server/log/memory.d.ts +11 -0
- package/dist/server/log/memory.d.ts.map +1 -0
- package/dist/server/log/memory.js +25 -0
- package/dist/server/log/memory.js.map +1 -0
- package/dist/server/log/types.d.ts +112 -0
- package/dist/server/log/types.d.ts.map +1 -0
- package/dist/server/log/types.js +58 -0
- package/dist/server/log/types.js.map +1 -0
- package/dist/server/policy.d.ts +29 -0
- package/dist/server/policy.d.ts.map +1 -0
- package/dist/server/policy.js +29 -0
- package/dist/server/policy.js.map +1 -0
- package/dist/server/queue.d.ts +1 -1
- package/dist/server/queue.d.ts.map +1 -1
- package/dist/server/queue.js.map +1 -1
- package/dist/server/ratelimit.d.ts +48 -0
- package/dist/server/ratelimit.d.ts.map +1 -0
- package/dist/server/ratelimit.js +102 -0
- package/dist/server/ratelimit.js.map +1 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime.d.ts +18 -3
- package/dist/server/runtime.d.ts.map +1 -1
- package/dist/server/runtime.js +32 -2
- package/dist/server/runtime.js.map +1 -1
- package/dist/server/schedule.d.ts +18 -0
- package/dist/server/schedule.d.ts.map +1 -0
- package/dist/server/schedule.js +70 -0
- package/dist/server/schedule.js.map +1 -0
- package/dist/server/sequence.d.ts +42 -0
- package/dist/server/sequence.d.ts.map +1 -0
- package/dist/server/sequence.js +97 -0
- package/dist/server/sequence.js.map +1 -0
- package/dist/server/session.d.ts +21 -0
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +84 -10
- package/dist/server/session.js.map +1 -1
- package/dist/server/sessionstore.d.ts +3 -1
- package/dist/server/sessionstore.d.ts.map +1 -1
- package/dist/server/sessionstore.js +7 -3
- package/dist/server/sessionstore.js.map +1 -1
- package/dist/server/storage/index.d.ts +1 -1
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +61 -1
- package/dist/server/storage/index.js.map +1 -1
- package/dist/server/storage/types.d.ts +8 -0
- package/dist/server/storage/types.d.ts.map +1 -1
- package/dist/server/storage/types.js +58 -0
- package/dist/server/storage/types.js.map +1 -1
- package/dist/server/stream.d.ts +8 -2
- package/dist/server/stream.d.ts.map +1 -1
- package/dist/server/stream.js +44 -4
- package/dist/server/stream.js.map +1 -1
- package/dist/server/streamstore.d.ts +10 -0
- package/dist/server/streamstore.d.ts.map +1 -1
- package/dist/server/streamstore.js +72 -6
- package/dist/server/streamstore.js.map +1 -1
- package/dist/server/tenants.d.ts +20 -18
- package/dist/server/tenants.d.ts.map +1 -1
- package/dist/server/tenants.js +133 -84
- package/dist/server/tenants.js.map +1 -1
- package/dist/server/transport/types.js +2 -2
- package/dist/server/transport/types.js.map +1 -1
- package/dist/server/worker.d.ts +19 -3
- package/dist/server/worker.d.ts.map +1 -1
- package/dist/server/worker.js +168 -21
- package/dist/server/worker.js.map +1 -1
- package/dist/testing.d.ts +26 -10
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +41 -14
- package/dist/testing.js.map +1 -1
- package/dist/theme/contracts.d.ts +14 -1
- package/dist/theme/contracts.d.ts.map +1 -1
- package/dist/theme/contracts.js +35 -6
- package/dist/theme/contracts.js.map +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/joint-runtime.d.ts +33 -0
- package/dist/theme/joint-runtime.d.ts.map +1 -0
- package/dist/theme/joint-runtime.js +121 -0
- package/dist/theme/joint-runtime.js.map +1 -0
- package/dist/theme/joints.d.ts.map +1 -1
- package/dist/theme/joints.js +15 -97
- package/dist/theme/joints.js.map +1 -1
- package/dist/theme/ktl/compile.d.ts +4 -0
- package/dist/theme/ktl/compile.d.ts.map +1 -1
- package/dist/theme/ktl/compile.js +54 -1
- package/dist/theme/ktl/compile.js.map +1 -1
- package/dist/theme/ktl/parser.d.ts +4 -0
- package/dist/theme/ktl/parser.d.ts.map +1 -1
- package/dist/theme/ktl/parser.js +8 -0
- package/dist/theme/ktl/parser.js.map +1 -1
- package/dist/theme/render.d.ts +8 -0
- package/dist/theme/render.d.ts.map +1 -1
- package/dist/theme/render.js +88 -118
- package/dist/theme/render.js.map +1 -1
- package/dist/theme/tokens.d.ts +18 -0
- package/dist/theme/tokens.d.ts.map +1 -1
- package/dist/theme/tokens.js +33 -0
- package/dist/theme/tokens.js.map +1 -1
- package/dist/theme/viewmodel.d.ts +24 -0
- package/dist/theme/viewmodel.d.ts.map +1 -1
- package/dist/theme/viewmodel.js +75 -6
- package/dist/theme/viewmodel.js.map +1 -1
- package/dist/types.d.ts +320 -56
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
- package/dist/kernel/apps.d.ts +0 -43
- package/dist/kernel/apps.d.ts.map +0 -1
- package/dist/kernel/apps.js +0 -234
- package/dist/kernel/apps.js.map +0 -1
package/dist/server/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// One server function, three surfaces: an HTTP endpoint, the typed client that
|
|
2
2
|
// calls it, and an agent tool descriptor — all read off the same manifest entry.
|
|
3
|
+
import { isVersioned } from './assets.js';
|
|
3
4
|
import { createServer } from 'node:http';
|
|
4
5
|
import { pipeline } from 'node:stream/promises';
|
|
5
6
|
import { isNavigationRequest, navigablePage } from './respond.js';
|
|
@@ -7,12 +8,65 @@ import { readFile } from 'node:fs/promises';
|
|
|
7
8
|
import { join, normalize, extname } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { callFn } from './fn.js';
|
|
10
|
-
import {
|
|
11
|
+
import { partitionTokens, tokensToCss } from '../theme/tokens.js';
|
|
12
|
+
import { createStreams, memoryStreamStore, streamsOf } from './stream.js';
|
|
11
13
|
import { agentDescriptor } from '../agent/capabilities.js';
|
|
12
14
|
import { KetError } from '../kernel/errors.js';
|
|
15
|
+
import { FormValidationError } from './form.js';
|
|
13
16
|
import { KetError as KetErr } from '../kernel/errors.js';
|
|
14
17
|
import { compileRoutes } from '../kernel/routes.js';
|
|
18
|
+
import { claimRateSlot } from './ratelimit.js';
|
|
15
19
|
import { html, trustedMarkup } from '@ketvietlab/ketjs-view';
|
|
20
|
+
/**
|
|
21
|
+
* What an error means over HTTP.
|
|
22
|
+
*
|
|
23
|
+
* Every KetError used to answer 400, which said "you sent something wrong" to a
|
|
24
|
+
* person whose request was fine and whose permissions were not. A monitor cannot
|
|
25
|
+
* tell a missing grant from a malformed body, and neither can a client.
|
|
26
|
+
*/
|
|
27
|
+
export const statusForError = (code) => {
|
|
28
|
+
if (code === 'E_FN_NOT_PERMITTED')
|
|
29
|
+
return 403;
|
|
30
|
+
if (code === 'E_NOT_FOUND' || code === 'E_UNKNOWN_FUNCTION')
|
|
31
|
+
return 404;
|
|
32
|
+
if (code === 'E_PAYLOAD_TOO_LARGE')
|
|
33
|
+
return 413;
|
|
34
|
+
return 400;
|
|
35
|
+
};
|
|
36
|
+
/** True when this request is a browser navigating, rather than a client calling. */
|
|
37
|
+
export const wantsHtml = (req) => {
|
|
38
|
+
const accept = String(req.headers.accept ?? '');
|
|
39
|
+
if (!accept.includes('text/html'))
|
|
40
|
+
return false;
|
|
41
|
+
// A fragment request is answered by the caller's own error handling; replacing
|
|
42
|
+
// a slot with a full document would paint a page inside a page.
|
|
43
|
+
return String(req.headers['x-ket-navigation'] ?? '') === '';
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A page that is not for the public.
|
|
47
|
+
*
|
|
48
|
+
* `no-store` rather than `private`: the reader's own browser cache is a place
|
|
49
|
+
* the draft outlives the link. `no-referrer` because the token is in the URL,
|
|
50
|
+
* and without it the first outbound click hands it to a third party.
|
|
51
|
+
*/
|
|
52
|
+
const PRIVATE_PAGE_HEADERS = {
|
|
53
|
+
'cache-control': 'no-store, max-age=0',
|
|
54
|
+
'x-robots-tag': 'noindex, nofollow, noarchive',
|
|
55
|
+
'referrer-policy': 'no-referrer',
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The file types a module may publish, and what they are served as.
|
|
59
|
+
*
|
|
60
|
+
* This doubles as the list of what gets served at all, so it has to cover what
|
|
61
|
+
* a browser actually loads from an asset directory rather than only what this
|
|
62
|
+
* repo happens to ship today. It was briefly the shorter list, which 404'd
|
|
63
|
+
* `.jpeg`, `.gif`, `.webp` and `.woff` — all of which had worked, because an
|
|
64
|
+
* unknown type used to fall through to `application/octet-stream` and browsers
|
|
65
|
+
* sniff images successfully.
|
|
66
|
+
*
|
|
67
|
+
* What stays out is source and anything that carries it: `.ts`, `.tsx`, and
|
|
68
|
+
* `.map` — a minified bundle's sourcemap inlines the whole original file.
|
|
69
|
+
*/
|
|
16
70
|
const ASSET_MIME = {
|
|
17
71
|
'.css': 'text/css',
|
|
18
72
|
'.js': 'text/javascript',
|
|
@@ -20,9 +74,25 @@ const ASSET_MIME = {
|
|
|
20
74
|
'.svg': 'image/svg+xml',
|
|
21
75
|
'.png': 'image/png',
|
|
22
76
|
'.jpg': 'image/jpeg',
|
|
77
|
+
'.jpeg': 'image/jpeg',
|
|
78
|
+
'.gif': 'image/gif',
|
|
79
|
+
'.webp': 'image/webp',
|
|
80
|
+
'.avif': 'image/avif',
|
|
81
|
+
'.woff': 'font/woff',
|
|
23
82
|
'.woff2': 'font/woff2',
|
|
83
|
+
'.ttf': 'font/ttf',
|
|
84
|
+
'.otf': 'font/otf',
|
|
24
85
|
'.ico': 'image/x-icon',
|
|
86
|
+
'.webmanifest': 'application/manifest+json',
|
|
87
|
+
'.pdf': 'application/pdf',
|
|
88
|
+
'.mp4': 'video/mp4',
|
|
89
|
+
'.webm': 'video/webm',
|
|
25
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Extensions are compared in lower case, because a file called `logo.PNG` is
|
|
93
|
+
* a PNG. `extname` reports the case it finds, so the table lookup missed it.
|
|
94
|
+
*/
|
|
95
|
+
const assetType = (rel) => ASSET_MIME[extname(rel).toLowerCase()];
|
|
26
96
|
const json = (res, status, body) => {
|
|
27
97
|
const s = JSON.stringify(body, null, 2);
|
|
28
98
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -40,6 +110,26 @@ const contentType = (type) => {
|
|
|
40
110
|
};
|
|
41
111
|
const islandScript = '<script type="module" src="/_ket/islands.js"></script>';
|
|
42
112
|
const viewRuntimeUrl = '/_ket/view/index.js';
|
|
113
|
+
const TOKENS_PATH = '/_ket/tokens.css';
|
|
114
|
+
const tokensLink = `<link rel="stylesheet" href="${TOKENS_PATH}">`;
|
|
115
|
+
/**
|
|
116
|
+
* A theme's declared tokens, linked into the document it renders.
|
|
117
|
+
*
|
|
118
|
+
* `tokens` had been a declaration with no consumer: every theme wrote one, the
|
|
119
|
+
* manifest composed them, `tokensToCss` turned them into `--ket-*` custom
|
|
120
|
+
* properties inside the `ket.theme` layer — and nothing ever put the result on a
|
|
121
|
+
* page, so themes hard-coded a second palette in their own stylesheet instead. The
|
|
122
|
+
* framework owns the cascade order it publishes, which is why it links this rather
|
|
123
|
+
* than asking every theme to.
|
|
124
|
+
*/
|
|
125
|
+
const withThemeTokens = (body, css) => {
|
|
126
|
+
if (!css || body.includes(TOKENS_PATH))
|
|
127
|
+
return body;
|
|
128
|
+
const closingHead = body.indexOf('</head>');
|
|
129
|
+
return closingHead < 0
|
|
130
|
+
? tokensLink + body
|
|
131
|
+
: body.slice(0, closingHead) + tokensLink + body.slice(closingHead);
|
|
132
|
+
};
|
|
43
133
|
const bootstrapDocument = (body) => {
|
|
44
134
|
if ((!body.includes('<ket-island') && !body.includes('data-ket-slot=')) ||
|
|
45
135
|
body.includes('src="/_ket/islands.js"'))
|
|
@@ -242,13 +332,88 @@ const send = async (res, result) => {
|
|
|
242
332
|
// 'close' — which hung the handler and leaked one fd per cancelled download.
|
|
243
333
|
await pipeline(result.body, res);
|
|
244
334
|
};
|
|
245
|
-
const
|
|
335
|
+
const requestUrl = (req) => {
|
|
336
|
+
const target = req.url ?? '/';
|
|
337
|
+
const host = req.headers.host ?? 'localhost';
|
|
338
|
+
const invalid = () => new KetError({
|
|
339
|
+
code: 'E_INVALID_REQUEST_URL',
|
|
340
|
+
message: 'request target or Host header is invalid',
|
|
341
|
+
});
|
|
342
|
+
// KetJS is an origin server, not a forward proxy. Absolute-form and
|
|
343
|
+
// scheme-relative targets can replace the Host-derived authority in WHATWG URL
|
|
344
|
+
// parsing, and a backslash is treated like a slash for special schemes.
|
|
345
|
+
if (!target.startsWith('/') || target.startsWith('//') || target.includes('\\'))
|
|
346
|
+
throw invalid();
|
|
347
|
+
// Parse only an authority. Userinfo and path/query delimiters are all valid to
|
|
348
|
+
// URL(), but none are valid in the Host contract and each can change which
|
|
349
|
+
// tenant a resolver sees.
|
|
350
|
+
const forbiddenHostCharacter = [...host].some((character) => {
|
|
351
|
+
const code = character.charCodeAt(0);
|
|
352
|
+
return (character.trim() === '' ||
|
|
353
|
+
code <= 0x1f ||
|
|
354
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
355
|
+
'@/\\?#%'.includes(character));
|
|
356
|
+
});
|
|
357
|
+
if (!host || forbiddenHostCharacter)
|
|
358
|
+
throw invalid();
|
|
359
|
+
try {
|
|
360
|
+
const base = new URL(`http://${host}`);
|
|
361
|
+
if (base.username || base.password || base.pathname !== '/' || base.search || base.hash)
|
|
362
|
+
throw invalid();
|
|
363
|
+
const url = new URL(target, base);
|
|
364
|
+
if (url.origin !== base.origin)
|
|
365
|
+
throw invalid();
|
|
366
|
+
return url;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
throw invalid();
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const decodeRequestPath = (value) => {
|
|
373
|
+
try {
|
|
374
|
+
return decodeURIComponent(value);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
throw new KetError({
|
|
378
|
+
code: 'E_ROUTE_ENCODING',
|
|
379
|
+
message: 'request path contains invalid percent encoding',
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const readBody = async (req, maxBytes) => {
|
|
384
|
+
const declared = Number(req.headers['content-length']);
|
|
385
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
386
|
+
throw new KetError({
|
|
387
|
+
code: 'E_PAYLOAD_TOO_LARGE',
|
|
388
|
+
message: `JSON body exceeds ${maxBytes} bytes`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
246
391
|
const chunks = [];
|
|
247
|
-
|
|
248
|
-
|
|
392
|
+
let total = 0;
|
|
393
|
+
for await (const held of req) {
|
|
394
|
+
const chunk = Buffer.isBuffer(held) ? held : Buffer.from(held);
|
|
395
|
+
total += chunk.byteLength;
|
|
396
|
+
if (total > maxBytes) {
|
|
397
|
+
throw new KetError({
|
|
398
|
+
code: 'E_PAYLOAD_TOO_LARGE',
|
|
399
|
+
message: `JSON body exceeds ${maxBytes} bytes`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
chunks.push(chunk);
|
|
403
|
+
}
|
|
249
404
|
if (!chunks.length)
|
|
250
405
|
return {};
|
|
251
|
-
|
|
406
|
+
let value;
|
|
407
|
+
try {
|
|
408
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
throw new KetError({ code: 'E_INVALID_JSON_BODY', message: 'request body must be valid JSON' });
|
|
412
|
+
}
|
|
413
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
414
|
+
throw new KetError({ code: 'E_INVALID_JSON_BODY', message: 'request body must be a JSON object' });
|
|
415
|
+
}
|
|
416
|
+
return value;
|
|
252
417
|
};
|
|
253
418
|
export async function createKetServer(o) {
|
|
254
419
|
if (!o.adapter && !o.pool)
|
|
@@ -258,6 +423,12 @@ export async function createKetServer(o) {
|
|
|
258
423
|
code: 'E_NO_RESOLVER',
|
|
259
424
|
message: 'a pool needs resolveDatastore to know which database a request belongs to',
|
|
260
425
|
});
|
|
426
|
+
const maxJsonBodyBytes = o.maxJsonBodyBytes ?? 1024 * 1024;
|
|
427
|
+
if (!Number.isSafeInteger(maxJsonBodyBytes) || maxJsonBodyBytes <= 0)
|
|
428
|
+
throw new KetErr({
|
|
429
|
+
code: 'E_JSON_BODY_LIMIT',
|
|
430
|
+
message: 'maxJsonBodyBytes must be a positive safe integer',
|
|
431
|
+
});
|
|
261
432
|
// Compile once: matching stays cheap per request, and an ambiguous surface is
|
|
262
433
|
// refused before the server starts rather than depending on object insertion order.
|
|
263
434
|
const matchRoute = compileRoutes(o.routes ?? {});
|
|
@@ -271,14 +442,26 @@ export async function createKetServer(o) {
|
|
|
271
442
|
throw new KetErr({
|
|
272
443
|
code: 'E_UNKNOWN_TENANT',
|
|
273
444
|
message: `no datastore for ${url.host}${url.pathname}`,
|
|
274
|
-
hint: 'resolveDatastore returned null — the request names a tenant this
|
|
445
|
+
hint: 'resolveDatastore returned null — the request names a tenant this deployment does not serve',
|
|
275
446
|
});
|
|
276
447
|
}
|
|
277
448
|
return o.pool.with(key, fn);
|
|
278
449
|
};
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Whose database a stream belongs to: the one it is about.
|
|
452
|
+
*
|
|
453
|
+
* This used to be left open. With a database per tenant there is no single
|
|
454
|
+
* adapter, so the store fell back to memory — per process, which meant a job in
|
|
455
|
+
* a worker had nowhere to write and two web instances shared nothing. A stream
|
|
456
|
+
* is about a record, and that record lives in exactly one tenant's database, so
|
|
457
|
+
* that is where its log goes.
|
|
458
|
+
*
|
|
459
|
+
* A store passed in explicitly is the caller saying they have answered the
|
|
460
|
+
* question a different way, and it stays one store for the deployment.
|
|
461
|
+
*/
|
|
462
|
+
const declaredStreams = o.streamStore ? createStreams(o.streamStore) : null;
|
|
463
|
+
const noDatabaseStreams = o.streamStore || o.adapter ? null : createStreams(memoryStreamStore());
|
|
464
|
+
const streamsFor = (adapter) => declaredStreams ?? (adapter ? streamsOf(adapter) : noDatabaseStreams);
|
|
282
465
|
const configuredMounts = o.assets ? (Array.isArray(o.assets) ? o.assets : [o.assets]) : [];
|
|
283
466
|
// Browser-safe ketjs-view output is framework infrastructure, like /_ket/fn:
|
|
284
467
|
// callers should not need to find and mount a transitive package themselves.
|
|
@@ -297,17 +480,38 @@ export async function createKetServer(o) {
|
|
|
297
480
|
return o.islandClients(url, req);
|
|
298
481
|
return o.islandClients ?? theme?.clients ?? {};
|
|
299
482
|
};
|
|
300
|
-
const
|
|
483
|
+
const tenantForLog = (url, req) => {
|
|
484
|
+
try {
|
|
485
|
+
return o.resolveDatastore?.(url, req) ?? null;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
301
491
|
const server = createServer(async (req, res) => {
|
|
302
|
-
const
|
|
492
|
+
const started = Date.now();
|
|
493
|
+
// The route pattern, never the pathname: a raw path carries record ids and a
|
|
494
|
+
// query string, which is how customer data reaches a log aggregator. An
|
|
495
|
+
// unmatched path stays unnamed for the same reason — the status already says
|
|
496
|
+
// what happened, and the string a stranger typed is not worth keeping.
|
|
497
|
+
let route = '(unmatched)';
|
|
498
|
+
let requestLog = o.log;
|
|
303
499
|
try {
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
500
|
+
const url = requestUrl(req);
|
|
501
|
+
// Which tenant this request belongs to, for attribution only.
|
|
502
|
+
//
|
|
503
|
+
// Guarded, and the guard is the point: `resolveDatastore` throws
|
|
504
|
+
// E_UNKNOWN_TENANT for a host this deployment does not serve, and static
|
|
505
|
+
// assets are answered before any tenant is needed. Letting that throw here
|
|
506
|
+
// would mean adding logging had changed what the server serves.
|
|
507
|
+
requestLog = o.log?.child({ tenant: tenantForLog(url, req) });
|
|
508
|
+
// Static files. Several mounts, because the deployment has its own and every
|
|
509
|
+
// composed module may ship some. `resolve` keeps the file lookup behind the
|
|
510
|
+
// same request boundary as every other route.
|
|
308
511
|
for (const mount of mounts) {
|
|
309
512
|
if (!url.pathname.startsWith(mount.prefix))
|
|
310
513
|
continue;
|
|
514
|
+
route = 'asset';
|
|
311
515
|
// Path traversal is the one thing a static handler must not get wrong.
|
|
312
516
|
const rel = normalize(url.pathname.slice(mount.prefix.length)).replace(/^(\.\.[/\\])+/, '');
|
|
313
517
|
const file = mount.dir !== undefined
|
|
@@ -317,10 +521,25 @@ export async function createKetServer(o) {
|
|
|
317
521
|
: await mount.resolve(rel, url, req);
|
|
318
522
|
if (file === null)
|
|
319
523
|
break;
|
|
524
|
+
// A module publishes a *directory*, not a list of files, so anything
|
|
525
|
+
// else that lives in that directory went out with the assets: the TSX an
|
|
526
|
+
// island is authored in, a sourcemap with the original text inlined, a
|
|
527
|
+
// README. They were answered as `application/octet-stream` — a download
|
|
528
|
+
// rather than an error, which is worse than useless for source nobody
|
|
529
|
+
// meant to publish. `ASSET_MIME` is already the list of things a browser
|
|
530
|
+
// loads as a page asset, so it is the list of things that get served.
|
|
531
|
+
const type = assetType(rel);
|
|
532
|
+
if (type === undefined)
|
|
533
|
+
break;
|
|
320
534
|
try {
|
|
321
535
|
const body = await readFile(file);
|
|
322
|
-
|
|
323
|
-
|
|
536
|
+
// A URL that names its own content can never go stale, so it is kept
|
|
537
|
+
// rather than revalidated. Anything else keeps the old answer: safe,
|
|
538
|
+
// and re-fetched every time, which is what versioning is for.
|
|
539
|
+
res.writeHead(200, {
|
|
540
|
+
'content-type': contentType(type),
|
|
541
|
+
'cache-control': isVersioned(rel) ? 'public, max-age=31536000, immutable' : 'no-cache',
|
|
542
|
+
});
|
|
324
543
|
return res.end(body);
|
|
325
544
|
}
|
|
326
545
|
catch {
|
|
@@ -331,16 +550,63 @@ export async function createKetServer(o) {
|
|
|
331
550
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
332
551
|
return res.end('not found');
|
|
333
552
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
553
|
+
// Before anything expensive, including before a route is chosen: refusing
|
|
554
|
+
// after doing the work is a limit that costs what it was meant to save.
|
|
555
|
+
const policy = o.rateLimit?.(url, req);
|
|
556
|
+
if (policy) {
|
|
557
|
+
const verdict = await withDb(url, req, (adapter) => claimRateSlot(adapter, policy));
|
|
558
|
+
if (!verdict.ok) {
|
|
559
|
+
route = `rate:${policy.action}`;
|
|
560
|
+
requestLog?.log({
|
|
561
|
+
level: 'warn',
|
|
562
|
+
event: 'rate_limited',
|
|
563
|
+
fields: {
|
|
564
|
+
action: policy.action,
|
|
565
|
+
limit: policy.limit,
|
|
566
|
+
retryAfterMs: verdict.retryAfterMs,
|
|
567
|
+
method: req.method ?? '',
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
res.writeHead(429, {
|
|
571
|
+
'content-type': 'application/json; charset=utf-8',
|
|
572
|
+
'retry-after': String(Math.max(1, Math.ceil(verdict.retryAfterMs / 1_000))),
|
|
573
|
+
});
|
|
574
|
+
return res.end(JSON.stringify({ code: 'E_RATE_LIMITED', message: `too many "${policy.action}" requests` }));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const matched = matchRoute(url.pathname);
|
|
578
|
+
if (matched) {
|
|
579
|
+
route = matched.path;
|
|
580
|
+
const r = await matched.value(url, req, matched.params);
|
|
337
581
|
return await send(res, r);
|
|
338
582
|
}
|
|
339
|
-
|
|
583
|
+
// Framework endpoints name themselves: the paths are fixed, so there is no
|
|
584
|
+
// identifier to leak and no cardinality to worry about.
|
|
585
|
+
if (url.pathname === '/_ket/manifest') {
|
|
586
|
+
route = url.pathname;
|
|
340
587
|
return json(res, 200, o.manifest);
|
|
341
|
-
|
|
588
|
+
}
|
|
589
|
+
if (url.pathname === '/_ket/agent') {
|
|
590
|
+
route = url.pathname;
|
|
342
591
|
return json(res, 200, agentDescriptor(o.manifest));
|
|
592
|
+
}
|
|
593
|
+
if (url.pathname === TOKENS_PATH) {
|
|
594
|
+
route = url.pathname;
|
|
595
|
+
const theme = await resolveTheme(url, req);
|
|
596
|
+
res.writeHead(theme ? 200 : 404, {
|
|
597
|
+
'content-type': contentType(theme ? 'text/css' : 'text/plain'),
|
|
598
|
+
'cache-control': 'no-cache',
|
|
599
|
+
});
|
|
600
|
+
if (!theme)
|
|
601
|
+
return res.end('not found');
|
|
602
|
+
// The site's own layer comes after the theme's, so a site that
|
|
603
|
+
// overrides nothing serves exactly what it served before.
|
|
604
|
+
const overrides = (await o.siteTokens?.(url, req)) ?? null;
|
|
605
|
+
const { safe } = partitionTokens(overrides ?? {});
|
|
606
|
+
return res.end(Object.keys(safe).length ? `${theme.tokensCss}\n${tokensToCss(safe, 'ket.app')}` : theme.tokensCss);
|
|
607
|
+
}
|
|
343
608
|
if (url.pathname === '/_ket/islands.js') {
|
|
609
|
+
route = url.pathname;
|
|
344
610
|
const theme = await resolveTheme(url, req);
|
|
345
611
|
const clients = await resolveIslandClients(url, req, theme);
|
|
346
612
|
res.writeHead(200, {
|
|
@@ -352,7 +618,14 @@ export async function createKetServer(o) {
|
|
|
352
618
|
// Resumable stream: the client reconnects with ?from=<cursor> and gets
|
|
353
619
|
// exactly what it missed, never a duplicate and never a gap.
|
|
354
620
|
if (url.pathname.startsWith('/_ket/stream/')) {
|
|
355
|
-
|
|
621
|
+
route = '/_ket/stream';
|
|
622
|
+
const publicId = decodeRequestPath(url.pathname.slice('/_ket/stream/'.length));
|
|
623
|
+
// A public stream id is never a storage key by default. The resolver is the
|
|
624
|
+
// deployment's authentication and tenant boundary; omitting it keeps this
|
|
625
|
+
// framework endpoint closed, and null does not reveal whether a topic exists.
|
|
626
|
+
const id = await o.resolveStream?.(publicId, url, req);
|
|
627
|
+
if (!id)
|
|
628
|
+
return json(res, 404, { code: 'E_STREAM_NOT_FOUND', message: 'stream not found' });
|
|
356
629
|
const from = Number(url.searchParams.get('from') ?? 0);
|
|
357
630
|
res.writeHead(200, {
|
|
358
631
|
'content-type': 'text/event-stream',
|
|
@@ -363,23 +636,41 @@ export async function createKetServer(o) {
|
|
|
363
636
|
// moment that happens: the durable log keeps the chunks, and the next
|
|
364
637
|
// connection resumes from its cursor.
|
|
365
638
|
let open = true;
|
|
639
|
+
// Two things have to happen when the reader disappears: stop writing, and
|
|
640
|
+
// stop reading. The second is the one that matters here — the tail sleeps
|
|
641
|
+
// between reads, and a read that wakes up after the server has moved on
|
|
642
|
+
// finds a database that is closing.
|
|
643
|
+
const gone = new AbortController();
|
|
366
644
|
const stop = () => {
|
|
367
645
|
open = false;
|
|
646
|
+
gone.abort();
|
|
368
647
|
};
|
|
369
648
|
req.on('close', stop);
|
|
370
649
|
res.on('close', stop);
|
|
371
|
-
for
|
|
650
|
+
// The lease is held for as long as the tail, which is what keeps this
|
|
651
|
+
// tenant's adapter from being evicted out from under a reader. It leases
|
|
652
|
+
// the datastore, not a connection: the reads inside take one and give it
|
|
653
|
+
// back like any other query.
|
|
654
|
+
return withDb(url, req, async (adapter) => {
|
|
655
|
+
const streams = await streamsFor(adapter);
|
|
656
|
+
for await (const chunk of streams.tail(id, from, {
|
|
657
|
+
timeoutMs: o.streamTimeoutMs ?? 30_000,
|
|
658
|
+
signal: gone.signal,
|
|
659
|
+
...(o.streamPollMs === undefined ? {} : { pollMs: o.streamPollMs }),
|
|
660
|
+
})) {
|
|
661
|
+
if (!open || res.writableEnded)
|
|
662
|
+
return;
|
|
663
|
+
res.write(`id: ${chunk.seq}\ndata: ${JSON.stringify(chunk.data)}\n\n`);
|
|
664
|
+
}
|
|
372
665
|
if (!open || res.writableEnded)
|
|
373
666
|
return;
|
|
374
|
-
res.write(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return;
|
|
378
|
-
res.write('event: done\ndata: {}\n\n');
|
|
379
|
-
return res.end();
|
|
667
|
+
res.write('event: done\ndata: {}\n\n');
|
|
668
|
+
return res.end();
|
|
669
|
+
});
|
|
380
670
|
}
|
|
381
671
|
if (url.pathname.startsWith('/_ket/fn/') && req.method === 'POST') {
|
|
382
|
-
|
|
672
|
+
route = '/_ket/fn';
|
|
673
|
+
const fnKey = decodeRequestPath(url.pathname.slice('/_ket/fn/'.length));
|
|
383
674
|
const meta = o.manifest.functions[fnKey];
|
|
384
675
|
if (meta?.exposure === 'internal') {
|
|
385
676
|
throw new KetError({
|
|
@@ -388,7 +679,10 @@ export async function createKetServer(o) {
|
|
|
388
679
|
hint: 'call it from the trusted route that owns its security policy',
|
|
389
680
|
});
|
|
390
681
|
}
|
|
391
|
-
|
|
682
|
+
// Bound the untrusted body before authentication/session resolvers run. A
|
|
683
|
+
// caller must not be able to make those expensive paths retain an unbounded
|
|
684
|
+
// payload first, whether or not Content-Length was supplied.
|
|
685
|
+
const args = await readBody(req, maxJsonBodyBytes);
|
|
392
686
|
// Resolved before the pool lease, so a session lookup never holds one.
|
|
393
687
|
const scope = await o.resolveScope?.(url, req);
|
|
394
688
|
const allow = await o.resolveAllow?.(url, req);
|
|
@@ -402,6 +696,7 @@ export async function createKetServer(o) {
|
|
|
402
696
|
queueNotify: o.queueNotify,
|
|
403
697
|
dryRun: url.searchParams.get('dryRun') === '1',
|
|
404
698
|
idempotencyKey: req.headers['idempotency-key'] ?? null,
|
|
699
|
+
idempotencyNamespace: `fn:http:${scope?.company ?? 'none'}:${actor ?? 'anonymous'}`,
|
|
405
700
|
}));
|
|
406
701
|
return json(res, 200, result);
|
|
407
702
|
}
|
|
@@ -412,27 +707,44 @@ export async function createKetServer(o) {
|
|
|
412
707
|
return res.end('not found');
|
|
413
708
|
}
|
|
414
709
|
const scope = o.pageScope ? await o.pageScope(url, req) : {};
|
|
710
|
+
const privatePage = o.pagePrivate?.(url, req) === true;
|
|
415
711
|
const pageRegion = o.pageRegion;
|
|
416
712
|
if (pageRegion && isNavigationRequest(req)) {
|
|
417
713
|
const page = scope['page'];
|
|
418
|
-
|
|
714
|
+
const fragment = navigablePage(req, {
|
|
419
715
|
title: String(page?.title ?? ''),
|
|
420
716
|
document: () => html ``,
|
|
421
717
|
slots: {
|
|
422
718
|
[pageRegion]: () => html `${trustedMarkup(theme.renderRegion(pageRegion, scope))}`,
|
|
423
719
|
},
|
|
424
|
-
})
|
|
720
|
+
});
|
|
721
|
+
return send(res, privatePage
|
|
722
|
+
? { ...fragment, headers: { ...fragment.headers, ...PRIVATE_PAGE_HEADERS } }
|
|
723
|
+
: fragment);
|
|
425
724
|
}
|
|
426
|
-
const fullHtml = bootstrapDocument(theme.renderRegion('layout', scope));
|
|
725
|
+
const fullHtml = bootstrapDocument(withThemeTokens(theme.renderRegion('layout', scope), theme.tokensCss));
|
|
427
726
|
res.writeHead(200, {
|
|
428
727
|
'content-type': 'text/html; charset=utf-8',
|
|
429
728
|
...(o.pageRegion ? { vary: 'X-Ket-Navigation' } : {}),
|
|
729
|
+
...(privatePage ? PRIVATE_PAGE_HEADERS : {}),
|
|
430
730
|
});
|
|
431
731
|
return res.end(fullHtml);
|
|
432
732
|
}
|
|
433
733
|
return json(res, 404, { code: 'E_NOT_FOUND', message: `no route for ${url.pathname}` });
|
|
434
734
|
}
|
|
435
735
|
catch (e) {
|
|
736
|
+
// Anything that is not a named contract failure escaped a `try` nobody wrote,
|
|
737
|
+
// and so did a failure that arrived after the headers went out. Both used to
|
|
738
|
+
// be discarded here: the client got a message and the server kept nothing.
|
|
739
|
+
if (!(e instanceof KetError) || res.headersSent) {
|
|
740
|
+
requestLog?.log({
|
|
741
|
+
level: 'error',
|
|
742
|
+
event: 'unhandled',
|
|
743
|
+
durationMs: Date.now() - started,
|
|
744
|
+
error: e,
|
|
745
|
+
fields: { method: req.method ?? '', route, headersSent: res.headersSent },
|
|
746
|
+
});
|
|
747
|
+
}
|
|
436
748
|
// A streaming response has already sent its headers; there is no status code
|
|
437
749
|
// left to send, so the only honest thing is to close the socket.
|
|
438
750
|
if (res.headersSent) {
|
|
@@ -440,14 +752,38 @@ export async function createKetServer(o) {
|
|
|
440
752
|
res.destroy(e);
|
|
441
753
|
return;
|
|
442
754
|
}
|
|
755
|
+
if (e instanceof FormValidationError)
|
|
756
|
+
return json(res, 422, e.toJSON());
|
|
757
|
+
const status = e instanceof KetError ? statusForError(e.code) : 500;
|
|
758
|
+
const code = e instanceof KetError ? e.code : 'E_INTERNAL';
|
|
759
|
+
if (o.renderErrorPage && wantsHtml(req)) {
|
|
760
|
+
// A page that fails to render an error is worse than the error, so the
|
|
761
|
+
// fallback is the JSON that was always there.
|
|
762
|
+
const page = await o.renderErrorPage({ code, status, url: requestUrl(req), req }).catch(() => null);
|
|
763
|
+
if (page !== null) {
|
|
764
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
765
|
+
return res.end(page);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
443
768
|
if (e instanceof KetError)
|
|
444
|
-
return json(res,
|
|
769
|
+
return json(res, status, e.toJSON());
|
|
445
770
|
return json(res, 500, { code: 'E_INTERNAL', message: e.message });
|
|
446
771
|
}
|
|
772
|
+
finally {
|
|
773
|
+
requestLog?.log({
|
|
774
|
+
// A stylesheet is not an application event. One page load is dozens of
|
|
775
|
+
// asset requests, and at info they would be most of what a deployment
|
|
776
|
+
// says about itself.
|
|
777
|
+
level: route === 'asset' ? 'debug' : 'info',
|
|
778
|
+
event: 'http_request',
|
|
779
|
+
durationMs: Date.now() - started,
|
|
780
|
+
fields: { method: req.method ?? '', route, status: res.statusCode },
|
|
781
|
+
});
|
|
782
|
+
}
|
|
447
783
|
});
|
|
448
784
|
return {
|
|
449
785
|
server,
|
|
450
|
-
streams,
|
|
786
|
+
streams: await streamsFor(o.adapter ?? null),
|
|
451
787
|
listen(port = o.port ?? 3000) {
|
|
452
788
|
return new Promise((resolve) => server.listen(port, () => resolve(server.address().port)));
|
|
453
789
|
},
|