lecodes-sdk 2.0.11 → 2.0.12

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/package.json CHANGED
@@ -45,7 +45,7 @@
45
45
  "marcidb-embedded": "^0.14.1",
46
46
  "sharp": "^0.35.2"
47
47
  },
48
- "version": "2.0.11",
48
+ "version": "2.0.12",
49
49
  "files": [
50
50
  "src",
51
51
  "dist",
@@ -19,7 +19,7 @@ export {
19
19
  type ServerExportKind,
20
20
  type ServerManifest,
21
21
  } from "./serverSplit"
22
- export { deriveEndpointParams, EndpointTypeError, SyncEndpointError, UnawaitedUserQueryError, WrongSideGlobalError, type DeriveResult } from "./serverTypes"
22
+ export { deriveEndpointParams, EndpointTypeError, ServerHelperImportError, UnawaitedUserQueryError, WrongSideGlobalError, type DeriveResult } from "./serverTypes"
23
23
  export {
24
24
  detectEntry,
25
25
  scanModulesFallback,
@@ -23,6 +23,12 @@ export const isServerPath = (path: string) => SERVER_FILE.test(path)
23
23
  /** Project path → endpoint-id prefix: no leading `./` or `/`, forward slashes. */
24
24
  export const serverModuleId = (path: string) => path.split("\\").join("/").replace(/^(\.\/|\/)+/, "")
25
25
 
26
+ /**
27
+ * What an export of a server module is to the app: `fn` = an endpoint — an ASYNC function, called
28
+ * over the network; `channel` = a channel; `other` = server-only — a model, the db, a constant, or
29
+ * a helper: a function that is not async is used by server files alone, and the app's import of
30
+ * it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
31
+ */
26
32
  export type ServerExportKind = "fn" | "channel" | "other"
27
33
  export type ServerExport = { name: string, kind: ServerExportKind }
28
34
 
@@ -60,18 +66,9 @@ const IDENT = "[A-Za-z_$][\\w$]*"
60
66
  const classifyInit = (init: string): ServerExportKind => {
61
67
  const s = init.trimStart()
62
68
  if (/^channel\s*[<(]/.test(s)) return "channel"
63
- if (/^(async\s+)?function\b/.test(s)) return "fn"
69
+ if (/^async\s+function\b/.test(s)) return "fn"
64
70
  if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
65
- if (new RegExp(`^${IDENT}\\s*=>`).test(s)) return "fn" // x => …
66
- if (/^\(/.test(s)) {
67
- // `(a, b) => …` — find the matching paren and look for `=>` (a type annotation may sit between)
68
- let depth = 0
69
- for (let i = 0; i < s.length; i++) {
70
- if (s[i] === "(") depth++
71
- else if (s[i] === ")") { depth--; if (depth === 0) return /^\s*(:[^=]*)?=>/.test(s.slice(i + 1)) ? "fn" : "other" }
72
- }
73
- }
74
- return "other"
71
+ return "other" // a sync function, a value: the server's own
75
72
  }
76
73
 
77
74
  /** Exported names of a server module with their kinds. Default exports are rejected. */
@@ -87,8 +84,8 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
87
84
  if (i >= 0) found[i] = { at: found[i].at, name, kind }
88
85
  else found.push({ at, name, kind })
89
86
  }
90
- // export [async] function name / export function* name
91
- for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(async\\s+)?function\\s*\\*?\\s*(${IDENT})`, "g"))) add(m.index!, m[3], "fn")
87
+ // export async function name = an endpoint; export function name = a helper of the server
88
+ for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(async\\s+)?function\\s*\\*?\\s*(${IDENT})`, "g"))) add(m.index!, m[3], m[2] ? "fn" : "other")
92
89
  // export const|let|var name [: T] = init
93
90
  for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(?:const|let|var)\\s+(${IDENT})\\s*(?::[^=]*?)?=\\s*([^\\n]*)`, "g"))) {
94
91
  add(m.index!, m[2], classifyInit(m[3]))
@@ -102,7 +99,7 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
102
99
  if (!local || /^type\s/.test(local)) continue
103
100
  const name = exported ?? local
104
101
  let kind: ServerExportKind = "other"
105
- if (new RegExp(`(^|\\n)\\s*(async\\s+)?function\\s*\\*?\\s*${local}\\b`).test(src)) kind = "fn"
102
+ if (new RegExp(`(^|\\n)\\s*async\\s+function\\s*\\*?\\s*${local}\\b`).test(src)) kind = "fn"
106
103
  else {
107
104
  const decl = src.match(new RegExp(`(^|\\n)\\s*(?:const|let|var)\\s+${local}\\s*(?::[^=]*?)?=\\s*([^\\n]*)`))
108
105
  if (decl) kind = classifyInit(decl[2])
@@ -47,15 +47,16 @@ export class EndpointTypeError extends Error {
47
47
  }
48
48
 
49
49
  /**
50
- * In the app an endpoint's call is always a promise: it goes over the network. An endpoint that is
51
- * not `async` is imported with the type it was written with (`number`), so `const n = add(1, 2)`
52
- * type-checks and holds a promise. Declared `async`, the two agree.
50
+ * In the app an endpoint's call is always a promise: it goes over the network. A function of a
51
+ * server file that is not `async` is therefore not an endpoint but a helper, the server's own
52
+ * (serverSplit.ts): imported by the app it would keep the type it was written with (`number`), so
53
+ * `const n = add(1, 2)` would type-check and throw. The import is refused instead.
53
54
  */
54
- export class SyncEndpointError extends Error {
55
+ export class ServerHelperImportError extends Error {
55
56
  readonly problems: string[]
56
57
  constructor(problems: string[]) {
57
- super(`an endpoint is called over the network, so in the app its result is a promise — declare it async:\n ${problems.join("\n ")}`)
58
- this.name = "SyncEndpointError"
58
+ super(`a function of a server file that is not async is a helper of the server, for server files only — the app calls endpoints, which are \`export async function\`:\n ${problems.join("\n ")}`)
59
+ this.name = "ServerHelperImportError"
59
60
  this.problems = problems
60
61
  }
61
62
  }
@@ -135,6 +136,38 @@ export type DeriveResult = {
135
136
  warnings: string[]
136
137
  }
137
138
 
139
+ /**
140
+ * The app's imports of a server module's helpers: a named import, from a `*.server.ts` file, of an
141
+ * export the scan classed `other` that is a function. The checker resolves the import to its
142
+ * declaration, so the module is the one the import names, however it was spelled.
143
+ */
144
+ const helperImports = (ts: TS, program: import("typescript").Program, checker: import("typescript").TypeChecker, modules: Record<string, ServerExport[]>, projectFiles: Set<string>): string[] => {
145
+ const kinds = new Map(Object.entries(modules).map(([path, exports]) => [normalizePath(path), new Map(exports.map(e => [e.name, e.kind]))]))
146
+ const out: string[] = []
147
+ for (const sf of program.getSourceFiles()) {
148
+ if (!projectFiles.has(sf.fileName) || sf.isDeclarationFile || isServerPath(sf.fileName)) continue
149
+ const file = serverModuleId(sf.fileName)
150
+ for (const statement of sf.statements) {
151
+ if (!ts.isImportDeclaration(statement) || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue
152
+ if (statement.importClause.isTypeOnly) continue
153
+ for (const spec of statement.importClause.namedBindings.elements) {
154
+ if (spec.isTypeOnly) continue
155
+ let symbol = checker.getSymbolAtLocation(spec.name)
156
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
157
+ const decl = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
158
+ if (!symbol || !decl) continue
159
+ const target = normalizePath(decl.getSourceFile().fileName)
160
+ const name = (spec.propertyName ?? spec.name).text
161
+ if (kinds.get(target)?.get(name) !== "other") continue
162
+ if (checker.getTypeOfSymbolAtLocation(symbol, decl).getCallSignatures().length === 0) continue
163
+ const { line } = sf.getLineAndCharacterOfPosition(spec.getStart(sf))
164
+ out.push(`${file}:${line + 1}: ${name} of ${serverModuleId(target)}`)
165
+ }
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
138
171
  /**
139
172
  * Derive `{ "<module>#<fn>": ParamSchema[] }` for every function export of every `*.server.ts` entry.
140
173
  * Throws `EndpointTypeError` when a parameter type can't be validated at runtime.
@@ -196,11 +229,6 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
196
229
  const params: Record<string, ParamSchema[]> = {}
197
230
  const warnings: string[] = []
198
231
  const problems: string[] = []
199
- const sync: string[] = []
200
- // a promise, or anything awaited the same way (a query handed back as it is); `any` is not judged
201
- const awaitable = (type: import("typescript").Type): boolean =>
202
- !!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
203
- || (type.flags & ts.TypeFlags.Union ? (type as import("typescript").UnionType).types.every(awaitable) : !!type.getProperty("then"))
204
232
 
205
233
  for (const [path, exports] of Object.entries(modules)) {
206
234
  const sf = program.getSourceFile(normalizePath(path))
@@ -229,10 +257,6 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
229
257
  }
230
258
  const sig = type.getCallSignatures()[0]
231
259
  if (!sig) { warnings.push(`${where}: not callable — unvalidated`); continue }
232
- if (ex.kind === "fn" && !awaitable(checker.getReturnTypeOfSignature(sig))) {
233
- const { line } = sf.getLineAndCharacterOfPosition(decl.getStart(sf))
234
- sync.push(`${serverModuleId(path)}:${line + 1}: ${ex.name} — write \`async\` before it`)
235
- }
236
260
  const list: ParamSchema[] = []
237
261
  for (const p of sig.getParameters()) {
238
262
  const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
@@ -257,9 +281,10 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
257
281
  }
258
282
  }
259
283
  if (problems.length) throw new EndpointTypeError(problems)
260
- if (sync.length) throw new SyncEndpointError(sync)
261
284
  const wrongSide = wrongSideGlobals(ts, program, checker, new Set(files.keys()))
262
285
  if (wrongSide.length) throw new WrongSideGlobalError(wrongSide)
286
+ const helpers = helperImports(ts, program, checker, modules, new Set(files.keys()))
287
+ if (helpers.length) throw new ServerHelperImportError(helpers)
263
288
  const unawaited: string[] = []
264
289
  for (const path of Object.keys(modules)) {
265
290
  const sf = program.getSourceFile(normalizePath(path))
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.0.11"
7
+ export const SDK_VERSION = "2.0.12"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {