lecodes-cli 1.0.0 → 2.0.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/dist/index.js +6211 -3649
- package/package.json +12 -8
- package/runtime/materials/lightmap.mat +115 -139
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/cli/help.ts +25 -0
- package/src/cli/index.ts +7 -7
- package/src/commands/app/android.ts +3 -1
- package/src/commands/app/desktop.ts +1 -1
- package/src/commands/app/desktopMac.ts +10 -9
- package/src/commands/app/icon.ts +9 -6
- package/src/commands/app/index.ts +3 -1
- package/src/commands/app/shared.ts +13 -5
- package/src/commands/app/templates/android.ts +10 -11
- package/src/commands/app/templates/ios.ts +1 -1
- package/src/commands/assets.ts +1 -1
- package/src/commands/compile.ts +15 -2
- package/src/commands/design/index.ts +4 -4
- package/src/commands/design/snapshot.ts +3 -3
- package/src/commands/desktop.ts +5 -1
- package/src/commands/dev.ts +82 -29
- package/src/commands/index.ts +64 -60
- package/src/commands/init/index.ts +11 -15
- package/src/commands/init/templates.ts +64 -21
- package/src/commands/lightmap.ts +459 -200
- package/src/commands/navmesh.ts +2 -2
- package/src/commands/plugin.ts +46 -0
- package/src/commands/render.ts +22 -16
- package/src/commands/scene.ts +6 -1
- package/src/commands/serve.ts +58 -0
- package/src/commands/shaders/new.ts +1 -1
- package/src/commands/test.ts +402 -398
- package/src/commands/update/index.ts +3 -3
- package/src/compile/designCompile.ts +1 -1
- package/src/compile/headlessBundle.ts +133 -133
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +10 -2
- package/src/compile/sceneCompile.ts +8 -22
- package/src/declarations/lecodes-3d-editor.d.ts +4 -0
- package/src/declarations/lecodes-assets.d.ts +1 -1
- package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +75 -38
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +82 -14
- package/src/hosts/cmgenTool.ts +1 -1
- package/src/hosts/desktopRenderer.ts +98 -27
- package/src/hosts/desktopScript.ts +150 -35
- package/src/hosts/distRoot.ts +1 -1
- package/src/hosts/matcTool.ts +1 -1
- package/src/hosts/peerInstall.ts +5 -5
- package/src/hosts/peers.ts +2 -2
- package/src/index.ts +4 -1
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/project/ignore.ts +164 -163
- package/runtime/materials/lightmap-baked-lite.mat +0 -180
- package/runtime/materials/lightmap-baked.mat +0 -181
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-Q06c6oHx.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// The generator's emitter for `android/`: a contract's channel → `<Name>.gen.kt`, everything of the
|
|
2
|
+
// Android half that is not the plugin's own work (docs/plans/plugins-plan.md in core) — the twin of
|
|
3
|
+
// emitSwift.ts, over the same lowered model:
|
|
4
|
+
// - the contract's types as Kotlin (enum classes, data classes, sealed unions) with their wire codecs;
|
|
5
|
+
// - `<Name>Code`: the codes the channel rejects with;
|
|
6
|
+
// - `<Name>Events`: one function per event;
|
|
7
|
+
// - `<Name>Plugin`: the INTERFACE the plugin implements — one method per call, a typed `Reply`;
|
|
8
|
+
// - `<Name>Channel.register(engine, make)` + the glue that reads a call's arguments, refuses a
|
|
9
|
+
// malformed one and hands the call over.
|
|
10
|
+
// It stands on the host SDK's PluginChannel.kt (Reply, PluginFile, Wire) and on the packed form's
|
|
11
|
+
// two ends (PackedReader, PackedWriter). The wire carries VALUES, PACKED, and the codecs read a
|
|
12
|
+
// class straight out of the buffer and write one straight into it — no tree of org.json's objects
|
|
13
|
+
// in between. A Uint8Array is a ByteArray, a File the handle of a host buffer; a `Json` member alone
|
|
14
|
+
// is org.json's objects, because JSON is what a half does with it.
|
|
15
|
+
//
|
|
16
|
+
// A READER IS A CURSOR (`r`): a codec takes the value at the cursor and moves past it, so what it
|
|
17
|
+
// reads is what comes next, in the order it was written — an object's members are met one by one
|
|
18
|
+
// and told apart by their keys, a union by what the value at hand is.
|
|
19
|
+
|
|
20
|
+
import { EmitError } from "./emitSdk"
|
|
21
|
+
import { block, indent, text, wrap, writerName } from "./lines"
|
|
22
|
+
import { waysOf, wireClass, type L, type LChannel, type LDecl, type LField, type Lowered, type Ways } from "./lower"
|
|
23
|
+
|
|
24
|
+
const KEYWORDS = new Set([
|
|
25
|
+
"as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", "is", "null",
|
|
26
|
+
"object", "package", "return", "super", "this", "throw", "true", "try", "typealias", "typeof", "val", "var", "when",
|
|
27
|
+
"while",
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const ident = (name: string): string => KEYWORDS.has(name) ? `\`${name}\`` : name
|
|
31
|
+
|
|
32
|
+
/** A string of the contract as a Kotlin string: `$` would open a template. */
|
|
33
|
+
const str = (value: string): string => JSON.stringify(value).replace(/\$/g, "\\$")
|
|
34
|
+
|
|
35
|
+
/** A string of the contract as a Kotlin constant: `"not-asked"`, `"notAsked"` → `NOT_ASKED`. */
|
|
36
|
+
const constName = (value: string): string => {
|
|
37
|
+
const words = value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean)
|
|
38
|
+
const name = words.map(w => w.toUpperCase()).join("_")
|
|
39
|
+
return /^[A-Z_]/.test(name) ? name : `_${name}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const doc = (text: string, indent = ""): string => {
|
|
43
|
+
if (!text) return ""
|
|
44
|
+
const lines = text.split("\n").map(l => l.trim())
|
|
45
|
+
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`
|
|
46
|
+
return `${indent}/**\n${lines.map(l => `${indent} * ${l}`.trimEnd()).join("\n")}\n${indent} */\n`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const kotlinType = (t: L): string => {
|
|
50
|
+
switch (t.kind) {
|
|
51
|
+
case "i32": return "Int"
|
|
52
|
+
case "f64": return "Double"
|
|
53
|
+
case "boolean": return "Boolean"
|
|
54
|
+
case "string": return "String"
|
|
55
|
+
case "json": return "Any"
|
|
56
|
+
case "file": return "PluginFile"
|
|
57
|
+
case "bytes": return "ByteArray"
|
|
58
|
+
case "void": return "Unit"
|
|
59
|
+
case "enum": case "struct": case "union": return t.name
|
|
60
|
+
case "list": return `List<${kotlinType(t.of)}>`
|
|
61
|
+
case "map": return `Map<String, ${kotlinType(t.of)}>`
|
|
62
|
+
case "optional": return `${kotlinType(t.of)}?`
|
|
63
|
+
case "tuple": return `${t.of.length === 2 ? "Pair" : "Triple"}<${t.of.map(kotlinType).join(", ")}>`
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TUPLE = ["first", "second", "third"]
|
|
68
|
+
|
|
69
|
+
/** The expression of type `T?` that reads the value at the cursor of `r`, as lines: null = it is
|
|
70
|
+
* not a T. */
|
|
71
|
+
const decode = (t: L, depth = 0): string[] => {
|
|
72
|
+
const v = `v${depth}`
|
|
73
|
+
switch (t.kind) {
|
|
74
|
+
case "i32": return [`r.i32()`]
|
|
75
|
+
case "f64": return [`r.f64()`]
|
|
76
|
+
case "boolean": return [`r.bool()`]
|
|
77
|
+
case "string": return [`r.string()`]
|
|
78
|
+
case "json": return [`r.json()`]
|
|
79
|
+
case "bytes": return [`r.bytes()`]
|
|
80
|
+
case "file": return [`Wire.file(r)`]
|
|
81
|
+
case "enum": case "struct": case "union": return [`${t.name}.read(r)`]
|
|
82
|
+
case "list": return block(`r.list {`, decode(t.of, depth + 1), `}`)
|
|
83
|
+
case "map": return block(`r.map {`, decode(t.of, depth + 1), `}`)
|
|
84
|
+
case "optional": return decode(t.of, depth)
|
|
85
|
+
case "tuple": return [
|
|
86
|
+
`r.tuple(${t.of.length}) {`,
|
|
87
|
+
...indent([
|
|
88
|
+
...t.of.flatMap((m, i) => wrap(`val ${v}_${i} = `, decode(m, depth + 1), ` ?: return@tuple null`)),
|
|
89
|
+
`${t.of.length === 2 ? "Pair" : "Triple"}(${t.of.map((_, i) => `${v}_${i}`).join(", ")})`,
|
|
90
|
+
]),
|
|
91
|
+
`}`,
|
|
92
|
+
]
|
|
93
|
+
case "void": throw new EmitError("void has no value to read")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The statements that write the value `e` (a `T`) to the wire `w` (a PackedWriter), as lines. */
|
|
98
|
+
const encode = (t: L, e: string, w: string, depth = 0): string[] => {
|
|
99
|
+
const v = `v${depth}`
|
|
100
|
+
switch (t.kind) {
|
|
101
|
+
case "i32": return [`${w}.i32(${e})`]
|
|
102
|
+
case "f64": return [`${w}.f64(${e})`]
|
|
103
|
+
case "boolean": return [`${w}.bool(${e})`]
|
|
104
|
+
case "string": return [`${w}.string(${e})`]
|
|
105
|
+
case "json": return [`${w}.json(${e})`]
|
|
106
|
+
case "bytes": return [`${w}.bytes(${e})`]
|
|
107
|
+
case "enum": return [`${w}.string(${e}.wire)`]
|
|
108
|
+
case "file": case "struct": case "union": return [`${e}.write(${w})`]
|
|
109
|
+
case "list": return [`${w}.beginArray()`, ...block(`for (${v} in ${e}) {`, encode(t.of, v, w, depth + 1), `}`), `${w}.end()`]
|
|
110
|
+
case "map": return [`${w}.beginObject()`, `for ((k${depth}, ${v}) in ${e}) {`, ...indent([`${w}.key(k${depth})`, ...encode(t.of, v, w, depth + 1)]), `}`, `${w}.end()`]
|
|
111
|
+
case "tuple": return [`${w}.beginArray()`, ...t.of.flatMap((m, i) => encode(m, `${e}.${TUPLE[i]}`, w, depth + 1)), `${w}.end()`]
|
|
112
|
+
case "optional": {
|
|
113
|
+
const some = encode(t.of, v, w, depth + 1)
|
|
114
|
+
const one = `${e}.let { ${v} -> if (${v} != null) ${some[0]} else ${w}.nil() }`
|
|
115
|
+
return some.length === 1 && one.length <= 100
|
|
116
|
+
? [one]
|
|
117
|
+
: [`${e}.let { ${v} ->`, ...indent([`if (${v} != null) {`, ...indent(some), `} else {`, ...indent([`${w}.nil()`]), `}`]), `}`]
|
|
118
|
+
}
|
|
119
|
+
case "void": throw new EmitError("void has no value to write")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A value under its key: on the key's line when it is one plain statement. */
|
|
124
|
+
const keyed = (key: string, value: string[]): string[] =>
|
|
125
|
+
value.length === 1 && !/^for /.test(value[0]!) && !value[0]!.includes(".let {") ? [`${key}; ${value[0]}`] : [key, ...value]
|
|
126
|
+
|
|
127
|
+
/** The statements that write a class's members — an object — to the wire `w`, as lines. */
|
|
128
|
+
const encodeFields = (fields: LField[], of: (f: LField) => string, w: string): string[] => [
|
|
129
|
+
`${w}.beginObject()`,
|
|
130
|
+
...fields.flatMap(f => {
|
|
131
|
+
const key = `${w}.key(${str(f.name)})`
|
|
132
|
+
if (f.type.kind !== "optional") return keyed(key, encode(f.type, of(f), w))
|
|
133
|
+
// An optional member that is null is left out; a nullable one is written as a null.
|
|
134
|
+
if (f.omit) return block(`${of(f)}?.let { v ->`, keyed(key, encode(f.type.of, "v", w, 1)), `}`)
|
|
135
|
+
return keyed(key, encode(f.type, of(f), w))
|
|
136
|
+
}),
|
|
137
|
+
`${w}.end()`,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
/** What PackedReader.kind() answers for a member of a union. */
|
|
141
|
+
const readerKind = (t: L): string => {
|
|
142
|
+
switch (wireClass(t)) {
|
|
143
|
+
case "number": return "PackedReader.NUMBER"
|
|
144
|
+
case "string": return "PackedReader.TEXT"
|
|
145
|
+
case "boolean": return "PackedReader.BOOLEAN"
|
|
146
|
+
case "object": return "PackedReader.MAP"
|
|
147
|
+
case "array": return "PackedReader.LIST"
|
|
148
|
+
default: throw new EmitError(`a union cannot hold ${t.kind}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const unionCase = (m: L): string => {
|
|
153
|
+
switch (m.kind) {
|
|
154
|
+
case "f64": return "OfNumber"
|
|
155
|
+
case "i32": return "OfInteger"
|
|
156
|
+
case "boolean": return "OfBool"
|
|
157
|
+
case "string": return "OfString"
|
|
158
|
+
case "enum": case "struct": return `Of${m.name}`
|
|
159
|
+
default: return `Of${m.kind.charAt(0).toUpperCase()}${m.kind.slice(1)}`
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const members = (fields: LField[], indent: string): string =>
|
|
164
|
+
fields.map(f => `${doc(f.doc, indent)}${indent}val ${ident(f.name)}: ${kotlinType(f.type)}${f.type.kind === "optional" ? " = null" : ""},`).join("\n")
|
|
165
|
+
|
|
166
|
+
const emitDecl = (d: LDecl, ways: Ways): string => {
|
|
167
|
+
const read = ways.read.has(d.name)
|
|
168
|
+
const write = ways.write.has(d.name)
|
|
169
|
+
if (d.kind === "enum") {
|
|
170
|
+
const cases = d.values.map(v => ` ${constName(v)}(${str(v)})`)
|
|
171
|
+
return [
|
|
172
|
+
doc(d.doc),
|
|
173
|
+
`enum class ${d.name}(val wire: String) {\n${cases.join(",\n")};\n`,
|
|
174
|
+
read ? `\n companion object {\n internal fun read(r: PackedReader): ${d.name}? = r.string()?.let { s -> values().firstOrNull { it.wire == s } }\n }\n` : "",
|
|
175
|
+
`}\n`,
|
|
176
|
+
].join("")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (d.kind === "union") {
|
|
180
|
+
const cases = d.members.map(m => ` data class ${unionCase(m)}(val value: ${kotlinType(m)}) : ${d.name}()`)
|
|
181
|
+
const reads = !read ? [] : d.members.flatMap(m => wrap(`${readerKind(m)} -> `, decode(m, 1), `?.let { ${unionCase(m)}(it) }`))
|
|
182
|
+
const writes = !write ? [] : d.members.flatMap(m => {
|
|
183
|
+
const lines = encode(m, "value", "w", 1)
|
|
184
|
+
return lines.length === 1 ? [`is ${unionCase(m)} -> ${lines[0]}`] : [`is ${unionCase(m)} -> {`, ...indent(lines), `}`]
|
|
185
|
+
})
|
|
186
|
+
return [
|
|
187
|
+
doc(d.doc),
|
|
188
|
+
`sealed class ${d.name} {\n${cases.join("\n")}\n`,
|
|
189
|
+
write ? `\n internal fun write(w: PackedWriter) {\n when (this) {\n${text(writes, " ")}\n }\n }\n` : "",
|
|
190
|
+
read ? `\n companion object {\n internal fun read(r: PackedReader): ${d.name}? = when (r.kind()) {\n${text(reads, " ")}\n else -> null\n }\n }\n` : "",
|
|
191
|
+
`}\n`,
|
|
192
|
+
].join("")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The members are read into numbered locals and written from `this.`: a member's own name is
|
|
196
|
+
// never an identifier here, so none can shadow the wire.
|
|
197
|
+
const locals = !read ? [] : d.fields.map((f, i) => ` var m${i}: ${kotlinType(f.type.kind === "optional" ? f.type : { kind: "optional", of: f.type })} = null`)
|
|
198
|
+
const reads = !read ? [] : d.fields.flatMap((f, i) => {
|
|
199
|
+
const key = str(f.name)
|
|
200
|
+
if (f.type.kind !== "optional") return wrap(`${key} -> m${i} = `, decode(f.type), ` ?: return null`)
|
|
201
|
+
// An optional member that IS there must read: a wrong type is a malformed value, not an absent one.
|
|
202
|
+
return wrap(`${key} -> if (r.isNull()) r.skip() else m${i} = `, decode(f.type), ` ?: return null`)
|
|
203
|
+
})
|
|
204
|
+
const made = d.fields.map((f, i) => `${ident(f.name)} = m${i}${f.type.kind === "optional" ? "" : " ?: return null"}`)
|
|
205
|
+
const body = [
|
|
206
|
+
!write ? "" : ` internal fun write(w: PackedWriter) {\n${text(encodeFields(d.fields, f => `this.${ident(f.name)}`, "w"), " ")}\n }\n`,
|
|
207
|
+
!read ? "" : d.fields.length
|
|
208
|
+
? ` companion object {\n internal fun read(r: PackedReader): ${d.name}? {\n val n = r.beginObject()\n if (n < 0) return null\n${locals.join("\n")}\n repeat(n) {\n when (r.key()) {\n${text(reads, " ")}\n else -> r.skip()\n }\n }\n return ${d.name}(${made.join(", ")})\n }\n }\n`
|
|
209
|
+
: ` companion object {\n internal fun read(r: PackedReader): ${d.name}? {\n if (r.isNull()) return ${d.name}()\n if (r.beginObject() < 0) return null\n return ${d.name}()\n }\n }\n`,
|
|
210
|
+
].filter(Boolean).join("\n")
|
|
211
|
+
// A data class has a member or more: the empty struct is a plain class.
|
|
212
|
+
const head = d.fields.length ? `data class ${d.name}(\n${members(d.fields, " ")}\n)` : `class ${d.name}`
|
|
213
|
+
return `${doc(d.doc)}${head}${body ? ` {\n${body}}` : ""}\n`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const emitChannel = (ch: LChannel): string => {
|
|
217
|
+
const isView = ch.kind === "view"
|
|
218
|
+
const iface = ch.iface
|
|
219
|
+
const code = ch.codes.length ? `${iface}Code` : "NoCode"
|
|
220
|
+
const out: string[] = []
|
|
221
|
+
|
|
222
|
+
if (ch.codes.length) {
|
|
223
|
+
out.push(`/** The codes the calls of "${ch.name}" reject with. */\nenum class ${iface}Code(override val code: String) : PluginCode {\n${ch.codes.map(c => ` ${constName(c)}(${str(c)})`).join(",\n")},\n}\n`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- events ----
|
|
227
|
+
const events = ch.events.map(e => {
|
|
228
|
+
const name = ident(e.name)
|
|
229
|
+
if (e.inline) {
|
|
230
|
+
const args = e.inline.map(f => `${ident(f.name)}: ${kotlinType(f.type)}${f.type.kind === "optional" ? " = null" : ""}`)
|
|
231
|
+
const w = writerName(e.inline.map(f => f.name))
|
|
232
|
+
return `${doc(e.doc, " ")} fun ${name}(${args.join(", ")}) {\n val ${w} = PackedWriter()\n${text(encodeFields(e.inline, f => ident(f.name), w), " ")}\n this.channel.emit(${str(e.name)}, ${w}.bytes())\n }`
|
|
233
|
+
}
|
|
234
|
+
if (e.payload.kind === "void") return `${doc(e.doc, " ")} fun ${name}() = channel.emit(${str(e.name)}, null)`
|
|
235
|
+
return `${doc(e.doc, " ")} fun ${name}(payload: ${kotlinType(e.payload)}) {\n val w = PackedWriter()\n${text(encode(e.payload, "payload", "w"), " ")}\n channel.emit(${str(e.name)}, w.bytes())\n }`
|
|
236
|
+
})
|
|
237
|
+
out.push([
|
|
238
|
+
`/** The events of "${ch.name}": what the plugin sends the app.${isView ? " From the main thread." : " Any thread."} */\n`,
|
|
239
|
+
`class ${iface}Events(private val channel: PluginEmit)${events.length ? ` {\n${events.join("\n\n")}\n}` : ""}\n`,
|
|
240
|
+
].join(""))
|
|
241
|
+
|
|
242
|
+
// ---- the interface ----
|
|
243
|
+
const signatures = ch.methods.map(m => {
|
|
244
|
+
const args = [...m.args.map(a => `${ident(a.name)}: ${kotlinType(a.type)}`), `reply: Reply<${kotlinType(m.result)}, ${code}>`]
|
|
245
|
+
const tags = [m.doc, m.rejects.length ? `Rejects with ${m.rejects.map(constName).join(" / ")}.` : ""].filter(Boolean).join("\n")
|
|
246
|
+
return `${doc(tags, " ")} fun ${ident(m.name)}(${args.join(", ")})`
|
|
247
|
+
})
|
|
248
|
+
out.push([
|
|
249
|
+
`/**\n * ${(ch.doc || `The ${ch.kind} "${ch.name}".`).split("\n").map(l => l.trim()).join("\n * ")}\n *\n`,
|
|
250
|
+
` * What the plugin implements. A call arrives on the main thread; its reply is settled exactly\n * once, now or later.\n */\n`,
|
|
251
|
+
`interface ${iface}Plugin {\n`,
|
|
252
|
+
isView ? ` /** The view the host mounts: fullscreen, or embedded among a screen's children. */\n val view: android.view.View\n` : "",
|
|
253
|
+
signatures.length ? (isView ? "\n" : "") + signatures.join("\n\n") + "\n" : "",
|
|
254
|
+
isView
|
|
255
|
+
? `\n /** The instance goes (the world swapped): release the hardware. */\n fun destroy() {}\n`
|
|
256
|
+
: `\n /** The session closed (the app's close, the world's teardown): stop what it started. */\n fun close() {}\n`,
|
|
257
|
+
`}\n`,
|
|
258
|
+
].join(""))
|
|
259
|
+
|
|
260
|
+
// ---- registration ----
|
|
261
|
+
out.push([
|
|
262
|
+
`object ${iface}Channel {\n`,
|
|
263
|
+
` /** What the halves register under. */\n const val NAME = ${str(ch.name)}\n\n`,
|
|
264
|
+
` /** The contract's version: the highest \`@since\` of its members. */\n const val VERSION = ${ch.version}\n\n`,
|
|
265
|
+
isView
|
|
266
|
+
? ` /** Register the view: \`make\` builds one instance from its params. */\n`
|
|
267
|
+
: ` /** Register the service: \`make\` builds one session from its params. It must be cheap — no\n * prompt, no I/O: that belongs to the first call that needs it. */\n`,
|
|
268
|
+
` fun register(engine: LecodesEngine, make: (params: ${ch.params}, events: ${iface}Events) -> ${iface}Plugin) {\n`,
|
|
269
|
+
` engine.register${isView ? "View" : "Service"}(NAME, VERSION) { params, channel ->\n`,
|
|
270
|
+
` val plugin = make(${ch.params}.read(Wire.reader(params)) ?: ${ch.params}(), ${iface}Events { event, packed -> channel.emitPacked(event, packed) })\n`,
|
|
271
|
+
isView
|
|
272
|
+
? ` NativeViews.Instance(plugin.view, onCall = { call -> ${iface}Glue.call(plugin, call.method, call.reader(), call) }, onDestroy = { plugin.destroy() })\n`
|
|
273
|
+
: ` Services.Instance(onCall = { call -> ${iface}Glue.call(plugin, call.method, call.reader(), call) }, onClose = { plugin.close() })\n`,
|
|
274
|
+
` }\n }\n}\n`,
|
|
275
|
+
].join(""))
|
|
276
|
+
|
|
277
|
+
// ---- the glue ----
|
|
278
|
+
// The arguments are read into numbered locals, one after the other as they were written: an
|
|
279
|
+
// argument's own name is never an identifier here.
|
|
280
|
+
const cases = ch.methods.map(m => {
|
|
281
|
+
const lines: string[] = []
|
|
282
|
+
m.args.forEach((a, i) => {
|
|
283
|
+
const bad = `return settle.reject("bad arguments — ${iface}.${m.name}: ${a.name}")`
|
|
284
|
+
if (a.type.kind !== "optional") {
|
|
285
|
+
lines.push(...wrap(`val a${i} = (if (${i} < n) `, decode(a.type), ` else null) ?: ${bad}`))
|
|
286
|
+
} else {
|
|
287
|
+
// An argument the app left out, or a null: nothing is read, a null that is there is passed over.
|
|
288
|
+
lines.push(...wrap(`val a${i} = if (${i} < n && !r.isNull()) (`, decode(a.type), ` ?: ${bad}) else { if (${i} < n) r.skip(); null }`))
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
const head = `Reply<${kotlinType(m.result)}, ${code}>(settle)`
|
|
292
|
+
const reply = m.result.kind === "void"
|
|
293
|
+
? [`Reply<Unit, ${code}>(settle, null)`]
|
|
294
|
+
: m.result.kind === "optional"
|
|
295
|
+
// Nothing is written for null: the app's promise resolves with `undefined`.
|
|
296
|
+
? block(`${head} { v, w ->`, block(`if (v != null) {`, encode(m.result.of, "v", "w", 2), `}`), `}`)
|
|
297
|
+
: block(`${head} { v, w ->`, encode(m.result, "v", "w", 1), `}`)
|
|
298
|
+
lines.push(...wrap(`plugin.${ident(m.name)}(${m.args.map((_, i) => `a${i}, `).join("")}`, reply, `)`))
|
|
299
|
+
return ` ${str(m.name)} -> {\n${text(lines, " ")}\n }`
|
|
300
|
+
})
|
|
301
|
+
out.push([
|
|
302
|
+
`/** Reads a call off the wire and hands it to the plugin; a malformed call never reaches it. */\n`,
|
|
303
|
+
`internal object ${iface}Glue {\n`,
|
|
304
|
+
` fun call(plugin: ${iface}Plugin, method: String, r: PackedReader, settle: ChannelSettle) {\n val n = r.beginArray() // the arguments, in their order\n when (method) {\n`,
|
|
305
|
+
cases.length ? cases.join("\n") + "\n" : "",
|
|
306
|
+
` else -> settle.reject("Unknown method: $method")\n }\n }\n}\n`,
|
|
307
|
+
].join(""))
|
|
308
|
+
|
|
309
|
+
return out.join("\n")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export type KotlinEmitOptions = {
|
|
313
|
+
id: string
|
|
314
|
+
/** The package of the plugin's Kotlin: the generated file is a file of it. */
|
|
315
|
+
package: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The host's names a generated file may use, by the import that brings each. */
|
|
319
|
+
const IMPORTS: [string, string][] = [
|
|
320
|
+
["LecodesEngine", "io.letary.lecodes.LecodesEngine"],
|
|
321
|
+
["ChannelSettle", "io.letary.lecodes.services.ChannelSettle"],
|
|
322
|
+
["NativeViews", "io.letary.lecodes.services.NativeViews"],
|
|
323
|
+
["NoCode", "io.letary.lecodes.services.NoCode"],
|
|
324
|
+
["PluginCode", "io.letary.lecodes.services.PluginCode"],
|
|
325
|
+
["PluginEmit", "io.letary.lecodes.services.PluginEmit"],
|
|
326
|
+
["PluginFile", "io.letary.lecodes.services.PluginFile"],
|
|
327
|
+
["Reply", "io.letary.lecodes.services.Reply"],
|
|
328
|
+
["Services", "io.letary.lecodes.services.Services"],
|
|
329
|
+
["Wire", "io.letary.lecodes.services.Wire"],
|
|
330
|
+
["PackedReader", "io.letary.lecodes.core.PackedReader"],
|
|
331
|
+
["PackedWriter", "io.letary.lecodes.core.PackedWriter"],
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
/** One file per plugin: the types every channel of the contract reaches, then each channel. */
|
|
335
|
+
export const emitKotlin = (lowered: Lowered, opts: KotlinEmitOptions): string => {
|
|
336
|
+
const header = lowered.channels.map(c => `the ${c.kind} "${c.name}", contract version ${c.version}`).join("; ")
|
|
337
|
+
const guarded = (what: string, body: () => string): string => {
|
|
338
|
+
try { return body() } catch (e) {
|
|
339
|
+
if (e instanceof EmitError) throw new EmitError(`${what}: ${e.message}`)
|
|
340
|
+
throw e
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const ways = waysOf(lowered)
|
|
344
|
+
const body = [
|
|
345
|
+
`// ---- The contract's types ----\n\n`,
|
|
346
|
+
lowered.decls.map(d => guarded(d.name, () => emitDecl(d, ways))).join("\n"),
|
|
347
|
+
...lowered.channels.map(ch => `\n// ---- ${ch.iface} ----\n\n${guarded(ch.iface, () => emitChannel(ch))}`),
|
|
348
|
+
].join("")
|
|
349
|
+
const imports = IMPORTS.filter(([name]) => new RegExp(`\\b${name}\\b`).test(body)).map(([, path]) => `import ${path}`)
|
|
350
|
+
return [
|
|
351
|
+
`// GENERATED by \`lecodes plugin gen\` from contract.d.ts — do not edit.\n`,
|
|
352
|
+
`// ${opts.id}: ${header}.\n\n`,
|
|
353
|
+
`package ${opts.package}\n\n`,
|
|
354
|
+
`${imports.join("\n")}\n\n`,
|
|
355
|
+
body,
|
|
356
|
+
].join("")
|
|
357
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The generator's emitter for `sdk/`: a contract's channel → `<id>.gen.ts`, the wrapper an APP calls
|
|
2
|
+
// (docs/plans/plugins-plan.md in core). TypeScript SOURCE: it is compiled with the app, so what an
|
|
3
|
+
// app does not name is shaken out.
|
|
4
|
+
//
|
|
5
|
+
// Per channel the file holds
|
|
6
|
+
// - the WIRE, call by call: one function per method (`takePhoto(view)`), for a hand-written
|
|
7
|
+
// wrapper whose public API is not the wire's (the map queues its calls, Geolocation fans one
|
|
8
|
+
// watch out);
|
|
9
|
+
// - `<Name>Base`: the element / the client with the wire's methods as its own — the public shape
|
|
10
|
+
// when nothing is hand-written, the class a hand-written wrapper extends otherwise;
|
|
11
|
+
// - the public export (`<Name>`), ONLY when the plugin has no hand-written `sdk/<id>.ts`.
|
|
12
|
+
//
|
|
13
|
+
// The emitter knows the wire's FORMAT in one place each: `_call` (lecodes-sdk/plugin) and the
|
|
14
|
+
// decoders below. A change of the wire is a change there, never in a plugin. The wire carries
|
|
15
|
+
// VALUES: what an app passes and gets is what crosses, a Uint8Array included — only a File is
|
|
16
|
+
// something else on it (the handle of a host buffer).
|
|
17
|
+
|
|
18
|
+
import { CONTRACT_MODULE, type Channel, type Contract, type Decl, type Field, type Type } from "./contract"
|
|
19
|
+
|
|
20
|
+
export class EmitError extends Error {}
|
|
21
|
+
|
|
22
|
+
export type SdkEmitOptions = {
|
|
23
|
+
/** The plugin's id: the file is `<id>.gen.ts`. */
|
|
24
|
+
id: string
|
|
25
|
+
/** The plugin has a hand-written `sdk/<id>.ts`: it owns the public export. */
|
|
26
|
+
handWritten: boolean
|
|
27
|
+
/** The contract, as the generated file imports it. */
|
|
28
|
+
contractImport?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const indent = (text: string, by = " "): string => text.split("\n").map(l => l ? by + l : l).join("\n")
|
|
32
|
+
|
|
33
|
+
const docBlock = (doc: string, extra: string[] = []): string => {
|
|
34
|
+
const lines = [...(doc ? doc.split("\n").map(l => l.trim()) : []), ...extra]
|
|
35
|
+
if (lines.length === 0) return ""
|
|
36
|
+
if (lines.length === 1) return `/** ${lines[0]} */\n`
|
|
37
|
+
return `/** ${lines[0]}\n${lines.slice(1).map(l => ` * ${l}`.trimEnd()).join("\n")} */\n`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const emitSdk = (contract: Contract, channel: Channel, opts: SdkEmitOptions): string => {
|
|
41
|
+
const decls = new Map<string, Decl>(contract.decls.map(d => [d.name, d]))
|
|
42
|
+
const usedTypes = new Set<string>() // names of the contract this file imports
|
|
43
|
+
const usedSdk = new Set<string>() // names of lecodes-sdk/plugin: values
|
|
44
|
+
const usedSdkTypes = new Set<string>() // …and types
|
|
45
|
+
|
|
46
|
+
// ---- types as TypeScript --------------------------------------------------------------------------
|
|
47
|
+
const typeText = (t: Type): string => {
|
|
48
|
+
switch (t.kind) {
|
|
49
|
+
case "i32": case "f64": return "number"
|
|
50
|
+
case "boolean": return "boolean"
|
|
51
|
+
case "string": return "string"
|
|
52
|
+
case "void": return "void"
|
|
53
|
+
case "bytes": return "Uint8Array"
|
|
54
|
+
case "file": usedSdkTypes.add("File"); return "File"
|
|
55
|
+
case "json": usedSdkTypes.add("Json"); return "Json"
|
|
56
|
+
case "ref": usedTypes.add(t.name); return t.name
|
|
57
|
+
case "literals": return t.values.map(v => JSON.stringify(v)).join(" | ")
|
|
58
|
+
case "object": return t.fields.length ? `{ ${t.fields.map(fieldText).join(", ")} }` : "{}"
|
|
59
|
+
case "map": return `{ [key: string]: ${typeText(t.of)} }`
|
|
60
|
+
case "list": return `${wrapped(t.of)}[]`
|
|
61
|
+
case "tuple": return `[${t.of.map(typeText).join(", ")}]`
|
|
62
|
+
case "union": return t.of.map(typeText).join(" | ")
|
|
63
|
+
case "nullable": return `${typeText(t.of)} | null`
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const wrapped = (t: Type): string => t.kind === "union" || t.kind === "nullable" || (t.kind === "literals" && t.values.length > 1) ? `(${typeText(t)})` : typeText(t)
|
|
67
|
+
const fieldText = (f: Field): string => `${f.name}${f.optional ? "?" : ""}: ${typeText(f.type)}`
|
|
68
|
+
|
|
69
|
+
// ---- what crosses as something else ---------------------------------------------------------------
|
|
70
|
+
// Only a `File`: it crosses as the handle of a host buffer. Everything else — a Uint8Array too — is
|
|
71
|
+
// on the wire what it is for the app.
|
|
72
|
+
/** Does a value of this type hold a `File` anywhere? */
|
|
73
|
+
const carriesBuffer = (t: Type, seen = new Set<string>()): boolean => {
|
|
74
|
+
switch (t.kind) {
|
|
75
|
+
case "file": return true
|
|
76
|
+
case "ref": {
|
|
77
|
+
if (seen.has(t.name)) return false
|
|
78
|
+
seen.add(t.name)
|
|
79
|
+
const d = decls.get(t.name)!
|
|
80
|
+
return d.kind === "alias" ? carriesBuffer(d.type, seen) : d.fields.some(f => carriesBuffer(f.type, seen))
|
|
81
|
+
}
|
|
82
|
+
case "object": return t.fields.some(f => carriesBuffer(f.type, seen))
|
|
83
|
+
case "map": case "list": case "nullable": return carriesBuffer(t.of, seen)
|
|
84
|
+
case "tuple": case "union": return t.of.some(m => carriesBuffer(m, seen))
|
|
85
|
+
default: return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The expression that turns the wire's value `v` into the contract's; undefined = as is. */
|
|
90
|
+
const decoders = new Map<string, string>() // struct / alias name → its decoder function
|
|
91
|
+
const decode = (t: Type, v: string, where: string): string | undefined => {
|
|
92
|
+
if (!carriesBuffer(t)) return undefined
|
|
93
|
+
switch (t.kind) {
|
|
94
|
+
case "file": usedSdk.add("_toFile"); return `_toFile(${v})`
|
|
95
|
+
case "nullable": return `${v} == null ? null : ${decode(t.of, v, where)}`
|
|
96
|
+
case "list": return `${v}.map((x: any) => ${decode(t.of, "x", where)})`
|
|
97
|
+
case "map": return `Object.fromEntries(Object.entries(${v}).map(([k, x]: [string, any]) => [k, ${decode(t.of, "x", where)}]))`
|
|
98
|
+
case "tuple": return `[${t.of.map((m, i) => decode(m, `${v}[${i}]`, where) ?? `${v}[${i}]`).join(", ")}]`
|
|
99
|
+
case "object": return `{ ...${v}, ${t.fields.filter(f => carriesBuffer(f.type)).map(f => `${f.name}: ${decodeField(f, v, where)}`).join(", ")} }`
|
|
100
|
+
case "union": throw new EmitError(`${where}: a File inside a union cannot be told apart on the wire — give it a member of its own`)
|
|
101
|
+
case "ref": {
|
|
102
|
+
const d = decls.get(t.name)!
|
|
103
|
+
if (!decoders.has(t.name)) {
|
|
104
|
+
decoders.set(t.name, "") // a recursive type finds its own name
|
|
105
|
+
const body = d.kind === "alias" ? decode(d.type, "w", d.name)! : decode({ kind: "object", fields: d.fields }, "w", d.name)!
|
|
106
|
+
usedTypes.add(t.name)
|
|
107
|
+
decoders.set(t.name, `const _to${t.name} = (w: any): ${t.name} => (${body})`)
|
|
108
|
+
}
|
|
109
|
+
return `_to${t.name}(${v})`
|
|
110
|
+
}
|
|
111
|
+
default: return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const decodeField = (f: Field, v: string, where: string): string => {
|
|
115
|
+
const value = `${v}.${f.name}`
|
|
116
|
+
const d = decode(f.type, value, `${where}.${f.name}`)!
|
|
117
|
+
return f.optional ? `${value} === undefined ? undefined : ${f.type.kind === "nullable" ? `(${d})` : d}` : d
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The other way — the expression that turns the app's value `v` into the wire's; undefined = as is. */
|
|
121
|
+
const encoders = new Map<string, string>() // struct / alias name → its encoder function
|
|
122
|
+
const encode = (t: Type, v: string, where: string): string | undefined => {
|
|
123
|
+
if (!carriesBuffer(t)) return undefined
|
|
124
|
+
switch (t.kind) {
|
|
125
|
+
case "file": usedSdk.add("_fromFile"); return `_fromFile(${v})`
|
|
126
|
+
case "nullable": return `${v} == null ? null : ${encode(t.of, v, where)}`
|
|
127
|
+
case "list": return `${v}.map((x: any) => ${encode(t.of, "x", where)})`
|
|
128
|
+
case "map": return `Object.fromEntries(Object.entries(${v}).map(([k, x]: [string, any]) => [k, ${encode(t.of, "x", where)}]))`
|
|
129
|
+
case "tuple": return `[${t.of.map((m, i) => encode(m, `${v}[${i}]`, where) ?? `${v}[${i}]`).join(", ")}]`
|
|
130
|
+
case "object": return `{ ...${v}, ${t.fields.filter(f => carriesBuffer(f.type)).map(f => `${f.name}: ${encodeField(f, v, where)}`).join(", ")} }`
|
|
131
|
+
case "union": throw new EmitError(`${where}: a File inside a union cannot be told apart on the wire — give it a member of its own`)
|
|
132
|
+
case "ref": {
|
|
133
|
+
const d = decls.get(t.name)!
|
|
134
|
+
if (!encoders.has(t.name)) {
|
|
135
|
+
encoders.set(t.name, "") // a recursive type finds its own name
|
|
136
|
+
const body = d.kind === "alias" ? encode(d.type, "v", d.name)! : encode({ kind: "object", fields: d.fields }, "v", d.name)!
|
|
137
|
+
usedTypes.add(t.name)
|
|
138
|
+
encoders.set(t.name, `const _from${t.name} = (v: ${t.name}): any => (${body})`)
|
|
139
|
+
}
|
|
140
|
+
return `_from${t.name}(${v})`
|
|
141
|
+
}
|
|
142
|
+
default: return undefined
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const encodeField = (f: Field, v: string, where: string): string => {
|
|
146
|
+
const value = `${v}.${f.name}`
|
|
147
|
+
const e = encode(f.type, value, `${where}.${f.name}`)!
|
|
148
|
+
return f.optional ? `${value} === undefined ? undefined : ${f.type.kind === "nullable" ? `(${e})` : e}` : e
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- the channel ------------------------------------------------------------------------------------
|
|
152
|
+
const isView = channel.kind === "view"
|
|
153
|
+
const base = isView ? "ViewChannel" : "ServiceChannel"
|
|
154
|
+
usedSdk.add(base)
|
|
155
|
+
const iface = channel.iface
|
|
156
|
+
const target = isView ? "view" : "service"
|
|
157
|
+
const { eventsType, paramsType } = channel
|
|
158
|
+
if (eventsType) usedTypes.add(eventsType)
|
|
159
|
+
if (paramsType) usedTypes.add(paramsType)
|
|
160
|
+
const targetType = `${base}<${eventsType ?? "{}"}>`
|
|
161
|
+
|
|
162
|
+
// An event whose payload holds a File is read before the app's listener sees it.
|
|
163
|
+
const readers = channel.events.filter(e => carriesBuffer(e.payload))
|
|
164
|
+
.map(e => ` ${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e.name) ? e.name : JSON.stringify(e.name)}: (w: any) => ${decode(e.payload, "w", `${iface}: the event "${e.name}"`)},`)
|
|
165
|
+
if (readers.length) usedSdkTypes.add("EventReaders")
|
|
166
|
+
|
|
167
|
+
const wire: string[] = []
|
|
168
|
+
const members: string[] = []
|
|
169
|
+
for (const m of channel.methods) {
|
|
170
|
+
const where = `${iface}.${m.name}`
|
|
171
|
+
const args = m.args.map(a => `${a.name}${a.optional ? "?" : ""}: ${typeText(a.type)}`)
|
|
172
|
+
// A File the app hands over crosses as its handle; an optional argument left out stays out.
|
|
173
|
+
const names = m.args.map(a => {
|
|
174
|
+
const e = encode(a.type, a.name, `${where}(${a.name})`)
|
|
175
|
+
if (e === undefined) return a.name
|
|
176
|
+
return a.optional ? `${a.name} === undefined ? undefined : ${a.type.kind === "nullable" ? `(${e})` : e}` : e
|
|
177
|
+
})
|
|
178
|
+
const own = m.args.map(a => a.name)
|
|
179
|
+
const result = `Promise<${typeText(m.result)}>`
|
|
180
|
+
const call = `${target}._call(${JSON.stringify(m.name)}, [${names.join(", ")}]${m.since > 1 ? `, ${m.since}` : ""})`
|
|
181
|
+
// A half resolves "nothing" as an absent value: a nullable result is null for the app.
|
|
182
|
+
const decoded = m.result.kind === "void" ? undefined
|
|
183
|
+
: decode(m.result, "w", `${where}()`) ?? (m.result.kind === "nullable" ? "w ?? null" : undefined)
|
|
184
|
+
const tags = [
|
|
185
|
+
...(m.since > 1 ? [`Contract version ${m.since}: an older half rejects the call — ask \`supports(${JSON.stringify(m.name)})\`.`] : []),
|
|
186
|
+
...(m.rejects.length ? [`Rejects with ${m.rejects.map(r => `"${r}"`).join(" / ")}.`] : []),
|
|
187
|
+
]
|
|
188
|
+
const doc = docBlock(m.doc, tags)
|
|
189
|
+
wire.push(`${doc}export const ${m.name} = (${[`${target}: ${targetType}`, ...args].join(", ")}): ${result} =>\n ${call}${decoded ? `.then((w: any) => ${decoded})` : ""}`)
|
|
190
|
+
members.push(`${doc}${m.name}(${args.join(", ")}): ${result} { return ${m.name}(${["this", ...own].join(", ")}) }`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- which methods the host's half has ---------------------------------------------------------------
|
|
194
|
+
const later = channel.methods.filter(m => m.since > 1)
|
|
195
|
+
const methodNames = channel.methods.length ? channel.methods.map(m => JSON.stringify(m.name)).join(" | ") : "never"
|
|
196
|
+
const supports = [
|
|
197
|
+
`/** A method of the contract. */
|
|
198
|
+
export type Method = ${methodNames}
|
|
199
|
+
|
|
200
|
+
`,
|
|
201
|
+
later.length ? `const SINCE: { [method: string]: number } = { ${later.map(m => `${m.name}: ${m.since}`).join(", ")} }
|
|
202
|
+
|
|
203
|
+
` : "",
|
|
204
|
+
`/** Whether this host has the ${channel.kind} "${channel.name}" — and, given a method, a half new enough for it
|
|
205
|
+
* (a method added after the first version of the contract is tagged \`@since\`). */
|
|
206
|
+
`,
|
|
207
|
+
`export const supports = (method?: Method): boolean =>
|
|
208
|
+
${base}.supports(NAME${later.length ? `, method === undefined ? 1 : SINCE[method] ?? 1` : ""})`,
|
|
209
|
+
later.length ? "" : ` // every method is of version 1`,
|
|
210
|
+
`
|
|
211
|
+
`,
|
|
212
|
+
].join("")
|
|
213
|
+
|
|
214
|
+
const paramsArg = `params: ${paramsType ?? "{}"} = {}`
|
|
215
|
+
const klass = [
|
|
216
|
+
docBlock(channel.doc || `The ${channel.kind} "${channel.name}" with the wire's methods as its own.`),
|
|
217
|
+
`export class ${iface}Base extends ${targetType} {\n`,
|
|
218
|
+
indent(`constructor(${paramsArg}) { super(${JSON.stringify(iface)}, NAME, params${readers.length ? ", READERS" : ""}) }`),
|
|
219
|
+
members.length ? "\n\n" + indent(members.join("\n")) : "",
|
|
220
|
+
`\n}`,
|
|
221
|
+
].join("")
|
|
222
|
+
|
|
223
|
+
const publicExport = opts.handWritten ? "" : isView ? [
|
|
224
|
+
`\nexport type ${iface} = ${iface}Base\n\n`,
|
|
225
|
+
`// PURE IIFE: an app that never names ${iface} carries none of this file.\n`,
|
|
226
|
+
`export const ${iface}: {\n (params?: ${paramsType ?? "{}"}): ${iface}\n /** Whether this host registered the view "${channel.name}". */\n readonly isSupported: boolean\n /** Whether the host's half has \`method\`: false on a host whose half is older than it. */\n supports(method: Method): boolean\n} = /*#__PURE__*/ (() => {\n`,
|
|
227
|
+
` const factory = (params?: ${paramsType ?? "{}"}): ${iface} => new ${iface}Base(params)\n`,
|
|
228
|
+
` Object.defineProperty(factory, "isSupported", { get: (): boolean => supports() })\n`,
|
|
229
|
+
` factory.supports = supports\n`,
|
|
230
|
+
` return factory as any\n})()\n`,
|
|
231
|
+
].join("") : [
|
|
232
|
+
`\nexport type ${iface} = ${iface}Base & {\n /** Whether this host registered the service "${channel.name}". */\n readonly isSupported: boolean\n /** Whether the host's half has \`method\`: false on a host whose half is older than it. */\n supports(method: Method): boolean\n}\n\n`,
|
|
233
|
+
`// PURE IIFE: an app that never names ${iface} carries none of this file. One session for the app's\n// life, opened by the first call.\n`,
|
|
234
|
+
`export const ${iface}: ${iface} = /*#__PURE__*/ (() => {\n`,
|
|
235
|
+
` const service = new ${iface}Base()\n`,
|
|
236
|
+
` Object.defineProperty(service, "isSupported", { get: (): boolean => supports() })\n`,
|
|
237
|
+
` Object.defineProperty(service, "supports", { value: supports })\n`,
|
|
238
|
+
` return service as any\n})()\n`,
|
|
239
|
+
].join("")
|
|
240
|
+
|
|
241
|
+
// ---- the file ---------------------------------------------------------------------------------------
|
|
242
|
+
const sdkImports = [...[...usedSdk].sort(), ...[...usedSdkTypes].sort().map(n => `type ${n}`)]
|
|
243
|
+
const out = [
|
|
244
|
+
`// GENERATED by \`lecodes plugin gen\` from contract.d.ts — do not edit.\n`,
|
|
245
|
+
`// ${opts.id}: the ${channel.kind} "${channel.name}", contract version ${channel.version}.\n\n`,
|
|
246
|
+
`import { ${sdkImports.join(", ")} } from "${CONTRACT_MODULE}"\n`,
|
|
247
|
+
usedTypes.size ? `import type { ${[...usedTypes].sort().join(", ")} } from "${opts.contractImport ?? "../contract"}"\n` : "",
|
|
248
|
+
`\n/** What the halves register under. */\nexport const NAME = ${JSON.stringify(channel.name)}\n`,
|
|
249
|
+
`/** The contract's version: the highest \`@since\` of its members. */\nexport const VERSION = ${channel.version}\n`,
|
|
250
|
+
decoders.size ? `\n${[...decoders.values()].join("\n")}\n` : "",
|
|
251
|
+
encoders.size ? `\n${[...encoders.values()].join("\n")}\n` : "",
|
|
252
|
+
readers.length ? `\n/** The events whose payload holds a File: read before a listener sees it. */\nexport const READERS: EventReaders = {\n${readers.join("\n")}\n}\n` : "",
|
|
253
|
+
wire.length ? `\n// ---- the wire, call by call ${"-".repeat(70)}\n\n${wire.join("\n\n")}\n` : "",
|
|
254
|
+
`\n// ---- what the host's half has ${"-".repeat(68)}\n\n${supports}`,
|
|
255
|
+
`\n// ---- the ${isView ? "element" : "client"} ${"-".repeat(isView ? 82 : 83)}\n\n${klass}\n`,
|
|
256
|
+
publicExport,
|
|
257
|
+
]
|
|
258
|
+
return out.join("")
|
|
259
|
+
}
|