@weft-js/start 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +82 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +616 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +308 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +456 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +144 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +198 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1014 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- package/src/vite.mjs +899 -0
package/src/vite.mjs
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
// weftStart() for Vite 8 (research 25 §6): file routes as virtual modules, the server and client entries Nitro's Vite
|
|
2
|
+
// plugin builds (`nitro()` from `nitro/vite` goes next to it), and route modules without their server exports in
|
|
3
|
+
// the client build. Used through `weft dev` / `weft build`, which add weft() and nitro() and pass the config's `site`.
|
|
4
|
+
//
|
|
5
|
+
// virtual:weft/routes server: the manifest, every route, layout and card module, and each one's stylesheets
|
|
6
|
+
// client: the route table and a chunk loader per route id (load/meta/prerender stripped)
|
|
7
|
+
// virtual:weft/app server: site config, build id, card fonts (bytes, so every preset can read them)
|
|
8
|
+
// virtual:weft/entry-server the Nitro SSR entry: `export default { fetch }`
|
|
9
|
+
// virtual:weft/entry-client the client entry: `start()`, unless the app has src/entry.client.ts
|
|
10
|
+
// virtual:weft/fns server: imports every server module, so `/_weft/fn/<id>` knows every function
|
|
11
|
+
// virtual:weft/context server: `src/context.server.ts`'s default export (the app's part of `ctx`)
|
|
12
|
+
// virtual:weft/auth server: `src/auth.server.ts`'s default export, mounted at its base path
|
|
13
|
+
// virtual:weft/migrations server: `drizzle/`'s migrations, embedded (a bundle can't read a folder on every preset)
|
|
14
|
+
// virtual:weft/media server: the `media.server` provider when one is bound (research 29 §6)
|
|
15
|
+
// virtual:weft/realtime server: `src/realtime.server.ts`'s default export (the bus, origins and limits)
|
|
16
|
+
// virtual:weft/extensions server: each `server.extension` provider with its `src/<key>.server.ts` (extension.ts)
|
|
17
|
+
//
|
|
18
|
+
// Server modules (`src/server/**`, `*.server.ts`, research 27 §2): each `query`/`mutation`/`stream`/`channel` export
|
|
19
|
+
// gets an `$id` on both sides; the client build replaces the module with stubs that keep only `optimistic`, the
|
|
20
|
+
// query's shape and a shared state's `initial`.
|
|
21
|
+
//
|
|
22
|
+
// Stylesheets: routes are chunks the entry only reaches through `import()`, so `?assets=client` names just the entry's
|
|
23
|
+
// CSS. Each route and layout's own CSS comes from Nitro's assets manifest (build) or the client module graph (dev);
|
|
24
|
+
// the server links the entry's and those of the levels it matched, so nothing paints before its styles and other
|
|
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
|
+
// A build keeps each chunk's sheet for client navigation and writes a shared sheet (what every route has) and one sheet
|
|
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. `site: { criticalCss: false }` splits by chunk and inlines up to 48 KB, as links past that;
|
|
29
|
+
// `site: { inlineCss: false }` keeps plain links.
|
|
30
|
+
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync,realpathSync } from 'node:fs'
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
import { createHash } from 'node:crypto'
|
|
36
|
+
import { isCSSRequest, parseSync, searchForWorkspaceRoot } from 'vite'
|
|
37
|
+
import { scanRoutes, routeTypes } from './routes.mjs'
|
|
38
|
+
import { solve } from '@weft-js/core/hydration'
|
|
39
|
+
import { readMigrations } from './migrations.mjs'
|
|
40
|
+
import { scanRequired } from './live-target.mjs'
|
|
41
|
+
import { pieces } from '@weft-js/core/node-purge'
|
|
42
|
+
import { planSheets, reach } from './sheets.mjs'
|
|
43
|
+
import { INLINE_CSS_MAX } from './inline.mjs'
|
|
44
|
+
|
|
45
|
+
/** This folder, wherever the package is installed. */
|
|
46
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
47
|
+
const V = {
|
|
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__'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The client build's stylesheets a page can carry, `{ href: css }`, read by the server build so every preset has them
|
|
72
|
+
* with no file system. Left out: a sheet over the page's limit, one a relative `url()` would break, and one that could
|
|
73
|
+
* close its `<style>`.
|
|
74
|
+
*/
|
|
75
|
+
export function inlineSheets(outDir, base, limit = INLINE_CSS_MAX) {
|
|
76
|
+
const dir = join(outDir, 'assets')
|
|
77
|
+
if (!existsSync(dir)) return {}
|
|
78
|
+
const sheets = {}
|
|
79
|
+
for (const name of readdirSync(dir).filter(f => f.endsWith('.css'))) {
|
|
80
|
+
const css = readFileSync(join(dir, name), 'utf8')
|
|
81
|
+
if (css.length <= limit && !/<\/style|<!--/i.test(css) && !/url\(\s*(?!["']?(?:\/|data:|https?:|#|%23))/.test(css)) sheets[`${base}assets/${name}`] = css
|
|
82
|
+
}
|
|
83
|
+
return sheets
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How long a replaced sheet stays in the build, for pages cached before the deploy that replaced it: a CDN's longest day. */
|
|
87
|
+
const KEEP_SHEETS_MS = 30 * 864e5
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Writes the build's combined stylesheets beside the chunks' own. A route's list is what its chunks import, the entry's
|
|
91
|
+
* included, plus the sheets of the islands in the files it imports.
|
|
92
|
+
*/
|
|
93
|
+
function combineSheets(dir, bundle, manifest, abs, islands) {
|
|
94
|
+
const chunks = Object.values(bundle).filter(f => f.type === 'chunk')
|
|
95
|
+
const holding = id => chunks.filter(c => c.moduleIds.some(m => m.split('?')[0] === id))
|
|
96
|
+
const sheetsOf = (start, seen = new Set()) => start.flatMap(c => {
|
|
97
|
+
if (seen.has(c)) return []
|
|
98
|
+
seen.add(c)
|
|
99
|
+
return [...(c.viteMetadata?.importedCss ?? []), ...sheetsOf(c.imports.map(i => bundle[i]).filter(Boolean), seen)]
|
|
100
|
+
})
|
|
101
|
+
const entry = sheetsOf(chunks.filter(c => c.isEntry))
|
|
102
|
+
const routes = manifest.routes.map(r => {
|
|
103
|
+
const levels = [...manifest.layouts.filter(l => r.layouts.includes(l.id)).map(l => abs(l.file)), abs(r.file)]
|
|
104
|
+
const files = reach(levels)
|
|
105
|
+
const held = islands.filter(i => files.has(i.file)).map(i => i.file)
|
|
106
|
+
return [...new Set([...entry, ...sheetsOf([...levels, ...held].flatMap(holding))])]
|
|
107
|
+
}).filter(list => list.length)
|
|
108
|
+
const plan = planSheets(routes, name => readFileSync(join(dir, name), 'utf8'))
|
|
109
|
+
for (const [name, css] of Object.entries(plan.files)) writeFileSync(join(dir, name), css)
|
|
110
|
+
return plan
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The first-paint table of the build's sheets (node/purge.mjs), and the last builds' combined sheets kept beside them: a
|
|
115
|
+
* page cached across a deploy links the ones it was built with. `.weft/sheets` carries them, so CI has to keep that folder.
|
|
116
|
+
*/
|
|
117
|
+
export function sheetPieces(outDir, base, plan, keep, root) {
|
|
118
|
+
const dir = join(outDir, 'assets')
|
|
119
|
+
const href = name => `${base}${name}`
|
|
120
|
+
const names = [...new Set([plan.shared, ...plan.routes].filter(Boolean).flatMap(s => s.has))]
|
|
121
|
+
const sheets = names.map(n => [href(n), readFileSync(join(outDir, n), 'utf8')]).filter(([, css]) => !/<\/style|<!--/i.test(css))
|
|
122
|
+
const old = join(root, '.weft/sheets')
|
|
123
|
+
mkdirSync(old, { recursive: true })
|
|
124
|
+
for (const f of Object.keys(plan.files)) writeFileSync(join(old, f.replace('assets/', '')), plan.files[f])
|
|
125
|
+
for (const f of readdirSync(old)) {
|
|
126
|
+
if (Date.now() - statSync(join(old, f)).mtimeMs > KEEP_SHEETS_MS) rmSync(join(old, f))
|
|
127
|
+
else if (!existsSync(join(dir, f))) writeFileSync(join(dir, f), readFileSync(join(old, f)))
|
|
128
|
+
}
|
|
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
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {{ root?: string, site?: import('@weft-js/core/ssr').Site, routesDir?: string, timeout?: number, fonts?: string[], hydration?: 'lazy' | 'partial' | 'full' }} [options]
|
|
135
|
+
*/
|
|
136
|
+
export default function weftStart(options = {}) {
|
|
137
|
+
let root = options.root ?? process.cwd()
|
|
138
|
+
let dev = false
|
|
139
|
+
let manifest
|
|
140
|
+
let build = ''
|
|
141
|
+
let serverModules = []
|
|
142
|
+
let vite
|
|
143
|
+
// To see what an edit changed: a route or layout file's server exports, and a server module's client stubs.
|
|
144
|
+
const loaders = new Map()
|
|
145
|
+
const stubs = new Map()
|
|
146
|
+
let compile
|
|
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
|
+
},
|
|
221
|
+
},
|
|
222
|
+
renderChunk: {
|
|
223
|
+
order: 'post',
|
|
224
|
+
handler(code) {
|
|
225
|
+
const token = new RegExp(`["'\`]${INLINE_SHEETS}["'\`]`)
|
|
226
|
+
if (!token.test(code)) return null
|
|
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
|
+
},
|
|
233
|
+
},
|
|
234
|
+
configResolved(config) {
|
|
235
|
+
build = createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 8)
|
|
236
|
+
if (dev) build = 'dev'
|
|
237
|
+
root = config.root
|
|
238
|
+
const main = config.plugins.find(p => p.name === 'weft')
|
|
239
|
+
words = config.plugins.find(p => p.name === 'weft:purge')?.api
|
|
240
|
+
words?.add('data-weft styled') // gate.css reveals a mark a static page's HTML may hold alone
|
|
241
|
+
if (main?.api) {
|
|
242
|
+
compile = createRequire(join(here, 'vite.mjs'))('@weft-js/core/compiler').transform
|
|
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)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
server.config.logger.info(`weft: full reload: ${rel} was added or removed, so the routes changed`, { timestamp: true })
|
|
276
|
+
server.ws.send({ type: 'full-reload' })
|
|
277
|
+
}
|
|
278
|
+
const w = watch(dir, { recursive: true }, (event, file) => { if (event === 'rename' && file) changed(file) })
|
|
279
|
+
// Not every runtime's recursive watch reports a new file, and Vite's watcher does.
|
|
280
|
+
for (const event of ['add', 'unlink']) server.watcher.on(event, file => { if (file.startsWith(dir)) changed(relative(dir, file)) })
|
|
281
|
+
// Close with the server (middleware mode, as `weft routes --meta` runs it, has no httpServer to listen on).
|
|
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
|
|
326
|
+
}
|
|
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
|
+
}
|
|
333
|
+
const base = source.replace(/\?.*$/, '')
|
|
334
|
+
if (Object.values(V).includes(base) || base.startsWith(ISLAND)) return id(source)
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
load: {
|
|
338
|
+
filter: { id: /^\0virtual:weft\// },
|
|
339
|
+
async handler(raw) {
|
|
340
|
+
// `virtual:weft/entry-client?assets=client` (no src/entry.client.ts) is Nitro's assets plugin's to load.
|
|
341
|
+
if (/[?&]assets\b/.test(raw)) return
|
|
342
|
+
const which = raw.slice(1).replace(/\?.*$/, '')
|
|
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
|
+
},
|
|
367
|
+
},
|
|
368
|
+
transform: {
|
|
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
|
|
752
|
+
}
|
|
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 }
|