@weft-js/start 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { EXTENSION, type ServerExtension } from '../extension'
|
|
2
|
+
import * as exec from '../exec'
|
|
3
|
+
import type { Scope } from '../exec'
|
|
4
|
+
import { rtRoute } from '../hub'
|
|
5
|
+
import { socketRoute } from '../socket'
|
|
6
|
+
import { serverFunction } from './functions'
|
|
7
|
+
import type { HandlerOptions } from './types'
|
|
8
|
+
|
|
9
|
+
interface Dispatch {
|
|
10
|
+
options: HandlerOptions
|
|
11
|
+
authBase: string
|
|
12
|
+
extensions: Map<string, ServerExtension>
|
|
13
|
+
form: (url: URL, request: Request, scope: Scope) => Promise<Response>
|
|
14
|
+
media: (url: URL, request: Request) => Promise<Response>
|
|
15
|
+
og: (url: URL, request: Request) => Promise<Response>
|
|
16
|
+
data: (path: string, request: Request, scope: Scope) => Promise<Response>
|
|
17
|
+
prerenderList: (request: Request, url: URL) => Promise<Response>
|
|
18
|
+
page: (url: URL, request: Request, scope: Scope) => Promise<Response>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Select the endpoint for a request. */
|
|
22
|
+
export function makeDispatch({
|
|
23
|
+
options,
|
|
24
|
+
authBase,
|
|
25
|
+
extensions,
|
|
26
|
+
form,
|
|
27
|
+
media,
|
|
28
|
+
og,
|
|
29
|
+
data,
|
|
30
|
+
prerenderList,
|
|
31
|
+
page,
|
|
32
|
+
}: Dispatch) {
|
|
33
|
+
/** Everything under `/_weft/x/<key>/`, for the extension with that key. Nothing else reaches it. */
|
|
34
|
+
async function extensionRoute(url: URL, request: Request, scope: Scope) {
|
|
35
|
+
const [key = '', ...rest] = url.pathname.slice(EXTENSION.length).split('/')
|
|
36
|
+
const extension = extensions.get(key)
|
|
37
|
+
if (!extension?.handle) return new Response('Not found', { status: 404 })
|
|
38
|
+
return extension.handle(request, rest.join('/'), {
|
|
39
|
+
invalidate: (tags) => exec.invalidate(scope, tags),
|
|
40
|
+
report: (d) => options.onDiagnostic?.(d),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (request: Request, scope: Scope): Promise<Response> => {
|
|
45
|
+
const url = new URL(request.url)
|
|
46
|
+
if (options.auth && (url.pathname === authBase || url.pathname.startsWith(authBase + '/')))
|
|
47
|
+
return Promise.resolve(options.auth.handler(request))
|
|
48
|
+
if (url.pathname.startsWith('/_weft/fn/')) return serverFunction(options, url, request, scope)
|
|
49
|
+
if (url.pathname.startsWith(EXTENSION)) return extensionRoute(url, request, scope)
|
|
50
|
+
if (
|
|
51
|
+
request.method === 'POST' &&
|
|
52
|
+
!url.pathname.startsWith('/_weft/') &&
|
|
53
|
+
/^(application\/x-www-form-urlencoded|multipart\/form-data)/.test(
|
|
54
|
+
request.headers.get('content-type') ?? '',
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return form(url, request, scope)
|
|
58
|
+
if (url.pathname === '/_weft/rt') return rtRoute(request, scope, options.realtime?.origins)
|
|
59
|
+
if (url.pathname === '/_weft/ws') return socketRoute(request, scope, options.realtime?.origins)
|
|
60
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
61
|
+
return Promise.resolve(new Response('Method not allowed', { status: 405 }))
|
|
62
|
+
if (url.pathname.startsWith('/_weft/m/')) return media(url, request)
|
|
63
|
+
if (url.pathname.startsWith('/_weft/og/') && url.pathname.endsWith('.png'))
|
|
64
|
+
return og(url, request)
|
|
65
|
+
if (url.pathname.startsWith('/_weft/data/') || url.pathname === '/_weft/data')
|
|
66
|
+
return data((url.pathname.slice('/_weft/data'.length) || '/') + url.search, request, scope)
|
|
67
|
+
if (url.pathname === '/_weft/og-routes') return prerenderList(request, url)
|
|
68
|
+
return page(url, request, scope)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeForm,
|
|
3
|
+
fieldsOf,
|
|
4
|
+
fns,
|
|
5
|
+
isRedirect,
|
|
6
|
+
StartError,
|
|
7
|
+
FnError,
|
|
8
|
+
type SchemaIssue,
|
|
9
|
+
} from '../data'
|
|
10
|
+
import * as exec from '../exec'
|
|
11
|
+
import type { Scope } from '../exec'
|
|
12
|
+
import { echoed, withHeaders } from './request'
|
|
13
|
+
import type { HandlerOptions } from './types'
|
|
14
|
+
export function makeForm(
|
|
15
|
+
options: HandlerOptions,
|
|
16
|
+
page: (url: URL, request: Request, scope: Scope) => Promise<Response>,
|
|
17
|
+
) {
|
|
18
|
+
/**
|
|
19
|
+
* A form POST to a page (research 29 §5): the mutation, then 303 (Post/Redirect/Get) or the page again with the
|
|
20
|
+
* errors and what was typed. A form can't send `x-weft`, so the CSRF proof is the browser's own origin headers.
|
|
21
|
+
*/
|
|
22
|
+
return async function form(url: URL, request: Request, scope: Scope): Promise<Response> {
|
|
23
|
+
const origin = request.headers.get('origin')
|
|
24
|
+
const from = request.headers.get('sec-fetch-site')
|
|
25
|
+
if (
|
|
26
|
+
!(origin
|
|
27
|
+
? origin === url.origin && (!from || from === 'same-origin')
|
|
28
|
+
: from === 'same-origin')
|
|
29
|
+
) {
|
|
30
|
+
options.onDiagnostic?.({
|
|
31
|
+
code: 'F002',
|
|
32
|
+
severity: 'error',
|
|
33
|
+
message: `F002: a form POST to ${url.pathname} without the same-origin proof (\`Origin\` or \`Sec-Fetch-Site\`)`,
|
|
34
|
+
})
|
|
35
|
+
return new Response('Forbidden', { status: 403 })
|
|
36
|
+
}
|
|
37
|
+
const fields = await request.formData()
|
|
38
|
+
const named = fields.get('$fn')
|
|
39
|
+
const id = typeof named === 'string' ? named : ''
|
|
40
|
+
const f = fns.get(id)
|
|
41
|
+
if (f?.kind !== 'mutation') {
|
|
42
|
+
options.onDiagnostic?.({
|
|
43
|
+
code: 'F003',
|
|
44
|
+
severity: 'error',
|
|
45
|
+
message: `F003: a form POST names \`${id}\`, which is no mutation (the page is older than the server)`,
|
|
46
|
+
})
|
|
47
|
+
return new Response('Not found', { status: 404 })
|
|
48
|
+
}
|
|
49
|
+
const see = (href: string) =>
|
|
50
|
+
withHeaders(
|
|
51
|
+
new Response(null, {
|
|
52
|
+
status: 303,
|
|
53
|
+
headers: { location: href, 'cache-control': 'no-cache' },
|
|
54
|
+
}),
|
|
55
|
+
scope,
|
|
56
|
+
)
|
|
57
|
+
const input = decodeForm(fields)
|
|
58
|
+
try {
|
|
59
|
+
await exec.runMutation(scope, f, input, [], true)
|
|
60
|
+
return see(url.pathname + url.search)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (isRedirect(e)) return see(e.href)
|
|
63
|
+
const known = e instanceof StartError || e instanceof FnError
|
|
64
|
+
if (!known)
|
|
65
|
+
options.onDiagnostic?.({
|
|
66
|
+
code: 'E_FN',
|
|
67
|
+
severity: 'error',
|
|
68
|
+
message: `${id}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
|
|
69
|
+
})
|
|
70
|
+
const failure = e as { issues?: SchemaIssue[]; status?: number; message?: string } | null
|
|
71
|
+
const issues = failure?.issues
|
|
72
|
+
const { errors, message } = fieldsOf(issues)
|
|
73
|
+
const key = fields.get('$form')
|
|
74
|
+
scope.form = {
|
|
75
|
+
id,
|
|
76
|
+
...(typeof key === 'string' && { key }),
|
|
77
|
+
values: echoed(input),
|
|
78
|
+
errors,
|
|
79
|
+
message: issues ? message : (exec.shown(e, scope) as Error).message,
|
|
80
|
+
status: issues ? 422 : (failure?.status ?? 500),
|
|
81
|
+
}
|
|
82
|
+
return page(url, request, scope)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { sessionChanged } from '../auth'
|
|
2
|
+
import { fns, isRedirect, FnError, StartError } from '../data'
|
|
3
|
+
import * as exec from '../exec'
|
|
4
|
+
import type { Scope } from '../exec'
|
|
5
|
+
import { multipartBody, withHeaders } from './request'
|
|
6
|
+
import type { HandlerOptions } from './types'
|
|
7
|
+
|
|
8
|
+
/** `/_weft/fn/<id>`: GET runs a query, POST a mutation (CSRF-checked), both answering JSON (research 27 §3). */
|
|
9
|
+
export async function serverFunction(
|
|
10
|
+
options: HandlerOptions,
|
|
11
|
+
url: URL,
|
|
12
|
+
request: Request,
|
|
13
|
+
scope: Scope,
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const id = decodeURIComponent(url.pathname.slice('/_weft/fn/'.length))
|
|
16
|
+
const f = fns.get(id)
|
|
17
|
+
const out = (body: unknown, status = 200) =>
|
|
18
|
+
withHeaders(Response.json(body, { status, headers: { 'cache-control': 'no-store' } }), scope)
|
|
19
|
+
const fail = (
|
|
20
|
+
code: string,
|
|
21
|
+
message: string,
|
|
22
|
+
status: number,
|
|
23
|
+
extra: object = {},
|
|
24
|
+
sent?: unknown,
|
|
25
|
+
) => {
|
|
26
|
+
options.onDiagnostic?.({ code, severity: 'error', message })
|
|
27
|
+
return out(
|
|
28
|
+
{ e: { code, message: sent instanceof Error ? sent.message : message, ...extra } },
|
|
29
|
+
status,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
if (!f)
|
|
33
|
+
return fail(
|
|
34
|
+
'Q009',
|
|
35
|
+
`Q009: no server function \`${id}\` (the page is older than the server, or the module isn't a server module): reload`,
|
|
36
|
+
404,
|
|
37
|
+
)
|
|
38
|
+
let fromForm = false
|
|
39
|
+
if (f.kind === 'stream')
|
|
40
|
+
return fail('Q009', `Q009: \`${id}\` is a stream: its client reads it over \`/_weft/rt\``, 405)
|
|
41
|
+
try {
|
|
42
|
+
if (f.kind === 'query') {
|
|
43
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
44
|
+
return fail('Q009', `Q009: \`${id}\` is a query: GET only`, 405)
|
|
45
|
+
const raw = url.searchParams.get('i')
|
|
46
|
+
const e = await exec.fetchQuery(scope, f, raw === null ? undefined : JSON.parse(raw))
|
|
47
|
+
return out({ v: e.v, t: e.tags })
|
|
48
|
+
}
|
|
49
|
+
if (request.method !== 'POST') return fail('Q010', `Q010: mutation \`${id}\` needs POST`, 405)
|
|
50
|
+
const origin = request.headers.get('origin')
|
|
51
|
+
const from = request.headers.get('sec-fetch-site')
|
|
52
|
+
const cross =
|
|
53
|
+
(origin && origin !== url.origin) || (from && from !== 'same-origin' && from !== 'none')
|
|
54
|
+
const type = request.headers.get('content-type') ?? ''
|
|
55
|
+
const multipart = type.startsWith('multipart/form-data') // `<Form>` with a file; `x-weft` still forces a preflight
|
|
56
|
+
if (
|
|
57
|
+
cross ||
|
|
58
|
+
request.headers.get('x-weft') !== '1' ||
|
|
59
|
+
!(multipart || type.startsWith('application/json'))
|
|
60
|
+
)
|
|
61
|
+
return fail(
|
|
62
|
+
'Q010',
|
|
63
|
+
`Q010: mutation \`${id}\` without the CSRF proof (POST + JSON + \`x-weft: 1\`, same origin): call it through its stub`,
|
|
64
|
+
403,
|
|
65
|
+
)
|
|
66
|
+
const body = multipart
|
|
67
|
+
? await multipartBody(request)
|
|
68
|
+
: ((await request.json()) as { i?: unknown; f?: [string, unknown][]; c?: 1 })
|
|
69
|
+
fromForm = !!body.c
|
|
70
|
+
// A token opens only the functions listed for it, and what a mutation refreshes would run as that token too.
|
|
71
|
+
const refresh =
|
|
72
|
+
Array.isArray(body.f) && !request.headers.has('authorization') ? body.f.slice(0, 200) : []
|
|
73
|
+
const answer = await exec.runMutation(scope, f, body.i, refresh, fromForm)
|
|
74
|
+
if (options.dev && sessionChanged(scope.headers))
|
|
75
|
+
options.onDiagnostic?.({
|
|
76
|
+
code: 'A004',
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
message: `\`${id}\` changed the session cookie without a redirect: the tab keeps what the last session cached. \`redirect()\` after it`,
|
|
79
|
+
})
|
|
80
|
+
return out(answer)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return functionError(e, { fromForm, scope, options, id, out, fail })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function functionError(
|
|
87
|
+
e: unknown,
|
|
88
|
+
c: {
|
|
89
|
+
fromForm: boolean
|
|
90
|
+
scope: Scope
|
|
91
|
+
options: HandlerOptions
|
|
92
|
+
id: string
|
|
93
|
+
out: (body: unknown, status?: number) => Response
|
|
94
|
+
fail: (
|
|
95
|
+
code: string,
|
|
96
|
+
message: string,
|
|
97
|
+
status: number,
|
|
98
|
+
extra?: object,
|
|
99
|
+
sent?: unknown,
|
|
100
|
+
) => Response
|
|
101
|
+
},
|
|
102
|
+
): Response {
|
|
103
|
+
const { fromForm, scope, options, id, out, fail } = c
|
|
104
|
+
// The session changed: a full load, so nothing cached for the last user survives in the tab (research 28 §3).
|
|
105
|
+
if (isRedirect(e)) return out({ r: e.href, ...(sessionChanged(scope.headers) && { x: 1 }) })
|
|
106
|
+
if (e instanceof FnError) return out({ e: { code: e.code, message: e.message } }, e.status)
|
|
107
|
+
// A wrong field in a form is an answer, not a fault: nothing to log.
|
|
108
|
+
if (e instanceof StartError && fromForm && e.extra.issues)
|
|
109
|
+
return out({ e: { code: e.code, message: e.message, issues: e.extra.issues } }, 422)
|
|
110
|
+
if (e instanceof StartError)
|
|
111
|
+
return fail(
|
|
112
|
+
e.code,
|
|
113
|
+
e.message,
|
|
114
|
+
(e.extra.status as number) ?? 500,
|
|
115
|
+
e.extra.issues ? { issues: e.extra.issues } : e.extra.typed ? { data: e.extra.data } : {},
|
|
116
|
+
exec.shown(e, scope),
|
|
117
|
+
)
|
|
118
|
+
options.onDiagnostic?.({
|
|
119
|
+
code: 'E_FN',
|
|
120
|
+
severity: 'error',
|
|
121
|
+
message: `${id}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
|
|
122
|
+
})
|
|
123
|
+
return out(
|
|
124
|
+
{
|
|
125
|
+
e: { message: options.dev && e instanceof Error ? e.message : 'Server function failed.' },
|
|
126
|
+
},
|
|
127
|
+
500,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import * as exec from '../exec'
|
|
3
|
+
import type { Scope } from '../exec'
|
|
4
|
+
import { hub } from '../hub'
|
|
5
|
+
import { compressed } from './compression'
|
|
6
|
+
import { stamped } from './request'
|
|
7
|
+
import type { makeServices } from './services'
|
|
8
|
+
import type { HandlerOptions } from './types'
|
|
9
|
+
|
|
10
|
+
/** Wrap endpoint routing in the request scope and boot lifecycle. */
|
|
11
|
+
export function makeHandler(
|
|
12
|
+
options: HandlerOptions,
|
|
13
|
+
als: AsyncLocalStorage<Scope>,
|
|
14
|
+
services: ReturnType<typeof makeServices>,
|
|
15
|
+
route: (request: Request, scope: Scope) => Promise<Response>,
|
|
16
|
+
) {
|
|
17
|
+
return async (incoming: Request): Promise<Response> => {
|
|
18
|
+
const request = options.auth ? stamped(incoming) : incoming
|
|
19
|
+
await services.loadExtensions()
|
|
20
|
+
services.check()
|
|
21
|
+
const scope = exec.createScope(request, services.scopeOptions)
|
|
22
|
+
// The build's prerender only lists cards: it holds no secrets and may have no database.
|
|
23
|
+
if (new URL(request.url).pathname === '/_weft/og-routes')
|
|
24
|
+
return als.run(scope, () => route(request, scope))
|
|
25
|
+
await services.boot()
|
|
26
|
+
if (options.liveRequired?.length) await hub().probed
|
|
27
|
+
if (hub().refused)
|
|
28
|
+
return Response.json(
|
|
29
|
+
{
|
|
30
|
+
e: {
|
|
31
|
+
code: 'R015',
|
|
32
|
+
message: options.dev ? hub().refused!.message : 'Live data is unavailable.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{ status: 503, headers: { 'cache-control': 'no-store' } },
|
|
36
|
+
)
|
|
37
|
+
return compressed(request, await als.run(scope, () => route(request, scope)))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mergeMeta, type Merged } from '@weft-js/core/ssr'
|
|
2
|
+
import { pageText } from '../derive.mjs'
|
|
3
|
+
import { pointAt } from './resolver'
|
|
4
|
+
import { cardPath, hash } from './text'
|
|
5
|
+
import type {
|
|
6
|
+
HandlerOptions,
|
|
7
|
+
Level,
|
|
8
|
+
Manifest,
|
|
9
|
+
MetaSource,
|
|
10
|
+
Resolved,
|
|
11
|
+
RouteEntry,
|
|
12
|
+
RouteModule,
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
/** Whether a title or description can come from the page itself. */
|
|
16
|
+
export const leftToPage = (s: MetaSource['title']) => s === 'config' || s === 'none'
|
|
17
|
+
|
|
18
|
+
/** Complete a head from the metadata and the rendered page. */
|
|
19
|
+
export function makeComplete(
|
|
20
|
+
options: HandlerOptions,
|
|
21
|
+
manifest: Manifest,
|
|
22
|
+
site: NonNullable<HandlerOptions['site']>,
|
|
23
|
+
mod: (file?: string) => RouteModule | undefined,
|
|
24
|
+
CARD: { width: number; height: number },
|
|
25
|
+
) {
|
|
26
|
+
/**
|
|
27
|
+
* The content hash of a generated card: what it draws. The built-in card reads the head and the site's look and
|
|
28
|
+
* nothing else, so a loader's timestamp can't change its URL; an `.og.tsx` also gets the page's data.
|
|
29
|
+
*/
|
|
30
|
+
function cardUrl(route: RouteEntry, levels: Level[], head: Merged, path: string) {
|
|
31
|
+
const card = route.card ?? manifest.defaultCard
|
|
32
|
+
const size = mod(card)?.size ?? CARD
|
|
33
|
+
const { image: _, ...graph } = head.og
|
|
34
|
+
const leaf = levels.at(-1)!
|
|
35
|
+
const v = hash(
|
|
36
|
+
[
|
|
37
|
+
options.build ?? '',
|
|
38
|
+
card ?? 'builtin',
|
|
39
|
+
JSON.stringify(size),
|
|
40
|
+
JSON.stringify({ ...head, og: graph }),
|
|
41
|
+
JSON.stringify(site.card ?? null),
|
|
42
|
+
card ? JSON.stringify(leaf.data ?? null) : '',
|
|
43
|
+
].join('\0'),
|
|
44
|
+
)
|
|
45
|
+
return `/_weft/og/${cardPath(path)}.png?v=${v}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The rest of the head, once the page's HTML can speak for it (research 20 §10): a level that set no title or
|
|
50
|
+
* description gets the page's own, under anything a layout or the route wrote, and `image: 'auto'` becomes the
|
|
51
|
+
* card, whose URL hashes the text it shows. Called once per request, before anything is written.
|
|
52
|
+
*/
|
|
53
|
+
return function complete(r: Resolved, html?: string) {
|
|
54
|
+
if (!r.metas || !r.source) return r.head
|
|
55
|
+
const { metas, source } = r
|
|
56
|
+
r.metas = undefined
|
|
57
|
+
const left = leftToPage(source.title) || leftToPage(source.description)
|
|
58
|
+
const words = left && r.status === 200 && html ? pageText(html) : {}
|
|
59
|
+
const title = leftToPage(source.title) ? words.title : undefined
|
|
60
|
+
const description = leftToPage(source.description) ? words.description : undefined
|
|
61
|
+
if (title) source.title = 'auto'
|
|
62
|
+
if (description) source.description = 'auto'
|
|
63
|
+
const head = (r.head = mergeMeta(
|
|
64
|
+
site,
|
|
65
|
+
{ ...(title !== undefined && { title }), ...(description !== undefined && { description }) },
|
|
66
|
+
...metas,
|
|
67
|
+
))
|
|
68
|
+
pointAt(head, r.origin)
|
|
69
|
+
if (
|
|
70
|
+
head.jsonLd === undefined &&
|
|
71
|
+
r.route?.pattern === '/' &&
|
|
72
|
+
r.status === 200 &&
|
|
73
|
+
r.origin &&
|
|
74
|
+
site.name
|
|
75
|
+
)
|
|
76
|
+
head.jsonLd = {
|
|
77
|
+
'@context': 'https://schema.org',
|
|
78
|
+
'@type': 'WebSite',
|
|
79
|
+
name: site.name,
|
|
80
|
+
url: r.origin,
|
|
81
|
+
...(site.description && { description: site.description }),
|
|
82
|
+
}
|
|
83
|
+
if (head.og.image === 'auto') {
|
|
84
|
+
const showing = r.status === 200 && r.route
|
|
85
|
+
if (showing)
|
|
86
|
+
head.og.image = {
|
|
87
|
+
url: cardUrl(r.route!, r.levels, head, r.path),
|
|
88
|
+
...CARD,
|
|
89
|
+
alt: head.og.title ?? head.title,
|
|
90
|
+
}
|
|
91
|
+
else delete head.og.image
|
|
92
|
+
if (showing) source.image = r.route!.card || manifest.defaultCard ? 'card' : 'auto'
|
|
93
|
+
} else if (head.og.image) source.image = 'explicit'
|
|
94
|
+
return head
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HandlerOptions } from './types'
|
|
2
|
+
import type { MediaServer } from '../exec'
|
|
3
|
+
export function makeMedia(
|
|
4
|
+
options: HandlerOptions,
|
|
5
|
+
mediaServer: () => Promise<MediaServer> | undefined,
|
|
6
|
+
) {
|
|
7
|
+
/** `/_weft/m/<hash>/<width>.<fmt>` (research 29 §6): content-addressed, so cached for good; `auto` varies on `Accept`. */
|
|
8
|
+
return async function media(url: URL, request: Request): Promise<Response> {
|
|
9
|
+
const m = /^\/_weft\/m\/([0-9a-f]{32})\/(\d+)\.([a-z0-9]+)$/.exec(url.pathname)
|
|
10
|
+
const server = await mediaServer()
|
|
11
|
+
if (!server)
|
|
12
|
+
options.onDiagnostic?.({
|
|
13
|
+
code: 'M017',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
message:
|
|
16
|
+
'M017: a media URL was requested, but nothing provides `media.server`. Add `media()` from `@weft-js/plugin-media` to `plugins`',
|
|
17
|
+
})
|
|
18
|
+
const out =
|
|
19
|
+
m &&
|
|
20
|
+
(await server?.serve({
|
|
21
|
+
hash: m[1]!,
|
|
22
|
+
width: Number(m[2]),
|
|
23
|
+
format: m[3]!,
|
|
24
|
+
accept: request.headers.get('accept') ?? '',
|
|
25
|
+
}))
|
|
26
|
+
if (!out) return new Response('Not found', { status: 404 })
|
|
27
|
+
const headers = new Headers({
|
|
28
|
+
'content-type': out.type,
|
|
29
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
30
|
+
etag: `"${m[1]}-${out.width}-${out.format}"`,
|
|
31
|
+
'x-content-type-options': 'nosniff',
|
|
32
|
+
})
|
|
33
|
+
if (out.negotiated) headers.set('vary', 'Accept')
|
|
34
|
+
if (out.type === 'image/svg+xml')
|
|
35
|
+
headers.set(
|
|
36
|
+
'content-security-policy',
|
|
37
|
+
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
38
|
+
)
|
|
39
|
+
if (out.temporary) {
|
|
40
|
+
// The original, standing in for a rung still encoding.
|
|
41
|
+
headers.set('cache-control', 'no-store')
|
|
42
|
+
headers.delete('etag')
|
|
43
|
+
}
|
|
44
|
+
if (request.headers.get('if-none-match') === headers.get('etag'))
|
|
45
|
+
return new Response(null, { status: 304, headers })
|
|
46
|
+
const range =
|
|
47
|
+
/^(video|audio)\//.test(out.type) &&
|
|
48
|
+
/^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') ?? '')
|
|
49
|
+
if (!range) return new Response(out.bytes as BodyInit, { headers })
|
|
50
|
+
const start = Number(range[1])
|
|
51
|
+
const end = Math.min(range[2] ? Number(range[2]) : Infinity, out.bytes.length - 1)
|
|
52
|
+
if (start > end)
|
|
53
|
+
return new Response(null, {
|
|
54
|
+
status: 416,
|
|
55
|
+
headers: { 'content-range': `bytes */${out.bytes.length}` },
|
|
56
|
+
})
|
|
57
|
+
headers.set('content-range', `bytes ${start}-${end}/${out.bytes.length}`)
|
|
58
|
+
return new Response(out.bytes.subarray(start, end + 1) as BodyInit, { status: 206, headers })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** Prevent retention of a route response. */
|
|
4
|
+
export function noRetention(response: Response): Response {
|
|
5
|
+
response.headers.set('cache-control', 'no-store')
|
|
6
|
+
response.headers.delete('etag')
|
|
7
|
+
return response
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function cacheDirectives(policy: string): string[] | undefined {
|
|
11
|
+
const parts: string[] = []
|
|
12
|
+
let start = 0
|
|
13
|
+
let quoted = false
|
|
14
|
+
let escaped = false
|
|
15
|
+
for (let i = 0; i < policy.length; i++) {
|
|
16
|
+
const char = policy[i]
|
|
17
|
+
if (escaped) escaped = false
|
|
18
|
+
else if (quoted && char === '\\') escaped = true
|
|
19
|
+
else if (char === '"') quoted = !quoted
|
|
20
|
+
else if (char === ',' && !quoted) {
|
|
21
|
+
parts.push(policy.slice(start, i).trim())
|
|
22
|
+
start = i + 1
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (quoted || escaped) return undefined
|
|
26
|
+
parts.push(policy.slice(start).trim())
|
|
27
|
+
return parts.filter(Boolean)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Validate only the final, authorized route representation. */
|
|
31
|
+
export function validateNavigation(
|
|
32
|
+
response: Response,
|
|
33
|
+
request: Request,
|
|
34
|
+
body: string,
|
|
35
|
+
build: string | undefined,
|
|
36
|
+
status: number,
|
|
37
|
+
): Response {
|
|
38
|
+
const headers = response.headers
|
|
39
|
+
const policy = headers.get('cache-control') ?? ''
|
|
40
|
+
const directives = cacheDirectives(policy)
|
|
41
|
+
if (!directives) return noRetention(response)
|
|
42
|
+
const noStore = directives.some((part) => /^no-store(?:\s*=|$)/i.test(part))
|
|
43
|
+
if (status < 200 || status >= 300 || headers.has('set-cookie') || noStore) {
|
|
44
|
+
if (noStore) {
|
|
45
|
+
headers.delete('etag')
|
|
46
|
+
return response
|
|
47
|
+
}
|
|
48
|
+
return noRetention(response)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const kept = directives.filter((part) => !/^(public|max-age)(?:\s*=|$)/i.test(part))
|
|
52
|
+
// A bare private directive protects the entire response, including unlisted fields.
|
|
53
|
+
const barePrivate = kept.some((part) => /^private$/i.test(part))
|
|
54
|
+
if (!barePrivate) kept.unshift('private')
|
|
55
|
+
kept.splice(1, 0, 'max-age=0')
|
|
56
|
+
if (!policy) kept.push('stale-while-revalidate=30')
|
|
57
|
+
headers.set('cache-control', kept.join(', '))
|
|
58
|
+
const vary = headers.get('vary')
|
|
59
|
+
if (vary !== '*') {
|
|
60
|
+
const names = (vary ?? '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((name) => name.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
for (const name of ['Accept', 'Cookie', 'Authorization'])
|
|
65
|
+
if (!names.some((existing) => existing.toLowerCase() === name.toLowerCase())) names.push(name)
|
|
66
|
+
headers.set('vary', names.join(', '))
|
|
67
|
+
}
|
|
68
|
+
const tag = `"${createHash('sha256')
|
|
69
|
+
.update(JSON.stringify([build ?? '', body]))
|
|
70
|
+
.digest('hex')}"`
|
|
71
|
+
headers.set('etag', tag)
|
|
72
|
+
const candidates = (request.headers.get('if-none-match') ?? '').split(',')
|
|
73
|
+
if (
|
|
74
|
+
candidates.some(
|
|
75
|
+
(candidate) => candidate.trim().replace(/^W\//, '') === tag || candidate.trim() === '*',
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return new Response(null, { status: 304, headers })
|
|
79
|
+
return response
|
|
80
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { renderToStringAsync } from '@weft-js/core/ssr'
|
|
2
|
+
import type { Merged } from '@weft-js/core/ssr'
|
|
3
|
+
import type { RoutePayload, createRouteState } from '../router'
|
|
4
|
+
import * as exec from '../exec'
|
|
5
|
+
import type { Scope } from '../exec'
|
|
6
|
+
import { App } from '../view'
|
|
7
|
+
import { withLevels } from './render'
|
|
8
|
+
import { payloadOf, wire } from './payload'
|
|
9
|
+
import { noRetention, validateNavigation } from './navigation-cache'
|
|
10
|
+
import { cookieJar, redirectBody, withHeaders } from './request'
|
|
11
|
+
import type { HandlerOptions, Resolved } from './types'
|
|
12
|
+
export function makeNavigation({
|
|
13
|
+
resolve,
|
|
14
|
+
finished,
|
|
15
|
+
statics,
|
|
16
|
+
stateOf,
|
|
17
|
+
PASSES,
|
|
18
|
+
options,
|
|
19
|
+
}: {
|
|
20
|
+
resolve: (path: string, request: Request) => Promise<Resolved>
|
|
21
|
+
finished: (r: Resolved, request: Request) => Promise<Merged>
|
|
22
|
+
statics: Set<string>
|
|
23
|
+
stateOf: (r: Resolved, payload: RoutePayload) => ReturnType<typeof createRouteState>
|
|
24
|
+
PASSES: number
|
|
25
|
+
options: HandlerOptions
|
|
26
|
+
}) {
|
|
27
|
+
/** The route as JSON for client navigation, with deferred fields awaited. */
|
|
28
|
+
return async function data(path: string, request: Request, scope: Scope): Promise<Response> {
|
|
29
|
+
const r = await resolve(path, request)
|
|
30
|
+
if (r.redirect) {
|
|
31
|
+
const headers = new Headers(r.redirectHeaders)
|
|
32
|
+
headers.delete('location')
|
|
33
|
+
headers.set('cache-control', 'no-store')
|
|
34
|
+
return noRetention(
|
|
35
|
+
redirectBody(
|
|
36
|
+
Response.json(
|
|
37
|
+
{ m: [], p: path, s: r.redirect.status, r: r.redirect.href } satisfies RoutePayload,
|
|
38
|
+
{ headers },
|
|
39
|
+
),
|
|
40
|
+
scope,
|
|
41
|
+
'application/json',
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
for (const lv of r.levels)
|
|
46
|
+
if (lv.d?.length)
|
|
47
|
+
lv.data = {
|
|
48
|
+
...(lv.data as object),
|
|
49
|
+
...Object.fromEntries(
|
|
50
|
+
await Promise.all(
|
|
51
|
+
lv.d.map(async (k) => [k, await (lv.raw as Record<string, unknown>)[k]]),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
await finished(r, request)
|
|
56
|
+
const c = wire(scope)
|
|
57
|
+
let payload = payloadOf(r, true, options)
|
|
58
|
+
if (r.levels.some((lv) => statics.has(lv.id))) {
|
|
59
|
+
const route = stateOf(r, payload)
|
|
60
|
+
const out = await renderToStringAsync(() => <App route={route} />, {
|
|
61
|
+
passes: PASSES,
|
|
62
|
+
shown: (e) => exec.shown(e, scope),
|
|
63
|
+
app: { cookies: cookieJar(request) },
|
|
64
|
+
})
|
|
65
|
+
const at = out.lastIndexOf('<script type="application/json" data-weft>')
|
|
66
|
+
payload = {
|
|
67
|
+
...withLevels(payload, at < 0 ? out : out.slice(0, at), statics, true),
|
|
68
|
+
...(at >= 0 && {
|
|
69
|
+
b: JSON.parse(
|
|
70
|
+
out
|
|
71
|
+
.slice(at)
|
|
72
|
+
.replace(/^<[^>]*>/, '')
|
|
73
|
+
.replace(/<\/script>$/, ''),
|
|
74
|
+
),
|
|
75
|
+
}),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const body = JSON.stringify({ ...payload, ...(c.length && { c }) })
|
|
79
|
+
const response = withHeaders(
|
|
80
|
+
new Response(body, { headers: { 'content-type': 'application/json' } }),
|
|
81
|
+
scope,
|
|
82
|
+
)
|
|
83
|
+
return validateNavigation(response, request, body, options.build, r.error ? 500 : r.status)
|
|
84
|
+
}
|
|
85
|
+
}
|