brustjs 0.1.11-alpha → 0.1.13-alpha

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.
@@ -1,172 +0,0 @@
1
- import { relative } from 'node:path'
2
- import type { ActionDef, ActionFn } from './actions.ts'
3
- import { isValidActionId, getActionMiddleware } from './actions.ts'
4
-
5
- const DIRECTIVE_HEAD_BYTES = 512
6
-
7
- /** Remove leading whitespace, line comments (`//`), and block comments
8
- * from `src` and return the rest. Stops at the first non-trivial character.
9
- * Does NOT understand string literals — fine because we only run this on a
10
- * directive prologue, which by spec contains comments and the directive only.
11
- * If a block comment never terminates, returns '' so the caller treats the
12
- * file as non-server. */
13
- export function stripLeadingTrivia(src: string): string {
14
- let i = 0
15
- while (i < src.length) {
16
- const ch = src[i]
17
- if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
18
- i++
19
- continue
20
- }
21
- if (ch === '/' && src[i + 1] === '/') {
22
- const nl = src.indexOf('\n', i)
23
- if (nl === -1) return ''
24
- i = nl + 1
25
- continue
26
- }
27
- if (ch === '/' && src[i + 1] === '*') {
28
- const end = src.indexOf('*/', i + 2)
29
- if (end === -1) return ''
30
- i = end + 2
31
- continue
32
- }
33
- break
34
- }
35
- return src.slice(i)
36
- }
37
-
38
- const USE_SERVER_PATTERN = /^(?:'use server'|"use server")\s*;?\s*(?:\r?\n|$)/
39
-
40
- /** Read the first 512 bytes of `filePath` and return true iff a file-level
41
- * `'use server'` directive sits at the prologue position (before any import
42
- * or other statement). Comments and whitespace ahead of the directive are
43
- * skipped. Mirrors the TC39 directive-prologue rule. */
44
- export async function hasUseServerDirective(filePath: string): Promise<boolean> {
45
- const f = Bun.file(filePath)
46
- const head = await f.slice(0, DIRECTIVE_HEAD_BYTES).text()
47
- const stripped = stripLeadingTrivia(head)
48
- return USE_SERVER_PATTERN.test(stripped)
49
- }
50
-
51
- /** Dynamically import `filePath` and collect every named function export as
52
- * an ActionDef. Skips non-function exports silently. Throws on:
53
- * - default export (must be named)
54
- * - class export (calling a class without `new` would 500 at dispatch)
55
- * - invalid id charset
56
- * - zero function exports (likely a bug — file marked 'use server' but
57
- * publishes nothing).
58
- * Middleware metadata installed by withMiddleware is preserved. */
59
- export async function collectExports(filePath: string): Promise<ActionDef[]> {
60
- const mod = (await import(filePath)) as Record<string, unknown>
61
- const defs: ActionDef[] = []
62
- for (const [name, value] of Object.entries(mod)) {
63
- if (typeof value !== 'function') continue
64
- // typeof class{} is 'function' in JS; reject explicitly so an accidental
65
- // `export class Foo {}` in a 'use server' file fails loudly at scan,
66
- // not with a confusing 500 at dispatch.
67
- if (Function.prototype.toString.call(value).startsWith('class ')) {
68
- throw new Error(
69
- `${filePath}: export "${name}" is a class. Actions must be plain async functions, not class constructors.`,
70
- )
71
- }
72
- if (name === 'default') {
73
- throw new Error(`${filePath}: default exports are not action-eligible. Use named export.`)
74
- }
75
- if (!isValidActionId(name)) {
76
- throw new Error(
77
- `${filePath}: export "${name}" has invalid id (must match [A-Za-z0-9_-]+, 1-128 chars).`,
78
- )
79
- }
80
- defs.push({
81
- id: name,
82
- fn: value as ActionFn,
83
- middleware: getActionMiddleware(value),
84
- })
85
- }
86
- if (defs.length === 0) {
87
- throw new Error(`${filePath}: marked 'use server' but exports no functions.`)
88
- }
89
- return defs
90
- }
91
-
92
- export interface ScanOptions {
93
- /** Glob roots to scan from. Default: ['./']. Pass an explicit root (e.g.
94
- * `import.meta.dirname`) when the project layout includes sibling subtrees
95
- * you don't want scanned — typical for example apps inside a larger repo. */
96
- roots?: string[]
97
- /** Ignore globs (matched against the path relative to each root). Override
98
- * the default array if you need a different policy — there's no merge.
99
- * Default covers build outputs and test patterns. */
100
- ignore?: string[]
101
- }
102
-
103
- const DEFAULT_IGNORE = Object.freeze([
104
- 'node_modules/**',
105
- '.brust/**',
106
- 'dist/**',
107
- 'build/**',
108
- 'tests/**',
109
- '__tests__/**',
110
- '*.test.ts',
111
- '*.test.tsx',
112
- '*.spec.ts',
113
- '*.spec.tsx',
114
- ])
115
-
116
- const FILE_PATTERN = '**/*.{ts,tsx,js,jsx,mjs,cjs}'
117
-
118
- async function findCandidateFiles(opts: ScanOptions): Promise<string[]> {
119
- const roots = opts.roots ?? ['./']
120
- const ignore = opts.ignore ?? [...DEFAULT_IGNORE]
121
- const ignoreGlobs = ignore.map((p) => new Bun.Glob(p))
122
- const out: string[] = []
123
- for (const root of roots) {
124
- const glob = new Bun.Glob(FILE_PATTERN)
125
- for await (const f of glob.scan({ cwd: root, dot: false, absolute: true })) {
126
- const rel = relative(root, f)
127
- if (ignoreGlobs.some((g) => g.match(rel))) continue
128
- out.push(f)
129
- }
130
- }
131
- return out
132
- }
133
-
134
- export interface ScanActionsResult {
135
- actions: ActionDef[]
136
- /** Absolute paths of files that had a `'use server'` directive — needed by
137
- * `brust.buildMcpManifest` to feed the TypeScript compiler API extractor. */
138
- sourceFiles: string[]
139
- }
140
-
141
- /** Walk the project, find files whose first statement is `'use server'`,
142
- * import each, and return all named function exports as ActionDef[] plus the
143
- * list of server source files.
144
- * Throws on duplicate ids across files. Always returns actions sorted by id
145
- * for deterministic logging. */
146
- export async function scanActions(opts: ScanOptions = {}): Promise<ScanActionsResult> {
147
- const candidates = await findCandidateFiles(opts)
148
- // Run directive checks in parallel — file IO scales well there.
149
- const directiveChecks = await Promise.all(
150
- candidates.map(async (p) => ({ path: p, isServer: await hasUseServerDirective(p) })),
151
- )
152
- const serverFiles = directiveChecks.filter((c) => c.isServer).map((c) => c.path)
153
-
154
- // Serial imports — heavy module side effects shouldn't all fire at once.
155
- const byId = new Map<string, string>()
156
- const all: ActionDef[] = []
157
- for (const file of serverFiles) {
158
- const defs = await collectExports(file)
159
- for (const def of defs) {
160
- const prior = byId.get(def.id)
161
- if (prior) {
162
- throw new Error(
163
- `Duplicate action "${def.id}" — defined in both ${prior} and ${file}. Rename one.`,
164
- )
165
- }
166
- byId.set(def.id, file)
167
- all.push(def)
168
- }
169
- }
170
- all.sort((a, b) => a.id.localeCompare(b.id))
171
- return { actions: all, sourceFiles: serverFiles }
172
- }