@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
package/src/run.tsx
CHANGED
|
@@ -9,194 +9,66 @@
|
|
|
9
9
|
// the cache). Redirects from loaders and server functions navigate (at most 5 in a row); a
|
|
10
10
|
// mutation that invalidates a tag the current route's loaders read refetches the route data in place.
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
import { App } from './view'
|
|
18
|
-
import { ErrorPage, NotFoundPage } from './builtin'
|
|
12
|
+
import { client, type Component } from './router'
|
|
13
|
+
import { mount, prepare } from './run/boot'
|
|
14
|
+
import { listeners } from './run/listeners'
|
|
15
|
+
import { Navigation } from './run/navigation'
|
|
16
|
+
import { prepareRouteShape } from './run/shape-preparation'
|
|
19
17
|
|
|
20
|
-
type Hot = ImportMeta & {
|
|
18
|
+
type Hot = ImportMeta & {
|
|
19
|
+
hot?: { on(event: string, on: (data: { queries: boolean }) => unknown): void }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Synchronous shape metadata from separately compiled modules. CSS and emitted
|
|
24
|
+
* asset bytes are null until known; consumers must not treat them as available
|
|
25
|
+
* or free. cssText holds generated groups and resolved static entry and level
|
|
26
|
+
* styles, including tokens. cssStatus remains partial.
|
|
27
|
+
* Unavailable covers unproven overrides and unsupported route files.
|
|
28
|
+
*/
|
|
29
|
+
export type RouteShape =
|
|
30
|
+
| { kind: 'disabled'; css: string[] | null; bytes: number | null }
|
|
31
|
+
| {
|
|
32
|
+
kind: 'unavailable'
|
|
33
|
+
reason: 'unresolved-export' | 'unsupported-file' | 'missing-shape'
|
|
34
|
+
css: null
|
|
35
|
+
bytes: null
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
kind: 'generated' | 'override'
|
|
39
|
+
component: Component
|
|
40
|
+
css: string[] | null
|
|
41
|
+
cssText: string[]
|
|
42
|
+
cssStatus: 'partial'
|
|
43
|
+
bytes: number | null
|
|
44
|
+
/** A generated shape with no structure of its own (`$weftShapeThin`): the router stands in for it. */
|
|
45
|
+
thin?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Shape entries are keyed by level id, including layouts and error routes. */
|
|
49
|
+
export type RouteShapes = Record<string, RouteShape>
|
|
21
50
|
|
|
22
51
|
/** The client half of `virtual:weft/routes`: the page routes in match order, and a chunk loader per route id. */
|
|
23
52
|
export interface ClientRoutes {
|
|
53
|
+
build?: string | null
|
|
24
54
|
table: { id: string; pattern: string; layouts: string[] }[]
|
|
25
55
|
load: Record<string, () => Promise<{ default?: Component }>>
|
|
56
|
+
/** Eager shape metadata, keyed by route or layout id. */
|
|
57
|
+
shapes?: RouteShapes
|
|
26
58
|
/** Each island's code, by id: all a static level loads (PLAN §11). */
|
|
27
59
|
islands?: Record<string, () => Promise<{ default: Component }>>
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
/** Hydrate the page in `#app` and take over its links. Resolves once hydrated. */
|
|
31
|
-
const SLOW_FADE = 100 // ms from asking for a fade to it starting
|
|
32
|
-
|
|
33
63
|
export async function start(routes: ClientRoutes, options: { container?: Element } = {}) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
: Promise.resolve(builtin[id] && (comps[id] = builtin[id]))
|
|
45
|
-
const islands: Record<string, Component> = {}
|
|
46
|
-
const island = (id: string) => islands[id] ? undefined : routes.islands![id]!().then(m => { islands[id] = m.default })
|
|
47
|
-
await Promise.all([...page.m.filter(m => !m.s).map(m => chunk(m.id)), ...(page.i ?? []).map(island)])
|
|
48
|
-
const route = createRouteState(page, comps)
|
|
49
|
-
let bag: RoutePayload['b']
|
|
50
|
-
route.mount = (html, depth, next) => staticLevel(html, depth, next, id => islands[id]! as never, bag ?? undefined, id => Promise.resolve(island(id)))
|
|
51
|
-
route.around = (_d, _id, body) => enter(body)
|
|
52
|
-
hydrate(() => <App route={route} />, container, { data })
|
|
53
|
-
client.route = route
|
|
54
|
-
const q = (window as Window & { __wq?: Held }).__wq
|
|
55
|
-
if (q) {
|
|
56
|
-
q.w = wake
|
|
57
|
-
if (q.l) wake(q.l)
|
|
58
|
-
drain()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let current: AbortController | undefined
|
|
62
|
-
let warm: { path: string; at: number; res: Promise<Response> } | undefined
|
|
63
|
-
// A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
|
|
64
|
-
let fades = !(navigator.hardwareConcurrency <= 4)
|
|
65
|
-
// A navigation before the first frame (a moved page's onMount) has no old picture to hold: Chrome aborts that
|
|
66
|
-
// transition and logs an InvalidStateError. Two frames in, the page has painted.
|
|
67
|
-
let painted = false
|
|
68
|
-
requestAnimationFrame(() => requestAnimationFrame(() => (painted = true)))
|
|
69
|
-
async function go(href: string, opts: NavigateOptions & { pop?: boolean; hops?: number } = {}): Promise<void> {
|
|
70
|
-
const url = new URL(href, location.href)
|
|
71
|
-
if (url.origin !== location.origin) return location.assign(url.href)
|
|
72
|
-
current?.abort()
|
|
73
|
-
const ctrl = (current = new AbortController())
|
|
74
|
-
const path = url.pathname + url.search
|
|
75
|
-
for (const id of guess(routes, url.pathname)) void chunk(id) // chunk loads start with the data fetch, not after it
|
|
76
|
-
let next: RoutePayload
|
|
77
|
-
try {
|
|
78
|
-
const held = warm?.path === path && performance.now() - warm.at < 10_000 ? warm.res : undefined
|
|
79
|
-
warm = undefined
|
|
80
|
-
next = await (held ?? ask(url, ctrl.signal)).then(r => r.clone().json())
|
|
81
|
-
} catch (e) {
|
|
82
|
-
if (ctrl.signal.aborted) return
|
|
83
|
-
return location.assign(url.href) // the data request failed: let the server render the page
|
|
84
|
-
}
|
|
85
|
-
if (next.r) {
|
|
86
|
-
const hops = (opts.hops ?? 0) + 1
|
|
87
|
-
return hops > 5 ? location.assign(next.r) : go(next.r, { replace: opts.pop || opts.replace, hops }) // a back/forward to a moved URL replaces its entry
|
|
88
|
-
}
|
|
89
|
-
await Promise.all([...next.m.map(m => chunk(m.id)), ...(next.i ?? []).map(island)])
|
|
90
|
-
if (ctrl.signal.aborted) return
|
|
91
|
-
if (!opts.pop) history[opts.replace ? 'replaceState' : 'pushState'](null, '', url.href)
|
|
92
|
-
at = path
|
|
93
|
-
if (next.c) client.seed ? client.seed(next.c) : (client.seeds ??= []).push(...next.c)
|
|
94
|
-
tags = next.m.map(m => m.t ?? [])
|
|
95
|
-
bag = next.b
|
|
96
|
-
const swap = () => {
|
|
97
|
-
applyMatches(route, next.m.map(revive), path, next.s)
|
|
98
|
-
if (next.m.some(m => m.s)) current_(url.pathname)
|
|
99
|
-
if (next.h) setHead(next.h, url.pathname)
|
|
100
|
-
flush()
|
|
101
|
-
if (opts.scroll !== false && !opts.pop) {
|
|
102
|
-
const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
|
|
103
|
-
target ? target.scrollIntoView() : window.scrollTo(0, 0)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// The old page stays on screen, as a picture, until the new one is ready to fade in. `hidden`: nothing would show it.
|
|
107
|
-
if (fades && painted && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
108
|
-
const started = performance.now()
|
|
109
|
-
const fade = document.startViewTransition(swap)
|
|
110
|
-
void fade.ready.then(() => { if (performance.now() - started > SLOW_FADE) fades = false }, () => {})
|
|
111
|
-
// a transition the browser aborts (a second navigation, a hidden tab) rejects `finished`: the page already swapped
|
|
112
|
-
void fade.finished.catch(() => {})
|
|
113
|
-
await fade.updateCallbackDone
|
|
114
|
-
}
|
|
115
|
-
else swap()
|
|
116
|
-
}
|
|
117
|
-
client.navigate = go
|
|
118
|
-
// Dev: server code changed under the page (vite.mjs). The route's data comes again, in place.
|
|
119
|
-
const refresh = async ({ queries }: { queries: boolean }) => {
|
|
120
|
-
const url = new URL(location.href)
|
|
121
|
-
const res = await ask(url)
|
|
122
|
-
// It doesn't load yet, and the error page would replace what's here: the next save asks again.
|
|
123
|
-
if (!res.ok || (await res.clone().json()).s >= 500) return
|
|
124
|
-
if (queries) client.refreshed?.()
|
|
125
|
-
warm = { path: url.pathname + url.search, at: performance.now(), res: Promise.resolve(res) }
|
|
126
|
-
await go(url.href, { pop: true })
|
|
127
|
-
}
|
|
128
|
-
if ((import.meta as Hot).hot) (import.meta as Hot).hot!.on('weft:refresh', changed => void refresh(changed))
|
|
129
|
-
// A mutation invalidated tags: when the route's loaders read one, refetch the route data (no history entry, no scroll).
|
|
130
|
-
client.invalidated = t => {
|
|
131
|
-
warm = undefined
|
|
132
|
-
if (tags.some(l => l.some(x => t.includes(x)))) void go(location.href, { pop: true })
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
document.addEventListener('click', e => {
|
|
136
|
-
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
137
|
-
const url = ours(e.target)
|
|
138
|
-
if (!url) return
|
|
139
|
-
e.preventDefault()
|
|
140
|
-
void go(url.href)
|
|
141
|
-
})
|
|
142
|
-
// A link the pointer rests on, or the keyboard lands on, has its chunks and data by the time it is pressed.
|
|
143
|
-
// `data-preload="off"` on a link opts out, and so does Save-Data.
|
|
144
|
-
let pending: ReturnType<typeof setTimeout> | undefined
|
|
145
|
-
const intent = (e: Event) => {
|
|
146
|
-
clearTimeout(pending)
|
|
147
|
-
const url = ours(e.target)
|
|
148
|
-
const path = url && url.pathname + url.search
|
|
149
|
-
if (!url || warm?.path === path || (e.target as Element).closest('[data-preload="off"]') || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) return
|
|
150
|
-
pending = setTimeout(() => {
|
|
151
|
-
for (const id of guess(routes, url.pathname)) void chunk(id)
|
|
152
|
-
warm = { path: path!, at: performance.now(), res: ask(url) }
|
|
153
|
-
warm.res.catch(() => { warm = undefined })
|
|
154
|
-
}, e.type === 'focusin' ? 0 : 80)
|
|
155
|
-
}
|
|
156
|
-
document.addEventListener('pointerover', intent)
|
|
157
|
-
document.addEventListener('focusin', intent)
|
|
158
|
-
// An in-page anchor fires this too, and the page hasn't changed: re-rendering it would lose what's been scrolled to.
|
|
159
|
-
let at = location.pathname + location.search
|
|
160
|
-
addEventListener('popstate', () => {
|
|
161
|
-
if (location.pathname + location.search !== at) void go(location.href, { pop: true })
|
|
162
|
-
})
|
|
163
|
-
return route
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const ask = (url: URL, signal?: AbortSignal) => fetch(`/_weft/data${url.pathname === '/' ? '' : url.pathname}${url.search}`, { signal: signal ?? null, headers: { accept: 'application/json' } })
|
|
167
|
-
|
|
168
|
-
/** The URL a client navigation would take from this event's link, or nothing for a link the browser should follow. */
|
|
169
|
-
function ours(target: EventTarget | null) {
|
|
170
|
-
const a = (target as Element | null)?.closest?.('a')
|
|
171
|
-
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download') || a.getAttribute('rel')?.includes('external')) return
|
|
172
|
-
const url = new URL(a.href, location.href)
|
|
173
|
-
if (url.origin !== location.origin || url.pathname.startsWith('/_weft/')) return
|
|
174
|
-
return url.pathname === location.pathname && url.search === location.search && url.hash ? undefined : url // not an in-page anchor
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** A kept static level has no binding for its links' current-page state: the same rule as `Link`, after the swap. */
|
|
178
|
-
function current_(path: string) {
|
|
179
|
-
flush()
|
|
180
|
-
for (const a of document.querySelectorAll('a[aria-current="page"]')) if (a.getAttribute('href') !== path) a.removeAttribute('aria-current')
|
|
181
|
-
for (const a of document.querySelectorAll(`a[href="${CSS.escape(path)}"]:not([aria-current])`)) a.setAttribute('aria-current', 'page')
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Deferred fields arrive resolved on navigation: they become settled promises again, so resources read them alike. */
|
|
185
|
-
function revive(m: Match): Match {
|
|
186
|
-
if (!m.d?.length) return m
|
|
187
|
-
const data = { ...(m.data as Record<string, unknown>) }
|
|
188
|
-
for (const k of m.d) data[k] = Promise.resolve(data[k])
|
|
189
|
-
return { ...m, data }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The route ids `pathname` probably needs (its layouts and page), from the table; the server's answer decides. */
|
|
193
|
-
function guess(routes: ClientRoutes, pathname: string): string[] {
|
|
194
|
-
const parts = pathname.split('/').filter(Boolean)
|
|
195
|
-
for (const r of routes.table) {
|
|
196
|
-
const segs = r.pattern.split('/').filter(Boolean)
|
|
197
|
-
const splat = segs.at(-1) === '$'
|
|
198
|
-
if (splat ? parts.length < segs.length - 1 : parts.length !== segs.length) continue
|
|
199
|
-
if (segs.every((s, i) => s === '$' || s.startsWith('$') || s === decodeURIComponent(parts[i] ?? ''))) return [...r.layouts, r.id]
|
|
200
|
-
}
|
|
201
|
-
return []
|
|
64
|
+
const prepared = prepare(routes, options)
|
|
65
|
+
await Promise.all(prepared.loads)
|
|
66
|
+
const page = mount(prepared)
|
|
67
|
+
page.route.prepareShape = (id) => prepareRouteShape(routes, id, document)
|
|
68
|
+
const navigation = new Navigation(routes, page)
|
|
69
|
+
client.navigate = navigation.go
|
|
70
|
+
listeners(navigation, () => (import.meta as Hot).hot)
|
|
71
|
+
// The router follows links from here: the browser's prefetching for full loads (capture.ts `speculate`) stops.
|
|
72
|
+
document.getElementById('weft-speculate')?.remove()
|
|
73
|
+
return page.route
|
|
202
74
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { raise, StartError, type Context, type Fn } from '../data'
|
|
2
|
+
import type { Claims, RemoteResult } from '../rust'
|
|
3
|
+
type User = Context extends { user: infer U } ? U | null : unknown
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_TIMEOUT = 10_000
|
|
6
|
+
export const encoder = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
// An app's tsc may have no Node types, so what's read of `process` is named here.
|
|
9
|
+
export const nodeHost = globalThis as unknown as {
|
|
10
|
+
process?: {
|
|
11
|
+
env: Record<string, string | undefined>
|
|
12
|
+
platform: string
|
|
13
|
+
arch: string
|
|
14
|
+
getBuiltinModule?(name: string): unknown
|
|
15
|
+
dlopen(mod: object, file: string): void
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const env = (name: string): string | undefined => nodeHost.process?.env?.[name] || undefined
|
|
19
|
+
export const randomHex = (bytes: number) =>
|
|
20
|
+
Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (x) =>
|
|
21
|
+
x.toString(16).padStart(2, '0'),
|
|
22
|
+
).join('')
|
|
23
|
+
|
|
24
|
+
export const defaultClaims = (user: User): Claims | null => {
|
|
25
|
+
const u = user as { id: unknown; roles?: string[]; role?: string; tenant?: string | null } | null
|
|
26
|
+
return (
|
|
27
|
+
u && {
|
|
28
|
+
id: String(u.id),
|
|
29
|
+
roles: u.roles ?? (u.role ? [u.role] : []),
|
|
30
|
+
...(u.tenant != null && { tenant: u.tenant }),
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TRACEPARENT = /^00-(?!0{32})[0-9a-f]{32}-(?!0{16})[0-9a-f]{16}-[0-9a-f]{2}$/
|
|
36
|
+
|
|
37
|
+
/** The caller's trace with a new span, or a new trace. */
|
|
38
|
+
function traceparent(request: Request): string {
|
|
39
|
+
const inbound = request.headers.get('traceparent')
|
|
40
|
+
const trace = inbound && TRACEPARENT.test(inbound) ? inbound.split('-')[1] : randomHex(16)
|
|
41
|
+
return `00-${trace}-${randomHex(8)}-01`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The value as a sidecar reads it: what the schema parsed, encoded back to the wire shape (codecs, defaults filled). */
|
|
45
|
+
export function wire(fn: Fn, input: unknown): unknown {
|
|
46
|
+
const schema = fn.def.input as { encodeAsync?: (value: unknown) => Promise<unknown> } | undefined
|
|
47
|
+
if (typeof schema?.encodeAsync === 'function') return schema.encodeAsync(input)
|
|
48
|
+
return input
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function checkedOutput(fn: Fn, value: unknown, validate: boolean): Promise<unknown> {
|
|
52
|
+
const schema = fn.def.output
|
|
53
|
+
if (!schema || !validate) return value
|
|
54
|
+
const r = await schema['~standard'].validate(value)
|
|
55
|
+
if (!r.issues) return r.value
|
|
56
|
+
const issues = r.issues
|
|
57
|
+
.map(
|
|
58
|
+
(i) =>
|
|
59
|
+
`${i.path?.map((p) => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`,
|
|
60
|
+
)
|
|
61
|
+
.join('; ')
|
|
62
|
+
throw new StartError(
|
|
63
|
+
'R103',
|
|
64
|
+
`\`${fn.$id}\`'s sidecar returned a value its \`output\` schema rejects: ${issues}`,
|
|
65
|
+
'the sidecar was built from another contract, or sets a field the schema refuses: rebuild it, or fix the schema',
|
|
66
|
+
{ status: 502 },
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A declared error: its data checked like any output, with the status a client can act on. */
|
|
71
|
+
async function declared(fn: Fn, error: { code: string; data?: unknown }): Promise<never> {
|
|
72
|
+
const schema = fn.def.errors?.[error.code]
|
|
73
|
+
if (!schema)
|
|
74
|
+
throw new StartError(
|
|
75
|
+
'R103',
|
|
76
|
+
`\`${fn.$id}\`'s sidecar answered with the undeclared error \`${error.code}\``,
|
|
77
|
+
'declare it in `errors`, or rebuild the sidecar from the current contract',
|
|
78
|
+
{ status: 502 },
|
|
79
|
+
)
|
|
80
|
+
const r = await schema['~standard'].validate(error.data ?? null)
|
|
81
|
+
if (r.issues)
|
|
82
|
+
throw new StartError(
|
|
83
|
+
'R103',
|
|
84
|
+
`\`${fn.$id}\`'s sidecar sent \`${error.code}\` with data its schema rejects`,
|
|
85
|
+
'rebuild the sidecar from the current contract',
|
|
86
|
+
{ status: 502 },
|
|
87
|
+
)
|
|
88
|
+
raise(error.code, r.value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A shared value goes by no one's claims: the crate's `SharedCtx` has no user to read, and neither has the wire. */
|
|
92
|
+
export function callContext(
|
|
93
|
+
fn: Fn,
|
|
94
|
+
ctx: Context,
|
|
95
|
+
request: Request,
|
|
96
|
+
timeout: number,
|
|
97
|
+
claims: (user: User) => Claims | null,
|
|
98
|
+
) {
|
|
99
|
+
const shared = fn.def.cache && fn.def.cache.scope !== 'user'
|
|
100
|
+
return {
|
|
101
|
+
user: shared ? null : (claims((ctx as { user?: User }).user ?? null) ?? null),
|
|
102
|
+
requestId: randomHex(8),
|
|
103
|
+
deadlineMs: timeout,
|
|
104
|
+
traceparent: traceparent(request),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What a reply comes to: the same for a sidecar's body and an addon's string. */
|
|
109
|
+
export async function settled(
|
|
110
|
+
fn: Fn,
|
|
111
|
+
reply: Reply | undefined,
|
|
112
|
+
target: string,
|
|
113
|
+
timeout: number,
|
|
114
|
+
validate: boolean,
|
|
115
|
+
): Promise<RemoteResult> {
|
|
116
|
+
if (reply?.ok === true)
|
|
117
|
+
return {
|
|
118
|
+
value: await checkedOutput(fn, reply.value, validate),
|
|
119
|
+
tags: reply.tags ?? [],
|
|
120
|
+
touched: reply.touched ?? [],
|
|
121
|
+
}
|
|
122
|
+
const error = reply?.error
|
|
123
|
+
if (error?.code === 'Refused')
|
|
124
|
+
throw new StartError(
|
|
125
|
+
'R106',
|
|
126
|
+
`\`${target}\` refused \`${fn.$id}\`'s signature`,
|
|
127
|
+
'`WEFT_RUST_SECRET` must be the same on both sides, and the clocks within a minute',
|
|
128
|
+
{ status: 503 },
|
|
129
|
+
)
|
|
130
|
+
if (error?.code === 'Deadline')
|
|
131
|
+
throw new StartError(
|
|
132
|
+
'R107',
|
|
133
|
+
`\`${fn.$id}\` ran past its ${timeout} ms`,
|
|
134
|
+
'raise `timeout`, or make the Rust answer sooner',
|
|
135
|
+
{ status: 504 },
|
|
136
|
+
)
|
|
137
|
+
if (error?.code === 'Drift')
|
|
138
|
+
throw new StartError(
|
|
139
|
+
'R103',
|
|
140
|
+
`\`${fn.$id}\` doesn't match \`${target}\`: ${error.message}`,
|
|
141
|
+
'rebuild it from the current contract',
|
|
142
|
+
{ status: 502 },
|
|
143
|
+
)
|
|
144
|
+
if (error?.code && error.code !== 'Internal')
|
|
145
|
+
await declared(fn, { code: error.code, data: error.data })
|
|
146
|
+
// The detail stays in a log, under this id: a sidecar's own, or this server's for what ran in this process.
|
|
147
|
+
if (error?.detail)
|
|
148
|
+
console.error(`[weft] \`${fn.$id}\` failed (request ${error.requestId}): ${error.detail}`)
|
|
149
|
+
throw new StartError(
|
|
150
|
+
'R108',
|
|
151
|
+
`\`${fn.$id}\` failed in \`${target}\` (request ${error?.requestId ?? 'unknown'})`,
|
|
152
|
+
"find the request id in the server's or the sidecar's log",
|
|
153
|
+
{ status: 500 },
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface Reply {
|
|
158
|
+
ok?: boolean
|
|
159
|
+
value?: unknown
|
|
160
|
+
tags?: string[]
|
|
161
|
+
touched?: string[]
|
|
162
|
+
error?: { code?: string; message?: string; detail?: string; requestId?: string; data?: unknown }
|
|
163
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { StartError } from '../data'
|
|
2
|
+
import type { Native, NativeOptions } from '../rust'
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_TIMEOUT,
|
|
5
|
+
callContext,
|
|
6
|
+
defaultClaims,
|
|
7
|
+
encoder,
|
|
8
|
+
env,
|
|
9
|
+
nodeHost,
|
|
10
|
+
settled,
|
|
11
|
+
wire,
|
|
12
|
+
} from './common'
|
|
13
|
+
|
|
14
|
+
const NAME = /^[a-z][a-z0-9_-]*$/
|
|
15
|
+
|
|
16
|
+
// ---- native ---------------------------------------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** The exports a `.wasm` built by the crate has. */
|
|
19
|
+
interface Wasm {
|
|
20
|
+
memory: WebAssembly.Memory
|
|
21
|
+
weft_init: () => void
|
|
22
|
+
weft_free: (ptr: number, len: number) => void
|
|
23
|
+
weft_alloc: (len: number) => number
|
|
24
|
+
weft_contract: () => bigint
|
|
25
|
+
weft_call: (
|
|
26
|
+
...args: [i: number, il: number, c: number, cl: number, b: number, bl: number]
|
|
27
|
+
) => bigint
|
|
28
|
+
}
|
|
29
|
+
interface Engine {
|
|
30
|
+
kind: 'addon' | 'wasm'
|
|
31
|
+
call(id: string, ctx: string, body: string): Promise<string> | string
|
|
32
|
+
}
|
|
33
|
+
interface Entry {
|
|
34
|
+
kind?: string
|
|
35
|
+
hash: string
|
|
36
|
+
addon?: string
|
|
37
|
+
wasm?: string
|
|
38
|
+
failure?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReadFile {
|
|
42
|
+
(file: string, encoding: 'utf8'): string
|
|
43
|
+
(file: string): Uint8Array<ArrayBuffer>
|
|
44
|
+
}
|
|
45
|
+
interface Builtins {
|
|
46
|
+
'node:fs': { existsSync(file: string): boolean; readFileSync: ReadFile }
|
|
47
|
+
'node:path': { dirname(path: string): string; join(...parts: string[]): string }
|
|
48
|
+
'node:url': { fileURLToPath(url: string): string }
|
|
49
|
+
}
|
|
50
|
+
const builtin = <K extends keyof Builtins>(name: K) =>
|
|
51
|
+
nodeHost.process?.getBuiltinModule?.(name) as Builtins[K] | undefined
|
|
52
|
+
const platform = () => {
|
|
53
|
+
const p = nodeHost.process
|
|
54
|
+
return `${p?.platform}-${p?.arch}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `.output/server/rust/` from wherever the bundle put this code. `WEFT_NATIVE_DIR` is `weft dev`'s, and `weft build`'s. */
|
|
58
|
+
function nativeDir(origin: string): string | undefined {
|
|
59
|
+
const fs = builtin('node:fs'),
|
|
60
|
+
path = builtin('node:path')!,
|
|
61
|
+
url = builtin('node:url')!
|
|
62
|
+
if (!fs) return undefined
|
|
63
|
+
const set = env('WEFT_NATIVE_DIR')
|
|
64
|
+
if (set) return set
|
|
65
|
+
let dir = path.dirname(url.fileURLToPath(origin))
|
|
66
|
+
for (let i = 0; i < 6; i++, dir = path.dirname(dir))
|
|
67
|
+
if (fs.existsSync(path.join(dir, 'rust', 'manifest.json'))) return path.join(dir, 'rust')
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function addonEngine(file: string, hash: string, target: string): Engine {
|
|
72
|
+
const mod = { exports: {} as { contract: () => string; call: Engine['call'] } }
|
|
73
|
+
nodeHost.process!.dlopen(mod, file)
|
|
74
|
+
const built = JSON.parse(mod.exports.contract())
|
|
75
|
+
if (built.hash !== hash)
|
|
76
|
+
throw new StartError(
|
|
77
|
+
'R103',
|
|
78
|
+
`\`${target}\`'s library was built from contract ${built.hash}, and this app's is ${hash}`,
|
|
79
|
+
'rebuild it: `weft build`',
|
|
80
|
+
{ status: 502 },
|
|
81
|
+
)
|
|
82
|
+
return { kind: 'addon', call: mod.exports.call }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The same functions as a `.wasm`, on this thread. A panic traps, which ends an instance: what it said arrives first,
|
|
87
|
+
* and the next call gets a new one, as a sidecar's crash gets a restart.
|
|
88
|
+
*/
|
|
89
|
+
async function wasmEngine(file: string, hash: string, target: string): Promise<Engine> {
|
|
90
|
+
const module = await WebAssembly.compile(builtin('node:fs')!.readFileSync(file))
|
|
91
|
+
const decoder = new TextDecoder()
|
|
92
|
+
let said = ''
|
|
93
|
+
let x: Wasm | undefined
|
|
94
|
+
const text = (ptr: number, len: number) =>
|
|
95
|
+
decoder.decode(new Uint8Array(x!.memory.buffer, ptr, len))
|
|
96
|
+
const fresh = () => {
|
|
97
|
+
x = new WebAssembly.Instance(module, {
|
|
98
|
+
weft: {
|
|
99
|
+
now: () => performance.now(),
|
|
100
|
+
panicked: (ptr: number, len: number) => {
|
|
101
|
+
said = text(ptr, len)
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}).exports as unknown as Wasm
|
|
105
|
+
x.weft_init()
|
|
106
|
+
}
|
|
107
|
+
const taken = (packed: bigint) => {
|
|
108
|
+
const ptr = Number(packed >> 32n),
|
|
109
|
+
len = Number(packed & 0xffffffffn)
|
|
110
|
+
const out = text(ptr, len)
|
|
111
|
+
x!.weft_free(ptr, len)
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
const put = (s: string): [number, number] => {
|
|
115
|
+
const bytes = encoder.encode(s)
|
|
116
|
+
const ptr = x!.weft_alloc(bytes.length)
|
|
117
|
+
new Uint8Array(x!.memory.buffer, ptr, bytes.length).set(bytes)
|
|
118
|
+
return [ptr, bytes.length]
|
|
119
|
+
}
|
|
120
|
+
fresh()
|
|
121
|
+
const built = JSON.parse(taken(x!.weft_contract()))
|
|
122
|
+
if (built.hash !== hash)
|
|
123
|
+
throw new StartError(
|
|
124
|
+
'R103',
|
|
125
|
+
`\`${target}\`'s library was built from contract ${built.hash}, and this app's is ${hash}`,
|
|
126
|
+
'rebuild it: `weft build`',
|
|
127
|
+
{ status: 502 },
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
kind: 'wasm',
|
|
131
|
+
call(id, ctx, body) {
|
|
132
|
+
if (!x) fresh()
|
|
133
|
+
const [i, il] = put(id),
|
|
134
|
+
[c, cl] = put(ctx),
|
|
135
|
+
[b, bl] = put(body)
|
|
136
|
+
try {
|
|
137
|
+
const reply = taken(x!.weft_call(i, il, c, cl, b, bl))
|
|
138
|
+
x!.weft_free(i, il)
|
|
139
|
+
x!.weft_free(c, cl)
|
|
140
|
+
return reply
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (!(e instanceof WebAssembly.RuntimeError)) throw e
|
|
143
|
+
x = undefined
|
|
144
|
+
return JSON.stringify({
|
|
145
|
+
ok: false,
|
|
146
|
+
error: {
|
|
147
|
+
code: 'Internal',
|
|
148
|
+
requestId: JSON.parse(ctx).requestId,
|
|
149
|
+
detail: said || e.message,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function load(dir: string, entry: Entry, target: string): Promise<Engine> {
|
|
158
|
+
const fs = builtin('node:fs')!,
|
|
159
|
+
path = builtin('node:path')!
|
|
160
|
+
const addon = entry.addon && path.join(dir, entry.addon)
|
|
161
|
+
if (addon && fs.existsSync(addon)) {
|
|
162
|
+
try {
|
|
163
|
+
return addonEngine(addon, entry.hash, target)
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e instanceof StartError) throw e
|
|
166
|
+
console.warn(
|
|
167
|
+
`[weft] ${target}: the addon didn't load here (${String((e as { message?: unknown }).message).split('\n')[0]})${entry.wasm ? ': using its WASM' : ''}`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (entry.wasm) return wasmEngine(path.join(dir, entry.wasm), entry.hash, target)
|
|
172
|
+
throw new StartError(
|
|
173
|
+
'R104',
|
|
174
|
+
`\`${target}\` has no addon for ${platform()} and no WASM`,
|
|
175
|
+
'build on this platform, drop `wasm: false` from `rust.services`, or use `remote()`',
|
|
176
|
+
{ status: 503 },
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function engineFor(name: string, target: string, origin: string) {
|
|
181
|
+
let loaded: Promise<Engine> | undefined, stamp: string | undefined
|
|
182
|
+
|
|
183
|
+
/** The engine for now. Under `weft dev` a rebuild is a new file: the next call loads it, and calls on the old one finish. */
|
|
184
|
+
function engine(): Promise<Engine> {
|
|
185
|
+
const fs = builtin('node:fs')!,
|
|
186
|
+
path = builtin('node:path')!
|
|
187
|
+
const dir = nativeDir(origin)
|
|
188
|
+
if (!dir)
|
|
189
|
+
throw new StartError(
|
|
190
|
+
'R104',
|
|
191
|
+
`\`${target}\` has nothing to load here`,
|
|
192
|
+
'this runtime has no file system, or the build has no `rust/` folder: use `remote()`, or run `weft build`',
|
|
193
|
+
{ status: 503 },
|
|
194
|
+
)
|
|
195
|
+
const file = path.join(dir, 'manifest.json')
|
|
196
|
+
const watching = !!env('WEFT_NATIVE_DIR')
|
|
197
|
+
// Its text, not its mtime: two writes can land in one tick of a coarse clock.
|
|
198
|
+
const text: string | undefined =
|
|
199
|
+
watching && fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : undefined
|
|
200
|
+
if (loaded && text === stamp) return loaded
|
|
201
|
+
const entry: Entry | undefined = fs.existsSync(file)
|
|
202
|
+
? JSON.parse(text ?? fs.readFileSync(file, 'utf8'))[name]
|
|
203
|
+
: undefined
|
|
204
|
+
if (!entry)
|
|
205
|
+
throw new StartError(
|
|
206
|
+
'R104',
|
|
207
|
+
`\`${target}\` isn't in this build`,
|
|
208
|
+
'run `weft build`, or check `crates/' + name + '`',
|
|
209
|
+
{ status: 503 },
|
|
210
|
+
)
|
|
211
|
+
if (entry.failure)
|
|
212
|
+
throw new StartError(
|
|
213
|
+
'R103',
|
|
214
|
+
`\`${target}\` didn't build: ${entry.failure}`,
|
|
215
|
+
'fix the crate: `weft dev` retries on every edit',
|
|
216
|
+
{ status: 503 },
|
|
217
|
+
)
|
|
218
|
+
stamp = text
|
|
219
|
+
const held = loaded
|
|
220
|
+
const next = load(dir, entry, target)
|
|
221
|
+
// A bad build keeps what was serving, as a sidecar's does.
|
|
222
|
+
loaded = next.catch((e) => {
|
|
223
|
+
if (held) {
|
|
224
|
+
loaded = held
|
|
225
|
+
return held
|
|
226
|
+
}
|
|
227
|
+
loaded = undefined
|
|
228
|
+
throw e
|
|
229
|
+
})
|
|
230
|
+
return loaded
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return engine
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A Rust library implementing the functions that point at it, in this process, as `impl: media`. An addon runs each call
|
|
238
|
+
* on the worker pool, so the event loop stays free; where one can't load, its `.wasm` runs them on this thread. Either
|
|
239
|
+
* way the declaration, the client and the caches are the same as for `run`.
|
|
240
|
+
*/
|
|
241
|
+
export function createNative(name: string, options: NativeOptions, origin: string): Native {
|
|
242
|
+
if (!NAME.test(name))
|
|
243
|
+
throw new StartError(
|
|
244
|
+
'R104',
|
|
245
|
+
`\`native('${name}')\` isn't a service name`,
|
|
246
|
+
"`native('media')`, for the crate at `crates/media`",
|
|
247
|
+
)
|
|
248
|
+
const { timeout = DEFAULT_TIMEOUT, claims = defaultClaims, validate = true } = options
|
|
249
|
+
const target = `native('${name}')`
|
|
250
|
+
// Before the pool's first use: its default of 4 is a ceiling on calls at once that an app would never think to raise.
|
|
251
|
+
const proc = nodeHost.process
|
|
252
|
+
if (proc?.env && !proc.env.UV_THREADPOOL_SIZE)
|
|
253
|
+
proc.env.UV_THREADPOOL_SIZE = String(
|
|
254
|
+
Math.max(4, globalThis.navigator?.hardwareConcurrency ?? 4),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
const engine = engineFor(name, target, origin)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
kind: 'native',
|
|
261
|
+
target: `native://${name}`,
|
|
262
|
+
service: name,
|
|
263
|
+
engine: async () => (await engine()).kind,
|
|
264
|
+
async call({ fn, input, ctx, request }) {
|
|
265
|
+
const context = JSON.stringify(callContext(fn, ctx, request, timeout, claims))
|
|
266
|
+
const body = JSON.stringify({ input: (await wire(fn, input)) ?? null })
|
|
267
|
+
const running = (await engine()).call(fn.$id, context, body)
|
|
268
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
269
|
+
const reply =
|
|
270
|
+
typeof running === 'string'
|
|
271
|
+
? running
|
|
272
|
+
: await Promise.race([
|
|
273
|
+
running,
|
|
274
|
+
new Promise<string>((ok) => {
|
|
275
|
+
timer = setTimeout(ok, timeout, '{"ok":false,"error":{"code":"Deadline"}}')
|
|
276
|
+
}),
|
|
277
|
+
])
|
|
278
|
+
clearTimeout(timer)
|
|
279
|
+
return settled(fn, JSON.parse(reply), target, timeout, validate)
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|