lecodes-cli 0.16.0 → 0.16.2

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.
Files changed (38) hide show
  1. package/README.md +27 -4
  2. package/dist/index.js +170706 -72
  3. package/package.json +11 -6
  4. package/runtime/sdk/compile/bundler.ts +10 -2
  5. package/runtime/sdk/compile/compileProject.ts +10 -2
  6. package/runtime/sdk/compile/detectEntry.ts +5 -2
  7. package/runtime/sdk/compile/index.ts +17 -1
  8. package/runtime/sdk/compile/serverSplit.ts +233 -0
  9. package/runtime/sdk/compile/serverTypes.ts +227 -0
  10. package/runtime/sdk/gl/AnimationClip.ts +174 -0
  11. package/runtime/sdk/gl/Animator.ts +634 -0
  12. package/runtime/sdk/gl/CharacterController.ts +6 -1
  13. package/runtime/sdk/gl/IK.ts +170 -0
  14. package/runtime/sdk/gl/Material.ts +1 -1
  15. package/runtime/sdk/gl/ModelAnimation.ts +34 -3
  16. package/runtime/sdk/gl/Node.ts +21 -0
  17. package/runtime/sdk/inject.ts +10 -0
  18. package/runtime/sdk/plugins/oauth.ts +61 -0
  19. package/runtime/sdk/runtime/rpc.ts +218 -0
  20. package/runtime/sdk/server/auth/appConfig.ts +12 -0
  21. package/runtime/sdk/server/auth/global.ts +80 -0
  22. package/runtime/sdk/server/auth/host.ts +318 -0
  23. package/runtime/sdk/server/auth/models.ts +83 -0
  24. package/runtime/sdk/server/auth/types.ts +50 -0
  25. package/runtime/sdk/server/channel.ts +56 -0
  26. package/runtime/sdk/server/context.ts +36 -0
  27. package/runtime/sdk/server/db/defineDb.ts +237 -0
  28. package/runtime/sdk/server/db/fields.ts +132 -0
  29. package/runtime/sdk/server/db/httpTransport.ts +93 -0
  30. package/runtime/sdk/server/db/index.ts +7 -0
  31. package/runtime/sdk/server/db/marci/query.ts +412 -0
  32. package/runtime/sdk/server/db/types.ts +202 -0
  33. package/runtime/sdk/server/errors.ts +12 -0
  34. package/runtime/sdk/server/host.ts +74 -0
  35. package/runtime/sdk/server/inject.ts +13 -0
  36. package/runtime/sdk/server/runtime.ts +133 -0
  37. package/runtime/sdk/server/validate.ts +87 -0
  38. package/runtime/sdk-types.json +1 -1
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "dependencies": {
5
- "@letary/chisel": "^0.6.0",
5
+ "@letary/chisel": "^0.8.0",
6
6
  "jimp": "^1.6.1"
7
7
  },
8
8
  "devDependencies": {
@@ -10,12 +10,14 @@
10
10
  "lecodes-renderer": "workspace:*",
11
11
  "qrcode-terminal": "^0.12.0",
12
12
  "lecodes-3d-editor": "workspace:*",
13
+ "lecodes-assets": "workspace:*",
13
14
  "sdk": "workspace:*"
14
15
  },
15
16
  "peerDependencies": {
16
- "lecodes-design": "^0.8.0",
17
- "lecodes-renderer": "^0.8.3",
18
- "lecodes-3d-editor": "^0.3.0"
17
+ "lecodes-design": "^0.8.1",
18
+ "lecodes-renderer": "^0.8.4",
19
+ "lecodes-3d-editor": "^0.3.0",
20
+ "lecodes-assets": "^0.1.0"
19
21
  },
20
22
  "bin": {
21
23
  "lecodes": "dist/index.js"
@@ -43,13 +45,16 @@
43
45
  },
44
46
  "lecodes-3d-editor": {
45
47
  "optional": true
48
+ },
49
+ "lecodes-assets": {
50
+ "optional": true
46
51
  }
47
52
  },
48
53
  "scripts": {
49
54
  "icon": "bun scripts/make-icon.ts",
50
55
  "vendor-runtime": "bun scripts/vendor-runtime.ts",
51
56
  "sync-peers": "bun scripts/sync-peer-versions.ts",
52
- "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner=\"#!/usr/bin/env node\"",
57
+ "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --external lecodes-assets --banner=\"#!/usr/bin/env node\"",
53
58
  "build:bin": "bun scripts/build-bin.ts",
54
59
  "typecheck": "tsc --noEmit",
55
60
  "dev": "bun run src/index.ts",
@@ -57,6 +57,12 @@ const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DE
57
57
  // `_onAnimationComplete`, `_emitAnim`, …), so keep every underscore-prefixed method on classes that survive.
58
58
  const CHISEL_KEEP = [ "_*" ]
59
59
 
60
+ /** The two inject modules (paths inside chisel's file map, see collectSdkFiles): the client SDK
61
+ * (every UI/2D/3D global) and the server SDK (`t`, `model`, `defineDb`, `channel`, … — see
62
+ * src/server/inject.ts). A bundle gets exactly one of them. */
63
+ export const CLIENT_INJECT = "/__sdk/inject.ts"
64
+ export const SERVER_INJECT = "/__sdk/server/inject.ts"
65
+
60
66
  /** The SDK source root (parent of `inject.ts`). chisel needs the source tree, not a prebuilt bundle. */
61
67
  const resolveSdkSrc = (): string => {
62
68
  const inject = resolveInject()
@@ -133,6 +139,7 @@ const bundleProjectChisel = async (
133
139
  minifyOverride?: boolean,
134
140
  editor?: boolean,
135
141
  libraries?: Record<string, MemoryFiles>,
142
+ inject: string = CLIENT_INJECT,
136
143
  ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => {
137
144
  const entry = normalize(entrypoint)
138
145
  const slugs = new Set(Object.keys(libraries ?? {}))
@@ -176,7 +183,7 @@ const bundleProjectChisel = async (
176
183
  const input = JSON.stringify({
177
184
  files: { ...sdkFiles, ...projFiles },
178
185
  entry,
179
- inject: [ "/__sdk/inject.ts" ],
186
+ inject: [ inject ],
180
187
  format,
181
188
  minify,
182
189
  // Chain fusion, ON by default (`LECODES_CHISEL_FUSE=0` opts out): Vec3/date math chains lower to
@@ -241,4 +248,5 @@ export const bundleProjectWithMap = async (
241
248
  editor?: boolean,
242
249
  libraries?: Record<string, MemoryFiles>,
243
250
  sourcemap = true,
244
- ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries)
251
+ inject?: string,
252
+ ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries, inject)
@@ -7,6 +7,7 @@ import { bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bun
7
7
  import { detectEntry } from "./detectEntry"
8
8
  import { buildHeader } from "./header"
9
9
  import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
10
+ import { stubServerEntries } from "./serverSplit"
10
11
 
11
12
  export type CompileFileType = "text" | "resource" | "shader"
12
13
 
@@ -57,16 +58,23 @@ export type CompileOptions = {
57
58
  * `/__lib/<slug>/…` and reachable from project code via `import … from "<slug>"`. See
58
59
  * docs/library-system-plan.md §4. */
59
60
  libraries?: Record<string, MemoryFiles>
61
+ /** Base URL of the project's deployed server (draft or prod), baked into the RPC/channel stubs
62
+ * that replace every `*.server.ts` module in the client bundle (docs/backend-plan.md §4). Server
63
+ * files never reach the client bundle either way; with no URL, calling an endpoint fails with
64
+ * "server not deployed". */
65
+ serverUrl?: string
60
66
  }
61
67
 
62
68
  export const compileProject = async (opts: CompileOptions): Promise<string> => {
63
- const entry = opts.entryOverride ?? detectEntry(opts.entries, scanModulesChisel).entry
69
+ // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
70
+ const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
71
+ const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
64
72
  if (!entry) {
65
73
  throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
66
74
  }
67
75
 
68
76
  const files: Record<string, string> = {}
69
- for (const e of opts.entries) {
77
+ for (const e of entries) {
70
78
  if (e.type === "resource") {
71
79
  if (opts.localAssets && !e.remote) {
72
80
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
@@ -37,6 +37,9 @@ export type DetectedEntry = {
37
37
  export const ENTRY_CONVENTION = "/main.ts"
38
38
 
39
39
  const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
40
+ /** Never entries: editor plugins and server modules (docs/backend-plan.md — a `*.server.ts` nobody
41
+ * imports is still not the app; server files compile into their own bundle). */
42
+ const isNonEntry = (path: string) => path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path)
40
43
 
41
44
  const normalize = (path: string): string => {
42
45
  const parts: string[] = []
@@ -87,7 +90,7 @@ export const scanModulesFallback = (files: Files): Record<string, ScanModule> =>
87
90
 
88
91
  /**
89
92
  * Detect the project's entrypoint. `entries` is the full project file list; only `.ts`/`.js` text
90
- * files participate (`.d.ts` and `.editor.ts` are never entries, and editor-only imports don't
93
+ * files participate (`.d.ts`, `.editor.ts` and `*.server.ts` are never entries, and editor-only imports don't
91
94
  * remove a file from candidacy — production compiles don't bundle editor plugins).
92
95
  */
93
96
  export const detectEntry = (entries: DetectEntryInput[], scanner?: ModuleScanner): DetectedEntry => {
@@ -99,7 +102,7 @@ export const detectEntry = (entries: DetectEntryInput[], scanner?: ModuleScanner
99
102
  if (isCode(path)) files[path] = e.text
100
103
  }
101
104
 
102
- const candidates = Object.keys(files).filter((p) => !p.endsWith(".editor.ts"))
105
+ const candidates = Object.keys(files).filter((p) => !isNonEntry(p))
103
106
  if (candidates.length === 0) return { entry: "", roots: [] }
104
107
 
105
108
  const scan = scanner?.(files) ?? scanModulesFallback(files)
@@ -1,7 +1,23 @@
1
1
  // The public entry of the SDK's bundle build system. The backend, CLI, and local renderer import
2
2
  // from here instead of re-forking a bundler. (Node + Bun safe — no top-level host globals.)
3
3
 
4
- export { bundleProject, bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bundler"
4
+ export { bundleProject, bundleProjectWithMap, scanModulesChisel, CLIENT_INJECT, SERVER_INJECT, type MemoryFiles } from "./bundler"
5
+ export {
6
+ compileServerBundle,
7
+ scanServerExports,
8
+ scanServerModules,
9
+ stubServerEntries,
10
+ serverStubSource,
11
+ serverEntrySource,
12
+ serverModuleId,
13
+ isServerPath,
14
+ SERVER_ENTRY_PATH,
15
+ type CompileServerOptions,
16
+ type ServerExport,
17
+ type ServerExportKind,
18
+ type ServerManifest,
19
+ } from "./serverSplit"
20
+ export { deriveEndpointParams, EndpointTypeError, type DeriveResult } from "./serverTypes"
5
21
  export {
6
22
  detectEntry,
7
23
  scanModulesFallback,
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The client/server split of a project (docs/backend-plan.md §4).
3
+ *
4
+ * `*.server.ts` files never reach the client bundle: `compileProject` swaps each one for a STUB
5
+ * module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__serverOnly` — client SDK
6
+ * globals, src/runtime/rpc.ts), so `import { listNotes } from "./notes.server"` in client code keeps
7
+ * its types and calls the server. `compileServerBundle` bundles the same files with the SERVER inject
8
+ * behind a synthetic entry that registers every module on `globalThis.__lecodesServer` (chisel keeps
9
+ * side effects, not entry exports) — the runner reads that (src/server/runtime.ts).
10
+ *
11
+ * Export kinds are found by a static scan (no TS program needed): a function export is an
12
+ * endpoint, a `channel(...)` export is a channel, anything else is server-only.
13
+ */
14
+
15
+ import type { AppConfig } from "../server/auth/appConfig"
16
+ import { bundleProjectWithMap, SERVER_INJECT, type MemoryFiles } from "./bundler"
17
+ import type { CompileEntry } from "./compileProject"
18
+ import { deriveEndpointParams } from "./serverTypes"
19
+
20
+ export const SERVER_FILE = /\.server\.[tj]s$/
21
+ export const isServerPath = (path: string) => SERVER_FILE.test(path)
22
+
23
+ /** Project path → endpoint-id prefix: no leading `./` or `/`, forward slashes. */
24
+ export const serverModuleId = (path: string) => path.split("\\").join("/").replace(/^(\.\/|\/)+/, "")
25
+
26
+ export type ServerExportKind = "fn" | "channel" | "other"
27
+ export type ServerExport = { name: string, kind: ServerExportKind }
28
+
29
+ // ───────────────────────────── static export scan ─────────────────────────────
30
+
31
+ /** Blank out comments and string/template contents (keeps line structure) so the regexes below don't
32
+ * trip on `export` inside a comment or a string. */
33
+ export const blankCommentsAndStrings = (src: string): string => {
34
+ let out = ""
35
+ let i = 0
36
+ const n = src.length
37
+ while (i < n) {
38
+ const c = src[i], d = src[i + 1]
39
+ if (c === "/" && d === "/") { while (i < n && src[i] !== "\n") { out += " "; i++ } continue }
40
+ if (c === "/" && d === "*") {
41
+ out += " "; i += 2
42
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) { out += src[i] === "\n" ? "\n" : " "; i++ }
43
+ out += " "; i += 2; continue
44
+ }
45
+ if (c === '"' || c === "'" || c === "`") {
46
+ const q = c
47
+ out += q; i++
48
+ while (i < n && src[i] !== q) {
49
+ if (src[i] === "\\") { out += " "; i += 2; continue }
50
+ out += src[i] === "\n" ? "\n" : " "; i++
51
+ }
52
+ out += q; i++; continue
53
+ }
54
+ out += c; i++
55
+ }
56
+ return out
57
+ }
58
+
59
+ const IDENT = "[A-Za-z_$][\\w$]*"
60
+ const classifyInit = (init: string): ServerExportKind => {
61
+ const s = init.trimStart()
62
+ if (/^channel\s*[<(]/.test(s)) return "channel"
63
+ if (/^(async\s+)?function\b/.test(s)) return "fn"
64
+ 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"
75
+ }
76
+
77
+ /** Exported names of a server module with their kinds. Default exports are rejected. */
78
+ export const scanServerExports = (source: string, path = "<server module>"): ServerExport[] => {
79
+ const src = blankCommentsAndStrings(source)
80
+ if (/(^|\n)\s*export\s+default\b/.test(src)) {
81
+ throw new Error(`${path}: default exports are not endpoints — use named exports`)
82
+ }
83
+ // (position, name, kind) — kept in source order so ids/manifests are stable and readable
84
+ const found: { at: number, name: string, kind: ServerExportKind }[] = []
85
+ const add = (at: number, name: string, kind: ServerExportKind) => {
86
+ const i = found.findIndex(f => f.name === name)
87
+ if (i >= 0) found[i] = { at: found[i].at, name, kind }
88
+ else found.push({ at, name, kind })
89
+ }
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")
92
+ // export const|let|var name [: T] = init
93
+ for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+(?:const|let|var)\\s+(${IDENT})\\s*(?::[^=]*?)?=\\s*([^\\n]*)`, "g"))) {
94
+ add(m.index!, m[2], classifyInit(m[3]))
95
+ }
96
+ // export class name → server-only
97
+ for (const m of src.matchAll(new RegExp(`(^|\\n)\\s*export\\s+class\\s+(${IDENT})`, "g"))) add(m.index!, m[2], "other")
98
+ // export { a, b as c } → classify by the local declaration
99
+ for (const m of src.matchAll(/(^|\n)\s*export\s*\{([^}]*)\}/g)) {
100
+ for (const part of m[2].split(",")) {
101
+ const [local, exported] = part.split(/\s+as\s+/).map(s => s.trim())
102
+ if (!local || /^type\s/.test(local)) continue
103
+ const name = exported ?? local
104
+ let kind: ServerExportKind = "other"
105
+ if (new RegExp(`(^|\\n)\\s*(async\\s+)?function\\s*\\*?\\s*${local}\\b`).test(src)) kind = "fn"
106
+ else {
107
+ const decl = src.match(new RegExp(`(^|\\n)\\s*(?:const|let|var)\\s+${local}\\s*(?::[^=]*?)?=\\s*([^\\n]*)`))
108
+ if (decl) kind = classifyInit(decl[2])
109
+ }
110
+ add(m.index!, name, kind)
111
+ }
112
+ }
113
+ return found.sort((a, b) => a.at - b.at).map(({ name, kind }) => ({ name, kind }))
114
+ }
115
+
116
+ // ───────────────────────────── client stubs ─────────────────────────────
117
+
118
+ /** The client-side replacement of a `*.server.ts` module. `serverUrl` is baked in (draft/prod are
119
+ * separate compiles); "" means "no server deployed" — calls fail with a clear error. */
120
+ export const serverStubSource = (path: string, exports: ServerExport[], serverUrl: string): string => {
121
+ const mod = serverModuleId(path)
122
+ const url = JSON.stringify(serverUrl)
123
+ const lines = exports.map(({ name, kind }) => {
124
+ const id = JSON.stringify(`${mod}#${name}`)
125
+ if (kind === "fn") return `export const ${name} = __rpc(${url}, ${id})`
126
+ if (kind === "channel") return `export const ${name} = __channel(${url}, ${id})`
127
+ return `export const ${name} = __serverOnly(${id})`
128
+ })
129
+ return `// client stub of ${mod} — generated by the lecodes compiler\n${lines.join("\n")}\n`
130
+ }
131
+
132
+ // ───────────────────────────── server entry ─────────────────────────────
133
+
134
+ export const SERVER_ENTRY_PATH = "/.server-entry.ts"
135
+ /** The SDK's app-config placeholder (src/server/auth/appConfig.ts) — the compile overrides it in the file map
136
+ * with the project's real values (project files win over SDK files), so `auth.model.user()` and friends see
137
+ * app.json as an ordinary import. */
138
+ export const APP_CONFIG_PATH = "/__sdk/server/auth/appConfig.ts"
139
+
140
+ /** Compile-time manifest embedded in the server bundle (validators land here — see serverTypes.ts). */
141
+ export type ServerManifest = {
142
+ modules: Record<string, ServerExport[]>
143
+ /** endpoint id → JSON-schema-ish param list (filled by the TS-checker pass; absent = unvalidated). */
144
+ params?: Record<string, unknown[]>
145
+ /** The `app.json` subset the server runtime reads (name, auth providers); the bundle itself gets it as the
146
+ * compiled-in `appConfig` module (APP_CONFIG_PATH). */
147
+ app?: AppConfig
148
+ /** Some server file references the `auth` global: the runner then resolves a device session on every
149
+ * request (minting one on first contact). Projects that never touch `auth` pay nothing. */
150
+ usesAuth?: boolean
151
+ }
152
+
153
+ /** The synthetic entry: imports every export by name (chisel has no namespace imports) and registers
154
+ * the module map + manifest on `globalThis.__lecodesServer` — a side effect chisel keeps. */
155
+ export const serverEntrySource = (modules: Record<string, ServerExport[]>, manifest: ServerManifest): string => {
156
+ const imports: string[] = []
157
+ const entries: string[] = []
158
+ Object.entries(modules).forEach(([path, exports], i) => {
159
+ const names = exports.map(e => `${e.name} as m${i}_${e.name}`).join(", ")
160
+ if (names) imports.push(`import { ${names} } from ${JSON.stringify(path)}`)
161
+ else imports.push(`import ${JSON.stringify(path)}`)
162
+ const props = exports.map(e => `${JSON.stringify(e.name)}: m${i}_${e.name}`).join(", ")
163
+ entries.push(`${JSON.stringify(serverModuleId(path))}: { ${props} }`)
164
+ })
165
+ return `${imports.join("\n")}\n;(globalThis as any).__lecodesServer = { modules: { ${entries.join(", ")} }, manifest: ${JSON.stringify(manifest)} }\n`
166
+ }
167
+
168
+ const normalizePath = (path: string) => {
169
+ const parts: string[] = []
170
+ for (const part of path.split("\\").join("/").split("/")) {
171
+ if (!part || part === ".") continue
172
+ if (part === "..") parts.pop()
173
+ else parts.push(part)
174
+ }
175
+ return "/" + parts.join("/")
176
+ }
177
+
178
+ /** Scan every `*.server.ts` entry → module path → exports. Throws on a default export. */
179
+ export const scanServerModules = (entries: CompileEntry[]): Record<string, ServerExport[]> => {
180
+ const out: Record<string, ServerExport[]> = {}
181
+ for (const e of entries) {
182
+ if (e.type !== "text" || !isServerPath(e.path)) continue
183
+ out[normalizePath(e.path)] = scanServerExports(e.text ?? "", serverModuleId(e.path))
184
+ }
185
+ return out
186
+ }
187
+
188
+ /** Replace each `*.server.ts` entry with its client stub (returns a new entries array). */
189
+ export const stubServerEntries = (entries: CompileEntry[], serverUrl: string): CompileEntry[] =>
190
+ entries.map(e => e.type === "text" && isServerPath(e.path)
191
+ ? { ...e, text: serverStubSource(e.path, scanServerExports(e.text ?? "", serverModuleId(e.path)), serverUrl) }
192
+ : e)
193
+
194
+ export type CompileServerOptions = {
195
+ entries: CompileEntry[]
196
+ /** Derive per-endpoint argument validators from the TS parameter types (serverTypes.ts) and embed
197
+ * them in the manifest. Default true; throws `EndpointTypeError` on non-JSON parameter types. */
198
+ validate?: boolean
199
+ /** Precomputed param schemas to embed instead of deriving them. */
200
+ params?: Record<string, unknown[]>
201
+ /** Server bundles are readable by default (stack traces in the server console). */
202
+ minify?: boolean
203
+ sourcemap?: boolean
204
+ /** The project's `app.json` (name + auth providers) — embedded in the manifest and exposed to the bundle. */
205
+ app?: AppConfig
206
+ }
207
+
208
+ /** Bundle the project's server side. Returns the code (a script that registers
209
+ * `globalThis.__lecodesServer`) and the manifest; `null` when the project has no server files. */
210
+ export const compileServerBundle = async (opts: CompileServerOptions): Promise<{ code: string, manifest: ServerManifest } | null> => {
211
+ const modules = scanServerModules(opts.entries)
212
+ if (Object.keys(modules).length === 0) return null
213
+ const files: MemoryFiles = {}
214
+ for (const e of opts.entries) {
215
+ if (e.type !== "text") continue
216
+ if (e.path.toLowerCase().endsWith(".svg")) continue
217
+ files[normalizePath(e.path)] = e.text ?? ""
218
+ }
219
+ let params = opts.params
220
+ if (!params && opts.validate !== false) {
221
+ const derived = await deriveEndpointParams(opts.entries, modules)
222
+ for (const w of derived.warnings) console.warn(`warning: ${w}`)
223
+ params = derived.params
224
+ }
225
+ const usesAuth = opts.entries.some(e => e.type === "text" && isServerPath(e.path) && /\bauth\s*\./.test(blankCommentsAndStrings(e.text ?? "")))
226
+ const manifest: ServerManifest = { modules: Object.fromEntries(Object.entries(modules).map(([p, ex]) => [serverModuleId(p), ex])), params, app: opts.app, usesAuth }
227
+ if (opts.app) files[APP_CONFIG_PATH] = `export const app = ${JSON.stringify(opts.app)}\n`
228
+ files[SERVER_ENTRY_PATH] = serverEntrySource(modules, manifest)
229
+ const { code } = await bundleProjectWithMap(
230
+ SERVER_ENTRY_PATH, files, "esm", opts.minify ?? false, false, undefined, opts.sourcemap ?? false, SERVER_INJECT,
231
+ )
232
+ return { code, manifest }
233
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Endpoint parameter schemas from TypeScript types (docs/backend-plan.md §2.3 / §4.3).
3
+ *
4
+ * Endpoints are plain functions; their argument types are ordinary TS annotations. The client is not
5
+ * trusted, so the server validates every call — and rather than making developers write a schema
6
+ * twice, this pass runs the TypeScript checker over the project's `*.server.ts` files (in memory) and
7
+ * lowers each exported function's parameter types to the compact JSON schema `src/server/validate.ts`
8
+ * interprets at runtime. The result is embedded in the server bundle's manifest.
9
+ *
10
+ * Supported: primitives, literals, unions, enums, arrays, tuples, object types / interfaces / type
11
+ * aliases (from any project file), optional properties, index signatures (`Record<string, T>`),
12
+ * optional / defaulted / rest parameters, `any`/`unknown` (= accept anything). Rejected with a
13
+ * compile error: functions, classes with methods, `Date`, `Map`/`Set`, symbols, recursive types —
14
+ * "endpoint args must be JSON-serializable". Return types are not validated.
15
+ *
16
+ * `typescript` is loaded lazily (it's a devDependency of the SDK and a dependency of the platform
17
+ * compile step); the browser-safe parts of the compiler never import this module's runtime.
18
+ */
19
+
20
+ import type { CompileEntry } from "./compileProject"
21
+ import type { ParamSchema, Schema } from "../server/validate"
22
+ import { serverModuleId, type ServerExport } from "./serverSplit"
23
+
24
+ type TS = typeof import("typescript")
25
+
26
+ const normalizePath = (path: string) => {
27
+ const parts: string[] = []
28
+ for (const part of path.split("\\").join("/").split("/")) {
29
+ if (!part || part === ".") continue
30
+ if (part === "..") parts.pop()
31
+ else parts.push(part)
32
+ }
33
+ return "/" + parts.join("/")
34
+ }
35
+
36
+ export class EndpointTypeError extends Error {
37
+ readonly problems: string[]
38
+ constructor(problems: string[]) {
39
+ super(`endpoint args must be JSON-serializable:\n ${problems.join("\n ")}`)
40
+ this.name = "EndpointTypeError"
41
+ this.problems = problems
42
+ }
43
+ }
44
+
45
+ export type DeriveResult = {
46
+ /** endpoint id → parameter schemas (only endpoints the checker could see). */
47
+ params: Record<string, ParamSchema[]>
48
+ warnings: string[]
49
+ }
50
+
51
+ /**
52
+ * Derive `{ "<module>#<fn>": ParamSchema[] }` for every function export of every `*.server.ts` entry.
53
+ * Throws `EndpointTypeError` when a parameter type can't be validated at runtime.
54
+ */
55
+ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Record<string, ServerExport[]>): Promise<DeriveResult> => {
56
+ let ts: TS
57
+ try {
58
+ ts = (await import("typescript")).default ?? (await import("typescript")) as unknown as TS
59
+ } catch {
60
+ throw new Error("serverTypes: the `typescript` package is required to compile server endpoints")
61
+ }
62
+
63
+ const files = new Map<string, string>()
64
+ for (const e of entries) {
65
+ if (e.type !== "text" || typeof e.text !== "string") continue
66
+ if (!/\.[tj]sx?$/.test(e.path)) continue
67
+ files.set(normalizePath(e.path), e.text)
68
+ }
69
+ const roots = Object.keys(modules).map(normalizePath).filter(p => files.has(p))
70
+
71
+ const options: import("typescript").CompilerOptions = {
72
+ target: ts.ScriptTarget.ESNext,
73
+ module: ts.ModuleKind.ESNext,
74
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
75
+ allowImportingTsExtensions: true,
76
+ strict: true,
77
+ noEmit: true,
78
+ skipLibCheck: true,
79
+ types: [],
80
+ lib: ["lib.es2022.d.ts"],
81
+ }
82
+ const defaultLibPath = ts.getDefaultLibFilePath(options).split("\\").join("/")
83
+ const defaultLibDir = defaultLibPath.slice(0, defaultLibPath.lastIndexOf("/"))
84
+ const sys = ts.sys
85
+ const host: import("typescript").CompilerHost = {
86
+ fileExists: p => files.has(normalizePath(p)) || (p.split("\\").join("/").startsWith(defaultLibDir) && sys.fileExists(p)),
87
+ readFile: p => files.get(normalizePath(p)) ?? (p.split("\\").join("/").startsWith(defaultLibDir) ? sys.readFile(p) : undefined),
88
+ getSourceFile: (p, lang) => {
89
+ const text = files.get(normalizePath(p)) ?? (p.split("\\").join("/").startsWith(defaultLibDir) ? sys.readFile(p) : undefined)
90
+ return text === undefined ? undefined : ts.createSourceFile(p, text, lang, true)
91
+ },
92
+ getDefaultLibFileName: o => ts.getDefaultLibFilePath(o),
93
+ writeFile: () => {},
94
+ getCurrentDirectory: () => "/",
95
+ getCanonicalFileName: p => p,
96
+ useCaseSensitiveFileNames: () => true,
97
+ getNewLine: () => "\n",
98
+ directoryExists: () => true,
99
+ getDirectories: () => [],
100
+ }
101
+ const program = ts.createProgram(roots, options, host)
102
+ const checker = program.getTypeChecker()
103
+
104
+ const params: Record<string, ParamSchema[]> = {}
105
+ const warnings: string[] = []
106
+ const problems: string[] = []
107
+
108
+ for (const [path, exports] of Object.entries(modules)) {
109
+ const sf = program.getSourceFile(normalizePath(path))
110
+ if (!sf) { warnings.push(`${path}: not in the TS program`); continue }
111
+ const modSym = checker.getSymbolAtLocation(sf)
112
+ if (!modSym) continue
113
+ const exported = new Map(checker.getExportsOfModule(modSym).map(s => [s.name, s]))
114
+ for (const ex of exports) {
115
+ if (ex.kind !== "fn") continue
116
+ const id = `${serverModuleId(path)}#${ex.name}`
117
+ let sym = exported.get(ex.name)
118
+ if (!sym) { warnings.push(`${id}: export not found by the checker — unvalidated`); continue }
119
+ if (sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym)
120
+ const decl = sym.valueDeclaration ?? sym.declarations?.[0]
121
+ if (!decl) { warnings.push(`${id}: no declaration — unvalidated`); continue }
122
+ const type = checker.getTypeOfSymbolAtLocation(sym, decl)
123
+ const sig = type.getCallSignatures()[0]
124
+ if (!sig) { warnings.push(`${id}: not callable — unvalidated`); continue }
125
+ const list: ParamSchema[] = []
126
+ for (const p of sig.getParameters()) {
127
+ const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
128
+ const ptype = checker.getTypeOfSymbolAtLocation(p, pd ?? decl)
129
+ const rest = !!pd?.dotDotDotToken
130
+ const optional = !!pd?.questionToken || !!pd?.initializer || rest
131
+ try {
132
+ // `x?: T` carries `undefined` in its type — the optional flag covers it, strip it from the schema
133
+ const members = ptype.flags & ts.TypeFlags.Union ? (ptype as import("typescript").UnionType).types : [ptype]
134
+ const schema = lowerMembers(ts, checker, pd?.questionToken ? members.filter(t => !(t.flags & ts.TypeFlags.Undefined)) : members, `${id}(${p.name})`, new Set())
135
+ list.push({ name: p.name, schema, optional, rest })
136
+ } catch (e) {
137
+ problems.push((e as Error).message)
138
+ }
139
+ }
140
+ params[id] = list
141
+ }
142
+ }
143
+ if (problems.length) throw new EndpointTypeError(problems)
144
+ return { params, warnings }
145
+ }
146
+
147
+ // ───────────────────────────── type → schema ─────────────────────────────
148
+
149
+ const lower = (ts: TS, checker: import("typescript").TypeChecker, type: import("typescript").Type, where: string, stack: Set<import("typescript").Type>): Schema => {
150
+ const f = type.flags
151
+ const T = ts.TypeFlags
152
+ if (f & (T.Any | T.Unknown)) return { t: "any" }
153
+ if (f & T.Never) throw new Error(`${where}: never`)
154
+ if (f & T.Null) return { t: "null" }
155
+ if (f & (T.Undefined | T.Void)) return { t: "undefined" }
156
+ if (f & T.BooleanLiteral) return { t: "lit", v: (type as any).intrinsicName === "true" }
157
+ if (f & T.Boolean) return { t: "boolean" }
158
+ if (f & T.StringLiteral) return { t: "lit", v: (type as import("typescript").StringLiteralType).value }
159
+ if (f & T.NumberLiteral) return { t: "lit", v: (type as import("typescript").NumberLiteralType).value }
160
+ if (f & T.String) return { t: "string" }
161
+ if (f & T.Number) return { t: "number" }
162
+ if (f & (T.BigInt | T.BigIntLiteral | T.ESSymbol | T.UniqueESSymbol)) throw new Error(`${where}: ${checker.typeToString(type)} is not JSON`)
163
+ if (f & T.EnumLiteral && f & T.Union) {
164
+ return { t: "union", of: (type as import("typescript").UnionType).types.map(t => lower(ts, checker, t, where, stack)) }
165
+ }
166
+ if (f & T.Union) return lowerMembers(ts, checker, (type as import("typescript").UnionType).types, where, stack)
167
+ if (f & T.Intersection || f & T.Object) {
168
+ if (stack.has(type)) throw new Error(`${where}: recursive type ${checker.typeToString(type)}`)
169
+ if (type.getCallSignatures().length || type.getConstructSignatures().length) throw new Error(`${where}: functions are not JSON`)
170
+ stack.add(type)
171
+ try {
172
+ if (checker.isArrayType(type)) {
173
+ const [el] = checker.getTypeArguments(type as import("typescript").TypeReference)
174
+ return { t: "array", of: el ? lower(ts, checker, el, where, stack) : { t: "any" } }
175
+ }
176
+ if (checker.isTupleType(type)) {
177
+ const ref = type as import("typescript").TypeReference
178
+ const target = ref.target as import("typescript").TupleType
179
+ const args = checker.getTypeArguments(ref)
180
+ const items: Schema[] = []
181
+ let rest: Schema | undefined
182
+ target.elementFlags.forEach((flag, i) => {
183
+ const s = lower(ts, checker, args[i], where, stack)
184
+ if (flag & ts.ElementFlags.Rest || flag & ts.ElementFlags.Variadic) rest = (s.t === "array" ? s.of : s)
185
+ else items.push(flag & ts.ElementFlags.Optional ? { t: "union", of: [s, { t: "undefined" }] } : s)
186
+ })
187
+ return { t: "tuple", items, rest }
188
+ }
189
+ const name = checker.typeToString(type)
190
+ if (/^(Date|Map<|Set<|WeakMap<|WeakSet<|Promise<|RegExp$|ArrayBuffer$|Uint8Array$)/.test(name)) throw new Error(`${where}: ${name} is not JSON`)
191
+ const props: Record<string, Schema> = {}
192
+ const optional: string[] = []
193
+ for (const p of checker.getPropertiesOfType(type)) {
194
+ const pt = checker.getTypeOfSymbolAtLocation(p, p.valueDeclaration ?? p.declarations?.[0]!)
195
+ if (pt.getCallSignatures().length) throw new Error(`${where}.${p.name}: functions are not JSON`)
196
+ const isOpt = !!(p.flags & ts.SymbolFlags.Optional)
197
+ // an optional property's type carries `undefined` — strip it, the optional list covers it
198
+ const members = pt.flags & T.Union ? (pt as import("typescript").UnionType).types : [pt]
199
+ props[p.name] = lowerMembers(ts, checker, isOpt ? members.filter(t => !(t.flags & T.Undefined)) : members, `${where}.${p.name}`, stack)
200
+ if (isOpt) optional.push(p.name)
201
+ }
202
+ let index: Schema | undefined
203
+ for (const info of checker.getIndexInfosOfType(type)) {
204
+ if (info.keyType.flags & T.String) index = lower(ts, checker, info.type, `${where}[string]`, stack)
205
+ }
206
+ return { t: "object", props, ...(optional.length ? { optional } : {}), ...(index ? { index } : {}) }
207
+ } finally {
208
+ stack.delete(type)
209
+ }
210
+ }
211
+ if (f & T.TypeParameter) throw new Error(`${where}: generic parameter ${checker.typeToString(type)} can't be validated`)
212
+ throw new Error(`${where}: unsupported type ${checker.typeToString(type)}`)
213
+ }
214
+
215
+ /** Lower a list of union members: `boolean` arrives as `true | false` — fold it back; a single member is itself. */
216
+ const lowerMembers = (ts: TS, checker: import("typescript").TypeChecker, members: readonly import("typescript").Type[], where: string, stack: Set<import("typescript").Type>): Schema => {
217
+ const T = ts.TypeFlags
218
+ if (members.length === 1) return lower(ts, checker, members[0], where, stack)
219
+ const isLit = (m: import("typescript").Type, v: string) => !!(m.flags & T.BooleanLiteral) && (m as any).intrinsicName === v
220
+ const foldBool = members.some(m => isLit(m, "true")) && members.some(m => isLit(m, "false"))
221
+ const of: Schema[] = foldBool ? [{ t: "boolean" }] : []
222
+ for (const m of members) {
223
+ if (foldBool && m.flags & T.BooleanLiteral) continue
224
+ of.push(lower(ts, checker, m, where, stack))
225
+ }
226
+ return of.length === 1 ? of[0] : { t: "union", of }
227
+ }