@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/exec.ts
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// The server's executor for queries and mutations (research 27 §3–§4). No runtime imports beyond data.ts, so the
|
|
2
|
+
// same code runs in the request handler (server.tsx, which finds the scope through AsyncLocalStorage) and in
|
|
3
|
+
// @weft-js/core/test's in-process fake transport.
|
|
4
|
+
//
|
|
5
|
+
// request scope key → entry (promise, then value and tags): a query runs at most once per request; loaders, every
|
|
6
|
+
// render pass of a streamed page and a mutation's refreshes share it. Its resolved entries are the
|
|
7
|
+
// page's `#weft-cache`.
|
|
8
|
+
// shared store only for `cache: { life }`: a value is valid while every tag's version still matches (a mutation
|
|
9
|
+
// bumps the versions of the tags it invalidates).
|
|
10
|
+
|
|
11
|
+
import { shapeTree } from './shape.mjs'
|
|
12
|
+
import type { MediaAsset } from '@weft-js/core/media'
|
|
13
|
+
import type { ServerExtension } from './extension'
|
|
14
|
+
import { FnError, enter, fns, isRedirect, keyOf, runtime, StartError, type Answer, type Context, type Fn, type Life, type SchemaIssue, type Wire } from './data'
|
|
15
|
+
import { unavailable } from './db/core'
|
|
16
|
+
|
|
17
|
+
export interface ScopeEntry {
|
|
18
|
+
id: string
|
|
19
|
+
input: unknown
|
|
20
|
+
p: Promise<ScopeEntry>
|
|
21
|
+
done?: boolean
|
|
22
|
+
v?: unknown
|
|
23
|
+
e?: unknown
|
|
24
|
+
tags: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Scope {
|
|
28
|
+
request: Request
|
|
29
|
+
entries: Map<string, ScopeEntry>
|
|
30
|
+
/** Tags a mutation or `invalidate()` dropped during this request. */
|
|
31
|
+
invalidated: Set<string>
|
|
32
|
+
/** Response headers the functions set (cookies), merged into the response. */
|
|
33
|
+
headers: Headers
|
|
34
|
+
/** The page's first byte went out: a redirect can't change the status any more (Q007). */
|
|
35
|
+
started?: boolean
|
|
36
|
+
/** The tags of the queries a route level's `load` read (a frame per loader; research 27 §4). */
|
|
37
|
+
tags?: Set<string>
|
|
38
|
+
/** Dev: the tables this function's statements read and wrote (a frame per `run`; research 28 §2). */
|
|
39
|
+
touched?: Touched
|
|
40
|
+
/** A no-JS form POST that failed: `<Form>` renders it into the page (research 29 §5). */
|
|
41
|
+
form?: FormFailure
|
|
42
|
+
/** The render read a live query or a stream: the page carries the realtime cursor (research 30 §1). */
|
|
43
|
+
live?: boolean
|
|
44
|
+
ctx(): Promise<Context>
|
|
45
|
+
options: ScopeOptions
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Touched { read: Set<string>; write: Set<string> }
|
|
49
|
+
|
|
50
|
+
export interface FormFailure { id: string; key?: string; values: Record<string, unknown>; errors: Record<string, string>; message?: string | undefined; status: number }
|
|
51
|
+
|
|
52
|
+
/** The `media.server@1` provider (`@weft-js/plugin-media/server`, research 29 §6). */
|
|
53
|
+
export interface MediaServer {
|
|
54
|
+
ingest(bytes: Uint8Array, options: { name?: string }): Promise<MediaAsset>
|
|
55
|
+
serve(want: { hash: string; width: number; format: string; accept: string }): Promise<{ bytes: Uint8Array; type: string; width: number; format: string; negotiated: boolean; temporary?: boolean } | null>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ScopeOptions {
|
|
59
|
+
/** The app's context (`src/context.server.ts`). */
|
|
60
|
+
context?: ((args: { request: Request }) => object | Promise<object>) | undefined
|
|
61
|
+
store?: Store | undefined
|
|
62
|
+
/** Tests (@weft-js/core/test `net.handle`): a replacement `run` by function id. */
|
|
63
|
+
handlers?: Map<string, (input: unknown, ctx: Context) => unknown>
|
|
64
|
+
onDiagnostic?: ((d: { code: string; severity: string; message: string }) => void) | undefined
|
|
65
|
+
dev?: boolean | undefined
|
|
66
|
+
media?: (() => Promise<MediaServer> | undefined) | undefined
|
|
67
|
+
/** What plugins add through `server.extension` (extension.ts). */
|
|
68
|
+
extensions?: ServerExtension[] | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createScope(request: Request, options: ScopeOptions = {}): Scope {
|
|
72
|
+
const headers = new Headers()
|
|
73
|
+
let ctx: Promise<Context> | undefined
|
|
74
|
+
return {
|
|
75
|
+
request, entries: new Map(), invalidated: new Set(), headers, options,
|
|
76
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- needed where a plugin such as pay adds required fields to Context
|
|
77
|
+
ctx: () => (ctx ??= Promise.resolve().then(() => options.context?.({ request }) ?? {}).then(app => extended({ ...app, request, response: { headers }, media: mediaApi(options) } as Context, options), e => { throw dbError(e) })),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Each extension's part of `ctx`, once the app's is there: an extension reads `ctx.user` to say what it adds. */
|
|
82
|
+
function extended(ctx: Context, options: ScopeOptions): Context {
|
|
83
|
+
for (const e of options.extensions ?? []) Object.assign(ctx, e.context?.(ctx))
|
|
84
|
+
return ctx
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `ctx.media`. A refusal is the user's answer, with its own status; `<Form>` shows it. */
|
|
88
|
+
const mediaApi = (options: ScopeOptions): Context['media'] => ({
|
|
89
|
+
async ingest(file, { name } = {}) {
|
|
90
|
+
const server = await options.media?.()
|
|
91
|
+
if (!server) throw new StartError('M017', '`ctx.media` needs a `media.server` provider', 'add `media()` from `@weft-js/plugin-media` to `plugins` in weft.config.ts', { status: 500 })
|
|
92
|
+
const bytes = file instanceof Uint8Array ? file : new Uint8Array(await file.arrayBuffer())
|
|
93
|
+
try {
|
|
94
|
+
return await server.ingest(bytes, { name: name ?? (file as File).name })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const { code, message } = e as Error & { code?: string }
|
|
97
|
+
throw code ? new StartError(code, message, undefined, { status: 422 }) : e
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** A child scope for one loader: the same entries, its own `tags` collector. */
|
|
103
|
+
export const frame = (scope: Scope): Scope => Object.create(scope, { tags: { value: new Set<string>() } })
|
|
104
|
+
|
|
105
|
+
// ---- the shared tagged cache --------------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Where `cache: { life }` values live. Memory by default; Nitro storage (fs, KV, Redis) plugs in here. */
|
|
108
|
+
export interface Store {
|
|
109
|
+
get(key: string): Stored | undefined | Promise<Stored | undefined>
|
|
110
|
+
set(key: string, value: Stored): void | Promise<void>
|
|
111
|
+
/** The current version of each tag (0 for one never bumped). */
|
|
112
|
+
versions(tags: string[]): number[] | Promise<number[]>
|
|
113
|
+
bump(tags: string[]): void | Promise<void>
|
|
114
|
+
}
|
|
115
|
+
export interface Stored { v: unknown; tags: string[]; versions: number[]; expires: number }
|
|
116
|
+
|
|
117
|
+
export function memoryStore(): Store {
|
|
118
|
+
const values = new Map<string, Stored>()
|
|
119
|
+
const tags = new Map<string, number>()
|
|
120
|
+
return {
|
|
121
|
+
get: k => values.get(k),
|
|
122
|
+
set: (k, v) => void values.set(k, v),
|
|
123
|
+
versions: t => t.map(x => tags.get(x) ?? 0),
|
|
124
|
+
bump: t => { for (const x of t) tags.set(x, (tags.get(x) ?? 0) + 1) },
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const LIFE: Record<Exclude<Life, number>, number> = { seconds: 1, minutes: 60, hours: 3600, days: 86_400, max: Infinity }
|
|
129
|
+
const seconds = (life: Life = 'minutes') => (typeof life === 'number' ? life : LIFE[life])
|
|
130
|
+
export const defaultStore = memoryStore()
|
|
131
|
+
const storeOf = (scope: Scope) => scope.options.store ?? defaultStore
|
|
132
|
+
|
|
133
|
+
// ---- queries --------------------------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** The value now if this request already has it, else the promise: what a query resource's fetcher returns in SSR. */
|
|
136
|
+
export function readQuery(scope: Scope, fn: Fn, input: unknown): unknown {
|
|
137
|
+
const e = fetchEntry(scope, fn, input)
|
|
138
|
+
if (!e.done) return e.p.then(x => x.v)
|
|
139
|
+
if (e.e !== undefined) throw e.e as Error
|
|
140
|
+
return e.v
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const trees = new WeakMap<Fn, { tree: unknown }>()
|
|
144
|
+
/** A query's shape tree, once per definition. */
|
|
145
|
+
export function shapeOf(fn: Fn): unknown {
|
|
146
|
+
let t = trees.get(fn)
|
|
147
|
+
if (!t) trees.set(fn, (t = { tree: shapeTree(fn.def) }))
|
|
148
|
+
return t.tree
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const fetchQuery = (scope: Scope, fn: Fn, input: unknown): Promise<ScopeEntry> => {
|
|
152
|
+
const e = fetchEntry(scope, fn, input)
|
|
153
|
+
const tags = scope.tags
|
|
154
|
+
return e.p.then(x => (tags && x.tags.forEach(t => tags.add(t)), x))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fetchEntry(scope: Scope, fn: Fn, input: unknown): ScopeEntry {
|
|
158
|
+
if (fn.kind !== 'query') throw new StartError('Q009', `\`${fn.$id}\` is a mutation, not a query`, 'call it as a function, from a handler')
|
|
159
|
+
const key = keyOf(fn.$id, input)
|
|
160
|
+
const e = scope.entries.get(key)
|
|
161
|
+
if (e) return e
|
|
162
|
+
const entry: ScopeEntry = { id: fn.$id, input, tags: [], p: undefined as never }
|
|
163
|
+
entry.p = (async () => {
|
|
164
|
+
const valid = await validate(fn, input)
|
|
165
|
+
entry.tags = await tagsOf(fn, valid, scope)
|
|
166
|
+
const cached = fn.def.cache
|
|
167
|
+
const store = storeOf(scope)
|
|
168
|
+
// Checked before the shared cache, so a value stored for one user is never answered to no one.
|
|
169
|
+
const early = fn.def.auth || cached?.scope ? await scope.ctx() : undefined
|
|
170
|
+
if (early) guard(fn, cached ? shared(early, fn, scope) : early)
|
|
171
|
+
const stored = cached?.scope ? `${key}@${String((early as { user?: { id?: string | number } } | undefined)?.user?.id ?? '')}` : key
|
|
172
|
+
if (cached) {
|
|
173
|
+
const hit = await store.get(stored)
|
|
174
|
+
if (hit && hit.expires > Date.now() && same(hit.versions, await store.versions(hit.tags))) return hit.v
|
|
175
|
+
}
|
|
176
|
+
const versions = cached ? await store.versions(entry.tags) : []
|
|
177
|
+
const ctx = early ?? (await scope.ctx())
|
|
178
|
+
const touched = tracker(scope)
|
|
179
|
+
const added: string[] = []
|
|
180
|
+
const v = await call(scope, fn, valid, cached ? shared(ctx, fn, scope) : ctx, touched, added)
|
|
181
|
+
const fresh = [...new Set(added)].filter(t => !entry.tags.includes(t))
|
|
182
|
+
if (fresh.length) {
|
|
183
|
+
entry.tags.push(...fresh)
|
|
184
|
+
if (cached) versions.push(...(await store.versions(fresh)))
|
|
185
|
+
}
|
|
186
|
+
if (scope.options.dev) await checked(v, fn, scope)
|
|
187
|
+
if (touched) {
|
|
188
|
+
untouched(fn, touched.read, entry.tags, scope)
|
|
189
|
+
missedTags(fn, 'read', touched.read, entry.tags, scope)
|
|
190
|
+
reads.set(fn.$id, touched.read)
|
|
191
|
+
}
|
|
192
|
+
if (cached) await store.set(stored, { v, tags: entry.tags, versions, expires: Date.now() + seconds(cached.life) * 1000 })
|
|
193
|
+
return v
|
|
194
|
+
})().then(
|
|
195
|
+
v => Object.assign(entry, { done: true, v }),
|
|
196
|
+
err => {
|
|
197
|
+
if (isRedirect(err) && scope.started) {
|
|
198
|
+
const message = `\`${fn.$id}\` called \`redirect('${err.href}')\` after the page started streaming: the status is already sent`
|
|
199
|
+
scope.options.onDiagnostic?.({ code: 'Q007', severity: 'error', message })
|
|
200
|
+
err = new StartError('Q007', message, `call \`${fn.$id.replace(/\..*$/, '')}.fetch()\` in the route's \`load\`, which runs before the first byte`)
|
|
201
|
+
}
|
|
202
|
+
Object.assign(entry, { done: true, e: err })
|
|
203
|
+
throw err
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
entry.p.catch(() => {}) // a failure surfaces where the value is read
|
|
207
|
+
scope.entries.set(key, entry)
|
|
208
|
+
return entry
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A query's tags. One that takes `ctx` is one user's, so it reads `ctx`; a signed-out caller fails here, as `guard` would. */
|
|
212
|
+
export async function tagsOf(fn: Fn, input: unknown, scope: Pick<Scope, 'ctx'>): Promise<string[]> {
|
|
213
|
+
const tags = fn.def.tags
|
|
214
|
+
if (typeof tags !== 'function') return [...(tags ?? [])]
|
|
215
|
+
if (tags.length < 2) return [...tags(input)]
|
|
216
|
+
const ctx = await scope.ctx()
|
|
217
|
+
guard(fn, ctx)
|
|
218
|
+
return [...tags(input, ctx)]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const same = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i])
|
|
222
|
+
|
|
223
|
+
/** A body in another process (research 36): what it adds to the tags and reports it touched arrive with the value. */
|
|
224
|
+
async function callImpl(fn: Fn, input: unknown, ctx: Context, scope: Scope, touched: Touched | undefined, added: string[]) {
|
|
225
|
+
const { value, tags, touched: tables } = await fn.def.impl!.call({ fn, input, ctx, request: scope.request })
|
|
226
|
+
added.push(...tags)
|
|
227
|
+
if (touched) for (const t of tables) touched[fn.kind === 'query' ? 'read' : 'write'].add(t)
|
|
228
|
+
return value
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const OWN = new Set(['$id', 'auth', 'cache', 'errors', 'impl', 'input', 'invalidates', 'live', 'optimistic', 'output', 'run', 'shape', 'tags'])
|
|
232
|
+
|
|
233
|
+
/** An option from `FnOptions` is a promise to enforce something: with the plugin gone, the call stops rather than runs open. */
|
|
234
|
+
function claimed(fn: Fn, scope: Scope) {
|
|
235
|
+
for (const k in fn.def) {
|
|
236
|
+
if (OWN.has(k) || scope.options.extensions?.some(e => e.options?.includes(k))) continue
|
|
237
|
+
throw new StartError('P101', `\`${fn.$id}\` sets \`${k}\`, and no \`server.extension\` enforces it`, 'add the plugin that provides it to `plugins`, with its `src/<key>.server.ts`', { status: 500 })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function call(scope: Scope, fn: Fn, input: unknown, ctx: Context, touched?: Touched, added: string[] = []) {
|
|
242
|
+
guard(fn, ctx)
|
|
243
|
+
claimed(fn, scope)
|
|
244
|
+
try {
|
|
245
|
+
for (const e of scope.options.extensions ?? []) await e.guard?.({ id: fn.$id, kind: fn.kind, def: fn.def }, ctx)
|
|
246
|
+
} catch (e) {
|
|
247
|
+
throw dbError(e)
|
|
248
|
+
}
|
|
249
|
+
const replaced = scope.options.handlers?.get(fn.$id)
|
|
250
|
+
// Awaited inside the frame: a Drizzle query is a thenable, and only runs when something awaits it.
|
|
251
|
+
const run = async () => await (replaced ? replaced(input, ctx) : fn.def.impl ? callImpl(fn, input, ctx, scope, touched, added) : fn.def.run!({ input, ctx }))
|
|
252
|
+
let v: unknown
|
|
253
|
+
try {
|
|
254
|
+
v = await (touched ? enter(Object.create(scope, { touched: { value: touched } }), run) : run())
|
|
255
|
+
} catch (e) {
|
|
256
|
+
throw dbError(e)
|
|
257
|
+
}
|
|
258
|
+
// oxlint-disable-next-line typescript/only-throw-error -- a returned redirect takes the same path as a thrown one
|
|
259
|
+
if (isRedirect(v)) throw v
|
|
260
|
+
return v
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `auth: 'user'`: A003 when `ctx` has no `user` at all, A002 when no one is signed in. */
|
|
264
|
+
export function guard(fn: Fn, ctx: Context) {
|
|
265
|
+
if (fn.def.auth !== 'user') return
|
|
266
|
+
if (!('user' in ctx)) throw new StartError('A003', `\`${fn.$id}\` has \`auth: 'user'\`, but \`ctx\` has no \`user\``, 'spread `session(auth, request)` into what `src/context.server.ts` returns', { status: 500 })
|
|
267
|
+
if (!(ctx as { user?: unknown }).user) throw new StartError('A002', `\`${fn.$id}\` needs a signed-in user`, 'sign in first, or guard the route with `redirect()` in its `load`', { status: 401 })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const DOWN = /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|CONNECT_TIMEOUT|CONNECTION_(CLOSED|ENDED|DESTROYED)|PROTOCOL_CONNECTION_LOST|P100[12])$/
|
|
271
|
+
|
|
272
|
+
interface Failure { cause?: Failure | null; answer?: { message?: string; code?: string; status: number }; code?: string | number; message?: string }
|
|
273
|
+
|
|
274
|
+
/** A dropped connection is Q016; a missing table is a migration that wasn't generated or applied (Q014): the driver's error, with the fix. */
|
|
275
|
+
export function dbError(e: unknown): unknown {
|
|
276
|
+
const failure = e as Failure | null | undefined
|
|
277
|
+
const cause = failure?.cause ?? failure
|
|
278
|
+
if (failure?.answer) return new FnError(failure.answer, failure.answer.status)
|
|
279
|
+
if (cause instanceof StartError) return cause // Drizzle wraps what a driver throws, the gate's Q016 included
|
|
280
|
+
if (!(e instanceof StartError) && DOWN.test(String(cause?.code ?? failure?.code ?? ''))) return unavailable(e)
|
|
281
|
+
const m = cause?.message ?? ''
|
|
282
|
+
if (e instanceof StartError || !(/no such (table|column)|has no column named/.test(m) || cause?.code === '42P01' || cause?.code === '42703')) return e
|
|
283
|
+
return new StartError('Q014', `the database doesn't match the schema: ${m}`, 'run `weft db generate`, then reload (new migrations apply on the next request)', { status: 500 })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
type Obj = Record<PropertyKey, unknown>
|
|
287
|
+
|
|
288
|
+
const tracker = (scope: Scope): Touched | undefined => (scope.options.dev ? { read: new Set(), write: new Set() } : undefined)
|
|
289
|
+
|
|
290
|
+
/** R105 (dev, once per function): a cached Rust query with tags that named no table, so Q011 has nothing to check it by. */
|
|
291
|
+
function untouched(fn: Fn, read: Set<string>, tags: readonly string[], scope: Scope) {
|
|
292
|
+
if (!fn.def.impl || !fn.def.cache || read.size || !tags.length || warned.has(`${fn.$id}:R105`)) return
|
|
293
|
+
warned.add(`${fn.$id}:R105`)
|
|
294
|
+
scope.options.onDiagnostic?.({ code: 'R105', severity: 'warning', message: `\`${fn.$id}\` is cached under ${tags.map(t => `\`${t}\``).join(', ')} and reported no table: nothing can tell whether those tags cover what it reads. Call \`ctx.touch("table")\` in the Rust, or drop \`tags\` if it reads none` })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Q011 (dev, once per function and table): a table the function touched that no tag names (PLAN §4). */
|
|
298
|
+
function missedTags(fn: Fn, verb: 'read' | 'write', tables: Set<string>, tags: readonly string[], scope: Scope) {
|
|
299
|
+
for (const t of tables) {
|
|
300
|
+
const key = `${fn.$id}:${t}`
|
|
301
|
+
if (warned.has(key) || tags.some(x => x === t || x.startsWith(`${t}:`))) continue
|
|
302
|
+
warned.add(key)
|
|
303
|
+
const list = fn.kind === 'query' ? '`tags`' : '`invalidates`'
|
|
304
|
+
scope.options.onDiagnostic?.({ code: 'Q011', severity: 'warning', message: `\`${fn.$id}\` ${verb === 'read' ? 'reads' : 'writes'} table \`${t}\`, but its ${list} don't name it: ${fn.kind === 'query' ? 'a mutation of that table won\'t refresh it' : 'queries of that table stay stale'}. Add \`'${t}'\` to ${list}` })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const warned = new Set<string>()
|
|
308
|
+
|
|
309
|
+
/** Dev: the tables each query last read, and the tags of the queries read with `.live()` (the hub fills it). */
|
|
310
|
+
const reads = new Map<string, Set<string>>()
|
|
311
|
+
export const liveQueries = new Map<string, string[]>()
|
|
312
|
+
|
|
313
|
+
/** R003 (dev, once per pair): this mutation wrote what a live query reads, and pokes none of its tags. */
|
|
314
|
+
function missedPokes(fn: Fn, write: Set<string>, tags: string[], scope: Scope) {
|
|
315
|
+
for (const [id, own] of liveQueries) {
|
|
316
|
+
const table = [...(reads.get(id) ?? [])].find(t => write.has(t))
|
|
317
|
+
const key = `${fn.$id}>${id}`
|
|
318
|
+
if (!table || warned.has(key) || own.some(t => tags.includes(t))) continue
|
|
319
|
+
warned.add(key)
|
|
320
|
+
scope.options.onDiagnostic?.({ code: 'R003', severity: 'warning', message: `\`${fn.$id}\` writes table \`${table}\`, which live query \`${id}\` reads, but their tags don't intersect (${JSON.stringify(tags)} and ${JSON.stringify(own)}): open pages won't update. Add one of the query's tags to \`invalidates\`` })
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** R012 (dev, once per query and table): a live query whose tags won't hear what `changes` reports, or a table it doesn't report. */
|
|
325
|
+
export function missedFeed(fn: Fn, tags: string[], tables: string[], report: ScopeOptions['onDiagnostic']) {
|
|
326
|
+
for (const t of reads.get(fn.$id) ?? []) {
|
|
327
|
+
const key = `${fn.$id}~${t}`
|
|
328
|
+
const named = tags.some(x => x === t || x.startsWith(`${t}:`))
|
|
329
|
+
if (warned.has(key) || (named && tables.includes(t))) continue
|
|
330
|
+
warned.add(key)
|
|
331
|
+
report?.({ code: 'R012', severity: 'warning', message: tables.includes(t) ? `live query \`${fn.$id}\` reads table \`${t}\`, but no tag names it: writes from outside the app won't refresh it. Add \`'${t}'\` to its \`tags\`` : `live query \`${fn.$id}\` reads table \`${t}\`, which \`changes\` doesn't list: only mutations refresh it, not writes from outside the app. Add the table to \`dbChanges\`` })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A cached query's ctx: no request, response or user. Its value is shared by every user (Q006). With `scope: 'user'`, one value per user: `ctx.user` is allowed. */
|
|
336
|
+
function shared(ctx: Context, fn: Fn, scope: Scope): Context {
|
|
337
|
+
const own = fn.def.cache?.scope === 'user'
|
|
338
|
+
return new Proxy(ctx, {
|
|
339
|
+
get(t, k, r) {
|
|
340
|
+
const personal = scope.options.extensions?.some(e => e.personal?.includes(String(k)))
|
|
341
|
+
if (own && (k === 'user' || k === 'session' || personal)) return Reflect.get(t, k, r)
|
|
342
|
+
if (k === 'request' || k === 'response' || k === 'user' || k === 'session' || personal)
|
|
343
|
+
throw new StartError('Q006', `\`${fn.$id}\` is cached (\`cache: { life }\`) and reads \`ctx.${String(k)}\`: its value would be shared by every user`, 'drop `cache`, or pass the value as input')
|
|
344
|
+
return Reflect.get(t, k, r)
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---- mutations --------------------------------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/** Run a mutation; then refresh the listed queries whose tags it invalidated (single-flight, research 27 §3). */
|
|
352
|
+
export async function runMutation(scope: Scope, fn: Fn, input: unknown, refresh: [string, unknown][], form?: boolean): Promise<Answer> {
|
|
353
|
+
if (fn.kind !== 'mutation') throw new StartError('Q009', `\`${fn.$id}\` is a query, not a mutation`, `\`${fn.$id.replace(/\..*$/, '')}.fetch(input)\``)
|
|
354
|
+
if (scope.request.method === 'GET' || scope.request.method === 'HEAD')
|
|
355
|
+
throw new StartError('Q004', `mutation \`${fn.$id}\` called while the server handles a GET (\`load\`, a page render or a query)`, 'call mutations from handlers or other mutations; GET handlers must be side-effect free', { status: 500 })
|
|
356
|
+
const valid = await (form ? formInput(fn, input) : validate(fn, input))
|
|
357
|
+
const touched = tracker(scope)
|
|
358
|
+
const added: string[] = []
|
|
359
|
+
const v = await call(scope, fn, valid, await scope.ctx(), touched, added)
|
|
360
|
+
if (scope.options.dev) await checked(v, fn, scope)
|
|
361
|
+
const declared = typeof fn.def.invalidates === 'function' ? fn.def.invalidates(valid, v) : fn.def.invalidates ?? []
|
|
362
|
+
await invalidate(scope, [...declared, ...added])
|
|
363
|
+
const tags = [...scope.invalidated]
|
|
364
|
+
if (touched) {
|
|
365
|
+
missedTags(fn, 'write', touched.write, tags, scope)
|
|
366
|
+
missedPokes(fn, touched.write, tags, scope)
|
|
367
|
+
}
|
|
368
|
+
const c: Wire[] = []
|
|
369
|
+
for (const [id, inp] of refresh) {
|
|
370
|
+
const q = fns.get(id)
|
|
371
|
+
if (!q || q.kind !== 'query') continue
|
|
372
|
+
let qtags: string[]
|
|
373
|
+
try {
|
|
374
|
+
const vi = await validate(q, inp)
|
|
375
|
+
qtags = await tagsOf(q, vi, scope)
|
|
376
|
+
} catch {
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
if (!qtags.some(t => tags.includes(t))) continue
|
|
380
|
+
try {
|
|
381
|
+
const e = await fetchQuery(scope, q, inp)
|
|
382
|
+
c.push([id, inp, e.v, e.tags])
|
|
383
|
+
} catch {} // the client refetches it and sees the error there
|
|
384
|
+
}
|
|
385
|
+
return { v, t: tags, c }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Drop `tags` for the rest of this request and bump them in the shared store. */
|
|
389
|
+
export async function invalidate(scope: Scope, tags: string[]) {
|
|
390
|
+
if (!tags.length) return
|
|
391
|
+
for (const t of tags) scope.invalidated.add(t)
|
|
392
|
+
for (const [k, e] of scope.entries) if (e.tags.some(t => tags.includes(t))) scope.entries.delete(k)
|
|
393
|
+
await storeOf(scope).bump(tags)
|
|
394
|
+
// After the bump, and after `run`: a refetch reads committed data and misses the shared cache (research 30 §5).
|
|
395
|
+
// A bus that's down costs open pages a refresh, not this request its result.
|
|
396
|
+
await runtime.poke?.(tags, scope.request.headers.get('x-weft-conn')).catch(e => scope.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `poke of ${tags.join(', ')} failed: ${e instanceof Error ? e.message : e}` }))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- validation and checks ------------------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
export async function validate(fn: Fn, input: unknown): Promise<unknown> {
|
|
402
|
+
const schema = fn.def.input
|
|
403
|
+
if (!schema) return input
|
|
404
|
+
const r = await schema['~standard'].validate(input)
|
|
405
|
+
if (!r.issues) return r.value
|
|
406
|
+
throw rejected(fn, r.issues)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const plainPath = (i: SchemaIssue) => (i.path ?? []).map(p => (typeof p === 'object' ? p.key : p))
|
|
410
|
+
|
|
411
|
+
function rejected(fn: Fn, list: readonly SchemaIssue[]) {
|
|
412
|
+
const issues = list.map(i => ({ message: i.message, path: i.path && plainPath(i) })) as (SchemaIssue & { path?: PropertyKey[] })[]
|
|
413
|
+
const where = issues.map(i => `${i.path?.length ? i.path.map(String).join('.') + ': ' : ''}${i.message}`).join('; ')
|
|
414
|
+
return new StartError('Q002', `\`${fn.$id}\` rejected its input: ${where}`, 'pass what the schema accepts', { status: 400, issues })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A form's string as the type it may stand for (research 29 §5): the schema says whether it was wrong. */
|
|
418
|
+
function reading(s: string): unknown {
|
|
419
|
+
if (s === '') return undefined
|
|
420
|
+
if (s === 'on' || s === 'true') return true
|
|
421
|
+
if (s === 'false') return false
|
|
422
|
+
return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : s
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A form sends strings and a schema may want numbers or booleans, and Standard Schema can't say which: each issue
|
|
427
|
+
* on a string tries its obvious reading, then the input is validated again. Fields a reading fixed report nothing; the
|
|
428
|
+
* rest report what the schema says of what was typed (an empty field: its "required", not "expected a string").
|
|
429
|
+
*/
|
|
430
|
+
async function formInput(fn: Fn, input: unknown): Promise<unknown> {
|
|
431
|
+
const schema = fn.def.input
|
|
432
|
+
if (!schema || !input || typeof input !== 'object') return validate(fn, input)
|
|
433
|
+
const value: Obj = { ...input }
|
|
434
|
+
let left: readonly SchemaIssue[] = []
|
|
435
|
+
for (let i = 0; i < 3; i++) {
|
|
436
|
+
const r = await schema['~standard'].validate(value)
|
|
437
|
+
if (!r.issues) return r.value
|
|
438
|
+
left = r.issues
|
|
439
|
+
let changed = false
|
|
440
|
+
for (const issue of r.issues) {
|
|
441
|
+
const path = plainPath(issue)
|
|
442
|
+
if (!path.length) continue
|
|
443
|
+
let at: Obj | undefined = value
|
|
444
|
+
for (const p of path.slice(0, -1)) {
|
|
445
|
+
const inner: unknown = at?.[p]
|
|
446
|
+
if (!inner || typeof inner !== 'object') { at = undefined; break }
|
|
447
|
+
at = at[p] = (Array.isArray(inner) ? [...inner] : { ...inner }) as Obj
|
|
448
|
+
}
|
|
449
|
+
const k = path.at(-1)!
|
|
450
|
+
const typed = at?.[k]
|
|
451
|
+
if (typeof typed !== 'string') continue
|
|
452
|
+
const next = reading(typed)
|
|
453
|
+
if (next === typed) continue
|
|
454
|
+
if (next === undefined) delete at![k]
|
|
455
|
+
else at![k] = next
|
|
456
|
+
changed = true
|
|
457
|
+
}
|
|
458
|
+
if (!changed) break
|
|
459
|
+
}
|
|
460
|
+
const first = await schema['~standard'].validate(input)
|
|
461
|
+
const where = (i: SchemaIssue) => JSON.stringify(plainPath(i))
|
|
462
|
+
throw rejected(fn, left.map(i => first.issues?.find(o => where(o) === where(i)) ?? i))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Dev and tests: the value against `output` (C002), and what JSON would change (Q005). */
|
|
466
|
+
async function checked(v: unknown, fn: Fn, scope: Scope) {
|
|
467
|
+
serializable(v, fn, scope)
|
|
468
|
+
const schema = fn.def.output
|
|
469
|
+
if (!schema || fn.def.impl) return // `impl`: rust.ts checked what came back, on every call
|
|
470
|
+
const r = await schema['~standard'].validate(v)
|
|
471
|
+
if (r.issues) throw new StartError('C002', `\`${fn.$id}\` returned a value its \`output\` schema rejects: ${r.issues.map(i => `${i.path?.map(p => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`).join('; ')}`, 'fix `run`, or the schema', { status: 500 })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Q005 (dev): what JSON would change on the way to the client. */
|
|
475
|
+
function serializable(v: unknown, fn: Fn, scope: Scope) {
|
|
476
|
+
const bad = (x: unknown, path: string): string | undefined => {
|
|
477
|
+
if (x === null || typeof x === 'string' || typeof x === 'boolean') return undefined
|
|
478
|
+
if (typeof x === 'number') return Number.isFinite(x) ? undefined : `${path} is ${x}`
|
|
479
|
+
if (x === undefined) return undefined
|
|
480
|
+
if (typeof x !== 'object') return `${path} is a ${typeof x}`
|
|
481
|
+
if (Array.isArray(x)) {
|
|
482
|
+
for (let i = 0; i < x.length; i++) {
|
|
483
|
+
if (x[i] === undefined) return `${path}[${i}] is undefined (JSON sends null)`
|
|
484
|
+
const b = bad(x[i], `${path}[${i}]`)
|
|
485
|
+
if (b) return b
|
|
486
|
+
}
|
|
487
|
+
return undefined
|
|
488
|
+
}
|
|
489
|
+
const proto: unknown = Object.getPrototypeOf(x)
|
|
490
|
+
if (proto !== Object.prototype && proto !== null) return `${path} is a ${(x as { constructor?: { name?: string } }).constructor?.name ?? 'class instance'}`
|
|
491
|
+
for (const k of Object.keys(x)) {
|
|
492
|
+
const b = bad((x as Obj)[k], `${path}.${k}`)
|
|
493
|
+
if (b) return b
|
|
494
|
+
}
|
|
495
|
+
return undefined
|
|
496
|
+
}
|
|
497
|
+
const b = bad(v, 'result')
|
|
498
|
+
if (b) scope.options.onDiagnostic?.({ code: 'Q005', severity: 'warning', message: `\`${fn.$id}\`'s ${b}: the client gets a different value than the server rendered with. Send plain data (ISO strings, arrays, objects)` })
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Q008 (dev, once): each mutation's static `invalidates` against the tags queries declare. A tags function is called
|
|
503
|
+
* with an input whose every field reads `*`, so `todo:${id}` declares `todo:*`.
|
|
504
|
+
*/
|
|
505
|
+
export function checkTags(report: (d: { code: string; severity: string; message: string }) => void) {
|
|
506
|
+
const star: unknown = new Proxy({}, { get: (_, k) => (k === Symbol.toPrimitive ? () => '*' : '*') })
|
|
507
|
+
const declared: string[] = []
|
|
508
|
+
for (const f of fns.values()) {
|
|
509
|
+
if (f.kind !== 'query') continue
|
|
510
|
+
try { declared.push(...(typeof f.def.tags === 'function' ? f.def.tags(star) : f.def.tags ?? [])) } catch {}
|
|
511
|
+
}
|
|
512
|
+
const matches = (tag: string) => declared.some(d => d === tag || (d.includes('*') && new RegExp(`^${d.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^]*')}$`).test(tag)) || (tag.includes('*') && d.startsWith(tag.slice(0, tag.indexOf('*')))))
|
|
513
|
+
for (const f of fns.values()) {
|
|
514
|
+
if (f.kind !== 'mutation' || !f.def.invalidates) continue
|
|
515
|
+
let tags: readonly string[]
|
|
516
|
+
try { tags = typeof f.def.invalidates === 'function' ? f.def.invalidates(star, star) : f.def.invalidates } catch { continue }
|
|
517
|
+
for (const t of tags) if (!matches(t)) report({ code: 'Q008', severity: 'warning', message: `\`${f.$id}\` invalidates \`${t}\`, a tag no query declares: nothing refreshes. Fix the tag, or add it to the query's \`tags\`` })
|
|
518
|
+
}
|
|
519
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `server.extension@1`: how a plugin reaches the server without a private path. One provider per key adds what it needs,
|
|
2
|
+
// and only an app with `src/<key>.server.ts` runs it, so nothing arrives behind the app's back (P118). The provider exports
|
|
3
|
+
// a function from that file's default export to a `ServerExtension`; `weft docs plugins` has a worked example.
|
|
4
|
+
//
|
|
5
|
+
// Four things, each optional. Routes live under `/_weft/x/<key>/`. `context` adds to `ctx`. `guard` runs before every server
|
|
6
|
+
// function, and options it reads come from augmenting `FnOptions`. `check` runs before the first request.
|
|
7
|
+
|
|
8
|
+
import type { Context } from './data'
|
|
9
|
+
|
|
10
|
+
/** The path prefix an extension's routes answer under. */
|
|
11
|
+
export const EXTENSION = '/_weft/x/'
|
|
12
|
+
|
|
13
|
+
/** What a server extension is handed. */
|
|
14
|
+
export interface ExtensionApi {
|
|
15
|
+
/** Drop these tags, as a mutation would: live readers refetch. */
|
|
16
|
+
invalidate(tags: string[]): Promise<void>
|
|
17
|
+
/** The server log. */
|
|
18
|
+
report(d: { code: string; severity: string; message: string }): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A plugin's server half: its routes, what it adds to `ctx`, and what it guards. */
|
|
22
|
+
export interface ServerExtension {
|
|
23
|
+
/** Serves `/_weft/x/<key>/<path>`. It sees the raw request: nothing has read the body. */
|
|
24
|
+
handle?(request: Request, path: string, api: ExtensionApi): Promise<Response> | Response
|
|
25
|
+
/** Merged into `ctx`, after the app's own. */
|
|
26
|
+
context?(ctx: Context): object
|
|
27
|
+
/** Before a function's `run`. Throw to refuse: an error with `answer: { code, message, status }` reaches the client as it is. */
|
|
28
|
+
guard?(fn: { id: string; kind: string; def: Record<string, unknown> }, ctx: Context): void | Promise<void>
|
|
29
|
+
/** The `FnOptions` keys it enforces. One no extension claims refuses the call: a guard that isn't there mustn't pass. */
|
|
30
|
+
options?: readonly string[]
|
|
31
|
+
/** The `ctx` keys that differ per user, which a shared cache may not read (Q006). */
|
|
32
|
+
personal?: readonly string[]
|
|
33
|
+
/** Before the first request, never in a build: throw for a server that can't run safely. */
|
|
34
|
+
check?(dev?: boolean): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One extension in the generated list, loaded when first asked for. */
|
|
38
|
+
export interface ExtensionEntry { key: string; load(): Promise<ServerExtension> }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The first-paint CSS of whatever HTML is passed in, by lookups: the build's `pieces` table, nothing parsed here. It reads
|
|
2
|
+
// the HTML rather than a list kept while rendering, so a cached or prerendered page, a static level and a streamed chunk
|
|
3
|
+
// take one path, nothing is shared between requests, and the same HTML always gets the same CSS.
|
|
4
|
+
import { markup } from '@weft-js/core/markup'
|
|
5
|
+
|
|
6
|
+
export interface Pieces {
|
|
7
|
+
names: string[]; layers: string; wraps: string[]; pieces: [number, number, string, string][]
|
|
8
|
+
always: number[]; alts: ([string[], string[][]][] | null)[]; deps: number[][]; index: Record<string, number[]>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GATE = '[data-weft=styled]{visibility:hidden;animation:weft-gate 0s 3s forwards}@keyframes weft-gate{to{visibility:visible}}'
|
|
12
|
+
const depth = (open: string) => open.split('{').length - 1
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One response's tracker: each call gives the CSS `html` needs that this response hasn't sent, from `sheets` alone, so
|
|
16
|
+
* another route's rules never reach this page. `extra`: what the page's head sets, in `markup`'s words.
|
|
17
|
+
*/
|
|
18
|
+
export function firstPaint(table: Pieces, sheets: string[], head = '') {
|
|
19
|
+
const rank = new Map(sheets.map((name, i) => [table.names.indexOf(name), i]))
|
|
20
|
+
const sent = new Set<number>()
|
|
21
|
+
const headHas = markup('')
|
|
22
|
+
for (const [, name, value] of head.matchAll(/dataset\.(\w+)\s*=\s*['"]([^'"]+)['"]/g)) headHas.add(`[data-${name}=${value}]`)
|
|
23
|
+
let gated = false
|
|
24
|
+
return (html: string) => {
|
|
25
|
+
const has = markup(html)
|
|
26
|
+
for (const k of headHas) has.add(k)
|
|
27
|
+
const fresh: number[] = []
|
|
28
|
+
const take = (id: number) => {
|
|
29
|
+
if (sent.has(id) || !rank.has(table.pieces[id]![0])) return
|
|
30
|
+
sent.add(id)
|
|
31
|
+
fresh.push(id)
|
|
32
|
+
table.deps[id]!.forEach(take)
|
|
33
|
+
}
|
|
34
|
+
table.always.forEach(take)
|
|
35
|
+
for (const key of has) table.index[key]?.forEach(id => {
|
|
36
|
+
const need = table.alts[id]
|
|
37
|
+
if (!need || need.some(([all, any]) => all.every(k => has.has(k)) && any.every(l => l.some(k => has.has(k))))) take(id)
|
|
38
|
+
})
|
|
39
|
+
let css = !gated && has.has('[data-weft=styled]') ? ((gated = true), GATE) : ''
|
|
40
|
+
fresh.sort((a, b) => rank.get(table.pieces[a]![0])! - rank.get(table.pieces[b]![0])! || a - b)
|
|
41
|
+
let open = 0, rule = ''
|
|
42
|
+
for (const id of fresh) {
|
|
43
|
+
const [, wrap, selector, body] = table.pieces[id]!
|
|
44
|
+
if (wrap !== open) {
|
|
45
|
+
css += (rule && '}') + '}'.repeat(depth(table.wraps[open]!)) + table.wraps[wrap]
|
|
46
|
+
open = wrap
|
|
47
|
+
rule = ''
|
|
48
|
+
}
|
|
49
|
+
if (selector === rule && selector[0] !== '@') css += `;${body}` // a token block: one head, however many of its tokens are in
|
|
50
|
+
else {
|
|
51
|
+
css += `${rule && '}'}${selector}{${body}`
|
|
52
|
+
rule = selector
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return css + (rule && '}') + '}'.repeat(depth(table.wraps[open]!))
|
|
56
|
+
}
|
|
57
|
+
}
|