@weft-js/start 0.1.1 → 0.1.3
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 +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -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/vite.mjs
CHANGED
|
@@ -25,875 +25,174 @@
|
|
|
25
25
|
// routes' CSS stays out of the page. In dev the links stand in for Vite's injected `<style>`s and HMR updates them.
|
|
26
26
|
// A build keeps each chunk's sheet for client navigation and writes a shared sheet (what every route has) and one sheet
|
|
27
27
|
// per route for the rest, hashed and cached for good. Each page carries the part of those its first paint needs
|
|
28
|
-
// (`firstPaint`) and links them at the end of its shell, where they block nothing above and hold whatever streams after.
|
|
29
|
-
// `site: {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
routes: 'virtual:weft/routes',
|
|
49
|
-
app: 'virtual:weft/app',
|
|
50
|
-
server: 'virtual:weft/entry-server',
|
|
51
|
-
client: 'virtual:weft/entry-client',
|
|
52
|
-
fns: 'virtual:weft/fns',
|
|
53
|
-
context: 'virtual:weft/context',
|
|
54
|
-
auth: 'virtual:weft/auth',
|
|
55
|
-
migrations: 'virtual:weft/migrations',
|
|
56
|
-
media: 'virtual:weft/media',
|
|
57
|
-
realtime: 'virtual:weft/realtime',
|
|
58
|
-
extensions: 'virtual:weft/extensions',
|
|
59
|
-
}
|
|
60
|
-
const SERVER_EXPORTS = new Set(['load', 'meta', 'prerender'])
|
|
61
|
-
const DEFAULT_FONTS = ['Inter:400,700']
|
|
62
|
-
const SHEET = '/@weft-sheet/'
|
|
63
|
-
const ASSETS = '?weft-assets'
|
|
64
|
-
const ISLAND = 'virtual:weft/island/'
|
|
65
|
-
const PRELOAD = 'Per page, from the HTML: an island that loads with the page, or a lazy one in about the first 900px, is modulepreloaded (fetched, not run). The rest load when needed. Save-Data: lazy ones are not preloaded, and wait for interaction.'
|
|
66
|
-
const NOT_ASSET = /\.[cm]?[jt]sx?$/
|
|
67
|
-
const INLINE_SHEETS = '__WEFT_INLINE_SHEETS__'
|
|
68
|
-
const SHEET_PIECES = '__WEFT_SHEET_PIECES__'
|
|
28
|
+
// (`firstPaint`) and links them at the end of its shell, where they block nothing above and hold whatever streams after.
|
|
29
|
+
// `site: { criticalCss: false }` splits by chunk and puts them in the HTML (`inlineSheets`, inline.mjs): whole while the
|
|
30
|
+
// document fits the first flight, else the rules the page's HTML reaches, with the sheets behind them as links that
|
|
31
|
+
// don't hold the paint. `site: { inlineCss: false }` keeps plain links.
|
|
32
|
+
|
|
33
|
+
import { existsSync } from 'node:fs'
|
|
34
|
+
import { relative } from 'node:path'
|
|
35
|
+
import { createState } from './vite/state.mjs'
|
|
36
|
+
import { shapeSignature } from './vite/route-modules.mjs'
|
|
37
|
+
import { entryCandidate, entryCssSignature, entryWatchLifecycle } from './vite/shape-entry-css.mjs'
|
|
38
|
+
import { V } from './vite/shared.mjs'
|
|
39
|
+
import { configHook, writeBundle, renderChunk, configResolved } from './vite/config-hooks.mjs'
|
|
40
|
+
import { configureServer, hotUpdate } from './vite/dev-hooks.mjs'
|
|
41
|
+
import { resolveId, load, transform, api } from './vite/module-hooks.mjs'
|
|
42
|
+
export { assetsOnly } from './vite/assets.mjs'
|
|
43
|
+
export { inlineSheets, sheetPieces } from './vite/sheets.mjs'
|
|
44
|
+
export { stripServerExports } from './vite/syntax.mjs'
|
|
45
|
+
export { isServerModule, fnId, withIds, clientStubs } from './vite/server-functions.mjs'
|
|
46
|
+
export { scanRoutes, routeTypes } from './routes.mjs'
|
|
47
|
+
export { here as startDir } from './vite/shared.mjs'
|
|
69
48
|
|
|
70
49
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* close its `<style>`.
|
|
50
|
+
* A signature of what a save means for the shapes, or undefined when it doesn't parse. A broken save takes ordinary HMR
|
|
51
|
+
* and its overlay: reloading onto it would leave the tab on the server's error page, which no fix reaches.
|
|
74
52
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
53
|
+
const parsed = (signature, file) => {
|
|
54
|
+
try {
|
|
55
|
+
return signature(file)
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reload when an edit changes shape metadata embedded in the client route module. */
|
|
62
|
+
export function shapeHotUpdate(state, modes, next) {
|
|
63
|
+
return async function (update) {
|
|
64
|
+
if (this.environment.config.consumer === 'client') {
|
|
65
|
+
const pending =
|
|
66
|
+
state.shapeScanActive === state.shapeScanGeneration && state.shapeScanActive !== undefined
|
|
67
|
+
if (pending) state.shapeScanGeneration++
|
|
68
|
+
const owners = [...(state.shapeOverrideDependencies ?? [])].flatMap(([file, routes]) =>
|
|
69
|
+
entryCandidate(new Set([file]), update.file) ? [...routes] : [],
|
|
70
|
+
)
|
|
71
|
+
if (owners.length) {
|
|
72
|
+
if (
|
|
73
|
+
update.type === 'update' &&
|
|
74
|
+
entryCandidate(state.shapeEntryMissing, update.file) &&
|
|
75
|
+
existsSync(update.file)
|
|
76
|
+
)
|
|
77
|
+
await this.environment.pluginContainer.watchChange(update.file, { event: 'create' })
|
|
78
|
+
const graph = this.environment.moduleGraph
|
|
79
|
+
for (const file of owners) {
|
|
80
|
+
const id = `\0virtual:weft/shape-override/${encodeURIComponent(file)}.tsx`
|
|
81
|
+
const asset = graph.getModuleById(id)
|
|
82
|
+
if (asset) graph.invalidateModule(asset)
|
|
83
|
+
}
|
|
84
|
+
const mod = graph.getModuleById('\0' + V.routes)
|
|
85
|
+
if (mod) graph.invalidateModule(mod)
|
|
86
|
+
state.vite?.ws.send({ type: 'full-reload' })
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
const rel = relative(state.root, update.file).split('\\').join('/')
|
|
90
|
+
const entrySignatures = state.shapeEntrySignatures
|
|
91
|
+
if (entrySignatures?.has(update.file)) {
|
|
92
|
+
const signature = parsed(entryCssSignature, update.file)
|
|
93
|
+
const previous = entrySignatures.get(update.file)
|
|
94
|
+
if (signature !== undefined) entrySignatures.set(update.file, signature)
|
|
95
|
+
if (signature !== undefined && previous !== signature) {
|
|
96
|
+
const graph = this.environment.moduleGraph
|
|
97
|
+
const mod = graph.getModuleById('\0' + V.routes)
|
|
98
|
+
if (mod) graph.invalidateModule(mod)
|
|
99
|
+
state.vite?.ws.send({ type: 'full-reload' })
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const candidateEvent =
|
|
104
|
+
(update.type === 'create' || update.type === 'delete') &&
|
|
105
|
+
entryCandidate(state.shapeEntryCandidates, update.file)
|
|
106
|
+
const missingUpdate =
|
|
107
|
+
update.type === 'update' && entryCandidate(state.shapeEntryMissing, update.file)
|
|
108
|
+
if (candidateEvent || missingUpdate) {
|
|
109
|
+
// A watched missing path may arrive as an update; Vite clears negative resolution on create.
|
|
110
|
+
if (missingUpdate && existsSync(update.file))
|
|
111
|
+
await this.environment.pluginContainer.watchChange(update.file, { event: 'create' })
|
|
112
|
+
const graph = this.environment.moduleGraph
|
|
113
|
+
const mod = graph.getModuleById('\0' + V.routes)
|
|
114
|
+
if (mod) graph.invalidateModule(mod)
|
|
115
|
+
state.vite?.ws.send({ type: 'full-reload' })
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
const levels = [...state.manifest.routes, ...state.manifest.layouts]
|
|
119
|
+
if (levels.some((level) => level.file === rel) || state.clientEntry?.() === update.file) {
|
|
120
|
+
const mode = parsed(shapeSignature, update.file)
|
|
121
|
+
const before = modes.get(rel)
|
|
122
|
+
if (mode !== undefined) modes.set(rel, mode)
|
|
123
|
+
if (mode !== undefined && before !== mode) {
|
|
124
|
+
const graph = this.environment.moduleGraph
|
|
125
|
+
const mod = graph.getModuleById('\0' + V.routes)
|
|
126
|
+
if (mod) graph.invalidateModule(mod)
|
|
127
|
+
state.vite?.ws.send({ type: 'full-reload' })
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (pending) {
|
|
132
|
+
const graph = this.environment.moduleGraph
|
|
133
|
+
const mod = graph.getModuleById('\0' + V.routes)
|
|
134
|
+
if (mod) graph.invalidateModule(mod)
|
|
135
|
+
state.vite?.ws.send({ type: 'full-reload' })
|
|
136
|
+
return []
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return next.call(this, update)
|
|
128
140
|
}
|
|
129
|
-
const shape = s => s && { href: href(s.file), has: s.has.map(href) }
|
|
130
|
-
return { ...pieces(sheets, keep), shared: shape(plan.shared), routes: plan.routes.map(shape) }
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
/**
|
|
134
|
-
* @param {{ root?: string, site?: import('@weft-js/core/ssr').Site, routesDir?: string, timeout?: number, fonts?: string[], hydration?: 'lazy' | 'partial' | 'full' }} [options]
|
|
144
|
+
* @param {{ root?: string, site?: import('@weft-js/core/ssr').Site, routesDir?: string, timeout?: number, hold?: number, fonts?: string[], hydration?: 'lazy' | 'partial' | 'full' }} [options]
|
|
135
145
|
*/
|
|
136
146
|
export default function weftStart(options = {}) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
let plan
|
|
148
|
-
let sheetPlan
|
|
149
|
-
let planned = 0
|
|
150
|
-
const facts = new Map()
|
|
151
|
-
/** Partial hydration's view of the project (PLAN §11), redone in dev when a file it read changed. */
|
|
152
|
-
const hydration = async ctx => {
|
|
153
|
-
if (options.hydration === 'full' || !compile) return undefined
|
|
154
|
-
if (plan && !dev) return plan
|
|
155
|
-
if (plan && Date.now() - planned < 200) return plan
|
|
156
|
-
planned = Date.now()
|
|
157
|
-
if (plan) {
|
|
158
|
-
const p = await plan
|
|
159
|
-
if (!p.stamps.some(f => statSync(f, { throwIfNoEntry: false })?.mtimeMs !== facts.get(f)?.mtime)) return p
|
|
160
|
-
}
|
|
161
|
-
const entries = [...manifest.routes, ...manifest.layouts].map(r => abs(r.file))
|
|
162
|
-
plan = solve({ entries, lazy: (options.hydration ?? 'lazy') === 'lazy', transform: compile, rel: f => relative(root, f).split('\\').join('/'), cache: facts, resolve: async (spec, from) => (await ctx.resolve(spec, from, { skipSelf: true }))?.id })
|
|
163
|
-
const done = await plan
|
|
164
|
-
try {
|
|
165
|
-
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
166
|
-
writeFileSync(join(root, '.weft', 'hydration.json'), `${JSON.stringify({ islands: done.islands.map(i => i.id), loads: done.loads, preload: PRELOAD, diagnostics: done.diagnostics, files: done.report }, null, 2)}\n`)
|
|
167
|
-
} catch {}
|
|
168
|
-
return done
|
|
169
|
-
}
|
|
170
|
-
let words
|
|
171
|
-
const scan = () => {
|
|
172
|
-
serverModules = scanServerModules(root)
|
|
173
|
-
return (manifest = scanRoutes(root, options.routesDir ? resolve(root, options.routesDir) : undefined))
|
|
174
|
-
}
|
|
175
|
-
const abs = f => resolve(root, f)
|
|
176
|
-
const id = s => (s.startsWith('virtual:') ? '\0' + s : s)
|
|
177
|
-
let shapes
|
|
178
|
-
/** Research 31 §3: the shape table, when the project asks for shapes at all. Loads the server modules, once per edit. */
|
|
179
|
-
const shapeTable = async ctx => {
|
|
180
|
-
if (!usesShapes(root)) return undefined
|
|
181
|
-
const stamp = serverModules.map(f => statSync(abs(f)).mtimeMs).join()
|
|
182
|
-
if (shapes?.stamp === stamp) return shapes.table
|
|
183
|
-
try {
|
|
184
|
-
const { readShapes } = await import('./contract.mjs')
|
|
185
|
-
shapes = { stamp, table: await readShapes({ root, site: options.site }) }
|
|
186
|
-
return shapes.table
|
|
187
|
-
} catch (e) {
|
|
188
|
-
ctx.warn(`the query shapes couldn't be read, so skeletons only have last-seen shapes: ${e.message}`)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const clientEntry = () => (existsSync(join(root, 'src', 'entry.client.ts')) ? join(root, 'src', 'entry.client.ts') : V.client)
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
name: 'weft:start',
|
|
195
|
-
enforce: 'pre',
|
|
196
|
-
config(config, env) {
|
|
197
|
-
root = config.root ? resolve(config.root) : root
|
|
198
|
-
dev = env.command === 'serve'
|
|
199
|
-
scan()
|
|
200
|
-
return {
|
|
201
|
-
environments: {
|
|
202
|
-
client: { build: { rollupOptions: { input: clientEntry() } } },
|
|
203
|
-
ssr: { build: { rollupOptions: { input: V.server } } },
|
|
204
|
-
},
|
|
205
|
-
// The router-core server bundle and Takumi's addon stay external to the SSR graph; Nitro traces them.
|
|
206
|
-
ssr: { noExternal: ['@weft-js/start', '@weft-js/warp', '@weft-js/warp-icons', '@weft-js/core'], external: ['@takumi-rs/core'] },
|
|
207
|
-
optimizeDeps: { exclude: ['@weft-js/start', '@weft-js/warp', '@weft-js/warp-icons', '@weft-js/core'] }, // they ship TSX for the weft plugin
|
|
208
|
-
// The runtime files of @weft-js/start, weft and Nitro are served from wherever they're installed (a linked package
|
|
209
|
-
// sits outside the app's root).
|
|
210
|
-
server: { fs: { allow: [searchForWorkspaceRoot(root), join(here, '..'), ...packageDirs(root)] } },
|
|
211
|
-
// One copy of the router: the app's route files and the generated entries import the same module.
|
|
212
|
-
resolve: { alias: [{ find: /^@weft-js\/start$/, replacement: join(here, 'index.ts') }, { find: /^@weft-js\/start\/(client|server)$/, replacement: join(here, '$1.tsx') }, { find: /^@weft-js\/start\/media$/, replacement: join(here, 'media.ts') }] },
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
writeBundle: {
|
|
216
|
-
order: 'post',
|
|
217
|
-
async handler(out, bundle) {
|
|
218
|
-
if (dev || this.environment?.config.consumer !== 'client' || options.site?.criticalCss === false || options.site?.inlineCss === false) return
|
|
219
|
-
sheetPlan = combineSheets(out.dir, bundle, manifest ?? scan(), abs, (await hydration(this))?.islands ?? [], options.site?.base ?? '/')
|
|
220
|
-
},
|
|
147
|
+
const state = createState(options)
|
|
148
|
+
const modes = new Map()
|
|
149
|
+
const configure = configureServer(state)
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
name: 'weft:start',
|
|
153
|
+
enforce: 'pre',
|
|
154
|
+
config: configHook(state),
|
|
155
|
+
configResolved: configResolved(state),
|
|
156
|
+
api: api(state),
|
|
221
157
|
},
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const top = this.environment.getTopLevelConfig()
|
|
228
|
-
const client = resolve(root, top.environments.client.build.outDir)
|
|
229
|
-
code = code.replace(new RegExp(`["'\`]${SHEET_PIECES}["'\`]`), () => JSON.stringify(sheetPlan && sheetPieces(client, top.base ?? '/', sheetPlan, options.site?.criticalKeep, root)))
|
|
230
|
-
const sheets = options.site?.inlineCss === false ? {} : inlineSheets(client, top.base ?? '/', INLINE_CSS_MAX)
|
|
231
|
-
return { code: code.replace(token, () => JSON.stringify(sheets)), map: null }
|
|
232
|
-
},
|
|
158
|
+
{
|
|
159
|
+
name: 'weft:start:sheets',
|
|
160
|
+
enforce: 'pre',
|
|
161
|
+
writeBundle: writeBundle(state),
|
|
162
|
+
renderChunk: renderChunk(state),
|
|
233
163
|
},
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
main.api.islands = async (ctx, file) => (await hydration(ctx))?.files.get(file.replace(/\?.*$/, ''))
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
configureServer(server) {
|
|
247
|
-
vite = server
|
|
248
|
-
// A `<link>` to a virtual sheet (`weft:fonts`): Vite only serves those as modules.
|
|
249
|
-
const sheet = async (req, res, next) => {
|
|
250
|
-
const css = await server.environments.client.pluginContainer.load(decodeURIComponent(req.url.slice(1))).catch(() => undefined)
|
|
251
|
-
if (css == null) return next()
|
|
252
|
-
res.setHeader('content-type', 'text/css')
|
|
253
|
-
res.end(typeof css === 'string' ? css : css.code)
|
|
254
|
-
}
|
|
255
|
-
server.middlewares.use(SHEET, (req, res, next) => void sheet(req, res, next))
|
|
256
|
-
server.middlewares.use(waitingForAFix)
|
|
257
|
-
// A route file added or removed changes the virtual modules; edits to existing ones are ordinary HMR/reloads.
|
|
258
|
-
const dir = join(root, 'src')
|
|
259
|
-
if (!existsSync(dir)) return
|
|
260
|
-
const routesDir = relative(dir, join(root, manifest.dir)).split('\\').join('/')
|
|
261
|
-
const shape = () => JSON.stringify([manifest.routes.map(r => r.file), manifest.layouts.map(l => l.file), serverModules])
|
|
262
|
-
const changed = file => {
|
|
263
|
-
const rel = file.split('\\').join('/')
|
|
264
|
-
if (!rel.startsWith(routesDir + '/') && !isServerModule('src/' + rel)) return
|
|
265
|
-
// Saving a file can count as a rename, depending on the platform and the editor.
|
|
266
|
-
const before = shape()
|
|
267
|
-
scan()
|
|
268
|
-
if (shape() === before) return
|
|
269
|
-
for (const env of Object.values(server.environments)) {
|
|
270
|
-
for (const v of [V.routes, V.fns, V.context]) {
|
|
271
|
-
const m = env.moduleGraph.getModuleById('\0' + v)
|
|
272
|
-
if (m) env.moduleGraph.invalidateModule(m)
|
|
164
|
+
{
|
|
165
|
+
name: 'weft:start:dev',
|
|
166
|
+
enforce: 'pre',
|
|
167
|
+
configureServer(server) {
|
|
168
|
+
for (const level of [...state.manifest.routes, ...state.manifest.layouts]) {
|
|
169
|
+
try {
|
|
170
|
+
modes.set(level.file, shapeSignature(state.abs(level.file)))
|
|
171
|
+
} catch {
|
|
172
|
+
modes.set(level.file, 'invalid')
|
|
273
173
|
}
|
|
274
174
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const close = server.close.bind(server)
|
|
283
|
-
server.close = () => (w.close(), close())
|
|
284
|
-
},
|
|
285
|
-
/**
|
|
286
|
-
* What an edit means for an open page, which keeps its state wherever it can. Components update themselves
|
|
287
|
-
* (@weft-js/core/hmr). Code that only runs on the server has nothing to swap: the page gets the server's new answer in place.
|
|
288
|
-
* Anything else that nothing above it can take is a full reload, and the terminal says why.
|
|
289
|
-
*/
|
|
290
|
-
async hotUpdate({ file, modules }) {
|
|
291
|
-
const rel = relative(root, file).split('\\').join('/')
|
|
292
|
-
if (this.environment.config.consumer === 'server') {
|
|
293
|
-
// An edit can add or drop a stylesheet import: the next server load of the routes walks the graph again.
|
|
294
|
-
const m = this.environment.moduleGraph.getModuleById('\0' + V.routes)
|
|
295
|
-
if (m) this.environment.moduleGraph.invalidateModule(m)
|
|
296
|
-
// A static component or a server helper is in no browser's module graph, so nothing else would tell the tab.
|
|
297
|
-
if (!vite?.environments.client.moduleGraph.getModulesByFile(file)?.size && this.environment.moduleGraph.getModulesByFile(file)?.size) refresh(vite, rel)
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
const route = manifest.routes.some(r => r.file === rel) || manifest.layouts.some(l => l.file === rel)
|
|
301
|
-
if (route) {
|
|
302
|
-
const part = serverExports(readFileSync(file, 'utf8'), file)
|
|
303
|
-
// A level the server renders has no module in the page, and its `load` and `meta` are on the server too.
|
|
304
|
-
refresh(vite, rel, loaders.get(file) !== part, false)
|
|
305
|
-
loaders.set(file, part)
|
|
306
|
-
}
|
|
307
|
-
if (isServerModule(rel) && stubs.has(file) && modules.length) {
|
|
308
|
-
const before = stubs.get(file)
|
|
309
|
-
await Promise.all(modules.map(m => this.environment.transformRequest(m.url).catch(() => undefined)))
|
|
310
|
-
if (stubs.get(file) === before) return refresh(vite, rel), []
|
|
311
|
-
}
|
|
312
|
-
// A static route's stylesheet list: nothing runs it, so nothing has to take the update.
|
|
313
|
-
const live = modules.filter(m => !m.url.endsWith(ASSETS))
|
|
314
|
-
if (live.some(m => !reaches(m))) this.environment.logger.info(`weft: full reload: ${rel} exports more than components and nothing that imports it can take the update`, { timestamp: true })
|
|
315
|
-
if (live.length < modules.length) return live
|
|
316
|
-
},
|
|
317
|
-
resolveId: {
|
|
318
|
-
filter: { id: /^(virtual:weft\/|@weft-js\/start\/(db|auth|bus|list|rust|sidecar)(\/|$)|@weft-js\/(realtime-(redis|nats|durable-object)|db-[\w-]+)$|sharp$)/ },
|
|
319
|
-
handler(source, importer) {
|
|
320
|
-
// Sharp's addon can't be bundled, so it stays external and Nitro traces it, which it can only do from the app.
|
|
321
|
-
if (source === 'sharp') {
|
|
322
|
-
if (dev || !importer?.includes('plugin-media')) return
|
|
323
|
-
try { createRequire(join(root, 'package.json')).resolve('sharp') } catch {
|
|
324
|
-
this.warn('M017: `sharp` isn\'t a dependency of this app, so the built server can\'t load it for the media route.\n fix: add `sharp` to dependencies')
|
|
325
|
-
return
|
|
175
|
+
const entry = state.clientEntry()
|
|
176
|
+
if (!entry.startsWith('virtual:')) {
|
|
177
|
+
const rel = relative(state.root, entry).split('\\').join('/')
|
|
178
|
+
try {
|
|
179
|
+
modes.set(rel, shapeSignature(entry))
|
|
180
|
+
} catch {
|
|
181
|
+
modes.set(rel, 'invalid')
|
|
326
182
|
}
|
|
327
|
-
return { id: 'sharp', external: true }
|
|
328
|
-
}
|
|
329
|
-
if (source.startsWith('@weft-js/')) {
|
|
330
|
-
if (this.environment?.config.consumer === 'client') this.error(`Q012: \`${source}\` reached the client graph${importer ? ` from ${relative(root, importer)}` : ''}: the database, auth and buses run on the server only.\n fix: import it from a server module (src/server/**, *.server.ts) or a route's \`load\``)
|
|
331
|
-
return
|
|
332
183
|
}
|
|
333
|
-
const
|
|
334
|
-
|
|
184
|
+
const configured = configure(server)
|
|
185
|
+
entryWatchLifecycle(state, server, modes)
|
|
186
|
+
return configured
|
|
335
187
|
},
|
|
188
|
+
hotUpdate: shapeHotUpdate(state, modes, hotUpdate(state)),
|
|
336
189
|
},
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const client = this.environment?.config.consumer === 'client'
|
|
344
|
-
if (which.startsWith(ISLAND)) {
|
|
345
|
-
const { islands } = await hydration(this)
|
|
346
|
-
const i = islands[Number(which.slice(ISLAND.length))]
|
|
347
|
-
return `export { $i${i.index} as default } from ${JSON.stringify(i.file)}\n`
|
|
348
|
-
}
|
|
349
|
-
if (which === V.routes) {
|
|
350
|
-
const p = await hydration(this)
|
|
351
|
-
return client ? clientRoutes(manifest, abs, p) : serverRoutes(manifest, abs, dev ? await devStyles(vite.environments.client, manifest, abs, clientEntry()) : undefined, p)
|
|
352
|
-
}
|
|
353
|
-
if (which === V.app) return appModule({ site: options.site ?? {}, build, dev, fonts: cardFonts(manifest, abs, options.fonts ?? DEFAULT_FONTS, root, options.site?.card?.fonts), timeout: options.timeout })
|
|
354
|
-
if (which === V.server) return serverEntry(clientEntry(), dev, root)
|
|
355
|
-
if (which === V.fns) return serverModules.map(f => `import ${JSON.stringify(abs(f))}`).join('\n') + '\n'
|
|
356
|
-
if (which === V.context || which === V.auth || which === V.realtime) {
|
|
357
|
-
const name = which.slice('virtual:weft/'.length)
|
|
358
|
-
const f = ['ts', 'mts', 'js', 'mjs'].map(x => `src/${name}.server.${x}`).find(x => existsSync(abs(x)))
|
|
359
|
-
if (which === V.realtime && busBound(root)) return busModule(f && abs(f), here)
|
|
360
|
-
return f ? `export { default } from ${JSON.stringify(abs(f))}\n` : 'export default undefined\n'
|
|
361
|
-
}
|
|
362
|
-
if (which === V.extensions) return extensionsModule(root, abs)
|
|
363
|
-
if (which === V.migrations) return dev ? `import { readMigrations } from ${JSON.stringify(join(here, 'migrations.mjs'))}\nexport default () => readMigrations(${JSON.stringify(root)})\n` : `const list = ${JSON.stringify(readMigrations(root))}\nexport default () => list\n`
|
|
364
|
-
if (which === V.media) return mediaModule(root, dev)
|
|
365
|
-
if (which === V.client) return `import { start } from ${JSON.stringify(join(here, 'client.tsx'))}\nimport * as routes from '${V.routes}'\nstart(routes)\n`
|
|
366
|
-
},
|
|
190
|
+
{
|
|
191
|
+
name: 'weft:start:modules',
|
|
192
|
+
enforce: 'pre',
|
|
193
|
+
resolveId: resolveId(state),
|
|
194
|
+
load: load(state),
|
|
195
|
+
transform: transform(state),
|
|
367
196
|
},
|
|
368
|
-
|
|
369
|
-
filter: { id: /\.[jt]sx?(\?weft-assets)?$|\/vite\/dist\/client\/client\.mjs$/ },
|
|
370
|
-
async handler(code, file) {
|
|
371
|
-
const path = file.replace(/\?.*$/, '')
|
|
372
|
-
if (file.endsWith(ASSETS)) return { code: await assetsOnly(this, code, path, root, words), map: null }
|
|
373
|
-
if (path.endsWith('/client.mjs')) return dev ? markLinks(root) + code : undefined
|
|
374
|
-
if (path.includes('/node_modules/')) return
|
|
375
|
-
const client = this.environment?.config.consumer === 'client'
|
|
376
|
-
const rel = relative(root, path).split('\\').join('/')
|
|
377
|
-
if (isServerModule(rel)) {
|
|
378
|
-
const out = client ? clientStubs(code, path, rel, await shapeTable(this)) : withIds(code, path, rel)
|
|
379
|
-
if (client && dev) stubs.set(path, out)
|
|
380
|
-
return out === code ? undefined : { code: out, map: null }
|
|
381
|
-
}
|
|
382
|
-
if (client && !rel.startsWith('..')) {
|
|
383
|
-
const q003 = definesFns(code, path)
|
|
384
|
-
if (q003) this.error(`Q003: ${rel} defines \`${q003.name}\` with \`${q003.kind}()\`, but it isn't a server module: its \`run\` and imports would ship to the client.\n fix: move it to src/server/ or a *.server.ts file`)
|
|
385
|
-
}
|
|
386
|
-
if (!client) return
|
|
387
|
-
if (!manifest.routes.some(r => r.file === rel) && !manifest.layouts.some(l => l.file === rel)) return
|
|
388
|
-
if (dev) loaders.set(file, serverExports(code, file))
|
|
389
|
-
const out = stripServerExports(code, file)
|
|
390
|
-
// @weft-js/core/hmr saw the server exports and left this to Vite, but they're gone and the rest is components.
|
|
391
|
-
if (dev && out !== code && !/^export (?!default\b|\{ \S+\$shape as default\$shape \})/m.test(out) && !out.includes('import.meta.hot.accept(')) return { code: `${out}\nif (import.meta.hot) import.meta.hot.accept();\n`, map: null }
|
|
392
|
-
return out === code ? undefined : { code: out, map: null }
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
/** `weft routes`, tests: the scanned manifest, the generated route types and the server modules. */
|
|
396
|
-
api: { manifest: () => manifest ?? scan(), types: () => routeTypes(manifest ?? scan()), serverModules: () => (manifest ?? scan(), serverModules) },
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/** Dev, in place of the bare JSON a page gets when its code won't load: this tab can't take an update, so it reloads. */
|
|
401
|
-
const NOT_LOADING = `<!doctype html><meta charset="utf-8"><title>This page failed to load</title>
|
|
402
|
-
<body style="font:16px system-ui;padding:2rem"><h1>This page failed to load</h1><p>The error is in the terminal. This tab reloads when you save a fix.</p>
|
|
403
|
-
<script type="module">
|
|
404
|
-
import { createHotContext } from '/@vite/client'
|
|
405
|
-
const hot = createHotContext('/@weft/not-loading')
|
|
406
|
-
for (const event of ['vite:afterUpdate', 'weft:refresh']) hot.on(event, () => location.reload())
|
|
407
|
-
</script>`
|
|
408
|
-
|
|
409
|
-
function waitingForAFix(req, res, next) {
|
|
410
|
-
if (!req.headers.accept?.includes('text/html')) return next()
|
|
411
|
-
const { writeHead, end } = res
|
|
412
|
-
const json = headers => /json/.test(String(Array.isArray(headers) ? headers.flat().join(' ') : headers && Object.entries(headers).flat().join(' ')) + res.getHeader('content-type'))
|
|
413
|
-
let failed = false
|
|
414
|
-
res.writeHead = function (status, ...rest) {
|
|
415
|
-
failed = status >= 500 && json(rest.at(-1))
|
|
416
|
-
if (!failed) return writeHead.call(this, status, ...rest)
|
|
417
|
-
res.statusCode = status
|
|
418
|
-
return this
|
|
419
|
-
}
|
|
420
|
-
const write = res.write
|
|
421
|
-
res.write = function (...args) { return failed ? true : write.apply(this, args) }
|
|
422
|
-
res.end = function (...args) {
|
|
423
|
-
if (!failed && !(res.statusCode >= 500 && !res.headersSent && json())) return end.apply(this, args)
|
|
424
|
-
res.setHeader('content-type', 'text/html')
|
|
425
|
-
res.removeHeader('content-length')
|
|
426
|
-
return end.call(this, NOT_LOADING)
|
|
427
|
-
}
|
|
428
|
-
next()
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Whether an update to `mod` stops at a module that accepts it. */
|
|
432
|
-
function reaches(mod, seen = new Set()) {
|
|
433
|
-
if (mod.isSelfAccepting || mod.acceptedHmrDeps?.size) return true
|
|
434
|
-
if (seen.has(mod)) return true
|
|
435
|
-
seen.add(mod)
|
|
436
|
-
return mod.importers.size > 0 && [...mod.importers].every(m => m.acceptedHmrDeps?.has(mod) || reaches(m, seen))
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Ask open pages for the server's new answer, once the server's own reload has been told. A burst of saves is one ask. */
|
|
440
|
-
const asking = new WeakMap()
|
|
441
|
-
function refresh(vite, rel, say = true, queries = true) {
|
|
442
|
-
if (!vite) return
|
|
443
|
-
clearTimeout(asking.get(vite))
|
|
444
|
-
asking.set(vite, setTimeout(() => {
|
|
445
|
-
if (say) vite.config.logger.info(`weft: ${rel} runs on the server: refreshing the page's data, state kept`, { timestamp: true })
|
|
446
|
-
vite.ws.send({ type: 'custom', event: 'weft:refresh', data: { queries } })
|
|
447
|
-
}, 50))
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function packageDirs(root) {
|
|
451
|
-
const req = createRequire(join(root, 'package.json'))
|
|
452
|
-
const own = createRequire(import.meta.url)
|
|
453
|
-
const out = []
|
|
454
|
-
for (const name of ['@weft-js/core/vite', 'nitro/package.json']) { // each at its package root
|
|
455
|
-
for (const r of [req, own]) {
|
|
456
|
-
try { out.push(dirname(realpathSync(r.resolve(name)))); break } catch {}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
return out
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** The project asks for shapes: a `pending="shape"`, or a read of `.shape`. */
|
|
463
|
-
function usesShapes(root) {
|
|
464
|
-
const src = join(root, 'src')
|
|
465
|
-
if (!existsSync(src)) return false
|
|
466
|
-
return readdirSync(src, { recursive: true, withFileTypes: true }).some(f => f.isFile() && /\.[jt]sx?$/.test(f.name) && /pending="shape"|\.shape\b/.test(readFileSync(join(f.parentPath, f.name), 'utf8')))
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* `dev`: the stylesheets from `devStyles`; a build asks Nitro's manifest instead (the plugin's expression reads up to
|
|
471
|
-
* the first `)`, which a path can hold). `meta` is read once per level that renders, before the first byte: that's
|
|
472
|
-
* how the entry learns the request's levels (`routeCss`), outermost first, 404 and error pages included.
|
|
473
|
-
*/
|
|
474
|
-
function serverRoutes(manifest, abs, dev, plan) {
|
|
475
|
-
const files = [...manifest.layouts.map(l => l.file), ...manifest.routes.map(r => r.file), ...manifest.routes.map(r => r.card).filter(Boolean), manifest.defaultCard].filter(Boolean)
|
|
476
|
-
const unique = [...new Set(files)]
|
|
477
|
-
const levels = new Set([...manifest.layouts, ...manifest.routes].map(r => r.file))
|
|
478
|
-
// A file with no default export renders nothing, and may leave no chunk for the manifest to find.
|
|
479
|
-
const renders = f => levels.has(f) && /\bdefault\b/.test(readFileSync(abs(f), 'utf8'))
|
|
480
|
-
const isStatic = f => plan?.statics.has(abs(f))
|
|
481
|
-
const clientAssets = file => `import.meta.vite.assets(${JSON.stringify({ import: file, environment: 'client' }).replaceAll(')', '\\u0029')})`
|
|
482
|
-
const css = f => dev ? JSON.stringify(dev.files[f] ?? []) : `${clientAssets(abs(f) + (isStatic(f) ? ASSETS : ''))}.css`
|
|
483
|
-
return [
|
|
484
|
-
...unique.map((f, i) => `import * as m${i} from ${JSON.stringify(abs(f))}`),
|
|
485
|
-
`import { runtime } from ${JSON.stringify(join(here, 'data.ts'))}`,
|
|
486
|
-
`const matched = new WeakMap()
|
|
487
|
-
const styled = (m, css) => !css.length ? m : { ...m, get meta() {
|
|
488
|
-
const scope = runtime.scope?.()
|
|
489
|
-
if (scope) matched.set(scope, [...(matched.get(scope) ?? []), ...css])
|
|
490
|
-
return m.meta
|
|
491
|
-
} }
|
|
492
|
-
export const routeCss = css => [...new Map([...${JSON.stringify(dev?.entry ?? [])}, ...css, ...(matched.get(runtime.scope?.()) ?? [])].map(c => [c.href, c])).values()]`,
|
|
493
|
-
`export const manifest = ${JSON.stringify({ dir: manifest.dir, routes: manifest.routes, layouts: manifest.layouts, defaultCard: manifest.defaultCard })}`,
|
|
494
|
-
// The built-in 404 and error pages have no state, and stand in only when the app has no route file for them.
|
|
495
|
-
`export const statics = ${JSON.stringify(!plan ? [] : [...[...manifest.layouts, ...manifest.routes].filter(r => isStatic(r.file)).map(r => r.id), ...['404', 'error'].filter(id => !manifest.routes.some(r => r.id === id))])}`,
|
|
496
|
-
`export const lazy = ${!!plan?.loads.some(l => l.when !== 'load')}`,
|
|
497
|
-
// The chunks a page's levels and islands will import, for the document's modulepreloads. Dev has no chunks.
|
|
498
|
-
`export const chunks = ${dev ? '{}' : `{ ${[...[...manifest.layouts, ...manifest.routes].filter(r => !isStatic(r.file) && renders(r.file)).map(r => [r.id, abs(r.file)]), ...(plan?.islands ?? []).map(i => [i.id, i.file]), ['$client', join(here, 'run.tsx')]].map(([id, file]) => `${JSON.stringify(id)}: ${clientAssets(file)}`).join(', ')} }`}`,
|
|
499
|
-
`export const modules = { ${unique.map((f, i) => `${JSON.stringify(f)}: ${renders(f) ? `styled(m${i}, ${css(f)})` : `m${i}`}`).join(', ')} }`,
|
|
500
|
-
].join('\n')
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* The CSS each module reaches in the client graph, as `?assets=client` can't say in dev: the entry's, and each route
|
|
505
|
-
* and layout's. The client env transforms what the browser is about to ask for anyway, and `<link href>` to a module's
|
|
506
|
-
* URL gets the compiled sheet (Tailwind's output too). A module that doesn't transform fails again when it's imported.
|
|
507
|
-
*/
|
|
508
|
-
async function devStyles(env, manifest, abs, entry) {
|
|
509
|
-
const reachFrom = async start => {
|
|
510
|
-
const seen = new Set(), css = new Map()
|
|
511
|
-
const walk = async url => {
|
|
512
|
-
const mod = await env.moduleGraph.getModuleByUrl(url).catch(() => undefined) ?? await env.moduleGraph.ensureEntryFromUrl(url).catch(() => undefined)
|
|
513
|
-
if (!mod || seen.has(mod) || mod.id?.startsWith('\0virtual:weft/routes')) return // routes import every route
|
|
514
|
-
seen.add(mod)
|
|
515
|
-
await env.transformRequest(mod.url).catch(() => {})
|
|
516
|
-
for (const next of mod.importedModules) {
|
|
517
|
-
if (!next.id) continue
|
|
518
|
-
if (!isCSSRequest(next.id)) await walk(next.url)
|
|
519
|
-
else if (!/[?&](url|inline|raw)\b/.test(next.id)) css.set(next.url, { href: next.url[0] === '/' ? next.url : SHEET + encodeURIComponent(next.id) })
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
await walk(start)
|
|
523
|
-
return [...css.values()]
|
|
524
|
-
}
|
|
525
|
-
const files = {}
|
|
526
|
-
for (const { file } of [...manifest.layouts, ...manifest.routes]) files[file] = await reachFrom(abs(file))
|
|
527
|
-
return { entry: await reachFrom(entry.startsWith('virtual:') ? join(here, 'client.tsx') : entry), files }
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Vite's client, in dev: the fullstack plugin's patch hands a sheet to its `<link data-vite-dev-id>`, so the module
|
|
532
|
-
* doesn't inject a second copy and CSS HMR swaps the link. The server only writes `href`, so the id is added here,
|
|
533
|
-
* before the patched client reads the links.
|
|
534
|
-
*/
|
|
535
|
-
const markLinks = root => `document.querySelectorAll('link[rel="stylesheet"]:not([data-vite-dev-id])').forEach(l => {
|
|
536
|
-
const href = l.getAttribute('href')
|
|
537
|
-
if (href.startsWith('${SHEET}')) l.setAttribute('data-vite-dev-id', decodeURIComponent(href.slice(${SHEET.length})))
|
|
538
|
-
else if (href.startsWith('/@fs/')) l.setAttribute('data-vite-dev-id', href.replace(/^\\/@fs(\\/(?=[A-Za-z]:))?/, ''))
|
|
539
|
-
else if (href[0] === '/' && href[1] !== '/') l.setAttribute('data-vite-dev-id', ${JSON.stringify(root)} + href)
|
|
540
|
-
})
|
|
541
|
-
`
|
|
542
|
-
|
|
543
|
-
function clientRoutes(manifest, abs, plan) {
|
|
544
|
-
const table = manifest.routes.filter(r => r.kind === 'page').map(r => ({ id: r.id, pattern: r.pattern, layouts: r.layouts }))
|
|
545
|
-
const levels = [...manifest.layouts, ...manifest.routes]
|
|
546
|
-
const isStatic = r => plan?.statics.has(abs(r.file))
|
|
547
|
-
// A static route loads only what its module imports that isn't code: its stylesheets, for a navigation to it.
|
|
548
|
-
const loads = levels.map(r => `${JSON.stringify(r.id)}: () => import(${JSON.stringify(abs(r.file) + (isStatic(r) ? ASSETS : ''))})`)
|
|
549
|
-
// Straight from the file: a module of its own per island would be a chunk that only re-exports.
|
|
550
|
-
const islands = (plan?.islands ?? []).map(i => `${JSON.stringify(i.id)}: () => import(${JSON.stringify(i.file)}).then(({ $i${i.index}: d }) => ({ default: d }))`)
|
|
551
|
-
return `export const table = ${JSON.stringify(table)}\nexport const load = { ${loads.join(', ')} }\nexport const islands = { ${islands.join(', ')} }\n`
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* A static module in the client build: what it imports that isn't code (stylesheets, images), and the same of the
|
|
556
|
-
* modules it imports. Its class names go to the purge, which only sees client files.
|
|
557
|
-
*/
|
|
558
|
-
async function assetsOnly(ctx, code, path, root, words) {
|
|
559
|
-
words?.add(code)
|
|
560
|
-
const ast = parse(path, code)
|
|
561
|
-
const out = []
|
|
562
|
-
for (const n of ast.program.body) {
|
|
563
|
-
if (!/^(ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration)$/.test(n.type) || !n.source || n.importKind === 'type' || n.exportKind === 'type') continue
|
|
564
|
-
const spec = n.source.value
|
|
565
|
-
if (/^@weft-js\/(?:core|start)($|\/)/.test(spec)) continue
|
|
566
|
-
const file = (await ctx.resolve(spec, path, { skipSelf: true }))?.id
|
|
567
|
-
if (!file) continue
|
|
568
|
-
const bare = file.replace(/\?.*$/, '')
|
|
569
|
-
if (!NOT_ASSET.test(bare)) out.push(`import ${JSON.stringify(spec)}`)
|
|
570
|
-
else if (!bare.includes('/node_modules/') && !isServerModule(relative(root, bare).split('\\').join('/'))) out.push(`import ${JSON.stringify(bare + ASSETS)}`)
|
|
571
|
-
}
|
|
572
|
-
if (code.includes('@weft-js/core/style')) out.push(`import 'virtual:weft/groups.css'`)
|
|
573
|
-
out.push('export default null') // a chunk of only CSS is dropped, and the server looks this one up
|
|
574
|
-
return out.join('\n') + '\n'
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function serverEntry(clientEntry, dev, root) {
|
|
578
|
-
return `import { createHandler } from ${JSON.stringify(join(here, 'server.tsx'))}
|
|
579
|
-
import { chunks, lazy, manifest, modules, routeCss, statics } from '${V.routes}'
|
|
580
|
-
import '${V.fns}'
|
|
581
|
-
import context from '${V.context}'
|
|
582
|
-
import auth from '${V.auth}'
|
|
583
|
-
import migrations from '${V.migrations}'
|
|
584
|
-
import media from '${V.media}'
|
|
585
|
-
import realtime from '${V.realtime}'
|
|
586
|
-
import extensions from '${V.extensions}'
|
|
587
|
-
import * as app from '${V.app}'
|
|
588
|
-
import assets from '${clientEntry}?assets=client'
|
|
589
|
-
import { preloads } from 'weft:fonts/preloads'
|
|
590
|
-
const fetch = createHandler({ manifest, modules, statics, chunks, lazy, context, auth, migrations, media, realtime, liveRequired: ${JSON.stringify(scanRequired(root).map(r => r.name))}, extensions, site: app.site, build: app.build, dev: app.dev, fonts: app.fonts(), timeout: app.timeout, assets: { entry: assets.entry, js: assets.js, get css() { return routeCss(assets.css) }, fonts: preloads, inline: ${dev ? 'undefined' : `'${INLINE_SHEETS}'`}, pieces: ${dev ? 'undefined' : `'${SHEET_PIECES}'`} },
|
|
591
|
-
// H005–H008, Q002–Q014, A002–A004, F002–F003, M017 and R-codes found while serving: the server log, in dev and production.
|
|
592
|
-
onDiagnostic: d => console.warn(\`[weft] \${d.code} \${d.severity}\${d.route ? \` (\${d.route})\` : ''}: \${d.message.replace(/^[A-Z]{1,3}\\d{3}:? /, '')}\`) })
|
|
593
|
-
export default { fetch }
|
|
594
|
-
`
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* A `server.extension` provider runs only for an app with `src/<key>.server.ts`, the file that says it wants it (P118):
|
|
599
|
-
* the provider is loaded on the first request and handed that file's default export. No provider gives an empty list.
|
|
600
|
-
*/
|
|
601
|
-
function extensionsModule(root, abs) {
|
|
602
|
-
const slot = join(root, '.weft', 'slots', 'server.extension.ts')
|
|
603
|
-
const keys = existsSync(slot) ? Object.entries(JSON.parse(/providers = (\{.*\}) as const/.exec(readFileSync(slot, 'utf8'))?.[1] ?? '{}')).filter(([, name]) => name).map(([key]) => key) : []
|
|
604
|
-
const files = keys.flatMap(key => {
|
|
605
|
-
const f = ['ts', 'mts', 'js', 'mjs'].map(x => `src/${key}.server.${x}`).find(x => existsSync(abs(x)))
|
|
606
|
-
return f ? [[key, abs(f)]] : []
|
|
607
|
-
})
|
|
608
|
-
if (!files.length) return 'export default []\n'
|
|
609
|
-
return `import { load } from 'weft:slot/server.extension'\n${files.map(([, f], i) => `import app${i} from ${JSON.stringify(f)}\n`).join('')}export default [${files.map(([key], i) => `{ key: ${JSON.stringify(key)}, load: () => load(${JSON.stringify(key)}).then(extend => extend(app${i})) }`).join(', ')}]\n`
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Dev serves from the project; a built server from the directory it runs in (or `WEFT_MEDIA_DIR`). */
|
|
613
|
-
function mediaModule(root, dev) {
|
|
614
|
-
const slot = join(root, '.weft', 'slots', 'media.server.ts')
|
|
615
|
-
if (!existsSync(slot) || readFileSync(slot, 'utf8').includes('provider = null')) return 'export default undefined\n'
|
|
616
|
-
const at = dev ? JSON.stringify(root) : 'undefined'
|
|
617
|
-
if (!existsSync(join(root, '.weft', 'slots', 'media.encode.ts'))) return `import { load } from 'weft:slot/media.server'\nexport default () => load().then(m => m.at(${at}))\n`
|
|
618
|
-
return `import { load } from 'weft:slot/media.server'
|
|
619
|
-
import { load as encoder, providers } from 'weft:slot/media.encode'
|
|
620
|
-
const encode = async () => Object.fromEntries(await Promise.all(Object.keys(providers).filter(k => providers[k]).map(async k => [k, await encoder(k)])))
|
|
621
|
-
export default () => Promise.all([load(), encode()]).then(([m, e]) => m.at(${at}, e))
|
|
622
|
-
`
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const busBound = root => {
|
|
626
|
-
const slot = join(root, '.weft', 'slots', 'realtime.bus.ts')
|
|
627
|
-
return existsSync(slot) && readFileSync(slot, 'utf8').includes('provider = {')
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** A bus in `src/realtime.server.ts` wins over the plugin's, like any explicit choice. */
|
|
631
|
-
const busModule = (file, from) => `import { load, provider } from 'weft:slot/realtime.bus'
|
|
632
|
-
import { slotBus } from ${JSON.stringify(join(from, 'bus.ts'))}
|
|
633
|
-
${file ? `import own from ${JSON.stringify(file)}` : 'const own = undefined'}
|
|
634
|
-
export default { ...own, bus: own?.bus ?? slotBus(provider.plugin, load) }
|
|
635
|
-
`
|
|
636
|
-
|
|
637
|
-
function appModule({ site, build, dev, fonts, timeout }) {
|
|
638
|
-
return `export const site = ${JSON.stringify(site)}
|
|
639
|
-
export const build = ${JSON.stringify(build)}
|
|
640
|
-
export const dev = ${dev}
|
|
641
|
-
export const timeout = ${timeout ?? 'undefined'}
|
|
642
|
-
const raw = ${JSON.stringify(fonts)}
|
|
643
|
-
let decoded
|
|
644
|
-
export function fonts() {
|
|
645
|
-
return (decoded ??= raw.map(f => ({ ...f, data: Uint8Array.from(atob(f.data), c => c.charCodeAt(0)) })))
|
|
646
|
-
}
|
|
647
|
-
`
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* The fonts every card can use, resolved at build and shipped inside the server bundle (research 20 §4.2: a render
|
|
652
|
-
* never fetches fonts). `Inter:400,700` → `@fontsource/inter`'s latin WOFF2 files, from the app or from @weft-js/start.
|
|
653
|
-
*/
|
|
654
|
-
function cardFonts(manifest, abs, defaults, root, own = {}) {
|
|
655
|
-
const specs = new Set(defaults)
|
|
656
|
-
for (const f of [...manifest.routes.map(r => r.card), manifest.defaultCard].filter(Boolean)) {
|
|
657
|
-
const m = /export\s+const\s+fonts\s*=\s*\[([^\]]*)\]/.exec(readFileSync(abs(f), 'utf8'))
|
|
658
|
-
if (m) for (const s of m[1].matchAll(/['"]([^'"]+)['"]/g)) specs.add(s[1])
|
|
659
|
-
}
|
|
660
|
-
const requires = [createRequire(join(root, 'package.json')), createRequire(import.meta.url)]
|
|
661
|
-
const out = []
|
|
662
|
-
// The app's own faces (`site.card.fonts`, filled in by the text plugin): the card's title and text look like the app.
|
|
663
|
-
for (const f of [own.display, own.body].filter(Boolean)) {
|
|
664
|
-
const path = join(root, f.src)
|
|
665
|
-
if (existsSync(path) && !out.some(x => x.name === f.family)) out.push({ name: f.family, weight: f.weight ?? 400, style: 'normal', data: readFileSync(path).toString('base64') })
|
|
666
|
-
}
|
|
667
|
-
for (const spec of specs) {
|
|
668
|
-
const [family, weights = '400'] = spec.split(':')
|
|
669
|
-
const pkg = family.toLowerCase().replace(/\s+/g, '-')
|
|
670
|
-
for (const w of weights.split(',')) {
|
|
671
|
-
const [weight, italic] = [Number.parseInt(w), /i(talic)?$/.test(w)]
|
|
672
|
-
const file = `@fontsource/${pkg}/files/${pkg}-latin-${weight}-${italic ? 'italic' : 'normal'}.woff2`
|
|
673
|
-
const path = requires.map(r => { try { return r.resolve(file) } catch { return undefined } }).find(Boolean)
|
|
674
|
-
if (!path) throw new Error(`weft: card font \`${spec}\` needs \`@fontsource/${pkg}\` installed (looked for ${file})`)
|
|
675
|
-
out.push({ name: family, weight, style: italic ? 'italic' : 'normal', data: readFileSync(path).toString('base64') })
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return out
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* The client build of a route module: `load`, `meta` and `prerender` removed (they run on the server; loaders can
|
|
683
|
-
* hold database code and secrets), then imports nothing else uses. Exported for tests.
|
|
684
|
-
*/
|
|
685
|
-
function serverCuts(code, file) {
|
|
686
|
-
const ast = parseSync(file, code, { lang: file.endsWith('.tsx') ? 'tsx' : file.endsWith('.ts') ? 'ts' : 'jsx', sourceType: 'module' })
|
|
687
|
-
const cuts = []
|
|
688
|
-
for (const node of ast.program.body) {
|
|
689
|
-
if (node.type !== 'ExportNamedDeclaration' || !node.declaration) continue
|
|
690
|
-
const d = node.declaration
|
|
691
|
-
const names = d.type === 'VariableDeclaration' ? d.declarations.map(x => x.id?.name) : [d.id?.name]
|
|
692
|
-
if (names.length && names.every(n => SERVER_EXPORTS.has(n))) cuts.push([node.start, node.end])
|
|
693
|
-
}
|
|
694
|
-
return cuts
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const serverExports = (code, file) => serverCuts(code, file).map(([a, b]) => code.slice(a, b)).join('\n')
|
|
698
|
-
|
|
699
|
-
/** The server-only exports of a route file, cut out of the client's copy. */
|
|
700
|
-
export function stripServerExports(code, file = 'route.tsx') {
|
|
701
|
-
const cuts = serverCuts(code, file)
|
|
702
|
-
if (!cuts.length) return code
|
|
703
|
-
let out = code
|
|
704
|
-
for (const [a, b] of cuts.sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + out.slice(b)
|
|
705
|
-
// Imports whose bindings are no longer used go too (a db client a loader imported must not reach the browser).
|
|
706
|
-
return pruneImports(out, file)
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const parse = (file, code) => parseSync(file, code, { lang: file.endsWith('.tsx') ? 'tsx' : /\.[cm]?ts$/.test(file) ? 'ts' : 'jsx', sourceType: 'module' })
|
|
710
|
-
|
|
711
|
-
/** The TS nodes that hold code; every other `TS*` node is a type, gone once TypeScript is stripped. */
|
|
712
|
-
const TS_VALUE = /^TS((As|Satisfies|NonNull|Instantiation)Expression|TypeAssertion|Enum\w+|Module\w+|ImportEqualsDeclaration|ExternalModuleReference|ParameterProperty|ExportAssignment)$/
|
|
713
|
-
const NAMED_MEMBER = /^(Property|MethodDefinition|PropertyDefinition|AccessorProperty)$/
|
|
714
|
-
const binds = p => !p ? [] : p.type === 'Identifier' ? [p.name] : (p.properties ?? p.elements ?? [p.value ?? p.left ?? p.argument ?? p.parameter]).flatMap(binds)
|
|
715
|
-
const declares = s => s.type === 'VariableDeclaration' ? s.declarations.flatMap(d => binds(d.id)) : /^(Function|Class)Declaration$/.test(s.type) && s.id ? [s.id.name] : []
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* The outer names `ast` reads at run time. Not types (`typeof x` in one reads nothing), property names, or what a
|
|
719
|
-
* function or block binds itself: `optimistic: ({ input }) => …` doesn't use a top-level `input`. It errs towards a name
|
|
720
|
-
* being used: a kept import is a bigger bundle, a dropped one a crash.
|
|
721
|
-
*/
|
|
722
|
-
function valueNames(ast) {
|
|
723
|
-
const out = new Set()
|
|
724
|
-
const walk = (n, bound) => {
|
|
725
|
-
if (Array.isArray(n)) return n.forEach(x => walk(x, bound))
|
|
726
|
-
if (typeof n?.type !== 'string' || n.type === 'ImportDeclaration' || n.exportKind === 'type' || (n.type.startsWith('TS') && !TS_VALUE.test(n.type))) return
|
|
727
|
-
if (n.type === 'Identifier' || n.type === 'JSXIdentifier') { if (!bound.has(n.name)) out.add(n.name); return }
|
|
728
|
-
const inner = /^(Arrow)?Function/.test(n.type) ? [...(n.id ? [n.id.name] : []), ...n.params.flatMap(binds)] : n.type === 'BlockStatement' ? n.body.flatMap(declares) : []
|
|
729
|
-
const scope = inner.length ? new Set([...bound, ...inner]) : bound
|
|
730
|
-
const skip = n.computed ? undefined : n.type === 'MemberExpression' ? 'property' : NAMED_MEMBER.test(n.type) ? 'key' : undefined
|
|
731
|
-
for (const k in n) if (k !== skip && k !== 'parent' && n[k] && typeof n[k] === 'object') walk(n[k], scope)
|
|
732
|
-
}
|
|
733
|
-
walk(ast.program.body, new Set())
|
|
734
|
-
return out
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Drop value import specifiers nothing in `out` reads (and imports left empty). */
|
|
738
|
-
function pruneImports(out, file) {
|
|
739
|
-
const again = parse(file, out)
|
|
740
|
-
const imports = again.program.body.filter(n => n.type === 'ImportDeclaration' && n.specifiers.length)
|
|
741
|
-
const used = valueNames(again)
|
|
742
|
-
let rest = out
|
|
743
|
-
for (const imp of imports) rest = rest.slice(0, imp.start) + ' '.repeat(imp.end - imp.start) + rest.slice(imp.end)
|
|
744
|
-
const spelled = new Set(rest.match(/[A-Za-z_$][\w$]*/g)) // a type import is erased, so a type may keep it
|
|
745
|
-
for (const imp of imports.sort((x, y) => y.start - x.start)) {
|
|
746
|
-
const keep = imp.specifiers.filter(s => (imp.importKind === 'type' || s.importKind === 'type' ? spelled : used).has(s.local.name))
|
|
747
|
-
if (keep.length === imp.specifiers.length) continue
|
|
748
|
-
const text = keep.length ? rewriteImport(imp, keep, out) : ''
|
|
749
|
-
out = out.slice(0, imp.start) + text + out.slice(imp.end)
|
|
750
|
-
}
|
|
751
|
-
return out
|
|
197
|
+
]
|
|
752
198
|
}
|
|
753
|
-
|
|
754
|
-
function rewriteImport(imp, keep, code) {
|
|
755
|
-
const def = keep.find(s => s.type === 'ImportDefaultSpecifier')
|
|
756
|
-
const ns = keep.find(s => s.type === 'ImportNamespaceSpecifier')
|
|
757
|
-
const named = keep.filter(s => s.type === 'ImportSpecifier').map(s => code.slice(s.start, s.end))
|
|
758
|
-
const parts = [def && def.local.name, ns && `* as ${ns.local.name}`, named.length && `{ ${named.join(', ')} }`].filter(Boolean)
|
|
759
|
-
return `import ${imp.importKind === 'type' ? 'type ' : ''}${parts.join(', ')} from ${code.slice(imp.source.start, imp.source.end)}`
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// ---- server modules (research 27 §2) ------------------------------------------------------------------------------
|
|
763
|
-
|
|
764
|
-
/** `src/server/**` and `*.server.ts(x)`: files whose `query`/`mutation` exports become client stubs. */
|
|
765
|
-
export const isServerModule = rel => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
|
|
766
|
-
|
|
767
|
-
function scanServerModules(root) {
|
|
768
|
-
const out = []
|
|
769
|
-
const walk = dir => {
|
|
770
|
-
let list
|
|
771
|
-
try { list = readdirSync(join(root, dir), { withFileTypes: true }) } catch { return }
|
|
772
|
-
for (const d of list) {
|
|
773
|
-
const rel = `${dir}/${d.name}`
|
|
774
|
-
if (d.isDirectory()) { if (d.name !== 'node_modules' && !d.name.startsWith('.')) walk(rel) }
|
|
775
|
-
else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.(test|spec)\./.test(d.name) && !d.name.endsWith('.d.ts') && isServerModule(rel)) out.push(rel)
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
walk('src')
|
|
779
|
-
return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const FN_KINDS = new Set(['query', 'mutation', 'stream', 'topic', 'channel', 'sharedState'])
|
|
783
|
-
|
|
784
|
-
/** Local names bound to the `FN_KINDS` from @weft-js/start. */
|
|
785
|
-
function fnLocals(ast) {
|
|
786
|
-
const locals = new Map()
|
|
787
|
-
for (const n of ast.program.body) {
|
|
788
|
-
if (n.type !== 'ImportDeclaration' || n.importKind === 'type' || !/^@weft-js\/start(\/data)?$/.test(n.source.value)) continue
|
|
789
|
-
for (const s of n.specifiers) if (s.type === 'ImportSpecifier' && s.importKind !== 'type' && FN_KINDS.has(s.imported.name ?? s.imported.value)) locals.set(s.local.name, s.imported.name ?? s.imported.value)
|
|
790
|
-
}
|
|
791
|
-
return locals
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/** `export const name = query({ … })` statements: the call, its options object and the export name. */
|
|
795
|
-
function fnExports(ast, locals) {
|
|
796
|
-
const out = []
|
|
797
|
-
for (const n of ast.program.body) {
|
|
798
|
-
if (n.type !== 'ExportNamedDeclaration' || n.declaration?.type !== 'VariableDeclaration') continue
|
|
799
|
-
for (const d of n.declaration.declarations) {
|
|
800
|
-
const call = d.init?.type === 'CallExpression' ? d.init : d.init?.type === 'TSAsExpression' || d.init?.type === 'TSSatisfiesExpression' ? d.init.expression : undefined
|
|
801
|
-
if (call?.type !== 'CallExpression' || call.callee.type !== 'Identifier' || !locals.has(call.callee.name)) continue
|
|
802
|
-
const obj = call.arguments[locals.get(call.callee.name) === 'sharedState' ? 1 : 0] // `sharedState(name, { … })`
|
|
803
|
-
if (obj?.type !== 'ObjectExpression' || d.id.type !== 'Identifier') continue
|
|
804
|
-
out.push({ node: n, name: d.id.name, callee: call.callee.name, obj, single: n.declaration.declarations.length === 1 })
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
return out
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
const prop = (obj, key) => obj.properties.find(p => p.type === 'Property' && !p.computed && (p.key.name ?? p.key.value) === key)
|
|
811
|
-
/** A function's id: its name and a hash of its file, stable across builds. */
|
|
812
|
-
export const fnId = (name, rel) => `${name}.${createHash('sha256').update(rel).digest('hex').slice(0, 6)}`
|
|
813
|
-
|
|
814
|
-
/** The server build of a server module: each definition gets its `$id`. Exported for tests. */
|
|
815
|
-
export function withIds(code, file, rel) {
|
|
816
|
-
const ast = parse(file, code)
|
|
817
|
-
const locals = fnLocals(ast)
|
|
818
|
-
const all = fnExports(ast, locals)
|
|
819
|
-
for (const f of all) {
|
|
820
|
-
const kind = locals.get(f.callee)
|
|
821
|
-
if ((kind === 'channel' || kind === 'sharedState') && !prop(f.obj, 'authorize')) {
|
|
822
|
-
const line = code.slice(0, f.obj.start).split('\n').length
|
|
823
|
-
throw Object.assign(new Error(`R001: ${rel}:${line} \`${f.name}\` has no \`authorize\`: anyone could join it.\n fix: add \`authorize\` (\`() => true\` must be written out)`), { code: 'R001' })
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
const list = all.filter(f => !prop(f.obj, '$id'))
|
|
827
|
-
let out = code
|
|
828
|
-
for (const f of list.sort((a, b) => b.obj.start - a.obj.start)) out = out.slice(0, f.obj.start + 1) + ` $id: ${JSON.stringify(fnId(f.name, rel))},` + out.slice(f.obj.start + 1)
|
|
829
|
-
return out
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* The client build of a server module: stubs (`query({ $id, shape })`, `mutation({ $id, optimistic })`), the top-level
|
|
834
|
-
* declarations a kept `optimistic` reads as values, and the imports those use. Nothing else: no `run`, no database
|
|
835
|
-
* client, no other export, and no types (they're erased, and `z.infer<typeof input>` would keep `input` and zod).
|
|
836
|
-
* Exported for tests.
|
|
837
|
-
*/
|
|
838
|
-
export function clientStubs(code, file, rel, shapes) {
|
|
839
|
-
const ast = parse(file, code)
|
|
840
|
-
const locals = fnLocals(ast)
|
|
841
|
-
const defs = new Map(fnExports(ast, locals).map(f => [f.node, f]))
|
|
842
|
-
const kept = [] // [start, text]
|
|
843
|
-
const candidates = []
|
|
844
|
-
const names = text => new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? [])
|
|
845
|
-
for (const n of ast.program.body) {
|
|
846
|
-
if (n.type === 'ImportDeclaration') {
|
|
847
|
-
if (n.specifiers.length) kept.push([n.start, code.slice(n.start, n.end)]) // pruned below; side-effect imports go
|
|
848
|
-
continue
|
|
849
|
-
}
|
|
850
|
-
const f = defs.get(n)
|
|
851
|
-
if (f && f.single) {
|
|
852
|
-
const opt = prop(f.obj, 'optimistic')
|
|
853
|
-
const id = fnId(f.name, rel)
|
|
854
|
-
const kind = locals.get(f.callee)
|
|
855
|
-
if (kind === 'channel' || kind === 'sharedState') {
|
|
856
|
-
const initial = prop(f.obj, 'initial')
|
|
857
|
-
kept.push([n.start, `export const ${f.name} = ${f.callee}(${kind === 'sharedState' ? "'', " : ''}{ $id: ${JSON.stringify(id)}, $stub: 1${initial ? `, ${code.slice(initial.start, initial.end)}` : ''} })`])
|
|
858
|
-
continue
|
|
859
|
-
}
|
|
860
|
-
// The tree the schema gave when there's a table; else what was declared, as it was written.
|
|
861
|
-
const shape = shapes?.[id] !== undefined ? `shape: ${JSON.stringify(shapes[id])}` : prop(f.obj, 'shape') && code.slice(prop(f.obj, 'shape').start, prop(f.obj, 'shape').end)
|
|
862
|
-
kept.push([n.start, `export const ${f.name} = ${f.callee}({ $id: ${JSON.stringify(id)}${opt ? `, ${code.slice(opt.start, opt.end)}` : ''}${shape ? `, ${shape}` : ''} })`])
|
|
863
|
-
continue
|
|
864
|
-
}
|
|
865
|
-
const decl = n.type === 'ExportNamedDeclaration' ? n.declaration : n
|
|
866
|
-
if (!decl) continue
|
|
867
|
-
if (/^TS(TypeAlias|Interface)Declaration$/.test(decl.type) || n.exportKind === 'type') continue
|
|
868
|
-
if (/^TS(Enum|Module)Declaration$/.test(decl.type)) { // the kinds with code in them, kept like any helper
|
|
869
|
-
candidates.push({ n, declares: [decl.id.name] })
|
|
870
|
-
continue
|
|
871
|
-
}
|
|
872
|
-
const bound = decl.type === 'VariableDeclaration' ? decl.declarations.flatMap(d => [...names(code.slice(d.id.start, d.id.end))]) : decl.id ? [decl.id.name] : []
|
|
873
|
-
if (bound.length) candidates.push({ n, declares: bound })
|
|
874
|
-
}
|
|
875
|
-
// Helpers a kept `optimistic` reads (and what they read), in source order.
|
|
876
|
-
for (let grew = true; grew; ) {
|
|
877
|
-
grew = false
|
|
878
|
-
const used = valueNames(parse(file, kept.map(([, t]) => t).join('\n')))
|
|
879
|
-
for (const c of candidates.splice(0)) {
|
|
880
|
-
if (c.declares.some(d => used.has(d))) { kept.push([c.n.start, code.slice(c.n.start, c.n.end)]); grew = true }
|
|
881
|
-
else candidates.push(c)
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
const out = `// weft: client stubs for ${rel} (research 27 §2)\n` + kept.sort((a, b) => a[0] - b[0]).map(([, t]) => t).join('\n') + '\n'
|
|
885
|
-
return pruneImports(out, file)
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/** Q003: a `query`/`mutation` defined in a file that isn't a server module (its name and kind), or undefined. */
|
|
889
|
-
function definesFns(code, file) {
|
|
890
|
-
if (!/@weft-js\/start/.test(code) || !/\b(query|mutation|stream|topic|channel|sharedState)\b/.test(code)) return
|
|
891
|
-
const ast = parse(file, code)
|
|
892
|
-
const locals = fnLocals(ast)
|
|
893
|
-
if (!locals.size) return
|
|
894
|
-
const f = fnExports(ast, locals)[0]
|
|
895
|
-
if (f) return { name: f.name, kind: locals.get(f.callee) }
|
|
896
|
-
for (const [local, kind] of locals) if (new RegExp(`(?<![\\w$.])${local}\\s*\\(`).test(code)) return { name: local, kind }
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
export { scanRoutes, routeTypes, here as startDir }
|